@opengsd/gsd-pi 1.0.2-dev.d456457 → 1.0.2-dev.dbfb371
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 +63 -12
- package/dist/resource-loader.d.ts +5 -0
- package/dist/resource-loader.js +24 -8
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/gsd/auto/loop.js +19 -0
- package/dist/resources/extensions/gsd/auto/phases.js +1 -1
- package/dist/resources/extensions/gsd/auto-worktree.js +2 -54
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +17 -15
- package/dist/resources/extensions/gsd/commands-handlers.js +3 -0
- package/dist/resources/extensions/gsd/worktree-post-create-hook.js +117 -0
- package/dist/resources/shared/package-manager-detection.js +36 -0
- package/dist/update-check.d.ts +6 -2
- package/dist/update-check.js +7 -3
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +7 -7
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/api/boot/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/session/events/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/shutdown/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/update/route.js +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +7 -7
- package/dist/web/standalone/.next/server/chunks/1834.js +1 -1
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/dist/web/standalone/node_modules/node-pty/build/Makefile +1 -1
- package/dist/worktree-cli.d.ts +0 -2
- package/dist/worktree-cli.js +21 -9
- package/package.json +5 -2
- package/packages/cloud-mcp-gateway/bin/gsd-cloud-mcp-gateway.js +14 -0
- package/packages/cloud-mcp-gateway/package.json +4 -3
- package/packages/contracts/package.json +1 -1
- package/packages/daemon/package.json +4 -4
- package/packages/gsd-agent-core/package.json +5 -5
- package/packages/gsd-agent-modes/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/components/tool-execution.js +3 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/components/tool-execution.js.map +1 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/components/transcript-design.d.ts.map +1 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/components/transcript-design.js +0 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/components/transcript-design.js.map +1 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode-class-constants.d.ts +1 -0
- package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode-class-constants.d.ts.map +1 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode-class-constants.js +1 -0
- package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode-class-constants.js.map +1 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode.js +2 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/packages/gsd-agent-modes/package.json +7 -7
- package/packages/mcp-server/bin/gsd-mcp-server.js +14 -0
- package/packages/mcp-server/package.json +5 -4
- package/packages/native/package.json +1 -1
- package/packages/pi-agent-core/dist/agent-loop.js +13 -13
- package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
- package/packages/pi-agent-core/package.json +1 -1
- package/packages/pi-ai/bin/pi-ai.js +14 -0
- package/packages/pi-ai/dist/models.generated.d.ts +40 -17
- package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
- package/packages/pi-ai/dist/models.generated.js +42 -23
- package/packages/pi-ai/dist/models.generated.js.map +1 -1
- package/packages/pi-ai/package.json +3 -2
- package/packages/pi-coding-agent/dist/core/tools/read.d.ts +2 -2
- package/packages/pi-coding-agent/dist/core/tools/read.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/tools/read.js +5 -3
- package/packages/pi-coding-agent/dist/core/tools/read.js.map +1 -1
- package/packages/pi-coding-agent/package.json +8 -8
- package/packages/pi-tui/package.json +1 -1
- package/packages/rpc-client/package.json +2 -2
- package/pkg/package.json +1 -1
- package/scripts/install/deps.js +10 -0
- package/scripts/install/detect-existing.js +17 -3
- package/scripts/install/npm-global.js +103 -33
- package/scripts/install.js +1 -0
- package/src/resources/extensions/gsd/auto/loop.ts +22 -0
- package/src/resources/extensions/gsd/auto/phases.ts +1 -1
- package/src/resources/extensions/gsd/auto-worktree.ts +2 -56
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +22 -15
- package/src/resources/extensions/gsd/commands-handlers.ts +2 -0
- package/src/resources/extensions/gsd/tests/custom-engine-loop-integration.test.ts +64 -0
- package/src/resources/extensions/gsd/tests/workflow-mcp-auto-prep.test.ts +60 -0
- package/src/resources/extensions/gsd/tests/worktree-post-create-hook.test.ts +141 -1
- package/src/resources/extensions/gsd/worktree-post-create-hook.ts +127 -0
- package/src/resources/shared/package-manager-detection.ts +39 -0
- package/dist/tsconfig.extensions.tsbuildinfo +0 -1
- /package/dist/web/standalone/.next/static/{4NVKiVx4C-8FUT9A7DZdq → BVrLsL82ynrLee5zxeihC}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{4NVKiVx4C-8FUT9A7DZdq → BVrLsL82ynrLee5zxeihC}/_ssgManifest.js +0 -0
|
@@ -59,6 +59,7 @@ import { debugLog } from "./debug-logger.js";
|
|
|
59
59
|
import { logWarning, logError } from "./workflow-logger.js";
|
|
60
60
|
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
|
61
61
|
import { MILESTONE_ID_RE } from "./milestone-ids.js";
|
|
62
|
+
import { runWorktreePostCreateHook } from "./worktree-post-create-hook.js";
|
|
62
63
|
import {
|
|
63
64
|
nativeGetCurrentBranch,
|
|
64
65
|
nativeDetectMainBranch,
|
|
@@ -912,62 +913,7 @@ export function syncWorktreeStateBack(
|
|
|
912
913
|
): { synced: string[] } {
|
|
913
914
|
return _finalizeProjectionForMergeImpl(mainBasePath, worktreePath, milestoneId);
|
|
914
915
|
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
/**
|
|
918
|
-
* Run the user-configured post-create hook script after worktree creation.
|
|
919
|
-
* The script receives SOURCE_DIR and WORKTREE_DIR as environment variables.
|
|
920
|
-
* Failure is non-fatal — returns the error message or null on success.
|
|
921
|
-
*
|
|
922
|
-
* Reads the hook path from git.worktree_post_create in preferences.
|
|
923
|
-
* Pass hookPath directly to bypass preference loading (useful for testing).
|
|
924
|
-
*/
|
|
925
|
-
export function runWorktreePostCreateHook(
|
|
926
|
-
sourceDir: string,
|
|
927
|
-
worktreeDir: string,
|
|
928
|
-
hookPath?: string,
|
|
929
|
-
): string | null {
|
|
930
|
-
if (hookPath === undefined) {
|
|
931
|
-
const prefs = loadEffectiveGSDPreferences()?.preferences?.git;
|
|
932
|
-
hookPath = prefs?.worktree_post_create;
|
|
933
|
-
}
|
|
934
|
-
if (!hookPath) return null;
|
|
935
|
-
|
|
936
|
-
// Resolve relative paths against the source project root.
|
|
937
|
-
// On Windows, convert 8.3 short paths (e.g. RUNNER~1) to long paths
|
|
938
|
-
// so execFileSync can locate the file correctly.
|
|
939
|
-
let resolved = isAbsolute(hookPath) ? hookPath : join(sourceDir, hookPath);
|
|
940
|
-
if (!existsSync(resolved)) {
|
|
941
|
-
return `Worktree post-create hook not found: ${resolved}`;
|
|
942
|
-
}
|
|
943
|
-
if (process.platform === "win32") {
|
|
944
|
-
try { resolved = realpathSync.native(resolved); } catch (err) { /* keep original */
|
|
945
|
-
logWarning("worktree", `realpath failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
try {
|
|
950
|
-
// .bat/.cmd files on Windows require shell mode — execFileSync cannot
|
|
951
|
-
// spawn them directly (EINVAL).
|
|
952
|
-
const needsShell = process.platform === "win32" && /\.(bat|cmd)$/i.test(resolved);
|
|
953
|
-
execFileSync(resolved, [], {
|
|
954
|
-
cwd: worktreeDir,
|
|
955
|
-
env: {
|
|
956
|
-
...process.env,
|
|
957
|
-
SOURCE_DIR: sourceDir,
|
|
958
|
-
WORKTREE_DIR: worktreeDir,
|
|
959
|
-
},
|
|
960
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
961
|
-
encoding: "utf-8",
|
|
962
|
-
timeout: 30_000, // 30 second timeout
|
|
963
|
-
shell: needsShell,
|
|
964
|
-
});
|
|
965
|
-
return null;
|
|
966
|
-
} catch (err) {
|
|
967
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
968
|
-
return `Worktree post-create hook failed: ${msg}`;
|
|
969
|
-
}
|
|
970
|
-
}
|
|
916
|
+
export { runWorktreePostCreateHook } from "./worktree-post-create-hook.js";
|
|
971
917
|
|
|
972
918
|
// ─── Auto-Worktree Branch Naming ───────────────────────────────────────────
|
|
973
919
|
|
|
@@ -507,6 +507,21 @@ function initSessionNotifications(ctx: ExtensionContext): void {
|
|
|
507
507
|
initNotificationWidget(ctx);
|
|
508
508
|
}
|
|
509
509
|
|
|
510
|
+
async function prepareWorkflowMcpForHookContext(
|
|
511
|
+
ctx: ExtensionContext,
|
|
512
|
+
basePath: string,
|
|
513
|
+
): Promise<void> {
|
|
514
|
+
// Skip MCP auto-prep when running inside an auto-worktree. The worktree
|
|
515
|
+
// already has .mcp.json from createAutoWorktree, and re-running the writer
|
|
516
|
+
// post-chdir rewrites the file mid-run (non-idempotent due to cwd-relative
|
|
517
|
+
// CLI path resolution), dirtying the tree and breaking the milestone merge.
|
|
518
|
+
const { isInAutoWorktree } = await import("../auto-worktree.js");
|
|
519
|
+
if (isInAutoWorktree(basePath)) return;
|
|
520
|
+
|
|
521
|
+
const { prepareWorkflowMcpForProject } = await import("../workflow-mcp-auto-prep.js");
|
|
522
|
+
prepareWorkflowMcpForProject(ctx, basePath);
|
|
523
|
+
}
|
|
524
|
+
|
|
510
525
|
export function registerHooks(
|
|
511
526
|
pi: ExtensionAPI,
|
|
512
527
|
ecosystemHandlers: GSDEcosystemBeforeAgentStartHandler[],
|
|
@@ -532,12 +547,7 @@ export function registerHooks(
|
|
|
532
547
|
await syncServiceTierStatus(ctx);
|
|
533
548
|
await applyDisabledModelProviderPolicy(ctx);
|
|
534
549
|
await applyCompactionThresholdOverride(ctx);
|
|
535
|
-
|
|
536
|
-
const { isInAutoWorktree } = await import("../auto-worktree.js");
|
|
537
|
-
if (!isInAutoWorktree(basePath)) {
|
|
538
|
-
const { prepareWorkflowMcpForProject } = await import("../workflow-mcp-auto-prep.js");
|
|
539
|
-
prepareWorkflowMcpForProject(ctx, basePath);
|
|
540
|
-
}
|
|
550
|
+
await prepareWorkflowMcpForHookContext(ctx, basePath);
|
|
541
551
|
|
|
542
552
|
// Apply show_token_cost preference (#1515)
|
|
543
553
|
try {
|
|
@@ -563,15 +573,7 @@ export function registerHooks(
|
|
|
563
573
|
await syncServiceTierStatus(ctx);
|
|
564
574
|
await applyDisabledModelProviderPolicy(ctx);
|
|
565
575
|
await applyCompactionThresholdOverride(ctx);
|
|
566
|
-
|
|
567
|
-
// already has .mcp.json from createAutoWorktree, and re-running the writer
|
|
568
|
-
// post-chdir rewrites the file mid-run (non-idempotent due to cwd-relative
|
|
569
|
-
// CLI path resolution), dirtying the tree and breaking the milestone merge.
|
|
570
|
-
const { isInAutoWorktree } = await import("../auto-worktree.js");
|
|
571
|
-
if (!isInAutoWorktree(basePath)) {
|
|
572
|
-
const { prepareWorkflowMcpForProject } = await import("../workflow-mcp-auto-prep.js");
|
|
573
|
-
prepareWorkflowMcpForProject(ctx, basePath);
|
|
574
|
-
}
|
|
576
|
+
await prepareWorkflowMcpForHookContext(ctx, basePath);
|
|
575
577
|
await loadToolApiKeysForSession();
|
|
576
578
|
if (!isAutoActive()) {
|
|
577
579
|
ctx.ui.setWidget("gsd-progress", undefined);
|
|
@@ -608,6 +610,11 @@ export function registerHooks(
|
|
|
608
610
|
}
|
|
609
611
|
clearDeferredApprovalGate(beforeAgentBasePath);
|
|
610
612
|
|
|
613
|
+
// session_start can fire before the active provider has settled. By
|
|
614
|
+
// before_agent_start, Claude Code CLI sessions should get the same
|
|
615
|
+
// project MCP config that /gsd mcp init would write.
|
|
616
|
+
await prepareWorkflowMcpForHookContext(ctx, beforeAgentBasePath);
|
|
617
|
+
|
|
611
618
|
// GSD's own context injection (existing behavior — unchanged).
|
|
612
619
|
const { buildBeforeAgentStartResult } = await import("./system-context.js");
|
|
613
620
|
const gsdResult = await buildBeforeAgentStartResult(event, ctx);
|
|
@@ -26,6 +26,7 @@ import { isAutoActive, checkRemoteAutoSession } from "./auto.js";
|
|
|
26
26
|
import { getAutoWorktreePath } from "./auto-worktree.js";
|
|
27
27
|
import { currentDirectoryRoot, projectRoot } from "./commands/context.js";
|
|
28
28
|
import { loadPrompt } from "./prompt-loader.js";
|
|
29
|
+
import { isPnpmInstall } from "../../shared/package-manager-detection.js";
|
|
29
30
|
import {
|
|
30
31
|
buildDoctorHealIssuePayload,
|
|
31
32
|
buildDoctorHealSummary,
|
|
@@ -57,6 +58,7 @@ function isBunInstall(argv1: string | undefined = process.argv[1]): boolean {
|
|
|
57
58
|
|
|
58
59
|
function resolveInstallCommand(pkg: string): string {
|
|
59
60
|
if (isBunInstall()) return `bun add -g ${pkg}`;
|
|
61
|
+
if (isPnpmInstall()) return `pnpm add -g ${pkg}`;
|
|
60
62
|
return `npm install -g ${pkg}`;
|
|
61
63
|
}
|
|
62
64
|
|
|
@@ -344,6 +344,70 @@ describe("Custom engine loop integration", () => {
|
|
|
344
344
|
);
|
|
345
345
|
});
|
|
346
346
|
|
|
347
|
+
it("step mode stops after one custom workflow step", async () => {
|
|
348
|
+
_resetPendingResolve();
|
|
349
|
+
|
|
350
|
+
const runDir = makeTmpDir();
|
|
351
|
+
const graph = makeGraph([
|
|
352
|
+
makeStep({ id: "step-a" }),
|
|
353
|
+
makeStep({ id: "step-b", dependsOn: ["step-a"] }),
|
|
354
|
+
makeStep({ id: "step-c", dependsOn: ["step-b"] }),
|
|
355
|
+
], "step-mode-custom");
|
|
356
|
+
writeGraph(runDir, graph);
|
|
357
|
+
writeDefinition(runDir, graph.steps, "step-mode-custom");
|
|
358
|
+
|
|
359
|
+
const ctx = makeMockCtx();
|
|
360
|
+
const pi = makeMockPi();
|
|
361
|
+
const s = makeLoopSession({
|
|
362
|
+
activeEngineId: "custom",
|
|
363
|
+
activeRunDir: runDir,
|
|
364
|
+
basePath: runDir,
|
|
365
|
+
stepMode: true,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const deps = makeMockDeps({
|
|
369
|
+
stopAuto: async (_ctx, _pi, reason) => {
|
|
370
|
+
deps.callLog.push(`stopAuto:${reason ?? "no-reason"}`);
|
|
371
|
+
s.active = false;
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const loopPromise = autoLoop(ctx, pi, s, deps);
|
|
376
|
+
await resolveNextAgentEnd();
|
|
377
|
+
|
|
378
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
379
|
+
try {
|
|
380
|
+
await Promise.race([
|
|
381
|
+
loopPromise,
|
|
382
|
+
new Promise((_, reject) =>
|
|
383
|
+
timeout = setTimeout(() => {
|
|
384
|
+
s.active = false;
|
|
385
|
+
if (_hasPendingResolveForTest()) {
|
|
386
|
+
resolveAgentEnd({ messages: [{ role: "assistant" }] });
|
|
387
|
+
}
|
|
388
|
+
reject(new Error(
|
|
389
|
+
`step mode did not stop after one custom workflow step; calls=${pi.calls.length}; log=${deps.callLog.join(",")}`,
|
|
390
|
+
));
|
|
391
|
+
}, 1_000),
|
|
392
|
+
),
|
|
393
|
+
]);
|
|
394
|
+
} finally {
|
|
395
|
+
if (timeout) clearTimeout(timeout);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const finalGraph = readGraph(runDir);
|
|
399
|
+
assert.equal(pi.calls.length, 1, "step mode should dispatch exactly one custom step");
|
|
400
|
+
assert.equal(finalGraph.steps[0]?.status, "complete", "first step should complete");
|
|
401
|
+
assert.equal(finalGraph.steps[1]?.status, "pending", "second step should wait for the next /gsd next");
|
|
402
|
+
assert.equal(finalGraph.steps[2]?.status, "pending", "third step should wait for a later step");
|
|
403
|
+
assert.equal(
|
|
404
|
+
deps.callLog.some((e: string) => e.startsWith("stopAuto:")),
|
|
405
|
+
false,
|
|
406
|
+
"step-mode pause should not complete or stop the whole workflow",
|
|
407
|
+
);
|
|
408
|
+
assert.equal(s.preserveStepSurfaceAfterLoopExit, true);
|
|
409
|
+
});
|
|
410
|
+
|
|
347
411
|
it("stops when engine reports isComplete on first derive", async () => {
|
|
348
412
|
_resetPendingResolve();
|
|
349
413
|
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
3
6
|
|
|
7
|
+
import { registerHooks } from "../bootstrap/register-hooks.ts";
|
|
8
|
+
import { GSD_WORKFLOW_MCP_SERVER_NAME } from "../mcp-project-config.ts";
|
|
4
9
|
import { prepareWorkflowMcpForProject, shouldAutoPrepareWorkflowMcp } from "../workflow-mcp-auto-prep.ts";
|
|
5
10
|
|
|
6
11
|
test("shouldAutoPrepareWorkflowMcp enables prep for externalCli local transport", () => {
|
|
@@ -74,3 +79,58 @@ test("prepareWorkflowMcpForProject warns with /gsd mcp init guidance when prep f
|
|
|
74
79
|
assert.equal(notifications[0].level, "warning");
|
|
75
80
|
assert.match(notifications[0].message, /Please run \/gsd mcp init \./);
|
|
76
81
|
});
|
|
82
|
+
|
|
83
|
+
test("before_agent_start auto-prepares project workflow MCP for Claude Code CLI", async (t) => {
|
|
84
|
+
const projectRoot = mkdtempSync(join(tmpdir(), "gsd-mcp-before-agent-"));
|
|
85
|
+
const originalCwd = process.cwd();
|
|
86
|
+
const notifications: string[] = [];
|
|
87
|
+
const handlers = new Map<string, Array<(event: any, ctx?: any) => Promise<any> | any>>();
|
|
88
|
+
const pi = {
|
|
89
|
+
on(event: string, handler: (event: any, ctx?: any) => Promise<any> | any) {
|
|
90
|
+
const existing = handlers.get(event) ?? [];
|
|
91
|
+
existing.push(handler);
|
|
92
|
+
handlers.set(event, existing);
|
|
93
|
+
},
|
|
94
|
+
getActiveTools: () => [],
|
|
95
|
+
getAllTools: () => [],
|
|
96
|
+
setActiveTools() {},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
t.after(() => {
|
|
100
|
+
process.chdir(originalCwd);
|
|
101
|
+
rmSync(projectRoot, { recursive: true, force: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
process.chdir(projectRoot);
|
|
105
|
+
registerHooks(pi as any, []);
|
|
106
|
+
|
|
107
|
+
const beforeAgentStart = handlers.get("before_agent_start")?.[0];
|
|
108
|
+
assert.ok(beforeAgentStart, "before_agent_start hook should be registered");
|
|
109
|
+
|
|
110
|
+
await beforeAgentStart(
|
|
111
|
+
{ prompt: "hello", systemPrompt: "base" },
|
|
112
|
+
{
|
|
113
|
+
cwd: projectRoot,
|
|
114
|
+
model: { provider: "claude-code", baseUrl: "local://claude-code" },
|
|
115
|
+
modelRegistry: {
|
|
116
|
+
getProviderAuthMode: () => "externalCli",
|
|
117
|
+
isProviderRequestReady: () => true,
|
|
118
|
+
},
|
|
119
|
+
ui: {
|
|
120
|
+
notify(message: string) {
|
|
121
|
+
notifications.push(message);
|
|
122
|
+
},
|
|
123
|
+
setWidget() {},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const configPath = join(projectRoot, ".mcp.json");
|
|
129
|
+
assert.equal(existsSync(configPath), true, "Claude Code CLI turns should create project MCP config");
|
|
130
|
+
|
|
131
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as {
|
|
132
|
+
mcpServers?: Record<string, unknown>;
|
|
133
|
+
};
|
|
134
|
+
assert.ok(parsed.mcpServers?.[GSD_WORKFLOW_MCP_SERVER_NAME]);
|
|
135
|
+
assert.match(notifications.join("\n"), /Claude Code MCP prepared/);
|
|
136
|
+
});
|
|
@@ -13,7 +13,7 @@ import { mkdtempSync, mkdirSync, rmSync, existsSync, writeFileSync, readFileSync
|
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
15
|
|
|
16
|
-
import { runWorktreePostCreateHook } from "../
|
|
16
|
+
import { runWorktreePostCreateHook } from "../worktree-post-create-hook.ts";
|
|
17
17
|
|
|
18
18
|
function makeTmpDir(): string {
|
|
19
19
|
return mkdtempSync(join(tmpdir(), "gsd-wt-hook-test-"));
|
|
@@ -45,10 +45,150 @@ function writeNodeHookScript(filePath: string, code: string): void {
|
|
|
45
45
|
test("returns null when no hook path is provided", () => {
|
|
46
46
|
const src = makeTmpDir();
|
|
47
47
|
const wt = makeTmpDir();
|
|
48
|
+
const previousGsdHome = process.env.GSD_HOME;
|
|
48
49
|
try {
|
|
50
|
+
process.env.GSD_HOME = join(src, "empty-gsd-home");
|
|
49
51
|
const result = runWorktreePostCreateHook(src, wt, undefined);
|
|
50
52
|
assert.equal(result, null);
|
|
51
53
|
} finally {
|
|
54
|
+
if (previousGsdHome === undefined) delete process.env.GSD_HOME;
|
|
55
|
+
else process.env.GSD_HOME = previousGsdHome;
|
|
56
|
+
rmSync(src, { recursive: true, force: true });
|
|
57
|
+
rmSync(wt, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("reads git.worktree_post_create from project preferences without loading auto-worktree", () => {
|
|
62
|
+
const src = makeTmpDir();
|
|
63
|
+
const wt = makeTmpDir();
|
|
64
|
+
const previousGsdHome = process.env.GSD_HOME;
|
|
65
|
+
try {
|
|
66
|
+
process.env.GSD_HOME = join(src, "empty-gsd-home");
|
|
67
|
+
const hooksDir = join(src, ".gsd", "hooks");
|
|
68
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
69
|
+
writeFileSync(
|
|
70
|
+
join(src, ".gsd", "PREFERENCES.md"),
|
|
71
|
+
[
|
|
72
|
+
"---",
|
|
73
|
+
"git:",
|
|
74
|
+
` worktree_post_create: ${hookPath(".gsd/hooks/post-create")}`,
|
|
75
|
+
"---",
|
|
76
|
+
"",
|
|
77
|
+
].join("\n"),
|
|
78
|
+
);
|
|
79
|
+
const hookFile = hookPath(join(hooksDir, "post-create"));
|
|
80
|
+
writeNodeHookScript(
|
|
81
|
+
hookFile,
|
|
82
|
+
`require("fs").writeFileSync(require("path").join(process.env.WORKTREE_DIR, "configured-hook-ran"), "ok");`,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const result = runWorktreePostCreateHook(src, wt);
|
|
86
|
+
|
|
87
|
+
assert.equal(result, null);
|
|
88
|
+
assert.ok(existsSync(join(wt, "configured-hook-ran")), "configured hook should run");
|
|
89
|
+
} finally {
|
|
90
|
+
if (previousGsdHome === undefined) delete process.env.GSD_HOME;
|
|
91
|
+
else process.env.GSD_HOME = previousGsdHome;
|
|
92
|
+
rmSync(src, { recursive: true, force: true });
|
|
93
|
+
rmSync(wt, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("prefers canonical project PREFERENCES.md over legacy lowercase preferences.md", () => {
|
|
98
|
+
const src = makeTmpDir();
|
|
99
|
+
const wt = makeTmpDir();
|
|
100
|
+
const previousGsdHome = process.env.GSD_HOME;
|
|
101
|
+
try {
|
|
102
|
+
process.env.GSD_HOME = join(src, "empty-gsd-home");
|
|
103
|
+
const hooksDir = join(src, ".gsd", "hooks");
|
|
104
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
105
|
+
writeFileSync(
|
|
106
|
+
join(src, ".gsd", "preferences.md"),
|
|
107
|
+
[
|
|
108
|
+
"---",
|
|
109
|
+
"git:",
|
|
110
|
+
` worktree_post_create: ${hookPath(".gsd/hooks/legacy")}`,
|
|
111
|
+
"---",
|
|
112
|
+
"",
|
|
113
|
+
].join("\n"),
|
|
114
|
+
);
|
|
115
|
+
writeFileSync(
|
|
116
|
+
join(src, ".gsd", "PREFERENCES.md"),
|
|
117
|
+
[
|
|
118
|
+
"---",
|
|
119
|
+
"git:",
|
|
120
|
+
` worktree_post_create: ${hookPath(".gsd/hooks/canonical")}`,
|
|
121
|
+
"---",
|
|
122
|
+
"",
|
|
123
|
+
].join("\n"),
|
|
124
|
+
);
|
|
125
|
+
writeNodeHookScript(
|
|
126
|
+
hookPath(join(hooksDir, "canonical")),
|
|
127
|
+
`require("fs").writeFileSync(require("path").join(process.env.WORKTREE_DIR, "configured-hook-ran"), "canonical");`,
|
|
128
|
+
);
|
|
129
|
+
writeNodeHookScript(
|
|
130
|
+
hookPath(join(hooksDir, "legacy")),
|
|
131
|
+
`require("fs").writeFileSync(require("path").join(process.env.WORKTREE_DIR, "configured-hook-ran"), "legacy");`,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const result = runWorktreePostCreateHook(src, wt);
|
|
135
|
+
|
|
136
|
+
assert.equal(result, null);
|
|
137
|
+
assert.equal(readFileSync(join(wt, "configured-hook-ran"), "utf-8"), "canonical");
|
|
138
|
+
} finally {
|
|
139
|
+
if (previousGsdHome === undefined) delete process.env.GSD_HOME;
|
|
140
|
+
else process.env.GSD_HOME = previousGsdHome;
|
|
141
|
+
rmSync(src, { recursive: true, force: true });
|
|
142
|
+
rmSync(wt, { recursive: true, force: true });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("prefers canonical global PREFERENCES.md over legacy lowercase preferences.md", () => {
|
|
147
|
+
const src = makeTmpDir();
|
|
148
|
+
const wt = makeTmpDir();
|
|
149
|
+
const previousGsdHome = process.env.GSD_HOME;
|
|
150
|
+
try {
|
|
151
|
+
const gsdHomeDir = join(src, "global-gsd-home");
|
|
152
|
+
process.env.GSD_HOME = gsdHomeDir;
|
|
153
|
+
const hooksDir = join(src, ".gsd", "hooks");
|
|
154
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
155
|
+
mkdirSync(gsdHomeDir, { recursive: true });
|
|
156
|
+
writeFileSync(
|
|
157
|
+
join(gsdHomeDir, "preferences.md"),
|
|
158
|
+
[
|
|
159
|
+
"---",
|
|
160
|
+
"git:",
|
|
161
|
+
` worktree_post_create: ${hookPath(".gsd/hooks/legacy")}`,
|
|
162
|
+
"---",
|
|
163
|
+
"",
|
|
164
|
+
].join("\n"),
|
|
165
|
+
);
|
|
166
|
+
writeFileSync(
|
|
167
|
+
join(gsdHomeDir, "PREFERENCES.md"),
|
|
168
|
+
[
|
|
169
|
+
"---",
|
|
170
|
+
"git:",
|
|
171
|
+
` worktree_post_create: ${hookPath(".gsd/hooks/canonical")}`,
|
|
172
|
+
"---",
|
|
173
|
+
"",
|
|
174
|
+
].join("\n"),
|
|
175
|
+
);
|
|
176
|
+
writeNodeHookScript(
|
|
177
|
+
hookPath(join(hooksDir, "canonical")),
|
|
178
|
+
`require("fs").writeFileSync(require("path").join(process.env.WORKTREE_DIR, "configured-hook-ran"), "canonical");`,
|
|
179
|
+
);
|
|
180
|
+
writeNodeHookScript(
|
|
181
|
+
hookPath(join(hooksDir, "legacy")),
|
|
182
|
+
`require("fs").writeFileSync(require("path").join(process.env.WORKTREE_DIR, "configured-hook-ran"), "legacy");`,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const result = runWorktreePostCreateHook(src, wt);
|
|
186
|
+
|
|
187
|
+
assert.equal(result, null);
|
|
188
|
+
assert.equal(readFileSync(join(wt, "configured-hook-ran"), "utf-8"), "canonical");
|
|
189
|
+
} finally {
|
|
190
|
+
if (previousGsdHome === undefined) delete process.env.GSD_HOME;
|
|
191
|
+
else process.env.GSD_HOME = previousGsdHome;
|
|
52
192
|
rmSync(src, { recursive: true, force: true });
|
|
53
193
|
rmSync(wt, { recursive: true, force: true });
|
|
54
194
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Project/App: gsd-pi
|
|
2
|
+
// File Purpose: Lightweight worktree post-create hook runner.
|
|
3
|
+
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { isAbsolute, join } from "node:path";
|
|
8
|
+
|
|
9
|
+
import { parse as parseYaml } from "yaml";
|
|
10
|
+
|
|
11
|
+
import { gsdHome } from "./gsd-home.js";
|
|
12
|
+
import { gsdRoot } from "./paths.js";
|
|
13
|
+
|
|
14
|
+
function readPreferencesObject(path: string): Record<string, unknown> | null {
|
|
15
|
+
if (!existsSync(path)) return null;
|
|
16
|
+
|
|
17
|
+
const content = readFileSync(path, "utf-8");
|
|
18
|
+
try {
|
|
19
|
+
const startMarker = content.startsWith("---\r\n") ? "---\r\n" : "---\n";
|
|
20
|
+
if (content.startsWith(startMarker)) {
|
|
21
|
+
const searchStart = startMarker.length;
|
|
22
|
+
const endIdx = content.indexOf("\n---", searchStart);
|
|
23
|
+
if (endIdx === -1) return null;
|
|
24
|
+
|
|
25
|
+
const parsed = parseYaml(content.slice(searchStart, endIdx).replace(/\r/g, ""));
|
|
26
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
27
|
+
? parsed as Record<string, unknown>
|
|
28
|
+
: null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const gitLines: string[] = [];
|
|
32
|
+
let inGitSection = false;
|
|
33
|
+
for (const rawLine of content.split("\n")) {
|
|
34
|
+
const line = rawLine.replace(/\r$/, "");
|
|
35
|
+
const heading = line.match(/^##\s+(.+)$/);
|
|
36
|
+
if (heading) {
|
|
37
|
+
inGitSection = heading[1].trim().toLowerCase().replace(/\s+/g, "_") === "git";
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (inGitSection && line.trim() && !line.trimStart().startsWith("#")) {
|
|
41
|
+
gitLines.push(line.replace(/^\s*-\s*/, ""));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (gitLines.length === 0) return null;
|
|
45
|
+
|
|
46
|
+
const parsed = parseYaml(gitLines.join("\n"));
|
|
47
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
48
|
+
? { git: parsed as Record<string, unknown> }
|
|
49
|
+
: null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extractHookPath(preferences: Record<string, unknown> | null): string | null {
|
|
56
|
+
const git = preferences?.git;
|
|
57
|
+
if (!git || typeof git !== "object" || Array.isArray(git)) return null;
|
|
58
|
+
const hookPath = (git as Record<string, unknown>).worktree_post_create;
|
|
59
|
+
return typeof hookPath === "string" && hookPath.trim() ? hookPath : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveConfiguredHookPath(sourceDir: string): string | null {
|
|
63
|
+
const paths = [
|
|
64
|
+
join(homedir(), ".pi", "agent", "gsd-preferences.md"),
|
|
65
|
+
join(gsdHome(), "preferences.md"),
|
|
66
|
+
join(gsdHome(), "PREFERENCES.md"),
|
|
67
|
+
join(gsdRoot(sourceDir), "preferences.md"),
|
|
68
|
+
join(gsdRoot(sourceDir), "PREFERENCES.md"),
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
let hookPath: string | null = null;
|
|
72
|
+
for (const path of paths) {
|
|
73
|
+
hookPath = extractHookPath(readPreferencesObject(path)) ?? hookPath;
|
|
74
|
+
}
|
|
75
|
+
return hookPath;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Run the user-configured post-create hook script after worktree creation.
|
|
80
|
+
* The script receives SOURCE_DIR and WORKTREE_DIR as environment variables.
|
|
81
|
+
* Failure is non-fatal -- returns the error message or null on success.
|
|
82
|
+
*
|
|
83
|
+
* Reads git.worktree_post_create from effective global/project preferences
|
|
84
|
+
* unless hookPath is provided directly.
|
|
85
|
+
*/
|
|
86
|
+
export function runWorktreePostCreateHook(
|
|
87
|
+
sourceDir: string,
|
|
88
|
+
worktreeDir: string,
|
|
89
|
+
hookPath?: string,
|
|
90
|
+
): string | null {
|
|
91
|
+
if (hookPath === undefined) {
|
|
92
|
+
hookPath = resolveConfiguredHookPath(sourceDir) ?? undefined;
|
|
93
|
+
}
|
|
94
|
+
if (!hookPath) return null;
|
|
95
|
+
|
|
96
|
+
let resolved = isAbsolute(hookPath) ? hookPath : join(sourceDir, hookPath);
|
|
97
|
+
if (!existsSync(resolved)) {
|
|
98
|
+
return `Worktree post-create hook not found: ${resolved}`;
|
|
99
|
+
}
|
|
100
|
+
if (process.platform === "win32") {
|
|
101
|
+
try {
|
|
102
|
+
resolved = realpathSync.native(resolved);
|
|
103
|
+
} catch {
|
|
104
|
+
// Keep the original path; the exec error below will include the failure.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const needsShell = process.platform === "win32" && /\.(bat|cmd)$/i.test(resolved);
|
|
110
|
+
execFileSync(resolved, [], {
|
|
111
|
+
cwd: worktreeDir,
|
|
112
|
+
env: {
|
|
113
|
+
...process.env,
|
|
114
|
+
SOURCE_DIR: sourceDir,
|
|
115
|
+
WORKTREE_DIR: worktreeDir,
|
|
116
|
+
},
|
|
117
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
118
|
+
encoding: "utf-8",
|
|
119
|
+
timeout: 30_000,
|
|
120
|
+
shell: needsShell,
|
|
121
|
+
});
|
|
122
|
+
return null;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
125
|
+
return `Worktree post-create hook failed: ${msg}`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join, resolve as resolvePath, sep } from 'node:path'
|
|
3
|
+
|
|
4
|
+
function hasPnpmPath(value: string | undefined): boolean {
|
|
5
|
+
if (!value) return false
|
|
6
|
+
const normalized = value.replace(/\\/g, '/').toLowerCase()
|
|
7
|
+
return (
|
|
8
|
+
normalized.includes('/.pnpm/') ||
|
|
9
|
+
normalized.endsWith('/pnpm') ||
|
|
10
|
+
normalized.endsWith('/pnpm.cjs') ||
|
|
11
|
+
normalized.endsWith('/pnpm.js')
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function pathStartsWith(pathValue: string | undefined, dir: string): boolean {
|
|
16
|
+
if (!pathValue) return false
|
|
17
|
+
const resolvedPath = resolvePath(pathValue)
|
|
18
|
+
const resolvedDir = resolvePath(dir)
|
|
19
|
+
return resolvedPath === resolvedDir || resolvedPath.startsWith(resolvedDir + sep)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Shared by update-check.ts and gsd command handlers. The JS installer keeps a
|
|
23
|
+
// parallel copy because it runs before TypeScript output exists.
|
|
24
|
+
export function isPnpmInstall(
|
|
25
|
+
argv1: string | undefined = process.argv[1],
|
|
26
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
27
|
+
): boolean {
|
|
28
|
+
if (env.npm_config_user_agent?.startsWith('pnpm/')) return true
|
|
29
|
+
if (hasPnpmPath(env.npm_execpath)) return true
|
|
30
|
+
if (hasPnpmPath(argv1)) return true
|
|
31
|
+
if (!argv1) return false
|
|
32
|
+
|
|
33
|
+
const pnpmBinDirs: string[] = []
|
|
34
|
+
if (env.PNPM_HOME) pnpmBinDirs.push(env.PNPM_HOME)
|
|
35
|
+
pnpmBinDirs.push(join(homedir(), 'Library', 'pnpm'))
|
|
36
|
+
pnpmBinDirs.push(join(homedir(), '.local', 'share', 'pnpm'))
|
|
37
|
+
|
|
38
|
+
return pnpmBinDirs.some((dir) => pathStartsWith(argv1, dir) || pathStartsWith(env.npm_execpath, dir))
|
|
39
|
+
}
|