@brainervirus/workit-core 0.4.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 (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -0
  3. package/commands/wk-changelog.md +2 -0
  4. package/commands/wk-commit.md +2 -0
  5. package/commands/wk-docs-refresh.md +2 -0
  6. package/commands/wk-handoff.md +2 -0
  7. package/commands/wk-implement.md +2 -0
  8. package/commands/wk-init.md +2 -0
  9. package/commands/wk-issue-update.md +2 -0
  10. package/commands/wk-meetings.md +2 -0
  11. package/commands/wk-pr.md +2 -0
  12. package/commands/wk-release-notes.md +2 -0
  13. package/commands/wk-status.md +2 -0
  14. package/commands/wk-verify.md +2 -0
  15. package/package.json +43 -0
  16. package/scripts/_shared/common.sh +158 -0
  17. package/scripts/changelog-context.sh +42 -0
  18. package/scripts/docs-refresh-context.sh +40 -0
  19. package/scripts/fixtures/sample-plan.md +15 -0
  20. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  21. package/scripts/init/apply.sh +5 -0
  22. package/scripts/init/status.sh +5 -0
  23. package/scripts/init/toolkit-status.sh +5 -0
  24. package/scripts/install-cursor-plugin.sh +83 -0
  25. package/scripts/install-opencode-plugin.sh +76 -0
  26. package/scripts/pr-create.sh +5 -0
  27. package/scripts/pr-ready-context.sh +88 -0
  28. package/scripts/present/ascii-wireframe.sh +5 -0
  29. package/scripts/present/flow-diagram.sh +5 -0
  30. package/scripts/release-notes-context.sh +40 -0
  31. package/scripts/rewrite-workspace-deps.ts +17 -0
  32. package/scripts/run-cursor-mcp.sh +10 -0
  33. package/scripts/sync-runtime.sh +96 -0
  34. package/scripts/update-superpowers.sh +56 -0
  35. package/scripts/vcs/config.sh +5 -0
  36. package/scripts/vcs/merged-style.sh +5 -0
  37. package/scripts/vcs/token-create-urls.sh +5 -0
  38. package/scripts/vcs/verify-token.sh +5 -0
  39. package/scripts/verify-project.sh +140 -0
  40. package/scripts/youtrack/api.sh +5 -0
  41. package/scripts/youtrack/config.sh +5 -0
  42. package/scripts/youtrack/greeting.sh +5 -0
  43. package/scripts/youtrack/parse-duration.sh +5 -0
  44. package/scripts/youtrack/token-create-url.sh +5 -0
  45. package/scripts/youtrack/verify-token.sh +5 -0
  46. package/scripts/youtrack/work-date-ms.sh +5 -0
  47. package/skills/wk-changelog/SKILL.md +15 -0
  48. package/skills/wk-commit/SKILL.md +16 -0
  49. package/skills/wk-docs-refresh/SKILL.md +15 -0
  50. package/skills/wk-handoff/SKILL.md +17 -0
  51. package/skills/wk-implement/SKILL.md +41 -0
  52. package/skills/wk-init/SKILL.md +31 -0
  53. package/skills/wk-issue-update/SKILL.md +27 -0
  54. package/skills/wk-issue-update/references/youtrack-update-style.md +81 -0
  55. package/skills/wk-meetings/SKILL.md +17 -0
  56. package/skills/wk-pr/SKILL.md +27 -0
  57. package/skills/wk-release-notes/SKILL.md +15 -0
  58. package/skills/wk-status/SKILL.md +16 -0
  59. package/skills/wk-verify/SKILL.md +16 -0
  60. package/src/core/branch.ts +246 -0
  61. package/src/core/changelog.ts +312 -0
  62. package/src/core/config-guard.ts +26 -0
  63. package/src/core/config.ts +73 -0
  64. package/src/core/detector.ts +207 -0
  65. package/src/core/doc-render.ts +14 -0
  66. package/src/core/docs-repo.ts +196 -0
  67. package/src/core/docs-validate.ts +255 -0
  68. package/src/core/flow-state.ts +225 -0
  69. package/src/core/git.ts +56 -0
  70. package/src/core/gitignore.ts +43 -0
  71. package/src/core/handoff-context.ts +115 -0
  72. package/src/core/hygiene.ts +77 -0
  73. package/src/core/init.ts +443 -0
  74. package/src/core/parse-sections.ts +24 -0
  75. package/src/core/plan-tasks.ts +33 -0
  76. package/src/core/ports/init-apply.ts +15 -0
  77. package/src/core/ports/init-status.ts +4 -0
  78. package/src/core/ports/init-toolkit-status.ts +4 -0
  79. package/src/core/ports/pr-create.ts +22 -0
  80. package/src/core/ports/present-ascii.ts +10 -0
  81. package/src/core/ports/present-flow.ts +10 -0
  82. package/src/core/ports/vcs-config.ts +14 -0
  83. package/src/core/ports/vcs-merged-style.ts +5 -0
  84. package/src/core/ports/vcs-token-create-urls.ts +4 -0
  85. package/src/core/ports/vcs-verify-token.ts +4 -0
  86. package/src/core/ports/youtrack-api.ts +17 -0
  87. package/src/core/ports/youtrack-config.ts +23 -0
  88. package/src/core/ports/youtrack-greeting.ts +10 -0
  89. package/src/core/ports/youtrack-parse-duration.ts +14 -0
  90. package/src/core/ports/youtrack-token-create-url.ts +4 -0
  91. package/src/core/ports/youtrack-verify-token.ts +12 -0
  92. package/src/core/ports/youtrack-work-date-ms.ts +10 -0
  93. package/src/core/pr-create.ts +212 -0
  94. package/src/core/present.ts +100 -0
  95. package/src/core/reminder.ts +82 -0
  96. package/src/core/repo-tool.ts +9 -0
  97. package/src/core/rules.ts +122 -0
  98. package/src/core/scripts.ts +42 -0
  99. package/src/core/sdd.ts +188 -0
  100. package/src/core/templates.ts +37 -0
  101. package/src/core/vcs-config.ts +252 -0
  102. package/src/core/verify-parse.ts +27 -0
  103. package/src/core/workspaces.ts +81 -0
  104. package/src/core/youtrack.ts +488 -0
  105. package/src/core.ts +62 -0
  106. package/src/state.ts +22 -0
  107. package/src/tools/docs-repo.ts +42 -0
  108. package/src/tools/flow.ts +88 -0
  109. package/src/tools/handoff.ts +160 -0
  110. package/src/tools/index.ts +22 -0
  111. package/src/tools/present.ts +45 -0
  112. package/src/tools/repo.ts +357 -0
  113. package/src/tools/rules.ts +30 -0
  114. package/src/tools/sdd.ts +189 -0
  115. package/src/tools/templates.ts +27 -0
  116. package/src/tools/youtrack.ts +356 -0
  117. package/templates/execution-contract.md +56 -0
  118. package/templates/greeting.md +1 -0
  119. package/templates/headers.md +3 -0
  120. package/templates/hygiene/.editorconfig +8 -0
  121. package/templates/hygiene/.gitattributes +3 -0
  122. package/templates/hygiene/CHANGELOG.md +14 -0
  123. package/templates/hygiene/CONTRIBUTING.md +3 -0
  124. package/templates/hygiene/LICENSE +21 -0
  125. package/templates/hygiene/README.md +3 -0
  126. package/templates/issue-update.md +6 -0
  127. package/templates/plan-template.md +25 -0
  128. package/templates/spec-template.md +51 -0
  129. package/templates/superpowers-doc-contract.md +69 -0
  130. package/vendor/superpowers/skills/brainstorming/SKILL.md +159 -0
  131. package/vendor/superpowers/skills/brainstorming/scripts/frame-template.html +213 -0
  132. package/vendor/superpowers/skills/brainstorming/scripts/helper.js +167 -0
  133. package/vendor/superpowers/skills/brainstorming/scripts/server.cjs +723 -0
  134. package/vendor/superpowers/skills/brainstorming/scripts/start-server.sh +209 -0
  135. package/vendor/superpowers/skills/brainstorming/scripts/stop-server.sh +120 -0
  136. package/vendor/superpowers/skills/brainstorming/spec-document-reviewer-prompt.md +49 -0
  137. package/vendor/superpowers/skills/brainstorming/visual-companion.md +291 -0
  138. package/vendor/superpowers/skills/dispatching-parallel-agents/SKILL.md +185 -0
  139. package/vendor/superpowers/skills/executing-plans/SKILL.md +70 -0
  140. package/vendor/superpowers/skills/finishing-a-development-branch/SKILL.md +241 -0
  141. package/vendor/superpowers/skills/receiving-code-review/SKILL.md +213 -0
  142. package/vendor/superpowers/skills/requesting-code-review/SKILL.md +103 -0
  143. package/vendor/superpowers/skills/requesting-code-review/code-reviewer.md +172 -0
  144. package/vendor/superpowers/skills/subagent-driven-development/SKILL.md +418 -0
  145. package/vendor/superpowers/skills/subagent-driven-development/implementer-prompt.md +139 -0
  146. package/vendor/superpowers/skills/subagent-driven-development/scripts/review-package +44 -0
  147. package/vendor/superpowers/skills/subagent-driven-development/scripts/sdd-workspace +22 -0
  148. package/vendor/superpowers/skills/subagent-driven-development/scripts/task-brief +40 -0
  149. package/vendor/superpowers/skills/subagent-driven-development/task-reviewer-prompt.md +188 -0
  150. package/vendor/superpowers/skills/systematic-debugging/CREATION-LOG.md +119 -0
  151. package/vendor/superpowers/skills/systematic-debugging/SKILL.md +296 -0
  152. package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting-example.ts +158 -0
  153. package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting.md +115 -0
  154. package/vendor/superpowers/skills/systematic-debugging/defense-in-depth.md +122 -0
  155. package/vendor/superpowers/skills/systematic-debugging/find-polluter.sh +63 -0
  156. package/vendor/superpowers/skills/systematic-debugging/root-cause-tracing.md +169 -0
  157. package/vendor/superpowers/skills/systematic-debugging/test-academic.md +14 -0
  158. package/vendor/superpowers/skills/systematic-debugging/test-pressure-1.md +58 -0
  159. package/vendor/superpowers/skills/systematic-debugging/test-pressure-2.md +68 -0
  160. package/vendor/superpowers/skills/systematic-debugging/test-pressure-3.md +69 -0
  161. package/vendor/superpowers/skills/test-driven-development/SKILL.md +371 -0
  162. package/vendor/superpowers/skills/test-driven-development/testing-anti-patterns.md +299 -0
  163. package/vendor/superpowers/skills/using-git-worktrees/SKILL.md +202 -0
  164. package/vendor/superpowers/skills/using-superpowers/SKILL.md +62 -0
  165. package/vendor/superpowers/skills/using-superpowers/references/antigravity-tools.md +23 -0
  166. package/vendor/superpowers/skills/using-superpowers/references/codex-tools.md +39 -0
  167. package/vendor/superpowers/skills/using-superpowers/references/pi-tools.md +16 -0
  168. package/vendor/superpowers/skills/verification-before-completion/SKILL.md +139 -0
  169. package/vendor/superpowers/skills/writing-plans/SKILL.md +174 -0
  170. package/vendor/superpowers/skills/writing-plans/plan-document-reviewer-prompt.md +49 -0
  171. package/vendor/superpowers/skills/writing-skills/SKILL.md +689 -0
  172. package/vendor/superpowers/skills/writing-skills/anthropic-best-practices.md +1150 -0
  173. package/vendor/superpowers/skills/writing-skills/examples/CLAUDE_MD_TESTING.md +189 -0
  174. package/vendor/superpowers/skills/writing-skills/graphviz-conventions.dot +172 -0
  175. package/vendor/superpowers/skills/writing-skills/persuasion-principles.md +187 -0
  176. package/vendor/superpowers/skills/writing-skills/render-graphs.js +168 -0
  177. package/vendor/superpowers/skills/writing-skills/testing-skills-with-subagents.md +384 -0
@@ -0,0 +1,488 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { readTemplate } from "./templates";
6
+ import { resolveWorkspaceRoot } from "./scripts";
7
+
8
+ const ISSUE_RE = /^[A-Z]+-\d+$/;
9
+ const TOKEN_PLACEHOLDER = "YOUR_TOKEN_HERE";
10
+
11
+ // Port of scripts/youtrack/config.sh chain: WORKFLOW_YOUTRACK_CONFIG ->
12
+ // XDG_CONFIG_HOME / HOME .config + workflow-toolkit/youtrack.json.
13
+ export const youTrackConfigPath = (): string =>
14
+ process.env.WORKFLOW_YOUTRACK_CONFIG ??
15
+ path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "workflow-toolkit", "youtrack.json");
16
+
17
+ const youTrackTokenModeOk = (p: string): boolean => {
18
+ if (process.platform === "win32") return true;
19
+ const mode = fs.statSync(p).mode & 0o777;
20
+ return mode === 0o600;
21
+ };
22
+
23
+ function readYouTrackConfig(required: boolean): { config: Record<string, any>; path: string } | { error: string } {
24
+ const cfgPath = youTrackConfigPath();
25
+ if (!fs.existsSync(cfgPath)) {
26
+ return required ? { error: "ERROR: missing youtrack.json" } : { config: {}, path: cfgPath };
27
+ }
28
+ try {
29
+ const config = JSON.parse(fs.readFileSync(cfgPath, "utf8")) as Record<string, any>;
30
+ return { config, path: cfgPath };
31
+ } catch {
32
+ return { error: "invalid youtrack.json" };
33
+ }
34
+ }
35
+
36
+ /** Load + redact youtrack.json; validates the token file like youtrack/config.sh load. */
37
+ export function youTrackConfigLoad(): { data: Record<string, any> } | { error: string } {
38
+ const loaded = readYouTrackConfig(true);
39
+ if ("error" in loaded) return loaded;
40
+ const cfgPath = loaded.path;
41
+ const tokenFile = String(loaded.config.tokenFile ?? "");
42
+ const tokenPath = tokenFile
43
+ ? (path.isAbsolute(tokenFile) ? path.resolve(tokenFile) : path.resolve(process.cwd(), tokenFile))
44
+ : "";
45
+ if (!tokenPath || !fs.existsSync(tokenPath)) {
46
+ return { error: "missing youtrack.token" };
47
+ }
48
+ if (!youTrackTokenModeOk(tokenPath)) {
49
+ return { error: "youtrack.token mode must be 0600" };
50
+ }
51
+ const token = fs.readFileSync(tokenPath, "utf8").trim();
52
+ if (!token || token === TOKEN_PLACEHOLDER || token.startsWith(TOKEN_PLACEHOLDER)) {
53
+ return { error: "token file still placeholder — edit locally, then /wk-status" };
54
+ }
55
+ const redacted: Record<string, any> = { ...loaded.config };
56
+ delete redacted.tokenFile;
57
+ redacted.tokenPresent = true;
58
+ redacted.configPath = path.resolve(cfgPath);
59
+ redacted.tokenPath = path.resolve(tokenPath);
60
+ return { data: redacted };
61
+ }
62
+
63
+ function tzParts(date: Date, tz: string): { y: string; m: string; d: string; hour: string; minute: string } {
64
+ 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",
67
+ }).formatToParts(date);
68
+ const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
69
+ return { y: map.year, m: map.month, d: map.day, hour: map.hour, minute: map.minute };
70
+ }
71
+
72
+ /** Port of scripts/youtrack/greeting.sh. */
73
+ export function youTrackGreeting(configOverride?: string): { stdout: string; exitCode: number; stderr: string } {
74
+ const cfgPath = configOverride ?? youTrackConfigPath();
75
+ try {
76
+ const config = JSON.parse(fs.readFileSync(cfgPath, "utf8")) as Record<string, any>;
77
+ const tz = String(config.timezone ?? "America/Santiago");
78
+ const now = new Date();
79
+ const { y, m, d, hour, minute } = tzParts(now, tz);
80
+ const cutoff = String(config.greetingCutoff ?? "12:00").split(":");
81
+ const cutoffHour = Number(cutoff[0]);
82
+ const cutoffMinute = Number(cutoff[1] ?? 0);
83
+ const greetings = (config.greetings ?? {}) as Record<string, string>;
84
+ const isMorning = Number(hour) < cutoffHour || (Number(hour) === cutoffHour && Number(minute) < cutoffMinute);
85
+ const greeting = isMorning
86
+ ? greetings.morning ?? "buenos días"
87
+ : greetings.afternoon ?? "buenas tardes";
88
+ const mention = String(config.defaultMention ?? "Alejandra.Flores");
89
+ void y; void m; void d;
90
+ return { stdout: `@${mention} Hola, ${greeting}.\n`, exitCode: 0, stderr: "" };
91
+ } catch (err) {
92
+ return { stdout: "", exitCode: 1, stderr: err instanceof Error ? err.message : "greeting failed" };
93
+ }
94
+ }
95
+
96
+ /** Port of scripts/youtrack/parse-duration.sh. */
97
+ export function youTrackParseDuration(text: string): { data: { minutes: number; text: string } } | { error: string } {
98
+ const lower = String(text).toLowerCase().trim();
99
+ let total = 0;
100
+ for (const match of lower.matchAll(/(\d+)\s*h/g)) total += Number(match[1]) * 60;
101
+ for (const match of lower.matchAll(/(\d+)\s*m/g)) total += Number(match[1]);
102
+ if (total === 0 && /^\d+$/.test(lower)) total = Number(lower);
103
+ if (total <= 0) return { error: "could not parse duration" };
104
+ return { data: { minutes: total, text: String(text).trim() } };
105
+ }
106
+
107
+ /** 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 } {
109
+ const cfgPath = youTrackConfigPath();
110
+ 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 */ }
115
+ const raw = dateRaw || "auto";
116
+ try {
117
+ if (raw === "auto" || !raw) {
118
+ const now = new Date();
119
+ const { y, m, d } = tzParts(now, tz);
120
+ const dateMs = Math.floor(Date.parse(`${y}-${m}-${d}T00:00:00`) / 86400000) * 86400000;
121
+ return { data: { dateMs, timezone: tz, localDate: `${y}-${m}-${d}` } };
122
+ }
123
+ if (/^\d+$/.test(raw)) {
124
+ const dt = new Date(Number(raw));
125
+ const { y, m, d } = tzParts(dt, tz);
126
+ return { data: { dateMs: Number(raw), timezone: tz, localDate: `${y}-${m}-${d}` } };
127
+ }
128
+ const [y, m, d] = raw.split("-").map(Number);
129
+ const iso = `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00`;
130
+ 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")}` } };
132
+ } catch (err) {
133
+ return { error: err instanceof Error ? err.message : "could not resolve date" };
134
+ }
135
+ }
136
+
137
+ const youTrackToken = (): { token: string; base: string } | { error: string } => {
138
+ const loaded = readYouTrackConfig(true);
139
+ if ("error" in loaded) return loaded;
140
+ const tokenFile = String(loaded.config.tokenFile ?? "");
141
+ const tokenPath = tokenFile
142
+ ? (path.isAbsolute(tokenFile) ? path.resolve(tokenFile) : path.resolve(process.cwd(), tokenFile))
143
+ : "";
144
+ if (!tokenPath || !fs.existsSync(tokenPath)) return { error: "missing youtrack.token" };
145
+ if (!youTrackTokenModeOk(tokenPath)) return { error: "youtrack.token mode must be 0600" };
146
+ const token = fs.readFileSync(tokenPath, "utf8").trim();
147
+ if (!token) return { error: "empty token file" };
148
+ 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" };
150
+ }
151
+ const base = String(loaded.config.baseUrl ?? "").replace(/\/+$/, "");
152
+ if (!base) return { error: "baseUrl missing in config" };
153
+ return { token, base };
154
+ };
155
+
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 ?? "" };
159
+ }
160
+
161
+ /** 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 } {
163
+ const cmd = args[0];
164
+ if (cmd === "log-time" || cmd === "post-comment") {
165
+ if (writeFlag !== "1") {
166
+ return { error: "YouTrack write operations require WORKFLOW_YT_WRITE=1 (refusing to mutate production)" };
167
+ }
168
+ }
169
+ const creds = youTrackToken();
170
+ if ("error" in creds) return creds;
171
+ const { token, base } = creds;
172
+ const auth = ["-H", `Authorization: Bearer ${token}`, "-H", "Accept: application/json"];
173
+
174
+ if (cmd === "log-time") {
175
+ const [issue, minutesRaw, text, dateArg] = args.slice(1);
176
+ const minutes = Number(minutesRaw);
177
+ const dateMs = youTrackWorkDateMs(dateArg ?? "auto");
178
+ 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,
182
+ `${base}/api/issues/${issue}/timeTracking/workItems?fields=id,idReadable`,
183
+ ]);
184
+ if (out.status !== 0) return { error: "YouTrack HTTP request failed" };
185
+ try {
186
+ 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 } };
188
+ } catch {
189
+ return { error: "invalid JSON from YouTrack API" };
190
+ }
191
+ }
192
+ if (cmd === "post-comment") {
193
+ 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
+ ]);
199
+ if (out.status !== 0) return { error: "YouTrack HTTP request failed" };
200
+ return { data: { ok: true, issueId: issue } };
201
+ }
202
+ return { error: "unknown subcommand" };
203
+ }
204
+
205
+ /** 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 } {
207
+ const cfgPath = youTrackConfigPath();
208
+ if (!fs.existsSync(cfgPath)) return { error: "missing youtrack.json" };
209
+ const creds = youTrackToken();
210
+ if ("error" in creds) return { error: creds.error };
211
+ const { token, base } = creds;
212
+
213
+ const me = youTrackCurl(["-H", `Authorization: Bearer ${token}`, "-H", "Accept: application/json",
214
+ `${base}/api/users/me?fields=id,login,name,email`]);
215
+ 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)}`;
218
+ return { error: err, http_status: me.status };
219
+ }
220
+ let user: Record<string, any>;
221
+ try {
222
+ user = JSON.parse(me.stdout) as Record<string, any>;
223
+ } catch {
224
+ return { error: "invalid JSON from YouTrack /api/users/me" };
225
+ }
226
+ 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,
229
+ };
230
+ const meeting = readYouTrackConfig(false);
231
+ const meetingIssue = "config" in meeting ? meeting.config.meetingIssue : undefined;
232
+ if (meetingIssue) {
233
+ const issue = youTrackCurl(["-H", `Authorization: Bearer ${token}`, "-H", "Accept: application/json",
234
+ `${base}/api/issues/${meetingIssue}?fields=id,idReadable,summary`]);
235
+ if (issue.status === 0) {
236
+ try {
237
+ const parsed = JSON.parse(issue.stdout) as Record<string, any>;
238
+ result.meetingIssue = meetingIssue;
239
+ result.meetingIssueReadable = true;
240
+ result.meetingIssueSummary = parsed.summary;
241
+ } catch { /* unreadable */ }
242
+ } else {
243
+ result.meetingIssue = meetingIssue;
244
+ result.meetingIssueReadable = false;
245
+ result.warning = `token valid but cannot read issue ${meetingIssue}`;
246
+ }
247
+ }
248
+ return { data: result };
249
+ }
250
+
251
+ /** Port of scripts/youtrack/token-create-url.sh — deep link to Account Security. */
252
+ export function youTrackTokenCreateUrl(): { data: Record<string, any> } {
253
+ const tokenName = process.env.WORKFLOW_YT_TOKEN_NAME ?? "workit";
254
+ const loaded = readYouTrackConfig(false);
255
+ const config = loaded && "config" in loaded ? loaded.config : {};
256
+ const cfgPath = loaded && "config" in loaded ? loaded.path : youTrackConfigPath();
257
+ const defaults = (config.tokenDefaults ?? {}) as Record<string, any>;
258
+ const name = String(defaults.name ?? tokenName);
259
+ const desc = String(defaults.description ?? "OpenCode workit — /wk-issue-update and /wk-meetings");
260
+ const scopes = Array.isArray(defaults.scopes) ? defaults.scopes : ["YouTrack"];
261
+ const base = String(config.baseUrl ?? "https://enghouseamg.youtrack.cloud").replace(/\/+$/, "");
262
+ const tokenFile = String(config.tokenFile ?? path.join(path.dirname(cfgPath), "youtrack.token"));
263
+ const tab = String(defaults.profileTab ?? "account-security");
264
+ const createUrl = `${base}/users/me?${new URLSearchParams({ tab })}`;
265
+ const docsUrl = "https://www.jetbrains.com/help/youtrack/cloud/manage-permanent-token.html";
266
+ return {
267
+ data: {
268
+ tokenName: name,
269
+ tokenDescription: desc,
270
+ scopes,
271
+ tokenFile: path.resolve(tokenFile),
272
+ createUrl,
273
+ docsUrl,
274
+ prefillSupported: false,
275
+ steps: [
276
+ "Profile → Account Security → **New token** (or open createUrl)",
277
+ `Name: **${name}**`,
278
+ `Scope: **${scopes.join(", ")}** only — remove other services`,
279
+ "**Create token** → copy immediately (shown once)",
280
+ "Paste into token file → save → `/wk-status`",
281
+ ],
282
+ },
283
+ };
284
+ }
285
+
286
+ /** Parse bare id (NSR-40) or YouTrack URL into issue id. */
287
+ export function parseIssueRef(input: unknown): { issueId: string; source: string } | { error: string } {
288
+ const trimmed = String(input ?? "").trim();
289
+ if (!trimmed) return { error: "empty issue reference" };
290
+
291
+ if (ISSUE_RE.test(trimmed)) {
292
+ return { issueId: trimmed, source: "id" };
293
+ }
294
+
295
+ const fromPath = trimmed.match(/\/(?:issue|issues)\/([A-Z]+-\d+)/i);
296
+ if (fromPath && ISSUE_RE.test(fromPath[1])) {
297
+ return { issueId: fromPath[1], source: "url" };
298
+ }
299
+
300
+ const anywhere = trimmed.match(/([A-Z]+-\d+)/);
301
+ if (anywhere && ISSUE_RE.test(anywhere[1])) {
302
+ return { issueId: anywhere[1], source: "url" };
303
+ }
304
+
305
+ return { error: `could not parse issue id from: ${trimmed}` };
306
+ }
307
+
308
+ export type YouTrackScripts = {
309
+ config(): Record<string, any>;
310
+ greeting(): { stdout: string; exitCode: number; stderr: string };
311
+ parseDuration(text: string): Record<string, any>;
312
+ api(args: string[]): Record<string, any>;
313
+ };
314
+
315
+ const defaultScripts: YouTrackScripts = {
316
+ config: () => youTrackConfigLoad(),
317
+ greeting: () => youTrackGreeting(),
318
+ parseDuration: (text) => youTrackParseDuration(text),
319
+ api: (args) => youTrackApi(args, process.env.WORKFLOW_YT_WRITE ?? ""),
320
+ };
321
+
322
+ export function verifyYouTrackToken(scripts: YouTrackScripts = defaultScripts): Record<string, any> {
323
+ return scripts.config();
324
+ }
325
+
326
+ function resolveYouTrackFromPaths(spec_path: string | undefined, plan_path: string | undefined, workspace_root: string): string | null {
327
+ const root = resolveWorkspaceRoot(workspace_root);
328
+ for (const rel of [spec_path, plan_path].filter(Boolean) as string[]) {
329
+ const full = path.isAbsolute(rel) ? rel : path.join(root, rel);
330
+ if (!fs.existsSync(full)) continue;
331
+ const text = fs.readFileSync(full, "utf8");
332
+ const m = text.match(/^\*\*YouTrack:\*\*\s*`?([A-Z]+-\d+)`?/m);
333
+ if (m) return m[1];
334
+ }
335
+ return null;
336
+ }
337
+
338
+ function meetingOptionsFromConfig(cfg: any): Record<string, any>[] {
339
+ const base = (cfg.baseUrl || "").replace(/\/$/, "");
340
+ if (cfg.meetingIssues && typeof cfg.meetingIssues === "object") {
341
+ return Object.entries(cfg.meetingIssues).map(([key, item]: [string, any]) => ({
342
+ key,
343
+ issue: item.issue,
344
+ label: item.label ?? item.issue,
345
+ workItemText: item.workItemText ?? "Reuniones",
346
+ url: item.url ?? (base && item.issue ? `${base}/issue/${item.issue}` : null),
347
+ }));
348
+ }
349
+ const issue = cfg.meetingIssue;
350
+ return [
351
+ {
352
+ key: "general",
353
+ issue,
354
+ label: "General meetings",
355
+ workItemText: "Reuniones",
356
+ url: base && issue ? `${base}/issue/${issue}` : null,
357
+ },
358
+ ];
359
+ }
360
+
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> {
362
+ const cfg = scripts.config();
363
+ if (cfg.error) return { error: cfg.error };
364
+
365
+ const greeting = scripts.greeting();
366
+ if (greeting.exitCode !== 0) {
367
+ return { error: (greeting.stderr || greeting.stdout || "greeting failed").trim() };
368
+ }
369
+
370
+ const meetingOptions = meetingOptionsFromConfig(cfg.data);
371
+
372
+ if (mode === "meetings" && !issue_id && !issue_url && !issue_ref) {
373
+ return {
374
+ config: cfg.data,
375
+ greeting: greeting.stdout.trim(),
376
+ mode: "meetings",
377
+ requiresMeetingChoice: true,
378
+ meetingOptions,
379
+ issueId: null,
380
+ };
381
+ }
382
+
383
+ let issue = issue_id;
384
+ if (!issue && (issue_url || issue_ref)) {
385
+ const parsed = parseIssueRef(issue_url ?? issue_ref);
386
+ if ("error" in parsed) return { error: parsed.error };
387
+ issue = parsed.issueId;
388
+ }
389
+ if (!issue && mode === "meetings") issue = meetingOptions[0]?.issue ?? cfg.data.meetingIssue;
390
+ if (!issue) issue = resolveYouTrackFromPaths(spec_path, plan_path, workspace_root) ?? undefined;
391
+ if (!issue || !ISSUE_RE.test(issue)) {
392
+ return {
393
+ error: "invalid or missing issue id — pass issue_url, issue_id, or spec/plan with **YouTrack:**",
394
+ requiresIssueInput: true,
395
+ };
396
+ }
397
+
398
+ const base = (cfg.data.baseUrl || "").replace(/\/$/, "");
399
+ const issueUrl = base ? `${base}/issue/${issue}` : null;
400
+
401
+ const selectedMeeting = meetingOptions.find((m) => m.issue === issue);
402
+
403
+ return {
404
+ config: cfg.data,
405
+ greeting: greeting.stdout.trim(),
406
+ issueId: issue,
407
+ issueUrl,
408
+ mode: mode ?? (selectedMeeting ? "meetings" : "task"),
409
+ meetingOptions,
410
+ workItemText: selectedMeeting?.workItemText ?? null,
411
+ };
412
+ }
413
+
414
+ export function parseDuration(text: string, _workspace_root: string, scripts: YouTrackScripts = defaultScripts): Record<string, any> {
415
+ const out = scripts.parseDuration(text);
416
+ if (out.error) return { error: out.error };
417
+ return out.data;
418
+ }
419
+
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> {
421
+ if (!issueId || !ISSUE_RE.test(issueId)) return { error: "invalid issueId" };
422
+ if (!minutes || minutes <= 0) return { error: "minutes must be positive" };
423
+ const workText = text ?? "workit";
424
+ 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]);
431
+ if (out.error) return { error: out.error };
432
+ return { issueId, minutes, text: workText, ...out.data, ok: true };
433
+ }
434
+
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> {
437
+ const tpl = readTemplate("issue-update").content;
438
+ const para = (value: string): string => (value ? `\n\n${value}` : "");
439
+ const filled = tpl
440
+ .replaceAll("{{greetingSection}}", para(greeting ? `${greeting}` : ""))
441
+ .replaceAll("{{projectSection}}", para(includeProjectOpener && projectName ? `Hoy estuve full con ${projectName}.` : ""))
442
+ .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") : ""));
447
+ const collapsed = filled.replace(/\n{3,}/g, "\n\n").trimEnd();
448
+ // Bare draft keeps the header's trailing blank line (matches legacy output);
449
+ // drafts with sections end right after the last one.
450
+ const markdown = collapsed === "# Actualización" ? `${collapsed}\n\n` : collapsed;
451
+ return { issueId, markdown };
452
+ }
453
+
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> {
455
+ operations ??= {};
456
+ if (!confirmed) return { error: "confirmed: true required" };
457
+ if (!issueId || !ISSUE_RE.test(issueId)) return { error: "invalid issueId" };
458
+ if (!markdown?.trim()) return { error: "markdown required" };
459
+
460
+ const postComment = operations.postComment ?? ((id: string, text: string, root: string) =>
461
+ youTrackApi(["post-comment", id, text], process.env.WORKFLOW_YT_WRITE ?? ""));
462
+ const logTimeOperation = operations.logTime ?? logTime;
463
+ const comment = postComment(issueId, markdown, workspace_root);
464
+ if (comment.error) return { error: comment.error };
465
+
466
+ if (minutes && minutes > 0) {
467
+ const time = logTimeOperation({
468
+ issueId,
469
+ minutes,
470
+ text: "workit update",
471
+ workspace_root,
472
+ });
473
+ if (time.error) {
474
+ return {
475
+ ok: false,
476
+ partial: true,
477
+ issueId,
478
+ postedComment: true,
479
+ loggedMinutes: 0,
480
+ error: time.error,
481
+ retry: "workflow_youtrack_log_time",
482
+ };
483
+ }
484
+ return { ok: true, issueId, postedComment: true, loggedMinutes: minutes };
485
+ }
486
+
487
+ return { ok: true, issueId, postedComment: true };
488
+ }
package/src/core.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export type Result<T> =
6
+ | { ok: true; data: T; error: null }
7
+ | { ok: false; data: T | null; error: string };
8
+
9
+ export const ok = <T>(data: T): Result<T> => ({ ok: true, data, error: null });
10
+ export const fail = <T = never>(error: string, data: T | null = null): Result<T> => ({ ok: false, data, error });
11
+
12
+ const revision = /^[A-Za-z0-9@][A-Za-z0-9@._/~^{}-]*$/;
13
+
14
+ export function gitRevisionParts(value: string): string[] {
15
+ if (!value || value.startsWith("-") || /[\s\\'"`$;|&<>]/.test(value)) {
16
+ throw new Error("invalid Git revision or range");
17
+ }
18
+ const separator = value.includes("...") ? "..." : value.includes("..") ? ".." : null;
19
+ const parts = separator ? value.split(separator) : [value];
20
+ if (parts.length > 2 || parts.some((part) => !revision.test(part))) {
21
+ throw new Error("invalid Git revision or range");
22
+ }
23
+ return parts;
24
+ }
25
+
26
+ export function resolveGitRevision(root: string, value: string): void {
27
+ for (const part of gitRevisionParts(value)) {
28
+ const result = run(root, "git", ["rev-parse", "--verify", "--quiet", "--end-of-options", `${part}^{commit}`]);
29
+ if (result.exitCode !== 0) throw new Error(`invalid Git revision or range: ${value}`);
30
+ }
31
+ }
32
+
33
+ export function resolveInside(root: string, candidate: string): string {
34
+ const base = realpathSync(root);
35
+ const target = path.resolve(base, candidate);
36
+ if (target !== base && !target.startsWith(base + path.sep)) {
37
+ throw new Error("path must stay inside repository root");
38
+ }
39
+
40
+ let ancestor = target;
41
+ while (!existsSync(ancestor)) ancestor = path.dirname(ancestor);
42
+ const canonicalAncestor = realpathSync(ancestor);
43
+ if (canonicalAncestor !== base && !canonicalAncestor.startsWith(base + path.sep)) {
44
+ throw new Error("path must stay inside repository root");
45
+ }
46
+ return target;
47
+ }
48
+
49
+ export function run(root: string, executable: string, args: string[], env: Record<string, string> = {}) {
50
+ const cwd = realpathSync(root);
51
+ const result = spawnSync(executable, args, {
52
+ cwd,
53
+ encoding: "utf8",
54
+ env: { ...process.env, ...env },
55
+ });
56
+ return {
57
+ exitCode: result.status ?? 1,
58
+ stdout: result.stdout ?? "",
59
+ stderr: result.stderr ?? result.error?.message ?? "",
60
+ cwd,
61
+ };
62
+ }
package/src/state.ts ADDED
@@ -0,0 +1,22 @@
1
+ export type WorkflowState = { spec: string; plan: string; sdd: string };
2
+
3
+ export class WorkflowStateStore {
4
+ #sessions = new Map<string, WorkflowState>();
5
+
6
+ constructor() {}
7
+
8
+ set(sessionID: string, state: WorkflowState) {
9
+ this.#sessions.set(sessionID, { spec: state.spec, plan: state.plan, sdd: state.sdd });
10
+ }
11
+
12
+ get(sessionID: string) {
13
+ return this.#sessions.get(sessionID);
14
+ }
15
+
16
+ compactionContext(sessionID: string) {
17
+ const value = this.get(sessionID);
18
+ return value
19
+ ? `Active workflow:\nSpec: ${value.spec}\nPlan: ${value.plan}\nSDD: ${value.sdd}`
20
+ : null;
21
+ }
22
+ }
@@ -0,0 +1,42 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { fail, ok } from "../core";
3
+ import { linkDocsRepo, listSpecs, promoteSpec } from "../core/docs-repo";
4
+
5
+ const output = (value: unknown) => JSON.stringify(value, null, 2);
6
+
7
+ export function createDocsRepoTools() {
8
+ return {
9
+ workflow_docs_repo_link: tool({
10
+ description: "Link the component docs repo in the toolkit config (validates git repo + features/)",
11
+ args: {
12
+ path: tool.schema.string(),
13
+ confirmed: tool.schema.boolean(),
14
+ },
15
+ execute: async ({ path: docsPath, confirmed }, _context) => {
16
+ const result = linkDocsRepo(docsPath, confirmed);
17
+ return output(result.ok ? ok({ path: result.path }) : fail(result.error));
18
+ },
19
+ }),
20
+ workflow_docs_list: tool({
21
+ description: "List local specs (docs/<slug>/spec.md) with docs-repo promotion status",
22
+ args: {},
23
+ execute: async (_input, context) => {
24
+ const result = listSpecs(context.directory);
25
+ return output(ok(result));
26
+ },
27
+ }),
28
+ workflow_docs_promote: tool({
29
+ description: "Promote a spec (+plan) to the linked docs repo features/YYYY-MM-<slug>/ with quality gate",
30
+ args: {
31
+ slug: tool.schema.string(),
32
+ confirmed: tool.schema.boolean(),
33
+ force: tool.schema.boolean().optional(),
34
+ },
35
+ execute: async ({ slug, confirmed, force }, context) => {
36
+ const result = promoteSpec(context.directory, slug, { confirmed, force });
37
+ if (result.ok) return output(ok({ target_dir: result.target_dir, files: result.files, index_updated: result.index_updated }));
38
+ return output(fail(result.error, { findings: result.findings ?? [] } as never));
39
+ },
40
+ }),
41
+ };
42
+ }