@brainervirus/workit-core 5.0.0 → 6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "5.0.0",
3
+ "version": "6.0.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
package/src/autonomy.ts CHANGED
@@ -1,37 +1,113 @@
1
- // Autonomy grants (D4, D15; design §0 #12, §4.2): the per-workspace ceiling on
2
- // what an agent may deliver without asking. S11 introduces the reader and the
3
- // one seam every delivery verb consults, `requireGrant(kind)`; S16 adds
4
- // `workit grant`, the D4 defaults and the legacy `autoApprove` mapping.
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
5
  //
6
- // Today (S11):
7
- // - Grants are read only from the matched workspace entry in the user's
8
- // `~/.config/workit/workspaces.json` (`"autonomy": {"push": true, "pr": true,
9
- // "merge": false | true | "verified", "release": false}`), never from a repo
10
- // file, and nothing here writes them (D15: never raised headless).
11
- // - An explicit value is honored: `false` denies with `grant_required`, `true`
12
- // allows, and `merge: "verified"` allows only with an accepted independent
13
- // verdict (S13). `merge: true` is the only way to merge without one.
14
- // - An absent grant is allowed on the host's own authority (`source:
15
- // "host_authority"`): the host permission prompt is the ceiling, and a merge
16
- // still needs an accepted verdict. S16 replaces that default with the D4
17
- // ceiling `{push: true, pr: true, merge: false, release: false}`.
18
- // - Grants require an account (design §2.0 Identity): a workspace with any
19
- // grant but no `vcs.account` cannot push, open or merge through a forge,
20
- // so a personal grant never rides a work credential.
21
- import { resolveRuntimeWorkspaceVcs } from "./core/workspaces";
22
-
23
- export type GrantKind = "push" | "pr" | "merge" | "release";
24
- const GRANT_KINDS: readonly GrantKind[] = ["push", "pr", "merge", "release"];
25
-
26
- /** One grant as configured: `"verified"` means allowed with an accepted verdict. */
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` has no consuming verb yet; it is reserved, not enforced.
17
+ // - Defaults (D4) when a kind is absent: push, pr and rerun are allowed;
18
+ // merge and release need an explicit grant. The default ceiling is a stack
19
+ // opened, CI green and independently verified: "verified, ready".
20
+ // - `merge: "verified"` allows a merge only with an accepted independent
21
+ // verdict (S13); `merge: true` is the only way to merge without one.
22
+ // - A legacy `autoApprove: true | [classes]` maps once to grants (its `merge`
23
+ // class becomes `merge: "verified"`; `branch`/`commit` are local, dropped).
24
+ // - Explicitly configured grants require an account (design §2.0 Identity):
25
+ // a workspace with grants but no `vcs.account` cannot push, open or merge
26
+ // through a forge, so a personal grant never rides a work credential.
27
+ // - Protected-branch pushes stay denied by the git verbs, and the host
28
+ // permission prompt applies in addition: a host deny always wins.
29
+ import { copyFileSync, existsSync } from "node:fs";
30
+ import os from "node:os";
31
+ import path from "node:path";
32
+ import { resolveConfigDir } from "./core/config";
33
+ import { writeFileAtomic } from "./core/safe-write";
34
+ import {
35
+ readWorkspacesResult,
36
+ resolveWorkspaceFrom,
37
+ validateWorkspacesDocument,
38
+ } from "./core/workspaces";
39
+
40
+ /**
41
+ * Grants are read only from the user's real config directory,
42
+ * `<account home>/.config/workit` (D15; see grantsHome). The WORKFLOW_TOOLKIT_CONFIG(_DIR) and
43
+ * XDG_CONFIG_HOME overrides that redirect the rest of the config never
44
+ * redirect grants: an agent could point them at a file it wrote. While an
45
+ * override points elsewhere, grants resolve to the D4 defaults.
46
+ */
47
+ const accountHome = (): string => {
48
+ try {
49
+ return os.userInfo().homedir;
50
+ } catch {
51
+ return os.homedir();
52
+ }
53
+ };
54
+
55
+ /**
56
+ * Where grants are read from: the OS account record's home directory, never
57
+ * `$HOME` (an agent can set that for one command). Tests swap `resolve`
58
+ * in-process; nothing outside this process can change it.
59
+ */
60
+ export const grantsHome = { resolve: accountHome };
61
+
62
+ export const grantsDir = (): string => path.join(grantsHome.resolve(), ".config", "workit");
63
+
64
+ /** Why grants fell back to the defaults, or null when the real file is used. */
65
+ export const grantsOverride = (): string | null => {
66
+ const configured = path.resolve(resolveConfigDir());
67
+ return configured === path.resolve(grantsDir())
68
+ ? null
69
+ : `the config directory is redirected to ${configured} (WORKFLOW_TOOLKIT_CONFIG, WORKFLOW_TOOLKIT_CONFIG_DIR or XDG_CONFIG_HOME); grants are read only from ${path.join(grantsDir(), "workspaces.json")}, so the D4 defaults apply`;
70
+ };
71
+
72
+ export const GRANT_KINDS = ["push", "pr", "merge", "release", "rerun"] as const;
73
+ export type GrantKind = (typeof GRANT_KINDS)[number];
74
+
75
+ /** One grant: `"verified"` means allowed with an accepted independent verdict. */
27
76
  export type GrantValue = boolean | "verified";
77
+ export type Grants = Record<GrantKind, GrantValue>;
78
+
79
+ /** The D4 ceiling: deliver up to "verified, ready"; merging and releasing are asked. */
80
+ export const DEFAULT_GRANTS: Readonly<Grants> = Object.freeze({
81
+ push: true,
82
+ pr: true,
83
+ merge: false,
84
+ release: false,
85
+ rerun: true,
86
+ });
87
+
88
+ /**
89
+ * Where an unnamed request stops (skills read it): `commit` (default) ends at
90
+ * a local commit; `pr` goes on to push and open the PR. User config only,
91
+ * same rules as grants: raising commit → pr needs the user at a terminal.
92
+ */
93
+ export type DefaultEndpoint = "commit" | "pr";
94
+ export const DEFAULT_ENDPOINT: DefaultEndpoint = "commit";
95
+ const endpointOf = (entry: unknown): DefaultEndpoint =>
96
+ (entry as { defaultEndpoint?: unknown } | null)?.defaultEndpoint === "pr" ? "pr" : "commit";
97
+
98
+ export type AutonomySource = "autonomy" | "autoApprove" | "default";
28
99
 
29
100
  export type Autonomy = {
30
101
  workspace: string | null;
31
- /** Explicitly configured grants; a kind that is absent falls back to host authority. */
32
- grants: Partial<Record<GrantKind, GrantValue>>;
33
- source: "autonomy" | "host_authority";
102
+ /** Every kind, with absent ones filled from DEFAULT_GRANTS. */
103
+ grants: Grants;
104
+ /** The kinds the user configured explicitly (the rest are defaults). */
105
+ configured: GrantKind[];
106
+ source: AutonomySource;
34
107
  accountConfigured: boolean;
108
+ defaultEndpoint: DefaultEndpoint;
109
+ /** Set when the grants file was not read (see grantsOverride). */
110
+ note?: string;
35
111
  };
36
112
 
37
113
  export type GrantDecision =
@@ -39,7 +115,7 @@ export type GrantDecision =
39
115
  allowed: true;
40
116
  kind: GrantKind;
41
117
  /** Where the permission came from. */
42
- source: "autonomy" | "host_authority";
118
+ source: AutonomySource;
43
119
  /** A merge must also have an accepted independent verdict (S13). */
44
120
  requireVerdict: boolean;
45
121
  workspace: string | null;
@@ -53,29 +129,82 @@ export type GrantDecision =
53
129
  workspace: string | null;
54
130
  };
55
131
 
132
+ export const isGrantKind = (value: unknown): value is GrantKind =>
133
+ typeof value === "string" && (GRANT_KINDS as readonly string[]).includes(value);
134
+
56
135
  const isGrantValue = (value: unknown): value is GrantValue =>
57
136
  value === true || value === false || value === "verified";
58
137
 
59
- /** The grants for the workspace `cwd` belongs to (read-only). */
60
- export function resolveAutonomy(cwd: string): Autonomy {
61
- const workspace = resolveRuntimeWorkspaceVcs(cwd);
62
- const raw = (workspace as { autonomy?: unknown } | null)?.autonomy;
63
- const grants: Autonomy["grants"] = {};
64
- if (raw && typeof raw === "object" && !Array.isArray(raw))
138
+ /** `verified` is meaningful only for merge; elsewhere it means allowed. */
139
+ const normalized = (kind: GrantKind, value: GrantValue): GrantValue =>
140
+ value === "verified" && kind !== "merge" ? true : value;
141
+
142
+ /** Legacy `autoApprove` (≤4.x standing approvals) → grants, mapped once on read. */
143
+ const fromAutoApprove = (value: unknown): Partial<Grants> | null => {
144
+ // A standing merge approval maps to merge "verified": an accepted
145
+ // independent verdict is still required (S16 review L1).
146
+ if (value === true) return { push: true, pr: true, merge: "verified" };
147
+ if (!Array.isArray(value)) return null;
148
+ const grants: Partial<Grants> = {};
149
+ for (const item of value as unknown[])
150
+ if (item === "push" || item === "pr") grants[item] = true;
151
+ else if (item === "merge") grants.merge = "verified";
152
+ return Object.keys(grants).length > 0 ? grants : null;
153
+ };
154
+
155
+ /** The explicit grants of one workspace entry, before defaults. */
156
+ export function configuredGrants(entry: unknown): {
157
+ grants: Partial<Grants>;
158
+ source: AutonomySource;
159
+ } {
160
+ const record = (entry ?? {}) as { autonomy?: unknown; autoApprove?: unknown };
161
+ const raw = record.autonomy;
162
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
163
+ const grants: Partial<Grants> = {};
65
164
  for (const kind of GRANT_KINDS) {
66
165
  const value = (raw as Record<string, unknown>)[kind];
67
- if (isGrantValue(value)) grants[kind] = value;
166
+ if (isGrantValue(value)) grants[kind] = normalized(kind, value);
68
167
  }
168
+ return { grants, source: "autonomy" };
169
+ }
170
+ const legacy = fromAutoApprove(record.autoApprove);
171
+ return legacy ? { grants: legacy, source: "autoApprove" } : { grants: {}, source: "default" };
172
+ }
173
+
174
+ /** The grants for the workspace `cwd` belongs to (read-only). */
175
+ export function resolveAutonomy(cwd: string): Autonomy {
176
+ const override = grantsOverride();
177
+ if (override)
178
+ return {
179
+ workspace: null,
180
+ grants: { ...DEFAULT_GRANTS },
181
+ configured: [],
182
+ source: "default",
183
+ accountConfigured: false,
184
+ defaultEndpoint: DEFAULT_ENDPOINT,
185
+ note: override,
186
+ };
187
+ const workspace = resolveWorkspaceFrom(
188
+ cwd,
189
+ grantsDir(),
190
+ process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined,
191
+ );
192
+ const { grants, source } = configuredGrants(workspace);
69
193
  return {
70
194
  workspace: workspace?.name ?? null,
71
- grants,
72
- source: Object.keys(grants).length > 0 ? "autonomy" : "host_authority",
195
+ grants: { ...DEFAULT_GRANTS, ...grants },
196
+ configured: GRANT_KINDS.filter((kind) => grants[kind] !== undefined),
197
+ source,
73
198
  accountConfigured: Boolean(workspace?.vcs?.account),
199
+ defaultEndpoint: endpointOf(workspace),
74
200
  };
75
201
  }
76
202
 
77
- const grantHint = (workspace: string | null, kind: GrantKind): string =>
78
- `ask the user to set "autonomy": {"${kind}": ${kind === "merge" ? '"verified"' : "true"}} for workspace "${workspace ?? "?"}" in ~/.config/workit/workspaces.json (S16: workit grant set ${workspace ?? "<workspace>"} ${kind}=${kind === "merge" ? "verified" : "true"})`;
203
+ const suggested = (kind: GrantKind): string => (kind === "merge" ? "verified" : "true");
204
+
205
+ /** The one way to clear a missing grant: the user raises it from a terminal. */
206
+ export const grantHint = (workspace: string | null, kind: GrantKind): string =>
207
+ `ask the user to run, in their own terminal: workit grant set ${workspace ?? "<workspace>"} ${kind}=${suggested(kind)}`;
79
208
 
80
209
  /**
81
210
  * The seam every delivery verb consults before an external effect. `forge`
@@ -102,17 +231,21 @@ export function requireGrant(
102
231
  }
103
232
  const { workspace } = autonomy;
104
233
  const value = autonomy.grants[kind];
234
+ const configured = autonomy.configured.includes(kind);
105
235
  if (value === false)
106
236
  return {
107
237
  allowed: false,
108
238
  kind,
109
239
  reason: "grant_required",
110
- error: `grant_required: ${kind} is not granted for workspace "${workspace ?? "?"}"`,
111
- unblock: grantHint(workspace, kind),
240
+ 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}` : ""}`,
241
+ unblock: workspace
242
+ ? grantHint(workspace, kind)
243
+ : "ask the user to add a workspace for this checkout (workit init → Workspaces), then: workit grant set <workspace> " +
244
+ `${kind}=${suggested(kind)}`,
112
245
  workspace,
113
246
  };
114
247
  if (
115
- autonomy.source === "autonomy" &&
248
+ autonomy.configured.length > 0 &&
116
249
  !autonomy.accountConfigured &&
117
250
  options.forge !== false &&
118
251
  kind !== "release"
@@ -128,8 +261,140 @@ export function requireGrant(
128
261
  return {
129
262
  allowed: true,
130
263
  kind,
131
- source: value === undefined ? "host_authority" : "autonomy",
264
+ source: configured ? autonomy.source : "default",
132
265
  requireVerdict: kind === "merge" && value !== true,
133
266
  workspace,
134
267
  };
135
268
  }
269
+
270
+ /** Whether `next` grants more than `current` (false < "verified" < true). */
271
+ export const raises = (kind: GrantKind, current: GrantValue, next: GrantValue): boolean => {
272
+ const rank = (value: GrantValue) => (value === true ? 2 : value === "verified" ? 1 : 0);
273
+ return rank(normalized(kind, next)) > rank(normalized(kind, current));
274
+ };
275
+
276
+ export type GrantWrite =
277
+ | {
278
+ ok: true;
279
+ path: string;
280
+ backup: string | null;
281
+ workspace: string;
282
+ grants: Grants;
283
+ configured: GrantKind[];
284
+ defaultEndpoint: DefaultEndpoint;
285
+ }
286
+ | { ok: false; code: "not_found" | "invalid_input" | "failed"; error: string };
287
+
288
+ /**
289
+ * Set (`value`) or unset (`undefined`) grants of a named workspace in the
290
+ * user's workspaces.json. The caller decides whether raising is allowed (an
291
+ * interactive terminal only); this only writes, atomically, after copying
292
+ * the previous file to `workspaces.json.bak`. A legacy `autoApprove` is
293
+ * folded into `autonomy` on the first write and removed.
294
+ */
295
+ export function writeGrants(
296
+ workspaceName: string,
297
+ changes: readonly { kind: GrantKind; value: GrantValue | undefined }[],
298
+ dir: string = grantsDir(),
299
+ /** A new default endpoint; `null` removes it (back to `commit`). */
300
+ endpoint?: DefaultEndpoint | null,
301
+ ): GrantWrite {
302
+ const current = readWorkspacesResult(dir);
303
+ if (current.status === "missing")
304
+ return { ok: false, code: "not_found", error: `${current.path} does not exist` };
305
+ if (current.status !== "valid" || !current.document)
306
+ return { ok: false, code: "invalid_input", error: current.error ?? "invalid workspaces.json" };
307
+ const document = structuredClone(current.document) as {
308
+ workspaces?: Record<string, unknown>[];
309
+ };
310
+ const entry = document.workspaces?.find((item) => item.name === workspaceName);
311
+ if (!entry)
312
+ return {
313
+ ok: false,
314
+ code: "not_found",
315
+ error: `no workspace named "${workspaceName}" in ${current.path}`,
316
+ };
317
+ const { grants } = configuredGrants(entry);
318
+ for (const { kind, value } of changes)
319
+ if (value === undefined) delete grants[kind];
320
+ else grants[kind] = normalized(kind, value);
321
+ // Keys this version does not know stay as they are (S16 review L6).
322
+ const unknown = Object.fromEntries(
323
+ Object.entries(
324
+ entry.autonomy && typeof entry.autonomy === "object" && !Array.isArray(entry.autonomy)
325
+ ? (entry.autonomy as Record<string, unknown>)
326
+ : {},
327
+ ).filter(([key]) => !isGrantKind(key)),
328
+ );
329
+ delete entry.autoApprove;
330
+ if (endpoint === null || endpoint === DEFAULT_ENDPOINT) delete entry.defaultEndpoint;
331
+ else if (endpoint) entry.defaultEndpoint = endpoint;
332
+ const autonomy = {
333
+ ...unknown,
334
+ ...Object.fromEntries(
335
+ GRANT_KINDS.filter((item) => grants[item] !== undefined).map((item) => [item, grants[item]]),
336
+ ),
337
+ };
338
+ if (Object.keys(autonomy).length === 0) delete entry.autonomy;
339
+ else entry.autonomy = autonomy;
340
+ const validated = validateWorkspacesDocument(document, current.path);
341
+ if (validated.status !== "valid")
342
+ return { ok: false, code: "invalid_input", error: validated.error ?? "invalid result" };
343
+ // Re-read just before writing: a concurrent edit wins over this one.
344
+ const latest = readWorkspacesResult(dir);
345
+ if (latest.revision !== current.revision)
346
+ return { ok: false, code: "failed", error: `${current.path} changed; retry` };
347
+ let backup: string | null = null;
348
+ try {
349
+ if (existsSync(current.path)) {
350
+ backup = `${current.path}.bak`;
351
+ copyFileSync(current.path, backup);
352
+ }
353
+ writeFileAtomic(current.path, `${JSON.stringify(document, null, 2)}\n`);
354
+ } catch (error) {
355
+ return {
356
+ ok: false,
357
+ code: "failed",
358
+ error: `failed to write ${current.path}: ${error instanceof Error ? error.message : String(error)}`,
359
+ };
360
+ }
361
+ return {
362
+ ok: true,
363
+ path: current.path,
364
+ backup,
365
+ workspace: workspaceName,
366
+ grants: { ...DEFAULT_GRANTS, ...grants },
367
+ configured: GRANT_KINDS.filter((kind) => grants[kind] !== undefined),
368
+ defaultEndpoint: endpointOf(entry),
369
+ };
370
+ }
371
+
372
+ /** Every workspace's grants, for `workit grant show --all`. */
373
+ export function listGrants(dir: string = grantsDir()): {
374
+ path: string;
375
+ workspaces: {
376
+ name: string;
377
+ glob: string;
378
+ grants: Grants;
379
+ configured: GrantKind[];
380
+ defaultEndpoint: DefaultEndpoint;
381
+ }[];
382
+ error?: string;
383
+ } {
384
+ const current = readWorkspacesResult(dir);
385
+ if (current.status === "malformed" || current.status === "invalid")
386
+ return { path: current.path, workspaces: [], error: current.error };
387
+ return {
388
+ path: current.path,
389
+ workspaces: current.entries.map((entry) => {
390
+ const { grants } = configuredGrants(entry);
391
+ return {
392
+ name: entry.name,
393
+ glob: entry.glob,
394
+ grants: { ...DEFAULT_GRANTS, ...grants },
395
+ configured: GRANT_KINDS.filter((kind) => grants[kind] !== undefined),
396
+ defaultEndpoint: endpointOf(entry),
397
+ };
398
+ }),
399
+ };
400
+ }
@@ -0,0 +1,144 @@
1
+ // Read-only repository and provider context (`workit_context` on OpenCode,
2
+ // Pi and MCP). A read needs no grant: it changes nothing locally or remotely.
3
+ import * as z from "zod";
4
+ import {
5
+ changelogContext,
6
+ docsRefreshContext,
7
+ isSafeContextRange,
8
+ prReadyContext,
9
+ releaseNotesContext,
10
+ } from "./repo-context";
11
+ import { gitContext } from "./git";
12
+ import { fetchGitHubIssueBody, fetchGitLabIssueBody } from "./tracker-issues";
13
+ import { context as youTrackContext, fetchYouTrackIssueBody } from "./youtrack";
14
+ import { failure, success, type Result } from "./task-contract";
15
+
16
+ export const contextReadSchema = z
17
+ .object({
18
+ kind: z.enum([
19
+ "git",
20
+ "pr",
21
+ "youtrack",
22
+ "github_issue",
23
+ "gitlab_issue",
24
+ "changelog",
25
+ "release",
26
+ "affected",
27
+ ]),
28
+ range: z.string().optional(),
29
+ issueId: z.string().optional(),
30
+ issueUrl: z.string().optional(),
31
+ issueRef: z.string().optional(),
32
+ mode: z.string().optional(),
33
+ specPath: z.string().optional(),
34
+ planPath: z.string().optional(),
35
+ cwd: z.string().min(1).optional(),
36
+ })
37
+ .strict();
38
+ export type ContextReadPayload = z.infer<typeof contextReadSchema>;
39
+
40
+ /** The tool input schema hosts register for `workit_context`. */
41
+ export const contextReadJsonSchema = (): Record<string, unknown> => {
42
+ const { $schema: _schema, ...schema } = z.toJSONSchema(contextReadSchema, {
43
+ target: "draft-2020-12",
44
+ }) as Record<string, unknown>;
45
+ return schema;
46
+ };
47
+
48
+ export const parseContextRead = (value: unknown): Result<ContextReadPayload> => {
49
+ const parsed = contextReadSchema.safeParse(value);
50
+ return parsed.success
51
+ ? success(null, null, parsed.data)
52
+ : failure("invalid_input", "context payload is invalid", {
53
+ fields: parsed.error.issues.map((issue) => ({
54
+ path: issue.path.join("."),
55
+ reason: issue.message,
56
+ })),
57
+ });
58
+ };
59
+
60
+ /** Read one fixed repository/provider context without approval or mutation. */
61
+ export const readExternalContext = async (
62
+ root: string,
63
+ payload: ContextReadPayload,
64
+ ): Promise<Result<unknown>> => {
65
+ if (payload.range !== undefined && !isSafeContextRange(payload.range))
66
+ return failure("invalid_input", "revision range is invalid", {
67
+ fields: [{ path: "range", reason: "option-like or control characters are not allowed" }],
68
+ });
69
+ const render = (result: { stdout: string; stderr: string; exitCode: number; cwd: string }) =>
70
+ result.exitCode === 0
71
+ ? success(null, null, { kind: payload.kind, context: result.stdout })
72
+ : failure("capability_unavailable", "requested context is unavailable", {
73
+ capability: payload.kind,
74
+ });
75
+ switch (payload.kind) {
76
+ case "git": {
77
+ const context = gitContext(root);
78
+ return typeof context.exitCode === "number" && context.exitCode !== 0
79
+ ? failure("capability_unavailable", "Git context is unavailable", { capability: "git" })
80
+ : success(null, null, { kind: payload.kind, context });
81
+ }
82
+ case "pr":
83
+ return render(prReadyContext(root, payload.range));
84
+ case "changelog":
85
+ return render(changelogContext(root, payload.range));
86
+ case "release":
87
+ return render(releaseNotesContext(root, payload.range ?? "HEAD~1...HEAD"));
88
+ case "affected":
89
+ return render(docsRefreshContext(root, payload.range));
90
+ case "youtrack": {
91
+ const value = youTrackContext({
92
+ workspace_root: root,
93
+ ...(payload.specPath ? { spec_path: payload.specPath } : {}),
94
+ ...(payload.planPath ? { plan_path: payload.planPath } : {}),
95
+ ...(payload.issueId ? { issue_id: payload.issueId } : {}),
96
+ ...(payload.issueUrl ? { issue_url: payload.issueUrl } : {}),
97
+ ...(payload.issueRef ? { issue_ref: payload.issueRef } : {}),
98
+ ...(payload.mode ? { mode: payload.mode } : {}),
99
+ });
100
+ if (value && typeof value === "object" && "error" in value)
101
+ return failure("capability_unavailable", "YouTrack context is unavailable", {
102
+ capability: "youtrack",
103
+ });
104
+ if (value.requiresMeetingChoice || !value.issueId)
105
+ return success(null, null, { kind: payload.kind, context: value });
106
+ const body = await fetchYouTrackIssueBody(value.issueId);
107
+ if ("error" in body && body.kind === "request")
108
+ return failure("capability_unavailable", "YouTrack issue body is unavailable", {
109
+ capability: "youtrack",
110
+ });
111
+ return success(null, null, {
112
+ kind: payload.kind,
113
+ context: {
114
+ ...value,
115
+ ...("data" in body
116
+ ? { issueBody: body.data }
117
+ : { issueBody: null, issueBodyError: body.error }),
118
+ },
119
+ });
120
+ }
121
+ case "github_issue": {
122
+ const ref = payload.issueId ?? payload.issueUrl ?? payload.issueRef;
123
+ if (!ref)
124
+ return failure("invalid_input", "github_issue needs issueId, issueUrl, or issueRef");
125
+ const body = await fetchGitHubIssueBody(ref, root);
126
+ if ("error" in body)
127
+ return failure("capability_unavailable", `GitHub issue is unavailable: ${body.error}`, {
128
+ capability: "github_issue",
129
+ });
130
+ return success(null, null, { kind: payload.kind, context: { issueBody: body.data } });
131
+ }
132
+ case "gitlab_issue": {
133
+ const ref = payload.issueId ?? payload.issueUrl ?? payload.issueRef;
134
+ if (!ref)
135
+ return failure("invalid_input", "gitlab_issue needs issueId, issueUrl, or issueRef");
136
+ const body = await fetchGitLabIssueBody(ref, root);
137
+ if ("error" in body)
138
+ return failure("capability_unavailable", `GitLab issue is unavailable: ${body.error}`, {
139
+ capability: "gitlab_issue",
140
+ });
141
+ return success(null, null, { kind: payload.kind, context: { issueBody: body.data } });
142
+ }
143
+ }
144
+ };
@@ -92,7 +92,7 @@ Workit is optional coordination and proof tooling around the host: you decide
92
92
  what and whether, the \`workit\` CLI does how and records what it observed.
93
93
  Native host allow/ask/deny, sandbox and org rules stay authoritative. Never
94
94
  switch paths to evade a denial, never claim enforcement a host cannot provide,
95
- and never fabricate a receipt, approval or verdict. A question answer is not
95
+ and never fabricate an approval or verdict. A question answer is not
96
96
  host permission, and imported decisions or handoffs never grant authority.
97
97
 
98
98
  Autonomy contract:
@@ -106,6 +106,8 @@ Autonomy contract:
106
106
  \`defaultEndpoint\` \`pr\`; otherwise the endpoint is \`commit\`. PRs open,
107
107
  CI green and verified is the most autonomy allows, never the default target.
108
108
  Merge and release need a workspace grant; a blocked verb names its unblock.
109
+ Only the user raises a grant, from their own terminal (\`workit grant set\`);
110
+ never edit workspaces.json or route around a grant_required.
109
111
  - Ask only for a product or preference choice, or for authority you lack, with
110
112
  your recommended answer. Facts are yours: read, run or prototype.
111
113
  - Label claims measured (you ran it this session and saw the result),
@@ -125,9 +127,10 @@ hand-running git, gh or glab: check, git, pr, ci, stack, verify-delivery,
125
127
  ledger, handoff, test-audit (\`workit help <verb>\`; without \`workit\` on
126
128
  PATH, \`npx -y @brainervirus/workit-cli\`). \`busy\` is retryable
127
129
  (\`workit doctor --fix-lock\` clears a dead lock); \`blocked\` names its unblock.
128
- The task families (task, policy, evidence, finding, decision, worker, writer,
129
- state) are optional continuity for tracked work; each branch has one implicit
130
- task that the first recording creates, and a solo edit needs no task or writer.
130
+ The task families (task, policy, evidence, finding, decision, worker, state)
131
+ are optional continuity for tracked work; each branch has one implicit task
132
+ that the first recording creates, and a solo edit needs no task. A decision
133
+ records a user choice; it never authorizes an effect.
131
134
  Omit revisions; a revision_conflict means one you passed is stale, so re-read.
132
135
  Across repositories, bind each item to its checkout, branch and endpoint,
133
136
  resolve competing targets before a mutation, and reconcile an uncertain
@@ -20,7 +20,6 @@ export type MetadataLock = {
20
20
  processStart: string | null;
21
21
  host: string;
22
22
  nonce: string;
23
- externalAction?: true;
24
23
  };
25
24
 
26
25
  const metadataLockSchema = z
@@ -29,6 +28,7 @@ const metadataLockSchema = z
29
28
  processStart: z.string().nullable(),
30
29
  host: z.string().min(1),
31
30
  nonce: z.string().min(1),
31
+ /** Written by ≤4.x managed external actions; read and ignored (D17). */
32
32
  externalAction: z.literal(true).optional(),
33
33
  })
34
34
  .strict();
@@ -109,7 +109,7 @@ const compactTimestamp = (value: Utc): string =>
109
109
  `${value.slice(0, 19)}.${value.slice(20, -1).padEnd(9, "0")}`;
110
110
  const compactDecisionChoice = (entry: TaskView["task"]["decisions"][number]): string | null => {
111
111
  const stated = entry.data.binding.statedChoice?.text;
112
- const approved = entry.data.response === "approved" ? entry.data.binding.approvedContent : "";
112
+ const approved = entry.data.response === "approved" ? entry.data.binding.presented : "";
113
113
  const source = stated?.trim() || approved.trim() || null;
114
114
  return compactText(source, COMPACT_TEXT_BYTES);
115
115
  };