@brainervirus/workit-core 0.6.1 → 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 (81) hide show
  1. package/package.json +3 -7
  2. package/scripts/doctor-check.ts +20 -0
  3. package/scripts/install-cursor-plugin.sh +51 -28
  4. package/scripts/install-opencode-plugin.sh +19 -21
  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 +30 -16
  13. package/src/core/config.ts +193 -31
  14. package/src/core/docs-layout.ts +251 -0
  15. package/src/core/docs-migration.ts +639 -0
  16. package/src/core/docs-repo.ts +11 -9
  17. package/src/core/docs-validate.ts +18 -6
  18. package/src/core/doctor.ts +801 -0
  19. package/src/core/flow-state.ts +1579 -141
  20. package/src/core/git.ts +22 -5
  21. package/src/{tools/handoff.ts → core/handoff-tools.ts} +5 -57
  22. package/src/core/hygiene.ts +26 -12
  23. package/src/core/init.ts +43 -11
  24. package/src/core/logger.ts +321 -0
  25. package/src/core/package-root.ts +28 -0
  26. package/src/core/ports/init-toolkit-status.ts +1 -1
  27. package/src/core/ports/vcs-verify-token.ts +1 -1
  28. package/src/core/ports/youtrack-api.ts +1 -1
  29. package/src/core/ports/youtrack-verify-token.ts +1 -1
  30. package/src/core/pr-create.ts +116 -21
  31. package/src/core/registration.ts +215 -0
  32. package/src/core/repo-context.ts +447 -0
  33. package/src/core/repo-tools.ts +23 -0
  34. package/src/core/safe-write.ts +22 -0
  35. package/src/core/scripts.ts +3 -44
  36. package/src/core/sdd.ts +45 -28
  37. package/src/core/setup-state.ts +54 -0
  38. package/src/core/setup.ts +1216 -0
  39. package/src/core/skill-manifests.ts +95 -0
  40. package/src/core/support-matrix.ts +12 -0
  41. package/src/core/sync-runtime.ts +348 -0
  42. package/src/core/templates.ts +2 -2
  43. package/src/core/vcs-config.ts +107 -37
  44. package/src/core/verify-project.ts +181 -0
  45. package/src/core/workspaces.ts +136 -17
  46. package/src/core/youtrack-tools.ts +228 -0
  47. package/src/core/youtrack.ts +125 -67
  48. package/templates/execution-contract.md +9 -7
  49. package/templates/superpowers-doc-contract.md +4 -3
  50. package/scripts/_shared/common.sh +0 -173
  51. package/scripts/changelog-context.sh +0 -42
  52. package/scripts/docs-refresh-context.sh +0 -40
  53. package/scripts/init/apply.sh +0 -5
  54. package/scripts/init/status.sh +0 -5
  55. package/scripts/init/toolkit-status.sh +0 -5
  56. package/scripts/pr-create.sh +0 -5
  57. package/scripts/pr-ready-context.sh +0 -88
  58. package/scripts/present/ascii-wireframe.sh +0 -5
  59. package/scripts/present/flow-diagram.sh +0 -5
  60. package/scripts/release-notes-context.sh +0 -40
  61. package/scripts/vcs/config.sh +0 -5
  62. package/scripts/vcs/merged-style.sh +0 -5
  63. package/scripts/vcs/token-create-urls.sh +0 -5
  64. package/scripts/vcs/verify-token.sh +0 -5
  65. package/scripts/verify-project.sh +0 -140
  66. package/scripts/youtrack/api.sh +0 -5
  67. package/scripts/youtrack/config.sh +0 -5
  68. package/scripts/youtrack/greeting.sh +0 -5
  69. package/scripts/youtrack/parse-duration.sh +0 -5
  70. package/scripts/youtrack/token-create-url.sh +0 -5
  71. package/scripts/youtrack/verify-token.sh +0 -5
  72. package/scripts/youtrack/work-date-ms.sh +0 -5
  73. package/src/tools/docs-repo.ts +0 -51
  74. package/src/tools/flow.ts +0 -99
  75. package/src/tools/index.ts +0 -22
  76. package/src/tools/present.ts +0 -49
  77. package/src/tools/repo.ts +0 -490
  78. package/src/tools/rules.ts +0 -30
  79. package/src/tools/sdd.ts +0 -216
  80. package/src/tools/templates.ts +0 -27
  81. package/src/tools/youtrack.ts +0 -423
