@brainervirus/workit-core 4.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/README.md +1 -1
- package/package.json +1 -1
- package/skills/workit-bdd/SKILL.md +35 -29
- package/skills/workit-continue/SKILL.md +53 -0
- package/skills/workit-debug/SKILL.md +41 -36
- package/skills/workit-deslop/SKILL.md +35 -25
- package/skills/workit-fanout/SKILL.md +62 -0
- package/skills/workit-fanout/references/brief.md +56 -0
- package/skills/workit-implement/SKILL.md +48 -44
- package/skills/workit-review/SKILL.md +42 -51
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +58 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +42 -0
- package/skills/workit-ship/SKILL.md +52 -0
- package/skills/workit-test-audit/SKILL.md +10 -2
- package/skills/workit-verify-app/SKILL.md +63 -0
- package/skills/workit-verify-app/references/template.md +49 -0
- package/src/autonomy.ts +309 -44
- package/src/core/context-read.ts +144 -0
- package/src/core/methods.ts +71 -98
- package/src/core/skill-manifests.ts +49 -25
- package/src/core/store-lock.ts +1 -1
- package/src/core/task-context.ts +1 -1
- package/src/core/task-contract.ts +8 -130
- package/src/core/task-engine.ts +57 -879
- package/src/core/task-evaluation.ts +63 -5
- package/src/core/task-store.ts +2 -107
- package/src/core/workers.ts +12 -158
- package/src/core/workspaces.ts +15 -0
- package/src/core/youtrack-writes.ts +92 -0
- package/src/core.ts +2 -79
- package/src/forge/pr-ops.ts +2 -2
- package/src/hooks/handle.ts +29 -5
- package/src/hooks/hosts/claude-code.ts +0 -8
- package/src/hooks/hosts/codex.ts +2 -11
- package/src/hooks/hosts/cursor.ts +0 -8
- package/src/hooks/hosts/opencode.ts +0 -8
- package/src/hooks/hosts/pi.ts +0 -10
- package/src/ledger.ts +19 -10
- package/templates/plan-template.md +17 -18
- package/templates/spec-template.md +4 -3
- package/templates/workit-contract.md +3 -2
- package/skills/workit-babysit/SKILL.md +0 -37
- package/skills/workit-behavioral-tdd/SKILL.md +0 -56
- package/skills/workit-blast-radius/SKILL.md +0 -26
- package/skills/workit-challenge/SKILL.md +0 -47
- package/skills/workit-diagram/SKILL.md +0 -27
- package/skills/workit-green-run/SKILL.md +0 -24
- package/skills/workit-handoff/SKILL.md +0 -37
- package/skills/workit-mockup/SKILL.md +0 -23
- package/skills/workit-plan/SKILL.md +0 -45
- package/skills/workit-steer/SKILL.md +0 -39
- package/src/core/authority.ts +0 -1033
- package/src/core/auto-approval.ts +0 -338
- package/src/core/external-action-effects.ts +0 -2441
- package/src/core/external-action.ts +0 -1204
- package/templates/execution-contract.md +0 -40
package/src/autonomy.ts
CHANGED
|
@@ -1,37 +1,113 @@
|
|
|
1
|
-
// Autonomy grants (D4, D15; design §0 #12, §4.2): the per-workspace
|
|
2
|
-
// what an agent may deliver without asking.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
/**
|
|
32
|
-
grants:
|
|
33
|
-
|
|
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:
|
|
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
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
|
|
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:
|
|
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.
|
|
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:
|
|
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
|
+
};
|