@brainervirus/workit-core 0.6.0 → 0.6.1
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.
- package/package.json +9 -9
- package/scripts/_shared/common.sh +18 -3
- package/scripts/install-opencode-plugin.sh +14 -9
- package/src/core/branch.ts +143 -48
- package/src/core/changelog.ts +17 -14
- package/src/core/config-guard.ts +9 -2
- package/src/core/config.ts +48 -15
- package/src/core/detector.ts +22 -11
- package/src/core/docs-repo.ts +49 -14
- package/src/core/docs-validate.ts +163 -37
- package/src/core/flow-state.ts +6 -2
- package/src/core/gitignore.ts +11 -2
- package/src/core/handoff-context.ts +18 -5
- package/src/core/hygiene.ts +27 -5
- package/src/core/init.ts +86 -21
- package/src/core/parse-sections.ts +2 -2
- package/src/core/plan-tasks.ts +13 -3
- package/src/core/ports/youtrack-api.ts +3 -1
- package/src/core/ports/youtrack-config.ts +1 -3
- package/src/core/pr-create.ts +47 -15
- package/src/core/present.ts +11 -2
- package/src/core/reminder.ts +1 -2
- package/src/core/repo-tool.ts +4 -1
- package/src/core/rules.ts +10 -7
- package/src/core/scripts.ts +7 -2
- package/src/core/sdd.ts +11 -3
- package/src/core/templates.ts +14 -4
- package/src/core/vcs-config.ts +93 -34
- package/src/core/verify-parse.ts +4 -2
- package/src/core/workspaces.ts +2 -2
- package/src/core/youtrack.ts +231 -56
- package/src/core.ts +18 -3
- package/src/tools/docs-repo.ts +12 -3
- package/src/tools/flow.ts +24 -13
- package/src/tools/handoff.ts +28 -23
- package/src/tools/present.ts +14 -10
- package/src/tools/repo.ts +220 -87
- package/src/tools/sdd.ts +93 -66
- package/src/tools/youtrack.ts +115 -52
- package/templates/superpowers-doc-contract.md +1 -1
package/src/core/youtrack.ts
CHANGED
|
@@ -11,8 +11,7 @@ const TOKEN_PLACEHOLDER = "YOUR_TOKEN_HERE";
|
|
|
11
11
|
// Port of scripts/youtrack/config.sh chain: WORKFLOW_YOUTRACK_CONFIG ->
|
|
12
12
|
// configDir() (XDG_CONFIG_HOME / HOME .config + workit).
|
|
13
13
|
export const youTrackConfigPath = (): string =>
|
|
14
|
-
process.env.WORKFLOW_YOUTRACK_CONFIG ??
|
|
15
|
-
path.join(configDir(), "youtrack.json");
|
|
14
|
+
process.env.WORKFLOW_YOUTRACK_CONFIG ?? path.join(configDir(), "youtrack.json");
|
|
16
15
|
|
|
17
16
|
const youTrackTokenModeOk = (p: string): boolean => {
|
|
18
17
|
if (process.platform === "win32") return true;
|
|
@@ -20,7 +19,9 @@ const youTrackTokenModeOk = (p: string): boolean => {
|
|
|
20
19
|
return mode === 0o600;
|
|
21
20
|
};
|
|
22
21
|
|
|
23
|
-
function readYouTrackConfig(
|
|
22
|
+
function readYouTrackConfig(
|
|
23
|
+
required: boolean,
|
|
24
|
+
): { config: Record<string, any>; path: string } | { error: string } {
|
|
24
25
|
const cfgPath = youTrackConfigPath();
|
|
25
26
|
if (!fs.existsSync(cfgPath)) {
|
|
26
27
|
return required ? { error: "ERROR: missing youtrack.json" } : { config: {}, path: cfgPath };
|
|
@@ -40,7 +41,9 @@ export function youTrackConfigLoad(): { data: Record<string, any> } | { error: s
|
|
|
40
41
|
const cfgPath = loaded.path;
|
|
41
42
|
const tokenFile = String(loaded.config.tokenFile ?? "");
|
|
42
43
|
const tokenPath = tokenFile
|
|
43
|
-
?
|
|
44
|
+
? path.isAbsolute(tokenFile)
|
|
45
|
+
? path.resolve(tokenFile)
|
|
46
|
+
: path.resolve(process.cwd(), tokenFile)
|
|
44
47
|
: "";
|
|
45
48
|
if (!tokenPath || !fs.existsSync(tokenPath)) {
|
|
46
49
|
return { error: "missing youtrack.token" };
|
|
@@ -60,17 +63,29 @@ export function youTrackConfigLoad(): { data: Record<string, any> } | { error: s
|
|
|
60
63
|
return { data: redacted };
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
function tzParts(
|
|
66
|
+
function tzParts(
|
|
67
|
+
date: Date,
|
|
68
|
+
tz: string,
|
|
69
|
+
): { y: string; m: string; d: string; hour: string; minute: string } {
|
|
64
70
|
const parts = new Intl.DateTimeFormat("en-GB", {
|
|
65
|
-
timeZone: tz,
|
|
66
|
-
|
|
71
|
+
timeZone: tz,
|
|
72
|
+
year: "numeric",
|
|
73
|
+
month: "2-digit",
|
|
74
|
+
day: "2-digit",
|
|
75
|
+
hour: "2-digit",
|
|
76
|
+
minute: "2-digit",
|
|
77
|
+
hourCycle: "h23",
|
|
67
78
|
}).formatToParts(date);
|
|
68
79
|
const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
|
|
69
80
|
return { y: map.year, m: map.month, d: map.day, hour: map.hour, minute: map.minute };
|
|
70
81
|
}
|
|
71
82
|
|
|
72
83
|
/** Port of scripts/youtrack/greeting.sh. */
|
|
73
|
-
export function youTrackGreeting(configOverride?: string): {
|
|
84
|
+
export function youTrackGreeting(configOverride?: string): {
|
|
85
|
+
stdout: string;
|
|
86
|
+
exitCode: number;
|
|
87
|
+
stderr: string;
|
|
88
|
+
} {
|
|
74
89
|
const cfgPath = configOverride ?? youTrackConfigPath();
|
|
75
90
|
try {
|
|
76
91
|
const config = JSON.parse(fs.readFileSync(cfgPath, "utf8")) as Record<string, any>;
|
|
@@ -81,20 +96,29 @@ export function youTrackGreeting(configOverride?: string): { stdout: string; exi
|
|
|
81
96
|
const cutoffHour = Number(cutoff[0]);
|
|
82
97
|
const cutoffMinute = Number(cutoff[1] ?? 0);
|
|
83
98
|
const greetings = (config.greetings ?? {}) as Record<string, string>;
|
|
84
|
-
const isMorning =
|
|
99
|
+
const isMorning =
|
|
100
|
+
Number(hour) < cutoffHour || (Number(hour) === cutoffHour && Number(minute) < cutoffMinute);
|
|
85
101
|
const greeting = isMorning
|
|
86
|
-
? greetings.morning ?? "buenos días"
|
|
87
|
-
: greetings.afternoon ?? "buenas tardes";
|
|
102
|
+
? (greetings.morning ?? "buenos días")
|
|
103
|
+
: (greetings.afternoon ?? "buenas tardes");
|
|
88
104
|
const mention = String(config.defaultMention ?? "Alejandra.Flores");
|
|
89
|
-
void y;
|
|
105
|
+
void y;
|
|
106
|
+
void m;
|
|
107
|
+
void d;
|
|
90
108
|
return { stdout: `@${mention} Hola, ${greeting}.\n`, exitCode: 0, stderr: "" };
|
|
91
109
|
} catch (err) {
|
|
92
|
-
return {
|
|
110
|
+
return {
|
|
111
|
+
stdout: "",
|
|
112
|
+
exitCode: 1,
|
|
113
|
+
stderr: err instanceof Error ? err.message : "greeting failed",
|
|
114
|
+
};
|
|
93
115
|
}
|
|
94
116
|
}
|
|
95
117
|
|
|
96
118
|
/** Port of scripts/youtrack/parse-duration.sh. */
|
|
97
|
-
export function youTrackParseDuration(
|
|
119
|
+
export function youTrackParseDuration(
|
|
120
|
+
text: string,
|
|
121
|
+
): { data: { minutes: number; text: string } } | { error: string } {
|
|
98
122
|
const lower = String(text).toLowerCase().trim();
|
|
99
123
|
let total = 0;
|
|
100
124
|
for (const match of lower.matchAll(/(\d+)\s*h/g)) total += Number(match[1]) * 60;
|
|
@@ -105,13 +129,17 @@ export function youTrackParseDuration(text: string): { data: { minutes: number;
|
|
|
105
129
|
}
|
|
106
130
|
|
|
107
131
|
/** Port of scripts/youtrack/work-date-ms.sh — resolve work-item date as epoch ms. */
|
|
108
|
-
export function youTrackWorkDateMs(
|
|
132
|
+
export function youTrackWorkDateMs(
|
|
133
|
+
dateRaw: string,
|
|
134
|
+
): { data: { dateMs: number; timezone: string; localDate: string } } | { error: string } {
|
|
109
135
|
const cfgPath = youTrackConfigPath();
|
|
110
136
|
let tz = "America/Santiago";
|
|
111
137
|
try {
|
|
112
138
|
const config = JSON.parse(fs.readFileSync(cfgPath, "utf8")) as Record<string, any>;
|
|
113
139
|
tz = String(config.timezone ?? "America/Santiago");
|
|
114
|
-
} catch {
|
|
140
|
+
} catch {
|
|
141
|
+
/* defaults */
|
|
142
|
+
}
|
|
115
143
|
const raw = dateRaw || "auto";
|
|
116
144
|
try {
|
|
117
145
|
if (raw === "auto" || !raw) {
|
|
@@ -128,7 +156,13 @@ export function youTrackWorkDateMs(dateRaw: string): { data: { dateMs: number; t
|
|
|
128
156
|
const [y, m, d] = raw.split("-").map(Number);
|
|
129
157
|
const iso = `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00`;
|
|
130
158
|
const dateMs = Math.floor(Date.parse(iso) / 86400000) * 86400000;
|
|
131
|
-
return {
|
|
159
|
+
return {
|
|
160
|
+
data: {
|
|
161
|
+
dateMs,
|
|
162
|
+
timezone: tz,
|
|
163
|
+
localDate: `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
132
166
|
} catch (err) {
|
|
133
167
|
return { error: err instanceof Error ? err.message : "could not resolve date" };
|
|
134
168
|
}
|
|
@@ -139,14 +173,19 @@ const youTrackToken = (): { token: string; base: string } | { error: string } =>
|
|
|
139
173
|
if ("error" in loaded) return loaded;
|
|
140
174
|
const tokenFile = String(loaded.config.tokenFile ?? "");
|
|
141
175
|
const tokenPath = tokenFile
|
|
142
|
-
?
|
|
176
|
+
? path.isAbsolute(tokenFile)
|
|
177
|
+
? path.resolve(tokenFile)
|
|
178
|
+
: path.resolve(process.cwd(), tokenFile)
|
|
143
179
|
: "";
|
|
144
180
|
if (!tokenPath || !fs.existsSync(tokenPath)) return { error: "missing youtrack.token" };
|
|
145
181
|
if (!youTrackTokenModeOk(tokenPath)) return { error: "youtrack.token mode must be 0600" };
|
|
146
182
|
const token = fs.readFileSync(tokenPath, "utf8").trim();
|
|
147
183
|
if (!token) return { error: "empty token file" };
|
|
148
184
|
if (token === TOKEN_PLACEHOLDER || token.startsWith(TOKEN_PLACEHOLDER)) {
|
|
149
|
-
return {
|
|
185
|
+
return {
|
|
186
|
+
error:
|
|
187
|
+
"token file still has placeholder YOUR_TOKEN_HERE — edit the file locally, then run /wk-status",
|
|
188
|
+
};
|
|
150
189
|
}
|
|
151
190
|
const base = String(loaded.config.baseUrl ?? "").replace(/\/+$/, "");
|
|
152
191
|
if (!base) return { error: "baseUrl missing in config" };
|
|
@@ -159,11 +198,17 @@ function youTrackCurl(args: string[]): { status: number; stdout: string; stderr:
|
|
|
159
198
|
}
|
|
160
199
|
|
|
161
200
|
/** Port of scripts/youtrack/api.sh — log-time / post-comment with the WORKFLOW_YT_WRITE guard. */
|
|
162
|
-
export function youTrackApi(
|
|
201
|
+
export function youTrackApi(
|
|
202
|
+
args: string[],
|
|
203
|
+
writeFlag = process.env.WORKFLOW_YT_WRITE ?? "",
|
|
204
|
+
): { data: Record<string, any> } | { error: string } {
|
|
163
205
|
const cmd = args[0];
|
|
164
206
|
if (cmd === "log-time" || cmd === "post-comment") {
|
|
165
207
|
if (writeFlag !== "1") {
|
|
166
|
-
return {
|
|
208
|
+
return {
|
|
209
|
+
error:
|
|
210
|
+
"YouTrack write operations require WORKFLOW_YT_WRITE=1 (refusing to mutate production)",
|
|
211
|
+
};
|
|
167
212
|
}
|
|
168
213
|
}
|
|
169
214
|
const creds = youTrackToken();
|
|
@@ -178,13 +223,25 @@ export function youTrackApi(args: string[], writeFlag = process.env.WORKFLOW_YT_
|
|
|
178
223
|
if ("error" in dateMs) return dateMs;
|
|
179
224
|
const body = JSON.stringify({ duration: { minutes }, text, date: dateMs.data.dateMs });
|
|
180
225
|
const out = youTrackCurl([
|
|
181
|
-
...auth,
|
|
226
|
+
...auth,
|
|
227
|
+
"-H",
|
|
228
|
+
"Content-Type: application/json",
|
|
229
|
+
"-d",
|
|
230
|
+
body,
|
|
182
231
|
`${base}/api/issues/${issue}/timeTracking/workItems?fields=id,idReadable`,
|
|
183
232
|
]);
|
|
184
233
|
if (out.status !== 0) return { error: "YouTrack HTTP request failed" };
|
|
185
234
|
try {
|
|
186
235
|
const created = JSON.parse(out.stdout) as Record<string, any>;
|
|
187
|
-
return {
|
|
236
|
+
return {
|
|
237
|
+
data: {
|
|
238
|
+
ok: true,
|
|
239
|
+
issueId: issue,
|
|
240
|
+
workItemId: created.id,
|
|
241
|
+
dateMs: dateMs.data.dateMs,
|
|
242
|
+
minutes,
|
|
243
|
+
},
|
|
244
|
+
};
|
|
188
245
|
} catch {
|
|
189
246
|
return { error: "invalid JSON from YouTrack API" };
|
|
190
247
|
}
|
|
@@ -193,7 +250,11 @@ export function youTrackApi(args: string[], writeFlag = process.env.WORKFLOW_YT_
|
|
|
193
250
|
const [issue, text] = args.slice(1);
|
|
194
251
|
const body = JSON.stringify({ text });
|
|
195
252
|
const out = youTrackCurl([
|
|
196
|
-
...auth,
|
|
253
|
+
...auth,
|
|
254
|
+
"-H",
|
|
255
|
+
"Content-Type: application/json",
|
|
256
|
+
"-d",
|
|
257
|
+
body,
|
|
197
258
|
`${base}/api/issues/${issue}/comments`,
|
|
198
259
|
]);
|
|
199
260
|
if (out.status !== 0) return { error: "YouTrack HTTP request failed" };
|
|
@@ -203,18 +264,26 @@ export function youTrackApi(args: string[], writeFlag = process.env.WORKFLOW_YT_
|
|
|
203
264
|
}
|
|
204
265
|
|
|
205
266
|
/** Port of scripts/youtrack/verify-token.sh — read-only GET /api/users/me. */
|
|
206
|
-
export function youTrackVerifyToken():
|
|
267
|
+
export function youTrackVerifyToken():
|
|
268
|
+
| { data: Record<string, any> }
|
|
269
|
+
| { error: string; http_status?: number; path?: string } {
|
|
207
270
|
const cfgPath = youTrackConfigPath();
|
|
208
271
|
if (!fs.existsSync(cfgPath)) return { error: "missing youtrack.json" };
|
|
209
272
|
const creds = youTrackToken();
|
|
210
273
|
if ("error" in creds) return { error: creds.error };
|
|
211
274
|
const { token, base } = creds;
|
|
212
275
|
|
|
213
|
-
const me = youTrackCurl([
|
|
214
|
-
|
|
276
|
+
const me = youTrackCurl([
|
|
277
|
+
"-H",
|
|
278
|
+
`Authorization: Bearer ${token}`,
|
|
279
|
+
"-H",
|
|
280
|
+
"Accept: application/json",
|
|
281
|
+
`${base}/api/users/me?fields=id,login,name,email`,
|
|
282
|
+
]);
|
|
215
283
|
if (me.status !== 0) {
|
|
216
284
|
const body = me.stderr.trim() || me.stdout.trim();
|
|
217
|
-
const err =
|
|
285
|
+
const err =
|
|
286
|
+
me.status === 22 ? "authentication failed (401/403)" : `HTTP error: ${body.slice(0, 200)}`;
|
|
218
287
|
return { error: err, http_status: me.status };
|
|
219
288
|
}
|
|
220
289
|
let user: Record<string, any>;
|
|
@@ -224,21 +293,33 @@ export function youTrackVerifyToken(): { data: Record<string, any> } | { error:
|
|
|
224
293
|
return { error: "invalid JSON from YouTrack /api/users/me" };
|
|
225
294
|
}
|
|
226
295
|
const result: Record<string, any> = {
|
|
227
|
-
ok: true,
|
|
228
|
-
|
|
296
|
+
ok: true,
|
|
297
|
+
method: "GET /api/users/me",
|
|
298
|
+
baseUrl: base,
|
|
299
|
+
login: user.login,
|
|
300
|
+
name: user.name,
|
|
301
|
+
email: user.email,
|
|
302
|
+
id: user.id,
|
|
229
303
|
};
|
|
230
304
|
const meeting = readYouTrackConfig(false);
|
|
231
305
|
const meetingIssue = "config" in meeting ? meeting.config.meetingIssue : undefined;
|
|
232
306
|
if (meetingIssue) {
|
|
233
|
-
const issue = youTrackCurl([
|
|
234
|
-
|
|
307
|
+
const issue = youTrackCurl([
|
|
308
|
+
"-H",
|
|
309
|
+
`Authorization: Bearer ${token}`,
|
|
310
|
+
"-H",
|
|
311
|
+
"Accept: application/json",
|
|
312
|
+
`${base}/api/issues/${meetingIssue}?fields=id,idReadable,summary`,
|
|
313
|
+
]);
|
|
235
314
|
if (issue.status === 0) {
|
|
236
315
|
try {
|
|
237
316
|
const parsed = JSON.parse(issue.stdout) as Record<string, any>;
|
|
238
317
|
result.meetingIssue = meetingIssue;
|
|
239
318
|
result.meetingIssueReadable = true;
|
|
240
319
|
result.meetingIssueSummary = parsed.summary;
|
|
241
|
-
} catch {
|
|
320
|
+
} catch {
|
|
321
|
+
/* unreadable */
|
|
322
|
+
}
|
|
242
323
|
} else {
|
|
243
324
|
result.meetingIssue = meetingIssue;
|
|
244
325
|
result.meetingIssueReadable = false;
|
|
@@ -256,7 +337,9 @@ export function youTrackTokenCreateUrl(): { data: Record<string, any> } {
|
|
|
256
337
|
const cfgPath = loaded && "config" in loaded ? loaded.path : youTrackConfigPath();
|
|
257
338
|
const defaults = (config.tokenDefaults ?? {}) as Record<string, any>;
|
|
258
339
|
const name = String(defaults.name ?? tokenName);
|
|
259
|
-
const desc = String(
|
|
340
|
+
const desc = String(
|
|
341
|
+
defaults.description ?? "OpenCode workit — /wk-issue-update and /wk-meetings",
|
|
342
|
+
);
|
|
260
343
|
const scopes = Array.isArray(defaults.scopes) ? defaults.scopes : ["YouTrack"];
|
|
261
344
|
const base = String(config.baseUrl ?? "https://enghouseamg.youtrack.cloud").replace(/\/+$/, "");
|
|
262
345
|
const tokenFile = String(config.tokenFile ?? path.join(path.dirname(cfgPath), "youtrack.token"));
|
|
@@ -284,7 +367,9 @@ export function youTrackTokenCreateUrl(): { data: Record<string, any> } {
|
|
|
284
367
|
}
|
|
285
368
|
|
|
286
369
|
/** Parse bare id (NSR-40) or YouTrack URL into issue id. */
|
|
287
|
-
export function parseIssueRef(
|
|
370
|
+
export function parseIssueRef(
|
|
371
|
+
input: unknown,
|
|
372
|
+
): { issueId: string; source: string } | { error: string } {
|
|
288
373
|
const trimmed = String(input ?? "").trim();
|
|
289
374
|
if (!trimmed) return { error: "empty issue reference" };
|
|
290
375
|
|
|
@@ -319,11 +404,17 @@ const defaultScripts: YouTrackScripts = {
|
|
|
319
404
|
api: (args) => youTrackApi(args, process.env.WORKFLOW_YT_WRITE ?? ""),
|
|
320
405
|
};
|
|
321
406
|
|
|
322
|
-
export function verifyYouTrackToken(
|
|
407
|
+
export function verifyYouTrackToken(
|
|
408
|
+
scripts: YouTrackScripts = defaultScripts,
|
|
409
|
+
): Record<string, any> {
|
|
323
410
|
return scripts.config();
|
|
324
411
|
}
|
|
325
412
|
|
|
326
|
-
function resolveYouTrackFromPaths(
|
|
413
|
+
function resolveYouTrackFromPaths(
|
|
414
|
+
spec_path: string | undefined,
|
|
415
|
+
plan_path: string | undefined,
|
|
416
|
+
workspace_root: string,
|
|
417
|
+
): string | null {
|
|
327
418
|
const root = resolveWorkspaceRoot(workspace_root);
|
|
328
419
|
for (const rel of [spec_path, plan_path].filter(Boolean) as string[]) {
|
|
329
420
|
const full = path.isAbsolute(rel) ? rel : path.join(root, rel);
|
|
@@ -358,7 +449,26 @@ function meetingOptionsFromConfig(cfg: any): Record<string, any>[] {
|
|
|
358
449
|
];
|
|
359
450
|
}
|
|
360
451
|
|
|
361
|
-
export function context(
|
|
452
|
+
export function context(
|
|
453
|
+
{
|
|
454
|
+
spec_path,
|
|
455
|
+
plan_path,
|
|
456
|
+
issue_id,
|
|
457
|
+
issue_url,
|
|
458
|
+
issue_ref,
|
|
459
|
+
mode,
|
|
460
|
+
workspace_root,
|
|
461
|
+
}: {
|
|
462
|
+
spec_path?: string;
|
|
463
|
+
plan_path?: string;
|
|
464
|
+
issue_id?: string;
|
|
465
|
+
issue_url?: string;
|
|
466
|
+
issue_ref?: string;
|
|
467
|
+
mode?: string;
|
|
468
|
+
workspace_root: string;
|
|
469
|
+
},
|
|
470
|
+
scripts: YouTrackScripts = defaultScripts,
|
|
471
|
+
): Record<string, any> {
|
|
362
472
|
const cfg = scripts.config();
|
|
363
473
|
if (cfg.error) return { error: cfg.error };
|
|
364
474
|
|
|
@@ -390,7 +500,8 @@ export function context({ spec_path, plan_path, issue_id, issue_url, issue_ref,
|
|
|
390
500
|
if (!issue) issue = resolveYouTrackFromPaths(spec_path, plan_path, workspace_root) ?? undefined;
|
|
391
501
|
if (!issue || !ISSUE_RE.test(issue)) {
|
|
392
502
|
return {
|
|
393
|
-
error:
|
|
503
|
+
error:
|
|
504
|
+
"invalid or missing issue id — pass issue_url, issue_id, or spec/plan with **YouTrack:**",
|
|
394
505
|
requiresIssueInput: true,
|
|
395
506
|
};
|
|
396
507
|
}
|
|
@@ -411,39 +522,86 @@ export function context({ spec_path, plan_path, issue_id, issue_url, issue_ref,
|
|
|
411
522
|
};
|
|
412
523
|
}
|
|
413
524
|
|
|
414
|
-
export function parseDuration(
|
|
525
|
+
export function parseDuration(
|
|
526
|
+
text: string,
|
|
527
|
+
_workspace_root: string,
|
|
528
|
+
scripts: YouTrackScripts = defaultScripts,
|
|
529
|
+
): Record<string, any> {
|
|
415
530
|
const out = scripts.parseDuration(text);
|
|
416
531
|
if (out.error) return { error: out.error };
|
|
417
532
|
return out.data;
|
|
418
533
|
}
|
|
419
534
|
|
|
420
|
-
export function logTime(
|
|
535
|
+
export function logTime(
|
|
536
|
+
{
|
|
537
|
+
issueId,
|
|
538
|
+
minutes,
|
|
539
|
+
text,
|
|
540
|
+
date,
|
|
541
|
+
dateMs,
|
|
542
|
+
workspace_root: _workspace_root,
|
|
543
|
+
}: {
|
|
544
|
+
issueId: string;
|
|
545
|
+
minutes: number;
|
|
546
|
+
text?: string;
|
|
547
|
+
date?: string;
|
|
548
|
+
dateMs?: number;
|
|
549
|
+
workspace_root: string;
|
|
550
|
+
},
|
|
551
|
+
scripts: YouTrackScripts = defaultScripts,
|
|
552
|
+
): Record<string, any> {
|
|
421
553
|
if (!issueId || !ISSUE_RE.test(issueId)) return { error: "invalid issueId" };
|
|
422
554
|
if (!minutes || minutes <= 0) return { error: "minutes must be positive" };
|
|
423
555
|
const workText = text ?? "workit";
|
|
424
556
|
const dateArg =
|
|
425
|
-
dateMs != null
|
|
426
|
-
? String(dateMs)
|
|
427
|
-
: date && /^\d+$/.test(String(date))
|
|
428
|
-
? String(date)
|
|
429
|
-
: "auto";
|
|
557
|
+
dateMs != null ? String(dateMs) : date && /^\d+$/.test(String(date)) ? String(date) : "auto";
|
|
430
558
|
const out = scripts.api(["log-time", issueId, String(minutes), workText, dateArg]);
|
|
431
559
|
if (out.error) return { error: out.error };
|
|
432
560
|
return { issueId, minutes, text: workText, ...out.data, ok: true };
|
|
433
561
|
}
|
|
434
562
|
|
|
435
|
-
|
|
436
|
-
|
|
563
|
+
export function buildDraft({
|
|
564
|
+
issueId,
|
|
565
|
+
projectName,
|
|
566
|
+
userNotes,
|
|
567
|
+
greeting,
|
|
568
|
+
facts,
|
|
569
|
+
includeProjectOpener,
|
|
570
|
+
includeFacts,
|
|
571
|
+
}: {
|
|
572
|
+
issueId: string;
|
|
573
|
+
projectName?: string;
|
|
574
|
+
userNotes?: string;
|
|
575
|
+
greeting?: string;
|
|
576
|
+
facts?: any;
|
|
577
|
+
includeProjectOpener?: boolean;
|
|
578
|
+
includeFacts?: boolean;
|
|
579
|
+
}): Record<string, any> {
|
|
437
580
|
const tpl = readTemplate("issue-update").content;
|
|
438
581
|
const para = (value: string): string => (value ? `\n\n${value}` : "");
|
|
439
582
|
const filled = tpl
|
|
440
583
|
.replaceAll("{{greetingSection}}", para(greeting ? `${greeting}` : ""))
|
|
441
|
-
.replaceAll(
|
|
584
|
+
.replaceAll(
|
|
585
|
+
"{{projectSection}}",
|
|
586
|
+
para(includeProjectOpener && projectName ? `Hoy estuve full con ${projectName}.` : ""),
|
|
587
|
+
)
|
|
442
588
|
.replaceAll("{{userNotesSection}}", para((userNotes ?? "").trim()))
|
|
443
|
-
.replaceAll(
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
589
|
+
.replaceAll(
|
|
590
|
+
"{{progressSection}}",
|
|
591
|
+
para(
|
|
592
|
+
includeFacts && facts?.progress_excerpt?.length
|
|
593
|
+
? facts.progress_excerpt.map((l: string) => `- ${l}`).join("\n")
|
|
594
|
+
: "",
|
|
595
|
+
),
|
|
596
|
+
)
|
|
597
|
+
.replaceAll(
|
|
598
|
+
"{{gitCommitsSection}}",
|
|
599
|
+
para(
|
|
600
|
+
includeFacts && facts?.git_commits?.length
|
|
601
|
+
? facts.git_commits.map((c: string) => `- ${c}`).join("\n")
|
|
602
|
+
: "",
|
|
603
|
+
),
|
|
604
|
+
);
|
|
447
605
|
const collapsed = filled.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
448
606
|
// Bare draft keeps the header's trailing blank line (matches legacy output);
|
|
449
607
|
// drafts with sections end right after the last one.
|
|
@@ -451,14 +609,31 @@ export function buildDraft({ issueId, projectName, userNotes, greeting, facts, i
|
|
|
451
609
|
return { issueId, markdown };
|
|
452
610
|
}
|
|
453
611
|
|
|
454
|
-
export function postUpdate(
|
|
612
|
+
export function postUpdate(
|
|
613
|
+
{
|
|
614
|
+
confirmed,
|
|
615
|
+
issueId,
|
|
616
|
+
markdown,
|
|
617
|
+
minutes,
|
|
618
|
+
workspace_root,
|
|
619
|
+
}: {
|
|
620
|
+
confirmed: boolean;
|
|
621
|
+
issueId: string;
|
|
622
|
+
markdown: string;
|
|
623
|
+
minutes?: number;
|
|
624
|
+
workspace_root?: string;
|
|
625
|
+
},
|
|
626
|
+
operations?: Record<string, any>,
|
|
627
|
+
): Record<string, any> {
|
|
455
628
|
operations ??= {};
|
|
456
629
|
if (!confirmed) return { error: "confirmed: true required" };
|
|
457
630
|
if (!issueId || !ISSUE_RE.test(issueId)) return { error: "invalid issueId" };
|
|
458
631
|
if (!markdown?.trim()) return { error: "markdown required" };
|
|
459
632
|
|
|
460
|
-
const postComment =
|
|
461
|
-
|
|
633
|
+
const postComment =
|
|
634
|
+
operations.postComment ??
|
|
635
|
+
((id: string, text: string, _root: string) =>
|
|
636
|
+
youTrackApi(["post-comment", id, text], process.env.WORKFLOW_YT_WRITE ?? ""));
|
|
462
637
|
const logTimeOperation = operations.logTime ?? logTime;
|
|
463
638
|
const comment = postComment(issueId, markdown, workspace_root);
|
|
464
639
|
if (comment.error) return { error: comment.error };
|
package/src/core.ts
CHANGED
|
@@ -7,7 +7,11 @@ export type Result<T> =
|
|
|
7
7
|
| { ok: false; data: T | null; error: string };
|
|
8
8
|
|
|
9
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> => ({
|
|
10
|
+
export const fail = <T = never>(error: string, data: T | null = null): Result<T> => ({
|
|
11
|
+
ok: false,
|
|
12
|
+
data,
|
|
13
|
+
error,
|
|
14
|
+
});
|
|
11
15
|
|
|
12
16
|
const revision = /^[A-Za-z0-9@][A-Za-z0-9@._/~^{}-]*$/;
|
|
13
17
|
|
|
@@ -25,7 +29,13 @@ export function gitRevisionParts(value: string): string[] {
|
|
|
25
29
|
|
|
26
30
|
export function resolveGitRevision(root: string, value: string): void {
|
|
27
31
|
for (const part of gitRevisionParts(value)) {
|
|
28
|
-
const result = run(root, "git", [
|
|
32
|
+
const result = run(root, "git", [
|
|
33
|
+
"rev-parse",
|
|
34
|
+
"--verify",
|
|
35
|
+
"--quiet",
|
|
36
|
+
"--end-of-options",
|
|
37
|
+
`${part}^{commit}`,
|
|
38
|
+
]);
|
|
29
39
|
if (result.exitCode !== 0) throw new Error(`invalid Git revision or range: ${value}`);
|
|
30
40
|
}
|
|
31
41
|
}
|
|
@@ -46,7 +56,12 @@ export function resolveInside(root: string, candidate: string): string {
|
|
|
46
56
|
return target;
|
|
47
57
|
}
|
|
48
58
|
|
|
49
|
-
export function run(
|
|
59
|
+
export function run(
|
|
60
|
+
root: string,
|
|
61
|
+
executable: string,
|
|
62
|
+
args: string[],
|
|
63
|
+
env: Record<string, string> = {},
|
|
64
|
+
) {
|
|
50
65
|
const cwd = realpathSync(root);
|
|
51
66
|
const result = spawnSync(executable, args, {
|
|
52
67
|
cwd,
|
package/src/tools/docs-repo.ts
CHANGED
|
@@ -7,7 +7,8 @@ const output = (value: unknown) => JSON.stringify(value, null, 2);
|
|
|
7
7
|
export function createDocsRepoTools() {
|
|
8
8
|
return {
|
|
9
9
|
workflow_docs_repo_link: tool({
|
|
10
|
-
description:
|
|
10
|
+
description:
|
|
11
|
+
"Link the component docs repo in the toolkit config (validates git repo + features/)",
|
|
11
12
|
args: {
|
|
12
13
|
path: tool.schema.string(),
|
|
13
14
|
confirmed: tool.schema.boolean(),
|
|
@@ -26,7 +27,8 @@ export function createDocsRepoTools() {
|
|
|
26
27
|
},
|
|
27
28
|
}),
|
|
28
29
|
workflow_docs_promote: tool({
|
|
29
|
-
description:
|
|
30
|
+
description:
|
|
31
|
+
"Promote a spec (+plan) to the linked docs repo features/YYYY-MM-<slug>/ with quality gate",
|
|
30
32
|
args: {
|
|
31
33
|
slug: tool.schema.string(),
|
|
32
34
|
confirmed: tool.schema.boolean(),
|
|
@@ -34,7 +36,14 @@ export function createDocsRepoTools() {
|
|
|
34
36
|
},
|
|
35
37
|
execute: async ({ slug, confirmed, force }, context) => {
|
|
36
38
|
const result = promoteSpec(context.directory, slug, { confirmed, force });
|
|
37
|
-
if (result.ok)
|
|
39
|
+
if (result.ok)
|
|
40
|
+
return output(
|
|
41
|
+
ok({
|
|
42
|
+
target_dir: result.target_dir,
|
|
43
|
+
files: result.files,
|
|
44
|
+
index_updated: result.index_updated,
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
38
47
|
return output(fail(result.error, { findings: result.findings ?? [] } as never));
|
|
39
48
|
},
|
|
40
49
|
}),
|
package/src/tools/flow.ts
CHANGED
|
@@ -11,8 +11,7 @@ import path from "node:path";
|
|
|
11
11
|
|
|
12
12
|
const output = (value: unknown) => JSON.stringify(value, null, 2);
|
|
13
13
|
|
|
14
|
-
const flowPathFor = (slug: string) =>
|
|
15
|
-
path.posix.join("docs", slug, "sdd", "flow.json");
|
|
14
|
+
const flowPathFor = (slug: string) => path.posix.join("docs", slug, "sdd", "flow.json");
|
|
16
15
|
|
|
17
16
|
export function createFlowTools() {
|
|
18
17
|
return {
|
|
@@ -27,20 +26,23 @@ export function createFlowTools() {
|
|
|
27
26
|
const slug = slugFromPath(plan_path ?? spec_path ?? "");
|
|
28
27
|
if (!slug) return output(fail("plan_path or spec_path required"));
|
|
29
28
|
const state = readFlowState(context.directory, slug);
|
|
30
|
-
return output(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
29
|
+
return output(
|
|
30
|
+
ok({
|
|
31
|
+
slug,
|
|
32
|
+
spec: state.spec,
|
|
33
|
+
plan: state.plan,
|
|
34
|
+
menu: state.menu,
|
|
35
|
+
flow_path: flowPathFor(slug),
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
37
38
|
} catch (error) {
|
|
38
39
|
return output(fail(error instanceof Error ? error.message : "flow status failed"));
|
|
39
40
|
}
|
|
40
41
|
},
|
|
41
42
|
}),
|
|
42
43
|
workflow_spec_approve: tool({
|
|
43
|
-
description:
|
|
44
|
+
description:
|
|
45
|
+
"Advance spec status: first call self_reviewed, second call approved (after user approval)",
|
|
44
46
|
args: {
|
|
45
47
|
confirmed: tool.schema.boolean(),
|
|
46
48
|
spec_path: tool.schema.string(),
|
|
@@ -56,7 +58,8 @@ export function createFlowTools() {
|
|
|
56
58
|
},
|
|
57
59
|
}),
|
|
58
60
|
workflow_plan_approve: tool({
|
|
59
|
-
description:
|
|
61
|
+
description:
|
|
62
|
+
"Advance plan status: first call self_reviewed, second call approved. Requires approved spec.",
|
|
60
63
|
args: {
|
|
61
64
|
confirmed: tool.schema.boolean(),
|
|
62
65
|
plan_path: tool.schema.string(),
|
|
@@ -76,12 +79,20 @@ export function createFlowTools() {
|
|
|
76
79
|
args: {
|
|
77
80
|
confirmed: tool.schema.boolean(),
|
|
78
81
|
plan_path: tool.schema.string(),
|
|
79
|
-
choice: tool.schema.enum([
|
|
82
|
+
choice: tool.schema.enum([
|
|
83
|
+
"subagent-driven",
|
|
84
|
+
"inline",
|
|
85
|
+
"handoff",
|
|
86
|
+
"review-spec",
|
|
87
|
+
"review-plan",
|
|
88
|
+
]),
|
|
80
89
|
},
|
|
81
90
|
execute: async ({ confirmed, plan_path, choice }, context) => {
|
|
82
91
|
const slug = slugFromPath(plan_path);
|
|
83
92
|
const result = recordMenuChoice(context.directory, slug, plan_path, choice, confirmed);
|
|
84
|
-
return output(
|
|
93
|
+
return output(
|
|
94
|
+
result.ok ? ok({ menu: { presented: true, chosen: choice } }) : fail(result.error),
|
|
95
|
+
);
|
|
85
96
|
},
|
|
86
97
|
}),
|
|
87
98
|
};
|