@opengsd/gsd-core 1.6.0-rc.2 → 1.6.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 (157) hide show
  1. package/.claude-plugin/plugin.json +2 -1
  2. package/agents/gsd-advisor-researcher.md +2 -0
  3. package/agents/gsd-ai-researcher.md +2 -0
  4. package/agents/gsd-assumptions-analyzer.md +2 -0
  5. package/agents/gsd-doc-classifier.md +2 -0
  6. package/agents/gsd-doc-synthesizer.md +2 -0
  7. package/agents/gsd-domain-researcher.md +2 -0
  8. package/agents/gsd-eval-auditor.md +6 -9
  9. package/agents/gsd-phase-researcher.md +2 -0
  10. package/agents/gsd-planner.md +8 -57
  11. package/agents/gsd-project-researcher.md +2 -0
  12. package/agents/gsd-research-synthesizer.md +2 -0
  13. package/agents/gsd-security-auditor.md +37 -18
  14. package/agents/gsd-ui-researcher.md +2 -0
  15. package/bin/install.js +370 -18
  16. package/gemini-extension.json +1 -1
  17. package/gsd-core/bin/gsd-tools.cjs +46 -4
  18. package/gsd-core/bin/lib/audit-command-router.cjs +52 -14
  19. package/gsd-core/bin/lib/capability-lifecycle.cjs +30 -7
  20. package/gsd-core/bin/lib/capability-registry.cjs +96 -83
  21. package/gsd-core/bin/lib/capability-validator.cjs +22 -0
  22. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +40 -2
  23. package/gsd-core/bin/lib/command-aliases.cjs +10 -1
  24. package/gsd-core/bin/lib/command-routing-hub.cjs +10 -3
  25. package/gsd-core/bin/lib/config-schema.cjs +1 -0
  26. package/gsd-core/bin/lib/config.cjs +67 -24
  27. package/gsd-core/bin/lib/coverage.cjs +464 -0
  28. package/gsd-core/bin/lib/decisions.cjs +27 -0
  29. package/gsd-core/bin/lib/eval-command-router.cjs +21 -0
  30. package/gsd-core/bin/lib/eval.cjs +60 -0
  31. package/gsd-core/bin/lib/frontmatter.cjs +132 -13
  32. package/gsd-core/bin/lib/graphify-command-router.cjs +53 -36
  33. package/gsd-core/bin/lib/init.cjs +139 -30
  34. package/gsd-core/bin/lib/install-profiles.cjs +6 -3
  35. package/gsd-core/bin/lib/intel-command-router.cjs +79 -60
  36. package/gsd-core/bin/lib/io.cjs +1 -0
  37. package/gsd-core/bin/lib/phase.cjs +16 -2
  38. package/gsd-core/bin/lib/plan-scan.cjs +2 -2
  39. package/gsd-core/bin/lib/planning-workspace.cjs +157 -13
  40. package/gsd-core/bin/lib/profile-output.cjs +18 -6
  41. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +53 -16
  42. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +21 -3
  43. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +15 -0
  44. package/gsd-core/bin/lib/runtime-name-policy.cjs +47 -2
  45. package/gsd-core/bin/lib/shell-command-projection.cjs +10 -0
  46. package/gsd-core/bin/lib/state.cjs +398 -60
  47. package/gsd-core/bin/lib/surface.cjs +42 -10
  48. package/gsd-core/bin/lib/uat-predicate.cjs +13 -6
  49. package/gsd-core/bin/lib/update-context.cjs +2 -2
  50. package/gsd-core/bin/lib/verification.cjs +67 -6
  51. package/gsd-core/bin/lib/verify.cjs +8 -1
  52. package/gsd-core/bin/shared/config-defaults.manifest.json +3 -0
  53. package/gsd-core/bin/shared/config-schema.manifest.json +2 -1
  54. package/gsd-core/references/planner-guidance.md +66 -0
  55. package/gsd-core/references/planning-config.md +2 -2
  56. package/gsd-core/references/security-asvs-levels.md +27 -0
  57. package/gsd-core/references/untrusted-input-boundary.md +13 -0
  58. package/gsd-core/templates/SECURITY.md +6 -4
  59. package/gsd-core/templates/summary-complex.md +4 -0
  60. package/gsd-core/templates/summary-minimal.md +3 -0
  61. package/gsd-core/templates/summary-standard.md +4 -0
  62. package/gsd-core/templates/summary.md +41 -0
  63. package/gsd-core/workflows/autonomous.md +53 -46
  64. package/gsd-core/workflows/complete-milestone.md +27 -8
  65. package/gsd-core/workflows/execute-phase.md +1 -1
  66. package/gsd-core/workflows/execute-plan.md +5 -0
  67. package/gsd-core/workflows/manager.md +17 -7
  68. package/gsd-core/workflows/new-project.md +82 -16
  69. package/gsd-core/workflows/plan-phase.md +15 -0
  70. package/gsd-core/workflows/profile-user.md +6 -2
  71. package/gsd-core/workflows/progress.md +37 -4
  72. package/gsd-core/workflows/quick.md +3 -1
  73. package/gsd-core/workflows/secure-phase.md +13 -7
  74. package/gsd-core/workflows/ship.md +3 -1
  75. package/gsd-core/workflows/spec-phase.md +3 -1
  76. package/gsd-core/workflows/transition.md +14 -12
  77. package/gsd-core/workflows/ui-review.md +2 -6
  78. package/gsd-core/workflows/verify-work.md +74 -1
  79. package/hooks/dist/gsd-read-injection-scanner.js +49 -25
  80. package/hooks/gsd-read-injection-scanner.js +49 -25
  81. package/hooks/hooks.json +1 -1
  82. package/package.json +4 -2
  83. package/scripts/check-alias-drift.cjs +5 -0
  84. package/scripts/gen-plugin-skills.cjs +117 -0
  85. package/scripts/lint-test-file-count.allowlist.json +2 -1
  86. package/scripts/prompt-injection-scan.sh +9 -0
  87. package/scripts/release-notes/conventional-title.cjs +88 -0
  88. package/scripts/release-notes/format-github-release-notes.cjs +4 -3
  89. package/skills/gsd-add-tests/SKILL.md +38 -0
  90. package/skills/gsd-ai-integration-phase/SKILL.md +37 -0
  91. package/skills/gsd-audit-fix/SKILL.md +33 -0
  92. package/skills/gsd-audit-milestone/SKILL.md +37 -0
  93. package/skills/gsd-audit-uat/SKILL.md +25 -0
  94. package/skills/gsd-autonomous/SKILL.md +51 -0
  95. package/skills/gsd-capture/SKILL.md +67 -0
  96. package/skills/gsd-cleanup/SKILL.md +24 -0
  97. package/skills/gsd-code-review/SKILL.md +59 -0
  98. package/skills/gsd-complete-milestone/SKILL.md +142 -0
  99. package/skills/gsd-config/SKILL.md +56 -0
  100. package/skills/gsd-debug/SKILL.md +53 -0
  101. package/skills/gsd-discuss-phase/SKILL.md +77 -0
  102. package/skills/gsd-docs-update/SKILL.md +49 -0
  103. package/skills/gsd-eval-review/SKILL.md +33 -0
  104. package/skills/gsd-execute-phase/SKILL.md +65 -0
  105. package/skills/gsd-explore/SKILL.md +28 -0
  106. package/skills/gsd-extract-learnings/SKILL.md +22 -0
  107. package/skills/gsd-fast/SKILL.md +31 -0
  108. package/skills/gsd-forensics/SKILL.md +56 -0
  109. package/skills/gsd-graphify/SKILL.md +204 -0
  110. package/skills/gsd-health/SKILL.md +31 -0
  111. package/skills/gsd-help/SKILL.md +29 -0
  112. package/skills/gsd-import/SKILL.md +46 -0
  113. package/skills/gsd-inbox/SKILL.md +39 -0
  114. package/skills/gsd-ingest-docs/SKILL.md +43 -0
  115. package/skills/gsd-manager/SKILL.md +45 -0
  116. package/skills/gsd-map-codebase/SKILL.md +83 -0
  117. package/skills/gsd-mempalace-capture/SKILL.md +71 -0
  118. package/skills/gsd-mempalace-recall/SKILL.md +102 -0
  119. package/skills/gsd-milestone-summary/SKILL.md +51 -0
  120. package/skills/gsd-mvp-phase/SKILL.md +45 -0
  121. package/skills/gsd-new-milestone/SKILL.md +45 -0
  122. package/skills/gsd-new-project/SKILL.md +47 -0
  123. package/skills/gsd-ns-context/SKILL.md +24 -0
  124. package/skills/gsd-ns-ideate/SKILL.md +23 -0
  125. package/skills/gsd-ns-manage/SKILL.md +35 -0
  126. package/skills/gsd-ns-project/SKILL.md +26 -0
  127. package/skills/gsd-ns-review/SKILL.md +28 -0
  128. package/skills/gsd-ns-workflow/SKILL.md +33 -0
  129. package/skills/gsd-pause-work/SKILL.md +43 -0
  130. package/skills/gsd-phase/SKILL.md +57 -0
  131. package/skills/gsd-plan-phase/SKILL.md +63 -0
  132. package/skills/gsd-plan-review-convergence/SKILL.md +60 -0
  133. package/skills/gsd-pr-branch/SKILL.md +26 -0
  134. package/skills/gsd-profile-user/SKILL.md +47 -0
  135. package/skills/gsd-progress/SKILL.md +49 -0
  136. package/skills/gsd-quick/SKILL.md +174 -0
  137. package/skills/gsd-resume-work/SKILL.md +31 -0
  138. package/skills/gsd-review/SKILL.md +42 -0
  139. package/skills/gsd-review-backlog/SKILL.md +63 -0
  140. package/skills/gsd-secure-phase/SKILL.md +36 -0
  141. package/skills/gsd-settings/SKILL.md +29 -0
  142. package/skills/gsd-ship/SKILL.md +24 -0
  143. package/skills/gsd-sketch/SKILL.md +60 -0
  144. package/skills/gsd-spec-phase/SKILL.md +63 -0
  145. package/skills/gsd-spike/SKILL.md +57 -0
  146. package/skills/gsd-stats/SKILL.md +20 -0
  147. package/skills/gsd-surface/SKILL.md +162 -0
  148. package/skills/gsd-thread/SKILL.md +24 -0
  149. package/skills/gsd-ui-phase/SKILL.md +35 -0
  150. package/skills/gsd-ui-review/SKILL.md +33 -0
  151. package/skills/gsd-ultraplan-phase/SKILL.md +34 -0
  152. package/skills/gsd-undo/SKILL.md +35 -0
  153. package/skills/gsd-update/SKILL.md +50 -0
  154. package/skills/gsd-validate-phase/SKILL.md +36 -0
  155. package/skills/gsd-verify-work/SKILL.md +39 -0
  156. package/skills/gsd-workspace/SKILL.md +53 -0
  157. package/skills/gsd-workstreams/SKILL.md +70 -0
