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

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 (151) hide show
  1. package/.claude-plugin/plugin.json +2 -1
  2. package/agents/gsd-planner.md +8 -57
  3. package/agents/gsd-roadmapper.md +6 -0
  4. package/agents/gsd-security-auditor.md +37 -18
  5. package/bin/install.js +243 -345
  6. package/commands/gsd/capture.md +5 -1
  7. package/gemini-extension.json +1 -1
  8. package/gsd-core/bin/gsd-tools.cjs +53 -6
  9. package/gsd-core/bin/lib/adr-parser.cjs +21 -6
  10. package/gsd-core/bin/lib/audit-command-router.cjs +52 -14
  11. package/gsd-core/bin/lib/capability-lifecycle.cjs +30 -7
  12. package/gsd-core/bin/lib/capability-registry.cjs +96 -83
  13. package/gsd-core/bin/lib/capability-validator.cjs +22 -0
  14. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +40 -2
  15. package/gsd-core/bin/lib/command-routing-hub.cjs +10 -3
  16. package/gsd-core/bin/lib/commands.cjs +247 -0
  17. package/gsd-core/bin/lib/config-loader.cjs +6 -0
  18. package/gsd-core/bin/lib/config-schema.cjs +1 -0
  19. package/gsd-core/bin/lib/config.cjs +70 -21
  20. package/gsd-core/bin/lib/coverage.cjs +464 -0
  21. package/gsd-core/bin/lib/frontmatter.cjs +7 -3
  22. package/gsd-core/bin/lib/graphify-command-router.cjs +53 -36
  23. package/gsd-core/bin/lib/init.cjs +8 -4
  24. package/gsd-core/bin/lib/install-profiles.cjs +6 -3
  25. package/gsd-core/bin/lib/intel-command-router.cjs +79 -60
  26. package/gsd-core/bin/lib/phase-id.cjs +25 -11
  27. package/gsd-core/bin/lib/phase.cjs +4 -4
  28. package/gsd-core/bin/lib/planning-workspace.cjs +157 -13
  29. package/gsd-core/bin/lib/probe-core.cjs +7 -0
  30. package/gsd-core/bin/lib/profile-output.cjs +18 -6
  31. package/gsd-core/bin/lib/prohibition-enforcement.cjs +59 -26
  32. package/gsd-core/bin/lib/roadmap-command-router.cjs +16 -3
  33. package/gsd-core/bin/lib/roadmap-parser.cjs +29 -8
  34. package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -17
  35. package/gsd-core/bin/lib/roadmap.cjs +5 -2
  36. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +473 -16
  37. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +77 -0
  38. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +22 -31
  39. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +15 -0
  40. package/gsd-core/bin/lib/runtime-name-policy.cjs +89 -0
  41. package/gsd-core/bin/lib/shell-command-projection.cjs +55 -1
  42. package/gsd-core/bin/lib/state.cjs +364 -52
  43. package/gsd-core/bin/lib/surface.cjs +47 -22
  44. package/gsd-core/bin/lib/update-context.cjs +2 -2
  45. package/gsd-core/bin/lib/validate.cjs +5 -2
  46. package/gsd-core/bin/lib/verify.cjs +11 -2
  47. package/gsd-core/bin/lib/worktree-safety.cjs +202 -0
  48. package/gsd-core/bin/shared/config-defaults.manifest.json +2 -1
  49. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  50. package/gsd-core/references/context-budget.md +8 -8
  51. package/gsd-core/references/execute-phase-context-guard.md +16 -0
  52. package/gsd-core/references/planner-guidance.md +66 -0
  53. package/gsd-core/references/planning-config.md +3 -2
  54. package/gsd-core/references/prohibition-probe.md +15 -9
  55. package/gsd-core/references/security-asvs-levels.md +27 -0
  56. package/gsd-core/templates/SECURITY.md +6 -4
  57. package/gsd-core/templates/summary-complex.md +4 -0
  58. package/gsd-core/templates/summary-minimal.md +3 -0
  59. package/gsd-core/templates/summary-standard.md +4 -0
  60. package/gsd-core/templates/summary.md +41 -0
  61. package/gsd-core/workflows/autonomous.md +33 -33
  62. package/gsd-core/workflows/diagnose-issues.md +6 -1
  63. package/gsd-core/workflows/execute-phase.md +8 -6
  64. package/gsd-core/workflows/execute-plan.md +5 -0
  65. package/gsd-core/workflows/help/modes/full.md +10 -0
  66. package/gsd-core/workflows/list-seeds.md +63 -0
  67. package/gsd-core/workflows/manager.md +37 -37
  68. package/gsd-core/workflows/new-project.md +4 -4
  69. package/gsd-core/workflows/plan-phase.md +15 -0
  70. package/gsd-core/workflows/pr-branch.md +156 -0
  71. package/gsd-core/workflows/quick.md +6 -1
  72. package/gsd-core/workflows/review.md +10 -2
  73. package/gsd-core/workflows/secure-phase.md +13 -7
  74. package/gsd-core/workflows/spec-phase.md +8 -3
  75. package/gsd-core/workflows/verify-phase.md +2 -2
  76. package/gsd-core/workflows/verify-work.md +30 -1
  77. package/package.json +7 -2
  78. package/scripts/gen-plugin-skills.cjs +117 -0
  79. package/scripts/lint-test-file-count.allowlist.json +2 -1
  80. package/scripts/prompt-injection-scan.sh +5 -0
  81. package/scripts/release-notes/conventional-title.cjs +88 -0
  82. package/scripts/release-notes/format-github-release-notes.cjs +4 -3
  83. package/skills/gsd-add-tests/SKILL.md +38 -0
  84. package/skills/gsd-ai-integration-phase/SKILL.md +37 -0
  85. package/skills/gsd-audit-fix/SKILL.md +33 -0
  86. package/skills/gsd-audit-milestone/SKILL.md +37 -0
  87. package/skills/gsd-audit-uat/SKILL.md +25 -0
  88. package/skills/gsd-autonomous/SKILL.md +51 -0
  89. package/skills/gsd-capture/SKILL.md +67 -0
  90. package/skills/gsd-cleanup/SKILL.md +24 -0
  91. package/skills/gsd-code-review/SKILL.md +59 -0
  92. package/skills/gsd-complete-milestone/SKILL.md +142 -0
  93. package/skills/gsd-config/SKILL.md +56 -0
  94. package/skills/gsd-debug/SKILL.md +53 -0
  95. package/skills/gsd-discuss-phase/SKILL.md +77 -0
  96. package/skills/gsd-docs-update/SKILL.md +49 -0
  97. package/skills/gsd-eval-review/SKILL.md +33 -0
  98. package/skills/gsd-execute-phase/SKILL.md +65 -0
  99. package/skills/gsd-explore/SKILL.md +28 -0
  100. package/skills/gsd-extract-learnings/SKILL.md +22 -0
  101. package/skills/gsd-fast/SKILL.md +31 -0
  102. package/skills/gsd-forensics/SKILL.md +56 -0
  103. package/skills/gsd-graphify/SKILL.md +204 -0
  104. package/skills/gsd-health/SKILL.md +31 -0
  105. package/skills/gsd-help/SKILL.md +29 -0
  106. package/skills/gsd-import/SKILL.md +46 -0
  107. package/skills/gsd-inbox/SKILL.md +39 -0
  108. package/skills/gsd-ingest-docs/SKILL.md +43 -0
  109. package/skills/gsd-manager/SKILL.md +45 -0
  110. package/skills/gsd-map-codebase/SKILL.md +83 -0
  111. package/skills/gsd-mempalace-capture/SKILL.md +71 -0
  112. package/skills/gsd-mempalace-recall/SKILL.md +102 -0
  113. package/skills/gsd-milestone-summary/SKILL.md +51 -0
  114. package/skills/gsd-mvp-phase/SKILL.md +45 -0
  115. package/skills/gsd-new-milestone/SKILL.md +45 -0
  116. package/skills/gsd-new-project/SKILL.md +47 -0
  117. package/skills/gsd-ns-context/SKILL.md +24 -0
  118. package/skills/gsd-ns-ideate/SKILL.md +23 -0
  119. package/skills/gsd-ns-manage/SKILL.md +35 -0
  120. package/skills/gsd-ns-project/SKILL.md +26 -0
  121. package/skills/gsd-ns-review/SKILL.md +28 -0
  122. package/skills/gsd-ns-workflow/SKILL.md +33 -0
  123. package/skills/gsd-pause-work/SKILL.md +43 -0
  124. package/skills/gsd-phase/SKILL.md +57 -0
  125. package/skills/gsd-plan-phase/SKILL.md +63 -0
  126. package/skills/gsd-plan-review-convergence/SKILL.md +60 -0
  127. package/skills/gsd-pr-branch/SKILL.md +26 -0
  128. package/skills/gsd-profile-user/SKILL.md +47 -0
  129. package/skills/gsd-progress/SKILL.md +49 -0
  130. package/skills/gsd-quick/SKILL.md +174 -0
  131. package/skills/gsd-resume-work/SKILL.md +31 -0
  132. package/skills/gsd-review/SKILL.md +42 -0
  133. package/skills/gsd-review-backlog/SKILL.md +63 -0
  134. package/skills/gsd-secure-phase/SKILL.md +36 -0
  135. package/skills/gsd-settings/SKILL.md +29 -0
  136. package/skills/gsd-ship/SKILL.md +24 -0
  137. package/skills/gsd-sketch/SKILL.md +60 -0
  138. package/skills/gsd-spec-phase/SKILL.md +63 -0
  139. package/skills/gsd-spike/SKILL.md +57 -0
  140. package/skills/gsd-stats/SKILL.md +20 -0
  141. package/skills/gsd-surface/SKILL.md +162 -0
  142. package/skills/gsd-thread/SKILL.md +24 -0
  143. package/skills/gsd-ui-phase/SKILL.md +35 -0
  144. package/skills/gsd-ui-review/SKILL.md +33 -0
  145. package/skills/gsd-ultraplan-phase/SKILL.md +34 -0
  146. package/skills/gsd-undo/SKILL.md +35 -0
  147. package/skills/gsd-update/SKILL.md +50 -0
  148. package/skills/gsd-validate-phase/SKILL.md +36 -0
  149. package/skills/gsd-verify-work/SKILL.md +39 -0
  150. package/skills/gsd-workspace/SKILL.md +53 -0
  151. 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,
