From 76ef60ba25a844bffca9a8de5feb11e690ee83db Mon Sep 17 00:00:00 2001 From: Michel Moreira Date: Tue, 22 Sep 2026 20:59:45 -0300 Subject: [PATCH] enhance(#4836): prefer the graphify CLI for planner and researcher graph queries (#4874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhance(#4836): prefer the graphify CLI for planner and researcher graph queries The planner gets one knowledge-graph query per phase and the researcher two or three, and that single shot decides which modules the plan treats as related — and therefore how tasks are ordered into waves. It was spent on the built-in reader, which seeds by case-insensitive substring match over a node's label and description and then expands a hardcoded two hops. The phase "User Authentication" seeds on `author`, `authoring` and `unauthorized` with the same weight as `authenticate`, and when the inflated payload exceeds `--budget` the trimmer drops edges by confidence tier — so the highest-confidence tier can be discarded to fit a payload that bad seeding inflated in the first place. The graphify CLI is already a hard dependency of /gsd-graphify build, and it ranks seeds (IDF weighting, trigram fuzzy matching) and applies context filters before traversal. Both prompts now prefer it and fall back to the built-in reader, branching on `command -v graphify` — the same degradation shape the repo already uses for Context7 to ctx7. Binary presence is a self-satisfying gate: a graph can only exist if the binary built it, so the fallback covers edge cases (a CI checkout with a committed graph, a binary since removed), not the common path. No new config key and no new tool grant — both agents already have Bash. The planner additionally runs `graphify affected`. The reference states its own goal as "which subsystems may be affected by changes in this phase", which is literally reverse traversal by relation; the built-in reader only approximates it with undirected two-hop expansion and has no equivalent verb, so `affected` is skipped on the fallback path. `graphify status` now reports `graph_path`, the resolved absolute graph location, on both the present and the missing branch. The CLI takes the graph location as `--graph`, and the prompts must not re-derive `.planning/graphs/graph.json` for it: that would point the CLI at a non-existent local mirror in exactly the umbrella multi-repo setup `graphify.graph_path` (#1825) exists to serve. For the same reason the presence gate in both prompts is now the `status` call itself rather than a bare `ls` of the default location, which was already blind to the override. Known limit, stated in both prompts rather than implied: the two paths return different shapes. `graphify query` emits prose and has no `--json` flag; the built-in emits JSON with per-edge confidence tiers and budget_met/budget_estimate. `--budget` also counts rendered output on one and estimated payload bytes on the other (#2738) — same flag name, different unit. Both are read by a model and nothing machine-parses the injected block. With graphify absent from PATH the injected context is byte-identical to before. Closes #4836 Emitted-Drift-Ack-Growth: gsd-phase-researcher.md — the CLI-first branch, the reason it is preferred, and the output-shape warning are the deliverable; a pointer to a part would not be read at the decision point. Emitted-Drift-Ack-Growth: gsd-planner.md — one sentence in the load_graph_context step pointer, so it stops naming the default graph path the reference no longer assumes. * docs(#4836): record the CLI-first graph query in the planner and researcher entries * chore(#4836): add changeset fragment * enhance(#4836): name the full domain word in the planner's query-term examples The reference's own example — phase "User Authentication" → term "auth" — is the exact collision the CLI-first path exists to avoid, and it stays a collision whenever the fallback path runs, since that path matches the term as a substring of label and description. * fix(#4836): surface graph_path on the unparseable-graph status branch graphifyStatus() returned graph_path on the exists:true and exists:false outcomes but not on the third, error, outcome (graph.json present but unparseable). The planner/researcher prompts gate CLI-first dispatch on exists, not on this outcome, so a corrupt graph file made them fall through to the CLI-first branch with the literal placeholder and no real path to substitute. * docs(#4836): note graph_path's trust boundary at the --graph interpolation graph_path is reflected verbatim into a double-quoted --graph argument the agent executes via Bash. It comes from graphify.graph_path, a config surface already trusted elsewhere, so this isn't a new trust boundary -- but it is a new injection site (no --graph flag existed on this call before). One-line caution for anyone hardening this later. --------- Co-authored-by: Tom Boucher --- .../4836-graphify-cli-first-graph-queries.md | 5 ++ CONTEXT.md | 2 +- agents/gsd-phase-researcher.md | 28 +++--- agents/gsd-planner.md | 7 +- docs/AGENTS.md | 2 + docs/CONFIGURATION.md | 22 +++++ docs/FEATURES.md | 55 ++++++++++++ .../graphify-cli-first-graph-queries.md | 54 ++++++++++++ docs/features/knowledge-graph-integration.md | 1 + .../references/planner-load-graph-context.md | 37 +++++--- src/graphify.cts | 12 ++- tests/graphify-graph-path.test.cjs | 87 +++++++++++++++++++ 12 files changed, 283 insertions(+), 29 deletions(-) create mode 100644 .changeset/4836-graphify-cli-first-graph-queries.md create mode 100644 docs/features/graphify-cli-first-graph-queries.md diff --git a/.changeset/4836-graphify-cli-first-graph-queries.md b/.changeset/4836-graphify-cli-first-graph-queries.md new file mode 100644 index 000000000..7781a5858 --- /dev/null +++ b/.changeset/4836-graphify-cli-first-graph-queries.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 4874 +--- +**The planner and the phase researcher now query the knowledge graph through the `graphify` CLI when it is on `PATH`, falling back to the built-in reader otherwise** — the planner gets one graph query per phase and the researcher two or three, and that single shot decides which modules the plan treats as related, and therefore how tasks are ordered into waves. It was spent on `seedAndExpand`, which seeds by case-insensitive substring match over a node label and description and then expands a hardcoded two hops: the phase "User Authentication" seeds on `author`, `authoring` and `unauthorized` with the same weight as `authenticate`, and when the inflated payload exceeds `--budget` the trimmer drops edges by confidence tier. The CLI, already a hard dependency of `/gsd-graphify build`, ranks seeds (IDF weighting, trigram fuzzy matching) and context-filters before traversal; the planner additionally runs `graphify affected`, reverse traversal for the exact question its reference states as its own goal, which the built-in reader has no equivalent for and which is skipped on the fallback path. The branch is `command -v graphify`, the same degradation shape already used for Context7 to `ctx7` — no new config key (a graph can only exist if the binary built it, so binary presence is a self-satisfying gate) and no new tool grant (both agents already have `Bash`). `gsd-tools graphify status` now returns `graph_path`, the resolved absolute graph location, on both the present and the missing branch: the CLI takes the graph as `--graph`, and re-deriving `.planning/graphs/graph.json` would point it at a non-existent local mirror in exactly the umbrella multi-repo setup `graphify.graph_path` (#1825) exists to serve — for the same reason the presence gate in both prompts is now the `status` call rather than a bare `ls`, which was already blind to the override. Declared limit: the two paths return different shapes (CLI prose with no `--json`, built-in JSON with confidence tiers and `budget_met`/`budget_estimate`) and `--budget` counts rendered output on one and estimated payload bytes on the other; both prompts state this instead of implying a stable shape. With `graphify` absent from `PATH` the injected context is byte-identical to before. (#4836) diff --git a/CONTEXT.md b/CONTEXT.md index 823d94de9..00aae55cc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -473,7 +473,7 @@ Module owning the explicit per-runtime config-mutation dispatch table for the in Module owning the projection of gsd-core's artifact surfaces (`commands`, `agents`, hooks) onto the Claude Code plugin contract (`.claude-plugin/plugin.json` + `hooks/hooks.json`) — the plugin-contract sibling of the Runtime Artifact Layout Module (which projects the same surfaces onto filesystem placements). Defined mapping: `name`=`binName` (drives the `/gsd-core:` command namespace), `repository`/`homepage`=`repoUrl` (Package Identity Module), `version`/`description`/`license` from `package.json` (`version` is required for `claude plugin validate --strict`), `commands`=`./commands/gsd/`, agents via Claude Code's default `agents/` discovery (the explicit string form is schema-rejected), `hooks`=`./hooks/hooks.json`. The hook projection carries ONLY the always-on subset of the Installer Module's Claude `settings.json` wiring (check-update, context-monitor, prompt-guard, read-guard, worktree-path-guard, read-injection-scanner, write-guard) via `${CLAUDE_PLUGIN_ROOT}`; config-gated opt-in hooks are excluded because a static manifest cannot honor per-project config gates, and plugin-shipped agents cannot carry hook frontmatter (so all plugin-path hook wiring lives in hooks.json). `hooks.json` covers all seven Claude Code lifecycle events: SessionStart, PreToolUse, PostToolUse, SubagentStop, Stop, PreCompact (all wired to context-monitor for context-headroom awareness), and FileChanged (matcher: `config.json` → config-reload, injects `additionalContext` when `.planning/config.json` changes mid-session). Additive — the file-copy path (Runtime Artifact Layout / Install Policy / Installer Modules) is unchanged. Conformance is validated by `claude plugin validate --strict` plus the in-repo drift-guard `tests/plugin-manifest.test.cjs`. _Avoid_: "the plugin API", "the plugin file" (when you mean the seam). See ADR-766 and Runtime Artifact Layout Module. ### Knowledge Graph Module -Module owning the graphify integration: tri-state capability gate (`isCapabilityActive('graphify', cwd)` from capability-state.cjs — requires installed AND surfaced AND config-enabled; replaces the former config-only `isGraphifyEnabled` gate, cutover in #1306), disabled response (`disabledResponse`), subprocess helper (`execGraphify`, typed `GRAPHIFY_REASON` enum), presence detection (`checkGraphifyInstalled`), version checking (`checkGraphifyVersion`), query surface (`graphifyQuery` — BFS seed-expand + budget trim), status surface (`graphifyStatus` — node/edge counts, mtime staleness, commit-staleness tri-state via `built_at_commit`/`commits_behind`/`commit_stale`), diff surface (`graphifyDiff` — added/removed/changed nodes+edges), build pre-flight (`graphifyBuild`), snapshot management (`writeSnapshot`). Config leg reads `.planning/config.json:graphify.enabled`; all three legs (install, surface, config) must be active; writes to `.planning/graphs/`. Graph location override (#1825): `graphify.graph_path` in `.planning/config.json` (a path relative to the project root, or absolute) redirects where `graphifyQuery`/`graphifyStatus`/`graphifyDiff` read `graph.json` — so one umbrella-level cross-repo graph serves multiple sibling projects without N drifting mirror copies; the diff snapshot (`.last-build-snapshot.json`) travels with the configured graph (same dir); the auto-update status sidecar stays project-local; `writeSnapshot` honors the key (reads the configured graph, writes the snapshot alongside it); build stays project-scoped (`.planning/graphs/`) since the build skill hardcodes that destination — the umbrella graph is built in the umbrella project and sub-projects only READ it. Unset/blank/non-string → byte-identical `.planning/graphs/graph.json` default; a configured-but-missing file yields an actionable error naming the path. The key is registered in `config-schema.manifest.json` `validKeys`. Auto-update hook (`hooks/gsd-graphify-update.sh`) triggers a detached background rebuild after HEAD-advancing git operations on the default branch when `graphify.auto_update=true`. Status file `.planning/graphs/.last-build-status.json` carries `{ ts, status, exit_code, duration_ms, head_at_build, graphify_version }`. Graph IR uses `nodes[]`, `edges[]` (or `links[]` for graphify ≥0.7 compat), `hyperedges[]`, `built_at_commit`. `commit_stale` is tri-state: `false` (known fresh), `true` (stale), `null` (unknown — no git or pre-v0.7 graph). Source: `gsd-core/bin/lib/graphify.cjs`. Skill: `commands/gsd/graphify.md`. +Module owning the graphify integration: tri-state capability gate (`isCapabilityActive('graphify', cwd)` from capability-state.cjs — requires installed AND surfaced AND config-enabled; replaces the former config-only `isGraphifyEnabled` gate, cutover in #1306), disabled response (`disabledResponse`), subprocess helper (`execGraphify`, typed `GRAPHIFY_REASON` enum), presence detection (`checkGraphifyInstalled`), version checking (`checkGraphifyVersion`), query surface (`graphifyQuery` — BFS seed-expand + budget trim), status surface (`graphifyStatus` — node/edge counts, mtime staleness, commit-staleness tri-state via `built_at_commit`/`commits_behind`/`commit_stale`, plus `graph_path`: the resolved absolute graph location, present on BOTH the exists and the missing branch), diff surface (`graphifyDiff` — added/removed/changed nodes+edges), build pre-flight (`graphifyBuild`), snapshot management (`writeSnapshot`). Config leg reads `.planning/config.json:graphify.enabled`; all three legs (install, surface, config) must be active; writes to `.planning/graphs/`. Graph location override (#1825): `graphify.graph_path` in `.planning/config.json` (a path relative to the project root, or absolute) redirects where `graphifyQuery`/`graphifyStatus`/`graphifyDiff` read `graph.json` — so one umbrella-level cross-repo graph serves multiple sibling projects without N drifting mirror copies; the diff snapshot (`.last-build-snapshot.json`) travels with the configured graph (same dir); the auto-update status sidecar stays project-local; `writeSnapshot` honors the key (reads the configured graph, writes the snapshot alongside it); build stays project-scoped (`.planning/graphs/`) since the build skill hardcodes that destination — the umbrella graph is built in the umbrella project and sub-projects only READ it. Unset/blank/non-string → byte-identical `.planning/graphs/graph.json` default; a configured-but-missing file yields an actionable error naming the path. The key is registered in `config-schema.manifest.json` `validKeys`. Auto-update hook (`hooks/gsd-graphify-update.sh`) triggers a detached background rebuild after HEAD-advancing git operations on the default branch when `graphify.auto_update=true`. Status file `.planning/graphs/.last-build-status.json` carries `{ ts, status, exit_code, duration_ms, head_at_build, graphify_version }`. Graph IR uses `nodes[]`, `edges[]` (or `links[]` for graphify ≥0.7 compat), `hyperedges[]`, `built_at_commit`. Retrieval preference (#4836): `gsd-planner` and `gsd-phase-researcher` query through the `graphify` CLI when `command -v graphify` succeeds — it ranks seeds (IDF, fuzzy) and context-filters before traversal, where `seedAndExpand` seeds by case-insensitive substring over label+description and expands a fixed two hops — and fall back to `graphifyQuery` otherwise; the planner also runs `graphify affected` (reverse traversal), which has no built-in equivalent. Binary presence is the whole gate (no config key): a graph can only exist if the binary built it. `graph_path` from `graphifyStatus` is what the prompts pass to the CLI as `--graph`, so the `graphify.graph_path` override survives on the CLI path; the two paths return different shapes (CLI prose vs built-in JSON) and `--budget` counts rendered output vs estimated payload bytes, which the prompts state rather than imply. `commit_stale` is tri-state: `false` (known fresh), `true` (stale), `null` (unknown — no git or pre-v0.7 graph). Source: `gsd-core/bin/lib/graphify.cjs`. Skill: `commands/gsd/graphify.md`. ### Intel Module Module owning the code-intelligence store: tri-state capability gate (`isCapabilityActive('intel', cwd)` from capability-state.cjs — honours installed+surfaced+config-enabled; replaces the former config-only `isIntelEnabled` gate, cutover in #1307; intel has `skills:[]` so installed/surfaced are vacuously true and the effective gate is `intel.enabled` in config), disabled response, query surface (`intelQuery` — full-text search across all intel JSON files), status surface (`intelStatus` — per-file freshness, 24-hour staleness threshold), diff surface (`intelDiff` — added/changed/removed files vs last-refresh snapshot), snapshot management (`saveRefreshSnapshot`/`intelSnapshot`), validation (`intelValidate` — existence, JSON validity, _meta.updated_at recency), api-surface render (`intelApiSurface` — generates `.planning/intel/API-SURFACE.md` from `api-map.json`), plus ungated utilities (`intelPatchMeta` — patches `_meta.updated_at` in any JSON file; `intelExtractExports` — extracts CJS/ESM exports from any JS file). Loop hook rendering gates on `state.active` (not `state.enabled`) so the `activationKey` config gate is honoured even without a per-hook `when` guard (Phase 4 tri-state alignment, #1307). Source: `gsd-core/bin/lib/intel.cjs`. Router: `gsd-core/bin/lib/intel-command-router.cjs`. See Capability Command Family Module (ADR-959 4d-impl-4) and Loop Extension Point. diff --git a/agents/gsd-phase-researcher.md b/agents/gsd-phase-researcher.md index 0a1d1d77f..c81e3d934 100644 --- a/agents/gsd-phase-researcher.md +++ b/agents/gsd-phase-researcher.md @@ -566,26 +566,34 @@ if [ -e "${_CTX[0]}" ]; then cat "${_CTX[@]}"; fi ## Step 1.3: Load Graph Context -Check for knowledge graph: - -```bash -ls .planning/graphs/graph.json 2>/dev/null -``` - -If graph.json exists, check freshness: +Check for a knowledge graph and read its freshness in one call. `status` resolves the +graph through `graphify.graph_path`, so it is also the presence gate — a bare `ls` of +the default location misses an umbrella graph shared across sibling repos: ```bash gsd_run graphify status ``` +If `exists` is `false`, continue to Step 1.5 without graph context. + If the status response has `stale: true`, note for later: "Graph is {age_hours}h old -- treat semantic relationships as approximate." Include this annotation inline with any graph context injected below. -Query the graph for each major capability in the phase scope (2-3 queries per D-05, discovery-focused): +The same response carries `graph_path` — the resolved graph location. Substitute it for `` below. `graph_path` comes from `graphify.graph_path` in `.planning/config.json`, a config surface already trusted elsewhere; if it ever carried attacker-controlled content, the literal double-quoted substitution below would need escaping. + +Query the graph for each major capability in the phase scope (2-3 queries per D-05, discovery-focused). Prefer the `graphify` CLI when it is on PATH; fall back to the built-in reader otherwise: ```bash -gsd_run graphify query "" --budget 1500 +if command -v graphify >/dev/null 2>&1; then + graphify query "" --graph "" --budget 1500 +else + gsd_run graphify query "" --budget 1500 +fi ``` +Why the CLI is preferred: it ranks seeds (IDF weighting, fuzzy matching) and applies context filters before traversal, where the built-in reader seeds by case-insensitive substring over label and description — so a term like "auth" seeds equally on `author` and `authorize` — and then expands a fixed two hops. + +The two paths return **different shapes**: the CLI emits prose, the built-in emits JSON with per-edge confidence tiers and `budget_met`/`budget_estimate`. `--budget` caps rendered output on the CLI and estimated payload bytes in the built-in — same flag name, different unit. Read whichever you get; do not assume a stable shape. + Derive query terms from the phase goal and requirement descriptions. Examples: - Phase "user authentication and session management" -> query "authentication", "session", "token" - Phase "payment integration" -> query "payment", "billing" @@ -597,7 +605,7 @@ Use graph results to: - Surface dependencies the phase description does not explicitly mention - Inform which subsystems to investigate more deeply in subsequent research steps -If no results or graph.json absent, continue to Step 1.5 without graph context. +If nothing comes back, continue to Step 1.5 without graph context. ## Step 1.5: Architectural Responsibility Mapping diff --git a/agents/gsd-planner.md b/agents/gsd-planner.md index 10578485b..05ff3dd36 100644 --- a/agents/gsd-planner.md +++ b/agents/gsd-planner.md @@ -620,9 +620,10 @@ If exists, load relevant documents by phase type: Read `gsd-core/references/planner-load-graph-context.md` and execute it. It checks for a -knowledge graph and, if `.planning/graphs/graph.json` exists, reads freshness and -phase-relevant dependency context via the `gsd_run` launcher and incorporates the results -into planning. If the graph is absent, skip and continue without graph context. +knowledge graph and, if one exists, reads freshness and phase-relevant dependency context +— through the `graphify` CLI when it is on PATH, otherwise through the `gsd_run` launcher +— and incorporates the results into planning. If the graph is absent, skip and continue +without graph context. diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 2715ed68b..d8f655f79 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -69,6 +69,7 @@ GSD uses a multi-agent architecture where thin orchestrators (workflow files) sp **Capabilities:** - Reads CONTEXT.md to focus research on user's decisions +- Queries the project knowledge graph through the `graphify` CLI when it is on `PATH` (IDF-ranked, fuzzy-matched, context-filtered seeding), falling back to the built-in substring-seeded reader otherwise (#4836) - Investigates implementation patterns for the specific phase domain - Detects test infrastructure for Nyquist validation mapping - Tags in-repo discrete values (enums, schema unions, error codes, status constants, paths) `[VERIFIED]` only after reading the source-of-truth file that run, citing path and line range, and quoting the values verbatim @@ -173,6 +174,7 @@ GSD uses a multi-agent architecture where thin orchestrators (workflow files) sp **Key behaviors:** - Reads PROJECT.md, REQUIREMENTS.md, CONTEXT.md, RESEARCH.md +- Queries the project knowledge graph through the `graphify` CLI when it is on `PATH`, adding `graphify affected` for reverse traversal, and falls back to the built-in substring-seeded reader otherwise (#4836) - Creates 2-3 atomic task plans sized for single context windows - Uses XML structure with `` elements - Emits a `` sibling for every runnable `` verify command, naming what output constitutes failure (#3172) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a62a3985f..295c0bc8e 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1139,6 +1139,28 @@ A CI-built graph rebuilt minutes ago against an old checkout will read as fresh on mtime but `commit_stale: true`. Surface both when answering architecture questions. +#### Who reads the graph: the `graphify` CLI is preferred + +`gsd-planner` and `gsd-phase-researcher` query the graph through the `graphify` +CLI when it is on `PATH`, and fall back to the built-in reader +(`gsd-tools graphify query`) otherwise. The CLI ranks seeds (IDF weighting, +fuzzy matching) and applies context filters before traversal; the built-in +reader seeds by case-insensitive substring over label and description and +expands a fixed two hops, so a term like `auth` seeds equally on `author`. The +planner also runs `graphify affected` for reverse traversal, which the built-in +reader has no equivalent for. + +There is **no config key for this** — the binary has to be installed to produce +a graph in the first place, so its presence is the gate. The two paths return +different shapes (the CLI emits prose, the built-in emits JSON with confidence +tiers and budget accounting) and `--budget` counts rendered output on one and +estimated payload bytes on the other; both are read by a model, and nothing +machine-parses the injected block. + +`graphify status` reports `graph_path`, the resolved absolute graph location +after `graphify.graph_path` is applied. That is the value passed to the CLI as +`--graph`, which is how the umbrella override keeps working on the CLI path. + ### Refactor-Trigger Settings diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 6a0570d95..f0be56c7d 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -211,6 +211,7 @@ - [Reachable Lint Rules and a Non-Destructive Quick-Task Append](#3951-reachable-lint-rules-and-a-non-destructive-quick-task-append) - [Per-Task External-Tracker Content-Resolution Seam](#3970-per-task-external-tracker-content-resolution-seam) - [Unreadable-Directory Scope Signal](#4014-unreadable-directory-scope-signal) + - [Graphify CLI Preferred for Planner and Researcher Graph Queries](#4836-graphify-cli-preferred-for-planner-and-researcher-graph-queries) --- @@ -2883,6 +2884,7 @@ With `features.global_learnings: true`, phase completion runs the extraction for - REQ-GRAPH-04: `graphify.cjs` falls back to `graph.links` when `graph.edges` is absent so older graph artifacts keep rendering. - REQ-GRAPH-05: Graphify is invoked through `gsd-tools.cjs graphify ...` command handlers. - REQ-GRAPH-06: The knowledge-graph location is configurable via `graphify.graph_path` (issue #1825) so one umbrella-level cross-repo graph can serve multiple sibling projects; `query`/`status`/`diff` read the configured graph (relative to project root), with a byte-identical `.planning/graphs/` default when unset. +- REQ-GRAPH-07: `status` reports the resolved graph location as `graph_path` — the same absolute path `query`/`diff` read, after the `graphify.graph_path` override is applied — so a caller shelling out to the `graphify` CLI passes it as `--graph` instead of re-deriving the default location (issue #4836). **Configuration:** `graphify.enabled`, `graphify.build_timeout`, `graphify.graph_path` **Reference files:** `commands/gsd/graphify.md`, `bin/lib/graphify.cjs` @@ -4397,6 +4399,59 @@ phase directory. --- +### 4836. Graphify CLI Preferred for Planner and Researcher Graph Queries + +**Purpose:** `gsd-planner` gets **one** knowledge-graph query per phase and +`gsd-phase-researcher` gets two or three. That single shot decides which modules +the plan treats as related, and therefore how tasks are ordered into waves. It +was spent on the built-in reader, which seeds by case-insensitive **substring** +match over a node's label and description and then expands a hardcoded two hops +— so the phase "User Authentication" seeds on `author`, `authoring`, and +`unauthorized` with exactly the same weight as `authenticate`, and when the +inflated result exceeds `--budget` the trimmer drops edges by confidence tier. +The `graphify` CLI, already a hard dependency of `/gsd-graphify build`, ranks +seeds (IDF weighting, trigram fuzzy matching) and applies context filters before +traversal. + +**Both prompts now prefer the CLI and fall back to the built-in reader.** The +branch is `command -v graphify`, the same degradation shape the repo already +uses for Context7 → `ctx7` in `references/research-documentation-lookup.md`. No +new config key: a `graph.json` can only exist if `graphify update .` ran, which +requires the binary, so binary presence is a self-satisfying gate. The fallback +covers edge cases — a CI checkout with a committed graph, a binary since removed +— not the common path. No new tool grant either: both agents already have +`Bash`. + +**The planner additionally runs `graphify affected`.** The reference states its +own goal as "which subsystems may be affected by changes in this phase", which is +literally reverse traversal by relation. The built-in reader only approximates it +with undirected two-hop expansion, and has no equivalent verb, so `affected` is +skipped on the fallback path. + +**`gsd-tools graphify status` now returns `graph_path`.** The CLI takes the graph +location as `--graph`, and the prompts must not re-derive +`.planning/graphs/graph.json` for it — that would point the CLI at a +non-existent local mirror in exactly the umbrella multi-repo setup +`graphify.graph_path` (#1825) exists to serve. `status` already resolves the +override, so it now reports the absolute path it resolved, on both the +graph-present and the graph-missing branch. For the same reason the presence gate +in both prompts is now the `status` call itself rather than a bare `ls` of the +default location. + +**Known limits:** +- **The two paths return different shapes.** `graphify query` emits prose and has + no `--json` flag; `gsd-tools graphify query` emits JSON with per-edge + confidence tiers and `budget_met`/`budget_estimate`. Both are consumed by a + model, and nothing machine-parses this block, but the prompts now say so + explicitly instead of implying a stable shape. +- **`--budget` means different things on the two paths** — rendered output on the + CLI, estimated payload bytes in the built-in reader (#2738). Same flag name, + different unit. +- With `graphify` absent from `PATH` the fallback runs and the injected graph + context is byte-identical to before. + +--- + _Generated by `scripts/gen-features.cjs` — add a fragment under `docs/features/` and run `--write`._ diff --git a/docs/features/graphify-cli-first-graph-queries.md b/docs/features/graphify-cli-first-graph-queries.md new file mode 100644 index 000000000..07a3b84c2 --- /dev/null +++ b/docs/features/graphify-cli-first-graph-queries.md @@ -0,0 +1,54 @@ +--- +id: 4836 +title: Graphify CLI Preferred for Planner and Researcher Graph Queries +group: v1.7.0 Features +--- + +**Purpose:** `gsd-planner` gets **one** knowledge-graph query per phase and +`gsd-phase-researcher` gets two or three. That single shot decides which modules +the plan treats as related, and therefore how tasks are ordered into waves. It +was spent on the built-in reader, which seeds by case-insensitive **substring** +match over a node's label and description and then expands a hardcoded two hops +— so the phase "User Authentication" seeds on `author`, `authoring`, and +`unauthorized` with exactly the same weight as `authenticate`, and when the +inflated result exceeds `--budget` the trimmer drops edges by confidence tier. +The `graphify` CLI, already a hard dependency of `/gsd-graphify build`, ranks +seeds (IDF weighting, trigram fuzzy matching) and applies context filters before +traversal. + +**Both prompts now prefer the CLI and fall back to the built-in reader.** The +branch is `command -v graphify`, the same degradation shape the repo already +uses for Context7 → `ctx7` in `references/research-documentation-lookup.md`. No +new config key: a `graph.json` can only exist if `graphify update .` ran, which +requires the binary, so binary presence is a self-satisfying gate. The fallback +covers edge cases — a CI checkout with a committed graph, a binary since removed +— not the common path. No new tool grant either: both agents already have +`Bash`. + +**The planner additionally runs `graphify affected`.** The reference states its +own goal as "which subsystems may be affected by changes in this phase", which is +literally reverse traversal by relation. The built-in reader only approximates it +with undirected two-hop expansion, and has no equivalent verb, so `affected` is +skipped on the fallback path. + +**`gsd-tools graphify status` now returns `graph_path`.** The CLI takes the graph +location as `--graph`, and the prompts must not re-derive +`.planning/graphs/graph.json` for it — that would point the CLI at a +non-existent local mirror in exactly the umbrella multi-repo setup +`graphify.graph_path` (#1825) exists to serve. `status` already resolves the +override, so it now reports the absolute path it resolved, on both the +graph-present and the graph-missing branch. For the same reason the presence gate +in both prompts is now the `status` call itself rather than a bare `ls` of the +default location. + +**Known limits:** +- **The two paths return different shapes.** `graphify query` emits prose and has + no `--json` flag; `gsd-tools graphify query` emits JSON with per-edge + confidence tiers and `budget_met`/`budget_estimate`. Both are consumed by a + model, and nothing machine-parses this block, but the prompts now say so + explicitly instead of implying a stable shape. +- **`--budget` means different things on the two paths** — rendered output on the + CLI, estimated payload bytes in the built-in reader (#2738). Same flag name, + different unit. +- With `graphify` absent from `PATH` the fallback runs and the injected graph + context is byte-identical to before. diff --git a/docs/features/knowledge-graph-integration.md b/docs/features/knowledge-graph-integration.md index 7e3ea1e65..e888df27f 100644 --- a/docs/features/knowledge-graph-integration.md +++ b/docs/features/knowledge-graph-integration.md @@ -13,6 +13,7 @@ group: v1.37.0 Features - REQ-GRAPH-04: `graphify.cjs` falls back to `graph.links` when `graph.edges` is absent so older graph artifacts keep rendering. - REQ-GRAPH-05: Graphify is invoked through `gsd-tools.cjs graphify ...` command handlers. - REQ-GRAPH-06: The knowledge-graph location is configurable via `graphify.graph_path` (issue #1825) so one umbrella-level cross-repo graph can serve multiple sibling projects; `query`/`status`/`diff` read the configured graph (relative to project root), with a byte-identical `.planning/graphs/` default when unset. +- REQ-GRAPH-07: `status` reports the resolved graph location as `graph_path` — the same absolute path `query`/`diff` read, after the `graphify.graph_path` override is applied — so a caller shelling out to the `graphify` CLI passes it as `--graph` instead of re-deriving the default location (issue #4836). **Configuration:** `graphify.enabled`, `graphify.build_timeout`, `graphify.graph_path` **Reference files:** `commands/gsd/graphify.md`, `bin/lib/graphify.cjs` diff --git a/gsd-core/references/planner-load-graph-context.md b/gsd-core/references/planner-load-graph-context.md index 74f0fe5c4..da80fbe76 100644 --- a/gsd-core/references/planner-load-graph-context.md +++ b/gsd-core/references/planner-load-graph-context.md @@ -2,35 +2,46 @@ > Loaded by `gsd-planner` at the `load_graph_context` step. -Check for knowledge graph: - -```bash -ls .planning/graphs/graph.json 2>/dev/null -``` - -If graph.json exists, check freshness: +Check for a knowledge graph and read its freshness in one call. `status` resolves the +graph through `graphify.graph_path`, so it is also the presence gate — a bare +`ls` of the default location misses an umbrella graph shared across sibling repos: ```bash _GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi gsd_run graphify status ``` +If `exists` is `false`, continue without graph context — skip the rest of this step. + If the status response has `stale: true`, note for later: "Graph is {age_hours}h old -- treat semantic relationships as approximate." Include this annotation inline with any graph context injected below. -Query the graph for phase-relevant dependency context (single query per D-06): +The same response carries `graph_path` — the resolved graph location. Substitute it for `` below. `graph_path` comes from `graphify.graph_path` in `.planning/config.json`, a config surface already trusted elsewhere; if it ever carried attacker-controlled content, the literal double-quoted substitution below would need escaping. + +Query the graph for phase-relevant dependency context (single query per D-06). Prefer the `graphify` CLI when it is on PATH; fall back to the built-in reader otherwise: ```bash -gsd_run graphify query "" --budget 2000 +if command -v graphify >/dev/null 2>&1; then + graphify query "" --graph "" --budget 2000 + graphify affected "" --graph "" --depth 2 +else + gsd_run graphify query "" --budget 2000 +fi ``` -Use the keyword that best captures the phase goal. Examples: -- Phase "User Authentication" -> query term "auth" +Why the CLI is preferred: it ranks seeds (IDF weighting, fuzzy matching) and applies context filters before traversal, where the built-in reader seeds by case-insensitive substring over label and description — so a term like "auth" seeds equally on `author` and `authorize` — and then expands a fixed two hops. `affected` answers "which subsystems may be affected by changes in this phase" directly, by reverse traversal; it has no built-in equivalent, so the fallback path runs the query alone. + +The two paths return **different shapes**: the CLI emits prose, the built-in emits JSON with per-edge confidence tiers and `budget_met`/`budget_estimate`. `--budget` caps rendered output on the CLI and estimated payload bytes in the built-in — same flag name, different unit. Read whichever you get; do not assume a stable shape and do not paste raw output into PLAN.md. + +Use the keyword that best captures the phase goal. Prefer the full domain word over a +prefix of it — on the fallback path a prefix is matched as a substring, so "auth" also +seeds on `author` and `authoring`. Examples: +- Phase "User Authentication" -> query term "authentication" - Phase "Payment Integration" -> query term "payment" - Phase "Database Migration" -> query term "migration" -If the query returns nodes and edges, incorporate as dependency context for planning: +If the query returns related nodes, incorporate as dependency context for planning: - Which modules/files are semantically related to this phase's domain - Which subsystems may be affected by changes in this phase - Cross-document relationships that inform task ordering and wave structure -If no results or graph.json absent, continue without graph context. +If nothing comes back, continue without graph context. diff --git a/src/graphify.cts b/src/graphify.cts index 1aba60557..26120fc9f 100644 --- a/src/graphify.cts +++ b/src/graphify.cts @@ -597,7 +597,7 @@ function graphifyStatus(cwd: string): unknown { const { graphPath, configured } = resolveGraphLocation(cwd, planningDir); if (!fs.existsSync(graphPath)) { - return { exists: false, message: configured + return { exists: false, graph_path: graphPath, message: configured ? `Configured graph not found at ${graphPath}. Set graphify.graph_path or run /gsd:graphify build.` : 'No graph built yet. Run graphify build to create one.' }; } @@ -605,7 +605,10 @@ function graphifyStatus(cwd: string): unknown { const stat = fs.statSync(graphPath); const graph = safeReadJson(graphPath); if (!graph) { - return { error: 'Failed to parse graph.json' }; + // Still surface graph_path (#4836 Minor 1): callers gate on `exists`, not + // on this outcome, so without it they'd fall through to the CLI-first + // branch with no --graph value to substitute for the placeholder. + return { error: 'Failed to parse graph.json', graph_path: graphPath }; } const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours @@ -639,6 +642,11 @@ function graphifyStatus(cwd: string): unknown { return { exists: true, + // The absolute location the whole graphify surface reads, already resolved + // through `graphify.graph_path` (#1825). Callers that shell out to the + // graphify CLI pass this as `--graph` so the umbrella override is honoured + // there too, instead of re-deriving `.planning/graphs/graph.json` (#4836). + graph_path: graphPath, last_build: stat.mtime.toISOString(), node_count: (graph.nodes || []).length, edge_count: (graph.edges || graph.links || []).length, diff --git a/tests/graphify-graph-path.test.cjs b/tests/graphify-graph-path.test.cjs index 3c792aa06..14c51db45 100644 --- a/tests/graphify-graph-path.test.cjs +++ b/tests/graphify-graph-path.test.cjs @@ -224,6 +224,93 @@ describe('graphify graph_path override — status', () => { assert.strictEqual(result.exists, false); assert.ok(result.message.includes(abs), 'status message must name the configured path'); }); + + // `graph_path` (#4836). The planner/researcher prompts prefer the `graphify` CLI, + // which takes the graph location as `--graph`. They read that value from status + // rather than re-deriving `.planning/graphs/graph.json`, which would silently + // point the CLI at a non-existent local mirror whenever the umbrella override is + // configured — the exact case graph_path exists to serve. So status must emit the + // SAME absolute path the built-in reader uses, on both the present and the missing + // branch (the missing branch is what makes the error actionable at the call site). + + test('set + present → status emits graph_path = the configured absolute path', () => { + const umbrellaDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-umbrella-gp-')); + try { + const abs = path.join(umbrellaDir, 'graph.json'); + fs.writeFileSync(abs, JSON.stringify(UMBRELLA_GRAPH), 'utf8'); + setGraphPath(planningDir, abs); + + const result = graphifyStatus(tmpDir); + assert.strictEqual(result.exists, true); + assert.strictEqual(result.graph_path, abs); + assert.notStrictEqual( + result.graph_path, + path.join(planningDir, 'graphs', 'graph.json'), + 'graph_path must be the configured location, not the default mirror', + ); + } finally { + cleanup(umbrellaDir); + } + }); + + test('set + relative → graph_path is resolved against the project root', () => { + const umbrellaDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-umbrella-rel-')); + try { + const abs = path.join(umbrellaDir, 'graph.json'); + fs.writeFileSync(abs, JSON.stringify(UMBRELLA_GRAPH), 'utf8'); + const rel = path.relative(tmpDir, abs); + assert.ok(!path.isAbsolute(rel), 'fixture must feed a relative graph_path'); + setGraphPath(planningDir, rel); + + const result = graphifyStatus(tmpDir); + assert.strictEqual(result.exists, true); + assert.ok(path.isAbsolute(result.graph_path), 'graph_path must be absolute'); + assert.strictEqual(result.graph_path, abs); + } finally { + cleanup(umbrellaDir); + } + }); + + test('set + missing → graph_path still names the configured path', () => { + const abs = path.join(tmpDir, 'missing.json'); + setGraphPath(planningDir, abs); + + const result = graphifyStatus(tmpDir); + assert.strictEqual(result.exists, false); + assert.strictEqual(result.graph_path, abs); + }); + + test('unset → graph_path is the default .planning/graphs/graph.json', () => { + fs.mkdirSync(path.join(planningDir, 'graphs'), { recursive: true }); + const def = path.join(planningDir, 'graphs', 'graph.json'); + fs.writeFileSync(def, JSON.stringify(SAMPLE_GRAPH), 'utf8'); + + const result = graphifyStatus(tmpDir); + assert.strictEqual(result.exists, true); + assert.strictEqual(result.graph_path, def); + }); + + test('unset + missing → graph_path is the default path', () => { + const result = graphifyStatus(tmpDir); + assert.strictEqual(result.exists, false); + assert.strictEqual(result.graph_path, path.join(planningDir, 'graphs', 'graph.json')); + }); + + // #4836 review Minor 1: a present-but-unparseable graph.json hits neither the + // `exists:false` nor the `exists:true` branch, so the CLI-first prompt gate + // ("if exists is false, skip") does not catch it and the CLI call would + // interpolate the literal `` placeholder without a real path to + // substitute. graph_path must be present here too. + test('present + unparseable → error response still names graph_path', () => { + const def = path.join(planningDir, 'graphs', 'graph.json'); + fs.mkdirSync(path.dirname(def), { recursive: true }); + fs.writeFileSync(def, '{not valid json', 'utf8'); + + const result = graphifyStatus(tmpDir); + assert.strictEqual(result.exists, undefined); + assert.ok(result.error, 'must report the parse failure'); + assert.strictEqual(result.graph_path, def); + }); }); // ─── diff + writeSnapshot (snapshot travels with the configured graph) ────────