@opengsd/gsd-core 1.18.0 → 1.19.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 (257) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -1
  3. package/.opencode/plugins/gsd-core.js +2 -3
  4. package/agents/gsd-code-fixer.compact.md +2 -2
  5. package/agents/gsd-code-fixer.md +4 -3
  6. package/agents/gsd-verifier.md +1 -1
  7. package/bin/install.js +422 -103
  8. package/gsd-core/bin/gsd-tools.cjs +106 -7
  9. package/gsd-core/bin/lib/agent-install-check.cjs +29 -3
  10. package/gsd-core/bin/lib/args-terminator.cjs +114 -0
  11. package/gsd-core/bin/lib/broken-windows.cjs +59 -1
  12. package/gsd-core/bin/lib/capability-lock.cjs +13 -2
  13. package/gsd-core/bin/lib/capability-registry.cjs +156 -74
  14. package/gsd-core/bin/lib/capability-state.cjs +14 -8
  15. package/gsd-core/bin/lib/capability-validator.cjs +17 -0
  16. package/gsd-core/bin/lib/config-loader.cjs +10 -0
  17. package/gsd-core/bin/lib/config.cjs +256 -58
  18. package/gsd-core/bin/lib/core-utils.cjs +34 -0
  19. package/gsd-core/bin/lib/decision-coverage-support.cjs +74 -0
  20. package/gsd-core/bin/lib/gate-context-drift.cjs +8 -3
  21. package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +6 -1
  22. package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +6 -1
  23. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +134 -13
  24. package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +63 -22
  25. package/gsd-core/bin/lib/gate-ui-safety.cjs +23 -5
  26. package/gsd-core/bin/lib/git-info-exclude.cjs +57 -0
  27. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +18 -3
  28. package/gsd-core/bin/lib/health-diagnostic-types.cjs +4 -0
  29. package/gsd-core/bin/lib/health-diagnostic.cjs +52 -1
  30. package/gsd-core/bin/lib/host-integration.cjs +13 -6
  31. package/gsd-core/bin/lib/init.cjs +65 -1
  32. package/gsd-core/bin/lib/install-engine.cjs +246 -32
  33. package/gsd-core/bin/lib/install-profiles.cjs +9 -4
  34. package/gsd-core/bin/lib/installer-migration-report.cjs +2 -0
  35. package/gsd-core/bin/lib/markdown-line-diff.cjs +147 -0
  36. package/gsd-core/bin/lib/markdown-sectionizer.cjs +113 -0
  37. package/gsd-core/bin/lib/milestone-lock.cjs +8 -1
  38. package/gsd-core/bin/lib/milestone-release-map.cjs +244 -0
  39. package/gsd-core/bin/lib/milestone-release-tags.cjs +440 -0
  40. package/gsd-core/bin/lib/milestone.cjs +43 -1
  41. package/gsd-core/bin/lib/model-catalog.cjs +8 -0
  42. package/gsd-core/bin/lib/normalize-test-command.cjs +67 -0
  43. package/gsd-core/bin/lib/phase-id.cjs +21 -4
  44. package/gsd-core/bin/lib/phase-lifecycle.cjs +37 -4
  45. package/gsd-core/bin/lib/phase.cjs +115 -26
  46. package/gsd-core/bin/lib/planning-snapshot.cjs +3 -1
  47. package/gsd-core/bin/lib/planning-workspace.cjs +18 -0
  48. package/gsd-core/bin/lib/pristine-baseline.cjs +11 -3
  49. package/gsd-core/bin/lib/read-only-git-env.cjs +58 -0
  50. package/gsd-core/bin/lib/review-lane-descriptor.cjs +35 -2
  51. package/gsd-core/bin/lib/review-lane-runner.cjs +33 -4
  52. package/gsd-core/bin/lib/roadmap-parser.cjs +12 -0
  53. package/gsd-core/bin/lib/roadmap-upgrade.cjs +39 -2
  54. package/gsd-core/bin/lib/roadmap.cjs +33 -25
  55. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +381 -14
  56. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +8 -4
  57. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +60 -16
  58. package/gsd-core/bin/lib/shell-command-projection.cjs +121 -16
  59. package/gsd-core/bin/lib/smart-entry.cjs +13 -0
  60. package/gsd-core/bin/lib/state-contract.cjs +29 -9
  61. package/gsd-core/bin/lib/state-document.cjs +12 -4
  62. package/gsd-core/bin/lib/state-transition.cjs +119 -24
  63. package/gsd-core/bin/lib/state.cjs +50 -28
  64. package/gsd-core/bin/lib/surface.cjs +9 -0
  65. package/gsd-core/bin/lib/task-command-router.cjs +5 -25
  66. package/gsd-core/bin/lib/task-content-resolution.cjs +46 -1
  67. package/gsd-core/bin/lib/verification.cjs +96 -9
  68. package/gsd-core/bin/lib/verify.cjs +2 -0
  69. package/gsd-core/bin/lib/worktree-safety.cjs +22 -1
  70. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  71. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  72. package/gsd-core/references/git-integration.md +11 -0
  73. package/gsd-core/references/loop-hook-dispatch.md +13 -6
  74. package/gsd-core/references/planning-config.md +2 -0
  75. package/gsd-core/references/tdd.md +2 -2
  76. package/gsd-core/templates/DEBUG.md +16 -0
  77. package/gsd-core/templates/config.json +2 -1
  78. package/gsd-core/templates/verification-report.md +1 -1
  79. package/gsd-core/workflows/audit-fix.md +2 -1
  80. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +10 -1
  81. package/gsd-core/workflows/code-review.md +61 -7
  82. package/gsd-core/workflows/complete-milestone/steps/project-md-evolution-review.md +145 -0
  83. package/gsd-core/workflows/complete-milestone/steps/release-map.md +80 -0
  84. package/gsd-core/workflows/complete-milestone.md +37 -150
  85. package/gsd-core/workflows/diagnose-issues.md +29 -5
  86. package/gsd-core/workflows/discuss-phase/modes/power.md +1 -1
  87. package/gsd-core/workflows/discuss-phase-assumptions.md +33 -2
  88. package/gsd-core/workflows/discuss-phase-power.md +19 -2
  89. package/gsd-core/workflows/discuss-phase.md +6 -10
  90. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +13 -7
  91. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +7 -2
  92. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -1
  93. package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +2 -2
  94. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +11 -2
  95. package/gsd-core/workflows/execute-phase.md +9 -5
  96. package/gsd-core/workflows/health.md +2 -1
  97. package/gsd-core/workflows/import.md +1 -1
  98. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +2 -2
  99. package/gsd-core/workflows/plan-phase.md +13 -13
  100. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -1
  101. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +8 -1
  102. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +2 -1
  103. package/gsd-core/workflows/quick.md +1 -1
  104. package/gsd-core/workflows/review.md +16 -13
  105. package/gsd-core/workflows/section-manifest.json +5 -0
  106. package/gsd-core/workflows/settings.md +1 -0
  107. package/gsd-core/workflows/ship.md +3 -4
  108. package/gsd-core/workflows/spec-phase.md +3 -6
  109. package/gsd-core/workflows/verify-work.md +4 -3
  110. package/hooks/dist/gsd-agent-isolation-guard.js +11 -1
  111. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  112. package/hooks/dist/gsd-path-export.js +95 -0
  113. package/hooks/dist/gsd-secret-read-guard.js +172 -39
  114. package/hooks/dist/gsd-statusline.js +35 -2
  115. package/hooks/dist/gsd-windsurf-pre-write.js +28 -1
  116. package/hooks/dist/gsd-workflow-guard.js +1 -1
  117. package/hooks/dist/gsd-worktree-path-guard.js +28 -1
  118. package/hooks/dist/lib/isolation-sentinel.js +44 -0
  119. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  120. package/hooks/gsd-agent-isolation-guard.js +11 -1
  121. package/hooks/gsd-cursor-subagent-start.js +6 -2
  122. package/hooks/gsd-path-export.js +95 -0
  123. package/hooks/gsd-secret-read-guard.js +172 -39
  124. package/hooks/gsd-statusline.js +35 -2
  125. package/hooks/gsd-windsurf-pre-write.js +28 -1
  126. package/hooks/gsd-workflow-guard.js +1 -1
  127. package/hooks/gsd-worktree-path-guard.js +28 -1
  128. package/hooks/hooks.json +1 -0
  129. package/hooks/lib/isolation-sentinel.js +44 -0
  130. package/hooks/managed-hooks-registry.cjs +1 -0
  131. package/package.json +13 -6
  132. package/pi/gsd.cjs +1047 -10
  133. package/pi/package-agents/gsd-advisor-researcher.md +113 -0
  134. package/pi/package-agents/gsd-ai-researcher.md +111 -0
  135. package/pi/package-agents/gsd-assumptions-analyzer.md +110 -0
  136. package/pi/package-agents/gsd-code-fixer.md +769 -0
  137. package/pi/package-agents/gsd-code-reviewer.md +402 -0
  138. package/pi/package-agents/gsd-codebase-mapper.md +850 -0
  139. package/pi/package-agents/gsd-debug-session-manager.md +416 -0
  140. package/pi/package-agents/gsd-debugger.md +1274 -0
  141. package/pi/package-agents/gsd-doc-classifier.md +269 -0
  142. package/pi/package-agents/gsd-doc-synthesizer.md +259 -0
  143. package/pi/package-agents/gsd-doc-verifier.md +212 -0
  144. package/pi/package-agents/gsd-doc-writer.md +626 -0
  145. package/pi/package-agents/gsd-dom-verifier.md +164 -0
  146. package/pi/package-agents/gsd-domain-researcher.md +144 -0
  147. package/pi/package-agents/gsd-eval-auditor.md +185 -0
  148. package/pi/package-agents/gsd-eval-planner.md +149 -0
  149. package/pi/package-agents/gsd-executor.md +894 -0
  150. package/pi/package-agents/gsd-framework-selector.md +159 -0
  151. package/pi/package-agents/gsd-integration-checker.md +476 -0
  152. package/pi/package-agents/gsd-intel-updater.md +338 -0
  153. package/pi/package-agents/gsd-mempalace-curator.md +49 -0
  154. package/pi/package-agents/gsd-nyquist-auditor.md +200 -0
  155. package/pi/package-agents/gsd-pattern-mapper.md +340 -0
  156. package/pi/package-agents/gsd-phase-researcher.md +897 -0
  157. package/pi/package-agents/gsd-plan-checker.md +1077 -0
  158. package/pi/package-agents/gsd-planner.md +1003 -0
  159. package/pi/package-agents/gsd-project-researcher.md +611 -0
  160. package/pi/package-agents/gsd-research-synthesizer.md +259 -0
  161. package/pi/package-agents/gsd-roadmapper.md +779 -0
  162. package/pi/package-agents/gsd-security-auditor.md +171 -0
  163. package/pi/package-agents/gsd-ui-auditor.md +581 -0
  164. package/pi/package-agents/gsd-ui-checker.md +417 -0
  165. package/pi/package-agents/gsd-ui-researcher.md +469 -0
  166. package/pi/package-agents/gsd-user-profiler.md +174 -0
  167. package/pi/package-agents/gsd-verifier.md +963 -0
  168. package/pi/package-agents/manifest.json +39 -0
  169. package/pi/package-skills/gsd-add-tests/SKILL.md +39 -0
  170. package/pi/package-skills/gsd-ai-integration-phase/SKILL.md +36 -0
  171. package/pi/package-skills/gsd-audit-fix/SKILL.md +34 -0
  172. package/pi/package-skills/gsd-audit-milestone/SKILL.md +40 -0
  173. package/pi/package-skills/gsd-audit-uat/SKILL.md +26 -0
  174. package/pi/package-skills/gsd-autonomous/SKILL.md +52 -0
  175. package/pi/package-skills/gsd-capture/SKILL.md +68 -0
  176. package/pi/package-skills/gsd-cleanup/SKILL.md +25 -0
  177. package/pi/package-skills/gsd-code-review/SKILL.md +71 -0
  178. package/pi/package-skills/gsd-complete-milestone/SKILL.md +147 -0
  179. package/pi/package-skills/gsd-config/SKILL.md +60 -0
  180. package/pi/package-skills/gsd-debug/SKILL.md +64 -0
  181. package/pi/package-skills/gsd-discuss-phase/SKILL.md +74 -0
  182. package/pi/package-skills/gsd-docs-update/SKILL.md +50 -0
  183. package/pi/package-skills/gsd-eval-review/SKILL.md +35 -0
  184. package/pi/package-skills/gsd-execute-phase/SKILL.md +62 -0
  185. package/pi/package-skills/gsd-explore/SKILL.md +26 -0
  186. package/pi/package-skills/gsd-extract-learnings/SKILL.md +25 -0
  187. package/pi/package-skills/gsd-fast/SKILL.md +33 -0
  188. package/pi/package-skills/gsd-forensics/SKILL.md +59 -0
  189. package/pi/package-skills/gsd-graphify/SKILL.md +210 -0
  190. package/pi/package-skills/gsd-health/SKILL.md +36 -0
  191. package/pi/package-skills/gsd-help/SKILL.md +37 -0
  192. package/pi/package-skills/gsd-import/SKILL.md +46 -0
  193. package/pi/package-skills/gsd-inbox/SKILL.md +42 -0
  194. package/pi/package-skills/gsd-ingest-docs/SKILL.md +43 -0
  195. package/pi/package-skills/gsd-manager/SKILL.md +46 -0
  196. package/pi/package-skills/gsd-map-codebase/SKILL.md +85 -0
  197. package/pi/package-skills/gsd-mempalace-capture/SKILL.md +111 -0
  198. package/pi/package-skills/gsd-mempalace-recall/SKILL.md +111 -0
  199. package/pi/package-skills/gsd-milestone-summary/SKILL.md +54 -0
  200. package/pi/package-skills/gsd-mvp-phase/SKILL.md +45 -0
  201. package/pi/package-skills/gsd-new-milestone/SKILL.md +49 -0
  202. package/pi/package-skills/gsd-new-project/SKILL.md +49 -0
  203. package/pi/package-skills/gsd-next/SKILL.md +34 -0
  204. package/pi/package-skills/gsd-ns-context/SKILL.md +31 -0
  205. package/pi/package-skills/gsd-ns-ideate/SKILL.md +30 -0
  206. package/pi/package-skills/gsd-ns-manage/SKILL.md +42 -0
  207. package/pi/package-skills/gsd-ns-project/SKILL.md +34 -0
  208. package/pi/package-skills/gsd-ns-review/SKILL.md +35 -0
  209. package/pi/package-skills/gsd-ns-workflow/SKILL.md +44 -0
  210. package/pi/package-skills/gsd-onboard/SKILL.md +46 -0
  211. package/pi/package-skills/gsd-pause-work/SKILL.md +48 -0
  212. package/pi/package-skills/gsd-phase/SKILL.md +61 -0
  213. package/pi/package-skills/gsd-plan-phase/SKILL.md +62 -0
  214. package/pi/package-skills/gsd-plan-review-convergence/SKILL.md +63 -0
  215. package/pi/package-skills/gsd-pr-branch/SKILL.md +31 -0
  216. package/pi/package-skills/gsd-profile-user/SKILL.md +48 -0
  217. package/pi/package-skills/gsd-progress/SKILL.md +51 -0
  218. package/pi/package-skills/gsd-quick/SKILL.md +179 -0
  219. package/pi/package-skills/gsd-quick-batch/SKILL.md +114 -0
  220. package/pi/package-skills/gsd-resume-work/SKILL.md +31 -0
  221. package/pi/package-skills/gsd-review/SKILL.md +50 -0
  222. package/pi/package-skills/gsd-review-backlog/SKILL.md +65 -0
  223. package/pi/package-skills/gsd-secure-phase/SKILL.md +37 -0
  224. package/pi/package-skills/gsd-settings/SKILL.md +30 -0
  225. package/pi/package-skills/gsd-ship/SKILL.md +27 -0
  226. package/pi/package-skills/gsd-sketch/SKILL.md +56 -0
  227. package/pi/package-skills/gsd-spec-phase/SKILL.md +63 -0
  228. package/pi/package-skills/gsd-spike/SKILL.md +53 -0
  229. package/pi/package-skills/gsd-stats/SKILL.md +22 -0
  230. package/pi/package-skills/gsd-surface/SKILL.md +177 -0
  231. package/pi/package-skills/gsd-thread/SKILL.md +29 -0
  232. package/pi/package-skills/gsd-ui-phase/SKILL.md +35 -0
  233. package/pi/package-skills/gsd-ui-review/SKILL.md +35 -0
  234. package/pi/package-skills/gsd-ultraplan-phase/SKILL.md +38 -0
  235. package/pi/package-skills/gsd-undo/SKILL.md +38 -0
  236. package/pi/package-skills/gsd-update/SKILL.md +51 -0
  237. package/pi/package-skills/gsd-validate-phase/SKILL.md +37 -0
  238. package/pi/package-skills/gsd-verify-work/SKILL.md +41 -0
  239. package/pi/package-skills/gsd-workspace/SKILL.md +57 -0
  240. package/pi/package-skills/gsd-workstreams/SKILL.md +73 -0
  241. package/scripts/build-hooks.js +4 -0
  242. package/scripts/command-contract-helpers.cjs +1 -1
  243. package/scripts/docs-guard-registry.cjs +20 -7
  244. package/scripts/gen-health-docs.cjs +13 -1
  245. package/scripts/gen-pi-package-skills.cjs +217 -0
  246. package/scripts/lib/macos-conformance-tier.generated.cjs +11 -1
  247. package/scripts/lib/platform-conformance-tier.generated.cjs +26 -1
  248. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +3 -3
  249. package/scripts/lint-retired-runtime-name.cjs +5 -0
  250. package/scripts/npm-audit-baseline.cjs +41 -7
  251. package/scripts/reviewed-or-maintainer-policy.cjs +181 -0
  252. package/scripts/select-docs-guards.cjs +1 -1
  253. package/scripts/self-test-report-policy.cjs +350 -0
  254. package/vscode/package.json +1 -1
  255. package/scripts/diagnose-validate-commit-in-tier.cjs +0 -270
  256. package/scripts/diagnose-validate-commit-phases.cjs +0 -348
  257. package/scripts/xtrace-phase-timing.cjs +0 -205
