@brainervirus/workit-core 2.6.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.
- package/package.json +1 -1
- package/skills/workit-behavioral-tdd/SKILL.md +15 -9
- package/skills/workit-debug/SKILL.md +2 -1
- package/src/check-config.ts +294 -0
- package/src/checks.ts +480 -0
- package/src/core/methods.ts +5 -2
- package/src/core/task-context.ts +6 -2
- package/src/core/task-contract.ts +70 -12
- package/src/core/task-engine.ts +120 -10
- package/src/core/task-evaluation.ts +176 -1
- package/src/git/rev.ts +50 -0
- package/src/hooks/context.ts +44 -5
package/src/core/task-engine.ts
CHANGED
|
@@ -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(
|
|
1214
|
-
|
|
1215
|
-
|
|
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
|
-
|
|
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(
|
|
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;
|
package/src/hooks/context.ts
CHANGED
|
@@ -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 = {
|
|
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 (
|
|
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
|
|
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, {
|
|
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
|
}
|