@brainervirus/workit-core 8.22.3 → 8.24.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-fanout/SKILL.md +5 -5
- package/skills/workit-fanout/references/brief.md +17 -11
- package/skills/workit-fanout/references/orchestration.md +6 -6
- package/skills/workit-implement/SKILL.md +7 -5
- package/skills/workit-review/SKILL.md +3 -0
- package/skills/workit-ship/SKILL.md +1 -2
- package/src/autonomy.ts +22 -11
- package/src/check-config.ts +9 -2
- package/src/core/methods.ts +3 -3
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +57 -9
- package/src/core/policy/lane.ts +513 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/workspaces.ts +1 -1
- package/src/fanout-brief.ts +23 -15
- package/src/fanout-worktree.ts +20 -6
- package/src/forge/pr-ops.ts +54 -12
- package/src/forge/report.ts +4 -2
- package/src/git/rev.ts +133 -0
- package/src/hooks/handle.ts +29 -8
- package/src/hooks/raw-git.ts +19 -0
- package/src/hooks/stop.ts +50 -33
- package/src/hooks/worktree-confine.ts +363 -0
- package/src/hooks/write-gate.ts +189 -35
- package/src/ledger.ts +7 -2
- package/src/stack.ts +54 -12
- package/src/worktree-deps.ts +142 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// Is a changed file cosmetic (#333, review finding mvkcoxgb)? Each side of
|
|
2
|
+
// the file is reduced to its substance by a small lexer that knows the
|
|
3
|
+
// file's strings and comments: comments drop out, whitespace outside strings
|
|
4
|
+
// is normalized, strings stay verbatim. Equal substance on both sides is a
|
|
5
|
+
// cosmetic change. The lexer fails closed: a syntax it does not know keeps
|
|
6
|
+
// its comments, an indentation-sensitive or unknown type keeps all but
|
|
7
|
+
// trailing whitespace, and anything it cannot read (invalid UTF-8, a NUL
|
|
8
|
+
// byte, an unterminated string or comment, a regex across lines) is
|
|
9
|
+
// substantive. Languages it would read wrongly stay unlexed (C-family, Rust,
|
|
10
|
+
// SQL, shell, Ruby, YAML): only trailing whitespace is cosmetic there.
|
|
11
|
+
|
|
12
|
+
type Syntax = {
|
|
13
|
+
/** Line comment openers. */
|
|
14
|
+
line: readonly string[];
|
|
15
|
+
/** Block comment delimiters (not nested). */
|
|
16
|
+
block: readonly (readonly [string, string])[];
|
|
17
|
+
/** String quotes with backslash escapes. */
|
|
18
|
+
quotes: string;
|
|
19
|
+
/** String quotes without escapes (TOML literal strings). */
|
|
20
|
+
literal?: string;
|
|
21
|
+
/** `"""` and `'''` strings. */
|
|
22
|
+
triple?: boolean;
|
|
23
|
+
/** JavaScript template literals with `${}` and regex literals. */
|
|
24
|
+
js?: boolean;
|
|
25
|
+
/** Go raw strings in backticks. */
|
|
26
|
+
rawBacktick?: boolean;
|
|
27
|
+
/** Strings may span lines. */
|
|
28
|
+
multiline?: boolean;
|
|
29
|
+
/** Python: an f-string with an unbalanced `{` holds a nested string this cannot read. */
|
|
30
|
+
fstring?: boolean;
|
|
31
|
+
/** CSS: an unquoted `url(` body is no string or comment this can read. */
|
|
32
|
+
cssUrl?: boolean;
|
|
33
|
+
/** Only trailing whitespace is cosmetic (indentation matters). */
|
|
34
|
+
indented?: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const SLASH_BLOCK = [["/*", "*/"]] as const;
|
|
38
|
+
const C_LIKE: Syntax = { line: ["//"], block: SLASH_BLOCK, quotes: `"'` };
|
|
39
|
+
const SYNTAXES: Record<string, Syntax> = Object.fromEntries([
|
|
40
|
+
// Not jsx or tsx: JSX text (`don't`, `</a>`) is no lexable string or regex.
|
|
41
|
+
...["ts", "mts", "cts", "js", "mjs", "cjs"].map((ext) => [ext, { ...C_LIKE, js: true }]),
|
|
42
|
+
...["java", "kt", "kts", "scala", "swift", "dart", "groovy"].map((ext) => [
|
|
43
|
+
ext,
|
|
44
|
+
{ ...C_LIKE, triple: true },
|
|
45
|
+
]),
|
|
46
|
+
["go", { ...C_LIKE, rawBacktick: true }],
|
|
47
|
+
...["css", "scss", "less"].map((ext) => [
|
|
48
|
+
ext,
|
|
49
|
+
{ line: [], block: SLASH_BLOCK, quotes: `"'`, cssUrl: true },
|
|
50
|
+
]),
|
|
51
|
+
...["py", "pyi"].map((ext) => [
|
|
52
|
+
ext,
|
|
53
|
+
{ line: ["#"], block: [], quotes: `"'`, triple: true, indented: true, fstring: true },
|
|
54
|
+
]),
|
|
55
|
+
["toml", { line: ["#"], block: [], quotes: `"`, literal: "'", triple: true }],
|
|
56
|
+
...["html", "htm", "xml", "svg", "xhtml"].map((ext) => [
|
|
57
|
+
ext,
|
|
58
|
+
// Text and <pre> whitespace can matter: only trailing whitespace is cosmetic.
|
|
59
|
+
{ line: [], block: [["<!--", "-->"]], quotes: "", indented: true },
|
|
60
|
+
]),
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/** Comments that steer a compiler, linter, bundler or runtime are code. */
|
|
64
|
+
const DIRECTIVE =
|
|
65
|
+
/@ts-|eslint|prettier|biome|oxlint|istanbul|\bc8\b|\bv8\b|@jsx|@flow|@vite|webpack|__PURE__|__NO_SIDE_EFFECTS__|source(?:Mapping)?URL|^\/\s*<|go:|\+build|nolint|noqa|type:|pyright|mypy|pylint|pragma|coding[:=]|fmt:|NOSONAR|@license|@preserve|@refresh|environment|language=|cython|ruff|isort|deno-|@vitest|@jest|swiftlint|ktlint|checkstyle|suppress|nullable|#(?:end)?region/iu;
|
|
66
|
+
|
|
67
|
+
const extensionOf = (file: string): string => {
|
|
68
|
+
const name = file.slice(file.lastIndexOf("/") + 1);
|
|
69
|
+
const dot = name.lastIndexOf(".");
|
|
70
|
+
return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** CI and workflow definitions are read as written: their comments are never stripped. */
|
|
74
|
+
const CI_PATH =
|
|
75
|
+
/^\.github\/|(?:^|\/)(?:\.gitlab-ci\.ya?ml|Jenkinsfile|azure-pipelines\.ya?ml|\.pre-commit-config\.ya?ml|\.circleci\/.*)$/u;
|
|
76
|
+
|
|
77
|
+
/** The syntax `file` is lexed with; null keeps comments and all but trailing whitespace. */
|
|
78
|
+
const syntaxOf = (file: string): Syntax | null =>
|
|
79
|
+
CI_PATH.test(file) ? null : (SYNTAXES[extensionOf(file)] ?? null);
|
|
80
|
+
|
|
81
|
+
const SPACE = /[ \t\r\f\v]/u;
|
|
82
|
+
const WORD = /[\w$]/u;
|
|
83
|
+
const NO_SPACE_AFTER = "([{,;";
|
|
84
|
+
const NO_SPACE_BEFORE = ")]},;";
|
|
85
|
+
const REGEX_AFTER = "(,=:[!&|?{};+-*%<>~^";
|
|
86
|
+
const REGEX_KEYWORDS = new Set([
|
|
87
|
+
"return",
|
|
88
|
+
"typeof",
|
|
89
|
+
"instanceof",
|
|
90
|
+
"case",
|
|
91
|
+
"do",
|
|
92
|
+
"else",
|
|
93
|
+
"in",
|
|
94
|
+
"of",
|
|
95
|
+
"new",
|
|
96
|
+
"delete",
|
|
97
|
+
"void",
|
|
98
|
+
"throw",
|
|
99
|
+
"yield",
|
|
100
|
+
"await",
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
type Frame = { kind: "code"; braces: number } | { kind: "template" };
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The substance of `text` for `file`: what stays after comments and
|
|
107
|
+
* insignificant whitespace are removed. Null when it cannot be read with
|
|
108
|
+
* certainty (the caller then counts the file as a behavior change).
|
|
109
|
+
*/
|
|
110
|
+
export function substanceOf(file: string, text: string): string | null {
|
|
111
|
+
const syntax = syntaxOf(file);
|
|
112
|
+
if (!syntax) return plainSubstance(text);
|
|
113
|
+
const markup = syntax.block.some(([open]) => open === "<!--");
|
|
114
|
+
// Markup with scripts, styles, CDATA or processing instructions holds
|
|
115
|
+
// other languages; a comment opener after anything but a tag end may sit
|
|
116
|
+
// in an attribute value.
|
|
117
|
+
if (
|
|
118
|
+
markup &&
|
|
119
|
+
(/<(?:script|style)\b|<!\[CDATA\[|<\?/iu.test(text) || /[^>\s][ \t]*<!--/u.test(text))
|
|
120
|
+
)
|
|
121
|
+
return plainSubstance(text);
|
|
122
|
+
return lex(syntax, text);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** No lexer: only trailing whitespace and blank lines are cosmetic. */
|
|
126
|
+
function plainSubstance(text: string): string {
|
|
127
|
+
return text
|
|
128
|
+
.split("\n")
|
|
129
|
+
.map((line) => line.replace(/[ \t\r\f\v]+$/u, ""))
|
|
130
|
+
.filter(Boolean)
|
|
131
|
+
.join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function lex(syntax: Syntax, text: string): string | null {
|
|
135
|
+
const collapse = !syntax.indented;
|
|
136
|
+
let out = "";
|
|
137
|
+
let lineStart = 0;
|
|
138
|
+
/** Output before this index is string content: trailing-space trimming stops there. */
|
|
139
|
+
let kept = 0;
|
|
140
|
+
let pendingSpace = false;
|
|
141
|
+
const frames: Frame[] = [{ kind: "code", braces: 0 }];
|
|
142
|
+
let index = 0;
|
|
143
|
+
|
|
144
|
+
// A shebang line is code, read as written.
|
|
145
|
+
if (text.startsWith("#!")) {
|
|
146
|
+
const end = text.indexOf("\n");
|
|
147
|
+
out = end < 0 ? text : text.slice(0, end);
|
|
148
|
+
kept = out.length;
|
|
149
|
+
index = end < 0 ? text.length : end;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const emitCode = (chunk: string) => {
|
|
153
|
+
if (collapse && pendingSpace && out.length > lineStart) {
|
|
154
|
+
const before = out[out.length - 1];
|
|
155
|
+
if (!NO_SPACE_AFTER.includes(before) && !NO_SPACE_BEFORE.includes(chunk[0])) out += " ";
|
|
156
|
+
}
|
|
157
|
+
pendingSpace = false;
|
|
158
|
+
out += chunk;
|
|
159
|
+
};
|
|
160
|
+
const emitVerbatim = (chunk: string) => {
|
|
161
|
+
emitCode(chunk);
|
|
162
|
+
kept = out.length;
|
|
163
|
+
};
|
|
164
|
+
const newline = () => {
|
|
165
|
+
if (!collapse) {
|
|
166
|
+
let end = out.length;
|
|
167
|
+
while (end > Math.max(lineStart, kept) && SPACE.test(out[end - 1])) end -= 1;
|
|
168
|
+
out = out.slice(0, end);
|
|
169
|
+
}
|
|
170
|
+
pendingSpace = false;
|
|
171
|
+
if (out.length > lineStart) {
|
|
172
|
+
out += "\n";
|
|
173
|
+
lineStart = out.length;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
/** Can a `/` start a regex here? After an operator or a keyword, not after a value. */
|
|
177
|
+
const regexAllowed = (): boolean => {
|
|
178
|
+
let end = out.length;
|
|
179
|
+
while (end > 0 && /\s/u.test(out[end - 1])) end -= 1;
|
|
180
|
+
if (end === 0 || REGEX_AFTER.includes(out[end - 1])) return true;
|
|
181
|
+
let start = end;
|
|
182
|
+
while (start > 0 && WORD.test(out[start - 1])) start -= 1;
|
|
183
|
+
return REGEX_KEYWORDS.has(out.slice(start, end));
|
|
184
|
+
};
|
|
185
|
+
/** Scan a quoted string from `index`; returns false when it does not close. */
|
|
186
|
+
const scanString = (quote: string, escapes: boolean, multiline: boolean): boolean => {
|
|
187
|
+
const prefix = text.slice(Math.max(0, index - 2), index).match(/[a-z]*$/iu)?.[0] ?? "";
|
|
188
|
+
const fstring = syntax.fstring === true && /f/iu.test(prefix);
|
|
189
|
+
let end = index + quote.length;
|
|
190
|
+
while (end < text.length) {
|
|
191
|
+
const char = text[end];
|
|
192
|
+
if (escapes && char === "\\") {
|
|
193
|
+
end += 2;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (text.startsWith(quote, end)) {
|
|
197
|
+
if (fstring && unbalanced(text.slice(index + quote.length, end))) return false;
|
|
198
|
+
emitVerbatim(text.slice(index, end + quote.length));
|
|
199
|
+
index = end + quote.length;
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
if (char === "\n" && !multiline) return false;
|
|
203
|
+
end += 1;
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
while (index < text.length) {
|
|
209
|
+
const frame = frames[frames.length - 1];
|
|
210
|
+
const char = text[index];
|
|
211
|
+
if (frame.kind === "template") {
|
|
212
|
+
if (char === "\\") {
|
|
213
|
+
emitVerbatim(text.slice(index, index + 2));
|
|
214
|
+
index += 2;
|
|
215
|
+
} else if (char === "`") {
|
|
216
|
+
emitVerbatim(char);
|
|
217
|
+
frames.pop();
|
|
218
|
+
index += 1;
|
|
219
|
+
} else if (text.startsWith("${", index)) {
|
|
220
|
+
emitVerbatim("${");
|
|
221
|
+
frames.push({ kind: "code", braces: 0 });
|
|
222
|
+
index += 2;
|
|
223
|
+
} else {
|
|
224
|
+
// Template text, newlines included, is the string's value.
|
|
225
|
+
out += char;
|
|
226
|
+
kept = out.length;
|
|
227
|
+
index += 1;
|
|
228
|
+
}
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (char === "\n") {
|
|
232
|
+
newline();
|
|
233
|
+
index += 1;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (SPACE.test(char)) {
|
|
237
|
+
if (collapse) pendingSpace = true;
|
|
238
|
+
else out += char;
|
|
239
|
+
index += 1;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const lineOpener = syntax.line.find((opener) => text.startsWith(opener, index));
|
|
243
|
+
if (lineOpener) {
|
|
244
|
+
const end = text.indexOf("\n", index);
|
|
245
|
+
const comment = text.slice(index, end < 0 ? text.length : end);
|
|
246
|
+
if (DIRECTIVE.test(comment.slice(lineOpener.length))) emitVerbatim(comment);
|
|
247
|
+
else pendingSpace = true;
|
|
248
|
+
index = end < 0 ? text.length : end;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const block = syntax.block.find(([open]) => text.startsWith(open, index));
|
|
252
|
+
if (block) {
|
|
253
|
+
const end = text.indexOf(block[1], index + block[0].length);
|
|
254
|
+
if (end < 0) return null;
|
|
255
|
+
const comment = text.slice(index, end + block[1].length);
|
|
256
|
+
if (DIRECTIVE.test(comment.slice(block[0].length, -block[1].length))) emitVerbatim(comment);
|
|
257
|
+
else pendingSpace = true;
|
|
258
|
+
// A newline inside the comment still ends the code line before it.
|
|
259
|
+
if (comment.includes("\n")) newline();
|
|
260
|
+
index = end + block[1].length;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (syntax.triple && (char === '"' || char === "'") && text.startsWith(char.repeat(3), index)) {
|
|
264
|
+
if (!scanString(char.repeat(3), !syntax.literal?.includes(char), true)) return null;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (syntax.quotes.includes(char)) {
|
|
268
|
+
if (!scanString(char, true, Boolean(syntax.multiline))) return null;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (syntax.literal?.includes(char)) {
|
|
272
|
+
if (!scanString(char, false, false)) return null;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (syntax.rawBacktick && char === "`") {
|
|
276
|
+
if (!scanString("`", false, true)) return null;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (syntax.js && char === "`") {
|
|
280
|
+
emitVerbatim(char);
|
|
281
|
+
frames.push({ kind: "template" });
|
|
282
|
+
index += 1;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (syntax.cssUrl && /^url\(\s*[^"')\s]/iu.test(text.slice(index, index + 64))) return null;
|
|
286
|
+
if (syntax.js && char === "/" && !regexAllowed() && out.trimEnd().endsWith(")")) {
|
|
287
|
+
// Division after `)` may be a regex after `if (x)`: with another `/` on
|
|
288
|
+
// the line the two readings differ on what is code, so give up.
|
|
289
|
+
const lineEnd = text.indexOf("\n", index);
|
|
290
|
+
if (text.slice(index + 1, lineEnd < 0 ? text.length : lineEnd).includes("/")) return null;
|
|
291
|
+
}
|
|
292
|
+
if (syntax.js && char === "/" && regexAllowed()) {
|
|
293
|
+
let end = index + 1;
|
|
294
|
+
let inClass = false;
|
|
295
|
+
while (end < text.length && (inClass || text[end] !== "/")) {
|
|
296
|
+
if (text[end] === "\n") return null;
|
|
297
|
+
if (text[end] === "\\") end += 1;
|
|
298
|
+
else if (text[end] === "[") inClass = true;
|
|
299
|
+
else if (text[end] === "]") inClass = false;
|
|
300
|
+
end += 1;
|
|
301
|
+
}
|
|
302
|
+
if (end >= text.length) return null;
|
|
303
|
+
emitVerbatim(text.slice(index, end + 1));
|
|
304
|
+
index = end + 1;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (frames.length > 1 && frame.kind === "code") {
|
|
308
|
+
if (char === "{") frame.braces += 1;
|
|
309
|
+
else if (char === "}") {
|
|
310
|
+
if (frame.braces === 0) {
|
|
311
|
+
emitVerbatim(char);
|
|
312
|
+
frames.pop();
|
|
313
|
+
index += 1;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
frame.braces -= 1;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
emitCode(char);
|
|
320
|
+
index += 1;
|
|
321
|
+
}
|
|
322
|
+
if (frames.length !== 1) return null;
|
|
323
|
+
newline();
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Does an f-string body hold a `{` that never closes, once `{{` and `}}` are set aside? */
|
|
328
|
+
function unbalanced(body: string): boolean {
|
|
329
|
+
let depth = 0;
|
|
330
|
+
for (const part of body.replaceAll("{{", "").replaceAll("}}", "")) {
|
|
331
|
+
if (part === "{") depth += 1;
|
|
332
|
+
else if (part === "}") depth = Math.max(0, depth - 1);
|
|
333
|
+
}
|
|
334
|
+
return depth > 0;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* A lone CR (not followed by LF), U+2028 or U+2029: some readers end a line
|
|
339
|
+
* there and others do not, so a comment can hide code from one of them.
|
|
340
|
+
*/
|
|
341
|
+
export function hasOpaqueLineBreak(text: string): boolean {
|
|
342
|
+
return /\r(?!\n)|[\u2028\u2029]/u.test(text);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
346
|
+
|
|
347
|
+
/** The UTF-8 text of `bytes`, or null for binary (a NUL byte) or invalid UTF-8. */
|
|
348
|
+
export function textOf(bytes: Uint8Array): string | null {
|
|
349
|
+
if (bytes.includes(0)) return null;
|
|
350
|
+
try {
|
|
351
|
+
return decoder.decode(bytes);
|
|
352
|
+
} catch {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Is the change from `before` to `after` of `file` cosmetic (equal substance)? */
|
|
358
|
+
export function cosmeticChange(file: string, before: Uint8Array, after: Uint8Array): boolean {
|
|
359
|
+
const left = textOf(before);
|
|
360
|
+
const right = textOf(after);
|
|
361
|
+
if (left === null || right === null) return false;
|
|
362
|
+
if (hasOpaqueLineBreak(left) || hasOpaqueLineBreak(right)) return false;
|
|
363
|
+
const a = substanceOf(file, left);
|
|
364
|
+
return a !== null && a === substanceOf(file, right);
|
|
365
|
+
}
|
package/src/core/workspaces.ts
CHANGED
|
@@ -142,7 +142,7 @@ const workspaceConfigSchema = z
|
|
|
142
142
|
*/
|
|
143
143
|
defaultEndpoint: z.unknown().optional(),
|
|
144
144
|
/** How a normal-risk behavior change is verified (S17); same rules as grants. */
|
|
145
|
-
verification: z.enum(["self", "independent"]).optional(),
|
|
145
|
+
verification: z.enum(["proportional", "self", "independent"]).optional(),
|
|
146
146
|
/**
|
|
147
147
|
* Who does the work (skills and `fanout plan` read it): `inline-first`
|
|
148
148
|
* (default) keeps the lead working itself and refuses a slice without a
|
package/src/fanout-brief.ts
CHANGED
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
// `<worktree>+try<n>` and session `<lead>-w-<slice>+try<n>`: slice ids cannot
|
|
9
9
|
// contain `+`, so no attempt collides with another slice's path or session.
|
|
10
10
|
// The lead's host (WORKIT_HOST, or its own session variable) picks the identity
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// rules: Claude Code workers use the WORKIT_SESSION_ID in their env (the
|
|
12
|
+
// lead's: SubagentStart issues ids only to judges); OpenCode, Pi and Codex
|
|
13
|
+
// workers export nothing (the host derives the id);
|
|
14
|
+
// Cursor, CI and plain terminals export `<lead>-w-<slice>`. Once
|
|
15
|
+
// `workit fanout worktree create` made the slice's worktree, WORKTREE and the
|
|
16
|
+
// RULES name it: every git and check command runs as `workit --cwd <worktree> …`.
|
|
14
17
|
// The lead must have a WORKIT_SESSION_ID: worker ids derive from it. A
|
|
15
18
|
// worker id longer than the ledger's 128 characters hashes its slice part. The fan-in line follows the plan's `fanIn`:
|
|
16
19
|
// one PR per slice forbids every merge; integration mode has the worker merge
|
|
@@ -64,8 +67,7 @@ const REPORT = [
|
|
|
64
67
|
* docs/agents/hosts.md):
|
|
65
68
|
* - `issued`: Claude Code. SubagentStart issues ids only to verifiers and
|
|
66
69
|
* reviewers, so an implementer keeps the lead's WORKIT_SESSION_ID from its
|
|
67
|
-
* env
|
|
68
|
-
* plain git and never mints an id;
|
|
70
|
+
* env and never mints an id;
|
|
69
71
|
* - `derived`: OpenCode, Pi, Codex. The worker's shell carries the host's own
|
|
70
72
|
* session id, which exporting WORKIT_SESSION_ID cannot replace;
|
|
71
73
|
* - `declared`: Cursor, CI, a plain terminal. Nothing vouches for an id, so
|
|
@@ -83,7 +85,7 @@ const identityOf = (host: string | null): BriefIdentity => {
|
|
|
83
85
|
const identityLines = (identity: BriefIdentity, session: string): string[] => {
|
|
84
86
|
if (identity === "issued")
|
|
85
87
|
return [
|
|
86
|
-
" Session: your env already has WORKIT_SESSION_ID (the lead's id; SubagentStart issues ids only to verifiers and reviewers). Use it as is
|
|
88
|
+
" Session: your env already has WORKIT_SESSION_ID (the lead's id; SubagentStart issues ids only to verifiers and reviewers). Use it as is: workit commands read it and `workit git commit` adds the Workit-Session trailer; never export or hand-mint one.",
|
|
87
89
|
];
|
|
88
90
|
if (identity === "derived")
|
|
89
91
|
return [
|
|
@@ -92,25 +94,28 @@ const identityLines = (identity: BriefIdentity, session: string): string[] => {
|
|
|
92
94
|
return [` export WORKIT_SESSION_ID=${session}`];
|
|
93
95
|
};
|
|
94
96
|
|
|
97
|
+
/**
|
|
98
|
+
* RULES. With the slice's worktree made (`worktree`), every git and check
|
|
99
|
+
* command names it (`workit --cwd <worktree> …`, one verb per call) and it is
|
|
100
|
+
* already on the branch; Workit's hooks keep an implementer inside it (#310).
|
|
101
|
+
*/
|
|
95
102
|
const rules = (
|
|
96
|
-
identity: BriefIdentity,
|
|
97
103
|
mode: BriefMode,
|
|
98
104
|
branch: string,
|
|
99
105
|
base: string,
|
|
106
|
+
worktree: string | null,
|
|
100
107
|
): string[] => {
|
|
101
|
-
const
|
|
108
|
+
const workit = worktree ? `workit --cwd ${worktree}` : "workit";
|
|
102
109
|
let first = ` 1. First command: \`workit git branch ${branch} --base ${base}\` (your worktree may start on another name).`;
|
|
103
|
-
if (
|
|
110
|
+
if (worktree)
|
|
111
|
+
first = ` 1. Work only in WORKTREE, already on ${branch}${mode === "resume" ? " at its head" : ""}: edit files under it and run each git and check command against it, one verb per call (\`${workit} git commit …\`, \`${workit} check <name>\`), never \`cd … &&\` chains or \`$VAR\` paths. An implementer's change outside it is denied.`;
|
|
112
|
+
else if (mode === "resume")
|
|
104
113
|
first = ` 1. First command: \`git switch ${branch}\`, then continue from its head.`;
|
|
105
|
-
else if (plain)
|
|
106
|
-
first = ` 1. First command: \`git fetch origin && git switch -c ${branch} origin/${base}\` (plain git: worktree isolation refuses every \`workit git\` form).`;
|
|
107
114
|
return [
|
|
108
115
|
"RULES:",
|
|
109
116
|
first,
|
|
110
117
|
' 2. Decide ambiguities yourself: `workit ledger ruling "<what>" --why "<why>" --cost-if-wrong "<cost>"`; stop only for an irreversible, security-sensitive or out-of-worktree action.',
|
|
111
|
-
|
|
112
|
-
? ' 3. Commit with plain `git commit -m <msg> --trailer "Workit-Session: <the WORKIT_SESSION_ID in your env>" --trailer "<your host attribution trailer>" -- <paths in SCOPE>`; push only if this brief says so.'
|
|
113
|
-
: " 3. Commit with `workit git commit -m <msg> -- <paths in SCOPE>`; push only if this brief says so.",
|
|
118
|
+
` 3. Commit with \`${workit} git commit -m <msg> -- <paths in SCOPE>\`; push only if this brief says so.`,
|
|
114
119
|
" 4. Never record a verdict on your own work.",
|
|
115
120
|
" 5. Run tests, lint, typecheck and builds only through `workit check` (a named check, or `workit check --heavy -- <cmd>`): one machine-wide lease queues every agent, so never a hand-made flock; `workit lock status` shows who holds it.",
|
|
116
121
|
" 6. A failed review gets one fix round and one recheck. Still failing: stop and report the open finding, never a third round (the lead spawns a decider). A product choice is the user's: report it, do not decide it.",
|
|
@@ -226,8 +231,11 @@ export function renderBrief(
|
|
|
226
231
|
...(standing.length
|
|
227
232
|
? standing.map((order) => ` - ${order.what}`)
|
|
228
233
|
: [" - none recorded beyond this brief"]),
|
|
234
|
+
exists
|
|
235
|
+
? `WORKTREE: ${planned} (made by \`workit fanout worktree create ${slice.id}\`; yours alone)`
|
|
236
|
+
: `WORKTREE: ${planned} (not made yet: the lead runs \`workit fanout worktree create ${slice.id}\` before spawning, then renders this brief again)`,
|
|
229
237
|
...identityLines(identity, minted),
|
|
230
|
-
...rules(
|
|
238
|
+
...rules(mode, branch, slice.base, exists ? planned : null),
|
|
231
239
|
];
|
|
232
240
|
return {
|
|
233
241
|
ok: true,
|
package/src/fanout-worktree.ts
CHANGED
|
@@ -28,6 +28,8 @@ import path from "node:path";
|
|
|
28
28
|
import { gitBranch } from "./git/ops";
|
|
29
29
|
import { GIT_TIMEOUTS, gitCommonDir, pushRemoteName, resolveRef } from "./git/rev";
|
|
30
30
|
import { appendObserved, readLedger, type LedgerActor } from "./ledger";
|
|
31
|
+
import { FANOUT_TOKEN_FILE } from "./hooks/worktree-confine";
|
|
32
|
+
import { linkWorktreeDependencies, type LinkedDependencies } from "./worktree-deps";
|
|
31
33
|
import {
|
|
32
34
|
branchRef,
|
|
33
35
|
fanoutFail,
|
|
@@ -154,8 +156,6 @@ function createdRow(cwd: string, plan: FanoutFile, slice: Slice, target: string)
|
|
|
154
156
|
return { row, exact: false };
|
|
155
157
|
}
|
|
156
158
|
|
|
157
|
-
const TOKEN_FILE = "workit-fanout-token";
|
|
158
|
-
|
|
159
159
|
/** The filesystem calls identity relies on; tests stub them. */
|
|
160
160
|
export const worktreeDeps = {
|
|
161
161
|
stat: (file: string): fs.BigIntStats => fs.statSync(file, { bigint: true }),
|
|
@@ -179,7 +179,7 @@ function adminDir(worktree: string): AdminDir | null {
|
|
|
179
179
|
const file = dir.stdout.trim();
|
|
180
180
|
let token: string | null = null;
|
|
181
181
|
try {
|
|
182
|
-
token = fs.readFileSync(path.join(file,
|
|
182
|
+
token = fs.readFileSync(path.join(file, FANOUT_TOKEN_FILE), "utf8").trim() || null;
|
|
183
183
|
} catch {
|
|
184
184
|
token = null;
|
|
185
185
|
}
|
|
@@ -204,7 +204,7 @@ function markAdminDir(worktree: string): (AdminDir & { token: string }) | null {
|
|
|
204
204
|
if (!admin) return null;
|
|
205
205
|
const token = randomBytes(16).toString("hex");
|
|
206
206
|
try {
|
|
207
|
-
fs.writeFileSync(path.join(admin.path,
|
|
207
|
+
fs.writeFileSync(path.join(admin.path, FANOUT_TOKEN_FILE), `${token}\n`);
|
|
208
208
|
} catch {
|
|
209
209
|
return null;
|
|
210
210
|
}
|
|
@@ -253,6 +253,8 @@ export type CreateOutcome = {
|
|
|
253
253
|
mode: "new" | "resume";
|
|
254
254
|
/** False when the worktree already existed on the slice's branch. */
|
|
255
255
|
created: boolean;
|
|
256
|
+
/** node_modules linked from the main checkout (worktree-deps.ts); null when none were. */
|
|
257
|
+
dependencies: LinkedDependencies | null;
|
|
256
258
|
notes: string[];
|
|
257
259
|
};
|
|
258
260
|
|
|
@@ -301,6 +303,7 @@ export function createSliceWorktree(
|
|
|
301
303
|
`remove it yourself (git worktree remove ${target}) once you know what it holds`,
|
|
302
304
|
);
|
|
303
305
|
excludeScratch(cwd);
|
|
306
|
+
const deps = linkWorktreeDependencies(mainCheckout(cwd), target);
|
|
304
307
|
return {
|
|
305
308
|
ok: true,
|
|
306
309
|
data: {
|
|
@@ -309,7 +312,13 @@ export function createSliceWorktree(
|
|
|
309
312
|
head: existing.head ?? "",
|
|
310
313
|
mode: resolveRef(cwd, localRef) ? "resume" : "new",
|
|
311
314
|
created: false,
|
|
312
|
-
|
|
315
|
+
dependencies: deps.linked,
|
|
316
|
+
notes: [
|
|
317
|
+
deps.linked
|
|
318
|
+
? "the worktree already exists; only its missing dependencies were linked"
|
|
319
|
+
: "the worktree already exists; nothing was changed",
|
|
320
|
+
...deps.notes,
|
|
321
|
+
],
|
|
313
322
|
},
|
|
314
323
|
};
|
|
315
324
|
}
|
|
@@ -383,6 +392,8 @@ export function createSliceWorktree(
|
|
|
383
392
|
}
|
|
384
393
|
excludeScratch(cwd);
|
|
385
394
|
const scratch = ensureScratch(target);
|
|
395
|
+
const deps = linkWorktreeDependencies(mainCheckout(cwd), target);
|
|
396
|
+
notes.push(...deps.notes);
|
|
386
397
|
const head = gitRun(target, ["rev-parse", "HEAD"]).stdout.trim();
|
|
387
398
|
const baseSha = mode === "new" ? head : null;
|
|
388
399
|
const admin = markAdminDir(target);
|
|
@@ -402,7 +413,10 @@ export function createSliceWorktree(
|
|
|
402
413
|
...(admin ? { adminId: admin.id, adminIno: admin.ino, adminToken: admin.token } : {}),
|
|
403
414
|
...(baseSha ? { baseSha } : {}),
|
|
404
415
|
});
|
|
405
|
-
return {
|
|
416
|
+
return {
|
|
417
|
+
ok: true,
|
|
418
|
+
data: { ...base, scratch, head, mode, created: true, dependencies: deps.linked, notes },
|
|
419
|
+
};
|
|
406
420
|
}
|
|
407
421
|
|
|
408
422
|
const worktreeFailed = (stderr: string) =>
|
package/src/forge/pr-ops.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
resolveAutonomy,
|
|
25
25
|
type AutonomySource,
|
|
26
26
|
type DefaultEndpoint,
|
|
27
|
+
type VerificationMode,
|
|
27
28
|
} from "../autonomy";
|
|
28
29
|
import { branchKindOf, flowRoute, gitFlowOf, isProtectedTarget } from "../core/branch";
|
|
29
30
|
import { vcsConfig } from "../core/vcs-config";
|
|
@@ -40,6 +41,8 @@ import {
|
|
|
40
41
|
type LedgerActor,
|
|
41
42
|
type VerdictCheck,
|
|
42
43
|
} from "../ledger";
|
|
44
|
+
import type { LaneNeed, ReviewLane } from "../core/policy/lane";
|
|
45
|
+
import { laneLabel, prLaneGate } from "../core/policy/pr-lane";
|
|
43
46
|
import { buildStatusDoc, selectPr, type PrStatusDoc } from "./report";
|
|
44
47
|
import type { ResolvedForge } from "./resolve";
|
|
45
48
|
import { failure, success, type ForgeResult, type MergeMethod, type PrRef } from "./types";
|
|
@@ -361,7 +364,22 @@ export type MergeOutcome = {
|
|
|
361
364
|
head: string;
|
|
362
365
|
method: MergeMethod;
|
|
363
366
|
mergeSha: string | null;
|
|
364
|
-
|
|
367
|
+
/**
|
|
368
|
+
* `required`: the gate asked for a verdict (false under lane none or a
|
|
369
|
+
* bypass); `accepted`: an accepted independent verdict covers the head;
|
|
370
|
+
* `verdictId`: the verdict the merge relied on (the author's own under lane
|
|
371
|
+
* self, shown as self-reviewed); `lane`/`need`: the review lane and what it
|
|
372
|
+
* asked (`need` is non-author outside `verification: proportional`).
|
|
373
|
+
*/
|
|
374
|
+
verdict: {
|
|
375
|
+
required: boolean;
|
|
376
|
+
accepted: boolean;
|
|
377
|
+
verdictId: string | null;
|
|
378
|
+
review: "verified" | "self-reviewed" | "none";
|
|
379
|
+
lane: ReviewLane;
|
|
380
|
+
need: LaneNeed;
|
|
381
|
+
verification: VerificationMode;
|
|
382
|
+
};
|
|
365
383
|
/** Set when `--unverified` bypassed the verdict: the reason and its ledger row. */
|
|
366
384
|
unverified: { reason: string; recorded: string } | null;
|
|
367
385
|
grant: { source: AutonomySource };
|
|
@@ -645,13 +663,24 @@ export async function mergePullRequest(
|
|
|
645
663
|
const ledger = readLedger(cwd, { integrity: true });
|
|
646
664
|
if (!ledger.ok) return ledger;
|
|
647
665
|
const check = checkVerdicts(cwd, branch, ledger.value.rows);
|
|
648
|
-
const
|
|
649
|
-
const
|
|
666
|
+
const gate = prLaneGate(cwd, check, { branch, base: doc.base, head });
|
|
667
|
+
const summary = {
|
|
668
|
+
head: check.head,
|
|
669
|
+
accepted: check.accepted,
|
|
670
|
+
authors: check.authors,
|
|
671
|
+
lane: gate.lane.lane,
|
|
672
|
+
need: gate.need,
|
|
673
|
+
};
|
|
674
|
+
// Lane none needs no verdict, so no verdict head either.
|
|
675
|
+
const covered = gate.satisfied && (gate.need === "none" || check.head === head);
|
|
650
676
|
let verdict: MergeOutcome["verdict"] = {
|
|
651
|
-
required:
|
|
652
|
-
accepted:
|
|
653
|
-
verdictId:
|
|
654
|
-
|
|
677
|
+
required: gate.need !== "none",
|
|
678
|
+
accepted: check.head === head && check.accepted.accepted,
|
|
679
|
+
verdictId: covered ? gate.verdictId : null,
|
|
680
|
+
review: covered ? gate.review : "none",
|
|
681
|
+
lane: gate.lane.lane,
|
|
682
|
+
need: gate.need,
|
|
683
|
+
verification: gate.verification,
|
|
655
684
|
};
|
|
656
685
|
let unverified: MergeOutcome["unverified"] = null;
|
|
657
686
|
// The bypass covers a missing verdict, never a rejection.
|
|
@@ -679,9 +708,9 @@ export async function mergePullRequest(
|
|
|
679
708
|
"failed",
|
|
680
709
|
`unverified_unrecorded: the --unverified bypass could not be recorded (${recorded.error}); nothing was merged`,
|
|
681
710
|
);
|
|
682
|
-
verdict = { required: false, accepted: false, verdictId: null };
|
|
711
|
+
verdict = { ...verdict, required: false, accepted: false, verdictId: null, review: "none" };
|
|
683
712
|
unverified = { reason, recorded: String(recorded.value.id) };
|
|
684
|
-
} else if (check.head !== head)
|
|
713
|
+
} else if (!covered && check.head !== head)
|
|
685
714
|
return refuse(
|
|
686
715
|
`head_mismatch: the verdict check reads ${branch} at ${check.head?.slice(0, 12) ?? "(missing)"} but ${label} is at ${head.slice(0, 12)}`,
|
|
687
716
|
`git fetch && git switch ${branch} && git merge --ff-only @{u} # then re-verify that head`,
|
|
@@ -693,12 +722,21 @@ export async function mergePullRequest(
|
|
|
693
722
|
tamperedUnblock(cwd),
|
|
694
723
|
{ reason: "ledger_tampered", verdict: summary },
|
|
695
724
|
);
|
|
696
|
-
else if (!covered)
|
|
725
|
+
else if (!covered) {
|
|
726
|
+
const bypassHint = grant.allowUnverified
|
|
727
|
+
? `; or, only if the user asks to merge unverified: workit pr merge --unverified --reason "<why>"`
|
|
728
|
+
: "";
|
|
729
|
+
const lane = gate.verification === "proportional" ? `, ${laneLabel(gate)}` : "";
|
|
697
730
|
return refuse(
|
|
698
|
-
|
|
699
|
-
|
|
731
|
+
gate.need === "self"
|
|
732
|
+
? `NEEDS_VERDICT: ${label} has no verdict for ${head.slice(0, 12)}${lane}: the author's own --self verdict is enough (${check.accepted.reasons.join(", ")})`
|
|
733
|
+
: `NEEDS_VERDICT: ${label} has no accepted independent verdict for ${head.slice(0, 12)}${lane} (${check.accepted.reasons.join(", ")})`,
|
|
734
|
+
gate.need === "self"
|
|
735
|
+
? `after an observed passing \`workit check test\`, the author runs: workit ledger verdict tests-verified --self --how "<what you ran>" --branch ${branch} (or a verifier records one)${bypassHint}`
|
|
736
|
+
: `an independent session verifies the head and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${branch}${bypassHint}`,
|
|
700
737
|
{ reason: "needs_verdict", verdict: summary },
|
|
701
738
|
);
|
|
739
|
+
}
|
|
702
740
|
|
|
703
741
|
const squashMessage = squashMessageFor(cwd, resolved, input, doc);
|
|
704
742
|
if (squashMessage && !squashMessage.ok) return squashMessage;
|
|
@@ -714,6 +752,8 @@ export async function mergePullRequest(
|
|
|
714
752
|
base: doc.base,
|
|
715
753
|
method: input.method,
|
|
716
754
|
verdictId: verdict.verdictId,
|
|
755
|
+
review: verdict.review,
|
|
756
|
+
lane: verdict.lane,
|
|
717
757
|
...(unverified ? { unverified: unverified.recorded } : {}),
|
|
718
758
|
grant: grant.source,
|
|
719
759
|
});
|
|
@@ -747,6 +787,8 @@ export async function mergePullRequest(
|
|
|
747
787
|
mergeSha: merged.data.mergeSha,
|
|
748
788
|
method: input.method,
|
|
749
789
|
verdictId: verdict.verdictId,
|
|
790
|
+
review: verdict.review,
|
|
791
|
+
lane: verdict.lane,
|
|
750
792
|
...(unverified ? { unverified: unverified.recorded } : {}),
|
|
751
793
|
grant: grant.source,
|
|
752
794
|
intent: intentId,
|