@brainervirus/workit-core 2.6.0 → 2.8.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/src/checks.ts ADDED
@@ -0,0 +1,480 @@
1
+ // Check runs for `workit check` (design §2.1 S9, §2.2; D5, D14): no shell
2
+ // (unless the caller passes one string with --shell), the child's output
3
+ // streams through while a bounded, redacted copy is kept for the log blob
4
+ // (`<store>/blobs/logs/<sha256>.log`) and the tail. Named-check config lives
5
+ // in check-config.ts.
6
+ //
7
+ // Plain TS over node built-ins: no zod, no task store.
8
+ import { spawn, spawnSync } from "node:child_process";
9
+ import { createHash } from "node:crypto";
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { StringDecoder } from "node:string_decoder";
14
+ import { redactText } from "./forge/redact";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // bounded, redacted capture
18
+
19
+ /** Bytes of combined output kept for the log blob (the newest bytes win). */
20
+ export const MAX_LOG_BYTES = 2 * 1024 * 1024;
21
+ export const TAIL_LINES = 80;
22
+ const MAX_LINE_CHARS = 400;
23
+
24
+ /**
25
+ * Mask secrets and strip terminal escapes from captured output with the forge
26
+ * redaction (tokens, key=value and quoted secrets, URL credentials, private
27
+ * keys, signed URLs, credential-looking base64). The logger's redaction is not
28
+ * used: it rewrites Windows backslashes and home paths, which would alter the
29
+ * observed output.
30
+ */
31
+ export const redactLog = (text: string): string => redactText(text);
32
+
33
+ /** The last `lines` non-empty lines, each cut to a bounded width. */
34
+ export function tailLines(text: string, lines: number = TAIL_LINES): string[] {
35
+ const all = text.replace(/\r\n?/gu, "\n").split("\n");
36
+ while (all.length && !all.at(-1)?.trim()) all.pop();
37
+ return all
38
+ .slice(-lines)
39
+ .map((line) => (line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}…` : line));
40
+ }
41
+
42
+ /** Keeps the newest `max` bytes of already-redacted text. */
43
+ export class TailBuffer {
44
+ private chunks: string[] = [];
45
+ private size = 0;
46
+ total = 0;
47
+ constructor(private readonly max: number) {}
48
+ push(text: string): void {
49
+ const bytes = Buffer.byteLength(text);
50
+ this.total += bytes;
51
+ this.chunks.push(text);
52
+ this.size += bytes;
53
+ while (this.chunks.length > 1 && this.size - Buffer.byteLength(this.chunks[0]) >= this.max)
54
+ this.size -= Buffer.byteLength(this.chunks.shift()!);
55
+ }
56
+ text(): string {
57
+ const joined = Buffer.from(this.chunks.join(""));
58
+ if (joined.length <= this.max) return joined.toString("utf8");
59
+ const value = joined.subarray(joined.length - this.max).toString("utf8");
60
+ // The head was cut: drop the partial first line.
61
+ const newline = value.indexOf("\n");
62
+ return newline >= 0 ? value.slice(newline + 1) : value;
63
+ }
64
+ }
65
+
66
+ const PEM_BEGIN = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/u;
67
+ const PEM_END = /-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/u;
68
+ const BATCH_BYTES = 64 * 1024;
69
+ const MAX_PENDING = 64 * 1024;
70
+
71
+ /** Lines that can belong to a PEM body: base64 (or blank), or an RFC 1421 encapsulated header. */
72
+ const PEM_BODY = /^(?:[A-Za-z0-9+/=]*|(?:Proc-Type|DEK-Info|Comment): .*)\s*$/u;
73
+ /** Suppression bounds for a key whose END never appears (truncated output). */
74
+ const MAX_KEY_LINES = 4096;
75
+ const MAX_KEY_BYTES = 256 * 1024;
76
+
77
+ /**
78
+ * Redacts output as it streams, before anything is bounded: complete lines
79
+ * are redacted in batches, and a private key block is replaced as a whole
80
+ * while its BEGIN line is still in view, so trimming the log to its newest
81
+ * bytes can never cut a key loose from its header. Key state spans the merged
82
+ * stdout+stderr log: while a key is open, base64-looking lines from either
83
+ * stream are dropped. The key ends at its END line, at the first non-base64
84
+ * line from the stream that began it, or after MAX_KEY_LINES/MAX_KEY_BYTES, so
85
+ * a key without an END never swallows the rest of the log. Non-key lines from
86
+ * the other stream pass through. A line longer than 64 KB is treated as complete.
87
+ */
88
+ export class StreamRedactor {
89
+ private pending = new Map<string, string>();
90
+ private key: { stream: string; lines: number; bytes: number } | null = null;
91
+ private batch: string[] = [];
92
+ private batchBytes = 0;
93
+ constructor(private readonly sink: (text: string) => void) {}
94
+ write(stream: string, text: string): void {
95
+ let rest = (this.pending.get(stream) ?? "") + text;
96
+ for (let newline = rest.indexOf("\n"); newline >= 0; newline = rest.indexOf("\n")) {
97
+ this.line(stream, rest.slice(0, newline + 1));
98
+ rest = rest.slice(newline + 1);
99
+ }
100
+ if (rest.length > MAX_PENDING) {
101
+ this.line(stream, rest);
102
+ rest = "";
103
+ }
104
+ this.pending.set(stream, rest);
105
+ }
106
+ end(): void {
107
+ for (const [stream, rest] of this.pending) if (rest) this.line(stream, `${rest}\n`);
108
+ this.pending.clear();
109
+ this.flush();
110
+ }
111
+ private line(stream: string, line: string): void {
112
+ if (this.key) {
113
+ if (PEM_END.test(line)) {
114
+ this.key = null;
115
+ return;
116
+ }
117
+ const body = PEM_BODY.test(line);
118
+ if (body) {
119
+ this.key.lines += 1;
120
+ this.key.bytes += line.length;
121
+ if (this.key.lines > MAX_KEY_LINES || this.key.bytes > MAX_KEY_BYTES) this.key = null;
122
+ return;
123
+ }
124
+ if (stream === this.key.stream) this.key = null;
125
+ }
126
+ const begin = line.search(PEM_BEGIN);
127
+ if (begin >= 0) {
128
+ if (!PEM_END.test(line.slice(begin))) this.key = { stream, lines: 0, bytes: 0 };
129
+ this.push(`${line.slice(0, begin)}[REDACTED PRIVATE KEY]\n`);
130
+ return;
131
+ }
132
+ this.push(line);
133
+ }
134
+ private push(line: string): void {
135
+ this.batch.push(line);
136
+ this.batchBytes += line.length;
137
+ if (this.batchBytes >= BATCH_BYTES) this.flush();
138
+ }
139
+ private flush(): void {
140
+ if (!this.batch.length) return;
141
+ this.sink(redactLog(this.batch.join("")));
142
+ this.batch = [];
143
+ this.batchBytes = 0;
144
+ }
145
+ }
146
+
147
+ export type CheckRun = {
148
+ /** The child's exit code; 128+n when killed by signal n; 127 when it could not start. */
149
+ exitCode: number;
150
+ signal: string | null;
151
+ timedOut: boolean;
152
+ durationMs: number;
153
+ /** Redacted, bounded combined output. */
154
+ log: string;
155
+ /** Total bytes of redacted output (before bounding). */
156
+ outputBytes: number;
157
+ truncated: boolean;
158
+ /** Set when the command could not be started. */
159
+ spawnError: string | null;
160
+ };
161
+
162
+ export type RunOptions = {
163
+ cwd: string;
164
+ shell?: boolean;
165
+ timeoutMs?: number;
166
+ env?: NodeJS.ProcessEnv;
167
+ /** Receives the child's stdout as it arrives. */
168
+ onStdout?: (text: string) => void;
169
+ onStderr?: (text: string) => void;
170
+ /** For tests: the platform whose spawn rules apply (default: process.platform). */
171
+ platform?: NodeJS.Platform;
172
+ };
173
+
174
+ // cmd.exe metacharacters (the cross-spawn set).
175
+ const CMD_META = /([()\][%!^"`<>&|;, *?])/gu;
176
+
177
+ /** One argument for a `cmd /d /s /c "…"` line; `double` for node_modules/.bin shims, which re-parse. */
178
+ export function cmdArgument(arg: string, double = false): string {
179
+ let out = arg.replace(/(\\*)"/gu, '$1$1\\"').replace(/(\\*)$/u, "$1$1");
180
+ out = `"${out}"`.replace(CMD_META, "^$1");
181
+ return double ? out.replace(CMD_META, "^$1") : out;
182
+ }
183
+
184
+ export type SpawnPlan = {
185
+ file: string;
186
+ args: string[];
187
+ /** Pass the args to CreateProcess verbatim (cmd.exe lines carry their own quoting). */
188
+ verbatim: boolean;
189
+ shell: boolean;
190
+ };
191
+
192
+ /**
193
+ * How to start argv without a shell. On Windows, argv[0] is resolved through
194
+ * PATH and PATHEXT (CreateProcess finds only .exe/.com); a .cmd/.bat shim
195
+ * (npm, pnpm, yarn, node_modules/.bin) runs as `cmd.exe /d /s /c "<line>"`
196
+ * with every argument escaped, so the observed argv (and `configured`) stay
197
+ * the configured ones. Null `isFile` uses the real filesystem.
198
+ */
199
+ export function planSpawn(
200
+ argv: readonly string[],
201
+ options: {
202
+ platform: NodeJS.Platform;
203
+ env: NodeJS.ProcessEnv;
204
+ cwd: string;
205
+ shell?: boolean;
206
+ isFile?: (file: string) => boolean;
207
+ },
208
+ ): SpawnPlan {
209
+ if (options.shell) return { file: argv[0], args: [], verbatim: false, shell: true };
210
+ if (options.platform !== "win32")
211
+ return { file: argv[0], args: argv.slice(1), verbatim: false, shell: false };
212
+ const win = path.win32;
213
+ const isFile =
214
+ options.isFile ??
215
+ ((file: string) => {
216
+ try {
217
+ return fs.statSync(file).isFile();
218
+ } catch {
219
+ return false;
220
+ }
221
+ });
222
+ // Windows names are case-insensitive; an exact upper-case key wins over a duplicate.
223
+ const env = (name: string): string | undefined =>
224
+ options.env[name] ??
225
+ Object.entries(options.env).find(([key]) => key.toUpperCase() === name)?.[1];
226
+ const exts = (env("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
227
+ const command = argv[0];
228
+ const hasDir = /[\\/]/u.test(command);
229
+ const dirs = hasDir ? [""] : (env("PATH") ?? "").split(";").filter(Boolean);
230
+ const candidates = (base: string) =>
231
+ win.extname(base) ? [base, ...exts.map((ext) => base + ext)] : exts.map((ext) => base + ext);
232
+ let resolved: string | null = null;
233
+ for (const dir of dirs) {
234
+ const base = hasDir ? win.resolve(options.cwd, command) : win.join(dir, command);
235
+ resolved = candidates(base).find(isFile) ?? null;
236
+ if (resolved) break;
237
+ }
238
+ if (!resolved) return { file: command, args: argv.slice(1), verbatim: false, shell: false };
239
+ if (!/\.(?:cmd|bat)$/iu.test(resolved))
240
+ return { file: resolved, args: argv.slice(1), verbatim: false, shell: false };
241
+ const double = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/iu.test(resolved);
242
+ const line = [
243
+ win.normalize(resolved).replace(CMD_META, "^$1"),
244
+ ...argv.slice(1).map((arg) => cmdArgument(arg, double)),
245
+ ].join(" ");
246
+ return {
247
+ file: env("COMSPEC") ?? "cmd.exe",
248
+ args: ["/d", "/s", "/c", `"${line}"`],
249
+ verbatim: true,
250
+ shell: false,
251
+ };
252
+ }
253
+
254
+ /** After the child exits, how long its pipes may stay open (held by leftovers) before they are cut. */
255
+ const DRAIN_MS = 250;
256
+ const FORWARDED_SIGNALS: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"];
257
+
258
+ /**
259
+ * Run one command, streaming its output to the callbacks while a redacted,
260
+ * bounded copy is captured. With `shell`, argv must be a single command
261
+ * string. On POSIX the child leads its own process group (stdin is not
262
+ * inherited), so a timeout, or the exit of the command, kills every process
263
+ * it started; Ctrl-C and SIGTERM sent to workit are forwarded to the group.
264
+ * On Windows the tree is killed with `taskkill /T /F`. The run settles on
265
+ * the child's exit plus a short drain, so a leftover holding the pipes open
266
+ * cannot hang it.
267
+ */
268
+ export function runCheckCommand(argv: readonly string[], options: RunOptions): Promise<CheckRun> {
269
+ const started = Date.now();
270
+ const platform = options.platform ?? process.platform;
271
+ const windows = process.platform === "win32";
272
+ const buffer = new TailBuffer(MAX_LOG_BYTES);
273
+ const redactor = new StreamRedactor((text) => buffer.push(text));
274
+ const env = options.env ?? process.env;
275
+ const plan = planSpawn(argv, { platform, env, cwd: options.cwd, shell: options.shell });
276
+ return new Promise((resolve) => {
277
+ let settled = false;
278
+ let timedOut = false;
279
+ let drain: NodeJS.Timeout | null = null;
280
+ let child: ReturnType<typeof spawn>;
281
+ let timer: NodeJS.Timeout | null = null;
282
+ const killTree = () => {
283
+ if (!child?.pid) return;
284
+ if (windows)
285
+ spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
286
+ stdio: "ignore",
287
+ windowsHide: true,
288
+ });
289
+ else
290
+ try {
291
+ process.kill(-child.pid, "SIGKILL");
292
+ } catch {
293
+ // The group is already gone.
294
+ }
295
+ };
296
+ const forwarders = new Map<NodeJS.Signals, () => void>();
297
+ const finish = (code: number | null, signal: NodeJS.Signals | null, spawnError?: string) => {
298
+ if (settled) return;
299
+ settled = true;
300
+ if (timer) clearTimeout(timer);
301
+ if (drain) clearTimeout(drain);
302
+ for (const [name, handler] of forwarders) process.off(name, handler);
303
+ child?.stdout?.destroy();
304
+ child?.stderr?.destroy();
305
+ if (spawnError) redactor.write("workit", `workit: cannot start ${argv[0]}: ${spawnError}\n`);
306
+ redactor.end();
307
+ resolve({
308
+ exitCode: spawnError
309
+ ? 127
310
+ : code !== null
311
+ ? code
312
+ : 128 + (signal ? (os.constants.signals[signal] ?? 1) : 1),
313
+ signal: signal ?? null,
314
+ timedOut,
315
+ durationMs: Date.now() - started,
316
+ log: buffer.text(),
317
+ outputBytes: buffer.total,
318
+ truncated: buffer.total > MAX_LOG_BYTES,
319
+ spawnError: spawnError ?? null,
320
+ });
321
+ };
322
+ const spawnOptions = {
323
+ cwd: options.cwd,
324
+ env,
325
+ stdio: ["ignore", "pipe", "pipe"] as ["ignore", "pipe", "pipe"],
326
+ windowsHide: true,
327
+ detached: !windows,
328
+ windowsVerbatimArguments: plan.verbatim,
329
+ shell: plan.shell,
330
+ };
331
+ try {
332
+ child = spawn(plan.file, plan.args, spawnOptions);
333
+ } catch (error) {
334
+ finish(null, null, (error as NodeJS.ErrnoException).code ?? (error as Error).message);
335
+ return;
336
+ }
337
+ if (!windows)
338
+ for (const name of FORWARDED_SIGNALS) {
339
+ const handler = () => {
340
+ if (child.pid)
341
+ try {
342
+ process.kill(-child.pid, name);
343
+ } catch {
344
+ // Already gone.
345
+ }
346
+ };
347
+ forwarders.set(name, handler);
348
+ process.on(name, handler);
349
+ }
350
+ if (options.timeoutMs && options.timeoutMs > 0)
351
+ timer = setTimeout(() => {
352
+ timedOut = true;
353
+ killTree();
354
+ }, options.timeoutMs);
355
+ const forward = (
356
+ name: string,
357
+ stream: NodeJS.ReadableStream | null,
358
+ sink?: (text: string) => void,
359
+ ) => {
360
+ if (!stream) return;
361
+ const decoder = new StringDecoder("utf8");
362
+ stream.on("data", (chunk: Buffer) => {
363
+ const text = decoder.write(chunk);
364
+ redactor.write(name, text);
365
+ if (sink) sink(text);
366
+ });
367
+ stream.on("end", () => {
368
+ const rest = decoder.end();
369
+ if (rest) {
370
+ redactor.write(name, rest);
371
+ if (sink) sink(rest);
372
+ }
373
+ });
374
+ stream.on("error", () => {
375
+ // A destroyed pipe after the drain: the captured output stands.
376
+ });
377
+ };
378
+ forward("stdout", child.stdout, options.onStdout);
379
+ forward("stderr", child.stderr, options.onStderr);
380
+ child.on("error", (error: NodeJS.ErrnoException) =>
381
+ finish(null, null, error.code ?? error.message),
382
+ );
383
+ child.on("exit", (code, signal) => {
384
+ // Leftovers of the command die with it; then give the pipes a moment.
385
+ if (!windows) killTree();
386
+ drain = setTimeout(() => finish(code, signal), DRAIN_MS);
387
+ });
388
+ child.on("close", (code, signal) => finish(code, signal));
389
+ });
390
+ }
391
+
392
+ /** Content address of a stored log: `sha256:<hex>`. */
393
+ export const logDigest = (log: string): string =>
394
+ `sha256:${createHash("sha256").update(log).digest("hex")}`;
395
+
396
+ /**
397
+ * Store a redacted log under `<storeRoot>/blobs/logs/<hex>.log` (content
398
+ * addressed, so concurrent writers of the same log agree). Returns the path
399
+ * relative to the store root, or null when the write fails.
400
+ */
401
+ export function storeLog(storeRoot: string, log: string): { digest: string; ref: string } | null {
402
+ const digest = logDigest(log);
403
+ const ref = `blobs/logs/${digest.slice("sha256:".length)}.log`;
404
+ const file = path.join(storeRoot, ref);
405
+ try {
406
+ if (!fs.existsSync(file)) {
407
+ fs.mkdirSync(path.dirname(file), { recursive: true });
408
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
409
+ fs.writeFileSync(temp, log, { mode: 0o600 });
410
+ fs.renameSync(temp, file);
411
+ }
412
+ return { digest, ref };
413
+ } catch {
414
+ return null;
415
+ }
416
+ }
417
+
418
+ export type LogPruneReport = { removed: number; removedBytes: number; kept: number };
419
+
420
+ /** Check-log retention for `workit gc`: newest 200, at most 30 days old, at most 256 MB. */
421
+ export const LOG_RETENTION = {
422
+ maxCount: 200,
423
+ maxAgeMs: 30 * 86_400_000,
424
+ maxBytes: 256 * 1024 * 1024,
425
+ };
426
+
427
+ /**
428
+ * Prune `<storeRoot>/blobs/logs`: keep the newest logs within the count, age
429
+ * and size caps, and drop leftover temp files. Evidence keeps its logDigest
430
+ * when its blob is pruned. `dryRun` only reports.
431
+ */
432
+ export function pruneCheckLogs(
433
+ storeRoot: string,
434
+ options: { dryRun?: boolean; now?: number; retention?: Partial<typeof LOG_RETENTION> } = {},
435
+ ): LogPruneReport {
436
+ const limits = { ...LOG_RETENTION, ...options.retention };
437
+ const now = options.now ?? Date.now();
438
+ const dir = path.join(storeRoot, "blobs", "logs");
439
+ const report: LogPruneReport = { removed: 0, removedBytes: 0, kept: 0 };
440
+ let names: string[];
441
+ try {
442
+ names = fs.readdirSync(dir);
443
+ } catch {
444
+ return report;
445
+ }
446
+ const files = names
447
+ .flatMap((name) => {
448
+ try {
449
+ const stat = fs.statSync(path.join(dir, name));
450
+ return stat.isFile() ? [{ name, size: stat.size, mtimeMs: stat.mtimeMs }] : [];
451
+ } catch {
452
+ return [];
453
+ }
454
+ })
455
+ .toSorted((a, b) => b.mtimeMs - a.mtimeMs);
456
+ let keptBytes = 0;
457
+ for (const file of files) {
458
+ const temp = file.name.endsWith(".tmp");
459
+ const keep =
460
+ !temp &&
461
+ report.kept < limits.maxCount &&
462
+ now - file.mtimeMs <= limits.maxAgeMs &&
463
+ keptBytes + file.size <= limits.maxBytes;
464
+ if (keep) {
465
+ report.kept += 1;
466
+ keptBytes += file.size;
467
+ continue;
468
+ }
469
+ if (temp && now - file.mtimeMs < 3_600_000) continue;
470
+ if (!options.dryRun)
471
+ try {
472
+ fs.rmSync(path.join(dir, file.name), { force: true });
473
+ } catch {
474
+ continue;
475
+ }
476
+ report.removed += 1;
477
+ report.removedBytes += file.size;
478
+ }
479
+ return report;
480
+ }
@@ -118,8 +118,11 @@ policy, and reapplies the call, returning busy under persistent contention. A
118
118
  revision_conflict means a revision you passed is stale: re-read the record
119
119
  before deciding whether to retry.
120
120
  A solo edit does not need writer acquisition; use it when concurrent checkout
121
- writers need coordination. Record only observed facts and checks. Evidence can
122
- become stale when its bound candidate changes; reconcile findings against the
121
+ writers need coordination. Record only observed facts and checks. Close-time
122
+ testing and verification gates accept only a configured check the CLI ran:
123
+ \`workit check <name>\` (\`npx -y @brainervirus/workit-cli check <name>\` when
124
+ \`workit\` is not on PATH); a recorded check result is a note and an ad-hoc
125
+ \`workit check -- <cmd>\` never satisfies a gate. Evidence can become stale when its bound candidate or tree changes; reconcile findings against the
123
126
  current candidate before recording completion.
124
127
 
125
128
  Workit validates domain policy against the actual action target, configured
@@ -168,7 +171,8 @@ target result; a local commit alone is not evidence of a requested remote push.
168
171
  Skill routing: slash aliases /wk-* load on demand. Use workit-steer for a
169
172
  substantial interruption or change of direction, workit-deslop when relevant
170
173
  to a PR-ready endpoint, workit-green-run for failing CI, workit-blast-radius
171
- when impact is uncertain, and workit-challenge for genuinely open consequential
172
- choices. Load workit-plan when dependencies or handoff need durable next actions.
174
+ when impact is uncertain, workit-challenge for genuinely open consequential
175
+ choices, workit-bdd to turn acceptance criteria into Given/When/Then tests, and
176
+ workit-test-audit to check tests for tautologies. Load workit-plan when dependencies or handoff need durable next actions.
173
177
  Load the skill; never act from memory of it.
174
178
  `.trim();
@@ -16,6 +16,8 @@ export const WORKIT_METHOD_SKILLS = [
16
16
  "workit-mockup",
17
17
  "workit-green-run",
18
18
  "workit-steer",
19
+ "workit-bdd",
20
+ "workit-test-audit",
19
21
  ] as const;
20
22
 
21
23
  /** wk- slash aliases (one per skill): alias → method skill. An alias routes
@@ -35,6 +37,8 @@ export const WORKIT_SKILL_ALIASES = {
35
37
  "wk-mockup": "workit-mockup",
36
38
  "wk-green-run": "workit-green-run",
37
39
  "wk-steer": "workit-steer",
40
+ "wk-bdd": "workit-bdd",
41
+ "wk-test-audit": "workit-test-audit",
38
42
  } as const;
39
43
 
40
44
  export const skillManifestNames = (root: string): string[] =>
@@ -13,7 +13,7 @@ import {
13
13
  type TaskView,
14
14
  type Utc,
15
15
  } from "./task-contract";
16
- import { captureCandidate, evaluateEvidence } from "./task-evaluation";
16
+ import { captureCandidate, evaluateEvidence, treeFreshness } from "./task-evaluation";
17
17
  import { selectMethods, type SelectedMethod } from "./methods";
18
18
  import type { NativeWorkerObservation } from "./workers";
19
19
 
@@ -39,7 +39,11 @@ export function reconcileResume(
39
39
  return failure("permission_denied", "worker observations require native host verification");
40
40
  const candidate = captureCandidate(view.workspace.root, view.task.intent.data.scope, []);
41
41
  if (!candidate.ok) return candidate;
42
- const staleEvidenceIds = evaluateEvidence(view.task, candidate.data)
42
+ const staleEvidenceIds = evaluateEvidence(
43
+ view.task,
44
+ candidate.data,
45
+ treeFreshness(view.workspace.root),
46
+ )
43
47
  .filter((entry) => entry.status === "stale")
44
48
  .map((entry) => entry.evidenceId);
45
49
  const blockers = [...view.task.progress.blockers];
@@ -541,20 +541,78 @@ export const candidateSchema = z
541
541
  });
542
542
  });
543
543
  export type Candidate = z.infer<typeof candidateSchema>;
544
- export const evidenceSchema = z
544
+ /**
545
+ * What `workit check` observed (design §2.1 S9, §2.2). Only the CLI's
546
+ * observing path writes it (WorkitCore.observeCheck); the agent-facing
547
+ * `evidence.record` operation rejects it. A task that holds one lists
548
+ * `evidence.*.data.observation` in `critical`, so an older reader fails
549
+ * closed instead of reading the check without it.
550
+ */
551
+ export const checkObservationSchema = z
545
552
  .object({
546
- kind: z.enum(["check", "review", "investigation", "artifact"]),
547
- claim: text,
548
- requirementIds: z.array(digest),
549
- beforeCandidateId: nullableDigest.optional(),
550
- candidateId: nullableDigest.optional(),
551
- result: z.enum(["passed", "failed", "missing", "skipped"]),
552
- summary: text,
553
- refs: z.array(refSchema),
554
- exitCode: safeInteger.nullable(),
555
- reviewContext: refSchema.nullable(),
553
+ observer: z.literal("workit_cli"),
554
+ /** The check name (`--name` or `workit check <name>`); null for an unnamed ad-hoc run. */
555
+ name: nonEmpty.nullable(),
556
+ /** The name is configured and argv is exactly its configured command, run from the repo top. */
557
+ configured: z.boolean(),
558
+ argv: z.array(text).min(1),
559
+ shell: z.boolean(),
560
+ /** Working directory relative to the repository top (posix), `.` at the top. */
561
+ cwd: nonEmpty,
562
+ exitCode: safeInteger,
563
+ durationMs: safeInteger.min(0),
564
+ timedOut: z.boolean(),
565
+ head: text.nullable(),
566
+ /** Worktree tree key before the run; evidence is fresh while the tree key is unchanged. */
567
+ tree: text.nullable(),
568
+ dirty: z.boolean().nullable(),
569
+ /** Cheap stat-cached worktree signal before the run (per-turn freshness; see worktreeSignal). */
570
+ signal: text.nullable(),
571
+ /** Tree key after the run; differs from `tree` when the command changed the worktree. */
572
+ treeAfter: text.nullable(),
573
+ /** The command changed the worktree: the evidence is stale (fail safe). */
574
+ modifiedWorktree: z.boolean(),
575
+ base: text.nullable(),
576
+ patchId: text.nullable(),
577
+ /** A minimal environment fingerprint: platform, arch and allowlisted variables. */
578
+ environment: z
579
+ .object({
580
+ platform: nonEmpty,
581
+ arch: nonEmpty,
582
+ vars: z.record(nonEmpty, text.nullable()),
583
+ })
584
+ .strict(),
585
+ logDigest: text.nullable(),
586
+ logRef: text.nullable(),
587
+ logTail: z.array(text).max(40),
588
+ ledgerRowId: text.nullable(),
589
+ /** Set only when a host hook later attests the run (design §2.1 attestation). */
590
+ attestation: z
591
+ .object({ host: nonEmpty, session: text.nullable(), agentId: text.nullable() })
592
+ .strict()
593
+ .nullable(),
556
594
  })
557
595
  .strict();
596
+ export type CheckObservation = z.infer<typeof checkObservationSchema>;
597
+ /** The record path a task lists in `critical` once it stores a check observation. */
598
+ export const CHECK_OBSERVATION_PATH = "evidence.*.data.observation";
599
+ const evidenceFields = {
600
+ kind: z.enum(["check", "review", "investigation", "artifact"]),
601
+ claim: text,
602
+ requirementIds: z.array(digest),
603
+ beforeCandidateId: nullableDigest.optional(),
604
+ candidateId: nullableDigest.optional(),
605
+ result: z.enum(["passed", "failed", "missing", "skipped"]),
606
+ summary: text,
607
+ refs: z.array(refSchema),
608
+ exitCode: safeInteger.nullable(),
609
+ reviewContext: refSchema.nullable(),
610
+ };
611
+ export const evidenceSchema = z
612
+ .object({ ...evidenceFields, observation: checkObservationSchema.optional() })
613
+ .strict();
614
+ /** Evidence as an agent may submit it: no CLI observation. */
615
+ const reportedEvidenceSchema = z.object(evidenceFields).strict();
558
616
  export type Evidence = z.infer<typeof evidenceSchema>;
559
617
  export const evidenceEvaluationSchema = z
560
618
  .object({
@@ -1015,7 +1073,7 @@ const evidenceOperations = {
1015
1073
  action: z.literal("record"),
1016
1074
  ...taskId,
1017
1075
  expectedRevision: revision.optional(),
1018
- evidence: evidenceSchema,
1076
+ evidence: reportedEvidenceSchema,
1019
1077
  }),
1020
1078
  };
1021
1079
  const findingOperations = {