@brainervirus/workit-core 8.24.0 → 8.25.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 +1 -1
- package/skills/workit-fanout/references/brief.md +4 -4
- package/skills/workit-review/SKILL.md +2 -0
- package/skills/workit-ship/SKILL.md +1 -1
- package/src/core/methods.ts +2 -2
- package/src/core/policy/carry.ts +74 -0
- package/src/core/policy/lane.ts +63 -14
- package/src/fanout-brief.ts +8 -6
- package/src/fanout-worktree.ts +1 -1
- package/src/forge/pr-ops.ts +11 -2
- package/src/git/default-base.ts +49 -0
- package/src/git/rev.ts +111 -0
- package/src/hooks/cwd-normalize.ts +44 -10
- package/src/hooks/delivery-verbs.ts +129 -0
- package/src/hooks/handle.ts +21 -6
- package/src/hooks/hosts/claude-code.ts +11 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/protocol.ts +2 -0
- package/src/hooks/raw-git.ts +34 -8
- package/src/hooks/shell-words.ts +32 -1
- package/src/hooks/worktree-confine.ts +3 -3
- package/src/hooks/write-gate.ts +10 -1
- package/src/ledger.ts +148 -50
- package/src/stack.ts +14 -4
- package/src/verdict-cover.ts +151 -0
package/package.json
CHANGED
|
@@ -40,7 +40,7 @@ units of ~100+ lines each. Coupled work stays with one owner. Workit never spawn
|
|
|
40
40
|
chain resumes. A fix round and recheck that both fail: a decider, not a
|
|
41
41
|
third round; product choices go to the user (`references/orchestration.md`).
|
|
42
42
|
7. **Verify by the workspace `verification` setting** (`workit grant show`).
|
|
43
|
-
A report is a pointer, never evidence. `proportional` (default): each slice's lane (`workit pr status`) decides; none needs no verdict, self the author's `--self`, focused (cheap model: Claude Code `model: sonnet`) and full a verifier after the last push. `self`: a verifier that wrote none
|
|
43
|
+
A report is a pointer, never evidence. `proportional` (default): each slice's lane (`workit pr status`) decides; none needs no verdict, self the author's `--self`, focused (cheap model: Claude Code `model: sonnet`) and full a verifier after the last push; one verdict at a stack's top covers its unchanged lower PRs, and a stale one with `reverify: focused` re-checks only the new diff, reusing green CI. `self`: a verifier that wrote none
|
|
44
44
|
of the slices, or you if you wrote none. `independent` or high risk: a
|
|
45
45
|
separate verifier session, never yours (doctrine: the ledger only refuses
|
|
46
46
|
authors). One verifier may take a batch of slices, one verdict per branch
|
|
@@ -25,7 +25,7 @@ REPORT: branch, head SHA, files changed, each VERIFY command with its exit code,
|
|
|
25
25
|
each ACCEPTANCE line met / not met, rulings you made (`workit ledger ruling`),
|
|
26
26
|
anything out of scope as a follow-up, not a diff.
|
|
27
27
|
STANDING: <the standing orders, verbatim: `workit ledger standing add` records each one>
|
|
28
|
-
WORKTREE: <the path `workit fanout worktree create <slice>` made; the worker runs `workit --cwd <it> …` there>
|
|
28
|
+
WORKTREE: <the path `workit fanout worktree create <slice>` made; the worker runs `workit <verb> --cwd <it> …` there>
|
|
29
29
|
<identity line, per the lead's host: see "Identity and git rules per host" below>
|
|
30
30
|
```
|
|
31
31
|
|
|
@@ -114,7 +114,7 @@ lead's host (`WORKIT_HOST`, or the host's own session variable):
|
|
|
114
114
|
|
|
115
115
|
Once `workit fanout worktree create <slice>` made the worktree, the brief's
|
|
116
116
|
WORKTREE line names it and RULES 1 and 3 run against it on every host:
|
|
117
|
-
`workit --cwd <worktree>
|
|
117
|
+
`workit git --cwd <worktree> commit …`, `workit check --cwd <worktree> <name>`,
|
|
118
118
|
one verb per call. The worktree is already on the branch (MODE new or resume),
|
|
119
119
|
so no branch command comes first. Without one, `MODE: resume` is
|
|
120
120
|
`git switch <branch>` (the branch exists), then continue from its head. A race
|
|
@@ -124,13 +124,13 @@ attempt (`--attempt <n>`) follows its host's row with the branch
|
|
|
124
124
|
## Worker rules (`workit fanout brief` appends them as RULES)
|
|
125
125
|
|
|
126
126
|
1. Work only in WORKTREE, already on the branch, one verb per call against it
|
|
127
|
-
(`workit --cwd <worktree> …`); Workit's hooks deny an implementer's change
|
|
127
|
+
(`workit <verb> --cwd <worktree> …`); Workit's hooks deny an implementer's change
|
|
128
128
|
outside it. With no worktree made yet: create the branch (`MODE: new`) or
|
|
129
129
|
`git switch` to it (`MODE: resume`), as the table above says.
|
|
130
130
|
2. Decide ambiguities yourself and record them:
|
|
131
131
|
`workit ledger ruling "<what>" --why "<why>" --cost-if-wrong "<cost>"`.
|
|
132
132
|
Stop only for an irreversible action, a security-sensitive one, or a side
|
|
133
133
|
effect outside the worktree.
|
|
134
|
-
3. Commit with `workit --cwd <worktree>
|
|
134
|
+
3. Commit with `workit git --cwd <worktree> commit -m "<msg>" -- <paths in SCOPE>`;
|
|
135
135
|
push only if the brief says so.
|
|
136
136
|
4. Never record a verdict on your own work.
|
|
@@ -16,6 +16,8 @@ independent; `type-check-only` never proves a behavior change.
|
|
|
16
16
|
Depth follows the review lane in `workit pr status`: focused reads the diff and
|
|
17
17
|
runs its tests; full also drives the real surface (verify-<app>). Review once
|
|
18
18
|
per PR after its last push; a second pass on an unchanged head is waste.
|
|
19
|
+
A stale verdict showing `reverify: focused (<files>)` in `workit pr status`: re-check only
|
|
20
|
+
those files since the judged head and reuse green CI there (no fresh install, no full suite).
|
|
19
21
|
|
|
20
22
|
1. Pin the candidate: `git rev-parse HEAD`, the base, `git diff <base>...HEAD`;
|
|
21
23
|
for a PR, `workit pr status --json` (checks, unresolved threads).
|
|
@@ -51,7 +51,7 @@ the same check. Fix round and recheck both failed: a decider (workit-fanout).
|
|
|
51
51
|
allows; no sleep loop. Red: read `logTail`, classify. Flake or infra: one
|
|
52
52
|
`workit ci rerun --failed --reason flake|infra` per head. Real: reproduce
|
|
53
53
|
with `workit check`, fix the root cause, batch every fix in one push.
|
|
54
|
-
6. **Verified.** The lane in `workit pr status` decides: none, no verdict; self, your `--self` verdict; focused or full (or `verification: self|independent`), one non-author verifier per PR after the last push, never per push (workit-review), on Claude Code `model: sonnet` when focused. Land only when
|
|
54
|
+
6. **Verified.** The lane in `workit pr status` decides: none, no verdict; self, your `--self` verdict; focused or full (or `verification: self|independent`), one non-author verifier per PR after the last push, never per push (workit-review), on Claude Code `model: sonnet` when focused. A docs-only push (or test-only under a unit verdict) keeps the verdict (`carried`); `reverify: focused` re-checks only the new diff, reusing green CI; one verdict at a stack's top covers its unchanged lower PRs. Land only when
|
|
55
55
|
granted: `workit stack land` or `workit pr merge`; `--unverified --reason` if user-asked.
|
|
56
56
|
7. **Observe it landed:** `workit verify-delivery pr` or `merge`. After a failed
|
|
57
57
|
verdict or a check red 3+ times, at any endpoint: offer `/wk-retro` in one
|
package/src/core/methods.ts
CHANGED
|
@@ -125,7 +125,7 @@ Autonomy contract:
|
|
|
125
125
|
CLI first: if a step has one right answer, use the \`workit\` verb instead of
|
|
126
126
|
hand-running git, gh or glab (\`workit git|pr|ci|check|help\`;
|
|
127
127
|
else \`npx -y @brainervirus/workit-cli\`).
|
|
128
|
-
Other checkout: \`workit --cwd <dir>
|
|
128
|
+
Other checkout: \`workit <verb> --cwd <dir> …\` (verb first: host rules match it); one workit
|
|
129
129
|
verb per shell call, never \`cd <dir> && workit\`. zsh-safe: no \`echo =====\`
|
|
130
130
|
(\`printf '%s\\n' ---\`), quote globs and URLs, no variable named \`status\` or \`path\`.
|
|
131
131
|
Add Co-Authored-By via \`workit git commit --trailer "K: v" -- <paths>\`, never
|
|
@@ -137,7 +137,7 @@ authorizes an effect. Judge work once with
|
|
|
137
137
|
\`workit policy assess --judge risk= behavior= product-choice= plan=\`.
|
|
138
138
|
Across repositories, bind items to checkout, branch and endpoint, resolve
|
|
139
139
|
competing targets before a mutation, and reconcile an uncertain
|
|
140
|
-
|
|
140
|
+
effect before a retry.
|
|
141
141
|
|
|
142
142
|
Mid-task: never silently drop or resume an objective (workit-continue).
|
|
143
143
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Verdict carry-over across a follow-up change (#334, decision mvk5q3io): a
|
|
2
|
+
// verdict judged head H; the branch is now at H'. The branch's own change
|
|
3
|
+
// since H (what came from the base left out, see lane.ts driftFiles) is
|
|
4
|
+
// classified with the review lane, and the verdict still applies when:
|
|
5
|
+
//
|
|
6
|
+
// lane-none that change is lane `none`: docs, comment or formatting-only
|
|
7
|
+
// edits, generated copies (by the base's .gitattributes), or
|
|
8
|
+
// nothing at all (an empty commit);
|
|
9
|
+
// tests-only the verdict is kind `unit` and the change is lane `self`
|
|
10
|
+
// (small) with every behavior file a test: test files off risk
|
|
11
|
+
// paths, no lockfile, no binary.
|
|
12
|
+
//
|
|
13
|
+
// Everything else is stale, failing closed: a behavior or risk-path change,
|
|
14
|
+
// an opaque file, an unreadable diff, a judged head git no longer has. A
|
|
15
|
+
// stale verdict whose change is lane `self` (small, no risk path, nothing
|
|
16
|
+
// opaque) asks only a focused re-verify of the changed files.
|
|
17
|
+
import { mergeBase, fileAt } from "../../git/rev";
|
|
18
|
+
import { computeLane, driftFiles, kindOf, parseRiskPaths, RISK_PATHS_FILE } from "./lane";
|
|
19
|
+
|
|
20
|
+
export type CarryRule = "lane-none" | "tests-only";
|
|
21
|
+
|
|
22
|
+
export type Drift =
|
|
23
|
+
| { carries: true; rule: CarryRule; since: string; files: string[] }
|
|
24
|
+
| {
|
|
25
|
+
carries: false;
|
|
26
|
+
since: string;
|
|
27
|
+
/** The changed files, or null when they cannot be read. */
|
|
28
|
+
files: string[] | null;
|
|
29
|
+
/** A focused re-verify of `files` is enough (lane self). */
|
|
30
|
+
focused: boolean;
|
|
31
|
+
reason: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const TEST_ONLY_KINDS: ReadonlySet<string> = new Set(["doc", "cosmetic", "generated", "test"]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether a `kind` verdict at `from` (keyed against `fromBase`, a commit)
|
|
38
|
+
* still applies at `to` (against `toBase`).
|
|
39
|
+
*/
|
|
40
|
+
export function verdictDrift(
|
|
41
|
+
cwd: string,
|
|
42
|
+
input: { from: string; to: string; fromBase: string; toBase: string; kind: string },
|
|
43
|
+
): Drift {
|
|
44
|
+
const since = input.from;
|
|
45
|
+
const stale = (reason: string, files: string[] | null = null, focused = false): Drift => ({
|
|
46
|
+
carries: false,
|
|
47
|
+
since,
|
|
48
|
+
files,
|
|
49
|
+
focused,
|
|
50
|
+
reason,
|
|
51
|
+
});
|
|
52
|
+
const fromFork = mergeBase(cwd, input.fromBase, input.from);
|
|
53
|
+
const toFork = mergeBase(cwd, input.toBase, input.to);
|
|
54
|
+
const files =
|
|
55
|
+
fromFork && toFork
|
|
56
|
+
? driftFiles(cwd, { from: input.from, to: input.to, fromFork, toFork })
|
|
57
|
+
: null;
|
|
58
|
+
if (!fromFork || !toFork || !files)
|
|
59
|
+
return stale(`the change since ${since.slice(0, 12)} cannot be read`);
|
|
60
|
+
// Repo risk paths from both base revisions: they only tighten.
|
|
61
|
+
const riskPaths = [fromFork, toFork].flatMap((fork) =>
|
|
62
|
+
parseRiskPaths(fileAt(cwd, fork, RISK_PATHS_FILE) ?? ""),
|
|
63
|
+
);
|
|
64
|
+
const lane = computeLane({ files, riskPaths });
|
|
65
|
+
const paths = files.map((file) => file.path);
|
|
66
|
+
if (lane.lane === "none") return { carries: true, rule: "lane-none", since, files: paths };
|
|
67
|
+
if (
|
|
68
|
+
input.kind === "unit" &&
|
|
69
|
+
lane.lane === "self" &&
|
|
70
|
+
files.every((file) => TEST_ONLY_KINDS.has(kindOf(file)))
|
|
71
|
+
)
|
|
72
|
+
return { carries: true, rule: "tests-only", since, files: paths };
|
|
73
|
+
return stale(lane.reasons[0] ?? `lane ${lane.lane}`, paths, lane.lane === "self");
|
|
74
|
+
}
|
package/src/core/policy/lane.ts
CHANGED
|
@@ -25,15 +25,18 @@ import {
|
|
|
25
25
|
diffNumstat,
|
|
26
26
|
diffRaw,
|
|
27
27
|
fileAt,
|
|
28
|
+
isTracked,
|
|
28
29
|
mergeBase,
|
|
30
|
+
ownChangeDrift,
|
|
29
31
|
readBlobs,
|
|
30
32
|
resolveRef,
|
|
31
33
|
treeFilesNamed,
|
|
32
34
|
untrackedFiles,
|
|
35
|
+
worktreeTop,
|
|
33
36
|
} from "../../git/rev";
|
|
34
37
|
import type { VerificationMode } from "../../autonomy";
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
38
|
+
import { defaultBase } from "../../git/default-base";
|
|
39
|
+
import type { VerdictCheck } from "../../ledger";
|
|
37
40
|
import { generatedByAttributes } from "./attributes";
|
|
38
41
|
import { cosmeticChange, hasOpaqueLineBreak } from "./substance";
|
|
39
42
|
|
|
@@ -117,14 +120,21 @@ const LOCKFILES = new Set([
|
|
|
117
120
|
"composer.lock",
|
|
118
121
|
]);
|
|
119
122
|
|
|
120
|
-
/**
|
|
121
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Docs are told by prose extension only: code under `docs/` is code. `.txt`
|
|
125
|
+
* is not a doc: requirements.txt, CMakeLists.txt or robots.txt change behavior.
|
|
126
|
+
*/
|
|
127
|
+
const DOC_EXT = /\.(?:md|mdx|markdown|rst|adoc)$/iu;
|
|
122
128
|
const DOC_NAMES = /^(?:license|licence|notice|authors|changelog|changes|contributors)$/iu;
|
|
123
129
|
/** Markdown that steers agents is behavior, not docs. */
|
|
124
130
|
const INSTRUCTIONS =
|
|
125
131
|
/(?:^|\/)(?:AGENTS|CLAUDE|GEMINI|SKILL)\.md$|(?:^|\/)(?:skills|agents|commands|rules|prompts)\/[^/]+/iu;
|
|
126
|
-
|
|
127
|
-
|
|
132
|
+
/**
|
|
133
|
+
* A test file: under a `test/`, `tests/` or `__tests__/` folder, or named
|
|
134
|
+
* `*.test.*`, `*.spec.*` or `*_test.*`. A `fixtures/` or `spec/` folder can
|
|
135
|
+
* hold runtime data or code, so it is not a test path (it reads as behavior).
|
|
136
|
+
*/
|
|
137
|
+
const TEST_PATH = /(?:^|\/)(?:test|tests|__tests__)\/|\.(?:test|spec)\.[^/]+$|_test\.[^/]+$/iu;
|
|
128
138
|
|
|
129
139
|
export type ChangedFile = {
|
|
130
140
|
path: string;
|
|
@@ -156,7 +166,7 @@ export type LaneAssessment = {
|
|
|
156
166
|
riskFiles: string[];
|
|
157
167
|
};
|
|
158
168
|
|
|
159
|
-
type FileKind = "doc" | "cosmetic" | "generated" | "lockfile" | "test" | "behavior";
|
|
169
|
+
export type FileKind = "doc" | "cosmetic" | "generated" | "lockfile" | "test" | "behavior";
|
|
160
170
|
|
|
161
171
|
const basename = (file: string): string => file.slice(file.lastIndexOf("/") + 1);
|
|
162
172
|
|
|
@@ -165,7 +175,7 @@ const isDoc = (file: string): boolean =>
|
|
|
165
175
|
!INSTRUCTIONS.test(file) && (DOC_EXT.test(file) || DOC_NAMES.test(basename(file)));
|
|
166
176
|
|
|
167
177
|
/** What a file off risk paths is; risk paths are decided before (see computeLane). */
|
|
168
|
-
function kindOf(file: ChangedFile): FileKind {
|
|
178
|
+
export function kindOf(file: ChangedFile): FileKind {
|
|
169
179
|
if (file.binary) return "behavior";
|
|
170
180
|
if (file.generated) return "generated";
|
|
171
181
|
if (LOCKFILES.has(basename(file.path))) return "lockfile";
|
|
@@ -360,7 +370,13 @@ function generatedAt(cwd: string, fork: string, paths: readonly string[]): Set<s
|
|
|
360
370
|
const forkOf = (cwd: string, base: string, head: string | null): string | null =>
|
|
361
371
|
mergeBase(cwd, base, head ?? "HEAD");
|
|
362
372
|
|
|
363
|
-
|
|
373
|
+
/** The files of `fork..head`; generated copies are read from `attributesAt` (the base revision). */
|
|
374
|
+
function laneFiles(
|
|
375
|
+
cwd: string,
|
|
376
|
+
fork: string,
|
|
377
|
+
head: string | null,
|
|
378
|
+
attributesAt: string = fork,
|
|
379
|
+
): ChangedFile[] | null {
|
|
364
380
|
const stats = diffNumstat(cwd, fork, head);
|
|
365
381
|
const cosmetic = stats ? cosmeticFiles(cwd, fork, head) : null;
|
|
366
382
|
if (!stats || !cosmetic) return null;
|
|
@@ -374,7 +390,7 @@ function laneFiles(cwd: string, fork: string, head: string | null): ChangedFile[
|
|
|
374
390
|
];
|
|
375
391
|
const generated = generatedAt(
|
|
376
392
|
cwd,
|
|
377
|
-
|
|
393
|
+
attributesAt,
|
|
378
394
|
files.map((entry) => entry.path),
|
|
379
395
|
);
|
|
380
396
|
return files.map((entry) => ({
|
|
@@ -383,6 +399,41 @@ function laneFiles(cwd: string, fork: string, head: string | null): ChangedFile[
|
|
|
383
399
|
}));
|
|
384
400
|
}
|
|
385
401
|
|
|
402
|
+
/**
|
|
403
|
+
* The files whose own change differs between two versions of a branch: the
|
|
404
|
+
* judged `from` (forked from `fromFork`) and the current `to` (forked from
|
|
405
|
+
* `toFork`). Generated copies are read from `toFork` only.
|
|
406
|
+
*
|
|
407
|
+
* - Same fork: the plain `from..to` diff, comments and formatting read as
|
|
408
|
+
* in the lane.
|
|
409
|
+
* - Moved fork (a rebase): each file the branch touches is compared by exact
|
|
410
|
+
* blob (see ownChangeDrift): it counts when the branch's resulting file
|
|
411
|
+
* differs, or when the base changed it too between the forks. Their lines
|
|
412
|
+
* come from `from..to`, which may include base edits (over-counting is the
|
|
413
|
+
* safe direction), and none is cosmetic.
|
|
414
|
+
*
|
|
415
|
+
* Null when git cannot read either side.
|
|
416
|
+
*/
|
|
417
|
+
export function driftFiles(
|
|
418
|
+
cwd: string,
|
|
419
|
+
range: { from: string; to: string; fromFork: string; toFork: string },
|
|
420
|
+
): ChangedFile[] | null {
|
|
421
|
+
if (range.fromFork === range.toFork) return laneFiles(cwd, range.from, range.to, range.toFork);
|
|
422
|
+
const drifted = ownChangeDrift(cwd, range);
|
|
423
|
+
const stats = drifted ? diffNumstat(cwd, range.from, range.to) : null;
|
|
424
|
+
if (!drifted || !stats) return null;
|
|
425
|
+
const byPath = new Map(stats.map((entry) => [entry.path, entry]));
|
|
426
|
+
const generated = generatedAt(cwd, range.toFork, drifted);
|
|
427
|
+
return drifted.map((file) => ({
|
|
428
|
+
path: file,
|
|
429
|
+
added: byPath.get(file)?.added ?? 0,
|
|
430
|
+
deleted: byPath.get(file)?.deleted ?? 0,
|
|
431
|
+
...(byPath.get(file)?.binary ? { binary: true } : {}),
|
|
432
|
+
cosmetic: false,
|
|
433
|
+
generated: generated.has(file),
|
|
434
|
+
}));
|
|
435
|
+
}
|
|
436
|
+
|
|
386
437
|
/**
|
|
387
438
|
* The changed files of `merge-base(base, head)..head`, or with `head` null
|
|
388
439
|
* of the working tree (tracked changes plus untracked files) since
|
|
@@ -455,11 +506,9 @@ export function branchLane(
|
|
|
455
506
|
* it is committed there. Null when there is nothing to warn about.
|
|
456
507
|
*/
|
|
457
508
|
export function untrackedRiskPathsWarning(cwd: string): string | null {
|
|
458
|
-
const
|
|
459
|
-
const root = top.ok ? top.stdout.trim() : "";
|
|
509
|
+
const root = worktreeTop(cwd);
|
|
460
510
|
if (!root || !existsSync(path.join(root, RISK_PATHS_FILE))) return null;
|
|
461
|
-
|
|
462
|
-
return tracked.ok
|
|
511
|
+
return isTracked(root, RISK_PATHS_FILE)
|
|
463
512
|
? null
|
|
464
513
|
: `${RISK_PATHS_FILE} is not committed: it does not apply until it is committed on the base branch`;
|
|
465
514
|
}
|
package/src/fanout-brief.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// workers export nothing (the host derives the id);
|
|
14
14
|
// Cursor, CI and plain terminals export `<lead>-w-<slice>`. Once
|
|
15
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> …`.
|
|
16
|
+
// RULES name it: every git and check command runs as `workit <verb> --cwd <worktree> …`.
|
|
17
17
|
// The lead must have a WORKIT_SESSION_ID: worker ids derive from it. A
|
|
18
18
|
// worker id longer than the ledger's 128 characters hashes its slice part. The fan-in line follows the plan's `fanIn`:
|
|
19
19
|
// one PR per slice forbids every merge; integration mode has the worker merge
|
|
@@ -96,8 +96,9 @@ const identityLines = (identity: BriefIdentity, session: string): string[] => {
|
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
98
|
* RULES. With the slice's worktree made (`worktree`), every git and check
|
|
99
|
-
* command names it (`workit --cwd <worktree> …`,
|
|
100
|
-
*
|
|
99
|
+
* command names it (`workit <verb> --cwd <worktree> …`, verb first so host
|
|
100
|
+
* allow rules match, one verb per call) and it is already on the branch;
|
|
101
|
+
* Workit's hooks keep an implementer inside it (#310).
|
|
101
102
|
*/
|
|
102
103
|
const rules = (
|
|
103
104
|
mode: BriefMode,
|
|
@@ -105,17 +106,18 @@ const rules = (
|
|
|
105
106
|
base: string,
|
|
106
107
|
worktree: string | null,
|
|
107
108
|
): string[] => {
|
|
108
|
-
const workit =
|
|
109
|
+
const workit = (verb: string) =>
|
|
110
|
+
worktree ? `workit ${verb} --cwd ${worktree}` : `workit ${verb}`;
|
|
109
111
|
let first = ` 1. First command: \`workit git branch ${branch} --base ${base}\` (your worktree may start on another name).`;
|
|
110
112
|
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}
|
|
113
|
+
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
114
|
else if (mode === "resume")
|
|
113
115
|
first = ` 1. First command: \`git switch ${branch}\`, then continue from its head.`;
|
|
114
116
|
return [
|
|
115
117
|
"RULES:",
|
|
116
118
|
first,
|
|
117
119
|
' 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.',
|
|
118
|
-
` 3. Commit with \`${workit}
|
|
120
|
+
` 3. Commit with \`${workit("git")} commit -m <msg> -- <paths in SCOPE>\`; push only if this brief says so.`,
|
|
119
121
|
" 4. Never record a verdict on your own work.",
|
|
120
122
|
" 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.",
|
|
121
123
|
" 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.",
|
package/src/fanout-worktree.ts
CHANGED
|
@@ -480,7 +480,7 @@ export function releaseSliceWorktree(
|
|
|
480
480
|
return fanoutFail(
|
|
481
481
|
"blocked",
|
|
482
482
|
`this command runs inside ${entry.path}`,
|
|
483
|
-
`run it from the main checkout: workit --cwd <main checkout>
|
|
483
|
+
`run it from the main checkout: workit fanout --cwd <main checkout> worktree release ${slice.id}`,
|
|
484
484
|
);
|
|
485
485
|
if (entry.branch !== null && entry.branch !== `refs/heads/${slice.branch}`)
|
|
486
486
|
return fanoutFail(
|
package/src/forge/pr-ops.ts
CHANGED
|
@@ -35,12 +35,13 @@ import { spawnSync } from "node:child_process";
|
|
|
35
35
|
import { currentBranch, fetchRefs, GIT_TIMEOUTS, remoteRefTip, resolveRef } from "../git/rev";
|
|
36
36
|
import {
|
|
37
37
|
appendObserved,
|
|
38
|
-
checkVerdicts,
|
|
39
38
|
tamperedUnblock,
|
|
40
39
|
readLedger,
|
|
40
|
+
type CarryNote,
|
|
41
41
|
type LedgerActor,
|
|
42
42
|
type VerdictCheck,
|
|
43
43
|
} from "../ledger";
|
|
44
|
+
import { checkVerdictsCovered, type StackCover } from "../verdict-cover";
|
|
44
45
|
import type { LaneNeed, ReviewLane } from "../core/policy/lane";
|
|
45
46
|
import { laneLabel, prLaneGate } from "../core/policy/pr-lane";
|
|
46
47
|
import { buildStatusDoc, selectPr, type PrStatusDoc } from "./report";
|
|
@@ -379,6 +380,10 @@ export type MergeOutcome = {
|
|
|
379
380
|
lane: ReviewLane;
|
|
380
381
|
need: LaneNeed;
|
|
381
382
|
verification: VerificationMode;
|
|
383
|
+
/** The stack verdict above this PR that covers it (#334), when it has none of its own. */
|
|
384
|
+
cover?: StackCover | null;
|
|
385
|
+
/** How a verdict on an older head carried to this one over a follow-up change (#334). */
|
|
386
|
+
carry?: CarryNote | null;
|
|
382
387
|
};
|
|
383
388
|
/** Set when `--unverified` bypassed the verdict: the reason and its ledger row. */
|
|
384
389
|
unverified: { reason: string; recorded: string } | null;
|
|
@@ -662,7 +667,8 @@ export async function mergePullRequest(
|
|
|
662
667
|
}
|
|
663
668
|
const ledger = readLedger(cwd, { integrity: true });
|
|
664
669
|
if (!ledger.ok) return ledger;
|
|
665
|
-
|
|
670
|
+
// A lower PR of a stack may be covered by the verdict at its top (#334).
|
|
671
|
+
const check = checkVerdictsCovered(cwd, branch, ledger.value.rows);
|
|
666
672
|
const gate = prLaneGate(cwd, check, { branch, base: doc.base, head });
|
|
667
673
|
const summary = {
|
|
668
674
|
head: check.head,
|
|
@@ -681,6 +687,9 @@ export async function mergePullRequest(
|
|
|
681
687
|
lane: gate.lane.lane,
|
|
682
688
|
need: gate.need,
|
|
683
689
|
verification: gate.verification,
|
|
690
|
+
...(covered && gate.review === "verified"
|
|
691
|
+
? { cover: check.cover, carry: check.cover ? null : check.carry }
|
|
692
|
+
: {}),
|
|
684
693
|
};
|
|
685
694
|
let unverified: MergeOutcome["unverified"] = null;
|
|
686
695
|
// The bypass covers a missing verdict, never a rejection.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The default base for patch-ids, author trailers and review lanes. Its own
|
|
2
|
+
// module so the lane (core/policy/lane.ts) and the ledger can both read it
|
|
3
|
+
// without importing each other.
|
|
4
|
+
import { vcsConfig } from "../core/vcs-config";
|
|
5
|
+
import { originHeadRef, resolveRef } from "./rev";
|
|
6
|
+
|
|
7
|
+
const safeRef = (value: string): boolean => value.length > 0 && !value.startsWith("-");
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The PR target of the release track the checkout is on, when the workspace
|
|
11
|
+
* configures tracks (as `fanout plan` and `stack plan` pick their trunk); a
|
|
12
|
+
* plain workspace default does not override origin's default branch.
|
|
13
|
+
*/
|
|
14
|
+
const trackTrunk = (cwd: string): string | null => {
|
|
15
|
+
try {
|
|
16
|
+
const resolved = vcsConfig("resolve", cwd);
|
|
17
|
+
if (resolved.ok === false || !resolved.releaseTrack || resolved.releaseTrack.blocking)
|
|
18
|
+
return null;
|
|
19
|
+
const target = String(resolved.defaultTargetBranch ?? "").trim();
|
|
20
|
+
return target && safeRef(target) ? target : null;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The default base for patch-ids and author trailers: the release track's PR
|
|
28
|
+
* target (origin's copy, else the local one) when tracks are configured, else
|
|
29
|
+
* origin's HEAD branch, else the first of origin/main, origin/master, main,
|
|
30
|
+
* master, origin/develop, develop that exists.
|
|
31
|
+
*/
|
|
32
|
+
export function defaultBase(cwd: string): string | null {
|
|
33
|
+
const trunk = trackTrunk(cwd);
|
|
34
|
+
if (trunk)
|
|
35
|
+
for (const candidate of [`origin/${trunk}`, trunk])
|
|
36
|
+
if (resolveRef(cwd, candidate)) return candidate;
|
|
37
|
+
const originHead = originHeadRef(cwd);
|
|
38
|
+
if (originHead && resolveRef(cwd, originHead)) return originHead;
|
|
39
|
+
for (const candidate of [
|
|
40
|
+
"origin/main",
|
|
41
|
+
"origin/master",
|
|
42
|
+
"main",
|
|
43
|
+
"master",
|
|
44
|
+
"origin/develop",
|
|
45
|
+
"develop",
|
|
46
|
+
])
|
|
47
|
+
if (resolveRef(cwd, candidate)) return candidate;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
package/src/git/rev.ts
CHANGED
|
@@ -515,6 +515,117 @@ export function resolveRef(cwd: string, ref: string): string | null {
|
|
|
515
515
|
return line(git(cwd, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]));
|
|
516
516
|
}
|
|
517
517
|
|
|
518
|
+
/** The branch origin's HEAD names (`origin/main`), or null. */
|
|
519
|
+
export function originHeadRef(cwd: string): string | null {
|
|
520
|
+
return line(git(cwd, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** The top of the working tree, or null outside one. */
|
|
524
|
+
export function worktreeTop(cwd: string): string | null {
|
|
525
|
+
return line(git(cwd, ["rev-parse", "--show-toplevel"]));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Whether git tracks `file` (repo-relative) in the index of the checkout at `top`. */
|
|
529
|
+
export function isTracked(top: string, file: string): boolean {
|
|
530
|
+
return git(top, ["ls-files", "--error-unmatch", "--", file]).ok;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The files whose own change differs between two versions of a branch: the
|
|
535
|
+
* judged `from` (forked from `fromFork`) and the current `to` (forked from
|
|
536
|
+
* `toFork`), compared by exact blob and mode, never by diff text (#334). A
|
|
537
|
+
* file the branch touches on one side only differs. A file it touches on
|
|
538
|
+
* both sides is unchanged only when the base left it alone between the forks
|
|
539
|
+
* and the branch's resulting blob is identical, so a hunk moved within a
|
|
540
|
+
* file, a re-indent or a base edit in a file the branch touches always
|
|
541
|
+
* counts. Null when git cannot read either side: the caller fails closed.
|
|
542
|
+
*/
|
|
543
|
+
export function ownChangeDrift(
|
|
544
|
+
cwd: string,
|
|
545
|
+
range: { from: string; to: string; fromFork: string; toFork: string },
|
|
546
|
+
): string[] | null {
|
|
547
|
+
const before = diffRaw(cwd, range.fromFork, range.from);
|
|
548
|
+
const after = diffRaw(cwd, range.toFork, range.to);
|
|
549
|
+
if (!before || !after) return null;
|
|
550
|
+
const judged = new Map(before.map((entry) => [entry.path, entry]));
|
|
551
|
+
const current = new Map(after.map((entry) => [entry.path, entry]));
|
|
552
|
+
const same = (a: RawEntry | undefined, b: RawEntry | undefined): boolean =>
|
|
553
|
+
a !== undefined &&
|
|
554
|
+
b !== undefined &&
|
|
555
|
+
a.oldMode === b.oldMode &&
|
|
556
|
+
a.oldBlob === b.oldBlob &&
|
|
557
|
+
a.newMode === b.newMode &&
|
|
558
|
+
a.newBlob === b.newBlob;
|
|
559
|
+
return [...new Set([...judged.keys(), ...current.keys()])]
|
|
560
|
+
.filter((file) => !same(judged.get(file), current.get(file)))
|
|
561
|
+
.toSorted();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Whether the branch's own files are identical at `from` (on `fromBase`) and
|
|
566
|
+
* `to` (on `toBase`): ownChangeDrift from each head's fork is empty. The
|
|
567
|
+
* exactness behind every patch-equal carry; false when git cannot tell.
|
|
568
|
+
*/
|
|
569
|
+
export function sameOwnChange(
|
|
570
|
+
cwd: string,
|
|
571
|
+
range: { from: string; to: string; fromBase: string; toBase: string },
|
|
572
|
+
): boolean {
|
|
573
|
+
const fromFork = mergeBase(cwd, range.fromBase, range.from);
|
|
574
|
+
const toFork = mergeBase(cwd, range.toBase, range.to);
|
|
575
|
+
if (!fromFork || !toFork) return false;
|
|
576
|
+
return ownChangeDrift(cwd, { from: range.from, to: range.to, fromFork, toFork })?.length === 0;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* The verbatim patch-ids of the non-merge commits of `fork..head` (empty
|
|
581
|
+
* commits have none) and how many merge commits the range holds: unlike
|
|
582
|
+
* `--stable`, whitespace counts, so a re-indent is another patch. Diff
|
|
583
|
+
* options are pinned as in patchId. Null when git fails, or has no
|
|
584
|
+
* `patch-id --verbatim`: the caller fails closed.
|
|
585
|
+
*/
|
|
586
|
+
export function commitPatchIds(
|
|
587
|
+
cwd: string,
|
|
588
|
+
fork: string,
|
|
589
|
+
head: string,
|
|
590
|
+
): { patchIds: string[]; merges: number } | null {
|
|
591
|
+
if (!safeArg(fork) || !safeArg(head)) return null;
|
|
592
|
+
const range = `${fork}..${head}`;
|
|
593
|
+
const merges = line(git(cwd, ["rev-list", "--count", "--merges", range]));
|
|
594
|
+
const log = git(
|
|
595
|
+
cwd,
|
|
596
|
+
[
|
|
597
|
+
"log",
|
|
598
|
+
"--no-merges",
|
|
599
|
+
"-p",
|
|
600
|
+
"-U3",
|
|
601
|
+
"--diff-algorithm=myers",
|
|
602
|
+
"--indent-heuristic",
|
|
603
|
+
"--no-color",
|
|
604
|
+
"--no-ext-diff",
|
|
605
|
+
"--no-textconv",
|
|
606
|
+
"--no-renames",
|
|
607
|
+
"--full-index",
|
|
608
|
+
"--src-prefix=a/",
|
|
609
|
+
"--dst-prefix=b/",
|
|
610
|
+
"--format=commit %H",
|
|
611
|
+
range,
|
|
612
|
+
"--",
|
|
613
|
+
],
|
|
614
|
+
{ timeoutMs: GIT_TIMEOUTS.worktree },
|
|
615
|
+
);
|
|
616
|
+
if (merges === null || !log.ok) return null;
|
|
617
|
+
if (!log.stdout.trim()) return { patchIds: [], merges: Number(merges) };
|
|
618
|
+
const ids = git(cwd, ["patch-id", "--verbatim"], { input: log.stdout });
|
|
619
|
+
if (!ids.ok) return null;
|
|
620
|
+
return {
|
|
621
|
+
patchIds: ids.stdout
|
|
622
|
+
.split("\n")
|
|
623
|
+
.map((entry) => entry.trim().split(/\s+/u)[0])
|
|
624
|
+
.filter(Boolean),
|
|
625
|
+
merges: Number(merges),
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
518
629
|
/** Pinned diff options for the review-lane reads: user config never changes them. */
|
|
519
630
|
const LANE_DIFF = [
|
|
520
631
|
"-c",
|