gsdd-cli 0.3.1 → 0.18.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 (62) hide show
  1. package/README.md +131 -67
  2. package/agents/DISTILLATION.md +15 -13
  3. package/agents/README.md +1 -1
  4. package/agents/planner.md +2 -0
  5. package/bin/adapters/agents.mjs +1 -0
  6. package/bin/adapters/claude.mjs +20 -4
  7. package/bin/adapters/codex.mjs +9 -1
  8. package/bin/adapters/opencode.mjs +20 -5
  9. package/bin/gsdd.mjs +24 -7
  10. package/bin/lib/cli-utils.mjs +1 -1
  11. package/bin/lib/evidence-contract.mjs +112 -0
  12. package/bin/lib/file-ops.mjs +161 -0
  13. package/bin/lib/health-truth.mjs +186 -0
  14. package/bin/lib/health.mjs +72 -67
  15. package/bin/lib/init-flow.mjs +50 -3
  16. package/bin/lib/init-prompts.mjs +22 -83
  17. package/bin/lib/init-runtime.mjs +47 -25
  18. package/bin/lib/init.mjs +3 -3
  19. package/bin/lib/lifecycle-preflight.mjs +333 -0
  20. package/bin/lib/lifecycle-state.mjs +293 -0
  21. package/bin/lib/models.mjs +19 -4
  22. package/bin/lib/phase.mjs +159 -18
  23. package/bin/lib/plan-constants.mjs +30 -0
  24. package/bin/lib/provenance.mjs +165 -0
  25. package/bin/lib/rendering.mjs +8 -0
  26. package/bin/lib/runtime-freshness.mjs +239 -0
  27. package/bin/lib/session-fingerprint.mjs +106 -0
  28. package/bin/lib/templates.mjs +17 -0
  29. package/distilled/DESIGN.md +733 -49
  30. package/distilled/EVIDENCE-INDEX.md +402 -0
  31. package/distilled/README.md +73 -33
  32. package/distilled/SKILL.md +89 -85
  33. package/distilled/templates/agents.block.md +13 -84
  34. package/distilled/templates/agents.md +0 -7
  35. package/distilled/templates/delegates/plan-checker.md +6 -3
  36. package/distilled/workflows/audit-milestone.md +56 -6
  37. package/distilled/workflows/complete-milestone.md +333 -0
  38. package/distilled/workflows/execute.md +201 -19
  39. package/distilled/workflows/map-codebase.md +17 -4
  40. package/distilled/workflows/new-milestone.md +262 -0
  41. package/distilled/workflows/new-project.md +7 -6
  42. package/distilled/workflows/pause.md +40 -6
  43. package/distilled/workflows/plan-milestone-gaps.md +183 -0
  44. package/distilled/workflows/plan.md +77 -11
  45. package/distilled/workflows/progress.md +107 -29
  46. package/distilled/workflows/quick.md +23 -12
  47. package/distilled/workflows/resume.md +135 -12
  48. package/distilled/workflows/verify-work.md +260 -0
  49. package/distilled/workflows/verify.md +159 -33
  50. package/docs/BROWNFIELD-PROOF.md +95 -0
  51. package/docs/RUNTIME-SUPPORT.md +77 -0
  52. package/docs/USER-GUIDE.md +439 -0
  53. package/docs/VERIFICATION-DISCIPLINE.md +59 -0
  54. package/docs/claude/context-monitor.md +98 -0
  55. package/docs/proof/consumer-node-cli/README.md +37 -0
  56. package/docs/proof/consumer-node-cli/ROADMAP.md +14 -0
  57. package/docs/proof/consumer-node-cli/SPEC.md +17 -0
  58. package/docs/proof/consumer-node-cli/brief.md +9 -0
  59. package/docs/proof/consumer-node-cli/phases/01-foundation/01-01-PLAN.md +34 -0
  60. package/docs/proof/consumer-node-cli/phases/01-foundation/01-01-SUMMARY.md +10 -0
  61. package/docs/proof/consumer-node-cli/phases/01-foundation/01-VERIFICATION.md +30 -0
  62. package/package.json +38 -29
