@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
@@ -1,9 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { spawnSync } from "node:child_process";
4
3
  import { readTemplate } from "./templates";
5
4
  import { resolveWorkspaceRoot } from "./scripts";
6
- import { configDir } from "./config";
5
+ import { configDir, isConfigObject } from "./config";
7
6
 
8
7
  const ISSUE_RE = /^[A-Z]+-\d+$/;
9
8
  const TOKEN_PLACEHOLDER = "YOUR_TOKEN_HERE";
@@ -11,8 +10,7 @@ const TOKEN_PLACEHOLDER = "YOUR_TOKEN_HERE";
11
10
  // Port of scripts/youtrack/config.sh chain: WORKFLOW_YOUTRACK_CONFIG ->
12
11
  // configDir() (XDG_CONFIG_HOME / HOME .config + workit).
13
12
  export const youTrackConfigPath = (): string =>
14
- process.env.WORKFLOW_YOUTRACK_CONFIG ??
15
- path.join(configDir(), "youtrack.json");
13
+ process.env.WORKFLOW_YOUTRACK_CONFIG ?? path.join(configDir(), "youtrack.json");
16
14
 
17
15
  const youTrackTokenModeOk = (p: string): boolean => {
18
16
  if (process.platform === "win32") return true;
@@ -20,27 +18,51 @@ const youTrackTokenModeOk = (p: string): boolean => {
20
18
  return mode === 0o600;
21
19
  };
22
20
 
23
- function readYouTrackConfig(required: boolean): { config: Record<string, any>; path: string } | { error: string } {
21
+ // AR-07/CA-37: the shared shape rule applies here too a parseable non-object
22
+ // youtrack.json (null, scalar, array) is malformed with the exact path, never
23
+ // missing/unconfigured, never silently defaulted.
24
+ function readYouTrackConfig(
25
+ required: boolean,
26
+ ): { config: Record<string, any>; path: string } | { error: string; path: string } {
24
27
  const cfgPath = youTrackConfigPath();
25
28
  if (!fs.existsSync(cfgPath)) {
26
- return required ? { error: "ERROR: missing youtrack.json" } : { config: {}, path: cfgPath };
29
+ return required
30
+ ? { error: "ERROR: missing youtrack.json", path: cfgPath }
31
+ : { config: {}, path: cfgPath };
27
32
  }
33
+ let parsed: unknown;
28
34
  try {
29
- const config = JSON.parse(fs.readFileSync(cfgPath, "utf8")) as Record<string, any>;
30
- return { config, path: cfgPath };
35
+ parsed = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
31
36
  } catch {
32
- return { error: "invalid youtrack.json" };
37
+ return { error: `${cfgPath} is not valid JSON`, path: cfgPath };
33
38
  }
39
+ if (!isConfigObject(parsed)) {
40
+ return { error: `${cfgPath} is not a JSON object`, path: cfgPath };
41
+ }
42
+ return { config: parsed as Record<string, any>, path: cfgPath };
34
43
  }
35
44
 
45
+ // RL-01: typed load result — malformed carries {ok:false, error, configPath}
46
+ // mirroring vcsConfig, so risky consumers stop on the exact path.
47
+ export type YouTrackConfigResult =
48
+ | { data: Record<string, any> }
49
+ | { error: string }
50
+ | { ok: false; error: string; configPath: string };
51
+
36
52
  /** Load + redact youtrack.json; validates the token file like youtrack/config.sh load. */
37
- export function youTrackConfigLoad(): { data: Record<string, any> } | { error: string } {
53
+ export function youTrackConfigLoad(): YouTrackConfigResult {
38
54
  const loaded = readYouTrackConfig(true);
39
- if ("error" in loaded) return loaded;
55
+ if ("error" in loaded) {
56
+ // RL-01: malformed (parse failure or non-object) fails closed with the exact
57
+ // path, mirroring vcsConfig's {ok:false,error,configPath} shape.
58
+ return { ok: false, error: loaded.error, configPath: loaded.path };
59
+ }
40
60
  const cfgPath = loaded.path;
41
61
  const tokenFile = String(loaded.config.tokenFile ?? "");
42
62
  const tokenPath = tokenFile
43
- ? (path.isAbsolute(tokenFile) ? path.resolve(tokenFile) : path.resolve(process.cwd(), tokenFile))
63
+ ? path.isAbsolute(tokenFile)
64
+ ? path.resolve(tokenFile)
65
+ : path.resolve(process.cwd(), tokenFile)
44
66
  : "";
45
67
  if (!tokenPath || !fs.existsSync(tokenPath)) {
46
68
  return { error: "missing youtrack.token" };
@@ -60,20 +82,47 @@ export function youTrackConfigLoad(): { data: Record<string, any> } | { error: s
60
82
  return { data: redacted };
61
83
  }
62
84
 
63
- function tzParts(date: Date, tz: string): { y: string; m: string; d: string; hour: string; minute: string } {
85
+ function tzParts(
86
+ date: Date,
87
+ tz: string,
88
+ ): { y: string; m: string; d: string; hour: string; minute: string } {
64
89
  const parts = new Intl.DateTimeFormat("en-GB", {
65
- timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit",
66
- hour: "2-digit", minute: "2-digit", hourCycle: "h23",
90
+ timeZone: tz,
91
+ year: "numeric",
92
+ month: "2-digit",
93
+ day: "2-digit",
94
+ hour: "2-digit",
95
+ minute: "2-digit",
96
+ hourCycle: "h23",
67
97
  }).formatToParts(date);
68
98
  const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
69
99
  return { y: map.year, m: map.month, d: map.day, hour: map.hour, minute: map.minute };
70
100
  }
71
101
 
72
102
  /** Port of scripts/youtrack/greeting.sh. */
73
- export function youTrackGreeting(configOverride?: string): { stdout: string; exitCode: number; stderr: string } {
103
+ export function youTrackGreeting(configOverride?: string): {
104
+ stdout: string;
105
+ exitCode: number;
106
+ stderr: string;
107
+ } {
74
108
  const cfgPath = configOverride ?? youTrackConfigPath();
109
+ let parsed: unknown;
110
+ try {
111
+ parsed = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
112
+ } catch {
113
+ return {
114
+ stdout: "",
115
+ exitCode: 1,
116
+ stderr: fs.existsSync(cfgPath)
117
+ ? `${cfgPath} is not valid JSON`
118
+ : `missing youtrack.json: ${cfgPath}`,
119
+ };
120
+ }
121
+ if (!isConfigObject(parsed)) {
122
+ return { stdout: "", exitCode: 1, stderr: `${cfgPath} is not a JSON object` };
123
+ }
124
+ const config = parsed as Record<string, any>;
75
125
  try {
76
- const config = JSON.parse(fs.readFileSync(cfgPath, "utf8")) as Record<string, any>;
77
126
  const tz = String(config.timezone ?? "America/Santiago");
78
127
  const now = new Date();
79
128
  const { y, m, d, hour, minute } = tzParts(now, tz);
@@ -81,20 +130,29 @@ export function youTrackGreeting(configOverride?: string): { stdout: string; exi
81
130
  const cutoffHour = Number(cutoff[0]);
82
131
  const cutoffMinute = Number(cutoff[1] ?? 0);
83
132
  const greetings = (config.greetings ?? {}) as Record<string, string>;
84
- const isMorning = Number(hour) < cutoffHour || (Number(hour) === cutoffHour && Number(minute) < cutoffMinute);
133
+ const isMorning =
134
+ Number(hour) < cutoffHour || (Number(hour) === cutoffHour && Number(minute) < cutoffMinute);
85
135
  const greeting = isMorning
86
- ? greetings.morning ?? "buenos días"
87
- : greetings.afternoon ?? "buenas tardes";
136
+ ? (greetings.morning ?? "buenos días")
137
+ : (greetings.afternoon ?? "buenas tardes");
88
138
  const mention = String(config.defaultMention ?? "Alejandra.Flores");
89
- void y; void m; void d;
139
+ void y;
140
+ void m;
141
+ void d;
90
142
  return { stdout: `@${mention} Hola, ${greeting}.\n`, exitCode: 0, stderr: "" };
91
143
  } catch (err) {
92
- return { stdout: "", exitCode: 1, stderr: err instanceof Error ? err.message : "greeting failed" };
144
+ return {
145
+ stdout: "",
146
+ exitCode: 1,
147
+ stderr: err instanceof Error ? err.message : "greeting failed",
148
+ };
93
149
  }
94
150
  }
95
151
 
96
152
  /** Port of scripts/youtrack/parse-duration.sh. */
97
- export function youTrackParseDuration(text: string): { data: { minutes: number; text: string } } | { error: string } {
153
+ export function youTrackParseDuration(
154
+ text: string,
155
+ ): { data: { minutes: number; text: string } } | { error: string } {
98
156
  const lower = String(text).toLowerCase().trim();
99
157
  let total = 0;
100
158
  for (const match of lower.matchAll(/(\d+)\s*h/g)) total += Number(match[1]) * 60;
@@ -105,13 +163,26 @@ export function youTrackParseDuration(text: string): { data: { minutes: number;
105
163
  }
106
164
 
107
165
  /** Port of scripts/youtrack/work-date-ms.sh — resolve work-item date as epoch ms. */
108
- export function youTrackWorkDateMs(dateRaw: string): { data: { dateMs: number; timezone: string; localDate: string } } | { error: string } {
166
+ export function youTrackWorkDateMs(
167
+ dateRaw: string,
168
+ ): { data: { dateMs: number; timezone: string; localDate: string } } | { error: string } {
109
169
  const cfgPath = youTrackConfigPath();
110
170
  let tz = "America/Santiago";
111
- try {
112
- const config = JSON.parse(fs.readFileSync(cfgPath, "utf8")) as Record<string, any>;
113
- tz = String(config.timezone ?? "America/Santiago");
114
- } catch { /* defaults */ }
171
+ // Missing file is a legitimate unconfigured state (reader: "missing" keeps
172
+ // defaults); a parseable non-object is malformed and must propagate the
173
+ // exact-path error instead of silently defaulting the timezone.
174
+ if (fs.existsSync(cfgPath)) {
175
+ let parsed: unknown;
176
+ try {
177
+ parsed = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
178
+ } catch {
179
+ return { error: `${cfgPath} is not valid JSON` };
180
+ }
181
+ if (!isConfigObject(parsed)) {
182
+ return { error: `${cfgPath} is not a JSON object` };
183
+ }
184
+ tz = String((parsed as Record<string, any>).timezone ?? "America/Santiago");
185
+ }
115
186
  const raw = dateRaw || "auto";
116
187
  try {
117
188
  if (raw === "auto" || !raw) {
@@ -128,7 +199,13 @@ export function youTrackWorkDateMs(dateRaw: string): { data: { dateMs: number; t
128
199
  const [y, m, d] = raw.split("-").map(Number);
129
200
  const iso = `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00`;
130
201
  const dateMs = Math.floor(Date.parse(iso) / 86400000) * 86400000;
131
- return { data: { dateMs, timezone: tz, localDate: `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}` } };
202
+ return {
203
+ data: {
204
+ dateMs,
205
+ timezone: tz,
206
+ localDate: `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`,
207
+ },
208
+ };
132
209
  } catch (err) {
133
210
  return { error: err instanceof Error ? err.message : "could not resolve date" };
134
211
  }
@@ -139,63 +216,106 @@ const youTrackToken = (): { token: string; base: string } | { error: string } =>
139
216
  if ("error" in loaded) return loaded;
140
217
  const tokenFile = String(loaded.config.tokenFile ?? "");
141
218
  const tokenPath = tokenFile
142
- ? (path.isAbsolute(tokenFile) ? path.resolve(tokenFile) : path.resolve(process.cwd(), tokenFile))
219
+ ? path.isAbsolute(tokenFile)
220
+ ? path.resolve(tokenFile)
221
+ : path.resolve(process.cwd(), tokenFile)
143
222
  : "";
144
223
  if (!tokenPath || !fs.existsSync(tokenPath)) return { error: "missing youtrack.token" };
145
224
  if (!youTrackTokenModeOk(tokenPath)) return { error: "youtrack.token mode must be 0600" };
146
225
  const token = fs.readFileSync(tokenPath, "utf8").trim();
147
226
  if (!token) return { error: "empty token file" };
148
227
  if (token === TOKEN_PLACEHOLDER || token.startsWith(TOKEN_PLACEHOLDER)) {
149
- return { error: "token file still has placeholder YOUR_TOKEN_HERE — edit the file locally, then run /wk-status" };
228
+ return {
229
+ error:
230
+ "token file still has placeholder YOUR_TOKEN_HERE — edit the file locally, then run /wk-status",
231
+ };
150
232
  }
151
233
  const base = String(loaded.config.baseUrl ?? "").replace(/\/+$/, "");
152
234
  if (!base) return { error: "baseUrl missing in config" };
153
235
  return { token, base };
154
236
  };
155
237
 
156
- function youTrackCurl(args: string[]): { status: number; stdout: string; stderr: string } {
157
- const result = spawnSync("curl", ["-fsS", ...args], { encoding: "utf8" });
158
- return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
238
+ // fetch replaces the previous curl -fsS request helper: check res.ok,
239
+ // surface HTTP errors without a token-bearing body, parse JSON on success.
240
+ async function youTrackRequest(
241
+ url: string,
242
+ init: { method: string; token: string; body?: unknown },
243
+ ): Promise<{ status: number; stdout: string; stderr: string }> {
244
+ const headers: Record<string, string> = {
245
+ Authorization: `Bearer ${init.token}`,
246
+ Accept: "application/json",
247
+ };
248
+ let body: string | undefined;
249
+ if (init.body !== undefined) {
250
+ headers["Content-Type"] = "application/json";
251
+ body = JSON.stringify(init.body);
252
+ }
253
+ try {
254
+ const res = await fetch(url, { method: init.method, headers, body });
255
+ const text = await res.text();
256
+ if (!res.ok) {
257
+ return { status: res.status, stdout: "", stderr: text.slice(0, 200) };
258
+ }
259
+ return { status: 0, stdout: text, stderr: "" };
260
+ } catch (err) {
261
+ return { status: 1, stdout: "", stderr: err instanceof Error ? err.message : "network error" };
262
+ }
159
263
  }
160
264
 
161
265
  /** Port of scripts/youtrack/api.sh — log-time / post-comment with the WORKFLOW_YT_WRITE guard. */
162
- export function youTrackApi(args: string[], writeFlag = process.env.WORKFLOW_YT_WRITE ?? ""): { data: Record<string, any> } | { error: string } {
266
+ export async function youTrackApi(
267
+ args: string[],
268
+ writeFlag = process.env.WORKFLOW_YT_WRITE ?? "",
269
+ ): Promise<{ data: Record<string, any> } | { error: string }> {
163
270
  const cmd = args[0];
164
271
  if (cmd === "log-time" || cmd === "post-comment") {
165
272
  if (writeFlag !== "1") {
166
- return { error: "YouTrack write operations require WORKFLOW_YT_WRITE=1 (refusing to mutate production)" };
273
+ return {
274
+ error:
275
+ "YouTrack write operations require WORKFLOW_YT_WRITE=1 (refusing to mutate production)",
276
+ };
167
277
  }
168
278
  }
169
279
  const creds = youTrackToken();
170
280
  if ("error" in creds) return creds;
171
281
  const { token, base } = creds;
172
- const auth = ["-H", `Authorization: Bearer ${token}`, "-H", "Accept: application/json"];
173
282
 
174
283
  if (cmd === "log-time") {
175
284
  const [issue, minutesRaw, text, dateArg] = args.slice(1);
176
285
  const minutes = Number(minutesRaw);
177
286
  const dateMs = youTrackWorkDateMs(dateArg ?? "auto");
178
287
  if ("error" in dateMs) return dateMs;
179
- const body = JSON.stringify({ duration: { minutes }, text, date: dateMs.data.dateMs });
180
- const out = youTrackCurl([
181
- ...auth, "-H", "Content-Type: application/json", "-d", body,
288
+ const out = await youTrackRequest(
182
289
  `${base}/api/issues/${issue}/timeTracking/workItems?fields=id,idReadable`,
183
- ]);
290
+ {
291
+ method: "POST",
292
+ token,
293
+ body: { duration: { minutes }, text, date: dateMs.data.dateMs },
294
+ },
295
+ );
184
296
  if (out.status !== 0) return { error: "YouTrack HTTP request failed" };
185
297
  try {
186
298
  const created = JSON.parse(out.stdout) as Record<string, any>;
187
- return { data: { ok: true, issueId: issue, workItemId: created.id, dateMs: dateMs.data.dateMs, minutes } };
299
+ return {
300
+ data: {
301
+ ok: true,
302
+ issueId: issue,
303
+ workItemId: created.id,
304
+ dateMs: dateMs.data.dateMs,
305
+ minutes,
306
+ },
307
+ };
188
308
  } catch {
189
309
  return { error: "invalid JSON from YouTrack API" };
190
310
  }
191
311
  }
192
312
  if (cmd === "post-comment") {
193
313
  const [issue, text] = args.slice(1);
194
- const body = JSON.stringify({ text });
195
- const out = youTrackCurl([
196
- ...auth, "-H", "Content-Type: application/json", "-d", body,
197
- `${base}/api/issues/${issue}/comments`,
198
- ]);
314
+ const out = await youTrackRequest(`${base}/api/issues/${issue}/comments`, {
315
+ method: "POST",
316
+ token,
317
+ body: { text },
318
+ });
199
319
  if (out.status !== 0) return { error: "YouTrack HTTP request failed" };
200
320
  return { data: { ok: true, issueId: issue } };
201
321
  }
@@ -203,18 +323,24 @@ export function youTrackApi(args: string[], writeFlag = process.env.WORKFLOW_YT_
203
323
  }
204
324
 
205
325
  /** Port of scripts/youtrack/verify-token.sh — read-only GET /api/users/me. */
206
- export function youTrackVerifyToken(): { data: Record<string, any> } | { error: string; http_status?: number; path?: string } {
326
+ export async function youTrackVerifyToken(): Promise<
327
+ { data: Record<string, any> } | { error: string; http_status?: number; path?: string }
328
+ > {
207
329
  const cfgPath = youTrackConfigPath();
208
330
  if (!fs.existsSync(cfgPath)) return { error: "missing youtrack.json" };
209
331
  const creds = youTrackToken();
210
332
  if ("error" in creds) return { error: creds.error };
211
333
  const { token, base } = creds;
212
334
 
213
- const me = youTrackCurl(["-H", `Authorization: Bearer ${token}`, "-H", "Accept: application/json",
214
- `${base}/api/users/me?fields=id,login,name,email`]);
335
+ const me = await youTrackRequest(`${base}/api/users/me?fields=id,login,name,email`, {
336
+ method: "GET",
337
+ token,
338
+ });
215
339
  if (me.status !== 0) {
216
- const body = me.stderr.trim() || me.stdout.trim();
217
- const err = me.status === 22 ? "authentication failed (401/403)" : `HTTP error: ${body.slice(0, 200)}`;
340
+ const err =
341
+ me.status === 401 || me.status === 403
342
+ ? "authentication failed (401/403)"
343
+ : `HTTP error: ${me.stderr.slice(0, 200)}`;
218
344
  return { error: err, http_status: me.status };
219
345
  }
220
346
  let user: Record<string, any>;
@@ -224,21 +350,30 @@ export function youTrackVerifyToken(): { data: Record<string, any> } | { error:
224
350
  return { error: "invalid JSON from YouTrack /api/users/me" };
225
351
  }
226
352
  const result: Record<string, any> = {
227
- ok: true, method: "GET /api/users/me", baseUrl: base,
228
- login: user.login, name: user.name, email: user.email, id: user.id,
353
+ ok: true,
354
+ method: "GET /api/users/me",
355
+ baseUrl: base,
356
+ login: user.login,
357
+ name: user.name,
358
+ email: user.email,
359
+ id: user.id,
229
360
  };
230
361
  const meeting = readYouTrackConfig(false);
231
362
  const meetingIssue = "config" in meeting ? meeting.config.meetingIssue : undefined;
232
363
  if (meetingIssue) {
233
- const issue = youTrackCurl(["-H", `Authorization: Bearer ${token}`, "-H", "Accept: application/json",
234
- `${base}/api/issues/${meetingIssue}?fields=id,idReadable,summary`]);
364
+ const issue = await youTrackRequest(
365
+ `${base}/api/issues/${meetingIssue}?fields=id,idReadable,summary`,
366
+ { method: "GET", token },
367
+ );
235
368
  if (issue.status === 0) {
236
369
  try {
237
370
  const parsed = JSON.parse(issue.stdout) as Record<string, any>;
238
371
  result.meetingIssue = meetingIssue;
239
372
  result.meetingIssueReadable = true;
240
373
  result.meetingIssueSummary = parsed.summary;
241
- } catch { /* unreadable */ }
374
+ } catch {
375
+ /* unreadable */
376
+ }
242
377
  } else {
243
378
  result.meetingIssue = meetingIssue;
244
379
  result.meetingIssueReadable = false;
@@ -252,14 +387,20 @@ export function youTrackVerifyToken(): { data: Record<string, any> } | { error:
252
387
  export function youTrackTokenCreateUrl(): { data: Record<string, any> } {
253
388
  const tokenName = process.env.WORKFLOW_YT_TOKEN_NAME ?? "workit";
254
389
  const loaded = readYouTrackConfig(false);
255
- const config = loaded && "config" in loaded ? loaded.config : {};
256
- const cfgPath = loaded && "config" in loaded ? loaded.path : youTrackConfigPath();
390
+ if ("error" in loaded) {
391
+ return { data: { error: loaded.error } };
392
+ }
393
+ const config = loaded.config;
257
394
  const defaults = (config.tokenDefaults ?? {}) as Record<string, any>;
258
395
  const name = String(defaults.name ?? tokenName);
259
- const desc = String(defaults.description ?? "OpenCode workit — /wk-issue-update and /wk-meetings");
396
+ const desc = String(
397
+ defaults.description ?? "OpenCode workit — /wk-issue-update and /wk-meetings",
398
+ );
260
399
  const scopes = Array.isArray(defaults.scopes) ? defaults.scopes : ["YouTrack"];
261
400
  const base = String(config.baseUrl ?? "https://enghouseamg.youtrack.cloud").replace(/\/+$/, "");
262
- const tokenFile = String(config.tokenFile ?? path.join(path.dirname(cfgPath), "youtrack.token"));
401
+ const tokenFile = String(
402
+ config.tokenFile ?? path.join(path.dirname(loaded.path), "youtrack.token"),
403
+ );
263
404
  const tab = String(defaults.profileTab ?? "account-security");
264
405
  const createUrl = `${base}/users/me?${new URLSearchParams({ tab })}`;
265
406
  const docsUrl = "https://www.jetbrains.com/help/youtrack/cloud/manage-permanent-token.html";
@@ -284,7 +425,9 @@ export function youTrackTokenCreateUrl(): { data: Record<string, any> } {
284
425
  }
285
426
 
286
427
  /** Parse bare id (NSR-40) or YouTrack URL into issue id. */
287
- export function parseIssueRef(input: unknown): { issueId: string; source: string } | { error: string } {
428
+ export function parseIssueRef(
429
+ input: unknown,
430
+ ): { issueId: string; source: string } | { error: string } {
288
431
  const trimmed = String(input ?? "").trim();
289
432
  if (!trimmed) return { error: "empty issue reference" };
290
433
 
@@ -309,7 +452,7 @@ export type YouTrackScripts = {
309
452
  config(): Record<string, any>;
310
453
  greeting(): { stdout: string; exitCode: number; stderr: string };
311
454
  parseDuration(text: string): Record<string, any>;
312
- api(args: string[]): Record<string, any>;
455
+ api(args: string[]): Record<string, any> | Promise<Record<string, any>>;
313
456
  };
314
457
 
315
458
  const defaultScripts: YouTrackScripts = {
@@ -319,11 +462,17 @@ const defaultScripts: YouTrackScripts = {
319
462
  api: (args) => youTrackApi(args, process.env.WORKFLOW_YT_WRITE ?? ""),
320
463
  };
321
464
 
322
- export function verifyYouTrackToken(scripts: YouTrackScripts = defaultScripts): Record<string, any> {
465
+ export function verifyYouTrackToken(
466
+ scripts: YouTrackScripts = defaultScripts,
467
+ ): Record<string, any> {
323
468
  return scripts.config();
324
469
  }
325
470
 
326
- function resolveYouTrackFromPaths(spec_path: string | undefined, plan_path: string | undefined, workspace_root: string): string | null {
471
+ function resolveYouTrackFromPaths(
472
+ spec_path: string | undefined,
473
+ plan_path: string | undefined,
474
+ workspace_root: string,
475
+ ): string | null {
327
476
  const root = resolveWorkspaceRoot(workspace_root);
328
477
  for (const rel of [spec_path, plan_path].filter(Boolean) as string[]) {
329
478
  const full = path.isAbsolute(rel) ? rel : path.join(root, rel);
@@ -358,7 +507,26 @@ function meetingOptionsFromConfig(cfg: any): Record<string, any>[] {
358
507
  ];
359
508
  }
360
509
 
361
- export function context({ spec_path, plan_path, issue_id, issue_url, issue_ref, mode, workspace_root }: { spec_path?: string; plan_path?: string; issue_id?: string; issue_url?: string; issue_ref?: string; mode?: string; workspace_root: string }, scripts: YouTrackScripts = defaultScripts): Record<string, any> {
510
+ export function context(
511
+ {
512
+ spec_path,
513
+ plan_path,
514
+ issue_id,
515
+ issue_url,
516
+ issue_ref,
517
+ mode,
518
+ workspace_root,
519
+ }: {
520
+ spec_path?: string;
521
+ plan_path?: string;
522
+ issue_id?: string;
523
+ issue_url?: string;
524
+ issue_ref?: string;
525
+ mode?: string;
526
+ workspace_root: string;
527
+ },
528
+ scripts: YouTrackScripts = defaultScripts,
529
+ ): Record<string, any> {
362
530
  const cfg = scripts.config();
363
531
  if (cfg.error) return { error: cfg.error };
364
532
 
@@ -390,7 +558,8 @@ export function context({ spec_path, plan_path, issue_id, issue_url, issue_ref,
390
558
  if (!issue) issue = resolveYouTrackFromPaths(spec_path, plan_path, workspace_root) ?? undefined;
391
559
  if (!issue || !ISSUE_RE.test(issue)) {
392
560
  return {
393
- error: "invalid or missing issue id — pass issue_url, issue_id, or spec/plan with **YouTrack:**",
561
+ error:
562
+ "invalid or missing issue id — pass issue_url, issue_id, or spec/plan with **YouTrack:**",
394
563
  requiresIssueInput: true,
395
564
  };
396
565
  }
@@ -411,39 +580,86 @@ export function context({ spec_path, plan_path, issue_id, issue_url, issue_ref,
411
580
  };
412
581
  }
413
582
 
414
- export function parseDuration(text: string, _workspace_root: string, scripts: YouTrackScripts = defaultScripts): Record<string, any> {
583
+ export function parseDuration(
584
+ text: string,
585
+ _workspace_root: string,
586
+ scripts: YouTrackScripts = defaultScripts,
587
+ ): Record<string, any> {
415
588
  const out = scripts.parseDuration(text);
416
589
  if (out.error) return { error: out.error };
417
590
  return out.data;
418
591
  }
419
592
 
420
- export function logTime({ issueId, minutes, text, date, dateMs, workspace_root }: { issueId: string; minutes: number; text?: string; date?: string; dateMs?: number; workspace_root: string }, scripts: YouTrackScripts = defaultScripts): Record<string, any> {
593
+ export async function logTime(
594
+ {
595
+ issueId,
596
+ minutes,
597
+ text,
598
+ date,
599
+ dateMs,
600
+ workspace_root: _workspace_root,
601
+ }: {
602
+ issueId: string;
603
+ minutes: number;
604
+ text?: string;
605
+ date?: string;
606
+ dateMs?: number;
607
+ workspace_root: string;
608
+ },
609
+ scripts: YouTrackScripts = defaultScripts,
610
+ ): Promise<Record<string, any>> {
421
611
  if (!issueId || !ISSUE_RE.test(issueId)) return { error: "invalid issueId" };
422
612
  if (!minutes || minutes <= 0) return { error: "minutes must be positive" };
423
613
  const workText = text ?? "workit";
424
614
  const dateArg =
425
- dateMs != null
426
- ? String(dateMs)
427
- : date && /^\d+$/.test(String(date))
428
- ? String(date)
429
- : "auto";
430
- const out = scripts.api(["log-time", issueId, String(minutes), workText, dateArg]);
615
+ dateMs != null ? String(dateMs) : date && /^\d+$/.test(String(date)) ? String(date) : "auto";
616
+ const out = await scripts.api(["log-time", issueId, String(minutes), workText, dateArg]);
431
617
  if (out.error) return { error: out.error };
432
618
  return { issueId, minutes, text: workText, ...out.data, ok: true };
433
619
  }
434
620
 
435
-
436
- export function buildDraft({ issueId, projectName, userNotes, greeting, facts, includeProjectOpener, includeFacts }: { issueId: string; projectName?: string; userNotes?: string; greeting?: string; facts?: any; includeProjectOpener?: boolean; includeFacts?: boolean }): Record<string, any> {
621
+ export function buildDraft({
622
+ issueId,
623
+ projectName,
624
+ userNotes,
625
+ greeting,
626
+ facts,
627
+ includeProjectOpener,
628
+ includeFacts,
629
+ }: {
630
+ issueId: string;
631
+ projectName?: string;
632
+ userNotes?: string;
633
+ greeting?: string;
634
+ facts?: any;
635
+ includeProjectOpener?: boolean;
636
+ includeFacts?: boolean;
637
+ }): Record<string, any> {
437
638
  const tpl = readTemplate("issue-update").content;
438
639
  const para = (value: string): string => (value ? `\n\n${value}` : "");
439
640
  const filled = tpl
440
641
  .replaceAll("{{greetingSection}}", para(greeting ? `${greeting}` : ""))
441
- .replaceAll("{{projectSection}}", para(includeProjectOpener && projectName ? `Hoy estuve full con ${projectName}.` : ""))
642
+ .replaceAll(
643
+ "{{projectSection}}",
644
+ para(includeProjectOpener && projectName ? `Hoy estuve full con ${projectName}.` : ""),
645
+ )
442
646
  .replaceAll("{{userNotesSection}}", para((userNotes ?? "").trim()))
443
- .replaceAll("{{progressSection}}", para(includeFacts && facts?.progress_excerpt?.length
444
- ? facts.progress_excerpt.map((l: string) => `- ${l}`).join("\n") : ""))
445
- .replaceAll("{{gitCommitsSection}}", para(includeFacts && facts?.git_commits?.length
446
- ? facts.git_commits.map((c: string) => `- ${c}`).join("\n") : ""));
647
+ .replaceAll(
648
+ "{{progressSection}}",
649
+ para(
650
+ includeFacts && facts?.progress_excerpt?.length
651
+ ? facts.progress_excerpt.map((l: string) => `- ${l}`).join("\n")
652
+ : "",
653
+ ),
654
+ )
655
+ .replaceAll(
656
+ "{{gitCommitsSection}}",
657
+ para(
658
+ includeFacts && facts?.git_commits?.length
659
+ ? facts.git_commits.map((c: string) => `- ${c}`).join("\n")
660
+ : "",
661
+ ),
662
+ );
447
663
  const collapsed = filled.replace(/\n{3,}/g, "\n\n").trimEnd();
448
664
  // Bare draft keeps the header's trailing blank line (matches legacy output);
449
665
  // drafts with sections end right after the last one.
@@ -451,20 +667,37 @@ export function buildDraft({ issueId, projectName, userNotes, greeting, facts, i
451
667
  return { issueId, markdown };
452
668
  }
453
669
 
454
- export function postUpdate({ confirmed, issueId, markdown, minutes, workspace_root }: { confirmed: boolean; issueId: string; markdown: string; minutes?: number; workspace_root?: string }, operations?: Record<string, any>): Record<string, any> {
670
+ export async function postUpdate(
671
+ {
672
+ confirmed,
673
+ issueId,
674
+ markdown,
675
+ minutes,
676
+ workspace_root,
677
+ }: {
678
+ confirmed: boolean;
679
+ issueId: string;
680
+ markdown: string;
681
+ minutes?: number;
682
+ workspace_root?: string;
683
+ },
684
+ operations?: Record<string, any>,
685
+ ): Promise<Record<string, any>> {
455
686
  operations ??= {};
456
687
  if (!confirmed) return { error: "confirmed: true required" };
457
688
  if (!issueId || !ISSUE_RE.test(issueId)) return { error: "invalid issueId" };
458
689
  if (!markdown?.trim()) return { error: "markdown required" };
459
690
 
460
- const postComment = operations.postComment ?? ((id: string, text: string, root: string) =>
461
- youTrackApi(["post-comment", id, text], process.env.WORKFLOW_YT_WRITE ?? ""));
691
+ const postComment =
692
+ operations.postComment ??
693
+ ((id: string, text: string, _root: string) =>
694
+ youTrackApi(["post-comment", id, text], process.env.WORKFLOW_YT_WRITE ?? ""));
462
695
  const logTimeOperation = operations.logTime ?? logTime;
463
- const comment = postComment(issueId, markdown, workspace_root);
696
+ const comment = await postComment(issueId, markdown, workspace_root);
464
697
  if (comment.error) return { error: comment.error };
465
698
 
466
699
  if (minutes && minutes > 0) {
467
- const time = logTimeOperation({
700
+ const time = await logTimeOperation({
468
701
  issueId,
469
702
  minutes,
470
703
  text: "workit update",