@workit-dev/core 0.0.1 → 1.0.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 (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -43
  3. package/package.json +56 -6
  4. package/schemas/config.json +74 -0
  5. package/schemas/vcs.json +79 -0
  6. package/schemas/workspaces.json +1029 -0
  7. package/schemas/youtrack.json +84 -0
  8. package/scripts/analyze-release-scope.ts +215 -0
  9. package/scripts/bundled-deps.json +131 -0
  10. package/scripts/fixtures/sample-plan.md +15 -0
  11. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  12. package/scripts/install-codex-plugin.sh +28 -0
  13. package/scripts/install-cursor-plugin.sh +146 -0
  14. package/scripts/install-opencode-plugin.sh +79 -0
  15. package/scripts/install-pi-package.sh +56 -0
  16. package/scripts/lib/config-dir.sh +28 -0
  17. package/scripts/publish-changed-packages.ts +395 -0
  18. package/scripts/rewrite-workspace-deps.ts +53 -0
  19. package/scripts/run-cursor-mcp.sh +6 -0
  20. package/scripts/sync-release-manifests.ts +82 -0
  21. package/scripts/sync-runtime.sh +142 -0
  22. package/scripts/validate-cursor-marketplace.ts +171 -0
  23. package/scripts/verify-bundle-sources.ts +134 -0
  24. package/scripts/write-schemas.ts +16 -0
  25. package/skills/workit-architecture/SKILL.md +72 -0
  26. package/skills/workit-architecture/agents/openai.yaml +3 -0
  27. package/skills/workit-architecture/references/instruction-files.md +52 -0
  28. package/skills/workit-architecture/references/vocabulary.md +53 -0
  29. package/skills/workit-bdd/SKILL.md +53 -0
  30. package/skills/workit-continue/SKILL.md +54 -0
  31. package/skills/workit-debug/SKILL.md +45 -0
  32. package/skills/workit-deslop/SKILL.md +58 -0
  33. package/skills/workit-fanout/SKILL.md +71 -0
  34. package/skills/workit-fanout/references/brief.md +141 -0
  35. package/skills/workit-fanout/references/orchestration.md +169 -0
  36. package/skills/workit-implement/SKILL.md +71 -0
  37. package/skills/workit-retro/SKILL.md +72 -0
  38. package/skills/workit-retro/agents/openai.yaml +3 -0
  39. package/skills/workit-retro/references/sources.md +35 -0
  40. package/skills/workit-retro/references/steering.md +39 -0
  41. package/skills/workit-review/SKILL.md +66 -0
  42. package/skills/workit-review/references/impact.md +24 -0
  43. package/skills/workit-shape/SKILL.md +71 -0
  44. package/skills/workit-shape/references/diagrams.md +17 -0
  45. package/skills/workit-shape/references/knowledge.md +61 -0
  46. package/skills/workit-shape/references/mockups.md +15 -0
  47. package/skills/workit-shape/references/slicing.md +44 -0
  48. package/skills/workit-ship/SKILL.md +71 -0
  49. package/skills/workit-ship/references/changelog.md +44 -0
  50. package/skills/workit-ship/references/pr-body.md +32 -0
  51. package/skills/workit-ship/references/release.md +39 -0
  52. package/skills/workit-test-audit/SKILL.md +54 -0
  53. package/skills/workit-verify-app/SKILL.md +68 -0
  54. package/skills/workit-verify-app/references/template.md +51 -0
  55. package/src/autonomy.ts +910 -0
  56. package/src/check-config.ts +613 -0
  57. package/src/checks.ts +534 -0
  58. package/src/ci-waiter.ts +159 -0
  59. package/src/core/boundary.ts +55 -0
  60. package/src/core/branch-policy.ts +112 -0
  61. package/src/core/branch.ts +1192 -0
  62. package/src/core/changelog-gate.ts +349 -0
  63. package/src/core/changelog-merge.ts +214 -0
  64. package/src/core/changelog.ts +392 -0
  65. package/src/core/cleanup.ts +638 -0
  66. package/src/core/cli-locate.ts +173 -0
  67. package/src/core/commit-flavors.ts +65 -0
  68. package/src/core/config-schemas.ts +653 -0
  69. package/src/core/config.ts +351 -0
  70. package/src/core/context-read.ts +144 -0
  71. package/src/core/docs-gate.ts +69 -0
  72. package/src/core/finding-evidence.ts +89 -0
  73. package/src/core/finding-queue.ts +36 -0
  74. package/src/core/git.ts +73 -0
  75. package/src/core/gitignore.ts +52 -0
  76. package/src/core/heavy-eta.ts +170 -0
  77. package/src/core/heavy-lease.ts +290 -0
  78. package/src/core/heavy-rules.ts +373 -0
  79. package/src/core/heavy-scope.ts +131 -0
  80. package/src/core/heavy-words.ts +83 -0
  81. package/src/core/heavy.ts +535 -0
  82. package/src/core/hygiene.ts +149 -0
  83. package/src/core/init.ts +218 -0
  84. package/src/core/live-session.ts +333 -0
  85. package/src/core/logger.ts +310 -0
  86. package/src/core/methods.ts +185 -0
  87. package/src/core/model-fallback.ts +208 -0
  88. package/src/core/node-resolve.mjs +249 -0
  89. package/src/core/operation-input.ts +632 -0
  90. package/src/core/package-root.ts +28 -0
  91. package/src/core/policy/attributes.ts +97 -0
  92. package/src/core/policy/bookkeeping.ts +119 -0
  93. package/src/core/policy/carry.ts +122 -0
  94. package/src/core/policy/check-evidence.ts +115 -0
  95. package/src/core/policy/current-judgment.ts +20 -0
  96. package/src/core/policy/derive.ts +387 -0
  97. package/src/core/policy/judgment.ts +246 -0
  98. package/src/core/policy/lane.ts +562 -0
  99. package/src/core/policy/pr-lane.ts +32 -0
  100. package/src/core/policy/substance.ts +365 -0
  101. package/src/core/policy-resolver.ts +94 -0
  102. package/src/core/pr-create.ts +448 -0
  103. package/src/core/pr-template.ts +162 -0
  104. package/src/core/release-notes.ts +197 -0
  105. package/src/core/release-tracks.ts +779 -0
  106. package/src/core/remotes.ts +436 -0
  107. package/src/core/repo-context.ts +542 -0
  108. package/src/core/repo-paths.ts +29 -0
  109. package/src/core/resources.ts +1299 -0
  110. package/src/core/role-models.ts +603 -0
  111. package/src/core/route-intent.ts +305 -0
  112. package/src/core/rules.ts +65 -0
  113. package/src/core/runtime-identity.ts +63 -0
  114. package/src/core/safe-write.ts +87 -0
  115. package/src/core/scope-glob.ts +56 -0
  116. package/src/core/scripts.ts +6 -0
  117. package/src/core/skill-manifests.ts +173 -0
  118. package/src/core/store-lock.ts +374 -0
  119. package/src/core/support-matrix.ts +24 -0
  120. package/src/core/task-context.ts +247 -0
  121. package/src/core/task-contract.ts +1279 -0
  122. package/src/core/task-delivery.ts +109 -0
  123. package/src/core/task-engine.ts +2248 -0
  124. package/src/core/task-evaluation.ts +1147 -0
  125. package/src/core/task-store.ts +2649 -0
  126. package/src/core/task-view.ts +180 -0
  127. package/src/core/templates.ts +22 -0
  128. package/src/core/tracker-issues.ts +174 -0
  129. package/src/core/vcs-config.ts +540 -0
  130. package/src/core/workers.ts +62 -0
  131. package/src/core/workit-paths.mjs +242 -0
  132. package/src/core/workspace-branch-policy.ts +123 -0
  133. package/src/core/workspaces.ts +1745 -0
  134. package/src/core/youtrack-backend.ts +288 -0
  135. package/src/core/youtrack-tools.ts +260 -0
  136. package/src/core/youtrack-writes.ts +73 -0
  137. package/src/core/youtrack-ytrack.ts +217 -0
  138. package/src/core/youtrack.ts +454 -0
  139. package/src/core/ytrack-skill.ts +297 -0
  140. package/src/core.ts +171 -0
  141. package/src/deslop/lex.ts +271 -0
  142. package/src/deslop/scan.ts +232 -0
  143. package/src/duration.ts +27 -0
  144. package/src/fanout-brief.ts +486 -0
  145. package/src/fanout-check.ts +455 -0
  146. package/src/fanout-landed.ts +370 -0
  147. package/src/fanout-stack.ts +108 -0
  148. package/src/fanout-status.ts +362 -0
  149. package/src/fanout-worktree.ts +893 -0
  150. package/src/fanout.ts +1473 -0
  151. package/src/forge/exec.ts +385 -0
  152. package/src/forge/github.ts +831 -0
  153. package/src/forge/gitlab.ts +906 -0
  154. package/src/forge/issue-forge.ts +209 -0
  155. package/src/forge/issue-link.ts +468 -0
  156. package/src/forge/issue-ops.ts +314 -0
  157. package/src/forge/merge-gate-base-red.ts +72 -0
  158. package/src/forge/pr-body.ts +100 -0
  159. package/src/forge/pr-edit.ts +292 -0
  160. package/src/forge/pr-ops.ts +1109 -0
  161. package/src/forge/pr-status.ts +10 -0
  162. package/src/forge/redact.ts +195 -0
  163. package/src/forge/report.ts +994 -0
  164. package/src/forge/reruns.ts +132 -0
  165. package/src/forge/resolve.ts +509 -0
  166. package/src/forge/types.ts +242 -0
  167. package/src/forge/verify.ts +398 -0
  168. package/src/git/default-base.ts +130 -0
  169. package/src/git/env-redirect.ts +28 -0
  170. package/src/git/facts.ts +122 -0
  171. package/src/git/ops.ts +1823 -0
  172. package/src/git/refs-read.ts +332 -0
  173. package/src/git/release.ts +1020 -0
  174. package/src/git/rev.ts +1175 -0
  175. package/src/git/session-hook.ts +597 -0
  176. package/src/git/stage.ts +63 -0
  177. package/src/hooks/agent-model.ts +149 -0
  178. package/src/hooks/budget-signal.ts +284 -0
  179. package/src/hooks/context.ts +461 -0
  180. package/src/hooks/cwd-normalize.ts +79 -0
  181. package/src/hooks/deadline.ts +228 -0
  182. package/src/hooks/delivery-verbs.ts +182 -0
  183. package/src/hooks/descriptor.ts +124 -0
  184. package/src/hooks/handle.ts +433 -0
  185. package/src/hooks/heavy-gate.ts +99 -0
  186. package/src/hooks/hook-runs.ts +130 -0
  187. package/src/hooks/hosts/claude-code.ts +380 -0
  188. package/src/hooks/hosts/codex.ts +372 -0
  189. package/src/hooks/hosts/cursor.ts +419 -0
  190. package/src/hooks/hosts/fields.ts +98 -0
  191. package/src/hooks/hosts/opencode.ts +79 -0
  192. package/src/hooks/hosts/pi.ts +78 -0
  193. package/src/hooks/index.ts +60 -0
  194. package/src/hooks/model-limit.ts +378 -0
  195. package/src/hooks/model-switch.ts +35 -0
  196. package/src/hooks/policy.ts +29 -0
  197. package/src/hooks/process.ts +48 -0
  198. package/src/hooks/protocol.ts +135 -0
  199. package/src/hooks/raw-git.ts +2289 -0
  200. package/src/hooks/repeat-failure.ts +115 -0
  201. package/src/hooks/run.ts +54 -0
  202. package/src/hooks/session-marker.ts +151 -0
  203. package/src/hooks/shell-words.ts +532 -0
  204. package/src/hooks/skill-nudge.ts +295 -0
  205. package/src/hooks/spawn-gate.ts +72 -0
  206. package/src/hooks/standing-signal.ts +99 -0
  207. package/src/hooks/standing.ts +225 -0
  208. package/src/hooks/steering.ts +74 -0
  209. package/src/hooks/stop.ts +408 -0
  210. package/src/hooks/worktree-confine.ts +415 -0
  211. package/src/hooks/write-gate.ts +1174 -0
  212. package/src/host-session.ts +459 -0
  213. package/src/knowledge.ts +368 -0
  214. package/src/ledger.ts +2913 -0
  215. package/src/stack.ts +2570 -0
  216. package/src/store/event-log.ts +217 -0
  217. package/src/store/notes.ts +29 -0
  218. package/src/store/patch.ts +135 -0
  219. package/src/store/paths.ts +368 -0
  220. package/src/store/reduce.ts +203 -0
  221. package/src/test-audit-mocks.ts +309 -0
  222. package/src/verdict-cover.ts +151 -0
  223. package/src/worktree-deps.ts +142 -0
  224. package/templates/headers.md +3 -0
  225. package/templates/hygiene/.editorconfig +8 -0
  226. package/templates/hygiene/.gitattributes +3 -0
  227. package/templates/hygiene/CHANGELOG.md +14 -0
  228. package/templates/hygiene/CONTRIBUTING.md +3 -0
  229. package/templates/hygiene/LICENSE +21 -0
  230. package/templates/hygiene/README.md +3 -0
  231. package/templates/issue-update.md +5 -0
  232. package/templates/plan-template.md +26 -0
  233. package/templates/spec-template.md +64 -0
  234. package/templates/workit-contract.md +13 -0
@@ -0,0 +1,392 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { resolveWorkspaceRoot } from "./scripts";
5
+
6
+ const CATEGORIES = ["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"];
7
+
8
+ // Port of scripts/changelog/apply-unreleased.py — merge Keep a Changelog
9
+ // entries into ## [Unreleased] without duplicating ### headings.
10
+ const CAT_RE = /^###\s+(?:Added|Changed|Deprecated|Removed|Fixed|Security)\s*$/i;
11
+ const UNRELEASED_RE = /^##\s+\[Unreleased\]\s*$/i;
12
+ const VERSION_RE = /^##\s+\[/;
13
+ const BULLET_RE = /^([-*]\s+)(.+?)\s*$/;
14
+ const HEADING_RE = /^###\s+/;
15
+
16
+ const SKELETON = `# Changelog
17
+
18
+ All notable changes to this project will be documented in this file.
19
+
20
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
21
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
22
+
23
+ ## [Unreleased]
24
+
25
+ `;
26
+
27
+ const digestBytes = (value: Buffer | string): string =>
28
+ createHash("sha256").update(value).digest("hex");
29
+
30
+ type ChangelogSource = { exists: boolean; bytes: Buffer; text: string } | { error: string };
31
+
32
+ const readChangelogSource = (target: string): ChangelogSource => {
33
+ if (!fs.existsSync(target)) {
34
+ const bytes = Buffer.from(SKELETON, "utf8");
35
+ return { exists: false, bytes, text: SKELETON };
36
+ }
37
+ try {
38
+ const bytes = fs.readFileSync(target);
39
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
40
+ return { exists: true, bytes, text };
41
+ } catch {
42
+ return { error: "changelog file must contain valid UTF-8" };
43
+ }
44
+ };
45
+
46
+ export function normalizeBullet(text: string): string {
47
+ text = text.trim();
48
+ const m = BULLET_RE.exec(text);
49
+ if (m) return m[2].trim();
50
+ if (text.startsWith("- ") || text.startsWith("* ")) return text.slice(2).trim();
51
+ return text;
52
+ }
53
+
54
+ function formatBullet(text: string): string {
55
+ return `- ${normalizeBullet(text)}`;
56
+ }
57
+
58
+ export function splitUnreleased(text: string): [string, string, string] {
59
+ const lines = text.split(/(?<=\n)/);
60
+ let start: number | null = null;
61
+ for (let i = 0; i < lines.length; i++) {
62
+ if (UNRELEASED_RE.test(lines[i].replace(/\n$/, ""))) {
63
+ start = i;
64
+ break;
65
+ }
66
+ }
67
+ if (start === null) return [text, "", ""];
68
+
69
+ let end = lines.length;
70
+ for (let j = start + 1; j < lines.length; j++) {
71
+ const stripped = lines[j].replace(/\n$/, "");
72
+ // Next version heading ends Unreleased (ignore a stray second Unreleased)
73
+ if (VERSION_RE.test(stripped)) {
74
+ if (UNRELEASED_RE.test(stripped)) continue;
75
+ end = j;
76
+ break;
77
+ }
78
+ }
79
+
80
+ const before = lines.slice(0, start + 1).join("");
81
+ const body = lines.slice(start + 1, end).join("");
82
+ const after = lines.slice(end).join("");
83
+ return [before, body, after];
84
+ }
85
+
86
+ function canonicalCategory(heading: string): string | null {
87
+ const match = CAT_RE.exec(heading.replace(/\r?\n$/, ""));
88
+ if (!match) return null;
89
+ return (
90
+ CATEGORIES.find(
91
+ (c) =>
92
+ c.toLowerCase() ===
93
+ match[0]
94
+ .replace(/^###\s+/, "")
95
+ .trim()
96
+ .toLowerCase(),
97
+ ) ?? null
98
+ );
99
+ }
100
+
101
+ export function splitSections(
102
+ body: string,
103
+ ): [string[], Array<{ heading: string; category: string | null; body: string[] }>] {
104
+ const preamble: string[] = [];
105
+ const sections: Array<{ heading: string; category: string | null; body: string[] }> = [];
106
+ let current: { heading: string; category: string | null; body: string[] } | null = null;
107
+ for (const line of body.split(/(?<=\n)/)) {
108
+ if (HEADING_RE.test(line)) {
109
+ current = { heading: line, category: canonicalCategory(line), body: [] };
110
+ sections.push(current);
111
+ } else if (current === null) {
112
+ preamble.push(line);
113
+ } else {
114
+ current.body.push(line);
115
+ }
116
+ }
117
+ return [preamble, sections];
118
+ }
119
+
120
+ export function bulletBlocks(lines: string[]): Array<[string | null, string[]]> {
121
+ const blocks: Array<[string | null, string[]]> = [];
122
+ let current: string[] = [];
123
+ let key: string | null = null;
124
+ for (const line of lines) {
125
+ const match = BULLET_RE.exec(line.replace(/\r\n$/, "").replace(/\n$/, ""));
126
+ if (match) {
127
+ if (current.length) blocks.push([key, current]);
128
+ key = normalizeBullet(line).toLowerCase();
129
+ current = [line];
130
+ } else {
131
+ current.push(line);
132
+ }
133
+ }
134
+ if (current.length) blocks.push([key, current]);
135
+ return blocks;
136
+ }
137
+
138
+ function mergeSections(
139
+ body: string,
140
+ entries: Record<string, string[]>,
141
+ normalizeOnly: boolean,
142
+ ): [string, Record<string, number>, Record<string, number> | null, string | null] {
143
+ const [preamble, sections] = splitSections(body);
144
+ const first: Record<string, { heading: string; body: string[] }> = {};
145
+ const rendered: Array<{ heading: string; body: string[] }> = [];
146
+ for (const section of sections) {
147
+ if (section.category && section.category in first) {
148
+ first[section.category].body.push(...section.body);
149
+ } else {
150
+ const entry = { heading: section.heading, body: section.body };
151
+ rendered.push(entry);
152
+ if (section.category) first[section.category] = entry;
153
+ }
154
+ }
155
+
156
+ const added: Record<string, number> = {};
157
+ if (!normalizeOnly) {
158
+ for (const [rawCategory, bullets] of Object.entries(entries)) {
159
+ const category = CATEGORIES.find((c) => c.toLowerCase() === rawCategory.toLowerCase());
160
+ if (category === undefined) {
161
+ return ["", {}, {}, `invalid category: ${rawCategory}`];
162
+ }
163
+ let section = first[category];
164
+ if (section === undefined) {
165
+ section = { heading: `### ${category}\n`, body: ["\n"] };
166
+ rendered.push(section);
167
+ first[category] = section;
168
+ }
169
+
170
+ const seen = new Set<string>();
171
+ const kept: string[] = [];
172
+ for (const [key, block] of bulletBlocks(section.body)) {
173
+ if (key !== null && seen.has(key)) continue;
174
+ if (key !== null) seen.add(key);
175
+ kept.push(...block);
176
+ }
177
+
178
+ const fresh: string[] = [];
179
+ for (const bullet of bullets ?? []) {
180
+ const key = normalizeBullet(bullet).toLowerCase();
181
+ if (!key || seen.has(key)) continue;
182
+ seen.add(key);
183
+ fresh.push(formatBullet(bullet) + "\n");
184
+ }
185
+ if (fresh.length) {
186
+ let insertAt = 0;
187
+ while (insertAt < kept.length && !kept[insertAt].trim()) insertAt++;
188
+ section.body = [...kept.slice(0, insertAt), ...fresh, ...kept.slice(insertAt)];
189
+ } else {
190
+ section.body = kept;
191
+ }
192
+ added[category] = fresh.length;
193
+ }
194
+ }
195
+
196
+ let output = preamble.join("");
197
+ for (const section of rendered) {
198
+ output += section.heading + section.body.join("");
199
+ }
200
+ const counts: Record<string, number> = {};
201
+ for (const [category, section] of Object.entries(first)) {
202
+ counts[category] = bulletBlocks(section.body).filter(([key]) => key !== null).length;
203
+ }
204
+ return [output, counts, added, null];
205
+ }
206
+
207
+ export function hasUnreleasedHeading(text: string): boolean {
208
+ return text.split("\n").some((line) => UNRELEASED_RE.test(line.replace(/\n$/, "")));
209
+ }
210
+
211
+ function applyChangelog(
212
+ target: string,
213
+ entries: Record<string, string[]>,
214
+ normalizeOnly: boolean,
215
+ ): Record<string, any> {
216
+ const source = readChangelogSource(target);
217
+ if ("error" in source) return { error: source.error };
218
+ const rendered = renderChangelog(source.text, entries, normalizeOnly);
219
+ if ("error" in rendered) return { error: rendered.error };
220
+ fs.mkdirSync(path.dirname(target), { recursive: true });
221
+ fs.writeFileSync(target, rendered.text, "utf8");
222
+ return { ...rendered.result, path: target };
223
+ }
224
+
225
+ function renderChangelog(
226
+ source: string,
227
+ entries: Record<string, string[]>,
228
+ normalizeOnly: boolean,
229
+ ): { text: string; result: Record<string, any> } | { error: string } {
230
+ let text = source;
231
+ if (!hasUnreleasedHeading(text)) {
232
+ const lines = text.split(/(?<=\n)/);
233
+ let insertAt = lines.length;
234
+ for (let i = 0; i < lines.length; i++) {
235
+ const stripped = lines[i].replace(/\n$/, "");
236
+ if (VERSION_RE.test(stripped) && !UNRELEASED_RE.test(stripped)) {
237
+ insertAt = i;
238
+ break;
239
+ }
240
+ }
241
+ text =
242
+ lines.slice(0, insertAt).join("") + "## [Unreleased]\n\n" + lines.slice(insertAt).join("");
243
+ }
244
+
245
+ const [before, body, after] = splitUnreleased(text);
246
+ const [newBody, counts, addedCounts, error] = mergeSections(body, entries, normalizeOnly);
247
+ if (error) return { error };
248
+ // before already includes "## [Unreleased]\n"
249
+ const out = (before.endsWith("\n") ? before : before + "\n") + newBody + after;
250
+ return {
251
+ text: out,
252
+ result: { ok: true, normalize_only: normalizeOnly, added: addedCounts, categories: counts },
253
+ };
254
+ }
255
+
256
+ const changelogTarget = (
257
+ workspaceRoot: string,
258
+ changelogPath: string,
259
+ ): { cwd: string; target: string } | { error: string } => {
260
+ try {
261
+ const cwd = fs.realpathSync(resolveWorkspaceRoot(workspaceRoot));
262
+ const target = path.resolve(cwd, changelogPath || "CHANGELOG.md");
263
+ if (target !== cwd && !target.startsWith(cwd + path.sep))
264
+ return { error: "changelog path must be inside workspace_root" };
265
+ let ancestor = target;
266
+ while (!fs.existsSync(ancestor)) ancestor = path.dirname(ancestor);
267
+ const canonicalAncestor = fs.realpathSync(ancestor);
268
+ if (canonicalAncestor !== cwd && !canonicalAncestor.startsWith(cwd + path.sep))
269
+ return { error: "changelog path must be inside workspace_root" };
270
+ return { cwd, target: fs.existsSync(target) ? fs.realpathSync(target) : target };
271
+ } catch {
272
+ return { error: "changelog path must be inside workspace_root" };
273
+ }
274
+ };
275
+
276
+ export function changelogApplyPreview({
277
+ entries,
278
+ path: changelogPath,
279
+ normalize_only,
280
+ workspace_root,
281
+ }: {
282
+ entries?: unknown;
283
+ path?: string;
284
+ normalize_only?: boolean;
285
+ workspace_root: string;
286
+ }): Record<string, any> {
287
+ const normalized = normalize_only
288
+ ? { data: {} as Record<string, string[]> }
289
+ : normalizeEntries(entries);
290
+ if ("error" in normalized) return { error: normalized.error };
291
+ if (!normalize_only && Object.keys(normalized.data).length === 0)
292
+ return { error: "entries required unless normalize_only" };
293
+ const location = changelogTarget(workspace_root, changelogPath || "CHANGELOG.md");
294
+ if ("error" in location) return location;
295
+ const source = readChangelogSource(location.target);
296
+ if ("error" in source) return source;
297
+ const rendered = renderChangelog(source.text, normalized.data, Boolean(normalize_only));
298
+ if ("error" in rendered) return rendered;
299
+ return {
300
+ ok: true,
301
+ path: location.target,
302
+ entries: normalized.data,
303
+ normalize_only: Boolean(normalize_only),
304
+ beforeDigest: digestBytes(source.bytes),
305
+ beforeExists: source.exists,
306
+ afterDigest: digestBytes(Buffer.from(rendered.text, "utf8")),
307
+ after: rendered.text,
308
+ ...rendered.result,
309
+ };
310
+ }
311
+
312
+ function normalizeEntries(entries: any): { data: Record<string, string[]> } | { error: string } {
313
+ if (!entries) return { data: {} };
314
+ if (Array.isArray(entries)) {
315
+ const grouped: Record<string, string[]> = {};
316
+ for (const item of entries) {
317
+ const cat = item.category ?? item.type;
318
+ const text = item.text ?? item.entry ?? "";
319
+ if (!cat || !text) {
320
+ return { error: "each entry needs category + text" };
321
+ }
322
+ const canon = CATEGORIES.find((c) => c.toLowerCase() === String(cat).toLowerCase());
323
+ if (!canon) return { error: `invalid category: ${cat}` };
324
+ (grouped[canon] ??= []).push(text);
325
+ }
326
+ return { data: grouped };
327
+ }
328
+ if (typeof entries === "object") {
329
+ const grouped: Record<string, string[]> = {};
330
+ for (const [cat, bullets] of Object.entries(entries)) {
331
+ const canon = CATEGORIES.find((c) => c.toLowerCase() === String(cat).toLowerCase());
332
+ if (!canon) return { error: `invalid category: ${cat}` };
333
+ const list = Array.isArray(bullets) ? bullets : [bullets];
334
+ grouped[canon] = list.filter(Boolean).map(String);
335
+ }
336
+ return { data: grouped };
337
+ }
338
+ return { error: "entries must be object or array" };
339
+ }
340
+
341
+ export function changelogApply({
342
+ entries,
343
+ path: changelogPath,
344
+ normalize_only,
345
+ workspace_root,
346
+ }: {
347
+ entries?: any;
348
+ path?: string;
349
+ normalize_only?: boolean;
350
+ workspace_root: string;
351
+ }): Record<string, any> {
352
+ const cwd = resolveWorkspaceRoot(workspace_root);
353
+ const normalized = normalize_only
354
+ ? { data: {} as Record<string, string[]> }
355
+ : normalizeEntries(entries);
356
+ if ("error" in normalized) return { error: normalized.error };
357
+ if (!normalize_only && Object.keys(normalized.data).length === 0) {
358
+ return { error: "entries required unless normalize_only" };
359
+ }
360
+
361
+ const rel = changelogPath || "CHANGELOG.md";
362
+ const location = changelogTarget(cwd, rel);
363
+ if ("error" in location) return location;
364
+ const target = location.target;
365
+
366
+ try {
367
+ return applyChangelog(target, normalized.data, Boolean(normalize_only));
368
+ } catch (err) {
369
+ return { error: err instanceof Error ? err.message : "changelog apply failed" };
370
+ }
371
+ }
372
+
373
+ export function changelogUnreleasedStats(workspace_root: string, changelogPath = "CHANGELOG.md") {
374
+ const cwd = resolveWorkspaceRoot(workspace_root);
375
+ const abs = path.isAbsolute(changelogPath) ? changelogPath : path.join(cwd, changelogPath);
376
+ if (!fs.existsSync(abs)) return { exists: false };
377
+ const text = fs.readFileSync(abs, "utf8");
378
+ const m = text.match(/##\s+\[Unreleased\]([\s\S]*?)(?=\n##\s+\[|$)/i);
379
+ if (!m) return { exists: true, has_unreleased: false };
380
+ const body = m[1];
381
+ const headings = [...body.matchAll(/^###\s+(\w+)\s*$/gim)].map((x) => x[1]);
382
+ const dupes = headings.filter((h, i) =>
383
+ headings.slice(0, i).some((p) => p.toLowerCase() === h.toLowerCase()),
384
+ );
385
+ return {
386
+ exists: true,
387
+ has_unreleased: true,
388
+ category_headings: headings,
389
+ duplicate_category_headings: dupes,
390
+ needs_normalize: dupes.length > 0,
391
+ };
392
+ }