@brainervirus/workit-core 0.6.1 → 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.
- package/package.json +3 -7
- package/scripts/doctor-check.ts +20 -0
- package/scripts/install-cursor-plugin.sh +51 -28
- package/scripts/install-opencode-plugin.sh +19 -21
- package/scripts/rewrite-workspace-deps.ts +15 -9
- package/scripts/sync-runtime.sh +71 -19
- package/scripts/vendor-assets.ts +37 -0
- package/skills/wk-implement/SKILL.md +2 -2
- package/skills/wk-pr/SKILL.md +1 -1
- package/src/core/boundary.ts +27 -0
- package/src/core/branch-policy.ts +63 -0
- package/src/core/branch.ts +30 -16
- package/src/core/config.ts +193 -31
- package/src/core/docs-layout.ts +251 -0
- package/src/core/docs-migration.ts +639 -0
- package/src/core/docs-repo.ts +11 -9
- package/src/core/docs-validate.ts +18 -6
- package/src/core/doctor.ts +801 -0
- package/src/core/flow-state.ts +1579 -141
- package/src/core/git.ts +22 -5
- package/src/{tools/handoff.ts → core/handoff-tools.ts} +5 -57
- package/src/core/hygiene.ts +26 -12
- package/src/core/init.ts +43 -11
- package/src/core/logger.ts +321 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/ports/init-toolkit-status.ts +1 -1
- package/src/core/ports/vcs-verify-token.ts +1 -1
- package/src/core/ports/youtrack-api.ts +1 -1
- package/src/core/ports/youtrack-verify-token.ts +1 -1
- package/src/core/pr-create.ts +116 -21
- package/src/core/registration.ts +215 -0
- package/src/core/repo-context.ts +447 -0
- package/src/core/repo-tools.ts +23 -0
- package/src/core/safe-write.ts +22 -0
- package/src/core/scripts.ts +3 -44
- package/src/core/sdd.ts +45 -28
- package/src/core/setup-state.ts +54 -0
- package/src/core/setup.ts +1216 -0
- package/src/core/skill-manifests.ts +95 -0
- package/src/core/support-matrix.ts +12 -0
- package/src/core/sync-runtime.ts +348 -0
- package/src/core/templates.ts +2 -2
- package/src/core/vcs-config.ts +107 -37
- package/src/core/verify-project.ts +181 -0
- package/src/core/workspaces.ts +136 -17
- package/src/core/youtrack-tools.ts +228 -0
- package/src/core/youtrack.ts +125 -67
- package/templates/execution-contract.md +9 -7
- package/templates/superpowers-doc-contract.md +4 -3
- package/scripts/_shared/common.sh +0 -173
- package/scripts/changelog-context.sh +0 -42
- package/scripts/docs-refresh-context.sh +0 -40
- package/scripts/init/apply.sh +0 -5
- package/scripts/init/status.sh +0 -5
- package/scripts/init/toolkit-status.sh +0 -5
- package/scripts/pr-create.sh +0 -5
- package/scripts/pr-ready-context.sh +0 -88
- package/scripts/present/ascii-wireframe.sh +0 -5
- package/scripts/present/flow-diagram.sh +0 -5
- package/scripts/release-notes-context.sh +0 -40
- package/scripts/vcs/config.sh +0 -5
- package/scripts/vcs/merged-style.sh +0 -5
- package/scripts/vcs/token-create-urls.sh +0 -5
- package/scripts/vcs/verify-token.sh +0 -5
- package/scripts/verify-project.sh +0 -140
- package/scripts/youtrack/api.sh +0 -5
- package/scripts/youtrack/config.sh +0 -5
- package/scripts/youtrack/greeting.sh +0 -5
- package/scripts/youtrack/parse-duration.sh +0 -5
- package/scripts/youtrack/token-create-url.sh +0 -5
- package/scripts/youtrack/verify-token.sh +0 -5
- package/scripts/youtrack/work-date-ms.sh +0 -5
- package/src/tools/docs-repo.ts +0 -51
- package/src/tools/flow.ts +0 -99
- package/src/tools/index.ts +0 -22
- package/src/tools/present.ts +0 -49
- package/src/tools/repo.ts +0 -490
- package/src/tools/rules.ts +0 -30
- package/src/tools/sdd.ts +0 -216
- package/src/tools/templates.ts +0 -27
- package/src/tools/youtrack.ts +0 -423
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Registration merge helpers for the OpenCode/Cursor installers (RR-06).
|
|
2
|
+
// Pure functions: accept the existing user config, return the deduplicated
|
|
3
|
+
// config PLUS the explicit list of keys changed. Unrelated user settings are
|
|
4
|
+
// never rewritten — their values round-trip JSON-identical. The install
|
|
5
|
+
// scripts (`packages/workit-core/scripts/install-*-plugin.sh`) import these so
|
|
6
|
+
// there is exactly one source of truth for registration merging.
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
export interface MergeResult<T> {
|
|
11
|
+
config: T;
|
|
12
|
+
changed: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
16
|
+
v !== null && typeof v === "object" && !Array.isArray(v);
|
|
17
|
+
|
|
18
|
+
/** Exact identity match — `name` or `name@version` — never a substring (D3). */
|
|
19
|
+
const named = (s: string, name: string) => s === name || s.startsWith(`${name}@`);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* True when a plugin identity is a current or legacy Workit entry. Matched by
|
|
23
|
+
* exact identity, never by substring: an unrelated plugin whose id merely
|
|
24
|
+
* contains "workflow-toolkit" is preserved (D3). Path-style identities are
|
|
25
|
+
* compared with normalized separators so a Windows file:// pin (backslashes)
|
|
26
|
+
* still matches the packages/workit-* path checks.
|
|
27
|
+
*/
|
|
28
|
+
export function isWorkitPlugin(value: unknown): boolean {
|
|
29
|
+
const s = String(value).replaceAll("\\", "/");
|
|
30
|
+
const url = s.startsWith("file://") || s.startsWith("git+file://");
|
|
31
|
+
const pkgPath =
|
|
32
|
+
s.includes("/packages/workit-opencode/") ||
|
|
33
|
+
s.includes("/packages/workit-cursor/") ||
|
|
34
|
+
s.includes("/node_modules/@brainervirus/workit-opencode/") ||
|
|
35
|
+
s.includes("/node_modules/@brainervirus/workit-cursor/");
|
|
36
|
+
return (
|
|
37
|
+
named(s, "workflow-toolkit") ||
|
|
38
|
+
named(s, "workflow-toolkit-opencode") ||
|
|
39
|
+
named(s, "local/workflow-toolkit") ||
|
|
40
|
+
named(s, "@brainervirus/workit-opencode") ||
|
|
41
|
+
named(s, "@brainervirus/workit-cursor") ||
|
|
42
|
+
(url && pkgPath) ||
|
|
43
|
+
(s.startsWith("git+file://") && s.includes("workflow-toolkit"))
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Deduplicate every legacy/current Workit plugin identity to one dev pin. */
|
|
48
|
+
export function mergeOpenCodePlugins(plugin: unknown, pin: string): MergeResult<unknown[]> {
|
|
49
|
+
const existing = Array.isArray(plugin) ? plugin : typeof plugin === "string" ? [plugin] : [];
|
|
50
|
+
const config = [pin, ...existing.filter((p) => !isWorkitPlugin(p))];
|
|
51
|
+
const changed = JSON.stringify(config) !== JSON.stringify(existing) ? ["plugin"] : [];
|
|
52
|
+
return { config, changed };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Merge an existing OpenCode config with a dev pin; preserve unrelated keys. */
|
|
56
|
+
export function mergeOpenCodeConfig(
|
|
57
|
+
config: unknown,
|
|
58
|
+
pin: string,
|
|
59
|
+
): MergeResult<Record<string, unknown>> {
|
|
60
|
+
const base = isRecord(config) ? { ...config } : {};
|
|
61
|
+
const changed: string[] = [];
|
|
62
|
+
|
|
63
|
+
const plugins = mergeOpenCodePlugins(base.plugin, pin);
|
|
64
|
+
if (plugins.changed.length > 0) {
|
|
65
|
+
base.plugin = plugins.config;
|
|
66
|
+
changed.push("plugin");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Drop share skills.paths — native ~/.config/opencode/skills links avoid
|
|
70
|
+
// triple-load duplicates. Matched by exact path segment, never substring: an
|
|
71
|
+
// unrelated dir like `~/projects/my-workflow-toolkit-skills` is preserved (D3).
|
|
72
|
+
const skills = base.skills;
|
|
73
|
+
if (isRecord(skills) && Array.isArray(skills.paths)) {
|
|
74
|
+
const next = skills.paths.filter((p) => {
|
|
75
|
+
const segment = String(p).split(/[\\/]/);
|
|
76
|
+
return !segment.some((seg) => named(seg, "workflow-toolkit"));
|
|
77
|
+
});
|
|
78
|
+
if (next.length !== skills.paths.length) {
|
|
79
|
+
base.skills = { ...skills, paths: next };
|
|
80
|
+
changed.push("skills.paths");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { config: base, changed };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Collapse current + legacy Cursor plugin identities to a single one. */
|
|
88
|
+
export function mergeCursorEnabledPlugins(enabled: unknown): MergeResult<Record<string, boolean>> {
|
|
89
|
+
const prev = isRecord(enabled) ? { ...(enabled as Record<string, boolean>) } : {};
|
|
90
|
+
const next: Record<string, boolean> = { ...prev, "workflow-toolkit": true };
|
|
91
|
+
delete next["local/workflow-toolkit"]; // legacy duplicate identity
|
|
92
|
+
const changed = JSON.stringify(next) !== JSON.stringify(prev) ? ["enabled_plugins"] : [];
|
|
93
|
+
return { config: next, changed };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Append the plugin dir once, preserving any existing plugin directories. */
|
|
97
|
+
export function mergeCursorPluginDirs(
|
|
98
|
+
pluginDirs: unknown,
|
|
99
|
+
pluginDir: string,
|
|
100
|
+
): MergeResult<string[]> {
|
|
101
|
+
// path.join does not strip a single trailing separator; do it explicitly for
|
|
102
|
+
// the dedup comparison (guarding the filesystem-root case).
|
|
103
|
+
const strip = (p: string) => {
|
|
104
|
+
const j = path.join(p);
|
|
105
|
+
return path.dirname(j) === j ? j : j.replace(/[\\/]+$/, "");
|
|
106
|
+
};
|
|
107
|
+
const normalized = strip(pluginDir);
|
|
108
|
+
const prev = Array.isArray(pluginDirs) ? pluginDirs.map(String) : [];
|
|
109
|
+
// Normalize both sides for comparison so a trailing-slash variant of an
|
|
110
|
+
// existing entry is not appended as a duplicate; existing entries are kept
|
|
111
|
+
// verbatim.
|
|
112
|
+
const exists = prev.some((d) => strip(d) === normalized);
|
|
113
|
+
const next = exists ? prev : [...prev, normalized];
|
|
114
|
+
const changed = next.length !== prev.length ? ["plugin_dirs"] : [];
|
|
115
|
+
return { config: next, changed };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Merge Cursor settings: one plugin identity, dirs appended, unrelated keys kept. */
|
|
119
|
+
export function mergeCursorSettings(
|
|
120
|
+
settings: unknown,
|
|
121
|
+
pluginDir: string,
|
|
122
|
+
): MergeResult<Record<string, unknown>> {
|
|
123
|
+
const base = isRecord(settings) ? { ...settings } : {};
|
|
124
|
+
const changed: string[] = [];
|
|
125
|
+
|
|
126
|
+
const enabled = mergeCursorEnabledPlugins(base.enabled_plugins);
|
|
127
|
+
if (enabled.changed.length > 0) {
|
|
128
|
+
base.enabled_plugins = enabled.config;
|
|
129
|
+
changed.push("enabled_plugins");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const dirs = mergeCursorPluginDirs(base.plugin_dirs, pluginDir);
|
|
133
|
+
if (dirs.changed.length > 0) {
|
|
134
|
+
base.plugin_dirs = dirs.config;
|
|
135
|
+
changed.push("plugin_dirs");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { config: base, changed };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Set one portable workit MCP server, dropping the legacy server name. */
|
|
142
|
+
export function mergeCursorMcp(
|
|
143
|
+
mcp: unknown,
|
|
144
|
+
serverName: string,
|
|
145
|
+
server: Record<string, unknown>,
|
|
146
|
+
): MergeResult<Record<string, unknown>> {
|
|
147
|
+
const base = isRecord(mcp) ? { ...mcp } : {};
|
|
148
|
+
const servers = isRecord(base.mcpServers)
|
|
149
|
+
? { ...(base.mcpServers as Record<string, unknown>) }
|
|
150
|
+
: {};
|
|
151
|
+
delete servers["workflow-toolkit"]; // legacy duplicate registration
|
|
152
|
+
servers[serverName] = server;
|
|
153
|
+
const changed = JSON.stringify(servers) !== JSON.stringify(base.mcpServers) ? ["mcpServers"] : [];
|
|
154
|
+
base.mcpServers = servers;
|
|
155
|
+
return { config: base, changed };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Swap the sessionStart hook command, preserving other hooks and fields. */
|
|
159
|
+
export function mergeCursorHooks(
|
|
160
|
+
hooks: unknown,
|
|
161
|
+
sessionStartEntry: Record<string, unknown>,
|
|
162
|
+
): MergeResult<Record<string, unknown>> {
|
|
163
|
+
const base: Record<string, unknown> = isRecord(hooks) ? { ...hooks } : { version: 1 };
|
|
164
|
+
const hooksMap = isRecord(base.hooks) ? { ...(base.hooks as Record<string, unknown>) } : {};
|
|
165
|
+
const list = Array.isArray(hooksMap.sessionStart) ? hooksMap.sessionStart : [];
|
|
166
|
+
const same =
|
|
167
|
+
list.length === 1 &&
|
|
168
|
+
isRecord(list[0]) &&
|
|
169
|
+
JSON.stringify(list[0]) === JSON.stringify(sessionStartEntry);
|
|
170
|
+
if (same) return { config: base, changed: [] };
|
|
171
|
+
hooksMap.sessionStart = [sessionStartEntry];
|
|
172
|
+
base.hooks = hooksMap;
|
|
173
|
+
return { config: base, changed: ["hooks.sessionStart"] };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Portable Cursor MCP server entry for an installed plugin dir: prefer the
|
|
178
|
+
* self-contained node dist bundle (PT-10); fall back to the bash shim for a
|
|
179
|
+
* dist-less dev checkout.
|
|
180
|
+
*/
|
|
181
|
+
export function cursorMcpServerEntry(packageDir: string): {
|
|
182
|
+
command: string;
|
|
183
|
+
args: string[];
|
|
184
|
+
} {
|
|
185
|
+
if (existsSync(path.join(packageDir, "dist", "mcp-server.js"))) {
|
|
186
|
+
return {
|
|
187
|
+
command: "node",
|
|
188
|
+
args: [path.join(packageDir, "dist", "mcp-server.js"), "${workspaceFolder}"],
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
command: "bash",
|
|
193
|
+
args: [path.join(packageDir, "mcp", "run-server.sh"), "${workspaceFolder}"],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Portable Cursor sessionStart hook entry: prefer the self-contained node dist
|
|
199
|
+
* bundle; fall back to the bash shim for a dist-less dev checkout (AR-06).
|
|
200
|
+
*/
|
|
201
|
+
export function cursorHooksEntry(packageDir: string): {
|
|
202
|
+
command: string;
|
|
203
|
+
args: string[];
|
|
204
|
+
} {
|
|
205
|
+
if (existsSync(path.join(packageDir, "dist", "cursor-session-start.js"))) {
|
|
206
|
+
return {
|
|
207
|
+
command: "node",
|
|
208
|
+
args: [path.join(packageDir, "dist", "cursor-session-start.js")],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
command: "bash",
|
|
213
|
+
args: [path.join(packageDir, "hooks", "session-start")],
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { vcsConfig, mergedPrStyle } from "./vcs-config";
|
|
5
|
+
|
|
6
|
+
// Ports of scripts/_shared/common.sh + the four maintained context generators
|
|
7
|
+
// (pr-ready-context.sh, changelog-context.sh, docs-refresh-context.sh,
|
|
8
|
+
// release-notes-context.sh). Each generator returns the same stdout text shape
|
|
9
|
+
// the shell produced (## sections parsed by parse-sections.ts).
|
|
10
|
+
|
|
11
|
+
export type ContextResult = { stdout: string; stderr: string; exitCode: number; cwd: string };
|
|
12
|
+
|
|
13
|
+
const runGit = (
|
|
14
|
+
cwd: string,
|
|
15
|
+
args: string[],
|
|
16
|
+
): { stdout: string; stderr: string; exitCode: number } => {
|
|
17
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
18
|
+
return {
|
|
19
|
+
stdout: (result.stdout ?? "").trimEnd(),
|
|
20
|
+
stderr: (result.stderr ?? "").trimEnd(),
|
|
21
|
+
exitCode: result.status ?? 1,
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** repo_root: git rev-parse --show-toplevel || pwd */
|
|
26
|
+
export const repoRoot = (cwd: string): string => {
|
|
27
|
+
const r = runGit(cwd, ["rev-parse", "--show-toplevel"]);
|
|
28
|
+
return r.exitCode === 0 && r.stdout ? r.stdout : cwd;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** current_branch: git rev-parse --abbrev-ref HEAD || unknown */
|
|
32
|
+
export const currentBranch = (cwd: string): string => {
|
|
33
|
+
const r = runGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
34
|
+
return r.exitCode === 0 && r.stdout ? r.stdout : "unknown";
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** default_base: first of origin/main main origin/master master origin/develop develop, else HEAD~1 */
|
|
38
|
+
export const defaultBase = (cwd: string): string => {
|
|
39
|
+
for (const ref of [
|
|
40
|
+
"origin/main",
|
|
41
|
+
"main",
|
|
42
|
+
"origin/master",
|
|
43
|
+
"master",
|
|
44
|
+
"origin/develop",
|
|
45
|
+
"develop",
|
|
46
|
+
]) {
|
|
47
|
+
if (runGit(cwd, ["rev-parse", "--verify", ref]).exitCode === 0) return ref;
|
|
48
|
+
}
|
|
49
|
+
return "HEAD~1";
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const rangeArgOrDefault = (arg: string | undefined, cwd: string): string =>
|
|
53
|
+
arg ? arg : `${defaultBase(cwd)}...HEAD`;
|
|
54
|
+
|
|
55
|
+
export const isProtectedBranch = (branch: string): boolean =>
|
|
56
|
+
["main", "master", "develop", "prod", "production"].includes(branch);
|
|
57
|
+
|
|
58
|
+
export const isPrBranch = (branch: string): boolean =>
|
|
59
|
+
branch.startsWith("feature/") || branch.startsWith("bugfix/");
|
|
60
|
+
|
|
61
|
+
export type PrBranchContext = {
|
|
62
|
+
baseRef: string;
|
|
63
|
+
mergeBase: string;
|
|
64
|
+
range: string;
|
|
65
|
+
diffRange: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** resolve_pr_branch_context: derive the branch-exclusive range from VCS config. */
|
|
69
|
+
export function resolvePrBranchContext(
|
|
70
|
+
cwd: string,
|
|
71
|
+
): { ok: true; value: PrBranchContext } | { ok: false; error: string } {
|
|
72
|
+
const branch = currentBranch(cwd);
|
|
73
|
+
if (isProtectedBranch(branch)) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
error: `cannot build PR context on protected branch ${branch} — PRs are for feature/* or bugfix/* only`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (!isPrBranch(branch)) {
|
|
80
|
+
if (branch === "unknown") {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
error: `not in a git repository at ${repoRoot(cwd)} — open the target repository as the OpenCode session directory`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
error: `branch ${branch} is not feature/* or bugfix/* — checkout a feature branch or pass an explicit git range`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const resolved = vcsConfig("resolve", cwd);
|
|
93
|
+
if (resolved.ok === false) {
|
|
94
|
+
return { ok: false, error: String(resolved.error) };
|
|
95
|
+
}
|
|
96
|
+
const base = String(resolved.defaultTargetBranch ?? "");
|
|
97
|
+
if (!base || runGit(cwd, ["check-ref-format", "--branch", base]).exitCode !== 0) {
|
|
98
|
+
return { ok: false, error: `invalid configured PR target branch ${base}` };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const ref of [`origin/${base}`, base]) {
|
|
102
|
+
if (runGit(cwd, ["rev-parse", "--verify", ref]).exitCode !== 0) continue;
|
|
103
|
+
const mb = runGit(cwd, ["merge-base", ref, "HEAD"]);
|
|
104
|
+
if (mb.exitCode !== 0 || !mb.stdout) continue;
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
value: {
|
|
108
|
+
baseRef: ref,
|
|
109
|
+
mergeBase: mb.stdout,
|
|
110
|
+
range: `${ref}..HEAD`,
|
|
111
|
+
diffRange: `${mb.stdout}..HEAD`,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
error: `configured PR target branch ${base} not found — fetch/checkout it or pass an explicit git range`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function resolvePrRange(cwd: string): string | null {
|
|
122
|
+
const ctx = resolvePrBranchContext(cwd);
|
|
123
|
+
return ctx.ok ? ctx.value.range : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const prRangeArgOrDefault = (arg: string | undefined, cwd: string): string | null =>
|
|
127
|
+
arg ? arg : resolvePrRange(cwd);
|
|
128
|
+
|
|
129
|
+
export const printSection = (title: string): string => `\n## ${title}\n\n`;
|
|
130
|
+
|
|
131
|
+
// macOS/Windows filesystems are case-insensitive: existsSync() would match a
|
|
132
|
+
// differently-cased candidate, so the returned template_path must be the
|
|
133
|
+
// ACTUAL on-disk name. Probe each candidate directory case-insensitively and
|
|
134
|
+
// report the real entry — identical to the case-sensitive Linux probe, which
|
|
135
|
+
// only ever sees the exact on-disk name.
|
|
136
|
+
const probeCaseInsensitive = (dir: string, wanted: string): string | null => {
|
|
137
|
+
try {
|
|
138
|
+
for (const entry of readdirSync(dir)) {
|
|
139
|
+
if (entry.toLowerCase() === wanted.toLowerCase()) return entry;
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
/* missing dir */
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const findPrTemplate = (cwd: string): string | null => {
|
|
148
|
+
for (const rel of [
|
|
149
|
+
".gitlab/merge_request_templates/Default.md",
|
|
150
|
+
".gitlab/merge_request_templates/default.md",
|
|
151
|
+
".gitlab/merge_request_templates/merge_request_template.md",
|
|
152
|
+
".github/PULL_REQUEST_TEMPLATE.md",
|
|
153
|
+
".github/pull_request_template.md",
|
|
154
|
+
".github/PULL_REQUEST_TEMPLATE/pull_request_template.md",
|
|
155
|
+
"docs/PULL_REQUEST_TEMPLATE.md",
|
|
156
|
+
"PULL_REQUEST_TEMPLATE.md",
|
|
157
|
+
]) {
|
|
158
|
+
const parts = rel.split("/");
|
|
159
|
+
const base = parts[parts.length - 1];
|
|
160
|
+
const dir = parts.length > 1 ? path.join(cwd, ...parts.slice(0, -1)) : cwd;
|
|
161
|
+
const actual = probeCaseInsensitive(dir, base);
|
|
162
|
+
if (actual) return [...parts.slice(0, -1), actual].join("/");
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export const fallbackPrTemplate = (): string => `## Summary
|
|
168
|
+
-
|
|
169
|
+
|
|
170
|
+
## Validation
|
|
171
|
+
- [ ] Not run`;
|
|
172
|
+
|
|
173
|
+
/** commit_log_for_range: git log --oneline --decorate --no-merges, falls back to -10. */
|
|
174
|
+
export const commitLogForRange = (cwd: string, range: string): string => {
|
|
175
|
+
const r = runGit(cwd, ["log", "--oneline", "--decorate", "--no-merges", range, "--"]);
|
|
176
|
+
if (r.exitCode === 0) return r.stdout;
|
|
177
|
+
return runGit(cwd, ["log", "--oneline", "--decorate", "-10", "--"]).stdout;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export const changedFilesForRange = (cwd: string, range: string): string => {
|
|
181
|
+
const r = runGit(cwd, ["diff", "--name-only", range, "--"]);
|
|
182
|
+
if (r.exitCode === 0) return r.stdout;
|
|
183
|
+
return runGit(cwd, ["diff", "--name-only", "--"]).stdout;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export const diffStatForRange = (cwd: string, range: string): string => {
|
|
187
|
+
const r = runGit(cwd, ["diff", "--stat", range, "--"]);
|
|
188
|
+
if (r.exitCode === 0) return r.stdout;
|
|
189
|
+
return runGit(cwd, ["diff", "--stat", "--"]).stdout;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const packageScripts = (cwd: string, keys: string[]): string[] => {
|
|
193
|
+
const pkgPath = path.join(cwd, "package.json");
|
|
194
|
+
if (!existsSync(pkgPath)) return [];
|
|
195
|
+
try {
|
|
196
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { scripts?: Record<string, string> };
|
|
197
|
+
return keys
|
|
198
|
+
.filter((k) => pkg.scripts && typeof pkg.scripts[k] === "string")
|
|
199
|
+
.map((k) => `${k}: ${pkg.scripts?.[k]}`);
|
|
200
|
+
} catch {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/** find . -maxdepth 3 -name README.md -o -name *.md with excludes, sorted, first 200. */
|
|
206
|
+
export const documentationFiles = (cwd: string): string[] => {
|
|
207
|
+
const excluded = new Set([".git", "node_modules", "target", "dist"]);
|
|
208
|
+
const matches: string[] = [];
|
|
209
|
+
const walk = (dir: string, depth: number) => {
|
|
210
|
+
if (depth > 3) return;
|
|
211
|
+
let entries;
|
|
212
|
+
try {
|
|
213
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
214
|
+
} catch {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
for (const entry of entries) {
|
|
218
|
+
if (entry.isDirectory()) {
|
|
219
|
+
if (excluded.has(entry.name)) continue;
|
|
220
|
+
walk(path.join(dir, entry.name), depth + 1);
|
|
221
|
+
} else if (entry.isFile() && (entry.name === "README.md" || entry.name.endsWith(".md"))) {
|
|
222
|
+
// Shell parity: the script printed "./path" with forward slashes even
|
|
223
|
+
// on win32, where path.relative would otherwise emit backslashes.
|
|
224
|
+
matches.push("./" + path.relative(cwd, path.join(dir, entry.name)).replaceAll("\\", "/"));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
walk(cwd, 1);
|
|
229
|
+
return matches.sort().slice(0, 200);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const readTrimmed = (file: string, maxLines: number): string => {
|
|
233
|
+
if (!existsSync(file)) return "";
|
|
234
|
+
const lines = readFileSync(file, "utf8").split("\n");
|
|
235
|
+
return lines.slice(0, maxLines).join("\n");
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/** Port of pr-ready-context.sh — PR-ready repository context. */
|
|
239
|
+
export function prReadyContext(root: string, range?: string): ContextResult {
|
|
240
|
+
const cwd = path.resolve(repoRoot(root));
|
|
241
|
+
let stdout = "";
|
|
242
|
+
const autoRange = range === undefined;
|
|
243
|
+
|
|
244
|
+
let ctx: { ok: true; value: PrBranchContext } | { ok: false; error: string } | null = null;
|
|
245
|
+
if (autoRange) {
|
|
246
|
+
ctx = resolvePrBranchContext(cwd);
|
|
247
|
+
if (!ctx.ok) return { stdout: "", stderr: `ERROR: ${ctx.error}\n`, exitCode: 1, cwd };
|
|
248
|
+
range = ctx.value.range;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
stdout += printSection("Repository");
|
|
252
|
+
stdout += `root: ${cwd}\n`;
|
|
253
|
+
stdout += `branch: ${currentBranch(cwd)}\n`;
|
|
254
|
+
stdout += `range: ${range}\n`;
|
|
255
|
+
if (autoRange && ctx?.ok) {
|
|
256
|
+
stdout += `base_ref: ${ctx.value.baseRef}\n`;
|
|
257
|
+
stdout += `merge_base: ${ctx.value.mergeBase}\n`;
|
|
258
|
+
stdout += `diff_range: ${ctx.value.diffRange}\n`;
|
|
259
|
+
stdout += "range_mode: branch-exclusive\n";
|
|
260
|
+
if (process.env.PR_SYNC_NOTES) stdout += `git_sync: ${process.env.PR_SYNC_NOTES}\n`;
|
|
261
|
+
}
|
|
262
|
+
const diffRange = autoRange && ctx?.ok ? ctx.value.diffRange : (range ?? "");
|
|
263
|
+
|
|
264
|
+
stdout += printSection("Working Tree");
|
|
265
|
+
const status = runGit(cwd, ["status", "--short"]);
|
|
266
|
+
stdout += status.stdout + (status.stderr ? `\n${status.stderr}` : "");
|
|
267
|
+
stdout += "\n";
|
|
268
|
+
|
|
269
|
+
stdout += printSection("Commits");
|
|
270
|
+
stdout += commitLogForRange(cwd, range ?? "");
|
|
271
|
+
stdout += "\n";
|
|
272
|
+
|
|
273
|
+
stdout += printSection("Diff Stat");
|
|
274
|
+
stdout += diffStatForRange(cwd, diffRange);
|
|
275
|
+
stdout += "\n";
|
|
276
|
+
|
|
277
|
+
stdout += printSection("Changed Files");
|
|
278
|
+
stdout += changedFilesForRange(cwd, diffRange);
|
|
279
|
+
stdout += "\n";
|
|
280
|
+
|
|
281
|
+
stdout += printSection("PR Template");
|
|
282
|
+
const template = findPrTemplate(cwd);
|
|
283
|
+
if (template) {
|
|
284
|
+
stdout += `template_path: ${template}\n\n`;
|
|
285
|
+
stdout += readTrimmed(path.join(cwd, template), 220);
|
|
286
|
+
} else {
|
|
287
|
+
stdout += "template_path: none\n\n";
|
|
288
|
+
stdout += fallbackPrTemplate();
|
|
289
|
+
}
|
|
290
|
+
stdout += "\n";
|
|
291
|
+
|
|
292
|
+
stdout += printSection("Recent Validation Signals");
|
|
293
|
+
if (existsSync(path.join(cwd, "package.json"))) {
|
|
294
|
+
stdout += "package.json detected. Common scripts:\n";
|
|
295
|
+
const found = packageScripts(cwd, ["lint", "format:check", "test", "build"]);
|
|
296
|
+
if (found.length) stdout += found.join("\n") + "\n";
|
|
297
|
+
}
|
|
298
|
+
if (
|
|
299
|
+
existsSync(path.join(cwd, "Cargo.toml")) ||
|
|
300
|
+
existsSync(path.join(cwd, "src-tauri/Cargo.toml"))
|
|
301
|
+
) {
|
|
302
|
+
stdout += "Rust project detected.\n";
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
stdout += printSection("VCS Config");
|
|
306
|
+
const vc = vcsConfig("resolve", cwd);
|
|
307
|
+
if (vc.ok) {
|
|
308
|
+
stdout += `workspace: ${String(vc.workspace_name ?? "none")}\n`;
|
|
309
|
+
stdout += `provider: ${String(vc.provider ?? "gitlab")}\n`;
|
|
310
|
+
} else {
|
|
311
|
+
// RL-01: never report silent defaults for malformed vcs.json — surface the
|
|
312
|
+
// exact-path diagnostic instead.
|
|
313
|
+
stdout += `vcs: unreadable (malformed) — ${String(vc.error ?? "cannot read vcs.json")}\n`;
|
|
314
|
+
}
|
|
315
|
+
// B4: the shell printed only workspace:/provider: (the summary dump was
|
|
316
|
+
// discarded). Keep that concise shape — no raw summary JSON in the context.
|
|
317
|
+
if (!vcsConfig("summary", cwd).ok) {
|
|
318
|
+
stdout += "vcs: not configured — run /wk-init action vcs_scaffold\n";
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
stdout += printSection("Merged PR Style");
|
|
322
|
+
const style = mergedPrStyle(6, cwd);
|
|
323
|
+
stdout += JSON.stringify(style, null, 2) + "\n";
|
|
324
|
+
|
|
325
|
+
return { stdout, stderr: "", exitCode: 0, cwd };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const CHANGELOG_RULES = `- Use an [Unreleased] section.
|
|
329
|
+
- Use Added, Changed, Deprecated, Removed, Fixed, Security.
|
|
330
|
+
- Entries should be human-readable and user-facing.
|
|
331
|
+
- Do not use raw commit messages as changelog bullets.
|
|
332
|
+
- MERGE into existing ### Category under [Unreleased] — never append a second ### Added / ### Fixed block.
|
|
333
|
+
- Apply with the native workflow_changelog_apply tool only (not hand-edits under Unreleased).
|
|
334
|
+
- If Unreleased already has duplicate category headings, normalize_only first.`;
|
|
335
|
+
|
|
336
|
+
/** Port of changelog-context.sh — changelog update context. */
|
|
337
|
+
export function changelogContext(root: string, range?: string): ContextResult {
|
|
338
|
+
const cwd = path.resolve(repoRoot(root));
|
|
339
|
+
const resolvedRange = rangeArgOrDefault(range, cwd);
|
|
340
|
+
let stdout = "";
|
|
341
|
+
|
|
342
|
+
stdout += printSection("Repository");
|
|
343
|
+
stdout += `root: ${cwd}\n`;
|
|
344
|
+
stdout += `branch: ${currentBranch(cwd)}\n`;
|
|
345
|
+
stdout += `range: ${resolvedRange}\n`;
|
|
346
|
+
|
|
347
|
+
stdout += printSection("Keep a Changelog Rules");
|
|
348
|
+
stdout += CHANGELOG_RULES + "\n";
|
|
349
|
+
|
|
350
|
+
stdout += printSection("Existing CHANGELOG.md");
|
|
351
|
+
const changelogPath = path.join(cwd, "CHANGELOG.md");
|
|
352
|
+
if (existsSync(changelogPath)) {
|
|
353
|
+
stdout += readTrimmed(changelogPath, 260) + "\n";
|
|
354
|
+
} else {
|
|
355
|
+
stdout += "CHANGELOG.md not found.\n";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
stdout += printSection("Commits");
|
|
359
|
+
stdout += commitLogForRange(cwd, resolvedRange) + "\n";
|
|
360
|
+
|
|
361
|
+
stdout += printSection("Diff Stat");
|
|
362
|
+
stdout += diffStatForRange(cwd, resolvedRange) + "\n";
|
|
363
|
+
|
|
364
|
+
stdout += printSection("Changed Files");
|
|
365
|
+
stdout += changedFilesForRange(cwd, resolvedRange) + "\n";
|
|
366
|
+
|
|
367
|
+
return { stdout, stderr: "", exitCode: 0, cwd };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Port of docs-refresh-context.sh — documentation refresh context. */
|
|
371
|
+
export function docsRefreshContext(root: string, range?: string): ContextResult {
|
|
372
|
+
const cwd = path.resolve(repoRoot(root));
|
|
373
|
+
const resolvedRange = rangeArgOrDefault(range, cwd);
|
|
374
|
+
let stdout = "";
|
|
375
|
+
|
|
376
|
+
stdout += printSection("Repository");
|
|
377
|
+
stdout += `root: ${cwd}\n`;
|
|
378
|
+
stdout += `branch: ${currentBranch(cwd)}\n`;
|
|
379
|
+
stdout += `range: ${resolvedRange}\n`;
|
|
380
|
+
|
|
381
|
+
stdout += printSection("Changed Files");
|
|
382
|
+
stdout += changedFilesForRange(cwd, resolvedRange) + "\n";
|
|
383
|
+
|
|
384
|
+
stdout += printSection("Documentation Files");
|
|
385
|
+
stdout += documentationFiles(cwd).join("\n") + "\n";
|
|
386
|
+
|
|
387
|
+
stdout += printSection("README Preview");
|
|
388
|
+
const readme = path.join(cwd, "README.md");
|
|
389
|
+
if (existsSync(readme)) {
|
|
390
|
+
stdout += readTrimmed(readme, 220) + "\n";
|
|
391
|
+
} else {
|
|
392
|
+
stdout += "README.md not found.\n";
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
stdout += printSection("Package Scripts");
|
|
396
|
+
const pkgPath = path.join(cwd, "package.json");
|
|
397
|
+
if (existsSync(pkgPath)) {
|
|
398
|
+
try {
|
|
399
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
|
|
400
|
+
stdout +=
|
|
401
|
+
JSON.stringify({ name: pkg.name, version: pkg.version, scripts: pkg.scripts }, null, 2) +
|
|
402
|
+
"\n";
|
|
403
|
+
} catch {
|
|
404
|
+
/* unreadable package.json */
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
stdout += "package.json not found.\n";
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return { stdout, stderr: "", exitCode: 0, cwd };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Port of release-notes-context.sh — tags + commits + files for a range. */
|
|
414
|
+
export function releaseNotesContext(root: string, rangeOrTag: string): ContextResult {
|
|
415
|
+
const cwd = path.resolve(repoRoot(root));
|
|
416
|
+
if (!rangeOrTag) {
|
|
417
|
+
return { stdout: "", stderr: "ERROR: release tag or range required\n", exitCode: 1, cwd };
|
|
418
|
+
}
|
|
419
|
+
const resolvedRange = rangeArgOrDefault(rangeOrTag, cwd);
|
|
420
|
+
let stdout = "";
|
|
421
|
+
|
|
422
|
+
stdout += printSection("Repository");
|
|
423
|
+
stdout += `root: ${cwd}\n`;
|
|
424
|
+
stdout += `branch: ${currentBranch(cwd)}\n`;
|
|
425
|
+
stdout += `requested: ${rangeOrTag}\n`;
|
|
426
|
+
stdout += `range: ${resolvedRange}\n`;
|
|
427
|
+
|
|
428
|
+
stdout += printSection("Tags");
|
|
429
|
+
const tags = runGit(cwd, ["tag", "--sort=-creatordate"]);
|
|
430
|
+
if (tags.exitCode === 0) stdout += tags.stdout.split("\n").slice(0, 20).join("\n") + "\n";
|
|
431
|
+
|
|
432
|
+
stdout += printSection("Commits");
|
|
433
|
+
stdout += commitLogForRange(cwd, resolvedRange) + "\n";
|
|
434
|
+
|
|
435
|
+
stdout += printSection("Diff Stat");
|
|
436
|
+
stdout += diffStatForRange(cwd, resolvedRange) + "\n";
|
|
437
|
+
|
|
438
|
+
stdout += printSection("Changed Files");
|
|
439
|
+
stdout += changedFilesForRange(cwd, resolvedRange) + "\n";
|
|
440
|
+
|
|
441
|
+
stdout += printSection("Existing Release Files");
|
|
442
|
+
for (const rel of ["CHANGELOG.md", "RELEASE_NOTES.md", ".github/releases.md"]) {
|
|
443
|
+
if (existsSync(path.join(cwd, rel))) stdout += rel + "\n";
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return { stdout, stderr: "", exitCode: 0, cwd };
|
|
447
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { fail, ok, run } from "../core";
|
|
2
|
+
|
|
3
|
+
export type RunResult = ReturnType<typeof run>;
|
|
4
|
+
|
|
5
|
+
export type RepoRuntime = {
|
|
6
|
+
git(root: string, args: string[]): RunResult;
|
|
7
|
+
verifyProject(root: string, dryRun: boolean): RunResult;
|
|
8
|
+
prContext(root: string, range: string | undefined): RunResult;
|
|
9
|
+
changelogContext(root: string, range: string | undefined): RunResult;
|
|
10
|
+
docsContext(root: string, range: string | undefined): RunResult;
|
|
11
|
+
releaseContext(root: string, range: string): RunResult;
|
|
12
|
+
prCreate(root: string, env: Record<string, string>): RunResult;
|
|
13
|
+
initApply(root: string, action: string, env: Record<string, string>): RunResult;
|
|
14
|
+
initStatus(root: string): RunResult;
|
|
15
|
+
toolkitStatus(root: string): RunResult | Promise<RunResult>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const normalizeLegacyResult = (value: Record<string, unknown>) => {
|
|
19
|
+
if (value.error) return fail(String(value.error));
|
|
20
|
+
if (value.ok === false) return fail("legacy operation reported failure");
|
|
21
|
+
const { ok: _legacyOk, ...data } = value;
|
|
22
|
+
return ok(data);
|
|
23
|
+
};
|