@@ -43,7 +43,14 @@ const io = require("./io.cjs");
43
43
  const coreUtils = require("./core-utils.cjs");
44
44
  // eslint-disable-next-line @typescript-eslint/no-require-imports
45
45
  const path = require("path");
46
+ // Phase 2 (#1646): route through the Hub per ADR-959 §III(B) line 75.
47
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
48
+ const commandRoutingHub = require("./command-routing-hub.cjs");
49
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
50
+ const cjsCommandRouterAdapter = require("./cjs-command-router-adapter.cjs");
46
51
  const { ERROR_REASON } = io;
52
+ const { makeInvalidArgs } = commandRoutingHub;
53
+ const { routeHubCommandFamily } = cjsCommandRouterAdapter;
47
54
  // Default CoreModule implementation assembled from leaf modules.
48
55
  // _core seam overrides this entirely for test injection.
49
56
  const _defaultCore = { output: io.output, timeAgo: coreUtils.timeAgo };
@@ -52,67 +59,79 @@ function routeIntelCommand({ args, cwd, raw, error, _intel, _core }) {
52
59
  // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
53
60
  const intel = _intel ?? require('./intel.cjs');
54
61
  const c = _core ?? _defaultCore;
55
- const subcommand = args[1];
56
- if (subcommand === 'query') {
57
- const term = args[2];
58
- if (!term) {
59
- error('Usage: gsd-tools intel query <term>', ERROR_REASON.USAGE);
60
- return;
61
- }
62
- const planningDir = path.join(cwd, '.planning');
63
- c.output(intel.intelQuery(term, planningDir), raw);
64
- }
65
- else if (subcommand === 'status') {
66
- const planningDir = path.join(cwd, '.planning');
67
- const status = intel.intelStatus(planningDir);
68
- if (!raw && status.files) {
69
- for (const file of Object.values(status.files)) {
70
- if (file.updated_at) {
71
- file.updated_at = c.timeAgo(new Date(file.updated_at));
62
+ // Phase 2 (#1646): routes through the Command Routing Hub per ADR-959 §III(B)
63
+ // line 75. Validation handlers return `makeInvalidArgs(...)` Results; the
64
+ // Hub → adapter translation preserves ERROR_REASON granularity via the
65
+ // exitReason field (Phase 1, #1644). Success handlers keep direct `c.output()`
66
+ // calls. The timeAgo mutation in non-raw `status` is preserved. Lazy require
67
+ // of intel.cjs inside the function is preserved (loads only when dispatched).
68
+ routeHubCommandFamily({
69
+ family: 'intel',
70
+ args,
71
+ // Alphabetical for stable unknownMessage text; the integration test asserts
72
+ // inclusion of all 9 subcommands, not order.
73
+ subcommands: ['api-surface', 'diff', 'extract-exports', 'patch-meta', 'query', 'snapshot', 'status', 'update', 'validate'],
74
+ handlers: {
75
+ query: () => {
76
+ const term = args[2];
77
+ if (!term) {
78
+ return makeInvalidArgs('term', 'Usage: gsd-tools intel query <term>', ERROR_REASON.USAGE);
79
+ }
80
+ const planningDir = path.join(cwd, '.planning');
81
+ c.output(intel.intelQuery(term, planningDir), raw);
82
+ },
83
+ status: () => {
84
+ const planningDir = path.join(cwd, '.planning');
85
+ const status = intel.intelStatus(planningDir);
86
+ if (!raw && status.files) {
87
+ for (const file of Object.values(status.files)) {
88
+ if (file.updated_at) {
89
+ file.updated_at = c.timeAgo(new Date(file.updated_at));
90
+ }
91
+ }
92
+ }
93
+ c.output(status, raw);
94
+ },
95
+ diff: () => {
96
+ const planningDir = path.join(cwd, '.planning');
97
+ c.output(intel.intelDiff(planningDir), raw);
98
+ },
99
+ snapshot: () => {
100
+ const planningDir = path.join(cwd, '.planning');
101
+ c.output(intel.intelSnapshot(planningDir), raw);
102
+ },
103
+ 'patch-meta': () => {
104
+ const filePath = args[2];
105
+ if (!filePath) {
106
+ return makeInvalidArgs('file-path', 'Usage: gsd-tools intel patch-meta <file-path>', ERROR_REASON.USAGE);
107
+ }
108
+ c.output(intel.intelPatchMeta(path.resolve(cwd, filePath)), raw);
109
+ },
110
+ validate: () => {
111
+ const planningDir = path.join(cwd, '.planning');
112
+ c.output(intel.intelValidate(planningDir), raw);
113
+ },
114
+ 'extract-exports': () => {
115
+ const filePath = args[2];
116
+ if (!filePath) {
117
+ return makeInvalidArgs('file-path', 'Usage: gsd-tools intel extract-exports <file-path>', ERROR_REASON.USAGE);
72
118
  }
73
- }
74
- }
75
- c.output(status, raw);
76
- }
77
- else if (subcommand === 'diff') {
78
- const planningDir = path.join(cwd, '.planning');
79
- c.output(intel.intelDiff(planningDir), raw);
80
- }
81
- else if (subcommand === 'snapshot') {
82
- const planningDir = path.join(cwd, '.planning');
83
- c.output(intel.intelSnapshot(planningDir), raw);
84
- }
85
- else if (subcommand === 'patch-meta') {
86
- const filePath = args[2];
87
- if (!filePath) {
88
- error('Usage: gsd-tools intel patch-meta <file-path>', ERROR_REASON.USAGE);
89
- return;
90
- }
91
- c.output(intel.intelPatchMeta(path.resolve(cwd, filePath)), raw);
92
- }
93
- else if (subcommand === 'validate') {
94
- const planningDir = path.join(cwd, '.planning');
95
- c.output(intel.intelValidate(planningDir), raw);
96
- }
97
- else if (subcommand === 'extract-exports') {
98
- const filePath = args[2];
99
- if (!filePath) {
100
- error('Usage: gsd-tools intel extract-exports <file-path>', ERROR_REASON.USAGE);
101
- return;
102
- }
103
- c.output(intel.intelExtractExports(path.resolve(cwd, filePath)), raw);
104
- }
105
- else if (subcommand === 'update') {
106
- const planningDir = path.join(cwd, '.planning');
107
- c.output(intel.intelUpdate(planningDir), raw);
108
- }
109
- else if (subcommand === 'api-surface') {
110
- const planningDir = path.join(cwd, '.planning');
111
- c.output(intel.intelApiSurface(planningDir), raw);
112
- }
113
- else {
114
- error('Unknown intel subcommand. Available: query, status, update, diff, snapshot, patch-meta, validate, extract-exports, api-surface', ERROR_REASON.SDK_UNKNOWN_COMMAND);
115
- }
119
+ c.output(intel.intelExtractExports(path.resolve(cwd, filePath)), raw);
120
+ },
121
+ update: () => {
122
+ const planningDir = path.join(cwd, '.planning');
123
+ c.output(intel.intelUpdate(planningDir), raw);
124
+ },
125
+ 'api-surface': () => {
126
+ const planningDir = path.join(cwd, '.planning');
127
+ c.output(intel.intelApiSurface(planningDir), raw);
128
+ },
129
+ },
130
+ unknownMessage: (subcommand, available) => `Unknown intel subcommand. Available: ${available.join(', ')}`,
131
+ error,
132
+ cwd,
133
+ raw,
134
+ });
116
135
  }
117
136
  module.exports = {
118
137
  routeIntelCommand,
@@ -167,6 +167,7 @@ const ERROR_REASON = Object.freeze({
167
167
  SDK_MISSING_ARG: 'sdk_missing_arg',
168
168
  // workflow / phase
169
169
  PHASE_NOT_FOUND: 'phase_not_found',
170
+ PHASE_VERIFICATION_INCOMPLETE: 'phase_verification_incomplete',
170
171
  SUMMARY_NO_PLANNING: 'summary_no_planning',
171
172
  // graphify
172
173
  GRAPHIFY_NO_GRAPH: 'graphify_no_graph',
@@ -52,6 +52,9 @@ const clock_cjs_1 = require("./clock.cjs");
52
52
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- uat-predicate.cjs is an export= CommonJS module
53
53
  const uatPredicate = require("./uat-predicate.cjs");
54
54
  const { evaluateUatPassed } = uatPredicate;
55
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
56
+ const verificationMod = require("./verification.cjs");
57
+ const { readVerificationStatus } = verificationMod;
55
58
  const { planningDir, withPlanningLock } = planningWorkspace;
56
59
  const { extractFrontmatter } = frontmatterMod;
57
60
  const { readModifyWriteStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, syncStateFrontmatter, withStateLock, updatePerformanceMetricsSection, } = stateMod;
@@ -1077,8 +1080,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1077
1080
  : 0;
1078
1081
  let requirementsUpdated = false;
1079
1082
  const warnings = [];
1083
+ const phaseFullDir = node_path_1.default.join(cwd, phaseInfo['directory']);
1080
1084
  try {
1081
- const phaseFullDir = node_path_1.default.join(cwd, phaseInfo['directory']);
1082
1085
  const phaseFiles = node_fs_1.default.readdirSync(phaseFullDir);
1083
1086
  for (const file of phaseFiles.filter((f) => f.includes('-UAT') && f.endsWith('.md'))) {
1084
1087
  const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseFullDir, file), 'utf-8');
@@ -1113,7 +1116,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1113
1116
  let nextPhaseNum = null;
1114
1117
  let nextPhaseName = null;
1115
1118
  let isLastPhase = true;
1116
- withPlanningLock(cwd, () => {
1119
+ const verificationBlocked = withPlanningLock(cwd, () => {
1120
+ const verificationStatus = readVerificationStatus(phaseFullDir);
1121
+ if (verificationStatus.status !== 'passed') {
1122
+ return verificationStatus;
1123
+ }
1117
1124
  const runPhaseCompleteTransaction = () => {
1118
1125
  const writes = [];
1119
1126
  let roadmapContent = null;
@@ -1381,7 +1388,14 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1381
1388
  else {
1382
1389
  runPhaseCompleteTransaction();
1383
1390
  }
1391
+ return null;
1384
1392
  });
1393
+ if (verificationBlocked) {
1394
+ const nextStep = verificationBlocked.next_command
1395
+ ? ` Next: ${verificationBlocked.next_command}`
1396
+ : '';
1397
+ error(`Phase ${phaseNum} verification is incomplete: ${verificationBlocked.next_action}${nextStep}`, ERROR_REASON.PHASE_VERIFICATION_INCOMPLETE);
1398
+ }
1385
1399
  let autoPruned = false;
1386
1400
  try {
1387
1401
  const configPath = node_path_1.default.join(planningDir(cwd), 'config.json');
@@ -63,8 +63,8 @@ function scanPhasePlans(phaseDir) {
63
63
  if ((0, node_fs_1.existsSync)(nestedDir)) {
64
64
  try {
65
65
  const nestedFiles = (0, node_fs_1.readdirSync)(nestedDir);
66
- nestedPlanFiles = nestedFiles.filter(isNestedPlanFile);
67
- nestedSummaryFiles = nestedFiles.filter(isNestedSummaryFile);
66
+ nestedPlanFiles = nestedFiles.filter(isNestedPlanFile).map((file) => `plans/${file}`);
67
+ nestedSummaryFiles = nestedFiles.filter(isNestedSummaryFile).map((file) => `plans/${file}`);
68
68
  hasNestedPlans = nestedPlanFiles.length > 0;
69
69
  }
70
70
  catch { /* ignore unreadable nested layout */ }
@@ -33,6 +33,54 @@ process.on('exit', () => {
33
33
  catch { /* already gone */ }
34
34
  }
35
35
  });
36
+ // ---------------------------------------------------------------------------
37
+ // Lock liveness probe (test seam) — audit M1
38
+ //
39
+ // mtime is a leaky proxy for "the holder is alive". The prior withPlanningLock
40
+ // timeout fallback unconditionally unlinked WHATEVER lock existed — even a fresh,
41
+ // live holder's — and re-acquired it, force-stealing a live writer's critical
42
+ // section. We backport capability-lock.cts's pid-liveness gate: a dead holder is
43
+ // stolen promptly inside the polite loop; a live holder is waited on. The
44
+ // indirection lets unit tests inject a deterministic isPidAlive without real pids.
45
+ // ---------------------------------------------------------------------------
46
+ /** Is `pid` a live process? process.kill(pid, 0) succeeds for a live (signalable) process. */
47
+ function _realIsPidAlive(pid) {
48
+ try {
49
+ process.kill(pid, 0);
50
+ return true; // signalable → alive
51
+ }
52
+ catch (err) {
53
+ // EPERM = process exists but we cannot signal it (still ALIVE). ESRCH = gone.
54
+ return err.code === 'EPERM';
55
+ }
56
+ }
57
+ const _planningLockProbes = { isPidAlive: _realIsPidAlive };
58
+ function _planningLockIsPidAlive(pid) {
59
+ return _planningLockProbes.isPidAlive(pid);
60
+ }
61
+ const _planningLockTestHooks = {};
62
+ // Monotonic sequence for unique stale-steal rename targets (no crypto dependency).
63
+ let _planningStealSeq = 0;
64
+ /**
65
+ * Is the holder recorded in the .lock body VERIFIED-LIVE? The body is JSON
66
+ * { pid, cwd, acquired }. Returns true ONLY when the body parses AND the recorded
67
+ * pid signals alive. A garbage / pid-less / unreadable body (or a dead pid) is NOT
68
+ * verified-live, so the lock stays stealable — corrupt locks never block forever,
69
+ * and a live holder is never force-stolen.
70
+ */
71
+ function _planningHolderVerifiedLive(lockPath) {
72
+ let parsed;
73
+ try {
74
+ parsed = JSON.parse(node_fs_1.default.readFileSync(lockPath, 'utf-8'));
75
+ }
76
+ catch {
77
+ return false; // unreadable / unparseable body → cannot verify → not verified-live
78
+ }
79
+ const pid = parsed?.pid;
80
+ if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0)
81
+ return false;
82
+ return _planningLockIsPidAlive(pid);
83
+ }
36
84
  // Transient errno codes that indicate a temporary filesystem condition under
37
85
  // concurrent O_EXCL races — Docker overlay-fs (ENOENT/EINVAL/EIO), NFS
38
86
  // (ESTALE), and OS-level interrupt/retry signals (EAGAIN/EINTR). These are
@@ -97,6 +145,12 @@ function withPlanningLock(cwd, fn, clock) {
97
145
  clock = clock_cjs_1.realClock;
98
146
  const lockPath = node_path_1.default.join(planningDir(cwd), '.lock');
99
147
  const lockTimeout = 10000; // 10 seconds
148
+ // Deadman ceiling (audit M1 / R4-FIX) — set ABOVE lockTimeout so a holder that reads
149
+ // as alive but is actually a pid-reuse alias (the .lock body has no startTime, so
150
+ // liveness alone cannot detect reuse) is still recovered once its lock ages past this
151
+ // absolute ceiling. Without it, a false-alive holder would make withPlanningLock throw
152
+ // on every call with no self-heal. Mirrors acquireStateLock's deadmanCeilingMs.
153
+ const deadmanCeilingMs = 60000;
100
154
  const start = clock.now();
101
155
  // Ensure .planning/ exists
102
156
  try {
@@ -143,31 +197,104 @@ function withPlanningLock(cwd, fn, clock) {
143
197
  continue;
144
198
  }
145
199
  if (nodeErr.code === 'EEXIST') {
146
- // Lock exists — check if stale (>30s old)
200
+ // Liveness-gated steal (audit M1). Steal the lock PROMPTLY only when its
201
+ // recorded holder is NOT verified-live (crashed/dead pid or garbage body).
202
+ // A verified-live holder is waited on — never force-stolen — because nuking
203
+ // a slow-but-live writer's lock corrupts the .planning/ critical section.
204
+ // The steal is an ATOMIC rename-then-recreate guarded by an identity re-confirm
205
+ // so a racer that recreates a fresh lock in the decision→steal gap never has
206
+ // its replacement deleted (audit M2 / PR #1532 review, window b). The body is
207
+ // written atomically (writeFileSync …{flag:'wx'}) so there is no empty-body
208
+ // create window here — only the double-steal needs hardening.
147
209
  try {
148
- const stat = node_fs_1.default.statSync(lockPath);
149
- if (clock.now() - stat.mtimeMs > 30000) {
150
- node_fs_1.default.unlinkSync(lockPath);
151
- continue; // retry
210
+ const decisionStat = node_fs_1.default.statSync(lockPath);
211
+ // Snapshot the decision-time body too: (dev, ino) alone is defeated by inode
212
+ // REUSE (a racer's unlink+recreate can land on the same inode), so the body
213
+ // content binds the identity as well — mirrors capability-lock.cts's (dev,
214
+ // ino, ts) re-confirm.
215
+ let decisionBody;
216
+ try {
217
+ decisionBody = node_fs_1.default.readFileSync(lockPath, 'utf-8');
218
+ }
219
+ catch {
220
+ decisionBody = null;
221
+ }
222
+ let stealable = !_planningHolderVerifiedLive(lockPath);
223
+ if (!stealable) {
224
+ // Verified-live, but recover anyway once the lock crosses the absolute
225
+ // deadman ceiling — defeats a pid-reuse false-alive that would otherwise
226
+ // block forever (R4-FIX; mtime age is from lock creation, not this call).
227
+ const age = clock.now() - decisionStat.mtimeMs;
228
+ stealable = age > deadmanCeilingMs;
229
+ }
230
+ if (stealable) {
231
+ if (_planningLockTestHooks.beforeSteal)
232
+ _planningLockTestHooks.beforeSteal({ lockPath });
233
+ // Identity re-confirm immediately before the steal: a racer that stole +
234
+ // recreated a fresh lock in the decision→steal gap changes (dev, ino) → do
235
+ // NOT delete the replacement; back off and re-evaluate.
236
+ let confirmStat;
237
+ try {
238
+ confirmStat = node_fs_1.default.statSync(lockPath);
239
+ }
240
+ catch {
241
+ continue; // vanished between decision and steal — retry the create.
242
+ }
243
+ let confirmBody;
244
+ try {
245
+ confirmBody = node_fs_1.default.readFileSync(lockPath, 'utf-8');
246
+ }
247
+ catch {
248
+ confirmBody = null;
249
+ }
250
+ const sameInstance = typeof decisionStat.dev === 'number' && typeof decisionStat.ino === 'number' &&
251
+ confirmStat.dev === decisionStat.dev && confirmStat.ino === decisionStat.ino &&
252
+ decisionBody !== null && confirmBody === decisionBody;
253
+ if (!sameInstance) {
254
+ clock.sleep(100); // a racer won the steal + recreated — re-evaluate, don't delete it.
255
+ continue;
256
+ }
257
+ // Atomic steal: rename the inode aside, then remove it. Only ONE racer can
258
+ // win the rename; a failed rename means another process already stole it, so
259
+ // we must NOT fall through to a delete — back off and retry the create.
260
+ const stolen = lockPath + '.stale-' + process.pid + '-' + clock.now() + '-' + (_planningStealSeq++);
261
+ let renamed = false;
262
+ try {
263
+ node_fs_1.default.renameSync(lockPath, stolen);
264
+ renamed = true;
265
+ }
266
+ catch { /* another racer won */ }
267
+ if (renamed) {
268
+ try {
269
+ node_fs_1.default.rmSync(stolen, { force: true });
270
+ }
271
+ catch { /* best-effort */ }
272
+ continue; // dead/garbage/expired holder freed — retry immediately to grab it.
273
+ }
274
+ clock.sleep(100); // lost the steal race — back off and retry.
275
+ continue;
152
276
  }
153
277
  }
154
278
  catch {
155
279
  continue;
156
280
  }
157
- // Wait and retry (cross-platform, no shell dependency)
281
+ // Live holder — wait and retry (cross-platform, no shell dependency).
158
282
  clock.sleep(100);
159
283
  continue;
160
284
  }
161
285
  throw err;
162
286
  }
163
287
  }
164
- // Timeout — stale-lock recovery, then re-acquire atomically before entering critical section.
165
- try {
166
- node_fs_1.default.unlinkSync(lockPath);
167
- }
168
- catch { /* ok */ }
169
- acquireLock();
170
- return runWithHeldLock();
288
+ // Timeout against a holder still present at budget exhaustion. The polite loop
289
+ // already stole any DEAD holder; reaching here means the holder is verified-live
290
+ // (or a pid-reuse alias we must not corrupt). Do NOT force-steal — the prior
291
+ // unconditional `unlinkSync(lockPath); acquireLock()` here (audit M1) robbed live
292
+ // writers, and its re-acquire sat OUTSIDE any try so a concurrent re-create raced
293
+ // a raw EEXIST out of the helper (audit M2). Surface a clear timeout error instead.
294
+ const timeoutErr = new Error('withPlanningLock: ' + lockPath + ' held by a live process for ' +
295
+ (clock.now() - start) + 'ms (exceeded ' + lockTimeout + 'ms budget)');
296
+ timeoutErr.lockTimeout = true;
297
+ throw timeoutErr;
171
298
  }
172
299
  function createPlanningWorkspace(cwd, opts = {}) {
173
300
  return {
@@ -242,4 +369,21 @@ module.exports = {
242
369
  getActiveWorkstream,
243
370
  setActiveWorkstream,
244
371
  findContextMdIn,
372
+ // Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated
373
+ // steal decision is exercised without real pids. Mirrors capability-lock.cts.
374
+ _setLockProbes(probes) {
375
+ if (typeof probes.isPidAlive === 'function')
376
+ _planningLockProbes.isPidAlive = probes.isPidAlive;
377
+ },
378
+ _resetLockProbes() {
379
+ _planningLockProbes.isPidAlive = _realIsPidAlive;
380
+ },
381
+ // Test seam (PR #1532 review): script the steal decision→steal gap (window b).
382
+ _setPlanningLockTestHooks(hooks) {
383
+ if ('beforeSteal' in hooks)
384
+ _planningLockTestHooks.beforeSteal = hooks.beforeSteal;
385
+ },
386
+ _resetPlanningLockTestHooks() {
387
+ delete _planningLockTestHooks.beforeSteal;
388
+ },
245
389
  };
@@ -1010,19 +1010,31 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
1010
1010
  // repo-root `CLAUDE.md`, so generated GSD content does not land next to — or
1011
1011
  // pollute — a hand-crafted repo-root CLAUDE.md. An explicit `claude_md_path`
1012
1012
  // config value or `--output` still wins.
1013
- let configClaudeMdPath = './.claude/CLAUDE.md';
1013
+ let configClaudeMdPath = '.claude/CLAUDE.md';
1014
1014
  try {
1015
1015
  const config = loadConfig(cwd);
1016
1016
  if (config['claude_md_path'])
1017
1017
  configClaudeMdPath = config['claude_md_path'];
1018
1018
  if (config['claude_md_assembly'])
1019
1019
  assemblyConfig = config['claude_md_assembly'];
1020
- // #3163: When runtime is codex, override the output target to AGENTS.md
1021
- // regardless of claude_md_path, so Codex projects never write to CLAUDE.md.
1022
- // GSD_RUNTIME env var takes precedence over config.runtime, mirroring detectRuntime().
1020
+ // #1529: When no explicit --output is provided, derive the instruction
1021
+ // file from the runtime via the shared `getProjectInstructionFile` policy
1022
+ // (single source of truth in runtime-name-policy.cjs, shared with the
1023
+ // new-project.md bash workflow via `gsd-tools query
1024
+ // project-instruction-file`). Previously this was a codex-only override
1025
+ // (#3163) that left AGENTS-native runtimes (opencode/kilo/kimi) emitting
1026
+ // CLAUDE.md; copilot now resolves to .github/copilot-instructions.md, and
1027
+ // antigravity/gemini to GEMINI.md. GSD_RUNTIME env var takes precedence
1028
+ // over config.runtime, mirroring detectRuntime().
1029
+ //
1030
+ // Non-claude runtimes always win over a stale `claude_md_path` (the #3163
1031
+ // rationale: a Codex/AGENTS-native project must never write to CLAUDE.md
1032
+ // even if a prior Claude setup left a `claude_md_path` behind). For the
1033
+ // claude runtime, `claude_md_path` config is honored — it IS the
1034
+ // Claude-specific output setting (per #1098 and the #3163 non-codex test).
1023
1035
  const effectiveRuntime = (0, runtime_name_policy_cjs_1.resolveRuntimeNameFromCandidates)(process.env['GSD_RUNTIME'], config['runtime']);
1024
- if (!options.output && effectiveRuntime === 'codex') {
1025
- configClaudeMdPath = './AGENTS.md';
1036
+ if (!options.output && effectiveRuntime && effectiveRuntime !== 'claude') {
1037
+ configClaudeMdPath = (0, runtime_name_policy_cjs_1.getProjectInstructionFile)(effectiveRuntime);
1026
1038
  }
1027
1039
  }
1028
1040
  catch { /* use default */ }
@@ -855,20 +855,18 @@ function convertClaudeToWindsurfMarkdown(content) {
855
855
  // Replace subagent_type from Claude to Windsurf format
856
856
  converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"');
857
857
  converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
858
- // Replace project-level Claude conventions with Windsurf/Devin equivalents
859
- // Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085).
860
- // Legacy .windsurf/ is still recognized on read but new installs use .devin/.
861
- converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.devin/rules`');
862
- converted = converted.replace(/\.\/CLAUDE\.md/g, '.devin/rules');
863
- converted = converted.replace(/`CLAUDE\.md`/g, '`.devin/rules`');
864
- converted = converted.replace(/\bCLAUDE\.md\b/g, '.devin/rules');
865
- converted = converted.replace(/\.claude\/skills\//g, '.devin/skills/');
866
- converted = converted.replace(/\.\/\.claude\//g, './.devin/');
867
- converted = converted.replace(/\.claude\//g, '.devin/');
858
+ // Replace project-level Claude conventions with Windsurf equivalents.
859
+ converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.windsurf/rules`');
860
+ converted = converted.replace(/\.\/CLAUDE\.md/g, '.windsurf/rules');
861
+ converted = converted.replace(/`CLAUDE\.md`/g, '`.windsurf/rules`');
862
+ converted = converted.replace(/\bCLAUDE\.md\b/g, '.windsurf/rules');
863
+ converted = converted.replace(/\.claude\/skills\//g, '.windsurf/skills/');
864
+ converted = converted.replace(/\.\/\.claude\//g, './.windsurf/');
865
+ converted = converted.replace(/\.claude\//g, '.windsurf/');
868
866
  // Bare forms (no trailing slash) — after slash forms to avoid double-rewrite.
869
867
  // Use negative lookahead (?![\w-]) to preserve .claude-plugin and .claudeignore.
870
- converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.devin');
871
- converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.devin');
868
+ converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.windsurf');
869
+ converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.windsurf');
872
870
  // Environment variable name rewrite
873
871
  converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'WINDSURF_CONFIG_DIR');
874
872
  // Remove Claude Code-specific bug workarounds before brand replacement
@@ -918,6 +916,30 @@ function convertClaudeCommandToWindsurfSkill(content, skillName) {
918
916
  const adapter = getWindsurfSkillAdapterHeader(skillName);
919
917
  return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
920
918
  }
919
+ function convertClaudeCommandToWindsurfWorkflow(content, commandName) {
920
+ // #1615 security: commandName flows unsanitized into a markdown body that
921
+ // Windsurf loads as an LLM-readable workflow. Validate at entry to prevent
922
+ // (a) prompt injection via newlines / markdown structure in the filename,
923
+ // (b) path-component injection via .., /, \ in stem → @-reference target.
924
+ // Pattern: optional gsd- prefix + lowercase alphanumeric + dashes; rejects
925
+ // everything else. See DEFECT.PROMPT-INJECTION-SCAN-COLLISION and the
926
+ // PR #1622 security review.
927
+ if (typeof commandName !== 'string' || !/^(?:gsd-)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(commandName)) {
928
+ const preview = typeof commandName === 'string' ? JSON.stringify(commandName.slice(0, 60)) : String(commandName);
929
+ throw new Error(`convertClaudeCommandToWindsurfWorkflow: rejected commandName ${preview}; ` +
930
+ 'must match /^(?:gsd-)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ (no slashes, backslashes, spaces, dots, trailing dash, or control chars — prevents prompt injection and path-component injection into the workflow body)');
931
+ }
932
+ const converted = convertClaudeToWindsurfMarkdown(content);
933
+ const { frontmatter } = extractFrontmatterAndBody(converted);
934
+ const description = frontmatter ? extractFrontmatterField(frontmatter, 'description') : '';
935
+ const stem = commandName.startsWith('gsd-') ? commandName.slice(4) : commandName;
936
+ const workflow = `# ${commandName}\n\n${toSingleLine(description || `Run ${commandName}.`)}\n\nRead and execute the GSD command at @~/.claude/gsd-core/commands/gsd/${stem}.md end-to-end. Treat the user's message after /${commandName} as the command arguments.`;
937
+ const byteLength = Buffer.byteLength(workflow, 'utf8');
938
+ if (byteLength > 12000) {
939
+ throw new Error(`Windsurf workflow ${commandName} exceeds 12000 bytes (${byteLength}); extract references before installing`);
940
+ }
941
+ return workflow;
942
+ }
921
943
  // --- Augment converters ---
922
944
  // Augment uses a tool set similar to Cursor/Windsurf.
923
945
  // Config lives in .augment/ (local) and ~/.augment/ (global).
@@ -1706,10 +1728,16 @@ function convertGeminiToolName(claudeTool) {
1706
1728
  // Task/Agent: exclude — agents are auto-registered as callable tools.
1707
1729
  // AskUserQuestion: exclude — Gemini CLI does not expose an ask_user tool;
1708
1730
  // emitting it causes frontmatter validation errors (#3362).
1731
+ // Skill/SlashCommand: exclude — Gemini CLI has no 'skill' built-in tool;
1732
+ // the lowercase fallback would emit an invalid 'skill'/'slashcommand' name
1733
+ // that fails frontmatter validation (tools.N: Invalid tool name) and aborts
1734
+ // the entire agent load (#1394).
1709
1735
  if (claudeTool === 'Task' ||
1710
1736
  claudeTool === 'Agent' ||
1711
1737
  claudeTool === 'AskUserQuestion' ||
1712
- claudeTool === 'ask_user') {
1738
+ claudeTool === 'ask_user' ||
1739
+ claudeTool === 'Skill' ||
1740
+ claudeTool === 'SlashCommand') {
1713
1741
  return null;
1714
1742
  }
1715
1743
  // Check for explicit mapping
@@ -1954,10 +1982,18 @@ function convertClaudeCommandToKiloSkill(content, skillName) {
1954
1982
  * @private — exported as `_computePathPrefix` for tests.
1955
1983
  */
1956
1984
  function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost, resolvedTarget, homeDir }) {
1957
- if (isGlobal && resolvedTarget.startsWith(homeDir) && !isOpencode) {
1958
- return '$HOME' + resolvedTarget.slice(homeDir.length) + '/';
1985
+ // #1615: normalize Windows backslashes to forward slashes. This prefix is
1986
+ // substituted into markdown @-references (e.g. Windsurf workflow files),
1987
+ // which use POSIX paths universally. Idempotent on POSIX (no backslashes).
1988
+ // Without this, path.join on Windows produces a backslash prefix that
1989
+ // leaks into markdown content and breaks cross-platform substring checks.
1990
+ // See DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT in CONTEXT.md.
1991
+ const posixTarget = String(resolvedTarget).replace(/\\/g, '/');
1992
+ const posixHome = homeDir ? String(homeDir).replace(/\\/g, '/') : homeDir;
1993
+ if (isGlobal && posixTarget.startsWith(posixHome) && !isOpencode) {
1994
+ return '$HOME' + posixTarget.slice(posixHome.length) + '/';
1959
1995
  }
1960
- return `${resolvedTarget}/`;
1996
+ return `${posixTarget}/`;
1961
1997
  }
1962
1998
  /**
1963
1999
  * Canonical list of every non-Claude runtime that gsd-core emits artifacts for.
@@ -2319,6 +2355,7 @@ module.exports = {
2319
2355
  convertClaudeCommandToCursorCommand,
2320
2356
  convertClaudeToWindsurfMarkdown,
2321
2357
  convertClaudeCommandToWindsurfSkill,
2358
+ convertClaudeCommandToWindsurfWorkflow,
2322
2359
  convertClaudeToAugmentMarkdown,
2323
2360
  convertClaudeCommandToAugmentSkill,
2324
2361
  convertClaudeToTraeMarkdown,
@@ -130,6 +130,20 @@ function agentsKind(destSubpath, prefix, configDir) {
130
130
  * Agent filenames are preserved verbatim (the prefix is already embedded in the
131
131
  * agent stem — e.g. `gsd-planner.md`).
132
132
  *
133
+ * #1173 SCOPE — plumbing only (declarations deferred): this provides the
134
+ * converter dispatch + `isGlobal` scope threading for the descriptor's `agents`
135
+ * kind, but NO runtime currently declares a converted `agents` kind in its
136
+ * `capability.json`. The descriptor declarations for the 8 non-Claude runtimes
137
+ * (copilot/antigravity/cursor/windsurf/augment/trae/codebuddy/cline) are
138
+ * DEFERRED to a follow-up that first ships the ADR-1235 §0 byte-for-byte parity
139
+ * harness, because the second `layout.kinds` consumer — `applySurface` /
140
+ * `/gsd:surface` / `--materialize` (`src/surface.cts`) — does not yet mirror the
141
+ * legacy agent pipeline (Copilot's `.agent.md` filename rename, the cross-cutting
142
+ * path-prefix rewrite + attribution, stale-file cleanup, config-reading steps),
143
+ * so declaring the kind now would regress the surface path. Until then the legacy
144
+ * `bin/install.js` agent loop remains authoritative for the real install, and
145
+ * this `convertedAgentsKind` is exercised only by synthetic-descriptor seam tests.
146
+ *
133
147
  * Mirrors the `convertedCommandsKind` pattern (#785).
134
148
  *
135
149
  * @param destSubpath destination subpath within configDir (e.g. 'agents')
@@ -137,14 +151,18 @@ function agentsKind(destSubpath, prefix, configDir) {
137
151
  * @param converterName name of converter function in Runtime Artifact Conversion exports
138
152
  * @param configDir runtime config dir (for .gsd-source marker resolution)
139
153
  */
140
- function convertedAgentsKind(destSubpath, prefix, converterName, configDir) {
154
+ function convertedAgentsKind(destSubpath, prefix, converterName, configDir, scope = 'global') {
141
155
  return {
142
156
  kind: 'agents',
143
157
  destSubpath,
144
158
  prefix,
145
159
  stage: (resolved) => {
160
+ // isGlobal is threaded so scope-aware agent converters (copilot, antigravity)
161
+ // choose global-home vs workspace-relative paths; converters that only take
162
+ // (content) ignore the extra positional arg. Mirrors skillsKind's scope
163
+ // threading (#1173).
146
164
  const converter = conversionExports[converterName];
147
- return stageAgentsForRuntimeWithConverter(findAgentsSourceRoot(configDir), resolved, converter);
165
+ return stageAgentsForRuntimeWithConverter(findAgentsSourceRoot(configDir), resolved, converter, scope === 'global');
148
166
  },
149
167
  };
150
168
  }
@@ -267,7 +285,7 @@ function dispatchKindEntry(entry, runtime, configDir, scope) {
267
285
  if (converter == null) {
268
286
  return agentsKind(destSubpath, prefix, configDir);
269
287
  }
270
- return convertedAgentsKind(destSubpath, prefix, converter, configDir);
288
+ return convertedAgentsKind(destSubpath, prefix, converter, configDir, scope);
271
289
  case 'skills':
272
290
  if (converter == null) {
273
291
  throw new TypeError(`resolveRuntimeArtifactLayout: skills entry for '${runtime}' has converter=null (converter is required for skills)`);