@tuan_son.dinh/gsd 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +453 -0
  3. package/dist/app-paths.d.ts +4 -0
  4. package/dist/app-paths.js +6 -0
  5. package/dist/cli.d.ts +1 -0
  6. package/dist/cli.js +269 -0
  7. package/dist/loader.d.ts +2 -0
  8. package/dist/loader.js +70 -0
  9. package/dist/logo.d.ts +16 -0
  10. package/dist/logo.js +25 -0
  11. package/dist/onboarding.d.ts +43 -0
  12. package/dist/onboarding.js +418 -0
  13. package/dist/pi-migration.d.ts +14 -0
  14. package/dist/pi-migration.js +57 -0
  15. package/dist/resource-loader.d.ts +22 -0
  16. package/dist/resource-loader.js +60 -0
  17. package/dist/tool-bootstrap.d.ts +4 -0
  18. package/dist/tool-bootstrap.js +74 -0
  19. package/dist/wizard.d.ts +7 -0
  20. package/dist/wizard.js +25 -0
  21. package/package.json +60 -0
  22. package/patches/@mariozechner+pi-coding-agent+0.57.1.patch +108 -0
  23. package/patches/@mariozechner+pi-tui+0.57.1.patch +47 -0
  24. package/pkg/dist/modes/interactive/theme/dark.json +85 -0
  25. package/pkg/dist/modes/interactive/theme/light.json +84 -0
  26. package/pkg/dist/modes/interactive/theme/theme-schema.json +335 -0
  27. package/pkg/dist/modes/interactive/theme/theme.d.ts +78 -0
  28. package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -0
  29. package/pkg/dist/modes/interactive/theme/theme.js +949 -0
  30. package/pkg/dist/modes/interactive/theme/theme.js.map +1 -0
  31. package/pkg/package.json +8 -0
  32. package/scripts/postinstall.js +127 -0
  33. package/src/resources/GSD-WORKFLOW.md +661 -0
  34. package/src/resources/agents/researcher.md +29 -0
  35. package/src/resources/agents/scout.md +56 -0
  36. package/src/resources/agents/worker.md +31 -0
  37. package/src/resources/extensions/ask-user-questions.ts +249 -0
  38. package/src/resources/extensions/bg-shell/index.ts +2808 -0
  39. package/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +1277 -0
  40. package/src/resources/extensions/browser-tools/core.js +1057 -0
  41. package/src/resources/extensions/browser-tools/index.ts +4989 -0
  42. package/src/resources/extensions/browser-tools/package.json +20 -0
  43. package/src/resources/extensions/context7/index.ts +428 -0
  44. package/src/resources/extensions/context7/package.json +11 -0
  45. package/src/resources/extensions/get-secrets-from-user.ts +352 -0
  46. package/src/resources/extensions/google-search/index.ts +323 -0
  47. package/src/resources/extensions/google-search/package.json +9 -0
  48. package/src/resources/extensions/gsd/activity-log.ts +69 -0
  49. package/src/resources/extensions/gsd/auto.ts +2744 -0
  50. package/src/resources/extensions/gsd/commands.ts +313 -0
  51. package/src/resources/extensions/gsd/crash-recovery.ts +85 -0
  52. package/src/resources/extensions/gsd/dashboard-overlay.ts +521 -0
  53. package/src/resources/extensions/gsd/docs/preferences-reference.md +176 -0
  54. package/src/resources/extensions/gsd/doctor.ts +690 -0
  55. package/src/resources/extensions/gsd/files.ts +732 -0
  56. package/src/resources/extensions/gsd/git-service.ts +597 -0
  57. package/src/resources/extensions/gsd/gitignore.ts +168 -0
  58. package/src/resources/extensions/gsd/guided-flow.ts +817 -0
  59. package/src/resources/extensions/gsd/index.ts +558 -0
  60. package/src/resources/extensions/gsd/metrics.ts +374 -0
  61. package/src/resources/extensions/gsd/migrate/command.ts +218 -0
  62. package/src/resources/extensions/gsd/migrate/index.ts +42 -0
  63. package/src/resources/extensions/gsd/migrate/parser.ts +323 -0
  64. package/src/resources/extensions/gsd/migrate/parsers.ts +624 -0
  65. package/src/resources/extensions/gsd/migrate/preview.ts +48 -0
  66. package/src/resources/extensions/gsd/migrate/transformer.ts +346 -0
  67. package/src/resources/extensions/gsd/migrate/types.ts +370 -0
  68. package/src/resources/extensions/gsd/migrate/validator.ts +55 -0
  69. package/src/resources/extensions/gsd/migrate/writer.ts +539 -0
  70. package/src/resources/extensions/gsd/observability-validator.ts +408 -0
  71. package/src/resources/extensions/gsd/package.json +11 -0
  72. package/src/resources/extensions/gsd/paths.ts +308 -0
  73. package/src/resources/extensions/gsd/preferences.ts +757 -0
  74. package/src/resources/extensions/gsd/prompt-loader.ts +50 -0
  75. package/src/resources/extensions/gsd/prompts/complete-milestone.md +25 -0
  76. package/src/resources/extensions/gsd/prompts/complete-slice.md +29 -0
  77. package/src/resources/extensions/gsd/prompts/discuss.md +189 -0
  78. package/src/resources/extensions/gsd/prompts/doctor-heal.md +29 -0
  79. package/src/resources/extensions/gsd/prompts/execute-task.md +61 -0
  80. package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -0
  81. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -0
  82. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +59 -0
  83. package/src/resources/extensions/gsd/prompts/guided-execute-task.md +1 -0
  84. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +23 -0
  85. package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -0
  86. package/src/resources/extensions/gsd/prompts/guided-research-slice.md +11 -0
  87. package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -0
  88. package/src/resources/extensions/gsd/prompts/plan-milestone.md +65 -0
  89. package/src/resources/extensions/gsd/prompts/plan-slice.md +51 -0
  90. package/src/resources/extensions/gsd/prompts/queue.md +85 -0
  91. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +48 -0
  92. package/src/resources/extensions/gsd/prompts/replan-slice.md +39 -0
  93. package/src/resources/extensions/gsd/prompts/research-milestone.md +37 -0
  94. package/src/resources/extensions/gsd/prompts/research-slice.md +28 -0
  95. package/src/resources/extensions/gsd/prompts/review-migration.md +66 -0
  96. package/src/resources/extensions/gsd/prompts/run-uat.md +109 -0
  97. package/src/resources/extensions/gsd/prompts/system.md +187 -0
  98. package/src/resources/extensions/gsd/prompts/worktree-merge.md +123 -0
  99. package/src/resources/extensions/gsd/session-forensics.ts +487 -0
  100. package/src/resources/extensions/gsd/skill-discovery.ts +137 -0
  101. package/src/resources/extensions/gsd/state.ts +460 -0
  102. package/src/resources/extensions/gsd/templates/context.md +76 -0
  103. package/src/resources/extensions/gsd/templates/decisions.md +8 -0
  104. package/src/resources/extensions/gsd/templates/milestone-summary.md +73 -0
  105. package/src/resources/extensions/gsd/templates/plan.md +131 -0
  106. package/src/resources/extensions/gsd/templates/preferences.md +24 -0
  107. package/src/resources/extensions/gsd/templates/project.md +31 -0
  108. package/src/resources/extensions/gsd/templates/reassessment.md +28 -0
  109. package/src/resources/extensions/gsd/templates/requirements.md +81 -0
  110. package/src/resources/extensions/gsd/templates/research.md +46 -0
  111. package/src/resources/extensions/gsd/templates/roadmap.md +118 -0
  112. package/src/resources/extensions/gsd/templates/slice-context.md +58 -0
  113. package/src/resources/extensions/gsd/templates/slice-summary.md +99 -0
  114. package/src/resources/extensions/gsd/templates/state.md +19 -0
  115. package/src/resources/extensions/gsd/templates/task-plan.md +52 -0
  116. package/src/resources/extensions/gsd/templates/task-summary.md +57 -0
  117. package/src/resources/extensions/gsd/templates/uat.md +54 -0
  118. package/src/resources/extensions/gsd/tests/activity-log-prune.test.ts +327 -0
  119. package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +56 -0
  120. package/src/resources/extensions/gsd/tests/auto-supervisor.test.mjs +53 -0
  121. package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +225 -0
  122. package/src/resources/extensions/gsd/tests/cost-projection.test.ts +160 -0
  123. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +341 -0
  124. package/src/resources/extensions/gsd/tests/derive-state.test.ts +689 -0
  125. package/src/resources/extensions/gsd/tests/discuss-prompt.test.ts +38 -0
  126. package/src/resources/extensions/gsd/tests/doctor.test.ts +505 -0
  127. package/src/resources/extensions/gsd/tests/git-service.test.ts +1313 -0
  128. package/src/resources/extensions/gsd/tests/idle-recovery.test.ts +308 -0
  129. package/src/resources/extensions/gsd/tests/metrics-io.test.ts +201 -0
  130. package/src/resources/extensions/gsd/tests/metrics.test.ts +217 -0
  131. package/src/resources/extensions/gsd/tests/migrate-command.test.ts +390 -0
  132. package/src/resources/extensions/gsd/tests/migrate-parser.test.ts +786 -0
  133. package/src/resources/extensions/gsd/tests/migrate-transformer.test.ts +657 -0
  134. package/src/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts +443 -0
  135. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +318 -0
  136. package/src/resources/extensions/gsd/tests/migrate-writer.test.ts +420 -0
  137. package/src/resources/extensions/gsd/tests/must-have-parser.test.ts +309 -0
  138. package/src/resources/extensions/gsd/tests/parsers.test.ts +1351 -0
  139. package/src/resources/extensions/gsd/tests/plan-milestone.test.ts +163 -0
  140. package/src/resources/extensions/gsd/tests/plan-quality-validator.test.ts +386 -0
  141. package/src/resources/extensions/gsd/tests/reassess-prompt.test.ts +171 -0
  142. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +155 -0
  143. package/src/resources/extensions/gsd/tests/remote-status.test.ts +99 -0
  144. package/src/resources/extensions/gsd/tests/replan-slice.test.ts +521 -0
  145. package/src/resources/extensions/gsd/tests/requirements.test.ts +125 -0
  146. package/src/resources/extensions/gsd/tests/resolve-ts-hooks.mjs +34 -0
  147. package/src/resources/extensions/gsd/tests/resolve-ts.mjs +11 -0
  148. package/src/resources/extensions/gsd/tests/run-uat.test.ts +348 -0
  149. package/src/resources/extensions/gsd/tests/unit-runtime.test.ts +247 -0
  150. package/src/resources/extensions/gsd/tests/workflow-config.test.mjs +53 -0
  151. package/src/resources/extensions/gsd/tests/workspace-index.test.ts +94 -0
  152. package/src/resources/extensions/gsd/tests/worktree-integration.test.ts +253 -0
  153. package/src/resources/extensions/gsd/tests/worktree-manager.test.ts +160 -0
  154. package/src/resources/extensions/gsd/tests/worktree.test.ts +264 -0
  155. package/src/resources/extensions/gsd/types.ts +159 -0
  156. package/src/resources/extensions/gsd/unit-runtime.ts +184 -0
  157. package/src/resources/extensions/gsd/workspace-index.ts +203 -0
  158. package/src/resources/extensions/gsd/worktree-command.ts +845 -0
  159. package/src/resources/extensions/gsd/worktree-manager.ts +392 -0
  160. package/src/resources/extensions/gsd/worktree.ts +183 -0
  161. package/src/resources/extensions/mac-tools/index.ts +852 -0
  162. package/src/resources/extensions/mac-tools/swift-cli/Package.swift +22 -0
  163. package/src/resources/extensions/mac-tools/swift-cli/Sources/main.swift +1318 -0
  164. package/src/resources/extensions/mcporter/index.ts +429 -0
  165. package/src/resources/extensions/remote-questions/config.ts +81 -0
  166. package/src/resources/extensions/remote-questions/discord-adapter.ts +128 -0
  167. package/src/resources/extensions/remote-questions/format.ts +163 -0
  168. package/src/resources/extensions/remote-questions/manager.ts +192 -0
  169. package/src/resources/extensions/remote-questions/remote-command.ts +307 -0
  170. package/src/resources/extensions/remote-questions/slack-adapter.ts +92 -0
  171. package/src/resources/extensions/remote-questions/status.ts +31 -0
  172. package/src/resources/extensions/remote-questions/store.ts +77 -0
  173. package/src/resources/extensions/remote-questions/types.ts +75 -0
  174. package/src/resources/extensions/search-the-web/cache.ts +78 -0
  175. package/src/resources/extensions/search-the-web/command-search-provider.ts +95 -0
  176. package/src/resources/extensions/search-the-web/format.ts +258 -0
  177. package/src/resources/extensions/search-the-web/http.ts +238 -0
  178. package/src/resources/extensions/search-the-web/index.ts +65 -0
  179. package/src/resources/extensions/search-the-web/native-search.ts +157 -0
  180. package/src/resources/extensions/search-the-web/provider.ts +118 -0
  181. package/src/resources/extensions/search-the-web/tavily.ts +116 -0
  182. package/src/resources/extensions/search-the-web/tool-fetch-page.ts +519 -0
  183. package/src/resources/extensions/search-the-web/tool-llm-context.ts +561 -0
  184. package/src/resources/extensions/search-the-web/tool-search.ts +576 -0
  185. package/src/resources/extensions/search-the-web/url-utils.ts +91 -0
  186. package/src/resources/extensions/shared/confirm-ui.ts +126 -0
  187. package/src/resources/extensions/shared/interview-ui.ts +613 -0
  188. package/src/resources/extensions/shared/next-action-ui.ts +197 -0
  189. package/src/resources/extensions/shared/progress-widget.ts +282 -0
  190. package/src/resources/extensions/shared/terminal.ts +23 -0
  191. package/src/resources/extensions/shared/thinking-widget.ts +107 -0
  192. package/src/resources/extensions/shared/ui.ts +400 -0
  193. package/src/resources/extensions/shared/wizard-ui.ts +551 -0
  194. package/src/resources/extensions/slash-commands/audit.ts +88 -0
  195. package/src/resources/extensions/slash-commands/clear.ts +10 -0
  196. package/src/resources/extensions/slash-commands/create-extension.ts +297 -0
  197. package/src/resources/extensions/slash-commands/create-slash-command.ts +234 -0
  198. package/src/resources/extensions/slash-commands/index.ts +12 -0
  199. package/src/resources/extensions/subagent/agents.ts +126 -0
  200. package/src/resources/extensions/subagent/index.ts +1020 -0
  201. package/src/resources/extensions/voice/index.ts +195 -0
  202. package/src/resources/extensions/voice/speech-recognizer.swift +154 -0
  203. package/src/resources/skills/debug-like-expert/SKILL.md +231 -0
  204. package/src/resources/skills/debug-like-expert/references/debugging-mindset.md +253 -0
  205. package/src/resources/skills/debug-like-expert/references/hypothesis-testing.md +373 -0
  206. package/src/resources/skills/debug-like-expert/references/investigation-techniques.md +337 -0
  207. package/src/resources/skills/debug-like-expert/references/verification-patterns.md +425 -0
  208. package/src/resources/skills/debug-like-expert/references/when-to-research.md +361 -0
  209. package/src/resources/skills/frontend-design/SKILL.md +45 -0
  210. package/src/resources/skills/swiftui/SKILL.md +208 -0
  211. package/src/resources/skills/swiftui/references/animations.md +921 -0
  212. package/src/resources/skills/swiftui/references/architecture.md +1561 -0
  213. package/src/resources/skills/swiftui/references/layout-system.md +1186 -0
  214. package/src/resources/skills/swiftui/references/navigation.md +1492 -0
  215. package/src/resources/skills/swiftui/references/networking-async.md +214 -0
  216. package/src/resources/skills/swiftui/references/performance.md +1706 -0
  217. package/src/resources/skills/swiftui/references/platform-integration.md +204 -0
  218. package/src/resources/skills/swiftui/references/state-management.md +1443 -0
  219. package/src/resources/skills/swiftui/references/swiftdata.md +297 -0
  220. package/src/resources/skills/swiftui/references/testing-debugging.md +247 -0
  221. package/src/resources/skills/swiftui/references/uikit-appkit-interop.md +218 -0
  222. package/src/resources/skills/swiftui/workflows/add-feature.md +191 -0
  223. package/src/resources/skills/swiftui/workflows/build-new-app.md +311 -0
  224. package/src/resources/skills/swiftui/workflows/debug-swiftui.md +192 -0
  225. package/src/resources/skills/swiftui/workflows/optimize-performance.md +197 -0
  226. package/src/resources/skills/swiftui/workflows/ship-app.md +203 -0
  227. package/src/resources/skills/swiftui/workflows/write-tests.md +235 -0