@@ -14,10 +14,24 @@
14
14
  function escapeRegex(value) {
15
15
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
16
16
  }
17
+ // project_code values start with an uppercase letter (e.g. PROJ, APP_CODE);
18
+ // leading underscores are not valid project codes per .planning/config.json.
19
+ const PROJECT_CODE_PREFIX_STRIP_RE = /^[A-Z][A-Z0-9_]*-(?=\d)/;
20
+ const PROJECT_CODE_PREFIX_STRIP_RE_I = /^[A-Z][A-Z0-9_]*-(?=\d)/i;
21
+ const PROJECT_CODE_PREFIX_CAPTURE_RE_I = /^([A-Z][A-Z0-9_]*)-(\d.*)/i;
22
+ const OPTIONAL_PROJECT_CODE_PREFIX_SOURCE = '(?:[A-Z][A-Z0-9_]*-)?';
23
+ function stripProjectCodePrefix(value, caseInsensitive = true) {
24
+ const input = String(value);
25
+ const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE;
26
+ return input.replace(re, '');
27
+ }
28
+ function hasProjectCodePrefix(value) {
29
+ return PROJECT_CODE_PREFIX_STRIP_RE_I.test(String(value));
30
+ }
17
31
  function normalizePhaseName(phase) {
18
32
  const str = String(phase);
19
33
  // Strip optional project_code prefix (e.g., 'CK-01' → '01')
20
- const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/, '');
34
+ const stripped = stripProjectCodePrefix(str, false);
21
35
  // Milestone-prefixed phase IDs: M-NN or M-N-N (deep decomposition).
