@opengsd/gsd-core 1.6.0-rc.3 → 1.6.1

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.
Files changed (56) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-advisor-researcher.md +2 -0
  3. package/agents/gsd-ai-researcher.md +2 -0
  4. package/agents/gsd-assumptions-analyzer.md +2 -0
  5. package/agents/gsd-doc-classifier.md +2 -0
  6. package/agents/gsd-doc-synthesizer.md +2 -0
  7. package/agents/gsd-domain-researcher.md +2 -0
  8. package/agents/gsd-eval-auditor.md +6 -9
  9. package/agents/gsd-phase-researcher.md +2 -0
  10. package/agents/gsd-project-researcher.md +2 -0
  11. package/agents/gsd-research-synthesizer.md +2 -0
  12. package/agents/gsd-ui-researcher.md +2 -0
  13. package/bin/install.js +219 -5
  14. package/gemini-extension.json +1 -1
  15. package/gsd-core/bin/gsd-tools.cjs +11 -1
  16. package/gsd-core/bin/lib/capability-registry.cjs +48 -48
  17. package/gsd-core/bin/lib/command-aliases.cjs +10 -1
  18. package/gsd-core/bin/lib/decisions.cjs +27 -0
  19. package/gsd-core/bin/lib/eval-command-router.cjs +21 -0
  20. package/gsd-core/bin/lib/eval.cjs +60 -0
  21. package/gsd-core/bin/lib/frontmatter.cjs +132 -13
  22. package/gsd-core/bin/lib/init.cjs +131 -26
  23. package/gsd-core/bin/lib/io.cjs +1 -0
  24. package/gsd-core/bin/lib/milestone.cjs +8 -0
  25. package/gsd-core/bin/lib/phase.cjs +29 -3
  26. package/gsd-core/bin/lib/plan-scan.cjs +2 -2
  27. package/gsd-core/bin/lib/roadmap.cjs +12 -1
  28. package/gsd-core/bin/lib/shell-command-projection.cjs +31 -1
  29. package/gsd-core/bin/lib/state.cjs +34 -8
  30. package/gsd-core/bin/lib/uat-predicate.cjs +13 -6
  31. package/gsd-core/bin/lib/verification.cjs +67 -6
  32. package/gsd-core/bin/lib/verify.cjs +8 -1
  33. package/gsd-core/bin/shared/config-defaults.manifest.json +3 -0
  34. package/gsd-core/bin/shared/config-schema.manifest.json +2 -1
  35. package/gsd-core/bin/shared/model-catalog.json +8 -8
  36. package/gsd-core/references/untrusted-input-boundary.md +13 -0
  37. package/gsd-core/workflows/autonomous.md +53 -46
  38. package/gsd-core/workflows/complete-milestone.md +27 -8
  39. package/gsd-core/workflows/execute-phase.md +1 -1
  40. package/gsd-core/workflows/manager.md +17 -7
  41. package/gsd-core/workflows/new-project.md +78 -12
  42. package/gsd-core/workflows/profile-user.md +6 -2
  43. package/gsd-core/workflows/progress.md +37 -4
  44. package/gsd-core/workflows/quick.md +3 -1
  45. package/gsd-core/workflows/settings-advanced.md +10 -10
  46. package/gsd-core/workflows/ship.md +3 -1
  47. package/gsd-core/workflows/spec-phase.md +3 -1
  48. package/gsd-core/workflows/transition.md +14 -12
  49. package/gsd-core/workflows/ui-review.md +2 -6
  50. package/gsd-core/workflows/verify-work.md +44 -0
  51. package/hooks/dist/gsd-read-injection-scanner.js +49 -25
  52. package/hooks/gsd-read-injection-scanner.js +49 -25
  53. package/hooks/hooks.json +1 -1
  54. package/package.json +1 -1
  55. package/scripts/check-alias-drift.cjs +5 -0
  56. package/scripts/prompt-injection-scan.sh +5 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gsd-core",
3
3
  "displayName": "GSD Core",
4
- "version": "1.6.0-rc.3",
4
+ "version": "1.6.1",
5
5
  "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
