@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,218 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { configDir, isConfigObject } from "./config";
5
+ import { applyWorkspaceBranchPolicy } from "./workspace-branch-policy";
6
+ import {
7
+ findYtrack,
8
+ ytrackLogin,
9
+ ytrackMissingUnblock,
10
+ ytrackReadiness,
11
+ ytrackRunner,
12
+ } from "./youtrack-ytrack";
13
+
14
+ // AR-07/CA-37: a parseable non-object (null, scalar, array) is not a config
15
+ // file — never display it as configured (fail-open) nor as unconfigured.
16
+ const readJson = (p: string): Record<string, any> | null => {
17
+ try {
18
+ const parsed: unknown = JSON.parse(fs.readFileSync(p, "utf8"));
19
+ return isConfigObject(parsed) ? (parsed as Record<string, any>) : null;
20
+ } catch {
21
+ return null;
22
+ }
23
+ };
24
+
25
+ /** Provider for a fresh vcs.json: explicit env, else origin remote, else null
26
+ * (no silent default — a tool for everyone must not assume a provider). */
27
+ export const resolveInitProvider = (cwd?: string): string | null => {
28
+ const env = process.env.WORKIT_VCS_PROVIDER?.trim();
29
+ if (env) return env.toLowerCase();
30
+ const root = cwd ?? process.env.WORKIT_WORKSPACE_ROOT ?? process.cwd();
31
+ try {
32
+ const out = spawnSync("git", ["remote", "get-url", "origin"], {
33
+ cwd: root,
34
+ encoding: "utf8",
35
+ });
36
+ if (out.status === 0) {
37
+ const url = (out.stdout ?? "").trim();
38
+ if (/github\.com[:/]/.test(url)) return "github";
39
+ if (/gitlab\.com[:/]/.test(url)) return "gitlab";
40
+ }
41
+ } catch {
42
+ /* no git: unresolvable, caller omits the field */
43
+ }
44
+ return null;
45
+ };
46
+
47
+ // Port of scripts/init/apply.sh write_youtrack_json — env overrides honored.
48
+ // Neutral draft (same shape as the setup wizard's): no organization URL, issue
49
+ // IDs, mentions, greetings or timezone, and no token: YouTrack goes through
50
+ // ytrack, which keeps its own token in the OS keyring. The work-item date uses
51
+ // the process timezone unless the user adds an explicit `timezone`.
52
+ const youtrackJsonContent = (): Record<string, any> => {
53
+ const meetingIssue = process.env.WORKIT_YT_MEETING_ISSUE;
54
+ return {
55
+ baseUrl: process.env.WORKIT_YT_BASE_URL ?? "https://youtrack.example.com",
56
+ ...(meetingIssue
57
+ ? {
58
+ meetingIssue,
59
+ meetingIssues: { general: { issue: meetingIssue, label: "General meetings" } },
60
+ }
61
+ : {}),
62
+ };
63
+ };
64
+
65
+ /** ytrack's state for youtrack.json's baseUrl: what the init result reports. */
66
+ const ytrackCheck = (env: NodeJS.ProcessEnv, baseUrl: string): Record<string, any> => {
67
+ const host = baseUrl || null;
68
+ const ready = ytrackReadiness(ytrackRunner(env, host), host);
69
+ return ready.ok
70
+ ? { ok: true, version: ready.version, login: ready.login }
71
+ : {
72
+ ok: false,
73
+ reason: ready.reason,
74
+ error: ready.error,
75
+ unblock: ready.unblock,
76
+ };
77
+ };
78
+
79
+ const vcsJsonContent = (): Record<string, any> => {
80
+ // Explicit provider at init: env wins, else the checkout's origin remote
81
+ // (same RL-03b rule as vcs-config). Unresolvable means the field is
82
+ // omitted — never a silent assumption downstream.
83
+ const provider = resolveInitProvider();
84
+ return {
85
+ ...(provider ? { provider } : {}),
86
+ defaultTargetBranch: process.env.WORKIT_VCS_TARGET_BRANCH ?? "develop",
87
+ gitlab: {
88
+ host: process.env.WORKIT_GITLAB_HOST ?? "gitlab.com",
89
+ apiUrl: process.env.WORKIT_GITLAB_API_URL ?? "https://gitlab.com/api/v4",
90
+ },
91
+ github: {
92
+ host: process.env.WORKIT_GITHUB_HOST ?? "github.com",
93
+ },
94
+ pr: { squashOnMerge: true, removeSourceBranch: true, pushBranch: true, confirmSkip: true },
95
+ };
96
+ };
97
+
98
+ /** Port of scripts/init/apply.sh — confirmed scaffold actions. */
99
+ export function initApplyData(
100
+ action: string,
101
+ env: NodeJS.ProcessEnv = process.env,
102
+ ): Record<string, any> {
103
+ const dir = String(env.WORKIT_CONFIG ?? configDir());
104
+ fs.mkdirSync(dir, { recursive: true });
105
+
106
+ switch (action) {
107
+ case "youtrack_json": {
108
+ const out = path.join(dir, "youtrack.json");
109
+ fs.writeFileSync(out, JSON.stringify(youtrackJsonContent(), null, 2) + "\n", "utf8");
110
+ return { action, ok: true, path: out };
111
+ }
112
+ case "youtrack_token_placeholder": {
113
+ // Kept as a name so an older host's call gets a clear answer, not "unknown action".
114
+ return {
115
+ action,
116
+ ok: false,
117
+ error:
118
+ "Workit no longer uses a YouTrack token file: YouTrack goes through ytrack, which keeps its token in the OS keyring",
119
+ unblock: findYtrack(env) ? ytrackLogin(null) : ytrackMissingUnblock(null),
120
+ };
121
+ }
122
+ case "youtrack_scaffold": {
123
+ const jsonOut = path.join(dir, "youtrack.json");
124
+ fs.writeFileSync(jsonOut, JSON.stringify(youtrackJsonContent(), null, 2) + "\n", "utf8");
125
+ const configPath = path.resolve(jsonOut);
126
+ const cfg = readJson(configPath) ?? {};
127
+ const base = String(cfg.baseUrl ?? "").replace(/\/+$/, "");
128
+ const meeting = String(cfg.meetingIssue ?? "");
129
+ const ytrack = ytrackCheck(env, base);
130
+ return {
131
+ action,
132
+ ok: true,
133
+ youtrack_json: configPath,
134
+ config_edit_path: configPath,
135
+ ytrack,
136
+ youtrack_config: {
137
+ config_edit_path: configPath,
138
+ baseUrl: base,
139
+ meetingIssue: meeting,
140
+ meetingIssueUrl: base && meeting ? `${base}/issue/${meeting}` : null,
141
+ locale: cfg.locale,
142
+ timeLogging: {
143
+ meetings: {
144
+ issue: meeting,
145
+ skill: "/wk-meetings",
146
+ logsTime: true,
147
+ postsComment: false,
148
+ },
149
+ taskWork: {
150
+ issueSource: "active spec/plan **YouTrack:** field or --issue",
151
+ skill: "/wk-issue-update",
152
+ logsTime: true,
153
+ postsComment: true,
154
+ },
155
+ },
156
+ },
157
+ instruction: ytrack.ok
158
+ ? `Set baseUrl in ${configPath} if needed; ytrack is logged in, so \`workit doctor\` should pass.`
159
+ : `Set baseUrl in ${configPath}, then ${ytrack.unblock}, then \`workit doctor\`.`,
160
+ };
161
+ }
162
+ case "vcs_scaffold": {
163
+ const jsonOut = path.join(dir, "vcs.json");
164
+ fs.writeFileSync(jsonOut, JSON.stringify(vcsJsonContent(), null, 2) + "\n", "utf8");
165
+ const configPath = path.resolve(jsonOut);
166
+ const prev = process.env.WORKIT_VCS_CONFIG;
167
+ process.env.WORKIT_VCS_CONFIG = configPath;
168
+ try {
169
+ const cfg = readJson(configPath) ?? {};
170
+ const rawProvider = cfg.provider;
171
+ const provider = typeof rawProvider === "string" && rawProvider.trim() ? rawProvider : null;
172
+ return {
173
+ action,
174
+ ok: true,
175
+ vcs_json: configPath,
176
+ config_edit_path: configPath,
177
+ vcs_config: {
178
+ config_edit_path: configPath,
179
+ provider,
180
+ defaultTargetBranch: cfg.defaultTargetBranch,
181
+ pr: cfg.pr,
182
+ switchHint: 'Change "provider" to "github" when you migrate; authenticate with gh/glab',
183
+ skill: "/wk-pr",
184
+ },
185
+ instruction:
186
+ provider === null
187
+ ? `No provider resolved — set provider to gitlab or github in ${configPath}, then \`workit doctor\`.`
188
+ : `Run ${provider === "gitlab" ? "glab" : "gh"} auth login, then \`workit doctor\`.`,
189
+ };
190
+ } finally {
191
+ if (prev === undefined) delete process.env.WORKIT_VCS_CONFIG;
192
+ else process.env.WORKIT_VCS_CONFIG = prev;
193
+ }
194
+ }
195
+ case "branch_policy": {
196
+ const root = env.WORKIT_WORKSPACE_ROOT?.trim() ? env.WORKIT_WORKSPACE_ROOT : process.cwd();
197
+ return applyWorkspaceBranchPolicy({ workspace_root: root, env });
198
+ }
199
+ default: {
200
+ return {
201
+ error: `unknown action ${action} (youtrack_scaffold|youtrack_json|youtrack_token_placeholder|vcs_scaffold)`,
202
+ };
203
+ }
204
+ }
205
+ }
206
+
207
+ export function initApply({
208
+ action,
209
+ confirmed,
210
+ env,
211
+ }: {
212
+ action: string;
213
+ confirmed: boolean;
214
+ env?: Record<string, string>;
215
+ }): Record<string, any> {
216
+ if (!confirmed) return { error: "confirmed: true required" };
217
+ return { data: initApplyData(action, env ? { ...process.env, ...env } : process.env) };
218
+ }
@@ -0,0 +1,333 @@
1
+ // Live-session markers (#274): which agent session last wrote in a checkout,
2
+ // so another session's write gate can refuse to edit a checkout a live agent
3
+ // owns. One marker per checkout (a worktree's top level: worktrees of one
4
+ // repository are distinct checkouts) in the per-user state dir, so nothing is
5
+ // written into a repository Workit does not manage:
6
+ // `<state>/live-sessions/<hash of the checkout>.json`, holding the owner's
7
+ // session, lead, host, process id, machine and heartbeat. The hooks each host
8
+ // already runs write it on every allowed edit or mutating command.
9
+ //
10
+ // A marker is live while its heartbeat is younger than LIVE_SESSION_TTL_MS
11
+ // and, on the same machine, its process still runs. The owning session, its
12
+ // subagents (`<lead>:<agent>`, `<lead>-w-<slice>`, a parent session) and any
13
+ // session in the same host process are never refused. Every state error reads
14
+ // as "no marker": the guard fails open.
15
+ import { spawnSync } from "node:child_process";
16
+ import { createHash } from "node:crypto";
17
+ import fs from "node:fs";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import { resolveStore } from "../store/paths";
21
+ import { resolveStateDir } from "./logger";
22
+ import { processStartOf } from "./store-lock";
23
+
24
+ export const LIVE_SESSION_TTL_MS = 15 * 60_000;
25
+ /** Rewrite a live marker of one's own at most this often. */
26
+ const HEARTBEAT_EVERY_MS = 30_000;
27
+
28
+ export type LiveOwner = {
29
+ host: string;
30
+ session: string;
31
+ /** Lead ids this session counts as (its own lead, its parent's). */
32
+ leads: string[];
33
+ /**
34
+ * The host process (null when it cannot be told), read lazily: off Linux
35
+ * it costs `ps` calls, so only a write or a foreign marker asks for it.
36
+ */
37
+ pid: () => number | null;
38
+ };
39
+
40
+ export type LiveMarker = {
41
+ version: 1;
42
+ session: string;
43
+ leads: string[];
44
+ host: string;
45
+ pid: number | null;
46
+ /** The pid's start time, so a reused pid does not keep the marker alive. */
47
+ pidStart: string | null;
48
+ machine: string;
49
+ checkout: string;
50
+ heartbeatAt: string;
51
+ };
52
+
53
+ export type LiveDeps = {
54
+ now?: Date;
55
+ alive?: (pid: number) => boolean;
56
+ machine?: string;
57
+ ttlMs?: number;
58
+ };
59
+
60
+ /**
61
+ * What reads the process table; tests swap it to count `ps` calls or to act
62
+ * as another platform.
63
+ */
64
+ export const liveProbe = {
65
+ platform: process.platform as string,
66
+ run: (args: string[]): string =>
67
+ spawnSync("ps", args, { encoding: "utf8", timeout: 2_000 }).stdout ?? "",
68
+ start: (pid: number): string | null => processStartOf(pid),
69
+ };
70
+
71
+ /** A session's lead: the part before `:` (an issued subagent id), minus a fanout `-w-<slice>` tail. */
72
+ export const leadOf = (session: string): string =>
73
+ session.split(":")[0].replace(/-w-.*$/su, "") || session;
74
+
75
+ const familyOf = (host: string): string => host.replace(/_(?:cli|desktop)$/u, "");
76
+
77
+ const processAlive = (pid: number): boolean => {
78
+ try {
79
+ process.kill(pid, 0);
80
+ return true;
81
+ } catch (error) {
82
+ return (error as NodeJS.ErrnoException).code === "EPERM";
83
+ }
84
+ };
85
+
86
+ /** Parent pid and command line of `pid`: /proc on Linux, `ps` on other POSIX systems. */
87
+ const processInfo = (pid: number): { ppid: number; command: string } | null => {
88
+ try {
89
+ if (liveProbe.platform === "linux") {
90
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
91
+ const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
92
+ const command = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").replaceAll("\0", " ");
93
+ return Number.isInteger(ppid) ? { ppid, command: command.trim() } : null;
94
+ }
95
+ if (liveProbe.platform === "win32") return null;
96
+ const match = /^\s*(\d+)\s+(.*)$/su.exec(
97
+ liveProbe.run(["-o", "ppid=,command=", "-p", String(pid)]),
98
+ );
99
+ return match ? { ppid: Number(match[1]), command: match[2].trim() } : null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ };
104
+
105
+ /** A process that only launches the hook and exits with it: a shell, npx, the Cursor launcher. */
106
+ const WRAPPER =
107
+ /^(?:\S*\/)?(?:sh|bash|dash|zsh|ash|ksh|fish|env|timeout|npx|npm|bunx|cmd\.exe|pwsh|powershell)(?:\s|$)|launch\.mjs|npx-cli\.js/u;
108
+
109
+ const PIDS = new Map<string, number | null>();
110
+
111
+ /**
112
+ * The long-lived host process behind this hook: the plugin's own process for
113
+ * an in-process host (OpenCode, Pi); for a hook process (Claude Code, Codex,
114
+ * Cursor), the nearest ancestor that is not a launcher wrapper. Null when the
115
+ * ancestry cannot be read (Windows). Cached per process.
116
+ */
117
+ export const hostPid = (transport: string): number | null => {
118
+ if (transport !== "hook-process") return process.pid;
119
+ const key = `${liveProbe.platform}\0${process.ppid}`;
120
+ if (PIDS.has(key)) return PIDS.get(key) ?? null;
121
+ // Windows: no ancestry to walk, so no owner pid at all.
122
+ if (liveProbe.platform === "win32") {
123
+ PIDS.set(key, null);
124
+ return null;
125
+ }
126
+ let pid = process.ppid;
127
+ for (let hop = 0; hop < 4; hop++) {
128
+ const info = processInfo(pid);
129
+ if (!info || !WRAPPER.test(info.command) || info.ppid <= 1) break;
130
+ pid = info.ppid;
131
+ }
132
+ PIDS.set(key, pid);
133
+ return pid;
134
+ };
135
+
136
+ const sessionSafe = (value: string): string => value.replace(/[^A-Za-z0-9_.:@/+-]/gu, "-");
137
+
138
+ /** The owner a hook acts for, or null without a session id (never refused, never recorded). */
139
+ export const liveOwner = (
140
+ input: { host: string; session: { id: string; parentId: string | null } },
141
+ transport: string,
142
+ env: NodeJS.ProcessEnv = process.env,
143
+ ): LiveOwner | null => {
144
+ const session = input.session.id?.trim();
145
+ if (!session) return null;
146
+ const leads = new Set([leadOf(sessionSafe(session))]);
147
+ if (input.session.parentId) leads.add(leadOf(sessionSafe(input.session.parentId)));
148
+ // A Pi worker's hooks see its own Pi session; its workit id names the lead.
149
+ if (env.WORKIT_PI_WORKER_SESSION && env.WORKIT_SESSION_ID)
150
+ leads.add(leadOf(sessionSafe(env.WORKIT_SESSION_ID)));
151
+ return {
152
+ host: input.host,
153
+ session: sessionSafe(session),
154
+ leads: [...leads],
155
+ pid: () => hostPid(transport),
156
+ };
157
+ };
158
+
159
+ /** The checkout (worktree top level) holding `target`; null outside git. */
160
+ const checkoutOf = (target: string): string | null => {
161
+ let dir = path.resolve(target);
162
+ // A path that does not exist yet: its nearest existing ancestor.
163
+ while (!fs.existsSync(dir) && path.dirname(dir) !== dir) dir = path.dirname(dir);
164
+ if (fs.existsSync(dir) && !fs.statSync(dir).isDirectory()) dir = path.dirname(dir);
165
+ // Inside a `.git` directory: the checkout it belongs to.
166
+ const parts = dir.split(path.sep);
167
+ const dotGit = parts.lastIndexOf(".git");
168
+ if (dotGit > 0) dir = parts.slice(0, dotGit).join(path.sep) || path.sep;
169
+ const location = resolveStore(dir);
170
+ if (location instanceof Error || !location.shared || !location.top) return null;
171
+ let top = location.top;
172
+ try {
173
+ top = fs.realpathSync.native(top);
174
+ } catch {
175
+ // keep git's spelling
176
+ }
177
+ return top;
178
+ };
179
+
180
+ /** The marker file of a checkout. */
181
+ export const markerFile = (top: string): string =>
182
+ path.join(
183
+ resolveStateDir(),
184
+ "live-sessions",
185
+ `${createHash("sha256").update(checkoutKey(top)).digest("hex").slice(0, 32)}.json`,
186
+ );
187
+
188
+ /**
189
+ * One spelling per checkout, however the path was written: resolved through
190
+ * symlinks and Windows 8.3 short names (`RUNNER~1`), native separators, and
191
+ * on Windows a lower-cased path (drive letter included).
192
+ */
193
+ export const checkoutKey = (top: string, platform: string = process.platform): string => {
194
+ let key = path.resolve(top);
195
+ try {
196
+ key = fs.realpathSync.native(key);
197
+ } catch {
198
+ // a path that does not exist keeps its resolved spelling
199
+ }
200
+ return platform === "win32" ? key.replaceAll("/", "\\").toLowerCase() : key;
201
+ };
202
+
203
+ const readMarker = (file: string): LiveMarker | null => {
204
+ try {
205
+ const value = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<LiveMarker>;
206
+ if (
207
+ value?.version !== 1 ||
208
+ typeof value.session !== "string" ||
209
+ typeof value.host !== "string" ||
210
+ typeof value.heartbeatAt !== "string" ||
211
+ Number.isNaN(Date.parse(value.heartbeatAt))
212
+ )
213
+ return null;
214
+ return {
215
+ version: 1,
216
+ session: value.session,
217
+ leads: Array.isArray(value.leads)
218
+ ? value.leads.filter((item) => typeof item === "string")
219
+ : [],
220
+ host: value.host,
221
+ pid: typeof value.pid === "number" ? value.pid : null,
222
+ pidStart: typeof value.pidStart === "string" ? value.pidStart : null,
223
+ machine: typeof value.machine === "string" ? value.machine : "",
224
+ checkout: typeof value.checkout === "string" ? value.checkout : "",
225
+ heartbeatAt: value.heartbeatAt,
226
+ };
227
+ } catch {
228
+ return null;
229
+ }
230
+ };
231
+
232
+ const ageOf = (marker: LiveMarker, now: Date): number =>
233
+ now.getTime() - Date.parse(marker.heartbeatAt);
234
+
235
+ /**
236
+ * Whether the marker's owner still counts: a fresh heartbeat and, on this
237
+ * machine, a running process that is still the one recorded (same start time).
238
+ */
239
+ const isLive = (marker: LiveMarker, deps: LiveDeps): boolean => {
240
+ const age = ageOf(marker, deps.now ?? new Date());
241
+ if (age > (deps.ttlMs ?? LIVE_SESSION_TTL_MS)) return false;
242
+ if (marker.machine !== (deps.machine ?? os.hostname()) || marker.pid === null) return true;
243
+ if (!(deps.alive ?? processAlive)(marker.pid)) return false;
244
+ if (marker.pidStart === null) return true;
245
+ const start = liveProbe.start(marker.pid);
246
+ return start === null || start === marker.pidStart;
247
+ };
248
+
249
+ /** The same session, one of its subagents or fanout workers, or its lead: ids alone, no I/O. */
250
+ const sameLead = (marker: LiveMarker, owner: LiveOwner): boolean =>
251
+ marker.session === owner.session ||
252
+ owner.leads.some((lead) => lead === marker.session || marker.leads.includes(lead));
253
+
254
+ /**
255
+ * The same session, one of its subagents or its lead, or another session of
256
+ * the same host process. Where a pid is unknown (Windows), any session of the
257
+ * same host family counts as the owner: the guard cannot tell a subagent or a
258
+ * cleared session from another window there, and never denies on a guess.
259
+ */
260
+ export const sameOwner = (marker: LiveMarker, owner: LiveOwner, machine: string): boolean => {
261
+ if (sameLead(marker, owner)) return true;
262
+ if (marker.machine !== machine) return false;
263
+ const pid = owner.pid();
264
+ // Without a pid and its start time the process cannot be told apart.
265
+ if (pid === null || marker.pid === null || marker.pidStart === null)
266
+ return familyOf(marker.host) === familyOf(owner.host);
267
+ return marker.pid === pid;
268
+ };
269
+
270
+ export type LiveConflict = { marker: LiveMarker; file: string; ageMs: number; checkout: string };
271
+
272
+ /** The live marker of another session in `target`'s checkout, or null (also on any error). */
273
+ export const liveConflict = (
274
+ target: string,
275
+ owner: LiveOwner,
276
+ deps: LiveDeps = {},
277
+ ): LiveConflict | null => {
278
+ try {
279
+ const checkout = checkoutOf(target);
280
+ if (!checkout) return null;
281
+ const file = markerFile(checkout);
282
+ const marker = readMarker(file);
283
+ // Ids first: the owner's own marker never costs a process-table read.
284
+ if (!marker || sameLead(marker, owner) || !isLive(marker, deps)) return null;
285
+ if (sameOwner(marker, owner, deps.machine ?? os.hostname())) return null;
286
+ return { marker, file, ageMs: ageOf(marker, deps.now ?? new Date()), checkout };
287
+ } catch {
288
+ return null;
289
+ }
290
+ };
291
+
292
+ /**
293
+ * Record `owner` as the live session of `target`'s checkout (a fresh
294
+ * heartbeat), unless another live session owns it; one's own marker is
295
+ * rewritten at most every 30 s. Two sessions claiming a free checkout at
296
+ * the same instant race: each writes a temp file and renames it, the last
297
+ * rename wins, and the loser is refused from its next write on. Never throws.
298
+ */
299
+ export const claimLiveSession = (target: string, owner: LiveOwner, deps: LiveDeps = {}): void => {
300
+ try {
301
+ const checkout = checkoutOf(target);
302
+ if (!checkout) return;
303
+ const file = markerFile(checkout);
304
+ const now = deps.now ?? new Date();
305
+ const machine = deps.machine ?? os.hostname();
306
+ const current = readMarker(file);
307
+ if (current) {
308
+ const age = ageOf(current, now);
309
+ // One's own fresh marker: nothing to read or write.
310
+ if (sameLead(current, owner) && age >= 0 && age < HEARTBEAT_EVERY_MS) return;
311
+ if (!sameLead(current, owner) && isLive(current, deps) && !sameOwner(current, owner, machine))
312
+ return;
313
+ }
314
+ const pid = owner.pid();
315
+ const marker: LiveMarker = {
316
+ version: 1,
317
+ session: owner.session,
318
+ leads: owner.leads,
319
+ host: owner.host,
320
+ pid,
321
+ pidStart: pid === null ? null : liveProbe.start(pid),
322
+ machine,
323
+ checkout,
324
+ heartbeatAt: now.toISOString(),
325
+ };
326
+ fs.mkdirSync(path.dirname(file), { recursive: true });
327
+ const temp = `${file}.${process.pid}.tmp`;
328
+ fs.writeFileSync(temp, JSON.stringify(marker));
329
+ fs.renameSync(temp, file);
330
+ } catch {
331
+ // Fail open: without a marker nobody is refused.
332
+ }
333
+ };