* fix(#2587): resolve cursor hook workspace from workspace_roots, not cwd gsd-cursor-session-start.js and gsd-cursor-stop.js both resolved the project as path.join(process.cwd(), '.planning', 'STATE.md'). Under the cursor-agent CLI, hooks are invoked with cwd set to the Cursor config dir (~/.cursor), not the workspace — so the lookup always missed. sessionStart could only ever emit the "no .planning/ workflow found" nudge and stop's verify-work reminder could never fire, even with .planning/STATE.md sitting in the workspace. Slash commands were unaffected, which is why only the hook layer looked blind. Both hooks already buffered stdin into `raw` and never parsed it; the payload's workspace_roots carries the real path. Multi-root was left open in the report ("first root vs any root"). Resolved forward: prefer the first root that actually carries .planning/STATE.md, so a workspace whose GSD project is not the first root still resolves — strictly better than first-root-only and identical to it in the single-root CLI case. Falls back to roots[0], then to cwd, keeping IDE behavior unchanged if the IDE ever invokes hooks from the workspace. The resolver is duplicated verbatim across the two scripts rather than shared via hooks/lib/: these hooks ship standalone, and a new hooks/lib/ file must be registered in the GENERATED installer's GSD_HOOK_LIB_FILES allowlist — the installer-omits-shipped-file class that yields MODULE_NOT_FOUND at runtime. Per CLAUDE.md "Generative Fix Divergence", the duplication carries a parity assertion so the copies cannot drift. Failing-first, demonstrated by direct invocation with cwd != workspace: pre-fix sessionStart -> "no .planning/ workflow found" stop -> {} post-fix sessionStart -> ".planning/STATE.md is present" stop -> reminder tests/fix-2587-cursor-hook-workspace-roots.test.cjs spawns the real scripts as child processes with a cwd lacking .planning/ and workspace_roots pointing at it. Boundary coverage on the roots array (0 / 1 / 2 entries), plus malformed-JSON fail-open, junk-entry filtering, the parity assertion, and a guard that neither script resolves .planning from cwd again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015TCwhbMuY37DzRMCfzTABJ * fix(#2587): extend workspace_roots fix to subagentStart; keep cwd a candidate Three findings from the isolated review, all fixed. 1. MISSED SITE (high). gsd-cursor-subagent-start.js carried the identical defect at line 43 — its own header documents workspace_roots in the input schema, but it resolved .planning/ from process.cwd() anyway. Under the cursor-agent CLI that meant every Cursor subagent (planner, executor, verifier) started with "no .planning/ workflow found" and no phase context. The report named only sessionStart and stop; the defect class was wider. Verified pre-fix vs post-fix by direct invocation with cwd != workspace. 2. SEMANTIC NARROWING (medium). The first cut searched only workspace_roots and fell back to cwd solely when the array was EMPTY. So when roots were supplied but none carried .planning/ while cwd did, the hook reported absent — where the pre-fix code, which always used cwd, reported present. That contradicted the fallback's own stated intent of preserving IDE behavior. cwd is now a CANDIDATE in the search (`[...roots, process.cwd()]`), so the fix is a strict superset of both the old behavior and the CLI fix, never a narrowing. 3. STALE GOLDEN FIXTURES (high, would have failed CI). The golden-install-parity fixtures store a content hash per installed file; these three hooks appear in 13 of the 19 runtime fixtures. Regenerated via `npm run gen:golden` — the diff is exactly the three hook hashes in exactly those 13 runtimes. Tests extended: subagentStart resolution via workspace_roots; the stop hook's absent branch (previously only session-start's was covered); an explicit regression guard that a project at cwd is still found when roots miss; parity now asserts all THREE copies byte-identical; and the cwd guard sweeps the whole RESOLVING_HOOKS list so a future hook in this family cannot be left on cwd. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015TCwhbMuY37DzRMCfzTABJ * refactor(#2587): extract cursor workspace resolution to a shared hooks/lib module The duplicate-plus-parity-test approach was the wrong call. The reported issue named two hooks; a third (subagentStart) had the identical defect. That is the signature of a systemic problem, and three copies of a resolver guarded by a parity assertion is a divergence risk maintained by hand rather than a fix. hooks/lib/cursor-workspace.js is now the single implementation. All three Cursor hooks require it; none defines a local copy. Divergence is prevented structurally instead of by asserting three copies stay byte-identical. The reason duplication looked necessary was real, and is fixed properly here rather than worked around: Cursor sets hostBehaviors.skipSharedHooksInstall (#2089), so it never reaches the installer's bulk hooks/lib copy — it was the ONE runtime shipping these hooks WITHOUT hooks/lib (verified against all 19 golden fixtures: cursor had the hook scripts, no lib). A naive require would have thrown MODULE_NOT_FOUND at load, BEFORE each hook's own try/catch, wedging every session on precisely the runtime this bug is about. writeCursorHooksJson (src/runtime-hooks-surface.cts) now stages the hooks/lib helpers the staged scripts actually require, discovered by scanning their require('./lib/…') calls rather than a hardcoded name — so a future helper cannot be silently omitted. This is narrower than flipping skipSharedHooksInstall, which would wrongly pull in every shared hook. cursor-workspace.js is also added to GSD_HOOK_LIB_FILES so uninstall and the manifest manage it for the runtimes that do receive hooks/lib. Verified against a REAL install (runMinimalInstall, cursor/global): the helper is staged, and all three INSTALLED hooks resolve the workspace end-to-end from a cwd that is not the project. Also closes the review gap that the stop hook was excluded from the cwd-candidate regression loop — it now sweeps RESOLVING_HOOKS. The byte-parity test is replaced by a structural guard (every hook requires the shared module, none redefines it) plus a new install test asserting the helper is staged and the installed hook actually loads against it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015TCwhbMuY37DzRMCfzTABJ * fix(#2587): fail loud on a missing hook lib source; drop unsubstituted version marker Two findings from the installer-focused review. H1 — the staging step's `if (!fs.existsSync(libSrc)) continue;` silently defeated the very guarantee it was added for. Reproduced: delete hooks/lib/cursor-workspace.js from source, run the cursor install — it exits 0, prints "Done!", and ships the three hook scripts with an EMPTY hooks/lib/. The installed hook then throws `Cannot find module './lib/cursor-workspace.js'` at load, before its own try/catch, wedging every session — and nothing surfaces until a user hits it. The scan protected against a required-but-UNLISTED helper while leaving required-but-MISSING wide open (typo, bad rebase, an accidental delete). It now throws: a missing helper source is a packaging bug and aborts the install. M1 — hooks/lib/cursor-workspace.js carried a `gsd-hook-version: <placeholder>` marker that NOTHING substitutes: copyLibDir stamps .sh files only, and writeCursorHooksJson's staging applies just the colon-to-dash rewrite. Verified the literal was reaching disk on both the bulk (--claude) and Cursor (--cursor) paths. hooks/lib/git-cmd.js — the only pre-existing hooks/lib/*.js — carries no such marker, so this was newly introduced, not inherited. Marker removed, matching that precedent, with a note on why. (The explanatory comment deliberately does not spell the token out, or it would reintroduce the literal.) M2 — the require-scan regex demanded the exact compact form, so `require( "./lib/x.js" )` would silently fail to stage its helper and compound H1. Now tolerant of interior whitespace and either quote style. Regression test added for H1 — the reviewer confirmed the invariant had zero coverage repo-wide: a source tree carrying the hooks but no hooks/lib/ must make writeCursorHooksJson throw rather than produce a broken install. Re-verified end to end: the missing-source case throws, no unsubstituted literal ships, and the installed hook still resolves the workspace from a foreign cwd. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015TCwhbMuY37DzRMCfzTABJ * chore(#2587): backfill changeset pr number (#2680) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
75 lines
3.4 KiB
JavaScript
75 lines
3.4 KiB
JavaScript
// cursor-workspace.js — shared workspace resolution for Cursor lifecycle hooks (#2587).
|
|
//
|
|
// No `gsd-hook-version:` marker, deliberately — matching hooks/lib/git-cmd.js.
|
|
// Neither copy path that stages hooks/lib/*.js substitutes the version
|
|
// placeholder (copyLibDir stamps .sh files only), and the managed-hooks
|
|
// staleness scan covers top-level hooks/ names, not hooks/lib/. A marker here
|
|
// would ship to users as an unsubstituted literal.
|
|
//
|
|
// Cursor invokes hooks with cwd set to the Cursor config dir (~/.cursor) under
|
|
// the cursor-agent CLI, NOT the workspace. Every hook that resolves a project
|
|
// path from process.cwd() therefore missed: sessionStart could only emit its
|
|
// "no .planning/ workflow found" nudge, stop's verify-work reminder never fired,
|
|
// and subagentStart left every subagent without phase context.
|
|
//
|
|
// The hook payload carries the real path in `workspace_roots`. This module is
|
|
// the single place that turns that payload into a project root, so the three
|
|
// hooks cannot drift apart (they previously carried three copies of it).
|
|
//
|
|
// Consumers: gsd-cursor-session-start.js, gsd-cursor-stop.js,
|
|
// gsd-cursor-subagent-start.js. Staged into <config>/hooks/lib/ by
|
|
// writeCursorHooksJson (src/runtime-hooks-surface.cts) alongside the scripts
|
|
// that require it, and registered in the installer's GSD_HOOK_LIB_FILES so
|
|
// uninstall and the manifest manage it.
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
/**
|
|
* Resolve the workspace root a Cursor hook should report on.
|
|
*
|
|
* Search order:
|
|
* 1. Each entry of `workspace_roots` that actually carries .planning/STATE.md
|
|
* — so a multi-root workspace whose GSD project is not the first root
|
|
* still resolves.
|
|
* 2. process.cwd(), if IT carries .planning/STATE.md. cwd is a CANDIDATE, not
|
|
* merely the empty-roots fallback: an IDE invocation can supply
|
|
* workspace_roots AND run from the project, and searching roots alone
|
|
* would report "absent" for a project sitting right at cwd — narrower than
|
|
* the pre-fix always-cwd behavior this replaces.
|
|
* 3. The first declared root, else cwd — so the "absent" message still names
|
|
* a sensible directory when there is genuinely no project.
|
|
*
|
|
* Never throws: a malformed payload degrades to cwd rather than wedging the
|
|
* session. (fs.existsSync itself does not throw — it returns false for NUL
|
|
* bytes, over-long components, and non-directory ancestors.)
|
|
*
|
|
* @param {string} rawInput Raw stdin payload as received by the hook.
|
|
* @returns {string} Absolute-or-relative directory to resolve .planning/ against.
|
|
*/
|
|
function resolveWorkspaceRoot(rawInput) {
|
|
let input = {};
|
|
try { input = JSON.parse(rawInput || '{}'); } catch { /* fall back to cwd */ }
|
|
const roots = Array.isArray(input.workspace_roots)
|
|
? input.workspace_roots.filter((r) => typeof r === 'string' && r.length > 0)
|
|
: [];
|
|
for (const root of [...roots, process.cwd()]) {
|
|
if (fs.existsSync(path.join(root, '.planning', 'STATE.md'))) return root;
|
|
}
|
|
return roots[0] || process.cwd();
|
|
}
|
|
|
|
/**
|
|
* Convenience: the .planning/STATE.md path for the resolved workspace.
|
|
*
|
|
* @param {string} rawInput Raw stdin payload as received by the hook.
|
|
* @returns {string}
|
|
*/
|
|
function resolveStatePath(rawInput) {
|
|
return path.join(resolveWorkspaceRoot(rawInput), '.planning', 'STATE.md');
|
|
}
|
|
|
|
module.exports = { resolveWorkspaceRoot, resolveStatePath };
|