@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
@@ -4,13 +4,19 @@ import { parseTasksFromPlan } from "./docs-validate";
4
4
  import { resolveBranch } from "./branch";
5
5
 
6
6
  const readSafe = (p: string): string | null => {
7
- try { return readFileSync(p, "utf8"); } catch { return null; }
7
+ try {
8
+ return readFileSync(p, "utf8");
9
+ } catch {
10
+ return null;
11
+ }
8
12
  };
9
13
 
10
14
  export function parsePlanTasks(
11
15
  planPath: string,
12
16
  workspaceRoot: string,
13
- ): { task_count: number; tasks: { id: number; title: string; section_text: string }[] } | { error: string } {
17
+ ):
18
+ | { task_count: number; tasks: { id: number; title: string; section_text: string }[] }
19
+ | { error: string } {
14
20
  const cwd = path.resolve(workspaceRoot);
15
21
  const resolved = path.isAbsolute(planPath) ? planPath : path.join(cwd, planPath);
16
22
  const text = readSafe(resolved);
@@ -27,7 +33,11 @@ export function resolveHandoffBranch(
27
33
  planPath: string,
28
34
  workspaceRoot: string,
29
35
  ): { branch: string } | { error: string } {
30
- const resolved = resolveBranch({ spec_path: specPath, plan_path: planPath, workspace_root: workspaceRoot });
36
+ const resolved = resolveBranch({
37
+ spec_path: specPath,
38
+ plan_path: planPath,
39
+ workspace_root: workspaceRoot,
40
+ });
31
41
  if ("error" in resolved) return { error: resolved.error as string };
32
42
  return { branch: resolved.branch };
33
43
  }
@@ -1,4 +1,4 @@
1
1
  // CLI port of scripts/init/toolkit-status.sh.
2
2
  import { toolkitStatusData } from "../init";
3
3
 
4
- console.log(JSON.stringify(toolkitStatusData(), null, 2));
4
+ console.log(JSON.stringify(await toolkitStatusData(), null, 2));
@@ -1,4 +1,4 @@
1
1
  // CLI port of scripts/vcs/verify-token.sh — soft-fail JSON, exit 0.
2
2
  import { vcsVerifyToken } from "../vcs-config";
3
3
 
4
- console.log(JSON.stringify(vcsVerifyToken()));
4
+ console.log(JSON.stringify(await vcsVerifyToken()));
@@ -5,11 +5,13 @@ const args = process.argv.slice(2);
5
5
  const cmd = args[0];
6
6
  if (cmd === "log-time" || cmd === "post-comment") {
7
7
  if ((process.env.WORKFLOW_YT_WRITE ?? "") !== "1") {
8
- console.error("ERROR: YouTrack write operations require WORKFLOW_YT_WRITE=1 (refusing to mutate production)");
8
+ console.error(
9
+ "ERROR: YouTrack write operations require WORKFLOW_YT_WRITE=1 (refusing to mutate production)",
10
+ );
9
11
  process.exit(1);
10
12
  }
11
13
  }
12
- const out = youTrackApi(args, process.env.WORKFLOW_YT_WRITE ?? "");
14
+ const out = await youTrackApi(args, process.env.WORKFLOW_YT_WRITE ?? "");
13
15
  if ("error" in out) {
14
16
  console.log(JSON.stringify({ ok: false, error: out.error }));
15
17
  process.exit(1);
@@ -1,7 +1,5 @@
1
1
  // CLI port of scripts/youtrack/config.sh — JSON out, argv/env in.
2
- import {
3
- youTrackConfigLoad,
4
- } from "../youtrack";
2
+ import { youTrackConfigLoad } from "../youtrack";
5
3
 
6
4
  const args = process.argv.slice(2);
7
5
  const cmd = args[0] ?? "load";
@@ -1,7 +1,7 @@
1
1
  // CLI port of scripts/youtrack/verify-token.sh.
2
2
  import { youTrackVerifyToken } from "../youtrack";
3
3
 
4
- const out = youTrackVerifyToken();
4
+ const out = await youTrackVerifyToken();
5
5
  if ("error" in out) {
6
6
  const payload: Record<string, any> = { ok: false, error: out.error };
7
7
  if (out.http_status !== undefined) payload.http_status = out.http_status;
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { vcsConfig } from "./vcs-config";
5
+ import { resolveBranchPolicyFor } from "./branch";
5
6
 
6
7
  // Port of scripts/pr-create.sh — build MR/PR body issue linking + create via glab/gh.
7
8
 
@@ -19,6 +20,23 @@ function parseGhIssue(value: string): string {
19
20
  return m ? m[1] : String(value).trim().replace(/^#/, "");
20
21
  }
21
22
 
23
+ // RL-03/CA-25/AR-08: a branch-derived numeric issue id must be a bare number at
24
+ // a segment or dash boundary — and never part of a date segment. Year-first
25
+ // (feature/2024-01-15/x) and day-first (feature/15-01-2024/x) dates are both
26
+ // skipped — a complete date anywhere in a segment (release-2024-01-15,
27
+ // v2-2024-01-15-fix) — so no date digit ever closes an issue. Deliberate
28
+ // numeric issue branches (feature/42-title, feature/2024-fix) keep linking.
29
+ function deriveGhIssueFromBranch(branch: string): string {
30
+ for (const segment of branch.split("/")) {
31
+ if (/^\d{4}-\d/.test(segment)) continue; // year-first date-like segment (incl. year-month)
32
+ if (/\d{4}-\d{1,2}-\d{1,2}/.test(segment)) continue; // complete year-first date anywhere
33
+ if (/\d{1,2}-\d{1,2}-\d{4}/.test(segment)) continue; // complete day-first date anywhere
34
+ const m = /(?:^|-)(\d+)(?:-|$)/.exec(segment);
35
+ if (m) return m[1];
36
+ }
37
+ return "";
38
+ }
39
+
22
40
  function buildBody(
23
41
  body: string,
24
42
  branch: string,
@@ -44,9 +62,7 @@ function buildBody(
44
62
  if (!issue && branch) {
45
63
  // pure-number issue id (feature/42-title -> 42); digits must be followed by a dash or end-of-string
46
64
  // so version tokens (release/1.2.3, backport/8.0.1, lodash-4.17.21, 2024.1) never link
47
- // ponytail: known date-style false positive (feature/2024-01-fix -> Closes #2024); accepted — bare 42-title support is deliberate
48
- const m = /(?:^|\/|-)(\d+)(?:-|$)/.exec(branch);
49
- if (m) issue = m[1];
65
+ issue = deriveGhIssueFromBranch(branch);
50
66
  }
51
67
  if (issue) {
52
68
  if (ghRelation === "related") {
@@ -61,17 +77,25 @@ function buildBody(
61
77
  return body ? `${body}\n\n${line}` : line;
62
78
  }
63
79
 
64
- const truthy = (v: string | undefined): boolean => ["1", "true", "yes"].includes(String(v ?? "").toLowerCase());
80
+ const truthy = (v: string | undefined): boolean =>
81
+ ["1", "true", "yes"].includes(String(v ?? "").toLowerCase());
65
82
 
66
83
  // Port of python's shutil.which — scan PATH in-process (no `which` binary needed).
67
84
  function whichOnPath(tool: string): string | null {
68
- for (const dir of (process.env.PATH ?? "").split(":")) {
85
+ // win32 CLIs/stubs carry .exe/.cmd suffixes (gh.exe, gh.cmd), so probe them
86
+ // too — accessSync with the bare name would never find them.
87
+ const names = process.platform === "win32" ? [tool, `${tool}.exe`, `${tool}.cmd`] : [tool];
88
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
69
89
  if (!dir) continue;
70
- const candidate = path.join(dir, tool);
71
- try {
72
- fs.accessSync(candidate, fs.constants.X_OK);
73
- return candidate;
74
- } catch { /* keep scanning */ }
90
+ for (const name of names) {
91
+ const candidate = path.join(dir, name);
92
+ try {
93
+ fs.accessSync(candidate, fs.constants.X_OK);
94
+ return candidate;
95
+ } catch {
96
+ /* keep scanning */
97
+ }
98
+ }
75
99
  }
76
100
  return null;
77
101
  }
@@ -105,14 +129,96 @@ export function prBuildBody(env: NodeJS.ProcessEnv, cwd?: string): string {
105
129
  /** Port of scripts/pr-create.sh create mode — glab/gh MR/PR creation. */
106
130
  export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, any> {
107
131
  const root = process.env.WORKFLOW_WORKSPACE_ROOT ?? repoRoot(cwd);
132
+ const policy = resolveBranchPolicyFor(root);
108
133
  const cfg = vcsConfig("load", root);
109
134
  if (!cfg.ok) return { error: cfg.error ?? "vcs config missing" };
110
- if (!cfg.tokenReady) return { error: "VCS token not readyrun /wk-init and edit token file locally" };
135
+ // Merge mode finishes the feature locally (git merge + push) no glab/gh,
136
+ // no API token, so token readiness is only required for the PR path.
137
+ if (policy.integration !== "merge" && !cfg.tokenReady)
138
+ return { error: "VCS token not ready — run /wk-init and edit token file locally" };
111
139
 
112
140
  const provider = cfg.provider as string;
113
- if (provider !== "gitlab" && provider !== "github") return { error: `unsupported provider: ${provider}` };
141
+ if (provider !== "gitlab" && provider !== "github")
142
+ return { error: `unsupported provider: ${provider}` };
143
+
144
+ // B1/RL-03: WF_PR_TARGET is the one deliberate override knob on the create
145
+ // surface (resolvePrBranchContext/docsBranch have none). Unlike the config
146
+ // default — which is authoritative by construction — a caller-supplied
147
+ // target is validated against the resolved branch policy so a PR can never
148
+ // be aimed at a protected or disallowed branch.
149
+ const targetOverride = env.WF_PR_TARGET;
150
+ const target =
151
+ targetOverride || String(cfg.defaultTargetBranch ?? policy.defaultTargetBranch ?? "develop");
152
+ if (targetOverride) {
153
+ const { allowed, protected: protectedTargets } = policy;
154
+ if (protectedTargets.has(targetOverride.toLowerCase()))
155
+ return {
156
+ error: `PR target ${JSON.stringify(targetOverride)} is a protected branch — override must be an allowed non-protected target`,
157
+ };
158
+ if (!allowed.some((r) => r.test(targetOverride)))
159
+ return {
160
+ error: `PR target ${JSON.stringify(targetOverride)} is not allowed by the branch policy`,
161
+ };
162
+ }
163
+
164
+ const title = String(env.WF_PR_TITLE ?? "");
165
+ const br = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
166
+ cwd: root,
167
+ encoding: "utf8",
168
+ });
169
+ const branch = br.status === 0 ? (br.stdout ?? "").trim() : "";
170
+
171
+ if (policy.integration === "merge") {
172
+ const finish = (): Record<string, any> => {
173
+ const merge = spawnSync("git", ["merge", "--no-ff", branch, "-m", title], {
174
+ cwd: root,
175
+ encoding: "utf8",
176
+ });
177
+ if (merge.status !== 0)
178
+ return {
179
+ error: "merge failed",
180
+ mode: "merge",
181
+ targetBranch: target,
182
+ stderr: (merge.stderr ?? "").slice(0, 800),
183
+ };
184
+ const push = spawnSync("git", ["push", "origin", target], { cwd: root, encoding: "utf8" });
185
+ if (push.status !== 0)
186
+ return {
187
+ error: "push failed",
188
+ mode: "merge",
189
+ targetBranch: target,
190
+ stderr: (push.stderr ?? "").slice(0, 800),
191
+ };
192
+ return {
193
+ ok: true,
194
+ mode: "merge",
195
+ targetBranch: target,
196
+ merged: true,
197
+ pushed: true,
198
+ output: (push.stdout ?? "").trim(),
199
+ };
200
+ };
201
+ const co = spawnSync("git", ["checkout", target], { cwd: root, encoding: "utf8" });
202
+ if (co.status !== 0)
203
+ return {
204
+ error: `cannot checkout target ${target}`,
205
+ mode: "merge",
206
+ targetBranch: target,
207
+ stderr: (co.stderr ?? "").slice(0, 800),
208
+ };
209
+ // finish() returns error objects, it never throws, so the best-effort
210
+ // `git checkout branch` restore below runs on ALL outcomes (success,
211
+ // merge failure, and push failure) — the tree always returns to the
212
+ // feature branch. Deliberately not a try/finally: the restore is
213
+ // best-effort and its own failure is not actionable on this path.
214
+ const result = finish();
215
+ spawnSync("git", ["checkout", branch], { cwd: root, encoding: "utf8" }); // best-effort return
216
+ return result;
217
+ }
218
+
114
219
  const cli = provider === "gitlab" ? "glab" : "gh";
115
- const installUrl = provider === "gitlab" ? "https://gitlab.com/gitlab-org/cli" : "https://cli.github.com";
220
+ const installUrl =
221
+ provider === "gitlab" ? "https://gitlab.com/gitlab-org/cli" : "https://cli.github.com";
116
222
  if (whichOnPath(cli) === null) {
117
223
  return {
118
224
  ok: false,
@@ -124,21 +230,20 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
124
230
 
125
231
  const pr = (cfg.pr ?? {}) as Record<string, any>;
126
232
  const token = fs.readFileSync(cfg.tokenPath as string, "utf8").trim();
127
- const title = String(env.WF_PR_TITLE ?? "");
128
233
  const body = env.WF_PR_BODY ?? "";
129
234
  const draft = String(env.WF_PR_DRAFT ?? "false").toLowerCase() === "true";
130
- const target = env.WF_PR_TARGET || String(cfg.defaultTargetBranch ?? "develop");
131
-
132
- const br = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root, encoding: "utf8" });
133
- const branch = br.status === 0 ? (br.stdout ?? "").trim() : "";
134
235
 
135
236
  let baseUrl = cfg.youtrack_base_url as string | undefined;
136
237
  if (!baseUrl) {
137
- const ytCfg = process.env.WORKFLOW_YOUTRACK_CONFIG ?? path.join(path.dirname(String(cfg.configPath)), "youtrack.json");
238
+ const ytCfg =
239
+ process.env.WORKFLOW_YOUTRACK_CONFIG ??
240
+ path.join(path.dirname(String(cfg.configPath)), "youtrack.json");
138
241
  try {
139
242
  const yt = JSON.parse(fs.readFileSync(ytCfg, "utf8")) as Record<string, any>;
140
243
  if (yt && typeof yt === "object") baseUrl = yt.baseUrl;
141
- } catch { /* optional */ }
244
+ } catch {
245
+ /* optional */
246
+ }
142
247
  }
143
248
  const ghLinkOnPr = cfg.issues_provider === "github" && cfg.link_on_pr === true;
144
249
  let ghRepo: string | null = null;
@@ -147,8 +252,15 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
147
252
  if (rr.status === 0) ghRepo = parseGhRepo(rr.stdout ?? "");
148
253
  }
149
254
  const finalBody = buildBody(
150
- body, branch, cfg.link_issues === true, baseUrl ?? "", env.WORKFLOW_YT_ISSUE ?? "",
151
- ghLinkOnPr, env.WORKFLOW_GH_ISSUE ?? "", env.WORKFLOW_GH_ISSUE_RELATION ?? "closes", ghRepo,
255
+ body,
256
+ branch,
257
+ cfg.link_issues === true,
258
+ baseUrl ?? "",
259
+ env.WORKFLOW_YT_ISSUE ?? "",
260
+ ghLinkOnPr,
261
+ env.WORKFLOW_GH_ISSUE ?? "",
262
+ env.WORKFLOW_GH_ISSUE_RELATION ?? "closes",
263
+ ghRepo,
152
264
  );
153
265
 
154
266
  const squash = pr.squashOnMerge !== false;
@@ -166,21 +278,30 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
166
278
  if (draft) cmd.push("--draft");
167
279
  if (push) cmd.push("--push");
168
280
  if (skipConfirm) cmd.push("--yes");
169
- cmdEnv = { ...process.env, GITLAB_TOKEN: token };
281
+ // bun's Windows spawn only consults PATH when the env object carries an
282
+ // explicit PATH key — a spread-only PATH is invisible to the lookup and
283
+ // uv_spawn fails with ENOENT even though the CLI is on PATH.
284
+ cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GITLAB_TOKEN: token };
170
285
  } else {
171
286
  cmd = ["gh", "pr", "create", "--title", title, "--base", target];
172
287
  if (finalBody) cmd.push("--body", finalBody);
173
288
  if (draft) cmd.push("--draft");
174
- cmdEnv = { ...process.env, GH_TOKEN: token };
289
+ cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GH_TOKEN: token };
175
290
  }
176
291
 
177
292
  const result = spawnSync(cmd[0], cmd.slice(1), { cwd: root, encoding: "utf8", env: cmdEnv });
178
293
  if (result.status !== 0) {
179
294
  const err = (result.stderr ?? result.stdout ?? "").trim();
180
295
  let hint: Record<string, any> | null = null;
181
- if (provider === "gitlab" && (err.includes("409") || err.toLowerCase().includes("already exists"))) {
182
- const list = spawnSync("glab", ["mr", "list", `--source-branch=${branch}`, "--output=json"],
183
- { cwd: root, encoding: "utf8", env: cmdEnv });
296
+ if (
297
+ provider === "gitlab" &&
298
+ (err.includes("409") || err.toLowerCase().includes("already exists"))
299
+ ) {
300
+ const list = spawnSync("glab", ["mr", "list", `--source-branch=${branch}`, "--output=json"], {
301
+ cwd: root,
302
+ encoding: "utf8",
303
+ env: cmdEnv,
304
+ });
184
305
  if (list.status === 0 && (list.stdout ?? "").trim()) {
185
306
  try {
186
307
  const mrs = JSON.parse(list.stdout ?? "") as Array<Record<string, any>>;
@@ -191,10 +312,16 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
191
312
  next_step: "Use glab mr update or close the open MR before creating again",
192
313
  };
193
314
  }
194
- } catch { /* no hint */ }
315
+ } catch {
316
+ /* no hint */
317
+ }
195
318
  }
196
319
  }
197
- const payload: Record<string, any> = { error: "create failed", provider, stderr: err.slice(0, 800) };
320
+ const payload: Record<string, any> = {
321
+ error: "create failed",
322
+ provider,
323
+ stderr: err.slice(0, 800),
324
+ };
198
325
  if (hint) payload.hint = hint;
199
326
  return payload;
200
327
  }
@@ -30,7 +30,12 @@ export function renderAsciiWireframe(spec: unknown): string {
30
30
  }
31
31
  if (kind === "button") {
32
32
  const label = "[ " + String(row?.label ?? "Button") + " ]";
33
- lines.push(boxLine(label.padStart(Math.max(0, (width - 4 + label.length) / 2)).padEnd(width - 4), width));
33
+ lines.push(
34
+ boxLine(
35
+ label.padStart(Math.max(0, (width - 4 + label.length) / 2)).padEnd(width - 4),
36
+ width,
37
+ ),
38
+ );
34
39
  continue;
35
40
  }
36
41
  if (kind === "field") {
@@ -42,7 +47,11 @@ export function renderAsciiWireframe(spec: unknown): string {
42
47
  if (kind === "columns") {
43
48
  const cols = Array.isArray(row?.columns) ? row.columns : [];
44
49
  const colW = Math.floor((width - 4 - cols.length + 1) / Math.max(cols.length, 1));
45
- const parts = cols.map((c: any) => String(c?.label ?? "").slice(0, Math.max(0, colW - 1)).padEnd(Math.max(0, colW)));
50
+ const parts = cols.map((c: any) =>
51
+ String(c?.label ?? "")
52
+ .slice(0, Math.max(0, colW - 1))
53
+ .padEnd(Math.max(0, colW)),
54
+ );
46
55
  lines.push(boxLine(parts.join(" | ").trim(), width));
47
56
  continue;
48
57
  }
@@ -0,0 +1,215 @@
1
+ // Registration merge helpers for the OpenCode/Cursor installers (RR-06).
2
+ // Pure functions: accept the existing user config, return the deduplicated
3
+ // config PLUS the explicit list of keys changed. Unrelated user settings are
4
+ // never rewritten — their values round-trip JSON-identical. The install
5
+ // scripts (`packages/workit-core/scripts/install-*-plugin.sh`) import these so
6
+ // there is exactly one source of truth for registration merging.
7
+ import { existsSync } from "node:fs";
8
+ import path from "node:path";
9
+
10
+ export interface MergeResult<T> {
11
+ config: T;
12
+ changed: string[];
13
+ }
14
+
15
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
16
+ v !== null && typeof v === "object" && !Array.isArray(v);
17
+
18
+ /** Exact identity match — `name` or `name@version` — never a substring (D3). */
19
+ const named = (s: string, name: string) => s === name || s.startsWith(`${name}@`);
20
+
21
+ /**
22
+ * True when a plugin identity is a current or legacy Workit entry. Matched by
23
+ * exact identity, never by substring: an unrelated plugin whose id merely
24
+ * contains "workflow-toolkit" is preserved (D3). Path-style identities are
25
+ * compared with normalized separators so a Windows file:// pin (backslashes)
26
+ * still matches the packages/workit-* path checks.
27
+ */
28
+ export function isWorkitPlugin(value: unknown): boolean {
29
+ const s = String(value).replaceAll("\\", "/");
30
+ const url = s.startsWith("file://") || s.startsWith("git+file://");
31
+ const pkgPath =
32
+ s.includes("/packages/workit-opencode/") ||
33
+ s.includes("/packages/workit-cursor/") ||
34
+ s.includes("/node_modules/@brainervirus/workit-opencode/") ||
35
+ s.includes("/node_modules/@brainervirus/workit-cursor/");
36
+ return (
37
+ named(s, "workflow-toolkit") ||
38
+ named(s, "workflow-toolkit-opencode") ||
39
+ named(s, "local/workflow-toolkit") ||
40
+ named(s, "@brainervirus/workit-opencode") ||
41
+ named(s, "@brainervirus/workit-cursor") ||
42
+ (url && pkgPath) ||
43
+ (s.startsWith("git+file://") && s.includes("workflow-toolkit"))
44
+ );
45
+ }
46
+
47
+ /** Deduplicate every legacy/current Workit plugin identity to one dev pin. */
48
+ export function mergeOpenCodePlugins(plugin: unknown, pin: string): MergeResult<unknown[]> {
49
+ const existing = Array.isArray(plugin) ? plugin : typeof plugin === "string" ? [plugin] : [];
50
+ const config = [pin, ...existing.filter((p) => !isWorkitPlugin(p))];
51
+ const changed = JSON.stringify(config) !== JSON.stringify(existing) ? ["plugin"] : [];
52
+ return { config, changed };
53
+ }
54
+
55
+ /** Merge an existing OpenCode config with a dev pin; preserve unrelated keys. */
56
+ export function mergeOpenCodeConfig(
57
+ config: unknown,
58
+ pin: string,
59
+ ): MergeResult<Record<string, unknown>> {
60
+ const base = isRecord(config) ? { ...config } : {};
61
+ const changed: string[] = [];
62
+
63
+ const plugins = mergeOpenCodePlugins(base.plugin, pin);
64
+ if (plugins.changed.length > 0) {
65
+ base.plugin = plugins.config;
66
+ changed.push("plugin");
67
+ }
68
+
69
+ // Drop share skills.paths — native ~/.config/opencode/skills links avoid
70
+ // triple-load duplicates. Matched by exact path segment, never substring: an
71
+ // unrelated dir like `~/projects/my-workflow-toolkit-skills` is preserved (D3).
72
+ const skills = base.skills;
73
+ if (isRecord(skills) && Array.isArray(skills.paths)) {
74
+ const next = skills.paths.filter((p) => {
75
+ const segment = String(p).split(/[\\/]/);
76
+ return !segment.some((seg) => named(seg, "workflow-toolkit"));
77
+ });
78
+ if (next.length !== skills.paths.length) {
79
+ base.skills = { ...skills, paths: next };
80
+ changed.push("skills.paths");
81
+ }
82
+ }
83
+
84
+ return { config: base, changed };
85
+ }
86
+
87
+ /** Collapse current + legacy Cursor plugin identities to a single one. */
88
+ export function mergeCursorEnabledPlugins(enabled: unknown): MergeResult<Record<string, boolean>> {
89
+ const prev = isRecord(enabled) ? { ...(enabled as Record<string, boolean>) } : {};
90
+ const next: Record<string, boolean> = { ...prev, "workflow-toolkit": true };
91
+ delete next["local/workflow-toolkit"]; // legacy duplicate identity
92
+ const changed = JSON.stringify(next) !== JSON.stringify(prev) ? ["enabled_plugins"] : [];
93
+ return { config: next, changed };
94
+ }
95
+
96
+ /** Append the plugin dir once, preserving any existing plugin directories. */
97
+ export function mergeCursorPluginDirs(
98
+ pluginDirs: unknown,
99
+ pluginDir: string,
100
+ ): MergeResult<string[]> {
101
+ // path.join does not strip a single trailing separator; do it explicitly for
102
+ // the dedup comparison (guarding the filesystem-root case).
103
+ const strip = (p: string) => {
104
+ const j = path.join(p);
105
+ return path.dirname(j) === j ? j : j.replace(/[\\/]+$/, "");
106
+ };
107
+ const normalized = strip(pluginDir);
108
+ const prev = Array.isArray(pluginDirs) ? pluginDirs.map(String) : [];
109
+ // Normalize both sides for comparison so a trailing-slash variant of an
110
+ // existing entry is not appended as a duplicate; existing entries are kept
111
+ // verbatim.
112
+ const exists = prev.some((d) => strip(d) === normalized);
113
+ const next = exists ? prev : [...prev, normalized];
114
+ const changed = next.length !== prev.length ? ["plugin_dirs"] : [];
115
+ return { config: next, changed };
116
+ }
117
+
118
+ /** Merge Cursor settings: one plugin identity, dirs appended, unrelated keys kept. */
119
+ export function mergeCursorSettings(
120
+ settings: unknown,
121
+ pluginDir: string,
122
+ ): MergeResult<Record<string, unknown>> {
123
+ const base = isRecord(settings) ? { ...settings } : {};
124
+ const changed: string[] = [];
125
+
126
+ const enabled = mergeCursorEnabledPlugins(base.enabled_plugins);
127
+ if (enabled.changed.length > 0) {
128
+ base.enabled_plugins = enabled.config;
129
+ changed.push("enabled_plugins");
130
+ }
131
+
132
+ const dirs = mergeCursorPluginDirs(base.plugin_dirs, pluginDir);
133
+ if (dirs.changed.length > 0) {
134
+ base.plugin_dirs = dirs.config;
135
+ changed.push("plugin_dirs");
136
+ }
137
+
138
+ return { config: base, changed };
139
+ }
140
+
141
+ /** Set one portable workit MCP server, dropping the legacy server name. */
142
+ export function mergeCursorMcp(
143
+ mcp: unknown,
144
+ serverName: string,
145
+ server: Record<string, unknown>,
146
+ ): MergeResult<Record<string, unknown>> {
147
+ const base = isRecord(mcp) ? { ...mcp } : {};
148
+ const servers = isRecord(base.mcpServers)
149
+ ? { ...(base.mcpServers as Record<string, unknown>) }
150
+ : {};
151
+ delete servers["workflow-toolkit"]; // legacy duplicate registration
152
+ servers[serverName] = server;
153
+ const changed = JSON.stringify(servers) !== JSON.stringify(base.mcpServers) ? ["mcpServers"] : [];
154
+ base.mcpServers = servers;
155
+ return { config: base, changed };
156
+ }
157
+
158
+ /** Swap the sessionStart hook command, preserving other hooks and fields. */
159
+ export function mergeCursorHooks(
160
+ hooks: unknown,
161
+ sessionStartEntry: Record<string, unknown>,
162
+ ): MergeResult<Record<string, unknown>> {
163
+ const base: Record<string, unknown> = isRecord(hooks) ? { ...hooks } : { version: 1 };
164
+ const hooksMap = isRecord(base.hooks) ? { ...(base.hooks as Record<string, unknown>) } : {};
165
+ const list = Array.isArray(hooksMap.sessionStart) ? hooksMap.sessionStart : [];
166
+ const same =
167
+ list.length === 1 &&
168
+ isRecord(list[0]) &&
169
+ JSON.stringify(list[0]) === JSON.stringify(sessionStartEntry);
170
+ if (same) return { config: base, changed: [] };
171
+ hooksMap.sessionStart = [sessionStartEntry];
172
+ base.hooks = hooksMap;
173
+ return { config: base, changed: ["hooks.sessionStart"] };
174
+ }
175
+
176
+ /**
177
+ * Portable Cursor MCP server entry for an installed plugin dir: prefer the
178
+ * self-contained node dist bundle (PT-10); fall back to the bash shim for a
179
+ * dist-less dev checkout.
180
+ */
181
+ export function cursorMcpServerEntry(packageDir: string): {
182
+ command: string;
183
+ args: string[];
184
+ } {
185
+ if (existsSync(path.join(packageDir, "dist", "mcp-server.js"))) {
186
+ return {
187
+ command: "node",
188
+ args: [path.join(packageDir, "dist", "mcp-server.js"), "${workspaceFolder}"],
189
+ };
190
+ }
191
+ return {
192
+ command: "bash",
193
+ args: [path.join(packageDir, "mcp", "run-server.sh"), "${workspaceFolder}"],
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Portable Cursor sessionStart hook entry: prefer the self-contained node dist
199
+ * bundle; fall back to the bash shim for a dist-less dev checkout (AR-06).
200
+ */
201
+ export function cursorHooksEntry(packageDir: string): {
202
+ command: string;
203
+ args: string[];
204
+ } {
205
+ if (existsSync(path.join(packageDir, "dist", "cursor-session-start.js"))) {
206
+ return {
207
+ command: "node",
208
+ args: [path.join(packageDir, "dist", "cursor-session-start.js")],
209
+ };
210
+ }
211
+ return {
212
+ command: "bash",
213
+ args: [path.join(packageDir, "hooks", "session-start")],
214
+ };
215
+ }
@@ -57,8 +57,7 @@ Skill: receiving-code-review. Verify before implementing — evaluate review fee
57
57
  export const shouldInjectVerification = (currentText: string): boolean =>
58
58
  !currentText.includes(VERIFICATION_TEXT);
59
59
 
60
- export const shouldInjectTdd = (currentText: string): boolean =>
61
- !currentText.includes(TDD_TEXT);
60
+ export const shouldInjectTdd = (currentText: string): boolean => !currentText.includes(TDD_TEXT);
62
61
 
63
62
  export const shouldInjectBrainstorm = (currentText: string): boolean =>
64
63
  !currentText.includes(BRAINSTORM_TEXT);