@brainervirus/workit-core 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/package.json +10 -14
  2. package/scripts/doctor-check.ts +20 -0
  3. package/scripts/install-cursor-plugin.sh +51 -28
  4. package/scripts/install-opencode-plugin.sh +25 -22
  5. package/scripts/rewrite-workspace-deps.ts +15 -9
  6. package/scripts/sync-runtime.sh +71 -19
  7. package/scripts/vendor-assets.ts +37 -0
  8. package/skills/wk-implement/SKILL.md +2 -2
  9. package/skills/wk-pr/SKILL.md +1 -1
  10. package/src/core/boundary.ts +27 -0
  11. package/src/core/branch-policy.ts +63 -0
  12. package/src/core/branch.ts +166 -57
  13. package/src/core/changelog.ts +17 -14
  14. package/src/core/config-guard.ts +9 -2
  15. package/src/core/config.ts +227 -32
  16. package/src/core/detector.ts +22 -11
  17. package/src/core/docs-layout.ts +251 -0
  18. package/src/core/docs-migration.ts +639 -0
  19. package/src/core/docs-repo.ts +58 -21
  20. package/src/core/docs-validate.ts +181 -43
  21. package/src/core/doctor.ts +801 -0
  22. package/src/core/flow-state.ts +1579 -137
  23. package/src/core/git.ts +22 -5
  24. package/src/core/gitignore.ts +11 -2
  25. package/src/core/handoff-context.ts +18 -5
  26. package/src/{tools/handoff.ts → core/handoff-tools.ts} +16 -63
  27. package/src/core/hygiene.ts +53 -17
  28. package/src/core/init.ts +128 -31
  29. package/src/core/logger.ts +321 -0
  30. package/src/core/package-root.ts +28 -0
  31. package/src/core/parse-sections.ts +2 -2
  32. package/src/core/plan-tasks.ts +13 -3
  33. package/src/core/ports/init-toolkit-status.ts +1 -1
  34. package/src/core/ports/vcs-verify-token.ts +1 -1
  35. package/src/core/ports/youtrack-api.ts +4 -2
  36. package/src/core/ports/youtrack-config.ts +1 -3
  37. package/src/core/ports/youtrack-verify-token.ts +1 -1
  38. package/src/core/pr-create.ts +156 -29
  39. package/src/core/present.ts +11 -2
  40. package/src/core/registration.ts +215 -0
  41. package/src/core/reminder.ts +1 -2
  42. package/src/core/repo-context.ts +447 -0
  43. package/src/core/repo-tool.ts +4 -1
  44. package/src/core/repo-tools.ts +23 -0
  45. package/src/core/rules.ts +10 -7
  46. package/src/core/safe-write.ts +22 -0
  47. package/src/core/scripts.ts +3 -39
  48. package/src/core/sdd.ts +56 -31
  49. package/src/core/setup-state.ts +54 -0
  50. package/src/core/setup.ts +1216 -0
  51. package/src/core/skill-manifests.ts +95 -0
  52. package/src/core/support-matrix.ts +12 -0
  53. package/src/core/sync-runtime.ts +348 -0
  54. package/src/core/templates.ts +16 -6
  55. package/src/core/vcs-config.ts +191 -62
  56. package/src/core/verify-parse.ts +4 -2
  57. package/src/core/verify-project.ts +181 -0
  58. package/src/core/workspaces.ts +136 -17
  59. package/src/core/youtrack-tools.ts +228 -0
  60. package/src/core/youtrack.ts +320 -87
  61. package/src/core.ts +18 -3
  62. package/templates/execution-contract.md +9 -7
  63. package/templates/superpowers-doc-contract.md +5 -4
  64. package/scripts/_shared/common.sh +0 -158
  65. package/scripts/changelog-context.sh +0 -42
  66. package/scripts/docs-refresh-context.sh +0 -40
  67. package/scripts/init/apply.sh +0 -5
  68. package/scripts/init/status.sh +0 -5
  69. package/scripts/init/toolkit-status.sh +0 -5
  70. package/scripts/pr-create.sh +0 -5
  71. package/scripts/pr-ready-context.sh +0 -88
  72. package/scripts/present/ascii-wireframe.sh +0 -5
  73. package/scripts/present/flow-diagram.sh +0 -5
  74. package/scripts/release-notes-context.sh +0 -40
  75. package/scripts/vcs/config.sh +0 -5
  76. package/scripts/vcs/merged-style.sh +0 -5
  77. package/scripts/vcs/token-create-urls.sh +0 -5
  78. package/scripts/vcs/verify-token.sh +0 -5
  79. package/scripts/verify-project.sh +0 -140
  80. package/scripts/youtrack/api.sh +0 -5
  81. package/scripts/youtrack/config.sh +0 -5
  82. package/scripts/youtrack/greeting.sh +0 -5
  83. package/scripts/youtrack/parse-duration.sh +0 -5
  84. package/scripts/youtrack/token-create-url.sh +0 -5
  85. package/scripts/youtrack/verify-token.sh +0 -5
  86. package/scripts/youtrack/work-date-ms.sh +0 -5
  87. package/src/tools/docs-repo.ts +0 -42
  88. package/src/tools/flow.ts +0 -88
  89. package/src/tools/index.ts +0 -22
  90. package/src/tools/present.ts +0 -45
  91. package/src/tools/repo.ts +0 -357
  92. package/src/tools/rules.ts +0 -30
  93. package/src/tools/sdd.ts +0 -189
  94. package/src/tools/templates.ts +0 -27
  95. package/src/tools/youtrack.ts +0 -360
