@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
@@ -0,0 +1,447 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { vcsConfig, mergedPrStyle } from "./vcs-config";
5
+
6
+ // Ports of scripts/_shared/common.sh + the four maintained context generators
7
+ // (pr-ready-context.sh, changelog-context.sh, docs-refresh-context.sh,
8
+ // release-notes-context.sh). Each generator returns the same stdout text shape
9
+ // the shell produced (## sections parsed by parse-sections.ts).
10
+
11
+ export type ContextResult = { stdout: string; stderr: string; exitCode: number; cwd: string };
12
+
13
+ const runGit = (
14
+ cwd: string,
15
+ args: string[],
16
+ ): { stdout: string; stderr: string; exitCode: number } => {
17
+ const result = spawnSync("git", args, { cwd, encoding: "utf8" });
18
+ return {
19
+ stdout: (result.stdout ?? "").trimEnd(),
20
+ stderr: (result.stderr ?? "").trimEnd(),
21
+ exitCode: result.status ?? 1,
22
+ };
23
+ };
24
+
25
+ /** repo_root: git rev-parse --show-toplevel || pwd */
26
+ export const repoRoot = (cwd: string): string => {
27
+ const r = runGit(cwd, ["rev-parse", "--show-toplevel"]);
28
+ return r.exitCode === 0 && r.stdout ? r.stdout : cwd;
29
+ };
30
+
31
+ /** current_branch: git rev-parse --abbrev-ref HEAD || unknown */
32
+ export const currentBranch = (cwd: string): string => {
33
+ const r = runGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
34
+ return r.exitCode === 0 && r.stdout ? r.stdout : "unknown";
35
+ };
36
+
37
+ /** default_base: first of origin/main main origin/master master origin/develop develop, else HEAD~1 */
38
+ export const defaultBase = (cwd: string): string => {
39
+ for (const ref of [
40
+ "origin/main",
41
+ "main",
42
+ "origin/master",
43
+ "master",
44
+ "origin/develop",
45
+ "develop",
46
+ ]) {
47
+ if (runGit(cwd, ["rev-parse", "--verify", ref]).exitCode === 0) return ref;
48
+ }
49
+ return "HEAD~1";
50
+ };
51
+
52
+ export const rangeArgOrDefault = (arg: string | undefined, cwd: string): string =>
53
+ arg ? arg : `${defaultBase(cwd)}...HEAD`;
54
+
55
+ export const isProtectedBranch = (branch: string): boolean =>
56
+ ["main", "master", "develop", "prod", "production"].includes(branch);
57
+
58
+ export const isPrBranch = (branch: string): boolean =>
59
+ branch.startsWith("feature/") || branch.startsWith("bugfix/");
60
+
61
+ export type PrBranchContext = {
62
+ baseRef: string;
63
+ mergeBase: string;
64
+ range: string;
65
+ diffRange: string;
66
+ };
67
+
68
+ /** resolve_pr_branch_context: derive the branch-exclusive range from VCS config. */
69
+ export function resolvePrBranchContext(
70
+ cwd: string,
71
+ ): { ok: true; value: PrBranchContext } | { ok: false; error: string } {
72
+ const branch = currentBranch(cwd);
73
+ if (isProtectedBranch(branch)) {
74
+ return {
75
+ ok: false,
76
+ error: `cannot build PR context on protected branch ${branch} — PRs are for feature/* or bugfix/* only`,
77
+ };
78
+ }
79
+ if (!isPrBranch(branch)) {
80
+ if (branch === "unknown") {
81
+ return {
82
+ ok: false,
83
+ error: `not in a git repository at ${repoRoot(cwd)} — open the target repository as the OpenCode session directory`,
84
+ };
85
+ }
86
+ return {
87
+ ok: false,
88
+ error: `branch ${branch} is not feature/* or bugfix/* — checkout a feature branch or pass an explicit git range`,
89
+ };
90
+ }
91
+
92
+ const resolved = vcsConfig("resolve", cwd);
93
+ if (resolved.ok === false) {
94
+ return { ok: false, error: String(resolved.error) };
95
+ }
96
+ const base = String(resolved.defaultTargetBranch ?? "");
97
+ if (!base || runGit(cwd, ["check-ref-format", "--branch", base]).exitCode !== 0) {
98
+ return { ok: false, error: `invalid configured PR target branch ${base}` };
99
+ }
100
+
101
+ for (const ref of [`origin/${base}`, base]) {
102
+ if (runGit(cwd, ["rev-parse", "--verify", ref]).exitCode !== 0) continue;
103
+ const mb = runGit(cwd, ["merge-base", ref, "HEAD"]);
104
+ if (mb.exitCode !== 0 || !mb.stdout) continue;
105
+ return {
106
+ ok: true,
107
+ value: {
108
+ baseRef: ref,
109
+ mergeBase: mb.stdout,
110
+ range: `${ref}..HEAD`,
111
+ diffRange: `${mb.stdout}..HEAD`,
112
+ },
113
+ };
114
+ }
115
+ return {
116
+ ok: false,
117
+ error: `configured PR target branch ${base} not found — fetch/checkout it or pass an explicit git range`,
118
+ };
119
+ }
120
+
121
+ export function resolvePrRange(cwd: string): string | null {
122
+ const ctx = resolvePrBranchContext(cwd);
123
+ return ctx.ok ? ctx.value.range : null;
124
+ }
125
+
126
+ export const prRangeArgOrDefault = (arg: string | undefined, cwd: string): string | null =>
127
+ arg ? arg : resolvePrRange(cwd);
128
+
129
+ export const printSection = (title: string): string => `\n## ${title}\n\n`;
130
+
131
+ // macOS/Windows filesystems are case-insensitive: existsSync() would match a
132
+ // differently-cased candidate, so the returned template_path must be the
133
+ // ACTUAL on-disk name. Probe each candidate directory case-insensitively and
134
+ // report the real entry — identical to the case-sensitive Linux probe, which
135
+ // only ever sees the exact on-disk name.
136
+ const probeCaseInsensitive = (dir: string, wanted: string): string | null => {
137
+ try {
138
+ for (const entry of readdirSync(dir)) {
139
+ if (entry.toLowerCase() === wanted.toLowerCase()) return entry;
140
+ }
141
+ } catch {
142
+ /* missing dir */
143
+ }
144
+ return null;
145
+ };
146
+
147
+ export const findPrTemplate = (cwd: string): string | null => {
148
+ for (const rel of [
149
+ ".gitlab/merge_request_templates/Default.md",
150
+ ".gitlab/merge_request_templates/default.md",
151
+ ".gitlab/merge_request_templates/merge_request_template.md",
152
+ ".github/PULL_REQUEST_TEMPLATE.md",
153
+ ".github/pull_request_template.md",
154
+ ".github/PULL_REQUEST_TEMPLATE/pull_request_template.md",
155
+ "docs/PULL_REQUEST_TEMPLATE.md",
156
+ "PULL_REQUEST_TEMPLATE.md",
157
+ ]) {
158
+ const parts = rel.split("/");
159
+ const base = parts[parts.length - 1];
160
+ const dir = parts.length > 1 ? path.join(cwd, ...parts.slice(0, -1)) : cwd;
161
+ const actual = probeCaseInsensitive(dir, base);
162
+ if (actual) return [...parts.slice(0, -1), actual].join("/");
163
+ }
164
+ return null;
165
+ };
166
+
167
+ export const fallbackPrTemplate = (): string => `## Summary
168
+ -
169
+
170
+ ## Validation
171
+ - [ ] Not run`;
172
+
173
+ /** commit_log_for_range: git log --oneline --decorate --no-merges, falls back to -10. */
174
+ export const commitLogForRange = (cwd: string, range: string): string => {
175
+ const r = runGit(cwd, ["log", "--oneline", "--decorate", "--no-merges", range, "--"]);
176
+ if (r.exitCode === 0) return r.stdout;
177
+ return runGit(cwd, ["log", "--oneline", "--decorate", "-10", "--"]).stdout;
178
+ };
179
+
180
+ export const changedFilesForRange = (cwd: string, range: string): string => {
181
+ const r = runGit(cwd, ["diff", "--name-only", range, "--"]);
182
+ if (r.exitCode === 0) return r.stdout;
183
+ return runGit(cwd, ["diff", "--name-only", "--"]).stdout;
184
+ };
185
+
186
+ export const diffStatForRange = (cwd: string, range: string): string => {
187
+ const r = runGit(cwd, ["diff", "--stat", range, "--"]);
188
+ if (r.exitCode === 0) return r.stdout;
189
+ return runGit(cwd, ["diff", "--stat", "--"]).stdout;
190
+ };
191
+
192
+ const packageScripts = (cwd: string, keys: string[]): string[] => {
193
+ const pkgPath = path.join(cwd, "package.json");
194
+ if (!existsSync(pkgPath)) return [];
195
+ try {
196
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { scripts?: Record<string, string> };
197
+ return keys
198
+ .filter((k) => pkg.scripts && typeof pkg.scripts[k] === "string")
199
+ .map((k) => `${k}: ${pkg.scripts?.[k]}`);
200
+ } catch {
201
+ return [];
202
+ }
203
+ };
204
+
205
+ /** find . -maxdepth 3 -name README.md -o -name *.md with excludes, sorted, first 200. */
206
+ export const documentationFiles = (cwd: string): string[] => {
207
+ const excluded = new Set([".git", "node_modules", "target", "dist"]);
208
+ const matches: string[] = [];
209
+ const walk = (dir: string, depth: number) => {
210
+ if (depth > 3) return;
211
+ let entries;
212
+ try {
213
+ entries = readdirSync(dir, { withFileTypes: true });
214
+ } catch {
215
+ return;
216
+ }
217
+ for (const entry of entries) {
218
+ if (entry.isDirectory()) {
219
+ if (excluded.has(entry.name)) continue;
220
+ walk(path.join(dir, entry.name), depth + 1);
221
+ } else if (entry.isFile() && (entry.name === "README.md" || entry.name.endsWith(".md"))) {
222
+ // Shell parity: the script printed "./path" with forward slashes even
223
+ // on win32, where path.relative would otherwise emit backslashes.
224
+ matches.push("./" + path.relative(cwd, path.join(dir, entry.name)).replaceAll("\\", "/"));
225
+ }
226
+ }
227
+ };
228
+ walk(cwd, 1);
229
+ return matches.sort().slice(0, 200);
230
+ };
231
+
232
+ const readTrimmed = (file: string, maxLines: number): string => {
233
+ if (!existsSync(file)) return "";
234
+ const lines = readFileSync(file, "utf8").split("\n");
235
+ return lines.slice(0, maxLines).join("\n");
236
+ };
237
+
238
+ /** Port of pr-ready-context.sh — PR-ready repository context. */
239
+ export function prReadyContext(root: string, range?: string): ContextResult {
240
+ const cwd = path.resolve(repoRoot(root));
241
+ let stdout = "";
242
+ const autoRange = range === undefined;
243
+
244
+ let ctx: { ok: true; value: PrBranchContext } | { ok: false; error: string } | null = null;
245
+ if (autoRange) {
246
+ ctx = resolvePrBranchContext(cwd);
247
+ if (!ctx.ok) return { stdout: "", stderr: `ERROR: ${ctx.error}\n`, exitCode: 1, cwd };
248
+ range = ctx.value.range;
249
+ }
250
+
251
+ stdout += printSection("Repository");
252
+ stdout += `root: ${cwd}\n`;
253
+ stdout += `branch: ${currentBranch(cwd)}\n`;
254
+ stdout += `range: ${range}\n`;
255
+ if (autoRange && ctx?.ok) {
256
+ stdout += `base_ref: ${ctx.value.baseRef}\n`;
257
+ stdout += `merge_base: ${ctx.value.mergeBase}\n`;
258
+ stdout += `diff_range: ${ctx.value.diffRange}\n`;
259
+ stdout += "range_mode: branch-exclusive\n";
260
+ if (process.env.PR_SYNC_NOTES) stdout += `git_sync: ${process.env.PR_SYNC_NOTES}\n`;
261
+ }
262
+ const diffRange = autoRange && ctx?.ok ? ctx.value.diffRange : (range ?? "");
263
+
264
+ stdout += printSection("Working Tree");
265
+ const status = runGit(cwd, ["status", "--short"]);
266
+ stdout += status.stdout + (status.stderr ? `\n${status.stderr}` : "");
267
+ stdout += "\n";
268
+
269
+ stdout += printSection("Commits");
270
+ stdout += commitLogForRange(cwd, range ?? "");
271
+ stdout += "\n";
272
+
273
+ stdout += printSection("Diff Stat");
274
+ stdout += diffStatForRange(cwd, diffRange);
275
+ stdout += "\n";
276
+
277
+ stdout += printSection("Changed Files");
278
+ stdout += changedFilesForRange(cwd, diffRange);
279
+ stdout += "\n";
280
+
281
+ stdout += printSection("PR Template");
282
+ const template = findPrTemplate(cwd);
283
+ if (template) {
284
+ stdout += `template_path: ${template}\n\n`;
285
+ stdout += readTrimmed(path.join(cwd, template), 220);
286
+ } else {
287
+ stdout += "template_path: none\n\n";
288
+ stdout += fallbackPrTemplate();
289
+ }
290
+ stdout += "\n";
291
+
292
+ stdout += printSection("Recent Validation Signals");
293
+ if (existsSync(path.join(cwd, "package.json"))) {
294
+ stdout += "package.json detected. Common scripts:\n";
295
+ const found = packageScripts(cwd, ["lint", "format:check", "test", "build"]);
296
+ if (found.length) stdout += found.join("\n") + "\n";
297
+ }
298
+ if (
299
+ existsSync(path.join(cwd, "Cargo.toml")) ||
300
+ existsSync(path.join(cwd, "src-tauri/Cargo.toml"))
301
+ ) {
302
+ stdout += "Rust project detected.\n";
303
+ }
304
+
305
+ stdout += printSection("VCS Config");
306
+ const vc = vcsConfig("resolve", cwd);
307
+ if (vc.ok) {
308
+ stdout += `workspace: ${String(vc.workspace_name ?? "none")}\n`;
309
+ stdout += `provider: ${String(vc.provider ?? "gitlab")}\n`;
310
+ } else {
311
+ // RL-01: never report silent defaults for malformed vcs.json — surface the
312
+ // exact-path diagnostic instead.
313
+ stdout += `vcs: unreadable (malformed) — ${String(vc.error ?? "cannot read vcs.json")}\n`;
314
+ }
315
+ // B4: the shell printed only workspace:/provider: (the summary dump was
316
+ // discarded). Keep that concise shape — no raw summary JSON in the context.
317
+ if (!vcsConfig("summary", cwd).ok) {
318
+ stdout += "vcs: not configured — run /wk-init action vcs_scaffold\n";
319
+ }
320
+
321
+ stdout += printSection("Merged PR Style");
322
+ const style = mergedPrStyle(6, cwd);
323
+ stdout += JSON.stringify(style, null, 2) + "\n";
324
+
325
+ return { stdout, stderr: "", exitCode: 0, cwd };
326
+ }
327
+
328
+ const CHANGELOG_RULES = `- Use an [Unreleased] section.
329
+ - Use Added, Changed, Deprecated, Removed, Fixed, Security.
330
+ - Entries should be human-readable and user-facing.
331
+ - Do not use raw commit messages as changelog bullets.
332
+ - MERGE into existing ### Category under [Unreleased] — never append a second ### Added / ### Fixed block.
333
+ - Apply with the native workflow_changelog_apply tool only (not hand-edits under Unreleased).
334
+ - If Unreleased already has duplicate category headings, normalize_only first.`;
335
+
336
+ /** Port of changelog-context.sh — changelog update context. */
337
+ export function changelogContext(root: string, range?: string): ContextResult {
338
+ const cwd = path.resolve(repoRoot(root));
339
+ const resolvedRange = rangeArgOrDefault(range, cwd);
340
+ let stdout = "";
341
+
342
+ stdout += printSection("Repository");
343
+ stdout += `root: ${cwd}\n`;
344
+ stdout += `branch: ${currentBranch(cwd)}\n`;
345
+ stdout += `range: ${resolvedRange}\n`;
346
+
347
+ stdout += printSection("Keep a Changelog Rules");
348
+ stdout += CHANGELOG_RULES + "\n";
349
+
350
+ stdout += printSection("Existing CHANGELOG.md");
351
+ const changelogPath = path.join(cwd, "CHANGELOG.md");
352
+ if (existsSync(changelogPath)) {
353
+ stdout += readTrimmed(changelogPath, 260) + "\n";
354
+ } else {
355
+ stdout += "CHANGELOG.md not found.\n";
356
+ }
357
+
358
+ stdout += printSection("Commits");
359
+ stdout += commitLogForRange(cwd, resolvedRange) + "\n";
360
+
361
+ stdout += printSection("Diff Stat");
362
+ stdout += diffStatForRange(cwd, resolvedRange) + "\n";
363
+
364
+ stdout += printSection("Changed Files");
365
+ stdout += changedFilesForRange(cwd, resolvedRange) + "\n";
366
+
367
+ return { stdout, stderr: "", exitCode: 0, cwd };
368
+ }
369
+
370
+ /** Port of docs-refresh-context.sh — documentation refresh context. */
371
+ export function docsRefreshContext(root: string, range?: string): ContextResult {
372
+ const cwd = path.resolve(repoRoot(root));
373
+ const resolvedRange = rangeArgOrDefault(range, cwd);
374
+ let stdout = "";
375
+
376
+ stdout += printSection("Repository");
377
+ stdout += `root: ${cwd}\n`;
378
+ stdout += `branch: ${currentBranch(cwd)}\n`;
379
+ stdout += `range: ${resolvedRange}\n`;
380
+
381
+ stdout += printSection("Changed Files");
382
+ stdout += changedFilesForRange(cwd, resolvedRange) + "\n";
383
+
384
+ stdout += printSection("Documentation Files");
385
+ stdout += documentationFiles(cwd).join("\n") + "\n";
386
+
387
+ stdout += printSection("README Preview");
388
+ const readme = path.join(cwd, "README.md");
389
+ if (existsSync(readme)) {
390
+ stdout += readTrimmed(readme, 220) + "\n";
391
+ } else {
392
+ stdout += "README.md not found.\n";
393
+ }
394
+
395
+ stdout += printSection("Package Scripts");
396
+ const pkgPath = path.join(cwd, "package.json");
397
+ if (existsSync(pkgPath)) {
398
+ try {
399
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
400
+ stdout +=
401
+ JSON.stringify({ name: pkg.name, version: pkg.version, scripts: pkg.scripts }, null, 2) +
402
+ "\n";
403
+ } catch {
404
+ /* unreadable package.json */
405
+ }
406
+ } else {
407
+ stdout += "package.json not found.\n";
408
+ }
409
+
410
+ return { stdout, stderr: "", exitCode: 0, cwd };
411
+ }
412
+
413
+ /** Port of release-notes-context.sh — tags + commits + files for a range. */
414
+ export function releaseNotesContext(root: string, rangeOrTag: string): ContextResult {
415
+ const cwd = path.resolve(repoRoot(root));
416
+ if (!rangeOrTag) {
417
+ return { stdout: "", stderr: "ERROR: release tag or range required\n", exitCode: 1, cwd };
418
+ }
419
+ const resolvedRange = rangeArgOrDefault(rangeOrTag, cwd);
420
+ let stdout = "";
421
+
422
+ stdout += printSection("Repository");
423
+ stdout += `root: ${cwd}\n`;
424
+ stdout += `branch: ${currentBranch(cwd)}\n`;
425
+ stdout += `requested: ${rangeOrTag}\n`;
426
+ stdout += `range: ${resolvedRange}\n`;
427
+
428
+ stdout += printSection("Tags");
429
+ const tags = runGit(cwd, ["tag", "--sort=-creatordate"]);
430
+ if (tags.exitCode === 0) stdout += tags.stdout.split("\n").slice(0, 20).join("\n") + "\n";
431
+
432
+ stdout += printSection("Commits");
433
+ stdout += commitLogForRange(cwd, resolvedRange) + "\n";
434
+
435
+ stdout += printSection("Diff Stat");
436
+ stdout += diffStatForRange(cwd, resolvedRange) + "\n";
437
+
438
+ stdout += printSection("Changed Files");
439
+ stdout += changedFilesForRange(cwd, resolvedRange) + "\n";
440
+
441
+ stdout += printSection("Existing Release Files");
442
+ for (const rel of ["CHANGELOG.md", "RELEASE_NOTES.md", ".github/releases.md"]) {
443
+ if (existsSync(path.join(cwd, rel))) stdout += rel + "\n";
444
+ }
445
+
446
+ return { stdout, stderr: "", exitCode: 0, cwd };
447
+ }
@@ -1,7 +1,10 @@
1
1
  import { resolveWorkspaceRoot } from "./scripts";
