@brainervirus/workit-core 0.6.1 → 0.7.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.
Files changed (81) hide show
  1. package/package.json +3 -7
  2. package/scripts/doctor-check.ts +20 -0
  3. package/scripts/install-cursor-plugin.sh +51 -28
  4. package/scripts/install-opencode-plugin.sh +19 -21
  5. package/scripts/rewrite-workspace-deps.ts +15 -9
  6. package/scripts/sync-runtime.sh +71 -19
  7. package/scripts/vendor-assets.ts +37 -0
  8. package/skills/wk-implement/SKILL.md +2 -2
  9. package/skills/wk-pr/SKILL.md +1 -1
  10. package/src/core/boundary.ts +27 -0
  11. package/src/core/branch-policy.ts +63 -0
  12. package/src/core/branch.ts +30 -16
  13. package/src/core/config.ts +193 -31
  14. package/src/core/docs-layout.ts +251 -0
  15. package/src/core/docs-migration.ts +639 -0
  16. package/src/core/docs-repo.ts +11 -9
  17. package/src/core/docs-validate.ts +18 -6
  18. package/src/core/doctor.ts +801 -0
  19. package/src/core/flow-state.ts +1579 -141
  20. package/src/core/git.ts +22 -5
  21. package/src/{tools/handoff.ts → core/handoff-tools.ts} +5 -57
  22. package/src/core/hygiene.ts +26 -12
  23. package/src/core/init.ts +43 -11
  24. package/src/core/logger.ts +321 -0
  25. package/src/core/package-root.ts +28 -0
  26. package/src/core/ports/init-toolkit-status.ts +1 -1
  27. package/src/core/ports/vcs-verify-token.ts +1 -1
  28. package/src/core/ports/youtrack-api.ts +1 -1
  29. package/src/core/ports/youtrack-verify-token.ts +1 -1
  30. package/src/core/pr-create.ts +116 -21
  31. package/src/core/registration.ts +215 -0
  32. package/src/core/repo-context.ts +447 -0
  33. package/src/core/repo-tools.ts +23 -0
  34. package/src/core/safe-write.ts +22 -0
  35. package/src/core/scripts.ts +3 -44
  36. package/src/core/sdd.ts +45 -28
  37. package/src/core/setup-state.ts +54 -0
  38. package/src/core/setup.ts +1216 -0
  39. package/src/core/skill-manifests.ts +95 -0
  40. package/src/core/support-matrix.ts +12 -0
  41. package/src/core/sync-runtime.ts +348 -0
  42. package/src/core/templates.ts +2 -2
  43. package/src/core/vcs-config.ts +107 -37
  44. package/src/core/verify-project.ts +181 -0
  45. package/src/core/workspaces.ts +136 -17
  46. package/src/core/youtrack-tools.ts +228 -0
  47. package/src/core/youtrack.ts +125 -67
  48. package/templates/execution-contract.md +9 -7
  49. package/templates/superpowers-doc-contract.md +4 -3
  50. package/scripts/_shared/common.sh +0 -173
  51. package/scripts/changelog-context.sh +0 -42
  52. package/scripts/docs-refresh-context.sh +0 -40
  53. package/scripts/init/apply.sh +0 -5
  54. package/scripts/init/status.sh +0 -5
  55. package/scripts/init/toolkit-status.sh +0 -5
  56. package/scripts/pr-create.sh +0 -5
  57. package/scripts/pr-ready-context.sh +0 -88
  58. package/scripts/present/ascii-wireframe.sh +0 -5
  59. package/scripts/present/flow-diagram.sh +0 -5
  60. package/scripts/release-notes-context.sh +0 -40
  61. package/scripts/vcs/config.sh +0 -5
  62. package/scripts/vcs/merged-style.sh +0 -5
  63. package/scripts/vcs/token-create-urls.sh +0 -5
  64. package/scripts/vcs/verify-token.sh +0 -5
  65. package/scripts/verify-project.sh +0 -140
  66. package/scripts/youtrack/api.sh +0 -5
  67. package/scripts/youtrack/config.sh +0 -5
  68. package/scripts/youtrack/greeting.sh +0 -5
  69. package/scripts/youtrack/parse-duration.sh +0 -5
  70. package/scripts/youtrack/token-create-url.sh +0 -5
  71. package/scripts/youtrack/verify-token.sh +0 -5
  72. package/scripts/youtrack/work-date-ms.sh +0 -5
  73. package/src/tools/docs-repo.ts +0 -51
  74. package/src/tools/flow.ts +0 -99
  75. package/src/tools/index.ts +0 -22
  76. package/src/tools/present.ts +0 -49
  77. package/src/tools/repo.ts +0 -490
  78. package/src/tools/rules.ts +0 -30
  79. package/src/tools/sdd.ts +0 -216
  80. package/src/tools/templates.ts +0 -27
  81. package/src/tools/youtrack.ts +0 -423
