@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,73 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ const run = (cwd: string, args: string[]): { stdout: string; stderr: string; exitCode: number } => {
4
+ try {
5
+ const stdout = execFileSync("git", args, {
6
+ cwd,
7
+ encoding: "utf8",
8
+ // AR-14: expected negative fixtures (non-repo dirs, unborn HEADs, bogus
9
+ // shas) must not inherit raw git usage/fatal stderr into the suite output.
10
+ stdio: ["pipe", "pipe", "pipe"],
11
+ });
12
+ return { stdout: stdout.trimEnd(), stderr: "", exitCode: 0 };
13
+ } catch (error) {
14
+ const e = error as { stdout?: string; stderr?: string; status?: number };
15
+ return {
16
+ stdout: (e.stdout ?? "").trimEnd(),
17
+ stderr: (e.stderr ?? "").trimEnd(),
18
+ exitCode: e.status ?? 1,
19
+ };
20
+ }
21
+ };
22
+
23
+ export const gitContext = (workspaceRoot: string, paths: string[] = []) => {
24
+ const cwd = workspaceRoot;
25
+ let failure: { stderr: string; exitCode: number } | undefined;
26
+
27
+ const runHere = (args: string[]) => {
28
+ const result = run(cwd, args);
29
+ if (!failure && result.exitCode !== 0) {
30
+ failure = { stderr: result.stderr, exitCode: result.exitCode };
31
+ }
32
+ return result.stdout;
33
+ };
34
+
35
+ const branch = runHere(["rev-parse", "--abbrev-ref", "HEAD"]) || "unknown";
36
+ const status_short = runHere(["status", "--porcelain"]);
37
+
38
+ const staged: string[] = [];
39
+ const unstaged: string[] = [];
40
+ const untracked: string[] = [];
41
+
42
+ for (const line of status_short.split("\n").filter(Boolean)) {
43
+ const code = line.slice(0, 2);
44
+ const file = line.slice(3);
45
+ if (code === "??") {
46
+ untracked.push(file);
47
+ } else {
48
+ if (code[0] !== " " && code[0] !== "?") staged.push(file);
49
+ if (code[1] !== " " && code[1] !== "?") unstaged.push(file);
50
+ }
51
+ }
52
+
53
+ const pathArgs = paths.length ? ["--", ...paths] : [];
54
+ const diff_stat = runHere(["diff", "--stat", ...pathArgs]);
55
+ const cached_stat = runHere(["diff", "--cached", "--stat", ...pathArgs]);
56
+
57
+ const stagedSet = new Set(staged);
58
+ const unstagedSet = new Set(unstaged);
59
+ const partial_staged = [...stagedSet].filter((f) => unstagedSet.has(f));
60
+
61
+ return {
62
+ workspace_root: cwd,
63
+ branch,
64
+ status_short,
65
+ staged,
66
+ unstaged,
67
+ untracked,
68
+ diff_stat: [diff_stat, cached_stat].filter(Boolean).join("\n"),
69
+ partial_staged: partial_staged.length > 0,
70
+ partial_staged_files: partial_staged,
71
+ ...(failure ? { stderr: failure.stderr, exitCode: failure.exitCode } : {}),
72
+ };
73
+ };
@@ -0,0 +1,52 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export const GITIGNORE_ENTRIES = [
5
+ "# workit: SDD working state (never commit)",
6
+ "docs/*/sdd/",
7
+ "",
8
+ "# OS / editor cruft",
9
+ ".DS_Store",
10
+ "Thumbs.db",
11
+ "*.swp",
12
+ ".idea/",
13
+ ".vscode/",
14
+ ".env",
15
+ "node_modules/",
16
+ "dist/",
17
+ "*.log",
18
+ ".cache/",
19
+ ];
20
+
21
+ export const ensureProjectGitignore = (
22
+ workspaceRoot: string,
23
+ confirmed: boolean,
24
+ ): { ok: true; path: string; added: string[] } | { ok: false; error: string } => {
25
+ if (!confirmed) return { ok: false, error: "confirmed: true required" };
26
+ const file = path.join(workspaceRoot, ".gitignore");
27
+ const existing = existsSync(file) ? readFileSync(file, "utf8") : "";
28
+ const existingLines = new Set(
29
+ existing
30
+ .split("\n")
31
+ .map((l) => l.trim())
32
+ .filter(Boolean),
33
+ );
34
+ const added: string[] = [];
35
+ const append: string[] = [];
36
+ for (const entry of GITIGNORE_ENTRIES) {
37
+ if (entry.trim() === "" || existingLines.has(entry.trim())) continue;
38
+ append.push(entry);
39
+ added.push(entry);
40
+ }
41
+ if (append.length) {
42
+ const separator = existing && !existing.endsWith("\n") ? "\n" : "";
43
+ writeFileSync(
44
+ file,
45
+ existing + separator + (existing ? "\n" : "") + append.join("\n") + "\n",
46
+ "utf8",
47
+ );
48
+ } else if (!existsSync(file)) {
49
+ writeFileSync(file, "", "utf8");
50
+ }
51
+ return { ok: true, path: file, added };
52
+ };
@@ -0,0 +1,170 @@
1
+ // Wait estimates for the heavy-check lease (#326): how long until a waiter
2
+ // gets a slot, from each check's last measured duration in the ledger of the
3
+ // repo it runs in. Only the CLI imports this module; hooks never do.
4
+ import fs from "node:fs";
5
+ import { ledgerPath } from "../ledger";
6
+ import { DEFAULT_MAX_HOLD_MS, formatWait, type LeaseHolder } from "./resources";
7
+
8
+ /** The last measured run of a holder's or waiter's check, in ms, or null when there is none. */
9
+ export type DurationOf = (holder: Pick<LeaseHolder, "check" | "cwd">) => number | null;
10
+
11
+ /**
12
+ * How much of the ledger's end measuredDurations reads: the last runs are what
13
+ * an estimate wants, and a long-lived ledger must not be read whole on every
14
+ * waiter's status line.
15
+ */
16
+ export const ETA_TAIL_BYTES = 512 * 1024;
17
+
18
+ /** The complete lines in the last `bytes` of `file` (a cut first line is dropped). */
19
+ function tailRows(file: string, bytes: number): string[] {
20
+ let fd: number;
21
+ try {
22
+ fd = fs.openSync(file, "r");
23
+ } catch {
24
+ return [];
25
+ }
26
+ try {
27
+ const size = fs.fstatSync(fd).size;
28
+ const span = Math.min(size, Math.max(1, bytes));
29
+ const buffer = Buffer.alloc(span);
30
+ fs.readSync(fd, buffer, 0, span, size - span);
31
+ const lines = buffer.toString("utf8").split("\n");
32
+ if (span < size) lines.shift();
33
+ return lines;
34
+ } finally {
35
+ fs.closeSync(fd);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Each check's last measured duration per label (the check's name, or its
41
+ * argv for an ad-hoc run), from the last `tailBytes` of the ledger at `cwd`.
42
+ * A passed run wins over a later failed one, which may have stopped early; a
43
+ * timed-out run never counts.
44
+ */
45
+ export function measuredDurations(cwd: string, tailBytes = ETA_TAIL_BYTES): Map<string, number> {
46
+ const passed = new Map<string, number>();
47
+ const any = new Map<string, number>();
48
+ const file = ledgerPath(cwd);
49
+ if (!file.ok) return passed;
50
+ for (const line of tailRows(file.value, tailBytes)) {
51
+ if (!line.trim()) continue;
52
+ let row: Record<string, unknown>;
53
+ try {
54
+ row = JSON.parse(line) as Record<string, unknown>;
55
+ } catch {
56
+ continue;
57
+ }
58
+ if (!row || typeof row !== "object" || row.type !== "check" || row.timedOut === true) continue;
59
+ const ms = row.durationMs;
60
+ if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) continue;
61
+ const label =
62
+ typeof row.name === "string"
63
+ ? row.name
64
+ : Array.isArray(row.argv)
65
+ ? row.argv.map(String).join(" ")
66
+ : null;
67
+ if (!label) continue;
68
+ any.set(label, ms);
69
+ if (row.result === "passed") passed.set(label, ms);
70
+ }
71
+ for (const [label, ms] of any) if (!passed.has(label)) passed.set(label, ms);
72
+ return passed;
73
+ }
74
+
75
+ /** A holder may run this many times its last measured run before it looks stuck. */
76
+ export const HOLD_MARGIN = 1.5;
77
+
78
+ /** The most a measured run can raise the hold limit to. */
79
+ export const MAX_DERIVED_HOLD_MS = 6 * 60 * 60_000;
80
+
81
+ /**
82
+ * How long a check may hold its slot before it is shown `stuck?`: HOLD_MARGIN
83
+ * times its last measured run, never below the configured limit (default
84
+ * DEFAULT_MAX_HOLD_MS), which alone applies when it was never measured. The
85
+ * lease carries it, so auto-reap's factor applies to it too.
86
+ */
87
+ export function holdLimit(measuredMs: number | null, configuredMs = DEFAULT_MAX_HOLD_MS): number {
88
+ if (measuredMs === null || !Number.isFinite(measuredMs) || measuredMs <= 0) return configuredMs;
89
+ // A corrupt or freak ledger row must not make a hung check unreapable.
90
+ return Math.max(
91
+ configuredMs,
92
+ Math.min(MAX_DERIVED_HOLD_MS, Math.round(measuredMs * HOLD_MARGIN)),
93
+ );
94
+ }
95
+
96
+ /** A DurationOf that reads each repo's ledger once. */
97
+ export function ledgerDurations(read: (cwd: string) => Map<string, number> = measuredDurations) {
98
+ const byRepo = new Map<string, Map<string, number>>();
99
+ const durationOf: DurationOf = ({ check, cwd }) => {
100
+ let known = byRepo.get(cwd);
101
+ if (!known) {
102
+ try {
103
+ known = read(cwd);
104
+ } catch {
105
+ known = new Map();
106
+ }
107
+ byRepo.set(cwd, known);
108
+ }
109
+ return known.get(check) ?? null;
110
+ };
111
+ return durationOf;
112
+ }
113
+
114
+ export type WaitEstimate =
115
+ | { ms: number }
116
+ /** Checks with no measured run, so no estimate. */
117
+ | { ms: null; unmeasured: string[] };
118
+
119
+ /**
120
+ * When a waiter behind `ahead` (in serving order) gets one of `slots`: each
121
+ * holder frees its slot when its last measured run would end (at once when it
122
+ * is already past that), and each waiter ahead takes the slot that frees
123
+ * first for its own measured run.
124
+ */
125
+ export function estimateWait(input: {
126
+ slots: number;
127
+ holders: LeaseHolder[];
128
+ ahead: LeaseHolder[];
129
+ durationOf: DurationOf;
130
+ now?: number;
131
+ }): WaitEstimate {
132
+ const now = input.now ?? Date.now();
133
+ const unmeasured = new Set<string>();
134
+ const measure = (holder: LeaseHolder): number => {
135
+ const ms = input.durationOf(holder);
136
+ if (ms === null) unmeasured.add(holder.check);
137
+ return ms ?? 0;
138
+ };
139
+ const free = input.holders.map((holder) =>
140
+ Math.max(0, measure(holder) - (now - Date.parse(holder.startedAt))),
141
+ );
142
+ while (free.length < Math.max(1, input.slots)) free.push(0);
143
+ for (const waiter of input.ahead) {
144
+ free.sort((a, b) => a - b);
145
+ free[0] += measure(waiter);
146
+ }
147
+ if (unmeasured.size) return { ms: null, unmeasured: [...unmeasured] };
148
+ return { ms: Math.min(...free) };
149
+ }
150
+
151
+ /** "estimated wait ~9m (last measured runs)" or "no wait estimate yet (no measured run of test)". */
152
+ export function describeEstimate(estimate: WaitEstimate): string {
153
+ if (estimate.ms === null)
154
+ return `no wait estimate yet (no measured run of ${estimate.unmeasured.join(", ")})`;
155
+ return estimate.ms === 0
156
+ ? "estimated wait: any moment (the holders are past their last measured runs)"
157
+ : `estimated wait ~${formatWait(estimate.ms)} (last measured runs)`;
158
+ }
159
+
160
+ /** "~3m left", "past its last 14m run", or null when the check was never measured. */
161
+ export function remainingNote(
162
+ holder: LeaseHolder,
163
+ durationOf: DurationOf,
164
+ now = Date.now(),
165
+ ): string | null {
166
+ const ms = durationOf(holder);
167
+ if (ms === null) return null;
168
+ const left = ms - (now - Date.parse(holder.startedAt));
169
+ return left > 0 ? `~${formatWait(left)} left` : `past its last ${formatWait(ms)} run`;
170
+ }
@@ -0,0 +1,290 @@
1
+ // Heavy-check lease hygiene (issue #608), over the lease in resources.ts:
2
+ // prune what no longer holds or waits, reap a stuck holder, cancel queued
3
+ // waiters, and keep a holder's heartbeat while its check prints or uses CPU.
4
+ // Only the CLI imports this module; hooks never do.
5
+ import { spawnSync } from "node:child_process";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import {
9
+ cancelMarker,
10
+ leaseQueue,
11
+ leaseSlots,
12
+ parseLease,
13
+ processStart,
14
+ queueDir,
15
+ reclaim,
16
+ staleReason,
17
+ stuckReason,
18
+ ticketStale,
19
+ updateLease,
20
+ type HeldLease,
21
+ type LeaseHolder,
22
+ type LeaseProbe,
23
+ type LeaseRecord,
24
+ } from "./resources";
25
+
26
+ const readLease = (file: string): LeaseRecord | null => {
27
+ try {
28
+ return parseLease(fs.readFileSync(file, "utf8"));
29
+ } catch {
30
+ return null;
31
+ }
32
+ };
33
+
34
+ const slotFile = (dir: string, slot: number): string => path.join(dir, `slot-${slot}.json`);
35
+
36
+ /** A cancel marker whose ticket is gone is debris after this. */
37
+ const MARKER_TTL_MS = 10 * 60_000;
38
+
39
+ export type Pruned = LeaseHolder & { reason: string; kind: "slot" | "waiter" };
40
+
41
+ /**
42
+ * Free every slot whose holder is gone and drop every queued waiter whose
43
+ * process or owner is gone, each under the reclaim guard; clear old cancel
44
+ * markers. What `workit lock status` runs before it reads.
45
+ */
46
+ export function pruneLease(dir: string, slots: number, probe: LeaseProbe = {}): Pruned[] {
47
+ const pruned: Pruned[] = [];
48
+ for (let slot = 0; slot < slots; slot += 1) {
49
+ const file = slotFile(dir, slot);
50
+ const lease = readLease(file);
51
+ if (!lease) continue;
52
+ let reason: string | null = null;
53
+ const removed = reclaim(file, (current) => {
54
+ reason = current ? staleReason(current, probe) : null;
55
+ return reason !== null;
56
+ });
57
+ if (removed && reason) pruned.push({ ...holderFields(lease), reason, kind: "slot" });
58
+ }
59
+ for (const entry of leaseQueue(dir, probe)) {
60
+ if (!entry.stale) continue;
61
+ const stale = entry.stale;
62
+ if (reclaim(entry.file, (current) => !current || ticketStale(current, probe) !== null))
63
+ pruned.push({ ...entry.holder, reason: stale, kind: "waiter" });
64
+ }
65
+ try {
66
+ const queue = queueDir(dir);
67
+ for (const name of fs.readdirSync(queue)) {
68
+ if (!name.endsWith(".cancel")) continue;
69
+ const marker = path.join(queue, name);
70
+ if (
71
+ !fs.existsSync(marker.slice(0, -".cancel".length)) &&
72
+ (probe.now ?? Date.now()) - fs.statSync(marker).mtimeMs > MARKER_TTL_MS
73
+ )
74
+ fs.rmSync(marker, { force: true });
75
+ }
76
+ } catch {
77
+ // No queue yet.
78
+ }
79
+ return pruned;
80
+ }
81
+
82
+ const holderFields = (lease: LeaseRecord): LeaseHolder => ({
83
+ pid: lease.pid,
84
+ check: lease.check,
85
+ cwd: lease.cwd,
86
+ session: lease.session,
87
+ startedAt: lease.startedAt,
88
+ });
89
+
90
+ /** A `workit lock reap` or `cancel` target: a pid, a session id (or its prefix) or a directory. */
91
+ export type LeaseTarget = { pid: number } | { session: string } | { dir: string };
92
+
93
+ export function parseTarget(text: string, cwd: string): LeaseTarget {
94
+ if (/^\d+$/u.test(text)) return { pid: Number(text) };
95
+ if (text.includes("/") || text.includes(path.sep) || text === "." || text === "..")
96
+ return { dir: path.resolve(cwd, text) };
97
+ return { session: text.replace(/…$/u, "") };
98
+ }
99
+
100
+ const within = (dir: string, cwd: string): boolean => {
101
+ const relative = path.relative(dir, path.resolve(cwd));
102
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
103
+ };
104
+
105
+ export function matchesTarget(holder: LeaseHolder, target: LeaseTarget): boolean {
106
+ if ("pid" in target) return holder.pid === target.pid;
107
+ if ("session" in target)
108
+ return (
109
+ holder.session !== null &&
110
+ (holder.session === target.session ||
111
+ (target.session.length >= 8 && holder.session.startsWith(target.session)))
112
+ );
113
+ return within(target.dir, holder.cwd);
114
+ }
115
+
116
+ export type Reaped = LeaseHolder & { slot: number; reason: string };
117
+
118
+ /**
119
+ * Free every slot whose live holder is stuck (stuckReason), or only those
120
+ * matching `target`. The holder's process is not signalled: it keeps running
121
+ * outside the lease, and its own release never touches the slot again.
122
+ */
123
+ export function reapStuck(
124
+ dir: string,
125
+ slots: number,
126
+ maxHoldMs: number,
127
+ options: { target?: LeaseTarget | null; probe?: LeaseProbe } = {},
128
+ ): Reaped[] {
129
+ const reaped: Reaped[] = [];
130
+ for (const state of leaseSlots(dir, slots, options.probe, maxHoldMs)) {
131
+ if (!state.holder || !state.stuck || state.stale) continue;
132
+ if (options.target && !matchesTarget(state.holder, options.target)) continue;
133
+ const file = slotFile(dir, state.slot);
134
+ let reason: string | null = null;
135
+ const removed = reclaim(file, (current) => {
136
+ reason =
137
+ current && !staleReason(current, options.probe)
138
+ ? stuckReason(current, maxHoldMs, options.probe?.now)
139
+ : null;
140
+ return reason !== null;
141
+ });
142
+ if (removed && reason)
143
+ reaped.push({ ...state.holder, stuck: reason, slot: state.slot, reason });
144
+ }
145
+ return reaped;
146
+ }
147
+
148
+ export type Cancelled = LeaseHolder & { how: "marker" | "signal" | "gone" };
149
+
150
+ /**
151
+ * Drop the queued waiters matching `target`. A waiter that reads cancel
152
+ * markers gets one and its ticket is removed at once; an older waiter gets
153
+ * SIGTERM, which makes it leave the queue itself.
154
+ */
155
+ export function cancelWaiters(
156
+ dir: string,
157
+ target: LeaseTarget,
158
+ by: string,
159
+ probe: LeaseProbe & { signal?: (pid: number) => boolean } = {},
160
+ ): Cancelled[] {
161
+ const cancelled: Cancelled[] = [];
162
+ for (const entry of leaseQueue(dir, probe)) {
163
+ if (!matchesTarget(entry.holder, target)) continue;
164
+ const record = entry.record;
165
+ if (entry.stale || !record) {
166
+ if (reclaim(entry.file, (current) => !current || ticketStale(current, probe) !== null))
167
+ cancelled.push({ ...entry.holder, how: "gone" });
168
+ continue;
169
+ }
170
+ if (record.cancellable) {
171
+ try {
172
+ fs.writeFileSync(
173
+ cancelMarker(entry.file),
174
+ `${JSON.stringify({ by, at: new Date().toISOString() })}\n`,
175
+ { mode: 0o600 },
176
+ );
177
+ } catch {
178
+ continue;
179
+ }
180
+ if (readLease(entry.file)?.id === record.id) fs.rmSync(entry.file, { force: true });
181
+ cancelled.push({ ...entry.holder, how: "marker" });
182
+ continue;
183
+ }
184
+ const start = record.processStart ? processStart(record.pid) : null;
185
+ if (record.processStart && start !== record.processStart) continue;
186
+ if ((probe.signal ?? terminate)(record.pid)) cancelled.push({ ...entry.holder, how: "signal" });
187
+ }
188
+ return cancelled;
189
+ }
190
+
191
+ const terminate = (pid: number): boolean => {
192
+ try {
193
+ process.kill(pid, "SIGTERM");
194
+ return true;
195
+ } catch {
196
+ return false;
197
+ }
198
+ };
199
+
200
+ /** `[[dd-]hh:]mm:ss[.cc]` from `ps` as hundredths of a second, or null. */
201
+ function parseCpuTime(text: string): number | null {
202
+ const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/u.exec(text);
203
+ if (!match) return null;
204
+ const [, days, hours, minutes, seconds] = match;
205
+ const total =
206
+ (Number(days ?? 0) * 24 + Number(hours ?? 0)) * 3600 + Number(minutes) * 60 + Number(seconds);
207
+ return Math.round(total * 100);
208
+ }
209
+
210
+ /** CPU hundredths of a second used so far by process group `pgid` (macOS, via `ps`), or null. */
211
+ export function psGroupCpu(pgid: number, run: typeof spawnSync = spawnSync): number | null {
212
+ const result = run("ps", ["-axo", "pgid=,cputime="], { encoding: "utf8", timeout: 5000 });
213
+ if (result.error || result.status !== 0 || typeof result.stdout !== "string") return null;
214
+ let total = 0;
215
+ let seen = false;
216
+ for (const line of result.stdout.split("\n")) {
217
+ const [group, cputime] = line.trim().split(/\s+/u);
218
+ if (Number(group) !== pgid || cputime === undefined) continue;
219
+ const ticks = parseCpuTime(cputime);
220
+ if (ticks === null) continue;
221
+ seen = true;
222
+ total += ticks;
223
+ }
224
+ return seen ? total : null;
225
+ }
226
+
227
+ /** CPU ticks used so far by the processes of group `pgid` (Linux, macOS), or null. */
228
+ export function groupCpuTicks(pgid: number): number | null {
229
+ if (!(pgid > 0)) return null;
230
+ if (process.platform === "darwin") return psGroupCpu(pgid);
231
+ if (process.platform !== "linux") return null;
232
+ let names: string[];
233
+ try {
234
+ names = fs.readdirSync("/proc");
235
+ } catch {
236
+ return null;
237
+ }
238
+ let total = 0;
239
+ let seen = false;
240
+ for (const name of names) {
241
+ if (!/^\d+$/u.test(name)) continue;
242
+ let stat: string;
243
+ try {
244
+ stat = fs.readFileSync(`/proc/${name}/stat`, "utf8");
245
+ } catch {
246
+ continue;
247
+ }
248
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
249
+ if (Number(fields[2]) !== pgid) continue;
250
+ seen = true;
251
+ // utime, stime, cutime, cstime: fields 14-17 of /proc/<pid>/stat.
252
+ total += [11, 12, 13, 14].reduce((sum, at) => sum + (Number(fields[at]) || 0), 0);
253
+ }
254
+ return seen ? total : null;
255
+ }
256
+
257
+ /** Heartbeats are written at most this often. */
258
+ export const HEARTBEAT_EVERY_MS = 30_000;
259
+
260
+ export type Heartbeat = { touch: () => void; stop: () => void };
261
+
262
+ /**
263
+ * Refresh the held lease's `heartbeatAt` every `everyMs` while its check
264
+ * printed since the last beat (`touch`) or its process group used CPU.
265
+ */
266
+ export function startHeartbeat(
267
+ lease: HeldLease,
268
+ options: { everyMs?: number; cpu?: () => number | null; now?: () => number } = {},
269
+ ): Heartbeat {
270
+ let active = false;
271
+ let lastCpu: number | null = null;
272
+ const cpu = options.cpu ?? (() => (lease.record.pgid ? groupCpuTicks(lease.record.pgid) : null));
273
+ const timer = setInterval(() => {
274
+ const ticks = cpu();
275
+ const moved = ticks !== null && lastCpu !== null && ticks > lastCpu;
276
+ if (ticks !== null) lastCpu = ticks;
277
+ if (!active && !moved) return;
278
+ active = false;
279
+ updateLease(lease, {
280
+ heartbeatAt: new Date(options.now?.() ?? Date.now()).toISOString(),
281
+ });
282
+ }, options.everyMs ?? HEARTBEAT_EVERY_MS);
283
+ timer.unref?.();
284
+ return {
285
+ touch: () => {
286
+ active = true;
287
+ },
288
+ stop: () => clearInterval(timer),
289
+ };
290
+ }