@brainervirus/workit-core 1.2.0 → 1.3.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 +1 -1
- package/skills/workit-babysit/SKILL.md +20 -26
- package/skills/workit-behavioral-tdd/SKILL.md +0 -5
- package/skills/workit-blast-radius/SKILL.md +0 -5
- package/skills/workit-challenge/SKILL.md +30 -47
- package/skills/workit-debug/SKILL.md +5 -27
- package/skills/workit-deslop/SKILL.md +0 -5
- package/skills/workit-diagram/SKILL.md +0 -5
- package/skills/workit-green-run/SKILL.md +0 -5
- package/skills/workit-handoff/SKILL.md +2 -8
- package/skills/workit-implement/SKILL.md +14 -20
- package/skills/workit-mockup/SKILL.md +0 -5
- package/skills/workit-plan/SKILL.md +30 -63
- package/skills/workit-review/SKILL.md +0 -5
- package/skills/workit-steer/SKILL.md +27 -27
- package/src/core/authority.ts +31 -8
- package/src/core/auto-approval.ts +44 -32
- package/src/core/branch.ts +293 -42
- package/src/core/config-conversion.ts +4 -4
- package/src/core/config-guard.ts +1 -7
- package/src/core/config.ts +1 -1
- package/src/core/cutover.ts +1207 -140
- package/src/core/doctor.ts +155 -82
- package/src/core/external-action-effects.ts +1102 -209
- package/src/core/external-action.ts +230 -103
- package/src/core/init.ts +25 -115
- package/src/core/legacy-ownership.ts +47 -0
- package/src/core/methods.ts +56 -52
- package/src/core/ports/pr-create.ts +23 -15
- package/src/core/pr-create.ts +374 -84
- package/src/core/registration.ts +0 -15
- package/src/core/repo-tools.ts +0 -1
- package/src/core/route-intent.ts +34 -82
- package/src/core/safe-write.ts +66 -1
- package/src/core/setup-state.ts +7 -0
- package/src/core/setup.ts +175 -82
- package/src/core/task-contract.ts +16 -3
- package/src/core/task-engine.ts +136 -31
- package/src/core/task-evaluation.ts +3 -5
- package/src/core/task-store.ts +159 -1
- package/src/core/tracker-issues.ts +55 -118
- package/src/core/vcs-config.ts +178 -187
- package/src/core/workers.ts +17 -0
- package/src/core/workspaces.ts +548 -46
- package/src/core.ts +1 -2
- package/src/core/ports/vcs-token-create-urls.ts +0 -4
package/src/core/route-intent.ts
CHANGED
|
@@ -1,85 +1,37 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
1
|
+
import { validateBranchNameFor, type BranchNamePolicyCheck } from "./branch";
|
|
2
|
+
|
|
3
|
+
// ponytail: recognize direct literal Git forms only; expand coverage if users need shell AST support.
|
|
4
|
+
export const shellBranchTarget = (command: string): string | null => {
|
|
5
|
+
if (
|
|
6
|
+
!command.trim() ||
|
|
7
|
+
/[\r\n;&|<>`'"$()\\*?{}~!#]/u.test(command) ||
|
|
8
|
+
command.includes("[") ||
|
|
9
|
+
command.includes("]")
|
|
10
|
+
)
|
|
11
|
+
return null;
|
|
12
|
+
|
|
13
|
+
const args = command.trim().split(/\s+/u);
|
|
14
|
+
if (args[0] !== "git") return null;
|
|
15
|
+
const option = args[2];
|
|
16
|
+
const target =
|
|
17
|
+
args[1] === "switch" &&
|
|
18
|
+
["-c", "--create", "-C", "--force-create"].includes(option ?? "") &&
|
|
19
|
+
(args.length === 4 || args.length === 5)
|
|
20
|
+
? args[3]
|
|
21
|
+
: args[1] === "checkout" &&
|
|
22
|
+
["-b", "-B"].includes(option ?? "") &&
|
|
23
|
+
(args.length === 4 || args.length === 5)
|
|
24
|
+
? args[3]
|
|
25
|
+
: args[1] === "branch" && (args.length === 3 || args.length === 4)
|
|
26
|
+
? args[2]
|
|
27
|
+
: null;
|
|
28
|
+
return target && !target.startsWith("-") ? target : null;
|
|
20
29
|
};
|
|
21
30
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return
|
|
31
|
+
export const shellBranchPolicyViolation = (
|
|
32
|
+
root: string,
|
|
33
|
+
command: string,
|
|
34
|
+
): BranchNamePolicyCheck | null => {
|
|
35
|
+
const target = shellBranchTarget(command);
|
|
36
|
+
return target === null ? null : validateBranchNameFor(root, target);
|
|
28
37
|
};
|
|
29
|
-
|
|
30
|
-
const unquote = (token: string): string => token.replace(/^['"]|['"]$/gu, "");
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Narrow recognizer for direct branch and PR creation commands. It returns null
|
|
34
|
-
* for everything it cannot classify unambiguously, so hosts deny only the exact
|
|
35
|
-
* routes that must go through the shared action reservation.
|
|
36
|
-
*/
|
|
37
|
-
export function shellRouteIntent(command: string): ShellRouteIntent | null {
|
|
38
|
-
if (typeof command !== "string" || command.trim() === "") return null;
|
|
39
|
-
for (const raw of command.split(/&&|\|\||;|\n/u)) {
|
|
40
|
-
const parts = segmentTokens(raw);
|
|
41
|
-
if (!parts) continue;
|
|
42
|
-
const head = unquote(parts[0]);
|
|
43
|
-
const second = unquote(parts[1] ?? "");
|
|
44
|
-
const third = unquote(parts[2] ?? "");
|
|
45
|
-
if (head === "git" && second === "switch" && (third === "-c" || third === "--create"))
|
|
46
|
-
return { route: "git.branch_setup", guidance: GUIDANCE["git.branch_setup"] };
|
|
47
|
-
if (head === "git" && second === "checkout" && (third === "-b" || third === "-B"))
|
|
48
|
-
return { route: "git.branch_setup", guidance: GUIDANCE["git.branch_setup"] };
|
|
49
|
-
if (head === "gh" && second === "pr" && third === "create")
|
|
50
|
-
return { route: "hosting.pull_request", guidance: GUIDANCE["hosting.pull_request"] };
|
|
51
|
-
if (head === "glab" && second === "mr" && third === "create")
|
|
52
|
-
return { route: "hosting.pull_request", guidance: GUIDANCE["hosting.pull_request"] };
|
|
53
|
-
if (head === "gh" && second === "pr" && third === "merge")
|
|
54
|
-
return { route: "hosting.merge", guidance: GUIDANCE["hosting.merge"] };
|
|
55
|
-
if (head === "glab" && second === "mr" && third === "merge")
|
|
56
|
-
return { route: "hosting.merge", guidance: GUIDANCE["hosting.merge"] };
|
|
57
|
-
}
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Session-scoped enforcement: the denial protects live Workit coordination,
|
|
63
|
-
* so it fires only where an active or paused task exists. Any filesystem or
|
|
64
|
-
* storage failure means "no live task" — never deny on uncertainty.
|
|
65
|
-
*/
|
|
66
|
-
export function hasLiveWorkitTask(dir: string): boolean {
|
|
67
|
-
try {
|
|
68
|
-
if (typeof dir !== "string" || dir === "") return false;
|
|
69
|
-
const listed = new TaskStore(dir).listTasks();
|
|
70
|
-
if (!listed.ok) return false;
|
|
71
|
-
return listed.data.some((task) => task.status === "active" || task.status === "paused");
|
|
72
|
-
} catch {
|
|
73
|
-
return false;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Single core gate for the shell route denial. Adapters pass their
|
|
79
|
-
* session/project directory and delete local deny duplication; outside
|
|
80
|
-
* attributed sessions the command is silently allowed.
|
|
81
|
-
*/
|
|
82
|
-
export function shouldDenyShellRoute(dir: string, command: string): ShellRouteIntent | null {
|
|
83
|
-
if (!hasLiveWorkitTask(dir)) return null;
|
|
84
|
-
return shellRouteIntent(command);
|
|
85
|
-
}
|
package/src/core/safe-write.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
fchmodSync,
|
|
5
|
+
fsyncSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
openSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
2
14
|
|
|
3
15
|
// Shared credential-safe write (Task 14 Step 7 / CA-13): `wx` exclusive create
|
|
4
16
|
// closes the TOCTOU window between an existence check and the write, and EEXIST
|
|
@@ -20,3 +32,56 @@ export function writeFileExclusive(
|
|
|
20
32
|
throw err;
|
|
21
33
|
}
|
|
22
34
|
}
|
|
35
|
+
|
|
36
|
+
export function writeFileAtomic(file: string, content: string, mode?: number): void {
|
|
37
|
+
const absolute = path.resolve(file);
|
|
38
|
+
let parent = path.dirname(absolute);
|
|
39
|
+
const root = path.parse(parent).root;
|
|
40
|
+
for (;;) {
|
|
41
|
+
try {
|
|
42
|
+
if (lstatSync(parent).isSymbolicLink()) throw new Error(`symlinked write path: ${parent}`);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
45
|
+
}
|
|
46
|
+
const next = path.dirname(parent);
|
|
47
|
+
if (parent === root || next === parent) break;
|
|
48
|
+
parent = next;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const directory = path.dirname(absolute);
|
|
52
|
+
mkdirSync(directory, { recursive: true });
|
|
53
|
+
let existing: ReturnType<typeof lstatSync> | null = null;
|
|
54
|
+
try {
|
|
55
|
+
existing = lstatSync(absolute);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
58
|
+
}
|
|
59
|
+
if (existing?.isSymbolicLink() || (existing && !existing.isFile())) {
|
|
60
|
+
throw new Error(`refusing to replace non-regular file: ${absolute}`);
|
|
61
|
+
}
|
|
62
|
+
const temporary = `${absolute}.${randomUUID()}.tmp`;
|
|
63
|
+
let fd: number | undefined;
|
|
64
|
+
try {
|
|
65
|
+
fd = openSync(temporary, "wx", mode ?? 0o666);
|
|
66
|
+
writeFileSync(fd, content, "utf8");
|
|
67
|
+
if (existing) fchmodSync(fd, existing.mode & 0o777);
|
|
68
|
+
fsyncSync(fd);
|
|
69
|
+
closeSync(fd);
|
|
70
|
+
fd = undefined;
|
|
71
|
+
renameSync(temporary, absolute);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (fd !== undefined) {
|
|
74
|
+
try {
|
|
75
|
+
closeSync(fd);
|
|
76
|
+
} catch {
|
|
77
|
+
// Keep the original write error.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
unlinkSync(temporary);
|
|
82
|
+
} catch {
|
|
83
|
+
// The temporary may already have been renamed or removed.
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/core/setup-state.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { isConfigObject, resolveConfigDir } from "./config";
|
|
4
|
+
import { readWorkspacesResult } from "./workspaces";
|
|
4
5
|
|
|
5
6
|
// WZ-06: typed setup-state reader distinguishing missing from malformed
|
|
6
7
|
// configuration. Purely read-only — nothing here ever writes; Apply decision
|
|
@@ -42,6 +43,12 @@ export const classifySetupFile = (dir: string, name: string): FileState => {
|
|
|
42
43
|
} catch {
|
|
43
44
|
return { file, status: "malformed", error: `${file} is not valid JSON` };
|
|
44
45
|
}
|
|
46
|
+
if (name === "workspaces.json") {
|
|
47
|
+
const workspaces = readWorkspacesResult(dir);
|
|
48
|
+
if (workspaces.status === "malformed" || workspaces.status === "invalid") {
|
|
49
|
+
return { file, status: "malformed", error: workspaces.error };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
45
52
|
return { file, status: "valid" };
|
|
46
53
|
};
|
|
47
54
|
|
package/src/core/setup.ts
CHANGED
|
@@ -26,10 +26,11 @@ import {
|
|
|
26
26
|
import { detectBranchPolicy } from "./branch-policy";
|
|
27
27
|
import { readSetupState, type SetupState } from "./setup-state";
|
|
28
28
|
import {
|
|
29
|
-
loadWorkspacesFrom,
|
|
30
|
-
matchWorkspace,
|
|
31
29
|
readWorkspacesResult,
|
|
30
|
+
resolveWorkspaceFrom,
|
|
31
|
+
validateWorkspacesDocument,
|
|
32
32
|
validateWorkspaceGlob,
|
|
33
|
+
workspacesRevision,
|
|
33
34
|
type WorkspaceConfig,
|
|
34
35
|
} from "./workspaces";
|
|
35
36
|
import { GITIGNORE_ENTRIES } from "./gitignore";
|
|
@@ -86,7 +87,12 @@ export type SetupPreviewInput = {
|
|
|
86
87
|
export type SetupMutation =
|
|
87
88
|
| { type: "create-file"; path: string; content: string; mode?: number }
|
|
88
89
|
| { type: "merge-json"; path: string; value: unknown }
|
|
89
|
-
| {
|
|
90
|
+
| {
|
|
91
|
+
type: "update-workspaces";
|
|
92
|
+
path: string;
|
|
93
|
+
entries: WorkspaceConfig[];
|
|
94
|
+
expectedRevision: string;
|
|
95
|
+
}
|
|
90
96
|
| { type: "append-gitignore"; path: string; entries: string[] }
|
|
91
97
|
// AR-09: host registration merges and the adapter package copy are planned
|
|
92
98
|
// during preview so the reviewed mutation set IS the Apply write set.
|
|
@@ -170,27 +176,15 @@ function youtrackDraft(values: SetupPreviewInput, tokenPath: string): Record<str
|
|
|
170
176
|
};
|
|
171
177
|
}
|
|
172
178
|
|
|
173
|
-
function vcsDraft(
|
|
174
|
-
provider: VcsProvider,
|
|
175
|
-
gitlabToken: string,
|
|
176
|
-
githubToken: string,
|
|
177
|
-
): Record<string, unknown> {
|
|
179
|
+
function vcsDraft(provider: VcsProvider): Record<string, unknown> {
|
|
178
180
|
return {
|
|
179
181
|
provider,
|
|
180
182
|
gitlab: {
|
|
181
183
|
host: "gitlab.com",
|
|
182
184
|
apiUrl: "https://gitlab.com/api/v4",
|
|
183
|
-
tokenFile: gitlabToken,
|
|
184
185
|
},
|
|
185
|
-
github: { host: "github.com"
|
|
186
|
+
github: { host: "github.com" },
|
|
186
187
|
pr: { squashOnMerge: true, removeSourceBranch: true, pushBranch: true, confirmSkip: true },
|
|
187
|
-
tokenDefaults: {
|
|
188
|
-
name: "workit",
|
|
189
|
-
description: "OpenCode workit — /wk-pr and glab/gh",
|
|
190
|
-
gitlabScopes: ["api"],
|
|
191
|
-
githubPermissions: { pull_requests: "write", contents: "write", metadata: "read" },
|
|
192
|
-
githubClassicScopes: ["repo"],
|
|
193
|
-
},
|
|
194
188
|
};
|
|
195
189
|
}
|
|
196
190
|
|
|
@@ -231,17 +225,6 @@ const readConfigRecord = (file: string): Record<string, unknown> | null => {
|
|
|
231
225
|
}
|
|
232
226
|
};
|
|
233
227
|
|
|
234
|
-
const deleteAtPath = (obj: Record<string, unknown>, keyPath: string): void => {
|
|
235
|
-
const keys = keyPath.split(".");
|
|
236
|
-
let cursor = obj;
|
|
237
|
-
for (const key of keys.slice(0, -1)) {
|
|
238
|
-
const next = cursor[key];
|
|
239
|
-
if (!isRecord(next)) return;
|
|
240
|
-
cursor = next;
|
|
241
|
-
}
|
|
242
|
-
delete cursor[keys[keys.length - 1]];
|
|
243
|
-
};
|
|
244
|
-
|
|
245
228
|
export function buildSetupPreview(
|
|
246
229
|
values: SetupPreviewInput,
|
|
247
230
|
opts: SetupPreviewOptions = {},
|
|
@@ -281,7 +264,8 @@ export function buildSetupPreview(
|
|
|
281
264
|
// WZ-12 parity: the draft is authoritative but a no-op workspaces section
|
|
282
265
|
// (unchanged vs disk, including the initial seed) must not claim a rewrite.
|
|
283
266
|
// Removing every workspace differs from disk and therefore still writes [].
|
|
284
|
-
const
|
|
267
|
+
const diskWorkspaceResult = readWorkspacesResult(state.configDir);
|
|
268
|
+
const diskWorkspaces = diskWorkspaceResult.entries;
|
|
285
269
|
if (!isDeepStrictEqual(values.workspaces, diskWorkspaces)) {
|
|
286
270
|
// RL-08: unsupported matcher grammar is rejected before any write —
|
|
287
271
|
// same gate the direct writeWorkspaces path and the wizard enforce.
|
|
@@ -298,6 +282,7 @@ export function buildSetupPreview(
|
|
|
298
282
|
type: "update-workspaces",
|
|
299
283
|
path: path.join(state.configDir, "workspaces.json"),
|
|
300
284
|
entries: values.workspaces,
|
|
285
|
+
expectedRevision: diskWorkspaceResult.revision!,
|
|
301
286
|
});
|
|
302
287
|
}
|
|
303
288
|
}
|
|
@@ -340,48 +325,11 @@ export function buildSetupPreview(
|
|
|
340
325
|
|
|
341
326
|
if (values.vcsProvider !== "skip") {
|
|
342
327
|
const vcsPath = path.join(state.configDir, "vcs.json");
|
|
343
|
-
const vcsExisting = readConfigRecord(vcsPath);
|
|
344
|
-
const glConfigured = configuredTokenPath(vcsExisting, "gitlab", "tokenFile");
|
|
345
|
-
const ghConfigured = configuredTokenPath(vcsExisting, "github", "tokenFile");
|
|
346
|
-
const gitlabToken = resolveTokenPath(
|
|
347
|
-
glConfigured,
|
|
348
|
-
path.join(state.configDir, "gitlab.token"),
|
|
349
|
-
values.tokenPaths?.gitlab,
|
|
350
|
-
);
|
|
351
|
-
const githubToken = resolveTokenPath(
|
|
352
|
-
ghConfigured,
|
|
353
|
-
path.join(state.configDir, "github.token"),
|
|
354
|
-
values.tokenPaths?.github,
|
|
355
|
-
);
|
|
356
|
-
const draft = vcsDraft(values.vcsProvider, gitlabToken, githubToken);
|
|
357
|
-
for (const [configured, token, key] of [
|
|
358
|
-
[glConfigured, gitlabToken, "gitlab.tokenFile"],
|
|
359
|
-
[ghConfigured, githubToken, "github.tokenFile"],
|
|
360
|
-
] as const) {
|
|
361
|
-
if (configured !== null && token !== configured) {
|
|
362
|
-
deleteAtPath(draft, key);
|
|
363
|
-
mutations.push({
|
|
364
|
-
type: "set-token-path",
|
|
365
|
-
path: vcsPath,
|
|
366
|
-
key,
|
|
367
|
-
value: token,
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
328
|
mutations.push({
|
|
372
329
|
type: "merge-json",
|
|
373
330
|
path: vcsPath,
|
|
374
|
-
value:
|
|
331
|
+
value: vcsDraft(values.vcsProvider),
|
|
375
332
|
});
|
|
376
|
-
const activeToken = values.vcsProvider === "gitlab" ? gitlabToken : githubToken;
|
|
377
|
-
if (existsSync(activeToken)) preserved.push(activeToken);
|
|
378
|
-
else
|
|
379
|
-
mutations.push({
|
|
380
|
-
type: "create-file",
|
|
381
|
-
path: activeToken,
|
|
382
|
-
content: TOKEN_PLACEHOLDER + "\n",
|
|
383
|
-
mode: 0o600,
|
|
384
|
-
});
|
|
385
333
|
}
|
|
386
334
|
|
|
387
335
|
// AR-09: host registrations and the adapter package copy are planned here
|
|
@@ -583,6 +531,22 @@ type CoreMutation = Exclude<
|
|
|
583
531
|
{ type: "register-platform" } | { type: "install-adapter" }
|
|
584
532
|
>;
|
|
585
533
|
|
|
534
|
+
const workspaceEntriesAlreadyMatch = (
|
|
535
|
+
contents: string | null,
|
|
536
|
+
file: string,
|
|
537
|
+
entries: WorkspaceConfig[],
|
|
538
|
+
): boolean => {
|
|
539
|
+
if (contents === null) return entries.length === 0;
|
|
540
|
+
try {
|
|
541
|
+
const current = validateWorkspacesDocument(JSON.parse(contents), file);
|
|
542
|
+
return (
|
|
543
|
+
current.status === "valid" && JSON.stringify(current.entries) === JSON.stringify(entries)
|
|
544
|
+
);
|
|
545
|
+
} catch {
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
|
|
586
550
|
function applyMutation(m: CoreMutation): SetupResultEntry {
|
|
587
551
|
const dir = path.dirname(m.path);
|
|
588
552
|
switch (m.type) {
|
|
@@ -622,19 +586,65 @@ function applyMutation(m: CoreMutation): SetupResultEntry {
|
|
|
622
586
|
: { platform: "core", file: m.path, status: "Installed" };
|
|
623
587
|
}
|
|
624
588
|
case "update-workspaces": {
|
|
625
|
-
// RL-08: a mutation built by a non-wizard caller still cannot write an
|
|
626
|
-
// unsupported matcher pattern — reported Failed, never silently stored.
|
|
627
|
-
for (const entry of m.entries) {
|
|
628
|
-
if (!entry || typeof entry.glob !== "string") continue;
|
|
629
|
-
const v = validateWorkspaceGlob(entry.glob);
|
|
630
|
-
if (!v.ok) return { platform: "core", file: m.path, status: "Failed", detail: v.error };
|
|
631
|
-
}
|
|
632
|
-
const next = JSON.stringify({ workspaces: m.entries }, null, 2) + "\n";
|
|
633
589
|
const prev = readFileSafe(m.path);
|
|
590
|
+
if (workspaceEntriesAlreadyMatch(prev, m.path, m.entries))
|
|
591
|
+
return { platform: "core", file: m.path, status: "Skipped", detail: "already configured" };
|
|
592
|
+
if (workspacesRevision(prev) !== m.expectedRevision) {
|
|
593
|
+
return {
|
|
594
|
+
platform: "core",
|
|
595
|
+
file: m.path,
|
|
596
|
+
status: "Failed",
|
|
597
|
+
detail:
|
|
598
|
+
"workspace config changed after preview; rebuild the preview and review the new values",
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
let document: Record<string, unknown> = {};
|
|
602
|
+
if (prev !== null) {
|
|
603
|
+
try {
|
|
604
|
+
const current = validateWorkspacesDocument(JSON.parse(prev), m.path);
|
|
605
|
+
if (current.status === "malformed" || current.status === "invalid") {
|
|
606
|
+
return { platform: "core", file: m.path, status: "Failed", detail: current.error };
|
|
607
|
+
}
|
|
608
|
+
document = current.document ?? {};
|
|
609
|
+
} catch {
|
|
610
|
+
return {
|
|
611
|
+
platform: "core",
|
|
612
|
+
file: m.path,
|
|
613
|
+
status: "Failed",
|
|
614
|
+
detail: `${m.path} is not valid JSON`,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
const proposed = validateWorkspacesDocument({ ...document, workspaces: m.entries }, m.path);
|
|
619
|
+
if (proposed.status !== "valid") {
|
|
620
|
+
return { platform: "core", file: m.path, status: "Failed", detail: proposed.error };
|
|
621
|
+
}
|
|
622
|
+
const next = JSON.stringify({ ...document, workspaces: m.entries }, null, 2) + "\n";
|
|
634
623
|
if (prev === next)
|
|
635
624
|
return { platform: "core", file: m.path, status: "Skipped", detail: "already configured" };
|
|
636
625
|
mkdirSync(dir, { recursive: true });
|
|
637
|
-
|
|
626
|
+
const tmp = `${m.path}.${process.pid}.tmp`;
|
|
627
|
+
try {
|
|
628
|
+
writeFileSync(tmp, next, "utf8");
|
|
629
|
+
if (workspacesRevision(readFileSafe(m.path)) !== m.expectedRevision) {
|
|
630
|
+
rmSync(tmp, { force: true });
|
|
631
|
+
return {
|
|
632
|
+
platform: "core",
|
|
633
|
+
file: m.path,
|
|
634
|
+
status: "Failed",
|
|
635
|
+
detail: "workspace config changed while applying the preview; rebuild and review it",
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
renameSync(tmp, m.path);
|
|
639
|
+
} catch (error) {
|
|
640
|
+
rmSync(tmp, { force: true });
|
|
641
|
+
return {
|
|
642
|
+
platform: "core",
|
|
643
|
+
file: m.path,
|
|
644
|
+
status: "Failed",
|
|
645
|
+
detail: `failed to write ${m.path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
638
648
|
return prev === null
|
|
639
649
|
? { platform: "core", file: m.path, status: "Installed" }
|
|
640
650
|
: { platform: "core", file: m.path, status: "Configured" };
|
|
@@ -1060,6 +1070,38 @@ export function applySetupPreview(
|
|
|
1060
1070
|
};
|
|
1061
1071
|
}
|
|
1062
1072
|
|
|
1073
|
+
const workspaceMutation = preview.mutations.find(
|
|
1074
|
+
(mutation): mutation is Extract<SetupMutation, { type: "update-workspaces" }> =>
|
|
1075
|
+
mutation.type === "update-workspaces",
|
|
1076
|
+
);
|
|
1077
|
+
const currentWorkspaceContents = workspaceMutation ? readFileSafe(workspaceMutation.path) : null;
|
|
1078
|
+
if (
|
|
1079
|
+
workspaceMutation &&
|
|
1080
|
+
workspacesRevision(currentWorkspaceContents) !== workspaceMutation.expectedRevision &&
|
|
1081
|
+
!workspaceEntriesAlreadyMatch(
|
|
1082
|
+
currentWorkspaceContents,
|
|
1083
|
+
workspaceMutation.path,
|
|
1084
|
+
workspaceMutation.entries,
|
|
1085
|
+
)
|
|
1086
|
+
) {
|
|
1087
|
+
return {
|
|
1088
|
+
ok: false,
|
|
1089
|
+
exitCode: 1,
|
|
1090
|
+
entries: [
|
|
1091
|
+
{
|
|
1092
|
+
platform: "core",
|
|
1093
|
+
file: workspaceMutation.path,
|
|
1094
|
+
status: "Failed",
|
|
1095
|
+
detail:
|
|
1096
|
+
"workspace config changed after preview; rebuild the preview and review the new values",
|
|
1097
|
+
},
|
|
1098
|
+
],
|
|
1099
|
+
preserved: preview.preserved,
|
|
1100
|
+
blocked: [],
|
|
1101
|
+
doctor: [],
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1063
1105
|
const res = resolveApply(preview, options);
|
|
1064
1106
|
const entries: SetupResultEntry[] = [];
|
|
1065
1107
|
// AR-09/AR-13: Apply dispatches ONLY the reviewed mutation union. The adapter
|
|
@@ -1218,8 +1260,16 @@ export function applyWorkspaceBranchPolicy(opts: {
|
|
|
1218
1260
|
}): Record<string, any> {
|
|
1219
1261
|
const { workspace_root, env = process.env } = opts;
|
|
1220
1262
|
const dir = path.join(env.WORKFLOW_TOOLKIT_CONFIG ?? configDir());
|
|
1221
|
-
const {
|
|
1222
|
-
|
|
1263
|
+
const {
|
|
1264
|
+
status,
|
|
1265
|
+
path: wsPath,
|
|
1266
|
+
entries,
|
|
1267
|
+
document,
|
|
1268
|
+
revision,
|
|
1269
|
+
error: workspacesError,
|
|
1270
|
+
} = readWorkspacesResult(dir);
|
|
1271
|
+
if (status === "malformed" || status === "invalid")
|
|
1272
|
+
return { ok: false, error: workspacesError ?? `invalid workspaces.json: ${wsPath}` };
|
|
1223
1273
|
const detection = detectBranchPolicy(workspace_root);
|
|
1224
1274
|
const name = String(env.WORKFLOW_BP_NAME ?? path.basename(workspace_root));
|
|
1225
1275
|
const integration = (env.WORKFLOW_BP_INTEGRATION ?? detection.integration) as "pr" | "merge";
|
|
@@ -1234,8 +1284,22 @@ export function applyWorkspaceBranchPolicy(opts: {
|
|
|
1234
1284
|
const glob = `${workspace_root.replace(/[\\/]+$/, "")}/**`;
|
|
1235
1285
|
if (!validateWorkspaceGlob(glob).ok)
|
|
1236
1286
|
return { ok: false, error: `invalid workspace glob: ${glob}` };
|
|
1237
|
-
|
|
1238
|
-
|
|
1287
|
+
let existing: WorkspaceConfig | null;
|
|
1288
|
+
try {
|
|
1289
|
+
existing = resolveWorkspaceFrom(workspace_root, dir);
|
|
1290
|
+
} catch (error) {
|
|
1291
|
+
const selectedName = env.WORKFLOW_BP_NAME?.trim();
|
|
1292
|
+
if (!selectedName)
|
|
1293
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
1294
|
+
try {
|
|
1295
|
+
existing = resolveWorkspaceFrom(workspace_root, dir, selectedName);
|
|
1296
|
+
} catch (selectionError) {
|
|
1297
|
+
return {
|
|
1298
|
+
ok: false,
|
|
1299
|
+
error: selectionError instanceof Error ? selectionError.message : String(selectionError),
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1239
1303
|
if (existing?.branchPolicy && isDeepStrictEqual(existing.branchPolicy, policy)) {
|
|
1240
1304
|
return {
|
|
1241
1305
|
ok: true,
|
|
@@ -1246,10 +1310,38 @@ export function applyWorkspaceBranchPolicy(opts: {
|
|
|
1246
1310
|
};
|
|
1247
1311
|
}
|
|
1248
1312
|
const next = existing
|
|
1249
|
-
? entries.map((w
|
|
1313
|
+
? entries.map((w) => (w.name === existing?.name ? { ...w, branchPolicy: policy } : w))
|
|
1250
1314
|
: [...entries, { name, glob, branchPolicy: policy }];
|
|
1315
|
+
if (!existing && entries.some((entry) => entry.name === name))
|
|
1316
|
+
return { ok: false, error: `workspace name ${JSON.stringify(name)} is already configured` };
|
|
1317
|
+
const proposed = validateWorkspacesDocument({ ...document, workspaces: next }, wsPath);
|
|
1318
|
+
if (proposed.status !== "valid") return { ok: false, error: proposed.error };
|
|
1319
|
+
if (workspacesRevision(readFileSafe(wsPath)) !== revision)
|
|
1320
|
+
return {
|
|
1321
|
+
ok: false,
|
|
1322
|
+
error: `workspaces.json changed while applying the branch policy: ${wsPath}`,
|
|
1323
|
+
};
|
|
1251
1324
|
mkdirSync(path.dirname(wsPath), { recursive: true });
|
|
1252
|
-
|
|
1325
|
+
const tmp = `${wsPath}.${process.pid}.tmp`;
|
|
1326
|
+
try {
|
|
1327
|
+
writeFileSync(tmp, JSON.stringify({ ...document, workspaces: next }, null, 2) + "\n", "utf8");
|
|
1328
|
+
if (workspacesRevision(readFileSafe(wsPath)) !== revision) {
|
|
1329
|
+
rmSync(tmp, { force: true });
|
|
1330
|
+
return {
|
|
1331
|
+
ok: false,
|
|
1332
|
+
error: `workspaces.json changed while applying the branch policy: ${wsPath}`,
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
renameSync(tmp, wsPath);
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
try {
|
|
1338
|
+
rmSync(tmp, { force: true });
|
|
1339
|
+
} catch {}
|
|
1340
|
+
return {
|
|
1341
|
+
ok: false,
|
|
1342
|
+
error: `failed to write ${wsPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1253
1345
|
return {
|
|
1254
1346
|
ok: true,
|
|
1255
1347
|
status: existing ? "updated" : "configured",
|
|
@@ -1264,6 +1356,7 @@ export function applyWorkspaceBranchPolicy(opts: {
|
|
|
1264
1356
|
export {
|
|
1265
1357
|
applyCutover,
|
|
1266
1358
|
applyRollback,
|
|
1359
|
+
resumeCutover,
|
|
1267
1360
|
classifyHostGeneration,
|
|
1268
1361
|
readCutoverReceipt,
|
|
1269
1362
|
detectCursorLatest,
|
|
@@ -588,6 +588,8 @@ export const decisionSchema = z
|
|
|
588
588
|
state: z.enum(["reserved", "consumed", "uncertain"]),
|
|
589
589
|
at: utc,
|
|
590
590
|
actionRef: refSchema,
|
|
591
|
+
/** Historical identifier only; never interpreted as native authority. */
|
|
592
|
+
historicalActionId: text.optional(),
|
|
591
593
|
})
|
|
592
594
|
.strict()
|
|
593
595
|
.nullable(),
|
|
@@ -658,6 +660,7 @@ export const workerSchema = z
|
|
|
658
660
|
assignment: assignmentSchema,
|
|
659
661
|
state: z.enum(["assigned", "dispatching", "running", "cancelling", "stopped", "unknown"]),
|
|
660
662
|
session: refSchema.nullable(),
|
|
663
|
+
coordinator: refSchema.optional(),
|
|
661
664
|
report: workerReportSchema.nullable(),
|
|
662
665
|
})
|
|
663
666
|
.strict();
|
|
@@ -808,6 +811,11 @@ export const taskSummarySchema = z
|
|
|
808
811
|
})
|
|
809
812
|
.strict();
|
|
810
813
|
export type TaskSummary = z.infer<typeof taskSummarySchema>;
|
|
814
|
+
export const taskListItemSchema = taskSummarySchema
|
|
815
|
+
.omit({ policy: true, requirements: true })
|
|
816
|
+
.extend({ source: provenanceSchema.pick({ host: true, kind: true }) })
|
|
817
|
+
.strict();
|
|
818
|
+
export type TaskListItem = z.infer<typeof taskListItemSchema>;
|
|
811
819
|
export const exportBundleSchema = z
|
|
812
820
|
.object({
|
|
813
821
|
schemaVersion: z.literal(1),
|
|
@@ -838,11 +846,16 @@ const taskOperations = {
|
|
|
838
846
|
expectedWorkspaceRevision: revision.nullable().optional(),
|
|
839
847
|
intent: intentSchema,
|
|
840
848
|
}),
|
|
841
|
-
list: operation({
|
|
849
|
+
list: operation({
|
|
850
|
+
action: z.literal("list"),
|
|
851
|
+
status: z.enum(["open", "closed", "all"]).optional(),
|
|
852
|
+
query: z.string().trim().min(1).max(200).optional(),
|
|
853
|
+
limit: z.number().int().min(1).max(50).optional(),
|
|
854
|
+
}),
|
|
842
855
|
inspect: operation({
|
|
843
856
|
action: z.literal("inspect"),
|
|
844
857
|
...taskId,
|
|
845
|
-
view: z.enum(["summary", "full"]),
|
|
858
|
+
view: z.enum(["summary", "full"]).optional(),
|
|
846
859
|
}),
|
|
847
860
|
revise: operation({
|
|
848
861
|
action: z.literal("revise"),
|
|
@@ -870,7 +883,7 @@ const taskOperations = {
|
|
|
870
883
|
...taskId,
|
|
871
884
|
...revisions,
|
|
872
885
|
expectedWorkspaceRevision: revision.optional(),
|
|
873
|
-
authorityRefs: z.array(refSchema),
|
|
886
|
+
authorityRefs: z.array(refSchema).optional(),
|
|
874
887
|
}),
|
|
875
888
|
close: operation({
|
|
876
889
|
action: z.literal("close"),
|