@clawplays/ospec-cli 0.1.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.
Files changed (191) hide show
  1. package/.ospec/templates/hooks/post-merge +8 -0
  2. package/.ospec/templates/hooks/pre-commit +8 -0
  3. package/LICENSE +21 -0
  4. package/README.md +549 -0
  5. package/README.zh-CN.md +549 -0
  6. package/assets/for-ai/en-US/ai-guide.md +98 -0
  7. package/assets/for-ai/en-US/execution-protocol.md +64 -0
  8. package/assets/for-ai/zh-CN/ai-guide.md +102 -0
  9. package/assets/for-ai/zh-CN/execution-protocol.md +68 -0
  10. package/assets/git-hooks/post-merge +12 -0
  11. package/assets/git-hooks/pre-commit +12 -0
  12. package/assets/global-skills/claude/ospec-change/SKILL.md +116 -0
  13. package/assets/global-skills/codex/ospec-change/SKILL.md +117 -0
  14. package/assets/global-skills/codex/ospec-change/agents/openai.yaml +7 -0
  15. package/assets/global-skills/codex/ospec-change/skill.yaml +19 -0
  16. package/assets/project-conventions/en-US/development-guide.md +32 -0
  17. package/assets/project-conventions/en-US/naming-conventions.md +51 -0
  18. package/assets/project-conventions/en-US/skill-conventions.md +40 -0
  19. package/assets/project-conventions/en-US/workflow-conventions.md +70 -0
  20. package/assets/project-conventions/zh-CN/development-guide.md +32 -0
  21. package/assets/project-conventions/zh-CN/naming-conventions.md +51 -0
  22. package/assets/project-conventions/zh-CN/skill-conventions.md +40 -0
  23. package/assets/project-conventions/zh-CN/workflow-conventions.md +74 -0
  24. package/dist/adapters/codex-stitch-adapter.js +420 -0
  25. package/dist/adapters/gemini-stitch-adapter.js +408 -0
  26. package/dist/adapters/playwright-checkpoint-adapter.js +2260 -0
  27. package/dist/advanced/BatchOperations.d.ts +36 -0
  28. package/dist/advanced/BatchOperations.js +159 -0
  29. package/dist/advanced/CachingLayer.d.ts +66 -0
  30. package/dist/advanced/CachingLayer.js +136 -0
  31. package/dist/advanced/FeatureUpdater.d.ts +46 -0
  32. package/dist/advanced/FeatureUpdater.js +151 -0
  33. package/dist/advanced/PerformanceMonitor.d.ts +52 -0
  34. package/dist/advanced/PerformanceMonitor.js +129 -0
  35. package/dist/advanced/StatePersistence.d.ts +61 -0
  36. package/dist/advanced/StatePersistence.js +168 -0
  37. package/dist/advanced/index.d.ts +14 -0
  38. package/dist/advanced/index.js +22 -0
  39. package/dist/cli/commands/config.d.ts +5 -0
  40. package/dist/cli/commands/config.js +6 -0
  41. package/dist/cli/commands/feature.d.ts +5 -0
  42. package/dist/cli/commands/feature.js +6 -0
  43. package/dist/cli/commands/index.d.ts +5 -0
  44. package/dist/cli/commands/index.js +6 -0
  45. package/dist/cli/commands/project.d.ts +5 -0
  46. package/dist/cli/commands/project.js +6 -0
  47. package/dist/cli/commands/validate.d.ts +5 -0
  48. package/dist/cli/commands/validate.js +6 -0
  49. package/dist/cli/index.d.ts +5 -0
  50. package/dist/cli/index.js +6 -0
  51. package/dist/cli.d.ts +3 -0
  52. package/dist/cli.js +1007 -0
  53. package/dist/commands/ArchiveCommand.d.ts +14 -0
  54. package/dist/commands/ArchiveCommand.js +241 -0
  55. package/dist/commands/BaseCommand.d.ts +33 -0
  56. package/dist/commands/BaseCommand.js +46 -0
  57. package/dist/commands/BatchCommand.d.ts +5 -0
  58. package/dist/commands/BatchCommand.js +42 -0
  59. package/dist/commands/ChangesCommand.d.ts +3 -0
  60. package/dist/commands/ChangesCommand.js +71 -0
  61. package/dist/commands/DocsCommand.d.ts +5 -0
  62. package/dist/commands/DocsCommand.js +118 -0
  63. package/dist/commands/FinalizeCommand.d.ts +3 -0
  64. package/dist/commands/FinalizeCommand.js +24 -0
  65. package/dist/commands/IndexCommand.d.ts +5 -0
  66. package/dist/commands/IndexCommand.js +57 -0
  67. package/dist/commands/InitCommand.d.ts +5 -0
  68. package/dist/commands/InitCommand.js +65 -0
  69. package/dist/commands/NewCommand.d.ts +11 -0
  70. package/dist/commands/NewCommand.js +262 -0
  71. package/dist/commands/PluginsCommand.d.ts +58 -0
  72. package/dist/commands/PluginsCommand.js +2491 -0
  73. package/dist/commands/ProgressCommand.d.ts +5 -0
  74. package/dist/commands/ProgressCommand.js +103 -0
  75. package/dist/commands/QueueCommand.d.ts +10 -0
  76. package/dist/commands/QueueCommand.js +147 -0
  77. package/dist/commands/RunCommand.d.ts +13 -0
  78. package/dist/commands/RunCommand.js +200 -0
  79. package/dist/commands/SkillCommand.d.ts +31 -0
  80. package/dist/commands/SkillCommand.js +1216 -0
  81. package/dist/commands/SkillsCommand.d.ts +5 -0
  82. package/dist/commands/SkillsCommand.js +68 -0
  83. package/dist/commands/StatusCommand.d.ts +6 -0
  84. package/dist/commands/StatusCommand.js +140 -0
  85. package/dist/commands/UpdateCommand.d.ts +8 -0
  86. package/dist/commands/UpdateCommand.js +251 -0
  87. package/dist/commands/VerifyCommand.d.ts +5 -0
  88. package/dist/commands/VerifyCommand.js +278 -0
  89. package/dist/commands/WorkflowCommand.d.ts +12 -0
  90. package/dist/commands/WorkflowCommand.js +150 -0
  91. package/dist/commands/index.d.ts +43 -0
  92. package/dist/commands/index.js +85 -0
  93. package/dist/core/constants.d.ts +41 -0
  94. package/dist/core/constants.js +73 -0
  95. package/dist/core/errors.d.ts +36 -0
  96. package/dist/core/errors.js +72 -0
  97. package/dist/core/index.d.ts +7 -0
  98. package/dist/core/index.js +23 -0
  99. package/dist/core/types.d.ts +369 -0
  100. package/dist/core/types.js +3 -0
  101. package/dist/index.d.ts +11 -0
  102. package/dist/index.js +27 -0
  103. package/dist/presets/ProjectPresets.d.ts +41 -0
  104. package/dist/presets/ProjectPresets.js +190 -0
  105. package/dist/scaffolds/ProjectScaffoldPresets.d.ts +20 -0
  106. package/dist/scaffolds/ProjectScaffoldPresets.js +151 -0
  107. package/dist/services/ConfigManager.d.ts +14 -0
  108. package/dist/services/ConfigManager.js +386 -0
  109. package/dist/services/FeatureManager.d.ts +5 -0
  110. package/dist/services/FeatureManager.js +6 -0
  111. package/dist/services/FileService.d.ts +21 -0
  112. package/dist/services/FileService.js +152 -0
  113. package/dist/services/IndexBuilder.d.ts +12 -0
  114. package/dist/services/IndexBuilder.js +130 -0
  115. package/dist/services/Logger.d.ts +20 -0
  116. package/dist/services/Logger.js +48 -0
  117. package/dist/services/ProjectAssetRegistry.d.ts +12 -0
  118. package/dist/services/ProjectAssetRegistry.js +96 -0
  119. package/dist/services/ProjectAssetService.d.ts +49 -0
  120. package/dist/services/ProjectAssetService.js +223 -0
  121. package/dist/services/ProjectScaffoldCommandService.d.ts +73 -0
  122. package/dist/services/ProjectScaffoldCommandService.js +159 -0
  123. package/dist/services/ProjectScaffoldService.d.ts +44 -0
  124. package/dist/services/ProjectScaffoldService.js +507 -0
  125. package/dist/services/ProjectService.d.ts +209 -0
  126. package/dist/services/ProjectService.js +13239 -0
  127. package/dist/services/QueueService.d.ts +17 -0
  128. package/dist/services/QueueService.js +142 -0
  129. package/dist/services/RunService.d.ts +40 -0
  130. package/dist/services/RunService.js +420 -0
  131. package/dist/services/SkillParser.d.ts +30 -0
  132. package/dist/services/SkillParser.js +88 -0
  133. package/dist/services/StateManager.d.ts +16 -0
  134. package/dist/services/StateManager.js +127 -0
  135. package/dist/services/TemplateEngine.d.ts +43 -0
  136. package/dist/services/TemplateEngine.js +119 -0
  137. package/dist/services/TemplateGenerator.d.ts +40 -0
  138. package/dist/services/TemplateGenerator.js +273 -0
  139. package/dist/services/ValidationService.d.ts +19 -0
  140. package/dist/services/ValidationService.js +44 -0
  141. package/dist/services/Validator.d.ts +5 -0
  142. package/dist/services/Validator.js +6 -0
  143. package/dist/services/index.d.ts +52 -0
  144. package/dist/services/index.js +91 -0
  145. package/dist/services/templates/ExecutionTemplateBuilder.d.ts +12 -0
  146. package/dist/services/templates/ExecutionTemplateBuilder.js +300 -0
  147. package/dist/services/templates/ProjectTemplateBuilder.d.ts +38 -0
  148. package/dist/services/templates/ProjectTemplateBuilder.js +1897 -0
  149. package/dist/services/templates/TemplateBuilderBase.d.ts +19 -0
  150. package/dist/services/templates/TemplateBuilderBase.js +60 -0
  151. package/dist/services/templates/TemplateInputFactory.d.ts +16 -0
  152. package/dist/services/templates/TemplateInputFactory.js +298 -0
  153. package/dist/services/templates/templateTypes.d.ts +90 -0
  154. package/dist/services/templates/templateTypes.js +3 -0
  155. package/dist/tools/build-index.js +632 -0
  156. package/dist/utils/DateUtils.d.ts +18 -0
  157. package/dist/utils/DateUtils.js +40 -0
  158. package/dist/utils/PathUtils.d.ts +9 -0
  159. package/dist/utils/PathUtils.js +66 -0
  160. package/dist/utils/StringUtils.d.ts +26 -0
  161. package/dist/utils/StringUtils.js +47 -0
  162. package/dist/utils/helpers.d.ts +5 -0
  163. package/dist/utils/helpers.js +6 -0
  164. package/dist/utils/index.d.ts +7 -0
  165. package/dist/utils/index.js +23 -0
  166. package/dist/utils/logger.d.ts +5 -0
  167. package/dist/utils/logger.js +6 -0
  168. package/dist/utils/path.d.ts +5 -0
  169. package/dist/utils/path.js +6 -0
  170. package/dist/utils/subcommandHelp.d.ts +11 -0
  171. package/dist/utils/subcommandHelp.js +119 -0
  172. package/dist/workflow/ArchiveGate.d.ts +30 -0
  173. package/dist/workflow/ArchiveGate.js +93 -0
  174. package/dist/workflow/ConfigurableWorkflow.d.ts +89 -0
  175. package/dist/workflow/ConfigurableWorkflow.js +186 -0
  176. package/dist/workflow/HookSystem.d.ts +38 -0
  177. package/dist/workflow/HookSystem.js +66 -0
  178. package/dist/workflow/IndexRegenerator.d.ts +49 -0
  179. package/dist/workflow/IndexRegenerator.js +147 -0
  180. package/dist/workflow/PluginWorkflowComposer.d.ts +138 -0
  181. package/dist/workflow/PluginWorkflowComposer.js +239 -0
  182. package/dist/workflow/SkillUpdateEngine.d.ts +26 -0
  183. package/dist/workflow/SkillUpdateEngine.js +113 -0
  184. package/dist/workflow/VerificationSystem.d.ts +24 -0
  185. package/dist/workflow/VerificationSystem.js +116 -0
  186. package/dist/workflow/WorkflowEngine.d.ts +15 -0
  187. package/dist/workflow/WorkflowEngine.js +57 -0
  188. package/dist/workflow/index.d.ts +19 -0
  189. package/dist/workflow/index.js +32 -0
  190. package/package.json +78 -0
  191. package/scripts/postinstall.js +43 -0
