@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,1020 @@
1
+ // `workit release start|finish|publish`: a git-flow release or hotfix without
2
+ // the git-flow binary, with `git flow release finish` semantics.
3
+ //
4
+ // - start cuts release/<version> (hotfix/<version> from production) on the
5
+ // line's integration branch through gitBranch, locally. It never pushes.
6
+ // - finish (local half, here): --no-ff merge of the release branch into
7
+ // production, an annotated tag <prefix><version> on that merge (message: the
8
+ // changelog section when there is one), a --no-ff merge of the tag into
9
+ // every merge-back branch (git-flow merges back through the tag), then
10
+ // `git branch -d` of the release branch. Every step is decided from the
11
+ // repository state plus the ledger rows of earlier runs, so an interrupted
12
+ // finish resumes without repeating a merge or a tag. The CLI verb then
13
+ // pushes production, the merge-back branches and the tag through the
14
+ // existing `--release` push, publishes the forge release and verifies it.
15
+ // - publish is the one path that pushes a release branch (verbs/release.ts).
16
+ import { readLedger, appendObserved, type LedgerActor } from "../ledger";
17
+ import { failure, success, type ForgeResult } from "../forge/types";
18
+ import { vcsConfig } from "../core/vcs-config";
19
+ import { trackBranchName } from "../core/release-tracks";
20
+ import { resolveChangelogPaths } from "../core/changelog-gate";
21
+ import { createHash } from "node:crypto";
22
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
23
+ import path from "node:path";
24
+ import {
25
+ resolveRuntimeWorkspacePolicy,
26
+ workspaceChangelog,
27
+ workspaceReleaseNotes,
28
+ type ReleaseNotesMode,
29
+ } from "../core/workspaces";
30
+ import { compareUrl, highlightsOf, previousTag, renderReleaseNotes } from "../core/release-notes";
31
+ import {
32
+ git,
33
+ gitBranch,
34
+ gitError,
35
+ inProgressFailure,
36
+ operationInProgress,
37
+ releaseLinesOf,
38
+ type ReleaseLine,
39
+ } from "./ops";
40
+ import {
41
+ currentBranch,
42
+ deriveForge,
43
+ fetchRefs,
44
+ gitCommonDir,
45
+ pushRemoteName,
46
+ pushUrl,
47
+ remoteRefTip,
48
+ resolveRef,
49
+ } from "./rev";
50
+
51
+ export type ReleaseKind = "release" | "hotfix";
52
+
53
+ const VERSION = /^[0-9A-Za-z][0-9A-Za-z._+-]{0,99}$/u;
54
+ const PLACEHOLDER = /\{[^}]*\}/gu;
55
+
56
+ const validVersion = (version: string): boolean =>
57
+ VERSION.test(version) && !version.includes("..") && !version.endsWith(".lock");
58
+
59
+ /** The branch template of a line and kind: the track's naming, else `<kind>/{version}`. */
60
+ const templateOf = (line: ReleaseLine, kind: ReleaseKind): string => {
61
+ const template = line.naming[kind];
62
+ if (!template) return `${kind}/{version}`;
63
+ return /\{[^}]*\}/u.test(template) ? template : `${template.replace(/\/?\*?$/u, "")}/{version}`;
64
+ };
65
+
66
+ const branchFor = (line: ReleaseLine, kind: ReleaseKind, version: string): string =>
67
+ trackBranchName({ naming: line.naming }, kind, version) ?? `${kind}/${version}`;
68
+
69
+ /** The version a release branch name carries under `template`, or null. */
70
+ const versionIn = (template: string, branch: string): string | null => {
71
+ const pattern = template
72
+ .split(PLACEHOLDER)
73
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"))
74
+ .join("(.+)");
75
+ const match = new RegExp(`^${pattern}$`, "u").exec(branch);
76
+ return match?.[1] && validVersion(match[1]) ? match[1] : null;
77
+ };
78
+
79
+ type ReleaseRow = Record<string, unknown> & { type: string };
80
+
81
+ /** This repository's release.* ledger rows, oldest first. */
82
+ const releaseRows = (cwd: string): ReleaseRow[] => {
83
+ const ledger = readLedger(cwd);
84
+ if (!ledger.ok) return [];
85
+ return ledger.value.rows.filter((row) => row.type.startsWith("release."));
86
+ };
87
+
88
+ const record = (cwd: string, actor: LedgerActor, row: ReleaseRow): void => {
89
+ appendObserved(cwd, { ...row, actor });
90
+ };
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // start
94
+
95
+ export type ReleaseStartOutcome = {
96
+ version: string;
97
+ kind: ReleaseKind;
98
+ branch: string;
99
+ base: string;
100
+ baseSha: string;
101
+ track: string | null;
102
+ tag: string;
103
+ notes: string[];
104
+ };
105
+
106
+ /** The line `--track`, else the checkout's resolved release track, names. */
107
+ function lineFor(cwd: string, track: string | null): ForgeResult<ReleaseLine> {
108
+ const lines = releaseLinesOf(cwd);
109
+ let name = track;
110
+ if (name === null && lines.length > 1) {
111
+ const resolved = vcsConfig("resolve", cwd);
112
+ if (resolved.ok === false) return failure("blocked", String(resolved.error));
113
+ if (resolved.releaseTrack?.blocking)
114
+ return failure("blocked", String(resolved.releaseTrack.blocking), "pass --track <name>");
115
+ name = resolved.releaseTrack?.track?.name ?? null;
116
+ }
117
+ const line = lines.find((entry) => entry.track === name);
118
+ if (!line)
119
+ return failure(
120
+ "invalid_input",
121
+ `no release track ${JSON.stringify(name)} (configured: ${
122
+ lines
123
+ .map((entry) => entry.track)
124
+ .filter(Boolean)
125
+ .join(", ") || "none"
126
+ })`,
127
+ );
128
+ return success(line);
129
+ }
130
+
131
+ export function releaseStart(
132
+ cwd: string,
133
+ input: {
134
+ version: string;
135
+ hotfix?: boolean;
136
+ track?: string | null;
137
+ actor: LedgerActor;
138
+ },
139
+ ): ForgeResult<ReleaseStartOutcome> {
140
+ const version = input.version.trim();
141
+ if (!validVersion(version))
142
+ return failure("invalid_input", `${JSON.stringify(version)} is not a version (e.g. 1.4.0)`);
143
+ const kind: ReleaseKind = input.hotfix ? "hotfix" : "release";
144
+ const lineResult = lineFor(cwd, input.track ?? null);
145
+ if (!lineResult.ok) return lineResult;
146
+ const line = lineResult.data;
147
+ // git-flow allows one release (or hotfix) in flight per line.
148
+ const template = templateOf(line, kind);
149
+ const existing = git(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads"])
150
+ .stdout.split("\n")
151
+ .filter((name) => name && versionIn(template, name) !== null);
152
+ if (existing.length)
153
+ return failure(
154
+ "blocked",
155
+ `release_exists: ${existing[0]} is still open; git-flow finishes one ${kind} before starting the next`,
156
+ `workit release finish # on ${existing[0]}`,
157
+ );
158
+ const tag = `${line.tagPrefix}${version}`;
159
+ if (resolveRef(cwd, `refs/tags/${tag}`))
160
+ return failure(
161
+ "blocked",
162
+ `tag_exists: ${tag} already exists`,
163
+ "start the next version instead",
164
+ );
165
+ const base = kind === "hotfix" ? line.production : line.integration;
166
+ // The release starts from the fetched remote base (gitBranch); local
167
+ // commits on it that the remote lacks would silently stay out of it.
168
+ const remote = pushRemoteName(cwd, base);
169
+ if (remote) {
170
+ fetchRefs(cwd, remote, [`+refs/heads/${base}:refs/remotes/${remote}/${base}`]);
171
+ const local = resolveRef(cwd, `refs/heads/${base}`);
172
+ const tracked = resolveRef(cwd, `refs/remotes/${remote}/${base}`);
173
+ if (
174
+ local &&
175
+ tracked &&
176
+ local !== tracked &&
177
+ !git(cwd, ["merge-base", "--is-ancestor", local, tracked]).ok
178
+ )
179
+ return failure(
180
+ "blocked",
181
+ `base_unpushed: local ${base} has commits ${remote}/${base} lacks; the ${kind} would start without them`,
182
+ `land them on ${remote}/${base} first, or: git switch ${base} && git reset --keep ${remote}/${base}`,
183
+ );
184
+ }
185
+ const branched = gitBranch(cwd, {
186
+ name: branchFor(line, kind, version),
187
+ base,
188
+ ...(line.track ? { track: line.track } : {}),
189
+ });
190
+ if (!branched.ok) return branched;
191
+ record(cwd, input.actor, {
192
+ type: "release.started",
193
+ version,
194
+ kind,
195
+ branch: branched.data.branch,
196
+ head: branched.data.baseSha,
197
+ base,
198
+ track: line.track,
199
+ tag,
200
+ });
201
+ return success({
202
+ version,
203
+ kind,
204
+ branch: branched.data.branch,
205
+ base,
206
+ baseSha: branched.data.baseSha,
207
+ track: line.track,
208
+ tag,
209
+ notes: branched.data.notes,
210
+ });
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // finish: the plan
215
+
216
+ export type FinishState = {
217
+ merged: boolean;
218
+ tagged: boolean;
219
+ /** Merge-back branch -> already holds the tag. */
220
+ mergedBack: Record<string, boolean>;
221
+ branchDeleted: boolean;
222
+ };
223
+
224
+ export type FinishPlan = {
225
+ version: string;
226
+ kind: ReleaseKind;
227
+ branch: string;
228
+ track: string | null;
229
+ tag: string;
230
+ production: string;
231
+ mergeBack: string[];
232
+ /** What the finish pushes: production and the merge-back branches, never a release branch. */
233
+ pushBranches: string[];
234
+ /** A hotfix's merge-back target when a release branch is open (git-flow), else null. */
235
+ openRelease: string | null;
236
+ /** git-flow merges locally; elsewhere finish opens a release PR. */
237
+ gitflow: boolean;
238
+ remote: string | null;
239
+ /** The release branch's tip (from the ledger once the branch is deleted). */
240
+ releaseTip: string;
241
+ /** The remote release branch's tip when it was published, else null. */
242
+ published: string | null;
243
+ /** The commit the tag marks: the production merge, once made. */
244
+ tagTarget: string | null;
245
+ /** A release.merged row exists (a merge resolved by hand has none). */
246
+ mergeRecorded: boolean;
247
+ state: FinishState;
248
+ /** The tag's prefix (`v` in v1.4.0). */
249
+ tagPrefix: string;
250
+ /** The changelog section of this version (the workspace's changelogPath), when there is one. */
251
+ changelog: string | null;
252
+ notes: string[];
253
+ /** Said before merging, never blocking (integration mode pr). */
254
+ warnings: string[];
255
+ };
256
+
257
+ const KINDS: ReleaseKind[] = ["release", "hotfix"];
258
+
259
+ type Located = {
260
+ line: ReleaseLine;
261
+ kind: ReleaseKind;
262
+ version: string;
263
+ branch: string;
264
+ };
265
+
266
+ /**
267
+ * Which release to finish: `version` (on the given or any line), else the
268
+ * checked-out release branch, else the newest started release this ledger
269
+ * has not finished, else the only release branch.
270
+ */
271
+ function locate(cwd: string, version: string | null, track: string | null): ForgeResult<Located> {
272
+ const lines = releaseLinesOf(cwd).filter((line) => track === null || line.track === track);
273
+ if (!lines.length) return failure("invalid_input", `no release track ${JSON.stringify(track)}`);
274
+ const rows = releaseRows(cwd);
275
+ const started = rows.filter((row) => row.type === "release.started");
276
+ const finished = new Set(
277
+ rows.filter((row) => row.type === "release.finished").map((row) => String(row.branch)),
278
+ );
279
+ /** The started row of `branch`, newest first: it names the release's line. */
280
+ const startedRow = (branch: string) =>
281
+ started.findLast((row) => row.branch === branch && !finished.has(branch));
282
+ const lineOf = (branch: string, candidates: ReleaseLine[]): ReleaseLine => {
283
+ const row = startedRow(branch);
284
+ const byRow = row ? candidates.find((line) => line.track === (row.track ?? null)) : undefined;
285
+ if (byRow) return byRow;
286
+ const resolved = vcsConfig("resolve", cwd, { branch });
287
+ const name = resolved.ok === false ? null : (resolved.releaseTrack?.track?.name ?? null);
288
+ return candidates.find((line) => line.track === name) ?? candidates[0];
289
+ };
290
+ const match = (branch: string): Located | null => {
291
+ const hits: Array<{
292
+ line: ReleaseLine;
293
+ kind: ReleaseKind;
294
+ version: string;
295
+ }> = [];
296
+ for (const line of lines)
297
+ for (const kind of KINDS) {
298
+ const found = versionIn(templateOf(line, kind), branch);
299
+ if (found) hits.push({ line, kind, version: found });
300
+ }
301
+ if (!hits.length) return null;
302
+ const line = lineOf(
303
+ branch,
304
+ hits.map((hit) => hit.line),
305
+ );
306
+ const hit = hits.find((entry) => entry.line === line) ?? hits[0];
307
+ return { ...hit, branch };
308
+ };
309
+ if (version !== null) {
310
+ if (!validVersion(version))
311
+ return failure("invalid_input", `${JSON.stringify(version)} is not a version`);
312
+ for (const line of lines)
313
+ for (const kind of KINDS) {
314
+ const branch = branchFor(line, kind, version);
315
+ if (resolveRef(cwd, `refs/heads/${branch}`) || startedRow(branch)) {
316
+ const located = match(branch);
317
+ if (located) return success(located);
318
+ }
319
+ }
320
+ return failure(
321
+ "not_found",
322
+ `no release or hotfix branch for ${version} here, and none this ledger started`,
323
+ );
324
+ }
325
+ const current = currentBranch(cwd);
326
+ const here = current ? match(current) : null;
327
+ if (here) return success(here);
328
+ const open = started.findLast((row) => !finished.has(String(row.branch)));
329
+ if (open) {
330
+ const located = match(String(open.branch));
331
+ if (located) return success(located);
332
+ }
333
+ const branches = git(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads"])
334
+ .stdout.split("\n")
335
+ .map((name) => (name ? match(name) : null))
336
+ .filter((entry): entry is Located => entry !== null);
337
+ if (branches.length === 1) return success(branches[0]);
338
+ return failure(
339
+ branches.length ? "invalid_input" : "not_found",
340
+ branches.length
341
+ ? `several releases are open (${branches.map((entry) => entry.branch).join(", ")}); name one`
342
+ : "no release or hotfix branch to finish",
343
+ branches.length
344
+ ? `workit release finish ${branches[0].version}`
345
+ : "workit release start <version>",
346
+ );
347
+ }
348
+
349
+ const isAncestor = (cwd: string, ancestor: string, descendant: string): boolean =>
350
+ git(cwd, ["merge-base", "--is-ancestor", ancestor, descendant]).ok;
351
+
352
+ /**
353
+ * The changelog section for `version` at `ref` (`file`: the first file the
354
+ * workspace's changelogPath resolves to at `ref`, default CHANGELOG.md): a `## [1.4.0]`, `## 1.4.0` or
355
+ * `# [1.4.0](…)` heading (a `v` before the version is fine), up to the next
356
+ * heading of the same or a higher level. Null without one.
357
+ */
358
+ export function changelogSection(
359
+ cwd: string,
360
+ ref: string,
361
+ version: string,
362
+ file: string | undefined = resolveChangelogPaths(cwd, workspaceChangelog(cwd).paths, ref)[0],
363
+ ): string | null {
364
+ if (!file) return null;
365
+ const shown = git(cwd, ["show", `${ref}:${file}`]);
366
+ if (!shown.ok) return null;
367
+ const lines = shown.stdout.split(/\r?\n/u);
368
+ const escaped = version.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
369
+ const heading = new RegExp(`^(#{1,3})\\s+\\[?v?${escaped}\\]?(?:[\\s(]|$)`, "u");
370
+ const start = lines.findIndex((line) => heading.test(line));
371
+ if (start < 0) return null;
372
+ const level = heading.exec(lines[start])?.[1].length ?? 2;
373
+ let end = lines.length;
374
+ for (let index = start + 1; index < lines.length; index += 1) {
375
+ const hashes = /^(#+)\s/u.exec(lines[index])?.[1].length;
376
+ if (hashes !== undefined && hashes <= level) {
377
+ end = index;
378
+ break;
379
+ }
380
+ }
381
+ const body = lines
382
+ .slice(start + 1, end)
383
+ .join("\n")
384
+ .trim();
385
+ return body || null;
386
+ }
387
+
388
+ /** Preflight and state of a finish: nothing changes here except fetched remote-tracking refs. */
389
+ export function planReleaseFinish(
390
+ cwd: string,
391
+ input: { version?: string | null; track?: string | null },
392
+ ): ForgeResult<FinishPlan> {
393
+ const progress = operationInProgress(cwd);
394
+ if (progress) return inProgressFailure(progress.operation, "finish the release");
395
+ const located = locate(cwd, input.version?.trim() || null, input.track ?? null);
396
+ if (!located.ok) return located;
397
+ const { line, kind, version, branch } = located.data;
398
+ const notes: string[] = [];
399
+ const dirty = git(cwd, ["status", "--porcelain", "--untracked-files=no"]).stdout.trim();
400
+ if (dirty)
401
+ return failure(
402
+ "blocked",
403
+ "dirty_worktree: tracked files have uncommitted changes; a release finish merges on a clean tree",
404
+ "workit git commit -m '<msg>' -- <paths> # on the release branch, or stash them",
405
+ );
406
+ const tag = `${line.tagPrefix}${version}`;
407
+ const rows = releaseRows(cwd).filter((row) => row.branch === branch || row.release === branch);
408
+ // git-flow: a hotfix finished while a release is open merges back into
409
+ // that release branch instead of develop (the release carries it there).
410
+ // Recorded once, so a resumed finish keeps the same target.
411
+ const recordedOpen = rows.findLast((row) => row.type === "release.hotfix_target");
412
+ const openRelease =
413
+ kind !== "hotfix"
414
+ ? null
415
+ : typeof recordedOpen?.target === "string"
416
+ ? recordedOpen.target
417
+ : (git(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads"])
418
+ .stdout.split("\n")
419
+ .find((name) => name && versionIn(templateOf(line, "release"), name) !== null) ?? null);
420
+ const mergeBack = openRelease
421
+ ? [...new Set(line.mergeBack.map((name) => (name === line.integration ? openRelease : name)))]
422
+ : line.mergeBack;
423
+ if (openRelease)
424
+ notes.push(
425
+ `${openRelease} is open: the hotfix merges back into it instead of ${line.integration}`,
426
+ );
427
+ const remote = pushRemoteName(cwd, line.production);
428
+ if (remote) {
429
+ const wanted = [line.production, ...mergeBack, branch];
430
+ for (const name of wanted)
431
+ fetchRefs(cwd, remote, [`+refs/heads/${name}:refs/remotes/${remote}/${name}`]);
432
+ }
433
+ const branchTip = resolveRef(cwd, `refs/heads/${branch}`);
434
+ const remoteBranch = remote ? resolveRef(cwd, `refs/remotes/${remote}/${branch}`) : null;
435
+ const mergedRow = rows.findLast((row) => row.type === "release.merged");
436
+ const deletedRow = rows.findLast((row) => row.type === "release.branch_deleted");
437
+ // Once the branch is gone its tip is in the ledger: the recorded merge, or
438
+ // the deletion (a merge resolved by hand left no release.merged row).
439
+ const recordedTip = [mergedRow?.releaseTip, deletedRow?.head].find(
440
+ (value): value is string => typeof value === "string",
441
+ );
442
+ const releaseTip = branchTip ?? recordedTip ?? null;
443
+ if (!releaseTip)
444
+ return failure(
445
+ "not_found",
446
+ `${branch} is gone and this ledger did not record its merge; nothing to resume`,
447
+ );
448
+ // Production and every merge-back branch: local, not behind the remote,
449
+ // and ahead of it only by this release's own merges.
450
+ for (const name of [line.production, ...mergeBack]) {
451
+ const local = resolveRef(cwd, `refs/heads/${name}`);
452
+ const tracked = remote ? resolveRef(cwd, `refs/remotes/${remote}/${name}`) : null;
453
+ if (!local && tracked) {
454
+ const created = git(cwd, ["branch", "--no-track", name, tracked]);
455
+ if (!created.ok)
456
+ return failure("failed", `could not create local ${name}: ${gitError(created)}`);
457
+ notes.push(`created local ${name} from ${remote}/${name}`);
458
+ continue;
459
+ }
460
+ if (!local)
461
+ return failure("not_found", `${name} exists neither locally nor on ${remote ?? "a remote"}`);
462
+ if (!tracked || name === openRelease) continue;
463
+ if (!isAncestor(cwd, tracked, local))
464
+ return failure(
465
+ "blocked",
466
+ `branch_behind: local ${name} is behind or diverged from ${remote}/${name}; git-flow finishes only onto up-to-date branches`,
467
+ `git switch ${name} && git merge --ff-only ${remote}/${name} # then: workit release finish ${version}`,
468
+ );
469
+ // Published production commits arrive through the merge-back; not new.
470
+ const productionTracked = remote
471
+ ? resolveRef(cwd, `refs/remotes/${remote}/${line.production}`)
472
+ : null;
473
+ const foreign = git(cwd, [
474
+ "rev-list",
475
+ local,
476
+ `^${tracked}`,
477
+ `^${releaseTip}`,
478
+ ...(productionTracked ? [`^${productionTracked}`] : []),
479
+ ])
480
+ .stdout.split("\n")
481
+ .filter(Boolean)
482
+ .filter(
483
+ (sha) =>
484
+ !(
485
+ git(cwd, ["rev-parse", "-q", "--verify", `${sha}^2`]).ok &&
486
+ isAncestor(cwd, releaseTip, sha)
487
+ ),
488
+ );
489
+ if (foreign.length)
490
+ return failure(
491
+ "blocked",
492
+ `branch_ahead: local ${name} has ${foreign.length} commit(s) ${remote}/${name} lacks that are not part of ${branch}; the finish push would publish them`,
493
+ `land them through review first, or: git switch ${name} && git reset --keep ${remote}/${name} # then: workit release finish ${version}`,
494
+ );
495
+ }
496
+ if (branchTip && remoteBranch && !isAncestor(cwd, remoteBranch, branchTip))
497
+ return failure(
498
+ "blocked",
499
+ `release_behind: ${remote}/${branch} has commits local ${branch} lacks`,
500
+ `git switch ${branch} && git merge --ff-only ${remote}/${branch}`,
501
+ );
502
+ const production = resolveRef(cwd, `refs/heads/${line.production}`) as string;
503
+ const merged = isAncestor(cwd, releaseTip, production);
504
+ const localTag = resolveRef(cwd, `refs/tags/${tag}`);
505
+ if (localTag && !(isAncestor(cwd, localTag, production) && isAncestor(cwd, releaseTip, localTag)))
506
+ return failure(
507
+ "blocked",
508
+ `tag_exists: ${tag} (${localTag.slice(0, 12)}) is not this release's merge on ${line.production}`,
509
+ "a release tag never moves; finish under the next version",
510
+ );
511
+ if (remote) {
512
+ const remoteTag = remoteRefTip(cwd, remote, `refs/tags/${tag}`);
513
+ if (remoteTag.ok && remoteTag.sha && remoteTag.sha !== localTag)
514
+ return failure(
515
+ "blocked",
516
+ `tag_exists: ${remote} already has ${tag} at ${remoteTag.sha.slice(0, 12)}`,
517
+ "a release tag never moves; finish under the next version",
518
+ );
519
+ }
520
+ const recordedMerge = typeof mergedRow?.merge === "string" ? mergedRow.merge : null;
521
+ const tagTarget = localTag ?? (merged ? (recordedMerge ?? production) : null);
522
+ const state: FinishState = {
523
+ merged,
524
+ tagged: localTag !== null,
525
+ mergedBack: Object.fromEntries(
526
+ mergeBack.map((name) => [
527
+ name,
528
+ localTag !== null &&
529
+ isAncestor(cwd, localTag, resolveRef(cwd, `refs/heads/${name}`) as string),
530
+ ]),
531
+ ),
532
+ branchDeleted: branchTip === null,
533
+ };
534
+ return success({
535
+ version,
536
+ kind,
537
+ branch,
538
+ track: line.track,
539
+ tag,
540
+ production: line.production,
541
+ mergeBack,
542
+ pushBranches: [line.production, ...mergeBack.filter((name) => name !== openRelease)],
543
+ openRelease,
544
+ gitflow: line.gitflow,
545
+ remote,
546
+ releaseTip,
547
+ published: remoteBranch,
548
+ tagTarget,
549
+ mergeRecorded: mergedRow !== undefined,
550
+ state,
551
+ tagPrefix: line.tagPrefix,
552
+ changelog: changelogSection(cwd, branchTip ?? production, version),
553
+ notes,
554
+ warnings: integrationPrWarning(cwd, line.production),
555
+ });
556
+ }
557
+
558
+ /**
559
+ * A workspace that sets `integration: "pr"` lands work through reviewed PRs;
560
+ * its forge may also protect production against the finish push. Said, not
561
+ * enforced: git-flow finishes locally either way.
562
+ */
563
+ function integrationPrWarning(cwd: string, production: string): string[] {
564
+ try {
565
+ const selected = resolveRuntimeWorkspacePolicy(
566
+ cwd,
567
+ "branch",
568
+ process.env.WORKIT_PROFILE?.trim() || undefined,
569
+ ).policy as { integration?: unknown } | undefined;
570
+ return selected?.integration === "pr"
571
+ ? [
572
+ `the workspace integration mode is pr: the remote may require pull requests on ${production}, which would reject the finish push`,
573
+ ]
574
+ : [];
575
+ } catch {
576
+ return [];
577
+ }
578
+ }
579
+
580
+ // ---------------------------------------------------------------------------
581
+ // finish: the local merges
582
+
583
+ export type LocalFinish = {
584
+ /** Steps run now (the rest were already done). */
585
+ ran: string[];
586
+ tagTarget: string;
587
+ };
588
+
589
+ const switchTo = (cwd: string, branch: string): ForgeResult<void> => {
590
+ if (currentBranch(cwd) === branch) return success(undefined);
591
+ const switched = git(cwd, ["switch", "-q", branch]);
592
+ return switched.ok
593
+ ? success(undefined)
594
+ : failure("failed", `git switch ${branch} failed: ${gitError(switched)}`);
595
+ };
596
+
597
+ /** A --no-ff merge of `what` into the checked-out branch; a conflict stops with the resume command. */
598
+ const mergeNoFf = (
599
+ cwd: string,
600
+ what: string,
601
+ into: string,
602
+ version: string,
603
+ ): ForgeResult<string> => {
604
+ const merged = git(cwd, ["merge", "--no-ff", "--no-edit", what], {
605
+ timeoutMs: 10 * 60_000,
606
+ });
607
+ if (!merged.ok) {
608
+ const conflicted = git(cwd, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]).ok;
609
+ return failure(
610
+ "failed",
611
+ conflicted
612
+ ? `merge_conflict: merging ${what} into ${into} conflicts`
613
+ : `git merge ${what} into ${into} failed: ${gitError(merged)}`,
614
+ conflicted
615
+ ? `resolve the conflicts, git add <files>, git merge --continue, then: workit release finish ${version}`
616
+ : undefined,
617
+ );
618
+ }
619
+ return success(git(cwd, ["rev-parse", "HEAD"]).stdout.trim());
620
+ };
621
+
622
+ /**
623
+ * The local half of `git flow release finish`, each step skipped when the
624
+ * plan's state says it is done and recorded as a ledger row when run.
625
+ */
626
+ export function finishLocally(
627
+ cwd: string,
628
+ plan: FinishPlan,
629
+ actor: LedgerActor,
630
+ ): ForgeResult<LocalFinish> {
631
+ const ran: string[] = [];
632
+ const base = {
633
+ version: plan.version,
634
+ release: plan.branch,
635
+ tag: plan.tag,
636
+ track: plan.track,
637
+ };
638
+ let tagTarget = plan.tagTarget;
639
+ // Once per release: a resumed finish reads the target back from this row.
640
+ if (
641
+ plan.openRelease &&
642
+ !releaseRows(cwd).some(
643
+ (row) => row.type === "release.hotfix_target" && row.release === plan.branch,
644
+ )
645
+ )
646
+ record(cwd, actor, {
647
+ type: "release.hotfix_target",
648
+ ...base,
649
+ branch: plan.branch,
650
+ target: plan.openRelease,
651
+ });
652
+ // A merge resolved by hand (`git merge --continue`) left no row: record
653
+ // what the repository shows, so a resume after the branch is gone has its tip.
654
+ if (plan.state.merged && !plan.mergeRecorded && tagTarget)
655
+ record(cwd, actor, {
656
+ type: "release.merged",
657
+ ...base,
658
+ branch: plan.production,
659
+ head: tagTarget,
660
+ merge: tagTarget,
661
+ releaseTip: plan.releaseTip,
662
+ observed: true,
663
+ });
664
+ if (!plan.state.merged) {
665
+ const on = switchTo(cwd, plan.production);
666
+ if (!on.ok) return on;
667
+ const merged = mergeNoFf(cwd, plan.branch, plan.production, plan.version);
668
+ if (!merged.ok) return merged;
669
+ tagTarget = merged.data;
670
+ ran.push("merge");
671
+ record(cwd, actor, {
672
+ type: "release.merged",
673
+ ...base,
674
+ branch: plan.production,
675
+ head: merged.data,
676
+ merge: merged.data,
677
+ releaseTip: plan.releaseTip,
678
+ });
679
+ }
680
+ if (!tagTarget) return failure("failed", `cannot tell which ${plan.production} commit to tag`);
681
+ if (!plan.state.tagged) {
682
+ const message = plan.changelog ?? `Release ${plan.version}`;
683
+ // verbatim: a changelog's `### Fixed` is not a comment to strip.
684
+ const tagged = git(cwd, [
685
+ "tag",
686
+ "-a",
687
+ "--cleanup=verbatim",
688
+ plan.tag,
689
+ "-m",
690
+ message,
691
+ tagTarget,
692
+ ]);
693
+ if (!tagged.ok) return failure("failed", `git tag ${plan.tag} failed: ${gitError(tagged)}`);
694
+ ran.push("tag");
695
+ record(cwd, actor, {
696
+ type: "release.tagged",
697
+ ...base,
698
+ branch: plan.production,
699
+ head: tagTarget,
700
+ });
701
+ }
702
+ for (const name of plan.mergeBack) {
703
+ if (plan.state.mergedBack[name]) continue;
704
+ const on = switchTo(cwd, name);
705
+ if (!on.ok) return on;
706
+ const merged = mergeNoFf(cwd, plan.tag, name, plan.version);
707
+ if (!merged.ok) return merged;
708
+ ran.push(`merge-back ${name}`);
709
+ record(cwd, actor, {
710
+ type: "release.merged_back",
711
+ ...base,
712
+ branch: name,
713
+ head: merged.data,
714
+ });
715
+ }
716
+ if (!plan.state.branchDeleted) {
717
+ if (currentBranch(cwd) === plan.branch) {
718
+ const on = switchTo(cwd, plan.mergeBack.at(-1) ?? plan.production);
719
+ if (!on.ok) return on;
720
+ }
721
+ // -d, not -D: git refuses when the release has commits nothing merged.
722
+ const deleted = git(cwd, ["branch", "-d", plan.branch]);
723
+ // A published branch is checked against its upstream: commits made
724
+ // after publishing would be lost with it.
725
+ if (!deleted.ok)
726
+ return failure(
727
+ "failed",
728
+ `git branch -d ${plan.branch} failed: ${gitError(deleted)}`,
729
+ plan.published
730
+ ? `workit release publish ${plan.version} # its remote copy lacks commits; then: workit release finish ${plan.version}`
731
+ : undefined,
732
+ );
733
+ ran.push("delete-branch");
734
+ record(cwd, actor, {
735
+ type: "release.branch_deleted",
736
+ ...base,
737
+ branch: plan.branch,
738
+ head: plan.releaseTip,
739
+ });
740
+ }
741
+ return success({ ran, tagTarget });
742
+ }
743
+
744
+ /** Check out the release branch (the release PR path pushes it from there). */
745
+ export const switchToRelease = (cwd: string, plan: FinishPlan): ForgeResult<void> =>
746
+ switchTo(cwd, plan.branch);
747
+
748
+ /** A ledger row for a finish step the CLI ran (push, forge release, done). */
749
+ export function recordReleaseStep(
750
+ cwd: string,
751
+ actor: LedgerActor,
752
+ plan: FinishPlan,
753
+ row: ReleaseRow,
754
+ ): void {
755
+ record(cwd, actor, {
756
+ version: plan.version,
757
+ release: plan.branch,
758
+ tag: plan.tag,
759
+ track: plan.track,
760
+ ...row,
761
+ });
762
+ }
763
+
764
+ // ---------------------------------------------------------------------------
765
+ // publish
766
+
767
+ /** The release branch `workit release publish` pushes: the named version's, else the checkout's. */
768
+ export function releaseToPublish(cwd: string, version: string | null): ForgeResult<Located> {
769
+ const located = locate(cwd, version, null);
770
+ if (!located.ok) return located;
771
+ if (!resolveRef(cwd, `refs/heads/${located.data.branch}`))
772
+ return failure("not_found", `${located.data.branch} is not a local branch`);
773
+ return located;
774
+ }
775
+
776
+ // ---------------------------------------------------------------------------
777
+ // release notes: the draft, its approval, and what finish publishes
778
+
779
+ export type ReleaseNotesContext = {
780
+ version: string;
781
+ tag: string;
782
+ track: string | null;
783
+ mode: ReleaseNotesMode;
784
+ /** Where the mode comes from (`track`, `workspace` or `default`). */
785
+ modeFrom: "track" | "workspace" | "default";
786
+ changelog: string | null;
787
+ /** The compare link from the previous tag, or null. */
788
+ compare: string | null;
789
+ previousTag: string | null;
790
+ };
791
+
792
+ /** The forge's compare link from the tag before `tag` (same prefix), or null. */
793
+ function compareLink(
794
+ cwd: string,
795
+ production: string,
796
+ prefix: string,
797
+ version: string,
798
+ tag: string,
799
+ ) {
800
+ const before = previousTag(
801
+ git(cwd, ["tag", "-l"]).stdout.split("\n").filter(Boolean),
802
+ prefix,
803
+ version,
804
+ );
805
+ const remote = pushRemoteName(cwd, production);
806
+ const url = remote ? pushUrl(cwd, remote) : null;
807
+ const forge = url ? deriveForge(url) : null;
808
+ return {
809
+ previousTag: before,
810
+ compare:
811
+ before && forge
812
+ ? compareUrl(forge.kind, `https://${forge.apiHost}/${forge.repo}`, before, tag)
813
+ : null,
814
+ };
815
+ }
816
+
817
+ /**
818
+ * What the notes of `version` are built from: its release branch when one is
819
+ * here (or the tag once finished, else HEAD), the line's tag prefix, and the
820
+ * workspace's (or release track's) releaseNotes mode.
821
+ */
822
+ export function releaseNotesContext(
823
+ cwd: string,
824
+ input: { version: string; track?: string | null },
825
+ ): ForgeResult<ReleaseNotesContext> {
826
+ const version = input.version.trim();
827
+ if (!validVersion(version))
828
+ return failure("invalid_input", `${JSON.stringify(version)} is not a version (e.g. 1.4.0)`);
829
+ const located = locate(cwd, version, input.track ?? null);
830
+ let line: ReleaseLine;
831
+ let ref = "HEAD";
832
+ if (located.ok) {
833
+ line = located.data.line;
834
+ if (resolveRef(cwd, `refs/heads/${located.data.branch}`)) ref = located.data.branch;
835
+ } else {
836
+ const lines = releaseLinesOf(cwd).filter(
837
+ (entry) => input.track == null || entry.track === input.track,
838
+ );
839
+ if (!lines.length)
840
+ return failure("invalid_input", `no release track ${JSON.stringify(input.track)}`);
841
+ line = lines[0];
842
+ }
843
+ const tag = `${line.tagPrefix}${version}`;
844
+ if (ref === "HEAD" && resolveRef(cwd, `refs/tags/${tag}`)) ref = tag;
845
+ const mode = workspaceReleaseNotes(cwd, line.track);
846
+ return success({
847
+ version,
848
+ tag,
849
+ track: line.track,
850
+ mode: mode.mode,
851
+ modeFrom: mode.from,
852
+ changelog: changelogSection(cwd, ref, version),
853
+ ...compareLink(cwd, line.production, line.tagPrefix, version, tag),
854
+ });
855
+ }
856
+
857
+ /** Where the draft of `version` lives: the git common dir, never committed. */
858
+ export const releaseNotesDraftPath = (cwd: string, version: string): string | null => {
859
+ const common = gitCommonDir(cwd);
860
+ return common ? path.join(common, "workit", "release-notes", `${version}.md`) : null;
861
+ };
862
+
863
+ const sha256 = (text: string): string => createHash("sha256").update(text).digest("hex");
864
+
865
+ export type ReleaseNotesDraft = { path: string; notes: string; sha256: string };
866
+
867
+ /** The draft of `version`, or null when none was written. */
868
+ export function readReleaseNotesDraft(cwd: string, version: string): ReleaseNotesDraft | null {
869
+ const file = releaseNotesDraftPath(cwd, version);
870
+ if (!file || !existsSync(file)) return null;
871
+ const notes = readFileSync(file, "utf8");
872
+ return { path: file, notes, sha256: sha256(notes) };
873
+ }
874
+
875
+ /**
876
+ * Write the draft of `version` from its changelog section. An existing
877
+ * draft's Highlights stay unless `reset`.
878
+ */
879
+ export function writeReleaseNotesDraft(
880
+ cwd: string,
881
+ context: ReleaseNotesContext,
882
+ options: { reset?: boolean } = {},
883
+ ): ForgeResult<ReleaseNotesDraft & { keptHighlights: boolean }> {
884
+ const file = releaseNotesDraftPath(cwd, context.version);
885
+ if (!file) return failure("invalid_input", "not a git repository: nowhere to keep the draft");
886
+ const existing = options.reset ? null : readReleaseNotesDraft(cwd, context.version);
887
+ const highlights = existing ? highlightsOf(existing.notes) : null;
888
+ const notes = renderReleaseNotes({
889
+ version: context.version,
890
+ changelog: context.changelog,
891
+ highlights,
892
+ compare: context.compare,
893
+ });
894
+ mkdirSync(path.dirname(file), { recursive: true });
895
+ writeFileSync(file, notes);
896
+ return success({
897
+ path: file,
898
+ notes,
899
+ sha256: sha256(notes),
900
+ keptHighlights: highlights !== null,
901
+ });
902
+ }
903
+
904
+ /** The newest `release.notes.approved` row of `version`, or null. */
905
+ export function latestReleaseNotesApproval(
906
+ cwd: string,
907
+ version: string,
908
+ ): { sha256: string } | null {
909
+ const row = releaseRows(cwd).findLast(
910
+ (entry) => entry.type === "release.notes.approved" && entry.version === version,
911
+ );
912
+ return row && typeof row.sha256 === "string" ? { sha256: row.sha256 } : null;
913
+ }
914
+
915
+ /** Record the user's approval of the draft as it is now. */
916
+ export function recordReleaseNotesApproval(
917
+ cwd: string,
918
+ actor: LedgerActor,
919
+ draft: ReleaseNotesDraft & { version: string; tag: string },
920
+ ): void {
921
+ record(cwd, actor, {
922
+ type: "release.notes.approved",
923
+ version: draft.version,
924
+ tag: draft.tag,
925
+ sha256: draft.sha256,
926
+ path: draft.path,
927
+ });
928
+ }
929
+
930
+ export type ReleaseNotesDecision =
931
+ | { publish: false; mode: "off"; reason: string }
932
+ | {
933
+ publish: true;
934
+ mode: ReleaseNotesMode;
935
+ body: string;
936
+ source: "draft" | "changelog";
937
+ }
938
+ | { publish: false; mode: "review"; blocked: string; unblock: string };
939
+
940
+ /**
941
+ * What finish publishes as the forge release body: nothing under `off`; the
942
+ * draft (else the notes built from the changelog) under `auto`; under
943
+ * `review` only the draft whose hash the newest approval names.
944
+ */
945
+ export function releaseNotesDecision(
946
+ cwd: string,
947
+ plan: Pick<FinishPlan, "version" | "track" | "tag" | "tagPrefix" | "production" | "changelog">,
948
+ ): ReleaseNotesDecision {
949
+ const setting = workspaceReleaseNotes(cwd, plan.track);
950
+ const where =
951
+ setting.from === "track" ? `release track ${plan.track}` : `workspace ${setting.workspace}`;
952
+ if (setting.mode === "off")
953
+ return {
954
+ publish: false,
955
+ mode: "off",
956
+ reason: `releaseNotes is off (${where}): no forge release`,
957
+ };
958
+ const draft = readReleaseNotesDraft(cwd, plan.version);
959
+ if (setting.mode === "review") {
960
+ const approved = latestReleaseNotesApproval(cwd, plan.version);
961
+ const draftCmd = `workit release notes draft ${plan.version}`;
962
+ const approveCmd = `workit release notes approve ${plan.version}`;
963
+ if (!draft)
964
+ return {
965
+ publish: false,
966
+ mode: "review",
967
+ blocked: `release_notes_unapproved: releaseNotes is review (${where}) and ${plan.version} has no draft`,
968
+ unblock: `${draftCmd} # write the Highlights, show the user, then ask the user to run, in a terminal outside the agent host: ${approveCmd}`,
969
+ };
970
+ if (!approved || approved.sha256 !== draft.sha256)
971
+ return {
972
+ publish: false,
973
+ mode: "review",
974
+ blocked: `release_notes_unapproved: releaseNotes is review (${where}) and the draft of ${plan.version} (${draft.path}) ${approved ? "changed after the user approved it" : "is not approved"}`,
975
+ unblock: `ask the user to run, in a terminal outside the agent host (not Claude Code's \`!\` prefix or an agent shell): ${approveCmd} # the draft: ${draftCmd}`,
976
+ };
977
+ return {
978
+ publish: true,
979
+ mode: "review",
980
+ body: draft.notes,
981
+ source: "draft",
982
+ };
983
+ }
984
+ if (draft) return { publish: true, mode: "auto", body: draft.notes, source: "draft" };
985
+ return {
986
+ publish: true,
987
+ mode: "auto",
988
+ source: "changelog",
989
+ body: renderReleaseNotes({
990
+ version: plan.version,
991
+ changelog: plan.changelog,
992
+ compare: compareLink(cwd, plan.production, plan.tagPrefix, plan.version, plan.tag).compare,
993
+ }),
994
+ };
995
+ }
996
+
997
+ /**
998
+ * The notes decision for a release tag `workit git push --release` pushes
999
+ * (the release PR path: the tag is made after the PR merged). The changelog
1000
+ * section is read at the tag.
1001
+ */
1002
+ export function releaseNotesForTag(
1003
+ cwd: string,
1004
+ tag: {
1005
+ name: string;
1006
+ production: string;
1007
+ version: string;
1008
+ track: string | null;
1009
+ tagPrefix: string;
1010
+ },
1011
+ ): ReleaseNotesDecision {
1012
+ return releaseNotesDecision(cwd, {
1013
+ version: tag.version,
1014
+ track: tag.track,
1015
+ tag: tag.name,
1016
+ tagPrefix: tag.tagPrefix,
1017
+ production: tag.production,
1018
+ changelog: changelogSection(cwd, `refs/tags/${tag.name}`, tag.version),
1019
+ });
1020
+ }