@brainervirus/workit-core 0.6.0 → 0.7.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 (95) hide show
  1. package/package.json +10 -14
  2. package/scripts/doctor-check.ts +20 -0
  3. package/scripts/install-cursor-plugin.sh +51 -28
  4. package/scripts/install-opencode-plugin.sh +25 -22
  5. package/scripts/rewrite-workspace-deps.ts +15 -9
  6. package/scripts/sync-runtime.sh +71 -19
  7. package/scripts/vendor-assets.ts +37 -0
  8. package/skills/wk-implement/SKILL.md +2 -2
  9. package/skills/wk-pr/SKILL.md +1 -1
  10. package/src/core/boundary.ts +27 -0
  11. package/src/core/branch-policy.ts +63 -0
  12. package/src/core/branch.ts +166 -57
  13. package/src/core/changelog.ts +17 -14
  14. package/src/core/config-guard.ts +9 -2
  15. package/src/core/config.ts +227 -32
  16. package/src/core/detector.ts +22 -11
  17. package/src/core/docs-layout.ts +251 -0
  18. package/src/core/docs-migration.ts +639 -0
  19. package/src/core/docs-repo.ts +58 -21
  20. package/src/core/docs-validate.ts +181 -43
  21. package/src/core/doctor.ts +801 -0
  22. package/src/core/flow-state.ts +1579 -137
  23. package/src/core/git.ts +22 -5
  24. package/src/core/gitignore.ts +11 -2
  25. package/src/core/handoff-context.ts +18 -5
  26. package/src/{tools/handoff.ts → core/handoff-tools.ts} +16 -63
  27. package/src/core/hygiene.ts +53 -17
  28. package/src/core/init.ts +128 -31
  29. package/src/core/logger.ts +321 -0
  30. package/src/core/package-root.ts +28 -0
  31. package/src/core/parse-sections.ts +2 -2
  32. package/src/core/plan-tasks.ts +13 -3
  33. package/src/core/ports/init-toolkit-status.ts +1 -1
  34. package/src/core/ports/vcs-verify-token.ts +1 -1
  35. package/src/core/ports/youtrack-api.ts +4 -2
  36. package/src/core/ports/youtrack-config.ts +1 -3
  37. package/src/core/ports/youtrack-verify-token.ts +1 -1
  38. package/src/core/pr-create.ts +156 -29
  39. package/src/core/present.ts +11 -2
  40. package/src/core/registration.ts +215 -0
  41. package/src/core/reminder.ts +1 -2
  42. package/src/core/repo-context.ts +447 -0
  43. package/src/core/repo-tool.ts +4 -1
  44. package/src/core/repo-tools.ts +23 -0
  45. package/src/core/rules.ts +10 -7
  46. package/src/core/safe-write.ts +22 -0
  47. package/src/core/scripts.ts +3 -39
  48. package/src/core/sdd.ts +56 -31
  49. package/src/core/setup-state.ts +54 -0
  50. package/src/core/setup.ts +1216 -0
  51. package/src/core/skill-manifests.ts +95 -0
  52. package/src/core/support-matrix.ts +12 -0
  53. package/src/core/sync-runtime.ts +348 -0
  54. package/src/core/templates.ts +16 -6
  55. package/src/core/vcs-config.ts +191 -62
  56. package/src/core/verify-parse.ts +4 -2
  57. package/src/core/verify-project.ts +181 -0
  58. package/src/core/workspaces.ts +136 -17
  59. package/src/core/youtrack-tools.ts +228 -0
  60. package/src/core/youtrack.ts +320 -87
  61. package/src/core.ts +18 -3
  62. package/templates/execution-contract.md +9 -7
  63. package/templates/superpowers-doc-contract.md +5 -4
  64. package/scripts/_shared/common.sh +0 -158
  65. package/scripts/changelog-context.sh +0 -42
  66. package/scripts/docs-refresh-context.sh +0 -40
  67. package/scripts/init/apply.sh +0 -5
  68. package/scripts/init/status.sh +0 -5
  69. package/scripts/init/toolkit-status.sh +0 -5
  70. package/scripts/pr-create.sh +0 -5
  71. package/scripts/pr-ready-context.sh +0 -88
  72. package/scripts/present/ascii-wireframe.sh +0 -5
  73. package/scripts/present/flow-diagram.sh +0 -5
  74. package/scripts/release-notes-context.sh +0 -40
  75. package/scripts/vcs/config.sh +0 -5
  76. package/scripts/vcs/merged-style.sh +0 -5
  77. package/scripts/vcs/token-create-urls.sh +0 -5
  78. package/scripts/vcs/verify-token.sh +0 -5
  79. package/scripts/verify-project.sh +0 -140
  80. package/scripts/youtrack/api.sh +0 -5
  81. package/scripts/youtrack/config.sh +0 -5
  82. package/scripts/youtrack/greeting.sh +0 -5
  83. package/scripts/youtrack/parse-duration.sh +0 -5
  84. package/scripts/youtrack/token-create-url.sh +0 -5
  85. package/scripts/youtrack/verify-token.sh +0 -5
  86. package/scripts/youtrack/work-date-ms.sh +0 -5
  87. package/src/tools/docs-repo.ts +0 -42
  88. package/src/tools/flow.ts +0 -88
  89. package/src/tools/index.ts +0 -22
  90. package/src/tools/present.ts +0 -45
  91. package/src/tools/repo.ts +0 -357
  92. package/src/tools/rules.ts +0 -30
  93. package/src/tools/sdd.ts +0 -189
  94. package/src/tools/templates.ts +0 -27
  95. package/src/tools/youtrack.ts +0 -360
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { configDir } from "./config";
5
5
  import { resolveWorkspace } from "./workspaces";