@@ -0,0 +1,2491 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PluginsCommand = void 0;
4
+ const child_process_1 = require("child_process");
5
+ const path = require("path");
6
+ const gray_matter_1 = require("gray-matter");
7
+ const constants_1 = require("../core/constants");
8
+ const BaseCommand_1 = require("./BaseCommand");
9
+ const services_1 = require("../services");
10
+ const subcommandHelp_1 = require("../utils/subcommandHelp");
11
+ class PluginsCommand extends BaseCommand_1.BaseCommand {
12
+ async execute(action, ...args) {
13
+ try {
14
+ if ((0, subcommandHelp_1.isHelpAction)(action)) {
15
+ this.info((0, subcommandHelp_1.getPluginsHelpText)());
16
+ return;
17
+ }
18
+ switch (action) {
19
+ case 'list': {
20
+ await this.listPlugins(args[0] || process.cwd());
21
+ break;
22
+ }
23
+ case 'status': {
24
+ await this.showStatus(args[0] || process.cwd());
25
+ break;
26
+ }
27
+ case 'doctor': {
28
+ if (args.length === 0) {
29
+ console.error('Usage: ospec plugins doctor <plugin> [path]');
30
+ process.exit(1);
31
+ }
32
+ const parsedDoctorArgs = this.parsePluginProjectArgs(args.slice(1));
33
+ if (parsedDoctorArgs.options.base_url) {
34
+ throw new Error('doctor does not accept --base-url. Use "ospec plugins enable checkpoint --base-url <url>" to update checkpoint runtime.base_url.');
35
+ }
36
+ await this.doctorPlugin(args[0], parsedDoctorArgs.projectPath || process.cwd());
37
+ break;
38
+ }
39
+ case 'enable': {
40
+ if (args.length === 0) {
41
+ console.error('Usage: ospec plugins enable <plugin> [path] [--base-url <url>]');
42
+ process.exit(1);
43
+ }
44
+ const parsedEnableArgs = this.parsePluginProjectArgs(args.slice(1));
45
+ await this.setPluginEnabled(args[0], true, parsedEnableArgs.projectPath || process.cwd(), parsedEnableArgs.options);
46
+ break;
47
+ }
48
+ case 'disable': {
49
+ if (args.length === 0) {
50
+ console.error('Usage: ospec plugins disable <plugin> [path]');
51
+ process.exit(1);
52
+ }
53
+ const parsedDisableArgs = this.parsePluginProjectArgs(args.slice(1));
54
+ await this.setPluginEnabled(args[0], false, parsedDisableArgs.projectPath || process.cwd(), parsedDisableArgs.options);
55
+ break;
56
+ }
57
+ case 'run': {
58
+ if (args.length < 2) {
59
+ console.error('Usage: ospec plugins run <plugin> <change-path>');
60
+ process.exit(1);
61
+ }
62
+ await this.runPlugin(args[0], args[1]);
63
+ break;
64
+ }
65
+ case 'approve': {
66
+ if (args.length < 2) {
67
+ console.error('Usage: ospec plugins approve stitch <change-path>');
68
+ process.exit(1);
69
+ }
70
+ await this.setPluginApproval(args[0], 'approved', args[1]);
71
+ break;
72
+ }
73
+ case 'reject': {
74
+ if (args.length < 2) {
75
+ console.error('Usage: ospec plugins reject stitch <change-path>');
76
+ process.exit(1);
77
+ }
78
+ await this.setPluginApproval(args[0], 'rejected', args[1]);
79
+ break;
80
+ }
81
+ default:
82
+ this.info((0, subcommandHelp_1.getPluginsHelpText)());
83
+ }
84
+ }
85
+ catch (error) {
86
+ this.error(`Plugins command failed: ${error}`);
87
+ throw error;
88
+ }
89
+ }
90
+ async listPlugins(projectPath) {
91
+ const config = await services_1.services.configManager.loadConfig(projectPath);
92
+ const plugins = this.getPluginEntries(config);
93
+ console.log('\nAvailable Plugins:');
94
+ console.log('==================\n');
95
+ if (plugins.length === 0) {
96
+ console.log(' (none)');
97
+ console.log('\n' + '='.repeat(18) + '\n');
98
+ return;
99
+ }
100
+ plugins.forEach(plugin => {
101
+ console.log(` - ${plugin.name}`);
102
+ console.log(` Enabled: ${plugin.enabled ? 'yes' : 'no'}`);
103
+ console.log(` Blocking: ${plugin.blocking ? 'yes' : 'no'}`);
104
+ console.log(` Capabilities: ${plugin.capabilities.length}`);
105
+ if (plugin.runner) {
106
+ console.log(` Runner configured: ${plugin.runner.configured ? 'yes' : 'no'}`);
107
+ }
108
+ });
109
+ console.log('\n' + '='.repeat(18) + '\n');
110
+ }
111
+ async showStatus(projectPath) {
112
+ const config = await services_1.services.configManager.loadConfig(projectPath);
113
+ const plugins = this.getPluginEntries(config);
114
+ console.log('\nPlugin Status:');
115
+ console.log('==============\n');
116
+ console.log(`Project: ${projectPath}\n`);
117
+ if (plugins.length === 0) {
118
+ console.log('No plugins configured.\n');
119
+ console.log('='.repeat(14) + '\n');
120
+ return;
121
+ }
122
+ plugins.forEach(plugin => {
123
+ console.log(`${plugin.name.toUpperCase()}: ${plugin.enabled ? 'ENABLED' : 'DISABLED'}`);
124
+ console.log(` Blocking: ${plugin.blocking ? 'yes' : 'no'}`);
125
+ if (plugin.runtimeBaseUrl) {
126
+ console.log(` Base URL: ${plugin.runtimeBaseUrl}`);
127
+ }
128
+ if (plugin.storageState) {
129
+ console.log(` Storage state: ${plugin.storageState}`);
130
+ }
131
+ if (plugin.capabilities.length === 0) {
132
+ console.log(' Capabilities: (none)');
133
+ }
134
+ else {
135
+ console.log(' Capabilities:');
136
+ plugin.capabilities.forEach(capability => {
137
+ console.log(` - ${capability.name}: ${capability.enabled ? 'enabled' : 'disabled'}`);
138
+ if (capability.step) {
139
+ console.log(` Step: ${capability.step}`);
140
+ }
141
+ if (capability.activateWhenFlags.length > 0) {
142
+ console.log(` Triggers: ${capability.activateWhenFlags.join(', ')}`);
143
+ }
144
+ });
145
+ }
146
+ if (plugin.runner) {
147
+ console.log(' Runner:');
148
+ console.log(` Mode: ${plugin.runner.mode}`);
149
+ console.log(` Configured: ${plugin.runner.configured ? 'yes' : 'no'}`);
150
+ console.log(` Command: ${plugin.runner.command || '(not set)'}`);
151
+ if (plugin.runner.source) {
152
+ console.log(` Source: ${plugin.runner.source}`);
153
+ }
154
+ console.log(` Cwd: ${plugin.runner.cwd || '(project root)'}`);
155
+ console.log(` Timeout: ${plugin.runner.timeoutMs}ms`);
156
+ if (plugin.runner.tokenEnv) {
157
+ console.log(` Token env: ${plugin.runner.tokenEnv} (${plugin.runner.tokenPresent ? 'set' : 'missing'})`);
158
+ }
159
+ else {
160
+ console.log(' Token env: (not required)');
161
+ }
162
+ if (plugin.runner.extraEnvCount > 0) {
163
+ console.log(` Extra env entries: ${plugin.runner.extraEnvCount}`);
164
+ }
165
+ console.log(` Doctor: ospec plugins doctor ${plugin.name} ${projectPath}`);
166
+ }
167
+ console.log();
168
+ });
169
+ console.log('Note: plugin changes affect new changes by default.');
170
+ console.log('Existing active changes should be migrated explicitly later.\n');
171
+ console.log('='.repeat(14) + '\n');
172
+ }
173
+ async setPluginEnabled(pluginName, enabled, projectPath, options = {}) {
174
+ const normalizedName = this.resolvePluginAlias(pluginName);
175
+ const config = await services_1.services.configManager.loadConfig(projectPath);
176
+ const nextConfig = JSON.parse(JSON.stringify(config));
177
+ switch (normalizedName) {
178
+ case 'stitch': {
179
+ if (options.base_url) {
180
+ throw new Error('stitch does not accept --base-url. Only checkpoint requires a runtime base URL.');
181
+ }
182
+ nextConfig.plugins = nextConfig.plugins || {};
183
+ nextConfig.plugins.stitch = nextConfig.plugins.stitch || this.createDefaultStitchPluginConfig();
184
+ nextConfig.plugins.stitch.runner = nextConfig.plugins.stitch.runner || this.createDefaultStitchPluginConfig().runner;
185
+ nextConfig.plugins.stitch.gemini = nextConfig.plugins.stitch.gemini || {
186
+ model: 'gemini-3-flash-preview',
187
+ auto_switch_on_limit: true,
188
+ save_on_fallback: true,
189
+ };
190
+ nextConfig.plugins.stitch.codex = nextConfig.plugins.stitch.codex || {
191
+ model: '',
192
+ mcp_server: 'stitch',
193
+ };
194
+ if (!nextConfig.plugins.stitch.provider) {
195
+ nextConfig.plugins.stitch.provider = 'gemini';
196
+ }
197
+ const provider = this.getStitchProvider(nextConfig.plugins.stitch);
198
+ if (!nextConfig.plugins.stitch.runner.command) {
199
+ nextConfig.plugins.stitch.runner.command = 'node';
200
+ }
201
+ if (!Array.isArray(nextConfig.plugins.stitch.runner.args) ||
202
+ nextConfig.plugins.stitch.runner.args.length === 0 ||
203
+ this.isBuiltInGeminiRunner(nextConfig.plugins.stitch.runner) ||
204
+ this.isBuiltInCodexRunner(nextConfig.plugins.stitch.runner)) {
205
+ nextConfig.plugins.stitch.runner.args = this.getDefaultRunnerArgs(provider);
206
+ }
207
+ if (!nextConfig.plugins.stitch.runner.cwd) {
208
+ nextConfig.plugins.stitch.runner.cwd = '${project_path}';
209
+ }
210
+ if (typeof nextConfig.plugins.stitch.runner.token_env !== 'string' || (provider === 'gemini' && this.isBuiltInGeminiRunner(nextConfig.plugins.stitch.runner) && nextConfig.plugins.stitch.runner.token_env.trim() === 'STITCH_API_TOKEN')) {
211
+ nextConfig.plugins.stitch.runner.token_env = '';
212
+ }
213
+ nextConfig.plugins.stitch.capabilities = nextConfig.plugins.stitch.capabilities || {};
214
+ nextConfig.plugins.stitch.capabilities.page_design_review = nextConfig.plugins.stitch.capabilities.page_design_review || {
215
+ enabled: false,
216
+ step: 'stitch_design_review',
217
+ activate_when_flags: ['ui_change', 'page_design', 'landing_page'],
218
+ };
219
+ nextConfig.plugins.stitch.enabled = enabled;
220
+ nextConfig.plugins.stitch.capabilities.page_design_review.enabled = enabled;
221
+ if (enabled) {
222
+ await this.ensureStitchWorkspaceScaffold(projectPath, nextConfig.plugins.stitch);
223
+ }
224
+ await services_1.services.configManager.saveConfig(projectPath, nextConfig);
225
+ this.success(`${enabled ? 'Enabled' : 'Disabled'} plugin stitch for ${projectPath}`);
226
+ this.info(` page_design_review: ${enabled ? 'enabled' : 'disabled'}`);
227
+ this.info(` provider: ${provider}`);
228
+ this.info(` runner.command: ${nextConfig.plugins.stitch.runner.command || '(built-in adapter)'}`);
229
+ this.info(` canonical project: ${nextConfig.plugins.stitch.project?.project_id || '(save on first successful run)'}`);
230
+ this.info(` gemini model: ${this.getStitchGeminiConfig(nextConfig.plugins.stitch).model}`);
231
+ this.info(` codex model: ${this.getStitchCodexConfig(nextConfig.plugins.stitch).model || '(cli default)'}`);
232
+ if (enabled) {
233
+ this.info(` token env: ${nextConfig.plugins.stitch.runner.token_env || '(not required)'}`);
234
+ this.info(` doctor: ospec plugins doctor stitch ${projectPath}`);
235
+ }
236
+ this.info(' Affects new changes by default; update existing changes manually if needed');
237
+ return;
238
+ }
239
+ case 'checkpoint': {
240
+ if (!enabled && options.base_url) {
241
+ throw new Error('disable checkpoint does not accept --base-url.');
242
+ }
243
+ nextConfig.plugins = nextConfig.plugins || {};
244
+ nextConfig.plugins.checkpoint = nextConfig.plugins.checkpoint || this.createDefaultCheckpointPluginConfig();
245
+ nextConfig.plugins.checkpoint.runtime = nextConfig.plugins.checkpoint.runtime || this.createDefaultCheckpointPluginConfig().runtime;
246
+ nextConfig.plugins.checkpoint.runner = nextConfig.plugins.checkpoint.runner || this.createDefaultCheckpointPluginConfig().runner;
247
+ nextConfig.plugins.checkpoint.capabilities = nextConfig.plugins.checkpoint.capabilities || {};
248
+ nextConfig.plugins.checkpoint.capabilities.ui_review = nextConfig.plugins.checkpoint.capabilities.ui_review || {
249
+ enabled: false,
250
+ step: 'checkpoint_ui_review',
251
+ activate_when_flags: ['ui_change', 'page_design', 'landing_page'],
252
+ };
253
+ nextConfig.plugins.checkpoint.capabilities.flow_check = nextConfig.plugins.checkpoint.capabilities.flow_check || {
254
+ enabled: false,
255
+ step: 'checkpoint_flow_check',
256
+ activate_when_flags: ['feature_flow', 'api_change', 'backend_change', 'integration_change'],
257
+ };
258
+ nextConfig.plugins.checkpoint.stitch_integration = nextConfig.plugins.checkpoint.stitch_integration || {
259
+ enabled: true,
260
+ auto_pass_stitch_review: true,
261
+ };
262
+ nextConfig.plugins.checkpoint.runtime.startup = nextConfig.plugins.checkpoint.runtime.startup || this.createDefaultCheckpointPluginConfig().runtime.startup;
263
+ nextConfig.plugins.checkpoint.runtime.readiness = nextConfig.plugins.checkpoint.runtime.readiness || this.createDefaultCheckpointPluginConfig().runtime.readiness;
264
+ nextConfig.plugins.checkpoint.runtime.auth = nextConfig.plugins.checkpoint.runtime.auth || this.createDefaultCheckpointPluginConfig().runtime.auth;
265
+ nextConfig.plugins.checkpoint.runtime.shutdown = nextConfig.plugins.checkpoint.runtime.shutdown || this.createDefaultCheckpointPluginConfig().runtime.shutdown;
266
+ const requestedBaseUrl = typeof options.base_url === 'string' ? options.base_url.trim() : '';
267
+ const persistedBaseUrl = typeof nextConfig.plugins.checkpoint.runtime.base_url === 'string'
268
+ ? nextConfig.plugins.checkpoint.runtime.base_url.trim()
269
+ : '';
270
+ const effectiveBaseUrl = requestedBaseUrl || persistedBaseUrl;
271
+ if (enabled) {
272
+ if (!effectiveBaseUrl) {
273
+ throw new Error('checkpoint requires --base-url <url> the first time it is enabled.');
274
+ }
275
+ if (!this.isHttpUrl(effectiveBaseUrl)) {
276
+ throw new Error(`Invalid checkpoint base URL: ${effectiveBaseUrl}`);
277
+ }
278
+ nextConfig.plugins.checkpoint.runtime.base_url = effectiveBaseUrl;
279
+ if (!nextConfig.plugins.checkpoint.runtime.readiness.url) {
280
+ nextConfig.plugins.checkpoint.runtime.readiness.url = effectiveBaseUrl;
281
+ }
282
+ }
283
+ if (!nextConfig.plugins.checkpoint.runner.command) {
284
+ nextConfig.plugins.checkpoint.runner.command = 'node';
285
+ }
286
+ if (!Array.isArray(nextConfig.plugins.checkpoint.runner.args) ||
287
+ nextConfig.plugins.checkpoint.runner.args.length === 0 ||
288
+ this.isBuiltInCheckpointRunner(nextConfig.plugins.checkpoint.runner)) {
289
+ nextConfig.plugins.checkpoint.runner.args = this.getDefaultCheckpointRunnerArgs();
290
+ }
291
+ if (!nextConfig.plugins.checkpoint.runner.cwd) {
292
+ nextConfig.plugins.checkpoint.runner.cwd = '${project_path}';
293
+ }
294
+ if (typeof nextConfig.plugins.checkpoint.runner.token_env !== 'string') {
295
+ nextConfig.plugins.checkpoint.runner.token_env = '';
296
+ }
297
+ nextConfig.plugins.checkpoint.enabled = enabled;
298
+ nextConfig.plugins.checkpoint.capabilities.ui_review.enabled = enabled;
299
+ nextConfig.plugins.checkpoint.capabilities.flow_check.enabled = enabled;
300
+ if (enabled) {
301
+ await this.ensureCheckpointWorkspaceScaffold(projectPath, nextConfig.plugins.checkpoint);
302
+ }
303
+ await services_1.services.configManager.saveConfig(projectPath, nextConfig);
304
+ this.success(`${enabled ? 'Enabled' : 'Disabled'} plugin checkpoint for ${projectPath}`);
305
+ this.info(` ui_review: ${enabled ? 'enabled' : 'disabled'}`);
306
+ this.info(` flow_check: ${enabled ? 'enabled' : 'disabled'}`);
307
+ this.info(` base_url: ${nextConfig.plugins.checkpoint.runtime.base_url || '(not set)'}`);
308
+ this.info(` readiness.url: ${nextConfig.plugins.checkpoint.runtime.readiness?.url || '(not set)'}`);
309
+ this.info(` auth.command: ${nextConfig.plugins.checkpoint.runtime.auth?.command || '(not set)'}`);
310
+ this.info(` storage_state: ${nextConfig.plugins.checkpoint.runtime.storage_state || '(not set)'}`);
311
+ this.info(` runner.command: ${nextConfig.plugins.checkpoint.runner.command || '(built-in adapter)'}`);
312
+ this.info(` stitch integration: ${nextConfig.plugins.checkpoint.stitch_integration.enabled ? 'enabled' : 'disabled'}`);
313
+ if (enabled) {
314
+ this.info(` doctor: ospec plugins doctor checkpoint ${projectPath}`);
315
+ }
316
+ this.info(' Affects new changes by default; update existing changes manually if needed');
317
+ return;
318
+ }
319
+ default:
320
+ throw new Error(`Unsupported plugin: ${pluginName}`);
321
+ }
322
+ }
323
+ async doctorPlugin(pluginName, projectPath) {
324
+ const normalizedName = this.resolvePluginAlias(pluginName);
325
+ switch (normalizedName) {
326
+ case 'stitch':
327
+ await this.doctorStitch(projectPath);
328
+ return;
329
+ case 'checkpoint':
330
+ await this.doctorCheckpoint(projectPath);
331
+ return;
332
+ default:
333
+ throw new Error(`Unsupported plugin: ${pluginName}`);
334
+ }
335
+ }
336
+ async doctorStitch(projectPath) {
337
+ const config = await services_1.services.configManager.loadConfig(projectPath);
338
+ const stitchConfig = config.plugins?.stitch;
339
+ const provider = this.getStitchProvider(stitchConfig);
340
+ const checks = [];
341
+ checks.push({
342
+ name: 'plugin.enabled',
343
+ status: stitchConfig?.enabled ? 'pass' : 'fail',
344
+ message: stitchConfig?.enabled
345
+ ? 'stitch plugin is enabled for this project'
346
+ : 'stitch plugin is disabled. Run "ospec plugins enable stitch <project-path>" first.',
347
+ });
348
+ const capabilityEnabled = stitchConfig?.capabilities?.page_design_review?.enabled === true;
349
+ checks.push({
350
+ name: 'capability.page_design_review',
351
+ status: capabilityEnabled ? 'pass' : 'fail',
352
+ message: capabilityEnabled
353
+ ? 'page_design_review capability is enabled'
354
+ : 'page_design_review capability is disabled',
355
+ });
356
+ checks.push({
357
+ name: 'provider',
358
+ status: provider ? 'pass' : 'warn',
359
+ message: `Configured provider is ${provider}`,
360
+ });
361
+ const runner = this.getEffectiveStitchRunnerConfig(stitchConfig, stitchConfig?.runner);
362
+ const runnerMode = typeof runner?.mode === 'string' ? runner.mode : 'command';
363
+ checks.push({
364
+ name: 'runner.mode',
365
+ status: runnerMode === 'command' ? 'pass' : 'fail',
366
+ message: runnerMode === 'command'
367
+ ? 'Command runner mode is configured'
368
+ : `Unsupported Stitch runner mode: ${runnerMode}`,
369
+ });
370
+ const command = typeof runner?.command === 'string' ? runner.command.trim() : '';
371
+ checks.push({
372
+ name: 'runner.command',
373
+ status: command.length > 0 ? 'pass' : 'fail',
374
+ message: command.length > 0
375
+ ? `Runner command is ready: ${command}${provider === 'gemini' && this.isBuiltInGeminiRunner(runner) ? ' (built-in Gemini adapter)' : provider === 'codex' && this.isBuiltInCodexRunner(runner) ? ' (built-in Codex adapter)' : ''}`
376
+ : 'Configure .skillrc.plugins.stitch.runner.command before using stitch',
377
+ });
378
+ if (command.length > 0) {
379
+ const availability = await this.checkCommandAvailability(command, projectPath);
380
+ checks.push({
381
+ name: 'runner.command.available',
382
+ status: availability.available ? 'pass' : 'fail',
383
+ message: availability.message,
384
+ });
385
+ }
386
+ const tokenEnv = typeof runner?.token_env === 'string' ? runner.token_env.trim() : '';
387
+ checks.push({
388
+ name: 'runner.token_env',
389
+ status: tokenEnv.length === 0 || Boolean(process.env[tokenEnv]) ? 'pass' : 'fail',
390
+ message: tokenEnv.length === 0
391
+ ? 'No token environment variable required'
392
+ : process.env[tokenEnv]
393
+ ? `Token environment variable is set: ${tokenEnv}`
394
+ : `Missing required token environment variable: ${tokenEnv}`,
395
+ });
396
+ const timeoutMs = Number.isFinite(runner?.timeout_ms) && runner.timeout_ms > 0 ? Math.floor(runner.timeout_ms) : 900000;
397
+ checks.push({
398
+ name: 'runner.timeout_ms',
399
+ status: timeoutMs >= 1000 ? 'pass' : 'warn',
400
+ message: `Runner timeout is ${timeoutMs}ms`,
401
+ });
402
+ if (provider === 'gemini') {
403
+ const usingBuiltInGeminiRunner = this.isBuiltInGeminiRunner(runner);
404
+ const geminiConfig = this.getStitchGeminiConfig(stitchConfig);
405
+ checks.push({
406
+ name: 'gemini.model',
407
+ status: geminiConfig.model ? 'pass' : 'warn',
408
+ message: geminiConfig.model
409
+ ? `Configured Gemini model is ${geminiConfig.model}${geminiConfig.auto_switch_on_limit ? ' with automatic fallback enabled' : ''}`
410
+ : 'No Gemini model is configured. The built-in adapter will rely on the Gemini CLI default model.',
411
+ });
412
+ const geminiMcp = await this.inspectGeminiCliStitch(projectPath);
413
+ checks.push({
414
+ name: 'gemini-cli.available',
415
+ status: usingBuiltInGeminiRunner ? (geminiMcp.geminiAvailable ? 'pass' : 'fail') : 'pass',
416
+ message: usingBuiltInGeminiRunner
417
+ ? geminiMcp.geminiAvailable
418
+ ? `Gemini CLI is available: ${geminiMcp.geminiCommandPath || 'gemini'}`
419
+ : 'Gemini CLI is not available on PATH. Install @google/gemini-cli to use the built-in Gemini Stitch adapter.'
420
+ : 'Gemini CLI readiness is not required because stitch.runner is customized.',
421
+ });
422
+ checks.push({
423
+ name: 'gemini-cli.settings',
424
+ status: usingBuiltInGeminiRunner ? (geminiMcp.settingsExists ? 'pass' : 'fail') : 'pass',
425
+ message: usingBuiltInGeminiRunner
426
+ ? geminiMcp.settingsExists
427
+ ? `Gemini settings detected: ${geminiMcp.settingsPath}`
428
+ : 'Gemini settings.json was not found in the default user profile location.'
429
+ : 'Gemini settings.json is not required because stitch.runner is customized.',
430
+ });
431
+ checks.push({
432
+ name: 'gemini-cli.stitch-mcp',
433
+ status: usingBuiltInGeminiRunner ? (geminiMcp.stitchMcpConfigured ? 'pass' : 'fail') : 'pass',
434
+ message: usingBuiltInGeminiRunner
435
+ ? geminiMcp.stitchMcpConfigured
436
+ ? `Gemini CLI Stitch MCP is configured${geminiMcp.stitchMcpType ? ` (${geminiMcp.stitchMcpType})` : ''}`
437
+ : 'Gemini CLI Stitch MCP was not found in settings.json. Follow docs/stitch-plugin-spec.zh-CN.md and add mcpServers.stitch before using the built-in adapter.'
438
+ : 'Gemini MCP config is not required because stitch.runner is customized.',
439
+ });
440
+ checks.push({
441
+ name: 'gemini-cli.stitch-url',
442
+ status: usingBuiltInGeminiRunner ? (geminiMcp.stitchHttpUrlConfigured ? 'pass' : 'fail') : 'pass',
443
+ message: usingBuiltInGeminiRunner
444
+ ? geminiMcp.stitchHttpUrlConfigured
445
+ ? 'Gemini CLI Stitch MCP uses the documented httpUrl'
446
+ : 'Gemini CLI Stitch MCP must set httpUrl = "https://stitch.googleapis.com/mcp" as documented in docs/stitch-plugin-spec.zh-CN.md.'
447
+ : 'Gemini MCP URL is not required because stitch.runner is customized.',
448
+ });
449
+ checks.push({
450
+ name: 'gemini-cli.stitch-auth',
451
+ status: usingBuiltInGeminiRunner ? (geminiMcp.stitchAuthConfigured ? 'pass' : 'fail') : 'pass',
452
+ message: usingBuiltInGeminiRunner
453
+ ? geminiMcp.stitchAuthConfigured
454
+ ? 'Gemini CLI Stitch MCP includes the documented X-Goog-Api-Key header'
455
+ : 'Gemini CLI Stitch MCP must set headers.X-Goog-Api-Key as documented in docs/stitch-plugin-spec.zh-CN.md.'
456
+ : 'Gemini MCP auth is not required because stitch.runner is customized.',
457
+ });
458
+ }
459
+ else {
460
+ const usingBuiltInCodexRunner = this.isBuiltInCodexRunner(runner);
461
+ const codexConfig = this.getStitchCodexConfig(stitchConfig);
462
+ checks.push({
463
+ name: 'codex.model',
464
+ status: 'pass',
465
+ message: codexConfig.model
466
+ ? `Configured Codex model is ${codexConfig.model}`
467
+ : 'No Codex model is configured. The built-in adapter will rely on the Codex CLI default model.',
468
+ });
469
+ const codexMcp = await this.inspectCodexCliStitch(projectPath);
470
+ checks.push({
471
+ name: 'codex-cli.available',
472
+ status: usingBuiltInCodexRunner ? (codexMcp.codexAvailable ? 'pass' : 'fail') : 'pass',
473
+ message: usingBuiltInCodexRunner
474
+ ? codexMcp.codexAvailable
475
+ ? `Codex CLI is available: ${codexMcp.codexCommandPath || 'codex'}`
476
+ : 'Codex CLI is not available on PATH. Install Codex CLI to use the built-in Codex Stitch adapter.'
477
+ : 'Codex CLI readiness is not required because stitch.runner is customized.',
478
+ });
479
+ checks.push({
480
+ name: 'codex-cli.settings',
481
+ status: usingBuiltInCodexRunner ? (codexMcp.settingsExists ? 'pass' : 'fail') : 'pass',
482
+ message: usingBuiltInCodexRunner
483
+ ? codexMcp.settingsExists
484
+ ? `Codex config detected: ${codexMcp.settingsPath}`
485
+ : 'Codex config.toml was not found in the default user profile location.'
486
+ : 'Codex config.toml is not required because stitch.runner is customized.',
487
+ });
488
+ checks.push({
489
+ name: 'codex-cli.stitch-mcp',
490
+ status: usingBuiltInCodexRunner ? (codexMcp.stitchMcpConfigured ? 'pass' : 'fail') : 'pass',
491
+ message: usingBuiltInCodexRunner
492
+ ? codexMcp.stitchMcpConfigured
493
+ ? 'Codex Stitch MCP is configured in config.toml'
494
+ : 'Codex Stitch MCP was not found in config.toml. Follow docs/stitch-plugin-spec.zh-CN.md and add [mcp_servers.stitch] before using the built-in adapter.'
495
+ : 'Codex MCP config is not required because stitch.runner is customized.',
496
+ });
497
+ checks.push({
498
+ name: 'codex-cli.stitch-transport',
499
+ status: usingBuiltInCodexRunner ? (codexMcp.stitchTransportHttp ? 'pass' : 'fail') : 'pass',
500
+ message: usingBuiltInCodexRunner
501
+ ? codexMcp.stitchTransportHttp
502
+ ? 'Codex Stitch MCP uses the documented HTTP transport'
503
+ : 'Codex Stitch MCP must set type = "http" as documented in docs/stitch-plugin-spec.zh-CN.md.'
504
+ : 'Codex MCP transport is not required because stitch.runner is customized.',
505
+ });
506
+ checks.push({
507
+ name: 'codex-cli.stitch-url',
508
+ status: usingBuiltInCodexRunner ? (codexMcp.stitchUrlConfigured ? 'pass' : 'fail') : 'pass',
509
+ message: usingBuiltInCodexRunner
510
+ ? codexMcp.stitchUrlConfigured
511
+ ? 'Codex Stitch MCP uses the documented Stitch MCP URL'
512
+ : 'Codex Stitch MCP must set url = "https://stitch.googleapis.com/mcp" as documented in docs/stitch-plugin-spec.zh-CN.md.'
513
+ : 'Codex MCP URL is not required because stitch.runner is customized.',
514
+ });
515
+ checks.push({
516
+ name: 'codex-cli.stitch-auth',
517
+ status: usingBuiltInCodexRunner ? (codexMcp.stitchAuthConfigured ? 'pass' : 'fail') : 'pass',
518
+ message: usingBuiltInCodexRunner
519
+ ? codexMcp.stitchAuthConfigured
520
+ ? 'Codex Stitch MCP includes the documented X-Goog-Api-Key header'
521
+ : 'Codex Stitch MCP must set X-Goog-Api-Key in headers or [mcp_servers.stitch.http_headers] as documented in docs/stitch-plugin-spec.zh-CN.md.'
522
+ : 'Codex MCP auth is not required because stitch.runner is customized.',
523
+ });
524
+ checks.push({
525
+ name: 'codex-cli.write-bypass',
526
+ status: usingBuiltInCodexRunner ? 'pass' : 'warn',
527
+ message: usingBuiltInCodexRunner
528
+ ? 'The built-in Codex Stitch adapter runs write operations with --dangerously-bypass-approvals-and-sandbox.'
529
+ : 'Custom Codex Stitch runners must explicitly pass --dangerously-bypass-approvals-and-sandbox for Stitch MCP write operations, or create/update calls can stall before mcp_tool_call even when read-only calls succeed.',
530
+ });
531
+ }
532
+ const failCount = checks.filter(check => check.status === 'fail').length;
533
+ const warnCount = checks.filter(check => check.status === 'warn').length;
534
+ console.log('\nPlugin Doctor');
535
+ console.log('=============\n');
536
+ console.log(`Project: ${projectPath}`);
537
+ console.log('Plugin: stitch\n');
538
+ checks.forEach(check => {
539
+ const icon = check.status === 'pass' ? 'PASS' : check.status === 'warn' ? 'WARN' : 'FAIL';
540
+ console.log(`${icon} ${check.name}: ${check.message}`);
541
+ });
542
+ console.log('');
543
+ console.log('Suggested next steps:');
544
+ if (provider === 'gemini') {
545
+ console.log(' 1. The default runner uses the built-in Gemini CLI Stitch adapter when runner.command is not customized');
546
+ console.log(' 2. Install Gemini CLI with npm install -g @google/gemini-cli if gemini-cli.available is not PASS');
547
+ console.log(' 3. Configure %USERPROFILE%/.gemini/settings.json using the Gemini snippet from docs/stitch-plugin-spec.zh-CN.md if gemini-cli.stitch-mcp, gemini-cli.stitch-url, or gemini-cli.stitch-auth is not PASS');
548
+ console.log(' 4. Override .skillrc.plugins.stitch.runner only if you prefer a custom Stitch bridge / wrapper');
549
+ console.log(' 5. Create a new UI/page-design change with --flags ui_change,page_design');
550
+ console.log(' 6. Run ospec plugins run stitch <change-path> before asking for review');
551
+ console.log(' Note: doctor validates local readiness; the first real run still depends on Gemini CLI auth and upstream network availability');
552
+ }
553
+ else {
554
+ console.log(' 1. The default runner uses the built-in Codex CLI Stitch adapter when runner.command is not customized');
555
+ console.log(' 2. Install Codex CLI if codex-cli.available is not PASS');
556
+ console.log(' 3. Configure %USERPROFILE%/.codex/config.toml using the Codex snippet from docs/stitch-plugin-spec.zh-CN.md if codex-cli.stitch-mcp, codex-cli.stitch-transport, codex-cli.stitch-url, or codex-cli.stitch-auth is not PASS');
557
+ console.log(' 4. If read-only Stitch calls succeed but create/update calls stall before mcp_tool_call, make sure the runner actually launches codex exec with --dangerously-bypass-approvals-and-sandbox');
558
+ console.log(' 5. Override .skillrc.plugins.stitch.runner only if you prefer a custom Stitch bridge / wrapper');
559
+ console.log(' 6. Create a new UI/page-design change with --flags ui_change,page_design');
560
+ console.log(' 7. Run ospec plugins run stitch <change-path> before asking for review');
561
+ console.log(' Note: doctor validates local readiness; real write operations still depend on Codex CLI auth, upstream network availability, and the write-bypass path actually being used');
562
+ }
563
+ console.log('');
564
+ if (failCount > 0) {
565
+ this.error(`Plugin doctor found ${failCount} blocking issue(s)${warnCount > 0 ? ` and ${warnCount} warning(s)` : ''}`);
566
+ process.exit(1);
567
+ }
568
+ this.success(`Plugin doctor passed${warnCount > 0 ? ` with ${warnCount} warning(s)` : ''}`);
569
+ }
570
+ async doctorCheckpoint(projectPath) {
571
+ const config = await services_1.services.configManager.loadConfig(projectPath);
572
+ const checkpointConfig = config.plugins?.checkpoint;
573
+ const checks = [];
574
+ checks.push({
575
+ name: 'plugin.enabled',
576
+ status: checkpointConfig?.enabled ? 'pass' : 'fail',
577
+ message: checkpointConfig?.enabled
578
+ ? 'checkpoint plugin is enabled for this project'
579
+ : 'checkpoint plugin is disabled. Run "ospec plugins enable checkpoint <project-path> --base-url <url>" first.',
580
+ });
581
+ const uiReviewEnabled = checkpointConfig?.capabilities?.ui_review?.enabled === true;
582
+ checks.push({
583
+ name: 'capability.ui_review',
584
+ status: uiReviewEnabled ? 'pass' : 'fail',
585
+ message: uiReviewEnabled
586
+ ? 'ui_review capability is enabled'
587
+ : 'ui_review capability is disabled',
588
+ });
589
+ const flowCheckEnabled = checkpointConfig?.capabilities?.flow_check?.enabled === true;
590
+ checks.push({
591
+ name: 'capability.flow_check',
592
+ status: flowCheckEnabled ? 'pass' : 'fail',
593
+ message: flowCheckEnabled
594
+ ? 'flow_check capability is enabled'
595
+ : 'flow_check capability is disabled',
596
+ });
597
+ const baseUrl = typeof checkpointConfig?.runtime?.base_url === 'string'
598
+ ? checkpointConfig.runtime.base_url.trim()
599
+ : '';
600
+ checks.push({
601
+ name: 'runtime.base_url',
602
+ status: baseUrl && this.isHttpUrl(baseUrl) ? 'pass' : 'fail',
603
+ message: baseUrl
604
+ ? this.isHttpUrl(baseUrl)
605
+ ? `Runtime base URL is configured: ${baseUrl}`
606
+ : `runtime.base_url is not a valid http/https URL: ${baseUrl}`
607
+ : 'runtime.base_url is missing. Re-run "ospec plugins enable checkpoint <project-path> --base-url <url>".',
608
+ });
609
+ const workspaceRoot = path.join(projectPath, '.ospec', 'plugins', 'checkpoint');
610
+ const routesPath = path.join(workspaceRoot, 'routes.yaml');
611
+ const flowsPath = path.join(workspaceRoot, 'flows.yaml');
612
+ const workspaceExists = await services_1.services.fileService.exists(workspaceRoot);
613
+ const routesExists = await services_1.services.fileService.exists(routesPath);
614
+ const flowsExists = await services_1.services.fileService.exists(flowsPath);
615
+ checks.push({
616
+ name: 'workspace.root',
617
+ status: workspaceExists ? 'pass' : 'fail',
618
+ message: workspaceExists
619
+ ? `Checkpoint workspace exists: ${workspaceRoot}`
620
+ : 'Checkpoint workspace is missing. Re-enable the plugin or create .ospec/plugins/checkpoint/.',
621
+ });
622
+ checks.push({
623
+ name: 'workspace.routes',
624
+ status: routesExists ? 'pass' : 'warn',
625
+ message: routesExists
626
+ ? `Route review config detected: ${routesPath}`
627
+ : 'routes.yaml is missing. Add route baseline definitions before ui_review is used.',
628
+ });
629
+ checks.push({
630
+ name: 'workspace.flows',
631
+ status: flowsExists ? 'pass' : 'warn',
632
+ message: flowsExists
633
+ ? `Flow review config detected: ${flowsPath}`
634
+ : 'flows.yaml is missing. Add flow definitions before flow_check is used.',
635
+ });
636
+ const runner = this.getEffectiveCheckpointRunnerConfig(checkpointConfig, checkpointConfig?.runner);
637
+ const runnerMode = typeof runner?.mode === 'string' ? runner.mode : 'command';
638
+ checks.push({
639
+ name: 'runner.mode',
640
+ status: runnerMode === 'command' ? 'pass' : 'fail',
641
+ message: runnerMode === 'command'
642
+ ? 'Command runner mode is configured'
643
+ : `Unsupported checkpoint runner mode: ${runnerMode}`,
644
+ });
645
+ const command = typeof runner?.command === 'string' ? runner.command.trim() : '';
646
+ checks.push({
647
+ name: 'runner.command',
648
+ status: command.length > 0 ? 'pass' : 'fail',
649
+ message: command.length > 0
650
+ ? `Runner command is ready: ${command}${this.isBuiltInCheckpointRunner(checkpointConfig?.runner) ? ' (built-in Playwright adapter)' : ''}`
651
+ : 'Configure .skillrc.plugins.checkpoint.runner.command before using checkpoint',
652
+ });
653
+ if (command.length > 0) {
654
+ const availability = await this.checkCommandAvailability(command, projectPath);
655
+ checks.push({
656
+ name: 'runner.command.available',
657
+ status: availability.available ? 'pass' : 'fail',
658
+ message: availability.message,
659
+ });
660
+ }
661
+ if (this.isBuiltInCheckpointRunner(checkpointConfig?.runner)) {
662
+ const adapterPath = path.resolve(__dirname, '..', 'adapters', 'playwright-checkpoint-adapter.js');
663
+ const adapterExists = await services_1.services.fileService.exists(adapterPath);
664
+ checks.push({
665
+ name: 'runner.adapter',
666
+ status: adapterExists ? 'pass' : 'fail',
667
+ message: adapterExists
668
+ ? `Built-in Playwright adapter is available: ${adapterPath}`
669
+ : `Built-in Playwright adapter is missing: ${adapterPath}`,
670
+ });
671
+ }
672
+ const tokenEnv = typeof runner?.token_env === 'string' ? runner.token_env.trim() : '';
673
+ checks.push({
674
+ name: 'runner.token_env',
675
+ status: tokenEnv.length === 0 || Boolean(process.env[tokenEnv]) ? 'pass' : 'fail',
676
+ message: tokenEnv.length === 0
677
+ ? 'No token environment variable required'
678
+ : process.env[tokenEnv]
679
+ ? `Token environment variable is set: ${tokenEnv}`
680
+ : `Missing required token environment variable: ${tokenEnv}`,
681
+ });
682
+ const timeoutMs = Number.isFinite(runner?.timeout_ms) && runner.timeout_ms > 0 ? Math.floor(runner.timeout_ms) : 900000;
683
+ checks.push({
684
+ name: 'runner.timeout_ms',
685
+ status: timeoutMs >= 1000 ? 'pass' : 'warn',
686
+ message: `Runner timeout is ${timeoutMs}ms`,
687
+ });
688
+ const startupCommand = typeof checkpointConfig?.runtime?.startup?.command === 'string'
689
+ ? checkpointConfig.runtime.startup.command.trim()
690
+ : '';
691
+ checks.push({
692
+ name: 'runtime.startup',
693
+ status: startupCommand.length > 0 ? 'pass' : 'warn',
694
+ message: startupCommand.length > 0
695
+ ? `Startup command is configured: ${startupCommand}`
696
+ : 'No startup command is configured. This is acceptable only if the target site is already running.',
697
+ });
698
+ const readinessType = typeof checkpointConfig?.runtime?.readiness?.type === 'string'
699
+ ? checkpointConfig.runtime.readiness.type.trim()
700
+ : '';
701
+ const readinessUrl = typeof checkpointConfig?.runtime?.readiness?.url === 'string'
702
+ ? checkpointConfig.runtime.readiness.url.trim()
703
+ : '';
704
+ checks.push({
705
+ name: 'runtime.readiness',
706
+ status: readinessType === 'url' && (!readinessUrl || this.isHttpUrl(readinessUrl)) ? 'pass' : 'fail',
707
+ message: readinessType === 'url'
708
+ ? readinessUrl
709
+ ? `Readiness probe uses URL: ${readinessUrl}`
710
+ : 'Readiness probe URL is empty; checkpoint will fall back to the configured base_url.'
711
+ : `Unsupported readiness probe type: ${readinessType || '(empty)'}`,
712
+ });
713
+ const authCommand = typeof checkpointConfig?.runtime?.auth?.command === 'string'
714
+ ? checkpointConfig.runtime.auth.command.trim()
715
+ : '';
716
+ const authWhen = typeof checkpointConfig?.runtime?.auth?.when === 'string'
717
+ ? checkpointConfig.runtime.auth.when.trim()
718
+ : 'missing_storage_state';
719
+ checks.push({
720
+ name: 'runtime.auth',
721
+ status: authCommand.length > 0 ? 'pass' : 'warn',
722
+ message: authCommand.length > 0
723
+ ? `Auth command is configured: ${authCommand} (${authWhen || 'missing_storage_state'})`
724
+ : 'No auth command is configured. This is acceptable only when the target routes do not require login or storage_state is managed externally.',
725
+ });
726
+ if (authCommand.length > 0) {
727
+ const authAvailability = await this.checkCommandAvailability(authCommand, projectPath);
728
+ checks.push({
729
+ name: 'runtime.auth.available',
730
+ status: authAvailability.available ? 'pass' : 'fail',
731
+ message: authAvailability.message,
732
+ });
733
+ }
734
+ const storageState = typeof checkpointConfig?.runtime?.storage_state === 'string'
735
+ ? checkpointConfig.runtime.storage_state.trim()
736
+ : '';
737
+ const resolvedStorageState = storageState ? this.resolveReferencedPath(storageState, projectPath, projectPath) : '';
738
+ const storageStateExists = resolvedStorageState ? await services_1.services.fileService.exists(resolvedStorageState) : false;
739
+ checks.push({
740
+ name: 'runtime.storage_state',
741
+ status: !storageState
742
+ ? authCommand.length > 0 ? 'fail' : 'pass'
743
+ : storageStateExists
744
+ ? 'pass'
745
+ : authCommand.length > 0 ? 'warn' : 'warn',
746
+ message: !storageState
747
+ ? authCommand.length > 0
748
+ ? 'runtime.auth is configured but runtime.storage_state is empty. The auth command should write a Playwright storage state file.'
749
+ : 'No storage_state file is configured. Add one if login is required.'
750
+ : storageStateExists
751
+ ? `Storage state file exists: ${resolvedStorageState}`
752
+ : authCommand.length > 0
753
+ ? `Storage state file is currently missing: ${resolvedStorageState}. The auth command is expected to create it before review runs.`
754
+ : `Storage state file is missing: ${resolvedStorageState}`,
755
+ });
756
+ checks.push({
757
+ name: 'stitch_integration',
758
+ status: checkpointConfig?.stitch_integration?.enabled !== false ? 'pass' : 'warn',
759
+ message: checkpointConfig?.stitch_integration?.enabled !== false
760
+ ? `Stitch integration is enabled${checkpointConfig?.stitch_integration?.auto_pass_stitch_review !== false ? ' with automatic Stitch approval sync' : ''}`
761
+ : 'Stitch integration is disabled',
762
+ });
763
+ const failCount = checks.filter(check => check.status === 'fail').length;
764
+ const warnCount = checks.filter(check => check.status === 'warn').length;
765
+ console.log('\nPlugin Doctor');
766
+ console.log('=============\n');
767
+ console.log(`Project: ${projectPath}`);
768
+ console.log('Plugin: checkpoint\n');
769
+ checks.forEach(check => {
770
+ const icon = check.status === 'pass' ? 'PASS' : check.status === 'warn' ? 'WARN' : 'FAIL';
771
+ console.log(`${icon} ${check.name}: ${check.message}`);
772
+ });
773
+ console.log('');
774
+ console.log('Suggested next steps:');
775
+ console.log(' 1. Keep .ospec/plugins/checkpoint/routes.yaml aligned with the routes and viewports you expect to review');
776
+ console.log(' 2. Keep .ospec/plugins/checkpoint/flows.yaml aligned with critical user flows and project-specific backend assertions');
777
+ console.log(' 3. Use docker compose or a stable startup command when the repo cannot boot the target app directly');
778
+ console.log(' 4. Save a storage state under .ospec/plugins/checkpoint/auth/ or configure runtime.auth to generate one before review');
779
+ console.log(' 5. Create new changes with matching flags such as --flags ui_change,page_design or --flags feature_flow,api_change');
780
+ console.log(' 6. Once checkpoint steps are active, verify/archive will block on artifacts/checkpoint/gate.json');
781
+ console.log('');
782
+ if (failCount > 0) {
783
+ this.error(`Plugin doctor found ${failCount} blocking issue(s)${warnCount > 0 ? ` and ${warnCount} warning(s)` : ''}`);
784
+ process.exit(1);
785
+ }
786
+ this.success(`Plugin doctor passed${warnCount > 0 ? ` with ${warnCount} warning(s)` : ''}`);
787
+ }
788
+ async checkCommandAvailability(command, projectPath) {
789
+ const resolvedCommand = this.resolveRunnerCommand(command, projectPath);
790
+ if (path.isAbsolute(resolvedCommand) || command.startsWith('.') || command.includes('/') || command.includes('\\')) {
791
+ const exists = await services_1.services.fileService.exists(resolvedCommand);
792
+ return {
793
+ available: exists,
794
+ path: resolvedCommand,
795
+ message: exists
796
+ ? `Runner command path exists: ${resolvedCommand}`
797
+ : `Runner command path not found: ${resolvedCommand}`,
798
+ };
799
+ }
800
+ const locator = process.platform === 'win32' ? 'where.exe' : 'which';
801
+ const result = (0, child_process_1.spawnSync)(locator, [command], {
802
+ cwd: projectPath,
803
+ encoding: 'utf-8',
804
+ shell: false,
805
+ });
806
+ const available = result.status === 0;
807
+ const output = String(result.stdout || '').trim().split(/\r?\n/).filter(Boolean)[0] || '';
808
+ return {
809
+ available,
810
+ path: output || '',
811
+ message: available
812
+ ? `Runner command is available on PATH: ${output || command}`
813
+ : `Runner command was not found on PATH: ${command}`,
814
+ };
815
+ }
816
+ async inspectGeminiCliStitch(projectPath) {
817
+ const userHome = process.env.USERPROFILE || process.env.HOME || '';
818
+ const settingsPath = userHome ? path.join(userHome, '.gemini', 'settings.json') : '';
819
+ const geminiAvailability = await this.checkCommandAvailability('gemini', projectPath);
820
+ const settingsExists = settingsPath ? await services_1.services.fileService.exists(settingsPath) : false;
821
+ if (!settingsExists) {
822
+ return {
823
+ geminiAvailable: geminiAvailability.available,
824
+ geminiCommandPath: geminiAvailability.path || '',
825
+ settingsExists: false,
826
+ settingsPath,
827
+ stitchMcpConfigured: false,
828
+ stitchMcpType: '',
829
+ stitchHttpUrlConfigured: false,
830
+ stitchAuthConfigured: false,
831
+ };
832
+ }
833
+ try {
834
+ const settings = await services_1.services.fileService.readJSON(settingsPath);
835
+ const stitchMcp = settings?.mcpServers?.stitch;
836
+ const stitchHeaders = stitchMcp?.headers && typeof stitchMcp.headers === 'object' ? stitchMcp.headers : {};
837
+ return {
838
+ geminiAvailable: geminiAvailability.available,
839
+ geminiCommandPath: geminiAvailability.path || '',
840
+ settingsExists: true,
841
+ settingsPath,
842
+ stitchMcpConfigured: Boolean(stitchMcp && typeof stitchMcp === 'object'),
843
+ stitchMcpType: typeof stitchMcp?.type === 'string' ? stitchMcp.type : '',
844
+ stitchHttpUrlConfigured: typeof stitchMcp?.httpUrl === 'string' && stitchMcp.httpUrl.trim() === 'https://stitch.googleapis.com/mcp',
845
+ stitchAuthConfigured: typeof stitchHeaders['X-Goog-Api-Key'] === 'string' && stitchHeaders['X-Goog-Api-Key'].trim().length > 0,
846
+ };
847
+ }
848
+ catch {
849
+ return {
850
+ geminiAvailable: geminiAvailability.available,
851
+ geminiCommandPath: geminiAvailability.path || '',
852
+ settingsExists: true,
853
+ settingsPath,
854
+ stitchMcpConfigured: false,
855
+ stitchMcpType: '',
856
+ stitchHttpUrlConfigured: false,
857
+ stitchAuthConfigured: false,
858
+ };
859
+ }
860
+ }
861
+ async runPlugin(pluginName, changePath) {
862
+ const normalizedName = this.resolvePluginAlias(pluginName);
863
+ switch (normalizedName) {
864
+ case 'stitch':
865
+ await this.runStitch(changePath);
866
+ return;
867
+ case 'checkpoint':
868
+ await this.runCheckpoint(changePath);
869
+ return;
870
+ default:
871
+ throw new Error(`Unsupported plugin: ${pluginName}`);
872
+ }
873
+ }
874
+ async runCheckpoint(changePath) {
875
+ const targetPath = path.resolve(changePath);
876
+ const changeFiles = await this.getChangeRuntimePaths(targetPath);
877
+ const projectPath = await this.findProjectRoot(targetPath);
878
+ const config = await services_1.services.configManager.loadConfig(projectPath);
879
+ const checkpointConfig = config.plugins?.checkpoint;
880
+ if (!checkpointConfig?.enabled) {
881
+ throw new Error('checkpoint plugin is not enabled for this project. Run "ospec plugins enable checkpoint <project-path> --base-url <url>" first.');
882
+ }
883
+ const verification = await this.readVerification(changeFiles.verificationPath);
884
+ const optionalSteps = Array.isArray(verification.data.optional_steps) ? verification.data.optional_steps : [];
885
+ const activeCheckpointSteps = optionalSteps.filter(step => step === 'checkpoint_ui_review' || step === 'checkpoint_flow_check');
886
+ if (activeCheckpointSteps.length === 0) {
887
+ throw new Error('This change does not activate checkpoint_ui_review or checkpoint_flow_check, so checkpoint cannot run for it.');
888
+ }
889
+ const runner = this.getEffectiveCheckpointRunnerConfig(checkpointConfig, checkpointConfig.runner);
890
+ if (!runner || runner.mode !== 'command') {
891
+ throw new Error('Unsupported checkpoint runner mode. Only command mode is supported in this version.');
892
+ }
893
+ const rawCommand = typeof runner.command === 'string' ? runner.command.trim() : '';
894
+ if (!rawCommand) {
895
+ throw new Error('Checkpoint runner is not configured. Configure .skillrc.plugins.checkpoint.runner.command or use the built-in adapter defaults.');
896
+ }
897
+ const tokenEnv = typeof runner.token_env === 'string' ? runner.token_env.trim() : '';
898
+ if (tokenEnv && !process.env[tokenEnv]) {
899
+ throw new Error(`Missing checkpoint token environment variable: ${tokenEnv}`);
900
+ }
901
+ await services_1.services.fileService.ensureDir(changeFiles.checkpointDir);
902
+ await services_1.services.fileService.ensureDir(changeFiles.checkpointScreenshotsDir);
903
+ await services_1.services.fileService.ensureDir(changeFiles.checkpointDiffsDir);
904
+ await services_1.services.fileService.ensureDir(changeFiles.checkpointTracesDir);
905
+ const featureState = await services_1.services.fileService.readJSON(changeFiles.statePath);
906
+ const context = {
907
+ change_path: targetPath,
908
+ project_path: projectPath,
909
+ approval_path: changeFiles.approvalPath,
910
+ gate_path: changeFiles.checkpointGatePath,
911
+ summary_path: changeFiles.checkpointSummaryPath,
912
+ result_path: changeFiles.checkpointResultPath,
913
+ change_name: featureState.feature || path.basename(targetPath),
914
+ };
915
+ const command = this.resolveRunnerCommand(this.replaceRunnerTokens(rawCommand, context), projectPath);
916
+ const args = Array.isArray(runner.args)
917
+ ? runner.args.map(arg => this.replaceRunnerTokens(String(arg), context))
918
+ : [];
919
+ const rawCwd = typeof runner.cwd === 'string' && runner.cwd.trim().length > 0
920
+ ? this.replaceRunnerTokens(runner.cwd.trim(), context)
921
+ : projectPath;
922
+ const cwd = path.isAbsolute(rawCwd) ? rawCwd : path.resolve(projectPath, rawCwd);
923
+ const extraEnv = runner.extra_env && typeof runner.extra_env === 'object'
924
+ ? Object.fromEntries(Object.entries(runner.extra_env).map(([key, value]) => [key, this.replaceRunnerTokens(String(value ?? ''), context)]))
925
+ : {};
926
+ const timeoutMs = Number.isFinite(runner.timeout_ms) && runner.timeout_ms > 0 ? Math.floor(runner.timeout_ms) : 900000;
927
+ const baseUrl = typeof checkpointConfig.runtime?.base_url === 'string' ? checkpointConfig.runtime.base_url.trim() : '';
928
+ const executionEnv = {
929
+ ...process.env,
930
+ ...extraEnv,
931
+ OSPEC_CHECKPOINT_CHANGE_PATH: targetPath,
932
+ OSPEC_CHECKPOINT_PROJECT_PATH: projectPath,
933
+ OSPEC_CHECKPOINT_GATE_PATH: changeFiles.checkpointGatePath,
934
+ OSPEC_CHECKPOINT_RESULT_PATH: changeFiles.checkpointResultPath,
935
+ OSPEC_CHECKPOINT_SUMMARY_PATH: changeFiles.checkpointSummaryPath,
936
+ OSPEC_CHECKPOINT_SCREENSHOTS_DIR: changeFiles.checkpointScreenshotsDir,
937
+ OSPEC_CHECKPOINT_DIFFS_DIR: changeFiles.checkpointDiffsDir,
938
+ OSPEC_CHECKPOINT_TRACES_DIR: changeFiles.checkpointTracesDir,
939
+ OSPEC_CHECKPOINT_CHANGE_NAME: context.change_name,
940
+ OSPEC_CHECKPOINT_BASE_URL: baseUrl,
941
+ OSPEC_CHECKPOINT_ACTIVE_STEPS: activeCheckpointSteps.join(','),
942
+ };
943
+ const result = (0, child_process_1.spawnSync)(command, args, {
944
+ cwd,
945
+ env: executionEnv,
946
+ encoding: 'utf-8',
947
+ timeout: timeoutMs,
948
+ shell: false,
949
+ });
950
+ if (result.error) {
951
+ throw new Error(`Failed to execute checkpoint runner: ${result.error.message}`);
952
+ }
953
+ const parsedOutput = this.parseCheckpointRunnerOutput(String(result.stdout || ''), String(result.stderr || ''), activeCheckpointSteps);
954
+ const normalizedOutput = this.normalizeCheckpointRunnerResult(parsedOutput, activeCheckpointSteps);
955
+ const stitchSync = await this.applyCheckpointStitchIntegration(projectPath, changeFiles, verification, checkpointConfig, config, normalizedOutput);
956
+ const persistedArtifacts = await this.writeCheckpointArtifacts(targetPath, changeFiles, checkpointConfig, runner, command, args, cwd, timeoutMs, tokenEnv, extraEnv, normalizedOutput, stitchSync, activeCheckpointSteps);
957
+ await this.syncCheckpointOptionalSteps(changeFiles.verificationPath, activeCheckpointSteps, persistedArtifacts.gate);
958
+ if (persistedArtifacts.gate.status !== 'passed') {
959
+ this.info(` gate: ${path.relative(targetPath, changeFiles.checkpointGatePath).replace(/\\/g, '/')}`);
960
+ this.info(` result: ${path.relative(targetPath, changeFiles.checkpointResultPath).replace(/\\/g, '/')}`);
961
+ this.info(` summary: ${path.relative(targetPath, changeFiles.checkpointSummaryPath).replace(/\\/g, '/')}`);
962
+ this.info(` status: ${persistedArtifacts.gate.status}`);
963
+ if (stitchSync.attempted) {
964
+ this.info(` stitch sync: ${stitchSync.status}${stitchSync.message ? ` (${stitchSync.message})` : ''}`);
965
+ }
966
+ this.error('Checkpoint gate failed. Inspect artifacts/checkpoint/summary.md for details.');
967
+ process.exit(1);
968
+ }
969
+ this.success(`Ran plugin checkpoint for ${changePath}`);
970
+ this.info(` gate: ${path.relative(targetPath, changeFiles.checkpointGatePath).replace(/\\/g, '/')}`);
971
+ this.info(` result: ${path.relative(targetPath, changeFiles.checkpointResultPath).replace(/\\/g, '/')}`);
972
+ this.info(` summary: ${path.relative(targetPath, changeFiles.checkpointSummaryPath).replace(/\\/g, '/')}`);
973
+ this.info(` status: ${persistedArtifacts.gate.status}`);
974
+ if (stitchSync.attempted) {
975
+ this.info(` stitch sync: ${stitchSync.status}${stitchSync.message ? ` (${stitchSync.message})` : ''}`);
976
+ }
977
+ }
978
+ async runStitch(changePath) {
979
+ const targetPath = path.resolve(changePath);
980
+ const changeFiles = await this.getChangeRuntimePaths(targetPath);
981
+ const projectPath = await this.findProjectRoot(targetPath);
982
+ const config = await services_1.services.configManager.loadConfig(projectPath);
983
+ const stitchConfig = config.plugins?.stitch;
984
+ const provider = this.getStitchProvider(stitchConfig);
985
+ if (!stitchConfig?.enabled) {
986
+ throw new Error('stitch plugin is not enabled for this project. Run "ospec plugins enable stitch <project-path>" first.');
987
+ }
988
+ if (!stitchConfig.capabilities?.page_design_review?.enabled) {
989
+ throw new Error('stitch capability page_design_review is not enabled for this project.');
990
+ }
991
+ const verification = await this.readVerification(changeFiles.verificationPath);
992
+ const optionalSteps = Array.isArray(verification.data.optional_steps) ? verification.data.optional_steps : [];
993
+ if (!optionalSteps.includes('stitch_design_review')) {
994
+ throw new Error('This change does not activate stitch_design_review, so Stitch cannot run for it.');
995
+ }
996
+ const runner = this.getEffectiveStitchRunnerConfig(stitchConfig, stitchConfig.runner);
997
+ if (!runner || runner.mode !== 'command') {
998
+ throw new Error('Unsupported Stitch runner mode. Only command mode is supported in this version.');
999
+ }
1000
+ const rawCommand = typeof runner.command === 'string' ? runner.command.trim() : '';
1001
+ if (!rawCommand) {
1002
+ throw new Error('Stitch runner is not configured. Configure .skillrc.plugins.stitch.runner.command or use the built-in adapter defaults.');
1003
+ }
1004
+ const tokenEnv = typeof runner.token_env === 'string' ? runner.token_env.trim() : '';
1005
+ if (tokenEnv && !process.env[tokenEnv]) {
1006
+ throw new Error(`Missing Stitch token environment variable: ${tokenEnv}`);
1007
+ }
1008
+ await services_1.services.fileService.ensureDir(changeFiles.stitchDir);
1009
+ const featureState = await services_1.services.fileService.readJSON(changeFiles.statePath);
1010
+ const approval = await this.loadOrCreateStitchApproval(changeFiles.approvalPath, stitchConfig.blocking !== false);
1011
+ const context = {
1012
+ change_path: targetPath,
1013
+ project_path: projectPath,
1014
+ approval_path: changeFiles.approvalPath,
1015
+ summary_path: changeFiles.summaryPath,
1016
+ result_path: changeFiles.resultPath,
1017
+ change_name: featureState.feature || path.basename(targetPath),
1018
+ };
1019
+ const command = this.resolveRunnerCommand(this.replaceRunnerTokens(rawCommand, context), projectPath);
1020
+ const args = Array.isArray(runner.args)
1021
+ ? runner.args.map(arg => this.replaceRunnerTokens(String(arg), context))
1022
+ : [];
1023
+ const rawCwd = typeof runner.cwd === 'string' && runner.cwd.trim().length > 0
1024
+ ? this.replaceRunnerTokens(runner.cwd.trim(), context)
1025
+ : projectPath;
1026
+ const cwd = path.isAbsolute(rawCwd) ? rawCwd : path.resolve(projectPath, rawCwd);
1027
+ const extraEnv = runner.extra_env && typeof runner.extra_env === 'object'
1028
+ ? Object.fromEntries(Object.entries(runner.extra_env).map(([key, value]) => [key, this.replaceRunnerTokens(String(value ?? ''), context)]))
1029
+ : {};
1030
+ const timeoutMs = Number.isFinite(runner.timeout_ms) && runner.timeout_ms > 0 ? Math.floor(runner.timeout_ms) : 900000;
1031
+ const baseEnv = {
1032
+ ...process.env,
1033
+ ...extraEnv,
1034
+ OSPEC_STITCH_CHANGE_PATH: targetPath,
1035
+ OSPEC_STITCH_PROJECT_PATH: projectPath,
1036
+ OSPEC_STITCH_APPROVAL_PATH: changeFiles.approvalPath,
1037
+ OSPEC_STITCH_SUMMARY_PATH: changeFiles.summaryPath,
1038
+ OSPEC_STITCH_RESULT_PATH: changeFiles.resultPath,
1039
+ OSPEC_STITCH_CHANGE_NAME: context.change_name,
1040
+ OSPEC_STITCH_CANONICAL_PROJECT_ID: typeof stitchConfig.project?.project_id === 'string' ? stitchConfig.project.project_id.trim() : '',
1041
+ OSPEC_STITCH_CANONICAL_PROJECT_URL: typeof stitchConfig.project?.project_url === 'string' ? stitchConfig.project.project_url.trim() : '',
1042
+ };
1043
+ const executedAt = new Date().toISOString();
1044
+ const usingBuiltInGeminiRunner = provider === 'gemini' && this.isBuiltInGeminiRunner(stitchConfig.runner);
1045
+ const usingBuiltInCodexRunner = provider === 'codex' && this.isBuiltInCodexRunner(stitchConfig.runner);
1046
+ const geminiConfig = this.getStitchGeminiConfig(stitchConfig);
1047
+ const codexConfig = this.getStitchCodexConfig(stitchConfig);
1048
+ const geminiModelCandidates = usingBuiltInGeminiRunner
1049
+ ? this.getGeminiModelCandidates(geminiConfig.model)
1050
+ : [''];
1051
+ let selectedGeminiModel = '';
1052
+ let selectedCodexModel = usingBuiltInCodexRunner ? codexConfig.model : '';
1053
+ let result = null;
1054
+ let parsedOutput = null;
1055
+ let lastGeminiFailure = null;
1056
+ for (const candidateModel of geminiModelCandidates) {
1057
+ const attemptEnv = {
1058
+ ...baseEnv,
1059
+ };
1060
+ if (candidateModel) {
1061
+ attemptEnv.OSPEC_STITCH_GEMINI_MODEL = candidateModel;
1062
+ }
1063
+ else {
1064
+ delete attemptEnv.OSPEC_STITCH_GEMINI_MODEL;
1065
+ }
1066
+ if (usingBuiltInCodexRunner && codexConfig.model) {
1067
+ attemptEnv.OSPEC_STITCH_CODEX_MODEL = codexConfig.model;
1068
+ }
1069
+ else {
1070
+ delete attemptEnv.OSPEC_STITCH_CODEX_MODEL;
1071
+ }
1072
+ const attemptResult = (0, child_process_1.spawnSync)(command, args, {
1073
+ cwd,
1074
+ env: attemptEnv,
1075
+ encoding: 'utf-8',
1076
+ timeout: timeoutMs,
1077
+ shell: false,
1078
+ });
1079
+ if (attemptResult.error) {
1080
+ throw new Error(`Failed to execute Stitch runner: ${attemptResult.error.message}`);
1081
+ }
1082
+ if (attemptResult.status === 0) {
1083
+ result = attemptResult;
1084
+ parsedOutput = this.parseStitchRunnerOutput(String(attemptResult.stdout || ''));
1085
+ selectedGeminiModel = candidateModel;
1086
+ break;
1087
+ }
1088
+ const failure = this.classifyStitchRunnerFailure(attemptResult, candidateModel);
1089
+ if (!(usingBuiltInGeminiRunner &&
1090
+ geminiConfig.auto_switch_on_limit &&
1091
+ this.isGeminiModelFallbackEligible(failure) &&
1092
+ candidateModel !== geminiModelCandidates[geminiModelCandidates.length - 1])) {
1093
+ throw new Error(failure.message);
1094
+ }
1095
+ lastGeminiFailure = failure;
1096
+ }
1097
+ if (!result || !parsedOutput) {
1098
+ if (lastGeminiFailure) {
1099
+ throw new Error(lastGeminiFailure.message);
1100
+ }
1101
+ throw new Error('Stitch runner did not return a usable result.');
1102
+ }
1103
+ if (!parsedOutput.preview_url) {
1104
+ throw new Error('Stitch runner must output a preview_url or print the preview URL to stdout.');
1105
+ }
1106
+ const previewProject = this.extractStitchProjectRef(parsedOutput.preview_url);
1107
+ const canonicalProject = stitchConfig.project && typeof stitchConfig.project === 'object'
1108
+ ? stitchConfig.project
1109
+ : {};
1110
+ const canonicalProjectId = typeof canonicalProject.project_id === 'string' ? canonicalProject.project_id.trim() : '';
1111
+ const canonicalProjectUrl = typeof canonicalProject.project_url === 'string' ? canonicalProject.project_url.trim() : '';
1112
+ const enforceSingleProject = canonicalProject.enforce_single_project !== false;
1113
+ if (canonicalProjectId && enforceSingleProject) {
1114
+ if (!previewProject.project_id) {
1115
+ throw new Error(`Stitch preview URL does not expose a project ID, so OSpec cannot verify reuse of canonical project ${canonicalProjectId}.`);
1116
+ }
1117
+ if (previewProject.project_id !== canonicalProjectId) {
1118
+ throw new Error(`Stitch returned project ${previewProject.project_id}, but this repo is pinned to canonical project ${canonicalProjectId}. Reuse the existing Stitch project instead of creating a new one.`);
1119
+ }
1120
+ }
1121
+ let summaryMarkdown = parsedOutput.summary_markdown;
1122
+ if (!summaryMarkdown && parsedOutput.summary_path) {
1123
+ const summarySourcePath = this.resolveReferencedPath(parsedOutput.summary_path, cwd, projectPath);
1124
+ if (!(await services_1.services.fileService.exists(summarySourcePath))) {
1125
+ throw new Error(`Stitch runner referenced missing summary_path: ${parsedOutput.summary_path}`);
1126
+ }
1127
+ summaryMarkdown = await services_1.services.fileService.readFile(summarySourcePath);
1128
+ }
1129
+ const nextApproval = {
1130
+ ...approval,
1131
+ plugin: 'stitch',
1132
+ capability: 'page_design_review',
1133
+ step: 'stitch_design_review',
1134
+ blocking: stitchConfig.blocking !== false,
1135
+ status: 'pending',
1136
+ preview_url: parsedOutput.preview_url,
1137
+ submitted_at: executedAt,
1138
+ reviewed_at: '',
1139
+ reviewer: '',
1140
+ notes: parsedOutput.notes || '',
1141
+ };
1142
+ const resultArtifact = {
1143
+ plugin: 'stitch',
1144
+ capability: 'page_design_review',
1145
+ step: 'stitch_design_review',
1146
+ status: 'submitted',
1147
+ executed_at: executedAt,
1148
+ runner: {
1149
+ mode: 'command',
1150
+ command,
1151
+ args,
1152
+ cwd,
1153
+ timeout_ms: timeoutMs,
1154
+ token_env: tokenEnv,
1155
+ extra_env_keys: Object.keys(extraEnv).sort((left, right) => left.localeCompare(right)),
1156
+ provider,
1157
+ gemini_model: provider === 'gemini' ? (selectedGeminiModel || geminiConfig.model || '') : '',
1158
+ codex_model: provider === 'codex' ? (selectedCodexModel || codexConfig.model || '') : '',
1159
+ },
1160
+ output: {
1161
+ ...parsedOutput,
1162
+ summary_markdown: summaryMarkdown,
1163
+ },
1164
+ };
1165
+ await services_1.services.fileService.writeJSON(changeFiles.resultPath, resultArtifact);
1166
+ await services_1.services.fileService.writeJSON(changeFiles.approvalPath, nextApproval);
1167
+ const shouldSaveCanonicalProject = !canonicalProjectId &&
1168
+ canonicalProject.save_on_first_run !== false &&
1169
+ previewProject.project_id &&
1170
+ previewProject.project_url;
1171
+ const configuredGeminiModel = provider === 'gemini' ? geminiConfig.model : '';
1172
+ const shouldPersistGeminiModel = usingBuiltInGeminiRunner &&
1173
+ geminiConfig.save_on_fallback !== false &&
1174
+ selectedGeminiModel &&
1175
+ selectedGeminiModel !== configuredGeminiModel;
1176
+ if (shouldSaveCanonicalProject || shouldPersistGeminiModel) {
1177
+ const nextConfig = JSON.parse(JSON.stringify(config));
1178
+ nextConfig.plugins = nextConfig.plugins || {};
1179
+ nextConfig.plugins.stitch = nextConfig.plugins.stitch || this.createDefaultStitchPluginConfig();
1180
+ if (shouldSaveCanonicalProject) {
1181
+ nextConfig.plugins.stitch.project = {
1182
+ ...(nextConfig.plugins.stitch.project || {}),
1183
+ project_id: previewProject.project_id,
1184
+ project_url: previewProject.project_url,
1185
+ save_on_first_run: canonicalProject.save_on_first_run !== false,
1186
+ enforce_single_project: enforceSingleProject,
1187
+ };
1188
+ }
1189
+ if (shouldPersistGeminiModel) {
1190
+ nextConfig.plugins.stitch.gemini = {
1191
+ ...(nextConfig.plugins.stitch.gemini || {}),
1192
+ model: selectedGeminiModel,
1193
+ auto_switch_on_limit: geminiConfig.auto_switch_on_limit !== false,
1194
+ save_on_fallback: geminiConfig.save_on_fallback !== false,
1195
+ };
1196
+ }
1197
+ await services_1.services.configManager.saveConfig(projectPath, nextConfig);
1198
+ }
1199
+ if (summaryMarkdown) {
1200
+ await services_1.services.fileService.writeFile(changeFiles.summaryPath, summaryMarkdown);
1201
+ }
1202
+ else if (await services_1.services.fileService.exists(changeFiles.summaryPath)) {
1203
+ await services_1.services.fileService.remove(changeFiles.summaryPath);
1204
+ }
1205
+ await this.syncVerificationOptionalStep(changeFiles.verificationPath, 'stitch_design_review', false);
1206
+ this.success(`Submitted plugin stitch review for ${changePath}`);
1207
+ this.info(` preview_url: ${parsedOutput.preview_url}`);
1208
+ this.info(` result: ${path.relative(targetPath, changeFiles.resultPath).replace(/\\/g, '/')}`);
1209
+ if (summaryMarkdown) {
1210
+ this.info(` summary: ${path.relative(targetPath, changeFiles.summaryPath).replace(/\\/g, '/')}`);
1211
+ }
1212
+ if (parsedOutput.artifacts.length > 0) {
1213
+ this.info(` artifacts: ${parsedOutput.artifacts.length}`);
1214
+ }
1215
+ if (tokenEnv) {
1216
+ this.info(` token env: ${tokenEnv}`);
1217
+ }
1218
+ this.info(` provider: ${provider}`);
1219
+ if (provider === 'gemini' && (selectedGeminiModel || configuredGeminiModel)) {
1220
+ this.info(` gemini model: ${selectedGeminiModel || configuredGeminiModel}`);
1221
+ }
1222
+ if (provider === 'codex' && (selectedCodexModel || codexConfig.model)) {
1223
+ this.info(` codex model: ${selectedCodexModel || codexConfig.model}`);
1224
+ }
1225
+ if (previewProject.project_id) {
1226
+ this.info(` stitch project: ${previewProject.project_id}`);
1227
+ }
1228
+ if (shouldSaveCanonicalProject) {
1229
+ this.info(` canonical project saved: ${previewProject.project_url}`);
1230
+ }
1231
+ else if (canonicalProjectUrl) {
1232
+ this.info(` canonical project: ${canonicalProjectUrl}`);
1233
+ }
1234
+ if (provider === 'gemini' && shouldPersistGeminiModel) {
1235
+ this.info(` gemini model saved: ${selectedGeminiModel}`);
1236
+ }
1237
+ this.info(' approval.json status: pending');
1238
+ this.info(' Next: send the preview URL to the reviewer and wait for ospec plugins approve stitch <change-path>');
1239
+ }
1240
+ async setPluginApproval(pluginName, status, changePath) {
1241
+ const normalizedName = this.resolvePluginAlias(pluginName);
1242
+ if (normalizedName !== 'stitch') {
1243
+ throw new Error(`Unsupported plugin: ${pluginName}`);
1244
+ }
1245
+ const targetPath = path.resolve(changePath);
1246
+ const changeFiles = await this.getChangeRuntimePaths(targetPath);
1247
+ if (!(await services_1.services.fileService.exists(changeFiles.approvalPath))) {
1248
+ throw new Error('Stitch approval artifact not found. Expected artifacts/stitch/approval.json');
1249
+ }
1250
+ const approval = await services_1.services.fileService.readJSON(changeFiles.approvalPath);
1251
+ if (approval.step !== 'stitch_design_review') {
1252
+ throw new Error('Stitch approval artifact step must be stitch_design_review');
1253
+ }
1254
+ if (status === 'approved') {
1255
+ const hasPreviewUrl = typeof approval.preview_url === 'string' && approval.preview_url.trim().length > 0;
1256
+ const hasSubmittedAt = typeof approval.submitted_at === 'string' && approval.submitted_at.trim().length > 0;
1257
+ if (!hasPreviewUrl || !hasSubmittedAt) {
1258
+ throw new Error('Cannot approve Stitch review before preview_url and submitted_at are recorded. Run ospec plugins run stitch <change-path> first.');
1259
+ }
1260
+ }
1261
+ const reviewer = process.env.USERNAME || process.env.USER || approval.reviewer || 'manual';
1262
+ const nextApproval = {
1263
+ ...approval,
1264
+ plugin: 'stitch',
1265
+ capability: approval.capability || 'page_design_review',
1266
+ step: 'stitch_design_review',
1267
+ status,
1268
+ reviewed_at: new Date().toISOString(),
1269
+ reviewer,
1270
+ };
1271
+ await services_1.services.fileService.writeJSON(changeFiles.approvalPath, nextApproval);
1272
+ await this.syncVerificationOptionalStep(changeFiles.verificationPath, 'stitch_design_review', status === 'approved');
1273
+ this.success(`${status === 'approved' ? 'Approved' : 'Rejected'} plugin stitch review for ${changePath}`);
1274
+ this.info(` approval.json status: ${status}`);
1275
+ this.info(` verification.md passed_optional_steps: ${status === 'approved' ? 'includes stitch_design_review' : 'removed stitch_design_review'}`);
1276
+ }
1277
+ async getChangeRuntimePaths(changePath) {
1278
+ const targetPath = path.resolve(changePath);
1279
+ const statePath = path.join(targetPath, constants_1.FILE_NAMES.STATE);
1280
+ const verificationPath = path.join(targetPath, constants_1.FILE_NAMES.VERIFICATION);
1281
+ if (!(await services_1.services.fileService.exists(statePath))) {
1282
+ throw new Error('Change state file not found. Expected changes/active/<change>/state.json');
1283
+ }
1284
+ if (!(await services_1.services.fileService.exists(verificationPath))) {
1285
+ throw new Error('verification.md is required for plugin workflow integration');
1286
+ }
1287
+ const stitchDir = path.join(targetPath, 'artifacts', 'stitch');
1288
+ const checkpointDir = path.join(targetPath, 'artifacts', 'checkpoint');
1289
+ return {
1290
+ targetPath,
1291
+ statePath,
1292
+ verificationPath,
1293
+ stitchDir,
1294
+ approvalPath: path.join(stitchDir, 'approval.json'),
1295
+ summaryPath: path.join(stitchDir, 'summary.md'),
1296
+ resultPath: path.join(stitchDir, 'result.json'),
1297
+ checkpointDir,
1298
+ checkpointGatePath: path.join(checkpointDir, 'gate.json'),
1299
+ checkpointSummaryPath: path.join(checkpointDir, 'summary.md'),
1300
+ checkpointResultPath: path.join(checkpointDir, 'result.json'),
1301
+ checkpointScreenshotsDir: path.join(checkpointDir, 'screenshots'),
1302
+ checkpointDiffsDir: path.join(checkpointDir, 'diffs'),
1303
+ checkpointTracesDir: path.join(checkpointDir, 'traces'),
1304
+ };
1305
+ }
1306
+ async findProjectRoot(startPath) {
1307
+ let currentPath = path.resolve(startPath);
1308
+ while (true) {
1309
+ const skillrcPath = path.join(currentPath, constants_1.FILE_NAMES.SKILLRC);
1310
+ if (await services_1.services.fileService.exists(skillrcPath)) {
1311
+ return currentPath;
1312
+ }
1313
+ const parentPath = path.dirname(currentPath);
1314
+ if (parentPath === currentPath) {
1315
+ break;
1316
+ }
1317
+ currentPath = parentPath;
1318
+ }
1319
+ throw new Error('Unable to locate project root containing .skillrc from the provided change path.');
1320
+ }
1321
+ async readVerification(verificationPath) {
1322
+ const verificationContent = await services_1.services.fileService.readFile(verificationPath);
1323
+ return (0, gray_matter_1)(verificationContent);
1324
+ }
1325
+ async syncVerificationOptionalStep(verificationPath, stepName, passed) {
1326
+ const verification = await this.readVerification(verificationPath);
1327
+ const optionalSteps = Array.isArray(verification.data.optional_steps) ? verification.data.optional_steps : [];
1328
+ if (!optionalSteps.includes(stepName)) {
1329
+ throw new Error(`verification.md does not include ${stepName} in optional_steps`);
1330
+ }
1331
+ const passedOptionalSteps = Array.isArray(verification.data.passed_optional_steps)
1332
+ ? verification.data.passed_optional_steps.filter(step => step !== stepName)
1333
+ : [];
1334
+ if (passed) {
1335
+ passedOptionalSteps.push(stepName);
1336
+ }
1337
+ verification.data.passed_optional_steps = Array.from(new Set(passedOptionalSteps));
1338
+ await services_1.services.fileService.writeFile(verificationPath, gray_matter_1.stringify(verification.content, verification.data));
1339
+ }
1340
+ async loadOrCreateStitchApproval(approvalPath, blocking) {
1341
+ if (await services_1.services.fileService.exists(approvalPath)) {
1342
+ const approval = await services_1.services.fileService.readJSON(approvalPath);
1343
+ if (approval.step && approval.step !== 'stitch_design_review') {
1344
+ throw new Error('Stitch approval artifact step must be stitch_design_review');
1345
+ }
1346
+ return approval;
1347
+ }
1348
+ const approval = {
1349
+ plugin: 'stitch',
1350
+ capability: 'page_design_review',
1351
+ step: 'stitch_design_review',
1352
+ status: 'pending',
1353
+ blocking: blocking !== false,
1354
+ preview_url: '',
1355
+ submitted_at: '',
1356
+ reviewed_at: '',
1357
+ reviewer: '',
1358
+ notes: '',
1359
+ };
1360
+ await services_1.services.fileService.writeJSON(approvalPath, approval);
1361
+ return approval;
1362
+ }
1363
+ parseCheckpointRunnerOutput(stdout, stderr, activeSteps) {
1364
+ const normalizedStdout = String(stdout || '').trim();
1365
+ const normalizedStderr = String(stderr || '').trim();
1366
+ const stdoutLines = normalizedStdout.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
1367
+ const stderrLines = normalizedStderr.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
1368
+ const candidates = Array.from(new Set([
1369
+ normalizedStdout,
1370
+ stdoutLines[stdoutLines.length - 1] || '',
1371
+ stderrLines[stderrLines.length - 1] || '',
1372
+ ].filter(Boolean)));
1373
+ for (const candidate of candidates) {
1374
+ try {
1375
+ const parsed = JSON.parse(candidate);
1376
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1377
+ return parsed;
1378
+ }
1379
+ }
1380
+ catch {
1381
+ }
1382
+ }
1383
+ const issues = [normalizedStderr || normalizedStdout || 'Checkpoint runner produced no structured JSON output.'];
1384
+ return {
1385
+ ok: false,
1386
+ status: 'failed',
1387
+ issues,
1388
+ steps: Object.fromEntries(activeSteps.map(step => [step, {
1389
+ status: 'failed',
1390
+ issues,
1391
+ }])),
1392
+ };
1393
+ }
1394
+ normalizeCheckpointRunnerResult(result, activeSteps) {
1395
+ const firstString = (...values) => values.find(value => typeof value === 'string' && value.trim().length > 0)?.trim() || '';
1396
+ const normalizeStatus = (value, fallback = 'pending') => {
1397
+ const normalized = String(value || '').trim().toLowerCase();
1398
+ if (normalized === 'pass') {
1399
+ return 'passed';
1400
+ }
1401
+ if (normalized === 'fail' || normalized === 'error') {
1402
+ return 'failed';
1403
+ }
1404
+ if (normalized === 'passed' || normalized === 'failed' || normalized === 'pending' || normalized === 'skipped') {
1405
+ return normalized;
1406
+ }
1407
+ return fallback;
1408
+ };
1409
+ const normalizeIssues = (value) => {
1410
+ if (!Array.isArray(value)) {
1411
+ return [];
1412
+ }
1413
+ return value
1414
+ .map(entry => {
1415
+ if (typeof entry === 'string' && entry.trim().length > 0) {
1416
+ return { message: entry.trim() };
1417
+ }
1418
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
1419
+ return null;
1420
+ }
1421
+ const message = firstString(entry.message, entry.notes, entry.title, entry.code);
1422
+ if (!message) {
1423
+ return null;
1424
+ }
1425
+ return {
1426
+ message,
1427
+ severity: firstString(entry.severity, entry.level),
1428
+ code: firstString(entry.code),
1429
+ path: firstString(entry.path),
1430
+ };
1431
+ })
1432
+ .filter(Boolean);
1433
+ };
1434
+ const normalizeArtifacts = (value) => this.normalizeRunnerArtifacts(value);
1435
+ const normalizeStep = (stepName, value) => {
1436
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1437
+ return {
1438
+ status: 'pending',
1439
+ issues: [],
1440
+ routes: [],
1441
+ flows: [],
1442
+ artifacts: [],
1443
+ summary: '',
1444
+ metadata: {},
1445
+ };
1446
+ }
1447
+ return {
1448
+ status: normalizeStatus(value.status, 'pending'),
1449
+ issues: normalizeIssues(value.issues || value.errors),
1450
+ routes: Array.isArray(value.routes) ? value.routes : [],
1451
+ flows: Array.isArray(value.flows) ? value.flows : [],
1452
+ artifacts: normalizeArtifacts(value.artifacts || value.files || value.outputs),
1453
+ summary: firstString(value.summary, value.summary_markdown, value.notes),
1454
+ metadata: value.metadata && typeof value.metadata === 'object' && !Array.isArray(value.metadata)
1455
+ ? value.metadata
1456
+ : {},
1457
+ step: stepName,
1458
+ };
1459
+ };
1460
+ const normalizedSteps = {};
1461
+ for (const stepName of activeSteps) {
1462
+ const fallbackValue = stepName === 'checkpoint_ui_review'
1463
+ ? result?.ui_review
1464
+ : stepName === 'checkpoint_flow_check'
1465
+ ? result?.flow_check
1466
+ : null;
1467
+ normalizedSteps[stepName] = normalizeStep(stepName, result?.steps?.[stepName] || fallbackValue || {});
1468
+ }
1469
+ const overallStatus = normalizeStatus(result?.status, '');
1470
+ const derivedStatus = overallStatus ||
1471
+ (activeSteps.some(step => normalizedSteps[step]?.status === 'failed')
1472
+ ? 'failed'
1473
+ : activeSteps.length > 0 && activeSteps.every(step => normalizedSteps[step]?.status === 'passed')
1474
+ ? 'passed'
1475
+ : 'pending');
1476
+ return {
1477
+ ok: result?.ok !== false && derivedStatus !== 'failed',
1478
+ status: derivedStatus,
1479
+ executed_at: firstString(result?.executed_at, result?.executedAt) || new Date().toISOString(),
1480
+ notes: firstString(result?.notes, result?.message),
1481
+ summary_markdown: firstString(result?.summary_markdown, result?.summaryMarkdown, result?.summary),
1482
+ issues: normalizeIssues(result?.issues || result?.errors),
1483
+ steps: normalizedSteps,
1484
+ metadata: result?.metadata && typeof result.metadata === 'object' && !Array.isArray(result.metadata)
1485
+ ? result.metadata
1486
+ : {},
1487
+ artifacts: normalizeArtifacts(result?.artifacts || result?.files || result?.outputs),
1488
+ };
1489
+ }
1490
+ buildCheckpointSummaryMarkdown(gate, output, stitchSync) {
1491
+ const lines = [
1492
+ '# Checkpoint Review Summary',
1493
+ '',
1494
+ `- Status: ${gate.status}`,
1495
+ `- Executed at: ${gate.executed_at}`,
1496
+ `- Blocking: ${gate.blocking ? 'yes' : 'no'}`,
1497
+ '',
1498
+ ];
1499
+ for (const [stepName, stepState] of Object.entries(gate.steps || {})) {
1500
+ lines.push(`## ${stepName}`);
1501
+ lines.push('');
1502
+ lines.push(`- Status: ${stepState.status}`);
1503
+ const stepIssues = Array.isArray(stepState.issues) ? stepState.issues : [];
1504
+ if (stepIssues.length === 0) {
1505
+ lines.push('- Issues: none');
1506
+ }
1507
+ else {
1508
+ lines.push('- Issues:');
1509
+ stepIssues.forEach(issue => {
1510
+ lines.push(` - ${issue.message || issue}`);
1511
+ });
1512
+ }
1513
+ const stepOutput = output.steps?.[stepName];
1514
+ const routeCount = Array.isArray(stepOutput?.routes) ? stepOutput.routes.length : 0;
1515
+ const flowCount = Array.isArray(stepOutput?.flows) ? stepOutput.flows.length : 0;
1516
+ if (routeCount > 0) {
1517
+ lines.push(`- Routes checked: ${routeCount}`);
1518
+ }
1519
+ if (flowCount > 0) {
1520
+ lines.push(`- Flows checked: ${flowCount}`);
1521
+ }
1522
+ lines.push('');
1523
+ }
1524
+ if (Array.isArray(gate.issues) && gate.issues.length > 0) {
1525
+ lines.push('## Global Issues');
1526
+ lines.push('');
1527
+ gate.issues.forEach(issue => {
1528
+ lines.push(`- ${issue.message || issue}`);
1529
+ });
1530
+ lines.push('');
1531
+ }
1532
+ if (stitchSync?.attempted) {
1533
+ lines.push('## Stitch Sync');
1534
+ lines.push('');
1535
+ lines.push(`- Status: ${stitchSync.status}`);
1536
+ if (stitchSync.message) {
1537
+ lines.push(`- Message: ${stitchSync.message}`);
1538
+ }
1539
+ lines.push('');
1540
+ }
1541
+ return lines.join('\n');
1542
+ }
1543
+ async writeCheckpointArtifacts(changePath, changeFiles, checkpointConfig, runner, command, args, cwd, timeoutMs, tokenEnv, extraEnv, output, stitchSync, activeSteps) {
1544
+ const gate = {
1545
+ plugin: 'checkpoint',
1546
+ status: output.status,
1547
+ blocking: checkpointConfig?.blocking !== false,
1548
+ executed_at: output.executed_at,
1549
+ steps: Object.fromEntries(activeSteps.map(stepName => [
1550
+ stepName,
1551
+ {
1552
+ status: output.steps?.[stepName]?.status || 'failed',
1553
+ issues: output.steps?.[stepName]?.issues || [],
1554
+ },
1555
+ ])),
1556
+ stitch_sync: stitchSync,
1557
+ issues: output.issues,
1558
+ };
1559
+ const resultArtifact = {
1560
+ plugin: 'checkpoint',
1561
+ status: output.status,
1562
+ executed_at: output.executed_at,
1563
+ active_steps: activeSteps,
1564
+ runner: {
1565
+ mode: 'command',
1566
+ command,
1567
+ args,
1568
+ cwd,
1569
+ timeout_ms: timeoutMs,
1570
+ token_env: tokenEnv,
1571
+ extra_env_keys: Object.keys(extraEnv).sort((left, right) => left.localeCompare(right)),
1572
+ },
1573
+ output,
1574
+ stitch_sync: stitchSync,
1575
+ };
1576
+ const summaryMarkdown = output.summary_markdown || this.buildCheckpointSummaryMarkdown(gate, output, stitchSync);
1577
+ await services_1.services.fileService.writeJSON(changeFiles.checkpointGatePath, gate);
1578
+ await services_1.services.fileService.writeJSON(changeFiles.checkpointResultPath, resultArtifact);
1579
+ await services_1.services.fileService.writeFile(changeFiles.checkpointSummaryPath, summaryMarkdown);
1580
+ return {
1581
+ gate,
1582
+ resultArtifact,
1583
+ summaryMarkdown,
1584
+ };
1585
+ }
1586
+ async syncCheckpointOptionalSteps(verificationPath, activeSteps, gate) {
1587
+ for (const stepName of activeSteps) {
1588
+ const stepStatus = gate.steps?.[stepName]?.status || 'failed';
1589
+ await this.syncVerificationOptionalStep(verificationPath, stepName, stepStatus === 'passed');
1590
+ }
1591
+ }
1592
+ async applyCheckpointStitchIntegration(projectPath, changeFiles, verification, checkpointConfig, config, output) {
1593
+ const optionalSteps = Array.isArray(verification.data.optional_steps) ? verification.data.optional_steps : [];
1594
+ const stitchStepActive = optionalSteps.includes('stitch_design_review');
1595
+ if (!stitchStepActive) {
1596
+ return {
1597
+ attempted: false,
1598
+ status: 'skipped',
1599
+ message: 'stitch_design_review is not active for this change',
1600
+ };
1601
+ }
1602
+ if (checkpointConfig?.stitch_integration?.enabled === false || checkpointConfig?.stitch_integration?.auto_pass_stitch_review === false) {
1603
+ return {
1604
+ attempted: false,
1605
+ status: 'skipped',
1606
+ message: 'checkpoint stitch integration is disabled',
1607
+ };
1608
+ }
1609
+ const uiReviewStatus = output.steps?.checkpoint_ui_review?.status || 'pending';
1610
+ const approval = await this.loadOrCreateStitchApproval(changeFiles.approvalPath, config.plugins?.stitch?.blocking !== false);
1611
+ if (uiReviewStatus !== 'passed') {
1612
+ if (approval.status === 'approved' && approval.reviewer === 'checkpoint') {
1613
+ const nextApproval = {
1614
+ ...approval,
1615
+ status: 'pending',
1616
+ reviewed_at: '',
1617
+ reviewer: '',
1618
+ notes: approval.notes
1619
+ ? `${approval.notes}\nCheckpoint ui_review no longer passes; auto-approval reverted.`
1620
+ : 'Checkpoint ui_review no longer passes; auto-approval reverted.',
1621
+ };
1622
+ await services_1.services.fileService.writeJSON(changeFiles.approvalPath, nextApproval);
1623
+ await this.syncVerificationOptionalStep(changeFiles.verificationPath, 'stitch_design_review', false);
1624
+ return {
1625
+ attempted: true,
1626
+ status: 'reverted',
1627
+ message: 'checkpoint ui_review failed, so previous automatic Stitch approval was reverted',
1628
+ };
1629
+ }
1630
+ await this.syncVerificationOptionalStep(changeFiles.verificationPath, 'stitch_design_review', approval.status === 'approved');
1631
+ return {
1632
+ attempted: true,
1633
+ status: 'skipped',
1634
+ message: 'checkpoint ui_review did not pass, so Stitch was not auto-approved',
1635
+ };
1636
+ }
1637
+ const stitchProjectUrl = typeof config.plugins?.stitch?.project?.project_url === 'string'
1638
+ ? config.plugins.stitch.project.project_url.trim()
1639
+ : '';
1640
+ const previewUrl = approval.preview_url || stitchProjectUrl || 'checkpoint:auto-pass';
1641
+ const submittedAt = approval.submitted_at || output.executed_at || new Date().toISOString();
1642
+ const nextApproval = {
1643
+ ...approval,
1644
+ plugin: 'stitch',
1645
+ capability: approval.capability || 'page_design_review',
1646
+ step: 'stitch_design_review',
1647
+ status: 'approved',
1648
+ preview_url: previewUrl,
1649
+ submitted_at: submittedAt,
1650
+ reviewed_at: new Date().toISOString(),
1651
+ reviewer: 'checkpoint',
1652
+ notes: approval.notes
1653
+ ? `${approval.notes}\nApproved automatically from checkpoint ui_review.`
1654
+ : 'Approved automatically from checkpoint ui_review.',
1655
+ };
1656
+ await services_1.services.fileService.writeJSON(changeFiles.approvalPath, nextApproval);
1657
+ await this.syncVerificationOptionalStep(changeFiles.verificationPath, 'stitch_design_review', true);
1658
+ return {
1659
+ attempted: true,
1660
+ status: 'approved',
1661
+ message: 'Stitch review approved automatically from checkpoint ui_review',
1662
+ };
1663
+ }
1664
+ parsePluginProjectArgs(args) {
1665
+ const options = {
1666
+ base_url: '',
1667
+ };
1668
+ let projectPath = '';
1669
+ for (let index = 0; index < args.length; index += 1) {
1670
+ const arg = String(args[index] || '').trim();
1671
+ if (!arg) {
1672
+ continue;
1673
+ }
1674
+ if (arg === '--base-url') {
1675
+ const nextValue = String(args[index + 1] || '').trim();
1676
+ if (!nextValue || nextValue.startsWith('--')) {
1677
+ throw new Error('Missing value for --base-url');
1678
+ }
1679
+ options.base_url = nextValue;
1680
+ index += 1;
1681
+ continue;
1682
+ }
1683
+ if (arg.startsWith('--base-url=')) {
1684
+ options.base_url = arg.slice('--base-url='.length).trim();
1685
+ if (!options.base_url) {
1686
+ throw new Error('Missing value for --base-url');
1687
+ }
1688
+ continue;
1689
+ }
1690
+ if (arg.startsWith('--')) {
1691
+ throw new Error(`Unknown plugin option: ${arg}`);
1692
+ }
1693
+ if (!projectPath) {
1694
+ projectPath = arg;
1695
+ continue;
1696
+ }
1697
+ throw new Error(`Unexpected plugin argument: ${arg}`);
1698
+ }
1699
+ return {
1700
+ projectPath,
1701
+ options,
1702
+ };
1703
+ }
1704
+ isHttpUrl(value) {
1705
+ try {
1706
+ const parsed = new URL(String(value || '').trim());
1707
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
1708
+ }
1709
+ catch {
1710
+ return false;
1711
+ }
1712
+ }
1713
+ async ensureFileIfMissing(filePath, content) {
1714
+ if (await services_1.services.fileService.exists(filePath)) {
1715
+ return;
1716
+ }
1717
+ await services_1.services.fileService.writeFile(filePath, content);
1718
+ }
1719
+ async ensureGitkeep(dirPath) {
1720
+ await services_1.services.fileService.ensureDir(dirPath);
1721
+ const gitkeepPath = path.join(dirPath, '.gitkeep');
1722
+ if (!(await services_1.services.fileService.exists(gitkeepPath))) {
1723
+ await services_1.services.fileService.writeFile(gitkeepPath, '');
1724
+ }
1725
+ }
1726
+ async ensureStitchWorkspaceScaffold(projectPath, stitchConfig) {
1727
+ const workspaceRoot = path.join(projectPath, '.ospec', 'plugins', 'stitch');
1728
+ const exportsDir = path.join(workspaceRoot, 'exports');
1729
+ const baselinesDir = path.join(workspaceRoot, 'baselines');
1730
+ const cacheDir = path.join(workspaceRoot, 'cache');
1731
+ await services_1.services.fileService.ensureDir(workspaceRoot);
1732
+ await this.ensureGitkeep(exportsDir);
1733
+ await this.ensureGitkeep(baselinesDir);
1734
+ await this.ensureGitkeep(cacheDir);
1735
+ const metadataPath = path.join(workspaceRoot, 'project.json');
1736
+ const readmePath = path.join(workspaceRoot, 'README.md');
1737
+ const provider = this.getStitchProvider(stitchConfig);
1738
+ const runner = this.getEffectiveStitchRunnerConfig(stitchConfig, stitchConfig?.runner) || this.createDefaultStitchPluginConfig(provider).runner;
1739
+ await this.ensureFileIfMissing(metadataPath, JSON.stringify({
1740
+ version: 1,
1741
+ plugin: 'stitch',
1742
+ workspace_root: '.ospec/plugins/stitch',
1743
+ created_at: new Date().toISOString(),
1744
+ provider,
1745
+ canonical_project: {
1746
+ project_id: typeof stitchConfig?.project?.project_id === 'string' ? stitchConfig.project.project_id.trim() : '',
1747
+ project_url: typeof stitchConfig?.project?.project_url === 'string' ? stitchConfig.project.project_url.trim() : '',
1748
+ save_on_first_run: stitchConfig?.project?.save_on_first_run !== false,
1749
+ enforce_single_project: stitchConfig?.project?.enforce_single_project !== false,
1750
+ },
1751
+ runner: {
1752
+ mode: typeof runner?.mode === 'string' ? runner.mode : 'command',
1753
+ command: typeof runner?.command === 'string' ? runner.command.trim() : 'node',
1754
+ cwd: typeof runner?.cwd === 'string' ? runner.cwd.trim() : '${project_path}',
1755
+ timeout_ms: Number.isFinite(runner?.timeout_ms) && runner.timeout_ms > 0 ? Math.floor(runner.timeout_ms) : 900000,
1756
+ token_env: typeof runner?.token_env === 'string' ? runner.token_env.trim() : '',
1757
+ },
1758
+ }, null, 2));
1759
+ await this.ensureFileIfMissing(readmePath, [
1760
+ '# Stitch Workspace',
1761
+ '',
1762
+ 'Repository-level Stitch assets for OSpec plugins live here.',
1763
+ '',
1764
+ '- `exports/`: reusable Stitch exports and snapshots',
1765
+ '- `baselines/`: repository-level design baselines shared with runtime review plugins',
1766
+ '- `cache/`: temporary Stitch cache files',
1767
+ '',
1768
+ 'Per-change results still live under `changes/active/<change>/artifacts/stitch/`.',
1769
+ '',
1770
+ ].join('\n'));
1771
+ }
1772
+ async ensureCheckpointWorkspaceScaffold(projectPath, checkpointConfig) {
1773
+ const workspaceRoot = path.join(projectPath, '.ospec', 'plugins', 'checkpoint');
1774
+ const baselinesDir = path.join(workspaceRoot, 'baselines');
1775
+ const authDir = path.join(workspaceRoot, 'auth');
1776
+ const cacheDir = path.join(workspaceRoot, 'cache');
1777
+ await services_1.services.fileService.ensureDir(workspaceRoot);
1778
+ await this.ensureGitkeep(baselinesDir);
1779
+ await this.ensureGitkeep(authDir);
1780
+ await this.ensureGitkeep(cacheDir);
1781
+ const routesPath = path.join(workspaceRoot, 'routes.yaml');
1782
+ const flowsPath = path.join(workspaceRoot, 'flows.yaml');
1783
+ const readmePath = path.join(workspaceRoot, 'README.md');
1784
+ const authReadmePath = path.join(authDir, 'README.md');
1785
+ const authExamplePath = path.join(authDir, 'login.example.js');
1786
+ const baseUrl = typeof checkpointConfig?.runtime?.base_url === 'string'
1787
+ ? checkpointConfig.runtime.base_url.trim()
1788
+ : '';
1789
+ await this.ensureFileIfMissing(routesPath, [
1790
+ 'defaults:',
1791
+ ' viewports:',
1792
+ ' - desktop',
1793
+ ' - mobile',
1794
+ ' diff_threshold: 0.01',
1795
+ ' wait_after_load_ms: 300',
1796
+ ' ignore_selectors:',
1797
+ ' - "[data-checkpoint-ignore]"',
1798
+ ' contrast:',
1799
+ ' - name: text-default',
1800
+ ' selectors: ["h1", "h2", "h3", "p", "a", "button", "label"]',
1801
+ ' min_ratio: 4.5',
1802
+ ' max_issues: 6',
1803
+ '',
1804
+ 'routes:',
1805
+ ' - name: home',
1806
+ ' path: /',
1807
+ ` base_url: ${baseUrl || 'http://127.0.0.1:3000'}`,
1808
+ ' baseline:',
1809
+ ' desktop: baselines/home-desktop.png',
1810
+ ' mobile: baselines/home-mobile.png',
1811
+ ' required_visible:',
1812
+ ' - h1',
1813
+ ' - a[href]',
1814
+ ' selectors:',
1815
+ ' no_overlap:',
1816
+ ' - name: hero-copy-vs-stats',
1817
+ ' first: .hero-copy',
1818
+ ' second: .hero-stats',
1819
+ ' typography:',
1820
+ ' - selector: h1',
1821
+ ' font_family_includes: ["Inter", "system-ui"]',
1822
+ ' font_size_min: 40',
1823
+ ' font_weight_min: 600',
1824
+ ' single_line: true',
1825
+ ' colors:',
1826
+ ' - selector: a[href]',
1827
+ ' property: color',
1828
+ ' equals: "#2563eb"',
1829
+ ' tolerance: 18',
1830
+ ' requirements:',
1831
+ ' - Hero title must remain on one line on desktop',
1832
+ ' - Primary CTA must stay above the fold',
1833
+ '',
1834
+ ].join('\n'));
1835
+ await this.ensureFileIfMissing(flowsPath, [
1836
+ 'flows:',
1837
+ ' - name: smoke-home',
1838
+ ' start_url: /',
1839
+ ' steps:',
1840
+ ' - action: wait_for_load',
1841
+ ' - action: assert_text',
1842
+ ' text: TODO',
1843
+ ' assert_command: ""',
1844
+ '',
1845
+ ].join('\n'));
1846
+ await this.ensureFileIfMissing(readmePath, [
1847
+ '# Checkpoint Workspace',
1848
+ '',
1849
+ 'Repository-level runtime review assets for the checkpoint plugin live here.',
1850
+ '',
1851
+ '- `routes.yaml`: page review targets, breakpoint matrix, baselines, and semantic UI checks',
1852
+ '- `flows.yaml`: critical flows, API assertions, and project-specific backend assertions',
1853
+ '- `baselines/`: repository baselines used when Stitch exports are not available',
1854
+ '- `auth/`: login helpers, example auth scripts, and Playwright storage state files',
1855
+ '- `cache/`: temporary review artifacts and regenerated intermediates',
1856
+ '',
1857
+ 'Per-change execution artifacts will live under `changes/active/<change>/artifacts/checkpoint/`.',
1858
+ '',
1859
+ ].join('\n'));
1860
+ await this.ensureFileIfMissing(authReadmePath, [
1861
+ '# Checkpoint Auth',
1862
+ '',
1863
+ 'Use this directory when checkpoint review requires login.',
1864
+ '',
1865
+ 'Recommended contract:',
1866
+ '',
1867
+ '1. Copy `login.example.js` to `login.js` and replace the placeholder selectors.',
1868
+ '2. Configure `.skillrc.plugins.checkpoint.runtime.auth` to run that script before review.',
1869
+ '3. The script should write the Playwright storage state file to the path in `OSPEC_CHECKPOINT_STORAGE_STATE`.',
1870
+ '',
1871
+ 'Environment variables provided to the auth command:',
1872
+ '',
1873
+ '- `OSPEC_CHECKPOINT_BASE_URL`',
1874
+ '- `OSPEC_CHECKPOINT_PROJECT_PATH`',
1875
+ '- `OSPEC_CHECKPOINT_CHANGE_PATH`',
1876
+ '- `OSPEC_CHECKPOINT_STORAGE_STATE`',
1877
+ '- `OSPEC_CHECKPOINT_OSPEC_PACKAGE_PATH`',
1878
+ '',
1879
+ ].join('\n'));
1880
+ await this.ensureFileIfMissing(authExamplePath, [
1881
+ '#!/usr/bin/env node',
1882
+ '',
1883
+ "const fs = require('fs/promises');",
1884
+ "const path = require('path');",
1885
+ "const { createRequire } = require('module');",
1886
+ '',
1887
+ 'async function main() {',
1888
+ " const baseUrl = process.env.OSPEC_CHECKPOINT_BASE_URL || '';",
1889
+ " const storageStatePath = process.env.OSPEC_CHECKPOINT_STORAGE_STATE || '';",
1890
+ " const ospecPackagePath = process.env.OSPEC_CHECKPOINT_OSPEC_PACKAGE_PATH || process.cwd();",
1891
+ ' if (!baseUrl) {',
1892
+ " throw new Error('OSPEC_CHECKPOINT_BASE_URL is required');",
1893
+ ' }',
1894
+ ' if (!storageStatePath) {',
1895
+ " throw new Error('OSPEC_CHECKPOINT_STORAGE_STATE is required');",
1896
+ ' }',
1897
+ '',
1898
+ " const scopedRequire = createRequire(path.join(ospecPackagePath, 'package.json'));",
1899
+ " const { chromium } = scopedRequire('playwright');",
1900
+ ' await fs.mkdir(path.dirname(storageStatePath), { recursive: true });',
1901
+ '',
1902
+ ' const browser = await chromium.launch({ headless: true });',
1903
+ ' const context = await browser.newContext({ baseURL: baseUrl });',
1904
+ ' const page = await context.newPage();',
1905
+ '',
1906
+ ' try {',
1907
+ " await page.goto(new URL('/login', baseUrl).toString(), { waitUntil: 'networkidle', timeout: 60000 });",
1908
+ " // TODO: replace selectors and credentials for the real project.",
1909
+ " // await page.locator('input[name=\"email\"]').fill(process.env.CHECKPOINT_LOGIN_EMAIL || '');",
1910
+ " // await page.locator('input[name=\"password\"]').fill(process.env.CHECKPOINT_LOGIN_PASSWORD || '');",
1911
+ " // await page.locator('button[type=\"submit\"]').click();",
1912
+ " // await page.waitForURL(/dashboard|home/, { timeout: 60000 });",
1913
+ '',
1914
+ ' await context.storageState({ path: storageStatePath });',
1915
+ ' } finally {',
1916
+ ' await context.close();',
1917
+ ' await browser.close();',
1918
+ ' }',
1919
+ '}',
1920
+ '',
1921
+ "main().catch(error => {",
1922
+ " console.error(error && error.message ? error.message : error);",
1923
+ ' process.exit(1);',
1924
+ '});',
1925
+ '',
1926
+ ].join('\n'));
1927
+ }
1928
+ resolveRunnerCommand(command, projectPath) {
1929
+ if (path.isAbsolute(command)) {
1930
+ return command;
1931
+ }
1932
+ if (command.startsWith('.') || command.includes('/') || command.includes('\\')) {
1933
+ return path.resolve(projectPath, command);
1934
+ }
1935
+ return command;
1936
+ }
1937
+ resolveReferencedPath(filePath, cwd, projectPath) {
1938
+ if (path.isAbsolute(filePath)) {
1939
+ return filePath;
1940
+ }
1941
+ if (filePath.startsWith('.') || filePath.includes('/') || filePath.includes('\\')) {
1942
+ return path.resolve(cwd, filePath);
1943
+ }
1944
+ return path.resolve(projectPath, filePath);
1945
+ }
1946
+ replaceRunnerTokens(value, context) {
1947
+ return String(value || '')
1948
+ .replace(/\{change_path\}|\$\{change_path\}/g, context.change_path)
1949
+ .replace(/\{project_path\}|\$\{project_path\}/g, context.project_path)
1950
+ .replace(/\{approval_path\}|\$\{approval_path\}/g, context.approval_path)
1951
+ .replace(/\{gate_path\}|\$\{gate_path\}/g, context.gate_path || '')
1952
+ .replace(/\{summary_path\}|\$\{summary_path\}/g, context.summary_path)
1953
+ .replace(/\{result_path\}|\$\{result_path\}/g, context.result_path)
1954
+ .replace(/\{ospec_package_path\}|\$\{ospec_package_path\}/g, path.resolve(__dirname, '..', '..'))
1955
+ .replace(/\{change_name\}|\$\{change_name\}/g, context.change_name);
1956
+ }
1957
+ parseStitchRunnerOutput(stdout) {
1958
+ const normalizedOutput = String(stdout || '').trim();
1959
+ if (!normalizedOutput) {
1960
+ throw new Error('Stitch runner produced no stdout output.');
1961
+ }
1962
+ const lines = normalizedOutput.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
1963
+ const candidates = Array.from(new Set([normalizedOutput, lines[lines.length - 1] || normalizedOutput]));
1964
+ for (const candidate of candidates) {
1965
+ try {
1966
+ const parsed = JSON.parse(candidate);
1967
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1968
+ return this.normalizeRunnerResult(parsed);
1969
+ }
1970
+ }
1971
+ catch {
1972
+ }
1973
+ }
1974
+ const lastLine = lines[lines.length - 1] || normalizedOutput;
1975
+ const normalizedPreviewUrl = this.normalizeStitchPreviewUrl(lastLine);
1976
+ return {
1977
+ preview_url: normalizedPreviewUrl.value,
1978
+ summary_markdown: '',
1979
+ summary_path: '',
1980
+ notes: '',
1981
+ metadata: normalizedPreviewUrl.normalized
1982
+ ? { original_preview_url: normalizedPreviewUrl.original }
1983
+ : {},
1984
+ artifacts: [],
1985
+ };
1986
+ }
1987
+ normalizeRunnerResult(result) {
1988
+ if (result.ok === false || result.success === false) {
1989
+ throw new Error(String(result.error || result.message || 'Stitch runner reported failure.'));
1990
+ }
1991
+ const firstString = (...values) => values.find(value => typeof value === 'string' && value.trim().length > 0)?.trim() || '';
1992
+ const baseMetadata = result.metadata && typeof result.metadata === 'object' && !Array.isArray(result.metadata)
1993
+ ? result.metadata
1994
+ : {};
1995
+ const normalizedPreviewUrl = this.normalizeStitchPreviewUrl(firstString(result.preview_url, result.previewUrl, result.url));
1996
+ const metadata = normalizedPreviewUrl.normalized
1997
+ ? {
1998
+ ...baseMetadata,
1999
+ original_preview_url: baseMetadata.original_preview_url || normalizedPreviewUrl.original,
2000
+ }
2001
+ : baseMetadata;
2002
+ return {
2003
+ preview_url: normalizedPreviewUrl.value,
2004
+ summary_markdown: firstString(result.summary_markdown, result.summaryMarkdown, result.summary),
2005
+ summary_path: firstString(result.summary_path, result.summaryPath),
2006
+ notes: firstString(result.notes, result.message),
2007
+ metadata,
2008
+ artifacts: this.normalizeRunnerArtifacts(result.artifacts || result.files || result.outputs),
2009
+ };
2010
+ }
2011
+ normalizeStitchPreviewUrl(previewUrl) {
2012
+ const normalized = String(previewUrl || '').trim();
2013
+ if (!normalized) {
2014
+ return {
2015
+ value: '',
2016
+ original: '',
2017
+ normalized: false,
2018
+ };
2019
+ }
2020
+ try {
2021
+ const parsed = new URL(normalized);
2022
+ if (parsed.hostname === 'stitch.canvas.google.com') {
2023
+ const match = parsed.pathname.match(/^\/projects\/([^/]+)\/screens\/([^/?#]+)/i);
2024
+ if (match) {
2025
+ return {
2026
+ value: `https://stitch.withgoogle.com/projects/${match[1]}?node-id=${match[2]}`,
2027
+ original: normalized,
2028
+ normalized: true,
2029
+ };
2030
+ }
2031
+ }
2032
+ if (parsed.hostname === 'stitch.withgoogle.com' || parsed.hostname === 'stitch.google.com') {
2033
+ const match = parsed.pathname.match(/^\/projects\/([^/?#]+)/i);
2034
+ if (match) {
2035
+ const canonical = new URL(`https://stitch.withgoogle.com/projects/${match[1]}`);
2036
+ const nodeId = parsed.searchParams.get('node-id') || parsed.searchParams.get('node_id') || '';
2037
+ if (nodeId) {
2038
+ canonical.searchParams.set('node-id', nodeId);
2039
+ }
2040
+ const nextValue = canonical.toString();
2041
+ return {
2042
+ value: nextValue,
2043
+ original: normalized,
2044
+ normalized: nextValue !== normalized,
2045
+ };
2046
+ }
2047
+ }
2048
+ }
2049
+ catch {
2050
+ }
2051
+ return {
2052
+ value: normalized,
2053
+ original: normalized,
2054
+ normalized: false,
2055
+ };
2056
+ }
2057
+ extractStitchProjectRef(previewUrl) {
2058
+ const normalizedPreview = this.normalizeStitchPreviewUrl(previewUrl);
2059
+ if (!normalizedPreview.value) {
2060
+ return {
2061
+ preview_url: '',
2062
+ project_id: '',
2063
+ project_url: '',
2064
+ node_id: '',
2065
+ };
2066
+ }
2067
+ try {
2068
+ const parsed = new URL(normalizedPreview.value);
2069
+ const match = parsed.pathname.match(/^\/projects\/([^/?#]+)/i);
2070
+ if (!match) {
2071
+ return {
2072
+ preview_url: normalizedPreview.value,
2073
+ project_id: '',
2074
+ project_url: '',
2075
+ node_id: '',
2076
+ };
2077
+ }
2078
+ return {
2079
+ preview_url: normalizedPreview.value,
2080
+ project_id: match[1],
2081
+ project_url: `https://stitch.withgoogle.com/projects/${match[1]}`,
2082
+ node_id: parsed.searchParams.get('node-id') || '',
2083
+ };
2084
+ }
2085
+ catch {
2086
+ return {
2087
+ preview_url: normalizedPreview.value,
2088
+ project_id: '',
2089
+ project_url: '',
2090
+ node_id: '',
2091
+ };
2092
+ }
2093
+ }
2094
+ normalizeRunnerArtifacts(value) {
2095
+ if (!Array.isArray(value)) {
2096
+ return [];
2097
+ }
2098
+ return value
2099
+ .map(entry => {
2100
+ if (typeof entry === 'string' && entry.trim().length > 0) {
2101
+ return { path: entry.trim() };
2102
+ }
2103
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
2104
+ return null;
2105
+ }
2106
+ const normalized = {};
2107
+ if (typeof entry.path === 'string' && entry.path.trim().length > 0) {
2108
+ normalized.path = entry.path.trim();
2109
+ }
2110
+ if (typeof entry.url === 'string' && entry.url.trim().length > 0) {
2111
+ normalized.url = entry.url.trim();
2112
+ }
2113
+ if (typeof entry.label === 'string' && entry.label.trim().length > 0) {
2114
+ normalized.label = entry.label.trim();
2115
+ }
2116
+ if (typeof entry.type === 'string' && entry.type.trim().length > 0) {
2117
+ normalized.type = entry.type.trim();
2118
+ }
2119
+ return Object.keys(normalized).length > 0 ? normalized : null;
2120
+ })
2121
+ .filter(Boolean);
2122
+ }
2123
+ resolvePluginAlias(pluginName) {
2124
+ return String(pluginName || '').trim().toLowerCase();
2125
+ }
2126
+ createDefaultCheckpointPluginConfig() {
2127
+ return {
2128
+ enabled: false,
2129
+ blocking: true,
2130
+ runtime: {
2131
+ base_url: '',
2132
+ startup: {
2133
+ command: '',
2134
+ args: [],
2135
+ cwd: '${project_path}',
2136
+ timeout_ms: 600000,
2137
+ },
2138
+ readiness: {
2139
+ type: 'url',
2140
+ url: '',
2141
+ timeout_ms: 180000,
2142
+ },
2143
+ auth: {
2144
+ command: '',
2145
+ args: [],
2146
+ cwd: '${project_path}',
2147
+ timeout_ms: 300000,
2148
+ when: 'missing_storage_state',
2149
+ },
2150
+ shutdown: {
2151
+ command: '',
2152
+ args: [],
2153
+ cwd: '${project_path}',
2154
+ timeout_ms: 120000,
2155
+ },
2156
+ storage_state: '.ospec/plugins/checkpoint/auth/storage-state.json',
2157
+ },
2158
+ runner: {
2159
+ mode: 'command',
2160
+ command: 'node',
2161
+ args: this.getDefaultCheckpointRunnerArgs(),
2162
+ cwd: '${project_path}',
2163
+ timeout_ms: 900000,
2164
+ token_env: '',
2165
+ extra_env: {},
2166
+ },
2167
+ capabilities: {
2168
+ ui_review: {
2169
+ enabled: false,
2170
+ step: 'checkpoint_ui_review',
2171
+ activate_when_flags: ['ui_change', 'page_design', 'landing_page'],
2172
+ },
2173
+ flow_check: {
2174
+ enabled: false,
2175
+ step: 'checkpoint_flow_check',
2176
+ activate_when_flags: ['feature_flow', 'api_change', 'backend_change', 'integration_change'],
2177
+ },
2178
+ },
2179
+ stitch_integration: {
2180
+ enabled: true,
2181
+ auto_pass_stitch_review: true,
2182
+ },
2183
+ };
2184
+ }
2185
+ createDefaultStitchPluginConfig(provider = 'gemini') {
2186
+ const normalizedProvider = provider === 'codex' ? 'codex' : 'gemini';
2187
+ return {
2188
+ enabled: false,
2189
+ blocking: true,
2190
+ project: {
2191
+ project_id: '',
2192
+ project_url: '',
2193
+ save_on_first_run: true,
2194
+ enforce_single_project: true,
2195
+ },
2196
+ provider: normalizedProvider,
2197
+ gemini: {
2198
+ model: 'gemini-3-flash-preview',
2199
+ auto_switch_on_limit: true,
2200
+ save_on_fallback: true,
2201
+ },
2202
+ codex: {
2203
+ model: '',
2204
+ mcp_server: 'stitch',
2205
+ },
2206
+ runner: {
2207
+ mode: 'command',
2208
+ command: 'node',
2209
+ args: this.getDefaultRunnerArgs(normalizedProvider),
2210
+ cwd: '${project_path}',
2211
+ timeout_ms: 900000,
2212
+ token_env: '',
2213
+ extra_env: {},
2214
+ },
2215
+ capabilities: {
2216
+ page_design_review: {
2217
+ enabled: false,
2218
+ step: 'stitch_design_review',
2219
+ activate_when_flags: ['ui_change', 'page_design', 'landing_page'],
2220
+ },
2221
+ },
2222
+ };
2223
+ }
2224
+ getDefaultCheckpointRunnerArgs() {
2225
+ return ['${ospec_package_path}/dist/adapters/playwright-checkpoint-adapter.js', '--change', '${change_path}', '--project', '${project_path}'];
2226
+ }
2227
+ getDefaultRunnerArgs(provider) {
2228
+ return provider === 'codex'
2229
+ ? ['${ospec_package_path}/dist/adapters/codex-stitch-adapter.js', '--change', '${change_path}', '--project', '${project_path}']
2230
+ : ['${ospec_package_path}/dist/adapters/gemini-stitch-adapter.js', '--change', '${change_path}', '--project', '${project_path}'];
2231
+ }
2232
+ getStitchProvider(stitchConfig) {
2233
+ return String(stitchConfig?.provider || '').trim().toLowerCase() === 'codex' ? 'codex' : 'gemini';
2234
+ }
2235
+ getStitchGeminiConfig(stitchConfig) {
2236
+ const gemini = stitchConfig?.gemini && typeof stitchConfig.gemini === 'object'
2237
+ ? stitchConfig.gemini
2238
+ : {};
2239
+ const model = typeof gemini.model === 'string' && gemini.model.trim().length > 0
2240
+ ? gemini.model.trim()
2241
+ : 'gemini-3-flash-preview';
2242
+ return {
2243
+ model,
2244
+ auto_switch_on_limit: gemini.auto_switch_on_limit !== false,
2245
+ save_on_fallback: gemini.save_on_fallback !== false,
2246
+ };
2247
+ }
2248
+ getStitchCodexConfig(stitchConfig) {
2249
+ const codex = stitchConfig?.codex && typeof stitchConfig.codex === 'object'
2250
+ ? stitchConfig.codex
2251
+ : {};
2252
+ return {
2253
+ model: typeof codex.model === 'string' ? codex.model.trim() : '',
2254
+ mcp_server: typeof codex.mcp_server === 'string' && codex.mcp_server.trim().length > 0
2255
+ ? codex.mcp_server.trim()
2256
+ : 'stitch',
2257
+ };
2258
+ }
2259
+ inspectCodexStitchToml(configText) {
2260
+ const normalized = String(configText || '');
2261
+ const stitchMatch = normalized.match(/\[mcp_servers\.stitch\]([\s\S]*?)(?=\r?\n\[|$)/i);
2262
+ const stitchBlock = stitchMatch ? stitchMatch[1] : '';
2263
+ const httpHeadersMatch = normalized.match(/\[mcp_servers\.stitch\.http_headers\]([\s\S]*?)(?=\r?\n\[|$)/i);
2264
+ const httpHeadersBlock = httpHeadersMatch ? httpHeadersMatch[1] : '';
2265
+ return {
2266
+ stitchMcpConfigured: Boolean(stitchMatch),
2267
+ stitchTransportHttp: /(^|\r?\n)\s*type\s*=\s*["']http["']/i.test(stitchBlock),
2268
+ stitchUrlConfigured: /(^|\r?\n)\s*url\s*=\s*["']https:\/\/stitch\.googleapis\.com\/mcp["']/i.test(stitchBlock),
2269
+ stitchAuthConfigured: /(^|\r?\n)\s*headers\s*=\s*\{[\s\S]*?\bX-Goog-Api-Key\b\s*=\s*["'][^"']+["'][\s\S]*?\}/i.test(stitchBlock)
2270
+ || /\bX-Goog-Api-Key\b\s*=\s*["'][^"']+["']/i.test(httpHeadersBlock),
2271
+ };
2272
+ }
2273
+ async inspectCodexCliStitch(projectPath) {
2274
+ const userHome = process.env.USERPROFILE || process.env.HOME || '';
2275
+ const settingsPath = userHome ? path.join(userHome, '.codex', 'config.toml') : '';
2276
+ const codexAvailability = await this.checkCommandAvailability('codex', projectPath);
2277
+ const settingsExists = settingsPath ? await services_1.services.fileService.exists(settingsPath) : false;
2278
+ if (!settingsExists) {
2279
+ return {
2280
+ codexAvailable: codexAvailability.available,
2281
+ codexCommandPath: codexAvailability.path || '',
2282
+ settingsExists: false,
2283
+ settingsPath,
2284
+ stitchMcpConfigured: false,
2285
+ stitchTransportHttp: false,
2286
+ stitchUrlConfigured: false,
2287
+ stitchAuthConfigured: false,
2288
+ };
2289
+ }
2290
+ try {
2291
+ const configText = await services_1.services.fileService.readFile(settingsPath);
2292
+ const stitchToml = this.inspectCodexStitchToml(configText);
2293
+ return {
2294
+ codexAvailable: codexAvailability.available,
2295
+ codexCommandPath: codexAvailability.path || '',
2296
+ settingsExists: true,
2297
+ settingsPath,
2298
+ stitchMcpConfigured: stitchToml.stitchMcpConfigured,
2299
+ stitchTransportHttp: stitchToml.stitchTransportHttp,
2300
+ stitchUrlConfigured: stitchToml.stitchUrlConfigured,
2301
+ stitchAuthConfigured: stitchToml.stitchAuthConfigured,
2302
+ };
2303
+ }
2304
+ catch {
2305
+ return {
2306
+ codexAvailable: codexAvailability.available,
2307
+ codexCommandPath: codexAvailability.path || '',
2308
+ settingsExists: true,
2309
+ settingsPath,
2310
+ stitchMcpConfigured: false,
2311
+ stitchTransportHttp: false,
2312
+ stitchUrlConfigured: false,
2313
+ stitchAuthConfigured: false,
2314
+ };
2315
+ }
2316
+ }
2317
+ getGeminiModelCandidates(preferredModel) {
2318
+ return Array.from(new Set([
2319
+ String(preferredModel || '').trim(),
2320
+ 'gemini-3-flash-preview',
2321
+ 'gemini-2.5-flash',
2322
+ 'gemini-2.5-pro',
2323
+ 'gemini-2.5-flash-preview',
2324
+ ].filter(Boolean)));
2325
+ }
2326
+ classifyStitchRunnerFailure(result, requestedModel) {
2327
+ const stderr = String(result?.stderr || '').trim();
2328
+ const stdout = String(result?.stdout || '').trim();
2329
+ const details = stderr || stdout;
2330
+ const normalizedDetails = details.toLowerCase();
2331
+ const codeMatch = details.match(/(?:Gemini|Codex) Stitch adapter failed \[([a-z0-9_-]+)\]:/i);
2332
+ const code = codeMatch?.[1]?.toLowerCase() ||
2333
+ (/rate limit|quota|too many requests|resource exhausted|429/.test(normalizedDetails)
2334
+ ? 'rate_limit'
2335
+ : /unknown model|invalid model|unsupported model|model.*not found|model.*not available|model.*unavailable|permission.*model|does not support model|404/.test(normalizedDetails)
2336
+ ? 'model_unavailable'
2337
+ : /authentication|login|auth/.test(normalizedDetails)
2338
+ ? 'auth'
2339
+ : /enotfound|eai_again|getaddrinfo|network|timed out|timeout/.test(normalizedDetails)
2340
+ ? 'network'
2341
+ : 'generic');
2342
+ const modelLabel = requestedModel ? ` for model ${requestedModel}` : '';
2343
+ return {
2344
+ code,
2345
+ message: details
2346
+ ? `Stitch runner exited with code ${result.status}${modelLabel}: ${details}`
2347
+ : `Stitch runner exited with code ${result.status}${modelLabel}`,
2348
+ details,
2349
+ };
2350
+ }
2351
+ isGeminiModelFallbackEligible(failure) {
2352
+ return failure?.code === 'rate_limit' || failure?.code === 'model_unavailable';
2353
+ }
2354
+ getEffectiveStitchRunnerConfig(stitchConfig, runnerConfig) {
2355
+ const provider = this.getStitchProvider(stitchConfig);
2356
+ const defaultRunner = this.createDefaultStitchPluginConfig(provider).runner;
2357
+ if (!defaultRunner) {
2358
+ return null;
2359
+ }
2360
+ const builtInGeminiRunner = this.isBuiltInGeminiRunner(runnerConfig);
2361
+ const builtInCodexRunner = this.isBuiltInCodexRunner(runnerConfig);
2362
+ const builtInRunner = provider === 'gemini'
2363
+ ? this.isBuiltInGeminiRunner(runnerConfig)
2364
+ : provider === 'codex'
2365
+ ? this.isBuiltInCodexRunner(runnerConfig)
2366
+ : false;
2367
+ const isGeminiRunner = provider === 'gemini';
2368
+ const command = typeof runnerConfig?.command === 'string' ? runnerConfig.command.trim() : '';
2369
+ const args = Array.isArray(runnerConfig?.args) &&
2370
+ runnerConfig.args.length > 0 &&
2371
+ !(builtInGeminiRunner || builtInCodexRunner)
2372
+ ? runnerConfig.args.map(arg => String(arg))
2373
+ : defaultRunner.args;
2374
+ const tokenEnv = typeof runnerConfig?.token_env === 'string' ? runnerConfig.token_env.trim() : defaultRunner.token_env;
2375
+ return {
2376
+ ...defaultRunner,
2377
+ ...(runnerConfig || {}),
2378
+ command: command || defaultRunner.command,
2379
+ args,
2380
+ cwd: typeof runnerConfig?.cwd === 'string' && runnerConfig.cwd.trim().length > 0 ? runnerConfig.cwd.trim() : defaultRunner.cwd,
2381
+ token_env: builtInRunner && isGeminiRunner && tokenEnv === 'STITCH_API_TOKEN' ? '' : tokenEnv,
2382
+ extra_env: runnerConfig?.extra_env && typeof runnerConfig.extra_env === 'object' ? runnerConfig.extra_env : defaultRunner.extra_env,
2383
+ };
2384
+ }
2385
+ isBuiltInGeminiRunner(runnerConfig) {
2386
+ const command = typeof runnerConfig?.command === 'string' ? runnerConfig.command.trim() : '';
2387
+ if (!command) {
2388
+ return true;
2389
+ }
2390
+ const args = Array.isArray(runnerConfig?.args) ? runnerConfig.args.map(arg => String(arg)) : [];
2391
+ return command === 'node' && args.some(arg => arg.includes('gemini-stitch-adapter.js'));
2392
+ }
2393
+ isBuiltInCodexRunner(runnerConfig) {
2394
+ const command = typeof runnerConfig?.command === 'string' ? runnerConfig.command.trim() : '';
2395
+ if (!command) {
2396
+ return true;
2397
+ }
2398
+ const args = Array.isArray(runnerConfig?.args) ? runnerConfig.args.map(arg => String(arg)) : [];
2399
+ return command === 'node' && args.some(arg => arg.includes('codex-stitch-adapter.js'));
2400
+ }
2401
+ getEffectiveCheckpointRunnerConfig(checkpointConfig, runnerConfig) {
2402
+ const defaultRunner = this.createDefaultCheckpointPluginConfig().runner;
2403
+ const builtInRunner = this.isBuiltInCheckpointRunner(runnerConfig);
2404
+ const command = typeof runnerConfig?.command === 'string' ? runnerConfig.command.trim() : '';
2405
+ const args = Array.isArray(runnerConfig?.args) &&
2406
+ runnerConfig.args.length > 0 &&
2407
+ !builtInRunner
2408
+ ? runnerConfig.args.map(arg => String(arg))
2409
+ : defaultRunner.args;
2410
+ return {
2411
+ ...defaultRunner,
2412
+ ...(runnerConfig || {}),
2413
+ command: command || defaultRunner.command,
2414
+ args,
2415
+ cwd: typeof runnerConfig?.cwd === 'string' && runnerConfig.cwd.trim().length > 0 ? runnerConfig.cwd.trim() : defaultRunner.cwd,
2416
+ token_env: typeof runnerConfig?.token_env === 'string' ? runnerConfig.token_env.trim() : defaultRunner.token_env,
2417
+ extra_env: runnerConfig?.extra_env && typeof runnerConfig.extra_env === 'object' ? runnerConfig.extra_env : defaultRunner.extra_env,
2418
+ };
2419
+ }
2420
+ isBuiltInCheckpointRunner(runnerConfig) {
2421
+ const command = typeof runnerConfig?.command === 'string' ? runnerConfig.command.trim() : '';
2422
+ if (!command) {
2423
+ return true;
2424
+ }
2425
+ const args = Array.isArray(runnerConfig?.args) ? runnerConfig.args.map(arg => String(arg)) : [];
2426
+ return command === 'node' && args.some(arg => arg.includes('playwright-checkpoint-adapter.js'));
2427
+ }
2428
+ getPluginEntries(config) {
2429
+ const plugins = config.plugins || {};
2430
+ return Object.entries(plugins).map(([name, pluginConfig]) => {
2431
+ const capabilities = pluginConfig && typeof pluginConfig === 'object' && pluginConfig.capabilities && typeof pluginConfig.capabilities === 'object'
2432
+ ? Object.entries(pluginConfig.capabilities).map(([capabilityName, capabilityConfig]) => ({
2433
+ name: capabilityName,
2434
+ enabled: capabilityConfig?.enabled === true,
2435
+ step: typeof capabilityConfig?.step === 'string' ? capabilityConfig.step : '',
2436
+ activateWhenFlags: Array.isArray(capabilityConfig?.activate_when_flags)
2437
+ ? capabilityConfig.activate_when_flags
2438
+ : [],
2439
+ }))
2440
+ : [];
2441
+ const effectiveRunner = name === 'stitch'
2442
+ ? this.getEffectiveStitchRunnerConfig(pluginConfig, pluginConfig?.runner)
2443
+ : name === 'checkpoint'
2444
+ ? this.getEffectiveCheckpointRunnerConfig(pluginConfig, pluginConfig?.runner)
2445
+ : pluginConfig?.runner;
2446
+ const tokenEnv = typeof effectiveRunner?.token_env === 'string' ? effectiveRunner.token_env.trim() : '';
2447
+ const command = typeof effectiveRunner?.command === 'string' ? effectiveRunner.command.trim() : '';
2448
+ const extraEnvCount = effectiveRunner?.extra_env && typeof effectiveRunner.extra_env === 'object'
2449
+ ? Object.keys(effectiveRunner.extra_env).length
2450
+ : 0;
2451
+ const runner = effectiveRunner && typeof effectiveRunner === 'object'
2452
+ ? {
2453
+ mode: typeof effectiveRunner.mode === 'string' ? effectiveRunner.mode : 'command',
2454
+ configured: command.length > 0,
2455
+ command,
2456
+ source: name === 'stitch' && this.getStitchProvider(pluginConfig) === 'gemini' && this.isBuiltInGeminiRunner(pluginConfig?.runner)
2457
+ ? 'built-in Gemini adapter'
2458
+ : name === 'stitch' && this.getStitchProvider(pluginConfig) === 'codex' && this.isBuiltInCodexRunner(pluginConfig?.runner)
2459
+ ? 'built-in Codex adapter'
2460
+ : name === 'checkpoint' && this.isBuiltInCheckpointRunner(pluginConfig?.runner)
2461
+ ? 'built-in Playwright adapter'
2462
+ : 'custom',
2463
+ cwd: typeof effectiveRunner.cwd === 'string' ? effectiveRunner.cwd : '',
2464
+ timeoutMs: Number.isFinite(effectiveRunner.timeout_ms) && effectiveRunner.timeout_ms > 0
2465
+ ? Math.floor(effectiveRunner.timeout_ms)
2466
+ : name === 'checkpoint'
2467
+ ? 900000
2468
+ : 900000,
2469
+ tokenEnv,
2470
+ tokenPresent: tokenEnv ? Boolean(process.env[tokenEnv]) : false,
2471
+ extraEnvCount,
2472
+ }
2473
+ : null;
2474
+ return {
2475
+ name,
2476
+ enabled: pluginConfig?.enabled === true,
2477
+ blocking: pluginConfig?.blocking !== false,
2478
+ capabilities,
2479
+ runner,
2480
+ runtimeBaseUrl: name === 'checkpoint' && typeof pluginConfig?.runtime?.base_url === 'string'
2481
+ ? pluginConfig.runtime.base_url.trim()
2482
+ : '',
2483
+ storageState: name === 'checkpoint' && typeof pluginConfig?.runtime?.storage_state === 'string'
2484
+ ? pluginConfig.runtime.storage_state.trim()
2485
+ : '',
2486
+ };
2487
+ });
2488
+ }
2489
+ }
2490
+ exports.PluginsCommand = PluginsCommand;
2491
+ //# sourceMappingURL=PluginsCommand.js.map