package/pi/gsd.cjs CHANGED
@@ -45,6 +45,8 @@
45
45
 
46
46
  const fs = require('fs');
47
47
  const path = require('path');
48
+ const os = require('os');
49
+ const crypto = require('crypto');
48
50
  const { spawnSync } = require('child_process');
49
51
 
50
52
  // Resolve the GSD engine tree (the dir holding gsd-core/ + hooks/).
@@ -276,6 +278,29 @@ function buildBeforeProviderRequestHandler({ tier = 'sonnet' } = {}) {
276
278
  };
277
279
  }
278
280
 
281
+ /** Largest hook stdout kept. A guard echoes the offending path, which a model controls. */
282
+ const HOOK_MAX_BUFFER = 8 * 1024 * 1024;
283
+ const warnedHookFailures = new Set();
284
+
285
+ /**
286
+ * A hook that overflowed its output buffer produced a verdict we cannot read: refuse the call
287
+ * (exit 2) rather than let a payload sized to overflow the buffer read as an allow. Any other
288
+ * spawn failure (no runtime, EPERM) fails open, but loudly once, because the guard did not run.
289
+ * @returns {{ stdout: string, exitCode: number, timedOut: boolean } | null}
290
+ */
291
+ function hookSpawnFailure(result, hookFile) {
292
+ const err = result && result.error;
293
+ if (!err || err.code === 'ETIMEDOUT') return null;
294
+ if (err.code === 'ENOBUFS') {
295
+ return { stdout: JSON.stringify({ decision: 'block', reason: `${hookFile} produced more output than GSD will read, so the call is refused.` }), exitCode: 2, timedOut: false };
296
+ }
297
+ if (!warnedHookFailures.has(hookFile)) {
298
+ warnedHookFailures.add(hookFile);
299
+ console.error(`[gsd-core] cannot run hook ${hookFile} (${err.code || err.message}) — that guard is NOT enforced.`);
300
+ }
301
+ return { stdout: '', exitCode: 0, timedOut: false };
302
+ }
303
+
279
304
  /**
280
305
  * Bounded subprocess bridge to GSD's Claude Code hook scripts. Mirrors
281
306
  * .opencode/plugins/gsd-core.js's `runHook` (SUBPROCESS-REUSE): spawns
@@ -294,26 +319,955 @@ function runHook(hookFile, payload, opts = {}) {
294
319
  const timeout = opts.timeout || 8000;
295
320
  let result;
296
321
  try {
297
- result = spawnSync(process.execPath, [hookPath], {
322
+ result = spawnSync(hookRuntime(), [hookPath], {
298
323
  input: JSON.stringify(payload || {}),
299
324
  encoding: 'utf8',
300
325
  timeout,
326
+ maxBuffer: HOOK_MAX_BUFFER,
301
327
  cwd: opts.cwd || process.cwd(),
302
328
  windowsHide: true,
303
329
  });
304
330
  } catch {
305
331
  return { stdout: '', exitCode: 0, timedOut: false };
306
332
  }
333
+ const failure = hookSpawnFailure(result, hookFile);
334
+ if (failure) return failure;
307
335
  const stdout = (result && typeof result.stdout === 'string') ? result.stdout.trim() : '';
308
336
  const exitCode = (result && result.status != null) ? result.status : 0;
309
337
  return { stdout, exitCode, timedOut: !!(result && result.signal === 'SIGTERM') };
310
338
  }
311
339
 
312
- module.exports = function gsdPiExtension(pi) {
340
+ // ── hook bridge: PreToolUse / PostToolUse (#5560) ─────────────────────────────
341
+ // Verified against live Pi 1.3.0 (docs/reference/pi-hook-parity.md): `tool_call` carries
342
+ // { toolName, toolCallId, input } with lowercase tool names; a handler returning
343
+ // { block: true, reason } stops the call and the model sees `reason` as the tool result (no
344
+ // `tool_result` follows); `tool_result` handlers may return { content } to replace the
345
+ // model-facing content. Pi blocks a tool whose `tool_call` handler THROWS, so every handler
346
+ // below catches everything and fails open. The guards stay the hook scripts (no reimplementation).
347
+
348
+ /** Hook scripts are Node; inside a Bun-hosted Pi `process.execPath` would boot Pi (#4849). */
349
+ function hookRuntime() {
350
+ const fromEnv = process.env.GSD_HOOK_RUNTIME;
351
+ if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv;
352
+ return typeof Bun !== 'undefined' ? (resolveWinExe('node') || 'node') : process.execPath;
353
+ }
354
+
355
+ /**
356
+ * On Windows a bare executable name is searched in the spawn cwd (the project) first, so a hostile
357
+ * repo could plant `bash.exe`. Resolve it to an absolute path from drive-letter PATH entries instead.
358
+ * Returns null off Windows or when nothing is found (the caller falls back to the bare name).
359
+ */
360
+ function resolveWinExe(name, env = process.env, platform = process.platform, exists = (f) => { try { return fs.statSync(f).isFile(); } catch { return false; } }) {
361
+ if (platform !== 'win32') return null;
362
+ const dirs = String(env.Path || env.PATH || '').split(';').filter((d) => /^[A-Za-z]:[\\/]/.test(d));
363
+ for (const dir of dirs) {
364
+ const candidate = path.win32.join(dir, `${name}.exe`);
365
+ if (exists(candidate)) return candidate;
366
+ }
367
+ return null;
368
+ }
369
+
370
+ const PI_FIELD_MAX_CHARS = 1024 * 1024;
371
+ const hasOwn = (o, k) => typeof k === 'string' && Object.prototype.hasOwnProperty.call(o, k);
372
+
373
+ /** Pi's file tools strip a leading `@` and expand `~`; the hooks see the absolute path Claude would send. */
374
+ function resolvePiPath(p, cwd) {
375
+ let v = p.startsWith('@') ? p.slice(1) : p;
376
+ if (v === '~' || v.startsWith('~/') || v.startsWith('~\\')) v = path.join(os.homedir(), v.slice(1));
377
+ return typeof cwd === 'string' && cwd ? path.resolve(cwd, v) : v;
378
+ }
379
+
380
+ /** Pi tool name -> the Claude tool name the hook scripts match on. Tools absent here run no hook. */
381
+ const PI_TOOL_NAME_MAP = Object.freeze({
382
+ bash: 'Bash', powershell: 'Bash', read: 'Read', write: 'Write', edit: 'Edit', grep: 'Grep', find: 'Glob',
383
+ });
384
+
385
+ /**
386
+ * Claude-shaped `tool_input` from a Pi tool input. Only well-typed fields are forwarded, so a
387
+ * malformed input yields a smaller object, never a throw.
388
+ * @param {string} piTool
389
+ * @param {unknown} input
390
+ */
391
+ function mapPiToolInput(piTool, input, cwd) {
392
+ const out = {};
393
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return out;
394
+ // Forwarded text is capped: a model can pad a field to push a guard past its timeout.
395
+ const str = (v) => (typeof v === 'string' ? v.slice(0, PI_FIELD_MAX_CHARS) : undefined);
396
+ if (str(input.path) !== undefined) out.file_path = resolvePiPath(input.path, cwd);
397
+ if (piTool === 'write' && str(input.content) !== undefined) out.content = str(input.content);
398
+ if (piTool === 'edit' && Array.isArray(input.edits)) {
399
+ const olds = [];
400
+ const news = [];
401
+ for (const e of input.edits) {
402
+ if (e && typeof e === 'object') {
403
+ if (str(e.oldText) !== undefined) olds.push(str(e.oldText));
404
+ if (str(e.newText) !== undefined) news.push(str(e.newText));
405
+ }
406
+ }
407
+ if (olds.length > 0) out.old_string = olds.join('\n').slice(0, PI_FIELD_MAX_CHARS);
408
+ if (news.length > 0) out.new_string = news.join('\n').slice(0, PI_FIELD_MAX_CHARS);
409
+ }
410
+ if ((piTool === 'bash' || piTool === 'powershell') && str(input.command) !== undefined) out.command = str(input.command);
411
+ if (piTool === 'grep' || piTool === 'find') {
412
+ if (str(input.pattern) !== undefined) out[piTool === 'grep' ? 'pattern' : 'glob'] = str(input.pattern);
413
+ if (piTool === 'grep' && str(input.glob) !== undefined) out.glob = str(input.glob);
414
+ }
415
+ return out;
416
+ }
417
+
418
+ /** Run a GSD `.sh` hook under bash with the payload on stdin. Fails open like runHook. */
419
+ function runShellHook(hookFile, payload, opts = {}) {
420
+ const none = { stdout: '', exitCode: 0, timedOut: false };
421
+ if (!SHARED_HOOKS_DIR) return none;
422
+ const hookPath = path.join(SHARED_HOOKS_DIR, hookFile);
423
+ if (!fs.existsSync(hookPath)) return none;
424
+ let result;
425
+ try {
426
+ result = spawnSync(process.env.GSD_HOOK_SHELL || resolveWinExe('bash') || 'bash', [hookPath], {
427
+ input: JSON.stringify(payload || {}),
428
+ encoding: 'utf8',
429
+ timeout: opts.timeout || 15000,
430
+ maxBuffer: HOOK_MAX_BUFFER,
431
+ cwd: opts.cwd || process.cwd(),
432
+ windowsHide: true,
433
+ });
434
+ } catch {
435
+ return none;
436
+ }
437
+ if (!result) return none;
438
+ const failure = hookSpawnFailure(result, hookFile);
439
+ if (failure) return failure;
440
+ if (result.error) return { stdout: '', exitCode: 0, timedOut: true };
441
+ return {
442
+ stdout: typeof result.stdout === 'string' ? result.stdout.trim() : '',
443
+ exitCode: result.status == null ? 0 : result.status,
444
+ timedOut: result.signal === 'SIGTERM',
445
+ };
446
+ }
447
+
448
+ /**
449
+ * Bound for the two blocking guards that probe git. A hook killed on timeout reports exit 0 (an
450
+ * allow), so a bound below the guard's own worst case silently disables the gate; read the
451
+ * guards' budget from the staged hooks/lib so it cannot drift.
452
+ */
453
+ const PI_GIT_PROBING_GUARDS = new Set(['gsd-worktree-path-guard.js', 'gsd-workflow-guard.js']);
454
+ function gitProbingGuardTimeoutMs() {
455
+ try {
456
+ const probe = require(path.join(SHARED_HOOKS_DIR, 'lib', 'git-probe.js'));
457
+ const worst = probe.BLOCKING_GUARD_MAX_SEQUENTIAL_PROBES * probe.BLOCKING_GUARD_PROBE_TIMEOUT_MS;
458
+ if (Number.isFinite(worst) && worst > 0) return worst + 5000;
459
+ } catch { /* partial install: use the fallback */ }
460
+ return 20000;
461
+ }
462
+
463
+ /**
464
+ * Interpret a hook result the way the Claude convention defines it: exit 2 or
465
+ * `{"decision":"block"}` blocks; `hookSpecificOutput.additionalContext` is an advisory.
466
+ * @returns {{ block: true, reason: string } | { advisory: string } | null}
467
+ */
468
+ function interpretHookResult(result) {
469
+ if (!result) return null;
470
+ const { stdout, exitCode } = result;
471
+ if (!stdout && exitCode !== 2) return null;
472
+ let parsed = null;
473
+ if (stdout) { try { parsed = JSON.parse(stdout); } catch { /* non-JSON stdout */ } }
474
+ if (exitCode === 2 || (parsed && parsed.decision === 'block')) {
475
+ return { block: true, reason: (parsed && typeof parsed.reason === 'string' && parsed.reason) || 'Blocked by GSD hook (no reason provided).' };
476
+ }
477
+ const advisory = parsed && parsed.hookSpecificOutput && parsed.hookSpecificOutput.additionalContext;
478
+ return typeof advisory === 'string' && advisory ? { advisory } : null;
479
+ }
480
+
481
+ /** [hook file, Claude tools it matches, runner kind, gate]. Order is the order the guards run in. */
482
+ const PI_PRE_TOOL_HOOKS = Object.freeze([
483
+ ['gsd-prompt-guard.js', ['Write', 'Edit'], 'js', null],
484
+ ['gsd-read-guard.js', ['Write', 'Edit'], 'js', null],
485
+ ['gsd-worktree-path-guard.js', ['Write', 'Edit'], 'js', null],
486
+ ['gsd-write-guard.js', ['Write'], 'js', null],
487
+ ['gsd-workflow-guard.js', ['Write', 'Edit', 'Bash'], 'js', null],
488
+ ['gsd-secret-read-guard.js', ['Read', 'Grep', 'Bash'], 'js', null],
489
+ ['gsd-validate-commit.sh', ['Bash'], 'sh', 'community'],
490
+ ]);
491
+ const PI_POST_TOOL_HOOKS = Object.freeze([
492
+ ['gsd-read-injection-scanner.js', ['Read'], 'js', null],
493
+ ['gsd-phase-boundary.sh', ['Write', 'Edit'], 'sh', 'community'],
494
+ ['gsd-graphify-update.sh', ['Bash'], 'sh', 'graphify'],
495
+ ]);
496
+ const PI_ADVISORY_QUEUE_MAX = 50;
497
+ const PI_ADVISORY_MAX_CHARS = 4000;
498
+ /** Hook output can echo repo- or model-controlled text (paths, scanned content): cap it and strip control characters. */
499
+ function sanitizeAdvisory(text) {
500
+ // eslint-disable-next-line no-control-regex
501
+ return String(text).replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, ' ').slice(0, PI_ADVISORY_MAX_CHARS);
502
+ }
503
+ const PI_CONFIG_MAX_BYTES = 1024 * 1024;
504
+ /** Blocking guards that must not read a timeout as an allow (they never probe git, so a timeout is not their normal worst case). */
505
+ const PI_FAIL_CLOSED_ON_TIMEOUT = new Set(['gsd-secret-read-guard.js']);
506
+
507
+ /**
508
+ * What each Claude hook becomes on Pi (#5560 acceptance: every hook has a disposition).
509
+ * status: bridged | unsupported | not-applicable | internal.
510
+ */
511
+ const PI_HOOK_DISPOSITIONS = Object.freeze({
512
+ 'gsd-ensure-canonical-path.js': [{ claude: 'SessionStart', status: 'bridged', pi: 'session_start' }],
513
+ 'gsd-session-state.sh': [{ claude: 'SessionStart', status: 'bridged', pi: 'before_agent_start (first turn, custom message)' }],
514
+ 'gsd-path-export.js': [{ claude: 'SessionStart', status: 'unsupported', reason: 'persists the gsd_run directory through $CLAUDE_ENV_FILE, which Claude Code provides only to SessionStart, Setup, CwdChanged and FileChanged hooks; Pi has no equivalent env file' }],
515
+ 'gsd-check-update.js': [{ claude: 'SessionStart', status: 'unsupported', reason: 'its cache is read only by the Claude statusline and update banner; Pi has neither, and Pi installs update through the installer or `pi update`' }],
516
+ 'gsd-check-update-worker.js': [{ claude: null, status: 'internal', reason: 'spawned by gsd-check-update.js' }],
517
+ 'gsd-update-banner.js': [{ claude: 'statusLine', status: 'unsupported', reason: 'renders into the Claude statusline; Pi extensions have no equivalent statusline command' }],
518
+ 'gsd-statusline.js': [{ claude: 'statusLine', status: 'unsupported', reason: 'Claude statusLine command; Pi extensions have no equivalent statusline command' }],
519
+ 'gsd-prompt-guard.js': [{ claude: 'PreToolUse', status: 'bridged', pi: 'tool_call (write, edit)' }],
520
+ 'gsd-read-guard.js': [{ claude: 'PreToolUse', status: 'bridged', pi: 'tool_call (write, edit)' }],
521
+ 'gsd-worktree-path-guard.js': [{ claude: 'PreToolUse', status: 'bridged', pi: 'tool_call (write, edit)' }],
522
+ 'gsd-write-guard.js': [{ claude: 'PreToolUse', status: 'bridged', pi: 'tool_call (write)' }],
523
+ 'gsd-workflow-guard.js': [{ claude: 'PreToolUse', status: 'bridged', pi: 'tool_call (write, edit, bash)' }],
524
+ 'gsd-secret-read-guard.js': [{ claude: 'PreToolUse', status: 'bridged', pi: 'tool_call (read, grep, bash)' }],
525
+ 'gsd-validate-commit.sh': [{ claude: 'PreToolUse', status: 'bridged', pi: 'tool_call (bash), opt-in hooks.community' }],
526
+ 'gsd-agent-isolation-guard.js': [{ claude: 'PreToolUse', status: 'unsupported', reason: 'guards the isolation argument of Claude\'s Agent call; gsd_dispatch has no isolation parameter and a dispatched role runs flat in its own process' }],
527
+ 'gsd-read-injection-scanner.js': [{ claude: 'PostToolUse', status: 'bridged', pi: 'tool_result (read)' }],
528
+ 'gsd-phase-boundary.sh': [{ claude: 'PostToolUse', status: 'bridged', pi: 'tool_result (write, edit), opt-in hooks.community' }],
529
+ 'gsd-graphify-update.sh': [{ claude: 'PostToolUse', status: 'bridged', pi: 'tool_result (bash), opt-in graphify.auto_update' }],
530
+ 'gsd-context-monitor.js': [
531
+ { claude: 'PreCompact', status: 'bridged', pi: 'session_before_compact' },
532
+ { claude: 'PostToolUse', status: 'unsupported', reason: 'reads the context metrics the Claude statusline writes; Pi has no statusline to produce them' },
533
+ { claude: 'SubagentStop', status: 'unsupported', reason: 'same metrics source as PostToolUse, and a dispatched role is a separate process with no extensions' },
534
+ { claude: 'Stop', status: 'unsupported', reason: 'same metrics source as PostToolUse' },
535
+ ],
536
+ 'gsd-config-reload.js': [{ claude: 'FileChanged', status: 'unsupported', reason: 'Pi 1.3.0 has no file-change event for extensions' }],
537
+ 'gsd-node-runner.sh': [{ claude: null, status: 'internal', reason: 'portable node resolver for managed Claude hook commands; Pi runs hooks through its own bridge' }],
538
+ 'gsd-cursor-post-tool.js': [{ claude: null, status: 'not-applicable', reason: 'Cursor-specific hook' }],
539
+ 'gsd-cursor-pre-tool.js': [{ claude: null, status: 'not-applicable', reason: 'Cursor-specific hook' }],
540
+ 'gsd-cursor-session-start.js': [{ claude: null, status: 'not-applicable', reason: 'Cursor-specific hook' }],
541
+ 'gsd-cursor-stop.js': [{ claude: null, status: 'not-applicable', reason: 'Cursor-specific hook' }],
542
+ 'gsd-cursor-subagent-start.js': [{ claude: null, status: 'not-applicable', reason: 'Cursor-specific hook' }],
543
+ 'gsd-cursor-subagent-stop.js': [{ claude: null, status: 'not-applicable', reason: 'Cursor-specific hook' }],
544
+ 'gsd-windsurf-pre-command.js': [{ claude: null, status: 'not-applicable', reason: 'Windsurf-specific hook' }],
545
+ 'gsd-windsurf-pre-write.js': [{ claude: null, status: 'not-applicable', reason: 'Windsurf-specific hook' }],
546
+ });
547
+
548
+ /**
549
+ * The PreToolUse / PostToolUse bridge. Seams: `runHook`, `runShellHook`, `readConfig(cwd)`, `env`.
550
+ * Handlers never throw and never block unless a hook explicitly said so.
551
+ */
552
+ function createHookBridge(deps = {}) {
553
+ const run = deps.runHook || runHook;
554
+ const runSh = deps.runShellHook || runShellHook;
555
+ const readConfig = deps.readConfig || ((cwd) => {
556
+ try {
557
+ const file = path.join(cwd, '.planning', 'config.json');
558
+ const st = fs.lstatSync(file);
559
+ if (!st.isFile() || st.size > PI_CONFIG_MAX_BYTES) return null;
560
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
561
+ } catch { return null; }
562
+ });
563
+ const env = deps.env || process.env;
564
+ /** @type {Map<string, string[]>} advisories raised before a tool ran, by toolCallId */
565
+ const pending = new Map();
566
+
567
+ let configMemo = null;
568
+ const gateOpen = (gate, cwd) => {
569
+ if (!gate) return true;
570
+ if (!configMemo || configMemo.cwd !== cwd) configMemo = { cwd, value: readConfig(cwd) };
571
+ const config = configMemo.value;
572
+ if (!config) return false;
573
+ if (gate === 'community') return !!config.hooks && config.hooks.community === true;
574
+ if (gate === 'graphify') return !env.CI && !!config.graphify && config.graphify.enabled === true && config.graphify.auto_update === true;
575
+ return false;
576
+ };
577
+ const invoke = (table, claudeTool, payload, cwd, onResult) => {
578
+ configMemo = null; // one config read per event
579
+ for (const [file, tools, kind, gate] of table) {
580
+ if (!tools.includes(claudeTool) || !gateOpen(gate, cwd)) continue;
581
+ const opts = { cwd };
582
+ if (PI_GIT_PROBING_GUARDS.has(file)) opts.timeout = gitProbingGuardTimeoutMs();
583
+ const raw = kind === 'sh' ? runSh(file, payload, opts) : run(file, payload, opts);
584
+ let outcome = interpretHookResult(raw);
585
+ if (raw && raw.timedOut) {
586
+ // A killed hook reports exit 0, which would read as an allow. Say so, and fail closed where the guard is cheap.
587
+ outcome = PI_FAIL_CLOSED_ON_TIMEOUT.has(file)
588
+ ? { block: true, reason: `${file} timed out, so the call is refused.` }
589
+ : { advisory: `${file} timed out and its check did not run.` };
590
+ }
591
+ if (outcome && onResult(outcome) === 'stop') return;
592
+ }
593
+ };
594
+ const remember = (id, text) => {
595
+ if (typeof id !== 'string') return;
596
+ text = sanitizeAdvisory(text);
597
+ if (!pending.has(id) && pending.size >= PI_ADVISORY_QUEUE_MAX) pending.delete(pending.keys().next().value);
598
+ const list = pending.get(id) || [];
599
+ list.push(text);
600
+ pending.set(id, list);
601
+ };
602
+
603
+ return {
604
+ pending,
605
+ onToolCall(event, ctx) {
606
+ try {
607
+ const claudeTool = event && hasOwn(PI_TOOL_NAME_MAP, event.toolName) ? PI_TOOL_NAME_MAP[event.toolName] : undefined;
608
+ if (!claudeTool) return undefined;
609
+ const cwd = (ctx && ctx.cwd) || process.cwd();
610
+ // No session_id: gsd-read-guard treats a non-empty one as a Claude Code session and skips its advisory.
611
+ const payload = { hook_event_name: 'PreToolUse', cwd, tool_name: claudeTool, tool_input: mapPiToolInput(event.toolName, event.input, cwd) };
612
+ let blocked = null;
613
+ invoke(PI_PRE_TOOL_HOOKS, claudeTool, payload, cwd, (outcome) => {
614
+ if (outcome.block) { blocked = outcome; return 'stop'; }
615
+ remember(event.toolCallId, outcome.advisory);
616
+ return undefined;
617
+ });
618
+ if (blocked) {
619
+ pending.delete(event.toolCallId); // Pi emits no tool_result for a blocked call
620
+ return { block: true, reason: sanitizeAdvisory(blocked.reason) };
621
+ }
622
+ } catch { /* fail open: a thrown handler would make Pi block the tool */ }
623
+ return undefined;
624
+ },
625
+ onToolResult(event, ctx) {
626
+ try {
627
+ if (!event) return undefined;
628
+ const advisories = pending.get(event.toolCallId) || [];
629
+ pending.delete(event.toolCallId);
630
+ const claudeTool = hasOwn(PI_TOOL_NAME_MAP, event.toolName) ? PI_TOOL_NAME_MAP[event.toolName] : undefined;
631
+ if (claudeTool) {
632
+ const cwd = (ctx && ctx.cwd) || process.cwd();
633
+ const payload = { hook_event_name: 'PostToolUse', cwd, tool_name: claudeTool, tool_input: mapPiToolInput(event.toolName, event.input, cwd), tool_response: { content: event.content } };
634
+ invoke(PI_POST_TOOL_HOOKS, claudeTool, payload, cwd, (outcome) => {
635
+ // A PostToolUse hook cannot undo the call; a block here is only a message to the model.
636
+ advisories.push(sanitizeAdvisory(outcome.block ? outcome.reason : outcome.advisory));
637
+ });
638
+ }
639
+ if (advisories.length === 0 || !Array.isArray(event.content)) return undefined;
640
+ return { content: [...event.content, ...advisories.map((a) => ({ type: 'text', text: `\n\n[GSD advisory] ${a}` }))] };
641
+ } catch { return undefined; }
642
+ },
643
+ };
644
+ }
645
+
646
+ // ── workflow hand-off (#5547) ─────────────────────────────────────────────────
647
+ // `/gsd <command>` used to be a data dispatcher only: `/gsd init new-project`
648
+ // printed the init JSON and stopped, because Pi had no entry point that mapped a
649
+ // GSD command to its workflow. The installer now writes each command as a Pi
650
+ // skill (`<config>/skills/gsd-<command>/SKILL.md`, invoked `/skill:gsd-<command>`),
651
+ // so a command that names one of those skills hands off to it instead.
652
+
653
+ // `/gsd init <workflow>` is a data call (the init bundle a workflow consumes).
654
+ // This maps each `gsd-tools init` workflow that has a GSD command to the command
655
+ // stem whose skill runs it — most are the same name, a few differ (`resume` ->
656
+ // `resume-work`, `new-workspace` -> `workspace`, ...). tests/pi-workflow-handoff
657
+ // pins every target to a commands/gsd/<name>.md file and every `gsd-tools init`
658
+ // workflow to either an entry here or an explicit no-skill reason.
659
+ const PI_WORKFLOW_INIT_COMMANDS = Object.freeze({
660
+ 'execute-phase': 'execute-phase',
661
+ 'plan-phase': 'plan-phase',
662
+ 'new-project': 'new-project',
663
+ 'new-milestone': 'new-milestone',
664
+ onboard: 'onboard',
665
+ quick: 'quick',
666
+ 'quick-batch': 'quick-batch',
667
+ 'ingest-docs': 'ingest-docs',
668
+ 'verify-work': 'verify-work',
669
+ 'code-review': 'code-review',
670
+ review: 'review',
671
+ 'map-codebase': 'map-codebase',
672
+ progress: 'progress',
673
+ manager: 'manager',
674
+ 'complete-milestone': 'complete-milestone',
675
+ autonomous: 'autonomous',
676
+ 'docs-update': 'docs-update',
677
+ update: 'update',
678
+ debug: 'debug',
679
+ resume: 'resume-work',
680
+ 'discuss-phase-assumptions': 'discuss-phase',
681
+ 'new-workspace': 'workspace',
682
+ });
683
+
684
+ // ── Pi package layout (#5558) ───────────────────────────────────────────────
685
+ // `pi install npm:@opengsd/gsd-core` loads THIS file straight out of the npm
686
+ // package (manifest `pi.extensions`). There, the engine tree is the package
687
+ // root and Pi's skills are not installed anywhere: they ship as templates under
688
+ // `pi/package-skills/` (generated by scripts/gen-pi-package-skills.cjs from the
689
+ // installer's own output, with the engine root replaced by a token) and are
690
+ // rendered with the real package location into a cache directory that
691
+ // `resources_discover` hands to Pi. The installer layout (`<cfg>/extensions/
692
+ // gsd.js` next to `<cfg>/gsd-core`) has no `pi/package-skills`, so it never
693
+ // enters this mode.
694
+ const PI_ENGINE_ROOT_TOKEN = '__GSD_PI_ENGINE_ROOT__';
695
+ // Characters a package location may contain for its text to be written into skills.
696
+ const SAFE_ENGINE_ROOT = /^[\p{L}\p{N}\p{M}._/:@+ ~()-]+$/u;
697
+
698
+ function isDirectory(p) {
699
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
700
+ }
701
+
702
+ function isFile(p) {
703
+ try { return fs.statSync(p).isFile(); } catch { return false; }
704
+ }
705
+
706
+ /** @param {string} engineRoot @returns {string} */
707
+ function packageSkillsDir(engineRoot) {
708
+ return path.join(engineRoot, 'pi', 'package-skills');
709
+ }
710
+
711
+ /**
712
+ * True when the extension runs out of the Pi package: the skill templates and
713
+ * the engine entry point are both present under the engine root.
714
+ * @param {string} [engineRoot]
715
+ * @returns {boolean}
716
+ */
717
+ function isPackageLayout(engineRoot = ENGINE_ROOT) {
718
+ return isDirectory(packageSkillsDir(engineRoot))
719
+ && isFile(path.join(engineRoot, 'gsd-core', 'bin', 'gsd-tools.cjs'));
720
+ }
721
+
722
+ /**
723
+ * Pi's agent directory: `PI_CODING_AGENT_DIR` when set, else `~/.pi/agent`.
724
+ * @param {NodeJS.ProcessEnv} [env]
725
+ * @param {string} [home]
726
+ * @returns {string}
727
+ */
728
+ function piAgentDir(env = process.env, home = os.homedir()) {
729
+ const configured = env && typeof env.PI_CODING_AGENT_DIR === 'string' ? env.PI_CODING_AGENT_DIR : '';
730
+ return configured ? path.resolve(configured) : path.join(home, '.pi', 'agent');
731
+ }
732
+
733
+ /**
734
+ * Render the package's skill templates for `engineRoot` into
735
+ * `<cacheRoot>/<key>/skills` and return that directory. `key` hashes the engine
736
+ * root and every rendered body, so a moved package or a changed template gets
737
+ * its own directory and a complete directory is reused. Written to a temp
738
+ * directory and renamed into place, so a concurrent session never sees a
739
+ * half-written tree. Throws on an fs failure; the caller degrades.
740
+ * @param {string} engineRoot
741
+ * @param {string} cacheRoot
742
+ * @returns {string}
743
+ */
744
+ function renderPackageSkills(engineRoot, cacheRoot) {
745
+ const srcDir = packageSkillsDir(engineRoot);
746
+ const names = fs.readdirSync(srcDir, { withFileTypes: true })
747
+ .filter((e) => e.isDirectory() && /^gsd-[a-z0-9-]+$/.test(e.name))
748
+ .map((e) => e.name)
749
+ .sort();
750
+ const root = engineRoot.replace(/\\/g, '/');
751
+ // The root is substituted into shell double quotes (the workflow launcher
752
+ // preamble) and into `@` include lines the model follows, so only a
753
+ // conservative path alphabet is rendered; anything else fails open upstream.
754
+ if (!SAFE_ENGINE_ROOT.test(root)) {
755
+ throw new Error(`package location contains characters GSD will not write into skill text: ${JSON.stringify(root)}`);
756
+ }
757
+ const rendered = names.map((name) => [
758
+ name,
759
+ fs.readFileSync(path.join(srcDir, name, 'SKILL.md'), 'utf8').split(PI_ENGINE_ROOT_TOKEN).join(root),
760
+ ]);
761
+ const hash = crypto.createHash('sha256').update(`${root}\0`);
762
+ for (const [name, body] of rendered) hash.update(`${name}\0${body}\0`);
763
+ const key = hash.digest('hex').slice(0, 16);
764
+ const finalDir = path.join(cacheRoot, key);
765
+ const skillsDir = path.join(finalDir, 'skills');
766
+ // A reused directory must hold exactly the rendered bodies as regular files
767
+ // (not symlinks), so a planted or truncated cache entry is rewritten, not trusted.
768
+ const complete = (dir) => rendered.every(([name, body]) => {
769
+ try {
770
+ const file = path.join(dir, name, 'SKILL.md');
771
+ return fs.lstatSync(file).isFile() && fs.readFileSync(file, 'utf8') === body;
772
+ } catch { return false; }
773
+ });
774
+ if (complete(skillsDir)) return skillsDir;
775
+
776
+ fs.mkdirSync(cacheRoot, { recursive: true });
777
+ const tmpDir = fs.mkdtempSync(path.join(cacheRoot, '.tmp-'));
778
+ try {
779
+ for (const [name, body] of rendered) {
780
+ fs.mkdirSync(path.join(tmpDir, 'skills', name), { recursive: true });
781
+ fs.writeFileSync(path.join(tmpDir, 'skills', name, 'SKILL.md'), body);
782
+ }
783
+ // Another session may have finished this very directory while ours was
784
+ // being written: never delete a complete one out from under its reader.
785
+ if (complete(skillsDir)) {
786
+ fs.rmSync(tmpDir, { recursive: true, force: true });
787
+ return skillsDir;
788
+ }
789
+ fs.rmSync(finalDir, { recursive: true, force: true });
790
+ fs.renameSync(tmpDir, finalDir);
791
+ } catch (e) {
792
+ fs.rmSync(tmpDir, { recursive: true, force: true });
793
+ // A concurrent session may have completed the same directory first.
794
+ if (complete(skillsDir)) return skillsDir;
795
+ throw e;
796
+ }
797
+ pruneRenderCache(cacheRoot, key);
798
+ return skillsDir;
799
+ }
800
+
801
+ // Rendered directories kept in the cache, the current one included. A new
802
+ // package version or a moved package adds one; older ones are removed oldest
803
+ // first, so the cache stays bounded without deleting what another running
804
+ // session of a recent version still reads.
805
+ const RENDER_CACHE_KEEP = 3;
806
+
807
+ /**
808
+ * Remove stale rendered directories and abandoned temp directories. Best
809
+ * effort: a failure here never affects the render that just succeeded.
810
+ * @param {string} cacheRoot
811
+ * @param {string} keepKey the directory just rendered, never removed
812
+ * @param {number} [keep] how many `<hash>` directories to keep in total
813
+ */
814
+ function pruneRenderCache(cacheRoot, keepKey, keep = RENDER_CACHE_KEEP) {
815
+ try {
816
+ const dirs = fs.readdirSync(cacheRoot, { withFileTypes: true }).filter((e) => e.isDirectory());
817
+ const hashed = dirs
818
+ .filter((e) => /^[0-9a-f]{16}$/.test(e.name) && e.name !== keepKey)
819
+ .map((e) => ({ name: e.name, mtime: fs.statSync(path.join(cacheRoot, e.name)).mtimeMs }))
820
+ .sort((a, b) => b.mtime - a.mtime);
821
+ for (const old of hashed.slice(Math.max(0, keep - 1))) {
822
+ fs.rmSync(path.join(cacheRoot, old.name), { recursive: true, force: true });
823
+ }
824
+ const hourAgo = Date.now() - 3600000;
825
+ for (const e of dirs.filter((d) => d.name.startsWith('.tmp-'))) {
826
+ const dir = path.join(cacheRoot, e.name);
827
+ if (fs.statSync(dir).mtimeMs < hourAgo) fs.rmSync(dir, { recursive: true, force: true });
828
+ }
829
+ } catch { /* best effort */ }
830
+ }
831
+
832
+ /**
833
+ * Put the package's `gsd-core/bin` first on PATH so the workflow launcher's
834
+ * identity-proving `gsd_run` fallback resolves without a per-runtime config home.
835
+ * @param {NodeJS.ProcessEnv} env
836
+ * @param {string} engineRoot
837
+ * @returns {boolean} true when PATH changed
838
+ */
839
+ function activatePackageEnv(env, engineRoot) {
840
+ const bin = path.join(engineRoot, 'gsd-core', 'bin');
841
+ const parts = String(env.PATH || '').split(path.delimiter).filter(Boolean);
842
+ if (parts.includes(bin)) return false;
843
+ env.PATH = [bin, ...parts].join(path.delimiter);
844
+ return true;
845
+ }
846
+
847
+ /**
848
+ * Whether the installer-layout extension is also installed, in which case the
849
+ * package stands down: two copies would register `/gsd` and every `gsd-*` skill twice.
850
+ * @param {NodeJS.ProcessEnv} [env]
851
+ * @param {string} [home]
852
+ * @returns {boolean}
853
+ */
854
+ function installerLayoutPresent(env = process.env, home = os.homedir()) {
855
+ return isFile(path.join(piAgentDir(env, home), 'extensions', 'gsd.js'));
856
+ }
857
+
858
+ /**
859
+ * Whether the installed tree carries `skills/gsd-<name>/SKILL.md`. Local to the
860
+ * config dir the extension was installed into, so a data-only (pre-#5547) tree
861
+ * or a partial install simply has no hand-off and keeps the data behavior.
862
+ * @param {string} name bare command stem, e.g. "new-project"
863
+ * @param {string} [engineRoot]
864
+ * @returns {boolean}
865
+ */
866
+ function hasWorkflowSkill(name, engineRoot = ENGINE_ROOT) {
867
+ if (typeof name !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(name)) return false;
868
+ try {
869
+ // In the Pi package layout `<root>/skills` is the Claude plugin's skill
870
+ // directory, not Pi's; the package's Pi skills are the rendered templates.
871
+ const skillsDir = isPackageLayout(engineRoot)
872
+ ? packageSkillsDir(engineRoot)
873
+ : path.join(engineRoot, 'skills');
874
+ return fs.statSync(path.join(skillsDir, `gsd-${name}`, 'SKILL.md')).isFile();
875
+ } catch {
876
+ return false;
877
+ }
878
+ }
879
+
880
+ /**
881
+ * Decide whether `/gsd <family> ...` is a workflow command. The data families
882
+ * (`init`, `state`, `phase`, ...) keep their scripted data semantics, so
883
+ * `/gsd phase list` and `gsd_invoke` are unchanged; anything else that names an
884
+ * installed `gsd-<name>` skill is handed off to it.
885
+ * @param {string} rawArgs the raw `/gsd` argument string
886
+ * @param {string} [engineRoot]
887
+ * @returns {{ skill: string, message: string } | null}
888
+ */
889
+ function resolveWorkflowHandoff(rawArgs, engineRoot = ENGINE_ROOT) {
890
+ const { family } = parseGsdCommandArgs(rawArgs);
891
+ if (PI_COMMAND_FAMILIES.includes(family)) return null;
892
+ if (!hasWorkflowSkill(family, engineRoot)) return null;
893
+ // The remainder is forwarded verbatim (not re-joined from tokens) so quoting
894
+ // the user typed survives into the skill's argument text.
895
+ const trimmed = String(rawArgs || '').trim();
896
+ const remainder = trimmed.slice(trimmed.indexOf(family) + family.length).trim();
897
+ const skill = `gsd-${family}`;
898
+ return { skill, message: remainder ? `/skill:${skill} ${remainder}` : `/skill:${skill}` };
899
+ }
900
+
901
+ /**
902
+ * The next-step line appended to `/gsd init <workflow>` output. `init <workflow>`
903
+ * is a data call the workflow itself makes, so it keeps returning its JSON; for
904
+ * a human at the prompt that JSON is a dead end, so name the way to run the
905
+ * workflow. Never added to `gsd_invoke`, whose output is parsed by callers.
906
+ * @param {{ family: string, subcommand?: string, args?: string[] }} parsed the tokenized
907
+ * `/gsd` call, as returned by parseGsdCommandArgs
908
+ * @param {string} [engineRoot]
909
+ * @returns {string} the line to append, or '' when none applies
910
+ */
911
+ function nextStepFor({ family, subcommand, args: rest = [] }, engineRoot = ENGINE_ROOT) {
912
+ if (family !== 'init' || typeof subcommand !== 'string') return '';
913
+ if (!Object.prototype.hasOwnProperty.call(PI_WORKFLOW_INIT_COMMANDS, subcommand)) return '';
914
+ const command = PI_WORKFLOW_INIT_COMMANDS[subcommand];
915
+ if (!hasWorkflowSkill(command, engineRoot)) return '';
916
+ // Positional arguments (a phase number, say) carry over; flags belong to the
917
+ // init data call and are not guaranteed to mean anything to the workflow.
918
+ const positional = (Array.isArray(rest) ? rest : []).filter((a) => typeof a === 'string' && !a.startsWith('-'));
919
+ const tail = positional.length > 0 ? ` ${positional.join(' ')}` : '';
920
+ return `\n\nNext: run /gsd ${command}${tail} (or /skill:gsd-${command}${tail}) to start the ${command} workflow — this output is only its init data.`;
921
+ }
922
+
923
+ // ── Agent-role dispatch (#5569, ADR-5559) ────────────────────────────────────
924
+ // Pi has no subagent primitive, so GSD supplies one: `gsd_dispatch` runs a `gsd-*` role in an
925
+ // isolated child `pi` process. Everything is fail-closed: every refusal is an error RESULT (never a
926
+ // throw, never an inline fallback that would run the role's work in the orchestrating session).
927
+ // The dispatcher lives in this file because the installer copies exactly one file
928
+ // (extensions/gsd.js); a sibling module would not be installed.
929
+ const DISPATCH = Object.freeze({
930
+ ROLE_RE: /^gsd-[a-z0-9-]+$/,
931
+ SAFE_VALUE_RE: /^[A-Za-z0-9_.:/@+][A-Za-z0-9_.:/@+,-]*$/,
932
+ MAX_DEPTH: 1,
933
+ TIMEOUT_MS: 30 * 60 * 1000,
934
+ KILL_GRACE_MS: 5000,
935
+ TASKKILL_TIMEOUT_MS: 10000,
936
+ STDOUT_CAP: 1024 * 1024,
937
+ MAX_LINE: 16 * 1024 * 1024,
938
+ STDERR_TAIL: 8 * 1024,
939
+ TRUNCATION_MARKER: '\n[GSD: dispatch output truncated at 1 MiB]',
940
+ FAILED_STOP_REASONS: Object.freeze(['error', 'aborted', 'length']),
941
+ });
942
+
943
+ const dispatchFail = (code, message) => ({ ok: false, code, message });
944
+ const errorText = (e) => (e && e.message ? e.message : String(e));
945
+ /** A failed spawn: ENOENT means no runnable pi, anything else is a child failure. */
946
+ const spawnFailure = (e) => (e && e.code === 'ENOENT'
947
+ ? dispatchFail('pi-not-found', 'No runnable `pi` was found on PATH.')
948
+ : dispatchFail('child-failed', `Could not start pi: ${errorText(e)}`));
949
+
950
+ /** `key: value` pairs of a role file's leading frontmatter block (values unquoted, single line). */
951
+ function readRoleFrontmatter(text) {
952
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(text));
953
+ const meta = {};
954
+ if (!block) return meta;
955
+ for (const line of block[1].split(/\r?\n/)) {
956
+ const kv = /^(\w+):\s*(.*)$/.exec(line);
957
+ if (kv) meta[kv[1]] = kv[2].trim().replace(/^(["'])(.*)\1$/, '$2');
958
+ }
959
+ return meta;
960
+ }
961
+
962
+ /**
963
+ * Resolve a role id to its file in the install this extension belongs to. The role is matched against
964
+ * a directory LISTING, never joined from the caller's string, so no input can name a path.
965
+ * @returns {{ ok: true, file: string, meta: object } | { ok: false, code: string, message: string }}
966
+ */
967
+ function resolveDispatchRole(role, extensionDir) {
968
+ if (typeof role !== 'string' || !DISPATCH.ROLE_RE.test(role)) {
969
+ return dispatchFail('role-unresolved', `"${String(role).slice(0, 80)}" is not a GSD role id (gsd-<name>).`);
970
+ }
971
+ // Installer layout: <config>/extensions/gsd.js beside <config>/agents and the install manifest.
972
+ // Package layout (pi install npm:...): <package>/pi/gsd.cjs beside the converted roles and their
973
+ // manifest, generated into the tarball (ADR-5559 amendment). Both are fixed relative to this file.
974
+ const inPackage = fs.existsSync(path.join(extensionDir, 'package-agents'));
975
+ const roleDir = inPackage ? path.join(extensionDir, 'package-agents') : path.join(extensionDir, '..', 'agents');
976
+ const manifestPath = inPackage ? path.join(roleDir, 'manifest.json') : path.join(extensionDir, '..', 'gsd-file-manifest.json');
977
+ const manifestKey = inPackage ? `${role}.md` : `agents/${role}.md`;
978
+ const fileName = `${role}.md`;
979
+ let listing;
980
+ try { listing = fs.readdirSync(roleDir); } catch { listing = []; }
981
+ if (!listing.includes(fileName)) {
982
+ return dispatchFail('role-unresolved', `${role} is not installed (looked in ${roleDir}).`);
983
+ }
984
+ const file = path.join(roleDir, fileName);
985
+ let bytes;
986
+ try {
987
+ if (fs.lstatSync(file).isSymbolicLink()) return dispatchFail('role-unresolved', `${fileName} is a symbolic link.`);
988
+ bytes = fs.readFileSync(file);
989
+ } catch (e) {
990
+ return dispatchFail('role-unresolved', `${fileName} could not be read: ${errorText(e)}`);
991
+ }
992
+ // The manifest is required: without it nothing vouches for the bytes that become the child's system prompt.
993
+ let expected;
994
+ try { expected = (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).files || {})[manifestKey]; } catch { expected = undefined; }
995
+ const actual = crypto.createHash('sha256').update(bytes).digest('hex');
996
+ if (expected !== actual) {
997
+ return dispatchFail('role-tampered', `${fileName} does not match the install manifest (or the manifest is missing); reinstall GSD to restore it.`);
998
+ }
999
+ // A package role is a template: its `@` references carry PI_ENGINE_ROOT_TOKEN for the package location.
1000
+ let text = bytes.toString('utf8');
1001
+ if (inPackage) {
1002
+ const root = path.resolve(extensionDir, '..').replace(/\\/g, '/');
1003
+ if (!SAFE_ENGINE_ROOT.test(root)) {
1004
+ return dispatchFail('role-unresolved', `the package location contains characters GSD will not write into a role: ${JSON.stringify(root)}`);
1005
+ }
1006
+ text = text.split(PI_ENGINE_ROOT_TOKEN).join(root);
1007
+ }
1008
+ return { ok: true, file, bytes: Buffer.from(text, 'utf8'), meta: readRoleFrontmatter(text) };
1009
+ }
1010
+
1011
+ /** taskkill by absolute path: a bare name on Windows is searched in the spawn cwd (the project) first. */
1012
+ function taskkillPath(env) {
1013
+ const root = (env && (env.SystemRoot || env.windir)) || 'C:\\Windows';
1014
+ return path.win32.join(root, 'System32', 'taskkill.exe');
1015
+ }
1016
+
1017
+ /**
1018
+ * The `pi` to run (ADR-5559 `pi-not-found`): the CLI next to this process's executable if there is
1019
+ * one, else the first `pi` on PATH. On Windows the PATH entries are searched here and an absolute
1020
+ * path is returned, so the project directory (the spawn cwd) is never searched for an executable.
1021
+ * @returns {string|null} null when no runnable pi was found on Windows
1022
+ */
1023
+ function resolvePiCommand({ platform, env, execPath, exists }) {
1024
+ const win = platform === 'win32';
1025
+ const pathMod = win ? path.win32 : path.posix;
1026
+ // Windows: only pi.exe. A .cmd shim cannot be spawned without a shell (Node refuses it, CVE-2024-27980).
1027
+ const names = win ? ['pi.exe'] : ['pi'];
1028
+ for (const name of names) {
1029
+ const sibling = pathMod.join(pathMod.dirname(execPath || ''), name);
1030
+ if (execPath && exists(sibling)) return sibling;
1031
+ }
1032
+ if (!win) return 'pi';
1033
+ const dirs = String((env && (env.Path || env.PATH)) || '').split(';').filter((d) => /^[A-Za-z]:[\\/]/.test(d));
1034
+ for (const dir of dirs) {
1035
+ for (const name of names) {
1036
+ const candidate = pathMod.join(dir, name);
1037
+ if (exists(candidate)) return candidate;
1038
+ }
1039
+ }
1040
+ return null;
1041
+ }
1042
+
1043
+ /**
1044
+ * The dispatcher. Seams (all optional): `spawn`, `kill`, `env`, `platform`, `extensionDir`, `command`, `execPath`, `exists`.
1045
+ * Calls run strictly one at a time (ADR-5559 section 2).
1046
+ */
1047
+ function createDispatcher(deps = {}) {
1048
+ const extensionDir = deps.extensionDir || __dirname;
1049
+ const spawn = deps.spawn || require('child_process').spawn;
1050
+ const killProcess = deps.kill || ((pid, signal) => process.kill(pid, signal));
1051
+ const env = deps.env || process.env;
1052
+ const platform = deps.platform || process.platform;
1053
+ const isWindows = platform === 'win32';
1054
+ const timeoutMs = Number(env.GSD_PI_DISPATCH_TIMEOUT_MS) > 0 ? Number(env.GSD_PI_DISPATCH_TIMEOUT_MS) : DISPATCH.TIMEOUT_MS;
1055
+ let queue = Promise.resolve();
1056
+
1057
+ function terminate(child) {
1058
+ if (isWindows) {
1059
+ const killer = spawn(taskkillPath(env), ['/T', '/F', '/PID', String(child.pid)], { stdio: 'ignore', windowsHide: true });
1060
+ const bound = setTimeout(() => { if (killer && typeof killer.kill === 'function') { try { killer.kill(); } catch { /* gone */ } } }, DISPATCH.TASKKILL_TIMEOUT_MS);
1061
+ if (killer && typeof killer.once === 'function') killer.once('exit', () => clearTimeout(bound));
1062
+ return;
1063
+ }
1064
+ try { killProcess(-child.pid, 'SIGTERM'); } catch { /* already gone */ }
1065
+ }
1066
+
1067
+ function runChild(child, signal, finishWith) {
1068
+ let timedOut = false;
1069
+ let closed = false;
1070
+ let escalate = null;
1071
+ let buffered = '';
1072
+ let last = null;
1073
+ let stderrTail = '';
1074
+ let overflow = false;
1075
+
1076
+ const stop = () => {
1077
+ timedOut = true;
1078
+ terminate(child);
1079
+ if (!isWindows) {
1080
+ escalate = setTimeout(() => { if (!closed) { try { killProcess(-child.pid, 'SIGKILL'); } catch { /* gone */ } } }, DISPATCH.KILL_GRACE_MS);
1081
+ }
1082
+ };
1083
+ const deadline = setTimeout(stop, timeoutMs);
1084
+ const onAbort = () => stop();
1085
+ if (signal) {
1086
+ if (signal.aborted) stop(); else signal.addEventListener('abort', onAbort, { once: true });
1087
+ }
1088
+
1089
+ const onLine = (line) => {
1090
+ if (!line.trim()) return;
1091
+ let event;
1092
+ try { event = JSON.parse(line); } catch { return; }
1093
+ if (event && event.type === 'message_end' && event.message && event.message.role === 'assistant') {
1094
+ const parts = Array.isArray(event.message.content) ? event.message.content : [];
1095
+ last = {
1096
+ text: parts.filter((p) => p && p.type === 'text' && typeof p.text === 'string').map((p) => p.text).join(''),
1097
+ stopReason: event.message.stopReason,
1098
+ };
1099
+ }
1100
+ };
1101
+ child.stdout.on('data', (chunk) => {
1102
+ buffered += chunk.toString('utf8');
1103
+ let newline;
1104
+ while ((newline = buffered.indexOf('\n')) >= 0) {
1105
+ onLine(buffered.slice(0, newline));
1106
+ buffered = buffered.slice(newline + 1);
1107
+ }
1108
+ if (buffered.length > DISPATCH.MAX_LINE) { overflow = true; buffered = ''; stop(); }
1109
+ });
1110
+ child.stderr.on('data', (chunk) => { stderrTail = (stderrTail + chunk.toString('utf8')).slice(-DISPATCH.STDERR_TAIL); });
1111
+ child.on('error', (e) => {
1112
+ closed = true;
1113
+ clearTimeout(deadline);
1114
+ clearTimeout(escalate);
1115
+ finishWith(spawnFailure(e));
1116
+ });
1117
+ child.on('close', (code) => {
1118
+ if (closed) return;
1119
+ closed = true;
1120
+ clearTimeout(deadline);
1121
+ clearTimeout(escalate);
1122
+ if (signal) signal.removeEventListener('abort', onAbort);
1123
+ if (buffered) onLine(buffered);
1124
+ if (overflow) return finishWith(dispatchFail('child-failed', 'The role produced a single output line larger than 16 MiB.'));
1125
+ if (timedOut) return finishWith(dispatchFail('timeout', `The role did not finish within ${Math.round(timeoutMs / 1000)} s or was cancelled; it was stopped.`));
1126
+ if (code !== 0) return finishWith(dispatchFail('child-failed', `pi exited with code ${code}.${stderrTail ? ` stderr (tail): ${stderrTail}` : ''}`));
1127
+ if (!last) return finishWith(dispatchFail('child-failed', 'pi finished without an assistant message.'));
1128
+ if (DISPATCH.FAILED_STOP_REASONS.includes(last.stopReason)) {
1129
+ return finishWith(dispatchFail('child-failed', `The role's final message stopped with "${last.stopReason}".${stderrTail ? ` stderr (tail): ${stderrTail}` : ''}`));
1130
+ }
1131
+ const text = last.text.length > DISPATCH.STDOUT_CAP ? last.text.slice(0, DISPATCH.STDOUT_CAP) + DISPATCH.TRUNCATION_MARKER : last.text;
1132
+ return finishWith({ ok: true, text });
1133
+ });
1134
+ }
1135
+
1136
+ function runOne(params, ctx) {
1137
+ const p = params && typeof params === 'object' ? params : {};
1138
+ // Unset or empty means top level; any other value must be a plain non-negative integer below the cap.
1139
+ const rawDepth = env.GSD_PI_DISPATCH_DEPTH;
1140
+ const depth = rawDepth === undefined || rawDepth === '' ? 0 : (/^\d{1,6}$/.test(rawDepth) ? Number(rawDepth) : Infinity);
1141
+ if (depth >= DISPATCH.MAX_DEPTH) {
1142
+ return Promise.resolve(dispatchFail('depth-exceeded', 'A dispatched role cannot dispatch another role (Pi dispatch is flat).'));
1143
+ }
1144
+ const resolved = resolveDispatchRole(p.role, extensionDir);
1145
+ if (!resolved.ok) return Promise.resolve(resolved);
1146
+ const roleTools = resolved.meta.tools ? resolved.meta.tools.split(',').map((s) => s.trim()).filter(Boolean).join(',') : '';
1147
+ const tools = roleTools || '-gsd_dispatch';
1148
+ if ((roleTools && !DISPATCH.SAFE_VALUE_RE.test(roleTools)) || (resolved.meta.model && !DISPATCH.SAFE_VALUE_RE.test(resolved.meta.model))) {
1149
+ return Promise.resolve(dispatchFail('role-unresolved', `${p.role} has a tools or model value that is not a plain identifier list.`));
1150
+ }
1151
+ let taskDir;
1152
+ try {
1153
+ taskDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pi-dispatch-'));
1154
+ fs.chmodSync(taskDir, 0o700);
1155
+ } catch (e) {
1156
+ return Promise.resolve(dispatchFail('child-failed', `Could not create a private task directory: ${errorText(e)}`));
1157
+ }
1158
+ const taskFile = path.join(taskDir, 'task.md');
1159
+ const cleanup = () => { try { fs.rmSync(taskDir, { recursive: true, force: true }); } catch { /* best-effort */ } };
1160
+ // The child reads the role from a copy of the bytes that were just hash-verified, so a swap of
1161
+ // the installed file between the check and the child's start changes nothing.
1162
+ const roleCopy = path.join(taskDir, 'role.md');
1163
+ try {
1164
+ fs.writeFileSync(taskFile, typeof p.task === 'string' ? p.task : '', { mode: 0o600 });
1165
+ fs.writeFileSync(roleCopy, resolved.bytes, { mode: 0o600 });
1166
+ } catch (e) {
1167
+ cleanup();
1168
+ return Promise.resolve(dispatchFail('child-failed', `Could not write the task file: ${errorText(e)}`));
1169
+ }
1170
+ const args = [
1171
+ '--mode', 'json', '-p', '--no-session', '--no-mcp', '--no-extensions', '--no-approve',
1172
+ '--append-system-prompt', roleCopy,
1173
+ ...(resolved.meta.model ? ['--model', resolved.meta.model] : []),
1174
+ '--tools', tools,
1175
+ `@${taskFile}`,
1176
+ ];
1177
+ const childEnv = { ...env, GSD_PI_DISPATCH_DEPTH: String(depth + 1) };
1178
+ return new Promise((resolve) => {
1179
+ const finishWith = (result) => { cleanup(); resolve(result); };
1180
+ let child;
1181
+ try {
1182
+ const command = deps.command || resolvePiCommand({
1183
+ platform, env, execPath: deps.execPath || process.execPath, exists: deps.exists || ((f) => { try { return fs.statSync(f).isFile(); } catch { return false; } }),
1184
+ });
1185
+ if (command === null) {
1186
+ finishWith(dispatchFail('pi-not-found', 'No runnable `pi` was found on PATH.'));
1187
+ return;
1188
+ }
1189
+ child = spawn(command, args, {
1190
+ cwd: (ctx && ctx.cwd) || process.cwd(),
1191
+ env: childEnv,
1192
+ detached: !isWindows,
1193
+ stdio: ['ignore', 'pipe', 'pipe'],
1194
+ windowsHide: true,
1195
+ });
1196
+ } catch (e) {
1197
+ finishWith(spawnFailure(e));
1198
+ return;
1199
+ }
1200
+ runChild(child, ctx && ctx.signal, finishWith);
1201
+ });
1202
+ }
1203
+
1204
+ return {
1205
+ dispatch(params, ctx) {
1206
+ const run = queue.then(() => runOne(params, ctx));
1207
+ queue = run.catch(() => undefined);
1208
+ return run;
1209
+ },
1210
+ };
1211
+ }
1212
+
1213
+ function buildGsdDispatchParameters() {
1214
+ try {
1215
+ const typebox = require('typebox');
1216
+ const Type = typebox && typebox.Type;
1217
+ if (Type) return Type.Object({ role: Type.String(), task: Type.String() });
1218
+ } catch { /* typebox is not a gsd-core dependency; the JSON-Schema fallback is the normal path */ }
1219
+ return { type: 'object', properties: { role: { type: 'string' }, task: { type: 'string' } }, required: ['role', 'task'] };
1220
+ }
1221
+
1222
+ /**
1223
+ * @param {object} pi pi ExtensionAPI
1224
+ * @param {{ env?: NodeJS.ProcessEnv, engineRoot?: string, home?: string }} [deps] test seam; Pi passes only `pi`
1225
+ */
1226
+ module.exports = function gsdPiExtension(pi, deps = {}) {
313
1227
  if (!pi || typeof pi !== 'object') {
314
1228
  throw new TypeError('gsdPiExtension: pi ExtensionAPI is required');
315
1229
  }
316
1230
 
1231
+ // ── Pi package mode (#5558) ──────────────────────────────────────────────
1232
+ const env = (deps && deps.env) || process.env;
1233
+ const engineRoot = (deps && deps.engineRoot) || ENGINE_ROOT;
1234
+ const home = (deps && deps.home) || os.homedir();
1235
+ let packageRenderError = null;
1236
+ let uiNotify = null;
1237
+ let renderErrorReported = false;
1238
+ // Pi may fire resources_discover before or after session_start; whichever
1239
+ // comes second reports the error, once.
1240
+ const reportRenderError = () => {
1241
+ if (!packageRenderError || renderErrorReported || !uiNotify) return;
1242
+ renderErrorReported = true;
1243
+ uiNotify(`GSD skills could not be prepared: ${packageRenderError.message}`, 'error');
1244
+ };
1245
+ if (isPackageLayout(engineRoot)) {
1246
+ if (installerLayoutPresent(env, home)) {
1247
+ // The installer copy of this extension is already loaded; registering the
1248
+ // package copy too would duplicate /gsd and every gsd-* skill.
1249
+ pi.on('session_start', async (event, ctx) => {
1250
+ if (ctx && ctx.ui && typeof ctx.ui.notify === 'function') {
1251
+ ctx.ui.notify(
1252
+ 'GSD is installed both as a Pi package and by the gsd-core installer. The package copy is inactive; remove one of the two (pi remove npm:@opengsd/gsd-core, or the installer files under the Pi agent directory).',
1253
+ 'warning',
1254
+ );
1255
+ }
1256
+ });
1257
+ return;
1258
+ }
1259
+ activatePackageEnv(env, engineRoot);
1260
+ pi.on('resources_discover', () => {
1261
+ try {
1262
+ return { skillPaths: [renderPackageSkills(engineRoot, path.join(piAgentDir(env, home), 'gsd-package-cache'))] };
1263
+ } catch (e) {
1264
+ packageRenderError = e;
1265
+ reportRenderError();
1266
+ return undefined; // fail-open: Pi still starts, without the GSD skills
1267
+ }
1268
+ });
1269
+ }
1270
+
317
1271
  // ── /gsd command: dispatch through gsd-tools.cjs (subprocess-reuse) ──────
318
1272
  pi.registerCommand('gsd', {
319
1273
  description: 'Invoke a GSD command via the embedded engine (subprocess-reuse adapter).',
@@ -332,6 +1286,28 @@ module.exports = function gsdPiExtension(pi) {
332
1286
  const notify = (ctx && ctx.ui && typeof ctx.ui.notify === 'function')
333
1287
  ? (message, type) => ctx.ui.notify(message, type)
334
1288
  : null;
1289
+ // #5547: a workflow-backed command starts its workflow instead of
1290
+ // printing data. `sendUserMessage` with `expandPromptTemplates` makes Pi
1291
+ // expand the `/skill:` command, so the agent follows the installed skill.
1292
+ const handoff = resolveWorkflowHandoff(args, engineRoot);
1293
+ if (handoff && packageRenderError) {
1294
+ // The skill was never registered with Pi, so handing off would only
1295
+ // produce an unknown-skill message. Say why instead.
1296
+ if (notify) {
1297
+ notify(`${handoff.skill} is unavailable: GSD skills could not be prepared (${packageRenderError.message}).`, 'error');
1298
+ }
1299
+ return;
1300
+ }
1301
+ if (handoff) {
1302
+ if (typeof pi.sendUserMessage === 'function') {
1303
+ pi.sendUserMessage(handoff.message, { deliverAs: 'followUp', expandPromptTemplates: true });
1304
+ } else if (notify) {
1305
+ // A host without sendUserMessage cannot be handed off to; say how to
1306
+ // start the workflow by hand rather than running its data command.
1307
+ notify(`This pi host cannot start a skill from /gsd. Run ${handoff.message} to start the ${handoff.skill} workflow.`, 'info');
1308
+ }
1309
+ return;
1310
+ }
335
1311
  const { family, subcommand, args: rest } = parseGsdCommandArgs(args);
336
1312
  let dispatchGsdCommand;
337
1313
  try {
@@ -344,7 +1320,7 @@ module.exports = function gsdPiExtension(pi) {
344
1320
  }
345
1321
  const result = dispatchGsdCommand({ family, subcommand, args: rest, cwd });
346
1322
  const text = result.ok
347
- ? result.stdout
1323
+ ? `${result.stdout}${nextStepFor({ family, subcommand, args: rest }, engineRoot)}`
348
1324
  : `GSD error: ${result.stderr || result.stdout || `dispatch failed (exit ${result.code})`}`;
349
1325
  if (notify) {
350
1326
  notify(text, result.ok ? 'info' : 'error');
@@ -379,6 +1355,20 @@ module.exports = function gsdPiExtension(pi) {
379
1355
  },
380
1356
  });
381
1357
 
1358
+ // ── gsd_dispatch tool: run a gsd-* role in an isolated child pi (#5569, ADR-5559) ──
1359
+ const dispatcher = createDispatcher({ extensionDir: __dirname, env });
1360
+ pi.registerTool({
1361
+ name: 'gsd_dispatch',
1362
+ label: 'GSD Dispatch',
1363
+ description: 'Run a GSD agent role (gsd-*) on a task in an isolated child Pi process and return its final message. Dispatch is flat and sequential; a role cannot dispatch another role.',
1364
+ parameters: buildGsdDispatchParameters(),
1365
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
1366
+ const result = await dispatcher.dispatch(params, { cwd: (ctx && ctx.cwd) || process.cwd(), signal });
1367
+ if (result.ok) return { content: [{ type: 'text', text: result.text }] };
1368
+ return { content: [{ type: 'text', text: `gsd_dispatch ${result.code}: ${result.message}` }], isError: true };
1369
+ },
1370
+ });
1371
+
382
1372
  // ── before_provider_request: active-model steering (modelMode: 'active') ──
383
1373
  // GSD steers pi's EXISTING built-in anthropic models; it does NOT call
384
1374
  // pi.registerProvider (that would wrongly register a NEW fake provider —
@@ -393,6 +1383,10 @@ module.exports = function gsdPiExtension(pi) {
393
1383
  // session_start → SessionStart-equivalent bootstrap.
394
1384
  pi.on('session_start', async (event, ctx) => {
395
1385
  try {
1386
+ if (ctx && ctx.ui && typeof ctx.ui.notify === 'function') {
1387
+ uiNotify = (message, type) => ctx.ui.notify(message, type);
1388
+ reportRenderError();
1389
+ }
396
1390
  const cwd = (ctx && ctx.cwd) || process.cwd();
397
1391
  runHook('gsd-ensure-canonical-path.js', { hook_event_name: 'SessionStart', cwd }, { cwd });
398
1392
  } catch { /* fail-open */ }
@@ -404,11 +1398,20 @@ module.exports = function gsdPiExtension(pi) {
404
1398
  // fires as a safe no-op today — wired so a future agent-start-scoped check
405
1399
  // can attach without a plugin change (mirrors the OpenCode session.idle
406
1400
  // recognized-but-unused sentinel pattern).
1401
+ let sessionOrientationSent = false;
407
1402
  pi.on('before_agent_start', async (event, ctx) => {
408
1403
  try {
409
1404
  const cwd = (ctx && ctx.cwd) || process.cwd();
410
1405
  runHook('gsd-workflow-guard.js', { hook_event_name: 'before_agent_start', cwd }, { cwd });
1406
+ // SessionStart orientation (gsd-session-state.sh, opt-in hooks.community): once per session,
1407
+ // delivered as a custom message because Pi's session_start cannot inject context.
1408
+ if (!sessionOrientationSent) {
1409
+ sessionOrientationSent = true;
1410
+ const outcome = interpretHookResult(runShellHook('gsd-session-state.sh', { hook_event_name: 'SessionStart', cwd }, { cwd }));
1411
+ if (outcome && outcome.advisory) return { message: { customType: 'gsd-session-state', content: sanitizeAdvisory(outcome.advisory), display: true } };
1412
+ }
411
1413
  } catch { /* fail-open */ }
1414
+ return undefined;
412
1415
  });
413
1416
 
414
1417
  // session_before_compact → PreCompact-equivalent (context-usage bridge).
@@ -419,13 +1422,10 @@ module.exports = function gsdPiExtension(pi) {
419
1422
  } catch { /* fail-open */ }
420
1423
  });
421
1424
 
422
- // tool_call event: lifecycle hook bridge attachment point (kept from the
423
- // original cut — the PreToolUse/PostToolUse tool_name/tool_input mapping
424
- // is a follow-up once pi's tool_call payload shape is verified against a
425
- // live host).
426
- pi.on('tool_call', async function () {
427
- /* GSD hook bridge attachment point (PreToolUse/PostToolUse mapping). */
428
- });
1425
+ // tool_call / tool_result: PreToolUse / PostToolUse bridge (#5560, payload verified on live Pi 1.3.0).
1426
+ const hookBridge = createHookBridge();
1427
+ pi.on('tool_call', async (event, ctx) => hookBridge.onToolCall(event, ctx));
1428
+ pi.on('tool_result', async (event, ctx) => hookBridge.onToolResult(event, ctx));
429
1429
  };
430
1430
 
431
1431
  // Test-only internals (mirrors the OpenCode plugin pattern) — wired to the
@@ -441,4 +1441,41 @@ module.exports._internals = {
441
1441
  buildBeforeProviderRequestHandler,
442
1442
  buildGsdInvokeParameters,
443
1443
  runHook,
1444
+ hasWorkflowSkill,
1445
+ PI_ENGINE_ROOT_TOKEN,
1446
+ isPackageLayout,
1447
+ packageSkillsDir,
1448
+ piAgentDir,
1449
+ renderPackageSkills,
1450
+ pruneRenderCache,
1451
+ RENDER_CACHE_KEEP,
1452
+ activatePackageEnv,
1453
+ installerLayoutPresent,
1454
+ resolveWorkflowHandoff,
1455
+ nextStepFor,
1456
+ PI_WORKFLOW_INIT_COMMANDS,
1457
+ DISPATCH,
1458
+ createDispatcher,
1459
+ resolveDispatchRole,
1460
+ hookRuntime,
1461
+ PI_TOOL_NAME_MAP,
1462
+ mapPiToolInput,
1463
+ runShellHook,
1464
+ interpretHookResult,
1465
+ PI_PRE_TOOL_HOOKS,
1466
+ PI_POST_TOOL_HOOKS,
1467
+ PI_ADVISORY_QUEUE_MAX,
1468
+ PI_ADVISORY_MAX_CHARS,
1469
+ PI_FIELD_MAX_CHARS,
1470
+ sanitizeAdvisory,
1471
+ resolvePiPath,
1472
+ resolveWinExe,
1473
+ hookSpawnFailure,
1474
+ HOOK_MAX_BUFFER,
1475
+ PI_HOOK_DISPOSITIONS,
1476
+ createHookBridge,
1477
+ readRoleFrontmatter,
1478
+ resolvePiCommand,
1479
+ taskkillPath,
1480
+ buildGsdDispatchParameters,
444
1481
  };