@@ -0,0 +1,321 @@
1
+ import { appendFileSync, mkdirSync, readdirSync, unlinkSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ // Secret-safe structured logger (DG-01-DG-03, DG-05, DG-10). Host-neutral:
6
+ // node: builtins only. Never logs prompts, messages, content, raw tool
7
+ // arguments/results, credentials, tokens, authorization headers, issue data,
8
+ // URL queries, home prefixes, or unbounded stacks. Every record is bounded,
9
+ // redacted, rate-limited JSONL under a daily filename.
10
+
11
+ export type JsonValue =
12
+ | null
13
+ | boolean
14
+ | number
15
+ | string
16
+ | JsonValue[]
17
+ | { [key: string]: JsonValue };
18
+
19
+ export type LogLevel = "debug" | "info" | "warn" | "error";
20
+
21
+ export type LogEvent = {
22
+ level: LogLevel;
23
+ time: string;
24
+ message: string;
25
+ context: Record<string, JsonValue>;
26
+ };
27
+
28
+ export type LogSink = (event: LogEvent) => void;
29
+
30
+ export type LoggerOptions = {
31
+ stateDir?: string;
32
+ now?: () => Date;
33
+ appLog?: LogSink;
34
+ stderr?: LogSink;
35
+ maxRate?: number;
36
+ rateWindowMs?: number;
37
+ maxFieldLength?: number;
38
+ maxStackLines?: number;
39
+ };
40
+
41
+ export type Logger = {
42
+ debug: (message: string, context?: Record<string, unknown>) => void;
43
+ info: (message: string, context?: Record<string, unknown>) => void;
44
+ warn: (message: string, context?: Record<string, unknown>) => void;
45
+ error: (message: string, context?: Record<string, unknown>) => void;
46
+ guard: <T>(name: string, fn: () => T) => T | undefined;
47
+ };
48
+
49
+ export const REDACTED = "[REDACTED]";
50
+
51
+ const DEFAULT_MAX_RATE = 20;
52
+ const DEFAULT_RATE_WINDOW_MS = 1000;
53
+ const DEFAULT_MAX_FIELD_LENGTH = 200;
54
+ const DEFAULT_MAX_STACK_LINES = 30;
55
+
56
+ const DAY_FILE = /^workit-\d{4}-\d{2}-\d{2}\.jsonl$/;
57
+ const RETAINED_DAYS = 7;
58
+
59
+ // Home prefixes, URL queries, and inline secret values are redacted inside any
60
+ // string. Key names drive the rest: a field whose name contains a secret or
61
+ // content word is fully replaced, a stack/trace field is line-bounded.
62
+ const SECRET_VALUE = /\b(?:Bearer|Basic|Digest|Token)\s+\S+/gi;
63
+ // Case-insensitive: `Authorization: abc`, `Api-Token=xyz`, `Bearer`-style
64
+ // headers are redacted regardless of casing (D2). The alternation is the same
65
+ // secret vocabulary as SENSITIVE_WORDS, so the over-redaction risk is limited
66
+ // to values next to a secret-ish key — acceptable for a security logger.
67
+ const KEY_EQ_VALUE =
68
+ /\b([A-Za-z0-9_-]*(?:token|secret|password|passwd|apikey|api[_-]?key|authorization|credential|bearer)[A-Za-z0-9_-]*)([:=]\s*).+/gi;
69
+ const URL_QUERY = /(https?:\/\/[^?#\s]+)\?[^#\s]*/g;
70
+
71
+ // Split camelCase AND acronym+word compounds (APIToken -> api|token, so the
72
+ // "token" word lands in SENSITIVE_WORDS instead of the whole lowercase
73
+ // compound "apitoken" leaking past it) (D1).
74
+ const splitKey = (key: string): string[] =>
75
+ key
76
+ .split(/[_-]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/)
77
+ .map((word) => word.toLowerCase());
78
+
79
+ const SENSITIVE_WORDS = new Set([
80
+ "token",
81
+ "secret",
82
+ "password",
83
+ "passwd",
84
+ "authorization",
85
+ "credential",
86
+ "credentials",
87
+ "cookie",
88
+ "api",
89
+ "apikey",
90
+ "apitoken",
91
+ "accesstoken",
92
+ "clientsecret",
93
+ "refreshtoken",
94
+ "key",
95
+ "bearer",
96
+ "prompt",
97
+ "message",
98
+ "messages",
99
+ "content",
100
+ "body",
101
+ "args",
102
+ "argument",
103
+ "result",
104
+ "results",
105
+ "output",
106
+ "issue",
107
+ "description",
108
+ "summary",
109
+ "payload",
110
+ "script",
111
+ "command",
112
+ "text",
113
+ "query",
114
+ ]);
115
+
116
+ const STACK_WORDS = new Set(["stack", "stacktrace", "trace"]);
117
+
118
+ const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
119
+
120
+ export const resolveStateDir = (): string => {
121
+ const override = process.env.WORKFLOW_TOOLKIT_STATE;
122
+ if (override) return override;
123
+ const home = os.homedir();
124
+ if (process.env.XDG_STATE_HOME) return path.join(process.env.XDG_STATE_HOME, "workit");
125
+ switch (os.platform()) {
126
+ case "darwin":
127
+ return path.join(home, "Library", "Application Support", "workit");
128
+ case "win32":
129
+ return path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "workit");
130
+ default:
131
+ return path.join(process.env.HOME ?? home, ".local", "state", "workit");
132
+ }
133
+ };
134
+
135
+ type RedactOptions = {
136
+ maxFieldLength: number;
137
+ maxStackLines: number;
138
+ };
139
+
140
+ // The separator lookahead accepts both / and \ so home-relative Windows paths
141
+ // (C:\Users\x\repo) redact too — os.homedir() uses backslashes there.
142
+ const homePattern = new RegExp(
143
+ `${escapeRegExp(os.homedir())}(?=[/\\\\]|$)|\\$HOME(?=[/\\\\]|$)`,
144
+ "g",
145
+ );
146
+
147
+ const applyPatterns = (value: string): string => {
148
+ let out = value;
149
+ out = out.replace(SECRET_VALUE, REDACTED);
150
+ out = out.replace(KEY_EQ_VALUE, "$1$2[REDACTED]");
151
+ out = out.replace(URL_QUERY, "$1?[REDACTED]");
152
+ out = out.replace(homePattern, "~");
153
+ // win32 paths keep backslashes after the ~ substitution (~\repo); normalize
154
+ // separators so logged paths are portable and match the shell-shaped ~/
155
+ // form. Other platforms are untouched.
156
+ if (process.platform === "win32") out = out.replace(/\\/g, "/");
157
+ return out;
158
+ };
159
+
160
+ /**
161
+ * Pattern-only redaction: strips secret value patterns from a string while
162
+ * preserving its full text — no field-length truncation. The receiver owns its
163
+ * own size bound (e.g. an MCP error string must stay intact for the client).
164
+ */
165
+ export const redactSecrets = (value: string): string => applyPatterns(value);
166
+
167
+ const redactString = (value: string, options: RedactOptions): string => {
168
+ const out = applyPatterns(value);
169
+ if (out.length > options.maxFieldLength) {
170
+ return `${out.slice(0, options.maxFieldLength)}…`;
171
+ }
172
+ return out;
173
+ };
174
+
175
+ const boundStack = (value: unknown, options: RedactOptions): JsonValue => {
176
+ if (typeof value !== "string") return redact(value, options);
177
+ const lines = value.split("\n");
178
+ const kept = lines.slice(0, options.maxStackLines).map(applyPatterns);
179
+ const dropped = lines.length - kept.length;
180
+ const body = dropped > 0 ? [...kept, ` ... ${dropped} more`] : kept;
181
+ return body.join("\n");
182
+ };
183
+
184
+ export const redact = (value: unknown, options: RedactOptions = DEFAULT_OPTIONS): JsonValue => {
185
+ if (value === null || value === undefined) return null;
186
+ if (typeof value === "boolean" || typeof value === "number") return value;
187
+ if (typeof value === "string") return redactString(value, options);
188
+ if (value instanceof Date) return value.toISOString();
189
+ if (Array.isArray(value)) return value.map((entry) => redact(entry, options));
190
+ if (typeof value === "object") {
191
+ const out: Record<string, JsonValue> = {};
192
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
193
+ const words = splitKey(key);
194
+ if (words.some((word) => STACK_WORDS.has(word))) out[key] = boundStack(entry, options);
195
+ else if (words.some((word) => SENSITIVE_WORDS.has(word))) out[key] = REDACTED;
196
+ else out[key] = redact(entry, options);
197
+ }
198
+ return out;
199
+ }
200
+ return null;
201
+ };
202
+
203
+ const DEFAULT_OPTIONS: RedactOptions = {
204
+ maxFieldLength: DEFAULT_MAX_FIELD_LENGTH,
205
+ maxStackLines: DEFAULT_MAX_STACK_LINES,
206
+ };
207
+
208
+ const dailyFileName = (date: Date): string => {
209
+ const y = date.getFullYear();
210
+ const m = String(date.getMonth() + 1).padStart(2, "0");
211
+ const d = String(date.getDate()).padStart(2, "0");
212
+ return `workit-${y}-${m}-${d}.jsonl`;
213
+ };
214
+
215
+ const pruneOldFiles = (dir: string): void => {
216
+ try {
217
+ const files = readdirSync(dir)
218
+ .filter((name) => DAY_FILE.test(name))
219
+ .sort()
220
+ .reverse();
221
+ for (const file of files.slice(RETAINED_DAYS)) {
222
+ try {
223
+ unlinkSync(path.join(dir, file));
224
+ } catch {
225
+ // a concurrent prune may already have removed it
226
+ }
227
+ }
228
+ } catch {
229
+ // logging must never break the host
230
+ }
231
+ };
232
+
233
+ export const createLogger = (options: LoggerOptions = {}): Logger => {
234
+ const stateDir = options.stateDir ?? resolveStateDir();
235
+ const logDir = path.join(stateDir, "logs");
236
+ const now = options.now ?? (() => new Date());
237
+ const maxRate = options.maxRate ?? DEFAULT_MAX_RATE;
238
+ const rateWindowMs = options.rateWindowMs ?? DEFAULT_RATE_WINDOW_MS;
239
+ const redactOptions: RedactOptions = {
240
+ maxFieldLength: options.maxFieldLength ?? DEFAULT_MAX_FIELD_LENGTH,
241
+ maxStackLines: options.maxStackLines ?? DEFAULT_MAX_STACK_LINES,
242
+ };
243
+
244
+ let windowStart = 0;
245
+ const windowCounts: Record<LogLevel, number> = { debug: 0, info: 0, warn: 0, error: 0 };
246
+
247
+ const emit = (level: LogLevel, message: string, context?: Record<string, unknown>): void => {
248
+ const date = now();
249
+ const elapsed = date.getTime() - windowStart;
250
+ if (elapsed >= rateWindowMs || windowStart === 0) {
251
+ windowStart = date.getTime();
252
+ windowCounts.debug = 0;
253
+ windowCounts.info = 0;
254
+ windowCounts.warn = 0;
255
+ windowCounts.error = 0;
256
+ }
257
+ windowCounts[level] += 1;
258
+ if (windowCounts[level] > maxRate) return;
259
+
260
+ // ponytail: per-level budget so an info flood can't starve warn/error
261
+ // canaries; shared-token budget would need cross-level prioritization.
262
+ let event: LogEvent | null = null;
263
+ try {
264
+ event = {
265
+ level,
266
+ time: date.toISOString(),
267
+ message: redactString(message, redactOptions),
268
+ context: redact(context ?? {}, redactOptions) as Record<string, JsonValue>,
269
+ };
270
+ const line = `${JSON.stringify(event)}\n`;
271
+ mkdirSync(logDir, { recursive: true, mode: 0o700 });
272
+ appendFileSync(path.join(logDir, dailyFileName(date)), line, { mode: 0o600 });
273
+ pruneOldFiles(logDir);
274
+ } catch {
275
+ // Serialization (e.g. a circular context) must never throw into the
276
+ // caller: record a bounded failure event instead.
277
+ try {
278
+ event = {
279
+ level,
280
+ time: date.toISOString(),
281
+ message: redactString(message, redactOptions),
282
+ context: { redaction_failed: true },
283
+ };
284
+ mkdirSync(logDir, { recursive: true, mode: 0o700 });
285
+ appendFileSync(path.join(logDir, dailyFileName(date)), `${JSON.stringify(event)}\n`, {
286
+ mode: 0o600,
287
+ });
288
+ } catch {
289
+ // logging must never break the host
290
+ }
291
+ }
292
+
293
+ for (const sink of [options.appLog, options.stderr]) {
294
+ if (!sink) continue;
295
+ if (!event) continue;
296
+ try {
297
+ sink(event);
298
+ } catch {
299
+ // a broken sink must not break the caller
300
+ }
301
+ }
302
+ };
303
+
304
+ return {
305
+ debug: (message, context) => emit("debug", message, context),
306
+ info: (message, context) => emit("info", message, context),
307
+ warn: (message, context) => emit("warn", message, context),
308
+ error: (message, context) => emit("error", message, context),
309
+ guard: <T>(name: string, fn: () => T): T | undefined => {
310
+ try {
311
+ return fn();
312
+ } catch (err) {
313
+ emit("warn", "detector_failed", {
314
+ detector: name,
315
+ error: err instanceof Error ? err.message : String(err),
316
+ });
317
+ return undefined;
318
+ }
319
+ },
320
+ };
321
+ };
@@ -0,0 +1,28 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ // Package-local asset root. Core modules resolve their static assets relative to
6
+ // their own package root (templates/, commands/, skills/, hygiene/). In the
7
+ // monorepo the source package keeps assets at the package root; packaged
8
+ // adapters ship the same content under an explicit `assets/` directory.
9
+ //
10
+ // The walk finds the nearest ancestor directory that owns a package.json, which
11
+ // is the package root for both the monorepo source (packages/<pkg>/src/core/…)
12
+ // and a bundled adapter entry (packages/<pkg>/dist/….js).
13
+ export const packageRoot = (): string => {
14
+ let dir = path.dirname(fileURLToPath(import.meta.url));
15
+ while (true) {
16
+ const parent = path.dirname(dir);
17
+ // ponytail: stop at the filesystem root even without an ancestor package.json,
18
+ // so the upward walk can never loop forever.
19
+ if (existsSync(path.join(dir, "package.json")) || parent === dir) return dir;
20
+ dir = parent;
21
+ }
22
+ };
23
+
24
+ export const assetRoot = (): string => {
25
+ const root = packageRoot();
26
+ const assets = path.join(root, "assets");
27
+ return existsSync(assets) ? assets : root;
28
+ };
@@ -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()));
@@ -11,7 +11,7 @@ if (cmd === "log-time" || cmd === "post-comment") {
11
11
  process.exit(1);
12
12
  }