package/bin/lib/phase.mjs CHANGED
@@ -6,10 +6,82 @@
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'fs';
7
7
  import { join, basename } from 'path';
8
8
  import { output } from './cli-utils.mjs';
9
+ import { writeFingerprint } from './session-fingerprint.mjs';
10
+
11
+ const PHASE_STATUS_MARKERS = {
12
+ not_started: '[ ]',
13
+ todo: '[ ]',
14
+ in_progress: '[-]',
15
+ done: '[x]',
16
+ };
17
+
18
+ const PHASE_MARKER_RE = '(\\[[ x]\\]|\\[-\\]|⬜|ðŸ"„|✅|⬜|🔄|✅)';
19
+ const PHASE_TOKEN_RE = '(\\d+(?:\\.\\d+)*[a-z]?)';
20
+ const PHASE_LINE_RE = new RegExp(
21
+ `^[-*]\\s*${PHASE_MARKER_RE}\\s*\\*\\*Phase\\s+${PHASE_TOKEN_RE}:\\s*(.+?)\\*\\*`,
22
+ 'i'
23
+ );
24
+ const ROADMAP_PHASE_STATUS_RE = new RegExp(
25
+ `^(\\s*[-*]\\s*)${PHASE_MARKER_RE}(\\s*\\*\\*Phase\\s+${PHASE_TOKEN_RE}:.*)$`,
26
+ 'i'
27
+ );
9
28
 
10
29
  function findFiles(dir, prefix) {
11
30
  if (!existsSync(dir)) return [];
12
- return readdirSync(dir).filter((f) => f.startsWith(prefix) || f.startsWith(prefix.replace(/^0+/, '')));
31
+
32
+ const phaseArtifactPrefix = String(prefix).match(/^(\d+(?:\.\d+)*[a-z]?)-(PLAN|SUMMARY)$/i);
33
+ if (!phaseArtifactPrefix) {
34
+ return readdirSync(dir).filter((f) => f.startsWith(prefix) || f.startsWith(prefix.replace(/^0+/, '')));
35
+ }
36
+
37
+ const targetPhase = normalizePhaseToken(phaseArtifactPrefix[1]);
38
+ const targetKind = phaseArtifactPrefix[2].toUpperCase();
39
+
40
+ return listPhaseArtifacts(dir)
41
+ .filter((artifact) => artifact.phaseToken === targetPhase && artifact.kind === targetKind)
42
+ .map((artifact) => artifact.displayPath);
43
+ }
44
+
45
+ function listPhaseArtifacts(dir) {
46
+ if (!existsSync(dir)) return [];
47
+
48
+ const artifacts = [];
49
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
50
+ if (entry.isFile()) {
51
+ const artifact = classifyPhaseArtifact('', entry.name);
52
+ if (artifact) artifacts.push(artifact);
53
+ continue;
54
+ }
55
+
56
+ if (!entry.isDirectory()) continue;
57
+
58
+ const entryPath = join(dir, entry.name);
59
+ for (const child of readdirSync(entryPath, { withFileTypes: true })) {
60
+ if (!child.isFile()) continue;
61
+ const artifact = classifyPhaseArtifact(entry.name, child.name);
62
+ if (artifact) artifacts.push(artifact);
63
+ }
64
+ }
65
+
66
+ return artifacts;
67
+ }
68
+
69
+ function classifyPhaseArtifact(dir, name) {
70
+ const dirMatch = dir ? dir.match(/^(\d+(?:\.\d+)*[a-z]?)-/i) : null;
71
+ const nameMatch = name.match(/^(\d+(?:\.\d+)*[a-z]?)/i);
72
+ const phaseToken = normalizePhaseToken((dirMatch || nameMatch)?.[1] || '');
73
+
74
+ let kind = 'OTHER';
75
+ if (name.includes('PLAN')) kind = 'PLAN';
76
+ else if (name.includes('SUMMARY')) kind = 'SUMMARY';
77
+
78
+ return {
79
+ dir,
80
+ name,
81
+ displayPath: dir ? `${dir}/${name}` : name,
82
+ phaseToken,
83
+ kind,
84
+ };
13
85
  }
