@workit-dev/core 0.0.1 → 1.0.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 (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -43
  3. package/package.json +56 -6
  4. package/schemas/config.json +74 -0
  5. package/schemas/vcs.json +79 -0
  6. package/schemas/workspaces.json +1029 -0
  7. package/schemas/youtrack.json +84 -0
  8. package/scripts/analyze-release-scope.ts +215 -0
  9. package/scripts/bundled-deps.json +131 -0
  10. package/scripts/fixtures/sample-plan.md +15 -0
  11. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  12. package/scripts/install-codex-plugin.sh +28 -0
  13. package/scripts/install-cursor-plugin.sh +146 -0
  14. package/scripts/install-opencode-plugin.sh +79 -0
  15. package/scripts/install-pi-package.sh +56 -0
  16. package/scripts/lib/config-dir.sh +28 -0
  17. package/scripts/publish-changed-packages.ts +395 -0
  18. package/scripts/rewrite-workspace-deps.ts +53 -0
  19. package/scripts/run-cursor-mcp.sh +6 -0
  20. package/scripts/sync-release-manifests.ts +82 -0
  21. package/scripts/sync-runtime.sh +142 -0
  22. package/scripts/validate-cursor-marketplace.ts +171 -0
  23. package/scripts/verify-bundle-sources.ts +134 -0
  24. package/scripts/write-schemas.ts +16 -0
  25. package/skills/workit-architecture/SKILL.md +72 -0
  26. package/skills/workit-architecture/agents/openai.yaml +3 -0
  27. package/skills/workit-architecture/references/instruction-files.md +52 -0
  28. package/skills/workit-architecture/references/vocabulary.md +53 -0
  29. package/skills/workit-bdd/SKILL.md +53 -0
  30. package/skills/workit-continue/SKILL.md +54 -0
  31. package/skills/workit-debug/SKILL.md +45 -0
  32. package/skills/workit-deslop/SKILL.md +58 -0
  33. package/skills/workit-fanout/SKILL.md +71 -0
  34. package/skills/workit-fanout/references/brief.md +141 -0
  35. package/skills/workit-fanout/references/orchestration.md +169 -0
  36. package/skills/workit-implement/SKILL.md +71 -0
  37. package/skills/workit-retro/SKILL.md +72 -0
  38. package/skills/workit-retro/agents/openai.yaml +3 -0
  39. package/skills/workit-retro/references/sources.md +35 -0
  40. package/skills/workit-retro/references/steering.md +39 -0
  41. package/skills/workit-review/SKILL.md +66 -0
  42. package/skills/workit-review/references/impact.md +24 -0
  43. package/skills/workit-shape/SKILL.md +71 -0
  44. package/skills/workit-shape/references/diagrams.md +17 -0
  45. package/skills/workit-shape/references/knowledge.md +61 -0
  46. package/skills/workit-shape/references/mockups.md +15 -0
  47. package/skills/workit-shape/references/slicing.md +44 -0
  48. package/skills/workit-ship/SKILL.md +71 -0
  49. package/skills/workit-ship/references/changelog.md +44 -0
  50. package/skills/workit-ship/references/pr-body.md +32 -0
  51. package/skills/workit-ship/references/release.md +39 -0
  52. package/skills/workit-test-audit/SKILL.md +54 -0
  53. package/skills/workit-verify-app/SKILL.md +68 -0
  54. package/skills/workit-verify-app/references/template.md +51 -0
  55. package/src/autonomy.ts +910 -0
  56. package/src/check-config.ts +613 -0
  57. package/src/checks.ts +534 -0
  58. package/src/ci-waiter.ts +159 -0
  59. package/src/core/boundary.ts +55 -0
  60. package/src/core/branch-policy.ts +112 -0
  61. package/src/core/branch.ts +1192 -0
  62. package/src/core/changelog-gate.ts +349 -0
  63. package/src/core/changelog-merge.ts +214 -0
  64. package/src/core/changelog.ts +392 -0
  65. package/src/core/cleanup.ts +638 -0
  66. package/src/core/cli-locate.ts +173 -0
  67. package/src/core/commit-flavors.ts +65 -0
  68. package/src/core/config-schemas.ts +653 -0
  69. package/src/core/config.ts +351 -0
  70. package/src/core/context-read.ts +144 -0
  71. package/src/core/docs-gate.ts +69 -0
  72. package/src/core/finding-evidence.ts +89 -0
  73. package/src/core/finding-queue.ts +36 -0
  74. package/src/core/git.ts +73 -0
  75. package/src/core/gitignore.ts +52 -0
  76. package/src/core/heavy-eta.ts +170 -0
  77. package/src/core/heavy-lease.ts +290 -0
  78. package/src/core/heavy-rules.ts +373 -0
  79. package/src/core/heavy-scope.ts +131 -0
  80. package/src/core/heavy-words.ts +83 -0
  81. package/src/core/heavy.ts +535 -0
  82. package/src/core/hygiene.ts +149 -0
  83. package/src/core/init.ts +218 -0
  84. package/src/core/live-session.ts +333 -0
  85. package/src/core/logger.ts +310 -0
  86. package/src/core/methods.ts +185 -0
  87. package/src/core/model-fallback.ts +208 -0
  88. package/src/core/node-resolve.mjs +249 -0
  89. package/src/core/operation-input.ts +632 -0
  90. package/src/core/package-root.ts +28 -0
  91. package/src/core/policy/attributes.ts +97 -0
  92. package/src/core/policy/bookkeeping.ts +119 -0
  93. package/src/core/policy/carry.ts +122 -0
  94. package/src/core/policy/check-evidence.ts +115 -0
  95. package/src/core/policy/current-judgment.ts +20 -0
  96. package/src/core/policy/derive.ts +387 -0
  97. package/src/core/policy/judgment.ts +246 -0
  98. package/src/core/policy/lane.ts +562 -0
  99. package/src/core/policy/pr-lane.ts +32 -0
  100. package/src/core/policy/substance.ts +365 -0
  101. package/src/core/policy-resolver.ts +94 -0
  102. package/src/core/pr-create.ts +448 -0
  103. package/src/core/pr-template.ts +162 -0
  104. package/src/core/release-notes.ts +197 -0
  105. package/src/core/release-tracks.ts +779 -0
  106. package/src/core/remotes.ts +436 -0
  107. package/src/core/repo-context.ts +542 -0
  108. package/src/core/repo-paths.ts +29 -0
  109. package/src/core/resources.ts +1299 -0
  110. package/src/core/role-models.ts +603 -0
  111. package/src/core/route-intent.ts +305 -0
  112. package/src/core/rules.ts +65 -0
  113. package/src/core/runtime-identity.ts +63 -0
  114. package/src/core/safe-write.ts +87 -0
  115. package/src/core/scope-glob.ts +56 -0
  116. package/src/core/scripts.ts +6 -0
  117. package/src/core/skill-manifests.ts +173 -0
  118. package/src/core/store-lock.ts +374 -0
  119. package/src/core/support-matrix.ts +24 -0
  120. package/src/core/task-context.ts +247 -0
  121. package/src/core/task-contract.ts +1279 -0
  122. package/src/core/task-delivery.ts +109 -0
  123. package/src/core/task-engine.ts +2248 -0
  124. package/src/core/task-evaluation.ts +1147 -0
  125. package/src/core/task-store.ts +2649 -0
  126. package/src/core/task-view.ts +180 -0
  127. package/src/core/templates.ts +22 -0
  128. package/src/core/tracker-issues.ts +174 -0
  129. package/src/core/vcs-config.ts +540 -0
  130. package/src/core/workers.ts +62 -0
  131. package/src/core/workit-paths.mjs +242 -0
  132. package/src/core/workspace-branch-policy.ts +123 -0
  133. package/src/core/workspaces.ts +1745 -0
  134. package/src/core/youtrack-backend.ts +288 -0
  135. package/src/core/youtrack-tools.ts +260 -0
  136. package/src/core/youtrack-writes.ts +73 -0
  137. package/src/core/youtrack-ytrack.ts +217 -0
  138. package/src/core/youtrack.ts +454 -0
  139. package/src/core/ytrack-skill.ts +297 -0
  140. package/src/core.ts +171 -0
  141. package/src/deslop/lex.ts +271 -0
  142. package/src/deslop/scan.ts +232 -0
  143. package/src/duration.ts +27 -0
  144. package/src/fanout-brief.ts +486 -0
  145. package/src/fanout-check.ts +455 -0
  146. package/src/fanout-landed.ts +370 -0
  147. package/src/fanout-stack.ts +108 -0
  148. package/src/fanout-status.ts +362 -0
  149. package/src/fanout-worktree.ts +893 -0
  150. package/src/fanout.ts +1473 -0
  151. package/src/forge/exec.ts +385 -0
  152. package/src/forge/github.ts +831 -0
  153. package/src/forge/gitlab.ts +906 -0
  154. package/src/forge/issue-forge.ts +209 -0
  155. package/src/forge/issue-link.ts +468 -0
  156. package/src/forge/issue-ops.ts +314 -0
  157. package/src/forge/merge-gate-base-red.ts +72 -0
  158. package/src/forge/pr-body.ts +100 -0
  159. package/src/forge/pr-edit.ts +292 -0
  160. package/src/forge/pr-ops.ts +1109 -0
  161. package/src/forge/pr-status.ts +10 -0
  162. package/src/forge/redact.ts +195 -0
  163. package/src/forge/report.ts +994 -0
  164. package/src/forge/reruns.ts +132 -0
  165. package/src/forge/resolve.ts +509 -0
  166. package/src/forge/types.ts +242 -0
  167. package/src/forge/verify.ts +398 -0
  168. package/src/git/default-base.ts +130 -0
  169. package/src/git/env-redirect.ts +28 -0
  170. package/src/git/facts.ts +122 -0
  171. package/src/git/ops.ts +1823 -0
  172. package/src/git/refs-read.ts +332 -0
  173. package/src/git/release.ts +1020 -0
  174. package/src/git/rev.ts +1175 -0
  175. package/src/git/session-hook.ts +597 -0
  176. package/src/git/stage.ts +63 -0
  177. package/src/hooks/agent-model.ts +149 -0
  178. package/src/hooks/budget-signal.ts +284 -0
  179. package/src/hooks/context.ts +461 -0
  180. package/src/hooks/cwd-normalize.ts +79 -0
  181. package/src/hooks/deadline.ts +228 -0
  182. package/src/hooks/delivery-verbs.ts +182 -0
  183. package/src/hooks/descriptor.ts +124 -0
  184. package/src/hooks/handle.ts +433 -0
  185. package/src/hooks/heavy-gate.ts +99 -0
  186. package/src/hooks/hook-runs.ts +130 -0
  187. package/src/hooks/hosts/claude-code.ts +380 -0
  188. package/src/hooks/hosts/codex.ts +372 -0
  189. package/src/hooks/hosts/cursor.ts +419 -0
  190. package/src/hooks/hosts/fields.ts +98 -0
  191. package/src/hooks/hosts/opencode.ts +79 -0
  192. package/src/hooks/hosts/pi.ts +78 -0
  193. package/src/hooks/index.ts +60 -0
  194. package/src/hooks/model-limit.ts +378 -0
  195. package/src/hooks/model-switch.ts +35 -0
  196. package/src/hooks/policy.ts +29 -0
  197. package/src/hooks/process.ts +48 -0
  198. package/src/hooks/protocol.ts +135 -0
  199. package/src/hooks/raw-git.ts +2289 -0
  200. package/src/hooks/repeat-failure.ts +115 -0
  201. package/src/hooks/run.ts +54 -0
  202. package/src/hooks/session-marker.ts +151 -0
  203. package/src/hooks/shell-words.ts +532 -0
  204. package/src/hooks/skill-nudge.ts +295 -0
  205. package/src/hooks/spawn-gate.ts +72 -0
  206. package/src/hooks/standing-signal.ts +99 -0
  207. package/src/hooks/standing.ts +225 -0
  208. package/src/hooks/steering.ts +74 -0
  209. package/src/hooks/stop.ts +408 -0
  210. package/src/hooks/worktree-confine.ts +415 -0
  211. package/src/hooks/write-gate.ts +1174 -0
  212. package/src/host-session.ts +459 -0
  213. package/src/knowledge.ts +368 -0
  214. package/src/ledger.ts +2913 -0
  215. package/src/stack.ts +2570 -0
  216. package/src/store/event-log.ts +217 -0
  217. package/src/store/notes.ts +29 -0
  218. package/src/store/patch.ts +135 -0
  219. package/src/store/paths.ts +368 -0
  220. package/src/store/reduce.ts +203 -0
  221. package/src/test-audit-mocks.ts +309 -0
  222. package/src/verdict-cover.ts +151 -0
  223. package/src/worktree-deps.ts +142 -0
  224. package/templates/headers.md +3 -0
  225. package/templates/hygiene/.editorconfig +8 -0
  226. package/templates/hygiene/.gitattributes +3 -0
  227. package/templates/hygiene/CHANGELOG.md +14 -0
  228. package/templates/hygiene/CONTRIBUTING.md +3 -0
  229. package/templates/hygiene/LICENSE +21 -0
  230. package/templates/hygiene/README.md +3 -0
  231. package/templates/issue-update.md +5 -0
  232. package/templates/plan-template.md +26 -0
  233. package/templates/spec-template.md +64 -0
  234. package/templates/workit-contract.md +13 -0
@@ -0,0 +1,653 @@
1
+ // JSON Schemas for the hand-edited config files (#614), generated from the
2
+ // zod schemas Workit validates with so the editor schema cannot drift from
3
+ // what Workit enforces. Fields Workit reads leniently on purpose (D17: an
4
+ // older Workit tolerates a newer value) stay z.unknown() at runtime and get
5
+ // an editor-only definition here, so completion offers their allowed values.
6
+ // The editor schemas are stricter than the readers: an unknown key or a bad
7
+ // value is underlined, while Workit itself still warns and falls back.
8
+ // `bun run write:schemas` writes packages/workit-core/schemas/; a test fails
9
+ // when a committed schema is stale.
10
+ import { z } from "zod";
11
+ import { DEFAULT_ENDPOINTS } from "../autonomy";
12
+ import { COMMIT_PRESETS, LOCALE_RE, PRESETS, UPDATES_MODES } from "./config";
13
+ import { MODEL_ROLES, MODEL_TIERS } from "./role-models";
14
+ import {
15
+ CHANGELOG_MODES,
16
+ DELEGATION_MODES,
17
+ DOCS_MODES,
18
+ MODEL_HOST_KEYS,
19
+ PR_TEMPLATE_MODES,
20
+ RELEASE_NOTES_MODES,
21
+ branchPreset,
22
+ commitPreset,
23
+ modelRoles,
24
+ nonBlank,
25
+ releaseTrackSchema,
26
+ workspaceConfigSchema,
27
+ workspacesFileSchema,
28
+ } from "./workspaces";
29
+
30
+ export const CONFIG_SCHEMA_FILES = [
31
+ "workspaces.json",
32
+ "config.json",
33
+ "vcs.json",
34
+ "youtrack.json",
35
+ ] as const;
36
+ export type ConfigSchemaFile = (typeof CONFIG_SCHEMA_FILES)[number];
37
+
38
+ export const CONFIG_SCHEMA_BASE_URL = "https://unpkg.com/@workit-dev/core/schemas/";
39
+
40
+ /** The published URL of a config file's schema, what `"$schema"` points at. */
41
+ export const configSchemaUrl = (file: ConfigSchemaFile): string =>
42
+ `${CONFIG_SCHEMA_BASE_URL}${file}`;
43
+
44
+ export const isConfigSchemaFile = (name: string): name is ConfigSchemaFile =>
45
+ (CONFIG_SCHEMA_FILES as readonly string[]).includes(name);
46
+
47
+ /**
48
+ * `document` with `"$schema"` as its first key: the user's own value when it
49
+ * has one, else the published schema of `file`.
50
+ */
51
+ export const withSchemaRef = <T extends Record<string, unknown>>(
52
+ file: ConfigSchemaFile,
53
+ document: T,
54
+ ): T & { $schema: unknown } => {
55
+ const { $schema, ...rest } = document;
56
+ return {
57
+ $schema: $schema === undefined ? configSchemaUrl(file) : $schema,
58
+ ...rest,
59
+ } as unknown as T & {
60
+ $schema: unknown;
61
+ };
62
+ };
63
+
64
+ type JsonSchema = Record<string, unknown>;
65
+
66
+ const string = (description?: string): JsonSchema => ({
67
+ type: "string",
68
+ ...(description ? { description } : {}),
69
+ });
70
+ const enumOf = (values: readonly string[]): JsonSchema => ({
71
+ type: "string",
72
+ enum: [...values],
73
+ });
74
+ const closed = (properties: Record<string, JsonSchema>, required?: string[]): JsonSchema => ({
75
+ type: "object",
76
+ properties,
77
+ ...(required ? { required } : {}),
78
+ additionalProperties: false,
79
+ });
80
+
81
+ /** A repo-relative path or glob: not absolute, no drive letter, no `..` segment. */
82
+ const REPO_PATH = String.raw`^(?![\\/]|[A-Za-z]:)(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).*\S`;
83
+ const repoPath = (description: string, suffix = ""): JsonSchema => ({
84
+ type: "string",
85
+ pattern: suffix ? `${REPO_PATH}.*${suffix}$` : REPO_PATH,
86
+ description,
87
+ });
88
+ const nonBlankString: JsonSchema = { type: "string", pattern: String.raw`\S` };
89
+
90
+ const perHost = (leaf: JsonSchema): JsonSchema =>
91
+ closed(Object.fromEntries(MODEL_HOST_KEYS.map((host) => [host, leaf])));
92
+ const perTier = (value: JsonSchema): JsonSchema =>
93
+ closed(Object.fromEntries(MODEL_TIERS.map((tier) => [tier, value])));
94
+ const ref = (name: string): JsonSchema => ({ $ref: `#/definitions/${name}` });
95
+
96
+ /** Shared editor fragments, referenced by `$ref` so the schema stays small. */
97
+ const DEFINITIONS: Record<string, JsonSchema> = {
98
+ modelLeaf: {
99
+ anyOf: [
100
+ { ...nonBlankString, description: "A model name." },
101
+ closed(
102
+ {
103
+ model: string("The model name."),
104
+ effort: string("The reasoning effort."),
105
+ },
106
+ ["model"],
107
+ ),
108
+ ],
109
+ },
110
+ modelList: {
111
+ type: "array",
112
+ minItems: 1,
113
+ items: ref("modelLeaf"),
114
+ description:
115
+ "Models in fallback order: the first whose provider has not hit its usage limit runs.",
116
+ },
117
+ roleModel: {
118
+ anyOf: [
119
+ ref("modelLeaf"),
120
+ ref("modelList"),
121
+ perHost({ anyOf: [ref("modelLeaf"), ref("modelList")] }),
122
+ {
123
+ type: "array",
124
+ minItems: 1,
125
+ items: { anyOf: [ref("modelLeaf"), perHost(ref("modelLeaf"))] },
126
+ description:
127
+ "Models in fallback order, each for every host or per host: the first whose provider has not hit its usage limit runs.",
128
+ },
129
+ ],
130
+ },
131
+ tierModel: { anyOf: [nonBlankString, perHost(nonBlankString)] },
132
+ };
133
+
134
+ const entry = workspaceConfigSchema.shape;
135
+
136
+ /** `models`: the roles (from `modelRoles`), the lead's own model and the override switch. */
137
+ const ROLES_EDITOR = closed(
138
+ Object.fromEntries(
139
+ MODEL_ROLES.map((role) => [
140
+ role,
141
+ role === "implementer" ? perTier(ref("roleModel")) : ref("roleModel"),
142
+ ]),
143
+ ),
144
+ );
145
+ const MODELS_EDITOR = closed({
146
+ roles: { ...ROLES_EDITOR, description: modelRoles.description },
147
+ lead: {
148
+ ...ref("roleModel"),
149
+ description:
150
+ "The lead's own model per host, or a list in fallback order; advisory (Workit cannot set a session's model, it prints a switch notice when the lead's provider hits its usage limit).",
151
+ },
152
+ allowLeadOverride: {
153
+ type: "boolean",
154
+ description:
155
+ "True lets a model the lead names on a spawn win over the configured role model (default false).",
156
+ },
157
+ limitWindow: {
158
+ anyOf: [
159
+ { type: "string", pattern: String.raw`^\s*(\d+(\.\d+)?\s*[dhms]\s*)+$` },
160
+ { type: "number", exclusiveMinimum: 0 },
161
+ ],
162
+ description:
163
+ "How long a provider stays exhausted after a usage limit that names no reset time: a duration (5h, 1d 12h) or hours (default 5h).",
164
+ },
165
+ });
166
+
167
+ /** Editor-only definitions for the leniently read fields, keyed by their zod def. */
168
+ const EDITOR_DEFINITIONS = new Map<unknown, JsonSchema>([
169
+ [entry.defaultEndpoint, enumOf(DEFAULT_ENDPOINTS)],
170
+ [entry.delegation, enumOf(DELEGATION_MODES)],
171
+ [entry.changelog, enumOf(CHANGELOG_MODES)],
172
+ [entry.docs, enumOf(DOCS_MODES)],
173
+ [
174
+ entry.changelogPath,
175
+ {
176
+ anyOf: [
177
+ repoPath("A repo-relative changelog path or glob."),
178
+ {
179
+ type: "array",
180
+ minItems: 1,
181
+ items: repoPath("A repo-relative changelog path or glob."),
182
+ },
183
+ ],
184
+ },
185
+ ],
186
+ [
187
+ entry.prTemplate,
188
+ {
189
+ anyOf: [
190
+ enumOf(PR_TEMPLATE_MODES),
191
+ repoPath("A repo-relative path to one .md template.", String.raw`\.[mM][dD]`),
192
+ ],
193
+ },
194
+ ],
195
+ [entry.releaseNotes, enumOf(RELEASE_NOTES_MODES)],
196
+ [releaseTrackSchema.shape.releaseNotes, enumOf(RELEASE_NOTES_MODES)],
197
+ [entry.vcs.unwrap().shape.host, nonBlankString],
198
+ [entry.issues.unwrap().shape.relation, enumOf(["closes", "refs"])],
199
+ [entry.issues.unwrap().shape.repo, string("owner/name")],
200
+ [entry.issues.unwrap().shape.host, nonBlankString],
201
+ [entry.fanout.unwrap().shape.tierModels, perTier(ref("tierModel"))],
202
+ [modelRoles, ROLES_EDITOR],
203
+ ...[entry.models.unwrap(), workspacesFileSchema.shape.models.unwrap()].map(
204
+ (models): [unknown, JsonSchema] => [models, MODELS_EDITOR],
205
+ ),
206
+ [
207
+ entry.resources,
208
+ closed({
209
+ slots: {
210
+ type: "integer",
211
+ minimum: 1,
212
+ maximum: 64,
213
+ description: "How many heavy checks may run at once on this machine.",
214
+ },
215
+ scope: {
216
+ type: "boolean",
217
+ description: "False turns the systemd scope off (the lease still applies).",
218
+ },
219
+ heavy: {
220
+ type: "array",
221
+ items: { type: "string" },
222
+ description: "Named checks that are heavy.",
223
+ },
224
+ light: {
225
+ type: "array",
226
+ items: { type: "string" },
227
+ description: "Named checks that never take the lease.",
228
+ },
229
+ memoryMax: {
230
+ type: ["string", "number"],
231
+ description:
232
+ 'MemoryMax of a heavy check\'s scope: a percent of RAM ("40%") or a size ("8G").',
233
+ },
234
+ cpuQuota: {
235
+ type: ["string", "number"],
236
+ description: "CPUQuota of a heavy check's scope.",
237
+ },
238
+ wait: {
239
+ type: ["number", "string"],
240
+ description: "How long a heavy check waits for the lease (seconds, or a duration).",
241
+ },
242
+ }),
243
+ ],
244
+ ]);
245
+
246
+ const configFileSchema = z
247
+ .object({
248
+ locale: z
249
+ .string()
250
+ .regex(LOCALE_RE)
251
+ .optional()
252
+ .describe("The language of Workit's prompts and drafts, e.g. en or es-419."),
253
+ localeOptions: z.array(z.string()).optional().describe("The locales the setup wizard offers."),
254
+ branchPolicy: z
255
+ .object({
256
+ preset: branchPreset.describe(
257
+ "The branch model: gitflow, github-flow, trunk-based or custom.",
258
+ ),
259
+ allowed: z
260
+ .array(z.string())
261
+ .optional()
262
+ .describe("Branch name globs a new branch must match."),
263
+ protected: z.array(z.string()).optional().describe("Branches nobody commits to directly."),
264
+ })
265
+ .strict()
266
+ .optional()
267
+ .describe("The global branch policy; a workspace's `branchPolicy` overrides it."),
268
+ commitPolicy: z
269
+ .object({
270
+ preset: commitPreset.describe("The commit message flavor, e.g. conventional."),
271
+ pattern: z
272
+ .string()
273
+ .optional()
274
+ .describe("A regular expression every commit subject must match."),
275
+ })
276
+ .strict()
277
+ .optional()
278
+ .describe("The global commit policy; a workspace's `commitPolicy` overrides it."),
279
+ updates: z
280
+ .enum(UPDATES_MODES as [string, ...string[]])
281
+ .optional()
282
+ .describe(
283
+ "How an interactive launch handles a newer release: prompt (default), auto or off. Set with `workit upgrade --set-updates`.",
284
+ ),
285
+ })
286
+ .strict();
287
+
288
+ // vcs.json and youtrack.json predate Workit's typed readers and are read key
289
+ // by key, so their editor schemas leave unknown keys alone.
290
+ const forgeHost = (kind: string) =>
291
+ z
292
+ .object({
293
+ host: z.string().optional().describe(`The ${kind} host, for a self-hosted server.`),
294
+ apiUrl: z.string().optional().describe(`The ${kind} API URL.`),
295
+ tokenFile: z.string().optional().describe(`A file holding the ${kind} token.`),
296
+ })
297
+ .passthrough()
298
+ .optional();
299
+ const vcsFileSchema = z
300
+ .object({
301
+ provider: z
302
+ .enum(["github", "gitlab"])
303
+ .optional()
304
+ .describe("The forge when no workspace or remote names one."),
305
+ defaultTargetBranch: z
306
+ .string()
307
+ .optional()
308
+ .describe(
309
+ "The branch PRs target when neither the workspace nor its branch preset names one.",
310
+ ),
311
+ github: forgeHost("GitHub").describe("GitHub settings."),
312
+ gitlab: forgeHost("GitLab").describe("GitLab settings."),
313
+ pr: z
314
+ .object({
315
+ squashOnMerge: z.boolean().optional(),
316
+ removeSourceBranch: z.boolean().optional(),
317
+ pushBranch: z.boolean().optional(),
318
+ confirmSkip: z.boolean().optional(),
319
+ })
320
+ .passthrough()
321
+ .optional()
322
+ .describe("PR style defaults."),
323
+ })
324
+ .passthrough();
325
+ const youtrackFileSchema = z
326
+ .object({
327
+ baseUrl: z.string().optional().describe("The YouTrack URL (required to use YouTrack)."),
328
+ tokenFile: z
329
+ .string()
330
+ .optional()
331
+ .describe("Obsolete: Workit reaches YouTrack only through ytrack and ignores this key."),
332
+ locale: z.string().optional().describe("The language of issue updates."),
333
+ timezone: z
334
+ .string()
335
+ .optional()
336
+ .describe("The IANA timezone that decides a work item's day; default the process timezone."),
337
+ meetingIssue: z.string().optional().describe("The issue meeting time is logged on."),
338
+ meetingIssues: z
339
+ .record(
340
+ z.string(),
341
+ z
342
+ .object({
343
+ issue: z.string().optional(),
344
+ label: z.string().optional(),
345
+ workItemText: z.string().optional(),
346
+ url: z.string().optional(),
347
+ })
348
+ .passthrough(),
349
+ )
350
+ .optional()
351
+ .describe("Named meetings, each with its issue, label, work item text and URL."),
352
+ meetingWorkItemText: z
353
+ .string()
354
+ .optional()
355
+ .describe("The work item text for meeting time (default Meetings)."),
356
+ tokenDefaults: z
357
+ .object({
358
+ name: z.string().optional(),
359
+ description: z.string().optional(),
360
+ scopes: z.array(z.string()).optional(),
361
+ profileTab: z.string().optional(),
362
+ })
363
+ .passthrough()
364
+ .optional()
365
+ .describe("Obsolete: Workit no longer creates YouTrack tokens and ignores this key."),
366
+ })
367
+ .passthrough();
368
+
369
+ /** Objects whose unknown keys stay allowed in the closed files' editor schemas. */
370
+ const OPEN_OBJECTS = new Set<unknown>([
371
+ entry.branchPolicy.unwrap().shape.prefixes.unwrap()._zod.def,
372
+ releaseTrackSchema.shape.naming.unwrap()._zod.def,
373
+ ]);
374
+
375
+ const SOURCES: Record<ConfigSchemaFile, { schema: z.ZodType; title: string; open?: true }> = {
376
+ "workspaces.json": {
377
+ schema: workspacesFileSchema,
378
+ title: "Workit workspaces (workspaces.json in the Workit config dir)",
379
+ },
380
+ "config.json": {
381
+ schema: configFileSchema,
382
+ title: "Workit config (config.json in the Workit config dir)",
383
+ },
384
+ "vcs.json": {
385
+ schema: vcsFileSchema,
386
+ title: "Workit forge settings (vcs.json in the Workit config dir)",
387
+ open: true,
388
+ },
389
+ "youtrack.json": {
390
+ schema: youtrackFileSchema,
391
+ title: "Workit YouTrack settings (youtrack.json in the Workit config dir)",
392
+ open: true,
393
+ },
394
+ };
395
+
396
+ const PRESET_ENUMS = new Map<unknown, readonly string[]>([
397
+ [branchPreset._zod.def, Object.keys(PRESETS)],
398
+ [commitPreset._zod.def, COMMIT_PRESETS],
399
+ ]);
400
+ const EDITOR_BY_DEF = new Map(
401
+ [...EDITOR_DEFINITIONS].map(([schema, json]) => [(schema as z.ZodType)._zod.def, json]),
402
+ );
403
+
404
+ const replace = (target: JsonSchema, next: JsonSchema): void => {
405
+ const description = target.description;
406
+ for (const key of Object.keys(target)) delete target[key];
407
+ Object.assign(target, next, description === undefined ? {} : { description });
408
+ };
409
+
410
+ /** The JSON Schema of one config file, as published. */
411
+ export const configJsonSchema = (file: ConfigSchemaFile): JsonSchema => {
412
+ const { schema, title, open } = SOURCES[file];
413
+ const generated = z.toJSONSchema(schema, {
414
+ target: "draft-7",
415
+ io: "input",
416
+ unrepresentable: "any",
417
+ override: ({ zodSchema, jsonSchema }) => {
418
+ const def = zodSchema._zod.def;
419
+ const editor = EDITOR_BY_DEF.get(def);
420
+ if (editor) return replace(jsonSchema, editor);
421
+ const presets = PRESET_ENUMS.get(def);
422
+ if (presets) return replace(jsonSchema, enumOf(presets));
423
+ if (def === nonBlank._zod.def) Object.assign(jsonSchema, { pattern: String.raw`\S` });
424
+ if (def.type === "object" && !open && !OPEN_OBJECTS.has(def))
425
+ jsonSchema.additionalProperties = false;
426
+ },
427
+ }) as JsonSchema;
428
+ const { $schema, definitions, ...body } = generated;
429
+ const all: Record<string, JsonSchema> = {
430
+ ...(definitions as Record<string, JsonSchema>),
431
+ };
432
+ for (let grew = true; grew;) {
433
+ const text = JSON.stringify([body, all]);
434
+ const missing = Object.keys(DEFINITIONS).filter(
435
+ (name) => !all[name] && text.includes(`"#/definitions/${name}"`),
436
+ );
437
+ for (const name of missing) all[name] = DEFINITIONS[name];
438
+ grew = missing.length > 0;
439
+ }
440
+ return {
441
+ $schema,
442
+ $id: configSchemaUrl(file),
443
+ title,
444
+ ...body,
445
+ properties: {
446
+ $schema: string("The JSON Schema this file follows; Workit writes it and keeps your value."),
447
+ ...(body.properties as Record<string, JsonSchema>),
448
+ },
449
+ ...(Object.keys(all).length ? { definitions: all } : {}),
450
+ };
451
+ };
452
+
453
+ const PRINT_WIDTH = 100;
454
+
455
+ /** JSON in the repository formatter's layout: objects expanded, scalar lists on one line when they fit. */
456
+ const printJson = (value: unknown, indent: string, prefix: number): string => {
457
+ const inner = `${indent} `;
458
+ if (Array.isArray(value)) {
459
+ if (value.length === 0) return "[]";
460
+ if (value.every((item) => item === null || typeof item !== "object")) {
461
+ const flat = `[${value.map((item) => JSON.stringify(item)).join(", ")}]`;
462
+ if (prefix + flat.length + 1 <= PRINT_WIDTH) return flat;
463
+ }
464
+ const items = value.map((item) => `${inner}${printJson(item, inner, inner.length)}`);
465
+ return `[\n${items.join(",\n")}\n${indent}]`;
466
+ }
467
+ if (value && typeof value === "object") {
468
+ const entries = Object.entries(value);
469
+ if (entries.length === 0) return "{}";
470
+ const lines = entries.map(([key, item]) => {
471
+ const head = `${inner}${JSON.stringify(key)}: `;
472
+ return `${head}${printJson(item, inner, head.length)}`;
473
+ });
474
+ return `{\n${lines.join(",\n")}\n${indent}}`;
475
+ }
476
+ return JSON.stringify(value);
477
+ };
478
+
479
+ /** The committed text of one schema file. */
480
+ export const renderConfigSchema = (file: ConfigSchemaFile): string =>
481
+ `${printJson(configJsonSchema(file), "", 0)}\n`;
482
+
483
+ export type SchemaIssue = { path: string; message: string };
484
+
485
+ /** The JSON Schema keywords validateConfigSchema understands; annotations are ignored. */
486
+ export const SCHEMA_KEYWORDS = new Set([
487
+ "$schema",
488
+ "$id",
489
+ "$ref",
490
+ "definitions",
491
+ "title",
492
+ "description",
493
+ "default",
494
+ "type",
495
+ "enum",
496
+ "const",
497
+ "properties",
498
+ "required",
499
+ "additionalProperties",
500
+ "propertyNames",
501
+ "items",
502
+ "minItems",
503
+ "maxItems",
504
+ "minLength",
505
+ "maxLength",
506
+ "pattern",
507
+ "format",
508
+ "minimum",
509
+ "maximum",
510
+ "exclusiveMinimum",
511
+ "exclusiveMaximum",
512
+ "anyOf",
513
+ "oneOf",
514
+ ]);
515
+
516
+ const typeOf = (value: unknown): string =>
517
+ value === null
518
+ ? "null"
519
+ : Array.isArray(value)
520
+ ? "array"
521
+ : Number.isInteger(value)
522
+ ? "integer"
523
+ : typeof value;
524
+
525
+ const typeMatches = (expected: unknown, value: unknown): boolean => {
526
+ const actual = typeOf(value);
527
+ return (Array.isArray(expected) ? expected : [expected]).some(
528
+ (type) => type === actual || (type === "number" && actual === "integer"),
529
+ );
530
+ };
531
+
532
+ const childPath = (base: string, key: string | number): string =>
533
+ typeof key === "number"
534
+ ? `${base}[${key}]`
535
+ : /^[A-Za-z_$][\w$-]*$/u.test(key)
536
+ ? base
537
+ ? `${base}.${key}`
538
+ : key
539
+ : `${base}[${JSON.stringify(key)}]`;
540
+
541
+ const describeForm = (schema: JsonSchema): string => {
542
+ if (Array.isArray(schema.enum)) return schema.enum.join("|");
543
+ if (schema.const !== undefined) return JSON.stringify(schema.const);
544
+ if (typeof schema.description === "string")
545
+ return schema.description
546
+ .replace(/^\p{Lu}/u, (first) => first.toLowerCase())
547
+ .replace(/\.$/u, "");
548
+ if (Array.isArray(schema.anyOf))
549
+ return (schema.anyOf as JsonSchema[]).map(describeForm).join(", ");
550
+ const type = Array.isArray(schema.type) ? schema.type.join(" or ") : String(schema.type ?? "any");
551
+ return schema.pattern ? `a ${type} matching ${String(schema.pattern)}` : `a ${type}`;
552
+ };
553
+
554
+ const check = (
555
+ root: JsonSchema,
556
+ schema: JsonSchema,
557
+ value: unknown,
558
+ at: string,
559
+ out: SchemaIssue[],
560
+ ): void => {
561
+ if (typeof schema.$ref === "string") {
562
+ const name = schema.$ref.replace(/^#\/definitions\//u, "");
563
+ const target = (root.definitions as Record<string, JsonSchema> | undefined)?.[name];
564
+ if (!target) throw new Error(`unresolved $ref ${schema.$ref}`);
565
+ return check(root, target, value, at, out);
566
+ }
567
+ const issue = (message: string): void => {
568
+ out.push({ path: at || "(root)", message });
569
+ };
570
+ const branches = (schema.anyOf ?? schema.oneOf) as JsonSchema[] | undefined;
571
+ if (branches) {
572
+ const results = branches.map((branch) => {
573
+ const found: SchemaIssue[] = [];
574
+ check(root, branch, value, at, found);
575
+ return { branch, found };
576
+ });
577
+ if (results.some((result) => result.found.length === 0)) return;
578
+ const sameType = results.filter(
579
+ (result) => result.branch.type !== undefined && typeMatches(result.branch.type, value),
580
+ );
581
+ if (sameType.length === 1 && ["object", "array"].includes(typeOf(value)))
582
+ out.push(...sameType[0].found);
583
+ else issue(`${JSON.stringify(value)} is not ${describeForm({ anyOf: branches })}`);
584
+ return;
585
+ }
586
+ if (schema.type !== undefined && !typeMatches(schema.type, value))
587
+ return issue(`expected ${describeForm({ type: schema.type })}, got ${typeOf(value)}`);
588
+ if (Array.isArray(schema.enum) && !schema.enum.includes(value))
589
+ return issue(`${JSON.stringify(value)} is not one of ${schema.enum.join("|")}`);
590
+ if (schema.const !== undefined && schema.const !== value)
591
+ return issue(`${JSON.stringify(value)} is not ${JSON.stringify(schema.const)}`);
592
+ if (typeof value === "string") {
593
+ if (typeof schema.minLength === "number" && value.length < schema.minLength)
594
+ issue(`must have at least ${schema.minLength} characters`);
595
+ if (typeof schema.maxLength === "number" && value.length > schema.maxLength)
596
+ issue(`must have at most ${schema.maxLength} characters`);
597
+ if (typeof schema.pattern === "string" && !new RegExp(schema.pattern, "u").test(value))
598
+ issue(
599
+ typeof schema.description === "string"
600
+ ? `${JSON.stringify(value)} is not ${describeForm(schema)}`
601
+ : `${JSON.stringify(value)} does not match ${schema.pattern}`,
602
+ );
603
+ }
604
+ if (typeof value === "number") {
605
+ if (typeof schema.minimum === "number" && value < schema.minimum)
606
+ issue(`must be at least ${schema.minimum}`);
607
+ if (typeof schema.maximum === "number" && value > schema.maximum)
608
+ issue(`must be at most ${schema.maximum}`);
609
+ if (typeof schema.exclusiveMinimum === "number" && value <= schema.exclusiveMinimum)
610
+ issue(`must be above ${schema.exclusiveMinimum}`);
611
+ if (typeof schema.exclusiveMaximum === "number" && value >= schema.exclusiveMaximum)
612
+ issue(`must be below ${schema.exclusiveMaximum}`);
613
+ }
614
+ if (Array.isArray(value)) {
615
+ if (typeof schema.minItems === "number" && value.length < schema.minItems)
616
+ issue(`must have at least ${schema.minItems} entries`);
617
+ if (typeof schema.maxItems === "number" && value.length > schema.maxItems)
618
+ issue(`must have at most ${schema.maxItems} entries`);
619
+ if (schema.items && typeof schema.items === "object")
620
+ value.forEach((item, index) =>
621
+ check(root, schema.items as JsonSchema, item, childPath(at, index), out),
622
+ );
623
+ }
624
+ if (typeOf(value) === "object") {
625
+ const record = value as Record<string, unknown>;
626
+ const properties = (schema.properties ?? {}) as Record<string, JsonSchema>;
627
+ for (const key of (schema.required ?? []) as string[])
628
+ if (!Object.hasOwn(record, key)) issue(`missing required key ${JSON.stringify(key)}`);
629
+ for (const [key, child] of Object.entries(record)) {
630
+ const where = childPath(at, key);
631
+ if (schema.propertyNames) check(root, schema.propertyNames as JsonSchema, key, where, out);
632
+ if (Object.hasOwn(properties, key)) check(root, properties[key], child, where, out);
633
+ else if (schema.additionalProperties === false)
634
+ out.push({
635
+ path: where,
636
+ message: `unknown key ${JSON.stringify(key)}`,
637
+ });
638
+ else if (schema.additionalProperties && typeof schema.additionalProperties === "object")
639
+ check(root, schema.additionalProperties as JsonSchema, child, where, out);
640
+ }
641
+ }
642
+ };
643
+
644
+ /** Where `value` breaks `schema`: each bad value's JSON path and why. */
645
+ export const validateConfigSchema = (schema: JsonSchema, value: unknown): SchemaIssue[] => {
646
+ const out: SchemaIssue[] = [];
647
+ check(schema, schema, value, "", out);
648
+ return out;
649
+ };
650
+
651
+ /** Where a parsed config file breaks its published schema. */
652
+ export const configSchemaIssues = (file: ConfigSchemaFile, value: unknown): SchemaIssue[] =>
653
+ validateConfigSchema(configJsonSchema(file), value);