@brainervirus/workit-core 2.5.0 → 2.7.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.
@@ -11,7 +11,7 @@ import {
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
13
 
14
- export type HostId = "opencode" | "cursor" | "codex" | "pi";
14
+ export type HostId = "opencode" | "cursor" | "codex" | "pi" | "claude-code";
15
15
 
16
16
  /** A reviewed argv vector. Arguments are never composed into a shell string. */
17
17
  export type HostInstallCommand = {
@@ -89,6 +89,12 @@ export type HostInstallOptions = {
89
89
 
90
90
  const MARKETPLACE = "https://github.com/BrainerVirus/workit.git";
91
91
  const CODEX_MARKETPLACE_NAME = "workflow-toolkit";
92
+ /** Claude Code reads the git-hosted marketplace at the repo root
93
+ * (`.claude-plugin/marketplace.json`, name `workit`); its entry installs the
94
+ * published npm package. */
95
+ export const CLAUDE_MARKETPLACE_REPO = "BrainerVirus/workit";
96
+ export const CLAUDE_MARKETPLACE_NAME = "workit";
97
+ export const CLAUDE_PLUGIN_ID = `workit@${CLAUDE_MARKETPLACE_NAME}`;
92
98
  const validPackageVersion = (value: string): boolean =>
93
99
  value === "latest" || /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value);
94
100
 
@@ -224,6 +230,109 @@ export const isCodexWorkitInstalled = (
224
230
  return false;
225
231
  };
226
232
 
233
+ /** Claude Code's config root (`CLAUDE_CONFIG_DIR`, default `~/.claude`). */
234
+ export const claudeConfigDir = (home: string, env: NodeJS.ProcessEnv = process.env): string =>
235
+ env.CLAUDE_CONFIG_DIR ?? path.join(home, ".claude");
236
+
237
+ export type ClaudeInstallScope = "user" | "project" | "local";
238
+
239
+ export type ClaudeWorkitInstall = {
240
+ id: string;
241
+ version: string | null;
242
+ installPath: string;
243
+ scope: ClaudeInstallScope;
244
+ /** The project a project/local-scope install belongs to; null for user scope. */
245
+ projectPath: string | null;
246
+ };
247
+
248
+ const CLAUDE_SCOPES = new Set<string>(["user", "project", "local"]);
249
+
250
+ /** A project/local install applies to `cwd` when cwd is inside its project. */
251
+ const appliesTo = (install: ClaudeWorkitInstall, cwd: string | null): boolean => {
252
+ if (install.scope === "user") return true;
253
+ if (cwd === null || install.projectPath === null) return false;
254
+ const relative = path.relative(path.resolve(install.projectPath), path.resolve(cwd));
255
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
256
+ };
257
+
258
+ const isCanonicalClaudePlugin = (root: string): boolean => {
259
+ try {
260
+ const manifest = JSON.parse(
261
+ readFileSync(path.join(root, ".claude-plugin", "plugin.json"), "utf8"),
262
+ ) as { name?: string; repository?: string | { url?: string } };
263
+ const repository =
264
+ typeof manifest.repository === "string" ? manifest.repository : manifest.repository?.url;
265
+ return (
266
+ manifest.name === "workit" &&
267
+ repository?.replace(/\.git$/, "").toLowerCase() === "https://github.com/brainervirus/workit"
268
+ );
269
+ } catch {
270
+ return false;
271
+ }
272
+ };
273
+
274
+ /**
275
+ * Workit installs recorded in Claude Code's plugin registry
276
+ * (`<config>/plugins/installed_plugins.json`, v2: `{plugins: {"name@market":
277
+ * [{installPath, version}]}}`). Only entries whose install directory carries
278
+ * the canonical Workit manifest count, whatever marketplace they came from.
279
+ * Only installs that apply here count: user scope always, project/local
280
+ * scope only when `cwd` is inside the recorded project (`cwd: null` keeps
281
+ * user scope only). A `--plugin-dir` pin is per-session and never recorded.
282
+ */
283
+ export const claudeWorkitInstalls = (
284
+ home: string,
285
+ env: NodeJS.ProcessEnv = process.env,
286
+ cwd: string | null = process.cwd(),
287
+ ): ClaudeWorkitInstall[] => {
288
+ const file = path.join(claudeConfigDir(home, env), "plugins", "installed_plugins.json");
289
+ let registry: unknown;
290
+ try {
291
+ registry = JSON.parse(readFileSync(file, "utf8"));
292
+ } catch {
293
+ return [];
294
+ }
295
+ const plugins =
296
+ registry && typeof registry === "object" && "plugins" in registry ? registry.plugins : null;
297
+ if (!plugins || typeof plugins !== "object") return [];
298
+ const found: ClaudeWorkitInstall[] = [];
299
+ for (const [id, entries] of Object.entries(plugins as Record<string, unknown>)) {
300
+ if (!id.startsWith("workit@") || !Array.isArray(entries)) continue;
301
+ for (const entry of entries as Array<{
302
+ installPath?: unknown;
303
+ version?: unknown;
304
+ scope?: unknown;
305
+ projectPath?: unknown;
306
+ }>) {
307
+ if (typeof entry?.installPath !== "string" || !isCanonicalClaudePlugin(entry.installPath))
308
+ continue;
309
+ // Older registries omit scope; Claude's default install scope is user.
310
+ const scope = entry.scope === undefined ? "user" : String(entry.scope);
311
+ if (!CLAUDE_SCOPES.has(scope)) continue; // e.g. managed: not ours to change
312
+ const install: ClaudeWorkitInstall = {
313
+ id,
314
+ installPath: entry.installPath,
315
+ version:
316
+ typeof entry.version === "string" && /^\d+\.\d+\.\d+/.test(entry.version)
317
+ ? entry.version
318
+ : null,
319
+ scope: scope as ClaudeInstallScope,
320
+ projectPath:
321
+ scope !== "user" && typeof entry.projectPath === "string" ? entry.projectPath : null,
322
+ };
323
+ if (appliesTo(install, cwd)) found.push(install);
324
+ }
325
+ }
326
+ return found;
327
+ };
328
+
329
+ /** A Workit install Claude Code loads for this user (any project). */
330
+ export const isClaudeWorkitInstalled = (
331
+ home: string,
332
+ env: NodeJS.ProcessEnv = process.env,
333
+ cwd: string | null = process.cwd(),
334
+ ): boolean => claudeWorkitInstalls(home, env, cwd).length > 0;
335
+
227
336
  export function installedHostApp(
228
337
  host: HostId,
229
338
  home: string,
@@ -297,6 +406,39 @@ export function planHostInstall(
297
406
  },
298
407
  ];
299
408
  }
409
+ if (host === "claude-code") {
410
+ if (!upgrading && isClaudeWorkitInstalled(home, env, null)) return [];
411
+ const base = hostCommand("claude", [], { home, cwd, env });
412
+ return [
413
+ nodePrerequisite({ home, cwd, env }),
414
+ {
415
+ ...base,
416
+ args: [...base.args, "plugin", "marketplace", "list", "--json"],
417
+ purpose: "Check whether the Workit Claude Code marketplace is already registered",
418
+ },
419
+ upgrading
420
+ ? {
421
+ ...base,
422
+ args: [...base.args, "plugin", "marketplace", "update", CLAUDE_MARKETPLACE_NAME],
423
+ purpose: "Refresh the Workit Claude Code marketplace",
424
+ }
425
+ : {
426
+ ...base,
427
+ args: [...base.args, "plugin", "marketplace", "add", CLAUDE_MARKETPLACE_REPO],
428
+ purpose: "Register the Workit Claude Code marketplace",
429
+ skipWhenOutputIncludes: `"name": "${CLAUDE_MARKETPLACE_NAME}"`,
430
+ },
431
+ {
432
+ ...base,
433
+ args: upgrading
434
+ ? [...base.args, "plugin", "update", CLAUDE_PLUGIN_ID]
435
+ : [...base.args, "plugin", "install", CLAUDE_PLUGIN_ID, "--scope", "user"],
436
+ purpose: upgrading
437
+ ? "Update the Workit Claude Code plugin"
438
+ : "Install the Workit Claude Code plugin",
439
+ },
440
+ ];
441
+ }
300
442
  if (host === "pi") {
301
443
  if (!upgrading && isPiWorkitInstalled(home, env)) return [];
302
444
  const base = hostCommand("pi", [], { home, cwd, env });
@@ -440,6 +582,7 @@ export function runHostCommand(
440
582
  const env: NodeJS.ProcessEnv = { ...process.env, ...context.env, HOME: home };
441
583
  env.CODEX_HOME = context.env?.CODEX_HOME ?? path.join(home, ".codex");
442
584
  env.PI_CODING_AGENT_DIR = context.env?.PI_CODING_AGENT_DIR ?? path.join(home, ".pi", "agent");
585
+ env.CLAUDE_CONFIG_DIR = context.env?.CLAUDE_CONFIG_DIR ?? path.join(home, ".claude");
443
586
  env.XDG_CONFIG_HOME = context.env?.XDG_CONFIG_HOME ?? path.join(home, ".config");
444
587
  return env;
445
588
  })(),
@@ -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
package/src/core/setup.ts CHANGED
@@ -52,6 +52,8 @@ import { writeFileExclusive } from "./safe-write";
52
52
  import {
53
53
  isCodexWorkitInstalled,
54
54
  isPiWorkitInstalled,
55
+ isClaudeWorkitInstalled,
56
+ claudeConfigDir,
55
57
  planHostInstall,
56
58
  runHostInstall,
57
59
  type HostCommandRunner,
@@ -385,25 +387,27 @@ export function buildSetupPreview(
385
387
  mutations.push({ type: "install-adapter", platform, path: paths.cursorPluginDir });
386
388
  mutations.push({ type: "register-platform", platform, path: paths.cursorSettings });
387
389
  mutations.push({ type: "register-platform", platform, path: paths.cursorMcp });
388
- } else if (platform === "codex" || platform === "pi") {
390
+ } else if (platform === "codex" || platform === "pi" || platform === "claude-code") {
389
391
  const commands = planHostInstall(platform, { home: paths.home, cwd: paths.cwd, env });
390
392
  if (commands.length > 0) {
391
393
  mutations.push({
392
394
  type: "install-host",
393
395
  platform,
394
396
  path:
395
- platform === "codex"
396
- ? path.join(
397
- env.CODEX_HOME ?? path.join(paths.home, ".codex"),
398
- "plugins",
399
- "cache",
400
- "workflow-toolkit",
401
- "workit",
402
- )
403
- : path.join(
404
- env.PI_CODING_AGENT_DIR ?? path.join(paths.home, ".pi", "agent"),
405
- "settings.json",
406
- ),
397
+ platform === "claude-code"
398
+ ? claudeInstallRecord(paths.home, env)
399
+ : platform === "codex"
400
+ ? path.join(
401
+ env.CODEX_HOME ?? path.join(paths.home, ".codex"),
402
+ "plugins",
403
+ "cache",
404
+ "workflow-toolkit",
405
+ "workit",
406
+ )
407
+ : path.join(
408
+ env.PI_CODING_AGENT_DIR ?? path.join(paths.home, ".pi", "agent"),
409
+ "settings.json",
410
+ ),
407
411
  commands,
408
412
  });
409
413
  }
@@ -446,6 +450,10 @@ export function buildSetupPreview(
446
450
  // Apply (Task 14: WZ-09, WZ-10, WZ-13-WZ-15; CA-08, CA-13, CA-14, CA-31).
447
451
  // ---------------------------------------------------------------------------
448
452
 
453
+ /** The Claude Code plugin registry a Workit install is recorded in. */
454
+ const claudeInstallRecord = (home: string, env: NodeJS.ProcessEnv): string =>
455
+ path.join(claudeConfigDir(home, env), "plugins", "installed_plugins.json");
456
+
449
457
  export type Platform = "opencode" | "cursor";
450
458
 
451
459
  export type SetupResultStatus = "Installed" | "Configured" | "Skipped" | "Failed";
@@ -1250,7 +1258,9 @@ export function applySetupPreview(
1250
1258
  res.env.PI_CODING_AGENT_DIR ?? path.join(res.home, ".pi", "agent"),
1251
1259
  "settings.json",
1252
1260
  )
1253
- : "";
1261
+ : mutation.platform === "claude-code"
1262
+ ? claudeInstallRecord(res.home, res.env)
1263
+ : "";
1254
1264
  if (mutation.path !== expectedPath) {
1255
1265
  entries.push({
1256
1266
  platform: mutation.platform,
@@ -1292,7 +1302,9 @@ export function applySetupPreview(
1292
1302
  ? isCodexWorkitInstalled(res.home, res.env)
1293
1303
  : mutation.platform === "pi"
1294
1304
  ? isPiWorkitInstalled(res.home, res.env)
1295
- : true;
1305
+ : mutation.platform === "claude-code"
1306
+ ? isClaudeWorkitInstalled(res.home, res.env, null)
1307
+ : true;
1296
1308
  if (!verified) {
1297
1309
  entries.push({
1298
1310
  platform: mutation.platform,
@@ -16,5 +16,9 @@ export const SUPPORT_MATRIX = {
16
16
  // an installed CLI drifts ahead so a fresh install never silently outruns
17
17
  // the qualification pin.
18
18
  codex: { cli: "0.153.4", desktopPackage: "26.901.20858", bundledCodexCli: "0.153.0-alpha.5" },
19
+ // Claude Code is a qualification host too: CI runs `claude plugin validate
20
+ // --strict` with exactly this CLI, and the hook output schema fixture
21
+ // (test/fixtures/claude-code-schemas) was transcribed from this binary.
22
+ claudeCode: { cli: "2.1.288" },
19
23
  os: ["ubuntu-latest", "macos-latest", "windows-latest"],
20
24
  } as const;
@@ -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 = {
@@ -1,7 +1,9 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import {
3
+ CHECK_OBSERVATION_PATH,
3
4
  POLICY_VERSION,
4
5
  canonicalJson,
6
+ checkObservationSchema,
5
7
  failure,
6
8
  decisionDigest,
7
9
  exportBundleSchema,
@@ -18,6 +20,7 @@ import {
18
20
  type Candidate,
19
21
  type Capability,
20
22
  type Caller,
23
+ type CheckObservation,
21
24
  type Constraint,
22
25
  type Decision,
23
26
  type Entry,
@@ -83,6 +86,9 @@ import {
83
86
  import {
84
87
  captureCandidate,
85
88
  checkPin,
89
+ signalFreshness,
90
+ treeFreshness,
91
+ type Freshness,
86
92
  evaluateClosure,
87
93
  evaluateEvidence,
88
94
  evaluateRequirements,
@@ -111,6 +117,13 @@ export type OperationContext = {
111
117
  workerId?: string | null;
112
118
  };
113
119
 
120
+ export type ObserveCheckRequest = {
121
+ taskId: string;
122
+ /** Omit to retry over concurrent writers (S2b); a value is strict compare-and-swap. */
123
+ expectedRevision?: string;
124
+ observation: CheckObservation;
125
+ };
126
+
114
127
  const provenance = (
115
128
  context: OperationContext,
116
129
  kind: "host_observed" | "agent_reported" = context.provenanceKind ?? "host_observed",
@@ -1210,10 +1223,11 @@ export class WorkitCore {
1210
1223
  // dispositions never auto-reopen.
1211
1224
  const withEntry = { ...current, candidates, evidence: [...current.evidence, entry] };
1212
1225
  const statuses = new Map(
1213
- evaluateEvidence(withEntry, currentCandidate.data).map((item) => [
1214
- item.evidenceId,
1215
- item.status,
1216
- ]),
1226
+ evaluateEvidence(
1227
+ withEntry,
1228
+ currentCandidate.data,
1229
+ treeFreshness(this.store.root),
1230
+ ).map((item) => [item.evidenceId, item.status]),
1217
1231
  );
1218
1232
  const verified = withEntry.evidence.some((item) =>
1219
1233
  findingVerificationPasses(finding.data.candidateId, {
@@ -1239,6 +1253,91 @@ export class WorkitCore {
1239
1253
  return success(changed.data.revision, null, changed.data.evidence.at(-1)!);
1240
1254
  }
1241
1255
 
1256
+ /**
1257
+ * Record a check the CLI itself ran and observed (`workit check`, design
1258
+ * §2.1 S9). Host-only, like observeDecision: the caller must be the workit
1259
+ * CLI (never a helper), and agents reach evidence only through
1260
+ * `evidence.record`, which stays agent_reported and cannot carry an
1261
+ * observation. The entry is `host_observed` by `workit_cli`, keyed to the
1262
+ * worktree tree in the observation, and the task lists the observation
1263
+ * path as critical so an older reader fails closed instead of dropping it.
1264
+ * Outside git (no tree key) it is bound to the current candidate instead.
1265
+ */
1266
+ observeCheck(request: ObserveCheckRequest): Result<Entry<Evidence>> {
1267
+ return this.retryOmittedRevisions(request, () => this.observeCheckOnce(request));
1268
+ }
1269
+
1270
+ private observeCheckOnce(request: ObserveCheckRequest): Result<Entry<Evidence>> {
1271
+ const root = this.contextRootError();
1272
+ if (!root.ok) return root;
1273
+ if (this.context.caller.host !== "workit_cli" || (this.context.workerId ?? null) !== null)
1274
+ return failure("permission_denied", "only the workit CLI records observed checks");
1275
+ const observation = checkObservationSchema.safeParse(request?.observation);
1276
+ if (!observation.success)
1277
+ return failure("invalid_input", "check observation is invalid", {
1278
+ fields: observation.error.issues.map((issue) => ({
1279
+ path: issue.path.join("."),
1280
+ reason: issue.message,
1281
+ })),
1282
+ });
1283
+ const task = this.store.readTask(request.taskId);
1284
+ if (!task.ok) return task;
1285
+ if (task.data.status === "closed")
1286
+ return failure("invalid_transition", "closed task cannot record evidence");
1287
+ const observed: CheckObservation = observation.data;
1288
+ let candidate: Candidate | null = null;
1289
+ if (observed.tree === null) {
1290
+ const captured = captureCandidate(
1291
+ this.store.root,
1292
+ task.data.intent.data.scope,
1293
+ environment(),
1294
+ );
1295
+ if (!captured.ok) return captured;
1296
+ candidate = captured.data;
1297
+ }
1298
+ const label = observed.name ?? observed.argv.join(" ");
1299
+ const data: Evidence = {
1300
+ kind: "check",
1301
+ claim: `workit check ${label}`.slice(0, 500),
1302
+ requirementIds: [],
1303
+ ...(candidate ? { beforeCandidateId: candidate.id, candidateId: candidate.id } : {}),
1304
+ result: observed.exitCode === 0 ? "passed" : "failed",
1305
+ summary: `${observed.configured ? "configured" : "ad-hoc"} check exited ${observed.exitCode}${observed.timedOut ? " (timed out)" : ""} in ${observed.durationMs} ms`,
1306
+ refs: [],
1307
+ exitCode: observed.exitCode,
1308
+ reviewContext: null,
1309
+ observation: observed,
1310
+ };
1311
+ const changed = this.store.mutateTask(
1312
+ task.data.id,
1313
+ request.expectedRevision ?? task.data.revision,
1314
+ (current, mutation) => {
1315
+ const candidates =
1316
+ candidate === null || current.candidates.some((item) => item.id === candidate.id)
1317
+ ? current.candidates
1318
+ : [...current.candidates, candidate];
1319
+ const critical = [...new Set([...(current.critical ?? []), CHECK_OBSERVATION_PATH])];
1320
+ return success(mutation.revision, null, {
1321
+ ...current,
1322
+ critical,
1323
+ candidates,
1324
+ evidence: [
1325
+ ...current.evidence,
1326
+ {
1327
+ id: newId(),
1328
+ recordedAt: mutation.now,
1329
+ provenance: provenance(this.context, "host_observed"),
1330
+ data,
1331
+ },
1332
+ ],
1333
+ });
1334
+ },
1335
+ trustedNow(this.context),
1336
+ );
1337
+ if (!changed.ok) return changed;
1338
+ return success(changed.data.revision, null, changed.data.evidence.at(-1)!);
1339
+ }
1340
+
1242
1341
  decision(request: unknown): Result<Entry<Decision>> {
1243
1342
  return this.recordDecision(request);
1244
1343
  }
@@ -1554,7 +1653,7 @@ export class WorkitCore {
1554
1653
  if (input.disposition === "fixed") {
1555
1654
  const current = captureCandidate(this.store.root, task.data.intent.data.scope, environment());
1556
1655
  if (!current.ok) return current;
1557
- const evaluations = evaluateEvidence(task.data, current.data);
1656
+ const evaluations = evaluateEvidence(task.data, current.data, treeFreshness(this.store.root));
1558
1657
  const verified = evidence.some((entry) => {
1559
1658
  const evaluation = evaluations.find((item) => item.evidenceId === entry.id);
1560
1659
  return (
@@ -2456,6 +2555,7 @@ export class WorkitCore {
2456
2555
  if (!current.ok) return current;
2457
2556
  const evaluationWorkspace =
2458
2557
  task.status === "closed" ? { ...workspace.data, writer: null } : workspace.data;
2558
+ const tree = treeFreshness(this.store.root);
2459
2559
  const requirements = evaluateRequirements(
2460
2560
  task,
2461
2561
  evaluationWorkspace,
@@ -2463,6 +2563,7 @@ export class WorkitCore {
2463
2563
  current.data,
2464
2564
  this.store.root,
2465
2565
  this.context.caller,
2566
+ tree,
2466
2567
  );
2467
2568
  return success(task.revision, workspace.data.revision, {
2468
2569
  id: task.id,
@@ -2487,19 +2588,24 @@ export class WorkitCore {
2487
2588
  * judged against the last recorded candidate, and live staleness is
2488
2589
  * detected by evidence, close, resume, and full inspection, which capture.
2489
2590
  */
2490
- compactContext(taskId: string): Result<string> {
2591
+ compactContext(taskId: string, freshness?: Freshness): Result<string> {
2491
2592
  const root = this.contextRootError();
2492
2593
  if (!root.ok) return root;
2493
2594
  const task = this.store.readTask(taskId);
2494
2595
  if (!task.ok) return task;
2495
2596
  const helper = this.helperTaskGuard(task.data, true);
2496
2597
  if (!helper.ok) return helper;
2497
- const view = this.view(task.data, false);
2598
+ // Per-turn path: the cheap worktree signal, never the object-writing tree hash.
2599
+ const view = this.view(task.data, false, freshness ?? signalFreshness(this.store.root));
2498
2600
  if (!view.ok) return view;
2499
2601
  return success(view.revision, view.workspaceRevision, compactTaskContext(view.data));
2500
2602
  }
2501
2603
 
2502
- private view(task: TaskRecord, capture = true): Result<TaskView> {
2604
+ private view(
2605
+ task: TaskRecord,
2606
+ capture = true,
2607
+ freshness: Freshness = treeFreshness(this.store.root),
2608
+ ): Result<TaskView> {
2503
2609
  const workspace = this.store.readWorkspace();
2504
2610
  if (!workspace.ok) return workspace;
2505
2611
  if (!workspace.data) return failure("not_found", "workspace not found");
@@ -2512,6 +2618,7 @@ export class WorkitCore {
2512
2618
  if (!current.ok) return current;
2513
2619
  const evaluationWorkspace =
2514
2620
  task.status === "closed" ? { ...workspace.data, writer: null } : workspace.data;
2621
+ const tree = freshness;
2515
2622
  const requirements = evaluateRequirements(
2516
2623
  task,
2517
2624
  evaluationWorkspace,
@@ -2519,13 +2626,14 @@ export class WorkitCore {
2519
2626
  current.data,
2520
2627
  this.store.root,
2521
2628
  this.context.caller,
2629
+ tree,
2522
2630
  );
2523
2631
  return success(task.revision, workspace.data.revision, {
2524
2632
  task,
2525
2633
  workspace: evaluationWorkspace,
2526
2634
  capabilities: this.context.capabilities,
2527
2635
  requirements,
2528
- evidence: evaluateEvidence(task, current.data),
2636
+ evidence: evaluateEvidence(task, current.data, tree),
2529
2637
  });
2530
2638
  }
2531
2639
 
@@ -2797,6 +2905,7 @@ export class WorkitCore {
2797
2905
  withCandidate.id === task.data.candidates.at(-1)?.id
2798
2906
  ? task.data
2799
2907
  : { ...task.data, candidates: [...task.data.candidates, withCandidate] };
2908
+ const tree = treeFreshness(this.store.root);
2800
2909
  const requirements = evaluateRequirements(
2801
2910
  taskForView,
2802
2911
  workspace.data,
@@ -2804,13 +2913,14 @@ export class WorkitCore {
2804
2913
  withCandidate,
2805
2914
  this.store.root,
2806
2915
  this.context.caller,
2916
+ tree,
2807
2917
  );
2808
2918
  const view = {
2809
2919
  task: taskForView,
2810
2920
  workspace: workspace.data,
2811
2921
  capabilities: this.context.capabilities,
2812
2922
  requirements,
2813
- evidence: evaluateEvidence(taskForView, withCandidate),
2923
+ evidence: evaluateEvidence(taskForView, withCandidate, tree),
2814
2924
  } satisfies TaskView;
2815
2925
  const closure = evaluateClosure(input.outcome, view);
2816
2926
  if (!closure.ok) return closure;