package/src/tools/sdd.ts DELETED
@@ -1,216 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- import path from "node:path";
3
- import { tool, type ToolContext } from "@opencode-ai/plugin";
4
- import { fail, ok, resolveGitRevision, resolveInside } from "../core";
5
- import { resolveBranch, docsBranch } from "../core/branch";
6
- import { docsValidate } from "../core/docs-validate";
7
- import { parsePlanTasks, resolveHandoffBranch } from "../core/plan-tasks";
8
- import { sddAppendProgress, sddContext, sddReviewPackage, sddTaskBrief } from "../core/sdd";
9
- import { WorkflowStateStore } from "../state";
10
-
11
- const output = (value: unknown) => JSON.stringify(value, null, 2);
12
- const requireConfirmed = (confirmed: boolean) =>
13
- confirmed === true ? null : output(fail("confirmed: true required"));
14
-
15
- const relativePath = (root: string, candidate: string) => {
16
- if (path.isAbsolute(candidate)) throw new Error("path must be repository-relative");
17
- resolveInside(root, candidate);
18
- return candidate;
19
- };
20
-
21
- const normalize = (value: Record<string, unknown>) => {
22
- if (value.error) return fail(String(value.error));
23
- if (value.ok === false) return fail("legacy operation reported failure");
24
- const { ok: _legacyOk, ...data } = value;
25
- return ok(data);
26
- };
27
-
28
- const invoke = (operation: () => Record<string, unknown>) => {
29
- try {
30
- return output(normalize(operation()));
31
- } catch (error) {
32
- return output(fail(error instanceof Error ? error.message : "workflow operation failed"));
33
- }
34
- };
35
-
36
- const planPaths = (root: string, planPath: string, suppliedSpecPath?: string) => {
37
- const resolved = resolveInside(root, planPath);
38
- const match = readFileSync(resolved, "utf8").match(/^\*\*Spec:\*\*\s*(?:`([^`]+)`|(\S+))/m);
39
- const spec_path = suppliedSpecPath ?? match?.[1] ?? match?.[2] ?? "";
40
- if (spec_path) relativePath(root, spec_path);
41
- return {
42
- spec_path,
43
- plan_path: planPath,
44
- sdd_dir: path.posix.join("docs", path.basename(path.dirname(planPath)), "sdd"),
45
- };
46
- };
47
-
48
- export function createSddTools(state: WorkflowStateStore) {
49
- const record = (context: ToolContext, data: Record<string, unknown>) =>
50
- state.set(context.sessionID, {
51
- spec: String(data.spec_path ?? ""),
52
- plan: String(data.plan_path ?? ""),
53
- sdd: String(data.sdd_dir ?? ""),
54
- });
55
-
56
- return {
57
- workflow_docs_branch: tool({
58
- description:
59
- "Resolve branch for spec/plan authors: keep current feature|bugfix or create from the configured base",
60
- args: {
61
- plan_path: tool.schema.string().optional(),
62
- kind: tool.schema.enum(["feature", "bugfix"]).optional(),
63
- },
64
- execute: async ({ plan_path, kind }, context) =>
65
- invoke(() => {
66
- if (plan_path) relativePath(context.directory, plan_path);
67
- return docsBranch({
68
- plan_path,
69
- kind,
70
- workspace_root: context.directory,
71
- }) as Record<string, unknown>;
72
- }),
73
- }),
74
- workflow_docs_validate: tool({
75
- description:
76
- "Hard-fail validate spec/plan headers, link, branch, task order; returns quality findings (hard/warning)",
77
- args: {
78
- spec_path: tool.schema.string(),
79
- plan_path: tool.schema.string(),
80
- },
81
- execute: async ({ spec_path, plan_path }, context) =>
82
- invoke(() => {
83
- relativePath(context.directory, spec_path);
84
- relativePath(context.directory, plan_path);
85
- const result = docsValidate({
86
- spec_path,
87
- plan_path,
88
- workspace_root: context.directory,
89
- }) as Record<string, unknown>;
90
- if (result.error) return result;
91
- if (result.ok === false) return result;
92
- return result;
93
- }),
94
- }),
95
- workflow_plan_tasks: tool({
96
- description: "Parse top-level tasks from a workflow plan",
97
- args: { plan_path: tool.schema.string(), spec_path: tool.schema.string().optional() },
98
- execute: async ({ plan_path, spec_path }, context) =>
99
- invoke(() => {
100
- relativePath(context.directory, plan_path);
101
- const paths = planPaths(context.directory, plan_path, spec_path);
102
- const parsed = parsePlanTasks(plan_path, context.directory) as Record<string, unknown>;
103
- if (parsed.error) return parsed;
104
- const branch = paths.spec_path
105
- ? (resolveHandoffBranch(paths.spec_path, plan_path, context.directory) as Record<
106
- string,
107
- unknown
108
- >)
109
- : {};
110
- if (branch.error) return branch;
111
- const data = { ...parsed, ...paths, ...branch };
112
- record(context, data);
113
- return data;
114
- }),
115
- }),
116
- workflow_resolve_branch: tool({
117
- description: "Resolve a branch from repository spec and plan metadata",
118
- args: { spec_path: tool.schema.string(), plan_path: tool.schema.string() },
119
- execute: async ({ spec_path, plan_path }, context) =>
120
- invoke(() => {
121
- relativePath(context.directory, spec_path);
122
- relativePath(context.directory, plan_path);
123
- return resolveBranch({ spec_path, plan_path, workspace_root: context.directory });
124
- }),
125
- }),
126
- workflow_sdd_context: tool({
127
- description: "Resolve the SDD workspace and progress ledger",
128
- args: { plan_path: tool.schema.string() },
129
- execute: async ({ plan_path }, context) =>
130
- invoke(() => {
131
- relativePath(context.directory, plan_path);
132
- const parsed = sddContext({
133
- slug: undefined,
134
- plan_path,
135
- workspace_root: context.directory,
136
- }) as Record<string, unknown>;
137
- if (parsed.error) return parsed;
138
- const todos = Array.isArray(parsed.todos)
139
- ? parsed.todos.map((todo: Record<string, unknown>) =>
140
- todo.status === "in_progress" ? { ...todo, status: "pending" } : todo,
141
- )
142
- : [];
143
- const data = {
144
- ...parsed,
145
- todos,
146
- ...planPaths(context.directory, plan_path),
147
- sdd_dir: parsed.sdd_dir,
148
- };
149
- record(context, data);
150
- return data;
151
- }),
152
- }),
153
- workflow_sdd_task_brief: tool({
154
- description: "Write a confirmed task brief",
155
- args: {
156
- confirmed: tool.schema.boolean(),
157
- sdd_dir: tool.schema.string(),
158
- task_id: tool.schema.number(),
159
- section_text: tool.schema.string(),
160
- },
161
- execute: async ({ confirmed, sdd_dir, task_id, section_text }, context) => {
162
- const rejected = requireConfirmed(confirmed);
163
- if (rejected) return rejected;
164
- return invoke(() => {
165
- relativePath(context.directory, sdd_dir);
166
- return sddTaskBrief({
167
- sdd_dir,
168
- task_id,
169
- section_text,
170
- workspace_root: context.directory,
171
- });
172
- });
173
- },
174
- }),
175
- workflow_sdd_review_package: tool({
176
- description: "Write a confirmed task review diff",
177
- args: {
178
- confirmed: tool.schema.boolean(),
179
- sdd_dir: tool.schema.string(),
180
- base_sha: tool.schema.string(),
181
- head_sha: tool.schema.string(),
182
- },
183
- execute: async ({ confirmed, sdd_dir, base_sha, head_sha }, context) => {
184
- const rejected = requireConfirmed(confirmed);
185
- if (rejected) return rejected;
186
- return invoke(() => {
187
- relativePath(context.directory, sdd_dir);
188
- resolveGitRevision(context.directory, base_sha);
189
- resolveGitRevision(context.directory, head_sha);
190
- return sddReviewPackage({
191
- sdd_dir,
192
- base_sha,
193
- head_sha,
194
- workspace_root: context.directory,
195
- });
196
- });
197
- },
198
- }),
199
- workflow_sdd_append_progress: tool({
200
- description: "Append one confirmed validated SDD progress line",
201
- args: {
202
- confirmed: tool.schema.boolean(),
203
- progress_path: tool.schema.string(),
204
- line: tool.schema.string(),
205
- },
206
- execute: async ({ confirmed, progress_path, line }, context) => {
207
- const rejected = requireConfirmed(confirmed);
208
- if (rejected) return rejected;
209
- return invoke(() => {
210
- relativePath(context.directory, progress_path);
211
- return sddAppendProgress({ progress_path, line, workspace_root: context.directory });
212
- });
213
- },
214
- }),
215
- };
216
- }
@@ -1,27 +0,0 @@
1
- import { tool } from "@opencode-ai/plugin";
2
- import { fail, ok } from "../core";
3
- import { listTemplates, writeTemplate, type TemplateName } from "../core/templates";
4
-
5
- const output = (value: unknown) => JSON.stringify(value, null, 2);
6
-
7
- export function createTemplateTools() {
8
- return {
9
- workflow_template_list: tool({
10
- description: "List editable templates (issue-update, greeting, headers) with their source",
11
- args: {},
12
- execute: async () => output(ok({ templates: listTemplates() })),
13
- }),
14
- workflow_template_edit: tool({
15
- description: "Write an edited template to the toolkit config dir (agent-assisted)",
16
- args: {
17
- name: tool.schema.enum(["issue-update", "greeting", "headers"]),
18
- content: tool.schema.string(),
19
- confirmed: tool.schema.boolean(),
20
- },
21
- execute: async ({ name, content, confirmed }) => {
22
- const result = writeTemplate(name as TemplateName, content, confirmed);
23
- return output(result.ok ? ok(result) : fail(result.error));
24
- },
25
- }),
26
- };
27
- }
@@ -1,423 +0,0 @@
1
- import { readFileSync, statSync } from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import { tool, type ToolContext } from "@opencode-ai/plugin";
5
- import { fail, ok, resolveInside, type Result } from "../core";
6
- import { configGuardError, describeConfigGaps } from "../core/config-guard";
7
- import { configDir } from "../core/config";
8
- import {
9
- buildDraft as legacyBuildDraft,
10
- context as legacyContext,
11
- logTime as legacyLogTime,
12
- parseDuration as legacyParseDuration,
13
- parseIssueRef,
14
- postUpdate as legacyPostUpdate,
15
- verifyYouTrackToken,
16
- } from "../core/youtrack";
17
-
18
- const ISSUE_RE = /^[A-Z]+-\d+$/;
19
- const output = (value: unknown) => JSON.stringify(value, null, 2);
20
- const message = (error: unknown) => (error instanceof Error ? error.message : String(error));
21
-
22
- // Both override names point at the config dir itself, same precedence as
23
- // src/core/config.ts and scripts/init/status.sh: WORKFLOW_TOOLKIT_CONFIG → WORKFLOW_TOOLKIT_CONFIG_DIR → XDG.
24
- // Default env (no args) routes through configDir() so the legacy migration runs.
25
- export const configPath = (env: NodeJS.ProcessEnv = process.env, home = os.homedir()) =>
26
- path.join(
27
- env === process.env
28
- ? configDir()
29
- : (env.WORKFLOW_TOOLKIT_CONFIG ??
30
- env.WORKFLOW_TOOLKIT_CONFIG_DIR ??
31
- path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "workit")),
32
- "youtrack.json",
33
- );
34
-
35
- export function readCredentials(env: NodeJS.ProcessEnv = process.env, home = os.homedir()) {
36
- const resolvedConfig = configPath(env, home);
37
- const config = JSON.parse(readFileSync(resolvedConfig, "utf8")) as { tokenFile?: string };
38
- const tokenFile = config.tokenFile ?? "youtrack.token";
39
- const tokenPath = path.resolve(path.dirname(resolvedConfig), tokenFile.replace(/^~(?=\/)/, home));
40
- if (process.platform !== "win32" && (statSync(tokenPath).mode & 0o777) !== 0o600)
41
- throw new Error("youtrack.token mode must be 0600");
42
- const token = readFileSync(tokenPath, "utf8").trim();
43
- if (!token) throw new Error("youtrack.token is empty");
44
- return { configPath: resolvedConfig, token };
45
- }
46
-
47
- export const redact = (text: string, token: string) =>
48
- token ? text.split(token).join("[REDACTED]") : text;
49
-
50
- type MaybePromise<T> = T | Promise<T>;
51
- type LegacyValue = Record<string, unknown> | void;
52
- export type NotApplied = { ok: false; error: string; outcome: "not_applied" };
53
-
54
- export type YouTrackOperations = {
55
- verifyToken(): MaybePromise<LegacyValue>;
56
- context(input: Record<string, unknown>): MaybePromise<LegacyValue>;
57
- parseDuration(text: string, workspaceRoot: string): MaybePromise<LegacyValue>;
58
- postComment(issueId: string, markdown: string, workspaceRoot?: string): MaybePromise<LegacyValue>;
59
- logTime(input: Record<string, unknown>): MaybePromise<LegacyValue>;
60
- };
61
-
62
- const unwrap = (value: LegacyValue) => {
63
- if (!value) return {};
64
- if (value.error) throw new Error(String(value.error));
65
- if (value.ok === false) throw new Error(String(value.error ?? "YouTrack operation failed"));
66
- return (value.data as Record<string, unknown> | undefined) ?? value;
67
- };
68
-
69
- const defaultOperations: YouTrackOperations = {
70
- verifyToken: () => unwrap(verifyYouTrackToken()),
71
- context: (input) => legacyContext(input as never),
72
- parseDuration: (text, workspaceRoot) => legacyParseDuration(text, workspaceRoot),
73
- postComment: (issueId, markdown, workspaceRoot) =>
74
- legacyPostUpdate({
75
- confirmed: true,
76
- issueId,
77
- markdown,
78
- workspace_root: workspaceRoot,
79
- } as never),
80
- logTime: (input) => legacyLogTime(input as never),
81
- };
82
-
83
- type PostInput = {
84
- confirmed: boolean;
85
- issueId: string;
86
- markdown: string;
87
- minutes?: number;
88
- workspace_root?: string;
89
- };
90
-
91
- type PostData = {
92
- issueId: string;
93
- postedComment: boolean;
94
- loggedMinutes: number;
95
- outcome?: "unknown" | "not_applied";
96
- instructions?: string;
97
- retry?: "workflow_youtrack_post" | "workflow_youtrack_log_time";
98
- };
99
-
100
- const notApplied = (value: LegacyValue): value is NotApplied =>
101
- value?.ok === false && value.outcome === "not_applied";
102
-
103
- const withWriteFlag = async <T>(fn: () => Promise<T>): Promise<T> => {
104
- const previous = process.env.WORKFLOW_YT_WRITE;
105
- process.env.WORKFLOW_YT_WRITE = "1";
106
- try {
107
- return await fn();
108
- } finally {
109
- if (previous === undefined) delete process.env.WORKFLOW_YT_WRITE;
110
- else process.env.WORKFLOW_YT_WRITE = previous;
111
- }
112
- };
113
-
114
- export async function postUpdate(
115
- input: PostInput,
116
- operations: Pick<YouTrackOperations, "postComment" | "logTime"> = defaultOperations,
117
- ): Promise<Result<PostData>> {
118
- if (input.confirmed !== true) return fail("confirmed: true required");
119
- if (!ISSUE_RE.test(input.issueId)) return fail("invalid issueId");
120
- if (!input.markdown?.trim()) return fail("markdown required");
121
- if (input.minutes != null && input.minutes <= 0) return fail("minutes must be positive");
122
-
123
- try {
124
- const comment = await operations.postComment(
125
- input.issueId,
126
- input.markdown,
127
- input.workspace_root,
128
- );
129
- if (notApplied(comment))
130
- return fail(comment.error, {
131
- issueId: input.issueId,
132
- postedComment: false,
133
- loggedMinutes: 0,
134
- outcome: "not_applied",
135
- retry: "workflow_youtrack_post",
136
- });
137
- unwrap(comment);
138
- } catch (error) {
139
- return fail(message(error), {
140
- issueId: input.issueId,
141
- postedComment: false,
142
- loggedMinutes: 0,
143
- outcome: "unknown",
144
- instructions: "Check YouTrack comments manually; do not retry while the outcome is unknown.",
145
- });
146
- }
147
-
148
- if (input.minutes != null) {
149
- try {
150
- const time = await operations.logTime({
151
- issueId: input.issueId,
152
- minutes: input.minutes,
153
- text: "workit update",
154
- workspace_root: input.workspace_root,
155
- });
156
- if (notApplied(time))
157
- return fail(time.error, {
158
- issueId: input.issueId,
159
- postedComment: true,
160
- loggedMinutes: 0,
161
- outcome: "not_applied",
162
- retry: "workflow_youtrack_log_time",
163
- });
164
- unwrap(time);
165
- } catch (error) {
166
- return fail(message(error), {
167
- issueId: input.issueId,
168
- postedComment: true,
169
- loggedMinutes: 0,
170
- outcome: "unknown",
171
- instructions:
172
- "Check YouTrack time entries manually; do not retry while the outcome is unknown.",
173
- });
174
- }
175
- }
176
-
177
- return ok({
178
- issueId: input.issueId,
179
- postedComment: true,
180
- loggedMinutes: input.minutes ?? 0,
181
- });
182
- }
183
-
184
- export async function logTimeUpdate(
185
- input: Record<string, unknown>,
186
- operation: Pick<YouTrackOperations, "logTime"> = defaultOperations,
187
- ): Promise<Result<Record<string, unknown>>> {
188
- try {
189
- const value = await operation.logTime(input);
190
- if (notApplied(value))
191
- return fail(value.error, {
192
- issueId: input.issueId,
193
- loggedMinutes: 0,
194
- outcome: "not_applied",
195
- retry: "workflow_youtrack_log_time",
196
- });
197
- return ok(unwrap(value));
198
- } catch (error) {
199
- return fail(message(error), {
200
- issueId: input.issueId,
201
- loggedMinutes: 0,
202
- outcome: "unknown",
203
- instructions:
204
- "Check YouTrack time entries manually; do not retry while the outcome is unknown.",
205
- });
206
- }
207
- }
208
-
209
- export function normalizeContext(value: LegacyValue, mode?: string): LegacyValue {
210
- if (!value || mode !== "meetings") return value;
211
- const config = value.config as Record<string, unknown> | undefined;
212
- const issue = String(config?.meetingIssue || "IRPT-12");
213
- const { meetingIssues: _meetingIssues, ...singleMeetingConfig } = config ?? {};
214
- const options = Array.isArray(value.meetingOptions)
215
- ? (value.meetingOptions as Array<Record<string, unknown>>)
216
- : [];
217
- const selected = options.find((option) => option.issue === issue) ?? {
218
- key: "general",
219
- issue,
220
- label: issue,
221
- workItemText: "Reuniones",
222
- };
223
- return {
224
- ...value,
225
- config: singleMeetingConfig,
226
- meetingOptions: [selected],
227
- requiresMeetingChoice: false,
228
- issueId: issue,
229
- workItemText: selected.workItemText ?? "Reuniones",
230
- };
231
- }
232
-
233
- const standardResult = (value: LegacyValue, token = "") => {
234
- try {
235
- const data = unwrap(value);
236
- const { ok: _legacyOk, ...normalized } = data;
237
- return ok(normalized);
238
- } catch (error) {
239
- return fail(redact(message(error), token));
240
- }
241
- };
242
-
243
- const invoke = async (operation: () => MaybePromise<LegacyValue>, token = "") => {
244
- try {
245
- return output(standardResult(await operation(), token));
246
- } catch (error) {
247
- return output(fail(redact(message(error), token)));
248
- }
249
- };
250
-
251
- const credentials = () => readCredentials();
252
- const configGap = () => {
253
- const { missing } = describeConfigGaps(["youtrack_json", "youtrack_token"]);
254
- return missing.length > 0 ? output(fail(configGuardError(missing))) : null;
255
- };
256
- const requireConfirmed = (confirmed: boolean) =>
257
- confirmed === true ? null : output(fail("confirmed: true required"));
258
-
259
- const rejectedTimeInput = (issueId: string, minutes: number) => {
260
- const error = !ISSUE_RE.test(issueId)
261
- ? "invalid issueId"
262
- : !Number.isFinite(minutes) || minutes <= 0
263
- ? "minutes must be positive"
264
- : null;
265
- return error
266
- ? output(
267
- fail(error, {
268
- issueId,
269
- loggedMinutes: 0,
270
- outcome: "not_applied",
271
- retry: "workflow_youtrack_log_time",
272
- instructions: "Correct the invalid input, then retry workflow_youtrack_log_time once.",
273
- }),
274
- )
275
- : null;
276
- };
277
-
278
- export function createYouTrackTools(operations: YouTrackOperations = defaultOperations) {
279
- return {
280
- workflow_youtrack_verify_token: tool({
281
- description: "Verify the configured YouTrack token with a read-only request",
282
- args: {},
283
- execute: async () => {
284
- let token = "";
285
- try {
286
- token = credentials().token;
287
- } catch (error) {
288
- const gap = configGap();
289
- if (gap) return gap;
290
- return output(fail(message(error)));
291
- }
292
- return invoke(() => operations.verifyToken(), token);
293
- },
294
- }),
295
- workflow_youtrack_parse_issue: tool({
296
- description: "Parse an existing YouTrack issue URL or id",
297
- args: { issue_ref: tool.schema.string() },
298
- execute: async ({ issue_ref }) => invoke(() => parseIssueRef(issue_ref)),
299
- }),
300
- workflow_youtrack_context: tool({
301
- description:
302
- "Load YouTrack context for the configured meeting issue or an existing task issue",
303
- args: {
304
- mode: tool.schema.enum(["meetings", "task"]).optional(),
305
- issue_id: tool.schema.string().optional(),
306
- issue_url: tool.schema.string().optional(),
307
- issue_ref: tool.schema.string().optional(),
308
- spec_path: tool.schema.string().optional(),
309
- plan_path: tool.schema.string().optional(),
310
- },
311
- execute: async (input, context) => {
312
- try {
313
- for (const candidate of [input.spec_path, input.plan_path].filter(Boolean) as string[]) {
314
- if (path.isAbsolute(candidate)) throw new Error("path must be repository-relative");
315
- resolveInside(context.directory, candidate);
316
- }
317
- } catch (error) {
318
- const detail = message(error);
319
- return output(
320
- fail(
321
- detail.includes("repository-relative")
322
- ? detail
323
- : `path must be repository-relative: ${detail}`,
324
- ),
325
- );
326
- }
327
- let token = "";
328
- try {
329
- token = credentials().token;
330
- } catch (error) {
331
- const gap = configGap();
332
- if (gap) return gap;
333
- return output(fail(message(error)));
334
- }
335
- return invoke(
336
- async () =>
337
- normalizeContext(
338
- await operations.context({ ...input, workspace_root: context.directory }),
339
- input.mode,
340
- ),
341
- token,
342
- );
343
- },
344
- }),
345
- workflow_youtrack_parse_duration: tool({
346
- description: "Parse duration text into integer minutes",
347
- args: { text: tool.schema.string() },
348
- execute: async ({ text }, context) =>
349
- invoke(() => operations.parseDuration(text, context.directory)),
350
- }),
351
- workflow_youtrack_draft: tool({
352
- description: "Build an es-CL update comment without posting it",
353
- args: {
354
- issueId: tool.schema.string(),
355
- userNotes: tool.schema.string(),
356
- greeting: tool.schema.string().optional(),
357
- projectName: tool.schema.string().optional(),
358
- includeProjectOpener: tool.schema.boolean().optional(),
359
- includeFacts: tool.schema.boolean().optional(),
360
- facts: tool.schema
361
- .object({
362
- progress_excerpt: tool.schema.array(tool.schema.string()).optional(),
363
- git_commits: tool.schema.array(tool.schema.string()).optional(),
364
- })
365
- .optional(),
366
- },
367
- execute: async (input) => invoke(() => legacyBuildDraft(input as never)),
368
- }),
369
- workflow_youtrack_log_time: tool({
370
- description: "Log confirmed time on an existing YouTrack issue without posting a comment",
371
- args: {
372
- confirmed: tool.schema.boolean(),
373
- issueId: tool.schema.string(),
374
- minutes: tool.schema.number(),
375
- text: tool.schema.string().optional(),
376
- dateMs: tool.schema.number().optional(),
377
- },
378
- execute: async ({ confirmed, ...input }, context: ToolContext) => {
379
- const rejected = requireConfirmed(confirmed);
380
- if (rejected) return rejected;
381
- const invalid = rejectedTimeInput(input.issueId, input.minutes);
382
- if (invalid) return invalid;
383
- let token = "";
384
- try {
385
- token = credentials().token;
386
- } catch (error) {
387
- const gap = configGap();
388
- if (gap) return gap;
389
- return output(fail(message(error)));
390
- }
391
- const result = await withWriteFlag(() =>
392
- logTimeUpdate({ ...input, workspace_root: context.directory }, operations),
393
- );
394
- return output(result.ok ? result : { ...result, error: redact(result.error, token) });
395
- },
396
- }),
397
- workflow_youtrack_post: tool({
398
- description: "Post a confirmed es-CL comment, then optionally log time",
399
- args: {
400
- confirmed: tool.schema.boolean(),
401
- issueId: tool.schema.string(),
402
- markdown: tool.schema.string(),
403
- minutes: tool.schema.number().optional(),
404
- },
405
- execute: async (input, context: ToolContext) => {
406
- const rejected = requireConfirmed(input.confirmed);
407
- if (rejected) return rejected;
408
- let token = "";
409
- try {
410
- token = credentials().token;
411
- } catch (error) {
412
- const gap = configGap();
413
- if (gap) return gap;
414
- return output(fail(message(error)));
415
- }
416
- const result = await withWriteFlag(() =>
417
- postUpdate({ ...input, workspace_root: context.directory }, operations),
418
- );
419
- return output(result.ok ? result : { ...result, error: redact(result.error, token) });
420
- },
421
- }),
422
- };
423
- }