@brainervirus/workit-core 8.3.0 → 8.4.1
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/scripts/install-cursor-plugin.sh +12 -9
- package/scripts/verify-bundle-sources.ts +134 -0
- package/skills/workit-implement/SKILL.md +3 -3
- package/src/core/policy/derive.ts +1 -1
- package/src/core/policy/judgment.ts +54 -0
- package/src/core/store-lock.ts +36 -2
- package/src/core/task-engine.ts +77 -2
- package/src/core/task-store.ts +4 -1
- package/src/hooks/hosts/cursor.ts +4 -2
- package/src/ledger.ts +421 -57
package/package.json
CHANGED
|
@@ -107,16 +107,19 @@ const mcp = fs.existsSync(mcpPath)
|
|
|
107
107
|
const mergedMcp = mergeCursorMcp(mcp, "workit", server);
|
|
108
108
|
fs.writeFileSync(mcpPath, JSON.stringify(mergedMcp.config, null, 2) + "\n");
|
|
109
109
|
|
|
110
|
-
// The
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
110
|
+
// The shipped hooks-cursor.json reaches the launcher through
|
|
111
|
+
// ${CURSOR_PLUGIN_ROOT}, which only a Marketplace install needs. A local
|
|
112
|
+
// install writes the absolute launcher path into every event so hooks do not
|
|
113
|
+
// depend on Cursor expanding it; --local-dist also points sessionStart at the
|
|
114
|
+
// installed dist.
|
|
115
|
+
const hooksPath = path.join(pluginDir, "hooks", "hooks-cursor.json");
|
|
116
|
+
const hooks = fs.existsSync(hooksPath)
|
|
117
|
+
? JSON.parse(fs.readFileSync(hooksPath, "utf8"))
|
|
118
|
+
: { version: 1, hooks: {} };
|
|
119
|
+
const sessionStart = localDist ? cursorHookLocalDistEntry(pluginDir) : cursorHooksEntry(pluginDir);
|
|
120
|
+
const mergedHooks = mergeCursorHooks(hooks, { command: sessionStart.command }, pluginDir);
|
|
121
|
+
if (mergedHooks.changed.length > 0)
|
|
118
122
|
fs.writeFileSync(hooksPath, JSON.stringify(mergedHooks.config, null, 2) + "\n");
|
|
119
|
-
}
|
|
120
123
|
'
|
|
121
124
|
|
|
122
125
|
# DG-09: verify the just-written Cursor registration with the shared offline doctor.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Release guard: every adapter dist/ must inline the workspace's own
|
|
3
|
+
// @brainervirus sources, never a registry copy. bun build resolves the nearest
|
|
4
|
+
// node_modules first, so an installed packages/<pkg>/node_modules/@brainervirus
|
|
5
|
+
// copy silently replaces the tagged source (8.0.0 shipped 7.7.0's core that
|
|
6
|
+
// way). Two checks:
|
|
7
|
+
// - installs: no packages/*/node_modules/@brainervirus/* entry, and every root
|
|
8
|
+
// node_modules/@brainervirus/* entry resolves inside packages/ (run before
|
|
9
|
+
// the build; it also covers minified bundles, which carry no path headers);
|
|
10
|
+
// - bundles: bun prefixes each inlined module with a `// <path>` header; a
|
|
11
|
+
// header under node_modules/@brainervirus/ names a stale copy, and every
|
|
12
|
+
// non-minified entry (with its split chunks) must inline core from
|
|
13
|
+
// packages/workit-core/src/.
|
|
14
|
+
// `--installs-only` runs only the first check (prepareCmd, before the build).
|
|
15
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
16
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
17
|
+
import { RELEASE_PACKAGES } from "./analyze-release-scope";
|
|
18
|
+
|
|
19
|
+
// Separator-agnostic: a Windows build may write `\` in module headers.
|
|
20
|
+
const REGISTRY_COPY = /^\/\/ (.*node_modules[\\/]@brainervirus[\\/].+)$/gmu;
|
|
21
|
+
const WORKSPACE_CORE = /^\/\/ packages[\\/]workit-core[\\/]src[\\/]/mu;
|
|
22
|
+
/** bun's unminified output averages well under 100 bytes a line; --minify is thousands. */
|
|
23
|
+
const MINIFIED_BYTES_PER_LINE = 1000;
|
|
24
|
+
|
|
25
|
+
const entries = (dir: string): string[] => (existsSync(dir) ? readdirSync(dir) : []);
|
|
26
|
+
/** `root`-relative path with `/` separators, for messages on every OS. */
|
|
27
|
+
const rel = (root: string, file: string): string => relative(root, file).split(sep).join("/");
|
|
28
|
+
/** Windows paths compare case-insensitively. */
|
|
29
|
+
const fold = (file: string): string => (process.platform === "win32" ? file.toLowerCase() : file);
|
|
30
|
+
|
|
31
|
+
/** Installed @brainervirus packages a build could resolve instead of workspace source. */
|
|
32
|
+
export function registryInstalls(root: string): string[] {
|
|
33
|
+
const nested = entries(join(root, "packages")).flatMap((pkg) => {
|
|
34
|
+
const scope = join(root, "packages", pkg, "node_modules", "@brainervirus");
|
|
35
|
+
return entries(scope).map((name) => rel(root, join(scope, name)));
|
|
36
|
+
});
|
|
37
|
+
const packages = fold(join(realpathSync(root), "packages") + sep);
|
|
38
|
+
const scope = join(root, "node_modules", "@brainervirus");
|
|
39
|
+
const hoisted = entries(scope)
|
|
40
|
+
.filter((name) => !fold(realpathSync(join(scope, name))).startsWith(packages))
|
|
41
|
+
.map((name) => rel(root, join(scope, name)));
|
|
42
|
+
return [...nested, ...hoisted];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function bundles(dir: string): string[] {
|
|
46
|
+
return entries(dir).flatMap((name) => {
|
|
47
|
+
const file = join(dir, name);
|
|
48
|
+
if (statSync(file).isDirectory()) return bundles(file);
|
|
49
|
+
return name.endsWith(".js") ? [file] : [];
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type BundleProblem = { bundle: string; problem: string };
|
|
54
|
+
|
|
55
|
+
/** Sibling chunks a split bundle imports (`from "./x.js"`, `import("./x.js")`). */
|
|
56
|
+
const LOCAL_CHUNK = /(?:\bfrom|\bimport\s*\()\s*["']\.\/([^"']+\.js)["']/gu;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Built adapter bundles under `root`, and what each one inlined wrongly. A
|
|
60
|
+
* code-split entry (codex's hook) inlines core through its chunks, so the
|
|
61
|
+
* core-header requirement applies to each entry together with the chunks it
|
|
62
|
+
* reaches; a chunk only another bundle imports is not an entry.
|
|
63
|
+
*/
|
|
64
|
+
export function bundleSources(root: string): { scanned: string[]; problems: BundleProblem[] } {
|
|
65
|
+
const scanned = RELEASE_PACKAGES.filter((pkg) => pkg !== "workit-core").flatMap((pkg) =>
|
|
66
|
+
bundles(join(root, "packages", pkg, "dist")),
|
|
67
|
+
);
|
|
68
|
+
const text = new Map(scanned.map((file) => [file, readFileSync(file, "utf8")]));
|
|
69
|
+
const chunks = (file: string): string[] =>
|
|
70
|
+
[...(text.get(file) ?? "").matchAll(LOCAL_CHUNK)]
|
|
71
|
+
.map((m) => join(dirname(file), m[1]))
|
|
72
|
+
.filter((chunk) => text.has(chunk));
|
|
73
|
+
const imported = new Set(scanned.flatMap(chunks));
|
|
74
|
+
const reachesCore = (entry: string): boolean => {
|
|
75
|
+
const seen = new Set<string>();
|
|
76
|
+
const queue = [entry];
|
|
77
|
+
while (queue.length) {
|
|
78
|
+
const file = queue.shift() as string;
|
|
79
|
+
if (seen.has(file)) continue;
|
|
80
|
+
seen.add(file);
|
|
81
|
+
if (WORKSPACE_CORE.test(text.get(file) ?? "")) return true;
|
|
82
|
+
queue.push(...chunks(file));
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
};
|
|
86
|
+
const problems = scanned.flatMap((file): BundleProblem[] => {
|
|
87
|
+
const bundle = rel(root, file);
|
|
88
|
+
const body = text.get(file) ?? "";
|
|
89
|
+
const copies = [...body.matchAll(REGISTRY_COPY)].map((m) => m[1].replaceAll("\\", "/"));
|
|
90
|
+
if (copies.length)
|
|
91
|
+
return [{ bundle, problem: `${copies.length} registry-copy modules, e.g. ${copies[0]}` }];
|
|
92
|
+
const minified = body.length / (body.split("\n").length || 1) > MINIFIED_BYTES_PER_LINE;
|
|
93
|
+
if (minified || imported.has(file) || reachesCore(file)) return [];
|
|
94
|
+
const first = /^\s*\/\/ (\S.*)$/mu.exec(body)?.[1] ?? "none";
|
|
95
|
+
return [
|
|
96
|
+
{
|
|
97
|
+
bundle,
|
|
98
|
+
problem: `no \`// packages/workit-core/src/\` module header in it or its chunks (first header: ${first})`,
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
});
|
|
102
|
+
return { scanned: scanned.map((file) => rel(root, file)), problems };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Every failure under `root` as printable lines; empty when the release may proceed. */
|
|
106
|
+
export function verifyBundleSources(root: string, options: { installsOnly?: boolean } = {}) {
|
|
107
|
+
const failures = registryInstalls(root).map(
|
|
108
|
+
(path) => `${path}: installed copy outside packages/ (bundles would inline it)`,
|
|
109
|
+
);
|
|
110
|
+
if (options.installsOnly) return { failures, scanned: 0 };
|
|
111
|
+
const { scanned, problems } = bundleSources(root);
|
|
112
|
+
if (scanned.length === 0)
|
|
113
|
+
failures.push(`no adapter bundles under packages/*/dist; run \`bun run build\` first`);
|
|
114
|
+
for (const { bundle, problem } of problems) failures.push(`${bundle}: ${problem}`);
|
|
115
|
+
return { failures, scanned: scanned.length };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (import.meta.main) {
|
|
119
|
+
const args = process.argv.slice(2);
|
|
120
|
+
const installsOnly = args.includes("--installs-only");
|
|
121
|
+
const root = resolve(args.find((arg) => !arg.startsWith("--")) ?? process.cwd());
|
|
122
|
+
const { failures, scanned } = verifyBundleSources(root, { installsOnly });
|
|
123
|
+
if (failures.length) {
|
|
124
|
+
console.error("adapter bundles would not inline the workspace's own sources:");
|
|
125
|
+
for (const failure of failures) console.error(` - ${failure}`);
|
|
126
|
+
console.error("remove the copies and rebuild; see release.yml NPM_CONFIG_WORKSPACES_UPDATE");
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
console.log(
|
|
130
|
+
installsOnly
|
|
131
|
+
? "no registry copies of workspace packages installed"
|
|
132
|
+
: `verified ${scanned} adapter bundles inline workspace sources`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -11,9 +11,9 @@ description: Build a requested change in small verified steps - follow local pat
|
|
|
11
11
|
neighbour that already does something similar. Copy its patterns, names and
|
|
12
12
|
error handling. Repo rules (AGENTS.md, CLAUDE.md, lint config) win.
|
|
13
13
|
2. On the default branch? Branch first: `workit git branch --kind feature --slug <s>`.
|
|
14
|
-
Tracking it? Judge once: `workit policy assess --judge risk=normal
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
Tracking it? Judge once: `workit policy assess --judge risk=normal behavior=yes
|
|
15
|
+
product-choice=no plan=no` (trivial and no for a mechanical fix). Already
|
|
16
|
+
judged? Cite the plan with `--ref <path>` or record the decision; don't re-judge.
|
|
17
17
|
3. Small steps that each leave the tree green. Behavior change: write the
|
|
18
18
|
acceptance as Given/When/Then and see a test fail first (workit-bdd).
|
|
19
19
|
Mechanical change: the existing checks are enough. No tautological tests:
|
|
@@ -226,7 +226,7 @@ const productDecision = (task: TaskRecord, ledger: LedgerView): RuleStatus => {
|
|
|
226
226
|
: {
|
|
227
227
|
met: false,
|
|
228
228
|
reason:
|
|
229
|
-
'open product choice: ask the user, then `workit ledger decision "<choice>" --why "<reason>"` (or
|
|
229
|
+
'open product choice: ask the user, then `workit ledger decision "<choice>" --why "<reason>"` (or, if it is not open, `workit policy assess --judge product-choice=no --why "<reason>"`)',
|
|
230
230
|
decisionIds: [],
|
|
231
231
|
};
|
|
232
232
|
};
|
|
@@ -190,3 +190,57 @@ export const judgeTokens = (tokens: string[]): Json => {
|
|
|
190
190
|
}
|
|
191
191
|
return out;
|
|
192
192
|
};
|
|
193
|
+
|
|
194
|
+
/** The judgments that, while true, block working-tree writes (S17). */
|
|
195
|
+
const BLOCKING = ["productChoiceOpen", "needsPlan"] as const;
|
|
196
|
+
type Blocking = (typeof BLOCKING)[number];
|
|
197
|
+
|
|
198
|
+
/** The before-write blockers a judgment lifts: true before, false now. */
|
|
199
|
+
export const liftedBlockers = (previous: Judgment | null, next: Judgment): Blocking[] =>
|
|
200
|
+
BLOCKING.filter((field) => previous?.[field] === true && next[field] === false);
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Of the lifted blockers, those `session` itself judged true earlier on this
|
|
204
|
+
* task (M6): lifting your own blocker needs a recorded reason. Another
|
|
205
|
+
* session (a lead, the user's) may lift it; the ledger still records it.
|
|
206
|
+
*/
|
|
207
|
+
export const ownLiftedBlockers = (
|
|
208
|
+
history: ReadonlyArray<{ provenance: { session: unknown }; data: Judgment }>,
|
|
209
|
+
lifted: readonly Blocking[],
|
|
210
|
+
session: string,
|
|
211
|
+
): Blocking[] => {
|
|
212
|
+
const handle = (value: unknown): unknown => (isObject(value) ? value.handle : undefined);
|
|
213
|
+
return lifted.filter((field) =>
|
|
214
|
+
history.some((entry) => handle(entry.provenance.session) === session && entry.data[field]),
|
|
215
|
+
);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/** The reason sent with this judgment (`--why`, `note=`), not one kept from before. */
|
|
219
|
+
export const givenReason = (raw: unknown): string | null => {
|
|
220
|
+
if (!isObject(raw)) return null;
|
|
221
|
+
for (const [key, value] of Object.entries(raw))
|
|
222
|
+
if (ALIASES.note.includes(squash(key)) && typeof value === "string" && value.trim())
|
|
223
|
+
return value.trim();
|
|
224
|
+
return null;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** Longest call value kept in a ledger summary of a judgment as sent. */
|
|
228
|
+
const SUMMARY_VALUE_CHARS = 80;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The judgment as sent, for the `policy.judged` ledger row: the calls only.
|
|
232
|
+
* The reason and refs are recorded elsewhere in the row, and long values are
|
|
233
|
+
* cut so the row fits one ledger line.
|
|
234
|
+
*/
|
|
235
|
+
export const judgeInputSummary = (raw: unknown): Json => {
|
|
236
|
+
const out: Json = {};
|
|
237
|
+
if (!isObject(raw)) return out;
|
|
238
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
239
|
+
const name = squash(key);
|
|
240
|
+
if (ALIASES.note.includes(name) || ALIASES.refs.includes(name) || LEGACY_KEYS.has(key))
|
|
241
|
+
continue;
|
|
242
|
+
if (typeof value === "string") out[key.slice(0, 40)] = value.slice(0, SUMMARY_VALUE_CHARS);
|
|
243
|
+
else if (typeof value === "boolean" || typeof value === "number") out[key.slice(0, 40)] = value;
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
};
|
package/src/core/store-lock.ts
CHANGED
|
@@ -157,10 +157,24 @@ export type LockOwnerState = {
|
|
|
157
157
|
reason: string;
|
|
158
158
|
};
|
|
159
159
|
|
|
160
|
+
export type ClassifyOptions = {
|
|
161
|
+
/**
|
|
162
|
+
* Task-store locks only: a holder keeps one for a single mutation, so a
|
|
163
|
+
* same-host lock past FOREIGN_LOCK_TTL_MS whose start time cannot be
|
|
164
|
+
* compared is taken as a reused pid (Windows has no start time). Never for
|
|
165
|
+
* the stack lock, which a long synchronous rebase holds without heartbeat.
|
|
166
|
+
*/
|
|
167
|
+
reclaimUnverifiedAfterTtl?: boolean;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/** How task-store locks (and doctor, which inspects them) classify an owner. */
|
|
171
|
+
export const TASK_STORE_LOCK: ClassifyOptions = { reclaimUnverifiedAfterTtl: true };
|
|
172
|
+
|
|
160
173
|
export const classifyLockOwner = (
|
|
161
174
|
payload: unknown,
|
|
162
175
|
ageMs: number | null,
|
|
163
176
|
localHost: string = localLockHost(),
|
|
177
|
+
options: ClassifyOptions = {},
|
|
164
178
|
): LockOwnerState => {
|
|
165
179
|
const lock = metadataLockSchema.safeParse(payload);
|
|
166
180
|
if (!lock.success)
|
|
@@ -188,6 +202,20 @@ export const classifyLockOwner = (
|
|
|
188
202
|
const currentStart = processStartOf(pid);
|
|
189
203
|
if (processStart !== null && currentStart !== null && processStart !== currentStart)
|
|
190
204
|
return { state: "stale", reason: `pid ${pid} now belongs to a different process` };
|
|
205
|
+
// Without both start times (Windows has none) a reused pid looks alive. A
|
|
206
|
+
// task-store holder keeps the lock for one mutation, never minutes, so past
|
|
207
|
+
// the TTL the pid is taken to be reused and the crashed writer's lock is
|
|
208
|
+
// reclaimed (opt-in: see ClassifyOptions).
|
|
209
|
+
if (
|
|
210
|
+
options.reclaimUnverifiedAfterTtl === true &&
|
|
211
|
+
(processStart === null || currentStart === null) &&
|
|
212
|
+
ageMs !== null &&
|
|
213
|
+
ageMs > FOREIGN_LOCK_TTL_MS
|
|
214
|
+
)
|
|
215
|
+
return {
|
|
216
|
+
state: "stale",
|
|
217
|
+
reason: `pid ${pid} is running but the lock is older than its TTL and the pid's start time cannot be checked (reused pid)`,
|
|
218
|
+
};
|
|
191
219
|
return { state: "live", reason: `held by running pid ${pid}` };
|
|
192
220
|
};
|
|
193
221
|
|
|
@@ -257,7 +285,8 @@ export const inspectMetadataLock = (root: string, nowMs = Date.now()): MetadataL
|
|
|
257
285
|
}
|
|
258
286
|
const owner = parseMetadataLockOrNull(raw);
|
|
259
287
|
const ageMs = ageOf(lockPath, nowMs);
|
|
260
|
-
|
|
288
|
+
// The task store's own reclaim rule, so doctor reports what a write will do.
|
|
289
|
+
const verdict = classifyLockOwner(owner, ageMs, localLockHost(), TASK_STORE_LOCK);
|
|
261
290
|
return { path: lockPath, present: true, owner, ...verdict, guard, raw, ageMs };
|
|
262
291
|
};
|
|
263
292
|
|
|
@@ -295,7 +324,12 @@ export const clearStaleMetadataLock = (
|
|
|
295
324
|
const before = fs.readFileSync(status.path, "utf8");
|
|
296
325
|
if (before !== status.raw) return { ...outcome, skipped: "the lock changed" };
|
|
297
326
|
if (!options.force) {
|
|
298
|
-
const verdict = classifyLockOwner(
|
|
327
|
+
const verdict = classifyLockOwner(
|
|
328
|
+
parseMetadataLockOrNull(before),
|
|
329
|
+
ageOf(status.path, nowMs),
|
|
330
|
+
localLockHost(),
|
|
331
|
+
TASK_STORE_LOCK,
|
|
332
|
+
);
|
|
299
333
|
if (verdict.state !== "stale") return { ...outcome, skipped: verdict.reason };
|
|
300
334
|
}
|
|
301
335
|
if (fs.readFileSync(status.path, "utf8") !== before)
|
package/src/core/task-engine.ts
CHANGED
|
@@ -48,7 +48,18 @@ import {
|
|
|
48
48
|
import { diffPolicy, resolvePolicy } from "./policy-resolver";
|
|
49
49
|
import { normalizeOperationInput } from "./operation-input";
|
|
50
50
|
import { latestJudgment } from "./policy/derive";
|
|
51
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
givenReason,
|
|
53
|
+
judgeInputSummary,
|
|
54
|
+
liftedBlockers,
|
|
55
|
+
normalizeJudgment,
|
|
56
|
+
ownLiftedBlockers,
|
|
57
|
+
} from "./policy/judgment";
|
|
58
|
+
import { MAX_LINE_BYTES, appendObserved, observedRowBytes } from "../ledger";
|
|
59
|
+
|
|
60
|
+
/** Refs kept in a `policy.judged` row (the task store keeps all of them). */
|
|
61
|
+
const MAX_JUDGED_REFS = 5;
|
|
62
|
+
import { currentBranch, headSha } from "../git/rev";
|
|
52
63
|
import { resolveAutonomy, type VerificationMode } from "../autonomy";
|
|
53
64
|
|
|
54
65
|
import { sameDirectoryIdentity, TaskStore } from "./task-store";
|
|
@@ -959,7 +970,8 @@ export class WorkitCore {
|
|
|
959
970
|
if ((this.context.workerId ?? null) !== null)
|
|
960
971
|
return failure("permission_denied", "helpers cannot change task requirements");
|
|
961
972
|
this.fillRevisions(input, task.data);
|
|
962
|
-
const
|
|
973
|
+
const previous = latestJudgment(task.data);
|
|
974
|
+
const judged = normalizeJudgment(input.judgment, previous);
|
|
963
975
|
if (!judged.ok) return judged;
|
|
964
976
|
const judgment = judged.data.judgment;
|
|
965
977
|
const resolved = this.resolve(task.data, judgment);
|
|
@@ -967,6 +979,32 @@ export class WorkitCore {
|
|
|
967
979
|
if (input.action === "preview") return success(null, null, resolved.data);
|
|
968
980
|
if (input.action !== "assess")
|
|
969
981
|
return failure("invalid_transition", "unsupported policy action");
|
|
982
|
+
// M6: a session lifting a before-write blocker it judged itself gives a
|
|
983
|
+
// reason; every assess is recorded in the ledger below either way.
|
|
984
|
+
const lifted = liftedBlockers(previous, judgment);
|
|
985
|
+
const why = givenReason(input.judgment);
|
|
986
|
+
const own = ownLiftedBlockers(task.data.judgments ?? [], lifted, this.context.caller.actor);
|
|
987
|
+
if (own.length && !why)
|
|
988
|
+
return failure(
|
|
989
|
+
"invalid_input",
|
|
990
|
+
`this session judged ${own.join(" and ")} true on this task; lifting it needs a reason: re-run with --why "<reason>" (recorded in the ledger)`,
|
|
991
|
+
);
|
|
992
|
+
// The audit row is sized before anything changes, so a blocker is never
|
|
993
|
+
// lifted with a reason too long to record.
|
|
994
|
+
const audit = this.judgedRow(
|
|
995
|
+
task.data.id,
|
|
996
|
+
input.judgment,
|
|
997
|
+
judgment,
|
|
998
|
+
resolved.data,
|
|
999
|
+
lifted,
|
|
1000
|
+
why,
|
|
1001
|
+
);
|
|
1002
|
+
const bytes = observedRowBytes(audit);
|
|
1003
|
+
if (bytes > MAX_LINE_BYTES)
|
|
1004
|
+
return failure(
|
|
1005
|
+
"invalid_input",
|
|
1006
|
+
`the judgment is too long to record in the ledger (${bytes} bytes; the limit is ${MAX_LINE_BYTES}): shorten --why and pass details by --ref <path>`,
|
|
1007
|
+
);
|
|
970
1008
|
const changed = this.store.mutateTask(
|
|
971
1009
|
task.data.id,
|
|
972
1010
|
input.expectedRevision,
|
|
@@ -1001,9 +1039,46 @@ export class WorkitCore {
|
|
|
1001
1039
|
trustedNow(this.context),
|
|
1002
1040
|
);
|
|
1003
1041
|
if (!changed.ok) return changed;
|
|
1042
|
+
// Best effort: the assessment already stands in the task store.
|
|
1043
|
+
appendObserved(this.store.root, audit);
|
|
1004
1044
|
return success(changed.data.revision, null, changed.data.policy);
|
|
1005
1045
|
}
|
|
1006
1046
|
|
|
1047
|
+
/**
|
|
1048
|
+
* The ledger row that makes a judge call auditable (M6): who judged, what
|
|
1049
|
+
* was sent (the reason once, in `why`), what it resolved to, the
|
|
1050
|
+
* requirements and the blockers it lifted. Refs are capped to keep the row
|
|
1051
|
+
* within one ledger line.
|
|
1052
|
+
*/
|
|
1053
|
+
private judgedRow(
|
|
1054
|
+
taskId: string,
|
|
1055
|
+
input: unknown,
|
|
1056
|
+
judgment: Judgment,
|
|
1057
|
+
policy: Policy | null,
|
|
1058
|
+
lifted: readonly string[],
|
|
1059
|
+
why: string | null,
|
|
1060
|
+
) {
|
|
1061
|
+
const root = this.store.root;
|
|
1062
|
+
const branch = currentBranch(root);
|
|
1063
|
+
const { note: _note, refs, ...calls } = judgment;
|
|
1064
|
+
return {
|
|
1065
|
+
type: "policy.judged",
|
|
1066
|
+
actor: {
|
|
1067
|
+
host: this.context.caller.host,
|
|
1068
|
+
session: this.context.caller.actor,
|
|
1069
|
+
agentId: this.context.workerId ?? null,
|
|
1070
|
+
},
|
|
1071
|
+
branch,
|
|
1072
|
+
head: branch ? headSha(root) : null,
|
|
1073
|
+
taskId,
|
|
1074
|
+
input: judgeInputSummary(input),
|
|
1075
|
+
judgment: { ...calls, refs: refs.slice(0, MAX_JUDGED_REFS), refCount: refs.length },
|
|
1076
|
+
requirements: (policy?.requirements ?? []).map((requirement) => requirement.ruleId),
|
|
1077
|
+
lifted,
|
|
1078
|
+
why,
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1007
1082
|
evidence(request: unknown): Result<Entry<Evidence>> {
|
|
1008
1083
|
const resolved = this.implicitTask("evidence", request);
|
|
1009
1084
|
if (!resolved.ok) return resolved;
|
package/src/core/task-store.ts
CHANGED
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
type WorkspaceRecord,
|
|
58
58
|
} from "./task-contract";
|
|
59
59
|
import {
|
|
60
|
+
TASK_STORE_LOCK,
|
|
60
61
|
classifyLockOwner,
|
|
61
62
|
clearAbandonedReclaimGuard,
|
|
62
63
|
defaultLockTimeout,
|
|
@@ -2146,7 +2147,9 @@ export class TaskStore {
|
|
|
2146
2147
|
try {
|
|
2147
2148
|
ageMs = nowMs - fs.lstatSync(lockPath).mtimeMs;
|
|
2148
2149
|
} catch {}
|
|
2149
|
-
return
|
|
2150
|
+
return (
|
|
2151
|
+
classifyLockOwner(payload, ageMs, localLockHost(), TASK_STORE_LOCK).state === "stale"
|
|
2152
|
+
);
|
|
2150
2153
|
},
|
|
2151
2154
|
// The library re-checks the bytes before removal, so a lock replaced
|
|
2152
2155
|
// after classification is never deleted.
|
|
@@ -77,8 +77,10 @@ export const CURSOR_DESCRIPTOR: HostDescriptor = {
|
|
|
77
77
|
interaction: { questions: "none", writeBoundary: "partial" },
|
|
78
78
|
stopControl: "undocumented",
|
|
79
79
|
shellAvailable: "native",
|
|
80
|
-
//
|
|
81
|
-
|
|
80
|
+
// Hooks run the plugin's launcher against a local runtime (bundled dist or a
|
|
81
|
+
// global bin); only an install with neither falls back to a pinned,
|
|
82
|
+
// --prefer-offline npx.
|
|
83
|
+
perEventCost: "low",
|
|
82
84
|
capabilities: [
|
|
83
85
|
{
|
|
84
86
|
name: "known_product_writes",
|
package/src/ledger.ts
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
// lines and rows without the required keys are skipped and counted, and the
|
|
20
20
|
// file is never rewritten. `seq` is the row's position among valid rows (a
|
|
21
21
|
// lock-free appender cannot allocate one); a row supersedes an older one by
|
|
22
|
-
// its `id`, and only a row of the same type
|
|
22
|
+
// its `id`, and only a row of the same type, session and branch (and verdict
|
|
23
|
+
// kind) may.
|
|
23
24
|
// - Trust (D18): the threat model is honest mistakes and accidental
|
|
24
25
|
// self-certification, not adversarial agents. `ledger verdict` rows are
|
|
25
26
|
// `observer:"agent_asserted"`; `observer:"workit_cli"` is reserved for rows
|
|
@@ -33,12 +34,22 @@
|
|
|
33
34
|
// `fresh` (same head; verdicts on a dirty worktree are refused, so the head
|
|
34
35
|
// is the judged tree), `carried` (new head, same patch-id AND same exact
|
|
35
36
|
// diff: a rebase that only moved the base) or `stale`.
|
|
37
|
+
// - Integrity (D18): every row the CLI appends carries `prevHash` (sha256 of
|
|
38
|
+
// the previous line) and `rowHash` (sha256 over `prevHash` and the row), a
|
|
39
|
+
// running chain. A row without it after the chain began, or whose hashes do
|
|
40
|
+
// not match, is reported as unverified (`workit ledger verify-integrity`, a
|
|
41
|
+
// warning in `ledger check`). Nothing is refused: this catches a hand edit
|
|
42
|
+
// made by mistake, it is not a defense against a forger.
|
|
43
|
+
// - Branch renames: rows keyed to a branch's earlier names (from its reflog)
|
|
44
|
+
// count for it, up to the rename.
|
|
36
45
|
//
|
|
37
|
-
// Plain TS over the git binary
|
|
46
|
+
// Plain TS over the git binary, plus the configured trunk (vcsConfig) for the
|
|
47
|
+
// default base; no task store.
|
|
38
48
|
import { spawnSync } from "node:child_process";
|
|
39
49
|
import { createHash, randomBytes } from "node:crypto";
|
|
40
50
|
import fs from "node:fs";
|
|
41
51
|
import path from "node:path";
|
|
52
|
+
import { vcsConfig } from "./core/vcs-config";
|
|
42
53
|
import { GIT_TIMEOUTS, currentBranch, headSha, patchId, worktreeTree } from "./git/rev";
|
|
43
54
|
import { hostSessionFromEnv } from "./host-session";
|
|
44
55
|
import { resolveStore } from "./store/paths";
|
|
@@ -100,7 +111,7 @@ type RowCommon = CodeKey & {
|
|
|
100
111
|
type: string;
|
|
101
112
|
actor: LedgerActor;
|
|
102
113
|
pr?: number;
|
|
103
|
-
/** The `id` of an older row of the same type and
|
|
114
|
+
/** The `id` of an older row of the same type, session and branch (and kind) that this one replaces. */
|
|
104
115
|
supersedes?: string;
|
|
105
116
|
};
|
|
106
117
|
|
|
@@ -118,6 +129,12 @@ export type RulingRow = RowCommon & {
|
|
|
118
129
|
refs: string[];
|
|
119
130
|
};
|
|
120
131
|
export type SelfReason = "flag" | "no_session";
|
|
132
|
+
/**
|
|
133
|
+
* Where a verdict's session came from: the environment (the host's hook),
|
|
134
|
+
* `--session` (named by the caller, shown in `ledger check` so a human can
|
|
135
|
+
* spot a claimed verifier id) or `--as` (minted from the environment's).
|
|
136
|
+
*/
|
|
137
|
+
export type SessionSource = "env" | "flag" | "as";
|
|
121
138
|
export type VerdictRow = RowCommon & {
|
|
122
139
|
type: "verdict";
|
|
123
140
|
kind: VerdictKind;
|
|
@@ -128,6 +145,7 @@ export type VerdictRow = RowCommon & {
|
|
|
128
145
|
/** Not an independent verdict: `--self`, or no session to tell it from the author. */
|
|
129
146
|
self: boolean;
|
|
130
147
|
selfReason: SelfReason | null;
|
|
148
|
+
sessionSource: SessionSource;
|
|
131
149
|
evidenceRefs: string[];
|
|
132
150
|
};
|
|
133
151
|
export type HandoffRow = RowCommon & { type: "handoff"; note: string | null; next: string };
|
|
@@ -147,6 +165,14 @@ export type LedgerRow =
|
|
|
147
165
|
| HandoffRow
|
|
148
166
|
| StandingRow
|
|
149
167
|
| StandingClearedRow;
|
|
168
|
+
/**
|
|
169
|
+
* A row's place in the hash chain: `verified` (the CLI wrote it and it is
|
|
170
|
+
* unchanged), `unverified` (see UnverifiedReason) or `legacy` (written before
|
|
171
|
+
* the ledger had a chain).
|
|
172
|
+
*/
|
|
173
|
+
export type RowIntegrity = "verified" | "unverified" | "legacy";
|
|
174
|
+
/** unsigned: no chain fields (hand-appended) · hash_mismatch: edited · broken_link: a row before it was edited or removed. */
|
|
175
|
+
export type UnverifiedReason = "unsigned" | "hash_mismatch" | "broken_link";
|
|
150
176
|
/** A row as read back: any known or future type, plus its position. */
|
|
151
177
|
export type ReadRow = RowCommon &
|
|
152
178
|
Record<string, unknown> & {
|
|
@@ -154,6 +180,8 @@ export type ReadRow = RowCommon &
|
|
|
154
180
|
superseded: boolean;
|
|
155
181
|
/** The row names a `supersedes` target the reader refused (D18 supersede rules). */
|
|
156
182
|
supersedeIgnored: boolean;
|
|
183
|
+
/** Set only when read with `{ integrity: true }`. */
|
|
184
|
+
integrity?: RowIntegrity;
|
|
157
185
|
};
|
|
158
186
|
|
|
159
187
|
export type LedgerError = {
|
|
@@ -223,13 +251,43 @@ export function branchHead(cwd: string, branch: string): string | null {
|
|
|
223
251
|
}
|
|
224
252
|
|
|
225
253
|
/**
|
|
226
|
-
* The
|
|
227
|
-
*
|
|
254
|
+
* The PR target of the release track the checkout is on, when the workspace
|
|
255
|
+
* configures tracks (as `fanout plan` and `stack plan` pick their trunk); a
|
|
256
|
+
* plain workspace default does not override origin's default branch.
|
|
257
|
+
*/
|
|
258
|
+
const trackTrunk = (cwd: string): string | null => {
|
|
259
|
+
try {
|
|
260
|
+
const resolved = vcsConfig("resolve", cwd);
|
|
261
|
+
if (resolved.ok === false || !resolved.releaseTrack || resolved.releaseTrack.blocking)
|
|
262
|
+
return null;
|
|
263
|
+
const target = String(resolved.defaultTargetBranch ?? "").trim();
|
|
264
|
+
return target && safeRef(target) ? target : null;
|
|
265
|
+
} catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The default base for patch-ids and author trailers: the release track's PR
|
|
272
|
+
* target (origin's copy, else the local one) when tracks are configured, else
|
|
273
|
+
* origin's HEAD branch, else the first of origin/main, origin/master, main,
|
|
274
|
+
* master, origin/develop, develop that exists.
|
|
228
275
|
*/
|
|
229
276
|
export function defaultBase(cwd: string): string | null {
|
|
277
|
+
const trunk = trackTrunk(cwd);
|
|
278
|
+
if (trunk)
|
|
279
|
+
for (const candidate of [`origin/${trunk}`, trunk])
|
|
280
|
+
if (resolveCommit(cwd, candidate)) return candidate;
|
|
230
281
|
const originHead = git(cwd, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]);
|
|
231
282
|
if (originHead && resolveCommit(cwd, originHead)) return originHead;
|
|
232
|
-
for (const candidate of [
|
|
283
|
+
for (const candidate of [
|
|
284
|
+
"origin/main",
|
|
285
|
+
"origin/master",
|
|
286
|
+
"main",
|
|
287
|
+
"master",
|
|
288
|
+
"origin/develop",
|
|
289
|
+
"develop",
|
|
290
|
+
])
|
|
233
291
|
if (resolveCommit(cwd, candidate)) return candidate;
|
|
234
292
|
return null;
|
|
235
293
|
}
|
|
@@ -466,10 +524,58 @@ export function withLedgerLock<T>(
|
|
|
466
524
|
|
|
467
525
|
type AppendOptions = { now?: Date; forceLock?: boolean };
|
|
468
526
|
|
|
469
|
-
|
|
527
|
+
const sha256 = (text: string): string => createHash("sha256").update(text).digest("hex");
|
|
528
|
+
/** The chain hash of a row: over the previous line's hash and the row without `rowHash`. */
|
|
529
|
+
const chainHash = (prevHash: string | null, body: string): string =>
|
|
530
|
+
sha256(`${prevHash ?? ""}\n${body}`);
|
|
531
|
+
/** Enough tail to hold the previous row (rows are at most MAX_LINE_BYTES). */
|
|
532
|
+
const TAIL_BYTES = 64 * 1024;
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* The last complete (newline-terminated) non-empty line before `size`, read
|
|
536
|
+
* from the tail. An unterminated tail is a torn row or another appender's
|
|
537
|
+
* write still in flight, so it is not chained onto. A line longer than the
|
|
538
|
+
* window (a hand-written one) is read whole by widening the window, so the
|
|
539
|
+
* link stays exact.
|
|
540
|
+
*/
|
|
541
|
+
const lastLine = (fd: number, size: number): string | null => {
|
|
542
|
+
for (let span = Math.min(size, TAIL_BYTES); span > 0; span = Math.min(size, span * 2)) {
|
|
543
|
+
const buffer = Buffer.alloc(span);
|
|
544
|
+
fs.readSync(fd, buffer, 0, span, size - span);
|
|
545
|
+
const end = buffer.lastIndexOf(0x0a);
|
|
546
|
+
if (end < 0) {
|
|
547
|
+
if (span === size) return null;
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
// Skip blank lines (a fence) back to the last non-empty complete line.
|
|
551
|
+
let stop = end;
|
|
552
|
+
let text = "";
|
|
553
|
+
while (stop >= 0) {
|
|
554
|
+
const start = buffer.lastIndexOf(0x0a, stop - 1);
|
|
555
|
+
text = buffer.toString("utf8", start + 1, stop).trim();
|
|
556
|
+
if (text) {
|
|
557
|
+
// Its start must be inside the window (or be the file's start).
|
|
558
|
+
if (start >= 0 || span === size) return text;
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
if (start < 0) {
|
|
562
|
+
if (span === size) return null;
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
stop = start;
|
|
566
|
+
}
|
|
567
|
+
if (span === size) return text || null;
|
|
568
|
+
}
|
|
569
|
+
return null;
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
/** The line to write, given the file's current last line (null: empty file). */
|
|
573
|
+
type LineBuilder = (previous: string | null) => LedgerResult<string>;
|
|
574
|
+
|
|
575
|
+
function appendLine(file: string, build: LineBuilder, options: AppendOptions): LedgerResult<void> {
|
|
470
576
|
const write = (): LedgerResult<void> => {
|
|
471
577
|
// a+ = O_APPEND|O_RDWR: every write lands at the end, and we can still
|
|
472
|
-
// peek at the last
|
|
578
|
+
// peek at the tail to chain onto the last row and fence off a torn one.
|
|
473
579
|
const fd = fs.openSync(file, "a+");
|
|
474
580
|
try {
|
|
475
581
|
const size = fs.fstatSync(fd).size;
|
|
@@ -479,7 +585,9 @@ function appendLine(file: string, line: string, options: AppendOptions): LedgerR
|
|
|
479
585
|
fs.readSync(fd, last, 0, 1, size - 1);
|
|
480
586
|
if (last[0] !== 0x0a) fence = "\n";
|
|
481
587
|
}
|
|
482
|
-
const
|
|
588
|
+
const line = build(lastLine(fd, size));
|
|
589
|
+
if (!line.ok) return line;
|
|
590
|
+
const bytes = Buffer.from(fence + line.value);
|
|
483
591
|
const written = fs.writeSync(fd, bytes);
|
|
484
592
|
if (written !== bytes.length)
|
|
485
593
|
return err(
|
|
@@ -510,22 +618,40 @@ function appendLine(file: string, line: string, options: AppendOptions): LedgerR
|
|
|
510
618
|
|
|
511
619
|
export type NewRow<T extends LedgerRow> = Omit<T, "v" | "id" | "at">;
|
|
512
620
|
|
|
513
|
-
|
|
514
|
-
|
|
621
|
+
/**
|
|
622
|
+
* Serialize a row chained onto `previous` (the file's last complete line).
|
|
623
|
+
* Concurrent lock-free appenders may chain onto the same or an older line;
|
|
624
|
+
* the reader accepts a link to any earlier line.
|
|
625
|
+
*/
|
|
626
|
+
const sealed = (row: object, id: string, at: string, previous: string | null) => {
|
|
627
|
+
const { prevHash: _prev, rowHash: _row, ...fields } = row as Record<string, unknown>;
|
|
628
|
+
const prevHash = previous === null ? null : sha256(previous);
|
|
629
|
+
const body = { v: LEDGER_VERSION, id, at, ...fields, prevHash };
|
|
630
|
+
const full = { ...body, rowHash: chainHash(prevHash, JSON.stringify(body)) };
|
|
515
631
|
return { full, line: `${JSON.stringify(full)}\n` };
|
|
516
632
|
};
|
|
517
633
|
|
|
518
634
|
function appendRaw<T>(cwd: string, row: object, options: AppendOptions): LedgerResult<T> {
|
|
519
|
-
const { full, line } = serialize(row, options.now ?? new Date());
|
|
520
|
-
const bytes = Buffer.byteLength(line);
|
|
521
|
-
if (bytes > MAX_LINE_BYTES)
|
|
522
|
-
return err(
|
|
523
|
-
"invalid_input",
|
|
524
|
-
`ledger row is ${bytes} bytes; the limit is ${MAX_LINE_BYTES}. Shorten the text and pass details by --ref <path|url>`,
|
|
525
|
-
);
|
|
526
635
|
const file = ledgerPath(cwd);
|
|
527
636
|
if (!file.ok) return file;
|
|
528
|
-
const
|
|
637
|
+
const id = newId();
|
|
638
|
+
const at = (options.now ?? new Date()).toISOString();
|
|
639
|
+
let full: unknown = null;
|
|
640
|
+
const written = appendLine(
|
|
641
|
+
file.value,
|
|
642
|
+
(previous) => {
|
|
643
|
+
const next = sealed(row, id, at, previous);
|
|
644
|
+
const bytes = Buffer.byteLength(next.line);
|
|
645
|
+
if (bytes > MAX_LINE_BYTES)
|
|
646
|
+
return err(
|
|
647
|
+
"invalid_input",
|
|
648
|
+
`ledger row is ${bytes} bytes; the limit is ${MAX_LINE_BYTES}. Shorten the text and pass details by --ref <path|url>`,
|
|
649
|
+
);
|
|
650
|
+
full = next.full;
|
|
651
|
+
return { ok: true, value: next.line };
|
|
652
|
+
},
|
|
653
|
+
options,
|
|
654
|
+
);
|
|
529
655
|
return written.ok ? { ok: true, value: full as T } : written;
|
|
530
656
|
}
|
|
531
657
|
|
|
@@ -540,6 +666,12 @@ export function appendRow<T extends LedgerRow>(
|
|
|
540
666
|
return appendRaw<T>(cwd, row, options);
|
|
541
667
|
}
|
|
542
668
|
|
|
669
|
+
/** The bytes `appendObserved` would write for `row` (its chain fields included). */
|
|
670
|
+
export function observedRowBytes(row: Record<string, unknown>): number {
|
|
671
|
+
const sample = sealed({ ...row, observer: "workit_cli" }, newId(), new Date().toISOString(), "");
|
|
672
|
+
return Buffer.byteLength(sample.line);
|
|
673
|
+
}
|
|
674
|
+
|
|
543
675
|
/**
|
|
544
676
|
* Internal API for observing verbs only (S9b `check`, S10 `ci`, S11 `pr`/`git
|
|
545
677
|
* commit`): append a row the CLI itself observed, stamped
|
|
@@ -582,26 +714,63 @@ const normalizeActor = (value: unknown): LedgerActor => {
|
|
|
582
714
|
};
|
|
583
715
|
};
|
|
584
716
|
|
|
717
|
+
type SupersedeRow = Pick<ReadRow, "type" | "actor"> & {
|
|
718
|
+
self?: unknown;
|
|
719
|
+
branch?: unknown;
|
|
720
|
+
kind?: unknown;
|
|
721
|
+
at?: unknown;
|
|
722
|
+
};
|
|
723
|
+
|
|
585
724
|
/**
|
|
586
|
-
* May `by` supersede `target`? Same type, same known session,
|
|
587
|
-
*
|
|
725
|
+
* May `by` supersede `target`? Same type, same known session, same branch
|
|
726
|
+
* (`onBranch`: the target belongs to `by`'s branch, which follows renames),
|
|
727
|
+
* for a verdict the same kind, later in the file, and a self verdict never
|
|
728
|
+
* replaces an independent one.
|
|
588
729
|
*/
|
|
589
730
|
export function supersedeAllowed(
|
|
590
|
-
by:
|
|
591
|
-
target:
|
|
731
|
+
by: SupersedeRow,
|
|
732
|
+
target: SupersedeRow,
|
|
733
|
+
onBranch: (target: SupersedeRow) => boolean = (row) =>
|
|
734
|
+
(by.branch ?? null) === (row.branch ?? null),
|
|
592
735
|
): string | null {
|
|
593
736
|
if (by.type !== target.type) return `a ${by.type} row cannot supersede a ${target.type} row`;
|
|
594
737
|
if (!by.actor.session || by.actor.session !== target.actor.session)
|
|
595
738
|
return "only the session that wrote a row can supersede it";
|
|
739
|
+
if (!onBranch(target))
|
|
740
|
+
return `the row is on another branch (${String(target.branch ?? "none")}); a row supersedes only one on its own branch`;
|
|
741
|
+
if (by.type === "verdict" && (by.kind ?? "review") !== (target.kind ?? "review"))
|
|
742
|
+
return `a ${String(by.kind ?? "review")} verdict cannot supersede a ${String(target.kind ?? "review")} verdict; record it with --kind ${String(target.kind ?? "review")}`;
|
|
596
743
|
if (by.type === "verdict" && by.self === true && target.self !== true)
|
|
597
744
|
return "a self verdict cannot replace an independent verdict";
|
|
598
745
|
return null;
|
|
599
746
|
}
|
|
600
747
|
|
|
601
|
-
export type
|
|
748
|
+
export type UnverifiedRow = {
|
|
749
|
+
seq: number;
|
|
750
|
+
id: string;
|
|
751
|
+
type: string;
|
|
752
|
+
branch: string | null;
|
|
753
|
+
reason: UnverifiedReason;
|
|
754
|
+
};
|
|
755
|
+
/** The hash chain over the file: rows the CLI wrote, rows from before the chain, and the rest. */
|
|
756
|
+
export type LedgerIntegrity = { chained: number; legacy: number; unverified: UnverifiedRow[] };
|
|
757
|
+
export type LedgerRead = {
|
|
758
|
+
path: string;
|
|
759
|
+
rows: ReadRow[];
|
|
760
|
+
skipped: number;
|
|
761
|
+
/** Only when read with `{ integrity: true }` (hashing every line has a cost). */
|
|
762
|
+
integrity: LedgerIntegrity | null;
|
|
763
|
+
};
|
|
602
764
|
|
|
603
|
-
/**
|
|
604
|
-
|
|
765
|
+
/**
|
|
766
|
+
* Every valid row in append order. A missing file is empty. `integrity`
|
|
767
|
+
* verifies the hash chain too (`ledger check`, `verify-integrity`); other
|
|
768
|
+
* readers, such as the write gate, skip that cost.
|
|
769
|
+
*/
|
|
770
|
+
export function readLedger(
|
|
771
|
+
cwd: string,
|
|
772
|
+
options: { integrity?: boolean } = {},
|
|
773
|
+
): LedgerResult<LedgerRead> {
|
|
605
774
|
const file = ledgerPath(cwd);
|
|
606
775
|
if (!file.ok) return file;
|
|
607
776
|
let text = "";
|
|
@@ -609,14 +778,32 @@ export function readLedger(cwd: string): LedgerResult<LedgerRead> {
|
|
|
609
778
|
text = fs.readFileSync(file.value, "utf8");
|
|
610
779
|
} catch (error) {
|
|
611
780
|
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
612
|
-
return {
|
|
781
|
+
return {
|
|
782
|
+
ok: true,
|
|
783
|
+
value: {
|
|
784
|
+
path: file.value,
|
|
785
|
+
rows: [],
|
|
786
|
+
skipped: 0,
|
|
787
|
+
integrity: options.integrity ? { chained: 0, legacy: 0, unverified: [] } : null,
|
|
788
|
+
},
|
|
789
|
+
};
|
|
613
790
|
return err("unavailable", `cannot read ${file.value}: ${(error as Error).message}`);
|
|
614
791
|
}
|
|
615
792
|
const rows: ReadRow[] = [];
|
|
616
793
|
let skipped = 0;
|
|
794
|
+
const integrity: LedgerIntegrity = { chained: 0, legacy: 0, unverified: [] };
|
|
795
|
+
// Hashes of every earlier line; null stands for the start of the file.
|
|
796
|
+
// Lock-free appenders racing on one file chain onto whatever line they saw
|
|
797
|
+
// last, which can be dozens of lines back, so any earlier line is a valid
|
|
798
|
+
// link. An edited row fails its own hash and breaks every link to it.
|
|
799
|
+
const earlier = new Set<string | null>([null]);
|
|
800
|
+
let chainStarted = false;
|
|
617
801
|
for (const raw of text.split("\n")) {
|
|
618
802
|
const trimmed = raw.trim();
|
|
619
803
|
if (!trimmed) continue;
|
|
804
|
+
// Checked after this line is added: a row cannot name its own line.
|
|
805
|
+
const linked = (prevHash: string | null): boolean => earlier.has(prevHash);
|
|
806
|
+
if (options.integrity) earlier.add(sha256(trimmed));
|
|
620
807
|
let parsed: unknown;
|
|
621
808
|
try {
|
|
622
809
|
parsed = JSON.parse(trimmed);
|
|
@@ -628,8 +815,21 @@ export function readLedger(cwd: string): LedgerResult<LedgerRead> {
|
|
|
628
815
|
skipped += 1;
|
|
629
816
|
continue;
|
|
630
817
|
}
|
|
818
|
+
let rowIntegrity: RowIntegrity | undefined = options.integrity ? "verified" : undefined;
|
|
819
|
+
let reason: UnverifiedReason | null = null;
|
|
820
|
+
if (!options.integrity) {
|
|
821
|
+
// not verified on this read
|
|
822
|
+
} else if (typeof parsed.rowHash === "string") {
|
|
823
|
+
chainStarted = true;
|
|
824
|
+
const { rowHash, ...body } = parsed;
|
|
825
|
+
const prevHash = str(parsed.prevHash);
|
|
826
|
+
if (chainHash(prevHash, JSON.stringify(body)) !== rowHash) reason = "hash_mismatch";
|
|
827
|
+
else if (!linked(prevHash)) reason = "broken_link";
|
|
828
|
+
} else if (chainStarted) reason = "unsigned";
|
|
829
|
+
else rowIntegrity = "legacy";
|
|
830
|
+
if (reason) rowIntegrity = "unverified";
|
|
631
831
|
const pr = typeof parsed.pr === "number" && Number.isInteger(parsed.pr) ? parsed.pr : undefined;
|
|
632
|
-
|
|
832
|
+
const row: ReadRow = {
|
|
633
833
|
...parsed,
|
|
634
834
|
v: typeof parsed.v === "number" ? parsed.v : LEDGER_VERSION,
|
|
635
835
|
id: parsed.id as string,
|
|
@@ -649,17 +849,42 @@ export function readLedger(cwd: string): LedgerResult<LedgerRead> {
|
|
|
649
849
|
seq: rows.length + 1,
|
|
650
850
|
superseded: false,
|
|
651
851
|
supersedeIgnored: false,
|
|
652
|
-
|
|
852
|
+
...(rowIntegrity ? { integrity: rowIntegrity } : {}),
|
|
853
|
+
};
|
|
854
|
+
rows.push(row);
|
|
855
|
+
if (!options.integrity) continue;
|
|
856
|
+
if (rowIntegrity === "verified") integrity.chained += 1;
|
|
857
|
+
else if (rowIntegrity === "legacy") integrity.legacy += 1;
|
|
858
|
+
else if (reason)
|
|
859
|
+
integrity.unverified.push({
|
|
860
|
+
seq: row.seq,
|
|
861
|
+
id: row.id,
|
|
862
|
+
type: row.type,
|
|
863
|
+
branch: row.branch,
|
|
864
|
+
reason,
|
|
865
|
+
});
|
|
653
866
|
}
|
|
654
|
-
// Only a well-formed supersede link counts; anything else is ignored.
|
|
867
|
+
// Only a well-formed supersede link counts; anything else is ignored. A
|
|
868
|
+
// link across branch names is checked against the renames (reflog read
|
|
869
|
+
// only for such links, once per branch).
|
|
870
|
+
const scopes = new Map<string, BranchScope>();
|
|
871
|
+
const scopeOf = (branch: string): BranchScope => {
|
|
872
|
+
let scope = scopes.get(branch);
|
|
873
|
+
if (!scope) scopes.set(branch, (scope = branchScope(cwd, branch)));
|
|
874
|
+
return scope;
|
|
875
|
+
};
|
|
655
876
|
const byId = new Map<string, ReadRow>();
|
|
656
877
|
for (const row of rows) {
|
|
657
878
|
const target = row.supersedes ? byId.get(row.supersedes) : undefined;
|
|
658
|
-
if (target && supersedeAllowed(row, target) === null)
|
|
879
|
+
if (target && supersedeAllowed(row, target, renameAwareBranch(scopeOf, row.branch)) === null)
|
|
880
|
+
target.superseded = true;
|
|
659
881
|
else if (row.supersedes) row.supersedeIgnored = true;
|
|
660
882
|
if (!byId.has(row.id)) byId.set(row.id, row);
|
|
661
883
|
}
|
|
662
|
-
return {
|
|
884
|
+
return {
|
|
885
|
+
ok: true,
|
|
886
|
+
value: { path: file.value, rows, skipped, integrity: options.integrity ? integrity : null },
|
|
887
|
+
};
|
|
663
888
|
}
|
|
664
889
|
|
|
665
890
|
export type RowFilter = { branch?: string; pr?: number; type?: string; last?: number };
|
|
@@ -809,6 +1034,12 @@ export type VerdictCheck = {
|
|
|
809
1034
|
selfVerdict: ReadRow | null;
|
|
810
1035
|
verdicts: VerdictEntry[];
|
|
811
1036
|
authors: string[];
|
|
1037
|
+
/**
|
|
1038
|
+
* Things a human should read before trusting the result; none of them
|
|
1039
|
+
* blocks (D18): unverified ledger rows on the branch, and an author's own
|
|
1040
|
+
* failure newer than the accepted pass.
|
|
1041
|
+
*/
|
|
1042
|
+
warnings: string[];
|
|
812
1043
|
};
|
|
813
1044
|
|
|
814
1045
|
const isLiveVerdict = (row: ReadRow): boolean =>
|
|
@@ -828,22 +1059,80 @@ function trailerSessions(cwd: string, base: string | null, head: string | null):
|
|
|
828
1059
|
.filter(Boolean);
|
|
829
1060
|
}
|
|
830
1061
|
|
|
1062
|
+
/** Does a row belong to a branch? Its own name, or an earlier name up to the rename. */
|
|
1063
|
+
export type BranchScope = (row: Pick<ReadRow, "branch" | "at">) => boolean;
|
|
1064
|
+
|
|
1065
|
+
const RENAMED = /^branch: renamed refs\/heads\/(.+) to refs\/heads\/(.+)$/iu;
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* The rows of `branch`, following `git branch -m` through the branch's
|
|
1069
|
+
* reflog (git moves the reflog with the branch): a row keyed to an earlier
|
|
1070
|
+
* name counts until that rename, so a name reused later does not leak in.
|
|
1071
|
+
*/
|
|
1072
|
+
export function branchScope(cwd: string, branch: string): BranchScope {
|
|
1073
|
+
const renamedAt = new Map<string, number>();
|
|
1074
|
+
// The branch's oldest reflog entry (its creation): rows of an older,
|
|
1075
|
+
// deleted branch that once had one of its earlier names predate it.
|
|
1076
|
+
let since = Number.NEGATIVE_INFINITY;
|
|
1077
|
+
if (safeRef(branch)) {
|
|
1078
|
+
const log = git(cwd, [
|
|
1079
|
+
"log",
|
|
1080
|
+
"-g",
|
|
1081
|
+
"--date=unix",
|
|
1082
|
+
"--format=%gd%x09%gs",
|
|
1083
|
+
`refs/heads/${branch}`,
|
|
1084
|
+
"--",
|
|
1085
|
+
]);
|
|
1086
|
+
for (const line of (log ?? "").split("\n")) {
|
|
1087
|
+
const [selector = "", subject = ""] = line.split("\t");
|
|
1088
|
+
const seconds = Number(/@\{(\d+)\}$/u.exec(selector)?.[1]);
|
|
1089
|
+
if (!Number.isFinite(seconds)) continue;
|
|
1090
|
+
// Newest first: the last entry read is the oldest.
|
|
1091
|
+
since = seconds * 1000;
|
|
1092
|
+
const match = RENAMED.exec(subject);
|
|
1093
|
+
if (!match || match[1] === branch) continue;
|
|
1094
|
+
// Reflog times are whole seconds: the rename's second still counts.
|
|
1095
|
+
const until = (seconds + 1) * 1000;
|
|
1096
|
+
renamedAt.set(match[1], Math.max(renamedAt.get(match[1]) ?? 0, until));
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
return (row) => {
|
|
1100
|
+
if (row.branch === branch) return true;
|
|
1101
|
+
const until = row.branch === null ? undefined : renamedAt.get(row.branch);
|
|
1102
|
+
if (until === undefined) return false;
|
|
1103
|
+
const at = Date.parse(row.at);
|
|
1104
|
+
return at >= since && at <= until;
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/** `supersedeAllowed`'s branch test for rows on `branch`, following its renames. */
|
|
1109
|
+
const renameAwareBranch = (scopeOf: (branch: string) => BranchScope, branch: unknown) => {
|
|
1110
|
+
return (target: SupersedeRow): boolean => {
|
|
1111
|
+
const own = typeof branch === "string" ? branch : null;
|
|
1112
|
+
const theirs = typeof target.branch === "string" ? target.branch : null;
|
|
1113
|
+
if (own === theirs) return true;
|
|
1114
|
+
if (own === null || theirs === null || typeof target.at !== "string") return false;
|
|
1115
|
+
return scopeOf(own)({ branch: theirs, at: target.at });
|
|
1116
|
+
};
|
|
1117
|
+
};
|
|
1118
|
+
|
|
831
1119
|
/**
|
|
832
1120
|
* Sessions that authored `branch`: the union of `commit.recorded` /
|
|
833
|
-
* `task.opened` ledger rows
|
|
834
|
-
* `base..head`. Git author name/email is
|
|
835
|
-
* a machine shares it, so it cannot tell sessions
|
|
1121
|
+
* `task.opened` ledger rows (under its current or an earlier name) and
|
|
1122
|
+
* `Workit-Session:` commit trailers on `base..head`. Git author name/email is
|
|
1123
|
+
* not used: every agent on a machine shares it, so it cannot tell sessions
|
|
1124
|
+
* apart.
|
|
836
1125
|
*/
|
|
837
1126
|
export function authorSessions(
|
|
838
1127
|
cwd: string,
|
|
839
1128
|
rows: readonly ReadRow[],
|
|
840
1129
|
branch: string,
|
|
841
1130
|
range: { base: string | null; head: string | null } = { base: null, head: null },
|
|
1131
|
+
scope: BranchScope = branchScope(cwd, branch),
|
|
842
1132
|
): Set<string> {
|
|
843
1133
|
const sessions = new Set<string>();
|
|
844
1134
|
for (const row of rows) {
|
|
845
|
-
if (row
|
|
846
|
-
continue;
|
|
1135
|
+
if (!scope(row) || (row.type !== "commit.recorded" && row.type !== "task.opened")) continue;
|
|
847
1136
|
const session = str(row.session) ?? row.actor.session;
|
|
848
1137
|
if (session) sessions.add(session);
|
|
849
1138
|
}
|
|
@@ -861,7 +1150,8 @@ const independenceReasons = (row: ReadRow, authors: Set<string>): RejectReason[]
|
|
|
861
1150
|
|
|
862
1151
|
/**
|
|
863
1152
|
* The effective verdict of one kind, walking rows in order: a self verdict
|
|
864
|
-
* never displaces
|
|
1153
|
+
* never displaces a current independent one (a stale one it does, so the
|
|
1154
|
+
* author's pass on new code is not hidden behind it), and an independent failed/blocked
|
|
865
1155
|
* verdict sticks, for the code it judged, until an independent verdict from a
|
|
866
1156
|
* different session (or its own session's supersede) replaces it. A verdict
|
|
867
1157
|
* from the same session on different code (`sameCode` false: a real change,
|
|
@@ -872,6 +1162,7 @@ function effectiveOf(
|
|
|
872
1162
|
rows: readonly ReadRow[],
|
|
873
1163
|
authors: Set<string>,
|
|
874
1164
|
sameCode: (failed: ReadRow, row: ReadRow) => boolean,
|
|
1165
|
+
isCurrent: (row: ReadRow) => boolean,
|
|
875
1166
|
): ReadRow | null {
|
|
876
1167
|
let effective: ReadRow | null = null;
|
|
877
1168
|
for (const row of rows) {
|
|
@@ -881,7 +1172,7 @@ function effectiveOf(
|
|
|
881
1172
|
}
|
|
882
1173
|
const rowIndependent = independenceReasons(row, authors).length === 0;
|
|
883
1174
|
const effIndependent = independenceReasons(effective, authors).length === 0;
|
|
884
|
-
if (effIndependent && !rowIndependent) continue;
|
|
1175
|
+
if (effIndependent && !rowIndependent && isCurrent(effective)) continue;
|
|
885
1176
|
if (
|
|
886
1177
|
effIndependent &&
|
|
887
1178
|
FAILING.has(String(effective.result)) &&
|
|
@@ -939,6 +1230,7 @@ function restackCarries(
|
|
|
939
1230
|
export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRow[]): VerdictCheck {
|
|
940
1231
|
const head = branchHead(cwd, branch);
|
|
941
1232
|
const fallbackBase = defaultBase(cwd);
|
|
1233
|
+
const scope = branchScope(cwd, branch);
|
|
942
1234
|
const keyCache = new Map<string, { patchId: string | null; diffHash: string | null }>();
|
|
943
1235
|
const keysAt = (base: string | null, at: string | null) => {
|
|
944
1236
|
if (!base || !at) return { patchId: null, diffHash: null };
|
|
@@ -951,26 +1243,38 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
|
|
|
951
1243
|
return key;
|
|
952
1244
|
};
|
|
953
1245
|
const keyFor = (base: string | null) => keysAt(base, head);
|
|
1246
|
+
const basisOf = (row: ReadRow): Exclude<VerdictBasis, "none"> => {
|
|
1247
|
+
const basis = verdictBasis(row, { head, ...keyFor(row.base ?? fallbackBase) });
|
|
1248
|
+
return basis === "stale" && restackCarries(rows, branch, row.head, head) ? "carried" : basis;
|
|
1249
|
+
};
|
|
954
1250
|
// A newer row is judged under the failure's own base, so a row recorded
|
|
955
|
-
// with a different --base cannot make unchanged code look changed.
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1251
|
+
// with a different --base cannot make unchanged code look changed. When
|
|
1252
|
+
// both rows were keyed against the same base commit, the newer row's own
|
|
1253
|
+
// stored keys are that comparison already: no git call per row.
|
|
1254
|
+
const sameCode = (failed: ReadRow, row: ReadRow): boolean => {
|
|
1255
|
+
if (failed.head && failed.head === row.head) return true;
|
|
1256
|
+
const base = failed.base ?? fallbackBase;
|
|
1257
|
+
const stored =
|
|
1258
|
+
(row.base ?? fallbackBase) === base && row.baseSha !== null && row.baseSha === failed.baseSha;
|
|
1259
|
+
const keys = stored ? { patchId: row.patchId, diffHash: row.diffHash } : keysAt(base, row.head);
|
|
1260
|
+
return (
|
|
1261
|
+
verdictBasis(failed, { head: row.head, ...keys }) !== "stale" ||
|
|
1262
|
+
restackCarries(rows, branch, failed.head, row.head)
|
|
1263
|
+
);
|
|
1264
|
+
};
|
|
1265
|
+
const authors = authorSessions(cwd, rows, branch, { base: fallbackBase, head }, scope);
|
|
962
1266
|
const byKind = new Map<string, ReadRow[]>();
|
|
963
1267
|
for (const row of rows)
|
|
964
|
-
if (row
|
|
1268
|
+
if (isLiveVerdict(row) && scope(row)) {
|
|
965
1269
|
const kind = typeof row.kind === "string" ? row.kind : "review";
|
|
966
1270
|
byKind.set(kind, [...(byKind.get(kind) ?? []), row]);
|
|
967
1271
|
}
|
|
968
1272
|
const verdicts: VerdictEntry[] = [];
|
|
1273
|
+
const warnings: string[] = [];
|
|
969
1274
|
for (const [kind, list] of byKind) {
|
|
970
|
-
const row = effectiveOf(list, authors, sameCode);
|
|
1275
|
+
const row = effectiveOf(list, authors, sameCode, (candidate) => basisOf(candidate) !== "stale");
|
|
971
1276
|
if (!row) continue;
|
|
972
|
-
|
|
973
|
-
if (basis === "stale" && restackCarries(rows, branch, row.head, head)) basis = "carried";
|
|
1277
|
+
const basis = basisOf(row);
|
|
974
1278
|
const current = basis !== "stale";
|
|
975
1279
|
const independence = independenceReasons(row, authors);
|
|
976
1280
|
const reasons: RejectReason[] = [
|
|
@@ -987,7 +1291,25 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
|
|
|
987
1291
|
reasons,
|
|
988
1292
|
verdict: row,
|
|
989
1293
|
});
|
|
1294
|
+
// The author's own failure never displaces an independent pass, but it
|
|
1295
|
+
// must not vanish either.
|
|
1296
|
+
if (reasons.length === 0)
|
|
1297
|
+
for (const later of list)
|
|
1298
|
+
if (
|
|
1299
|
+
later.seq > row.seq &&
|
|
1300
|
+
independenceReasons(later, authors).length > 0 &&
|
|
1301
|
+
FAILING.has(String(later.result)) &&
|
|
1302
|
+
basisOf(later) !== "stale"
|
|
1303
|
+
)
|
|
1304
|
+
warnings.push(
|
|
1305
|
+
`the author's own (self) ${String(later.result)} ${kind} verdict ${later.id} by ${later.actor.session ?? "no session"} on the current code is newer than the accepted pass ${row.id}: read it before merging`,
|
|
1306
|
+
);
|
|
990
1307
|
}
|
|
1308
|
+
for (const row of rows)
|
|
1309
|
+
if (row.integrity === "unverified" && scope(row))
|
|
1310
|
+
warnings.push(
|
|
1311
|
+
`ledger row ${row.id} (${row.type}) on ${row.branch ?? branch} is unverified: the workit CLI did not write it or it changed afterwards (workit ledger verify-integrity)`,
|
|
1312
|
+
);
|
|
991
1313
|
verdicts.sort((a, b) => a.verdict.seq - b.verdict.seq);
|
|
992
1314
|
const newestCurrent = verdicts.findLast((entry) => entry.current) ?? verdicts.at(-1) ?? null;
|
|
993
1315
|
const failing = verdicts.find(
|
|
@@ -1028,6 +1350,7 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
|
|
|
1028
1350
|
accepted,
|
|
1029
1351
|
verdicts,
|
|
1030
1352
|
authors: [...authors].toSorted(),
|
|
1353
|
+
warnings,
|
|
1031
1354
|
};
|
|
1032
1355
|
}
|
|
1033
1356
|
|
|
@@ -1041,6 +1364,8 @@ export type RecordContext = {
|
|
|
1041
1364
|
base?: string | null;
|
|
1042
1365
|
pr?: number;
|
|
1043
1366
|
supersedes?: string;
|
|
1367
|
+
/** Where `actor.session` came from (verdicts record it). Default: the environment. */
|
|
1368
|
+
sessionSource?: SessionSource;
|
|
1044
1369
|
now?: Date;
|
|
1045
1370
|
};
|
|
1046
1371
|
|
|
@@ -1072,13 +1397,23 @@ const required = (value: string | undefined | null, flag: string): LedgerResult<
|
|
|
1072
1397
|
};
|
|
1073
1398
|
|
|
1074
1399
|
/** Refuse a supersede link that the reader would ignore. */
|
|
1075
|
-
function checkSupersede(
|
|
1400
|
+
function checkSupersede(
|
|
1401
|
+
context: RecordContext,
|
|
1402
|
+
type: string,
|
|
1403
|
+
row: { self?: boolean; kind?: string; branch?: string | null } = {},
|
|
1404
|
+
): LedgerResult<void> {
|
|
1076
1405
|
if (!context.supersedes) return { ok: true, value: undefined };
|
|
1077
1406
|
const ledger = readLedger(context.cwd);
|
|
1078
1407
|
if (!ledger.ok) return ledger;
|
|
1079
|
-
const target = ledger.value.rows.find((
|
|
1408
|
+
const target = ledger.value.rows.find((candidate) => candidate.id === context.supersedes);
|
|
1080
1409
|
if (!target) return err("not_found", `no ledger row ${context.supersedes} to supersede`);
|
|
1081
|
-
const
|
|
1410
|
+
const branch =
|
|
1411
|
+
row.branch === undefined ? (context.branch ?? currentBranch(context.cwd)) : row.branch;
|
|
1412
|
+
const problem = supersedeAllowed(
|
|
1413
|
+
{ type, actor: context.actor, ...row, branch },
|
|
1414
|
+
target,
|
|
1415
|
+
renameAwareBranch((name) => branchScope(context.cwd, name), branch),
|
|
1416
|
+
);
|
|
1082
1417
|
return problem
|
|
1083
1418
|
? err("invalid_input", `--supersedes: ${problem}`)
|
|
1084
1419
|
: { ok: true, value: undefined };
|
|
@@ -1269,6 +1604,23 @@ export function clearStanding(
|
|
|
1269
1604
|
);
|
|
1270
1605
|
}
|
|
1271
1606
|
|
|
1607
|
+
/**
|
|
1608
|
+
* Is `branch` checked out, with uncommitted changes, in another worktree of
|
|
1609
|
+
* this repository? (`codeKey` sees only the cwd's own worktree.)
|
|
1610
|
+
*/
|
|
1611
|
+
function dirtyElsewhere(cwd: string, branch: string): boolean {
|
|
1612
|
+
const list = git(cwd, ["worktree", "list", "--porcelain"]);
|
|
1613
|
+
let worktree: string | null = null;
|
|
1614
|
+
for (const line of (list ?? "").split("\n")) {
|
|
1615
|
+
if (line.startsWith("worktree ")) worktree = line.slice("worktree ".length);
|
|
1616
|
+
else if (line === `branch refs/heads/${branch}` && worktree) {
|
|
1617
|
+
const status = gitRun(worktree, ["status", "--porcelain", "--untracked-files=normal"]);
|
|
1618
|
+
return status.ok && status.stdout.trim().length > 0;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
return false;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1272
1624
|
/**
|
|
1273
1625
|
* Record an agent-asserted verdict on a branch's committed head. Refused on a
|
|
1274
1626
|
* dirty worktree (the head would not be what was judged) and, for an author
|
|
@@ -1316,7 +1668,7 @@ export function recordVerdict(
|
|
|
1316
1668
|
if (!ledger.ok) return ledger;
|
|
1317
1669
|
const key = codeKey(context.cwd, { branch, base: context.base });
|
|
1318
1670
|
if (!key.head) return err("not_found", `branch "${branch}" has no commit to judge`);
|
|
1319
|
-
if (key.dirty === true)
|
|
1671
|
+
if (key.dirty === true || (key.dirty === null && dirtyElsewhere(context.cwd, branch)))
|
|
1320
1672
|
return err(
|
|
1321
1673
|
"blocked",
|
|
1322
1674
|
`dirty_worktree: ${branch} has uncommitted changes, so its head is not what was judged`,
|
|
@@ -1341,7 +1693,7 @@ export function recordVerdict(
|
|
|
1341
1693
|
? "have a non-author session record the verdict: spawn a verifier with its own WORKIT_SESSION_ID=<lead>-v<n> (Claude Code: subagents get one from the SubagentStart hook); or, as the author, pass --self (self-reviewed, never accepted)"
|
|
1342
1694
|
: "run the verifier as a separate session: the lead spawns it with its own WORKIT_SESSION_ID (Claude Code: subagents get one from the SubagentStart hook)",
|
|
1343
1695
|
);
|
|
1344
|
-
const link = checkSupersede(context, "verdict", self);
|
|
1696
|
+
const link = checkSupersede(context, "verdict", { self, kind, branch });
|
|
1345
1697
|
if (!link.ok) return link;
|
|
1346
1698
|
return appendRow<VerdictRow>(
|
|
1347
1699
|
context.cwd,
|
|
@@ -1355,6 +1707,7 @@ export function recordVerdict(
|
|
|
1355
1707
|
observer: "agent_asserted",
|
|
1356
1708
|
self,
|
|
1357
1709
|
selfReason,
|
|
1710
|
+
sessionSource: context.sessionSource ?? "env",
|
|
1358
1711
|
evidenceRefs: input.evidenceRefs ?? [],
|
|
1359
1712
|
},
|
|
1360
1713
|
{ now: context.now },
|
|
@@ -1387,7 +1740,12 @@ export type RowSummary = {
|
|
|
1387
1740
|
};
|
|
1388
1741
|
|
|
1389
1742
|
/** Row types that count only when an observing verb wrote them. */
|
|
1390
|
-
const OBSERVED_ONLY: ReadonlySet<string> = new Set([
|
|
1743
|
+
const OBSERVED_ONLY: ReadonlySet<string> = new Set([
|
|
1744
|
+
"check",
|
|
1745
|
+
"merge.unverified",
|
|
1746
|
+
"policy.judged",
|
|
1747
|
+
...PR_ROW_TYPES,
|
|
1748
|
+
]);
|
|
1391
1749
|
|
|
1392
1750
|
export function rowLabels(row: ReadRow): RowSummary["labels"] {
|
|
1393
1751
|
const labels: RowSummary["labels"] = [];
|
|
@@ -1409,8 +1767,14 @@ export function summarizeRow(row: ReadRow): RowSummary {
|
|
|
1409
1767
|
summary = `${text("what")} (why: ${text("why")}; cost if wrong: ${text("costIfWrong")})`;
|
|
1410
1768
|
break;
|
|
1411
1769
|
case "verdict":
|
|
1412
|
-
summary = `${text("result")} [${text("kind") || "review"}] at ${(row.head ?? "?").slice(0, 12)}${row.self === true ? ` (self${text("selfReason") === "no_session" ? ": no session" : ""})` : ""}: ${text("how")}`;
|
|
1770
|
+
summary = `${text("result")} [${text("kind") || "review"}] at ${(row.head ?? "?").slice(0, 12)}${row.self === true ? ` (self${text("selfReason") === "no_session" ? ": no session" : ""})` : ""}${text("sessionSource") === "flag" ? ` (session ${row.actor.session ?? "?"} named by --session)` : ""}: ${text("how")}`;
|
|
1413
1771
|
break;
|
|
1772
|
+
case "policy.judged": {
|
|
1773
|
+
const lifted = Array.isArray(row.lifted) ? row.lifted.map(String) : [];
|
|
1774
|
+
const requirements = Array.isArray(row.requirements) ? row.requirements.map(String) : [];
|
|
1775
|
+
summary = `judged by ${row.actor.session ?? "no session"}: ${requirements.length ? requirements.join(", ") : "no requirements"}${lifted.length ? `; lifted ${lifted.join(", ")} (why: ${text("why") || "none given"})` : ""}`;
|
|
1776
|
+
break;
|
|
1777
|
+
}
|
|
1414
1778
|
case "handoff":
|
|
1415
1779
|
summary = `next: ${text("next")}${text("note") ? ` (note: ${text("note")})` : ""}`;
|
|
1416
1780
|
break;
|