@@ -1,19 +1,70 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, realpathSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { configDir } from "./config";
3
+ import { configDir, isConfigObject, type BranchPreset } from "./config";
4
4
 
5
5
  export type VcsProvider = "gitlab" | "github";
6
6
 
7
+ export type IntegrationMode = "pr" | "merge";
8
+
9
+ export type WorkspaceBranchPolicy = {
10
+ preset: BranchPreset;
11
+ developBranch?: string;
12
+ prefixes?: { feature: string; bugfix: string; release: string; hotfix: string };
13
+ allowed?: string[];
14
+ protected?: string[];
15
+ integration: IntegrationMode;
16
+ };
17
+
7
18
  export type WorkspaceConfig = {
8
19
  name: string;
9
20
  glob: string;
10
21
  vcs?: { provider: VcsProvider; defaultTargetBranch?: string };
11
22
  youtrack?: { baseUrl?: string; link_issues?: boolean };
12
23
  issues?: { provider?: "github"; link_on_pr?: boolean };
24
+ branchPolicy?: WorkspaceBranchPolicy;
13
25
  };
14
26
 
15
27
  export const workspacesPath = (): string => path.join(configDir(), "workspaces.json");
16
28
 
29
+ // RL-01: typed workspaces reader. Missing is a legitimate unconfigured state
30
+ // (empty list); malformed JSON is reported with the exact path so risky
31
+ // consumers (wizard/installer/doctor) can block instead of silently resetting.
32
+ export type WorkspacesResult = {
33
+ status: "missing" | "valid" | "malformed";
34
+ path: string;
35
+ entries: WorkspaceConfig[];
36
+ error?: string;
37
+ };
38
+
39
+ export const readWorkspacesResult = (dir: string = configDir()): WorkspacesResult => {
40
+ const file = path.join(dir, "workspaces.json");
41
+ let raw: string;
42
+ try {
43
+ raw = readFileSync(file, "utf8");
44
+ } catch {
45
+ return { status: "missing", path: file, entries: [] };
46
+ }
47
+ let parsed: unknown;
48
+ try {
49
+ parsed = JSON.parse(raw);
50
+ } catch {
51
+ return { status: "malformed", path: file, entries: [], error: `${file} is not valid JSON` };
52
+ }
53
+ if (!isConfigObject(parsed)) {
54
+ return { status: "malformed", path: file, entries: [], error: `${file} is not a JSON object` };
55
+ }
56
+ const list = (parsed as { workspaces?: unknown }).workspaces;
57
+ return {
58
+ status: "valid",
59
+ path: file,
60
+ entries: Array.isArray(list) ? (list as WorkspaceConfig[]) : [],
61
+ };
62
+ };
63
+
64
+ /** Parse the workspaces.json list under an explicit config dir; [] when missing/malformed. */
65
+ export const loadWorkspacesFrom = (dir: string): WorkspaceConfig[] =>
66
+ readWorkspacesResult(dir).entries;
67
+
17
68
  // ponytail: only globstar (`**`) is supported; if more minimatch parity is needed
