@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,1174 @@
1
+ // The before-write gate (S17). With no writer lease, a branch task's unmet
2
+ // before-write requirements (an open product choice, a missing plan) deny
3
+ // working-tree edits where the host has a pre-write hook, naming the exact
4
+ // unblock. History moves (commit, merge, rebase, stash pop) are not edits.
5
+ // Reads only the task index, one task snapshot and the ledger; never
6
+ // migrates, never captures a candidate, and fails open on any state error.
7
+ import { existsSync, realpathSync } from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { fileSignature, TaskStore } from "../core/task-store";
11
+ import { latestJudgment, writeBlockers, type WriteBlocker } from "../core/policy/derive";
12
+ import { applicableDecision } from "../core/task-evaluation";
13
+ import {
14
+ claimLiveSession,
15
+ liveConflict,
16
+ type LiveConflict,
17
+ type LiveDeps,
18
+ type LiveOwner,
19
+ } from "../core/live-session";
20
+ import { resolveStateDir } from "../core/logger";
21
+ import { workspaceLiveSessionGuard } from "../core/workspaces";
22
+ import { appendHookObserved, ledgerPath } from "../ledger";
23
+ import type { HookDecision } from "./protocol";
24
+ import {
25
+ inWorkitWorkspace,
26
+ rawInvocations,
27
+ workitCheckTail,
28
+ skipWorkitGlobals,
29
+ workitInvocations,
30
+ type RawInvocation,
31
+ } from "./raw-git";
32
+ import { segmentsOf, type Segment, type ShellDialect } from "./shell-words";
33
+
34
+ const NONE: HookDecision = { kind: "none" };
35
+
36
+ const CASE_INSENSITIVE = process.platform === "darwin" || process.platform === "win32";
37
+
38
+ /**
39
+ * The real path of `file`: the nearest existing ancestor resolved through
40
+ * symlinks (macOS /var → /private/var), then the not-yet-existing rest.
41
+ */
42
+ const canonical = (file: string): string => {
43
+ let rest = "";
44
+ for (let dir = path.resolve(file); ; dir = path.dirname(dir)) {
45
+ try {
46
+ // Native resolution also expands Windows 8.3 short names (RUNNER~1).
47
+ return path.join(realpathSync.native(dir), rest);
48
+ } catch {
49
+ if (path.dirname(dir) === dir) return path.resolve(file);
50
+ rest = rest ? path.join(path.basename(dir), rest) : path.basename(dir);
51
+ }
52
+ }
53
+ };
54
+
55
+ /**
56
+ * Writes the gate never blocks: files outside the checkout (e.g. /tmp),
57
+ * Markdown (*.md, *.mdx), the top-level `docs/` tree, any `plans/` directory,
58
+ * and the plan files the task's judgment cites.
59
+ */
60
+ const exempt = (cwd: string, root: string, file: string, plans: ReadonlySet<string>): boolean => {
61
+ const fold = (value: string) => (CASE_INSENSITIVE ? value.toLowerCase() : value);
62
+ const relative = path.relative(fold(canonical(root)), fold(canonical(path.resolve(cwd, file))));
63
+ if (relative.startsWith("..") || path.isAbsolute(relative)) return true;
64
+ const posix = relative.split(path.sep).join("/");
65
+ const segments = posix.split("/");
66
+ return (
67
+ /\.mdx?$/i.test(posix) ||
68
+ segments[0] === "docs" ||
69
+ segments.includes("plans") ||
70
+ [...plans].some((plan) => fold(plan) === posix)
71
+ );
72
+ };
73
+
74
+ const PREFIXES = new Set(["sudo", "command", "env", "nohup", "time", "exec", "xargs", "busybox"]);
75
+ const operands = (words: string[]) => words.filter((item) => !item.startsWith("-"));
76
+
77
+ const SCRIPT_SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
78
+
79
+ /**
80
+ * The script `workit check … -- <cmd>` runs, so the gates judge it as if it
81
+ * ran raw: the `--shell` string, a `bash -c` script, or the argv. Other
82
+ * workit verbs stay exempt (null).
83
+ */
84
+ const wrappedScript = (input: string[]): string | null => {
85
+ let words = input;
86
+ while (words.length && (PREFIXES.has(words[0]) || /^\w+=/.test(words[0]))) words = words.slice(1);
87
+ const tail = workitCheckTail(words);
88
+ if (!tail) return null;
89
+ if ("script" in tail) return tail.script;
90
+ const script = shellScript(tail.argv);
91
+ if (SCRIPT_SHELLS.has((tail.argv[0]?.split(/[\\/]/).at(-1) ?? "").toLowerCase()) && script)
92
+ return script;
93
+ return tail.argv.map(quoteWord).join(" ");
94
+ };
95
+
96
+ /** The files one simple command edits: [] none, null unknown. */
97
+ const editsOf = (input: string[]): string[] | null => {
98
+ let words = input;
99
+ while (words.length && (PREFIXES.has(words[0]) || /^\w+=/.test(words[0]))) words = words.slice(1);
100
+ const [verb, ...args] = words;
101
+ const name = (verb?.split(/[\\/]/).at(-1) ?? "").toLowerCase();
102
+ if (!name || name === "workit" || (name === "npx" && args.some((item) => /workit/.test(item))))
103
+ return [];
104
+ switch (name) {
105
+ case "touch":
106
+ case "mkdir":
107
+ case "rm":
108
+ case "rmdir":
109
+ case "tee":
110
+ case "mv":
111
+ case "truncate":
112
+ return operands(args);
113
+ case "cp":
114
+ case "ln":
115
+ case "install": {
116
+ const target = args.indexOf("-t");
117
+ if (target >= 0) return args[target + 1] ? [args[target + 1]] : null;
118
+ const last = operands(args).at(-1);
119
+ return last ? [last] : null;
120
+ }
121
+ case "dd":
122
+ return args.filter((item) => item.startsWith("of=")).map((item) => item.slice(3));
123
+ case "sed":
124
+ case "perl": {
125
+ if (!args.some((item) => /^-[a-zA-Z]*i|^--in-place/.test(item))) return [];
126
+ const scripted = args.some((item) => item === "-e" || item === "-f");
127
+ const files: string[] = [];
128
+ for (let index = 0; index < args.length; index++) {
129
+ if (args[index] === "-e" || args[index] === "-f") index++;
130
+ else if (!args[index].startsWith("-")) files.push(args[index]);
131
+ }
132
+ return scripted ? files : files.slice(1);
133
+ }
134
+ case "patch":
135
+ return null;
136
+ // PowerShell (Claude Code's PowerShell tool): the path parameter, else the first operand.
137
+ case "set-content":
138
+ case "add-content":
139
+ case "out-file":
140
+ case "new-item":
141
+ case "ni":
142
+ case "remove-item":
143
+ case "del":
144
+ case "copy-item":
145
+ case "move-item": {
146
+ const flag = args.findIndex((item) =>
147
+ /^-(?:path|filepath|literalpath|destination)$/i.test(item),
148
+ );
149
+ if (flag >= 0) return args[flag + 1] ? [args[flag + 1]] : null;
150
+ const files = operands(args);
151
+ if (name === "move-item") return files.length ? files : null;
152
+ const file = name === "copy-item" ? files.at(-1) : files[0];
153
+ return file ? [file] : null;
154
+ }
155
+ case "git": {
156
+ const sub = operands(args)[0];
157
+ if (sub === "apply") return null;
158
+ if (sub === "restore" || sub === "mv" || sub === "rm") return operands(args).slice(1);
159
+ if (sub === "checkout" && args.includes("--")) return args.slice(args.indexOf("--") + 1);
160
+ // commit, merge, rebase, stash, cherry-pick… move history, not edits.
161
+ return [];
162
+ }
163
+ default:
164
+ return [];
165
+ }
166
+ };
167
+
168
+ /** A `bash -c '<script>'` (any SCRIPT_SHELLS) script, else null. */
169
+ const shellScript = (words: readonly string[]): string | null => {
170
+ if (!SCRIPT_SHELLS.has((words[0]?.split(/[\\/]/).at(-1) ?? "").toLowerCase())) return null;
171
+ // Options may come before `-c` (`bash --norc -c`, `sh -e -c`); the script follows it.
172
+ for (let index = 1; index < words.length; index++) {
173
+ const word = words[index];
174
+ if (/^-[a-z]*c$/.test(word)) return words[index + 1] ?? null;
175
+ if (/^[-+][oO]$/.test(word)) index++;
176
+ else if (!/^[-+]/.test(word)) return null;
177
+ }
178
+ return null;
179
+ };
180
+
181
+ /** xargs options that take the next word as their value. */
182
+ const XARGS_VALUE = /^-[IinLPdsEa]$/;
183
+
184
+ /** The command `xargs` (or `find -exec`) runs with `{}` filled in by a path. */
185
+ const runsWriter = (words: readonly string[]): boolean => {
186
+ const filled = [...words.filter((word) => word !== "{}" && word !== ";" && word !== "+"), "x"];
187
+ const edits = editsOf(filled);
188
+ return edits === null || edits.length > 0 || opaqueWrites(filled) !== null;
189
+ };
190
+
191
+ /**
192
+ * For an implementer's confinement: commands that may write where the edit
193
+ * analysis cannot see (interpreters, chmod, rsync, `find -delete`, `xargs rm`,
194
+ * `awk -i`, a `-c` shell script): the paths they may change, "." standing for
195
+ * the directory they run in; null when the command is none of them.
196
+ */
197
+ const opaqueWrites = (
198
+ input: readonly string[],
199
+ scriptOf: (index: number) => string | undefined = (index) => input[index],
200
+ ): string[] | null => {
201
+ let words = input;
202
+ while (words.length && (PREFIXES.has(words[0]) || /^\w+=/.test(words[0]))) {
203
+ if (words[0] === "xargs") {
204
+ let at = 1;
205
+ while (words[at]?.startsWith("-")) at += XARGS_VALUE.test(words[at]) ? 2 : 1;
206
+ return runsWriter(words.slice(at)) ? ["."] : null;
207
+ }
208
+ words = words.slice(1);
209
+ }
210
+ const [verb, ...args] = words;
211
+ const name = (verb?.split(/[\\/]/).at(-1) ?? "").toLowerCase();
212
+ const files = operands(args);
213
+ const has = (pattern: RegExp) => args.some((arg) => pattern.test(arg));
214
+ const listed = (named: string[]) => (named.length ? named : ["."]);
215
+ if (name === "unlink" || name === "shred") return listed(files);
216
+ // The mode or owner comes first, unless `--reference` names it.
217
+ if (/^(?:chmod|chown|chgrp|chattr)$/.test(name))
218
+ return listed(has(/^--reference/) ? files : files.slice(1));
219
+ if (name === "rsync") {
220
+ const dest = files.at(-1);
221
+ // `host:path` is remote; a drive letter (`C:/…`, `C:\…`) is a local path.
222
+ const remote = /^[\w.@-]*:/.test(dest ?? "") && !/^[A-Za-z]:[\\/]/.test(dest ?? "");
223
+ return dest === undefined ? ["."] : remote ? [] : [dest];
224
+ }
225
+ if (name === "find") {
226
+ const exec = args.findIndex((arg) => /^-(?:exec|execdir|ok|okdir)$/.test(arg));
227
+ const writes =
228
+ has(/^-(?:delete|fprint\w*|fls)$/) || (exec >= 0 && runsWriter(args.slice(exec + 1)));
229
+ const end = args.findIndex((arg) => /^[-(!]/.test(arg));
230
+ return writes ? listed(end < 0 ? args : args.slice(0, end)) : null;
231
+ }
232
+ if (/^g?awk$/.test(name)) {
233
+ if (!has(/^-i|^--include/)) return null;
234
+ const named: string[] = [];
235
+ let program = !has(/^-f$|^--file/);
236
+ for (let index = 0; index < args.length; index++)
237
+ if (/^(?:-[ifvF]|--(?:include|file|assign|field-separator))$/.test(args[index])) index++;
238
+ else if (args[index].startsWith("-")) continue;
239
+ else if (program) program = false;
240
+ else named.push(args[index]);
241
+ return listed(named);
242
+ }
243
+ const inline = (flag: RegExp): string[] | null => {
244
+ const at = args.findIndex((arg) => flag.test(arg));
245
+ if (at < 0) return null;
246
+ const runs = input.length - args.length - 1;
247
+ // The script, the interpreter's options and arguments, and the environment
248
+ // it is given (`X=/p node -e …`, `NODE_OPTIONS=--require …`).
249
+ return [
250
+ ".",
251
+ ...input.flatMap((word, index) => {
252
+ if (index === runs || (index < runs && !/^\w+=/.test(word))) return [];
253
+ const text = scriptOf(index);
254
+ return text === undefined ? [unknownVarTarget(word)] : pathsIn(text);
255
+ }),
256
+ ];
257
+ };
258
+ if (name === "perl") return inline(/^-[a-zA-Z]*[eE]$/);
259
+ if (/^(?:python[\d.]*|node|bun|deno|ruby|php)$/.test(name))
260
+ return inline(/^-[a-zA-Z]*[ce]$|^--eval$|^-p$/);
261
+ return shellScript(words) === null ? null : ["."];
262
+ };
263
+
264
+ /**
265
+ * The paths an inline script or its arguments spell out: every absolute or
266
+ * `~/` path, and every relative one that climbs with `..`. An interpreter's
267
+ * inline script may write anywhere it names, so each counts as a change, with
268
+ * the directory it runs in.
269
+ */
270
+ const pathsIn = (text: string): string[] =>
271
+ [
272
+ ...text.matchAll(
273
+ /(?<![\w.~:/$-])(?:~\/|\/|[A-Za-z]:\/|\.\.(?:\/|$))[^\s'"`$),;:*?<>|\\[\]{}]*/g,
274
+ ),
275
+ ].map((match) => match[0]);
276
+
277
+ /**
278
+ * `ln`'s link sources, resolved as the link will: a relative symbolic source
279
+ * against the directory the link lands in. Writes through such a link change
280
+ * its source, so an implementer's links are judged by where they point.
281
+ */
282
+ const linkSources = (args: readonly string[], at: (file: string) => string | null): string[] => {
283
+ const names = operands([...args]);
284
+ const into = args.indexOf("-t");
285
+ const dest = into >= 0 ? args[into + 1] : names.length > 1 ? names.at(-1) : ".";
286
+ const sources = names.filter((name, index) =>
287
+ into >= 0 ? name !== dest : index < names.length - 1 || names.length === 1,
288
+ );
289
+ const target = dest === undefined ? null : at(dest);
290
+ const symbolic = args.some((arg) => /^-[a-zA-Z]*s|^--symbolic$/.test(arg));
291
+ const base =
292
+ target === null
293
+ ? null
294
+ : into >= 0 || sources.length > 1 || existsSync(target) || /[\\/]$/.test(dest ?? "")
295
+ ? target
296
+ : path.dirname(target);
297
+ return sources.flatMap((source) => {
298
+ if (symbolic && base !== null && !path.isAbsolute(homeExpanded(source)))
299
+ return [path.resolve(base, source)];
300
+ const resolved = at(source);
301
+ return resolved ? [resolved] : [];
302
+ });
303
+ };
304
+
305
+ /**
306
+ * Whether a shell command recognizably edits files (redirects, tee, touch,
307
+ * mkdir, cp/mv/rm, `sed -i`/`perl -i`, dd, and working-tree git: apply,
308
+ * restore, checkout --, mv, rm). `targets` lists the files when they are all
309
+ * known, else null. Interpreters and formatters are not detected; Workit's
310
+ * own commands never count.
311
+ */
312
+ export const shellWrites = (
313
+ command: string,
314
+ depth = 0,
315
+ ): { writes: boolean; targets: string[] | null } => {
316
+ const targets: string[] = [];
317
+ let unknown = false;
318
+ for (const segment of segmentsOf(command)) {
319
+ const inner = depth < 4 ? wrappedScript(segment.words) : null;
320
+ if (inner !== null) {
321
+ const nested = shellWrites(inner, depth + 1);
322
+ if (nested.writes && nested.targets === null) unknown = true;
323
+ else if (nested.targets) targets.push(...nested.targets);
324
+ }
325
+ targets.push(
326
+ ...segment.redirects.filter((item) => !/^\/dev\/(?:null|stdout|stderr|tty)$/.test(item)),
327
+ );
328
+ const edits = editsOf(segment.words);
329
+ if (edits === null) unknown = true;
330
+ else targets.push(...edits);
331
+ }
332
+ if (!unknown && targets.length === 0) return { writes: false, targets: null };
333
+ return { writes: true, targets: unknown ? null : targets };
334
+ };
335
+
336
+ type Gate = { blockers: WriteBlocker[]; root: string; plans: Set<string> };
337
+ type Cached = Gate & { key: string; planKey: string };
338
+ const cache = new Map<string, Cached>();
339
+
340
+ const planKeyOf = (root: string, plans: Iterable<string>) =>
341
+ [...plans].map((file) => fileSignature(path.resolve(root, file)) ?? "-").join("\0");
342
+
343
+ /** Unmet, unwaived before-write requirements of the checkout branch's open task. */
344
+ const gateFor = (cwd: string): Gate | null => {
345
+ const store = new TaskStore(cwd);
346
+ const listed = store.listTaskIndex();
347
+ if (!listed.ok) return null;
348
+ const key = store.currentKey();
349
+ if (!key.ok) return null;
350
+ const entry = listed.data
351
+ .filter((item) => item.key === key.data.key && item.status !== "closed")
352
+ .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
353
+ if (!entry) return { blockers: [], root: store.root, plans: new Set() };
354
+ const ledger = ledgerPath(store.root);
355
+ const cacheKey = `${entry.id}\0${entry.revision}\0${(ledger.ok && fileSignature(ledger.value)) || "-"}`;
356
+ const hit = cache.get(store.root);
357
+ // A cited plan created or edited since changes the answer (long-lived hosts).
358
+ if (hit && hit.key === cacheKey && hit.planKey === planKeyOf(store.root, hit.plans)) return hit;
359
+ const task = store.readTask(entry.id);
360
+ if (!task.ok) return null;
361
+ const workspace = store.readWorkspace();
362
+ const plans = new Set(
363
+ (latestJudgment(task.data)?.refs ?? []).flatMap((ref) =>
364
+ ref.kind === "file" ? [ref.path] : [],
365
+ ),
366
+ );
367
+ // An approved limitation clears a waivable requirement, as at close.
368
+ const blockers = writeBlockers(task.data, store.root).filter((blocker) => {
369
+ const requirement = task.data.policy?.requirements.find(
370
+ (item) => item.ruleId === blocker.ruleId && item.before === "write",
371
+ );
372
+ return !(
373
+ requirement?.acceptanceAllowed &&
374
+ workspace.ok &&
375
+ workspace.data &&
376
+ applicableDecision(task.data, workspace.data, requirement, store.root).length > 0
377
+ );
378
+ });
379
+ const gate: Cached = {
380
+ blockers,
381
+ root: store.root,
382
+ plans,
383
+ key: cacheKey,
384
+ planKey: planKeyOf(store.root, plans),
385
+ };
386
+ cache.set(store.root, gate);
387
+ if (cache.size > 32) cache.delete(cache.keys().next().value!);
388
+ return gate;
389
+ };
390
+
391
+ // --- the live-session guard (#274) ------------------------------------------
392
+
393
+ /** git subcommands that change a checkout's files, index or HEAD. */
394
+ const MUTATING_GIT = new Set([
395
+ "add",
396
+ "am",
397
+ "apply",
398
+ "checkout",
399
+ "cherry-pick",
400
+ "clean",
401
+ "commit",
402
+ "merge",
403
+ "mv",
404
+ "pull",
405
+ "rebase",
406
+ "reset",
407
+ "restore",
408
+ "revert",
409
+ "rm",
410
+ "stash",
411
+ "switch",
412
+ ]);
413
+ /** Subcommands whose `-n` means `--dry-run` (for `commit`, `-n` skips hooks). */
414
+ const DRY_N = new Set(["add", "clean", "rm", "mv"]);
415
+ /** `workit git` verbs that commit, move HEAD or change the index in the checkout. */
416
+ const MUTATING_WORKIT_GIT = new Set(["commit", "branch", "stage", "unstage"]);
417
+
418
+ /** A placeholder file name that stands for its directory (an unknown write, a git command). */
419
+ const DIR_TARGET = ".workit-live-target";
420
+
421
+ /** A one-command override: `WORKIT_LIVE_SESSION=override <command>`. */
422
+ const OVERRIDE = "WORKIT_LIVE_SESSION=override";
423
+
424
+ /** Only asks for help or a dry run: changes nothing. */
425
+ const inert = (args: readonly string[], sub: string): boolean =>
426
+ args.some(
427
+ (arg) =>
428
+ arg === "--help" ||
429
+ arg === "-h" ||
430
+ arg === "--dry-run" ||
431
+ (DRY_N.has(sub) && /^-[a-zA-Z]*n[a-zA-Z]*$/.test(arg) && !arg.startsWith("--")),
432
+ );
433
+
434
+ /**
435
+ * The directories mutating git commands in `command` run in (raw-git's
436
+ * analysis: `cd`/`pushd`, groups, `-C`, env and wrapper prefixes, `bash -c`,
437
+ * `$(…)`, `eval`, `xargs`, `find -exec`, PowerShell, `cmd /c`). A command
438
+ * whose directory cannot be known is skipped, never denied.
439
+ */
440
+ const gitMutationDirs = (cwd: string, command: string, dialect: ShellDialect): string[] => {
441
+ let invocations: readonly RawInvocation[];
442
+ try {
443
+ invocations = rawInvocations(command, dialect);
444
+ } catch {
445
+ return [];
446
+ }
447
+ return invocations.flatMap((invocation) => {
448
+ if (invocation.tool !== "git" || invocation.dir === undefined) return [];
449
+ const at = invocation.args.findIndex((arg) => !arg.startsWith("-"));
450
+ const sub = invocation.args[at] ?? "";
451
+ const rest = invocation.args.slice(at + 1);
452
+ if (!MUTATING_GIT.has(sub) || inert(rest, sub)) return [];
453
+ // `git stash list|show` only reads.
454
+ if (sub === "stash" && ["list", "show"].includes(rest[0] ?? "")) return [];
455
+ return [path.resolve(cwd, invocation.dir ?? ".")];
456
+ });
457
+ };
458
+
459
+ const CD = new Set(["cd", "chdir", "set-location", "sl"]);
460
+ const PUSHD = new Set(["pushd", "push-location"]);
461
+ const POPD = new Set(["popd", "pop-location"]);
462
+
463
+ /**
464
+ * Stands for a directory an implementer's command moved to but the analysis
465
+ * cannot name (`cd -`, `cd "$X"`, a bare `popd`): a write that lands under it
466
+ * is denied by `confineImplementer`, as the shell may be anywhere.
467
+ */
468
+ export const UNKNOWN_DIR = path.resolve(path.parse(process.cwd()).root, ".workit-unknown-dir");
469
+
470
+ /**
471
+ * A `cd` operand resolved against `dir`. A target that cannot be known is
472
+ * undefined, or `UNKNOWN_DIR` when `strict`.
473
+ */
474
+ const cdInto = (
475
+ dir: string | undefined,
476
+ words: string[],
477
+ strict = false,
478
+ vars: ReadonlyMap<string, string> = new Map(),
479
+ ): string | undefined => {
480
+ const lost = strict ? UNKNOWN_DIR : undefined;
481
+ const found = words.slice(1).find((word) => !word.startsWith("-") || word === "-");
482
+ const named = found === undefined ? undefined : expandVars(found, vars);
483
+ // Only a leading `~` or `$HOME` expands; a mid-path `~` is literal (Windows 8.3
484
+ // short names like RUNNER~1).
485
+ const target = named === undefined ? undefined : homeExpanded(named);
486
+ if (target === "-" || (target && /^~|[$`*?]/.test(target))) return lost;
487
+ if (dir === undefined) return lost;
488
+ return target === undefined ? os.homedir() : path.resolve(dir, target);
489
+ };
490
+
491
+ const quoteWord = (word: string): string =>
492
+ /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
493
+
494
+ /**
495
+ * `$NAME` and `${NAME}` replaced by the value a same-command assignment gave
496
+ * it. A name the command never assigned stays as written.
497
+ */
498
+ const expandVars = (text: string, vars: ReadonlyMap<string, string>): string =>
499
+ vars.size === 0 || !text.includes("$")
500
+ ? text
501
+ : text.replace(/\$(?:(\w+)|\{(\w+)\})/g, (whole, bare?: string, braced?: string) => {
502
+ const value = vars.get(bare ?? braced ?? "");
503
+ return value === undefined ? whole : value;
504
+ });
505
+
506
+ const DECLARERS = new Set(["export", "declare", "typeset", "local", "readonly"]);
507
+
508
+ /** The `NAME=value` assignments a segment makes to the shell itself (alone, or after `export`). */
509
+ const assignmentsOf = (words: readonly string[]): Array<[string, string]> => {
510
+ const first = words.findIndex((word) => !/^\w+=/.test(word));
511
+ const named =
512
+ first < 0
513
+ ? words
514
+ : first === 0 && DECLARERS.has(words[0])
515
+ ? words.slice(1).filter((word) => !word.startsWith("-"))
516
+ : [];
517
+ return named.flatMap((word) => {
518
+ const match = /^(\w+)=(.*)$/s.exec(word);
519
+ return match && /^[A-Za-z_]/.test(match[1]) ? [[match[1], match[2]] as [string, string]] : [];
520
+ });
521
+ };
522
+
523
+ /** `words` without the leading `do`, `then`, `else`… that only introduce the command. */
524
+ const commandWords = (words: readonly string[]): string[] => {
525
+ let start = 0;
526
+ while (start < words.length && COMPOUND.has(words[start])) start++;
527
+ return words.slice(start);
528
+ };
529
+
530
+ /** Commands that can set any variable, whatever their arguments name: no variable is trusted after one. */
531
+ const REBINDS_ANY = new Set([
532
+ "source",
533
+ ".",
534
+ "eval",
535
+ "trap",
536
+ "alias",
537
+ "declare",
538
+ "typeset",
539
+ "local",
540
+ ]);
541
+
542
+ const BINDERS = new Set([
543
+ "read",
544
+ "mapfile",
545
+ "readarray",
546
+ "printf",
547
+ "getopts",
548
+ "unset",
549
+ "eval",
550
+ "let",
551
+ "set",
552
+ "source",
553
+ ".",
554
+ "declare",
555
+ "typeset",
556
+ "local",
557
+ "readonly",
558
+ "export",
559
+ "builtin",
560
+ "command",
561
+ ]);
562
+ const COMPOUND = new Set([
563
+ "if",
564
+ "then",
565
+ "elif",
566
+ "else",
567
+ "fi",
568
+ "for",
569
+ "while",
570
+ "until",
571
+ "do",
572
+ "done",
573
+ "case",
574
+ "esac",
575
+ "select",
576
+ "function",
577
+ "{",
578
+ "}",
579
+ ]);
580
+
581
+ /** Keywords that open and close a compound body; `case`, `select` and functions are not followed. */
582
+ const OPENS = new Set(["if", "for", "while", "until", "{"]);
583
+ const CLOSES = new Set(["fi", "done", "}"]);
584
+ const UNFOLLOWED = new Set(["case", "esac", "select", "function"]);
585
+ /** Words before the command that leave it the command: `builtin eval` is still `eval`. */
586
+ const TRANSPARENT = new Set(["builtin", "command", "time", "!"]);
587
+
588
+ /**
589
+ * Which variables a command's assignments may not be trusted for: none at all
590
+ * after `case`, `select`, a function or a command that can set any variable
591
+ * (`eval`, `source`, `declare`…) (`all`); else the names assigned inside a
592
+ * loop or conditional body, and the names a conditional or non-plain
593
+ * assignment (`+=`, `a[i]=`), a binder (`read`, `unset`, `declare -n`,
594
+ * `printf -v`…) or `${N:=…}` can change. A `for` variable is followed
595
+ * apart, in `segmentTargets`. Only a plain `NAME=literal` that runs in
596
+ * sequence, or `export NAME=literal`, is substituted.
597
+ */
598
+ const untrustedVars = (
599
+ segments: readonly Segment[],
600
+ ): { all: boolean; names: ReadonlySet<string> } => {
601
+ const names = new Set<string>();
602
+ let all = false;
603
+ let depth = 0;
604
+ const poison = (text: string) => {
605
+ for (const match of text.matchAll(/[A-Za-z_]\w*/g)) names.add(match[0]);
606
+ };
607
+ for (const segment of segments) {
608
+ let start = 0;
609
+ for (; start < segment.words.length && COMPOUND.has(segment.words[start]); start++) {
610
+ const keyword = segment.words[start];
611
+ if (UNFOLLOWED.has(keyword)) all = true;
612
+ if (OPENS.has(keyword)) depth++;
613
+ else if (CLOSES.has(keyword)) depth = Math.max(0, depth - 1);
614
+ }
615
+ const words = segment.words.slice(start);
616
+ if (depth > 0) for (const [key] of assignmentsOf(words)) names.add(key);
617
+ const lead = words.findIndex((word) => !/^\w+\+?=|^\w+\[.*\]\+?=/.test(word));
618
+ const first = lead < 0 ? undefined : words[lead];
619
+ let command = lead;
620
+ while (command >= 0 && TRANSPARENT.has(words[command] ?? "")) command++;
621
+ if (command >= 0 && REBINDS_ANY.has(words[command] ?? "")) all = true;
622
+ if (command >= 0 && UNFOLLOWED.has(words[command] ?? "")) all = true;
623
+ for (const word of words)
624
+ for (const match of word.matchAll(/\$\{(\w+)(?:\[[^\]]*\])?:?[=]/g)) names.add(match[1]);
625
+ const assigned = lead < 0 ? words : first === "export" ? words.slice(lead + 1) : [];
626
+ const plainOnly = assigned.every((word) => /^\w+=/.test(word) || word === "export");
627
+ if (segment.conditional || (!plainOnly && lead < 0))
628
+ for (const word of assigned) poison(word.split("=")[0]);
629
+ if (lead >= 0 && words.slice(lead).some((word) => BINDERS.has(word))) {
630
+ const flagged = words.slice(lead + 1).some((word) => word.startsWith("-"));
631
+ const plainExport = first === "export" && !flagged;
632
+ if (!plainExport) poison(words.slice(lead).join(" "));
633
+ else if (segment.conditional) poison(words.slice(lead).join(" "));
634
+ }
635
+ if (words.some((word) => /^\w+\+=/.test(word)))
636
+ for (const word of words) if (/^\w+\+=/.test(word)) poison(word.split("+")[0]);
637
+ }
638
+ return { all, names };
639
+ };
640
+
641
+ /**
642
+ * Where a variable reference stands in a path Workit cannot resolve: a write
643
+ * under it is denied by `confineImplementer`, naming the references
644
+ * (URI-encoded, one path segment).
645
+ */
646
+ export const UNKNOWN_VAR = path.resolve(path.parse(process.cwd()).root, ".workit-unknown-var");
647
+
648
+ const REFERENCE = /\$\{(\w+)[^}]*\}|\$(\w+)|\$\([^)]*\)?|`[^`]*`?/g;
649
+
650
+ /** The variable references and substitutions left in `text`, as written (`$T`, `$(cat f)`). */
651
+ export const unresolvedRefs = (text: string): string[] => [
652
+ ...new Set(
653
+ [...text.matchAll(REFERENCE)].map((match) => {
654
+ const name = match[1] ?? match[2];
655
+ return name === undefined ? match[0] : `$${name}`;
656
+ }),
657
+ ),
658
+ ];
659
+
660
+ const unknownVarTarget = (text: string): string =>
661
+ path.join(UNKNOWN_VAR, encodeURIComponent(unresolvedRefs(text).join(" ") || text));
662
+
663
+ /** Literal-pattern parameter operators: `/`, `//`, `%`, `%%`, `#`, `##`; null for any other. */
664
+ const applyOperator = (value: string, operator: string): string | null => {
665
+ if (operator === "") return value;
666
+ const swap = /^(\/\/?)([^/*?[\]]+)\/([^*?[\]]*)$/.exec(operator);
667
+ if (swap)
668
+ return swap[1] === "//" ? value.replaceAll(swap[2], swap[3]) : value.replace(swap[2], swap[3]);
669
+ const trim = /^(%%?|##?)([^*?[\]]*)$/.exec(operator);
670
+ if (!trim) return null;
671
+ if (trim[1].startsWith("%"))
672
+ return value.endsWith(trim[2]) ? value.slice(0, value.length - trim[2].length) : value;
673
+ return value.startsWith(trim[2]) ? value.slice(trim[2].length) : value;
674
+ };
675
+
676
+ /** One word expands to at most this many texts; past it, the reference stays unresolved. */
677
+ const MAX_EXPANSIONS = 16;
678
+
679
+ /**
680
+ * Every text `text` may expand to: each `$NAME`, `${NAME}` or literal
681
+ * `${NAME/a/b}`-style reference replaced by the value an assignment gave it,
682
+ * or by each word of the `for` list it loops over. Other references stay as
683
+ * written.
684
+ */
685
+ const expansions = (
686
+ text: string,
687
+ vars: ReadonlyMap<string, string>,
688
+ loops: ReadonlyMap<string, readonly string[]>,
689
+ ): string[] => {
690
+ if (!text.includes("$")) return [text];
691
+ let results = [""];
692
+ let last = 0;
693
+ for (const match of text.matchAll(/\$\{(\w+)([^}]*)\}|\$(\w+)/g)) {
694
+ const name = match[1] ?? match[3];
695
+ const literal = text.slice(last, match.index);
696
+ last = match.index + match[0].length;
697
+ const value = vars.get(name);
698
+ const values = loops.get(name) ?? (value === undefined ? null : [value]);
699
+ const applied = values?.map((item) => applyOperator(item, match[2] ?? ""));
700
+ const fits = applied && results.length * applied.length <= MAX_EXPANSIONS;
701
+ results =
702
+ fits && applied.every((item) => item !== null)
703
+ ? results.flatMap((prefix) => applied.map((item) => prefix + literal + item))
704
+ : results.map((prefix) => prefix + literal + match[0]);
705
+ }
706
+ return results.map((prefix) => prefix + text.slice(last));
707
+ };
708
+
709
+ /**
710
+ * The path `$(mktemp [-d] [-p DIR|-t] [TEMPLATE])` prints: TEMPLATE under DIR,
711
+ * the temp dir, or (no `-p`/`-t`) the directory it runs in; null for any
712
+ * other value.
713
+ */
714
+ const mktempPath = (value: string, dir: string | undefined): string | null => {
715
+ const match = /^\$\(\s*mktemp\b([^()`$]*)\)$/.exec(value.trim());
716
+ if (!match) return null;
717
+ const args = match[1]
718
+ .trim()
719
+ .split(/\s+/)
720
+ .filter(Boolean)
721
+ .map((arg) => arg.replace(/^(["'])(.*)\1$/, "$2"));
722
+ let base: string | null = null;
723
+ let template: string | undefined;
724
+ let temp = false;
725
+ for (let index = 0; index < args.length; index++) {
726
+ const arg = args[index];
727
+ if (arg === "-p" || arg === "--tmpdir") {
728
+ if (arg === "-p") base = args[++index] ?? null;
729
+ else temp = true;
730
+ if (arg === "-p" && base === null) return null;
731
+ } else if (arg.startsWith("--tmpdir=")) base = arg.slice("--tmpdir=".length);
732
+ else if (arg === "-t") temp = true;
733
+ else if (!arg.startsWith("-")) template = arg;
734
+ }
735
+ if (base === null && (temp || template === undefined)) base = os.tmpdir();
736
+ const name = homeExpanded(template ?? "tmp.XXXXXXXXXX");
737
+ if (path.isAbsolute(name)) return name;
738
+ const from = base === null ? dir : homeExpanded(base);
739
+ if (from === undefined || (!path.isAbsolute(from) && dir === undefined)) return null;
740
+ return path.resolve(dir ?? from, from, name);
741
+ };
742
+
743
+ /** A leading `~`, `$HOME` or `${HOME}` the shell would expand. */
744
+ const homeExpanded = (file: string): string => {
745
+ const match = /^(?:~|\$HOME|\$\{HOME\})(?=\/|$)/.exec(file);
746
+ return match ? (process.env.HOME || os.homedir()) + file.slice(match[0].length) : file;
747
+ };
748
+
749
+ /**
750
+ * Per top-level segment, the files it writes (redirects, `touch`, `rm`,
751
+ * `sed -i`…) and `workit git commit|branch|stage|unstage`, resolved against the
752
+ * directory that segment runs in (`cd`, `pushd`/`popd`, `( … )` groups). A
753
+ * relative target in an unknown directory is skipped.
754
+ */
755
+ const segmentTargets = (
756
+ cwd: string,
757
+ command: string,
758
+ dialect: ShellDialect,
759
+ depth = 0,
760
+ strict = false,
761
+ inherited: ReadonlyMap<string, string> = new Map(),
762
+ ): string[] => {
763
+ const targets: string[] = [];
764
+ let dir: string | undefined = cwd;
765
+ let stack: Array<string | undefined> = [];
766
+ const segments = segmentsOf(command, dialect);
767
+ const untrusted = untrustedVars(segments);
768
+ let vars = new Map(
769
+ untrusted.all ? [] : [...inherited].filter(([key]) => !untrusted.names.has(key)),
770
+ );
771
+ let loops = new Map<string, string[]>();
772
+ let nesting = 0;
773
+ const groups: Array<{
774
+ dir: string | undefined;
775
+ stack: Array<string | undefined>;
776
+ vars: Map<string, string>;
777
+ loops: Map<string, string[]>;
778
+ }> = [];
779
+ const atAll = (file: string, base: string | undefined = dir): string[] =>
780
+ expansions(file, vars, loops).flatMap((text) => {
781
+ const home = homeExpanded(text);
782
+ if (strict && unresolvedRefs(home).length > 0) return [unknownVarTarget(home)];
783
+ if (path.isAbsolute(home)) return [home];
784
+ return base === undefined ? [] : [path.resolve(base, home)];
785
+ });
786
+ const at = (file: string): string | null => {
787
+ const all = atAll(file);
788
+ return all.length > 1 ? unknownVarTarget(file) : (all[0] ?? null);
789
+ };
790
+ /** A reference the command spells without a `\\` before its `$` somewhere: the shell expands it. */
791
+ const unescaped = (ref: string): boolean => {
792
+ const name = /^\$(\w+)$/.exec(ref)?.[1];
793
+ return (
794
+ name === undefined || new RegExp(`(?<!\\\\)\\$(?:${name}(?!\\w)|\\{${name}\\b)`).test(command)
795
+ );
796
+ };
797
+ const assigned = (value: string): string | null => {
798
+ const made = mktempPath(value, dir);
799
+ if (made !== null) return made;
800
+ const all = expansions(value, vars, loops).map(homeExpanded);
801
+ return all.length === 1 && unresolvedRefs(all[0]).length === 0 ? all[0] : null;
802
+ };
803
+ for (const segment of segments) {
804
+ if (segment.group === "open") {
805
+ groups.push({ dir, stack: [...stack], vars: new Map(vars), loops: new Map(loops) });
806
+ continue;
807
+ }
808
+ if (segment.group === "close") {
809
+ const saved = groups.pop();
810
+ if (saved) ({ dir, stack, vars, loops } = saved);
811
+ continue;
812
+ }
813
+ const body = commandWords(segment.words);
814
+ const keywords = segment.words.length - body.length;
815
+ for (const keyword of segment.words.slice(0, keywords))
816
+ if (OPENS.has(keyword)) nesting++;
817
+ else if (CLOSES.has(keyword)) nesting = Math.max(0, nesting - 1);
818
+ const loop = segment.words.slice(0, keywords).lastIndexOf("for");
819
+ if (loop >= 0) {
820
+ const [name = "", keyword, ...items] = segment.words.slice(loop + 1);
821
+ const expanding = new Set(segment.expands ?? []);
822
+ const literal =
823
+ keyword === "in" && items.every((_, index) => !expanding.has(loop + 3 + index));
824
+ vars.delete(name);
825
+ if (literal && !untrusted.all && !untrusted.names.has(name))
826
+ loops.set(name, items.map(homeExpanded));
827
+ else loops.delete(name);
828
+ continue;
829
+ }
830
+ const lead = body.findIndex((word) => !/^\w+=/.test(word));
831
+ const words = lead < 0 ? [] : body.slice(lead);
832
+ if (!segment.conditional && !untrusted.all)
833
+ for (const [key, value] of assignmentsOf(segment.words))
834
+ if (!untrusted.names.has(key)) {
835
+ loops.delete(key);
836
+ const resolved = assigned(value);
837
+ if (resolved === null) vars.delete(key);
838
+ else vars.set(key, resolved);
839
+ }
840
+ const name = (words[0]?.split(/[\\/]/).at(-1) ?? "").toLowerCase();
841
+ // A directory change inside a loop or conditional body may or may not run.
842
+ const moves = CD.has(name) || PUSHD.has(name) || POPD.has(name);
843
+ if (moves && strict && nesting > 0) {
844
+ dir = UNKNOWN_DIR;
845
+ continue;
846
+ }
847
+ if (moves && keywords > 0) continue;
848
+ if (CD.has(name)) {
849
+ dir = cdInto(dir, words, strict, vars);
850
+ continue;
851
+ }
852
+ if (PUSHD.has(name)) {
853
+ stack.push(dir);
854
+ dir = cdInto(dir, words, strict, vars);
855
+ continue;
856
+ }
857
+ if (POPD.has(name)) {
858
+ dir = stack.length ? stack.pop() : strict ? UNKNOWN_DIR : undefined;
859
+ continue;
860
+ }
861
+ const inner = depth < 4 ? (wrappedScript(words) ?? (strict ? shellScript(words) : null)) : null;
862
+ if (inner !== null && dir !== undefined)
863
+ for (const runIn of workitRunDir(dir, words, atAll))
864
+ targets.push(...segmentTargets(runIn, inner, "posix", depth + 1, strict, vars));
865
+ if (strict && dir !== undefined) {
866
+ const runIn = dir;
867
+ for (const sub of depth < 4 ? (segment.subs ?? []) : [])
868
+ targets.push(...segmentTargets(runIn, sub, dialect, depth + 1, true, vars));
869
+ const expanding = new Set(segment.expands ?? []);
870
+ const scriptOf = (index: number): string | undefined => {
871
+ const word = body[index];
872
+ if (word === undefined || !expanding.has(keywords + index)) return word;
873
+ const all = expansions(word, vars, loops);
874
+ const open = all.length === 1 ? unresolvedRefs(all[0]).filter(unescaped) : [];
875
+ return all.length === 1 && open.length === 0 ? all[0] : undefined;
876
+ };
877
+ const opaque = segment.unanalyzable
878
+ ? ["."]
879
+ : inner === null
880
+ ? opaqueWrites(body, scriptOf)
881
+ : null;
882
+ for (const file of opaque ?? [])
883
+ for (const resolved of atAll(file))
884
+ targets.push(file === "." ? path.join(resolved, DIR_TARGET) : resolved);
885
+ const ln = words.findIndex((word) => /^ln$/i.test(word.split(/[\\/]/).at(-1) ?? ""));
886
+ if (ln >= 0 && words.slice(0, ln).every((word) => PREFIXES.has(word)))
887
+ targets.push(...linkSources(words.slice(ln + 1), at));
888
+ }
889
+ for (const file of segment.redirects)
890
+ if (!/^\/dev\/(?:null|stdout|stderr|tty)$/.test(file)) targets.push(...atAll(file));
891
+ const edits = editsOf(body);
892
+ if (edits === null) {
893
+ if (dir !== undefined) targets.push(path.join(dir, DIR_TARGET));
894
+ } else for (const file of edits) targets.push(...atAll(file));
895
+ if (dir !== undefined)
896
+ for (const invocation of workitInvocations(body.map(quoteWord).join(" "), "posix")) {
897
+ const { at: runsIn, args } = workitCwd(dir, invocation, atAll);
898
+ if (
899
+ args[0] === "git" &&
900
+ MUTATING_WORKIT_GIT.has(args[1] ?? "") &&
901
+ !inert(args.slice(2), "")
902
+ )
903
+ targets.push(...runsIn.map((runs) => path.join(runs, DIR_TARGET)));
904
+ }
905
+ }
906
+ return targets;
907
+ };
908
+
909
+ /** Where `workit check|lock run [--cwd <dir>] -- <cmd>` runs its command: `--cwd` resolved against `dir`. */
910
+ const workitRunDir = (dir: string, words: string[], resolve: Resolve): string[] => {
911
+ for (const invocation of workitInvocations(words.map(quoteWord).join(" "), "posix")) {
912
+ const { at, args } = workitCwd(dir, invocation, resolve);
913
+ if (args[0] === "check" || (args[0] === "lock" && args[1] === "run")) return at;
914
+ }
915
+ return [dir];
916
+ };
917
+
918
+ /** Resolves a path a command names against a directory: every value it may take. */
919
+ type Resolve = (file: string, base: string) => string[];
920
+
921
+ /** A workit invocation's global `--cwd <dir>` (or `--cwd=<dir>`), resolved against `dir`. */
922
+ const workitCwd = (
923
+ dir: string,
924
+ args: string[],
925
+ resolve: Resolve,
926
+ ): { at: string[]; args: string[] } => {
927
+ const { rest, cwd } = skipWorkitGlobals(args);
928
+ return { at: cwd === null ? [dir] : resolve(cwd, dir), args: rest };
929
+ };
930
+
931
+ /**
932
+ * Where one shell command changes files: the files it writes and, as
933
+ * `<dir>/.workit-live-target`, each directory a mutating git or
934
+ * `workit git commit|branch|stage|unstage` runs in or an unknown write happens in.
935
+ * `strict` (an implementer's confinement) also reads `-c` shell scripts and
936
+ * `$(…)`, counts writers the analysis cannot see in the directory they run
937
+ * in, and judges `ln` sources. Absolute paths; [] when the command cannot be
938
+ * analysed (fail open).
939
+ */
940
+ export const shellMutationTargets = (
941
+ cwd: string,
942
+ command: string,
943
+ dialect: ShellDialect = "posix",
944
+ strict = false,
945
+ ): string[] => {
946
+ try {
947
+ return [
948
+ ...segmentTargets(cwd, command, dialect, 0, strict),
949
+ ...gitMutationDirs(cwd, command, dialect).map((dir) => path.join(dir, DIR_TARGET)),
950
+ ];
951
+ } catch {
952
+ return [];
953
+ }
954
+ };
955
+
956
+ /** The command is one simple command led by `WORKIT_LIVE_SESSION=override`. */
957
+ const overridden = (command: string, dialect: ShellDialect): boolean => {
958
+ const segments = segmentsOf(command, dialect);
959
+ if (segments.length !== 1 || segments[0].group || segments[0].subs?.length) return false;
960
+ const words = segments[0].words;
961
+ const lead = words.findIndex((word) => !/^\w+=/.test(word));
962
+ return lead > 0 && words.slice(0, lead).includes(OVERRIDE);
963
+ };
964
+
965
+ const HOST_LABELS: Readonly<Record<string, string>> = {
966
+ claude_code: "Claude Code",
967
+ opencode: "OpenCode",
968
+ codex_cli: "Codex CLI",
969
+ codex_desktop: "Codex Desktop",
970
+ cursor: "Cursor",
971
+ pi: "Pi",
972
+ };
973
+
974
+ const ageText = (ms: number): string => {
975
+ const seconds = Math.max(0, Math.round(ms / 1000));
976
+ return seconds < 120 ? `${seconds}s` : `${Math.round(seconds / 60)} min`;
977
+ };
978
+
979
+ const liveDeny = ({ marker, checkout, ageMs, file }: LiveConflict): HookDecision => {
980
+ const unblock = `Work in your own worktree (\`git worktree add <path> -b <branch>\`, or \`workit fanout worktree create <slice>\`). Only if the user confirms that session is finished: release it with \`rm '${file}'\`, or prefix one command with ${OVERRIDE}.`;
981
+ return {
982
+ kind: "deny",
983
+ reason: `workit live-session guard: ${checkout} belongs to another live agent session, ${HOST_LABELS[marker.host] ?? marker.host} session ${marker.session}${marker.pid === null ? "" : ` (pid ${marker.pid})`}, last active ${ageText(ageMs)} ago. Do not edit a checkout another session is working in; read-only commands stay allowed. ${unblock}`,
984
+ unblock,
985
+ };
986
+ };
987
+
988
+ /** One target per directory. */
989
+ const guarded = (targets: readonly string[]): string[] => {
990
+ const seen = new Set<string>();
991
+ return targets.filter((target) => {
992
+ const dir = path.dirname(target);
993
+ if (seen.has(dir)) return false;
994
+ seen.add(dir);
995
+ return true;
996
+ });
997
+ };
998
+
999
+ /**
1000
+ * Deny when a target's checkout belongs to another live session and its
1001
+ * workspace keeps `liveSessionGuard` on. Fails open.
1002
+ */
1003
+ const liveGate = (owner: LiveOwner, targets: readonly string[], deps?: LiveDeps): HookDecision => {
1004
+ try {
1005
+ for (const target of guarded(targets)) {
1006
+ const conflict = liveConflict(target, owner, deps);
1007
+ if (conflict && workspaceLiveSessionGuard(conflict.checkout)) return liveDeny(conflict);
1008
+ }
1009
+ } catch {
1010
+ // Fail open.
1011
+ }
1012
+ return NONE;
1013
+ };
1014
+
1015
+ const claimAll = (owner: LiveOwner, targets: readonly string[], deps?: LiveDeps): void => {
1016
+ for (const target of guarded(targets)) claimLiveSession(target, owner, deps);
1017
+ };
1018
+
1019
+ /** The absolute paths an edit touches: its files, or cwd itself when unknown. */
1020
+ export const liveTargets = (cwd: string, paths: readonly string[] | null): string[] =>
1021
+ paths?.length ? paths.map((file) => path.resolve(cwd, file)) : [path.join(cwd, DIR_TARGET)];
1022
+
1023
+ // --- the issued-registry guard (#292) ---------------------------------------
1024
+
1025
+ /**
1026
+ * The files an agent tool call writes under `<state>/issued*`: the issued-id
1027
+ * records and their token hashes. Only the hook and the CLI write there; an
1028
+ * agent that could would forge an issued session or token by hand.
1029
+ */
1030
+ const issuedTarget = (cwd: string, targets: readonly string[]): string | null => {
1031
+ const state = resolveStateDir();
1032
+ const found = targets.find((target) =>
1033
+ underIssued(state, cwd, homeExpanded(target), process.platform, canonical),
1034
+ );
1035
+ return found ?? null;
1036
+ };
1037
+
1038
+ /**
1039
+ * Whether `target` (resolved against `cwd`) is `<state>/issued*` or inside
1040
+ * it. Pure over `platform`, so Windows shapes are testable anywhere: mixed
1041
+ * `\` and `/`, drive-letter case, and the `/c/Users/...` form a POSIX shell
1042
+ * on Windows uses. `real` resolves symlinks and 8.3 short names (RUNNER~1) on
1043
+ * the live platform.
1044
+ */
1045
+ export const underIssued = (
1046
+ state: string,
1047
+ cwd: string,
1048
+ target: string,
1049
+ platform: NodeJS.Platform = process.platform,
1050
+ real: (file: string) => string = (file) => file,
1051
+ ): boolean => {
1052
+ const win = platform === "win32";
1053
+ const p = win ? path.win32 : path.posix;
1054
+ const fold = (value: string) => (win || platform === "darwin" ? value.toLowerCase() : value);
1055
+ const named = win ? target.replace(/^\/([a-zA-Z])(?=\/|$)/, "$1:") : target;
1056
+ const relative = p.relative(fold(real(p.resolve(state))), fold(real(p.resolve(cwd, named))));
1057
+ if (relative.startsWith("..") || p.isAbsolute(relative)) return false;
1058
+ return relative.split(p.sep)[0].startsWith("issued");
1059
+ };
1060
+
1061
+ /**
1062
+ * A POSIX shell drops an unquoted `\` before the analysis sees the words, so
1063
+ * `> C:\state\issued\x` loses its separators. On Windows, a command that
1064
+ * writes and spells out the registry path is denied.
1065
+ */
1066
+ const unquotedWindowsIssued = (command: string): boolean => {
1067
+ if (process.platform !== "win32" || !command.includes("\\")) return false;
1068
+ const flat = (value: string) => value.replaceAll("\\", "/").toLowerCase();
1069
+ return flat(command).includes(`${flat(resolveStateDir())}/issued`) && shellWrites(command).writes;
1070
+ };
1071
+
1072
+ const issuedDeny = (file: string): HookDecision => {
1073
+ const unblock =
1074
+ "Issued ids and tokens are written only by Workit's SubagentStart hook. Run the verifier or reviewer agent so the host issues its own session; never edit the issued registries by hand.";
1075
+ return {
1076
+ kind: "deny",
1077
+ reason: `workit issued-registry guard: ${file} is under Workit's issued-session state. ${unblock}`,
1078
+ unblock,
1079
+ };
1080
+ };
1081
+
1082
+ /**
1083
+ * Deny a working-tree edit while the branch task has unmet before-write
1084
+ * requirements. `paths` are the files the tool writes, or null when unknown
1085
+ * (a shell write, a patch whose headers cannot be read); unknown or empty
1086
+ * targets are gated, and an edit touching only exempt files is always allowed.
1087
+ * With `owner` (the hook's session), the live-session guard runs first, and
1088
+ * an allowed edit refreshes the owner's marker in each checkout it touches.
1089
+ */
1090
+ export function writeGate(
1091
+ cwd: string,
1092
+ paths: readonly string[] | null,
1093
+ owner?: LiveOwner | null,
1094
+ deps?: LiveDeps,
1095
+ ): HookDecision {
1096
+ const forged = paths?.length ? issuedTarget(cwd, paths) : null;
1097
+ if (forged) return issuedDeny(forged);
1098
+ if (!owner) return beforeWriteGate(cwd, paths);
1099
+ const targets = liveTargets(cwd, paths);
1100
+ const live = liveGate(owner, targets, deps);
1101
+ if (live.kind !== "none") return live;
1102
+ const decision = beforeWriteGate(cwd, paths);
1103
+ if (decision.kind === "none") claimAll(owner, targets, deps);
1104
+ return decision;
1105
+ }
1106
+
1107
+ /**
1108
+ * The write gates on one shell command: the live-session guard on its writes
1109
+ * and mutating git commands in the directories they run in, then the
1110
+ * before-write gate on its writes. A command that is only
1111
+ * `WORKIT_LIVE_SESSION=override <command>` skips the guard and leaves a
1112
+ * ledger row. An allowed mutation refreshes the owner's marker.
1113
+ */
1114
+ export function shellGate(
1115
+ cwd: string,
1116
+ command: string,
1117
+ owner: LiveOwner | null,
1118
+ dialect: ShellDialect = "posix",
1119
+ deps?: LiveDeps,
1120
+ ): HookDecision {
1121
+ try {
1122
+ const forged =
1123
+ issuedTarget(cwd, segmentTargets(cwd, command, dialect)) ??
1124
+ (unquotedWindowsIssued(command) ? "the issued registry" : null);
1125
+ if (forged) return issuedDeny(forged);
1126
+ } catch {
1127
+ // Fail open, like the other shell analyses here.
1128
+ }
1129
+ const writes = shellWrites(command);
1130
+ let targets: string[] = [];
1131
+ if (owner) targets = shellMutationTargets(cwd, command, dialect);
1132
+ if (owner && targets.length) {
1133
+ const live = liveGate(owner, targets, deps);
1134
+ if (live.kind !== "none") {
1135
+ if (!overridden(command, dialect)) return live;
1136
+ noteOverride(cwd, owner, command);
1137
+ }
1138
+ }
1139
+ const decision = writes.writes ? beforeWriteGate(cwd, writes.targets) : NONE;
1140
+ if (owner && targets.length && decision.kind === "none") claimAll(owner, targets, deps);
1141
+ return decision;
1142
+ }
1143
+
1144
+ /** A ledger row for an override that let a command past the guard (managed repositories only). */
1145
+ const noteOverride = (cwd: string, owner: LiveOwner, command: string): void => {
1146
+ try {
1147
+ if (!inWorkitWorkspace(cwd)) return;
1148
+ appendHookObserved(cwd, {
1149
+ type: "live_session.override",
1150
+ actor: { host: owner.host, session: owner.session, agentId: null },
1151
+ session: owner.session,
1152
+ command: command.slice(0, 200),
1153
+ });
1154
+ } catch {
1155
+ // A note never blocks the command.
1156
+ }
1157
+ };
1158
+
1159
+ function beforeWriteGate(cwd: string, paths: readonly string[] | null): HookDecision {
1160
+ try {
1161
+ const gate = gateFor(cwd);
1162
+ if (!gate || gate.blockers.length === 0) return NONE;
1163
+ if (paths?.length && paths.every((file) => exempt(cwd, gate.root, file, gate.plans)))
1164
+ return NONE;
1165
+ const reasons = gate.blockers.map((item) => `${item.ruleId}: ${item.reason}`).join("; ");
1166
+ return {
1167
+ kind: "deny",
1168
+ reason: `workit before-write gate: ${reasons}. Markdown, docs/, plans/ and files outside the checkout stay writable.`,
1169
+ unblock: gate.blockers[0].reason,
1170
+ };
1171
+ } catch {
1172
+ return NONE;
1173
+ }
1174
+ }