6
6
  "author": {
7
7
  "name": "open-gsd",
@@ -17,6 +17,8 @@ Spawned by `discuss-phase` via `Task()`. You do NOT present output directly to t
17
17
  - Return structured markdown output for the main agent to synthesize
18
18
  </role>
19
19
 
20
+ @~/.claude/gsd-core/references/untrusted-input-boundary.md
21
+
20
22
  <documentation_lookup>
21
23
  @~/.claude/gsd-core/references/research-documentation-lookup.md
22
24
  </documentation_lookup>
@@ -16,6 +16,8 @@ You are a GSD AI researcher. Answer: "How do I correctly implement this AI syste
16
16
  Write Sections 3–4b of AI-SPEC.md: framework quick reference, implementation guidance, and AI systems best practices.
17
17
  </role>
18
18
 
19
+ @~/.claude/gsd-core/references/untrusted-input-boundary.md
20
+
19
21
  <documentation_lookup>
20
22
  @~/.claude/gsd-core/references/research-documentation-lookup.md
21
23
  </documentation_lookup>
@@ -18,6 +18,8 @@ Spawned by `discuss-phase-assumptions` via `Task()`. You do NOT present output d
18
18
  - Flag topics where codebase analysis alone is insufficient (needs external research)
19
19
  </role>
20
20
 
21
+ @~/.claude/gsd-core/references/untrusted-input-boundary.md
22
+
21
23
  <input>
22
24
  Agent receives via prompt:
23
25
 
@@ -18,6 +18,8 @@ You are a GSD doc classifier. You read ONE document and write a structured class
18
18
  If the prompt contains a `<required_reading>` block, use the `Read` tool to load every file listed there before doing anything else. That is your primary context.
19
19
  </role>
20
20
 
21
+ @~/.claude/gsd-core/references/untrusted-input-boundary.md
22
+
21
23
  <why_this_matters>
22
24
  Your classification drives extraction. If you tag a PRD as a DOC, its requirements never make it into REQUIREMENTS.md. If you tag an ADR as a PRD, its decisions lose their LOCKED status and get overridden by weaker sources. Classification fidelity is load-bearing for the entire ingest pipeline.
23
25
  </why_this_matters>
@@ -20,6 +20,8 @@ You do NOT prompt the user. You do NOT write PROJECT.md, REQUIREMENTS.md, or ROA
20
20
  If the prompt contains a `<required_reading>` block, load every file listed there first — especially `references/doc-conflict-engine.md` which defines your conflict report format.
21
21
  </role>
22
22
 
23
+ @~/.claude/gsd-core/references/untrusted-input-boundary.md
24
+
23
25
  <why_this_matters>
24
26
  You are the precedence-enforcing layer. Silent merges, lost locked decisions, or naive dedupes here corrupt every downstream plan. When in doubt, surface the conflict rather than pick.
25
27
  </why_this_matters>
@@ -16,6 +16,8 @@ You are a GSD domain researcher. Answer: "What do domain experts actually care a
16
16
  Research the business domain — not the technical framework. Write Section 1b of AI-SPEC.md.
17
17
  </role>
18
18
 
19
+ @~/.claude/gsd-core/references/untrusted-input-boundary.md
20
+
19
21
  <documentation_lookup>
20
22
  @~/.claude/gsd-core/references/research-documentation-lookup.md
21
23
  </documentation_lookup>
@@ -109,17 +109,14 @@ Score 5 components (ok / partial / missing):
109
109
  </step>
110
110
 
111
111
  <step name="calculate_scores">
112
- ```
113
- coverage_score = covered_count / total_dimensions × 100
114
- infra_score = (tooling + dataset + cicd + guardrails + tracing) / 5 × 100
115
- overall_score = (coverage_score × 0.6) + (infra_score × 0.4)
112
+ Do NOT compute scores by hand. Call the deterministic verb with your audited inputs:
113
+
114
+ ```bash
115
+ _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 [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/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" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/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; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
116
+ gsd_run query eval.score --covered <covered_count> --total <total_dimensions> --infra <tooling>,<dataset>,<cicd>,<guardrails>,<tracing> --raw
116
117
  ```
117
118
 
118
- Verdict:
119
- - 80-100: **PRODUCTION READY** — deploy with monitoring
120
- - 60-79: **NEEDS WORK** — address CRITICAL gaps before production
121
- - 40-59: **SIGNIFICANT GAPS** — do not deploy
122
- - 0-39: **NOT IMPLEMENTED** — review AI-SPEC.md and implement
119
+ where each infra component is `ok`, `partial`, or `missing` (from the audit_infrastructure step). Parse the JSON result — it returns `coverage_score`, `infra_score`, `overall_score`, and `verdict` (PRODUCTION READY / NEEDS WORK / SIGNIFICANT GAPS / NOT IMPLEMENTED). Use those values verbatim in EVAL-REVIEW.md; never recompute or override them.
123
120
  </step>
124
121
 
125
122
  <step name="write_eval_review">
@@ -35,6 +35,8 @@ Spawned by `/gsd:plan-phase` (integrated) or `/gsd:plan-phase --research-phase <
35
35
  Claims tagged `[ASSUMED]` signal to the planner and discuss-phase that the information needs user confirmation before becoming a locked decision. Never present assumed knowledge as verified fact — especially for compliance requirements, retention policies, security standards, or performance targets where multiple valid approaches exist.
36
36
  </role>
37
37
 
38
+ @~/.claude/gsd-core/references/untrusted-input-boundary.md
39
+
38
40
  <documentation_lookup>
39
41
  @~/.claude/gsd-core/references/research-documentation-lookup.md
40
42
  </documentation_lookup>
@@ -32,6 +32,8 @@ Your files feed the roadmap:
32
32
  **Be comprehensive but opinionated.** "Use X because Y" not "Options are X, Y, Z."
33
33
  </role>
34
34
 
35
+ @~/.claude/gsd-core/references/untrusted-input-boundary.md
36
+
35
37
  <documentation_lookup>
36
38
  @~/.claude/gsd-core/references/research-documentation-lookup.md
37
39
  </documentation_lookup>
@@ -32,6 +32,8 @@ If the prompt contains a `<required_reading>` block, you MUST use the `Read` too
32
32
  - Commit ALL research files (researchers write but don't commit — you commit everything)
33
33
  </role>
34
34
 
35
+ @~/.claude/gsd-core/references/untrusted-input-boundary.md
36
+
35
37
  <downstream_consumer>
36
38
  Your SUMMARY.md is consumed by the gsd-roadmapper agent which uses it to:
37
39
 
@@ -27,6 +27,8 @@ If the prompt contains a `<required_reading>` block, you MUST use the `Read` too
27
27
  - Return structured result to orchestrator
28
28
  </role>
29
29
 
30
+ @~/.claude/gsd-core/references/untrusted-input-boundary.md
31
+
30
32
  <documentation_lookup>
31
33
  @~/.claude/gsd-core/references/research-documentation-lookup.md
32
34
  </documentation_lookup>
package/bin/install.js CHANGED
@@ -11307,10 +11307,14 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
11307
11307
  configureKiloPermissions(isGlobal, configDir);
11308
11308
  }
11309
11309
 
11310
- // For non-Claude runtimes, set resolve_model_ids: "omit" in ~/.gsd/defaults.json
11311
- // so resolveModelInternal() returns '' instead of Claude aliases (opus/sonnet/haiku)
11312
- // that the runtime can't resolve. Users can still use model_overrides for explicit IDs.
11313
- // See #1156. Guard matches the #130-class pattern on configureOpencodePermissions above.
11310
+ // For non-Claude runtimes, DEFAULT resolve_model_ids to "omit" in ~/.gsd/defaults.json
11311
+ // when it is absent or falsy, so resolveModelInternal() returns '' instead of Claude
11312
+ // aliases (opus/sonnet/haiku) the runtime can't resolve. An explicit `true` opt-in
11313
+ // (resolveModelInternal returns full materialized model IDs) MUST be preserved —
11314
+ // rewriting it to "omit" would make generated agent manifests inherit the active
11315
+ // chat model instead of pinning the resolved model. See #1156 (default-to-omit
11316
+ // intent) and #1569 (preserve explicit true). Guard matches the #130-class pattern
11317
+ // on configureOpencodePermissions above.
11314
11318
  if (runtime !== 'claude' && !process.env.GSD_TEST_MODE) {
11315
11319
  const gsdDir = path.join(os.homedir(), '.gsd');
11316
11320
  const defaultsPath = path.join(gsdDir, 'defaults.json');
@@ -11318,7 +11322,22 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
11318
11322
  fs.mkdirSync(gsdDir, { recursive: true });
11319
11323
  let defaults = {};
11320
11324
  try { defaults = JSON.parse(fs.readFileSync(defaultsPath, 'utf8')); } catch { /* new file */ }
11321
- if (defaults.resolve_model_ids !== 'omit') {
11325
+ // Recover a malformed (valid-JSON-but-non-object) defaults.json to a fresh object so
11326
+ // the write below succeeds and the file is no longer broken. Without this, `null` /
11327
+ // `[]` / a number / a string bypass the parse catch and either throw a TypeError on
11328
+ // property access (swallowed by the outer try/catch, leaving the file broken) or get
11329
+ // a property set that won't round-trip through JSON.stringify. (#1657)
11330
+ if (defaults === null || typeof defaults !== 'object' || Array.isArray(defaults)) {
11331
+ defaults = {};
11332
+ }
11333
+ // Three-valued domain: false/absent → aliases; true → full IDs; "omit" → ''.
11334
+ // Honor ONLY an explicit canonical `true` opt-in (full model IDs) and an existing
11335
+ // "omit"; default everything else — absent, falsy, OR any non-canonical value — to
11336
+ // "omit", the safe non-Claude default. Allowlist-based so malformed values
11337
+ // (0, "", "yes", {}, …) don't leak Claude aliases the runtime can't resolve (#1569).
11338
+ const existing = defaults.resolve_model_ids;
11339
+ const shouldDefaultToOmit = existing !== true && existing !== 'omit';
11340
+ if (shouldDefaultToOmit) {
11322
11341
  defaults.resolve_model_ids = 'omit';
11323
11342
  fs.writeFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n');
11324
11343
  console.log(` ${green}✓${reset} Set resolve_model_ids: "omit" in ~/.gsd/defaults.json`);
@@ -11774,6 +11793,189 @@ function homePathCoveredByRc(globalBin, homeDir, rcFileNames) {
11774
11793
  return false;
11775
11794
  }
11776
11795
 
11796
+ /**
11797
+ * Decode fish's universal-variable value escaping (the inverse of fish's
11798
+ * `full_escape`). fish serializes every non-`[A-Za-z0-9/_]` byte in
11799
+ * `fish_variables` — e.g. space -> `\x20`, hyphen -> `\x2d`, dot -> `\x2e` —
11800
+ * and joins list elements with the literal 4-char token `\x1e` (NOT a raw
11801
+ * 0x1e byte). Callers split on `\x1e` first, then decode each element here.
11802
+ *
11803
+ * Pure and total: any unrecognised `\`-sequence is passed through verbatim,
11804
+ * so `decode(fishEscape(p)) === p` holds for every path string. Exported for
11805
+ * a fast-check round-trip property test (#323).
11806
+ *
11807
+ * @param {string} s A single (already `\x1e`-split) escaped value.
11808
+ * @returns {string} The decoded literal.
11809
+ */
11810
+ function decodeFishUniversalValue(s) {
11811
+ let out = '';
11812
+ for (let i = 0; i < s.length; i++) {
11813
+ const c = s[i];
11814
+ if (c !== '\\') { out += c; continue; }
11815
+ const n = s[i + 1];
11816
+ if (n === 'n') { out += '\n'; i += 1; }
11817
+ else if (n === 'r') { out += '\r'; i += 1; }
11818
+ else if (n === 't') { out += '\t'; i += 1; }
11819
+ else if (n === '\\') { out += '\\'; i += 1; }
11820
+ else if (n === 'x' || n === 'X') {
11821
+ const hex = s.slice(i + 2, i + 4);
11822
+ if (/^[0-9a-fA-F]{2}$/.test(hex)) { out += String.fromCharCode(parseInt(hex, 16)); i += 3; }
11823
+ else { out += c; }
11824
+ } else if (n === 'u') {
11825
+ const hex = s.slice(i + 2, i + 6);
11826
+ if (/^[0-9a-fA-F]{4}$/.test(hex)) { out += String.fromCharCode(parseInt(hex, 16)); i += 5; }
11827
+ else { out += c; }
11828
+ } else if (n === 'U') {
11829
+ const hex = s.slice(i + 2, i + 10);
11830
+ if (/^[0-9a-fA-F]{8}$/.test(hex)) { out += String.fromCodePoint(parseInt(hex, 16)); i += 9; }
11831
+ else { out += c; }
11832
+ } else { out += c; }
11833
+ }
11834
+ return out;
11835
+ }
11836
+
11837
+ /**
11838
+ * Check whether fish's configuration already places `globalBin` on PATH (#323).
11839
+ *
11840
+ * fish does not use the sh-style `export PATH=` rc files that
11841
+ * `homePathCoveredByRc()` parses, so a fish user whose `fish_user_paths`
11842
+ * already covers the global bin would otherwise see a false-positive
11843
+ * "not on your PATH" warning on every install. Two detection routes,
11844
+ * mirroring how `fish_add_path` actually persists:
11845
+ *
11846
+ * 1. The universal-variable store `fish_variables` — a
11847
+ * `SETUVAR fish_user_paths:<a>\x1e<b>…` line whose `\x1e`-separated
11848
+ * entries are absolute paths (fish does not HOME-expand them here).
11849
+ * 2. `config.fish` — explicit `fish_add_path …`, `set -gx PATH …`, or
11850
+ * `set -Ux fish_user_paths …` lines that name the directory after
11851
+ * HOME expansion.
11852
+ *
11853
+ * Best-effort and side-effect-free: any unreadable / missing file is ignored
11854
+ * (no fish subprocess is spawned). Honours `$XDG_CONFIG_HOME` and always also
11855
+ * checks `~/.config/fish`. Pass `fishConfigDir` to override the lookup
11856
+ * directory (tests).
11857
+ *
11858
+ * @param {string} globalBin Absolute path to npm's global bin directory.
11859
+ * @param {string} homeDir Absolute path used to substitute HOME / ~.
11860
+ * @param {string} [fishConfigDir] Override the fish config directory.
11861
+ * @returns {boolean} true iff fish config adds globalBin to PATH.
11862
+ */
11863
+ function homePathCoveredByFishConfig(globalBin, homeDir, fishConfigDir) {
11864
+ if (!globalBin || !homeDir) return false;
11865
+ const path = require('path');
11866
+ const fs = require('fs');
11867
+
11868
+ const normalise = (p) => {
11869
+ if (!p) return '';
11870
+ let n = p.replace(/[\\/]+$/g, '');
11871
+ if (n === '') n = p.startsWith('/') ? '/' : p;
11872
+ return n;
11873
+ };
11874
+
11875
+ const targetAbs = normalise(path.resolve(globalBin));
11876
+ const homeAbs = path.resolve(homeDir);
11877
+
11878
+ const baseDirs = [];
11879
+ if (fishConfigDir) {
11880
+ baseDirs.push(fishConfigDir);
11881
+ } else {
11882
+ if (process.env.XDG_CONFIG_HOME) {
11883
+ baseDirs.push(path.join(process.env.XDG_CONFIG_HOME, 'fish'));
11884
+ }
11885
+ baseDirs.push(path.join(homeAbs, '.config', 'fish'));
11886
+ }
11887
+
11888
+ const expandHome = (segment) => {
11889
+ let s = segment;
11890
+ s = s.replace(/\$\{HOME\}/g, homeAbs).replace(/\$HOME/g, homeAbs);
11891
+ if (s.startsWith('~/') || s === '~') {
11892
+ s = s === '~' ? homeAbs : path.join(homeAbs, s.slice(2));
11893
+ }
11894
+ return s;
11895
+ };
11896
+
11897
+ // Compare an already-resolved absolute literal (a decoded fish_user_paths
11898
+ // entry — fish stores these resolved, never as `$VAR`/`~`). A literal `$`
11899
+ // here is part of the directory name, so it must NOT be treated as an
11900
+ // unexpanded variable.
11901
+ const matchesLiteral = (segment) => {
11902
+ if (!segment || !path.isAbsolute(segment)) return false;
11903
+ try {
11904
+ return normalise(path.resolve(segment)) === targetAbs;
11905
+ } catch {
11906
+ return false;
11907
+ }
11908
+ };
11909
+
11910
+ // Compare a config.fish shell token: strip surrounding quotes, expand the
11911
+ // common HOME forms, and skip anything still holding a `$` (an unexpanded
11912
+ // variable such as `$PATH` / `$fish_user_paths`) or still relative.
11913
+ const matchesTarget = (rawSegment) => {
11914
+ if (!rawSegment) return false;
11915
+ let seg = rawSegment.trim();
11916
+ if ((seg.startsWith('"') && seg.endsWith('"')) ||
11917
+ (seg.startsWith("'") && seg.endsWith("'"))) {
11918
+ seg = seg.slice(1, -1);
11919
+ }
11920
+ const expanded = expandHome(seg);
11921
+ if (expanded.includes('$')) return false;
11922
+ return matchesLiteral(expanded);
11923
+ };
11924
+
11925
+ const readLines = (filePath) => {
11926
+ try {
11927
+ return fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
11928
+ } catch {
11929
+ return null;
11930
+ }
11931
+ };
11932
+
11933
+ for (const baseDir of baseDirs) {
11934
+ // Route 1: universal variable store.
11935
+ const uvarLines = readLines(path.join(baseDir, 'fish_variables'));
11936
+ if (uvarLines) {
11937
+ for (const rawLine of uvarLines) {
11938
+ const m = /^SETUVAR(?:\s+--\S+)*\s+fish_user_paths:(.*)$/.exec(rawLine);
11939
+ if (!m) continue;
11940
+ // Elements are joined by the literal `\x1e` token; decode each. The
11941
+ // decoded entry is an absolute literal — compare it directly.
11942
+ for (const entry of m[1].split('\\x1e')) {
11943
+ if (matchesLiteral(decodeFishUniversalValue(entry))) return true;
11944
+ }
11945
+ }
11946
+ }
11947
+
11948
+ // Route 2: config.fish explicit PATH mutations.
11949
+ const configLines = readLines(path.join(baseDir, 'config.fish'));
11950
+ if (configLines) {
11951
+ for (const rawLine of configLines) {
11952
+ const line = rawLine.replace(/^\s+/, '');
11953
+ if (line.startsWith('#')) continue;
11954
+
11955
+ let rest = null;
11956
+ let m;
11957
+ if ((m = /^fish_add_path\s+(.+)$/.exec(line))) {
11958
+ rest = m[1];
11959
+ } else if ((m = /^set\s+(?:-\S+\s+)*PATH\s+(.+)$/.exec(line))) {
11960
+ rest = m[1];
11961
+ } else if ((m = /^set\s+(?:-\S+\s+)*fish_user_paths\s+(.+)$/.exec(line))) {
11962
+ rest = m[1];
11963
+ }
11964
+ if (rest === null) continue;
11965
+
11966
+ // Tokens are whitespace-separated; flag tokens (`-g`, `--path`) and
11967
+ // variable references are skipped by matchesTarget / the `-` guard.
11968
+ for (const tok of rest.split(/\s+/)) {
11969
+ if (!tok || tok.startsWith('-')) continue;
11970
+ if (matchesTarget(tok)) return true;
11971
+ }
11972
+ }
11973
+ }
11974
+ }
11975
+
11976
+ return false;
11977
+ }
11978
+
11777
11979
  /**
11778
11980
  * Emit a PATH-export suggestion if globalBin is not already on PATH AND
11779
11981
  * the user's shell rc files do not already cover it via a HOME-relative
@@ -11816,6 +12018,16 @@ function maybeSuggestPathExport(globalBin, homeDir) {
11816
12018
  return;
11817
12019
  }
11818
12020
 
12021
+ // Same idea for fish users: fish_user_paths / config.fish already covers the
12022
+ // dir, the current session just predates it. fish has no sh-style rc file so
12023
+ // homePathCoveredByRc never sees it — check the fish config explicitly (#323).
12024
+ if (homePathCoveredByFishConfig(globalBin, homeDir)) {
12025
+ console.log('');
12026
+ console.log(` ${yellow}⚠${reset} ${bold}${globalBin}${reset}'s directory is already on your PATH via fish's universal variables — open a new fish session (or run ${cyan}exec fish${reset}).`);
12027
+ console.log('');
12028
+ return;
12029
+ }
12030
+
11819
12031
  console.log('');
11820
12032
  console.log(` ${yellow}⚠${reset} ${bold}${globalBin}${reset} is not on your PATH.`);
11821
12033
  console.log(` Add it with one of:`);
@@ -12153,6 +12365,8 @@ module.exports = {
12153
12365
  USER_OWNED_ARTIFACTS,
12154
12366
  finishInstall,
12155
12367
  homePathCoveredByRc,
12368
+ homePathCoveredByFishConfig,
12369
+ decodeFishUniversalValue,
12156
12370
  maybeSuggestPathExport,
12157
12371
  runtimeMap,
12158
12372
  allRuntimes,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-core",
3
- "version": "1.6.0-rc.3",
3
+ "version": "1.6.1",
4
4
  "description": "GSD Core — a meta-prompting, context engineering, and spec-driven development system for AI coding agents. Loads gsd's operating context into every Gemini CLI session.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }
@@ -222,6 +222,8 @@ const learnings = require('./lib/learnings.cjs');
222
222
  const gapChecker = require('./lib/gap-checker.cjs');
223
223
  const { routeStateCommand } = require('./lib/state-command-router.cjs');
224
224
  const { routeVerifyCommand } = require('./lib/verify-command-router.cjs');
225
+ const { routeEvalCommand } = require('./lib/eval-command-router.cjs');
226
+ const evalMod = require('./lib/eval.cjs');
225
227
  const { routeVerificationCommand } = require('./lib/verification-command-router.cjs');
226
228
  const verification = require('./lib/verification.cjs');
227
229
  const { routeInitCommand } = require('./lib/init-command-router.cjs');
@@ -640,7 +642,7 @@ async function main() {
640
642
  'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
641
643
  'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' +
642
644
  'profile-sample, progress, project-instruction-file, prompt-budget, requirements, research-plan, research-store, resolve-granularity, resolve-model, roadmap, scaffold, state, ' +
643
- 'task, template, user-story, validate, verify, verify-path-exists, verify-summary, workstream, worktree\n\n' +
645
+ 'task, template, user-story, validate, verify, verify-path-exists, verify-summary, eval, workstream, worktree\n\n' +
644
646
  'Global flags:\n' +
645
647
  ' --raw Emit raw output without post-processing\n' +
646
648
  ' --pick <field> Extract a single field from JSON output (dot/bracket notation)\n' +
@@ -694,6 +696,9 @@ async function main() {
694
696
  // .planning/ access needed, and resolving project root would break workflow
695
697
  // invocations that run before .planning/ exists (new-project Step 1).
696
698
  'project-instruction-file',
699
+ // #1579: eval.score is pure arithmetic (covered/total + infra weights); it
700
+ // needs no .planning/ access, so skip the findProjectRoot traversal.
701
+ 'eval',
697
702
  ]);
698
703
  if (!SKIP_ROOT_RESOLUTION.has(command)) {
699
704
  cwd = findProjectRoot(cwd);
@@ -1062,6 +1067,11 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
1062
1067
  break;
1063
1068
  }
1064
1069
 
1070
+ case 'eval': {
1071
+ routeEvalCommand({ evalMod, args, cwd, raw, error });
1072
+ break;
1073
+ }
1074
+
1065
1075
  // ─── Verification Status ───────────────────────────────────────────────
1066
1076
  //
1067
1077
  // verification status <phaseDir>