6
-
6
+ import { resolveBranchPolicyFor } from "./branch";
7
7
  // Ports of scripts/vcs/config.sh + verify-token.sh + token-create-urls.sh + merged-style.sh.
8
8
  // Token never printed.
9
9
 
@@ -14,22 +14,55 @@ export const vcsConfigPath = (): string =>
14
14
 
15
15
  const workspacesPath = (): string => path.join(configDir(), "workspaces.json");
16
16
 
17
- const vcsCwd = (cwd?: string): string => process.env.WORKFLOW_WORKSPACE_ROOT ?? cwd ?? process.cwd();
17
+ const vcsCwd = (cwd?: string): string =>
18
+ process.env.WORKFLOW_WORKSPACE_ROOT ?? cwd ?? process.cwd();
18
19
 
19
- function readVcsJson(): { config: Record<string, any>; path: string; ok: boolean } {
20
- const cfgPath = vcsConfigPath();
21
- let config: Record<string, any> = {};
22
- let ok = false;
23
- if (fs.existsSync(cfgPath)) {
24
- try {
25
- const parsed = JSON.parse(fs.readFileSync(cfgPath, "utf8")) as unknown;
26
- if (parsed && typeof parsed === "object") {
27
- config = parsed as Record<string, any>;
28
- ok = true;
29
- }
30
- } catch { /* missing or invalid */ }
20
+ // RL-03b: the origin remote is the ground truth for PR creation. A stale global
21
+ // provider (e.g. gitlab from another repo) must not drive glab on a
22
+ // github.com-hosted checkout. Recognized hosts only — custom/self-hosted
23
+ // remotes keep the configured provider, and a missing origin is untouched.
24
+ const remoteProvider = (cwd: string): string | null => {
25
+ const r = spawnSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" });
26
+ if (r.status !== 0) return null;
27
+ const url = (r.stdout ?? "").trim();
28
+ if (/github\.com[:/]/.test(url)) return "github";
29
+ if (/gitlab\.com[:/]/.test(url)) return "gitlab";
30
+ return null;
31
+ };
32
+
33
+ // RL-01: typed vcs.json reader. Missing is a legitimate unconfigured state;
34
+ // malformed (parse failure or a non-object) is reported with the exact path so
35
+ // risky consumers stop instead of silently reading defaults.
36
+ export type VcsConfigResult = {
37
+ status: "missing" | "valid" | "malformed";
38
+ path: string;
39
+ config: Record<string, any>;
40
+ error?: string;
41
+ };
42
+
43
+ export const readVcsConfig = (): VcsConfigResult => {
44
+ const file = vcsConfigPath();
45
+ let raw: string;
46
+ try {
47
+ raw = fs.readFileSync(file, "utf8");
48
+ } catch {
49
+ return { status: "missing", path: file, config: {} };
50
+ }
51
+ let parsed: unknown;
52
+ try {
53
+ parsed = JSON.parse(raw);
54
+ } catch {
55
+ return { status: "malformed", path: file, config: {}, error: `${file} is not valid JSON` };
31
56
  }
32
- return { config, path: cfgPath, ok };
57
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
58
+ return { status: "valid", path: file, config: parsed as Record<string, any> };
59
+ }
60
+ return { status: "malformed", path: file, config: {}, error: `${file} is not a JSON object` };
61
+ };
62
+
63
+ function readVcsJson(): { config: Record<string, any>; path: string; ok: boolean } {
64
+ const { status, path: cfgPath, config } = readVcsConfig();
65
+ return { config, path: cfgPath, ok: status === "valid" };
33
66
  }
34
67
 
35
68
  /** Port of scripts/vcs/config.sh — mode: load | summary | resolve. */
@@ -38,21 +71,50 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
38
71
  const wsVcs = (ws?.vcs ?? {}) as Record<string, any>;
39
72
  const wsYt = (ws?.youtrack ?? {}) as Record<string, any>;
40
73
  const wsIssues = (ws?.issues ?? {}) as Record<string, any>;
41
- const { config: cfg, path: cfgPath, ok: cfgOk } = readVcsJson();
74
+ const { status: cfgStatus, path: cfgPath, config: cfg, error: cfgError } = readVcsConfig();
42
75
 
43
- const provider = String(wsVcs.provider ?? cfg.provider ?? "gitlab").toLowerCase();
44
- const defaultTarget = String(wsVcs.defaultTargetBranch ?? cfg.defaultTargetBranch ?? "develop");
76
+ // Explicit workspace vcs.provider is the user's per-repo intent and wins;
77
+ // otherwise, with a determinate repo root (explicit cwd or host-provided
78
+ // WORKFLOW_WORKSPACE_ROOT), the origin remote is authoritative over the
79
+ // global default (RL-03b). Ambient process.cwd() callers keep the legacy
80
+ // config-only contract (CA-06 parity), so a stale global default cannot
81
+ // contaminate host-agnostic reads.
82
+ const root = vcsCwd(cwd);
83
+ const determinateRoot = cwd !== undefined || process.env.WORKFLOW_WORKSPACE_ROOT !== undefined;
84
+ const provider = String(
85
+ wsVcs.provider ?? (determinateRoot ? remoteProvider(root) : null) ?? cfg.provider ?? "gitlab",
86
+ ).toLowerCase();
87
+ // CA-05: workspace/global explicit target wins; when unset the resolved
88
+ // branch-policy preset supplies the default (gitflow->develop,
89
+ // github-flow->main, trunk-based->master, custom->develop). Shared by load
90
+ // and resolve so both surfaces stay consistent.
91
+ // CA-09: the one resolver wrapper — same policy resolution every consumer
92
+ // uses, so the tightened gate in branch-policy-resolver.test.ts stays green.
93
+ const defaultTarget = String(
94
+ wsVcs.defaultTargetBranch ??
95
+ cfg.defaultTargetBranch ??
96
+ resolveBranchPolicyFor(root).defaultTargetBranch ??
97
+ "develop",
98
+ );
45
99
  const linkIssues = typeof wsYt.link_issues === "boolean" ? wsYt.link_issues : null;
46
100
  const youtrackBaseUrl = typeof wsYt.baseUrl === "string" ? wsYt.baseUrl : null;
47
101
  // github issues path only when BOTH providers are github (mirrors WorkspaceConfig.issues).
48
102
  let issuesProvider: string | null = null;
49
103
  let linkOnPr: boolean | null = null;
50
- if (provider === "github" && typeof wsIssues.provider === "string" && wsIssues.provider.toLowerCase() === "github") {
104
+ if (
105
+ provider === "github" &&
106
+ typeof wsIssues.provider === "string" &&
107
+ wsIssues.provider.toLowerCase() === "github"
108
+ ) {
51
109
  issuesProvider = "github";
52
110
  linkOnPr = typeof wsIssues.link_on_pr === "boolean" ? wsIssues.link_on_pr : null;
53
111
  }
54
112
 
55
113
  if (mode === "resolve") {
114
+ // RL-01: malformed vcs.json blocks resolution with an exact-path diagnostic
115
+ // instead of silently resolving defaults; a missing file is still a
116
+ // legitimate unconfigured state that falls back to defaults.
117
+ if (cfgStatus === "malformed") return { ok: false, error: cfgError, configPath: cfgPath };
56
118
  return {
57
119
  ok: true,
58
120
  workspace_name: ws?.name ?? null,
@@ -65,7 +127,12 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
65
127
  };
66
128
  }
67
129
 
68
- if (!cfgOk) return { ok: false, error: "missing or invalid vcs.json" };
130
+ if (cfgStatus === "malformed") {
131
+ return { ok: false, error: cfgError, configPath: cfgPath };
132
+ }
133
+ if (cfgStatus === "missing") {
134
+ return { ok: false, error: `vcs.json is missing: ${cfgPath}`, configPath: cfgPath };
135
+ }
69
136
 
70
137
  const prov = (cfg[provider] ?? {}) as Record<string, any>;
71
138
  const tokenFile = String(prov.tokenFile ?? path.join(configDir(), `${provider}.token`));
@@ -73,7 +140,8 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
73
140
  let tokenOk = false;
74
141
  if (fs.existsSync(tokenPath)) {
75
142
  const token = fs.readFileSync(tokenPath, "utf8").trim();
76
- const placeholder = !token || token === TOKEN_PLACEHOLDER || token.startsWith(TOKEN_PLACEHOLDER);
143
+ const placeholder =
144
+ !token || token === TOKEN_PLACEHOLDER || token.startsWith(TOKEN_PLACEHOLDER);
77
145
  tokenOk = !placeholder;
78
146
  }
79
147
 
@@ -93,7 +161,10 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
93
161
  link_on_pr: linkOnPr,
94
162
  };
95
163
  if (provider === "gitlab") {
96
- out.gitlab = { host: prov.host ?? "gitlab.com", apiUrl: prov.apiUrl ?? "https://gitlab.com/api/v4" };
164
+ out.gitlab = {
165
+ host: prov.host ?? "gitlab.com",
166
+ apiUrl: prov.apiUrl ?? "https://gitlab.com/api/v4",
167
+ };
97
168
  } else if (provider === "github") {
98
169
  out.github = { host: prov.host ?? "github.com" };
99
170
  }
@@ -102,7 +173,7 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
102
173
  }
