@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,597 @@
1
+ // The prepare-commit-msg hook that gives a plain `git commit` the
2
+ // `Workit-Session:` trailer `workit git commit` adds (D18), for agents that
3
+ // cannot run `workit git` (a Claude Code worktree-isolated subagent).
4
+ // `workit doctor` reports it; `workit doctor --fix` installs it, in a Workit
5
+ // workspace only.
6
+ //
7
+ // - One source of truth: the trailer logic is SESSION_TRAILER_SCRIPT, written
8
+ // to `<git-common-dir>/workit/session-trailer.sh` (Workit-owned, never a hook
9
+ // slot, never the working tree). The hook Workit installs, and every line a
10
+ // user is told to add to a hook Workit does not own, is the same one-liner
11
+ // that runs it with the hook's arguments.
12
+ // - Slot: prepare-commit-msg, not commit-msg. Git runs it for -m/-F, amend,
13
+ // merge and squash, even with --no-verify, and before commit-msg, so a
14
+ // commitlint there sees the trailer. commit-msg stays the user's.
15
+ // - Where: `git rev-parse --git-path hooks`, so core.hooksPath is honoured.
16
+ // Workit writes only an empty slot or its own hook. Another tool's hook in
17
+ // the slot (husky, lefthook, a hand-written one) is never renamed, wrapped or
18
+ // edited: the state is `blocked` and `manual` names the line to add there;
19
+ // once that hook (or husky script, or lefthook config) runs the helper, the
20
+ // state is `manual`.
21
+ // A hooks directory outside the repository (a global core.hooksPath), or one
22
+ // in the working tree that git does not ignore, gets no hook either.
23
+ // - The session comes from the same variables as host-session.ts: WORKIT_SESSION_ID
24
+ // (unless WORKIT_HOST names another host than the shell's own), else
25
+ // OPENCODE_SESSION_ID, PI_SESSION_ID or CODEX_THREAD_ID. The message is left
26
+ // unchanged with no session or an unsafe id; for a plain editor commit (no
27
+ // message source: the trailer would sit in the buffer, and a user who empties
28
+ // it would commit a trailer-only message instead of aborting); for a
29
+ // template (git aborts an unedited one); for a comment-only message; and in a
30
+ // rebase or cherry-pick replaying commits that keep their own trailers.
31
+ // - A workspace with `trailers: false`: the helper adds nothing when
32
+ // WORKIT_TRAILERS=off (set by `workit git commit`) or `workit git trailers`
33
+ // prints `off` (asked with a timeout when workit is on PATH). Without
34
+ // workit it fails open and adds the trailer, so doctor warns about any
35
+ // Workit hook or helper left there and `--fix` removes them
36
+ // (removeSessionHook); another tool's hook and its manual line stay.
37
+ // WORKIT_TRAILERS=on is honoured the same way, on purpose: it is the answer
38
+ // `workit git commit` already computed, so the hook never asks twice, and a
39
+ // user who exports it opts in to the trailer whatever the workspace says.
40
+ // - The bounded ask uses `timeout 5` only when `timeout --version` names GNU
41
+ // coreutils (Linux, Git Bash): on Windows a bare `timeout` can resolve to
42
+ // timeout.exe, which pauses instead; elsewhere the ask is unbounded.
43
+ // - `--if-exists addIfDifferent`: the same session's trailer is never repeated;
44
+ // another session that amends adds its own, as `workit git commit --amend` does.
45
+ import { spawnSync } from "node:child_process";
46
+ import {
47
+ chmodSync,
48
+ existsSync,
49
+ lstatSync,
50
+ mkdirSync,
51
+ readFileSync,
52
+ renameSync,
53
+ rmSync,
54
+ statSync,
55
+ writeFileSync,
56
+ } from "node:fs";
57
+ import path from "node:path";
58
+ import { GIT_TIMEOUTS } from "./rev";
59
+
60
+ export const SESSION_HOOK_NAME = "prepare-commit-msg";
61
+ export const SESSION_HOOK_MARKER = "# workit-session-trailer-hook";
62
+ /** The helper under the git common dir that holds the trailer logic. */
63
+ const HELPER = ["workit", "session-trailer.sh"] as const;
64
+ /** Left by an unreleased build that chained commit-msg; reported, never used. */
65
+ const LEGACY_CHAINED = "commit-msg.workit-chained";
66
+
67
+ // LF only: Git for Windows runs hooks with its own sh, which reads LF scripts.
68
+ export const SESSION_TRAILER_SCRIPT = `#!/bin/sh
69
+ # Workit (https://github.com/BrainerVirus/workit) wrote this file with
70
+ # \`workit doctor --fix\`. A prepare-commit-msg hook runs it with its own
71
+ # arguments to add a Workit-Session trailer to commits made in an agent session.
72
+ # Not a hook itself; delete it together with the line that runs it.
73
+ # lefthook passes a missing argument as its literal placeholder.
74
+ case "\${2-}" in "" | "{2}" | template) exit 0 ;; esac
75
+ gitdir=$(git rev-parse --git-dir) || exit 0
76
+ if [ -d "$gitdir/rebase-merge" ] || [ -d "$gitdir/rebase-apply" ] || [ -f "$gitdir/CHERRY_PICK_HEAD" ]; then
77
+ exit 0
78
+ fi
79
+ trim() { printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'; }
80
+ inner=$(trim "\${OPENCODE_SESSION_ID-}"); inner_host=opencode
81
+ if [ -z "$inner" ]; then inner=$(trim "\${PI_SESSION_ID-}"); inner_host=pi; fi
82
+ if [ -z "$inner" ]; then inner=$(trim "\${CODEX_THREAD_ID-}"); inner_host=codex; fi
83
+ [ -n "$inner" ] || inner_host=
84
+ declared=$(trim "\${WORKIT_HOST-}")
85
+ family=$(printf '%s' "$declared" | sed 's/_cli$//;s/_desktop$//')
86
+ session=$inner
87
+ if [ "\${WORKIT_SESSION_ID+set}" = set ]; then
88
+ if [ -z "$inner" ] || [ -z "$declared" ] || [ "$family" = "$inner_host" ]; then
89
+ session=$(trim "$WORKIT_SESSION_ID")
90
+ fi
91
+ fi
92
+ [ -n "$session" ] || exit 0
93
+ [ \${#session} -le 128 ] || exit 0
94
+ case "$session" in *[!A-Za-z0-9_.:@/+-]*) exit 0 ;; esac
95
+ # A workspace with \`trailers: false\` keeps authorship in the ledger only.
96
+ # \`workit git commit\` decides it already (WORKIT_TRAILERS); otherwise ask
97
+ # workit, bounded, and add the trailer when it cannot answer.
98
+ case "\${WORKIT_TRAILERS-}" in
99
+ off) exit 0 ;;
100
+ on) ;;
101
+ *)
102
+ if command -v workit >/dev/null 2>&1; then
103
+ # Bound the call only with GNU coreutils timeout: on Windows a bare
104
+ # \`timeout\` can be timeout.exe, a different program (a pause).
105
+ t=; case "$(timeout --version 2>/dev/null)" in *coreutils*) t="timeout 5" ;; esac
106
+ [ "$($t workit git trailers 2>/dev/null)" != off ] || exit 0
107
+ fi
108
+ ;;
109
+ esac
110
+ add_trailer() {
111
+ [ -f "$1" ] && [ -r "$1" ] || return 0
112
+ body=$(sed '/^# -\\{24\\} >8 -\\{24\\}$/,$d' "$1" | git stripspace --strip-comments)
113
+ [ -n "$body" ] || return 0
114
+ # A merge message has no final newline; interpret-trailers would glue the
115
+ # trailer to the subject line.
116
+ [ -z "$(tail -c 1 "$1")" ] || printf '\\n' >> "$1"
117
+ git -c trailer.separators=: -c trailer.where=end interpret-trailers --in-place \\
118
+ --no-divider --if-exists addIfDifferent --trailer "Workit-Session: $session" "$1" || return 0
119
+ }
120
+ # $1 is the message file. lefthook cannot quote a path it pastes in, so its
121
+ # job passes '' and the file is found here: git hands the hook COMMIT_EDITMSG,
122
+ # except \`git merge\`, which hands it MERGE_MSG. With source "merge" both are
123
+ # updated: the one git did not hand over is stale or removed after the commit.
124
+ case "\${1-}" in "" | "{1}") ;; *) add_trailer "$1"; exit 0 ;; esac
125
+ add_trailer "$(git rev-parse --git-path COMMIT_EDITMSG)"
126
+ [ "$2" != merge ] || add_trailer "$(git rev-parse --git-path MERGE_MSG)"
127
+ exit 0
128
+ `;
129
+
130
+ /**
131
+ * The line that runs the helper from a shell prepare-commit-msg hook, with the
132
+ * hook's own arguments. Hooks run at the top of the work tree, where the
133
+ * relative common dir resolves. A clone without the helper (a teammate who
134
+ * never ran `--fix`, while the line sits in a committed husky hook) runs
135
+ * nothing and prints nothing.
136
+ */
137
+ export const manualTrailerLine = (): string =>
138
+ `h="$(git rev-parse --git-common-dir 2>/dev/null)/${HELPER.join("/")}"; [ ! -f "$h" ] || sh "$h" "$@" || true`;
139
+
140
+ /**
141
+ * The same for a lefthook `run:`, with no double quote in it: on Windows
142
+ * lefthook starts `"sh" -c "<run>"` as one raw command line, so a `"` in the
143
+ * run ends that argument early. `set -f` and an empty IFS keep the unquoted
144
+ * `$h` one word (a path with spaces) and unglobbed. lefthook pastes `{1}` in
145
+ * raw, and from a linked worktree it is an absolute path that may hold a
146
+ * space or a quote, which no quoting survives; so the job passes '' and the
147
+ * helper finds the message file itself. `{2}` and `{3}` are a source word and
148
+ * a sha, safe unquoted.
149
+ */
150
+ export const lefthookTrailerRun = (): string =>
151
+ `set -f; IFS=; h=$(git rev-parse --git-common-dir 2>/dev/null)/${HELPER.join("/")}; [ ! -f $h ] || sh $h '' {2} {3} || true`;
152
+
153
+ /** What marks a hook or config that already runs the helper. */
154
+ const HELPER_REF = HELPER.join("/");
155
+
156
+ export const SESSION_HOOK_SCRIPT = `#!/bin/sh
157
+ ${SESSION_HOOK_MARKER} v4
158
+ # Workit (https://github.com/BrainerVirus/workit) installed this hook with
159
+ # \`workit doctor --fix\`: it runs Workit's session-trailer helper, which adds a
160
+ # Workit-Session trailer to commits made in an agent session. To remove it,
161
+ # delete this file.
162
+ ${manualTrailerLine()}
163
+ `;
164
+
165
+ export type SessionHookState =
166
+ /** Not inside a git work tree, or git is unavailable. */
167
+ | "not_git"
168
+ /** The current Workit hook and helper are installed. */
169
+ | "installed"
170
+ /** An older Workit hook, or a missing or older helper; --fix rewrites them. */
171
+ | "outdated"
172
+ /** The slot is empty; --fix installs the hook. */
173
+ | "missing"
174
+ /** --fix will not write the hook; `reason` says why and `manual` what to do instead. */
175
+ | "blocked"
176
+ /** Another tool's hook or config already runs the helper (`via`, `manualTarget`). */
177
+ | "manual";
178
+
179
+ /** Who owns the slot when it is blocked by another hook. */
180
+ export type HookOwner = "husky" | "lefthook" | "other";
181
+
182
+ export type SessionHookStatus = {
183
+ state: SessionHookState;
184
+ hooksDir: string | null;
185
+ hookPath: string | null;
186
+ /** The helper the hook and the manual line run. */
187
+ helperPath: string | null;
188
+ /** Whether the helper on disk is the current script. */
189
+ helperCurrent: boolean;
190
+ owner?: HookOwner;
191
+ reason?: string;
192
+ /** The manual fix when --fix cannot install the hook. */
193
+ manual?: string;
194
+ /** Where the manual line goes (or already is): a husky script, a lefthook config, a hook. */
195
+ manualTarget?: string;
196
+ /** For `manual`: who runs the helper. */
197
+ via?: "husky" | "lefthook" | "existing hook";
198
+ /** Files left by the unreleased commit-msg design, with how to recover. */
199
+ leftover?: string;
200
+ };
201
+
202
+ const run = (cwd: string, args: string[], env: NodeJS.ProcessEnv) => {
203
+ const r = spawnSync("git", args, {
204
+ cwd,
205
+ encoding: "utf8",
206
+ timeout: GIT_TIMEOUTS.local,
207
+ windowsHide: true,
208
+ stdio: ["ignore", "pipe", "pipe"],
209
+ env: { ...env, GIT_TERMINAL_PROMPT: "0" },
210
+ });
211
+ return { ok: r.status === 0, out: (r.stdout ?? "").trim() };
212
+ };
213
+
214
+ const inside = (child: string, parent: string): boolean => {
215
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
216
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
217
+ };
218
+
219
+ const readText = (file: string): string | null => {
220
+ try {
221
+ return readFileSync(file, "utf8");
222
+ } catch {
223
+ return null;
224
+ }
225
+ };
226
+
227
+ const exists = (file: string): boolean => {
228
+ try {
229
+ lstatSync(file);
230
+ return true;
231
+ } catch {
232
+ return false;
233
+ }
234
+ };
235
+
236
+ const LEFTHOOK_CONFIGS = [
237
+ "lefthook.yml",
238
+ "lefthook.yaml",
239
+ ".lefthook.yml",
240
+ ".lefthook.yaml",
241
+ "lefthook.json",
242
+ "lefthook.toml",
243
+ ];
244
+
245
+ const lefthookConfig = (top: string): string | null =>
246
+ LEFTHOOK_CONFIGS.map((name) => path.join(top, name)).find((file) => existsSync(file)) ?? null;
247
+
248
+ // Whether a hook, script or config really runs the helper, not just mentions
249
+ // it. Shell text: a non-comment line invoking it, either directly
250
+ // (`sh …/workit/session-trailer.sh`) or as `sh "$h"` after an `h=` line
251
+ // naming it. Control flow (an `exit` before the line, an `if false`) is out
252
+ // of scope: this reads lines, it does not run them.
253
+ const isComment = (line: string) => /^\s*#/.test(line);
254
+ // `sh` in command position: line start or after `;`, `&`, `|`, `(`, `!`,
255
+ // `then`, `do` or `else` (so `echo sh $h` does not count).
256
+ const COMMAND_SH = String.raw`(?:^|[;&|(!]|\b(?:then|do|else)\s)\s*sh\s+`;
257
+ const DIRECT_CALL = new RegExp(
258
+ String.raw`${COMMAND_SH}(?:"[^"]*|\S*)${HELPER_REF.replaceAll(".", String.raw`\.`)}`,
259
+ );
260
+ const VAR_CALL = new RegExp(String.raw`${COMMAND_SH}"?\$h\b`);
261
+ const runsHelperText = (text: string): boolean => {
262
+ const lines = text.split(/\r?\n/).filter((line) => !isComment(line));
263
+ const assigned = lines.some((line) => /(?:^|[\s;])h=/.test(line) && line.includes(HELPER_REF));
264
+ return lines.some((line) => DIRECT_CALL.test(line) || (assigned && VAR_CALL.test(line)));
265
+ };
266
+
267
+ const LEFTHOOK_YAML = [
268
+ "lefthook.yml",
269
+ "lefthook.yaml",
270
+ ".lefthook.yml",
271
+ ".lefthook.yaml",
272
+ "lefthook-local.yml",
273
+ "lefthook-local.yaml",
274
+ ".lefthook-local.yml",
275
+ ".lefthook-local.yaml",
276
+ ];
277
+ const indentOf = (line: string) => line.length - line.trimStart().length;
278
+
279
+ /**
280
+ * A small indentation-aware read of a lefthook YAML config (no YAML library
281
+ * in deps): a `run:` value under the top-level `prepare-commit-msg:` key, on
282
+ * a non-comment line (or its `|`/`>` block), that runs the helper.
283
+ * lefthook.json/.toml are not read, so they never count as manual.
284
+ */
285
+ const lefthookRunsHelper = (text: string): boolean => {
286
+ const lines = text.split(/\r?\n/);
287
+ const start = lines.findIndex((line) => /^prepare-commit-msg:\s*(?:#.*)?$/.test(line));
288
+ if (start < 0) return false;
289
+ const block: string[] = [];
290
+ for (const line of lines.slice(start + 1)) {
291
+ if (line.trim() && !isComment(line) && indentOf(line) === 0) break;
292
+ block.push(line);
293
+ }
294
+ return block.some((line, i) => {
295
+ if (isComment(line)) return false;
296
+ const runKey = /^(\s*)(?:-\s+)?run:\s*(.*)$/.exec(line);
297
+ if (!runKey) return false;
298
+ let value = runKey[2] ?? "";
299
+ if (/^[|>][-+]?\s*$/.test(value)) {
300
+ const body: string[] = [];
301
+ for (const next of block.slice(i + 1)) {
302
+ if (next.trim() && indentOf(next) <= indentOf(line)) break;
303
+ body.push(next);
304
+ }
305
+ value = body.join("\n");
306
+ }
307
+ return runsHelperText(value);
308
+ });
309
+ };
310
+
311
+ const isExecutable = (file: string): boolean => {
312
+ if (process.platform === "win32") return true;
313
+ try {
314
+ return (statSync(file).mode & 0o111) !== 0;
315
+ } catch {
316
+ return false;
317
+ }
318
+ };
319
+
320
+ /** Whether the manual line is really in place for this owner. */
321
+ const manualInPlace = (input: ManualInput, target: string): boolean => {
322
+ if (input.owner === "lefthook")
323
+ return LEFTHOOK_YAML.some((name) => {
324
+ const text = readText(path.join(input.top, name));
325
+ return text !== null && lefthookRunsHelper(text);
326
+ });
327
+ const text = readText(target);
328
+ if (text === null || !runsHelperText(text)) return false;
329
+ // husky v9's `h` runs the user script with `sh -e`, so it needs no +x; any
330
+ // other target is a hook git runs itself, which git skips unless executable.
331
+ return (input.owner === "husky" && target !== input.hookPath) || isExecutable(target);
332
+ };
333
+
334
+ /** husky v9 stubs source `h` beside them; v4-v8 hooks source `_/husky.sh`. */
335
+ const isHuskyV9 = (text: string, hooksDir: string): boolean =>
336
+ /\$\(dirname (?:-- )?"\$0"\)\/h"/.test(text) && existsSync(path.join(hooksDir, "h"));
337
+
338
+ const ownerOf = (text: string, hooksDir: string, top: string): HookOwner => {
339
+ if (text.includes("husky.sh") || isHuskyV9(text, hooksDir)) return "husky";
340
+ if (/lefthook/i.test(text) || lefthookConfig(top)) return "lefthook";
341
+ return "other";
342
+ };
343
+
344
+ type ManualInput = {
345
+ owner: HookOwner;
346
+ text: string;
347
+ hooksDir: string;
348
+ hookPath: string;
349
+ top: string;
350
+ /** Workit's earlier hook, moved aside by lefthook. */
351
+ displaced: string | null;
352
+ };
353
+
354
+ /** The file the manual line goes in for this owner. */
355
+ const manualTargetOf = ({ owner, text, hooksDir, hookPath, top }: ManualInput): string => {
356
+ // husky v9 points core.hooksPath at `.husky/_` and runs `.husky/<hook>`
357
+ // from the stub; husky v4-v8 hooks are the user's files themselves.
358
+ if (owner === "husky" && isHuskyV9(text, hooksDir))
359
+ return path.join(path.dirname(hooksDir), SESSION_HOOK_NAME);
360
+ if (owner === "lefthook") return lefthookConfig(top) ?? path.join(top, "lefthook.yml");
361
+ return hookPath;
362
+ };
363
+
364
+ const manualFor = (input: ManualInput): string => {
365
+ const { owner, displaced } = input;
366
+ const target = manualTargetOf(input);
367
+ const stale = displaced ? `; delete the stale ${displaced} (nothing runs it)` : "";
368
+ if (owner === "husky")
369
+ return `add this line to ${target} (create it if missing; husky runs it)${stale}: ${manualTrailerLine()}`;
370
+ if (owner === "lefthook")
371
+ return [
372
+ `merge this job into ${target} (add it to an existing \`${SESSION_HOOK_NAME}:\` \`jobs:\` list rather than a second \`${SESSION_HOOK_NAME}:\` key), then run \`lefthook install\`${stale}:`,
373
+ `${SESSION_HOOK_NAME}:`,
374
+ " jobs:",
375
+ " - name: workit-session",
376
+ ` run: ${lefthookTrailerRun()}`,
377
+ ].join("\n");
378
+ return `add this line to ${target} (a new file needs \`#!/bin/sh\` as its first line and \`chmod +x\`)${stale}: ${manualTrailerLine()}`;
379
+ };
380
+
381
+ const leftoverOf = (hooksDir: string): string | undefined => {
382
+ const chained = path.join(hooksDir, LEGACY_CHAINED);
383
+ const commitMsg = path.join(hooksDir, "commit-msg");
384
+ const oldOurs = readText(commitMsg)?.includes(SESSION_HOOK_MARKER) ?? false;
385
+ if (exists(chained))
386
+ return oldOurs
387
+ ? `${chained} was left by an earlier Workit build: move it back to ${commitMsg} (replacing Workit's old hook there)`
388
+ : `${chained} was left by an earlier Workit build and nothing runs it: if it is your original commit-msg hook, move it back to ${commitMsg}; otherwise delete it`;
389
+ if (oldOurs)
390
+ return `${commitMsg} is a hook from an earlier Workit build; delete it (the trailer now comes from ${SESSION_HOOK_NAME})`;
391
+ return undefined;
392
+ };
393
+
394
+ /** Where the prepare-commit-msg hook of the repository at `cwd` lives, and what it is. */
395
+ export function inspectSessionHook(
396
+ cwd: string,
397
+ env: NodeJS.ProcessEnv = process.env,
398
+ ): SessionHookStatus {
399
+ const none = { hooksDir: null, hookPath: null, helperPath: null, helperCurrent: false };
400
+ if (!existsSync(cwd)) return { state: "not_git", ...none };
401
+ const top = run(cwd, ["rev-parse", "--show-toplevel"], env);
402
+ if (!top.ok || !top.out) return { state: "not_git", ...none };
403
+ const paths = run(
404
+ cwd,
405
+ ["rev-parse", "--path-format=absolute", "--git-common-dir", "--git-path", "hooks"],
406
+ env,
407
+ );
408
+ const [commonDir, hooksDir] = paths.out.split(/\r?\n/);
409
+ if (!paths.ok || !commonDir || !hooksDir) return { state: "not_git", ...none };
410
+ const hookPath = path.join(hooksDir, SESSION_HOOK_NAME);
411
+ const helperPath = path.join(commonDir, ...HELPER);
412
+ const helperCurrent = readText(helperPath) === SESSION_TRAILER_SCRIPT;
413
+ const leftover = leftoverOf(hooksDir);
414
+ const base = { hooksDir, hookPath, helperPath, helperCurrent, ...(leftover ? { leftover } : {}) };
415
+ const blocked = (reason: string, owner: HookOwner, text = ""): SessionHookStatus => {
416
+ const old = `${hookPath}.old`;
417
+ const displaced = readText(old)?.includes(SESSION_HOOK_MARKER) ? old : null;
418
+ const input = { owner, text, hooksDir, hookPath, top: top.out, displaced };
419
+ const manualTarget = manualTargetOf(input);
420
+ const owned = text ? { owner } : {};
421
+ // The user already added the line where it belongs.
422
+ if (manualInPlace(input, manualTarget))
423
+ return {
424
+ state: "manual",
425
+ ...base,
426
+ ...owned,
427
+ manualTarget,
428
+ via: owner === "other" ? "existing hook" : owner,
429
+ };
430
+ return {
431
+ state: "blocked",
432
+ ...base,
433
+ ...owned,
434
+ reason: displaced ? `${reason} (Workit's earlier hook was moved to ${old})` : reason,
435
+ manual: manualFor(input),
436
+ manualTarget,
437
+ };
438
+ };
439
+ if (!inside(hooksDir, commonDir) && !inside(hooksDir, top.out))
440
+ return blocked(
441
+ `core.hooksPath ${hooksDir} is outside the repository; a hook there runs in every repository that uses it`,
442
+ "other",
443
+ );
444
+ const text = exists(hookPath) ? (readText(hookPath) ?? "") : null;
445
+ if (text !== null) {
446
+ if (text === SESSION_HOOK_SCRIPT)
447
+ return { state: helperCurrent ? "installed" : "outdated", ...base };
448
+ if (text.includes(SESSION_HOOK_MARKER)) return { state: "outdated", ...base };
449
+ const owner = ownerOf(text, hooksDir, top.out);
450
+ return blocked(
451
+ `${hookPath} belongs to ${owner === "other" ? "another hook" : owner}; Workit never replaces it`,
452
+ owner,
453
+ text,
454
+ );
455
+ }
456
+ if (!inside(hooksDir, commonDir)) {
457
+ // A hooks directory in the working tree (a committed .githooks): a new
458
+ // file there must be ignored, or it shows up in git status.
459
+ const rel = path.relative(top.out, hookPath).split(path.sep).join("/");
460
+ if (!run(top.out, ["check-ignore", "-q", "--", rel], env).ok)
461
+ return blocked(
462
+ `${hooksDir} is in the working tree and not ignored; the hook would show up as a new file`,
463
+ "other",
464
+ );
465
+ }
466
+ return { state: "missing", ...base };
467
+ }
468
+
469
+ /**
470
+ * Write `content` through a staged file renamed into place. The finally only
471
+ * matters when the write or chmod throws after the staged file exists; a
472
+ * rename failure cannot be provoked through inspect (an unreadable or
473
+ * directory slot is reported blocked first), so that path is not tested.
474
+ */
475
+ const writeExecutable = (file: string, content: string) => {
476
+ mkdirSync(path.dirname(file), { recursive: true });
477
+ const staged = `${file}.workit-new`;
478
+ try {
479
+ writeFileSync(staged, content, { mode: 0o755 });
480
+ chmodSync(staged, 0o755);
481
+ renameSync(staged, file);
482
+ } finally {
483
+ rmSync(staged, { force: true });
484
+ }
485
+ };
486
+
487
+ export type SessionHookInstall = {
488
+ /** What --fix did to the hook slot: wrote the hook, rewrote an older one, or nothing. */
489
+ action: "installed" | "updated" | "unchanged" | "skipped";
490
+ /** Whether the helper was (re)written. */
491
+ helperWritten: boolean;
492
+ status: SessionHookStatus;
493
+ detail: string;
494
+ };
495
+
496
+ /**
497
+ * Install (or refresh) the helper and the hook for the repository at `cwd`.
498
+ * The caller decides that `cwd` is a Workit workspace. The helper is written
499
+ * even when the slot is blocked, so the manual line works; the slot is written
500
+ * only when empty or holding Workit's own hook.
501
+ */
502
+ export function installSessionHook(
503
+ cwd: string,
504
+ env: NodeJS.ProcessEnv = process.env,
505
+ ): SessionHookInstall {
506
+ const status = inspectSessionHook(cwd, env);
507
+ if (status.state === "not_git")
508
+ return {
509
+ action: "skipped",
510
+ helperWritten: false,
511
+ status,
512
+ detail: "not inside a git work tree",
513
+ };
514
+ const helperWritten = !status.helperCurrent;
515
+ if (helperWritten) writeExecutable(status.helperPath!, SESSION_TRAILER_SCRIPT);
516
+ const helper = helperWritten ? `; wrote ${status.helperPath}` : "";
517
+ if (status.state === "installed")
518
+ return {
519
+ action: "unchanged",
520
+ helperWritten,
521
+ status,
522
+ detail: `hook already at ${status.hookPath}`,
523
+ };
524
+ if (status.state === "manual")
525
+ return {
526
+ action: "unchanged",
527
+ helperWritten,
528
+ status: inspectSessionHook(cwd, env),
529
+ detail: `${status.manualTarget} already runs the helper (via ${status.via})${helper}`,
530
+ };
531
+ if (status.state === "blocked")
532
+ return {
533
+ action: "skipped",
534
+ helperWritten,
535
+ status: inspectSessionHook(cwd, env),
536
+ detail: `${status.reason}${helper}`,
537
+ };
538
+ const fresh = status.state === "missing";
539
+ const slotCurrent = readText(status.hookPath!) === SESSION_HOOK_SCRIPT;
540
+ if (!slotCurrent) writeExecutable(status.hookPath!, SESSION_HOOK_SCRIPT);
541
+ const verb = fresh ? "installed" : "updated";
542
+ return {
543
+ action: verb,
544
+ helperWritten,
545
+ status: inspectSessionHook(cwd, env),
546
+ detail: `${verb} the Workit ${SESSION_HOOK_NAME} hook at ${status.hookPath}${helper}`,
547
+ };
548
+ }
549
+
550
+ export type SessionHookRemoval = {
551
+ /** Whether Workit's own hook or helper was deleted. */
552
+ action: "removed" | "unchanged" | "skipped";
553
+ /** The files deleted: Workit's hook (only one carrying its marker) and the helper. */
554
+ removed: string[];
555
+ status: SessionHookStatus;
556
+ detail: string;
557
+ };
558
+
559
+ /** Whether `status` finds Workit's own hook (any version) in the slot. */
560
+ export const workitHookIn = (status: SessionHookStatus): boolean =>
561
+ status.hookPath !== null && (readText(status.hookPath)?.includes(SESSION_HOOK_MARKER) ?? false);
562
+
563
+ /** Whether the helper exists for `status`, current or not. */
564
+ export const helperIn = (status: SessionHookStatus): boolean =>
565
+ status.helperPath !== null && exists(status.helperPath);
566
+
567
+ /**
568
+ * For a workspace with `trailers: false`: delete Workit's own hook (only a
569
+ * slot holding its marker) and the helper. Another tool's hook, a husky
570
+ * script or a lefthook job that runs the helper is never edited; without the
571
+ * helper its manual line runs nothing.
572
+ */
573
+ export function removeSessionHook(
574
+ cwd: string,
575
+ env: NodeJS.ProcessEnv = process.env,
576
+ ): SessionHookRemoval {
577
+ const status = inspectSessionHook(cwd, env);
578
+ if (status.state === "not_git")
579
+ return { action: "skipped", removed: [], status, detail: "not inside a git work tree" };
580
+ const removed: string[] = [];
581
+ if (workitHookIn(status)) {
582
+ rmSync(status.hookPath!, { force: true });
583
+ removed.push(status.hookPath!);
584
+ }
585
+ if (helperIn(status)) {
586
+ rmSync(status.helperPath!, { force: true });
587
+ removed.push(status.helperPath!);
588
+ }
589
+ return {
590
+ action: removed.length ? "removed" : "unchanged",
591
+ removed,
592
+ status: inspectSessionHook(cwd, env),
593
+ detail: removed.length
594
+ ? `removed ${removed.join(" and ")}`
595
+ : "no Workit session hook or helper to remove",
596
+ };
597
+ }
@@ -0,0 +1,63 @@
1
+ // `workit git stage|unstage`: index-only staging an agent can drive
2
+ // without `git add -p`. A unified diff (`--patch`) goes to the index alone
3
+ // (`git apply --cached`, checked first), so selected hunks become one focused
4
+ // commit with `workit git commit --staged`; whole paths stage or unstage as
5
+ // `git add -A` / `git reset` would. The working tree is never touched.
6
+ import { failure, success, type ForgeResult } from "../forge/types";
7
+ import { gitDirs } from "./facts";
8
+ import { git, gitError } from "./ops";
9
+
10
+ export type StageOutcome = {
11
+ /** Every path staged after the call (index differs from HEAD), top-relative. */
12
+ staged: string[];
13
+ };
14
+
15
+ const stagedNow = (cwd: string): string[] =>
16
+ git(cwd, ["diff", "--cached", "--name-only", "-z", "--no-renames"])
17
+ .stdout.split("\0")
18
+ .filter(Boolean);
19
+
20
+ const badPaths = (paths: readonly string[]) =>
21
+ !paths.length || paths.some((value) => value.startsWith("-") || !value.trim());
22
+
23
+ /**
24
+ * Stage a unified diff (`patch`) or whole `paths`. A patch is applied from the
25
+ * top of the work tree, as `git diff` writes its paths, with hunk line counts
26
+ * recounted so a hand-trimmed hunk still applies.
27
+ */
28
+ export function gitStage(
29
+ cwd: string,
30
+ input: { patch: string } | { paths: readonly string[] },
31
+ ): ForgeResult<StageOutcome> {
32
+ const top = gitDirs(cwd)?.top ?? null;
33
+ if (!top) return failure("invalid_input", "not inside a git work tree");
34
+ if ("patch" in input) {
35
+ if (!input.patch.trim()) return failure("invalid_input", "the patch is empty");
36
+ const root = top;
37
+ const apply = (...flags: string[]) =>
38
+ git(root, ["apply", "--cached", "--recount", ...flags, "-"], { input: input.patch });
39
+ const checked = apply("--check");
40
+ if (!checked.ok)
41
+ return failure(
42
+ "failed",
43
+ `patch_does_not_apply: ${gitError(checked, 6)}`,
44
+ "regenerate it from `git diff -- <file>` (the index is the base: `git diff` shows only unstaged hunks) and keep whole hunks",
45
+ );
46
+ const applied = apply();
47
+ if (!applied.ok) return failure("failed", `git apply --cached failed: ${gitError(applied)}`);
48
+ return success({ staged: stagedNow(cwd) });
49
+ }
50
+ if (badPaths(input.paths))
51
+ return failure("invalid_input", "paths must not be empty or start with -");
52
+ const added = git(cwd, ["add", "-A", "--", ...input.paths]);
53
+ if (!added.ok) return failure("failed", `git add failed: ${gitError(added)}`);
54
+ return success({ staged: stagedNow(cwd) });
55
+ }
56
+
57
+ /** Put `paths` back to HEAD in the index; the working tree keeps every change. */
58
+ export function gitUnstage(cwd: string, paths: readonly string[]): ForgeResult<StageOutcome> {
59
+ if (badPaths(paths)) return failure("invalid_input", "paths must not be empty or start with -");
60
+ const reset = git(cwd, ["reset", "-q", "--", ...paths]);
61
+ if (!reset.ok) return failure("failed", `git reset failed: ${gitError(reset)}`);
62
+ return success({ staged: stagedNow(cwd) });
63
+ }