@brainervirus/workit-core 1.1.1 → 1.2.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -23,6 +23,7 @@ import {
23
23
  type Utc,
24
24
  } from "./task-contract";
25
25
  import { TaskStore } from "./task-store";
26
+ import { currentWriterOwnsTask } from "./workers";
26
27
 
27
28
  export type NativeAuthorityContext = {
28
29
  now: Utc;
@@ -542,6 +543,27 @@ export const storedDecisionApplicable = (
542
543
  verifyDecisionContentAtRoot(store.root, entry.data.binding).ok,
543
544
  );
544
545
 
546
+ const approvalAuthorityMatches = (
547
+ store: TaskStore,
548
+ task: TaskRecord,
549
+ approval: Provenance,
550
+ current: Provenance,
551
+ ): boolean => {
552
+ if (approval.kind !== "host_observed" || approval.receipts.length === 0) return false;
553
+ if (provenanceMatches(approval, current)) return true;
554
+ const session = current.kind === "host_observed" ? current.session : null;
555
+ if (!session || session.kind !== "host") return false;
556
+ const workspace = store.readWorkspace();
557
+ return (
558
+ workspace.ok &&
559
+ workspace.data !== null &&
560
+ currentWriterOwnsTask(task, workspace.data, {
561
+ host: session.host,
562
+ actor: session.handle,
563
+ })
564
+ );
565
+ };
566
+
545
567
  const validateAction = (
546
568
  store: TaskStore,
547
569
  task: TaskRecord,
@@ -574,11 +596,7 @@ const validateAction = (
574
596
  return failure("permission_denied", "native reservation authority does not match decision");
575
597
  if (decision.response !== "approved")
576
598
  return failure("permission_denied", "decision was rejected");
577
- if (
578
- entry.provenance.kind !== "host_observed" ||
579
- entry.provenance.receipts.length === 0 ||
580
- !provenanceMatches(entry.provenance, authority.provenance)
581
- )
599
+ if (!approvalAuthorityMatches(store, task, entry.provenance, authority.provenance))
582
600
  return failure("permission_denied", "action approval lacks native receipt assurance");
583
601
  // Standing auto-approval receipts carry no live question. They authorize
584
602
  // only while the referenced workspace rule still covers the operation —
@@ -793,7 +811,7 @@ export function settleAction(input: SettleActionInput): Result<Entry<Decision>>
793
811
  if (!entry) return failure("not_found", "decision not found");
794
812
  if (!authorityDecisionMatches(authority, entry.data))
795
813
  return failure("permission_denied", "native settlement authority does not match decision");
796
- if (!provenanceMatches(entry.provenance, authority.provenance))
814
+ if (!approvalAuthorityMatches(input.store, current, entry.provenance, authority.provenance))
797
815
  return failure("permission_denied", "native settlement caller does not match approval");
798
816
  if (entry.data.purpose !== "action" || entry.data.digest !== decisionDigest(entry.data))
799
817
  return failure("permission_denied", "decision binding is invalid");
@@ -931,7 +949,7 @@ export function reconcileAction(input: ReconcileActionInput): Result<Entry<Decis
931
949
  return failure("not_found", "decision not found");
932
950
  if (
933
951
  !authorityDecisionMatches(authority, entry.data) ||
934
- !provenanceMatches(entry.provenance, authority.provenance)
952
+ !approvalAuthorityMatches(input.store, current, entry.provenance, authority.provenance)
935
953
  )
936
954
  return failure("permission_denied", "native reconciliation caller does not match approval");
937
955
  if (
@@ -13,9 +13,10 @@ import {
13
13
  type Revision,
14
14
  type TaskRecord,
15
15
  } from "./task-contract";
16
- import type { TaskStore } from "./task-store";
16
+ import { TaskStore } from "./task-store";
17
17
  import type { WorkitCore } from "./task-engine";
18
18
  import type { Decision } from "./task-contract";
19
+ import { currentWriterOwnsTask } from "./workers";
19
20
 
20
21
  /** Action classes auto-approval can cover. Unknown classes never match. */
21
22
  export const AUTO_CLASSES = ["branch", "commit", "push", "pr", "merge"] as const;
@@ -154,14 +155,10 @@ export const verifyStandingApproval = (
154
155
  ): Result<Provenance> => {
155
156
  if (task.status !== "active")
156
157
  return failure("invalid_transition", "only active tasks can authorize actions");
157
- const session = task.intent?.provenance?.session;
158
- if (
159
- !session ||
160
- session.kind !== "host" ||
161
- session.host !== caller.host ||
162
- session.handle !== caller.actor
163
- )
164
- return failure("permission_denied", "standing approval requires the lead session");
158
+ const workspace = new TaskStore(root).readWorkspace();
159
+ if (!workspace.ok) return workspace as Result<never>;
160
+ if (!workspace.data || !currentWriterOwnsTask(task, workspace.data, caller))
161
+ return failure("permission_denied", "standing approval requires the current writer session");
165
162
  const standing = binding.standing;
166
163
  if (!standing || typeof standing.workspace !== "string" || typeof standing.class !== "string")
167
164
  return failure("invalid_input", "standing approval needs a workspace and class");
@@ -243,19 +240,14 @@ export const standingAutoApplies = (
243
240
  const listed = store.listTasks();
244
241
  const workspace = store.readWorkspace();
245
242
  if (!listed.ok || !workspace.ok || !workspace.data) return null;
246
- const matches = listed.data.filter(
247
- (task) =>
248
- task.status === "active" &&
249
- task.workspaceId === workspace.data!.id &&
250
- task.intent?.provenance?.session?.kind === "host" &&
251
- (task.intent.provenance.session as { host?: unknown; handle?: unknown }).host === host &&
252
- (task.intent.provenance.session as { host?: unknown; handle?: unknown }).handle === actor,
243
+ const task = listed.data.find((task) =>
244
+ currentWriterOwnsTask(task, workspace.data!, { host, actor }),
253
245
  );
254
- if (matches.length !== 1) return null;
246
+ if (!task) return null;
255
247
  const resolved = resolveAutoApproval(store.root);
256
248
  if (resolved.status !== "on") return null;
257
249
  return {
258
- task: matches[0],
250
+ task,
259
251
  workspaceId: workspace.data.id,
260
252
  workspaceRevision: workspace.data.revision,
261
253
  cls,
@@ -25,7 +25,15 @@ import {
25
25
  } from "./youtrack";
26
26
  import fs from "node:fs";
27
27
  import path from "node:path";
28
- import { failure, success, sha256, type Caller, type Result, type Ref } from "./task-contract";
28
+ import {
29
+ failure,
30
+ success,
31
+ sha256,
32
+ type Caller,
33
+ type Owner,
34
+ type Result,
35
+ type Ref,
36
+ } from "./task-contract";
29
37
  import { changelogApply, changelogApplyPreview } from "./changelog";
30
38
  import type { ExternalActionRequest } from "./external-action";
31
39
  import { externalActionDescriptor, externalActionRequest } from "./external-action";
@@ -33,7 +41,7 @@ import { normalizeChainSteps } from "./authority";
33
41
  import { resolveInside, run as coreRun } from "../core";
34
42
  import { vcsConfig } from "./vcs-config";
35
43
  import { detectCommitFlavor, matchCommitFlavor, type CommitFlavor } from "./commit-flavors";
36
- import { assertProductWriteAllowed } from "./workers";
44
+ import { assertProductWriteAllowed, currentWriterOwnsTask } from "./workers";
37
45
  import { TaskStore } from "./task-store";
38
46
 
39
47
  const run = (root: string, args: string[]) => {
@@ -201,26 +209,22 @@ const localAction = (operation: ExternalActionRequest["operation"]): boolean =>
201
209
  operation === "git.commit" ||
202
210
  operation === "git.push" ||
203
211
  operation === "hosting.pull_request" ||
212
+ operation === "hosting.merge" ||
204
213
  operation === "changelog.apply";
205
214
 
206
215
  export const assertLocalExternalActionWriter = (
207
216
  root: string,
208
217
  caller: { host: Caller["host"]; actor: string },
209
218
  paths: string[] = ["."],
210
- ): Result<null> => {
219
+ ): Result<Owner> => {
211
220
  const store = new TaskStore(root);
212
221
  const listed = store.listTasks();
213
222
  const workspace = store.readWorkspace();
214
223
  if (!listed.ok) return listed as Result<never>;
215
224
  if (!workspace.ok) return workspace as Result<never>;
216
225
  if (!workspace.data) return failure("not_found", "workspace not found");
217
- const candidates = listed.data.filter(
218
- (task) =>
219
- task.status === "active" &&
220
- task.workspaceId === workspace.data!.id &&
221
- task.intent.provenance.session?.kind === "host" &&
222
- task.intent.provenance.session.host === caller.host &&
223
- task.intent.provenance.session.handle === caller.actor,
226
+ const candidates = listed.data.filter((task) =>
227
+ currentWriterOwnsTask(task, workspace.data!, caller),
224
228
  );
225
229
  if (candidates.length !== 1)
226
230
  return failure("permission_denied", "local external action requires exactly one active task");
@@ -231,7 +235,7 @@ export const assertLocalExternalActionWriter = (
231
235
  paths,
232
236
  store,
233
237
  });
234
- return allowed.ok ? success(null, null, null) : (allowed as Result<never>);
238
+ return allowed;
235
239
  };
236
240
 
237
241
  const safePath = (root: string, value: string): string | null => {
@@ -248,16 +252,29 @@ const branchCreationBaseline = (
248
252
  root: string,
249
253
  target: string,
250
254
  base: string,
251
- ): { base_branch: string; target_exists: boolean; remote_base: string | null } => {
255
+ ): {
256
+ base_branch: string;
257
+ target_exists: boolean;
258
+ remote_base: string | null;
259
+ target_head: string | null;
260
+ } => {
252
261
  const exists =
253
262
  gitValue(root, ["rev-parse", "--verify", "--quiet", `refs/heads/${target}`]) !== null;
254
- if (exists) return { base_branch: base, target_exists: true, remote_base: null };
263
+ if (exists)
264
+ return {
265
+ base_branch: base,
266
+ target_exists: true,
267
+ remote_base: null,
268
+ target_head: gitValue(root, ["rev-parse", `refs/heads/${target}`]),
269
+ };
255
270
  const remote = run(root, ["ls-remote", "origin", `refs/heads/${base}`]);
256
271
  const sha = remote.exitCode === 0 ? remote.stdout.trim().split(/\s+/)[0] : "";
272
+ const remoteBase = /^[0-9a-f]{40}$/.test(sha ?? "") ? (sha as string) : null;
257
273
  return {
258
274
  base_branch: base,
259
275
  target_exists: false,
260
- remote_base: /^[0-9a-f]{40}$/.test(sha ?? "") ? (sha as string) : null,
276
+ remote_base: remoteBase,
277
+ target_head: remoteBase,
261
278
  };
262
279
  };
263
280
 
@@ -408,6 +425,9 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
408
425
  try {
409
426
  const value = JSON.parse(content) as { operation?: unknown; payload?: unknown };
410
427
  if (
428
+ value.operation !== "git.branch_setup" &&
429
+ value.operation !== "git.commit" &&
430
+ value.operation !== "git.push" &&
411
431
  value.operation !== "hosting.pull_request" &&
412
432
  value.operation !== "hosting.merge" &&
413
433
  value.operation !== "changelog.apply" &&
@@ -425,6 +445,28 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
425
445
  if (!request.ok || !resolved || typeof resolved !== "object")
426
446
  return failure("invalid_input", "approved hosting target is incomplete");
427
447
  const target = resolved as Record<string, unknown>;
448
+ if (
449
+ value.operation === "git.commit" &&
450
+ (typeof target.head !== "string" ||
451
+ typeof target.staged !== "string" ||
452
+ typeof payload.message !== "string")
453
+ )
454
+ return failure("invalid_input", "approved Git commit target is incomplete");
455
+ if (
456
+ value.operation === "git.push" &&
457
+ (typeof target.branch !== "string" ||
458
+ typeof target.commit !== "string" ||
459
+ typeof target.remote !== "string")
460
+ )
461
+ return failure("invalid_input", "approved Git push target is incomplete");
462
+ if (
463
+ value.operation === "git.branch_setup" &&
464
+ (typeof target.head !== "string" ||
465
+ (payload.action === "reapply_stash"
466
+ ? typeof target.stash_commit !== "string"
467
+ : typeof payload.target_branch !== "string" || typeof target.target_head !== "string"))
468
+ )
469
+ return failure("invalid_input", "approved Git branch target is incomplete");
428
470
  if (
429
471
  value.operation === "hosting.pull_request" &&
430
472
  (typeof target.marker !== "string" ||
@@ -452,7 +494,11 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
452
494
  )
453
495
  return failure("invalid_input", "approved changelog target is incomplete");
454
496
  if (
497
+ value.operation !== "git.branch_setup" &&
498
+ value.operation !== "git.commit" &&
499
+ value.operation !== "git.push" &&
455
500
  value.operation !== "hosting.pull_request" &&
501
+ value.operation !== "hosting.merge" &&
456
502
  value.operation !== "changelog.apply" &&
457
503
  (typeof target.marker !== "string" ||
458
504
  typeof target.baseUrl !== "string" ||
@@ -463,7 +509,7 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
463
509
  return success(null, null, {
464
510
  request: request.data,
465
511
  descriptorPayload: value.payload,
466
- marker: String(target.marker),
512
+ ...(typeof target.marker === "string" ? { marker: target.marker } : {}),
467
513
  });
468
514
  } catch {
469
515
  return failure("invalid_input", "approved action descriptor is invalid");
@@ -499,8 +545,9 @@ export const readHostingAction = async (
499
545
  resolved: ResolvedExternalAction,
500
546
  actionRef: Ref,
501
547
  ): Promise<Result<HostingReadEvidence>> => {
502
- if (resolved.request.operation !== "hosting.pull_request" || !resolved.marker)
503
- return unknownHostingEvidence("hosting.pull_request");
548
+ const operation = resolved.request.operation;
549
+ if ((operation !== "hosting.pull_request" && operation !== "hosting.merge") || !resolved.marker)
550
+ return unknownHostingEvidence(operation);
504
551
  try {
505
552
  const cfg = vcsConfig("load", root);
506
553
  if (
@@ -509,7 +556,7 @@ export const readHostingAction = async (
509
556
  !cfg.provider ||
510
557
  (cfg.provider !== "github" && cfg.provider !== "gitlab")
511
558
  )
512
- return unknownHostingEvidence("hosting.pull_request");
559
+ return unknownHostingEvidence(operation);
513
560
  const remote = pushRemote(root);
514
561
  const repo = remote ? remoteRepo(remote) : null;
515
562
  const approved = resolved.descriptorPayload as {
@@ -549,7 +596,7 @@ export const readHostingAction = async (
549
596
  remoteProvider(approvedRemote) !== cfg.provider) ||
550
597
  remoteParts(approvedRemote)?.host !== apiHost
551
598
  )
552
- return unknownHostingEvidence("hosting.pull_request");
599
+ return unknownHostingEvidence(operation);
553
600
  const token = fs.readFileSync(cfg.tokenPath, "utf8").trim();
554
601
  const headers = { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" };
555
602
  const url =
@@ -559,15 +606,14 @@ export const readHostingAction = async (
559
606
  const response = await fetch(url, {
560
607
  headers: cfg.provider === "gitlab" ? { "PRIVATE-TOKEN": token } : headers,
561
608
  });
562
- if (!response.ok) return unknownHostingEvidence("hosting.pull_request");
609
+ if (!response.ok) return unknownHostingEvidence(operation);
563
610
  const values: unknown = await response.json();
564
611
  // A full page is intentionally treated as truncated until pagination is added.
565
- if (!Array.isArray(values) || values.length >= 100)
566
- return unknownHostingEvidence("hosting.pull_request");
612
+ if (!Array.isArray(values) || values.length >= 100) return unknownHostingEvidence(operation);
567
613
  const records: Record<string, unknown>[] = [];
568
614
  for (const item of values) {
569
615
  if (!item || typeof item !== "object" || Array.isArray(item))
570
- return unknownHostingEvidence("hosting.pull_request");
616
+ return unknownHostingEvidence(operation);
571
617
  const record = item as Record<string, unknown>;
572
618
  const body = record.body ?? record.description;
573
619
  const base = record.base;
@@ -609,35 +655,48 @@ export const readHostingAction = async (
609
655
  typeof shaValue !== "string" ||
610
656
  (typeof idValue !== "string" && typeof idValue !== "number")
611
657
  )
612
- return unknownHostingEvidence("hosting.pull_request");
658
+ return unknownHostingEvidence(operation);
613
659
  records.push(record);
614
660
  }
615
- const matches = records.filter((item): item is Record<string, unknown> => {
616
- const body = String(
617
- (item as Record<string, unknown>).body ??
618
- (item as Record<string, unknown>).description ??
619
- "",
620
- );
621
- const base = (item as Record<string, unknown>).base as Record<string, unknown> | undefined;
622
- const head = (item as Record<string, unknown>).head as Record<string, unknown> | undefined;
623
- const itemTarget =
624
- cfg.provider === "github" ? base?.ref : (item as Record<string, unknown>).target_branch;
625
- const itemSource =
626
- cfg.provider === "github" ? head?.ref : (item as Record<string, unknown>).source_branch;
627
- const itemSha = cfg.provider === "github" ? head?.sha : (item as Record<string, unknown>).sha;
661
+ const matches = records.filter((item) => {
662
+ const body = String(item.body ?? item.description ?? "");
663
+ const base = item.base as Record<string, unknown> | undefined;
664
+ const head = item.head as Record<string, unknown> | undefined;
665
+ const itemTarget = cfg.provider === "github" ? base?.ref : item.target_branch;
666
+ const itemSource = cfg.provider === "github" ? head?.ref : item.source_branch;
667
+ const itemSha = cfg.provider === "github" ? head?.sha : item.sha;
668
+ const merged =
669
+ cfg.provider === "github"
670
+ ? typeof item.merged_at === "string"
671
+ : item.state === "merged" || typeof item.merged_at === "string";
628
672
  return (
629
- body.includes(resolved.marker!) &&
673
+ (operation === "hosting.merge" ? merged : body.includes(resolved.marker!)) &&
630
674
  itemTarget === target &&
631
675
  itemSource === branch &&
632
676
  itemSha === sourceCommit
633
677
  );
634
678
  });
635
- if (matches.length !== 1) return unknownHostingEvidence("hosting.pull_request");
679
+ if (
680
+ operation === "hosting.merge" &&
681
+ records.some((item) => {
682
+ const base = item.base as Record<string, unknown> | undefined;
683
+ const head = item.head as Record<string, unknown> | undefined;
684
+ return (
685
+ (cfg.provider === "github" ? base?.ref : item.target_branch) === target &&
686
+ (cfg.provider === "github" ? head?.ref : item.source_branch) === branch &&
687
+ (cfg.provider === "github"
688
+ ? typeof item.merged_at !== "string"
689
+ : item.state !== "merged" && typeof item.merged_at !== "string")
690
+ );
691
+ })
692
+ )
693
+ return unknownHostingEvidence(operation);
694
+ if (matches.length !== 1) return unknownHostingEvidence(operation);
636
695
  const item = matches[0];
637
696
  const id = item.number ?? item.iid ?? item.id;
638
- if (typeof id !== "string" && typeof id !== "number")
639
- return unknownHostingEvidence("hosting.pull_request");
697
+ if (typeof id !== "string" && typeof id !== "number") return unknownHostingEvidence(operation);
640
698
  const evidenceDigest = sha256({
699
+ operation,
641
700
  provider: cfg.provider,
642
701
  id,
643
702
  marker: resolved.marker,
@@ -658,7 +717,7 @@ export const readHostingAction = async (
658
717
  data: { provider: cfg.provider, id },
659
718
  });
660
719
  } catch {
661
- return unknownHostingEvidence("hosting.pull_request");
720
+ return unknownHostingEvidence(operation);
662
721
  }
663
722
  };
664
723
 
@@ -812,14 +871,112 @@ export const readYouTrackAction = async (
812
871
  }
813
872
  };
814
873
 
874
+ export const readLocalAction = async (
875
+ root: string,
876
+ resolved: ResolvedExternalAction,
877
+ actionRef: Ref,
878
+ ): Promise<Result<HostingReadEvidence>> => {
879
+ const operation = resolved.request.operation;
880
+ const target = (resolved.descriptorPayload as { resolved?: Record<string, unknown> }).resolved;
881
+ if (!target) return unknownHostingEvidence(operation);
882
+ let observed: Record<string, unknown> | null = null;
883
+ if (operation === "git.commit") {
884
+ const commit = gitValue(root, ["rev-parse", "HEAD"]);
885
+ const parent = commit ? gitValue(root, ["rev-parse", `${commit}^`]) : null;
886
+ const message = commit ? gitValue(root, ["show", "-s", "--format=%B", commit]) : null;
887
+ const diff =
888
+ commit && parent
889
+ ? gitRaw(root, ["diff", "--raw", "-z", "--no-ext-diff", "--no-textconv", parent, commit])
890
+ : null;
891
+ if (
892
+ commit &&
893
+ parent === target.head &&
894
+ message === resolved.request.payload.message &&
895
+ diff !== null &&
896
+ sha256(diff) === target.staged
897
+ )
898
+ observed = { commit, parent, message, staged: target.staged };
899
+ } else if (operation === "git.push") {
900
+ const branch = target.branch;
901
+ const commit = target.commit;
902
+ const approvedRemote = target.remote;
903
+ const currentRemote = pushRemote(root);
904
+ const currentIdentity = currentRemote ? credentialFreeRemote(currentRemote) : null;
905
+ if (
906
+ typeof branch === "string" &&
907
+ typeof commit === "string" &&
908
+ typeof approvedRemote === "string" &&
909
+ currentIdentity === approvedRemote
910
+ ) {
911
+ const remote = run(root, ["ls-remote", currentRemote!, `refs/heads/${branch}`]);
912
+ const remoteCommit = remote.exitCode === 0 ? remote.stdout.trim().split(/\s+/)[0] : null;
913
+ if (remoteCommit === commit) observed = { branch, commit, remote: approvedRemote };
914
+ }
915
+ } else if (operation === "git.branch_setup") {
916
+ const targetBranch = resolved.request.payload.target_branch;
917
+ const sdd = resolved.request.payload.sdd_dir;
918
+ if (resolved.request.payload.action === "reapply_stash" && typeof sdd === "string") {
919
+ try {
920
+ const manifest = JSON.parse(fs.readFileSync(path.join(sdd, "manifest.json"), "utf8")) as {
921
+ stash_ref?: unknown;
922
+ };
923
+ const stashes = gitRaw(root, ["stash", "list", "--format=%H"]);
924
+ if (
925
+ typeof target.stash_commit === "string" &&
926
+ manifest.stash_ref === undefined &&
927
+ stashes !== null &&
928
+ !stashes.split("\n").includes(target.stash_commit)
929
+ )
930
+ observed = { stash_commit: target.stash_commit };
931
+ } catch {}
932
+ } else if (
933
+ typeof targetBranch === "string" &&
934
+ typeof sdd === "string" &&
935
+ gitValue(root, ["branch", "--show-current"]) === targetBranch &&
936
+ typeof target.target_head === "string" &&
937
+ gitValue(root, ["rev-parse", "HEAD"]) === target.target_head
938
+ ) {
939
+ try {
940
+ const manifest = JSON.parse(fs.readFileSync(path.join(sdd, "manifest.json"), "utf8")) as {
941
+ branch?: unknown;
942
+ };
943
+ if (manifest.branch === targetBranch) observed = { branch: targetBranch };
944
+ } catch {}
945
+ }
946
+ } else if (operation === "changelog.apply") {
947
+ const afterDigest = target.afterDigest;
948
+ const preview = changelogApplyPreview({ ...resolved.request.payload, workspace_root: root });
949
+ if (!preview.error && typeof afterDigest === "string" && preview.beforeDigest === afterDigest)
950
+ observed = { target: target.target, afterDigest };
951
+ }
952
+ if (!observed) return unknownHostingEvidence(operation);
953
+ const evidenceDigest = sha256({ operation, observed });
954
+ return success(null, null, {
955
+ outcome: "succeeded",
956
+ evidenceDigest,
957
+ observation: {
958
+ kind: "provider_read",
959
+ actionRef,
960
+ outcome: "succeeded",
961
+ evidenceDigest,
962
+ },
963
+ });
964
+ };
965
+
815
966
  export const readExternalAction = async (
816
967
  root: string,
817
968
  resolved: ResolvedExternalAction,
818
969
  actionRef: Ref,
819
970
  ): Promise<Result<HostingReadEvidence>> =>
820
- resolved.request.operation === "hosting.pull_request"
821
- ? readHostingAction(root, resolved, actionRef)
822
- : readYouTrackAction(root, resolved, actionRef);
971
+ resolved.request.operation === "git.branch_setup" ||
972
+ resolved.request.operation === "git.commit" ||
973
+ resolved.request.operation === "git.push" ||
974
+ resolved.request.operation === "changelog.apply"
975
+ ? readLocalAction(root, resolved, actionRef)
976
+ : resolved.request.operation === "hosting.pull_request" ||
977
+ resolved.request.operation === "hosting.merge"
978
+ ? readHostingAction(root, resolved, actionRef)
979
+ : readYouTrackAction(root, resolved, actionRef);
823
980
 
824
981
  const youTrackBase = (): string | null => {
825
982
  try {
@@ -905,6 +1062,17 @@ export const resolveExternalActionRequest = (
905
1062
  action === "reapply_stash" || !target_branch
906
1063
  ? { base_branch: policy.defaultTargetBranch }
907
1064
  : branchCreationBaseline(root, target_branch, policy.defaultTargetBranch);
1065
+ let stashTarget: { stash_ref: string; stash_commit: string } | undefined;
1066
+ if (action === "reapply_stash") {
1067
+ try {
1068
+ const manifest = JSON.parse(
1069
+ fs.readFileSync(path.join(sdd_dir, "manifest.json"), "utf8"),
1070
+ ) as { stash_ref?: unknown };
1071
+ const stash_ref = typeof manifest.stash_ref === "string" ? manifest.stash_ref : "";
1072
+ const stash_commit = stash_ref ? gitValue(root, ["rev-parse", stash_ref]) : null;
1073
+ if (stash_ref && stash_commit) stashTarget = { stash_ref, stash_commit };
1074
+ } catch {}
1075
+ }
908
1076
  const normalized = {
909
1077
  operation: request.operation,
910
1078
  payload: {
@@ -919,7 +1087,7 @@ export const resolveExternalActionRequest = (
919
1087
  request: normalized,
920
1088
  descriptorPayload: {
921
1089
  ...normalized.payload,
922
- resolved: { head, dirty, ...creation },
1090
+ resolved: { head, dirty, ...creation, ...stashTarget },
923
1091
  },
924
1092
  });
925
1093
  }
@@ -1106,7 +1274,9 @@ export const resolveExternalActionRequest = (
1106
1274
  outcome: "not_started",
1107
1275
  fields: [{ path: "source_branch", reason: "protected branch" }],
1108
1276
  });
1109
- const source_commit = gitValue(root, ["rev-parse", "HEAD"]);
1277
+ const source_commit = source_branch
1278
+ ? gitValue(root, ["rev-parse", "--verify", `refs/heads/${source_branch}`])
1279
+ : null;
1110
1280
  const remote = pushRemote(root);
1111
1281
  const identity = remote ? credentialFreeRemote(remote) : null;
1112
1282
  if (
@@ -1521,7 +1691,7 @@ export const executeResolvedExternalAction = async (
1521
1691
  typeof resolvedDetails.resolved?.beforeExists === "boolean"
1522
1692
  ? resolvedDetails.resolved.beforeExists
1523
1693
  : undefined;
1524
- return executeConcreteExternalAction(
1694
+ const result = await executeConcreteExternalAction(
1525
1695
  fresh.data.request,
1526
1696
  root,
1527
1697
  fresh.data.marker,
@@ -1532,4 +1702,13 @@ export const executeResolvedExternalAction = async (
1532
1702
  beforeDigest,
1533
1703
  beforeExists,
1534
1704
  );
1705
+ if (!result.ok && result.code === "external_outcome_unknown" && caller) {
1706
+ const observed = await readLocalAction(root, fresh.data, {
1707
+ kind: "host",
1708
+ host: caller.host,
1709
+ handle: `local-recovery:${fresh.data.request.operation}`,
1710
+ });
1711
+ if (observed.ok) return success(null, null, { recovered: true });
1712
+ }
1713
+ return result;
1535
1714
  };
@@ -18,6 +18,12 @@ import { chainStepKey, normalizeChainSteps, type ChainStep } from "./authority";
18
18
  import * as z from "zod";
19
19
  import { execFileSync } from "node:child_process";
20
20
 
21
+ const chainStepSchema = z.union([
22
+ z.string().min(1),
23
+ z.object({ branch: z.string().min(1) }).strict(),
24
+ z.object({ pr: z.literal(true) }).strict(),
25
+ ]);
26
+
21
27
  export type AuthorizedActionInput = {
22
28
  core: WorkitCore;
23
29
  taskId: Id;
@@ -60,7 +66,7 @@ const externalActionSchema = z.discriminatedUnion("operation", [
60
66
  payload: z
61
67
  .object({
62
68
  message: z.string().min(1).optional(),
63
- plan_steps: z.array(z.string().min(1)).optional(),
69
+ plan_steps: z.array(chainStepSchema).min(1).max(32).optional(),
64
70
  plan_branch: z.string().min(1).optional(),
65
71
  })
66
72
  .strict(),
@@ -225,9 +231,26 @@ export const externalActionDescriptor = (operation: string, payload: unknown): s
225
231
  : {}),
226
232
  });
227
233
 
234
+ /**
235
+ * Plan reservations never execute a git commit. A git.commit whose resolved
236
+ * payload carries a plan_steps array records the listed chain once and lets
237
+ * each listed single commit execute later under that authorization. Adapters
238
+ * must return plan success without running the commit effect, both for prior
239
+ * approvals and standing auto-approval; otherwise the reservation fails as a
240
+ * single commit requiring a message. Returns the listed count, else null.
241
+ */
242
+ export const planReservationLength = (
243
+ operation: string,
244
+ descriptorPayload: unknown,
245
+ ): number | null => {
246
+ if (operation !== "git.commit") return null;
247
+ const payload = (descriptorPayload ?? {}) as { plan_steps?: unknown };
248
+ return Array.isArray(payload.plan_steps) ? payload.plan_steps.length : null;
249
+ };
250
+
228
251
  /** Compact host-facing help for the fixed optional-action surface. */
229
252
  export const externalActionHelp =
230
- "Fixed actions: git.branch_setup {action?,sdd_dir?,target_branch(required unless reapply_stash; the working branch to create or switch to),stash?}; git.commit {message}; git.push {branch?}; hosting.pull_request {title,body?,draft?,target_branch?,babysit?}; hosting.merge {target_branch?,source_branch?}; youtrack.update {issueId,markdown,minutes?}; youtrack.time {issueId,minutes,text?,dateMs?}; youtrack.meeting {issueId,minutes,text}; changelog.apply {entries?,path?,normalize_only?}; context.read {kind,range?,issueId?,issueUrl?,issueRef?,mode?,specPath?,planPath?}. context.read is read-only and needs no approval; all other operations require native approval (CLI uses a TTY; caller-unattested MCP cannot mutate).";
253
+ "Fixed actions: git.branch_setup {action?,sdd_dir?,target_branch(required unless reapply_stash; the working branch to create or switch to),stash?}; git.commit {message?; plan_steps?: (string | {branch:string} | {pr:true})[]; plan_branch?}; git.push {branch?}; hosting.pull_request {title,body?,draft?,target_branch?,babysit?}; hosting.merge {target_branch?,source_branch?}; youtrack.update {issueId,markdown,minutes?}; youtrack.time {issueId,minutes,text?,dateMs?}; youtrack.meeting {issueId,minutes,text}; changelog.apply {entries?,path?,normalize_only?}; context.read {kind: git|pr|youtrack|github_issue|gitlab_issue|changelog|release|affected,range?,issueId?,issueUrl?,issueRef?,mode?,specPath?,planPath?}. context.read is read-only and needs no approval; all other operations require native approval (CLI uses a TTY; caller-unattested MCP cannot mutate).";
231
254
 
232
255
  export const externalActionRef = (
233
256
  host: "opencode" | "pi" | "workit_cli",
@@ -251,14 +274,7 @@ export const externalActionState = (
251
274
  return failure("storage_error", "external action state is unavailable");
252
275
  if (!workspace.data) return failure("not_found", "workspace not found");
253
276
  const candidates = listed.data.flatMap((task) => {
254
- if (
255
- task.status !== "active" ||
256
- task.workspaceId !== workspace.data!.id ||
257
- task.intent.provenance.session?.kind !== "host" ||
258
- task.intent.provenance.session.host !== host ||
259
- task.intent.provenance.session.handle !== actor
260
- )
261
- return [];
277
+ if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
262
278
  return task.decisions
263
279
  .filter(
264
280
  (entry) =>
@@ -273,8 +289,8 @@ export const externalActionState = (
273
289
  return failure(
274
290
  "permission_denied",
275
291
  candidates.length === 0
276
- ? "no approved action is bound to this session"
277
- : "multiple approved actions match this session",
292
+ ? "no approved action is bound to the active task"
293
+ : "multiple approved actions match the active task",
278
294
  );
279
295
  return success(workspace.data.revision, null, { ...candidates[0], workspace: workspace.data });
280
296
  };
@@ -414,12 +430,6 @@ export const approvedPlanCommit = (
414
430
  if (!workspace.data) return failure("not_found", "workspace not found");
415
431
  const matches = listed.data.flatMap((task) => {
416
432
  if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
417
- if (
418
- task.intent.provenance.session?.kind !== "host" ||
419
- task.intent.provenance.session.host !== host ||
420
- task.intent.provenance.session.handle !== actor
421
- )
422
- return [];
423
433
  return task.decisions.flatMap((entry) => {
424
434
  const decision = entry.data;
425
435
  if (
@@ -464,12 +474,7 @@ export type ChainStepQuery =
464
474
  | { operation: "git.branch_setup"; target: string }
465
475
  | { operation: "hosting.pull_request" };
466
476
 
467
- /**
468
- * Match one branch or PR step of a chain authorization: same session-bound
469
- * search as plan commits, next unconsumed step of the right kind, lease
470
- * intact. Commit steps stay on the plan path; this covers the steps plans
471
- * could never express.
472
- */
477
+ /** Match the next branch or PR step of an active chain authorization. */
473
478
  export const approvedChainStep = (
474
479
  store: TaskStore,
475
480
  host: string,
@@ -483,12 +488,6 @@ export const approvedChainStep = (
483
488
  if (!workspace.data) return failure("not_found", "workspace not found");
484
489
  const matches = listed.data.flatMap((task) => {
485
490
  if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
486
- if (
487
- task.intent.provenance.session?.kind !== "host" ||
488
- task.intent.provenance.session.host !== host ||
489
- task.intent.provenance.session.handle !== actor
490
- )
491
- return [];
492
491
  return task.decisions.flatMap((entry) => {
493
492
  const decision = entry.data;
494
493
  if (
@@ -734,21 +733,15 @@ export const approvedBranchSetupIntent = (
734
733
  operation: string,
735
734
  ) => {
736
735
  const current = branchSetupIntent(operation);
737
- if (!current) return failure("permission_denied", "no approved action is bound to this session");
736
+ if (!current)
737
+ return failure("permission_denied", "no approved action is bound to the active task");
738
738
  const listed = store.listTasks();
739
739
  const workspace = store.readWorkspace();
740
740
  if (!listed.ok || !workspace.ok)
741
741
  return failure("storage_error", "external action state is unavailable");
742
742
  if (!workspace.data) return failure("not_found", "workspace not found");
743
743
  const candidates = listed.data.flatMap((task) => {
744
- if (
745
- task.status !== "active" ||
746
- task.workspaceId !== workspace.data!.id ||
747
- task.intent.provenance.session?.kind !== "host" ||
748
- task.intent.provenance.session.host !== host ||
749
- task.intent.provenance.session.handle !== actor
750
- )
751
- return [];
744
+ if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
752
745
  return task.decisions
753
746
  .filter(
754
747
  (entry) =>
@@ -772,8 +765,8 @@ export const approvedBranchSetupIntent = (
772
765
  return failure(
773
766
  "permission_denied",
774
767
  matches.length === 0
775
- ? "no approved action is bound to this session"
776
- : "multiple approved actions match this session",
768
+ ? "no approved action is bound to the active task"
769
+ : "multiple approved actions match the active task",
777
770
  );
778
771
  return success(workspace.data.revision, null, { ...matches[0], workspace: workspace.data });
779
772
  };
@@ -7,7 +7,10 @@
7
7
  export const SUPPORT_MATRIX = {
8
8
  bun: "1.4.1",
9
9
  node: { minimum: "24", current: "24.20.0" },
10
- opencode: { minimum: "1.15.0", current: "1.18.30" },
10
+ // The object-form dual entry (server() + setup()) only loads from
11
+ // 1.18.29+; the supported floor is 1.18.30 so no host can pass doctor while
12
+ // being unable to load the entry shape.
13
+ opencode: { minimum: "1.18.30", current: "1.18.30" },
11
14
  // Codex is a qualification host, not a runtime dependency: the CLI version
12
15
  // below is the one live qualification evidence covers. The doctor warns when
13
16
  // an installed CLI drifts ahead so a fresh install never silently outruns
@@ -658,6 +658,7 @@ export const workerSchema = z
658
658
  assignment: assignmentSchema,
659
659
  state: z.enum(["assigned", "dispatching", "running", "cancelling", "stopped", "unknown"]),
660
660
  session: refSchema.nullable(),
661
+ coordinator: refSchema.optional(),
661
662
  report: workerReportSchema.nullable(),
662
663
  })
663
664
  .strict();
@@ -808,6 +809,8 @@ export const taskSummarySchema = z
808
809
  })
809
810
  .strict();
810
811
  export type TaskSummary = z.infer<typeof taskSummarySchema>;
812
+ export const taskListItemSchema = taskSummarySchema.omit({ policy: true, requirements: true });
813
+ export type TaskListItem = z.infer<typeof taskListItemSchema>;
811
814
  export const exportBundleSchema = z
812
815
  .object({
813
816
  schemaVersion: z.literal(1),
@@ -838,11 +841,15 @@ const taskOperations = {
838
841
  expectedWorkspaceRevision: revision.nullable().optional(),
839
842
  intent: intentSchema,
840
843
  }),
841
- list: operation({ action: z.literal("list") }),
844
+ list: operation({
845
+ action: z.literal("list"),
846
+ status: z.enum(["open", "closed", "all"]).optional(),
847
+ limit: z.number().int().min(1).max(50).optional(),
848
+ }),
842
849
  inspect: operation({
843
850
  action: z.literal("inspect"),
844
851
  ...taskId,
845
- view: z.enum(["summary", "full"]),
852
+ view: z.enum(["summary", "full"]).optional(),
846
853
  }),
847
854
  revise: operation({
848
855
  action: z.literal("revise"),
@@ -870,7 +877,7 @@ const taskOperations = {
870
877
  ...taskId,
871
878
  ...revisions,
872
879
  expectedWorkspaceRevision: revision.optional(),
873
- authorityRefs: z.array(refSchema),
880
+ authorityRefs: z.array(refSchema).optional(),
874
881
  }),
875
882
  close: operation({
876
883
  action: z.literal("close"),
@@ -29,6 +29,7 @@ import {
29
29
  type Result,
30
30
  type Scope,
31
31
  type TaskRecord,
32
+ type TaskListItem,
32
33
  type TaskSummary,
33
34
  type TaskView,
34
35
  type Utc,
@@ -152,6 +153,7 @@ type WorkerAuthority = {
152
153
  expectedWorkspaceRevision: string;
153
154
  state: NativeWorkerObservation["state"];
154
155
  session: NativeWorkerObservation["session"];
156
+ report: NativeWorkerObservation["report"] | null;
155
157
  provenance: Provenance;
156
158
  owner: object;
157
159
  store: TaskStore;
@@ -280,6 +282,7 @@ const verifyNativeWorker = (
280
282
  expectedWorkspaceRevision: expected.expectedWorkspaceRevision,
281
283
  state: expected.state,
282
284
  session: expected.session,
285
+ report: expected.report ?? null,
283
286
  provenance: result.data,
284
287
  ...binding,
285
288
  });
@@ -305,7 +308,8 @@ const takeWorker = (
305
308
  value.expectedRevision !== expected.expectedRevision ||
306
309
  value.expectedWorkspaceRevision !== expected.expectedWorkspaceRevision ||
307
310
  value.state !== expected.state ||
308
- !sameValue(value.session, expected.session)
311
+ !sameValue(value.session, expected.session) ||
312
+ !sameValue(value.report, expected.report ?? null)
309
313
  )
310
314
  return null;
311
315
  return value;
@@ -361,6 +365,8 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
361
365
  return failure("invalid_transition", "worker already has an observed session");
362
366
  if (notStarted && !["dispatching", "cancelling"].includes(entry.data.state))
363
367
  return failure("invalid_transition", "worker launch was not claimed");
368
+ if (input.report && input.state !== "stopped")
369
+ return failure("invalid_input", "worker reports require a stopped worker");
364
370
  if (
365
371
  (input.state === "running" ||
366
372
  input.state === "cancelling" ||
@@ -383,6 +389,7 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
383
389
  ...entry.data,
384
390
  state: terminalCancel ? ("cancelling" as const) : input.state,
385
391
  session: input.session,
392
+ report: entry.data.report ?? input.report ?? null,
386
393
  },
387
394
  } satisfies Entry<Worker>;
388
395
  const shouldClear =
@@ -402,6 +409,7 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
402
409
  if (
403
410
  entry.data.state === nextEntry.data.state &&
404
411
  sameValue(entry.data.session, input.session) &&
412
+ sameValue(entry.data.report, nextEntry.data.report) &&
405
413
  !shouldClear &&
406
414
  !shouldUncertain
407
415
  )
@@ -858,7 +866,7 @@ export class WorkitCore {
858
866
  });
859
867
  }
860
868
 
861
- task(request: unknown): Result<TaskSummary | TaskSummary[] | TaskView> {
869
+ task(request: unknown): Result<TaskSummary | TaskListItem[] | TaskView> {
862
870
  const root = this.contextRootError();
863
871
  if (!root.ok) return root as Result<never>;
864
872
  const parsed = parseOperation("task", request);
@@ -887,20 +895,43 @@ export class WorkitCore {
887
895
  case "list": {
888
896
  const listed = this.store.listTasks();
889
897
  if (!listed.ok) return listed as Result<never>;
890
- const summaries: TaskSummary[] = [];
891
- for (const task of listed.data) {
892
- const summary = this.summary(task);
893
- if (!summary.ok) return summary;
894
- summaries.push(summary.data);
895
- }
896
- return success(null, null, summaries);
898
+ const status = input.status ?? "open";
899
+ const limit = input.limit ?? 20;
900
+ const selected = listed.data
901
+ .filter((task) =>
902
+ status === "all"
903
+ ? true
904
+ : status === "closed"
905
+ ? task.status === "closed"
906
+ : task.status !== "closed",
907
+ )
908
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
909
+ .slice(0, limit);
910
+ if (selected.length === 0) return success(null, null, []);
911
+ const workspace = this.store.readWorkspace();
912
+ if (!workspace.ok) return workspace as Result<never>;
913
+ if (!workspace.data) return failure("not_found", "workspace not found");
914
+ const tasks = selected.map((task): TaskListItem => ({
915
+ id: task.id,
916
+ revision: task.revision,
917
+ workspaceRevision: workspace.data!.revision,
918
+ createdAt: task.createdAt,
919
+ updatedAt: task.updatedAt,
920
+ runtime: task.runtime ?? null,
921
+ objective: task.intent.data.objective,
922
+ status: task.status,
923
+ closure: task.closure,
924
+ progress: task.progress,
925
+ writer: task.status === "closed" ? null : workspace.data!.writer,
926
+ }));
927
+ return success(null, null, tasks);
897
928
  }
898
929
  case "inspect": {
899
930
  const task = this.store.readTask(input.taskId);
900
931
  if (!task.ok) return task as Result<never>;
901
932
  const helper = this.helperTaskGuard(task.data, true);
902
933
  if (!helper.ok) return helper as Result<never>;
903
- if (input.view === "summary") return this.summary(task.data);
934
+ if ((input.view ?? "summary") === "summary") return this.summary(task.data);
904
935
  return this.view(task.data);
905
936
  }
906
937
  case "pause":
@@ -1344,11 +1375,14 @@ export class WorkitCore {
1344
1375
  const evaluations = evaluateEvidence(task.data, current.data);
1345
1376
  const verified = evidence.some((entry) => {
1346
1377
  const evaluation = evaluations.find((item) => item.evidenceId === entry.id);
1347
- return findingVerificationPasses(finding.data.candidateId, {
1348
- kind: entry.data.kind,
1349
- candidateId: entry.data.candidateId,
1350
- status: evaluation?.status ?? "missing",
1351
- });
1378
+ return (
1379
+ entry.recordedAt >= finding.recordedAt &&
1380
+ findingVerificationPasses(finding.data.candidateId, {
1381
+ kind: entry.data.kind,
1382
+ candidateId: entry.data.candidateId,
1383
+ status: evaluation?.status ?? "missing",
1384
+ })
1385
+ );
1352
1386
  });
1353
1387
  if (!verified)
1354
1388
  return failure("permission_denied", "fixed findings require passing verification evidence");
@@ -1451,7 +1485,18 @@ export class WorkitCore {
1451
1485
  if (task.data.status !== "active")
1452
1486
  return failure("invalid_transition", "paused or closed tasks cannot assign workers");
1453
1487
  if (!bindingCovers(task.data.intent.data.scope, input.assignment.scope))
1454
- return failure("permission_denied", "worker assignment is outside task scope");
1488
+ return failure("permission_denied", "worker assignment is outside task scope", {
1489
+ fields: [
1490
+ {
1491
+ path: "assignment.scope",
1492
+ reason: `paths ${JSON.stringify(input.assignment.scope.paths)} must be contained by lead paths ${JSON.stringify(task.data.intent.data.scope.paths)}`,
1493
+ },
1494
+ {
1495
+ path: "task.intent.scope",
1496
+ reason: `lead paths are ${JSON.stringify(task.data.intent.data.scope.paths)}`,
1497
+ },
1498
+ ],
1499
+ });
1455
1500
  if (
1456
1501
  input.assignment.decisionIds.some(
1457
1502
  (id: string) => !task.data.decisions.some((entry) => entry.id === id),
@@ -1739,7 +1784,15 @@ export class WorkitCore {
1739
1784
  ...item,
1740
1785
  recordedAt: mutation.now,
1741
1786
  provenance: attested.data,
1742
- data: { ...item.data, state: "dispatching" as const },
1787
+ data: {
1788
+ ...item.data,
1789
+ state: "dispatching" as const,
1790
+ coordinator: {
1791
+ kind: "host" as const,
1792
+ host: this.context.caller.host,
1793
+ handle: this.context.caller.actor,
1794
+ },
1795
+ },
1743
1796
  }
1744
1797
  : item,
1745
1798
  ),
@@ -1828,6 +1881,7 @@ export class WorkitCore {
1828
1881
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
1829
1882
  state: "stopped",
1830
1883
  session: null,
1884
+ report: null,
1831
1885
  provenance: attested.data,
1832
1886
  owner: this.authorityOwner,
1833
1887
  store: this.store,
@@ -2205,11 +2259,16 @@ export class WorkitCore {
2205
2259
  const workspace = this.store.readWorkspace();
2206
2260
  if (!workspace.ok) return workspace as Result<never>;
2207
2261
  if (!workspace.data) return failure("not_found", "workspace not found");
2208
- const current = captureCandidate(this.store.root, task.intent.data.scope, environment());
2262
+ const historical = task.status === "closed" ? task.candidates.at(-1) : undefined;
2263
+ const current = historical
2264
+ ? success(null, null, historical)
2265
+ : captureCandidate(this.store.root, task.intent.data.scope, environment());
2209
2266
  if (!current.ok) return current as Result<never>;
2267
+ const evaluationWorkspace =
2268
+ task.status === "closed" ? { ...workspace.data, writer: null } : workspace.data;
2210
2269
  const requirements = evaluateRequirements(
2211
2270
  task,
2212
- workspace.data,
2271
+ evaluationWorkspace,
2213
2272
  this.context.capabilities,
2214
2273
  current.data,
2215
2274
  this.store.root,
@@ -2228,7 +2287,7 @@ export class WorkitCore {
2228
2287
  progress: task.progress,
2229
2288
  policy: task.policy,
2230
2289
  requirements,
2231
- writer: workspace.data.writer,
2290
+ writer: evaluationWorkspace.writer,
2232
2291
  });
2233
2292
  }
2234
2293
 
@@ -2236,11 +2295,16 @@ export class WorkitCore {
2236
2295
  const workspace = this.store.readWorkspace();
2237
2296
  if (!workspace.ok) return workspace as Result<never>;
2238
2297
  if (!workspace.data) return failure("not_found", "workspace not found");
2239
- const current = captureCandidate(this.store.root, task.intent.data.scope, environment());
2298
+ const historical = task.status === "closed" ? task.candidates.at(-1) : undefined;
2299
+ const current = historical
2300
+ ? success(null, null, historical)
2301
+ : captureCandidate(this.store.root, task.intent.data.scope, environment());
2240
2302
  if (!current.ok) return current as Result<never>;
2303
+ const evaluationWorkspace =
2304
+ task.status === "closed" ? { ...workspace.data, writer: null } : workspace.data;
2241
2305
  const requirements = evaluateRequirements(
2242
2306
  task,
2243
- workspace.data,
2307
+ evaluationWorkspace,
2244
2308
  this.context.capabilities,
2245
2309
  current.data,
2246
2310
  this.store.root,
@@ -2248,7 +2312,7 @@ export class WorkitCore {
2248
2312
  );
2249
2313
  return success(task.revision, workspace.data.revision, {
2250
2314
  task,
2251
- workspace: workspace.data,
2315
+ workspace: evaluationWorkspace,
2252
2316
  capabilities: this.context.capabilities,
2253
2317
  requirements,
2254
2318
  evidence: evaluateEvidence(task, current.data),
@@ -2357,8 +2421,6 @@ export class WorkitCore {
2357
2421
  (status === "active" && task.data.status !== "paused")
2358
2422
  )
2359
2423
  return failure("invalid_transition", `cannot transition ${task.data.status} to ${status}`);
2360
- if (status === "active" && input.authorityRefs.length === 0)
2361
- return failure("permission_denied", "resuming a task requires authority references");
2362
2424
  const workspace = this.store.readWorkspace();
2363
2425
  if (!workspace.ok) return workspace as Result<never>;
2364
2426
  if (!workspace.data) return failure("not_found", "workspace not found");
@@ -2403,7 +2465,7 @@ export class WorkitCore {
2403
2465
  )
2404
2466
  )
2405
2467
  return failure("recovery_required", "imported task requires resume reconciliation");
2406
- if (!validResumeApproval(task.data, workspace.data, input.authorityRefs, this.context))
2468
+ if (!validResumeApproval(task.data, workspace.data, input.authorityRefs ?? [], this.context))
2407
2469
  return failure("permission_denied", "imported resume requires native destination approval");
2408
2470
  resumeCandidate = current.data.candidate;
2409
2471
  }
@@ -2552,9 +2614,7 @@ export class WorkitCore {
2552
2614
  success(context.revision, null, {
2553
2615
  ...value,
2554
2616
  status: "closed",
2555
- candidates: value.candidates.some((item) => item.id === withCandidate.id)
2556
- ? value.candidates
2557
- : [...value.candidates, withCandidate],
2617
+ candidates: taskForView.candidates,
2558
2618
  closure: {
2559
2619
  outcome: closure.data.outcome,
2560
2620
  at: context.now,
@@ -412,17 +412,15 @@ export const resolveBinding = (
412
412
  };
413
413
  };
414
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. */
415
+ /** Evidence evaluation establishes freshness against the current tree;
416
+ * the finding's original candidate identifies where the defect was found. */
419
417
  export const findingVerificationPasses = (
420
418
  findingCandidateId: string | null | undefined,
421
419
  evidence: { kind: string; candidateId: string | null | undefined; status: string },
422
420
  ): boolean =>
423
421
  evidence.status === "passed" &&
424
422
  (evidence.kind === "check" || evidence.kind === "review") &&
425
- ((findingCandidateId ?? null) === null || evidence.candidateId === findingCandidateId);
423
+ ((findingCandidateId ?? null) === null || evidence.candidateId != null);
426
424
  const applicableDecision = (
427
425
  task: TaskRecord,
428
426
  workspace: WorkspaceRecord,
@@ -11,6 +11,7 @@ import {
11
11
  type Revision,
12
12
  type TaskRecord,
13
13
  type Worker,
14
+ type WorkerReport,
14
15
  type WorkspaceRecord,
15
16
  } from "./task-contract";
16
17
  import { TaskStore } from "./task-store";
@@ -31,6 +32,7 @@ export type NativeWorkerObservation = {
31
32
  expectedWorkspaceRevision: Revision;
32
33
  state: WorkerState;
33
34
  session: HostSession | null;
35
+ report?: WorkerReport;
34
36
  observation: unknown;
35
37
  };
36
38
 
@@ -132,6 +134,21 @@ export type ProductWriteInput = {
132
134
  store?: TaskStore;
133
135
  };
134
136
 
137
+ export const currentWriterOwnsTask = (
138
+ task: TaskRecord,
139
+ workspace: WorkspaceRecord,
140
+ caller: { host: string; actor: string },
141
+ ): boolean => {
142
+ const owner = workspace.writer?.owner;
143
+ return (
144
+ task.status === "active" &&
145
+ task.workspaceId === workspace.id &&
146
+ owner?.taskId === task.id &&
147
+ owner.session.host === caller.host &&
148
+ owner.session.handle === caller.actor
149
+ );
150
+ };
151
+
135
152
  /** Check the authoritative workspace owner and the caller's role. File paths
136
153
  * are host-policy territory: only malformed paths are rejected here, never
137
154
  * out-of-scope ones. */
package/src/core.ts CHANGED
@@ -125,6 +125,7 @@ export {
125
125
  externalActionRef,
126
126
  externalActionDescriptor,
127
127
  externalActionHelp,
128
+ planReservationLength,
128
129
  externalActionRequest,
129
130
  matchesNativeExternalAction,
130
131
  nativeExternalActionObservation,