@@ -0,0 +1,757 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join } from "node:path";
4
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
5
+ import type { GitPreferences } from "./git-service.ts";
6
+ import { VALID_BRANCH_NAME } from "./git-service.ts";
7
+
8
+ const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md");
9
+ const LEGACY_GLOBAL_PREFERENCES_PATH = join(homedir(), ".pi", "agent", "gsd-preferences.md");
10
+ const PROJECT_PREFERENCES_PATH = join(process.cwd(), ".gsd", "preferences.md");
11
+ const SKILL_ACTIONS = new Set(["use", "prefer", "avoid"]);
12
+
13
+ export interface GSDSkillRule {
14
+ when: string;
15
+ use?: string[];
16
+ prefer?: string[];
17
+ avoid?: string[];
18
+ }
19
+
20
+ export interface GSDModelConfig {
21
+ research?: string; // e.g. "claude-sonnet-4-6"
22
+ planning?: string; // e.g. "claude-opus-4-6"
23
+ execution?: string; // e.g. "claude-sonnet-4-6"
24
+ completion?: string; // e.g. "claude-sonnet-4-6"
25
+ }
26
+
27
+ export type PlanningDepth = "thorough" | "standard" | "minimal";
28
+
29
+ export interface GSDWorkflowConfig {
30
+ skip_milestone_research?: boolean;
31
+ skip_slice_research?: boolean;
32
+ skip_plan_self_audit?: boolean;
33
+ skip_reassessment?: boolean;
34
+ skip_observability?: boolean;
35
+ }
36
+
37
+ export type SkillDiscoveryMode = "auto" | "suggest" | "off";
38
+
39
+ export interface AutoSupervisorConfig {
40
+ model?: string;
41
+ soft_timeout_minutes?: number;
42
+ idle_timeout_minutes?: number;
43
+ hard_timeout_minutes?: number;
44
+ }
45
+
46
+ export interface RemoteQuestionsConfig {
47
+ channel: "slack" | "discord";
48
+ channel_id: string | number;
49
+ timeout_minutes?: number; // clamped to 1-30
50
+ poll_interval_seconds?: number; // clamped to 2-30
51
+ }
52
+
53
+ export interface GSDPreferences {
54
+ version?: number;
55
+ always_use_skills?: string[];
56
+ prefer_skills?: string[];
57
+ avoid_skills?: string[];
58
+ skill_rules?: GSDSkillRule[];
59
+ custom_instructions?: string[];
60
+ models?: GSDModelConfig;
61
+ skill_discovery?: SkillDiscoveryMode;
62
+ auto_supervisor?: AutoSupervisorConfig;
63
+ uat_dispatch?: boolean;
64
+ budget_ceiling?: number;
65
+ remote_questions?: RemoteQuestionsConfig;
66
+ git?: GitPreferences;
67
+ workflow?: GSDWorkflowConfig;
68
+ planning_depth?: PlanningDepth;
69
+ }
70
+
71
+ export interface LoadedGSDPreferences {
72
+ path: string;
73
+ scope: "global" | "project";
74
+ preferences: GSDPreferences;
75
+ }
76
+
77
+ export function getGlobalGSDPreferencesPath(): string {
78
+ return GLOBAL_PREFERENCES_PATH;
79
+ }
80
+
81
+ export function getLegacyGlobalGSDPreferencesPath(): string {
82
+ return LEGACY_GLOBAL_PREFERENCES_PATH;
83
+ }
84
+
85
+ export function getProjectGSDPreferencesPath(): string {
86
+ return PROJECT_PREFERENCES_PATH;
87
+ }
88
+
89
+ export function loadGlobalGSDPreferences(): LoadedGSDPreferences | null {
90
+ return loadPreferencesFile(GLOBAL_PREFERENCES_PATH, "global")
91
+ ?? loadPreferencesFile(LEGACY_GLOBAL_PREFERENCES_PATH, "global");
92
+ }
93
+
94
+ export function loadProjectGSDPreferences(): LoadedGSDPreferences | null {
95
+ return loadPreferencesFile(PROJECT_PREFERENCES_PATH, "project");
96
+ }
97
+
98
+ export function loadEffectiveGSDPreferences(): LoadedGSDPreferences | null {
99
+ const globalPreferences = loadGlobalGSDPreferences();
100
+ const projectPreferences = loadProjectGSDPreferences();
101
+
102
+ if (!globalPreferences && !projectPreferences) return null;
103
+ if (!globalPreferences) return projectPreferences;
104
+ if (!projectPreferences) return globalPreferences;
105
+
106
+ return {
107
+ path: projectPreferences.path,
108
+ scope: "project",
109
+ preferences: mergePreferences(globalPreferences.preferences, projectPreferences.preferences),
110
+ };
111
+ }
112
+
113
+ // ─── Skill Reference Resolution ───────────────────────────────────────────────
114
+
115
+ export interface SkillResolution {
116
+ /** The original reference from preferences (bare name or path). */
117
+ original: string;
118
+ /** The resolved absolute path to the SKILL.md file, or null if unresolved. */
119
+ resolvedPath: string | null;
120
+ /** How it was resolved. */
121
+ method: "absolute-path" | "absolute-dir" | "user-skill" | "project-skill" | "unresolved";
122
+ }
123
+
124
+ export interface SkillResolutionReport {
125
+ /** All resolution results, keyed by original reference. */
126
+ resolutions: Map<string, SkillResolution>;
127
+ /** References that could not be resolved. */
128
+ warnings: string[];
129
+ }
130
+
131
+ /**
132
+ * Known skill directories, in priority order.
133
+ * User skills (~/.gsd/agent/skills/) take precedence over project skills.
134
+ */
135
+ function getSkillSearchDirs(cwd: string): Array<{ dir: string; method: SkillResolution["method"] }> {
136
+ return [
137
+ { dir: join(getAgentDir(), "skills"), method: "user-skill" },
138
+ { dir: join(cwd, ".pi", "agent", "skills"), method: "project-skill" },
139
+ ];
140
+ }
141
+
142
+ /**
143
+ * Resolve a single skill reference to an absolute path.
144
+ *
145
+ * Resolution order:
146
+ * 1. Absolute path to a file → check existsSync
147
+ * 2. Absolute path to a directory → check for SKILL.md inside
148
+ * 3. Bare name → scan known skill directories for <name>/SKILL.md
149
+ */
150
+ function resolveSkillReference(ref: string, cwd: string): SkillResolution {
151
+ const trimmed = ref.trim();
152
+
153
+ // Expand tilde
154
+ const expanded = trimmed.startsWith("~/")
155
+ ? join(homedir(), trimmed.slice(2))
156
+ : trimmed;
157
+
158
+ // Absolute path
159
+ if (isAbsolute(expanded)) {
160
+ // Direct file reference
161
+ if (existsSync(expanded)) {
162
+ // Check if it's a directory — look for SKILL.md inside
163
+ try {
164
+ const stat = statSync(expanded);
165
+ if (stat.isDirectory()) {
166
+ const skillFile = join(expanded, "SKILL.md");
167
+ if (existsSync(skillFile)) {
168
+ return { original: ref, resolvedPath: skillFile, method: "absolute-dir" };
169
+ }
170
+ return { original: ref, resolvedPath: null, method: "unresolved" };
171
+ }
172
+ } catch { /* fall through */ }
173
+ return { original: ref, resolvedPath: expanded, method: "absolute-path" };
174
+ }
175
+ // Maybe it's a directory path without SKILL.md suffix
176
+ const withSkillMd = join(expanded, "SKILL.md");
177
+ if (existsSync(withSkillMd)) {
178
+ return { original: ref, resolvedPath: withSkillMd, method: "absolute-dir" };
179
+ }
180
+ return { original: ref, resolvedPath: null, method: "unresolved" };
181
+ }
182
+
183
+ // Bare name — scan known skill directories
184
+ for (const { dir, method } of getSkillSearchDirs(cwd)) {
185
+ if (!existsSync(dir)) continue;
186
+ try {
187
+ const entries = readdirSync(dir, { withFileTypes: true });
188
+ for (const entry of entries) {
189
+ if (!entry.isDirectory()) continue;
190
+ if (entry.name === expanded) {
191
+ const skillFile = join(dir, entry.name, "SKILL.md");
192
+ if (existsSync(skillFile)) {
193
+ return { original: ref, resolvedPath: skillFile, method };
194
+ }
195
+ }
196
+ }
197
+ } catch { /* directory not readable — skip */ }
198
+ }
199
+
200
+ return { original: ref, resolvedPath: null, method: "unresolved" };
201
+ }
202
+
203
+ /**
204
+ * Resolve all skill references in a preferences object.
205
+ * Caches resolution per reference string to avoid redundant filesystem scans.
206
+ */
207
+ export function resolveAllSkillReferences(preferences: GSDPreferences, cwd: string): SkillResolutionReport {
208
+ const validated = validatePreferences(preferences).preferences;
209
+ preferences = validated;
210
+
211
+ const resolutions = new Map<string, SkillResolution>();
212
+ const warnings: string[] = [];
213
+
214
+ function resolve(ref: string): SkillResolution {
215
+ const existing = resolutions.get(ref);
216
+ if (existing) return existing;
217
+ const result = resolveSkillReference(ref, cwd);
218
+ resolutions.set(ref, result);
219
+ if (result.method === "unresolved") {
220
+ warnings.push(ref);
221
+ }
222
+ return result;
223
+ }
224
+
225
+ // Resolve all skill lists
226
+ for (const skill of preferences.always_use_skills ?? []) resolve(skill);
227
+ for (const skill of preferences.prefer_skills ?? []) resolve(skill);
228
+ for (const skill of preferences.avoid_skills ?? []) resolve(skill);
229
+
230
+ // Resolve skill rules
231
+ for (const rule of preferences.skill_rules ?? []) {
232
+ for (const skill of rule.use ?? []) resolve(skill);
233
+ for (const skill of rule.prefer ?? []) resolve(skill);
234
+ for (const skill of rule.avoid ?? []) resolve(skill);
235
+ }
236
+
237
+ return { resolutions, warnings };
238
+ }
239
+
240
+ /**
241
+ * Format a skill reference for the system prompt.
242
+ * If resolved, shows the path so the agent knows exactly where to read.
243
+ * If unresolved, marks it clearly.
244
+ */
245
+ function formatSkillRef(ref: string, resolutions: Map<string, SkillResolution>): string {
246
+ const resolution = resolutions.get(ref);
247
+ if (!resolution || resolution.method === "unresolved") {
248
+ return `${ref} (⚠ not found — check skill name or path)`;
249
+ }
250
+ // For absolute paths where SKILL.md is just appended, don't clutter the output
251
+ if (resolution.method === "absolute-path" || resolution.method === "absolute-dir") {
252
+ return ref;
253
+ }
254
+ // For bare names resolved from skill directories, show the resolved path
255
+ return `${ref} → \`${resolution.resolvedPath}\``;
256
+ }
257
+
258
+ // ─── System Prompt Rendering ──────────────────────────────────────────────────
259
+
260
+ export function renderPreferencesForSystemPrompt(preferences: GSDPreferences, resolutions?: Map<string, SkillResolution>): string {
261
+ const validated = validatePreferences(preferences);
262
+ const lines: string[] = ["## GSD Skill Preferences"];
263
+
264
+ if (validated.errors.length > 0) {
265
+ lines.push("- Validation: some preference values were ignored because they were invalid.");
266
+ }
267
+
268
+ preferences = validated.preferences;
269
+
270
+ lines.push(
271
+ "- Treat these as explicit skill-selection policy for GSD work.",
272
+ "- If a listed skill exists and is relevant, load and follow it instead of treating it as a vague suggestion.",
273
+ "- Current user instructions still override these defaults.",
274
+ );
275
+
276
+ const fmt = (ref: string) => resolutions ? formatSkillRef(ref, resolutions) : ref;
277
+
278
+ if (preferences.always_use_skills && preferences.always_use_skills.length > 0) {
279
+ lines.push("- Always use these skills when relevant:");
280
+ for (const skill of preferences.always_use_skills) {
281
+ lines.push(` - ${fmt(skill)}`);
282
+ }
283
+ }
284
+
285
+ if (preferences.prefer_skills && preferences.prefer_skills.length > 0) {
286
+ lines.push("- Prefer these skills when relevant:");
287
+ for (const skill of preferences.prefer_skills) {
288
+ lines.push(` - ${fmt(skill)}`);
289
+ }
290
+ }
291
+
292
+ if (preferences.avoid_skills && preferences.avoid_skills.length > 0) {
293
+ lines.push("- Avoid these skills unless clearly needed:");
294
+ for (const skill of preferences.avoid_skills) {
295
+ lines.push(` - ${fmt(skill)}`);
296
+ }
297
+ }
298
+
299
+ if (preferences.skill_rules && preferences.skill_rules.length > 0) {
300
+ lines.push("- Situational rules:");
301
+ for (const rule of preferences.skill_rules) {
302
+ lines.push(` - When ${rule.when}:`);
303
+ if (rule.use && rule.use.length > 0) {
304
+ lines.push(` - use: ${rule.use.map(fmt).join(", ")}`);
305
+ }
306
+ if (rule.prefer && rule.prefer.length > 0) {
307
+ lines.push(` - prefer: ${rule.prefer.map(fmt).join(", ")}`);
308
+ }
309
+ if (rule.avoid && rule.avoid.length > 0) {
310
+ lines.push(` - avoid: ${rule.avoid.map(fmt).join(", ")}`);
311
+ }
312
+ }
313
+ }
314
+
315
+ if (preferences.custom_instructions && preferences.custom_instructions.length > 0) {
316
+ lines.push("- Additional instructions:");
317
+ for (const instruction of preferences.custom_instructions) {
318
+ lines.push(` - ${instruction}`);
319
+ }
320
+ }
321
+
322
+ return lines.join("\n");
323
+ }
324
+
325
+ function loadPreferencesFile(path: string, scope: "global" | "project"): LoadedGSDPreferences | null {
326
+ if (!existsSync(path)) return null;
327
+
328
+ const raw = readFileSync(path, "utf-8");
329
+ const preferences = parsePreferencesMarkdown(raw);
330
+ if (!preferences) return null;
331
+
332
+ return {
333
+ path,
334
+ scope,
335
+ preferences,
336
+ };
337
+ }
338
+
339
+ function parsePreferencesMarkdown(content: string): GSDPreferences | null {
340
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
341
+ if (!match) return null;
342
+ return parseFrontmatterBlock(match[1]);
343
+ }
344
+
345
+ function parseFrontmatterBlock(frontmatter: string): GSDPreferences {
346
+ const root: Record<string, unknown> = {};
347
+ const stack: Array<{ indent: number; value: Record<string, unknown> }> = [{ indent: -1, value: root }];
348
+
349
+ const lines = frontmatter.split(/\r?\n/);
350
+ for (let i = 0; i < lines.length; i++) {
351
+ const line = lines[i];
352
+ if (!line.trim()) continue;
353
+
354
+ const indent = line.match(/^\s*/)?.[0].length ?? 0;
355
+ const trimmed = line.trim();
356
+
357
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
358
+ stack.pop();
359
+ }
360
+
361
+ const current = stack[stack.length - 1].value;
362
+ const keyMatch = trimmed.match(/^([A-Za-z0-9_]+):(.*)$/);
363
+ if (!keyMatch) continue;
364
+
365
+ const [, key, remainder] = keyMatch;
366
+ const valuePart = remainder.trim();
367
+
368
+ if (valuePart === "") {
369
+ const nextLine = lines[i + 1] ?? "";
370
+ const nextTrimmed = nextLine.trim();
371
+ if (nextTrimmed.startsWith("- ")) {
372
+ const items: unknown[] = [];
373
+ let j = i + 1;
374
+ while (j < lines.length) {
375
+ const candidate = lines[j];
376
+ const candidateIndent = candidate.match(/^\s*/)?.[0].length ?? 0;
377
+ const candidateTrimmed = candidate.trim();
378
+ if (!candidateTrimmed) {
379
+ j++;
380
+ continue;
381
+ }
382
+ if (candidateIndent <= indent || !candidateTrimmed.startsWith("- ")) break;
383
+
384
+ const itemText = candidateTrimmed.slice(2).trim();
385
+ const nextCandidate = lines[j + 1] ?? "";
386
+ const nextCandidateIndent = nextCandidate.match(/^\s*/)?.[0].length ?? 0;
387
+ const nextCandidateTrimmed = nextCandidate.trim();
388
+
389
+ if (itemText.includes(":") || (nextCandidateTrimmed && nextCandidateIndent > candidateIndent)) {
390
+ const obj: Record<string, unknown> = {};
391
+ const firstMatch = itemText.match(/^([A-Za-z0-9_]+):(.*)$/);
392
+ if (firstMatch) {
393
+ obj[firstMatch[1]] = parseScalar(firstMatch[2].trim());
394
+ }
395
+ j++;
396
+ while (j < lines.length) {
397
+ const nested = lines[j];
398
+ const nestedIndent = nested.match(/^\s*/)?.[0].length ?? 0;
399
+ const nestedTrimmed = nested.trim();
400
+ if (!nestedTrimmed) {
401
+ j++;
402
+ continue;
403
+ }
404
+ if (nestedIndent <= candidateIndent) break;
405
+ const nestedMatch = nestedTrimmed.match(/^([A-Za-z0-9_]+):(.*)$/);
406
+ if (nestedMatch) {
407
+ const nestedValue = nestedMatch[2].trim();
408
+ if (nestedValue === "") {
409
+ const nestedItems: string[] = [];
410
+ j++;
411
+ while (j < lines.length) {
412
+ const nestedArrayLine = lines[j];
413
+ const nestedArrayIndent = nestedArrayLine.match(/^\s*/)?.[0].length ?? 0;
414
+ const nestedArrayTrimmed = nestedArrayLine.trim();
415
+ if (!nestedArrayTrimmed) {
416
+ j++;
417
+ continue;
418
+ }
419
+ if (nestedArrayIndent <= nestedIndent || !nestedArrayTrimmed.startsWith("- ")) break;
420
+ nestedItems.push(String(parseScalar(nestedArrayTrimmed.slice(2).trim())));
421
+ j++;
422
+ }
423
+ obj[nestedMatch[1]] = nestedItems;
424
+ continue;
425
+ }
426
+ obj[nestedMatch[1]] = parseScalar(nestedValue);
427
+ }
428
+ j++;
429
+ }
430
+ items.push(obj);
431
+ continue;
432
+ }
433
+
434
+ items.push(parseScalar(itemText));
435
+ j++;
436
+ }
437
+ current[key] = items;
438
+ i = j - 1;
439
+ } else {
440
+ const obj: Record<string, unknown> = {};
441
+ current[key] = obj;
442
+ stack.push({ indent, value: obj });
443
+ }
444
+ continue;
445
+ }
446
+
447
+ current[key] = parseScalar(valuePart);
448
+ }
449
+
450
+ return root as GSDPreferences;
451
+ }
452
+
453
+ function parseScalar(value: string): string | number | boolean {
454
+ // Strip inline comments: "true # some comment" → "true"
455
+ // Preserve # inside quoted strings by only stripping when unquoted.
456
+ const stripped = /^['"]/.test(value) ? value : value.replace(/\s+#.*$/, "");
457
+ if (stripped === "true") return true;
458
+ if (stripped === "false") return false;
459
+ if (/^-?\d+$/.test(stripped)) {
460
+ const n = Number(stripped);
461
+ // Keep large integers (e.g. Discord channel IDs) as strings to avoid precision loss
462
+ if (Number.isSafeInteger(n)) return n;
463
+ return stripped;
464
+ }
465
+ return stripped.replace(/^['\"]|['\"]$/g, "");
466
+ }
467
+
468
+ /**
469
+ * Resolve the skill discovery mode from effective preferences.
470
+ * Defaults to "suggest" — skills are identified during research but not installed automatically.
471
+ */
472
+ export function resolveSkillDiscoveryMode(): SkillDiscoveryMode {
473
+ const prefs = loadEffectiveGSDPreferences();
474
+ return prefs?.preferences.skill_discovery ?? "suggest";
475
+ }
476
+
477
+ /**
478
+ * Resolve which model ID to use for a given auto-mode unit type.
479
+ * Returns undefined if no model preference is set for this unit type.
480
+ */
481
+ export function resolveModelForUnit(unitType: string): string | undefined {
482
+ const prefs = loadEffectiveGSDPreferences();
483
+ if (!prefs?.preferences.models) return undefined;
484
+ const m = prefs.preferences.models;
485
+
486
+ switch (unitType) {
487
+ case "research-milestone":
488
+ case "research-slice":
489
+ return m.research;
490
+ case "plan-milestone":
491
+ case "plan-slice":
492
+ case "replan-slice":
493
+ return m.planning;
494
+ case "execute-task":
495
+ return m.execution;
496
+ case "complete-slice":
497
+ case "run-uat":
498
+ return m.completion;
499
+ default:
500
+ return undefined;
501
+ }
502
+ }
503
+
504
+ export function resolveAutoSupervisorConfig(): AutoSupervisorConfig {
505
+ const prefs = loadEffectiveGSDPreferences();
506
+ const configured = prefs?.preferences.auto_supervisor ?? {};
507
+
508
+ return {
509
+ soft_timeout_minutes: configured.soft_timeout_minutes ?? 20,
510
+ idle_timeout_minutes: configured.idle_timeout_minutes ?? 10,
511
+ hard_timeout_minutes: configured.hard_timeout_minutes ?? 30,
512
+ ...(configured.model ? { model: configured.model } : {}),
513
+ };
514
+ }
515
+
516
+ /**
517
+ * Resolve workflow configuration from effective preferences.
518
+ * Individual workflow.* overrides always win over planning_depth shortcut.
519
+ */
520
+ export function resolveWorkflowConfig(): Required<GSDWorkflowConfig> {
521
+ const prefs = loadEffectiveGSDPreferences();
522
+ const depth = prefs?.preferences.planning_depth ?? "thorough";
523
+ const wf = prefs?.preferences.workflow ?? {};
524
+
525
+ // Expand planning_depth into defaults
526
+ const defaults: Required<GSDWorkflowConfig> = {
527
+ skip_milestone_research: depth === "minimal",
528
+ skip_slice_research: depth === "standard" || depth === "minimal",
529
+ skip_plan_self_audit: depth === "standard" || depth === "minimal",
530
+ skip_reassessment: depth === "minimal",
531
+ skip_observability: depth === "minimal",
532
+ };
533
+
534
+ // Individual overrides win
535
+ return {
536
+ skip_milestone_research: wf.skip_milestone_research ?? defaults.skip_milestone_research,
537
+ skip_slice_research: wf.skip_slice_research ?? defaults.skip_slice_research,
538
+ skip_plan_self_audit: wf.skip_plan_self_audit ?? defaults.skip_plan_self_audit,
539
+ skip_reassessment: wf.skip_reassessment ?? defaults.skip_reassessment,
540
+ skip_observability: wf.skip_observability ?? defaults.skip_observability,
541
+ };
542
+ }
543
+
544
+ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPreferences {
545
+ return {
546
+ version: override.version ?? base.version,
547
+ always_use_skills: mergeStringLists(base.always_use_skills, override.always_use_skills),
548
+ prefer_skills: mergeStringLists(base.prefer_skills, override.prefer_skills),
549
+ avoid_skills: mergeStringLists(base.avoid_skills, override.avoid_skills),
550
+ skill_rules: [...(base.skill_rules ?? []), ...(override.skill_rules ?? [])],
551
+ custom_instructions: mergeStringLists(base.custom_instructions, override.custom_instructions),
552
+ models: { ...(base.models ?? {}), ...(override.models ?? {}) },
553
+ skill_discovery: override.skill_discovery ?? base.skill_discovery,
554
+ auto_supervisor: { ...(base.auto_supervisor ?? {}), ...(override.auto_supervisor ?? {}) },
555
+ uat_dispatch: override.uat_dispatch ?? base.uat_dispatch,
556
+ budget_ceiling: override.budget_ceiling ?? base.budget_ceiling,
557
+ remote_questions: override.remote_questions
558
+ ? { ...(base.remote_questions ?? {}), ...override.remote_questions }
559
+ : base.remote_questions,
560
+ git: (base.git || override.git)
561
+ ? { ...(base.git ?? {}), ...(override.git ?? {}) }
562
+ : undefined,
563
+ workflow: { ...(base.workflow ?? {}), ...(override.workflow ?? {}) },
564
+ planning_depth: override.planning_depth ?? base.planning_depth,
565
+ };
566
+ }
567
+
568
+ function validatePreferences(preferences: GSDPreferences): {
569
+ preferences: GSDPreferences;
570
+ errors: string[];
571
+ } {
572
+ const errors: string[] = [];
573
+ const validated: GSDPreferences = {};
574
+
575
+ if (preferences.version !== undefined) {
576
+ if (preferences.version === 1) {
577
+ validated.version = 1;
578
+ } else {
579
+ errors.push(`unsupported version ${preferences.version}`);
580
+ }
581
+ }
582
+
583
+ const validDiscoveryModes = new Set(["auto", "suggest", "off"]);
584
+ if (preferences.skill_discovery) {
585
+ if (validDiscoveryModes.has(preferences.skill_discovery)) {
586
+ validated.skill_discovery = preferences.skill_discovery;
587
+ } else {
588
+ errors.push(`invalid skill_discovery value: ${preferences.skill_discovery}`);
589
+ }
590
+ }
591
+
592
+ validated.always_use_skills = normalizeStringList(preferences.always_use_skills);
593
+ validated.prefer_skills = normalizeStringList(preferences.prefer_skills);
594
+ validated.avoid_skills = normalizeStringList(preferences.avoid_skills);
595
+ validated.custom_instructions = normalizeStringList(preferences.custom_instructions);
596
+
597
+ if (preferences.skill_rules) {
598
+ const validRules: GSDSkillRule[] = [];
599
+ for (const rule of preferences.skill_rules) {
600
+ if (!rule || typeof rule !== "object") {
601
+ errors.push("invalid skill_rules entry");
602
+ continue;
603
+ }
604
+ const when = typeof rule.when === "string" ? rule.when.trim() : "";
605
+ if (!when) {
606
+ errors.push("skill_rules entry missing when");
607
+ continue;
608
+ }
609
+ const validatedRule: GSDSkillRule = { when };
610
+ for (const action of SKILL_ACTIONS) {
611
+ const values = normalizeStringList((rule as Record<string, unknown>)[action]);
612
+ if (values.length > 0) {
613
+ validatedRule[action as keyof GSDSkillRule] = values as never;
614
+ }
615
+ }
616
+ if (!validatedRule.use && !validatedRule.prefer && !validatedRule.avoid) {
617
+ errors.push(`skill rule has no actions: ${when}`);
618
+ continue;
619
+ }
620
+ validRules.push(validatedRule);
621
+ }
622
+ if (validRules.length > 0) {
623
+ validated.skill_rules = validRules;
624
+ }
625
+ }
626
+
627
+ for (const key of ["always_use_skills", "prefer_skills", "avoid_skills", "custom_instructions"] as const) {
628
+ if (validated[key] && validated[key]!.length === 0) {
629
+ delete validated[key];
630
+ }
631
+ }
632
+
633
+ if (preferences.uat_dispatch !== undefined) {
634
+ validated.uat_dispatch = !!preferences.uat_dispatch;
635
+ }
636
+
637
+ if (preferences.budget_ceiling !== undefined) {
638
+ const raw = preferences.budget_ceiling;
639
+ if (typeof raw === "number" && Number.isFinite(raw)) {
640
+ validated.budget_ceiling = raw;
641
+ } else if (typeof raw === "string" && Number.isFinite(Number(raw))) {
642
+ validated.budget_ceiling = Number(raw);
643
+ } else {
644
+ errors.push("budget_ceiling must be a finite number");
645
+ }
646
+ }
647
+
648
+ // ─── Git Preferences ───────────────────────────────────────────────────
649
+ if (preferences.git && typeof preferences.git === "object") {
650
+ const git: Record<string, unknown> = {};
651
+ const g = preferences.git as Record<string, unknown>;
652
+
653
+ if (g.auto_push !== undefined) {
654
+ if (typeof g.auto_push === "boolean") git.auto_push = g.auto_push;
655
+ else errors.push("git.auto_push must be a boolean");
656
+ }
657
+ if (g.push_branches !== undefined) {
658
+ if (typeof g.push_branches === "boolean") git.push_branches = g.push_branches;
659
+ else errors.push("git.push_branches must be a boolean");
660
+ }
661
+ if (g.remote !== undefined) {
662
+ if (typeof g.remote === "string" && g.remote.trim() !== "") git.remote = g.remote.trim();
663
+ else errors.push("git.remote must be a non-empty string");
664
+ }
665
+ if (g.snapshots !== undefined) {
666
+ if (typeof g.snapshots === "boolean") git.snapshots = g.snapshots;
667
+ else errors.push("git.snapshots must be a boolean");
668
+ }
669
+ if (g.pre_merge_check !== undefined) {
670
+ if (typeof g.pre_merge_check === "boolean") {
671
+ git.pre_merge_check = g.pre_merge_check;
672
+ } else if (typeof g.pre_merge_check === "string" && g.pre_merge_check.trim() !== "") {
673
+ git.pre_merge_check = g.pre_merge_check.trim();
674
+ } else {
675
+ errors.push("git.pre_merge_check must be a boolean or a non-empty string command");
676
+ }
677
+ }
678
+ if (g.commit_type !== undefined) {
679
+ const validCommitTypes = new Set([
680
+ "feat", "fix", "refactor", "docs", "test", "chore", "perf", "ci", "build", "style",
681
+ ]);
682
+ if (typeof g.commit_type === "string" && validCommitTypes.has(g.commit_type)) {
683
+ git.commit_type = g.commit_type;
684
+ } else {
685
+ errors.push(`git.commit_type must be one of: feat, fix, refactor, docs, test, chore, perf, ci, build, style`);
686
+ }
687
+ }
688
+ if (g.main_branch !== undefined) {
689
+ if (typeof g.main_branch === "string" && g.main_branch.trim() !== "" && VALID_BRANCH_NAME.test(g.main_branch)) {
690
+ git.main_branch = g.main_branch;
691
+ } else {
692
+ errors.push("git.main_branch must be a valid branch name (alphanumeric, _, -, /, .)");
693
+ }
694
+ }
695
+
696
+ if (Object.keys(git).length > 0) {
697
+ validated.git = git as GitPreferences;
698
+ }
699
+ }
700
+
701
+ // planning_depth
702
+ const validPlanningDepths = new Set(["thorough", "standard", "minimal"]);
703
+ if (preferences.planning_depth) {
704
+ if (validPlanningDepths.has(preferences.planning_depth)) {
705
+ validated.planning_depth = preferences.planning_depth;
706
+ } else {
707
+ errors.push(`invalid planning_depth value: ${preferences.planning_depth}`);
708
+ }
709
+ }
710
+
711
+ // workflow
712
+ if (preferences.workflow && typeof preferences.workflow === "object") {
713
+ const validWorkflow: GSDWorkflowConfig = {};
714
+ const boolKeys: Array<keyof GSDWorkflowConfig> = [
715
+ "skip_milestone_research",
716
+ "skip_slice_research",
717
+ "skip_plan_self_audit",
718
+ "skip_reassessment",
719
+ "skip_observability",
720
+ ];
721
+ for (const key of boolKeys) {
722
+ const raw = (preferences.workflow as Record<string, unknown>)[key];
723
+ if (raw !== undefined) {
724
+ if (typeof raw === "boolean") {
725
+ validWorkflow[key] = raw;
726
+ } else if (raw === "true" || raw === "false") {
727
+ validWorkflow[key] = raw === "true";
728
+ } else {
729
+ errors.push(`workflow.${key} must be a boolean`);
730
+ }
731
+ }
732
+ }
733
+ if (Object.keys(validWorkflow).length > 0) {
734
+ validated.workflow = validWorkflow;
735
+ }
736
+ }
737
+
738
+ return { preferences: validated, errors };
739
+ }
740
+
741
+ function mergeStringLists(base?: unknown, override?: unknown): string[] | undefined {
742
+ const merged = [
743
+ ...normalizeStringList(base),
744
+ ...normalizeStringList(override),
745
+ ]
746
+ .map((item) => item.trim())
747
+ .filter(Boolean);
748
+ return merged.length > 0 ? Array.from(new Set(merged)) : undefined;
749
+ }
750
+
751
+ function normalizeStringList(value: unknown): string[] {
752
+ if (!Array.isArray(value)) return [];
753
+ return value
754
+ .filter((item): item is string => typeof item === "string")
755
+ .map((item) => item.trim())
756
+ .filter(Boolean);
757
+ }