2
2
 
3
3
  /** Attach the resolved repository root to each repository tool response. */
4
- export function withWorkspace(workspaceRoot: string, data: Record<string, any> = {}): Record<string, any> {
4
+ export function withWorkspace(
5
+ workspaceRoot: string,
6
+ data: Record<string, any> = {},
7
+ ): Record<string, any> {
5
8
  return {
6
9
  workspace_root: resolveWorkspaceRoot(workspaceRoot),
7
10
  ...data,
@@ -0,0 +1,23 @@
1
+ import { fail, ok, run } from "../core";
2
+
3
+ export type RunResult = ReturnType<typeof run>;
4
+
5
+ export type RepoRuntime = {
6
+ git(root: string, args: string[]): RunResult;
7
+ verifyProject(root: string, dryRun: boolean): RunResult;
8
+ prContext(root: string, range: string | undefined): RunResult;
9
+ changelogContext(root: string, range: string | undefined): RunResult;
10
+ docsContext(root: string, range: string | undefined): RunResult;
11
+ releaseContext(root: string, range: string): RunResult;
12
+ prCreate(root: string, env: Record<string, string>): RunResult;
13
+ initApply(root: string, action: string, env: Record<string, string>): RunResult;
14
+ initStatus(root: string): RunResult;
15
+ toolkitStatus(root: string): RunResult | Promise<RunResult>;
16
+ };
17
+
18
+ export const normalizeLegacyResult = (value: Record<string, unknown>) => {
19
+ if (value.error) return fail(String(value.error));
20
+ if (value.ok === false) return fail("legacy operation reported failure");
21
+ const { ok: _legacyOk, ...data } = value;
22
+ return ok(data);
23
+ };
package/src/core/rules.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
3
  import { configDir } from "./config";
5
4
 
6
5
  export type RulePlatform = "cursor" | "opencode";
@@ -11,13 +10,12 @@ export type CanonicalRule = {
11
10
  body: string;
12
11
  };
13
12
 
14
- const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
15
-
16
13
  export const rulesDir = () => path.join(configDir(), "rules");
17
14
 
18
15
  export const parseRule = (markdown: string): CanonicalRule | { error: string } => {
19
16
  const fm = markdown.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
20
- if (!fm) return { error: "rule must start with frontmatter (--- name/description/platforms ---)" };
17
+ if (!fm)
18
+ return { error: "rule must start with frontmatter (--- name/description/platforms ---)" };
21
19
  const meta: Record<string, string> = {};
22
20
  const unquote = (v: string) => v.replace(/^["']|["']$/g, "").trim();
23
21
  for (const line of fm[1].split("\n")) {
@@ -27,9 +25,13 @@ export const parseRule = (markdown: string): CanonicalRule | { error: string } =
27
25
  const name = meta.name ?? "";
28
26
  const description = meta.description ?? "";
29
27
  const rawPlatforms = (meta.platforms ?? "")
30
- .replace(/^\[|\]$/g, "").split(",").map((p) => p.trim().replace(/['"]/g, ""))
28
+ .replace(/^\[|\]$/g, "")
29
+ .split(",")
30
+ .map((p) => p.trim().replace(/['"]/g, ""))
31
31
  .filter(Boolean);
32
- const platforms = rawPlatforms.filter((p): p is RulePlatform => p === "cursor" || p === "opencode");
32
+ const platforms = rawPlatforms.filter(
33
+ (p): p is RulePlatform => p === "cursor" || p === "opencode",
34
+ );
33
35
  if (!name || !description || platforms.length === 0) {
34
36
  return { error: "rule frontmatter requires name, description, and platforms" };
35
37
  }
@@ -73,7 +75,8 @@ export const writeRule = (
73
75
  confirmed: boolean,
74
76
  ): { ok: true; path: string } | { ok: false; error: string } => {
75
77
  if (!confirmed) return { ok: false, error: "confirmed: true required" };
76
- if (!RULE_NAME_RE.test(rule.name)) return { ok: false, error: `invalid rule name: ${JSON.stringify(rule.name)}` };
78
+ if (!RULE_NAME_RE.test(rule.name))
79
+ return { ok: false, error: `invalid rule name: ${JSON.stringify(rule.name)}` };
77
80
  const dir = path.join(rulesDir(), rule.name);
78
81
  mkdirSync(dir, { recursive: true });
79
82
  const file = path.join(dir, "rule.md");
@@ -0,0 +1,22 @@
1
+ import { writeFileSync } from "node:fs";
2
+
3
+ // Shared credential-safe write (Task 14 Step 7 / CA-13): `wx` exclusive create
4
+ // closes the TOCTOU window between an existence check and the write, and EEXIST
5
+ // means the other writer won — their bytes are preserved instead of clobbered.
6
+ // Every token write in the wizard (ensureToken), the apply path (create-file
7
+ // mutations) and initApplyData routes through this one primitive.
8
+ export type ExclusiveWriteResult = "created" | "preserved";
9
+
10
+ export function writeFileExclusive(
11
+ file: string,
12
+ content: string,
13
+ mode?: number,
14
+ ): ExclusiveWriteResult {
15
+ try {
16
+ writeFileSync(file, content, { encoding: "utf8", flag: "wx", mode });
17
+ return "created";
18
+ } catch (err) {
19
+ if ((err as NodeJS.ErrnoException).code === "EEXIST") return "preserved";
20
+ throw err;
21
+ }
22
+ }
@@ -1,42 +1,6 @@
1
- import { spawnSync } from "node:child_process";
2
- import path from "node:path";
3
- import { fileURLToPath } from "node:url";
1
+ import { assetRoot, packageRoot } from "./package-root";
4
2
 
5
- const dirname = path.dirname(fileURLToPath(import.meta.url));
6
- export const PLUGIN_ROOT = path.resolve(dirname, "../..");
3
+ export const PLUGIN_ROOT = packageRoot();
4
+ export const ASSET_ROOT = assetRoot();
7
5
 
8
6
  export const resolveWorkspaceRoot = (explicit?: string) => explicit || process.cwd();
9
-
10
- export function runScript(
11
- scriptName: string,
12
- args: string[],
13
- workspaceRoot: string,
14
- extraEnv?: Record<string, string>,
15
- ) {
16
- const cwd = resolveWorkspaceRoot(workspaceRoot);
17
- const scriptPath = path.join(PLUGIN_ROOT, "scripts", scriptName);
18
- const result = spawnSync("bash", [scriptPath, ...args], {
19
- cwd,
20
- encoding: "utf8",
21
- env: { ...process.env, ...(extraEnv ?? {}) },
22
- });
23
- return {
24
- stdout: result.stdout ?? "",
25
- stderr: result.stderr ?? "",
26
- exitCode: result.status ?? 1,
27
- scriptPath,
28
- cwd,
29
- };
30
- }
31
-
32
- export function runScriptJson(scriptName: string, args: string[], workspaceRoot: string, extraEnv?: Record<string, string>) {
33
- const { stdout, stderr, exitCode } = runScript(scriptName, args, workspaceRoot, extraEnv);
34
- if (exitCode !== 0) {
35
- return { error: (stderr || stdout || "script failed").trim(), exitCode };
36
- }
37
- try {
38
- return { data: JSON.parse(stdout.trim()) };
39
- } catch {
40
- return { error: "invalid JSON from script", raw: stdout.trim() };
41
- }
42
- }