14
86
 
15
87
  function padPhase(n) {
@@ -20,13 +92,7 @@ function parsePhaseStatuses(roadmap) {
20
92
  const phases = [];
21
93
  const lines = roadmap.split('\n');
22
94
  for (const line of lines) {
23
- // Supports:
24
- // - checkbox statuses: [ ] / [x]
25
- // - legacy emoji markers in ROADMAP templates: not started / in progress / done
26
- // - mojibake-encoded variants that exist in some files
27
- const match = line.match(
28
- /^[-*]\s*(\[[ x]\]|\[-\]|⬜|ðŸ"„|✅|⬜|🔄|✅)\s*\*\*Phase\s+(\d+):\s*(.+?)\*\*/i
29
- );
95
+ const match = line.match(PHASE_LINE_RE);
30
96
  if (match) {
31
97
  const rawStatus = match[1].toLowerCase();
32
98
  let status = 'not_started';
@@ -34,7 +100,7 @@ function parsePhaseStatuses(roadmap) {
34
100
  else if (rawStatus === '[-]') status = 'in_progress';
35
101
  else if (rawStatus === 'ðÿ"„' || rawStatus === '🔄') status = 'in_progress';
36
102
  phases.push({
37
- number: parseInt(match[2], 10),
103
+ number: match[2],
38
104
  name: match[3].replace(/\*\*/g, '').split('-')[0].trim(),
39
105
  status,
40
106
  });
@@ -43,6 +109,81 @@ function parsePhaseStatuses(roadmap) {
43
109
  return phases;
44
110
  }
45
111
 
112
+ function normalizePhaseToken(value) {
113
+ const raw = String(value).trim().toLowerCase();
114
+ const match = raw.match(/^(\d+(?:\.\d+)*)([a-z]?)$/i);
115
+ if (!match) return raw;
116
+
117
+ const numericSegments = match[1]
118
+ .split('.')
119
+ .map((segment) => String(parseInt(segment, 10)));
120
+ return `${numericSegments.join('.')}${match[2] || ''}`;
121
+ }
122
+
123
+ export function updateRoadmapPhaseStatus(roadmap, phaseNumber, status) {
124
+ const marker = PHASE_STATUS_MARKERS[status];
125
+ if (!marker) {
126
+ throw new Error(`Unsupported phase status: ${status}`);
127
+ }
128
+
129
+ const normalizedTarget = normalizePhaseToken(phaseNumber);
130
+ let matchCount = 0;
131
+
132
+ const updated = roadmap
133
+ .split('\n')
134
+ .map((line) => {
135
+ const match = line.match(ROADMAP_PHASE_STATUS_RE);
136
+ if (!match) return line;
137
+ if (normalizePhaseToken(match[4]) !== normalizedTarget) return line;
138
+ matchCount += 1;
139
+ return `${match[1]}${marker}${match[3]}`;
140
+ })
141
+ .join('\n');
142
+
143
+ if (matchCount === 0) {
144
+ throw new Error(`Phase ${phaseNumber} was not found in ROADMAP.md`);
145
+ }
146
+
147
+ if (matchCount > 1) {
148
+ throw new Error(`Phase ${phaseNumber} matched multiple ROADMAP.md entries`);
149
+ }
150
+
151
+ return updated;
152
+ }
153
+
154
+ export function cmdPhaseStatus(...args) {
155
+ const cwd = process.cwd();
156
+ const planningDir = join(cwd, '.planning');
157
+ const roadmapPath = join(planningDir, 'ROADMAP.md');
158
+ const [phaseNumber, status] = args;
159
+
160
+ if (!phaseNumber || !status) {
161
+ console.error('Usage: gsdd phase-status <phase-number> <not_started|todo|in_progress|done>');
162
+ process.exitCode = 1;
163
+ return;
164
+ }
165
+
166
+ if (!existsSync(roadmapPath)) {
167
+ console.error('No ROADMAP.md found. Run the new-project workflow first.');
168
+ process.exitCode = 1;
169
+ return;
170
+ }
171
+
172
+ try {
173
+ const roadmap = readFileSync(roadmapPath, 'utf-8');
174
+ const updated = updateRoadmapPhaseStatus(roadmap, phaseNumber, status);
175
+ const changed = updated !== roadmap;
176
+ if (changed) {
177
+ writeFileSync(roadmapPath, updated);
178
+ try { writeFingerprint(planningDir); } catch { /* best-effort */ }
179
+ }
180
+ output({ phase: phaseNumber, status, roadmap: '.planning/ROADMAP.md', changed });
181
+ } catch (error) {
182
+ console.error(error.message);
183
+ process.exitCode = 1;
184
+ }
185
+ }
186
+
46
187
  export function cmdFindPhase(...args) {
47
188
  const cwd = process.cwd();
48
189
  const planningDir = join(cwd, '.planning');
@@ -67,7 +208,7 @@ export function cmdFindPhase(...args) {
67
208
  const summaries = findFiles(phasesDir, `${padPhase(phaseNum)}-SUMMARY`);
68
209
 
69
210
  output({
70
- phase: parseInt(phaseNum, 10),
211
+ phase: normalizePhaseToken(phaseNum),
71
212
  directory: phasesDir,
72
213
  plans,
73
214
  summaries,
@@ -77,9 +218,9 @@ export function cmdFindPhase(...args) {
77
218
  return;
78
219
  }
79
220
 
80
- const allFiles = existsSync(phasesDir) ? readdirSync(phasesDir) : [];
81
- const plans = allFiles.filter((f) => f.includes('PLAN'));
82
- const summaries = allFiles.filter((f) => f.includes('SUMMARY'));
221
+ const allArtifacts = listPhaseArtifacts(phasesDir);
222
+ const plans = allArtifacts.filter((artifact) => artifact.kind === 'PLAN');
223
+ const summaries = allArtifacts.filter((artifact) => artifact.kind === 'SUMMARY');
83
224
 
84
225
  const roadmap = readFileSync(roadmapPath, 'utf-8');
85
226
  const phases = parsePhaseStatuses(roadmap);
@@ -99,18 +240,18 @@ export function cmdVerify(...args) {
99
240
  const phaseNum = args[0];
100
241
  if (!phaseNum) {
101
242
  console.error('Usage: gsdd verify <phase-number>');
102
- process.exit(1);
243
+ process.exitCode = 1; return;
103
244
  }
104
245
 
105
246
  if (!existsSync(planningDir)) {
106
247
  console.error('No .planning/ directory found.');
107
- process.exit(1);
248
+ process.exitCode = 1; return;
108
249
  }
109
250
 
110
251
  const planFile = findFiles(join(planningDir, 'phases'), `${padPhase(phaseNum)}-PLAN`)[0];
111
252
  if (!planFile) {
112
253
  console.error(`No plan found for phase ${phaseNum}`);
113
- process.exit(1);
254
+ process.exitCode = 1; return;
114
255
  }
115
256
 
116
257
  const planPath = join(planningDir, 'phases', planFile);
@@ -183,14 +324,14 @@ export function cmdScaffold(...args) {
183
324
 
184
325
  if (type !== 'phase') {
185
326
  console.error('Usage: gsdd scaffold phase <number> [name]');
186
- process.exit(1);
327
+ process.exitCode = 1; return;
187
328
  }
188
329
 
189
330
  const phaseNum = rest[0];
190
331
  const phaseName = rest.slice(1).join(' ');
191
332
  if (!phaseNum) {
192
333
  console.error('Usage: gsdd scaffold phase <number> [name]');
193
- process.exit(1);
334
+ process.exitCode = 1; return;
194
335
  }
195
336
 
196
337
  const phasesDir = join(planningDir, 'phases');
@@ -0,0 +1,30 @@
1
+ export const PLAN_CHECK_DIMENSIONS = [
2
+ 'requirement_coverage',
3
+ 'task_completeness',
4
+ 'dependency_correctness',
5
+ 'key_link_completeness',
6
+ 'scope_sanity',
7
+ 'must_have_quality',
8
+ 'context_compliance',
9
+ 'goal_achievement',
10
+ 'approach_alignment',
11
+ ];
12
+
13
+ export const MAX_CHECKER_CYCLES = 3;
14
+
15
+ export const CHECKER_STATUSES = ['passed', 'issues_found'];
16
+
17
+ export const CHECKER_JSON_SCHEMA = {
18
+ status: 'passed | issues_found',
19
+ summary: 'string',
20
+ issues: [
21
+ {
22
+ dimension: 'string',
23
+ severity: 'blocker | warning',
24
+ description: 'string',
25
+ plan: 'string',
26
+ task: 'string',
27
+ fix_hint: 'string',
28
+ },
29
+ ],
30
+ };
@@ -0,0 +1,165 @@
1
+ function normalizePrState(prState) {
2
+ if (!prState) return 'none';
3
+ return String(prState).trim().toLowerCase();
4
+ }
5
+
6
+ function normalizeCount(value) {
7
+ if (value === 'unknown' || value === null || value === undefined) return 'unknown';
8
+ const numeric = Number(value);
9
+ return Number.isFinite(numeric) ? numeric : 'unknown';
10
+ }
11
+
12
+ function normalizeCheckpointWorkflow(workflow) {
13
+ const normalized = String(workflow || 'generic').trim().toLowerCase();
14
+ return ['phase', 'quick', 'generic'].includes(normalized) ? normalized : 'generic';
15
+ }
16
+
17
+ export function classifyCheckpointRouting(workflow) {
18
+ const normalizedWorkflow = normalizeCheckpointWorkflow(workflow);
19
+ const progressBlocks = normalizedWorkflow === 'phase' || normalizedWorkflow === 'quick';
20
+
21
+ return {
22
+ workflow: normalizedWorkflow,
23
+ routingClass: progressBlocks ? 'blocking' : 'informational',
24
+ progressBlocks,
25
+ resumeOwnsCleanup: true,
26
+ };
27
+ }
28
+
29
+ export function parseGitStatusShort(statusText = '') {
30
+ const lines = statusText
31
+ .replace(/\r\n/g, '\n')
32
+ .split('\n')
33
+ .map((line) => line.trimEnd())
34
+ .filter(Boolean);
35
+
36
+ const files = [];
37
+ for (const line of lines) {
38
+ const match = line.match(/^(.)(.)\s+(.+)$/);
39
+ if (!match) continue;
40
+
41
+ const indexStatus = match[1];
42
+ const worktreeStatus = match[2];
43
+ if (indexStatus === '!' && worktreeStatus === '!') continue;
44
+
45
+ const filePath = match[3].replace(/\\/g, '/');
46
+ files.push({
47
+ path: filePath,
48
+ staged: indexStatus !== ' ' && indexStatus !== '?' && indexStatus !== '!',
49
+ unstaged: worktreeStatus !== ' ' && worktreeStatus !== '?' && worktreeStatus !== '!',
50
+ untracked: indexStatus === '?' || worktreeStatus === '?',
51
+ });
52
+ }
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 = [];
75
+
76
+ if (status.dirty) {
77
+ warnings.push({
78
+ id: 'dirty_worktree',
79
+ severity: 'warning',
80
+ summary: 'Local worktree contains staged, unstaged, or untracked changes.',
81
+ });
82
+ }
83
+
84
+ if (commitsAheadOfMain !== 'unknown' && commitsAheadOfMain > 0) {
85
+ warnings.push({
86
+ id: 'ahead_of_main',
87
+ severity: 'warning',
88
+ summary: `${commitsAheadOfMain} commit(s) are ahead of main on the current branch.`,
89
+ });
90
+ }
91
+
92
+ if (commitsAheadOfRemote !== 'unknown' && commitsAheadOfRemote > 0) {
93
+ warnings.push({
94
+ id: 'unpushed_commits',
95
+ severity: 'warning',
96
+ summary: `${commitsAheadOfRemote} commit(s) are ahead of the tracked remote branch.`,
97
+ });
98
+ }
99
+
100
+ if (prState === 'none') {
101
+ warnings.push({
102
+ id: 'missing_pr',
103
+ severity: 'warning',
104
+ summary: 'No pull request is associated with the current branch.',
105
+ });
106
+ }
107
+
108
+ if (git.staleBranch) {
109
+ warnings.push({
110
+ id: 'stale_branch',
111
+ severity: 'warning',
112
+ summary: 'The current branch is stale or spent relative to the intended integration surface.',
113
+ });
114
+ }
115
+
116
+ if (git.mixedScope) {
117
+ warnings.push({
118
+ id: 'mixed_scope',
119
+ severity: 'warning',
120
+ summary: 'The current worktree appears to mix multiple write scopes or phases.',
121
+ });
122
+ }
123
+
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',
149
+ prState,
150
+ commitsAheadOfMain,
151
+ commitsAheadOfRemote,
152
+ stagedCount: status.stagedCount,
153
+ unstagedCount: status.unstagedCount,
154
+ untrackedCount: status.untrackedCount,
155
+ dirty: status.dirty,
156
+ },
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
+ }
@@ -30,6 +30,13 @@ agent: ${workflow.agent}
30
30
  ${workflowContent}`;
31
31
  }
32
32
 
33
+ function buildPortableSkillEntries(workflows) {
34
+ return workflows.map((workflow) => ({
35
+ relativePath: `.agents/skills/${workflow.name}/SKILL.md`,
36
+ content: renderSkillContent(workflow),
37
+ }));
38
+ }
39
+
33
40
  function renderOpenCodeCommandContent(workflow) {
34
41
  const workflowContent = getWorkflowContent(workflow.workflow);
35
42
  return `---
@@ -85,6 +92,7 @@ function upsertBoundedBlock(existing, blockContent) {
85
92
  }
86
93
 
87
94
  export {
95
+ buildPortableSkillEntries,
88
96
  getDelegateContent,
89
97
  getWorkflowContent,
90
98
  renderAgentsBoundedBlock,
@@ -0,0 +1,239 @@
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';
9
+ import {
10
+ CLAUDE_MODEL_PROFILES,
11
+ renderClaudeApproachExplorer,
12
+ renderClaudePlanChecker,
13
+ renderClaudePlanCommand,
14
+ renderClaudePlanSkill,
15
+ } from '../adapters/claude.mjs';
16
+ import {
17
+ renderOpenCodeApproachExplorer,
18
+ renderOpenCodePlanChecker,
19
+ renderOpenCodePlanCommand,
20
+ } from '../adapters/opencode.mjs';
21
+ import {
22
+ renderCodexApproachExplorer,
23
+ renderCodexPlanChecker,
24
+ } from '../adapters/codex.mjs';
25
+ import {
26
+ getRuntimeModelOverride,
27
+ loadProjectModelConfig,
28
+ resolveRuntimeAgentModel,
29
+ } from './models.mjs';
30
+
31
+ function normalizeContent(content) {
32
+ return String(content).replace(/\r\n/g, '\n');
33
+ }
34
+
35
+ function compareGeneratedFile({ cwd, runtime, relativePath, expectedContent, repairCommand }) {
36
+ const absolutePath = join(cwd, relativePath);
37
+ if (!existsSync(absolutePath)) {
38
+ return {
39
+ runtime,
40
+ relativePath,
41
+ status: 'missing',
42
+ repairCommand,
43
+ };
44
+ }
45
+
46
+ const actualContent = normalizeContent(readFileSync(absolutePath, 'utf-8'));
47
+ const expected = normalizeContent(expectedContent);
48
+ if (actualContent === expected) {
49
+ return {
50
+ runtime,
51
+ relativePath,
52
+ status: 'clean',
53
+ repairCommand,
54
+ };
55
+ }
56
+
57
+ return {
58
+ runtime,
59
+ relativePath,
60
+ status: 'stale',
61
+ repairCommand,
62
+ };
63
+ }
64
+
65
+ function buildClaudeEntries({ cwd, workflows }) {
66
+ const checkerModelAlias = resolveRuntimeAgentModel({
67
+ cwd,
68
+ runtime: 'claude',
69
+ agentId: 'plan-checker',
70
+ profileMap: CLAUDE_MODEL_PROFILES,
71
+ });
72
+ const explorerModelAlias = resolveRuntimeAgentModel({
73
+ cwd,
74
+ runtime: 'claude',
75
+ agentId: 'approach-explorer',
76
+ profileMap: CLAUDE_MODEL_PROFILES,
77
+ });
78
+
79
+ const entries = workflows.map((workflow) => ({
80
+ relativePath: `.claude/skills/${workflow.name}/SKILL.md`,
81
+ expectedContent: workflow.name === 'gsdd-plan'
82
+ ? renderClaudePlanSkill()
83
+ : renderSkillContent(workflow),
84
+ }));
85
+
86
+ entries.push(
87
+ {
88
+ relativePath: '.claude/commands/gsdd-plan.md',
89
+ expectedContent: renderClaudePlanCommand(),
90
+ },
91
+ {
92
+ relativePath: '.claude/agents/gsdd-plan-checker.md',
93
+ expectedContent: renderClaudePlanChecker(getDelegateContent('plan-checker.md'), checkerModelAlias),
94
+ },
95
+ {
96
+ relativePath: '.claude/agents/gsdd-approach-explorer.md',
97
+ expectedContent: renderClaudeApproachExplorer(getDelegateContent('approach-explorer.md'), explorerModelAlias),
98
+ }
99
+ );
100
+
101
+ return entries;
102
+ }
103
+
104
+ function buildOpenCodeEntries({ cwd, workflows }) {
105
+ const config = loadProjectModelConfig(cwd);
106
+ const checkerModelId = getRuntimeModelOverride(config, 'opencode', 'plan-checker');
107
+ const explorerModelId = getRuntimeModelOverride(config, 'opencode', 'approach-explorer');
108
+
109
+ const entries = workflows.map((workflow) => ({
110
+ relativePath: `.opencode/commands/${workflow.name}.md`,
111
+ expectedContent: workflow.name === 'gsdd-plan'
112
+ ? renderOpenCodePlanCommand()
113
+ : renderOpenCodeCommandContent(workflow),
114
+ }));
115
+
116
+ entries.push(
117
+ {
118
+ relativePath: '.opencode/agents/gsdd-plan-checker.md',
119
+ expectedContent: renderOpenCodePlanChecker(getDelegateContent('plan-checker.md'), checkerModelId),
120
+ },
121
+ {
122
+ relativePath: '.opencode/agents/gsdd-approach-explorer.md',
123
+ expectedContent: renderOpenCodeApproachExplorer(getDelegateContent('approach-explorer.md'), explorerModelId),
124
+ }
125
+ );
126
+
127
+ return entries;
128
+ }
129
+
130
+ function buildCodexEntries({ cwd }) {
131
+ const config = loadProjectModelConfig(cwd);
132
+ const checkerModelId = getRuntimeModelOverride(config, 'codex', 'plan-checker');
133
+ const explorerModelId = getRuntimeModelOverride(config, 'codex', 'approach-explorer');
134
+
135
+ return [
136
+ {
137
+ relativePath: '.codex/agents/gsdd-plan-checker.toml',
138
+ expectedContent: renderCodexPlanChecker(getDelegateContent('plan-checker.md'), checkerModelId),
139
+ },
140
+ {
141
+ relativePath: '.codex/agents/gsdd-approach-explorer.toml',
142
+ expectedContent: renderCodexApproachExplorer(getDelegateContent('approach-explorer.md'), explorerModelId),
143
+ },
144
+ ];
145
+ }
146
+
147
+ export function collectExpectedRuntimeSurfaceGroups({ cwd = process.cwd(), workflows }) {
148
+ return [
149
+ {
150
+ runtime: 'portable',
151
+ label: 'portable skills',
152
+ root: '.agents/skills',
153
+ repairCommand: 'gsdd update',
154
+ entries: buildPortableSkillEntries(workflows).map((entry) => ({
155
+ relativePath: entry.relativePath,
156
+ expectedContent: entry.content,
157
+ })),
158
+ },
159
+ {
160
+ runtime: 'claude',
161
+ label: 'Claude Code native surfaces',
162
+ root: '.claude',
163
+ repairCommand: 'gsdd update --tools claude',
164
+ entries: buildClaudeEntries({ cwd, workflows }),
165
+ },
166
+ {
167
+ runtime: 'opencode',
168
+ label: 'OpenCode native surfaces',
169
+ root: '.opencode',
170
+ repairCommand: 'gsdd update --tools opencode',
171
+ entries: buildOpenCodeEntries({ cwd, workflows }),
172
+ },
173
+ {
174
+ runtime: 'codex',
175
+ label: 'Codex CLI native agents',
176
+ root: '.codex',
177
+ repairCommand: 'gsdd update --tools codex',
178
+ entries: buildCodexEntries({ cwd }),
179
+ },
180
+ ];
181
+ }
182
+
183
+ export function evaluateRuntimeFreshness({ cwd = process.cwd(), workflows = [] }) {
184
+ const groups = collectExpectedRuntimeSurfaceGroups({ cwd, workflows }).map((group) => {
185
+ const installed = existsSync(join(cwd, group.root));
186
+ const comparisons = installed
187
+ ? group.entries.map((entry) => compareGeneratedFile({
188
+ cwd,
189
+ runtime: group.runtime,
190
+ relativePath: entry.relativePath,
191
+ expectedContent: entry.expectedContent,
192
+ repairCommand: group.repairCommand,
193
+ }))
194
+ : [];
195
+
196
+ const stale = comparisons.filter((entry) => entry.status === 'stale');
197
+ const missing = comparisons.filter((entry) => entry.status === 'missing');
198
+
199
+ return {
200
+ ...group,
201
+ installed,
202
+ comparisons,
203
+ stale,
204
+ missing,
205
+ issueCount: stale.length + missing.length,
206
+ };
207
+ });
208
+
209
+ const checkedGroups = groups.filter((group) => group.installed);
210
+ const issues = checkedGroups.flatMap((group) => group.comparisons.filter((entry) => entry.status !== 'clean'));
211
+
212
+ return {
213
+ groups,
214
+ checkedGroups: checkedGroups.map((group) => group.runtime),
215
+ hasInstalledRuntimeSurfaces: checkedGroups.length > 0,
216
+ issueCount: issues.length,
217
+ staleCount: issues.filter((entry) => entry.status === 'stale').length,
218
+ missingCount: issues.filter((entry) => entry.status === 'missing').length,
219
+ issues,
220
+ };
221
+ }
222
+
223
+ export function summarizeRuntimeFreshnessIssues(report, limit = 4) {
224
+ if (!report || report.issueCount === 0) return '';
225
+ const listed = report.issues
226
+ .slice(0, limit)
227
+ .map((entry) => `${entry.relativePath} [${entry.status}]`);
228
+ const remainder = report.issueCount - listed.length;
229
+ return remainder > 0 ? `${listed.join(', ')} (+${remainder} more)` : listed.join(', ');
230
+ }
231
+
232
+ export function getRuntimeFreshnessRepairGuidance(report) {
233
+ if (!report || report.issueCount === 0) return 'Run `gsdd update` to regenerate installed runtime surfaces.';
234
+ const commands = [...new Set(report.issues.map((entry) => entry.repairCommand))];
235
+ if (commands.length === 1) {
236
+ return `Run \`${commands[0]}\` to regenerate the installed runtime surfaces.`;
237
+ }
238
+ return `Run \`gsdd update\` to regenerate all installed runtime surfaces, or target the affected adapters individually: ${commands.map((command) => `\`${command}\``).join(', ')}.`;
239
+ }