@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,613 @@
1
+ // Named checks (design §2.1 S9 `workit check`; D5, D14). A check is *named*
2
+ // (configured) when its name is in the repo's check config and the argv run is
3
+ // exactly the configured argv, run from the repository top. Only named checks
4
+ // satisfy a gate that binds to them; `workit check -- true` is ad-hoc and
5
+ // never does (design §0 #3).
6
+ //
7
+ // Config, first source that exists at the repository top:
8
+ // 1. `.workit/checks.json`: `{"checks":{"test":"bun test","lint":["bun","run","lint"]},
9
+ // "gates":{"testing":"test"},"groups":{"ci":["lint","test"]}}`. A *group* is
10
+ // a named list of configured checks that `workit check <group>` runs one by
11
+ // one; the `ci` group is the checks the repo's CI runs, so a PR never goes
12
+ // red on a gate that never ran locally. Per-repo workit config lives in
13
+ // `.workit/`, which holds only committed team config (per-clone data lives
14
+ // in `<git-common-dir>/workit/`), never at the repo root. A legacy root
15
+ // `workit.checks.json` is still read when `.workit/checks.json` is absent,
16
+ // with a note that it moved.
17
+ // In a linked worktree (a fanout slice's, an agent's) that has neither
18
+ // file, the main checkout's is read: a config not committed yet (or
19
+ // ignored by an older Workit's `.workit/.gitignore`) is not in a new worktree,
20
+ // which shares the main checkout's checks (#426). Configured commands
21
+ // still run from the worktree's own top.
22
+ // `cleanup.regenerable` lists repo-relative globs (`*`, `**`) of generated,
23
+ // git-ignored dirs; cleanup (core/cleanup.ts) discards ignored files under
24
+ // them, on top of its built-in list, when it removes a merged worktree.
25
+ // 2. Built-in ecosystem defaults detected from the repo: package.json scripts
26
+ // `test`, `lint`, `typecheck`, `check` (`<pm> run <script>`, pm from the
27
+ // lockfile); go.mod → `go test ./...`; Cargo.toml → `cargo test`;
28
+ // pytest.ini or a pyproject.toml that mentions pytest → `pytest`; a Makefile
29
+ // `test:` target → `make test`. The first source to define a name wins.
30
+ //
31
+ // Gates: `testing` binds to `gates.testing` (default `test`), `verification`
32
+ // to `gates.verification` (default: every configured check). Ad-hoc runs never
33
+ // satisfy a gate; with nothing configured the gate says to add the file.
34
+ //
35
+ // Kept separate from checks.ts (the runner) because task evaluation, and so
36
+ // every hook bundle, imports it. Plain TS over node built-ins.
37
+ import { spawnSync } from "node:child_process";
38
+ import fs from "node:fs";
39
+ import path from "node:path";
40
+ import { gitDirs } from "./git/facts";
41
+
42
+ /** The package.json scripts that count as named checks when no checks config exists. */
43
+ export const SCRIPT_CHECKS = ["test", "lint", "typecheck", "check"] as const;
44
+ /** Scripts run as a check under another name: `test:packaging` is `workit check packaging` (#509). */
45
+ const SCRIPT_CHECK_ALIASES = { packaging: "test:packaging" } as const;
46
+ export const CHECKS_FILE = ".workit/checks.json";
47
+ export const LEGACY_CHECKS_FILE = "workit.checks.json";
48
+
49
+ /**
50
+ * Where the repo's checks config lives under the repository top `root` (the
51
+ * one path resolver): `.workit/checks.json`, else the legacy root file when
52
+ * only that exists, else the `.workit/` path a new config belongs at.
53
+ */
54
+ export function checksConfigFile(root: string): string {
55
+ const current = path.join(root, CHECKS_FILE);
56
+ const legacy = path.join(root, LEGACY_CHECKS_FILE);
57
+ return !fs.existsSync(current) && fs.existsSync(legacy) ? legacy : current;
58
+ }
59
+
60
+ /** The `.workit/.gitignore` bodies older Workit wrote to make the dir self-ignoring. */
61
+ const GENERATED_GITIGNORES = new Set(["*\n", "*\n!risk-paths\n"]);
62
+
63
+ /**
64
+ * Create `<root>/.workit/` (idempotent) and return the dir. It holds committed
65
+ * team config only, so a self-ignoring `.gitignore` an older Workit wrote
66
+ * (`*`, or `*` with `!risk-paths`) is removed; any other `.gitignore` is the
67
+ * repo's own and kept.
68
+ */
69
+ export function ensureWorkitDir(root: string): string {
70
+ const dir = path.join(root, ".workit");
71
+ fs.mkdirSync(dir, { recursive: true });
72
+ const ignore = path.join(dir, ".gitignore");
73
+ try {
74
+ if (GENERATED_GITIGNORES.has(fs.readFileSync(ignore, "utf8"))) fs.rmSync(ignore);
75
+ } catch {}
76
+ return dir;
77
+ }
78
+
79
+ /**
80
+ * `resources.heavyCommands` of the repo's checks config under `root`, read
81
+ * leniently (no git, no check validation): the heavy-command gate's patterns.
82
+ */
83
+ export function repoHeavyCommands(root: string): string[] {
84
+ try {
85
+ const value: unknown = JSON.parse(fs.readFileSync(checksConfigFile(root), "utf8"));
86
+ const resources = isRecord(value) ? value.resources : undefined;
87
+ const list = isRecord(resources) ? resources.heavyCommands : undefined;
88
+ return Array.isArray(list)
89
+ ? list.filter((item): item is string => typeof item === "string")
90
+ : [];
91
+ } catch {
92
+ return [];
93
+ }
94
+ }
95
+
96
+ export type NamedCheck = {
97
+ name: string;
98
+ /** The argv `workit check <name>` runs. */
99
+ argv: string[];
100
+ /** Every argv that counts as this check (the run argv plus package-manager aliases). */
101
+ accepts: string[][];
102
+ };
103
+
104
+ export type CheckConfig = {
105
+ /** Where the config came from; `defaults` = detected ecosystem defaults, `none` = nothing. */
106
+ source: typeof CHECKS_FILE | typeof LEGACY_CHECKS_FILE | "defaults" | "none";
107
+ /** The directory configured commands run in (the repository top). */
108
+ root: string;
109
+ checks: NamedCheck[];
110
+ /** Explicit gate bindings from the checks config (`gates.testing`, `gates.verification`). */
111
+ gates: Partial<Record<"testing" | "verification", string[]>>;
112
+ /** Named lists of configured checks (`groups.ci`: what CI runs); `workit check <group>` runs each member. */
113
+ groups: Record<string, string[]>;
114
+ /** Set when the config exists but cannot be used; gates then fail closed. */
115
+ error: string | null;
116
+ /** One line for the user when the config was read from the legacy root file. */
117
+ note?: string;
118
+ /** The raw `resources` block of the checks config (heavy-check limits; core/resources.ts reads it). */
119
+ resources?: unknown;
120
+ /** `cleanup.regenerable`: repo globs of generated, ignored dirs that cleanup may discard. */
121
+ regenerable?: string[];
122
+ };
123
+
124
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
125
+ typeof value === "object" && value !== null && !Array.isArray(value);
126
+
127
+ const SHELL_META = /[|&;<>()$`\\*?[\]{}~!#]/u;
128
+
129
+ /**
130
+ * Split a configured command string into argv: whitespace separated, with
131
+ * single or double quotes grouping. Shell syntax (pipes, globs, variables,
132
+ * redirects) is refused: configured checks never run through a shell.
133
+ */
134
+ export function splitCommand(command: string): string[] | Error {
135
+ const argv: string[] = [];
136
+ let current = "";
137
+ let quote: '"' | "'" | null = null;
138
+ let started = false;
139
+ for (const char of command) {
140
+ if (quote) {
141
+ if (char === quote) quote = null;
142
+ else current += char;
143
+ continue;
144
+ }
145
+ if (char === '"' || char === "'") {
146
+ quote = char;
147
+ started = true;
148
+ continue;
149
+ }
150
+ if (/\s/u.test(char)) {
151
+ if (started) argv.push(current);
152
+ current = "";
153
+ started = false;
154
+ continue;
155
+ }
156
+ if (SHELL_META.test(char))
157
+ return new Error(`"${command}" uses shell syntax (${char}); configure an argv array instead`);
158
+ current += char;
159
+ started = true;
160
+ }
161
+ if (quote) return new Error(`"${command}" has an unterminated quote`);
162
+ if (started) argv.push(current);
163
+ return argv.length ? argv : new Error("empty command");
164
+ }
165
+
166
+ const git = (cwd: string, args: string[]): string | null => {
167
+ const run = spawnSync("git", args, {
168
+ cwd,
169
+ encoding: "utf8",
170
+ timeout: 20_000,
171
+ killSignal: "SIGKILL",
172
+ windowsHide: true,
173
+ });
174
+ const value = run.status === 0 ? (run.stdout ?? "").trim() : "";
175
+ return value || null;
176
+ };
177
+
178
+ /** The repository top for `cwd`, or `cwd` itself outside git. */
179
+ export function checkRoot(cwd: string): string {
180
+ return path.resolve(gitDirs(cwd)?.top ?? cwd);
181
+ }
182
+
183
+ const packageManager = (root: string): "bun" | "pnpm" | "yarn" | "npm" => {
184
+ const has = (file: string) => fs.existsSync(path.join(root, file));
185
+ if (has("bun.lock") || has("bun.lockb")) return "bun";
186
+ if (has("pnpm-lock.yaml")) return "pnpm";
187
+ if (has("yarn.lock")) return "yarn";
188
+ return "npm";
189
+ };
190
+
191
+ const scriptCheck = (pm: ReturnType<typeof packageManager>, name: string): NamedCheck => {
192
+ const argv = [pm, "run", name];
193
+ const accepts = [argv];
194
+ // `bun test` is bun's own runner, not the script; npm only aliases `test`.
195
+ if (pm === "pnpm" || pm === "yarn" || (pm === "npm" && name === "test")) accepts.push([pm, name]);
196
+ return { name, argv, accepts };
197
+ };
198
+
199
+ const CHECK_NAME = /^[a-z0-9][a-z0-9:._-]{0,63}$/u;
200
+
201
+ /** Built-in ecosystem defaults detected at `root` (see the header). */
202
+ function detectedChecks(root: string): NamedCheck[] {
203
+ const read = (file: string): string | null => {
204
+ try {
205
+ return fs.readFileSync(path.join(root, file), "utf8");
206
+ } catch {
207
+ return null;
208
+ }
209
+ };
210
+ const checks: NamedCheck[] = [];
211
+ const add = (check: NamedCheck) => {
212
+ if (!checks.some((item) => item.name === check.name)) checks.push(check);
213
+ };
214
+ const manifestText = read("package.json");
215
+ if (manifestText !== null) {
216
+ let manifest: unknown = null;
217
+ try {
218
+ manifest = JSON.parse(manifestText);
219
+ } catch {
220
+ // An unparsable package.json defines no scripts.
221
+ }
222
+ const scripts = isRecord(manifest) && isRecord(manifest.scripts) ? manifest.scripts : {};
223
+ const pm = packageManager(root);
224
+ for (const name of SCRIPT_CHECKS) {
225
+ const script = scripts[name];
226
+ if (typeof script === "string" && script.trim() !== "") add(scriptCheck(pm, name));
227
+ }
228
+ for (const [name, script] of Object.entries(SCRIPT_CHECK_ALIASES)) {
229
+ const body = scripts[script];
230
+ if (typeof body === "string" && body.trim() !== "") add({ ...scriptCheck(pm, script), name });
231
+ }
232
+ }
233
+ const plain = (name: string, argv: string[]): NamedCheck => ({ name, argv, accepts: [argv] });
234
+ if (read("go.mod") !== null) add(plain("test", ["go", "test", "./..."]));
235
+ if (read("Cargo.toml") !== null) add(plain("test", ["cargo", "test"]));
236
+ if (read("pytest.ini") !== null || /\bpytest\b/u.test(read("pyproject.toml") ?? ""))
237
+ add(plain("test", ["pytest"]));
238
+ const makefile = read("Makefile") ?? read("makefile") ?? read("GNUmakefile");
239
+ if (makefile !== null && /^test\s*:(?!=)/mu.test(makefile)) add(plain("test", ["make", "test"]));
240
+ return checks;
241
+ }
242
+
243
+ const GATES = ["testing", "verification"] as const;
244
+
245
+ /** What the cleanup matcher (core/workspaces.ts matchWorkspace) supports: `*` and `**`. */
246
+ const UNSUPPORTED_GLOB = /[?[\]{}!\\]|[@+*]\(/u;
247
+ const REGENERABLE_SHAPE =
248
+ 'cleanup.regenerable must be a list of repo-relative globs (["packages/*/skills"])';
249
+
250
+ /** `cleanup` of the checks config: the regenerable globs (trailing `/` dropped), or why it is invalid. */
251
+ function cleanupGlobs(value: unknown): string[] | Error {
252
+ if (value === undefined) return [];
253
+ if (!isRecord(value)) return new Error('cleanup must be {"regenerable":["<glob>",…]}');
254
+ const unknown = Object.keys(value).find((key) => key !== "regenerable");
255
+ if (unknown) return new Error(`cleanup.${unknown} is not a setting (use cleanup.regenerable)`);
256
+ const list = value.regenerable ?? [];
257
+ if (!Array.isArray(list)) return new Error(REGENERABLE_SHAPE);
258
+ const globs: string[] = [];
259
+ for (const item of list) {
260
+ if (typeof item !== "string") return new Error(REGENERABLE_SHAPE);
261
+ const glob = item.trim().replace(/\/+$/u, "");
262
+ if (!glob || glob.startsWith("/") || glob.split("/").includes(".."))
263
+ return new Error(`${REGENERABLE_SHAPE}; "${item}" is not repo-relative`);
264
+ if (UNSUPPORTED_GLOB.test(glob))
265
+ return new Error(`${REGENERABLE_SHAPE}; "${item}" uses syntax beyond * and **`);
266
+ // A last segment of only wildcards ("*", "**", "dist/**") names every ignored
267
+ // file under it, `.env` included: cleanup would discard what it must keep.
268
+ if (/^\*+$/u.test(glob.split("/").at(-1) ?? ""))
269
+ return new Error(
270
+ `${REGENERABLE_SHAPE}; "${item}" ends in a bare wildcard, which would discard ignored files such as .env: name the generated dir`,
271
+ );
272
+ globs.push(glob);
273
+ }
274
+ return globs;
275
+ }
276
+
277
+ /**
278
+ * The checks config file for `root`: its own, else (in a linked worktree
279
+ * without one) the main checkout's, else the path a new config belongs at.
280
+ */
281
+ function configFileFor(root: string): string {
282
+ const own = checksConfigFile(root);
283
+ if (fs.existsSync(own)) return own;
284
+ const common = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
285
+ if (!common || path.basename(common) !== ".git") return own;
286
+ const main = path.dirname(common);
287
+ if (path.resolve(main) === path.resolve(root)) return own;
288
+ const shared = checksConfigFile(main);
289
+ return fs.existsSync(shared) ? shared : own;
290
+ }
291
+
292
+ /** The named checks for the repository containing `cwd` (see the header). */
293
+ export function loadCheckConfig(cwd: string): CheckConfig {
294
+ const root = checkRoot(cwd);
295
+ const file = configFileFor(root);
296
+ const legacy = path.basename(file) === LEGACY_CHECKS_FILE;
297
+ const label = legacy ? LEGACY_CHECKS_FILE : CHECKS_FILE;
298
+ const note = legacy
299
+ ? {
300
+ note: `${LEGACY_CHECKS_FILE} is deprecated; move it to ${CHECKS_FILE} (workit doctor --fix)`,
301
+ }
302
+ : {};
303
+ const none = (error: string | null = null): CheckConfig => ({
304
+ source: error ? label : "none",
305
+ root,
306
+ checks: [],
307
+ gates: {},
308
+ groups: {},
309
+ error,
310
+ ...note,
311
+ });
312
+ let text: string | null = null;
313
+ try {
314
+ text = fs.readFileSync(file, "utf8");
315
+ } catch (error) {
316
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT")
317
+ return none(`cannot read ${label}: ${(error as Error).message}`);
318
+ }
319
+ if (text === null) {
320
+ const checks = detectedChecks(root);
321
+ return checks.length
322
+ ? { source: "defaults", root, checks, gates: {}, groups: {}, error: null }
323
+ : none();
324
+ }
325
+ let parsed: unknown;
326
+ try {
327
+ parsed = JSON.parse(text);
328
+ } catch {
329
+ return none(`${label} is not valid JSON`);
330
+ }
331
+ if (!isRecord(parsed) || !isRecord(parsed.checks))
332
+ return none(`${label} must be {"checks":{"<name>":"<command>"|["argv",…]}}`);
333
+ const checks: NamedCheck[] = [];
334
+ for (const [name, value] of Object.entries(parsed.checks)) {
335
+ if (!CHECK_NAME.test(name))
336
+ return none(`${label}: check name "${name}" must match ${CHECK_NAME.source}`);
337
+ const argv =
338
+ typeof value === "string"
339
+ ? splitCommand(value)
340
+ : Array.isArray(value) &&
341
+ value.length > 0 &&
342
+ value.every((item) => typeof item === "string" && item.length > 0)
343
+ ? (value as string[])
344
+ : new Error("expected a command string or a non-empty argv array");
345
+ if (argv instanceof Error) return none(`${label}: check "${name}": ${argv.message}`);
346
+ checks.push({ name, argv, accepts: [argv] });
347
+ }
348
+ const gates: CheckConfig["gates"] = {};
349
+ if (parsed.gates !== undefined) {
350
+ if (!isRecord(parsed.gates))
351
+ return none(`${label}: gates must be {"testing":"<name>"|["<name>",…]}`);
352
+ for (const [gate, value] of Object.entries(parsed.gates)) {
353
+ if (!(GATES as readonly string[]).includes(gate))
354
+ return none(`${label}: unknown gate "${gate}" (use ${GATES.join(" or ")})`);
355
+ const names = typeof value === "string" ? [value] : value;
356
+ if (
357
+ !Array.isArray(names) ||
358
+ names.length === 0 ||
359
+ !names.every((name) => typeof name === "string" && checks.some((c) => c.name === name))
360
+ )
361
+ return none(`${label}: gates.${gate} must name configured checks`);
362
+ gates[gate as (typeof GATES)[number]] = names as string[];
363
+ }
364
+ }
365
+ const groups: CheckConfig["groups"] = {};
366
+ if (parsed.groups !== undefined) {
367
+ if (!isRecord(parsed.groups)) return none(`${label}: groups must be {"ci":["<name>",…]}`);
368
+ for (const [group, value] of Object.entries(parsed.groups)) {
369
+ if (!CHECK_NAME.test(group))
370
+ return none(`${label}: group name "${group}" must match ${CHECK_NAME.source}`);
371
+ if (checks.some((c) => c.name === group))
372
+ return none(`${label}: group "${group}" has the name of a configured check`);
373
+ if (
374
+ !Array.isArray(value) ||
375
+ value.length === 0 ||
376
+ !value.every((name) => typeof name === "string" && checks.some((c) => c.name === name))
377
+ )
378
+ return none(`${label}: groups.${group} must be a non-empty list of configured checks`);
379
+ groups[group] = [...new Set(value as string[])];
380
+ }
381
+ }
382
+ const regenerable = cleanupGlobs(parsed.cleanup);
383
+ if (regenerable instanceof Error) return none(`${label}: ${regenerable.message}`);
384
+ return {
385
+ source: label,
386
+ root,
387
+ checks,
388
+ gates,
389
+ groups,
390
+ error: null,
391
+ ...note,
392
+ resources: parsed.resources,
393
+ regenerable,
394
+ };
395
+ }
396
+
397
+ const sameArgv = (left: readonly string[], right: readonly string[]): boolean =>
398
+ left.length === right.length && left.every((item, index) => item === right[index]);
399
+
400
+ /**
401
+ * Is a run of `argv` from `cwd` (relative to the repo top, posix) the named
402
+ * check `name`? Only an exact argv match from the repository top counts.
403
+ */
404
+ export function matchesNamedCheck(
405
+ config: CheckConfig,
406
+ name: string | null,
407
+ argv: readonly string[],
408
+ cwd: string,
409
+ ): boolean {
410
+ if (config.error || name === null || cwd !== ".") return false;
411
+ const check = config.checks.find((item) => item.name === name);
412
+ return Boolean(check?.accepts.some((accepted) => sameArgv(accepted, argv)));
413
+ }
414
+
415
+ /**
416
+ * The named checks a gate of `dimension` binds to: `gates.<dimension>` when
417
+ * set, else `testing` → `test` (only when configured) and `verification` →
418
+ * every configured check. Empty means nothing can satisfy the gate: ad-hoc
419
+ * runs never do (D14).
420
+ */
421
+ export function gateCheckNames(config: CheckConfig, dimension: string): string[] {
422
+ const explicit = config.gates[dimension as (typeof GATES)[number]];
423
+ if (explicit) return explicit;
424
+ const names = config.checks.map((check) => check.name);
425
+ if (dimension === "testing") return names.includes("test") ? ["test"] : [];
426
+ return names;
427
+ }
428
+
429
+ let workitOnPath: boolean | undefined;
430
+
431
+ /** Is a `workit` executable on PATH (PATHEXT-aware on Windows)? Probed once per process. */
432
+ export function workitAvailable(env: NodeJS.ProcessEnv = process.env): boolean {
433
+ if (workitOnPath !== undefined) return workitOnPath;
434
+ const windows = process.platform === "win32";
435
+ const exts = windows ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
436
+ const dirs = (env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean);
437
+ workitOnPath = dirs.some((dir) =>
438
+ exts.some((ext) => {
439
+ try {
440
+ return fs.statSync(path.join(dir, `workit${ext}`)).isFile();
441
+ } catch {
442
+ return false;
443
+ }
444
+ }),
445
+ );
446
+ return workitOnPath;
447
+ }
448
+
449
+ /**
450
+ * The command that runs `workit check <args>`: plain `workit` when it is on
451
+ * PATH, else a pinned `npx -y @workit-dev/cli@<version>` fallback.
452
+ */
453
+ export function checkCommand(args: string, version: string, onPath = workitAvailable()): string {
454
+ return onPath ? `workit check ${args}` : `npx -y @workit-dev/cli@${version} check ${args}`;
455
+ }
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // light commands (#418, #326): formatters and file-scoped linters finish in
459
+ // seconds, so they never take the heavy-check lease, even with --heavy, and
460
+ // the shell hook leaves them alone.
461
+
462
+ type ToolRule = "any" | ReadonlySet<string>;
463
+ const only = (...names: string[]): ToolRule => new Set(names);
464
+
465
+ /** Formatters: light whatever they format. */
466
+ const FORMATTERS: Record<string, ToolRule> = {
467
+ oxfmt: "any",
468
+ prettier: "any",
469
+ dprint: only("fmt", "check"),
470
+ biome: only("format"),
471
+ black: "any",
472
+ isort: "any",
473
+ yapf: "any",
474
+ autopep8: "any",
475
+ ruff: only("format"),
476
+ gofmt: "any",
477
+ goimports: "any",
478
+ rustfmt: "any",
479
+ "clang-format": "any",
480
+ shfmt: "any",
481
+ stylua: "any",
482
+ taplo: only("fmt", "format"),
483
+ cargo: only("fmt"),
484
+ go: only("fmt"),
485
+ deno: only("fmt"),
486
+ mix: only("format"),
487
+ terraform: only("fmt"),
488
+ zig: only("fmt"),
489
+ };
490
+
491
+ /** Linters: light only when every target is a file. */
492
+ const FILE_LINTERS: Record<string, ToolRule> = {
493
+ eslint: "any",
494
+ oxlint: "any",
495
+ stylelint: "any",
496
+ tslint: "any",
497
+ biome: only("lint", "check"),
498
+ ruff: only("check"),
499
+ deno: only("lint"),
500
+ flake8: "any",
501
+ pylint: "any",
502
+ shellcheck: "any",
503
+ markdownlint: "any",
504
+ "markdownlint-cli2": "any",
505
+ hadolint: "any",
506
+ yamllint: "any",
507
+ actionlint: "any",
508
+ rubocop: "any",
509
+ };
510
+
511
+ /** Package scripts that only format. */
512
+ const FORMAT_SCRIPT = /^(?:format|fmt|prettier)(?:$|[:_.-])/iu;
513
+ const RUNNERS = new Set(["npx", "bunx", "pnpx"]);
514
+ const PACKAGE_MANAGERS = new Set(["npm", "pnpm", "yarn", "bun"]);
515
+ const PM_EXEC = new Set(["exec", "x", "dlx"]);
516
+ const PM_RUN = new Set(["run", "run-script"]);
517
+ const PYTHON_RUNNERS = new Set(["uv", "poetry", "pipenv", "pdm", "hatch", "rye"]);
518
+
519
+ export type LightCommand = { kind: "formatter" | "file-lint"; tool: string; reason: string };
520
+
521
+ const toolName = (word: string | undefined): string =>
522
+ (word?.split(/[\\/]/u).at(-1) ?? "").toLowerCase().replace(/\.(?:exe|cmd|bat|ps1)$/u, "");
523
+
524
+ const firstPositional = (words: readonly string[], from: number): number => {
525
+ let index = from;
526
+ while (index < words.length && words[index].startsWith("-")) {
527
+ if (words[index] === "--") return index + 1;
528
+ index += 1;
529
+ }
530
+ return index;
531
+ };
532
+
533
+ /** A target that names one file: an extension, no glob, not a directory under `cwd`. */
534
+ function fileTarget(word: string, cwd: string | undefined): boolean {
535
+ if (/[*?[\]{}]/u.test(word) || word.endsWith("/") || word.endsWith(path.sep)) return false;
536
+ if (!/\.[A-Za-z0-9]+$/u.test(path.basename(word))) return false;
537
+ if (!cwd) return true;
538
+ try {
539
+ return !fs.statSync(path.resolve(cwd, word)).isDirectory();
540
+ } catch {
541
+ return true;
542
+ }
543
+ }
544
+
545
+ /** The subcommand a tool's rule names, or null when the rule is `any`. */
546
+ const subcommandOf = (rule: ToolRule, words: readonly string[]): string | null | undefined => {
547
+ if (rule === "any") return null;
548
+ const at = firstPositional(words, 1);
549
+ return rule.has(words[at] ?? "") ? words[at] : undefined;
550
+ };
551
+
552
+ function classify(
553
+ words: readonly string[],
554
+ cwd: string | undefined,
555
+ depth: number,
556
+ ): LightCommand | null {
557
+ if (depth > 4 || !words.length) return null;
558
+ const name = toolName(words[0]);
559
+ const inner = (from: number) => classify(words.slice(from), cwd, depth + 1);
560
+ if (RUNNERS.has(name)) return inner(firstPositional(words, 1));
561
+ if (PYTHON_RUNNERS.has(name) && words[firstPositional(words, 1)] === "run")
562
+ return inner(firstPositional(words, firstPositional(words, 1) + 1));
563
+ if (/^python[\d.]*$/u.test(name)) {
564
+ const at = words.indexOf("-m");
565
+ return at > 0 ? inner(at + 1) : null;
566
+ }
567
+ if (PACKAGE_MANAGERS.has(name)) {
568
+ const at = firstPositional(words, 1);
569
+ const command = words[at] ?? "";
570
+ if (PM_EXEC.has(command)) return inner(firstPositional(words, at + 1));
571
+ const script = PM_RUN.has(command) ? words[firstPositional(words, at + 1)] : command;
572
+ if (script && FORMAT_SCRIPT.test(script))
573
+ return {
574
+ kind: "formatter",
575
+ tool: `${name} ${script}`,
576
+ reason: `\`${name} ${PM_RUN.has(command) ? `${command} ` : ""}${script}\` only formats`,
577
+ };
578
+ return name === "npm" || PM_RUN.has(command) ? null : inner(at);
579
+ }
580
+ const formatter = FORMATTERS[name];
581
+ if (formatter) {
582
+ const sub = subcommandOf(formatter, words);
583
+ if (sub !== undefined) {
584
+ const tool = sub ? `${name} ${sub}` : name;
585
+ return { kind: "formatter", tool, reason: `${tool} is a formatter` };
586
+ }
587
+ }
588
+ const linter = FILE_LINTERS[name];
589
+ if (!linter) return null;
590
+ const sub = subcommandOf(linter, words);
591
+ if (sub === undefined) return null;
592
+ const targets = words
593
+ .slice(sub ? words.indexOf(sub) + 1 : 1)
594
+ .filter((word) => !word.startsWith("-") && !/^\d+$/u.test(word));
595
+ if (!targets.length || !targets.every((word) => fileTarget(word, cwd))) return null;
596
+ const tool = sub ? `${name} ${sub}` : name;
597
+ return {
598
+ kind: "file-lint",
599
+ tool,
600
+ reason: `${tool} lints ${targets.length} named file${targets.length === 1 ? "" : "s"}`,
601
+ };
602
+ }
603
+
604
+ /**
605
+ * Is `argv` a light command: a formatter, or a linter whose every target is a
606
+ * file (`eslint a.ts b.ts`, not `eslint src` or `eslint "src/**"`)? Package
607
+ * runners (`npx`, `bunx`, `pnpm exec`, `uv run`, `python -m`) and format
608
+ * scripts (`bun run format`) are seen through. `cwd` resolves targets so a
609
+ * directory named like a file is not one.
610
+ */
611
+ export function lightCommand(argv: readonly string[], cwd?: string): LightCommand | null {
612
+ return classify(argv, cwd, 0);
613
+ }