@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,910 @@
1
+ // Autonomy grants (D2, D4, D15; design §0 #12, §4.2): the per-workspace
2
+ // ceiling on what an agent may deliver without asking. Authority is the
3
+ // host's own permission prompt plus these grants; no approval ticket or
4
+ // checkout lease stands behind them.
5
+ //
6
+ // - Grants live only in the matched workspace entry of the user's
7
+ // `~/.config/workit/workspaces.json`, where `~` is the OS account's home
8
+ // (neither $HOME nor config-dir overrides redirect them; see grantsDir):
9
+ // "autonomy": { "push": true, "pr": true, "merge": false | true | "verified",
10
+ // "release": false, "rerun": true }
11
+ // never in a repo file. `workit grant set` raises only from an interactive
12
+ // terminal (see verbs/grant.ts). Threat model (D18): this stops an honest
13
+ // agent from raising its own ceiling by accident, not an adversarial one —
14
+ // a process that drives a pseudo-terminal or edits the file directly can
15
+ // still defeat it; the host's permission prompt is the hard boundary.
16
+ // - `release` gates `workit git push --release`: pushing the git-flow
17
+ // production and develop branches (fast-forward only) and release tags (`v*` or git-flow's prefix) whose
18
+ // commit already landed (a tag never moves). Outside git-flow it pushes
19
+ // tags only. It needs `push` too.
20
+ // - Defaults (D4) when a kind is absent: push, pr and rerun are allowed;
21
+ // merge and release need an explicit grant. The default ceiling is a stack
22
+ // opened, CI green and independently verified: "verified, ready".
23
+ // - Every merge needs an accepted independent verdict (S13). `merge: true`
24
+ // also allows an explicit, ledger-recorded `--unverified --reason` bypass;
25
+ // `merge: "verified"` never does.
26
+ // - A legacy `autoApprove: true | [classes]` maps once to grants (its `merge`
27
+ // class becomes `merge: "verified"`; `branch`/`commit` are local, dropped).
28
+ // - Explicitly configured grants require an account (design §2.0 Identity):
29
+ // a workspace with grants but no `vcs.account` cannot push, open or merge
30
+ // through a forge, so a personal grant never rides a work credential.
31
+ // - Protected-branch pushes stay denied by the git verbs (except the git-flow
32
+ // release push above), and the host
33
+ // permission prompt applies in addition: a host deny always wins.
34
+ import { copyFileSync, existsSync } from "node:fs";
35
+ import os from "node:os";
36
+ import path from "node:path";
37
+ import { configFilePath, resolveConfigDir } from "./core/config";
38
+ import { configDir as workitConfigDir } from "./core/workit-paths.mjs";
39
+ import { writeFileAtomic } from "./core/safe-write";
40
+ import {
41
+ checkoutRemotes,
42
+ remoteMatches,
43
+ type CheckoutRemote,
44
+ type GrantTarget,
45
+ type LazyTarget,
46
+ } from "./core/remotes";
47
+ import {
48
+ checkoutsAbove,
49
+ loadWorkspacesFrom,
50
+ readWorkspacesResult,
51
+ resolveWorkspaceFrom,
52
+ validateWorkspacesDocument,
53
+ type WorkspaceConfig,
54
+ } from "./core/workspaces";
55
+
56
+ /**
57
+ * Grants are read only from the user's real config directory, the default
58
+ * config dir under the account home (`~/.config/workit`, `%APPDATA%\\workit` on
59
+ * Windows; D15, see grantsHome). The WORKIT_CONFIG(_DIR) and
60
+ * XDG_CONFIG_HOME overrides that redirect the rest of the config never
61
+ * redirect grants: an agent could point them at a file it wrote. While an
62
+ * override points elsewhere, grants resolve to the D4 defaults.
63
+ */
64
+ const accountHome = (): string => {
65
+ try {
66
+ return os.userInfo().homedir;
67
+ } catch {
68
+ return os.homedir();
69
+ }
70
+ };
71
+
72
+ /**
73
+ * Where grants are read from: the OS account record's home directory, never
74
+ * `$HOME` (an agent can set that for one command). Tests swap `resolve`
75
+ * in-process; nothing outside this process can change it.
76
+ */
77
+ export const grantsHome = { resolve: accountHome };
78
+
79
+ /** The default config dir (core/workit-paths.mjs) for the account home, with no override. */
80
+ export const grantsDir = (): string => workitConfigDir({ HOME: grantsHome.resolve() });
81
+
82
+ /** Why grants fell back to the defaults, or null when the real file is used. */
83
+ export const grantsOverride = (): string | null => {
84
+ const configured = path.resolve(resolveConfigDir());
85
+ return configured === path.resolve(grantsDir())
86
+ ? null
87
+ : `the config directory is redirected to ${configured} (WORKIT_CONFIG, WORKIT_CONFIG_DIR or XDG_CONFIG_HOME), but that override does not apply to grants: grants are read only from ${path.join(grantsDir(), "workspaces.json")}, so the D4 defaults apply until grants are set there`;
88
+ };
89
+
90
+ export const GRANT_KINDS = ["push", "pr", "merge", "release", "rerun"] as const;
91
+ export type GrantKind = (typeof GRANT_KINDS)[number];
92
+
93
+ /** One grant: `"verified"` means allowed with an accepted independent verdict. */
94
+ export type GrantValue = boolean | "verified";
95
+ export type Grants = Record<GrantKind, GrantValue>;
96
+
97
+ /** The D4 ceiling: deliver up to "verified, ready"; merging and releasing are asked. */
98
+ export const DEFAULT_GRANTS: Readonly<Grants> = Object.freeze({
99
+ push: true,
100
+ pr: true,
101
+ merge: false,
102
+ release: false,
103
+ rerun: true,
104
+ });
105
+
106
+ /**
107
+ * Where an unnamed request stops (skills read it), lowest to highest:
108
+ * `commit` (default) ends at a local commit; `pr` pushes and opens the PR;
109
+ * `green` opens the PR, then babysits it (CI, threads, base) until it is
110
+ * merge-ready, never merging; `merged` also lands it with `workit pr merge`,
111
+ * and only takes effect with the merge grant (otherwise it acts as `green`).
112
+ * User config only, same rules as grants: any step up needs the user at a
113
+ * terminal; stepping down is free.
114
+ */
115
+ export const DEFAULT_ENDPOINTS = ["commit", "pr", "green", "merged"] as const;
116
+ export type DefaultEndpoint = (typeof DEFAULT_ENDPOINTS)[number];
117
+ export const DEFAULT_ENDPOINT: DefaultEndpoint = "commit";
118
+ export const isDefaultEndpoint = (value: unknown): value is DefaultEndpoint =>
119
+ typeof value === "string" && (DEFAULT_ENDPOINTS as readonly string[]).includes(value);
120
+ /** Whether `next` lets an unnamed request go further than `current`. */
121
+ export const raisesEndpoint = (current: DefaultEndpoint, next: DefaultEndpoint): boolean =>
122
+ DEFAULT_ENDPOINTS.indexOf(next) > DEFAULT_ENDPOINTS.indexOf(current);
123
+
124
+ /**
125
+ * The configured endpoint of one workspace entry. Lenient (D17): a value this
126
+ * version does not know falls back to `commit` and is reported in `issue`, so
127
+ * a newer Workit's endpoint never invalidates the file for an older one.
128
+ */
129
+ function endpointOf(entry: unknown): {
130
+ raw: unknown;
131
+ endpoint: DefaultEndpoint;
132
+ issue?: string;
133
+ } {
134
+ const raw = (entry as { defaultEndpoint?: unknown } | null)?.defaultEndpoint;
135
+ if (raw === undefined) return { raw, endpoint: DEFAULT_ENDPOINT };
136
+ if (isDefaultEndpoint(raw)) return { raw, endpoint: raw };
137
+ return {
138
+ raw,
139
+ endpoint: DEFAULT_ENDPOINT,
140
+ issue: `defaultEndpoint ${JSON.stringify(raw)} is not one of ${DEFAULT_ENDPOINTS.join(", ")}; using ${DEFAULT_ENDPOINT}`,
141
+ };
142
+ }
143
+
144
+ type EndpointContext = {
145
+ workspace: string | null;
146
+ grants: Grants;
147
+ configured: readonly GrantKind[];
148
+ accountConfigured: boolean;
149
+ };
150
+
151
+ /**
152
+ * What the configured endpoint means today, mirroring the grant checks the
153
+ * delivery verbs make (requireGrant): without `push` or `pr` (or without the
154
+ * account explicit grants need) nothing reaches the forge, so anything above
155
+ * `commit` acts as `commit`; without `merge`, `merged` acts as `green`. The
156
+ * reason names the unblock. `merged` stays `merged` under either merge grant;
157
+ * landing still needs an accepted verdict (forge/pr-ops.ts).
158
+ */
159
+ function effectiveEndpoint(
160
+ endpoint: DefaultEndpoint,
161
+ context: EndpointContext,
162
+ ): { endpoint: DefaultEndpoint; reason?: string } {
163
+ if (endpoint === "commit") return { endpoint };
164
+ const missing = (kind: GrantKind) =>
165
+ `${kind} grant missing; ${grantHint(context.workspace, kind)}`;
166
+ if (context.grants.push === false) return { endpoint: "commit", reason: missing("push") };
167
+ if (context.grants.pr === false) return { endpoint: "commit", reason: missing("pr") };
168
+ if (context.configured.length > 0 && !context.accountConfigured)
169
+ return {
170
+ endpoint: "commit",
171
+ reason: `vcs.account missing; ${accountHint(context.workspace)}`,
172
+ };
173
+ if (endpoint === "merged" && context.grants.merge === false)
174
+ return { endpoint: "green", reason: missing("merge") };
175
+ return { endpoint };
176
+ }
177
+
178
+ const STOPS: Record<DefaultEndpoint, string> = {
179
+ commit: "the local commit",
180
+ pr: "the PR",
181
+ green: "a green PR",
182
+ merged: "the merge",
183
+ };
184
+
185
+ /**
186
+ * One line when an explicit grant reaches further than the endpoint: `merge`
187
+ * granted but the endpoint stops at the PR, or `pr` granted with endpoint
188
+ * `commit`. Defaults never trigger it (pr is on by default), so only a grant
189
+ * the user wrote counts. Null when grant and endpoint agree.
190
+ */
191
+ export function endpointMismatch(view: {
192
+ workspace: string | null;
193
+ grants: Grants;
194
+ configured: readonly GrantKind[];
195
+ defaultEndpoint: DefaultEndpoint;
196
+ }): string | null {
197
+ const { defaultEndpoint: endpoint } = view;
198
+ const ws = view.workspace ?? "<workspace>";
199
+ const explicit = (kind: GrantKind) =>
200
+ view.configured.includes(kind) && view.grants[kind] !== false;
201
+ if (explicit("merge") && endpoint !== "merged")
202
+ return `merge granted, but endpoint ${endpoint} stops at ${STOPS[endpoint]}; to land on green: workit grant set ${ws} defaultEndpoint=merged (your terminal)`;
203
+ if (explicit("pr") && endpoint === "commit")
204
+ return `pr granted, but endpoint commit stops at the local commit; to open PRs: workit grant set ${ws} defaultEndpoint=pr (your terminal)`;
205
+ return null;
206
+ }
207
+
208
+ /** The mismatch line for one workspace entry as stored (null for no entry). */
209
+ export function entryMismatch(entry: unknown): string | null {
210
+ if (!entry) return null;
211
+ const { grants } = configuredGrants(entry);
212
+ return endpointMismatch({
213
+ workspace: (entry as { name?: string }).name ?? null,
214
+ grants: { ...DEFAULT_GRANTS, ...grants },
215
+ configured: GRANT_KINDS.filter((kind) => grants[kind] !== undefined),
216
+ defaultEndpoint: endpointOf(entry).endpoint,
217
+ });
218
+ }
219
+
220
+ /** The endpoint fields every grant view carries. */
221
+ export type EndpointView = {
222
+ /** As configured (an unknown value reads as the default, see endpointIssue). */
223
+ defaultEndpoint: DefaultEndpoint;
224
+ /** The raw stored value (null when unset), including one this version does not know. */
225
+ configuredEndpoint: unknown;
226
+ /** What an agent acts on, e.g. `merged` without the merge grant is `green`. */
227
+ effectiveEndpoint: DefaultEndpoint;
228
+ /** Why the effective endpoint is lower than the configured one, with its unblock. */
229
+ endpointReason?: string;
230
+ /** A configured value this version does not know (reported, then ignored). */
231
+ endpointIssue?: string;
232
+ };
233
+
234
+ function endpointView(entry: unknown, context: EndpointContext): EndpointView {
235
+ const { raw, endpoint, issue } = endpointOf(entry);
236
+ const effective = effectiveEndpoint(endpoint, context);
237
+ return {
238
+ defaultEndpoint: endpoint,
239
+ configuredEndpoint: raw ?? null,
240
+ effectiveEndpoint: effective.endpoint,
241
+ ...(effective.reason ? { endpointReason: effective.reason } : {}),
242
+ ...(issue ? { endpointIssue: issue } : {}),
243
+ };
244
+ }
245
+
246
+ /**
247
+ * How a behavior change is verified (S17, #333). `proportional` (default):
248
+ * the review lane computed from the diff decides (core/policy/lane.ts): none
249
+ * needs no verdict, self the author's own `--self` verdict, focused and full
250
+ * a non-author verdict, at every gate including merge. `self`: an observed
251
+ * passing `workit check test` plus the author's own `--self` verdict at
252
+ * normal risk, labelled self-reviewed; merge still needs a non-author
253
+ * verdict. `independent`: a verdict from a session that did not author the
254
+ * branch. User config only, like grants.
255
+ */
256
+ export type VerificationMode = "self" | "independent" | "proportional";
257
+ export const VERIFICATION_MODES: readonly VerificationMode[] = [
258
+ "proportional",
259
+ "self",
260
+ "independent",
261
+ ];
262
+ /**
263
+ * Strictest first. `self` is the loosest: the author's own `--self` verdict
264
+ * satisfies even lanes that `proportional` sends to an independent verifier.
265
+ */
266
+ export const VERIFICATION_STRICTNESS: readonly VerificationMode[] = [
267
+ "independent",
268
+ "proportional",
269
+ "self",
270
+ ];
271
+ export const DEFAULT_VERIFICATION: VerificationMode = "proportional";
272
+ export const verificationOf = (entry: unknown): VerificationMode => {
273
+ const value = (entry as { verification?: unknown } | null)?.verification;
274
+ return VERIFICATION_MODES.includes(value as VerificationMode)
275
+ ? (value as VerificationMode)
276
+ : DEFAULT_VERIFICATION;
277
+ };
278
+
279
+ export type AutonomySource = "autonomy" | "autoApprove" | "default";
280
+
281
+ export type Autonomy = EndpointView & {
282
+ workspace: string | null;
283
+ /** Every kind, with absent ones filled from DEFAULT_GRANTS. */
284
+ grants: Grants;
285
+ /** The kinds the user configured explicitly (the rest are defaults). */
286
+ configured: GrantKind[];
287
+ source: AutonomySource;
288
+ accountConfigured: boolean;
289
+ verification: VerificationMode;
290
+ /**
291
+ * Set when the grants file was not read (see grantsOverride), or when
292
+ * WORKIT_WORKSPACE_NAME (#536) or a linked worktree's main checkout (#540)
293
+ * capped the grants.
294
+ */
295
+ note?: string;
296
+ /** The unblock of each grant a cap lowered: it names the capping entry. */
297
+ unblocks?: Partial<Record<GrantKind, string>>;
298
+ };
299
+
300
+ export type GrantDecision =
301
+ | {
302
+ allowed: true;
303
+ kind: GrantKind;
304
+ /** Where the permission came from. */
305
+ source: AutonomySource;
306
+ /** `merge: true` only: `--unverified --reason` may bypass the verdict (S13). */
307
+ allowUnverified: boolean;
308
+ workspace: string | null;
309
+ }
310
+ | {
311
+ allowed: false;
312
+ kind: GrantKind;
313
+ reason: "grant_required" | "account_required" | "config_invalid";
314
+ error: string;
315
+ unblock: string;
316
+ workspace: string | null;
317
+ };
318
+
319
+ export const isGrantKind = (value: unknown): value is GrantKind =>
320
+ typeof value === "string" && (GRANT_KINDS as readonly string[]).includes(value);
321
+
322
+ const isGrantValue = (value: unknown): value is GrantValue =>
323
+ value === true || value === false || value === "verified";
324
+
325
+ /** `verified` is meaningful only for merge; elsewhere it means allowed. */
326
+ const normalized = (kind: GrantKind, value: GrantValue): GrantValue =>
327
+ value === "verified" && kind !== "merge" ? true : value;
328
+
329
+ /** Legacy `autoApprove` (≤4.x standing approvals) → grants, mapped once on read. */
330
+ const fromAutoApprove = (value: unknown): Partial<Grants> | null => {
331
+ // A standing merge approval maps to merge "verified": an accepted
332
+ // independent verdict is still required (S16 review L1).
333
+ if (value === true) return { push: true, pr: true, merge: "verified" };
334
+ if (!Array.isArray(value)) return null;
335
+ const grants: Partial<Grants> = {};
336
+ for (const item of value as unknown[])
337
+ if (item === "push" || item === "pr") grants[item] = true;
338
+ else if (item === "merge") grants.merge = "verified";
339
+ return Object.keys(grants).length > 0 ? grants : null;
340
+ };
341
+
342
+ /** The explicit grants of one workspace entry, before defaults. */
343
+ export function configuredGrants(entry: unknown): {
344
+ grants: Partial<Grants>;
345
+ source: AutonomySource;
346
+ } {
347
+ const record = (entry ?? {}) as { autonomy?: unknown; autoApprove?: unknown };
348
+ const raw = record.autonomy;
349
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
350
+ const grants: Partial<Grants> = {};
351
+ for (const kind of GRANT_KINDS) {
352
+ const value = (raw as Record<string, unknown>)[kind];
353
+ if (isGrantValue(value)) grants[kind] = normalized(kind, value);
354
+ }
355
+ return { grants, source: "autonomy" };
356
+ }
357
+ const legacy = fromAutoApprove(record.autoApprove);
358
+ return legacy ? { grants: legacy, source: "autoApprove" } : { grants: {}, source: "default" };
359
+ }
360
+
361
+ /** The grants one resolved workspace entry (or none) gives. */
362
+ function autonomyOf(workspace: WorkspaceConfig | null): Autonomy {
363
+ const { grants, source } = configuredGrants(workspace);
364
+ const full = { ...DEFAULT_GRANTS, ...grants };
365
+ const configured = GRANT_KINDS.filter((kind) => grants[kind] !== undefined);
366
+ const accountConfigured = Boolean(workspace?.vcs?.account);
367
+ const name = workspace?.name ?? null;
368
+ return {
369
+ workspace: name,
370
+ grants: full,
371
+ configured,
372
+ source,
373
+ accountConfigured,
374
+ ...endpointView(workspace, {
375
+ workspace: name,
376
+ grants: full,
377
+ configured,
378
+ accountConfigured,
379
+ }),
380
+ verification: verificationOf(workspace),
381
+ };
382
+ }
383
+
384
+ /**
385
+ * The grants, endpoint and verification of `named` never exceed what `where`
386
+ * resolves to without a name, or nothing granted when its entries tie (#536:
387
+ * WORKIT_WORKSPACE_NAME, which any shell can set; #540: a linked worktree's
388
+ * main checkout). Explicit grants without vcs.account reach no forge, so such
389
+ * an entry caps at nothing granted too. A lowered grant's unblock names the
390
+ * capping entry, never the named one. Settings that are not authority stay.
391
+ */
392
+ function capAt(
393
+ named: Autonomy,
394
+ where: string | Error | { entry: WorkspaceConfig },
395
+ by: string,
396
+ why: string,
397
+ main = false,
398
+ ): Autonomy {
399
+ let folder: Autonomy | null = null;
400
+ let none = "";
401
+ try {
402
+ if (where instanceof Error) throw where;
403
+ folder = autonomyOf(
404
+ typeof where === "string" ? resolveWorkspaceFrom(where, grantsDir()) : where.entry,
405
+ );
406
+ } catch (error) {
407
+ // Its entries tie (the only resolver error once `named` resolved), or git
408
+ // did not answer (`where`): nothing granted.
409
+ none = (error as Error).message;
410
+ }
411
+ // A name equal to the folder's entry is the folder's resolution; a main
412
+ // checkout caps even its own entry, so naming it never beats the unnamed.
413
+ if (!main && folder?.workspace === named.workspace) return named;
414
+ const nothing = !folder || (folder.configured.length > 0 && !folder.accountConfigured);
415
+ const grants = { ...named.grants };
416
+ const unblocks = { ...named.unblocks };
417
+ const lowered: string[] = [];
418
+ for (const kind of GRANT_KINDS) {
419
+ const floor = !nothing && folder!.grants[kind];
420
+ if (!raises(kind, floor, grants[kind])) continue;
421
+ grants[kind] = floor;
422
+ unblocks[kind] = folder
423
+ ? nothing
424
+ ? accountHint(folder.workspace)
425
+ : grantHint(folder.workspace, kind)
426
+ : `ask the user to fix: ${none}`;
427
+ lowered.push(kind);
428
+ }
429
+ // The folder's effective endpoint already reflects its own grants.
430
+ const ceiling = (!nothing && folder!.effectiveEndpoint) || DEFAULT_ENDPOINT;
431
+ const endpoint = raisesEndpoint(ceiling, named.effectiveEndpoint);
432
+ if (endpoint) lowered.push("defaultEndpoint");
433
+ const strict = folder?.verification ?? "independent";
434
+ const looser =
435
+ VERIFICATION_STRICTNESS.indexOf(named.verification) > VERIFICATION_STRICTNESS.indexOf(strict);
436
+ if (looser) lowered.push("verification");
437
+ if (lowered.length === 0) return named;
438
+ const note = `${by}: ${lowered.join(", ")} capped at ${folder ? `workspace ${folder.workspace}` : `nothing (${none})`}; ${why}`;
439
+ return {
440
+ ...named,
441
+ grants,
442
+ unblocks,
443
+ ...(endpoint ? { effectiveEndpoint: ceiling, endpointReason: undefined } : {}),
444
+ verification: looser ? strict : named.verification,
445
+ note: named.note ? `${named.note}; ${note}` : note,
446
+ };
447
+ }
448
+
449
+ /** The grants for the workspace `cwd` belongs to (read-only). */
450
+ export function resolveAutonomy(cwd: string, target?: LazyTarget): Autonomy {
451
+ const override = grantsOverride();
452
+ // No entry: the D4 defaults.
453
+ if (override) return { ...autonomyOf(null), note: override };
454
+ const name = process.env.WORKIT_WORKSPACE_NAME?.trim() || undefined;
455
+ let autonomy = autonomyOf(resolveWorkspaceFrom(cwd, grantsDir(), name));
456
+ if (name)
457
+ autonomy = capAt(autonomy, cwd, `WORKIT_WORKSPACE_NAME=${name}`, "a name never raises grants");
458
+ const above = checkoutsAbove(cwd);
459
+ for (const where of above instanceof Error ? [above] : above)
460
+ autonomy = capAt(
461
+ autonomy,
462
+ where,
463
+ where instanceof Error ? "git" : `checkout ${where}`,
464
+ "a worktree or submodule never exceeds it",
465
+ true,
466
+ );
467
+ return capAtRemotes(autonomy, cwd, target);
468
+ }
469
+
470
+ /**
471
+ * Every entry whose `remotes` claims one of the checkout's remote URLs caps
472
+ * its grants (#549): grants follow the folder, so a fresh repository whose
473
+ * origin is a stricter workspace's remote would otherwise push there with the
474
+ * folder's grants. Git is asked only when some entry claims remotes; a git
475
+ * error or an unreadable URL caps at nothing.
476
+ */
477
+ function capAtRemotes(autonomy: Autonomy, cwd: string, target?: LazyTarget): Autonomy {
478
+ const claiming = loadWorkspacesFrom(grantsDir()).filter((entry) => entry.remotes?.length);
479
+ if (claiming.length === 0) return autonomy;
480
+ // The action's own target when the verb names it (a fork pushes to its own
481
+ // origin with the folder's grants); every remote otherwise.
482
+ // Git is asked only now, when some entry claims remotes (no extra call otherwise).
483
+ const remotes =
484
+ target === undefined ? checkoutRemotes(cwd) : typeof target === "function" ? target() : target;
485
+ if (remotes instanceof Error)
486
+ return capAt(
487
+ autonomy,
488
+ remotes,
489
+ target === undefined ? "git" : "target",
490
+ "a target that cannot be resolved never raises grants",
491
+ );
492
+ let capped = autonomy;
493
+ for (const remote of Array.isArray(remotes) ? remotes : remotes.remotes)
494
+ for (const entry of claiming) {
495
+ const pattern = entry.remotes!.find((item) => remoteMatches(item, remote.key));
496
+ if (pattern)
497
+ capped = capAt(
498
+ capped,
499
+ { entry },
500
+ `remote ${remote.name} ${remote.url} (workspace ${entry.name} claims ${pattern})`,
501
+ "a checkout never exceeds the workspace that owns its remote",
502
+ );
503
+ }
504
+ return capped;
505
+ }
506
+
507
+ /**
508
+ * Whether grants could stop a raw `kind` command: a workspace claims remotes,
509
+ * or an entry leaves `kind` ungranted (or has grants but no account). False
510
+ * costs no git call (the shell hook asks first).
511
+ */
512
+ export const grantsMatter = (kind: GrantKind): boolean =>
513
+ !grantsOverride() &&
514
+ loadWorkspacesFrom(grantsDir()).some((entry) => {
515
+ const autonomy = autonomyOf(entry);
516
+ return (
517
+ entry.remotes?.length ||
518
+ autonomy.grants[kind] === false ||
519
+ (autonomy.configured.length > 0 && !autonomy.accountConfigured)
520
+ );
521
+ });
522
+
523
+ /** Whether any workspace claims remotes (then targets are worth resolving). */
524
+ export const remotesClaimed = (dir: string = grantsDir()): boolean =>
525
+ loadWorkspacesFrom(dir).some((entry) => entry.remotes?.length);
526
+
527
+ /** The entries whose `remotes` claim one of a target's URLs (grant show). */
528
+ export function targetClaims(
529
+ target: GrantTarget,
530
+ dir: string = grantsDir(),
531
+ ): { url: string; workspace: string; pattern: string }[] | Error {
532
+ if (target instanceof Error) return target;
533
+ const claiming = loadWorkspacesFrom(dir).filter((entry) => entry.remotes?.length);
534
+ return target.remotes.flatMap((remote) =>
535
+ claiming.flatMap((entry) => {
536
+ const pattern = entry.remotes!.find((item) => remoteMatches(item, remote.key));
537
+ return pattern ? [{ url: remote.url, workspace: entry.name, pattern }] : [];
538
+ }),
539
+ );
540
+ }
541
+
542
+ /** The entries whose `remotes` claim one of `remotes` (grant show, doctor). */
543
+ const claimsOf = (remotes: CheckoutRemote[], dir: string) => {
544
+ const claiming = loadWorkspacesFrom(dir).filter((entry) => entry.remotes?.length);
545
+ return remotes.flatMap((remote) =>
546
+ claiming.flatMap((entry) => {
547
+ const pattern = entry.remotes!.find((item) => remoteMatches(item, remote.key));
548
+ return pattern
549
+ ? [{ remote: remote.name, url: remote.url, workspace: entry.name, pattern }]
550
+ : [];
551
+ }),
552
+ );
553
+ };
554
+
555
+ /** The entries whose `remotes` claim one of `cwd`'s remote URLs (grant show, doctor). */
556
+ export function remoteClaims(
557
+ cwd: string,
558
+ dir: string = grantsDir(),
559
+ ): { remote: string; url: string; workspace: string; pattern: string }[] | Error {
560
+ if (!remotesClaimed(dir)) return [];
561
+ const remotes = checkoutRemotes(cwd);
562
+ return remotes instanceof Error ? remotes : claimsOf(remotes, dir);
563
+ }
564
+
565
+ /**
566
+ * What `grant show` and doctor warn about remotes (#549): a remote of this
567
+ * checkout another workspace claims (a warning: grants here are capped at
568
+ * it), and a workspace that claims no remotes (a note, since every entry
569
+ * starts without: a checkout elsewhere whose origin is its remote keeps that
570
+ * folder's grants), with the command that claims a remote no other workspace
571
+ * claims. Git is asked once, and only when one of them needs it.
572
+ */
573
+ export function remoteFindings(
574
+ cwd: string,
575
+ dir: string = grantsDir(),
576
+ ): { warnings: string[]; notes: string[] } {
577
+ let own: WorkspaceConfig | null = null;
578
+ try {
579
+ own = resolveWorkspaceFrom(cwd, dir);
580
+ } catch {
581
+ return { warnings: [], notes: [] };
582
+ }
583
+ const unclaimed = own !== null && !own.remotes?.length;
584
+ if (!unclaimed && !remotesClaimed(dir)) return { warnings: [], notes: [] };
585
+ const remotes = checkoutRemotes(cwd);
586
+ if (remotes instanceof Error && remotesClaimed(dir))
587
+ return { warnings: [`${remotes.message}: grants here are capped at nothing`], notes: [] };
588
+ const listed = remotes instanceof Error ? [] : remotes;
589
+ const others = claimsOf(listed, dir).filter((claim) => claim.workspace !== own?.name);
590
+ const warnings = others.map(
591
+ (claim) =>
592
+ `remote ${claim.remote} ${claim.url} is claimed by workspace ${claim.workspace} (${claim.pattern}): an action that targets it never exceeds ${claim.workspace}'s grants`,
593
+ );
594
+ const notes: string[] = [];
595
+ if (own && unclaimed) {
596
+ // Never suggest a remote another workspace already claims.
597
+ const free = listed.filter((remote) => !others.some((claim) => claim.url === remote.url));
598
+ const suggest = free.find((remote) => remote.name === "origin") ?? free[0];
599
+ notes.push(
600
+ `workspace ${own.name} claims no remotes, so a folder outside it whose origin is ${suggest ? suggest.url : "one of its remotes"} keeps that folder's grants; to claim it, the user runs in their own terminal: workit workspace set ${own.name} remotes=${suggest ? suggest.url : "<url>"}`,
601
+ );
602
+ }
603
+ return { warnings, notes };
604
+ }
605
+
606
+ const accountOf = (entry: unknown): unknown =>
607
+ (entry as { vcs?: { account?: unknown } } | null)?.vcs?.account;
608
+
609
+ const suggested = (kind: GrantKind): string => (kind === "merge" ? "verified" : "true");
610
+
611
+ const accountHint = (workspace: string | null): string =>
612
+ `set vcs.account for workspace "${workspace ?? "?"}" in ${configFilePath("workspaces.json")}`;
613
+
614
+ /** The one way to clear a missing grant: the user raises it from a terminal. */
615
+ export const grantHint = (workspace: string | null, kind: GrantKind): string =>
616
+ `ask the user to run, in their own terminal: workit grant set ${workspace ?? "<workspace>"} ${kind}=${suggested(kind)}`;
617
+
618
+ /**
619
+ * The seam every delivery verb consults before an external effect. `forge`
620
+ * says the effect goes through a GitHub/GitLab account (a push to a local
621
+ * path does not).
622
+ */
623
+ export function requireGrant(
624
+ cwd: string,
625
+ kind: GrantKind,
626
+ options: {
627
+ forge?: boolean;
628
+ /** What the action pushes to or acts on (core/remotes.ts pushTarget, forgeTarget). */
629
+ target?: LazyTarget;
630
+ } = {},
631
+ ): GrantDecision {
632
+ let autonomy: Autonomy;
633
+ try {
634
+ autonomy = resolveAutonomy(cwd, options.target);
635
+ } catch (error) {
636
+ return {
637
+ allowed: false,
638
+ kind,
639
+ reason: "config_invalid",
640
+ error: `grant_unreadable: ${error instanceof Error ? error.message : String(error)}`,
641
+ unblock: `fix ${configFilePath("workspaces.json")} (workit init → Workspaces)`,
642
+ workspace: null,
643
+ };
644
+ }
645
+ const { workspace } = autonomy;
646
+ const value = autonomy.grants[kind];
647
+ const configured = autonomy.configured.includes(kind);
648
+ if (value === false)
649
+ return {
650
+ allowed: false,
651
+ kind,
652
+ reason: "grant_required",
653
+ error: `grant_required: ${kind} is not granted for ${workspace ? `workspace "${workspace}"` : "this checkout (no workspace matches it)"}${configured ? "" : " (the default ceiling stops at verified, ready)"}${autonomy.note ? `; ${autonomy.note}` : ""}`,
654
+ unblock:
655
+ autonomy.unblocks?.[kind] ??
656
+ (workspace
657
+ ? grantHint(workspace, kind)
658
+ : "ask the user to add a workspace for this checkout (workit init → Workspaces), then: workit grant set <workspace> " +
659
+ `${kind}=${suggested(kind)}`),
660
+ workspace,
661
+ };
662
+ if (
663
+ autonomy.configured.length > 0 &&
664
+ !autonomy.accountConfigured &&
665
+ options.forge !== false &&
666
+ kind !== "release"
667
+ )
668
+ return {
669
+ allowed: false,
670
+ kind,
671
+ reason: "account_required",
672
+ error: `account_required: workspace "${workspace ?? "?"}" has autonomy grants but no vcs.account, so the credential cannot be checked`,
673
+ unblock: accountHint(workspace),
674
+ workspace,
675
+ };
676
+ return {
677
+ allowed: true,
678
+ kind,
679
+ source: configured ? autonomy.source : "default",
680
+ allowUnverified: kind === "merge" && value === true,
681
+ workspace,
682
+ };
683
+ }
684
+
685
+ /**
686
+ * Why a raw shell command (the pre-tool hook: `git push`, `gh pr create`, a
687
+ * release) may not run, or null. Only what a workspace governs is judged: a
688
+ * workspace claims remotes, or some entry could leave `kind` ungranted
689
+ * (otherwise no git runs). A folder outside every workspace, with nothing
690
+ * claimed, keeps today's behaviour. A target that cannot be resolved denies.
691
+ */
692
+ export function rawGrantDenial(
693
+ cwd: string,
694
+ kind: GrantKind,
695
+ target?: LazyTarget,
696
+ ): { error: string; unblock: string } | null {
697
+ const claimed = remotesClaimed();
698
+ if (!grantsMatter(kind)) return null;
699
+ // Resolved once: the cap and the claim check below share it.
700
+ let resolved: GrantTarget | undefined;
701
+ const lazy = target && (() => (resolved ??= typeof target === "function" ? target() : target));
702
+ let autonomy: Autonomy;
703
+ try {
704
+ autonomy = resolveAutonomy(cwd, lazy);
705
+ } catch (error) {
706
+ return claimed
707
+ ? {
708
+ error: String(error),
709
+ unblock: `fix ${configFilePath("workspaces.json")}`,
710
+ }
711
+ : null;
712
+ }
713
+ const { workspace, note } = autonomy;
714
+ const capped = autonomy.unblocks?.[kind];
715
+ if (autonomy.grants[kind] !== false) return null;
716
+ if (workspace !== null || capped)
717
+ return {
718
+ error: `${kind} is not granted for ${workspace ? `workspace ${workspace}` : "this checkout"}${note ? ` (${note})` : ""}`,
719
+ unblock: capped ?? grantHint(workspace, kind),
720
+ };
721
+ // A folder outside every workspace: judged only by a workspace that claims
722
+ // the target and does not grant `kind` (a default-false grant records no cap).
723
+ if (!claimed || !lazy) return null;
724
+ const claims = targetClaims(lazy());
725
+ if (claims instanceof Error)
726
+ return { error: `${kind}: ${claims.message}`, unblock: "name a target workit can resolve" };
727
+ const entries = loadWorkspacesFrom(grantsDir());
728
+ const strict = claims.find(({ workspace: name }) => {
729
+ const entry = entries.find((item) => item.name === name);
730
+ return entry && autonomyOf(entry).grants[kind] === false;
731
+ });
732
+ return strict
733
+ ? {
734
+ error: `${kind} is not granted for workspace ${strict.workspace}, which claims ${strict.url} (${strict.pattern})`,
735
+ unblock: grantHint(strict.workspace, kind),
736
+ }
737
+ : null;
738
+ }
739
+
740
+ /** Whether `next` grants more than `current` (false < "verified" < true). */
741
+ export const raises = (kind: GrantKind, current: GrantValue, next: GrantValue): boolean => {
742
+ const rank = (value: GrantValue) => (value === true ? 2 : value === "verified" ? 1 : 0);
743
+ return rank(normalized(kind, next)) > rank(normalized(kind, current));
744
+ };
745
+
746
+ export type GrantWrite =
747
+ | (EndpointView & {
748
+ ok: true;
749
+ path: string;
750
+ backup: string | null;
751
+ workspace: string;
752
+ grants: Grants;
753
+ configured: GrantKind[];
754
+ verification: VerificationMode;
755
+ })
756
+ | {
757
+ ok: false;
758
+ code: "not_found" | "invalid_input" | "failed";
759
+ error: string;
760
+ };
761
+
762
+ /**
763
+ * Set (`value`) or unset (`undefined`) grants of a named workspace in the
764
+ * user's workspaces.json. The caller decides whether raising is allowed (an
765
+ * interactive terminal only); this only writes, atomically, after copying
766
+ * the previous file to `workspaces.json.bak`. A legacy `autoApprove` is
767
+ * folded into `autonomy` on the first write and removed.
768
+ */
769
+ export function writeGrants(
770
+ workspaceName: string,
771
+ changes: readonly { kind: GrantKind; value: GrantValue | undefined }[],
772
+ dir: string = grantsDir(),
773
+ /** A new default endpoint; `null` removes it (back to `commit`). */
774
+ endpoint?: DefaultEndpoint | null,
775
+ /** A new verification mode; `null` removes it (back to `proportional`). */
776
+ verification?: VerificationMode | null,
777
+ ): GrantWrite {
778
+ const current = readWorkspacesResult(dir);
779
+ if (current.status === "missing")
780
+ return {
781
+ ok: false,
782
+ code: "not_found",
783
+ error: `${current.path} does not exist`,
784
+ };
785
+ if (current.status !== "valid" || !current.document)
786
+ return {
787
+ ok: false,
788
+ code: "invalid_input",
789
+ error: current.error ?? "invalid workspaces.json",
790
+ };
791
+ const document = structuredClone(current.document) as {
792
+ workspaces?: Record<string, unknown>[];
793
+ };
794
+ const entry = document.workspaces?.find((item) => item.name === workspaceName);
795
+ if (!entry)
796
+ return {
797
+ ok: false,
798
+ code: "not_found",
799
+ error: `no workspace named "${workspaceName}" in ${current.path}`,
800
+ };
801
+ const { grants } = configuredGrants(entry);
802
+ for (const { kind, value } of changes)
803
+ if (value === undefined) delete grants[kind];
804
+ else grants[kind] = normalized(kind, value);
805
+ // Keys this version does not know stay as they are (S16 review L6).
806
+ const unknown = Object.fromEntries(
807
+ Object.entries(
808
+ entry.autonomy && typeof entry.autonomy === "object" && !Array.isArray(entry.autonomy)
809
+ ? (entry.autonomy as Record<string, unknown>)
810
+ : {},
811
+ ).filter(([key]) => !isGrantKind(key)),
812
+ );
813
+ delete entry.autoApprove;
814
+ if (endpoint === null || endpoint === DEFAULT_ENDPOINT) delete entry.defaultEndpoint;
815
+ else if (endpoint) entry.defaultEndpoint = endpoint;
816
+ if (verification === null || verification === DEFAULT_VERIFICATION) delete entry.verification;
817
+ else if (verification) entry.verification = verification;
818
+ const autonomy = {
819
+ ...unknown,
820
+ ...Object.fromEntries(
821
+ GRANT_KINDS.filter((item) => grants[item] !== undefined).map((item) => [item, grants[item]]),
822
+ ),
823
+ };
824
+ if (Object.keys(autonomy).length === 0) delete entry.autonomy;
825
+ else entry.autonomy = autonomy;
826
+ const validated = validateWorkspacesDocument(document, current.path);
827
+ if (validated.status !== "valid")
828
+ return {
829
+ ok: false,
830
+ code: "invalid_input",
831
+ error: validated.error ?? "invalid result",
832
+ };
833
+ // Re-read just before writing: a concurrent edit wins over this one.
834
+ const latest = readWorkspacesResult(dir);
835
+ if (latest.revision !== current.revision)
836
+ return {
837
+ ok: false,
838
+ code: "failed",
839
+ error: `${current.path} changed; retry`,
840
+ };
841
+ let backup: string | null = null;
842
+ try {
843
+ if (existsSync(current.path)) {
844
+ backup = `${current.path}.bak`;
845
+ copyFileSync(current.path, backup);
846
+ }
847
+ writeFileAtomic(current.path, `${JSON.stringify(document, null, 2)}\n`);
848
+ } catch (error) {
849
+ return {
850
+ ok: false,
851
+ code: "failed",
852
+ error: `failed to write ${current.path}: ${error instanceof Error ? error.message : String(error)}`,
853
+ };
854
+ }
855
+ const full = { ...DEFAULT_GRANTS, ...grants };
856
+ const configured = GRANT_KINDS.filter((kind) => grants[kind] !== undefined);
857
+ return {
858
+ ok: true,
859
+ path: current.path,
860
+ backup,
861
+ workspace: workspaceName,
862
+ grants: full,
863
+ configured,
864
+ ...endpointView(entry, {
865
+ workspace: workspaceName,
866
+ grants: full,
867
+ configured,
868
+ accountConfigured: Boolean(accountOf(entry)),
869
+ }),
870
+ verification: verificationOf(entry),
871
+ };
872
+ }
873
+
874
+ /** Every workspace's grants, for `workit grant show --all`. */
875
+ export function listGrants(dir: string = grantsDir()): {
876
+ path: string;
877
+ workspaces: (EndpointView & {
878
+ name: string;
879
+ glob: string;
880
+ grants: Grants;
881
+ configured: GrantKind[];
882
+ verification: VerificationMode;
883
+ })[];
884
+ error?: string;
885
+ } {
886
+ const current = readWorkspacesResult(dir);
887
+ if (current.status === "malformed" || current.status === "invalid")
888
+ return { path: current.path, workspaces: [], error: current.error };
889
+ return {
890
+ path: current.path,
891
+ workspaces: current.entries.map((entry) => {
892
+ const { grants } = configuredGrants(entry);
893
+ const full = { ...DEFAULT_GRANTS, ...grants };
894
+ const configured = GRANT_KINDS.filter((kind) => grants[kind] !== undefined);
895
+ return {
896
+ name: entry.name,
897
+ glob: entry.glob,
898
+ grants: full,
899
+ configured,
900
+ ...endpointView(entry, {
901
+ workspace: entry.name,
902
+ grants: full,
903
+ configured,
904
+ accountConfigured: Boolean(entry.vcs?.account),
905
+ }),
906
+ verification: verificationOf(entry),
907
+ };
908
+ }),
909
+ };
910
+ }