gsdd-cli 0.18.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,9 +39,10 @@ export function hashDirectory(dir, baseDir = dir) {
39
39
  /**
40
40
  * Build a full manifest snapshot from installed project files.
41
41
  */
42
- export function buildManifest({ planningDir, frameworkVersion }) {
43
- const templatesDir = join(planningDir, 'templates');
44
- const rolesDir = join(templatesDir, 'roles');
42
+ export function buildManifest({ planningDir, frameworkVersion }) {
43
+ const templatesDir = join(planningDir, 'templates');
44
+ const rolesDir = join(templatesDir, 'roles');
45
+ const runtimeHelpersDir = join(planningDir, 'bin');
45
46
 
46
47
  // Template subcategories
47
48
  const delegatesHashes = hashDirectory(join(templatesDir, 'delegates'), join(templatesDir, 'delegates'));
@@ -59,28 +60,31 @@ export function buildManifest({ planningDir, frameworkVersion }) {
59
60
  }
60
61
  }
61
62
 
62
- // Role contracts
63
- const rolesHashes = {};
64
- if (existsSync(rolesDir)) {
65
- for (const entry of readdirSync(rolesDir)) {
66
- if (entry.endsWith('.md')) {
67
- rolesHashes[entry] = fileHash(join(rolesDir, entry));
68
- }
69
- }
70
- }
71
-
72
- return {
73
- frameworkVersion,
74
- generatedAt: new Date().toISOString(),
63
+ // Role contracts
64
+ const rolesHashes = {};
65
+ if (existsSync(rolesDir)) {
66
+ for (const entry of readdirSync(rolesDir)) {
67
+ if (entry.endsWith('.md')) {
68
+ rolesHashes[entry] = fileHash(join(rolesDir, entry));
69
+ }
70
+ }
71
+ }
72
+
73
+ const runtimeHelpersHashes = hashDirectory(runtimeHelpersDir, planningDir);
74
+
75
+ return {
76
+ frameworkVersion,
77
+ generatedAt: new Date().toISOString(),
75
78
  templates: {
76
79
  delegates: delegatesHashes,
77
80
  research: researchHashes,
78
81
  codebase: codebaseHashes,
79
82
  root: rootHashes,
80
- },
81
- roles: rolesHashes,
82
- };
83
- }
83
+ },
84
+ roles: rolesHashes,
85
+ runtimeHelpers: runtimeHelpersHashes,
86
+ };
87
+ }
84
88
 