13
13
  }
14
- const out = youTrackApi(args, process.env.WORKFLOW_YT_WRITE ?? "");
14
+ const out = await youTrackApi(args, process.env.WORKFLOW_YT_WRITE ?? "");
15
15
  if ("error" in out) {
16
16
  console.log(JSON.stringify({ ok: false, error: out.error }));
17
17
  process.exit(1);
@@ -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") {
@@ -66,14 +82,19 @@ const truthy = (v: string | undefined): boolean =>
66
82
 
67
83
  // Port of python's shutil.which — scan PATH in-process (no `which` binary needed).
68
84
  function whichOnPath(tool: string): string | null {
69
- 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)) {
70
89
  if (!dir) continue;
71
- const candidate = path.join(dir, tool);
72
- try {
73
- fs.accessSync(candidate, fs.constants.X_OK);
74
- return candidate;
75
- } catch {
76
- /* 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
+ }
77
98
  }
78
99
  }
79
100
  return null;
@@ -108,14 +129,93 @@ export function prBuildBody(env: NodeJS.ProcessEnv, cwd?: string): string {
108
129
  /** Port of scripts/pr-create.sh create mode — glab/gh MR/PR creation. */
109
130
  export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, any> {
110
131
  const root = process.env.WORKFLOW_WORKSPACE_ROOT ?? repoRoot(cwd);
132
+ const policy = resolveBranchPolicyFor(root);
111
133
  const cfg = vcsConfig("load", root);
112
134
  if (!cfg.ok) return { error: cfg.error ?? "vcs config missing" };
113
- if (!cfg.tokenReady)
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)
114
138
  return { error: "VCS token not ready — run /wk-init and edit token file locally" };
115
139
 
116
140
  const provider = cfg.provider as string;
117
141
  if (provider !== "gitlab" && provider !== "github")
118
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
+
119
219
  const cli = provider === "gitlab" ? "glab" : "gh";
120
220
  const installUrl =
121
221
  provider === "gitlab" ? "https://gitlab.com/gitlab-org/cli" : "https://cli.github.com";
@@ -130,16 +230,8 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
130
230
 
131
231
  const pr = (cfg.pr ?? {}) as Record<string, any>;
132
232
  const token = fs.readFileSync(cfg.tokenPath as string, "utf8").trim();
133
- const title = String(env.WF_PR_TITLE ?? "");
134
233
  const body = env.WF_PR_BODY ?? "";
135
234
  const draft = String(env.WF_PR_DRAFT ?? "false").toLowerCase() === "true";
136
- const target = env.WF_PR_TARGET || String(cfg.defaultTargetBranch ?? "develop");
137
-
138
- const br = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
139
- cwd: root,
140
- encoding: "utf8",
141
- });
142
- const branch = br.status === 0 ? (br.stdout ?? "").trim() : "";
143
235
 
144
236
  let baseUrl = cfg.youtrack_base_url as string | undefined;
145
237
  if (!baseUrl) {
@@ -186,12 +278,15 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
186
278
  if (draft) cmd.push("--draft");
187
279
  if (push) cmd.push("--push");
188
280
  if (skipConfirm) cmd.push("--yes");
189
- 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 };
190
285
  } else {
191
286
  cmd = ["gh", "pr", "create", "--title", title, "--base", target];
192
287
  if (finalBody) cmd.push("--body", finalBody);
193
288
  if (draft) cmd.push("--draft");
194
- cmdEnv = { ...process.env, GH_TOKEN: token };
289
+ cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GH_TOKEN: token };
195
290
  }
196
291
 
197
292
  const result = spawnSync(cmd[0], cmd.slice(1), { cwd: root, encoding: "utf8", env: cmdEnv });