@brainervirus/workit-core 1.0.11 → 1.0.12

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.
@@ -377,6 +377,52 @@ export function evaluateEvidence(
377
377
  }
378
378
 
379
379
  export const scopeCovers = contractScopeCovers;
380
+
381
+ /** One shared pin check for evidence and finding records: an unpinned
382
+ * assignment accepts anything in scope; a pinned one accepts the pin on
383
+ * either candidate slot. Absent IDs behave as null. */
384
+ export const checkPin = (
385
+ assignment: { candidateId?: string | null },
386
+ beforeCandidateId: string | null | undefined,
387
+ candidateId: string | null | undefined,
388
+ ): boolean => {
389
+ const pin = assignment.candidateId ?? null;
390
+ if (pin === null) return true;
391
+ return (beforeCandidateId ?? null) === pin || (candidateId ?? null) === pin;
392
+ };
393
+
394
+ /** Resolve effective evidence binding: explicit IDs win; otherwise a pinned
395
+ * worker defaults to its pin and everyone else to the current candidate
396
+ * (checks and reviews) or null. Callers validate and pin-check the resolved
397
+ * values — never the caller's possibly-empty fields. */
398
+ export const resolveBinding = (
399
+ evidence: {
400
+ beforeCandidateId?: string | null;
401
+ candidateId?: string | null;
402
+ kind: string;
403
+ },
404
+ pin: string | null | undefined,
405
+ currentCandidateId: string | null,
406
+ ): { beforeCandidateId: string | null; candidateId: string | null } => {
407
+ const bound = evidence.kind === "check" || evidence.kind === "review";
408
+ const fallback = bound ? (pin ?? currentCandidateId) : null;
409
+ return {
410
+ beforeCandidateId: evidence.beforeCandidateId ?? fallback,
411
+ candidateId: evidence.candidateId ?? fallback,
412
+ };
413
+ };
414
+
415
+ /** One shared verification predicate for fixed findings: a passed
416
+ * check/review counts when the finding is unscoped or the evidence is
417
+ * bound to the finding's candidate. Used by the auto-reopen scan, the
418
+ * resolve gate, and closure — one predicate, one error code each. */
419
+ export const findingVerificationPasses = (
420
+ findingCandidateId: string | null | undefined,
421
+ evidence: { kind: string; candidateId: string | null | undefined; status: string },
422
+ ): boolean =>
423
+ evidence.status === "passed" &&
424
+ (evidence.kind === "check" || evidence.kind === "review") &&
425
+ ((findingCandidateId ?? null) === null || evidence.candidateId === findingCandidateId);
380
426
  const applicableDecision = (
381
427
  task: TaskRecord,
382
428
  workspace: WorkspaceRecord,
@@ -430,9 +476,11 @@ const evidenceMatchesRequirement = (
430
476
  kind: EvidenceEvaluation["status"],
431
477
  evidenceKind: string,
432
478
  dimension: Requirement["dimension"],
479
+ ruleId?: string,
433
480
  ): boolean => {
434
481
  if (kind !== "passed") return false;
435
- if (dimension === "testing" || dimension === "verification") return evidenceKind === "check";
482
+ if (dimension === "testing" || dimension === "verification")
483
+ return evidenceKind === "check" || (ruleId === "pre-pr-cleanup" && evidenceKind === "artifact");
436
484
  if (dimension === "review") return evidenceKind === "review";
437
485
  if (dimension === "investigation" || dimension === "challenge")
438
486
  return evidenceKind === "investigation";
@@ -440,6 +488,19 @@ const evidenceMatchesRequirement = (
440
488
  return false;
441
489
  };
442
490
 
491
+ /** Human-readable evidence kinds each requirement dimension accepts, used
492
+ * so kind mismatches fail with the expected kind instead of silence. */
493
+ const expectedKinds = (dimension: Requirement["dimension"], ruleId?: string): string => {
494
+ if (dimension === "testing") return "check";
495
+ if (dimension === "verification")
496
+ return ruleId === "pre-pr-cleanup" ? "check or artifact" : "check";
497
+ if (dimension === "review") return "review";
498
+ if (dimension === "investigation" || dimension === "challenge") return "investigation";
499
+ if (dimension === "artifacts" || dimension === "continuity") return "artifact";
500
+ if (dimension === "decisions") return "decision";
501
+ return "worker report";
502
+ };
503
+
443
504
  export function evaluateRequirements(
444
505
  task: TaskRecord,
445
506
  workspace: WorkspaceRecord,
@@ -462,7 +523,14 @@ export function evaluateRequirements(
462
523
  .map((entry, index) => ({ entry, evaluation: evidence[index] }))
463
524
  .filter(({ entry }) => entry.data.requirementIds.includes(requirement.id));
464
525
  const passed = related.filter(({ entry, evaluation }) => {
465
- if (!evidenceMatchesRequirement(evaluation.status, entry.data.kind, requirement.dimension))
526
+ if (
527
+ !evidenceMatchesRequirement(
528
+ evaluation.status,
529
+ entry.data.kind,
530
+ requirement.dimension,
531
+ requirement.ruleId,
532
+ )
533
+ )
466
534
  return false;
467
535
  if (
468
536
  (entry.data.kind === "check" || entry.data.kind === "review") &&
@@ -474,10 +542,15 @@ export function evaluateRequirements(
474
542
  if (requirement.dimension !== "review") return true;
475
543
  const reviewSession = sessionFromRef(entry.data.reviewContext);
476
544
  const sameImplementation = sameSession(reviewSession, task.intent.provenance.session);
477
- const sameEvidenceSession = task.evidence.some(
478
- (other) =>
545
+ // Reviewer-session uniqueness is per candidate, not per task: the
546
+ // same session may re-verify after the candidate changes, but never
547
+ // manufacture two reviews for one candidate.
548
+ const sameEvidenceSession = related.some(
549
+ ({ entry: other }) =>
479
550
  other.id !== entry.id &&
480
551
  other.data.kind === "review" &&
552
+ other.data.candidateId !== null &&
553
+ other.data.candidateId === entry.data.candidateId &&
481
554
  sameSession(reviewSession, other.provenance.session),
482
555
  );
483
556
  return (
@@ -488,19 +561,34 @@ export function evaluateRequirements(
488
561
  );
489
562
  });
490
563
  if (passed.length) {
491
- // RED-first: a testing requirement is only satisfied by a pass that was
492
- // preceded by a recorded failure on the same requirement. A GREEN with
493
- // no RED is indistinguishable from code-then-tests.
564
+ // RED-first, scoped to fresh claims: a testing pass bound to the
565
+ // current candidate needs a preceding recorded failure, unless a
566
+ // passing baseline already exists from earlier work — a check that
567
+ // passed (when recorded) against a different candidate. Stale passes
568
+ // count as baselines: green on an older tree is exactly what "prior
569
+ // passing baseline" means. A GREEN with neither is indistinguishable
570
+ // from code-then-tests.
571
+ const currentId = candidate?.id ?? null;
494
572
  const withRed =
495
573
  requirement.dimension === "testing"
496
- ? passed.filter(({ entry }) =>
497
- related.some(
498
- (other) =>
499
- other.entry.data.kind === "check" &&
500
- other.evaluation.status === "failed" &&
501
- other.entry.data.requirementIds.includes(requirement.id) &&
502
- other.entry.recordedAt <= entry.recordedAt,
503
- ),
574
+ ? passed.filter(
575
+ ({ entry }) =>
576
+ related.some(
577
+ (other) =>
578
+ other.entry.data.kind === "check" &&
579
+ other.evaluation.status === "failed" &&
580
+ other.entry.data.requirementIds.includes(requirement.id) &&
581
+ other.entry.recordedAt <= entry.recordedAt,
582
+ ) ||
583
+ related.some(
584
+ (baseline) =>
585
+ baseline.entry.id !== entry.id &&
586
+ baseline.entry.data.kind === "check" &&
587
+ baseline.entry.data.result === "passed" &&
588
+ baseline.entry.data.candidateId !== null &&
589
+ currentId !== null &&
590
+ baseline.entry.data.candidateId !== currentId,
591
+ ),
504
592
  )
505
593
  : passed;
506
594
  if (withRed.length)
@@ -573,6 +661,14 @@ export function evaluateRequirements(
573
661
  capability.assurance === "unavailable" &&
574
662
  (capability.name === requirement.dimension || capability.surface === requirement.dimension),
575
663
  );
664
+ if (!unavailable && related.length > 0 && !passed.length)
665
+ return {
666
+ requirementId: requirement.id,
667
+ status: "unsatisfied" as const,
668
+ evidenceIds: related.map(({ entry }) => entry.id),
669
+ decisionIds: [],
670
+ reason: `no passing evidence (needs kind:${expectedKinds(requirement.dimension, requirement.ruleId)})`,
671
+ };
576
672
  return {
577
673
  requirementId: requirement.id,
578
674
  status: unavailable ? ("unavailable" as const) : ("unsatisfied" as const),
@@ -614,10 +710,12 @@ export function evaluateClosure(
614
710
  if (entry.data.disposition === "fixed") {
615
711
  const verified = entry.data.resolution?.evidenceIds.some((id) => {
616
712
  const evidence = view.task.evidence.find((item) => item.id === id);
617
- return (
618
- evidenceById.get(id) === "passed" &&
619
- (evidence?.data.kind === "check" || evidence?.data.kind === "review")
620
- );
713
+ if (!evidence) return false;
714
+ return findingVerificationPasses(entry.data.candidateId, {
715
+ kind: evidence.data.kind,
716
+ candidateId: evidence.data.candidateId,
717
+ status: evidenceById.get(id) ?? "missing",
718
+ });
621
719
  });
622
720
  if (!verified)
623
721
  return failure("requirements_unsatisfied", "fixed findings require current verification");
@@ -652,9 +750,16 @@ export function evaluateClosure(
652
750
  }
653
751
  }
654
752
  }
655
- const blocking = evaluations.filter(
656
- (item) => item.status === "unsatisfied" || item.status === "unavailable",
657
- );
753
+ // Close is gated only by close-timed requirements. Dependent-action gates
754
+ // (e.g. deslop before hosting.pull_request) block their named action at
755
+ // reservation time, never the close itself.
756
+ const blocking = evaluations.filter((item) => {
757
+ if (item.status !== "unsatisfied" && item.status !== "unavailable") return false;
758
+ const requirement = view.task.policy?.requirements.find(
759
+ (candidate) => candidate.id === item.requirementId,
760
+ );
761
+ return !requirement || requirement.before === "close";
762
+ });
658
763
  if (requestedOutcome !== "stopped" && blocking.length)
659
764
  return failure("requirements_unsatisfied", "applicable requirements are unsatisfied", {
660
765
  requirementIds: blocking.map((item) => item.requirementId),
@@ -41,8 +41,7 @@ export type WorkspaceMutation = (
41
41
  ) => Result<WorkspaceRecord>;
42
42
  export type CoupledMutation = {
43
43
  taskId: Id;
44
- expectedTaskRevision?: Revision;
45
- expectedRevision?: Revision;
44
+ expectedRevision: Revision;
46
45
  expectedWorkspaceRevision: Revision;
47
46
  now?: Utc;
48
47
  task: TaskMutation;
@@ -514,10 +513,9 @@ export class TaskStore {
514
513
  const workspace = this.readWorkspace();
515
514
  if (!workspace.ok) return workspace;
516
515
  if (!workspace.data) return failure("not_found", "workspace not found");
517
- const expectedTaskRevision = input.expectedTaskRevision ?? input.expectedRevision!;
518
- if (!expectedTaskRevision) return failure("invalid_input", "task revision is required");
519
- if (task.data.revision !== expectedTaskRevision)
520
- return this.conflict(expectedTaskRevision, task.data.revision);
516
+ const expected = input.expectedRevision;
517
+ if (!expected) return failure("invalid_input", "task revision is required");
518
+ if (task.data.revision !== expected) return this.conflict(expected, task.data.revision);
521
519
  if (workspace.data.revision !== input.expectedWorkspaceRevision)
522
520
  return this.conflict(input.expectedWorkspaceRevision, workspace.data.revision);
523
521
  const previousWorkspaceBytes = this.snapshotBytes(this.workspacePath);
@@ -96,9 +96,19 @@ const same = (left: unknown, right: unknown): boolean => {
96
96
  /** The one shared definition of active or uncertain worker state used by
97
97
  * close, revise, resume, and replacement-launch checks. */
98
98
  export const UNCERTAIN_WORKER_STATES = ["dispatching", "running", "cancelling", "unknown"] as const;
99
- export const isUncertainWorker = (state: string): boolean =>
99
+ export const isUncertainWorker = (state: WorkerState): boolean =>
100
100
  (UNCERTAIN_WORKER_STATES as readonly string[]).includes(state);
101
101
 
102
+ /**
103
+ * Lifecycle gate table: which worker states block each lead transition.
104
+ * Pause freezes, so live workers never block it (writer release stays a
105
+ * separate explicit step); resume, revise, and close must reconcile first.
106
+ */
107
+ export const workerBlocksTransition = (
108
+ transition: "pause" | "resume" | "revise" | "close",
109
+ state: WorkerState,
110
+ ): boolean => (transition === "pause" ? false : isUncertainWorker(state));
111
+
102
112
  const callerSession = (caller: CallerContext): HostSession =>
103
113
  caller.session ?? { kind: "host", host: caller.host, handle: caller.actor };
104
114
 
package/src/core.ts CHANGED
@@ -22,6 +22,7 @@ export {
22
22
  decisionDigest,
23
23
  candidateDigest,
24
24
  requirementId,
25
+ rewriteRecordRefs,
25
26
  BINDING_QUESTION_BUDGET,
26
27
  workitBindingQuestionIssue,
27
28
  } from "./core/task-contract";
@@ -55,6 +56,7 @@ export type {
55
56
  TaskView,
56
57
  TaskSummary,
57
58
  ExportBundle,
59
+ RefMap,
58
60
  Result as ContractResult,
59
61
  } from "./core/task-contract";
60
62
  export { TaskStore, runtimeVersion } from "./core/task-store";
@@ -69,8 +71,13 @@ export type {
69
71
  export { METHODS, invariantBootstrap, selectMethods } from "./core/methods";
70
72
  export type { MethodId, SelectedMethod } from "./core/methods";
71
73
  export { hasLiveWorkitTask, shellRouteIntent, shouldDenyShellRoute } from "./core/route-intent";
74
+ export { classifyBranchDirt } from "./core/branch";
75
+ export type { BranchDirt } from "./core/branch";
76
+ export { bundleHashOfFile, isEphemeralCachePath, sha256Hex } from "./core/runtime-identity";
72
77
  export type { ShellRouteIntent } from "./core/route-intent";
73
78
  export { applicableDecision, reserveAction, settleAction, reconcileAction } from "./core/authority";
79
+ export { normalizeChainSteps, chainStepKey } from "./core/authority";
80
+ export type { ChainStep } from "./core/authority";
74
81
  export {
75
82
  createAuthorizedExternalActionRunner,
76
83
  runAuthorizedExternalAction,
@@ -87,9 +94,11 @@ export type {
87
94
  } from "./core/external-action";
88
95
  export {
89
96
  approvedBranchSetupIntent,
97
+ approvedChainStep,
90
98
  approvedExternalAction,
91
99
  approvedPlanCommit,
92
100
  branchSetupIntent,
101
+ chainStepBinding,
93
102
  planCommitDescriptor,
94
103
  planCommitBinding,
95
104
  commitMessageFromDescriptor,
@@ -117,9 +126,12 @@ export type {
117
126
  } from "./core/authority";
118
127
  export {
119
128
  captureCandidate,
129
+ checkPin,
120
130
  evaluateEvidence,
121
131
  evaluateRequirements,
122
132
  evaluateClosure,
133
+ findingVerificationPasses,
134
+ resolveBinding,
123
135
  } from "./core/task-evaluation";
124
136
  export type { CandidateEnvironment, ClosureEvaluation } from "./core/task-evaluation";
125
137
  export { WorkitCore } from "./core/task-engine";