22
36
  const milestoneMatch = stripped.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
23
37
  if (milestoneMatch) {
@@ -39,8 +53,7 @@ function normalizePhaseName(phase) {
39
53
  return str;
40
54
  }
41
55
  function getMilestoneFromPhaseId(phaseId) {
42
- const str = String(phaseId);
43
- const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
56
+ const stripped = stripProjectCodePrefix(phaseId);
44
57
  const m = stripped.match(/^0*(\d+)-\d/);
45
58
  if (!m)
46
59
  return null;
@@ -50,8 +63,7 @@ function getMilestoneFromPhaseId(phaseId) {
50
63
  return `v${major}.0`;
51
64
  }
52
65
  function getPhaseDirFromPhaseId(phaseId, phaseName, projectCode) {
53
- const str = String(phaseId);
54
- const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
66
+ const stripped = stripProjectCodePrefix(phaseId);
55
67
  const m = stripped.match(/^0*(\d+)-(0*(\d+(?:-\d+)*))$/);
56
68
  if (!m)
57
69
  return null;
@@ -70,7 +82,7 @@ function getPhaseDirFromPhaseId(phaseId, phaseName, projectCode) {
70
82
  * prose regardless of zero-padding on either side.
71
83
  */
72
84
  function phaseMarkdownRegexSource(phaseNum) {
73
- const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
85
+ const stripped = stripProjectCodePrefix(phaseNum);
74
86
  // Milestone-prefixed IDs: M-NN or M-N-N (deep).
75
87
  const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i);
76
88
  if (milestoneSegments && milestoneSegments[2]) {
@@ -99,14 +111,14 @@ function phaseMarkdownRegexSource(phaseNum) {
99
111
  */
100
112
  function phaseMarkdownRegexSourceExact(phaseNum) {
101
113
  const raw = String(phaseNum);
102
- if (!/^[A-Z]{1,6}-(?=\d)/i.test(raw))
114
+ if (!hasProjectCodePrefix(raw))
103
115
  return null;
104
116
  return escapeRegex(raw);
105
117
  }
106
118
  function comparePhaseNum(a, b) {
107
119
  // Strip optional project_code prefix before comparing
108
- const sa = String(a).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
109
- const sb = String(b).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
120
+ const sa = stripProjectCodePrefix(a);
121
+ const sb = stripProjectCodePrefix(b);
110
122
  const milestoneA = sa.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
111
123
  const milestoneB = sb.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
112
124
  if (milestoneA && milestoneB) {
@@ -162,7 +174,7 @@ function comparePhaseNum(a, b) {
162
174
  * Extract the phase token from a directory name.
163
175
  */
164
176
  function extractPhaseToken(dirName) {
165
- const codePrefixMatch = dirName.match(/^([A-Z]{1,6})-(\d.*)/i);
177
+ const codePrefixMatch = dirName.match(PROJECT_CODE_PREFIX_CAPTURE_RE_I);
166
178
  let prefix = '';
167
179
  let rest = dirName;
168
180
  if (codePrefixMatch) {
@@ -192,7 +204,7 @@ function phaseTokenMatches(dirName, normalized) {
192
204
  const token = extractPhaseToken(dirName);
193
205
  if (token.toUpperCase() === normalized.toUpperCase())
194
206
  return true;
195
- const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
207
+ const stripped = stripProjectCodePrefix(dirName);
196
208
  if (stripped !== dirName) {
197
209
  const strippedToken = extractPhaseToken(stripped);
198
210
  if (strippedToken.toUpperCase() === normalized.toUpperCase())
@@ -202,6 +214,8 @@ function phaseTokenMatches(dirName, normalized) {
202
214
  }
203
215
  module.exports = {
204
216
  escapeRegex,
217
+ OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
218
+ stripProjectCodePrefix,
205
219
  normalizePhaseName,
206
220
  getMilestoneFromPhaseId,
207
221
  getPhaseDirFromPhaseId,
@@ -32,7 +32,7 @@ const coreUtilsMod = require("./core-utils.cjs");
32
32
  const { toPosixPath, generateSlugInternal, readSubdirectories } = coreUtilsMod;
33
33
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
34
34
  const phaseIdMod = require("./phase-id.cjs");
35
- const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, phaseTokenMatches } = phaseIdMod;
35
+ const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, phaseTokenMatches, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, } = phaseIdMod;
36
36
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
37
37
  const phaseLocatorMod = require("./phase-locator.cjs");
38
38
  const { findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorMod;
@@ -167,7 +167,7 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
167
167
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
168
168
  const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
169
169
  baseExists = dirs.some((d) => phaseTokenMatches(d, normalized));
170
- const dirPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${escapeRegex(normalized)}\\.(\\d+)`);
170
+ const dirPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalized)}\\.(\\d+)`);
171
171
  for (const dir of dirs) {
172
172
  const match = dir.match(dirPattern);
173
173
  if (match)
@@ -310,7 +310,7 @@ function cmdFindPhase(cwd, phase, raw) {
310
310
  const match = dirs.find((d) => phaseTokenMatches(d, normalized));
311
311
  if (!match)
312
312
  continue;
313
- const dirMatch = match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i) ||
313
+ const dirMatch = match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+[A-Z]?(?:\\.\\d+)*)-?(.*)`, 'i')) ||
314
314
  match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
315
315
  const phaseNumber = dirMatch ? dirMatch[1] : normalized;
316
316
  const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
@@ -756,7 +756,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
756
756
  try {
757
757
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
758
758
  const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
759
- const decimalPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${escapeRegex(normalizedBase)}\\.(\\d+)`);
759
+ const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalizedBase)}\\.(\\d+)`);
760
760
  for (const dir of dirs) {
761
761
  const dm = dir.match(decimalPattern);
762
762
  if (dm)
@@ -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
  };
@@ -288,6 +288,13 @@ function projectProhibitions(items) {
288
288
  if (typeof p.check_violation_fixture === 'string' && p.check_violation_fixture.trim() !== '') {
289
289
  entry.check_violation_fixture = String(p.check_violation_fixture);
290
290
  }
291
+ // `check_clean_fixture` (#1346) rides BOTH kinds — the KNOWN-CLEAN control subject the prover
292
+ // requires to stay GREEN (content-dependence proof). Emit ONLY a non-empty fixture (blank ->
293
+ // absent so no control runs; the documented residual remains). Like the violation fixture it is
294
+ // meaningless without the descriptor, so it lives inside this well-formed-descriptor branch.
295
+ if (typeof p.check_clean_fixture === 'string' && p.check_clean_fixture.trim() !== '') {
296
+ entry.check_clean_fixture = String(p.check_clean_fixture);
297
+ }
291
298
  }
292
299
  out.push(entry);
293
300
  }
@@ -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 */ }