18
69
  // (char classes, braces, `?`), swap this matcher for the minimatch dependency.
19
70
  const globToRegExp = (glob: string): RegExp => {
@@ -54,28 +105,96 @@ const globToRegExp = (glob: string): RegExp => {
54
105
  return new RegExp(`^${out}$`);
55
106
  };
56
107
 
57
- export const resolveWorkspace = (cwd: string): WorkspaceConfig | null => {
58
- let raw: string;
59
- try {
60
- raw = readFileSync(workspacesPath(), "utf8");
61
- } catch {
62
- return null;
108
+ /** Shared authoritative workspace matcher (WZ-12): the wizard's pattern
109
+ * preview and resolveWorkspaceFrom both route through it. */
110
+ export const matchWorkspace = (glob: string, target: string): boolean =>
111
+ globToRegExp(glob.replaceAll("\\", "/")).test(target.replaceAll("\\", "/"));
112
+
113
+ // RL-08: the matcher above implements `*` and `**` only. Classic glob
114
+ // metacharacters it would otherwise store as literals (char classes, `?`,
115
+ // brace expansion) are rejected at write time so a saved pattern never
116
+ // silently matches nothing (Task 15 advisory: unsupported patterns were
117
+ // accepted and shown as "no match").
118
+ const UNSUPPORTED_GLOB = /[?[\]{}]/;
119
+
120
+ // B3: `!`-negation and extglob prefixes (@(...), +(...), *(...)) are the same
121
+ // no-match trap — the matcher stores them as literals, so `!**` or `@(a|b)`
122
+ // silently matches nothing. Reject them at write time too; the Task 15 match
123
+ // preview still routes through matchWorkspace (literals -> no match) and is
124
+ // unchanged.
125
+ const UNSUPPORTED_EXTGLOB = /[@+*]\(|!/;
126
+
127
+ export type GlobValidation = { ok: true } | { ok: false; error: string };
128
+
129
+ export const validateWorkspaceGlob = (glob: string): GlobValidation => {
130
+ const trimmed = glob.trim();
131
+ if (!trimmed) return { ok: false, error: "workspace pattern is required" };
132
+ const m = UNSUPPORTED_GLOB.exec(trimmed);
133
+ if (m) {
134
+ return {
135
+ ok: false,
136
+ error: `unsupported glob character ${JSON.stringify(m[0])} in workspace pattern ${JSON.stringify(glob)} — the matcher supports * and ** only (e.g. /work/**)`,
137
+ };
63
138
  }
64
- let parsed: unknown;
139
+ const ext = UNSUPPORTED_EXTGLOB.exec(trimmed);
140
+ if (ext) {
141
+ const token = ext[0];
142
+ const kind = token === "!" ? "negation (!)" : `extglob (${token})`;
143
+ return {
144
+ ok: false,
145
+ error: `unsupported glob ${kind} in workspace pattern ${JSON.stringify(glob)} — the matcher supports * and ** only (e.g. /work/**)`,
146
+ };
147
+ }
148
+ return { ok: true };
149
+ };
150
+
151
+ const realpathOf = (p: string): string => {
152
+ // realpathSync.native goes through libuv, which expands Windows 8.3 short
153
+ // names (C:\Users\RUNNER~1 -> C:\Users\runneradmin); the plain realpathSync
154
+ // keeps them, so a git-derived long path would miss a glob written with the
155
+ // short form (GitHub runner TMP uses the short name).
156
+ const real = realpathSync.native ?? realpathSync;
65
157
  try {
66
- parsed = JSON.parse(raw);
158
+ return real(p);
67
159
  } catch {
68
- return null;
160
+ return p;
69
161
  }
70
- if (!parsed || typeof parsed !== "object") return null;
71
- const list = (parsed as { workspaces?: unknown }).workspaces;
72
- if (!Array.isArray(list)) return null;
73
- const cwdPosix = cwd.split(path.sep).join("/");
74
- for (const entry of list) {
162
+ };
163
+
164
+ // Rebuild a glob with the realpath of its static prefix, so a config glob
165
+ // written with the logical path also matches a canonical target (and vice
166
+ // versa). The glob metacharacters themselves are left untouched.
167
+ const canonicalGlob = (glob: string): string => {
168
+ const m = /[*?[\]{]/.exec(glob);
169
+ const prefix = m ? glob.slice(0, m.index) : glob;
170
+ const rest = m ? glob.slice(m.index) : "";
171
+ if (!prefix) return glob;
172
+ const real = realpathOf(prefix.replace(/\/+$/, "") || "/");
173
+ return real + (prefix.endsWith("/") ? "/" : "") + rest;
174
+ };
175
+
176
+ /** Match a cwd against the workspaces.json under an explicit config dir. */
177
+ export const resolveWorkspaceFrom = (cwd: string, dir: string): WorkspaceConfig | null => {
178
+ // macOS/Windows tmpdir symlinks (/var -> /private/var): git's
179
+ // --show-toplevel returns the realpath while config globs are usually
180
+ // written with the logical path, so a workspace would silently stop
181
+ // matching on macOS. Match both forms on each side — same class as the
182
+ // docs-migration escape-guard realpath comparison; on Linux both forms
183
+ // are identical so behavior is unchanged.
184
+ const targets = [cwd, realpathOf(cwd)].map((p) => p.replaceAll("\\", "/"));
185
+ for (const entry of loadWorkspacesFrom(dir)) {
75
186
  if (!entry || typeof entry !== "object") continue;
76
187
  const ws = entry as WorkspaceConfig;
77
188
  if (typeof ws.glob !== "string" || !ws.glob) continue;
78
- if (globToRegExp(ws.glob).test(cwdPosix)) return ws;
189
+ const glob = ws.glob.replaceAll("\\", "/");
190
+ const canonical = canonicalGlob(glob);
191
+ for (const target of targets) {
192
+ if (matchWorkspace(glob, target)) return ws;
193
+ if (canonical !== glob && matchWorkspace(canonical, target)) return ws;
194
+ }
79
195
  }
80
196
  return null;
81
197
  };
198
+
199
+ export const resolveWorkspace = (cwd: string): WorkspaceConfig | null =>
200
+ resolveWorkspaceFrom(cwd, configDir());
@@ -0,0 +1,228 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fail, ok, type Result } from "../core";
5
+ import { configDir, isConfigObject } from "./config";
6
+ import {
7
+ context as legacyContext,
8
+ logTime as legacyLogTime,
9
+ parseDuration as legacyParseDuration,
10
+ postUpdate as legacyPostUpdate,
11
+ verifyYouTrackToken,
12
+ } from "./youtrack";
13
+
14
+ export const ISSUE_RE = /^[A-Z]+-\d+$/;
15
+ export const message = (error: unknown) => (error instanceof Error ? error.message : String(error));
16
+
17
+ // Both override names point at the config dir itself, same precedence as
18
+ // src/core/config.ts and scripts/init/status.sh: WORKFLOW_TOOLKIT_CONFIG → WORKFLOW_TOOLKIT_CONFIG_DIR → XDG.
19
+ // Default env (no args) routes through configDir() so the legacy migration runs.
20
+ export const configPath = (env: NodeJS.ProcessEnv = process.env, home = os.homedir()) =>
21
+ path.join(
22
+ env === process.env
23
+ ? configDir()
24
+ : (env.WORKFLOW_TOOLKIT_CONFIG ??
25
+ env.WORKFLOW_TOOLKIT_CONFIG_DIR ??
26
+ path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "workit")),
27
+ "youtrack.json",
28
+ );
29
+
30
+ export function readCredentials(env: NodeJS.ProcessEnv = process.env, home = os.homedir()) {
31
+ const resolvedConfig = configPath(env, home);
32
+ // AR-07/CA-37: a parseable non-object youtrack.json is malformed — exact-path
33
+ // error, never a raw TypeError or a silent tokenFile default.
34
+ let parsed: unknown;
35
+ try {
36
+ parsed = JSON.parse(readFileSync(resolvedConfig, "utf8"));
37
+ } catch {
38
+ throw new Error(`${resolvedConfig} is not valid JSON`);
39
+ }
40
+ if (!isConfigObject(parsed)) {
41
+ throw new Error(`${resolvedConfig} is not a JSON object`);
42
+ }
43
+ const config = parsed as { tokenFile?: string };
44
+ const tokenFile = config.tokenFile ?? "youtrack.token";
45
+ const tokenPath = path.resolve(path.dirname(resolvedConfig), tokenFile.replace(/^~(?=\/)/, home));
46
+ if (process.platform !== "win32" && (statSync(tokenPath).mode & 0o777) !== 0o600)
47
+ throw new Error("youtrack.token mode must be 0600");
48
+ const token = readFileSync(tokenPath, "utf8").trim();
49
+ if (!token) throw new Error("youtrack.token is empty");
50
+ return { configPath: resolvedConfig, token };
51
+ }
52
+
53
+ export const redact = (text: string, token: string) =>
54
+ token ? text.split(token).join("[REDACTED]") : text;
55
+
56
+ type MaybePromise<T> = T | Promise<T>;
57
+ export type LegacyValue = Record<string, unknown> | void;
58
+ export type NotApplied = { ok: false; error: string; outcome: "not_applied" };
59
+
60
+ export type YouTrackOperations = {
61
+ verifyToken(): MaybePromise<LegacyValue>;
62
+ context(input: Record<string, unknown>): MaybePromise<LegacyValue>;
63
+ parseDuration(text: string, workspaceRoot: string): MaybePromise<LegacyValue>;
64
+ postComment(issueId: string, markdown: string, workspaceRoot?: string): MaybePromise<LegacyValue>;
65
+ logTime(input: Record<string, unknown>): MaybePromise<LegacyValue>;
66
+ };
67
+
68
+ export const unwrap = (value: LegacyValue) => {
69
+ if (!value) return {};
70
+ if (value.error) throw new Error(String(value.error));
71
+ if (value.ok === false) throw new Error(String(value.error ?? "YouTrack operation failed"));
72
+ return (value.data as Record<string, unknown> | undefined) ?? value;
73
+ };
74
+
75
+ export const defaultOperations: YouTrackOperations = {
76
+ verifyToken: () => unwrap(verifyYouTrackToken()),
77
+ context: (input) => legacyContext(input as never),
78
+ parseDuration: (text, workspaceRoot) => legacyParseDuration(text, workspaceRoot),
79
+ postComment: async (issueId, markdown, workspaceRoot) =>
80
+ unwrap(
81
+ await legacyPostUpdate({
82
+ confirmed: true,
83
+ issueId,
84
+ markdown,
85
+ workspace_root: workspaceRoot,
86
+ } as never),
87
+ ),
88
+ logTime: async (input) => unwrap(await legacyLogTime(input as never)),
89
+ };
90
+
91
+ type PostInput = {
92
+ confirmed: boolean;
93
+ issueId: string;
94
+ markdown: string;
95
+ minutes?: number;
96
+ workspace_root?: string;
97
+ };
98
+
99
+ type PostData = {
100
+ issueId: string;
101
+ postedComment: boolean;
102
+ loggedMinutes: number;
103
+ outcome?: "unknown" | "not_applied";
104
+ instructions?: string;
105
+ retry?: "workflow_youtrack_post" | "workflow_youtrack_log_time";
106
+ };
107
+
108
+ const notApplied = (value: LegacyValue): value is NotApplied =>
109
+ value?.ok === false && value.outcome === "not_applied";
110
+
111
+ export async function postUpdate(
112
+ input: PostInput,
113
+ operations: Pick<YouTrackOperations, "postComment" | "logTime"> = defaultOperations,
114
+ ): Promise<Result<PostData>> {
115
+ if (input.confirmed !== true) return fail("confirmed: true required");
116
+ if (!ISSUE_RE.test(input.issueId)) return fail("invalid issueId");
117
+ if (!input.markdown?.trim()) return fail("markdown required");
118
+ if (input.minutes != null && input.minutes <= 0) return fail("minutes must be positive");
119
+
120
+ try {
121
+ const comment = await operations.postComment(
122
+ input.issueId,
123
+ input.markdown,
124
+ input.workspace_root,
125
+ );
126
+ if (notApplied(comment))
127
+ return fail(comment.error, {
128
+ issueId: input.issueId,
129
+ postedComment: false,
130
+ loggedMinutes: 0,
131
+ outcome: "not_applied",
132
+ retry: "workflow_youtrack_post",
133
+ });
134
+ unwrap(comment);
135
+ } catch (error) {
136
+ return fail(message(error), {
137
+ issueId: input.issueId,
138
+ postedComment: false,
139
+ loggedMinutes: 0,
140
+ outcome: "unknown",
141
+ instructions: "Check YouTrack comments manually; do not retry while the outcome is unknown.",
142
+ });
143
+ }
144
+
145
+ if (input.minutes != null) {
146
+ try {
147
+ const time = await operations.logTime({
148
+ issueId: input.issueId,
149
+ minutes: input.minutes,
150
+ text: "workit update",
151
+ workspace_root: input.workspace_root,
152
+ });
153
+ if (notApplied(time))
154
+ return fail(time.error, {
155
+ issueId: input.issueId,
156
+ postedComment: true,
157
+ loggedMinutes: 0,
158
+ outcome: "not_applied",
159
+ retry: "workflow_youtrack_log_time",
160
+ });
161
+ unwrap(time);
162
+ } catch (error) {
163
+ return fail(message(error), {
164
+ issueId: input.issueId,
165
+ postedComment: true,
166
+ loggedMinutes: 0,
167
+ outcome: "unknown",
168
+ instructions:
169
+ "Check YouTrack time entries manually; do not retry while the outcome is unknown.",
170
+ });
171
+ }
172
+ }
173
+
174
+ return ok({
175
+ issueId: input.issueId,
176
+ postedComment: true,
177
+ loggedMinutes: input.minutes ?? 0,
178
+ });
179
+ }
180
+
181
+ export async function logTimeUpdate(
182
+ input: Record<string, unknown>,
183
+ operation: Pick<YouTrackOperations, "logTime"> = defaultOperations,
184
+ ): Promise<Result<Record<string, unknown>>> {
185
+ try {
186
+ const value = await operation.logTime(input);
187
+ if (notApplied(value))
188
+ return fail(value.error, {
189
+ issueId: input.issueId,
190
+ loggedMinutes: 0,
191
+ outcome: "not_applied",
192
+ retry: "workflow_youtrack_log_time",
193
+ });
194
+ return ok(unwrap(value));
195
+ } catch (error) {
196
+ return fail(message(error), {
197
+ issueId: input.issueId,
198
+ loggedMinutes: 0,
199
+ outcome: "unknown",
200
+ instructions:
201
+ "Check YouTrack time entries manually; do not retry while the outcome is unknown.",
202
+ });
203
+ }
204
+ }
205
+
206
+ export function normalizeContext(value: LegacyValue, mode?: string): LegacyValue {
207
+ if (!value || mode !== "meetings") return value;
208
+ const config = value.config as Record<string, unknown> | undefined;
209
+ const issue = String(config?.meetingIssue || "IRPT-12");
210
+ const { meetingIssues: _meetingIssues, ...singleMeetingConfig } = config ?? {};
211
+ const options = Array.isArray(value.meetingOptions)
212
+ ? (value.meetingOptions as Array<Record<string, unknown>>)
213
+ : [];
214
+ const selected = options.find((option) => option.issue === issue) ?? {
215
+ key: "general",
216
+ issue,
217
+ label: issue,
218
+ workItemText: "Reuniones",
219
+ };
220
+ return {
221
+ ...value,
222
+ config: singleMeetingConfig,
223
+ meetingOptions: [selected],
224
+ requiresMeetingChoice: false,
225
+ issueId: issue,
226
+ workItemText: selected.workItemText ?? "Reuniones",
227
+ };
228
+ }