103
174
 
104
175
  /** Port of scripts/vcs/verify-token.sh — soft-fail JSON out, always exit 0. */
105
- export function vcsVerifyToken(): Record<string, any> {
176
+ export async function vcsVerifyToken(): Promise<Record<string, any>> {
106
177
  const cfg = vcsConfig("load");
107
178
  if (!cfg.ok) return { ok: false, error: cfg.error ?? "vcs config not ready" };
108
179
  const provider = cfg.provider as string;
@@ -118,22 +189,49 @@ export function vcsVerifyToken(): Record<string, any> {
118
189
 
119
190
  if (provider === "gitlab") {
120
191
  const host = (cfg.gitlab as Record<string, any>)?.host ?? "gitlab.com";
121
- const api = ((cfg.gitlab as Record<string, any>)?.apiUrl ?? `https://${host}/api/v4`).replace(/\/+$/, "");
122
- const result = spawnSync("curl", ["-fsS", "-H", `PRIVATE-TOKEN: ${token}`, `${api}/user`], { encoding: "utf8" });
123
- if (result.status !== 0) {
124
- return { ok: false, provider, error: "GitLab API rejected token", detail: (result.stderr ?? result.stdout ?? "").slice(0, 200) };
125
- }
192
+ const api = ((cfg.gitlab as Record<string, any>)?.apiUrl ?? `https://${host}/api/v4`).replace(
193
+ /\/+$/,
194
+ "",
195
+ );
196
+ let user: Record<string, any>;
126
197
  try {
127
- const user = JSON.parse(result.stdout ?? "") as Record<string, any>;
128
- return { ok: true, provider, username: user.username ?? user.login, name: user.name };
129
- } catch {
130
- return { ok: false, provider, error: "invalid JSON from GitLab /user" };
198
+ const res = await fetch(`${api}/user`, {
199
+ headers: { "PRIVATE-TOKEN": token },
200
+ });
201
+ if (!res.ok) {
202
+ return {
203
+ ok: false,
204
+ provider,
205
+ error: "GitLab API rejected token",
206
+ detail: (await res.text()).slice(0, 200),
207
+ };
208
+ }
209
+ user = JSON.parse(await res.text()) as Record<string, any>;
210
+ } catch (err) {
211
+ if (err instanceof SyntaxError) {
212
+ return { ok: false, provider, error: "invalid JSON from GitLab /user" };
213
+ }
214
+ return {
215
+ ok: false,
216
+ provider,
217
+ error: "GitLab API rejected token",
218
+ detail: (err instanceof Error ? err.message : "network error").slice(0, 200),
219
+ };
131
220
  }
221
+ return { ok: true, provider, username: user.username ?? user.login, name: user.name };
132
222
  }
133
223
  if (provider === "github") {
134
- const result = spawnSync("gh", ["api", "user"], { encoding: "utf8", env: { ...process.env, GH_TOKEN: token } });
224
+ const result = spawnSync("gh", ["api", "user"], {
225
+ encoding: "utf8",
226
+ env: { ...process.env, GH_TOKEN: token },
227
+ });
135
228
  if (result.status !== 0) {
136
- return { ok: false, provider, error: "GitHub API rejected token", detail: (result.stderr ?? result.stdout ?? "").slice(0, 200) };
229
+ return {
230
+ ok: false,
231
+ provider,
232
+ error: "GitHub API rejected token",
233
+ detail: (result.stderr ?? result.stdout ?? "").slice(0, 200),
234
+ };
137
235
  }
138
236
  try {
139
237
  const user = JSON.parse(result.stdout ?? "") as Record<string, any>;
@@ -156,32 +254,44 @@ export function vcsTokenCreateUrls(): Record<string, any> {
156
254
  const gitlab = (cfg.gitlab ?? {}) as Record<string, any>;
157
255
  const host = String(gitlab.host ?? "gitlab.com");
158
256
  const gitlabScopes = Array.isArray(defaults.gitlabScopes) ? defaults.gitlabScopes : ["api"];
159
- const gitlabParams = new URLSearchParams({ name, description: desc, scopes: gitlabScopes.join(",") });
257
+ const gitlabParams = new URLSearchParams({
258
+ name,
259
+ description: desc,
260
+ scopes: gitlabScopes.join(","),
261
+ });
160
262
  const gitlabUrl = `https://${host}/-/user_settings/personal_access_tokens?${gitlabParams}`;
161
263
 
162
- const githubPerms = defaults.githubPermissions ?? { pull_requests: "write", contents: "write", metadata: "read" };
264
+ const githubPerms = defaults.githubPermissions ?? {
265
+ pull_requests: "write",
266
+ contents: "write",
267
+ metadata: "read",
268
+ };
163
269
  const ghParams = new URLSearchParams({ name, description: desc, ...githubPerms });
164
270
  const githubFineUrl = `https://github.com/settings/personal-access-tokens/new?${ghParams}`;
165
271
 
166
- const classicScopes = Array.isArray(defaults.githubClassicScopes) ? defaults.githubClassicScopes : ["repo"];
272
+ const classicScopes = Array.isArray(defaults.githubClassicScopes)
273
+ ? defaults.githubClassicScopes
274
+ : ["repo"];
167
275
  const githubClassicUrl = `https://github.com/settings/tokens/new?${new URLSearchParams({ description: name, scopes: classicScopes.join(",") })}`;
168
276
 
169
277
  const provider = String(cfg.provider ?? "gitlab").toLowerCase();
170
- const active = {
171
- gitlab: {
172
- tokenFile: gitlab.tokenFile ?? path.join(configDir(), "gitlab.token"),
173
- createUrl: gitlabUrl,
174
- scopes: gitlabScopes,
175
- name,
176
- },
177
- github: {
178
- tokenFile: (cfg.github as Record<string, any>)?.tokenFile ?? path.join(configDir(), "github.token"),
179
- createUrl: githubFineUrl,
180
- createUrlClassic: githubClassicUrl,
181
- permissions: githubPerms,
182
- name,
183
- },
184
- }[provider] ?? {};
278
+ const active =
279
+ {
280
+ gitlab: {
281
+ tokenFile: gitlab.tokenFile ?? path.join(configDir(), "gitlab.token"),
282
+ createUrl: gitlabUrl,
283
+ scopes: gitlabScopes,
284
+ name,
285
+ },
286
+ github: {
287
+ tokenFile:
288
+ (cfg.github as Record<string, any>)?.tokenFile ?? path.join(configDir(), "github.token"),
289
+ createUrl: githubFineUrl,
290
+ createUrlClassic: githubClassicUrl,
291
+ permissions: githubPerms,
292
+ name,
293
+ },
294
+ }[provider] ?? {};
185
295
 
186
296
  return {
187
297
  tokenName: name,
@@ -189,13 +299,18 @@ export function vcsTokenCreateUrls(): Record<string, any> {
189
299
  activeProvider: provider,
190
300
  active,
191
301
  gitlab: { host, createUrl: gitlabUrl, scopes: gitlabScopes, tokenFile: gitlab.tokenFile },
192
- github: { createUrl: githubFineUrl, createUrlClassic: githubClassicUrl, permissions: githubPerms, tokenFile: (cfg.github as Record<string, any>)?.tokenFile },
302
+ github: {
303
+ createUrl: githubFineUrl,
304
+ createUrlClassic: githubClassicUrl,
305
+ permissions: githubPerms,
306
+ tokenFile: (cfg.github as Record<string, any>)?.tokenFile,
307
+ },
193
308
  };
194
309
  }
195
310
 
196
311
  /** Port of scripts/vcs/merged-style.sh — recent merged MR/PR bodies for style reference. */
197
- export function mergedPrStyle(limit = 6): Record<string, any> {
198
- const cfg = vcsConfig("load");
312
+ export function mergedPrStyle(limit = 6, cwd?: string): Record<string, any> {
313
+ const cfg = vcsConfig("load", cwd);
199
314
  if (!cfg.ok || !cfg.tokenReady) return { ok: false, error: "vcs not configured" };
200
315
  const provider = cfg.provider as string;
201
316
  const token = fs.readFileSync(cfg.tokenPath as string, "utf8").trim();
@@ -203,31 +318,45 @@ export function mergedPrStyle(limit = 6): Record<string, any> {
203
318
 
204
319
  const descInfo = (desc: string, caseInsensitiveNotes = false): Record<string, any> => ({
205
320
  hasNotesSection: caseInsensitiveNotes ? /##\s*notes/i.test(desc) : /##\s*Notes/.test(desc),
206
- sections: desc.split("\n").filter((l) => l.startsWith("## ")).map((l) => l.trim()),
321
+ sections: desc
322
+ .split("\n")
323
+ .filter((l) => l.startsWith("## "))
324
+ .map((l) => l.trim()),
207
325
  descriptionPreview: desc.slice(0, 600),
208
326
  });
209
327
 
210
328
  if (provider === "gitlab") {
211
- const remote = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" });
329
+ const remote = spawnSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" });
212
330
  if (remote.status !== 0) return { ok: false, error: "no origin remote" };
213
331
  const url = (remote.stdout ?? "").trim();
214
332
  const m = /gitlab\.com[:/](.+?)(?:\.git)?$/.exec(url);
215
333
  if (!m) return { ok: false, error: "not a gitlab.com origin" };
216
334
  const project = m[1];
217
335
  const env = { ...process.env, GITLAB_TOKEN: token };
218
- const run = (args: string[]) => spawnSync("glab", ["api", ...args], { encoding: "utf8", env });
219
- let r = run([`projects/${project.replaceAll("/", "%2F")}/merge_requests?state=merged&per_page=${limit}&order_by=updated_at&sort=desc`]);
336
+ const run = (args: string[]) =>
337
+ spawnSync("glab", ["api", ...args], { cwd, encoding: "utf8", env });
338
+ let r = run([
339
+ `projects/${project.replaceAll("/", "%2F")}/merge_requests?state=merged&per_page=${limit}&order_by=updated_at&sort=desc`,
340
+ ]);
220
341
  if (r.status !== 0) {
221
342
  r = run([`merge_requests?state=merged&per_page=${limit}&order_by=updated_at&sort=desc`]);
222
343
  }
223
344
  if (r.status !== 0) return { ok: false, error: "could not list merge requests" };
224
345
  for (const mr of JSON.parse(r.stdout ?? "[]") as Array<Record<string, any>>) {
225
346
  const desc = String(mr.description ?? "").trim();
226
- examples.push({ title: mr.title, url: mr.web_url, squash: mr.squash, ...descInfo(desc, true) });
347
+ examples.push({
348
+ title: mr.title,
349
+ url: mr.web_url,
350
+ squash: mr.squash,
351
+ ...descInfo(desc, true),
352
+ });
227
353
  }
228
354
  } else if (provider === "github") {
229
- const r = spawnSync("gh", ["pr", "list", "--state", "merged", "--limit", String(limit), "--json", "title,url,body"],
230
- { encoding: "utf8", env: { ...process.env, GH_TOKEN: token } });
355
+ const r = spawnSync(
356
+ "gh",
357
+ ["pr", "list", "--state", "merged", "--limit", String(limit), "--json", "title,url,body"],
358
+ { cwd, encoding: "utf8", env: { ...process.env, GH_TOKEN: token } },
359
+ );
231
360
  if (r.status !== 0) return { ok: false, error: "could not list pull requests" };
232
361
  for (const pr of JSON.parse(r.stdout ?? "[]") as Array<Record<string, any>>) {
233
362
  const desc = String(pr.body ?? "").trim();
@@ -3,7 +3,7 @@ export function parseVerifyOutput(stdout: string): Record<string, any> {
3
3
  const commands = [];
4
4
  const parts = stdout.split(/\n## /);
5
5
  for (const part of parts.slice(1)) {
6
- const nl = part.indexOf('\n');
6
+ const nl = part.indexOf("\n");
7
7
  const label = part.slice(0, nl).trim();
8
8
  const body = part.slice(nl + 1);
9
9
  const cmdMatch = body.match(/^command: (.+)$/m);
@@ -18,7 +18,9 @@ export function parseVerifyOutput(stdout: string): Record<string, any> {
18
18
  }
19
19
  }
20
20
 
21
- const summaryMatch = stdout.match(/# Summary[\s\S]*?passed: (\d+)[\s\S]*?failed: (\d+)[\s\S]*?skipped: (\d+)/);
21
+ const summaryMatch = stdout.match(
22
+ /# Summary[\s\S]*?passed: (\d+)[\s\S]*?failed: (\d+)[\s\S]*?skipped: (\d+)/,
23
+ );
22
24
  const passed = summaryMatch ? Number(summaryMatch[1]) : 0;
23
25
  const failed = summaryMatch ? Number(summaryMatch[2]) : 0;
24
26
  const skipped = summaryMatch ? Number(summaryMatch[3]) : 0;
@@ -0,0 +1,181 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { repoRoot } from "./repo-context";
5
+
6
+ // Port of scripts/verify-project.sh — discover + run project verification checks
7
+ // (npm/pnpm/yarn, cargo, pytest/ruff, CHANGELOG format). Output is parsed by
8
+ // verify-parse.ts and keeps the same ## section text shape.
9
+
10
+ export type VerifyResult = { stdout: string; stderr: string; exitCode: number; cwd: string };
11
+
12
+ const commandOnPath = (name: string): boolean => {
13
+ const dirs = (process.env.PATH ?? "").split(path.delimiter);
14
+ // win32 executables carry an .exe suffix (pytest.exe), so probe both names.
15
+ const names = process.platform === "win32" ? [name, `${name}.exe`] : [name];
16
+ for (const dir of dirs) {
17
+ if (!dir) continue;
18
+ for (const candidateName of names) {
19
+ const candidate = path.join(dir, candidateName);
20
+ try {
21
+ statSync(candidate);
22
+ if (process.platform !== "win32") {
23
+ const mode = statSync(candidate).mode & 0o111;
24
+ if (mode === 0) continue;
25
+ }
26
+ return true;
27
+ } catch {
28
+ /* keep scanning */
29
+ }
30
+ }
31
+ }
32
+ return false;
33
+ };
34
+
35
+ const hasScript = (cwd: string, name: string): boolean => {
36
+ const pkgPath = path.join(cwd, "package.json");
37
+ if (!existsSync(pkgPath)) return false;
38
+ try {
39
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { scripts?: Record<string, string> };
40
+ return Boolean(pkg.scripts && typeof pkg.scripts[name] === "string");
41
+ } catch {
42
+ return false;
43
+ }
44
+ };
45
+
46
+ const packageRunner = (cwd: string): string => {
47
+ if (existsSync(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
48
+ const pkgPath = path.join(cwd, "package.json");
49
+ if (existsSync(pkgPath)) {
50
+ try {
51
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { packageManager?: string };
52
+ if (typeof pkg.packageManager === "string" && pkg.packageManager.startsWith("pnpm")) {
53
+ return "pnpm";
54
+ }
55
+ } catch {
56
+ /* fall through */
57
+ }
58
+ }
59
+ if (existsSync(path.join(cwd, "yarn.lock"))) return "yarn";
60
+ return "npm";
61
+ };
62
+
63
+ export function runVerifyProject(root: string, dryRun = false): VerifyResult {
64
+ // Shell parity: verify-project.sh did `root=$(git rev-parse --show-toplevel || pwd); cd "$root"`,
65
+ // so a run from a repo subdirectory still checks the repo root.
66
+ const cwd = repoRoot(root);
67
+ let passed = 0;
68
+ let failed = 0;
69
+ let skipped = 0;
70
+ const lines: string[] = [];
71
+ const stderrParts: string[] = [];
72
+
73
+ const runCheck = (label: string, cmd: string, args: string[]) => {
74
+ lines.push(`\n## ${label}\n\n`);
75
+ lines.push(`command: ${[cmd, ...args].join(" ")}\n\n`);
76
+ if (dryRun) {
77
+ lines.push("status: skipped (dry run)\n");
78
+ skipped += 1;
79
+ return;
80
+ }
81
+ const r = spawnSync(cmd, args, { cwd, encoding: "utf8" });
82
+ const so = (r.stdout ?? "").trimEnd();
83
+ if (so) lines.push(so + "\n");
84
+ const se = (r.stderr ?? "").trimEnd();
85
+ if (se) stderrParts.push(se);
86
+ if (r.status === 0) {
87
+ lines.push("\nstatus: pass\n");
88
+ passed += 1;
89
+ } else {
90
+ lines.push(`\nstatus: fail (exit ${r.status})\n`);
91
+ failed += 1;
92
+ }
93
+ };
94
+
95
+ const skipCheck = (label: string, reason: string) => {
96
+ lines.push(`\n## ${label}\n\nstatus: skipped (${reason})\n`);
97
+ skipped += 1;
98
+ };
99
+
100
+ lines.push("# Verification Context\n");
101
+ lines.push(`\nroot: ${cwd}\n`);
102
+ lines.push(`dry_run: ${String(dryRun)}\n`);
103
+
104
+ const pkgPath = path.join(cwd, "package.json");
105
+ if (existsSync(pkgPath)) {
106
+ const runner = packageRunner(cwd);
107
+ for (const script of ["lint", "format:check", "test", "build"]) {
108
+ if (hasScript(cwd, script)) {
109
+ if (runner === "npm") runCheck(script, "npm", ["run", script]);
110
+ else runCheck(script, runner, [script]);
111
+ } else {
112
+ skipCheck(script, `package.json has no ${script} script`);
113
+ }
114
+ }
115
+ } else {
116
+ skipCheck("javascript", "package.json not found");
117
+ }
118
+
119
+ const cargoManifest = existsSync(path.join(cwd, "Cargo.toml"))
120
+ ? "Cargo.toml"
121
+ : existsSync(path.join(cwd, "src-tauri/Cargo.toml"))
122
+ ? "src-tauri/Cargo.toml"
123
+ : "";
124
+ if (cargoManifest) {
125
+ runCheck("cargo fmt", "cargo", ["fmt", "--manifest-path", cargoManifest, "--", "--check"]);
126
+ runCheck("cargo clippy", "cargo", [
127
+ "clippy",
128
+ "--manifest-path",
129
+ cargoManifest,
130
+ "--all-targets",
131
+ "--",
132
+ "-D",
133
+ "warnings",
134
+ ]);
135
+ runCheck("cargo test", "cargo", ["test", "--manifest-path", cargoManifest, "--all-targets"]);
136
+ } else {
137
+ skipCheck("rust", "Cargo.toml not found");
138
+ }
139
+
140
+ if (
141
+ existsSync(path.join(cwd, "pyproject.toml")) ||
142
+ existsSync(path.join(cwd, "pytest.ini")) ||
143
+ existsSync(path.join(cwd, "tests"))
144
+ ) {
145
+ if (commandOnPath("pytest")) runCheck("pytest", "pytest", []);
146
+ else skipCheck("pytest", "pytest not available");
147
+ if (commandOnPath("ruff")) runCheck("ruff check", "ruff", ["check", "."]);
148
+ else skipCheck("ruff check", "ruff not available");
149
+ }
150
+
151
+ lines.push("\n## CHANGELOG.md format\n\n");
152
+ lines.push('command: grep -qi "## [Unreleased]" CHANGELOG.md\n\n');
153
+ const changelogPath = path.join(cwd, "CHANGELOG.md");
154
+ if (dryRun) {
155
+ lines.push("status: skipped (dry run)\n");
156
+ skipped += 1;
157
+ } else if (existsSync(changelogPath)) {
158
+ if (/## \[Unreleased\]/i.test(readFileSync(changelogPath, "utf8"))) {
159
+ lines.push("status: pass\n");
160
+ passed += 1;
161
+ } else {
162
+ lines.push("status: fail (missing ## [Unreleased])\n");
163
+ failed += 1;
164
+ }
165
+ } else {
166
+ lines.push("status: fail (missing CHANGELOG.md)\n");
167
+ failed += 1;
168
+ }
169
+
170
+ lines.push("\n# Summary\n\n");
171
+ lines.push(`passed: ${passed}\n`);
172
+ lines.push(`failed: ${failed}\n`);
173
+ lines.push(`skipped: ${skipped}\n`);
174
+
175
+ return {
176
+ stdout: lines.join(""),
177
+ stderr: stderrParts.join("\n"),
178
+ exitCode: failed > 0 ? 1 : 0,
179
+ cwd,
180
+ };
181
+ }