@brainervirus/workit-core 8.23.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-implement/SKILL.md +5 -5
- package/skills/workit-review/SKILL.md +5 -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 +5 -5
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/carry.ts +74 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +57 -9
- package/src/core/policy/lane.ts +562 -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 +8 -6
- package/src/fanout-worktree.ts +1 -1
- package/src/forge/pr-ops.ts +65 -14
- package/src/forge/report.ts +4 -2
- package/src/git/default-base.ts +49 -0
- package/src/git/rev.ts +244 -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/stop.ts +50 -33
- package/src/hooks/worktree-confine.ts +3 -3
- package/src/hooks/write-gate.ts +10 -1
- package/src/ledger.ts +155 -52
- package/src/stack.ts +68 -16
- 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. `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.
|
|
@@ -36,11 +36,11 @@ description: Build a requested change in small verified steps - follow local pat
|
|
|
36
36
|
when that was requested, or the request implies delivery and the effective
|
|
37
37
|
endpoint in `workit grant show` is `pr`, `green` or `merged`; `green` and
|
|
38
38
|
`merged` keep babysitting. Otherwise it is `commit`.
|
|
39
|
-
7. Verify
|
|
40
|
-
|
|
41
|
-
"<what you ran>"`;
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
7. Verify as far as the review lane asks (`workit policy assess`, `workit pr
|
|
40
|
+
status`). Lane none: no verdict. Lane self: after `workit check test` passes,
|
|
41
|
+
record `workit ledger verdict tests-verified --self --how "<what you ran>"`;
|
|
42
|
+
it reads self-reviewed, never verified, and is enough. Focused, full, high
|
|
43
|
+
risk or `verification: self|independent` merges: one fresh verifier per PR after the last push, its own host-derived session (Claude Code: the `verifier` agent, `model: sonnet` when focused; Codex:
|
|
44
44
|
the `verifier` agent; OpenCode: a child session; Pi: a supervised worker),
|
|
45
45
|
never a hand-minted id. Before saying done, reconcile
|
|
46
46
|
every named deliverable against the target checkout and observe it (for a push:
|
|
@@ -13,6 +13,11 @@ OpenCode, a child session; Pi, a supervised worker. Never hand-mint one such
|
|
|
13
13
|
as `<lead>-v1`: it is refused as `forged_session`.
|
|
14
14
|
An author's `--self` verdict reads self-reviewed and never counts as
|
|
15
15
|
independent; `type-check-only` never proves a behavior change.
|
|
16
|
+
Depth follows the review lane in `workit pr status`: focused reads the diff and
|
|
17
|
+
runs its tests; full also drives the real surface (verify-<app>). Review once
|
|
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).
|
|
16
21
|
|
|
17
22
|
1. Pin the candidate: `git rev-parse HEAD`, the base, `git diff <base>...HEAD`;
|
|
18
23
|
for a PR, `workit pr status --json` (checks, unresolved threads).
|
|
@@ -51,8 +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.**
|
|
55
|
-
non-author verdict (workit-review); else a self verdict stands. 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
|
|
56
55
|
granted: `workit stack land` or `workit pr merge`; `--unverified --reason` if user-asked.
|
|
57
56
|
7. **Observe it landed:** `workit verify-delivery pr` or `merge`. After a failed
|
|
58
57
|
verdict or a check red 3+ times, at any endpoint: offer `/wk-retro` in one
|
package/src/autonomy.ts
CHANGED
|
@@ -185,17 +185,28 @@ function endpointView(entry: unknown, context: EndpointContext): EndpointView {
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
/**
|
|
188
|
-
* How a
|
|
189
|
-
*
|
|
190
|
-
* verdict,
|
|
191
|
-
*
|
|
188
|
+
* How a behavior change is verified (S17, #333). `proportional` (default):
|
|
189
|
+
* the review lane computed from the diff decides (core/policy/lane.ts): none
|
|
190
|
+
* needs no verdict, self the author's own `--self` verdict, focused and full
|
|
191
|
+
* a non-author verdict, at every gate including merge. `self`: an observed
|
|
192
|
+
* passing `workit check test` plus the author's own `--self` verdict at
|
|
193
|
+
* normal risk, labelled self-reviewed; merge still needs a non-author
|
|
194
|
+
* verdict. `independent`: a verdict from a session that did not author the
|
|
195
|
+
* branch. User config only, like grants.
|
|
192
196
|
*/
|
|
193
|
-
export type VerificationMode = "self" | "independent";
|
|
194
|
-
export const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
export type VerificationMode = "self" | "independent" | "proportional";
|
|
198
|
+
export const VERIFICATION_MODES: readonly VerificationMode[] = [
|
|
199
|
+
"proportional",
|
|
200
|
+
"self",
|
|
201
|
+
"independent",
|
|
202
|
+
];
|
|
203
|
+
export const DEFAULT_VERIFICATION: VerificationMode = "proportional";
|
|
204
|
+
export const verificationOf = (entry: unknown): VerificationMode => {
|
|
205
|
+
const value = (entry as { verification?: unknown } | null)?.verification;
|
|
206
|
+
return VERIFICATION_MODES.includes(value as VerificationMode)
|
|
207
|
+
? (value as VerificationMode)
|
|
208
|
+
: DEFAULT_VERIFICATION;
|
|
209
|
+
};
|
|
199
210
|
|
|
200
211
|
export type AutonomySource = "autonomy" | "autoApprove" | "default";
|
|
201
212
|
|
|
@@ -415,7 +426,7 @@ export function writeGrants(
|
|
|
415
426
|
dir: string = grantsDir(),
|
|
416
427
|
/** A new default endpoint; `null` removes it (back to `commit`). */
|
|
417
428
|
endpoint?: DefaultEndpoint | null,
|
|
418
|
-
/** A new verification mode; `null` removes it (back to `
|
|
429
|
+
/** A new verification mode; `null` removes it (back to `proportional`). */
|
|
419
430
|
verification?: VerificationMode | null,
|
|
420
431
|
): GrantWrite {
|
|
421
432
|
const current = readWorkspacesResult(dir);
|
package/src/check-config.ts
CHANGED
|
@@ -42,12 +42,19 @@ export function checksConfigFile(root: string): string {
|
|
|
42
42
|
return !fs.existsSync(current) && fs.existsSync(legacy) ? legacy : current;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
const WORKIT_GITIGNORE = "*\n!risk-paths\n";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Create `<root>/.workit/` with its `.gitignore` (idempotent) and return the
|
|
49
|
+
* dir. It ignores everything but `risk-paths`, which a repo commits; the old
|
|
50
|
+
* `*` alone is rewritten.
|
|
51
|
+
*/
|
|
46
52
|
export function ensureWorkitDir(root: string): string {
|
|
47
53
|
const dir = path.join(root, ".workit");
|
|
48
54
|
fs.mkdirSync(dir, { recursive: true });
|
|
49
55
|
const ignore = path.join(dir, ".gitignore");
|
|
50
|
-
if (!fs.existsSync(ignore)
|
|
56
|
+
if (!fs.existsSync(ignore) || fs.readFileSync(ignore, "utf8") === "*\n")
|
|
57
|
+
fs.writeFileSync(ignore, WORKIT_GITIGNORE);
|
|
51
58
|
return dir;
|
|
52
59
|
}
|
|
53
60
|
|
package/src/core/methods.ts
CHANGED
|
@@ -115,9 +115,9 @@ Autonomy contract:
|
|
|
115
115
|
Prose is a note: only a configured check the CLI ran (\`workit check <name>\`)
|
|
116
116
|
satisfies a gate; an ad-hoc \`workit check -- <cmd>\` never does.
|
|
117
117
|
- Author is not verifier: your own verdict is \`--self\`, shown as
|
|
118
|
-
self-reviewed, never verified. High risk,
|
|
119
|
-
merge need a verifier with its
|
|
120
|
-
\`<lead>:<child>\`); never hand-mint one (\`<lead>-v1\`).
|
|
118
|
+
self-reviewed, never verified. High risk, a focused or full lane and
|
|
119
|
+
merge under \`verification: self|independent\` need a verifier with its
|
|
120
|
+
own host-derived session (a subagent's \`<lead>:<child>\`); never hand-mint one (\`<lead>-v1\`).
|
|
121
121
|
- A local-commit endpoint does not imply PR readiness, and a local commit alone
|
|
122
122
|
is not evidence of a requested remote push. Before reporting delivery,
|
|
123
123
|
reconcile every requested item and observe it (\`workit verify-delivery\`).
|
|
@@ -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,97 @@
|
|
|
1
|
+
// `linguist-generated` from the `.gitattributes` files of one tree (#333,
|
|
2
|
+
// review finding mvkcoxgb). The review lane reads them from the base
|
|
3
|
+
// revision only: never `.git/info/attributes`, a global attributes file or
|
|
4
|
+
// the working tree, so a change cannot mark its own code generated. What this
|
|
5
|
+
// parser does not understand (quoted patterns, macros, directory patterns)
|
|
6
|
+
// marks nothing generated, which only raises a lane.
|
|
7
|
+
|
|
8
|
+
type Rule = { regex: RegExp; generated: boolean };
|
|
9
|
+
|
|
10
|
+
/** A gitattributes pattern relative to `dir` ("" for the root) as a regex over repo paths. */
|
|
11
|
+
function patternRegex(dir: string, pattern: string): RegExp | null {
|
|
12
|
+
if (!pattern || pattern.endsWith("/") || pattern.startsWith('"') || pattern.startsWith("!"))
|
|
13
|
+
return null;
|
|
14
|
+
const anchored = pattern.includes("/");
|
|
15
|
+
const body = pattern.replace(/^\//u, "");
|
|
16
|
+
let source = "";
|
|
17
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
18
|
+
const char = body[index];
|
|
19
|
+
if (char === "*" && body[index + 1] === "*") {
|
|
20
|
+
const slash = body[index + 2] === "/";
|
|
21
|
+
source += slash ? "(?:.*/)?" : ".*";
|
|
22
|
+
index += slash ? 2 : 1;
|
|
23
|
+
} else if (char === "*") source += "[^/]*";
|
|
24
|
+
else if (char === "?") source += "[^/]";
|
|
25
|
+
else if (char === "[") {
|
|
26
|
+
const end = body.indexOf("]", index + 2);
|
|
27
|
+
if (end < 0) return null;
|
|
28
|
+
const set = body
|
|
29
|
+
.slice(index + 1, end)
|
|
30
|
+
.replace(/^!/u, "^")
|
|
31
|
+
.replace(/\\/gu, "\\\\");
|
|
32
|
+
source += `[${set}]`;
|
|
33
|
+
index = end;
|
|
34
|
+
} else if (char === "\\" && index + 1 < body.length) {
|
|
35
|
+
source += body[index + 1].replace(/[.*+?^${}()|[\]\\/]/gu, "\\$&");
|
|
36
|
+
index += 1;
|
|
37
|
+
} else source += char.replace(/[.*+?^${}()|[\]\\/]/gu, "\\$&");
|
|
38
|
+
}
|
|
39
|
+
const prefix = dir ? `${dir.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}/` : "";
|
|
40
|
+
try {
|
|
41
|
+
return new RegExp(anchored ? `^${prefix}${source}$` : `^${prefix}(?:.*/)?${source}$`, "u");
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The `linguist-generated` rules of one `.gitattributes` file in `dir`. */
|
|
48
|
+
function rulesOf(dir: string, text: string): Rule[] {
|
|
49
|
+
const out: Rule[] = [];
|
|
50
|
+
for (const raw of text.split("\n")) {
|
|
51
|
+
const line = raw.trim();
|
|
52
|
+
if (!line || line.startsWith("#") || line.startsWith("[attr]")) continue;
|
|
53
|
+
const [pattern, ...attributes] = line.split(/\s+/u);
|
|
54
|
+
let generated: boolean | null = null;
|
|
55
|
+
for (const attribute of attributes) {
|
|
56
|
+
if (attribute === "linguist-generated" || attribute === "linguist-generated=true")
|
|
57
|
+
generated = true;
|
|
58
|
+
else if (
|
|
59
|
+
attribute === "-linguist-generated" ||
|
|
60
|
+
attribute === "!linguist-generated" ||
|
|
61
|
+
attribute.startsWith("linguist-generated=")
|
|
62
|
+
)
|
|
63
|
+
generated = false;
|
|
64
|
+
}
|
|
65
|
+
if (generated === null) continue;
|
|
66
|
+
const regex = patternRegex(dir, pattern);
|
|
67
|
+
if (regex) out.push({ regex, generated });
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Of `paths`, those the `.gitattributes` files of one tree mark
|
|
74
|
+
* `linguist-generated` (`files`: each file's repo path and text). A deeper
|
|
75
|
+
* file overrides a shallower one, and a later line an earlier one.
|
|
76
|
+
*/
|
|
77
|
+
export function generatedByAttributes(
|
|
78
|
+
files: ReadonlyMap<string, string>,
|
|
79
|
+
paths: readonly string[],
|
|
80
|
+
): Set<string> {
|
|
81
|
+
const rules = [...files]
|
|
82
|
+
.map(([file, text]) => {
|
|
83
|
+
const dir = file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : "";
|
|
84
|
+
return {
|
|
85
|
+
depth: dir ? dir.split("/").length : 0,
|
|
86
|
+
rules: rulesOf(dir, text),
|
|
87
|
+
};
|
|
88
|
+
})
|
|
89
|
+
.toSorted((left, right) => left.depth - right.depth)
|
|
90
|
+
.flatMap((entry) => entry.rules);
|
|
91
|
+
const out = new Set<string>();
|
|
92
|
+
for (const file of paths) {
|
|
93
|
+
const last = rules.findLast((rule) => rule.regex.test(file));
|
|
94
|
+
if (last?.generated) out.add(file);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Judgment } from "../task-contract";
|
|
2
|
+
import { TaskStore } from "../task-store";
|
|
3
|
+
import { latestJudgment } from "./derive";
|
|
4
|
+
|
|
5
|
+
/** The open task's latest judgment (the one policy/derive.ts reads), or null when none is recorded. Never throws. */
|
|
6
|
+
export function currentJudgment(cwd: string): Judgment | null {
|
|
7
|
+
try {
|
|
8
|
+
const store = new TaskStore(cwd);
|
|
9
|
+
const listed = store.listTaskIndex();
|
|
10
|
+
const key = store.currentKey();
|
|
11
|
+
if (!listed.ok || !key.ok) return null;
|
|
12
|
+
const entry = listed.data
|
|
13
|
+
.filter((item) => item.key === key.data.key && item.status !== "closed")
|
|
14
|
+
.toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
|
|
15
|
+
const task = entry ? store.readTask(entry.id) : null;
|
|
16
|
+
return task?.ok === true ? latestJudgment(task.data) : null;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -6,6 +6,7 @@ import path from "node:path";
|
|
|
6
6
|
import { currentBranch } from "../../git/rev";
|
|
7
7
|
import { STRONG_RESULTS, checkVerdicts, readLedger, type ReadRow } from "../../ledger";
|
|
8
8
|
import type { VerificationMode } from "../../autonomy";
|
|
9
|
+
import { branchLane, laneNeed, type BranchLane } from "./lane";
|
|
9
10
|
import {
|
|
10
11
|
requirementId,
|
|
11
12
|
sha256,
|
|
@@ -21,6 +22,7 @@ export const RULES = {
|
|
|
21
22
|
self: "verdict:self",
|
|
22
23
|
nonAuthor: "verdict:non-author",
|
|
23
24
|
verified: "verdict:verified",
|
|
25
|
+
lane: "verdict:lane",
|
|
24
26
|
product: "decision:product",
|
|
25
27
|
plan: "plan",
|
|
26
28
|
} as const;
|
|
@@ -46,8 +48,11 @@ const normalizedScope = (scope: Scope): Scope => ({
|
|
|
46
48
|
/**
|
|
47
49
|
* | judgment | requirement |
|
|
48
50
|
* | behaviorChange | check:test (configured, fresh tree), close |
|
|
49
|
-
* |
|
|
50
|
-
* |
|
|
51
|
+
* | risk = normal, | verdict:lane: the review lane from the diff |
|
|
52
|
+
* | verification: proportional | decides (core/policy/lane.ts; met under lane |
|
|
53
|
+
* | (whatever behaviorChange says) | none), close |
|
|
54
|
+
* | behaviorChange && risk = normal | verdict:self (verification: self) or |
|
|
55
|
+
* | | verdict:non-author (independent), close |
|
|
51
56
|
* | risk = high | verdict:verified (ledger), close |
|
|
52
57
|
* | productChoiceOpen | decision:product, before write |
|
|
53
58
|
* | needsPlan or risk = high | plan (doc ref, soft), before write |
|
|
@@ -62,6 +67,7 @@ export function deriveRequirements(
|
|
|
62
67
|
): Requirement[] {
|
|
63
68
|
const out: Requirement[] = [];
|
|
64
69
|
const base = { scope, dependentAction: null };
|
|
70
|
+
const proportional = verification === "proportional";
|
|
65
71
|
if (judgment.behaviorChange)
|
|
66
72
|
out.push(
|
|
67
73
|
derived({
|
|
@@ -74,7 +80,25 @@ export function deriveRequirements(
|
|
|
74
80
|
acceptanceAllowed: false,
|
|
75
81
|
}),
|
|
76
82
|
);
|
|
77
|
-
|
|
83
|
+
// The diff, not the behavior-change judgment, decides: the requirement is
|
|
84
|
+
// derived at normal risk whatever behaviorChange says and is met under lane
|
|
85
|
+
// none (review finding mvkcoxgb). Trivial work keeps needing nothing at
|
|
86
|
+
// close; the merge gate still reads its lane.
|
|
87
|
+
if (proportional && judgment.riskTier === "normal")
|
|
88
|
+
out.push(
|
|
89
|
+
derived({
|
|
90
|
+
...base,
|
|
91
|
+
ruleId: RULES.lane,
|
|
92
|
+
dimension: "review",
|
|
93
|
+
reason:
|
|
94
|
+
"The review lane computed from the diff decides the verdict: none needs none, self the author's own (shown as self-reviewed), focused and full a non-author one.",
|
|
95
|
+
satisfaction:
|
|
96
|
+
"Read the lane in `workit policy assess` or `workit pr status`; record what it asks: `workit ledger verdict tests-verified --self --how <what you ran>` for self, or hand focused and full to a verifier.",
|
|
97
|
+
before: "close",
|
|
98
|
+
acceptanceAllowed: false,
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
if (!proportional && judgment.behaviorChange && judgment.riskTier === "normal")
|
|
78
102
|
out.push(
|
|
79
103
|
verification === "independent"
|
|
80
104
|
? derived({
|
|
@@ -264,7 +288,29 @@ const planRecorded = (task: TaskRecord, root: string): RuleStatus => {
|
|
|
264
288
|
* an accepted independent one; `verdict:verified` an accepted independent
|
|
265
289
|
* `verified` verdict of kind `live`.
|
|
266
290
|
*/
|
|
267
|
-
|
|
291
|
+
/** The review lane of the checked-out work (the working tree against the default base). */
|
|
292
|
+
export const workingLane = (task: TaskRecord, root: string): BranchLane =>
|
|
293
|
+
branchLane(root, { head: null, workingTree: true, judgment: latestJudgment(task) });
|
|
294
|
+
|
|
295
|
+
/** `verdict:lane`: the rule the lane asks for, or met under lane none. */
|
|
296
|
+
const laneVerdict = (task: TaskRecord, root: string, ledger: LedgerView): RuleStatus => {
|
|
297
|
+
// With no branch (outside git) there is no diff to read and nothing could
|
|
298
|
+
// hold a verdict: a judgment of no behavior change owes none, as before lanes.
|
|
299
|
+
if (!ledger.branch() && latestJudgment(task)?.behaviorChange === false)
|
|
300
|
+
return {
|
|
301
|
+
met: true,
|
|
302
|
+
reason: "no branch to read a lane from, and the judgment records no behavior change",
|
|
303
|
+
decisionIds: [],
|
|
304
|
+
};
|
|
305
|
+
const lane = workingLane(task, root);
|
|
306
|
+
const label = `lane ${lane.lane} (${lane.reasons.join("; ")})`;
|
|
307
|
+
const need = laneNeed(lane.lane);
|
|
308
|
+
if (need === "none") return { met: true, reason: `${label}: no verdict needed`, decisionIds: [] };
|
|
309
|
+
const status = verdict(need === "self" ? RULES.self : RULES.nonAuthor, root, ledger);
|
|
310
|
+
return { ...status, reason: `${label}: ${status.reason}` };
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const verdict = (ruleId: string, root: string, ledger: LedgerView): RuleStatus => {
|
|
268
314
|
const branch = ledger.branch();
|
|
269
315
|
if (!branch)
|
|
270
316
|
return { met: false, reason: "no branch is checked out to hold a verdict", decisionIds: [] };
|
|
@@ -274,12 +320,12 @@ const verdict = (requirement: Requirement, root: string, ledger: LedgerView): Ru
|
|
|
274
320
|
STRONG_RESULTS.has(String(entry.verdict.result));
|
|
275
321
|
const met =
|
|
276
322
|
!failing &&
|
|
277
|
-
(
|
|
323
|
+
(ruleId === RULES.verified
|
|
278
324
|
? check.verdicts.find(
|
|
279
325
|
(entry) =>
|
|
280
326
|
entry.accepted && entry.kind === "live" && String(entry.verdict.result) === "verified",
|
|
281
327
|
)
|
|
282
|
-
:
|
|
328
|
+
: ruleId === RULES.nonAuthor
|
|
283
329
|
? check.verdicts.find((entry) => entry.accepted && strong(entry))
|
|
284
330
|
: check.verdicts.find((entry) => entry.current && strong(entry)));
|
|
285
331
|
if (met)
|
|
@@ -294,9 +340,9 @@ const verdict = (requirement: Requirement, root: string, ledger: LedgerView): Ru
|
|
|
294
340
|
? "type-check-only does not prove a behavior change"
|
|
295
341
|
: (check.accepted.reasons.join(", ") || "no_verdict").replace(/_/g, " ");
|
|
296
342
|
const unblock =
|
|
297
|
-
|
|
343
|
+
ruleId === RULES.self
|
|
298
344
|
? "record your own verdict after an observed `workit check test`: `workit ledger verdict tests-verified --self --how <what you ran>` (shown as self-reviewed), or have a verifier record one"
|
|
299
|
-
: `an independent session records \`workit ledger verdict verified${
|
|
345
|
+
: `an independent session records \`workit ledger verdict verified${ruleId === RULES.verified ? " --kind live" : ""} --how <what it exercised>\``;
|
|
300
346
|
return { met: false, reason: `${why}: ${unblock}`, decisionIds: [] };
|
|
301
347
|
};
|
|
302
348
|
|
|
@@ -315,7 +361,9 @@ export function derivedRuleStatus(
|
|
|
315
361
|
case RULES.self:
|
|
316
362
|
case RULES.nonAuthor:
|
|
317
363
|
case RULES.verified:
|
|
318
|
-
return verdict(requirement, root, ledger);
|
|
364
|
+
return verdict(requirement.ruleId, root, ledger);
|
|
365
|
+
case RULES.lane:
|
|
366
|
+
return laneVerdict(task, root, ledger);
|
|
319
367
|
default:
|
|
320
368
|
return null;
|
|
321
369
|
}
|