@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,542 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { gitDirs } from "../git/facts";
3
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { vcsConfig, mergedPrStyle } from "./vcs-config";
6
+ import { gitRevisionParts, resolveGitRevision } from "../core";
7
+
8
+ // Ports of scripts/_shared/common.sh + the four maintained context generators
9
+ // (pr-ready-context.sh, changelog-context.sh, docs-refresh-context.sh,
10
+ // release-notes-context.sh). Each generator returns the same stdout text shape
11
+ // the shell produced (## sections).
12
+
13
+ export type ContextResult = {
14
+ stdout: string;
15
+ stderr: string;
16
+ exitCode: number;
17
+ cwd: string;
18
+ };
19
+
20
+ // Range/revision values are passed as positional git arguments. Reject option
21
+ // syntax and control characters before any context generator invokes git; the
22
+ // trailing `--` used for file paths cannot protect an earlier revision arg.
23
+ export const isSafeContextRange = (value: string): boolean =>
24
+ value.length <= 4096 &&
25
+ (() => {
26
+ try {
27
+ gitRevisionParts(value);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ })();
33
+
34
+ const isResolvableContextRange = (cwd: string, value: string): boolean => {
35
+ if (!isSafeContextRange(value)) return false;
36
+ try {
37
+ resolveGitRevision(cwd, value);
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ };
43
+
44
+ const invalidRange = (cwd: string): ContextResult => ({
45
+ stdout: "",
46
+ stderr: "ERROR: invalid revision range\n",
47
+ exitCode: 2,
48
+ cwd,
49
+ });
50
+
51
+ const runGit = (
52
+ cwd: string,
53
+ args: string[],
54
+ ): { stdout: string; stderr: string; exitCode: number } => {
55
+ const result = spawnSync("git", args, { cwd, encoding: "utf8" });
56
+ return {
57
+ stdout: (result.stdout ?? "").trimEnd(),
58
+ stderr: (result.stderr ?? "").trimEnd(),
59
+ exitCode: result.status ?? 1,
60
+ };
61
+ };
62
+
63
+ /** repo_root: git rev-parse --show-toplevel || pwd */
64
+ export const repoRoot = (cwd: string): string => gitDirs(cwd)?.top ?? cwd;
65
+
66
+ /** current_branch: git rev-parse --abbrev-ref HEAD || unknown */
67
+ export const currentBranch = (cwd: string): string => {
68
+ const r = runGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
69
+ return r.exitCode === 0 && r.stdout ? r.stdout : "unknown";
70
+ };
71
+
72
+ /** default_base: first of origin/main main origin/master master origin/develop develop, else HEAD~1 */
73
+ export const defaultBase = (cwd: string): string => {
74
+ for (const ref of [
75
+ "origin/main",
76
+ "main",
77
+ "origin/master",
78
+ "master",
79
+ "origin/develop",
80
+ "develop",
81
+ ]) {
82
+ if (runGit(cwd, ["rev-parse", "--verify", ref]).exitCode === 0) return ref;
83
+ }
84
+ return "HEAD~1";
85
+ };
86
+
87
+ export const rangeArgOrDefault = (arg: string | undefined, cwd: string): string =>
88
+ arg ? arg : `${defaultBase(cwd)}...HEAD`;
89
+
90
+ export const isProtectedBranch = (branch: string): boolean =>
91
+ ["main", "master", "develop", "prod", "production"].includes(branch);
92
+
93
+ export const isPrBranch = (branch: string): boolean =>
94
+ branch.startsWith("feature/") || branch.startsWith("bugfix/");
95
+
96
+ export type PrBranchContext = {
97
+ baseRef: string;
98
+ mergeBase: string;
99
+ range: string;
100
+ diffRange: string;
101
+ };
102
+
103
+ /** resolve_pr_branch_context: derive the branch-exclusive range from VCS config. */
104
+ export function resolvePrBranchContext(
105
+ cwd: string,
106
+ ): { ok: true; value: PrBranchContext } | { ok: false; error: string } {
107
+ const branch = currentBranch(cwd);
108
+ if (isProtectedBranch(branch)) {
109
+ return {
110
+ ok: false,
111
+ error: `cannot build PR context on protected branch ${branch} — PRs are for feature/* or bugfix/* only`,
112
+ };
113
+ }
114
+ if (!isPrBranch(branch)) {
115
+ if (branch === "unknown") {
116
+ return {
117
+ ok: false,
118
+ error: `not in a git repository at ${repoRoot(cwd)} — open the target repository as the OpenCode session directory`,
119
+ };
120
+ }
121
+ return {
122
+ ok: false,
123
+ error: `branch ${branch} is not feature/* or bugfix/* — checkout a feature branch or pass an explicit git range`,
124
+ };
125
+ }
126
+
127
+ const resolved = vcsConfig("resolve", cwd);
128
+ if (resolved.ok === false) {
129
+ return { ok: false, error: String(resolved.error) };
130
+ }
131
+ const base = String(resolved.defaultTargetBranch ?? "");
132
+ if (!base || runGit(cwd, ["check-ref-format", "--branch", base]).exitCode !== 0) {
133
+ return { ok: false, error: `invalid configured PR target branch ${base}` };
134
+ }
135
+
136
+ for (const ref of [`origin/${base}`, base]) {
137
+ if (runGit(cwd, ["rev-parse", "--verify", ref]).exitCode !== 0) continue;
138
+ const mb = runGit(cwd, ["merge-base", ref, "HEAD"]);
139
+ if (mb.exitCode !== 0 || !mb.stdout) continue;
140
+ return {
141
+ ok: true,
142
+ value: {
143
+ baseRef: ref,
144
+ mergeBase: mb.stdout,
145
+ range: `${ref}..HEAD`,
146
+ diffRange: `${mb.stdout}..HEAD`,
147
+ },
148
+ };
149
+ }
150
+ return {
151
+ ok: false,
152
+ error: `configured PR target branch ${base} not found — fetch/checkout it or pass an explicit git range`,
153
+ };
154
+ }
155
+
156
+ export function resolvePrRange(cwd: string): string | null {
157
+ const ctx = resolvePrBranchContext(cwd);
158
+ return ctx.ok ? ctx.value.range : null;
159
+ }
160
+
161
+ export const prRangeArgOrDefault = (arg: string | undefined, cwd: string): string | null =>
162
+ arg ? arg : resolvePrRange(cwd);
163
+
164
+ export const printSection = (title: string): string => `\n## ${title}\n\n`;
165
+
166
+ // macOS/Windows filesystems are case-insensitive: existsSync() would match a
167
+ // differently-cased candidate, so the returned template_path must be the
168
+ // ACTUAL on-disk name. Probe each candidate directory case-insensitively and
169
+ // report the real entry — identical to the case-sensitive Linux probe, which
170
+ // only ever sees the exact on-disk name.
171
+ const probeCaseInsensitive = (dir: string, wanted: string): string | null => {
172
+ try {
173
+ for (const entry of readdirSync(dir)) {
174
+ if (entry.toLowerCase() === wanted.toLowerCase()) return entry;
175
+ }
176
+ } catch {
177
+ /* missing dir */
178
+ }
179
+ return null;
180
+ };
181
+
182
+ /** The single-file PR/MR templates GitHub and GitLab read, in the order Workit tries them. */
183
+ export const PR_TEMPLATE_FILES = [
184
+ ".gitlab/merge_request_templates/Default.md",
185
+ ".gitlab/merge_request_templates/default.md",
186
+ ".gitlab/merge_request_templates/merge_request_template.md",
187
+ ".github/PULL_REQUEST_TEMPLATE.md",
188
+ ".github/pull_request_template.md",
189
+ ".github/PULL_REQUEST_TEMPLATE/pull_request_template.md",
190
+ "docs/PULL_REQUEST_TEMPLATE.md",
191
+ "PULL_REQUEST_TEMPLATE.md",
192
+ ] as const;
193
+
194
+ /**
195
+ * Template directories with no forge default: one `.md` file in them is the
196
+ * template; several leave the choice to `prTemplate: <path>`.
197
+ */
198
+ export const PR_TEMPLATE_DIRS = [
199
+ ".gitlab/merge_request_templates",
200
+ ".github/PULL_REQUEST_TEMPLATE",
201
+ ] as const;
202
+
203
+ const singleMarkdown = (dir: string): string | null => {
204
+ try {
205
+ const files = readdirSync(dir, { withFileTypes: true }).filter(
206
+ (entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md"),
207
+ );
208
+ return files.length === 1 ? files[0].name : null;
209
+ } catch {
210
+ return null;
211
+ }
212
+ };
213
+
214
+ export const findPrTemplate = (cwd: string): string | null => {
215
+ for (const rel of PR_TEMPLATE_FILES) {
216
+ const parts = rel.split("/");
217
+ const base = parts[parts.length - 1];
218
+ const dir = parts.length > 1 ? path.join(cwd, ...parts.slice(0, -1)) : cwd;
219
+ const actual = probeCaseInsensitive(dir, base);
220
+ if (actual) return [...parts.slice(0, -1), actual].join("/");
221
+ }
222
+ for (const rel of PR_TEMPLATE_DIRS) {
223
+ const only = singleMarkdown(path.join(cwd, ...rel.split("/")));
224
+ if (only) return `${rel}/${only}`;
225
+ }
226
+ return null;
227
+ };
228
+
229
+ export const fallbackPrTemplate = (): string => `## Summary
230
+ -
231
+
232
+ ## Validation
233
+ - [ ] Not run`;
234
+
235
+ /** commit_log_for_range: git log --oneline --decorate --no-merges, falls back to -10. */
236
+ export const commitLogForRange = (cwd: string, range: string): string => {
237
+ const r = runGit(cwd, ["log", "--oneline", "--decorate", "--no-merges", range, "--"]);
238
+ if (r.exitCode === 0) return r.stdout;
239
+ return runGit(cwd, ["log", "--oneline", "--decorate", "-10", "--"]).stdout;
240
+ };
241
+
242
+ export const changedFilesForRange = (cwd: string, range: string): string => {
243
+ const r = runGit(cwd, ["diff", "--name-only", range, "--"]);
244
+ if (r.exitCode === 0) return r.stdout;
245
+ return runGit(cwd, ["diff", "--name-only", "--"]).stdout;
246
+ };
247
+
248
+ export const diffStatForRange = (cwd: string, range: string): string => {
249
+ const r = runGit(cwd, ["diff", "--stat", range, "--"]);
250
+ if (r.exitCode === 0) return r.stdout;
251
+ return runGit(cwd, ["diff", "--stat", "--"]).stdout;
252
+ };
253
+
254
+ const packageScripts = (cwd: string, keys: string[]): string[] => {
255
+ const pkgPath = path.join(cwd, "package.json");
256
+ if (!existsSync(pkgPath)) return [];
257
+ try {
258
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
259
+ scripts?: Record<string, string>;
260
+ };
261
+ return keys
262
+ .filter((k) => pkg.scripts && typeof pkg.scripts[k] === "string")
263
+ .map((k) => `${k}: ${pkg.scripts?.[k]}`);
264
+ } catch {
265
+ return [];
266
+ }
267
+ };
268
+
269
+ /** find . -maxdepth 3 -name README.md -o -name *.md with excludes, sorted, first 200. */
270
+ export const documentationFiles = (cwd: string): string[] => {
271
+ const excluded = new Set([".git", "node_modules", "target", "dist"]);
272
+ const matches: string[] = [];
273
+ const walk = (dir: string, depth: number) => {
274
+ if (depth > 3) return;
275
+ let entries;
276
+ try {
277
+ entries = readdirSync(dir, { withFileTypes: true });
278
+ } catch {
279
+ return;
280
+ }
281
+ for (const entry of entries) {
282
+ if (entry.isDirectory()) {
283
+ if (excluded.has(entry.name)) continue;
284
+ walk(path.join(dir, entry.name), depth + 1);
285
+ } else if (entry.isFile() && (entry.name === "README.md" || entry.name.endsWith(".md"))) {
286
+ // Shell parity: the script printed "./path" with forward slashes even
287
+ // on win32, where path.relative would otherwise emit backslashes.
288
+ matches.push("./" + path.relative(cwd, path.join(dir, entry.name)).replaceAll("\\", "/"));
289
+ }
290
+ }
291
+ };
292
+ walk(cwd, 1);
293
+ return matches.toSorted().slice(0, 200);
294
+ };
295
+
296
+ const readTrimmed = (file: string, maxLines: number): string => {
297
+ if (!existsSync(file)) return "";
298
+ const lines = readFileSync(file, "utf8").split("\n");
299
+ return lines.slice(0, maxLines).join("\n");
300
+ };
301
+
302
+ /** Port of pr-ready-context.sh — PR-ready repository context. */
303
+ export function prReadyContext(root: string, range?: string): ContextResult {
304
+ const cwd = path.resolve(repoRoot(root));
305
+ if (range !== undefined && !isResolvableContextRange(cwd, range)) return invalidRange(cwd);
306
+ let stdout = "";
307
+ const autoRange = range === undefined;
308
+
309
+ let ctx: { ok: true; value: PrBranchContext } | { ok: false; error: string } | null = null;
310
+ if (autoRange) {
311
+ ctx = resolvePrBranchContext(cwd);
312
+ if (!ctx.ok) return { stdout: "", stderr: `ERROR: ${ctx.error}\n`, exitCode: 1, cwd };
313
+ range = ctx.value.range;
314
+ }
315
+
316
+ stdout += printSection("Repository");
317
+ stdout += `root: ${cwd}\n`;
318
+ stdout += `branch: ${currentBranch(cwd)}\n`;
319
+ stdout += `range: ${range}\n`;
320
+ if (autoRange && ctx?.ok) {
321
+ stdout += `base_ref: ${ctx.value.baseRef}\n`;
322
+ stdout += `merge_base: ${ctx.value.mergeBase}\n`;
323
+ stdout += `diff_range: ${ctx.value.diffRange}\n`;
324
+ stdout += "range_mode: branch-exclusive\n";
325
+ if (process.env.PR_SYNC_NOTES) stdout += `git_sync: ${process.env.PR_SYNC_NOTES}\n`;
326
+ }
327
+ const diffRange = autoRange && ctx?.ok ? ctx.value.diffRange : (range ?? "");
328
+
329
+ stdout += printSection("Working Tree");
330
+ const status = runGit(cwd, ["status", "--short"]);
331
+ stdout += status.stdout + (status.stderr ? `\n${status.stderr}` : "");
332
+ stdout += "\n";
333
+
334
+ stdout += printSection("Commits");
335
+ stdout += commitLogForRange(cwd, range ?? "");
336
+ stdout += "\n";
337
+
338
+ stdout += printSection("Diff Stat");
339
+ stdout += diffStatForRange(cwd, diffRange);
340
+ stdout += "\n";
341
+
342
+ stdout += printSection("Changed Files");
343
+ stdout += changedFilesForRange(cwd, diffRange);
344
+ stdout += "\n";
345
+
346
+ stdout += printSection("PR Template");
347
+ const template = findPrTemplate(cwd);
348
+ if (template) {
349
+ stdout += `template_path: ${template}\n\n`;
350
+ stdout += readTrimmed(path.join(cwd, template), 220);
351
+ } else {
352
+ stdout += "template_path: none\n\n";
353
+ stdout += fallbackPrTemplate();
354
+ }
355
+ stdout += "\n";
356
+
357
+ stdout += printSection("Recent Validation Signals");
358
+ if (existsSync(path.join(cwd, "package.json"))) {
359
+ stdout += "package.json detected. Common scripts:\n";
360
+ const found = packageScripts(cwd, ["lint", "format:check", "test", "build"]);
361
+ if (found.length) stdout += found.join("\n") + "\n";
362
+ }
363
+ if (
364
+ existsSync(path.join(cwd, "Cargo.toml")) ||
365
+ existsSync(path.join(cwd, "src-tauri/Cargo.toml"))
366
+ ) {
367
+ stdout += "Rust project detected.\n";
368
+ }
369
+
370
+ stdout += printSection("VCS Config");
371
+ const vc = vcsConfig("resolve", cwd);
372
+ if (vc.ok) {
373
+ stdout += `workspace: ${String(vc.workspace_name ?? "none")}\n`;
374
+ stdout += `provider: ${String(vc.provider ?? "unconfigured")}\n`;
375
+ } else {
376
+ // RL-01: never report silent defaults for malformed vcs.json — surface the
377
+ // exact-path diagnostic instead.
378
+ stdout += `vcs: unreadable (malformed) — ${String(vc.error ?? "cannot read vcs.json")}\n`;
379
+ }
380
+ // B4: the shell printed only workspace:/provider: (the summary dump was
381
+ // discarded). Keep that concise shape — no raw summary JSON in the context.
382
+ if (!vcsConfig("summary", cwd).ok) {
383
+ stdout += "vcs: not configured — run /wk-init action vcs_scaffold\n";
384
+ }
385
+
386
+ stdout += printSection("Merged PR Style");
387
+ const style = mergedPrStyle(6, cwd);
388
+ stdout += JSON.stringify(style, null, 2) + "\n";
389
+
390
+ return { stdout, stderr: "", exitCode: 0, cwd };
391
+ }
392
+
393
+ const CHANGELOG_RULES = `- Use an [Unreleased] section.
394
+ - Use Added, Changed, Deprecated, Removed, Fixed, Security.
395
+ - Entries should be human-readable and user-facing.
396
+ - Do not use raw commit messages as changelog bullets.
397
+ - MERGE into existing ### Category under [Unreleased] — never append a second ### Added / ### Fixed block.
398
+ - Apply with the host-native editor, preserving the repository changelog conventions.
399
+ - If Unreleased already has duplicate category headings, normalize_only first.`;
400
+
401
+ /** Port of changelog-context.sh — changelog update context. */
402
+ export function changelogContext(root: string, range?: string): ContextResult {
403
+ const cwd = path.resolve(repoRoot(root));
404
+ if (range !== undefined && !isResolvableContextRange(cwd, range)) return invalidRange(cwd);
405
+ const resolvedRange = rangeArgOrDefault(range, cwd);
406
+ if (!isResolvableContextRange(cwd, resolvedRange)) return invalidRange(cwd);
407
+ let stdout = "";
408
+
409
+ stdout += printSection("Repository");
410
+ stdout += `root: ${cwd}\n`;
411
+ stdout += `branch: ${currentBranch(cwd)}\n`;
412
+ stdout += `range: ${resolvedRange}\n`;
413
+
414
+ stdout += printSection("Keep a Changelog Rules");
415
+ stdout += CHANGELOG_RULES + "\n";
416
+
417
+ stdout += printSection("Existing CHANGELOG.md");
418
+ const changelogPath = path.join(cwd, "CHANGELOG.md");
419
+ if (existsSync(changelogPath)) {
420
+ stdout += readTrimmed(changelogPath, 260) + "\n";
421
+ } else {
422
+ stdout += "CHANGELOG.md not found.\n";
423
+ }
424
+
425
+ stdout += printSection("Commits");
426
+ stdout += commitLogForRange(cwd, resolvedRange) + "\n";
427
+
428
+ stdout += printSection("Diff Stat");
429
+ stdout += diffStatForRange(cwd, resolvedRange) + "\n";
430
+
431
+ stdout += printSection("Changed Files");
432
+ stdout += changedFilesForRange(cwd, resolvedRange) + "\n";
433
+
434
+ return { stdout, stderr: "", exitCode: 0, cwd };
435
+ }
436
+
437
+ /** Port of docs-refresh-context.sh — documentation refresh context. */
438
+ export function docsRefreshContext(root: string, range?: string): ContextResult {
439
+ const cwd = path.resolve(repoRoot(root));
440
+ if (range !== undefined && !isResolvableContextRange(cwd, range)) return invalidRange(cwd);
441
+ const resolvedRange = rangeArgOrDefault(range, cwd);
442
+ if (!isResolvableContextRange(cwd, resolvedRange)) return invalidRange(cwd);
443
+ let stdout = "";
444
+
445
+ stdout += printSection("Repository");
446
+ stdout += `root: ${cwd}\n`;
447
+ stdout += `branch: ${currentBranch(cwd)}\n`;
448
+ stdout += `range: ${resolvedRange}\n`;
449
+
450
+ stdout += printSection("Changed Files");
451
+ stdout += changedFilesForRange(cwd, resolvedRange) + "\n";
452
+
453
+ stdout += printSection("Documentation Files");
454
+ stdout += documentationFiles(cwd).join("\n") + "\n";
455
+
456
+ stdout += printSection("README Preview");
457
+ const readme = path.join(cwd, "README.md");
458
+ if (existsSync(readme)) {
459
+ stdout += readTrimmed(readme, 220) + "\n";
460
+ } else {
461
+ stdout += "README.md not found.\n";
462
+ }
463
+
464
+ stdout += printSection("Package Scripts");
465
+ const pkgPath = path.join(cwd, "package.json");
466
+ if (existsSync(pkgPath)) {
467
+ try {
468
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
469
+ stdout +=
470
+ JSON.stringify({ name: pkg.name, version: pkg.version, scripts: pkg.scripts }, null, 2) +
471
+ "\n";
472
+ } catch {
473
+ /* unreadable package.json */
474
+ }
475
+ } else {
476
+ stdout += "package.json not found.\n";
477
+ }
478
+
479
+ return { stdout, stderr: "", exitCode: 0, cwd };
480
+ }
481
+
482
+ /** Port of release-notes-context.sh — tags + commits + files for a range. */
483
+ export function releaseNotesContext(root: string, rangeOrTag: string): ContextResult {
484
+ const cwd = path.resolve(repoRoot(root));
485
+ if (!rangeOrTag) {
486
+ return {
487
+ stdout: "",
488
+ stderr: "ERROR: release tag or range required\n",
489
+ exitCode: 1,
490
+ cwd,
491
+ };
492
+ }
493
+ if (!isResolvableContextRange(cwd, rangeOrTag)) return invalidRange(cwd);
494
+ const resolvedRange = rangeArgOrDefault(rangeOrTag, cwd);
495
+ if (!isResolvableContextRange(cwd, resolvedRange)) return invalidRange(cwd);
496
+ let stdout = "";
497
+
498
+ stdout += printSection("Repository");
499
+ stdout += `root: ${cwd}\n`;
500
+ stdout += `branch: ${currentBranch(cwd)}\n`;
501
+ stdout += `requested: ${rangeOrTag}\n`;
502
+ stdout += `range: ${resolvedRange}\n`;
503
+
504
+ stdout += printSection("Tags");
505
+ const tags = runGit(cwd, ["tag", "--sort=-creatordate"]);
506
+ if (tags.exitCode === 0) stdout += tags.stdout.split("\n").slice(0, 20).join("\n") + "\n";
507
+
508
+ stdout += printSection("Commits");
509
+ stdout += commitLogForRange(cwd, resolvedRange) + "\n";
510
+
511
+ stdout += printSection("Diff Stat");
512
+ stdout += diffStatForRange(cwd, resolvedRange) + "\n";
513
+
514
+ stdout += printSection("Changed Files");
515
+ stdout += changedFilesForRange(cwd, resolvedRange) + "\n";
516
+
517
+ stdout += printSection("Existing Release Files");
518
+ for (const rel of ["CHANGELOG.md", "RELEASE_NOTES.md", ".github/releases.md"]) {
519
+ if (existsSync(path.join(cwd, rel))) stdout += rel + "\n";
520
+ }
521
+
522
+ // A deterministic draft is useful to every read-only host surface. It is
523
+ // derived only from the already-resolved range; publication and file edits
524
+ // remain separate, writer-guarded actions.
525
+ stdout += printSection("Release Notes Draft");
526
+ stdout += `## Release notes (${resolvedRange})\n\n`;
527
+ const commits = commitLogForRange(cwd, resolvedRange)
528
+ .split("\n")
529
+ .map((line) => line.trim())
530
+ .filter(Boolean);
531
+ stdout += commits.length
532
+ ? `${commits.map((line) => `- ${line}`).join("\n")}\n`
533
+ : "- No commits in range.\n";
534
+ stdout += "\n### Changed files\n\n";
535
+ const files = changedFilesForRange(cwd, resolvedRange)
536
+ .split("\n")
537
+ .map((line) => line.trim())
538
+ .filter(Boolean);
539
+ stdout += files.length ? `${files.map((line) => `- ${line}`).join("\n")}\n` : "- None.\n";
540
+
541
+ return { stdout, stderr: "", exitCode: 0, cwd };
542
+ }
@@ -0,0 +1,29 @@
1
+ // Where a clone keeps its per-clone working files (docs/agents/file-locations.md).
2
+ // A repo's `.workit/` holds only committed team config (checks.json,
3
+ // risk-paths); plans, retro reports and handoffs an agent writes live in the
4
+ // store, `<git-common-dir>/workit/` (shared by every worktree of the clone,
5
+ // beside the ledger and check logs), or `<dir>/.workit` outside git.
6
+ // User-level locations (config, state, data, cache, runtime) are
7
+ // workit-paths.mjs's.
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { resolveStore } from "../store/paths";
11
+
12
+ /** The per-clone directories `workit dir <kind>` names and creates. */
13
+ export const REPO_DIR_KINDS = ["plans", "retros", "handoffs"] as const;
14
+ export type RepoDirKind = (typeof REPO_DIR_KINDS)[number];
15
+
16
+ export const isRepoDirKind = (kind: string): kind is RepoDirKind =>
17
+ (REPO_DIR_KINDS as readonly string[]).includes(kind);
18
+
19
+ /**
20
+ * The absolute per-clone directory of `kind` for `cwd`, created when missing,
21
+ * or an Error when git cannot say where the store is.
22
+ */
23
+ export function repoDir(kind: RepoDirKind, cwd: string): string | Error {
24
+ const location = resolveStore(path.resolve(cwd));
25
+ if (location instanceof Error) return location;
26
+ const dir = path.join(location.dir, kind);
27
+ fs.mkdirSync(dir, { recursive: true });
28
+ return dir;
29
+ }