@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.
@@ -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;
@@ -14,6 +14,8 @@ import {
14
14
  type Capability,
15
15
  type Caller,
16
16
  type Decision,
17
+ type Entry,
18
+ type Evidence,
17
19
  type EvidenceEvaluation,
18
20
  type Outcome,
19
21
  type Requirement,
@@ -24,6 +26,16 @@ import {
24
26
  type WorkspaceRecord,
25
27
  } from "./task-contract";
26
28
  import { verifyDecisionContentAtRoot } from "./authority";
29
+ import {
30
+ CHECKS_FILE,
31
+ checkCommand,
32
+ gateCheckNames,
33
+ loadCheckConfig,
34
+ matchesNamedCheck,
35
+ type CheckConfig,
36
+ } from "../check-config";
37
+ import { worktreeSignal, worktreeTree } from "../git/rev";
38
+ import { runtimeVersion } from "./task-store";
27
39
 
28
40
  export type CandidateEnvironment =
29
41
  | readonly (string | { name: string; value?: string | null })[]
@@ -318,9 +330,49 @@ const evidenceScopes = (task: TaskRecord, ids: string[]): Scope[] =>
318
330
  .filter((requirement) => ids.includes(requirement.id))
319
331
  .map((requirement) => requirement.scope);
320
332
 
333
+ /**
334
+ * How observed checks are judged fresh (§2.2). `tree`: the exact worktree tree
335
+ * key (worktreeTree hashes changed files into the object store, so it is only
336
+ * for paths that decide: close, inspect, evidence). `signal`: the cheap
337
+ * stat-cached worktreeSignal for per-turn context, which writes nothing.
338
+ * `current` is computed at most once and is null outside git.
339
+ */
340
+ export type Freshness = { mode: "tree" | "signal"; current: () => string | null };
341
+
342
+ const memo = (compute: () => string | null): (() => string | null) => {
343
+ let value: string | null | undefined;
344
+ return () => {
345
+ if (value === undefined) value = compute();
346
+ return value;
347
+ };
348
+ };
349
+ export const treeFreshness = (root: string): Freshness => ({
350
+ mode: "tree",
351
+ current: memo(() => worktreeTree(root)?.key ?? null),
352
+ });
353
+ export const signalFreshness = (root: string): Freshness => ({
354
+ mode: "signal",
355
+ current: memo(() => worktreeSignal(root)),
356
+ });
357
+
358
+ /** Evidence the workit CLI itself observed (`workit check`), as opposed to an agent's report. */
359
+ export const isObservedCheck = (entry: Entry<Evidence>): boolean =>
360
+ entry.data.kind === "check" &&
361
+ entry.data.observation?.observer === "workit_cli" &&
362
+ entry.provenance?.kind === "host_observed" &&
363
+ entry.provenance.host === "workit_cli";
364
+
365
+ /**
366
+ * Evidence status against the current state. Observed checks keyed to a tree
367
+ * are fresh while the worktree is unchanged (§2.2), judged by `freshness`
368
+ * (exact tree key, or the cheap signal on per-turn paths); a check that
369
+ * changed the worktree itself is stale (fail safe), and a closed task keeps
370
+ * the recorded status. Without `freshness` they read stale.
371
+ */
321
372
  export function evaluateEvidence(
322
373
  task: TaskRecord,
323
374
  candidate: Candidate | null,
375
+ freshness?: Freshness,
324
376
  ): EvidenceEvaluation[] {
325
377
  const current = candidate ?? task.candidates.at(-1) ?? null;
326
378
  return task.evidence.map((entry) => {
@@ -331,6 +383,28 @@ export function evaluateEvidence(
331
383
  status: "failed" as const,
332
384
  reason: "failed evidence remains historical",
333
385
  };
386
+ const observation = isObservedCheck(entry) ? evidence.observation : undefined;
387
+ if (observation && observation.tree !== null) {
388
+ if (task.status === "closed")
389
+ return { evidenceId: entry.id, status: evidence.result, reason: "recorded before close" };
390
+ if (observation.modifiedWorktree)
391
+ return {
392
+ evidenceId: entry.id,
393
+ status: "stale" as const,
394
+ reason: "the check changed the worktree while it ran; ignore or clean its outputs",
395
+ };
396
+ const recorded = freshness?.mode === "signal" ? observation.signal : observation.tree;
397
+ if (recorded !== null && freshness && recorded === freshness.current())
398
+ return { evidenceId: entry.id, status: evidence.result, reason: "worktree is unchanged" };
399
+ return {
400
+ evidenceId: entry.id,
401
+ status: "stale" as const,
402
+ reason:
403
+ freshness?.mode === "signal"
404
+ ? "the worktree may have changed since the check ran (quick signal; inspect re-checks exactly)"
405
+ : "the worktree changed since the check ran",
406
+ };
407
+ }
334
408
  if (evidence.kind === "check" || evidence.kind === "review") {
335
409
  if (!evidence.beforeCandidateId || !evidence.candidateId)
336
410
  return {
@@ -499,6 +573,82 @@ const expectedKinds = (dimension: Requirement["dimension"], ruleId?: string): st
499
573
  return "worker report";
500
574
  };
501
575
 
576
+ /** Close-time testing/verification gates accept only CLI-observed checks (D5, D14). */
577
+ const observedGate = (requirement: Requirement): boolean =>
578
+ (requirement.dimension === "testing" || requirement.dimension === "verification") &&
579
+ requirement.before === "close";
580
+
581
+ type GateResult = { satisfied: string[]; considered: string[]; reason: string };
582
+
583
+ /**
584
+ * A close gate over CLI-observed checks. Only a fresh passing run of one of
585
+ * the gate's named checks whose argv is still its configured command
586
+ * satisfies it; ad-hoc runs never do, and with no named check for the gate
587
+ * the reason says how to configure one. Agent-reported checks are notes and
588
+ * never count. A broken check config fails closed.
589
+ */
590
+ function evaluateObservedGate(
591
+ task: TaskRecord,
592
+ requirement: Requirement,
593
+ statuses: Map<string, EvidenceEvaluation["status"]>,
594
+ config: CheckConfig,
595
+ ): GateResult {
596
+ const names = gateCheckNames(config, requirement.dimension);
597
+ const observed = task.evidence.filter(isObservedCheck);
598
+ const fresh = observed.filter(
599
+ (entry) => statuses.get(entry.id) === "passed" && entry.data.observation?.exitCode === 0,
600
+ );
601
+ const named = (entry: Entry<Evidence>): boolean => {
602
+ const observation = entry.data.observation!;
603
+ return (
604
+ observation.configured &&
605
+ !observation.shell &&
606
+ names.includes(observation.name ?? "") &&
607
+ matchesNamedCheck(config, observation.name, observation.argv, observation.cwd)
608
+ );
609
+ };
610
+ const considered = observed.map((entry) => entry.id);
611
+ const run = checkCommand(names[0] ?? "<name>", runtimeVersion());
612
+ if (config.error)
613
+ return {
614
+ satisfied: [],
615
+ considered,
616
+ reason: `check config is invalid (${config.error}); fix it, then run ${run}`,
617
+ };
618
+ if (!names.length)
619
+ return {
620
+ satisfied: [],
621
+ considered,
622
+ reason:
623
+ requirement.dimension === "testing" && config.checks.length
624
+ ? `no \`test\` check is configured; add one (or gates.testing) to ${CHECKS_FILE}, then run ${checkCommand("test", runtimeVersion())}`
625
+ : `no checks are configured or detected; add ${CHECKS_FILE} (committed, so it shows in the diff), e.g. {"checks":{"test":"<your test command>"}}, then run ${checkCommand("test", runtimeVersion())}`,
626
+ };
627
+ const satisfied = fresh.filter(named);
628
+ if (satisfied.length)
629
+ return {
630
+ satisfied: satisfied.map((entry) => entry.id),
631
+ considered,
632
+ reason: `a fresh passing configured check (${names.join("|")}) was observed by workit check`,
633
+ };
634
+ const reported = task.evidence.some(
635
+ (entry) =>
636
+ entry.data.kind === "check" &&
637
+ !isObservedCheck(entry) &&
638
+ entry.data.requirementIds.includes(requirement.id),
639
+ );
640
+ const reason = fresh.length
641
+ ? `ad-hoc checks do not satisfy a configured gate; run ${run}`
642
+ : observed.some((entry) => statuses.get(entry.id) === "stale")
643
+ ? `observed checks are stale (the worktree changed); run ${run}`
644
+ : observed.some((entry) => statuses.get(entry.id) === "failed")
645
+ ? `the observed check failed; fix it and run ${run}`
646
+ : reported
647
+ ? `agent-reported checks are notes, not evidence; run ${run}`
648
+ : `no CLI-observed check (needs kind:check observed by workit check); run ${run}`;
649
+ return { satisfied: [], considered, reason };
650
+ }
651
+
502
652
  export function evaluateRequirements(
503
653
  task: TaskRecord,
504
654
  workspace: WorkspaceRecord,
@@ -506,8 +656,12 @@ export function evaluateRequirements(
506
656
  candidate: Candidate | null,
507
657
  checkoutRoot: string,
508
658
  _caller?: Caller,
659
+ freshness: Freshness = treeFreshness(checkoutRoot),
509
660
  ) {
510
- const evidence = evaluateEvidence(task, candidate);
661
+ const evidence = evaluateEvidence(task, candidate, freshness);
662
+ const statuses = new Map(evidence.map((item) => [item.evidenceId, item.status]));
663
+ let checkConfig: CheckConfig | undefined;
664
+ const configFor = (): CheckConfig => (checkConfig ??= loadCheckConfig(checkoutRoot));
511
665
  if (task.policy && task.policy.policyVersion !== POLICY_VERSION)
512
666
  return task.policy.requirements.map((requirement) => ({
513
667
  requirementId: requirement.id,
@@ -517,10 +671,23 @@ export function evaluateRequirements(
517
671
  reason: "stored policy version is unsupported; reassessment is required",
518
672
  }));
519
673
  return (task.policy?.requirements ?? []).map((requirement) => {
674
+ const gate = observedGate(requirement)
675
+ ? evaluateObservedGate(task, requirement, statuses, configFor())
676
+ : null;
677
+ if (gate?.satisfied.length)
678
+ return {
679
+ requirementId: requirement.id,
680
+ status: "satisfied" as const,
681
+ evidenceIds: gate.satisfied,
682
+ decisionIds: [],
683
+ reason: gate.reason,
684
+ };
520
685
  const related = task.evidence
521
686
  .map((entry, index) => ({ entry, evaluation: evidence[index] }))
522
687
  .filter(({ entry }) => entry.data.requirementIds.includes(requirement.id));
523
688
  const passed = related.filter(({ entry, evaluation }) => {
689
+ // A gated requirement was decided above: reported checks are only notes.
690
+ if (gate) return false;
524
691
  if (
525
692
  !evidenceMatchesRequirement(
526
693
  evaluation.status,
@@ -659,6 +826,14 @@ export function evaluateRequirements(
659
826
  capability.assurance === "unavailable" &&
660
827
  (capability.name === requirement.dimension || capability.surface === requirement.dimension),
661
828
  );
829
+ if (gate)
830
+ return {
831
+ requirementId: requirement.id,
832
+ status: unavailable ? ("unavailable" as const) : ("unsatisfied" as const),
833
+ evidenceIds: [...new Set([...gate.considered, ...related.map(({ entry }) => entry.id)])],
834
+ decisionIds: [],
835
+ reason: unavailable ? "required capability is unavailable" : gate.reason,
836
+ };
662
837
  if (!unavailable && related.length > 0 && !passed.length)
663
838
  return {
664
839
  requirementId: requirement.id,
package/src/git/rev.ts CHANGED
@@ -296,6 +296,56 @@ export function worktreeTree(
296
296
  }
297
297
  }
298
298
 
299
+ /**
300
+ * A cheap worktree freshness signal for per-turn paths: sha256 over HEAD and,
301
+ * for every entry `git status` reports (untracked files listed one by one),
302
+ * its path, lstat mode, size and mtime. A directory entry (a submodule or an
303
+ * untracked nested repository) also contributes its own HEAD, so moving a
304
+ * submodule or committing in a nested repo changes the signal. Git answers
305
+ * from its stat cache and, with GIT_OPTIONAL_LOCKS=0, never refreshes the
306
+ * index, and nothing is written to the object store (unlike worktreeTree).
307
+ * Equal signals mean "probably unchanged": an edit to an already-dirty file
308
+ * that keeps its mode, size and mtime is not seen, so gates that decide
309
+ * (close, inspect) use worktreeTree. Null outside a repository.
310
+ */
311
+ export function worktreeSignal(cwd: string, options: { timeoutMs?: number } = {}): string | null {
312
+ const top = line(git(cwd, ["rev-parse", "--show-toplevel"]));
313
+ if (!top) return null;
314
+ const status = git(
315
+ top,
316
+ [
317
+ "status",
318
+ "--porcelain=v1",
319
+ "-z",
320
+ "--untracked-files=all",
321
+ "--no-renames",
322
+ "--ignore-submodules=dirty",
323
+ ],
324
+ {
325
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
326
+ timeoutMs: options.timeoutMs ?? GIT_TIMEOUTS.worktree,
327
+ },
328
+ );
329
+ if (!status.ok) return null;
330
+ const hash = createHash("sha256");
331
+ hash.update(`${headSha(top) ?? "unborn"}\0`);
332
+ for (const entry of status.stdout.split("\0")) {
333
+ if (entry.length < 4) continue;
334
+ const file = entry.slice(3);
335
+ const absolute = path.join(top, file);
336
+ let stat = "-";
337
+ try {
338
+ const info = fs.lstatSync(absolute, { bigint: true });
339
+ stat = `${info.mode.toString(8)}:${info.size}:${info.mtimeNs}`;
340
+ if (info.isDirectory()) stat += `:${headSha(absolute) ?? "-"}`;
341
+ } catch {
342
+ // Deleted: the status code alone records it.
343
+ }
344
+ hash.update(`${entry.slice(0, 2)}\0${file}\0${stat}\0`);
345
+ }
346
+ return `sig:${hash.digest("hex")}`;
347
+ }
348
+
299
349
  /** The best common ancestor of two revisions, or null. */
300
350
  export function mergeBase(cwd: string, a: string, b: string): string | null {
301
351
  if (!safeArg(a) || !safeArg(b)) return null;
@@ -3,6 +3,8 @@
3
3
  import path from "node:path";
4
4
  import { invariantBootstrap } from "../core/methods";
5
5
  import { canonicalJson } from "../core/task-contract";
6
+ import { isObservedCheck, type Freshness } from "../core/task-evaluation";
7
+ import { worktreeSignal } from "../git/rev";
6
8
  import { WorkitCore, type OperationContext } from "../core/task-engine";
7
9
  import { fileSignature, racySignature, TaskStore, type TaskIndexEntry } from "../core/task-store";
8
10
  import { capabilitiesFor, type HostDescriptor } from "./descriptor";
@@ -80,8 +82,30 @@ const filesKey = (files: string[]): string | null => {
80
82
  : signatures.join("\0");
81
83
  };
82
84
 
83
- type CachedContext = { key: string; files: string[]; filesKey: string | null; value: string };
85
+ type CachedContext = {
86
+ key: string;
87
+ files: string[];
88
+ filesKey: string | null;
89
+ /** The worktree signal the value was judged against; null when no observed check depends on it. */
90
+ signal: string | null;
91
+ value: string;
92
+ };
84
93
  const CACHE_LIMIT = 64;
94
+
95
+ /** How long a computed worktree signal is reused, so cache hits do not run `git status` each turn. */
96
+ export const SIGNAL_TTL_MS = 1_500;
97
+ const signals = new Map<string, { at: number; value: string | null }>();
98
+
99
+ /** worktreeSignal for `root`, reused for SIGNAL_TTL_MS. */
100
+ const recentSignal = (root: string): string | null => {
101
+ const now = Date.now();
102
+ const cached = signals.get(root);
103
+ if (cached && now - cached.at < SIGNAL_TTL_MS) return cached.value;
104
+ const value = worktreeSignal(root);
105
+ signals.set(root, { at: now, value });
106
+ if (signals.size > CACHE_LIMIT) signals.delete(signals.keys().next().value!);
107
+ return value;
108
+ };
85
109
  const cache = new Map<string, CachedContext>();
86
110
 
87
111
  /**
@@ -123,20 +147,35 @@ function entryCompactContext(
123
147
  capabilities: context.capabilities,
124
148
  });
125
149
  const hit = cache.get(slot);
126
- if (hit && hit.key === key && hit.filesKey !== null && hit.filesKey === filesKey(hit.files))
150
+ if (
151
+ hit &&
152
+ hit.key === key &&
153
+ hit.filesKey !== null &&
154
+ hit.filesKey === filesKey(hit.files) &&
155
+ // Observed-check freshness changes with the worktree, not the revision.
156
+ (hit.signal === null || hit.signal === recentSignal(store.root))
157
+ )
127
158
  return hit.value;
128
159
  const task = store.readTask(entry.id);
129
160
  if (!task.ok) return null;
161
+ const freshness: Freshness = { mode: "signal", current: () => recentSignal(store.root) };
162
+ const observed = task.data.evidence.some(isObservedCheck);
130
163
  const files = task.data.decisions.flatMap((decision) =>
131
164
  decision.data.binding.contentRefs.flatMap((ref) =>
132
165
  ref.kind === "file" ? [path.resolve(store.root, ref.path)] : [],
133
166
  ),
134
167
  );
135
- const observed = filesKey(files);
136
- const compact = new WorkitCore(store, context).compactContext(entry.id);
168
+ const observedFiles = filesKey(files);
169
+ const compact = new WorkitCore(store, context).compactContext(entry.id, freshness);
137
170
  if (!compact.ok) return null;
138
171
  cache.delete(slot);
139
- cache.set(slot, { key, files, filesKey: observed, value: compact.data });
172
+ cache.set(slot, {
173
+ key,
174
+ files,
175
+ filesKey: observedFiles,
176
+ signal: observed ? freshness.current() : null,
177
+ value: compact.data,
178
+ });
140
179
  if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value!);
141
180
  return compact.data;
142
181
  }