85
89
  /**
86
90
  * Read existing manifest from planningDir, or return null if missing/corrupt.
@@ -14,9 +14,9 @@ function normalizeCheckpointWorkflow(workflow) {
14
14
  return ['phase', 'quick', 'generic'].includes(normalized) ? normalized : 'generic';
15
15
  }
16
16
 
17
- export function classifyCheckpointRouting(workflow) {
18
- const normalizedWorkflow = normalizeCheckpointWorkflow(workflow);
19
- const progressBlocks = normalizedWorkflow === 'phase' || normalizedWorkflow === 'quick';
17
+ export function classifyCheckpointRouting(workflow) {
18
+ const normalizedWorkflow = normalizeCheckpointWorkflow(workflow);
19
+ const progressBlocks = normalizedWorkflow === 'phase' || normalizedWorkflow === 'quick';
20
20
 
21
21
  return {
22
22
  workflow: normalizedWorkflow,
@@ -26,7 +26,7 @@ export function classifyCheckpointRouting(workflow) {
26
26
  };
27
27
  }
28
28
 
29
- export function parseGitStatusShort(statusText = '') {
29
+ export function parseGitStatusShort(statusText = '') {
30
30
  const lines = statusText
31
31
  .replace(/\r\n/g, '\n')
32
32
  .split('\n')
@@ -51,27 +51,161 @@ export function parseGitStatusShort(statusText = '') {
51
51
  });
52
52
  }
53
53
 
54
- return {
55
- files,
56
- stagedCount: files.filter((file) => file.staged).length,
57
- unstagedCount: files.filter((file) => file.unstaged).length,
58
- untrackedCount: files.filter((file) => file.untracked).length,
59
- dirty: files.length > 0,
60
- };
61
- }
62
-
63
- export function buildProvenanceSnapshot({
64
- checkpoint = {},
65
- planning = {},
66
- git = {},
67
- } = {}) {
68
- const checkpointRouting = classifyCheckpointRouting(checkpoint.workflow);
69
- const status = parseGitStatusShort(git.statusShort || '');
70
- const commitsAheadOfMain = normalizeCount(git.commitsAheadOfMain);
71
- const commitsAheadOfRemote = normalizeCount(git.commitsAheadOfRemote);
72
- const prState = normalizePrState(git.prState);
73
-
74
- const warnings = [];
54
+ return {
55
+ files,
56
+ stagedCount: files.filter((file) => file.staged).length,
57
+ unstagedCount: files.filter((file) => file.unstaged).length,
58
+ untrackedCount: files.filter((file) => file.untracked).length,
59
+ dirty: files.length > 0,
60
+ };
61
+ }
62
+
63
+ export function classifyBrownfieldCheckpointPrecedence({
64
+ checkpoint = {},
65
+ planning = {},
66
+ quick = {},
67
+ brownfieldChange = {},
68
+ git = {},
69
+ status = parseGitStatusShort(git.statusShort || ''),
70
+ } = {}) {
71
+ const checkpointRouting = classifyCheckpointRouting(checkpoint.workflow);
72
+ if (!brownfieldChange?.exists) {
73
+ return {
74
+ primary: checkpointRouting.progressBlocks ? 'checkpoint' : 'none',
75
+ checkpointRouting,
76
+ branchAligned: false,
77
+ scopeAligned: false,
78
+ executionActive: false,
79
+ checkpointCanOverrideBrownfield: false,
80
+ strictMatchRequired: false,
81
+ };
82
+ }
83
+
84
+ if (checkpointRouting.workflow === 'generic') {
85
+ return {
86
+ primary: 'brownfield_change',
87
+ checkpointRouting,
88
+ branchAligned: false,
89
+ scopeAligned: false,
90
+ executionActive: false,
91
+ checkpointCanOverrideBrownfield: false,
92
+ strictMatchRequired: true,
93
+ };
94
+ }
95
+
96
+ const brownfieldMismatch = classifyBrownfieldArtifactMismatch({
97
+ brownfieldChange,
98
+ git,
99
+ status,
100
+ });
101
+ const currentBranch = normalizeBranchName(git.branch);
102
+ const checkpointBranch = normalizeBranchName(checkpoint.branch);
103
+ const brownfieldBranch = normalizeBranchName(brownfieldChange.currentIntegrationSurface);
104
+ const branchAligned = Boolean(
105
+ currentBranch
106
+ && checkpointBranch
107
+ && brownfieldBranch
108
+ && checkpointBranch === currentBranch
109
+ && brownfieldBranch === currentBranch
110
+ );
111
+ const scopeAligned = !brownfieldMismatch.warnings.some((warning) =>
112
+ warning.id === 'brownfield_scope_mismatch' || warning.id === 'brownfield_status_mismatch'
113
+ );
114
+ const executionActive = checkpointExecutionIsActive({
115
+ checkpoint,
116
+ checkpointRouting,
117
+ planning,
118
+ quick,
119
+ });
120
+ const checkpointCanOverrideBrownfield = branchAligned && scopeAligned && executionActive;
121
+
122
+ return {
123
+ primary: checkpointCanOverrideBrownfield ? 'checkpoint' : 'brownfield_change',
124
+ checkpointRouting,
125
+ branchAligned,
126
+ scopeAligned,
127
+ executionActive,
128
+ checkpointCanOverrideBrownfield,
129
+ strictMatchRequired: true,
130
+ };
131
+ }
132
+
133
+ export function classifyBrownfieldArtifactMismatch({
134
+ brownfieldChange = {},
135
+ git = {},
136
+ status = parseGitStatusShort(git.statusShort || ''),
137
+ } = {}) {
138
+ if (!brownfieldChange?.exists) {
139
+ return {
140
+ warnings: [],
141
+ requiresAcknowledgement: false,
142
+ outsideOwnedPaths: [],
143
+ };
144
+ }
145
+
146
+ const warnings = [];
147
+ const declaredBranch = normalizeDeclaredBranch(brownfieldChange.currentIntegrationSurface);
148
+ const branch = String(git.branch || '').trim().toLowerCase();
149
+ if (declaredBranch && branch && declaredBranch !== branch) {
150
+ warnings.push({
151
+ id: 'brownfield_branch_mismatch',
152
+ severity: 'acknowledgement_required',
153
+ summary: `CHANGE.md says the active integration surface is "${brownfieldChange.currentIntegrationSurface}", but git reports "${git.branch}".`,
154
+ });
155
+ }
156
+
157
+ const ownedPaths = normalizeOwnedPaths(brownfieldChange.declaredOwnedPaths || []);
158
+ const outsideOwnedPaths = ownedPaths.length === 0
159
+ ? []
160
+ : status.files
161
+ .map((file) => file.path)
162
+ .filter((filePath) => !ownedPaths.some((ownedPath) => matchesOwnedPath(filePath, ownedPath)));
163
+ if (outsideOwnedPaths.length > 0) {
164
+ warnings.push({
165
+ id: 'brownfield_scope_mismatch',
166
+ severity: 'acknowledgement_required',
167
+ summary: `Dirty files fall outside the CHANGE.md write scope: ${outsideOwnedPaths.join(', ')}`,
168
+ });
169
+ }
170
+
171
+ if ((brownfieldChange.currentStatus === 'closed' || brownfieldChange.currentStatus === 'ready_for_verification') && status.dirty) {
172
+ warnings.push({
173
+ id: 'brownfield_status_mismatch',
174
+ severity: 'acknowledgement_required',
175
+ summary: `CHANGE.md marks the change as "${brownfieldChange.currentStatus}", but the live worktree is still dirty.`,
176
+ });
177
+ }
178
+
179
+ return {
180
+ warnings,
181
+ requiresAcknowledgement: warnings.some((warning) => warning.severity === 'acknowledgement_required'),
182
+ outsideOwnedPaths,
183
+ };
184
+ }
185
+
186
+ export function buildProvenanceSnapshot({
187
+ checkpoint = {},
188
+ planning = {},
189
+ quick = {},
190
+ brownfieldChange = {},
191
+ git = {},
192
+ } = {}) {
193
+ const checkpointRouting = classifyCheckpointRouting(checkpoint.workflow);
194
+ const status = parseGitStatusShort(git.statusShort || '');
195
+ const commitsAheadOfMain = normalizeCount(git.commitsAheadOfMain);
196
+ const commitsAheadOfRemote = normalizeCount(git.commitsAheadOfRemote);
197
+ const prState = normalizePrState(git.prState);
198
+ const brownfieldMismatch = classifyBrownfieldArtifactMismatch({ brownfieldChange, git, status });
199
+ const brownfieldRouting = classifyBrownfieldCheckpointPrecedence({
200
+ checkpoint,
201
+ planning,
202
+ quick,
203
+ brownfieldChange,
204
+ git,
205
+ status,
206
+ });
207
+
208
+ const warnings = [];
75
209
 
76
210
  if (status.dirty) {
77
211
  warnings.push({
@@ -121,31 +255,50 @@ export function buildProvenanceSnapshot({
121
255
  });
122
256
  }
123
257
 
124
- if (git.materialCheckpointMismatch) {
125
- warnings.push({
126
- id: 'checkpoint_mismatch',
127
- severity: 'acknowledgement_required',
128
- summary: 'Checkpoint narrative truth materially understates or conflicts with the live branch/worktree truth.',
129
- });
130
- }
131
-
132
- return {
133
- checkpoint: {
134
- workflow: checkpointRouting.workflow,
135
- phase: checkpoint.phase ?? null,
136
- runtime: checkpoint.runtime || 'unknown',
137
- hasNarrative: Boolean(checkpoint.hasNarrative),
138
- routing: checkpointRouting,
139
- },
140
- planning: {
141
- currentPhase: planning.currentPhase || null,
142
- nextPhase: planning.nextPhase || null,
143
- completedPhaseCount: Number.isFinite(Number(planning.completedPhaseCount))
144
- ? Number(planning.completedPhaseCount)
145
- : 0,
146
- },
147
- git: {
148
- branch: git.branch || 'unknown',
258
+ if (git.materialCheckpointMismatch) {
259
+ warnings.push({
260
+ id: 'checkpoint_mismatch',
261
+ severity: 'acknowledgement_required',
262
+ summary: 'Checkpoint narrative truth materially understates or conflicts with the live branch/worktree truth.',
263
+ });
264
+ }
265
+
266
+ warnings.push(...brownfieldMismatch.warnings);
267
+
268
+ return {
269
+ checkpoint: {
270
+ workflow: checkpointRouting.workflow,
271
+ phase: checkpoint.phase ?? null,
272
+ branch: checkpoint.branch || null,
273
+ runtime: checkpoint.runtime || 'unknown',
274
+ hasNarrative: Boolean(checkpoint.hasNarrative),
275
+ routing: checkpointRouting,
276
+ },
277
+ planning: {
278
+ currentPhase: planning.currentPhase || null,
279
+ nextPhase: planning.nextPhase || null,
280
+ completedPhaseCount: Number.isFinite(Number(planning.completedPhaseCount))
281
+ ? Number(planning.completedPhaseCount)
282
+ : 0,
283
+ },
284
+ brownfieldChange: {
285
+ exists: Boolean(brownfieldChange?.exists),
286
+ title: brownfieldChange?.title || null,
287
+ currentStatus: brownfieldChange?.currentStatus || null,
288
+ currentIntegrationSurface: brownfieldChange?.currentIntegrationSurface || null,
289
+ nextAction: brownfieldChange?.nextAction || null,
290
+ declaredOwnedPaths: brownfieldChange?.declaredOwnedPaths || [],
291
+ },
292
+ routing: {
293
+ primary: brownfieldRouting.primary,
294
+ strictMatchRequired: brownfieldRouting.strictMatchRequired,
295
+ checkpointCanOverrideBrownfield: brownfieldRouting.checkpointCanOverrideBrownfield,
296
+ branchAligned: brownfieldRouting.branchAligned,
297
+ scopeAligned: brownfieldRouting.scopeAligned,
298
+ executionActive: brownfieldRouting.executionActive,
299
+ },
300
+ git: {
301
+ branch: git.branch || 'unknown',
149
302
  prState,
150
303
  commitsAheadOfMain,
151
304
  commitsAheadOfRemote,
@@ -154,12 +307,81 @@ export function buildProvenanceSnapshot({
154
307
  untrackedCount: status.untrackedCount,
155
308
  dirty: status.dirty,
156
309
  },
157
- integrationSurface: {
158
- staleBranch: Boolean(git.staleBranch),
159
- mixedScope: Boolean(git.mixedScope),
160
- materialCheckpointMismatch: Boolean(git.materialCheckpointMismatch),
161
- },
162
- warnings,
163
- requiresAcknowledgement: warnings.some((warning) => warning.severity === 'acknowledgement_required'),
164
- };
165
- }
310
+ integrationSurface: {
311
+ staleBranch: Boolean(git.staleBranch),
312
+ mixedScope: Boolean(git.mixedScope),
313
+ materialCheckpointMismatch: Boolean(git.materialCheckpointMismatch),
314
+ materialBrownfieldMismatch: brownfieldMismatch.requiresAcknowledgement,
315
+ },
316
+ warnings,
317
+ requiresAcknowledgement: warnings.some((warning) => warning.severity === 'acknowledgement_required'),
318
+ };
319
+ }
320
+
321
+ function normalizeBranchName(value) {
322
+ return String(value || '')
323
+ .trim()
324
+ .toLowerCase()
325
+ .replace(/\\/g, '/');
326
+ }
327
+
328
+ function normalizeDeclaredBranch(value) {
329
+ return normalizeBranchName(value);
330
+ }
331
+
332
+ function normalizeOwnedPaths(values) {
333
+ return values
334
+ .map((value) => String(value || '').trim().replace(/\\/g, '/'))
335
+ .filter(Boolean);
336
+ }
337
+
338
+ function matchesOwnedPath(filePath, ownedPath) {
339
+ if (ownedPath.includes('*')) {
340
+ const prefix = ownedPath.split('*')[0];
341
+ return filePath.startsWith(prefix);
342
+ }
343
+ return filePath === ownedPath
344
+ || filePath.startsWith(`${ownedPath}/`)
345
+ || ownedPath.startsWith(`${filePath}/`);
346
+ }
347
+
348
+ function checkpointExecutionIsActive({
349
+ checkpoint = {},
350
+ checkpointRouting = classifyCheckpointRouting(checkpoint.workflow),
351
+ planning = {},
352
+ quick = {},
353
+ } = {}) {
354
+ if (checkpointRouting.workflow === 'quick') {
355
+ return quick.hasIncompleteWork === true;
356
+ }
357
+
358
+ if (checkpointRouting.workflow !== 'phase') {
359
+ return false;
360
+ }
361
+
362
+ const checkpointPhase = normalizePhaseRef(checkpoint.phase);
363
+ if (!checkpointPhase) return false;
364
+
365
+ const phases = Array.isArray(planning.phases) ? planning.phases : [];
366
+ if (phases.length > 0) {
367
+ const matchingPhase = phases.find((phase) => normalizePhaseRef(phase.number) === checkpointPhase);
368
+ return Boolean(matchingPhase && matchingPhase.status !== 'done');
369
+ }
370
+
371
+ const currentPhase = normalizePhaseRef(planning.currentPhase);
372
+ const nextPhase = normalizePhaseRef(planning.nextPhase);
373
+ return checkpointPhase === currentPhase || checkpointPhase === nextPhase;
374
+ }
375
+
376
+ function normalizePhaseRef(value) {
377
+ const raw = String(value || '').trim().toLowerCase();
378
+ if (!raw) return '';
379
+
380
+ const match = raw.match(/^(\d+(?:\.\d+)*)([a-z]?)$/i);
381
+ if (!match) return raw;
382
+
383
+ const numericSegments = match[1]
384
+ .split('.')
385
+ .map((segment) => String(parseInt(segment, 10)));
386
+ return `${numericSegments.join('.')}${match[2] || ''}`;
387
+ }
@@ -18,23 +18,72 @@ function getDelegateContent(delegateFile) {
18
18
  return `<!-- Delegate file not found: ${delegateFile} -->\n`;
19
19
  }
20
20
 
21
- function renderSkillContent(workflow) {
22
- const workflowContent = getWorkflowContent(workflow.workflow);
23
- return `---
24
- name: ${workflow.name}
25
- description: ${workflow.description}
21
+ function renderSkillContent(workflow) {
22
+ const workflowContent = getWorkflowContent(workflow.workflow);
23
+ return `---
24
+ name: ${workflow.name}
25
+ description: ${workflow.description}
26
26
  context: fork
27
27
  agent: ${workflow.agent}
28
28
  ---
29
29
 
30
- ${workflowContent}`;
31
- }
32
-
33
- function buildPortableSkillEntries(workflows) {
34
- return workflows.map((workflow) => ({
35
- relativePath: `.agents/skills/${workflow.name}/SKILL.md`,
36
- content: renderSkillContent(workflow),
37
- }));
30
+ ${workflowContent}`;
31
+ }
32
+
33
+ function renderPlanningCliLauncher({ packageName = 'gsdd-cli', packageVersion }) {
34
+ if (!packageVersion) {
35
+ throw new Error('renderPlanningCliLauncher requires packageVersion');
36
+ }
37
+
38
+ const packageSpec = `${packageName}@${packageVersion}`;
39
+
40
+ return `#!/usr/bin/env node
41
+
42
+ import { spawnSync } from 'node:child_process';
43
+
44
+ const packageSpec = ${JSON.stringify(packageSpec)};
45
+ const args = process.argv.slice(2);
46
+ const env = { ...process.env };
47
+
48
+ function forwardResult(result, fallbackMessage) {
49
+ if (result.error) {
50
+ console.error(fallbackMessage ?? result.error.message);
51
+ process.exitCode = 1;
52
+ return;
53
+ }
54
+
55
+ if (typeof result.status === 'number') {
56
+ process.exitCode = result.status;
57
+ return;
58
+ }
59
+
60
+ if (result.signal) {
61
+ process.exitCode = 1;
62
+ }
63
+ }
64
+
65
+ if (env.GSDD_CLI_PATH) {
66
+ const localResult = spawnSync(process.execPath, [env.GSDD_CLI_PATH, ...args], {
67
+ stdio: 'inherit',
68
+ env,
69
+ });
70
+ forwardResult(localResult, 'Failed to run the local GSDD CLI path from GSDD_CLI_PATH.');
71
+ } else {
72
+ const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx';
73
+ const npxResult = spawnSync(npxCommand, ['--yes', packageSpec, ...args], {
74
+ stdio: 'inherit',
75
+ env,
76
+ });
77
+ forwardResult(npxResult, \`Failed to run \${packageSpec} via npx.\`);
78
+ }
79
+ `;
80
+ }
81
+
82
+ function buildPortableSkillEntries(workflows) {
83
+ return workflows.map((workflow) => ({
84
+ relativePath: `.agents/skills/${workflow.name}/SKILL.md`,
85
+ content: renderSkillContent(workflow),
86
+ }));
38
87
  }
39
88
 
40
89
  function renderOpenCodeCommandContent(workflow) {
@@ -95,9 +144,10 @@ export {
95
144
  buildPortableSkillEntries,
96
145
  getDelegateContent,
97
146
  getWorkflowContent,
98
- renderAgentsBoundedBlock,
99
- renderAgentsFileContent,
100
- renderOpenCodeCommandContent,
101
- renderSkillContent,
102
- upsertBoundedBlock,
103
- };
147
+ renderAgentsBoundedBlock,
148
+ renderAgentsFileContent,
149
+ renderOpenCodeCommandContent,
150
+ renderPlanningCliLauncher,
151
+ renderSkillContent,
152
+ upsertBoundedBlock,
153
+ };
@@ -1,11 +1,13 @@
1
- import { existsSync, readFileSync } from 'fs';
2
- import { join } from 'path';
3
- import {
4
- buildPortableSkillEntries,
5
- getDelegateContent,
6
- renderOpenCodeCommandContent,
7
- renderSkillContent,
8
- } from './rendering.mjs';
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import {
5
+ buildPortableSkillEntries,
6
+ getDelegateContent,
7
+ renderPlanningCliLauncher,
8
+ renderOpenCodeCommandContent,
9
+ renderSkillContent,
10
+ } from './rendering.mjs';
9
11
  import {
10
12
  CLAUDE_MODEL_PROFILES,
11
13
  renderClaudeApproachExplorer,
@@ -22,11 +24,15 @@ import {
22
24
  renderCodexApproachExplorer,
23
25
  renderCodexPlanChecker,
24
26
  } from '../adapters/codex.mjs';
25
- import {
26
- getRuntimeModelOverride,
27
- loadProjectModelConfig,
28
- resolveRuntimeAgentModel,
29
- } from './models.mjs';
27
+ import {
28
+ getRuntimeModelOverride,
29
+ loadProjectModelConfig,
30
+ resolveRuntimeAgentModel,
31
+ } from './models.mjs';
32
+
33
+ const __filename = fileURLToPath(import.meta.url);
34
+ const __dirname = dirname(__filename);
35
+ const PACKAGE_JSON = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8'));
30
36
 
31
37
  function normalizeContent(content) {
32
38
  return String(content).replace(/\r\n/g, '\n');
@@ -127,7 +133,7 @@ function buildOpenCodeEntries({ cwd, workflows }) {
127
133
  return entries;
128
134
  }
129
135
 
130
- function buildCodexEntries({ cwd }) {
136
+ function buildCodexEntries({ cwd }) {
131
137
  const config = loadProjectModelConfig(cwd);
132
138
  const checkerModelId = getRuntimeModelOverride(config, 'codex', 'plan-checker');
133
139
  const explorerModelId = getRuntimeModelOverride(config, 'codex', 'approach-explorer');
@@ -142,13 +148,32 @@ function buildCodexEntries({ cwd }) {
142
148
  expectedContent: renderCodexApproachExplorer(getDelegateContent('approach-explorer.md'), explorerModelId),
143
149
  },
144
150
  ];
145
- }
146
-
147
- export function collectExpectedRuntimeSurfaceGroups({ cwd = process.cwd(), workflows }) {
148
- return [
149
- {
150
- runtime: 'portable',
151
- label: 'portable skills',
151
+ }
152
+
153
+ function buildWorkspaceHelperEntries() {
154
+ return [
155
+ {
156
+ relativePath: '.planning/bin/gsdd.mjs',
157
+ expectedContent: renderPlanningCliLauncher({
158
+ packageName: PACKAGE_JSON.name,
159
+ packageVersion: PACKAGE_JSON.version,
160
+ }),
161
+ },
162
+ ];
163
+ }
164
+
165
+ export function collectExpectedRuntimeSurfaceGroups({ cwd = process.cwd(), workflows }) {
166
+ return [
167
+ {
168
+ runtime: 'workspace-helper',
169
+ label: 'workspace workflow helper',
170
+ root: '.planning/bin',
171
+ repairCommand: 'gsdd update',
172
+ entries: buildWorkspaceHelperEntries(),
173
+ },
174
+ {
175
+ runtime: 'portable',
176
+ label: 'portable skills',
152
177
  root: '.agents/skills',
153
178
  repairCommand: 'gsdd update',
154
179
  entries: buildPortableSkillEntries(workflows).map((entry) => ({