@opengsd/gsd-core 1.7.0-rc.5 → 1.7.0

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 (112) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-executor.md +2 -1
  4. package/agents/gsd-security-auditor.md +13 -15
  5. package/agents/gsd-ui-checker.md +2 -0
  6. package/agents/gsd-ui-researcher.md +1 -0
  7. package/bin/install.js +975 -196
  8. package/commands/gsd/mempalace-capture.md +27 -1
  9. package/commands/gsd/surface.md +6 -6
  10. package/gsd-core/bin/gsd-tools.cjs +63 -2
  11. package/gsd-core/bin/lib/api-coverage.cjs +3 -4
  12. package/gsd-core/bin/lib/audit.cjs +7 -6
  13. package/gsd-core/bin/lib/capability-registry.cjs +503 -87
  14. package/gsd-core/bin/lib/capability-validator.cjs +56 -18
  15. package/gsd-core/bin/lib/check-command-router.cjs +1 -1
  16. package/gsd-core/bin/lib/clock.cjs +19 -0
  17. package/gsd-core/bin/lib/commands.cjs +48 -9
  18. package/gsd-core/bin/lib/config-loader.cjs +6 -2
  19. package/gsd-core/bin/lib/config.cjs +12 -0
  20. package/gsd-core/bin/lib/core-utils.cjs +8 -2
  21. package/gsd-core/bin/lib/drift.cjs +4 -4
  22. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  23. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  24. package/gsd-core/bin/lib/host-integration.cjs +33 -8
  25. package/gsd-core/bin/lib/init.cjs +60 -53
  26. package/gsd-core/bin/lib/install-engine.cjs +93 -22
  27. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  28. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  29. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  30. package/gsd-core/bin/lib/markdown-sectionizer.cjs +342 -0
  31. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  32. package/gsd-core/bin/lib/mcp-server.cjs +18 -7
  33. package/gsd-core/bin/lib/milestone.cjs +217 -31
  34. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  35. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  36. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  37. package/gsd-core/bin/lib/phase.cjs +436 -61
  38. package/gsd-core/bin/lib/plan-scan.cjs +3 -0
  39. package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
  40. package/gsd-core/bin/lib/roadmap-parser.cjs +218 -13
  41. package/gsd-core/bin/lib/roadmap.cjs +100 -49
  42. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +242 -44
  43. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  44. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -14
  45. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
  46. package/gsd-core/bin/lib/runtime-homes.cjs +22 -0
  47. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +526 -29
  48. package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
  49. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  50. package/gsd-core/bin/lib/security.cjs +7 -37
  51. package/gsd-core/bin/lib/shell-command-projection.cjs +176 -27
  52. package/gsd-core/bin/lib/smart-entry.cjs +4 -3
  53. package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
  54. package/gsd-core/bin/lib/state-transition.cjs +100 -45
  55. package/gsd-core/bin/lib/state.cjs +391 -126
  56. package/gsd-core/bin/lib/surface.cjs +12 -8
  57. package/gsd-core/bin/lib/template.cjs +2 -1
  58. package/gsd-core/bin/lib/uat.cjs +54 -8
  59. package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
  60. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  61. package/gsd-core/bin/lib/verify.cjs +4 -3
  62. package/gsd-core/bin/lib/workstream.cjs +3 -2
  63. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  64. package/gsd-core/bin/lib/write-set.cjs +38 -0
  65. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  66. package/gsd-core/bin/shared/model-catalog.json +8 -3
  67. package/gsd-core/references/checkpoints.md +12 -0
  68. package/gsd-core/references/ui-consideration-probe.md +73 -0
  69. package/gsd-core/templates/UI-SPEC.md +25 -0
  70. package/gsd-core/templates/VALIDATION.md +2 -0
  71. package/gsd-core/workflows/add-tests.md +1 -1
  72. package/gsd-core/workflows/audit-milestone.md +7 -4
  73. package/gsd-core/workflows/debug.md +2 -0
  74. package/gsd-core/workflows/execute-phase.md +5 -3
  75. package/gsd-core/workflows/fast.md +8 -22
  76. package/gsd-core/workflows/plan-phase.md +6 -0
  77. package/gsd-core/workflows/progress.md +2 -2
  78. package/gsd-core/workflows/quick.md +2 -0
  79. package/gsd-core/workflows/review.md +42 -3
  80. package/gsd-core/workflows/secure-phase.md +1 -1
  81. package/gsd-core/workflows/settings-advanced.md +7 -4
  82. package/gsd-core/workflows/ship.md +8 -2
  83. package/gsd-core/workflows/spec-phase.md +1 -1
  84. package/gsd-core/workflows/transition.md +1 -1
  85. package/gsd-core/workflows/ui-phase.md +146 -1
  86. package/gsd-core/workflows/validate-phase.md +2 -2
  87. package/hooks/dist/gsd-statusline.js +164 -14
  88. package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
  89. package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
  90. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  91. package/hooks/gsd-statusline.js +164 -14
  92. package/hooks/gsd-windsurf-pre-command.js +275 -0
  93. package/hooks/gsd-windsurf-pre-write.js +132 -0
  94. package/hooks/managed-hooks-registry.cjs +2 -0
  95. package/package.json +10 -4
  96. package/pi/gsd.cjs +354 -0
  97. package/scripts/build-hooks.js +3 -0
  98. package/scripts/ci-test-scope.cjs +39 -1
  99. package/scripts/gen-golden-install-parity-zcode.cjs +35 -35
  100. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  101. package/scripts/gen-registry.cjs +128 -0
  102. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  103. package/scripts/lint-table-schema-drift.cjs +157 -0
  104. package/scripts/lint-test-file-count.allowlist.json +2 -1
  105. package/scripts/registry-schema.cjs +565 -0
  106. package/scripts/validate-registry.cjs +117 -0
  107. package/skills/gsd-mempalace-capture/SKILL.md +27 -1
  108. package/skills/gsd-surface/SKILL.md +6 -6
  109. package/vscode/browser.js +197 -0
  110. package/vscode/extension.js +383 -0
  111. package/vscode/host-binding.js +113 -0
  112. package/vscode/package.json +96 -0
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ // gsd-hook-version: {{GSD_VERSION}}
3
+ // gsd-windsurf-pre-write.js — Windsurf/Cascade pre_write_code hook (ADR-1239 / #2100)
4
+ //
5
+ // Cascade (Windsurf's agent) invokes this script before each file-write tool
6
+ // call executes, via the workspace/global hooks.json hook bus.
7
+ //
8
+ // Input schema (Cascade pre_write_code envelope, JSON on stdin):
9
+ // { agent_action_name: 'pre_write_code', trajectory_id, execution_id,
10
+ // timestamp, model_name,
11
+ // tool_info: { file_path, edits: [{ old_string, new_string }] } }
12
+ //
13
+ // Decision protocol — DISTINCT from Cursor's stdout-JSON form:
14
+ // - exit 0 -> allow the write to proceed (no stdout contract)
15
+ // - exit 2 -> BLOCK the write; the printed stderr text is the reason shown
16
+ // to the agent/user
17
+ //
18
+ // Behaviour: reimplements the core containment check from
19
+ // hooks/gsd-worktree-path-guard.js — block a write whose file_path resolves
20
+ // (via `git rev-parse --show-toplevel`) to a DIFFERENT git root than the
21
+ // current working directory, or lands inside a `.git/` internals directory.
22
+ // Fails OPEN on any error, timeout, non-git cwd, or missing git binary — a
23
+ // hook bug must never wedge Cascade.
24
+ //
25
+ // Cascade hooks docs (reference): https://docs.windsurf.com/llms-full.txt ,
26
+ // https://docs.devin.ai/desktop/cascade/hooks
27
+
28
+ 'use strict';
29
+
30
+ const fs = require('fs');
31
+ const path = require('path');
32
+ const { spawnSync } = require('child_process');
33
+
34
+ const SPAWNOPT = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000, windowsHide: true };
35
+
36
+ function git(args, cwd) {
37
+ return spawnSync('git', args, { ...SPAWNOPT, cwd });
38
+ }
39
+
40
+ // Walk up from `start` to find the nearest existing DIRECTORY (not merely an
41
+ // existing filesystem entry) — a linked git worktree's `.git` is a plain FILE
42
+ // (a `gitdir:` pointer), not a directory, so a plain existence check would
43
+ // hand spawnSync an invalid `cwd` and silently fail the git calls below.
44
+ // Returns null if we reach the filesystem root without finding one.
45
+ function nearestExistingDir(start) {
46
+ let dir = start;
47
+ let prev;
48
+ do {
49
+ prev = dir;
50
+ try { if (fs.statSync(dir).isDirectory()) return dir; } catch { /* keep walking */ }
51
+ dir = path.dirname(dir);
52
+ } while (dir !== prev);
53
+ return null;
54
+ }
55
+
56
+ function block(reason) {
57
+ process.stderr.write(`GSD windsurf pre_write_code guard: ${reason}\n`);
58
+ process.exit(2);
59
+ }
60
+
61
+ function allow() {
62
+ process.exit(0);
63
+ }
64
+
65
+ let input = '';
66
+ const stdinTimeout = setTimeout(() => process.exit(0), 10000);
67
+ process.stdin.setEncoding('utf8');
68
+ process.stdin.on('data', (chunk) => { input += chunk; });
69
+ process.stdin.on('end', () => {
70
+ clearTimeout(stdinTimeout);
71
+ try {
72
+ const data = JSON.parse(input || '{}');
73
+ const toolInfo = (data && typeof data.tool_info === 'object' && data.tool_info) || {};
74
+ const rawFilePath = typeof toolInfo.file_path === 'string' ? toolInfo.file_path : '';
75
+ if (!rawFilePath) { allow(); return; }
76
+
77
+ const cwd = process.cwd();
78
+
79
+ // Determine the active project's git root. No git root at all -> nothing
80
+ // to enforce a boundary against -> fail open.
81
+ const cwdTopResult = git(['rev-parse', '--show-toplevel'], cwd);
82
+ if (cwdTopResult.status !== 0 || !cwdTopResult.stdout) { allow(); return; }
83
+ const cwdTopRaw = cwdTopResult.stdout.trim();
84
+
85
+ const filePath = path.isAbsolute(rawFilePath) ? path.resolve(rawFilePath) : path.resolve(cwd, rawFilePath);
86
+
87
+ // Find the nearest existing ancestor of filePath so we can ask git for its
88
+ // toplevel. The file itself may not exist yet (a write can create it).
89
+ const checkDir = nearestExistingDir(
90
+ (() => {
91
+ try {
92
+ return fs.statSync(filePath).isDirectory() ? filePath : path.dirname(filePath);
93
+ } catch {
94
+ return path.dirname(filePath);
95
+ }
96
+ })(),
97
+ );
98
+ if (!checkDir) { allow(); return; } // synthetic path with no existing ancestor — fail open
99
+
100
+ const fileTopResult = git(['rev-parse', '--show-toplevel'], checkDir);
101
+ if (fileTopResult.status !== 0 || !fileTopResult.stdout) {
102
+ // Not inside any git worktree. Distinguish "inside a .git/ internals
103
+ // directory" (dangerous — BLOCK) from "outside all git repos entirely"
104
+ // (not the escape vector this guard targets — fail open).
105
+ const insideGitDir = git(['rev-parse', '--is-inside-git-dir'], checkDir);
106
+ if (insideGitDir.status === 0 && insideGitDir.stdout && insideGitDir.stdout.trim() === 'true') {
107
+ block(
108
+ `'${filePath}' is inside a git internal (.git) directory, not the active project at ` +
109
+ `'${cwdTopRaw}'. Writing to repository internals via an absolute path is not permitted. ` +
110
+ `Use a relative path. (cwd: '${cwd}')`,
111
+ );
112
+ return;
113
+ }
114
+ allow();
115
+ return;
116
+ }
117
+
118
+ const fileTopRaw = fileTopResult.stdout.trim();
119
+ if (fileTopRaw === cwdTopRaw) { allow(); return; }
120
+
121
+ // BLOCK: file resolves to a different git root than the active project.
122
+ block(
123
+ `'${filePath}' resolves to git root '${fileTopRaw}' which differs from the active project root ` +
124
+ `'${cwdTopRaw}'. This likely means an absolute path was derived from a different repository. ` +
125
+ `Use a relative path within the active project, or re-derive the base directory with ` +
126
+ `\`git rev-parse --show-toplevel\` from the active project. (cwd: '${cwd}')`,
127
+ );
128
+ } catch {
129
+ // Silent fail-open — never block a valid tool call due to a hook bug.
130
+ allow();
131
+ }
132
+ });
@@ -36,6 +36,8 @@ const MANAGED_HOOKS = [
36
36
  'gsd-statusline.js',
37
37
  'gsd-update-banner.js',
38
38
  'gsd-validate-commit.sh',
39
+ 'gsd-windsurf-pre-command.js',
40
+ 'gsd-windsurf-pre-write.js',
39
41
  'gsd-workflow-guard.js',
40
42
  'gsd-worktree-path-guard.js',
41
43
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengsd/gsd-core",
3
- "version": "1.7.0-rc.5",
3
+ "version": "1.7.0",
4
4
  "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
5
5
  "main": ".opencode/plugins/gsd-core.js",
6
6
  "bin": {
@@ -20,7 +20,9 @@
20
20
  ".opencode",
21
21
  "GEMINI.md",
22
22
  "hooks",
23
- "scripts"
23
+ "scripts",
24
+ "pi",
25
+ "vscode"
24
26
  ],
25
27
  "keywords": [
26
28
  "claude",
@@ -87,6 +89,9 @@
87
89
  "gen:loop-host-contract": "node scripts/gen-loop-host-contract.cjs --write",
88
90
  "gen:plugin-skills": "node scripts/gen-plugin-skills.cjs --write",
89
91
  "gen:capability-registry": "node scripts/gen-capability-registry.cjs --write",
92
+ "gen:registry": "node scripts/gen-registry.cjs --write",
93
+ "gen:golden": "node scripts/gen-golden-install-parity-zcode.cjs && node scripts/gen-install-tree-fixtures.cjs",
94
+ "validate:registry": "node scripts/validate-registry.cjs",
90
95
  "prepack": "npm run build:lib",
91
96
  "prepare": "npm run build:lib",
92
97
  "version": "node scripts/sync-manifest-versions.cjs --stage && node scripts/gen-capability-registry.cjs --write && git add gsd-core/bin/lib/capability-registry.cjs",
@@ -95,7 +100,8 @@
95
100
  "pretest:coverage": "npm run build:lib && npm run lint:skill-deps",
96
101
  "lint": "eslint . --cache --cache-location node_modules/.cache/eslint/",
97
102
  "lint:fix": "eslint . --fix",
98
- "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs",
103
+ "lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs",
104
+ "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs",
99
105
  "lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs",
100
106
  "lint:regression-names": "node scripts/lint-regression-test-names.cjs",
101
107
  "lint:descriptions": "node scripts/lint-descriptions.cjs",
@@ -103,7 +109,7 @@
103
109
  "lint:test-file-count": "node scripts/lint-test-file-count.cjs",
104
110
  "lint:pr-checks": "node scripts/lint-pr-check-project-dir.cjs",
105
111
  "lint:changeset": "node scripts/changeset/lint.cjs",
106
- "lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check",
112
+ "lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check",
107
113
  "lint:docs": "node scripts/lint-docs-required.cjs",
108
114
  "lint:legacy-name": "node scripts/lint-legacy-dir-name.cjs",
109
115
  "ci:test-scope": "node scripts/ci-test-scope.cjs",
package/pi/gsd.cjs ADDED
@@ -0,0 +1,354 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * GSD extension for pi (pi.dev) — ADR-1239 Phase D / #1944, upgraded #2102 Stage 2.
5
+ *
6
+ * pi is a Programmatic-CLI host whose TS extensions implement the ExtensionAPI
7
+ * (`@earendil-works/pi-coding-agent`): registerCommand({handler(args, ctx)}) /
8
+ * registerTool({execute(toolCallId, params, signal, onUpdate, ctx)}) / pi.on(event, handler).
9
+ * This extension binds GSD's command surface to pi via the imperative adapter
10
+ * path — the programmatic-CLI peer of the OpenCode worked binding.
11
+ *
12
+ * Installation: copy this file to ~/.pi/agent/extensions/gsd.cjs (pi loads
13
+ * extensions via jiti from that dir). The engine is resolved from the installed
14
+ * GSD tree (walk-up like the OpenCode plugin). pi's shared hooks/ bundle
15
+ * (hooks/*.js + hooks/lib/git-cmd.js) is installed alongside the extension —
16
+ * capabilities/pi/capability.json does NOT set
17
+ * `hostBehaviors.skipSharedHooksInstall` (#2102 Stage 2 fix; pi is
18
+ * architecturally identical to OpenCode here: `hooksSurface: 'none'` + a
19
+ * native extension that spawns the staged hooks — not Kilo/ZCode's
20
+ * no-plugin-surface case, where the same hooks would be genuine dead weight).
21
+ * This is what makes the event bridges below (and the tokenizer require)
22
+ * resolve for real in an installed tree, not just in this dev repo.
23
+ *
24
+ * Engine entry: dispatch is SUBPROCESS-REUSE to gsd-tools.cjs (bounded,
25
+ * no-throw — dispatchGsdCommand in shell-command-projection.cjs), NOT an
26
+ * in-process command-routing hub. No fully-populated hub factory exists
27
+ * anywhere in gsd-core — every createHub() caller in the tree builds a
28
+ * single-family hub for its own narrow purpose — so the "in-process createHub"
29
+ * framing of the original #1944 cut was aspirational and is not achievable
30
+ * without a hub factory that doesn't exist. This mirrors the precedent already
31
+ * established for the OpenCode/Kilo hook bridge (.opencode/plugins/gsd-core.js
32
+ * header: "Architecture: SUBPROCESS REUSE ... spawns existing hook scripts as
33
+ * child processes") — the same pattern, applied to command dispatch. The
34
+ * companion MCP server (gsd-mcp-server) dispatches through the SAME shared
35
+ * helper for out-of-process hosts.
36
+ *
37
+ * @param {object} pi pi ExtensionAPI (registerTool/registerCommand/on/…)
38
+ */
39
+
40
+ const fs = require('fs');
41
+ const path = require('path');
42
+ const { spawnSync } = require('child_process');
43
+
44
+ // Resolve the GSD engine tree (the dir holding gsd-core/ + hooks/).
45
+ // Works across dev (<root>/pi/gsd.cjs → <root>) and installed layouts.
46
+ function resolveEngineRoot(startDir) {
47
+ let dir = startDir;
48
+ for (let i = 0; i < 6; i++) {
49
+ if (fs.existsSync(path.join(dir, 'gsd-core'))) return dir;
50
+ const parent = path.dirname(dir);
51
+ if (parent === dir) break;
52
+ dir = parent;
53
+ }
54
+ return path.resolve(startDir, '..');
55
+ }
56
+
57
+ const ENGINE_ROOT = resolveEngineRoot(__dirname);
58
+ const GSD_CORE = path.join(ENGINE_ROOT, 'gsd-core');
59
+
60
+ // ── curated top-level command families (gsd-tools.cjs TOP_LEVEL_USAGE) ──────
61
+ // readCmdNames() (scripts/fix-slash-commands.cjs) reads commands/, which pi
62
+ // does NOT install (it ships a single native-extension file, no shared
63
+ // commands/ dir) — it would always return []. This is a self-contained,
64
+ // hand-curated subset of the STABLE top-level families documented by
65
+ // `node gsd-core/bin/gsd-tools.cjs --help` (gsd-tools.cjs:689-705). Named +
66
+ // exported (via _internals) so a test can assert against it directly.
67
+ const PI_COMMAND_FAMILIES = Object.freeze([
68
+ 'agent', 'capability', 'check', 'commit', 'config-get', 'config-path',
69
+ 'config-set', 'effort', 'git', 'graphify', 'init', 'intel', 'learnings',
70
+ 'list-todos', 'loop', 'milestone', 'phase', 'phases', 'progress',
71
+ 'requirements', 'research-plan', 'research-store', 'resolve-granularity',
72
+ 'resolve-model', 'roadmap', 'scaffold', 'smart-entry', 'state', 'task',
73
+ 'template', 'user-story', 'validate', 'verify', 'workstream', 'worktree',
74
+ ]);
75
+
76
+ /**
77
+ * Filter PI_COMMAND_FAMILIES by prefix (startsWith). Returns null when there
78
+ * are no matches, per pi's `AutocompleteItem[]|null` contract.
79
+ * @param {string} prefix
80
+ * @returns {{value: string, label: string}[] | null}
81
+ */
82
+ function getArgumentCompletions(prefix) {
83
+ const p = typeof prefix === 'string' ? prefix : '';
84
+ const matches = PI_COMMAND_FAMILIES.filter((name) => name.startsWith(p));
85
+ if (matches.length === 0) return null;
86
+ return matches.map((value) => ({ value, label: value }));
87
+ }
88
+
89
+ /**
90
+ * Tokenize the raw `/gsd <args>` string into { family, subcommand, args }.
91
+ * Reuses the quote-aware whitespace tokenizer already shipped for hooks
92
+ * (hooks/lib/git-cmd.js's `tokenize`) rather than re-implementing shell-word
93
+ * splitting a second time. #2102 Stage 2: pi's capability descriptor no
94
+ * longer sets `hostBehaviors.skipSharedHooksInstall` (adversarial-review
95
+ * finding #1/#2 — pi ships NO hooks/ with that flag set, so this require was
96
+ * dead in a real install), so the shared hooks/ bundle — including
97
+ * hooks/lib/git-cmd.js — is installed alongside the extension for real
98
+ * (mirrors OpenCode, whose native plugin also spawns the staged hooks/*.js
99
+ * bundle). The require below is therefore the PRIMARY, live path in an
100
+ * installed pi tree; the whitespace-split fallback stays as defense-in-depth
101
+ * for a corrupted/partial install (e.g. a user who deleted hooks/lib/ by
102
+ * hand) rather than the only-ever-taken path.
103
+ * @param {string} rawArgs
104
+ * @returns {{ family: string, subcommand?: string, args: string[] }}
105
+ */
106
+ function parseGsdCommandArgs(rawArgs) {
107
+ let tokenize;
108
+ try {
109
+ ({ tokenize } = require(path.join(ENGINE_ROOT, 'hooks', 'lib', 'git-cmd.js')));
110
+ } catch {
111
+ tokenize = (s) => String(s || '').split(/\s+/).filter(Boolean);
112
+ }
113
+ const tokens = tokenize(typeof rawArgs === 'string' ? rawArgs : '');
114
+ return {
115
+ // Empty args → dispatch gsd-tools.cjs's own --help surface (a real,
116
+ // working, ok:true default — NOT the 'query'/'help' pairing the original
117
+ // #1944 cut used, which is not a valid gsd-tools.cjs command).
118
+ family: tokens[0] || '--help',
119
+ subcommand: tokens[1],
120
+ args: tokens.slice(2),
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Best-effort TypeBox schema for gsd_invoke's `parameters`, falling back to a
126
+ * plain JSON-Schema object when the `typebox` package is unavailable (it is
127
+ * NOT a gsd-core dependency — pi's own ExtensionAPI contract expects TypeBox,
128
+ * but nothing in this repo installs it). TypeBox schemas ARE JSON Schema, so
129
+ * the fallback object is structurally equivalent for hosts that accept plain
130
+ * JSON Schema; this is a best-effort shim for the flat-file extension case.
131
+ * @returns {object}
132
+ */
133
+ function buildGsdInvokeParameters() {
134
+ try {
135
+ const typebox = require('typebox');
136
+ const Type = typebox && typebox.Type;
137
+ if (Type) {
138
+ return Type.Object({
139
+ family: Type.String(),
140
+ subcommand: Type.Optional(Type.String()),
141
+ args: Type.Optional(Type.Array(Type.String())),
142
+ });
143
+ }
144
+ } catch {
145
+ // typebox is not installed in this environment — fall through.
146
+ }
147
+ process.stderr.write(
148
+ 'gsd: typebox unavailable — gsd_invoke "parameters" falling back to a plain JSON-schema object.\n',
149
+ );
150
+ return {
151
+ type: 'object',
152
+ properties: {
153
+ family: { type: 'string' },
154
+ subcommand: { type: 'string' },
155
+ args: { type: 'array', items: { type: 'string' } },
156
+ },
157
+ required: ['family'],
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Build the `before_provider_request` handler that steers pi's model
163
+ * selection to GSD's tier-resolved id (modelMode: 'active' per
164
+ * capabilities/pi/capability.json). GSD does NOT call `pi.registerProvider` —
165
+ * that registers a NEW model provider; GSD's job here is only to pick a
166
+ * tier-appropriate id AMONG pi's EXISTING built-in anthropic models, so
167
+ * registerProvider would be the wrong primitive (it would wrongly add a fake
168
+ * provider instead of steering the real one).
169
+ *
170
+ * v1 tier policy: GSD does not yet expose a per-turn/per-agent tier signal to
171
+ * this event, so a conservative fixed default tier is used (parameterized —
172
+ * default 'sonnet' — so a future richer signal, or a test, can override it).
173
+ *
174
+ * ASSUMPTION (flagged — verify against a live pi host): the event payload's
175
+ * model field is named `model`, matching the anthropic-messages payload shape
176
+ * (Context7-confirmed for the wire protocol; pi's own before_provider_request
177
+ * event schema was not independently verifiable in this environment). If pi's
178
+ * actual field name differs, this returns the WRONG key and pi's fail-open
179
+ * default takes over only because bare-model-id mismatches degrade to
180
+ * provider-level errors, not GSD-level ones — a discrepancy here needs a
181
+ * live-host smoke test before shipping past this stage.
182
+ *
183
+ * Fail-open: any resolution failure (or a null/falsy resolved model — e.g. an
184
+ * unrecognized tier) returns `undefined`, leaving pi's model choice untouched.
185
+ * NEVER returns a payload with a missing/empty model id.
186
+ *
187
+ * @param {{ tier?: string }} [opts]
188
+ * @returns {(event: object, ctx: object) => Promise<object|undefined>}
189
+ */
190
+ function buildBeforeProviderRequestHandler({ tier = 'sonnet' } = {}) {
191
+ return async function onBeforeProviderRequest(event, ctx) {
192
+ try {
193
+ const effectiveCwd = (ctx && ctx.cwd) || process.cwd();
194
+ const { resolveTierEntry } = require(path.join(GSD_CORE, 'bin', 'lib', 'model-resolver.cjs'));
195
+ const { loadConfig } = require(path.join(GSD_CORE, 'bin', 'lib', 'config-loader.cjs'));
196
+ const config = loadConfig(effectiveCwd);
197
+ const overrides = (config && config.model_profile_overrides) || undefined;
198
+ const entry = resolveTierEntry({ runtime: 'pi', tier, overrides });
199
+ const modelId = entry && typeof entry.model === 'string' && entry.model.length > 0 ? entry.model : null;
200
+ if (!modelId) return undefined; // fail-open — leave pi's model untouched
201
+ const basePayload = (event && typeof event === 'object' && event.payload && typeof event.payload === 'object')
202
+ ? event.payload
203
+ : {};
204
+ return { ...basePayload, model: modelId };
205
+ } catch {
206
+ return undefined; // fail-open on any resolution error
207
+ }
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Bounded subprocess bridge to GSD's Claude Code hook scripts. Mirrors
213
+ * .opencode/plugins/gsd-core.js's `runHook` (SUBPROCESS-REUSE): spawns
214
+ * `node <hooks/hookFile>` with the payload piped to stdin, on a bounded
215
+ * timeout. NEVER throws — a missing hook file, a spawn error, or a timeout
216
+ * all degrade to a silent-allow result so a hook problem can never block pi.
217
+ * @param {string} hookFile filename under hooks/, e.g. "gsd-context-monitor.js"
218
+ * @param {object} payload
219
+ * @param {{ timeout?: number, cwd?: string }} [opts]
220
+ * @returns {{ stdout: string, exitCode: number, timedOut: boolean }}
221
+ */
222
+ function runHook(hookFile, payload, opts = {}) {
223
+ const hookPath = path.join(ENGINE_ROOT, 'hooks', hookFile);
224
+ if (!fs.existsSync(hookPath)) return { stdout: '', exitCode: 0, timedOut: false };
225
+ const timeout = opts.timeout || 8000;
226
+ let result;
227
+ try {
228
+ result = spawnSync(process.execPath, [hookPath], {
229
+ input: JSON.stringify(payload || {}),
230
+ encoding: 'utf8',
231
+ timeout,
232
+ cwd: opts.cwd || process.cwd(),
233
+ windowsHide: true,
234
+ });
235
+ } catch {
236
+ return { stdout: '', exitCode: 0, timedOut: false };
237
+ }
238
+ const stdout = (result && typeof result.stdout === 'string') ? result.stdout.trim() : '';
239
+ const exitCode = (result && result.status != null) ? result.status : 0;
240
+ return { stdout, exitCode, timedOut: !!(result && result.signal === 'SIGTERM') };
241
+ }
242
+
243
+ module.exports = function gsdPiExtension(pi) {
244
+ if (!pi || typeof pi !== 'object') {
245
+ throw new TypeError('gsdPiExtension: pi ExtensionAPI is required');
246
+ }
247
+
248
+ // ── /gsd command: dispatch through gsd-tools.cjs (subprocess-reuse) ──────
249
+ pi.registerCommand('gsd', {
250
+ description: 'Invoke a GSD command via the embedded engine (subprocess-reuse adapter).',
251
+ getArgumentCompletions,
252
+ handler: async (args, ctx) => {
253
+ const cwd = (ctx && ctx.cwd) || process.cwd();
254
+ const { family, subcommand, args: rest } = parseGsdCommandArgs(args);
255
+ let dispatchGsdCommand;
256
+ try {
257
+ ({ dispatchGsdCommand } = require(path.join(GSD_CORE, 'bin', 'lib', 'shell-command-projection.cjs')));
258
+ } catch (e) {
259
+ return `GSD engine unavailable: ${e && e.message ? e.message : String(e)}`;
260
+ }
261
+ const result = dispatchGsdCommand({ family, subcommand, args: rest, cwd });
262
+ if (result.ok) return result.stdout;
263
+ return `GSD error: ${result.stderr || result.stdout || `dispatch failed (exit ${result.code})`}`;
264
+ },
265
+ });
266
+
267
+ // ── gsd_invoke tool: programmatic command invocation ────────────────────
268
+ pi.registerTool({
269
+ name: 'gsd_invoke',
270
+ label: 'GSD Invoke',
271
+ description: 'Invoke a GSD command family/subcommand through the engine.',
272
+ parameters: buildGsdInvokeParameters(),
273
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
274
+ const p = (params && typeof params === 'object') ? params : {};
275
+ const family = typeof p.family === 'string' ? p.family : '';
276
+ if (!family) {
277
+ return { content: [{ type: 'text', text: 'gsd_invoke requires a non-empty string "family".' }] };
278
+ }
279
+ const subcommand = typeof p.subcommand === 'string' ? p.subcommand : undefined;
280
+ const invokeArgs = Array.isArray(p.args) ? p.args : [];
281
+ const cwd = (ctx && ctx.cwd) || process.cwd();
282
+ let dispatchGsdCommand;
283
+ try {
284
+ ({ dispatchGsdCommand } = require(path.join(GSD_CORE, 'bin', 'lib', 'shell-command-projection.cjs')));
285
+ } catch (e) {
286
+ return { content: [{ type: 'text', text: `GSD engine unavailable: ${e && e.message ? e.message : String(e)}` }] };
287
+ }
288
+ const result = dispatchGsdCommand({ family, subcommand, args: invokeArgs, cwd });
289
+ const text = result.ok ? result.stdout : (result.stderr || result.stdout || `dispatch failed (exit ${result.code})`);
290
+ return { content: [{ type: 'text', text }] };
291
+ },
292
+ });
293
+
294
+ // ── before_provider_request: active-model steering (modelMode: 'active') ──
295
+ // GSD steers pi's EXISTING built-in anthropic models; it does NOT call
296
+ // pi.registerProvider (that would wrongly register a NEW fake provider —
297
+ // see buildBeforeProviderRequestHandler's doc comment).
298
+ pi.on('before_provider_request', buildBeforeProviderRequestHandler());
299
+
300
+ // ── Event bindings: bounded subprocess bridge to GSD's hook scripts ──────
301
+ // Each binding fails open — a hook error/timeout/missing-file never blocks
302
+ // pi (mirrors .opencode/plugins/gsd-core.js's runHook SUBPROCESS-REUSE
303
+ // pattern, applied to pi's ExtensionAPI event names).
304
+
305
+ // session_start → SessionStart-equivalent bootstrap.
306
+ pi.on('session_start', async (event, ctx) => {
307
+ try {
308
+ const cwd = (ctx && ctx.cwd) || process.cwd();
309
+ runHook('gsd-ensure-canonical-path.js', { hook_event_name: 'SessionStart', cwd }, { cwd });
310
+ } catch { /* fail-open */ }
311
+ });
312
+
313
+ // before_agent_start → workflow-guard bridge. Forward-compatible binding:
314
+ // gsd-workflow-guard.js's current triggers are tool-scoped (Write/Edit/
315
+ // Bash via tool_name/tool_input), so with no tool_name in the payload it
316
+ // fires as a safe no-op today — wired so a future agent-start-scoped check
317
+ // can attach without a plugin change (mirrors the OpenCode session.idle
318
+ // recognized-but-unused sentinel pattern).
319
+ pi.on('before_agent_start', async (event, ctx) => {
320
+ try {
321
+ const cwd = (ctx && ctx.cwd) || process.cwd();
322
+ runHook('gsd-workflow-guard.js', { hook_event_name: 'before_agent_start', cwd }, { cwd });
323
+ } catch { /* fail-open */ }
324
+ });
325
+
326
+ // session_before_compact → PreCompact-equivalent (context-usage bridge).
327
+ pi.on('session_before_compact', async (event, ctx) => {
328
+ try {
329
+ const cwd = (ctx && ctx.cwd) || process.cwd();
330
+ runHook('gsd-context-monitor.js', { hook_event_name: 'PreCompact', cwd }, { cwd });
331
+ } catch { /* fail-open */ }
332
+ });
333
+
334
+ // tool_call event: lifecycle hook bridge attachment point (kept from the
335
+ // original cut — the PreToolUse/PostToolUse tool_name/tool_input mapping
336
+ // is a follow-up once pi's tool_call payload shape is verified against a
337
+ // live host).
338
+ pi.on('tool_call', async function () {
339
+ /* GSD hook bridge attachment point (PreToolUse/PostToolUse mapping). */
340
+ });
341
+ };
342
+
343
+ // Test-only internals (mirrors the OpenCode plugin pattern) — wired to the
344
+ // real functions (not stubs) so tests can exercise parsing/completions/model
345
+ // resolution WITHOUT a live pi runtime.
346
+ module.exports._internals = {
347
+ resolveEngineRoot,
348
+ parseGsdCommandArgs,
349
+ getArgumentCompletions,
350
+ PI_COMMAND_FAMILIES,
351
+ buildBeforeProviderRequestHandler,
352
+ buildGsdInvokeParameters,
353
+ runHook,
354
+ };
@@ -44,6 +44,9 @@ const HOOKS_TO_COPY = [
44
44
  'gsd-cursor-stop.js',
45
45
  'gsd-cursor-subagent-start.js',
46
46
  'gsd-cursor-subagent-stop.js',
47
+ // Windsurf/Cascade lifecycle hooks (ADR-1239/#2100 Stage 2): 2 blocking events
48
+ 'gsd-windsurf-pre-write.js',
49
+ 'gsd-windsurf-pre-command.js',
47
50
  // Claude Code FileChanged hook (#770) — hot-reloads gsd config when
48
51
  // .planning/config.json changes mid-session. Must ship to dist so the
49
52
  // installer can copy it to the target hooks/ dir and register FileChanged.
@@ -145,6 +145,37 @@ const RULES = [
145
145
  'tests/golden-install-parity.test.cjs', // any src/installer change can alter emitted install artifacts → re-verify golden install parity (drift guard)
146
146
  ],
147
147
  },
148
+ {
149
+ name: 'shipped install content (golden-parity drift guard, #2267)',
150
+ // Every source file the installer EMITS into a runtime layout is captured by
151
+ // golden-install-parity + the install-tree snapshot. A source edit here that
152
+ // changes emitted output MUST re-verify the fixtures — otherwise stale golden
153
+ // fixtures merge silently (#2266: a hooks/gsd-statusline.js edit changed
154
+ // installed output but no rule selected golden-parity, so stale fixtures
155
+ // shipped to next undetected). Union semantics: this ADDS the parity guard on
156
+ // top of each path's existing content-specific tests. Targeted lane only (the
157
+ // golden test skips win32 by design), no fullMatrix.
158
+ // NOTE: intentionally NOT a blanket 'gsd-core/' prefix, for two reasons:
159
+ // (1) gsd-core/bin/** is tsc-compiled runtime output — EXCLUDED_PREFIXES-
160
+ // excluded from both manifests, and already covered by the 'installer and
161
+ // package layout' rule (path.startsWith('gsd-core/bin/')) — so matching it
162
+ // here would be pure noise; and
163
+ // (2) enumerating only the installer-shipped content subtrees preserves the
164
+ // bug-408 unit-fallback contract: a gsd-core/ path that is NOT shipped
165
+ // verbatim (the bug-408 test uses gsd-core/src/some-util.js) must still
166
+ // fall back to ['unit'] when no rule matches.
167
+ // Listed: the four gsd-core content subtrees the installer ships verbatim
168
+ // (contexts, references, templates, workflows) + bin/shared/*.json data files.
169
+ // Verify against Object.keys(golden fixture) grouped by gsd-core/<subdir>.
170
+ match: path =>
171
+ ['hooks/', 'commands/', 'agents/', 'skills/', 'gsd-core/workflows/', 'gsd-core/templates/', 'gsd-core/references/', 'gsd-core/contexts/', 'scripts/changeset/', 'scripts/lib/'].some(p => path.startsWith(p)) ||
172
+ (path.startsWith('gsd-core/bin/shared/') && path.endsWith('.json')) ||
173
+ ['scripts/fix-slash-commands.cjs', 'scripts/gen-capability-registry.cjs', 'scripts/gen-loop-host-contract.cjs'].includes(path),
174
+ tests: [
175
+ 'tests/golden-install-parity.test.cjs',
176
+ 'tests/golden-install-tree.test.cjs',
177
+ ],
178
+ },
148
179
  {
149
180
  name: 'hooks',
150
181
  match: path => path.startsWith('hooks/'),
@@ -364,8 +395,15 @@ function classify(files) {
364
395
  for (const file of files) {
365
396
  // Determine if this file is product/pipeline code.
366
397
  // docs/ and root-level .md files are intentionally excluded.
398
+ // 'skills/' is shipped agent-skill content installed into every runtime by
399
+ // the installer (see the 'shipped install content' RULES entry below) — it
400
+ // must be product code, or a skills/-only change silently gets
401
+ // code_changed=false and skips the ENTIRE CI matrix, not merely golden-parity
402
+ // (found while verifying the #2267 golden-parity rule against skills/**: the
403
+ // rule fired in `reasons` but classify()'s codeChanged gate zeroed out every
404
+ // targeted test because 'skills/' was absent from this list).
367
405
  if (
368
- ['bin/', 'src/', 'gsd-core/', 'agents/', 'commands/', 'hooks/', 'tests/', 'scripts/', 'eslint-rules/'].some(p => file.startsWith(p)) ||
406
+ ['bin/', 'src/', 'gsd-core/', 'agents/', 'commands/', 'hooks/', 'skills/', 'tests/', 'scripts/', 'eslint-rules/'].some(p => file.startsWith(p)) ||
369
407
  file === 'package.json' || file === 'package-lock.json' ||
370
408
  (file.startsWith('tsconfig') && file.endsWith('.json')) ||
371
409
  file.startsWith('.github/rulesets/')