@zq-silk/yui 0.8.2 → 0.8.3

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/README.md CHANGED
@@ -659,6 +659,28 @@ yui task complete <task-id> --summary-file delivery.txt --refresh-remote
659
659
  yui task reopen <task-id>
660
660
  ```
661
661
 
662
+ When a verified squash-merge Publication records a remote commit that is
663
+ ancestry-divergent from the unchanged, frozen Task-final Review head, a user or
664
+ global Operator may explicitly authorize completion against its identical Git
665
+ tree:
666
+
667
+ ```sh
668
+ yui task complete <task-id> --summary-file delivery.txt \
669
+ --accept-published-tree <publication-id>
670
+ ```
671
+
672
+ This does not weaken normal freshness checks. Yui requires the current,
673
+ unsuperseded Publication to be merged and verified, its local commit to equal
674
+ the reviewed physical Task head, its remote commit to be ancestry-divergent,
675
+ and both commits to resolve to the same exact tree. `--refresh-remote` fetches
676
+ the remote object graph before resolving that Publication commit. For a Task
677
+ governed by a durable exact final-review contract, the user/Operator command
678
+ persists the exact authorization tuple and wakes the Task Leader; only that
679
+ contract-capable Leader may consume it and complete the Task. Tasks without
680
+ that contract retain the one-step explicit completion path. The Task event
681
+ audit records the authorization and, on completion, the accepted Project,
682
+ Publication, ReviewRound, both commits, and tree.
683
+
662
684
  Completed Tasks reject messages, dispatch, Provider authority changes, retry, and late yields until explicitly reopened, while retaining Task main for inspection or integration. Every isolated WorkItem worktree must be explicitly cleaned as integrated or abandoned before archive; that cleanup also removes its managed branch. Archive requires `--integrated` or `--abandon` to state the Task main outcome and is allowed only after Task main is clean. It removes managed worktrees but retains Task and WorkItem records. The Task main branch is retained as a recovery artifact instead of being silently deleted.
663
685
  Task lifecycle completion/selection only suggests valid source states: Draft for activate, active for complete, and completed for reopen.
664
686
 
@@ -277,8 +277,8 @@ const taskChildren = [
277
277
  {
278
278
  name: "complete",
279
279
  summary: "Complete an active Task and stop automatic wakeups.",
280
- usage: "yui task complete <id> (--summary <text>|--summary-file <path|->) [--refresh-remote]",
281
- options: ["--summary", "--summary-file", "--refresh-remote"],
280
+ usage: "yui task complete <id> (--summary <text>|--summary-file <path|->) [--refresh-remote] [--accept-published-tree <publication-id>]",
281
+ options: ["--summary", "--summary-file", "--refresh-remote", "--accept-published-tree"],
282
282
  fileOptions: ["--summary-file"]
283
283
  },
284
284
  {
@@ -727,8 +727,8 @@ const taskChildren = [
727
727
  {
728
728
  name: "expand",
729
729
  summary: "Expand one authorized Run context reference.",
730
- usage: "yui task run context expand <task>/<run> <ref-id> [--mode full]",
731
- options: ["--mode"]
730
+ usage: "yui task run context expand <task>/<run> <ref-id> [--store <store>] [--mode full]",
731
+ options: ["--store", "--mode"]
732
732
  },
733
733
  {
734
734
  name: "delta",
@@ -751,7 +751,7 @@ const taskChildren = [
751
751
  {
752
752
  name: "review",
753
753
  summary: "Control Task-final ReviewRounds.",
754
- sections: [{ id: "manage", title: "Commands", entries: ["request", "group", "retry", "finding"] }],
754
+ sections: [{ id: "manage", title: "Commands", entries: ["request", "force-fresh", "group", "retry", "finding"] }],
755
755
  children: [
756
756
  {
757
757
  name: "request",
@@ -759,6 +759,11 @@ const taskChildren = [
759
759
  usage: "yui task review request <task> --role <global-role> [--strategy fixed:<count>|adaptive:<max>] [--lane-role <role> ...] [--delta-recheck]",
760
760
  options: ["--role", "--strategy", "--lane-role", "--delta-recheck"]
761
761
  },
762
+ {
763
+ name: "force-fresh",
764
+ summary: "Replace one exact non-semantic failed Task-final Review with a distinct full Round.",
765
+ usage: "yui task review force-fresh <task>/<review-round>"
766
+ },
762
767
  {
763
768
  name: "group",
764
769
  summary: "Resolve a Reviewer ExecutionGroup after its Lanes finish.",
package/dist/cli.js CHANGED
@@ -48,7 +48,7 @@ import { createUpdatePorts } from "./cli/updatePorts.js";
48
48
  import { createReleaseWorkflowPorts } from "./release/releaseWorkflowPorts.js";
49
49
  import { readRuntimeIdentity } from "./release/runtimeRelease.js";
50
50
  import { renderReleaseActivateResult, renderReleaseInstallResult, renderReleaseList, runReleaseActivate, runReleaseInstall, runReleaseList } from "./commands/releaseCommands.js";
51
- import { reconcileTaskRemoteBaselines } from "./commands/taskCompletionGate.js";
51
+ import { reconcileTaskRemoteBaselines, verifyTaskCompletionPublishedTree } from "./commands/taskCompletionGate.js";
52
52
  import { runTaskBaseStatusCommand } from "./commands/taskBaseCommands.js";
53
53
  import { assertTaskBaseFreshnessForCompletion, inspectTaskBaseFreshness } from "./repository/taskBaseFreshness.js";
54
54
  import { FileCompletionManager, resolveCliIdentity } from "./completion/fileCompletionManager.js";
@@ -944,6 +944,7 @@ export async function main() {
944
944
  }
945
945
  }
946
946
  let completionSummary;
947
+ let completionPublishedTreeProof;
947
948
  if (resolved[1] === "base" && resolved[2] === "status") {
948
949
  const result = await runTaskBaseStatusCommand(resolved.slice(3), store);
949
950
  emit(result.output, false, result.data);
@@ -958,12 +959,26 @@ export async function main() {
958
959
  ...(taskFinalReviewContract === undefined
959
960
  ? {}
960
961
  : { taskFinalReviewContract })
961
- });
962
+ }, completionRequest);
962
963
  if (!completion.completed && !completion.activeTaskReview) {
963
- const freshness = await inspectTaskBaseFreshness(resolved[2], store, {
964
- refresh: refreshRemote
965
- });
966
- for (const warning of assertTaskBaseFreshnessForCompletion(freshness)) {
964
+ // An explicit refresh must fetch the remote object graph before the
965
+ // Publication proof resolves its exact commit. Without the flag the
966
+ // command remains offline and preserves the existing proof-first path.
967
+ const refreshedFreshness = refreshRemote
968
+ ? await inspectTaskBaseFreshness(resolved[2], store, { refresh: true })
969
+ : undefined;
970
+ if (completionRequest.acceptedPublishedTreePublicationId !== undefined) {
971
+ completionPublishedTreeProof = await verifyTaskCompletionPublishedTree(completionRequest.taskId, completionRequest.acceptedPublishedTreePublicationId, store);
972
+ }
973
+ const freshness = refreshedFreshness
974
+ ?? await inspectTaskBaseFreshness(resolved[2], store);
975
+ for (const warning of assertTaskBaseFreshnessForCompletion(freshness, {
976
+ ...(completionPublishedTreeProof === undefined
977
+ ? {}
978
+ : {
979
+ acceptedPublishedTreeProjectId: completionPublishedTreeProof.projectId
980
+ })
981
+ })) {
967
982
  process.stderr.write(`Warning: ${warning}\n`);
968
983
  }
969
984
  // Keep completion offline by default. An explicit refresh is the only
@@ -1002,6 +1017,9 @@ export async function main() {
1002
1017
  ? {}
1003
1018
  : { taskFinalReviewContract }),
1004
1019
  ...(completionSummary === undefined ? {} : { completionSummary }),
1020
+ ...(completionPublishedTreeProof === undefined
1021
+ ? {}
1022
+ : { completionPublishedTreeProof }),
1005
1023
  ...(workItemIntegrationProof === undefined ? {} : { workItemIntegrationProof }),
1006
1024
  ...(candidateGitSnapshot === undefined ? {} : { candidateGitSnapshot }),
1007
1025
  ...(candidateMaterialization === undefined
@@ -1673,6 +1691,15 @@ async function actualTaskReviewCandidateForTaskCommand(args, store, preparer, en
1673
1691
  taskId = reference.taskId;
1674
1692
  }
1675
1693
  }
1694
+ else if (args[1] === "review"
1695
+ && args[2] === "force-fresh"
1696
+ && args[3] !== undefined) {
1697
+ const reference = cliTaskRecordReference(args[3], "reviewRound", environment);
1698
+ const round = store.getReviewRound(reference.taskId, reference.localId);
1699
+ if (round !== null && (round.scope ?? "work-item") === "task") {
1700
+ taskId = reference.taskId;
1701
+ }
1702
+ }
1676
1703
  else if (args[1] === "work"
1677
1704
  && args[2] === "review"
1678
1705
  && args[3] === "retry"
@@ -7,6 +7,7 @@ import { CliError, dataError, roleNotFound, runtimeError, taskNotFound, usageErr
7
7
  import { createTaskEvent } from "../event/taskEvent.js";
8
8
  import { clearMatchingLeaderStallAttention, isRoleRunStalled, RUN_PROGRESS_EVENT, RUN_RECOVERED_EVENT } from "../scheduler/roleRunStall.js";
9
9
  import { readCommandText } from "./textInput.js";
10
+ import { assertTaskCompletionPublishedTreeProof } from "./taskCompletionGate.js";
10
11
  import { createRoleSessionSet, roleAgentSessionResumeMode, updateTaskRoleProviderRuntime } from "../executor/agentExecutor.js";
11
12
  import { currentProviderActivation, transferProviderAuthority } from "../runtime/providerRuntimeIdentity.js";
12
13
  import { resolveEffectiveLaunch } from "../executor/effectiveLaunch.js";
@@ -36,10 +37,11 @@ import { blockingProviderContinuations, projectProviderContinuations } from "../
36
37
  import { runtimeObservationFromTaskEvent } from "../runtime/runtimeObservation.js";
37
38
  import { activateTask, addTaskProjectBinding, archiveTask, completeTask, createTask, retireTask, reopenTask, updateTaskMetadata } from "../task/task.js";
38
39
  import { resolveTaskRecordReference } from "../task/taskRecordReference.js";
40
+ import { TASK_COMPLETION_PUBLISHED_TREE_AUTHORIZED_EVENT } from "../task/publicationReference.js";
39
41
  import { projectCompletionReadiness } from "../task/completionReadiness.js";
40
42
  import { resolveProject } from "../repository/project.js";
41
43
  import { acquireProjectMaintenanceLocks } from "../repository/projectMaintenanceLock.js";
42
- import { currentWorkItemCandidate, currentWorkItemExecutionGroup, workItemExecutionGroupById, createWorkItem, attachWorkItemExecutionGroup, updateWorkItemExecutionGroup, retireWorkItem, retryFailedWorkItem, submitWorkItemCandidate, updateWorkItemWriteProjects, updateWorkItemStatus } from "../workItem/workItem.js";
44
+ import { currentWorkItemCandidate, governingWorkItemCandidate, currentWorkItemExecutionGroup, workItemExecutionGroupById, createWorkItem, attachWorkItemExecutionGroup, updateWorkItemExecutionGroup, retireWorkItem, retryFailedWorkItem, submitWorkItemCandidate, updateWorkItemWriteProjects, updateWorkItemStatus } from "../workItem/workItem.js";
43
45
  import { addExecutionLane, createExecutionGroup, resolveExecutionGroup, restartExecutionLane, updateExecutionLane } from "../execution/executionGroup.js";
44
46
  import { sameTaskFinalReviewContract, taskFinalReviewConfig, validateTaskFinalReviewContract } from "../review/taskFinalReviewContract.js";
45
47
  import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
@@ -80,8 +82,12 @@ function legacyWorkItemReviewConfig(config) {
80
82
  }
81
83
  function storedTaskFinalReviewContract(store, taskId) {
82
84
  const contracts = store.listWorkItems(taskId)
83
- .flatMap(({ candidates }) => candidates)
84
- .flatMap(({ taskFinalReviewContract }) => (taskFinalReviewContract === undefined ? [] : [taskFinalReviewContract]));
85
+ .flatMap((item) => {
86
+ const candidate = governingWorkItemCandidate(item);
87
+ return candidate?.taskFinalReviewContract === undefined
88
+ ? []
89
+ : [candidate.taskFinalReviewContract];
90
+ });
85
91
  const first = contracts[0];
86
92
  if (first === undefined)
87
93
  return undefined;
@@ -97,7 +103,7 @@ function storedTaskFinalReviewContract(store, taskId) {
97
103
  * acceptance, and completion mutation must present the same verified
98
104
  * capability; shared review-config drift is intentionally irrelevant.
99
105
  */
100
- function taskFinalReviewContractForMutation(store, taskId, options) {
106
+ function taskFinalReviewContractForMutation(store, taskId, options, authorization = {}) {
101
107
  const supplied = options.taskFinalReviewContract;
102
108
  if (supplied !== undefined) {
103
109
  validateTaskFinalReviewContract(supplied);
@@ -113,6 +119,8 @@ function taskFinalReviewContractForMutation(store, taskId, options) {
113
119
  if (stored === undefined)
114
120
  return supplied;
115
121
  if (supplied === undefined) {
122
+ if (authorization.allowStoredWithoutSupplied === true)
123
+ return stored;
116
124
  throw usageError(`Task final-review contract is missing for ${taskId}.`);
117
125
  }
118
126
  if (!sameTaskFinalReviewContract(stored, supplied)) {
@@ -121,8 +129,8 @@ function taskFinalReviewContractForMutation(store, taskId, options) {
121
129
  return stored;
122
130
  }
123
131
  export function parseTaskCompletionRequest(args, summaryOverride) {
124
- const usage = "Task complete usage: yui task complete <id> (--summary <text>|--summary-file <path|->) [--refresh-remote].";
125
- const parsed = parseTail(args, new Set(["--summary", "--summary-file"]), usage, new Set(["--refresh-remote"]));
132
+ const usage = "Task complete usage: yui task complete <id> (--summary <text>|--summary-file <path|->) [--refresh-remote] [--accept-published-tree <publication-id>].";
133
+ const parsed = parseTail(args, new Set(["--summary", "--summary-file", "--accept-published-tree"]), usage, new Set(["--refresh-remote"]));
126
134
  exactPositionals(parsed.positionals, 1, usage);
127
135
  const inlineSummary = parsed.options.get("--summary");
128
136
  const summaryFile = parsed.options.get("--summary-file");
@@ -130,7 +138,14 @@ export function parseTaskCompletionRequest(args, summaryOverride) {
130
138
  throw usageError(`Specify exactly one of --summary or --summary-file.`, usage);
131
139
  }
132
140
  const summary = summaryOverride ?? readCommandText(inlineSummary, summaryFile, "--summary", usage);
133
- return { taskId: parsed.positionals[0], summary };
141
+ const acceptedPublishedTreePublicationId = parsed.options.get("--accept-published-tree");
142
+ return {
143
+ taskId: parsed.positionals[0],
144
+ summary,
145
+ ...(acceptedPublishedTreePublicationId === undefined
146
+ ? {}
147
+ : { acceptedPublishedTreePublicationId })
148
+ };
134
149
  }
135
150
  /**
136
151
  * Check every read-only completion blocker before a caller resolves remote
@@ -144,7 +159,7 @@ export function parseTaskCompletionRequest(args, summaryOverride) {
144
159
  * terminalization precondition before attempting completion. All blockers
145
160
  * are reported in one error instead of one per attempt.
146
161
  */
147
- export function preflightTaskCompletion(taskId, store, options = {}) {
162
+ export function preflightTaskCompletion(taskId, store, options = {}, request = {}) {
148
163
  const task = requireTask(store, taskId);
149
164
  const actor = taskActor(options, task.id);
150
165
  if (task.status === "completed") {
@@ -157,7 +172,15 @@ export function preflightTaskCompletion(taskId, store, options = {}) {
157
172
  // Resolve and authenticate the durable Task-local gate before any remote
158
173
  // fetch or Integration write. All checks below mirror the transactional
159
174
  // completion path, which remains the final CAS fence after reconciliation.
160
- const taskFinalReviewContract = taskFinalReviewContractForMutation(store, task.id, options);
175
+ // A human/global Operator cannot present the exact managed Leader contract.
176
+ // For the explicit published-tree path only, let that caller authenticate
177
+ // the stored contract far enough to persist an exact authorization fact.
178
+ // The same command must return before any contract-governed completion
179
+ // mutation; the exact Leader later consumes the authorization with the real
180
+ // contract capability.
181
+ const authorizingPublishedTree = request.acceptedPublishedTreePublicationId !== undefined
182
+ && actor !== "leader";
183
+ const taskFinalReviewContract = taskFinalReviewContractForMutation(store, task.id, options, { allowStoredWithoutSupplied: authorizingPublishedTree });
161
184
  const activeTaskReview = store.listReviewRounds(task.id).some((round) => ((round.scope ?? "work-item") === "task"
162
185
  && (round.status === "pending" || round.status === "running")));
163
186
  // Issue 06: one shared readiness projection enumerates every blocker.
@@ -647,17 +670,42 @@ function completeTaskCommand(args, store, options) {
647
670
  const summary = request.summary;
648
671
  const now = clock(options);
649
672
  const result = store.transaction((tx) => {
650
- const preflight = preflightTaskCompletion(request.taskId, tx, options);
673
+ const preflight = preflightTaskCompletion(request.taskId, tx, options, request);
651
674
  const { task, actor } = preflight;
652
675
  if (preflight.completed) {
653
676
  return {
654
677
  task,
655
678
  changed: false,
656
679
  runtimeCleanupTargets: [],
657
- finalReview: undefined
680
+ finalReview: undefined,
681
+ publishedTreeAuthorization: undefined
658
682
  };
659
683
  }
660
684
  const taskFinalContract = preflight.taskFinalReviewContract;
685
+ const publishedTreeProof = request.acceptedPublishedTreePublicationId === undefined
686
+ ? undefined
687
+ : assertTaskCompletionPublishedTreeProof(tx, task, request.acceptedPublishedTreePublicationId, options.completionPublishedTreeProof, actualTaskReviewCandidateForMutation(tx, task, options));
688
+ const requiresContractHandoff = publishedTreeProof !== undefined
689
+ && taskFinalContract !== undefined;
690
+ if (requiresContractHandoff && actor !== "leader") {
691
+ const existing = matchingPublishedTreeAuthorization(tx, publishedTreeProof);
692
+ const event = existing ?? recordTaskEventRecord(tx, task.id, TASK_COMPLETION_PUBLISHED_TREE_AUTHORIZED_EVENT, publishedTreeAuthorizationPayload(actor, publishedTreeProof), now);
693
+ enqueueWork(tx, leaderMailbox(task.id), wakeReason("published-tree-authorized", event.id), now, [eventRef(task.id, event.id)]);
694
+ return {
695
+ task,
696
+ changed: false,
697
+ runtimeCleanupTargets: [],
698
+ finalReview: undefined,
699
+ publishedTreeAuthorization: {
700
+ event,
701
+ proof: publishedTreeProof,
702
+ created: existing === undefined
703
+ }
704
+ };
705
+ }
706
+ if (publishedTreeProof !== undefined && actor === "leader") {
707
+ requirePublishedTreeAuthorization(tx, publishedTreeProof);
708
+ }
661
709
  const roles = tx.listRoles(task.id);
662
710
  const activeRuns = roles
663
711
  .map((role) => ({ role, run: tx.getActiveAgentRun(task.id, role.name) }))
@@ -706,7 +754,8 @@ function completeTaskCommand(args, store, options) {
706
754
  runtimeCleanupTargets: [],
707
755
  finalReview,
708
756
  resumedPendingFinalReview: pendingFinalReviewIds.has(finalReview.id),
709
- terminalizedLeaderRun
757
+ terminalizedLeaderRun,
758
+ publishedTreeAuthorization: undefined
710
759
  };
711
760
  }
712
761
  // Issue 06: re-validate the full completion readiness inside the
@@ -729,6 +778,17 @@ function completeTaskCommand(args, store, options) {
729
778
  tx.clearOperatorNotification(task.id);
730
779
  tx.saveOperatorNotification(createTaskTerminalNotification(task.id, "completed", actor, summary, now));
731
780
  enqueueWork(tx, { kind: "operator" }, "task-terminal", now, [taskRef(task.id)]);
781
+ if (publishedTreeProof !== undefined) {
782
+ recordTaskEvent(tx, task.id, "task.completion-published-tree-accepted", {
783
+ by: actor,
784
+ projectId: publishedTreeProof.projectId,
785
+ publicationId: publishedTreeProof.publicationId,
786
+ reviewRoundId: publishedTreeProof.reviewRoundId,
787
+ localCommit: publishedTreeProof.localCommit,
788
+ remoteCommit: publishedTreeProof.remoteCommit,
789
+ tree: publishedTreeProof.tree
790
+ }, now);
791
+ }
732
792
  recordTaskEvent(tx, task.id, "task.completed", { by: actor, summary }, now);
733
793
  // A terminal Task must never leave a Task-lane signal that can wake it.
734
794
  // The durable records remain intact; only the derived mailbox work is
@@ -747,9 +807,21 @@ function completeTaskCommand(args, store, options) {
747
807
  runtimeCleanupTargets,
748
808
  finalReview: undefined,
749
809
  resumedPendingFinalReview: false,
750
- terminalizedLeaderRun
810
+ terminalizedLeaderRun,
811
+ publishedTreeAuthorization: undefined
751
812
  };
752
813
  });
814
+ if (result.publishedTreeAuthorization !== undefined) {
815
+ notifyMailbox(options.runtime, leaderMailbox(result.task.id), result.task.id);
816
+ const authorization = result.publishedTreeAuthorization;
817
+ return output(authorization.created
818
+ ? `Authorized published-tree completion for ${result.task.id} as ${authorization.event.id}; exact Task Leader completion is required.\n`
819
+ : `Published-tree completion is already authorized for ${result.task.id} as ${authorization.event.id}; exact Task Leader completion is required.\n`, {
820
+ task: result.task,
821
+ authorizationEvent: authorization.event,
822
+ publishedTreeProof: authorization.proof
823
+ });
824
+ }
753
825
  if (result.changed) {
754
826
  for (const target of result.runtimeCleanupTargets) {
755
827
  // Cleanup owns an independent lifecycle lane. Never fall back to the
@@ -2608,6 +2680,8 @@ function taskReviewCommand(args, store, options) {
2608
2680
  const [command, ...rest] = args;
2609
2681
  if (command === "request")
2610
2682
  return requestTaskReviewRound(rest, store, options);
2683
+ if (command === "force-fresh")
2684
+ return forceFreshTaskReviewRound(rest, store, options);
2611
2685
  if (command === "retry")
2612
2686
  return retryFailedTaskReviewRound(rest, store, options);
2613
2687
  if (command === "group")
@@ -3078,6 +3152,209 @@ function requestTaskReviewRound(args, store, options) {
3078
3152
  : `Task-final delta-recheck requested as ${round.id} (rechecks ${round.deltaRecheck.previousReviewRoundId})\n`
3079
3153
  : `Task-final Review is already ${round.status}: ${round.id}\n`, { reviewRound: round });
3080
3154
  }
3155
+ const TASK_FINAL_FORCE_FRESH_EVENT = "review.task-final-force-fresh-requested";
3156
+ /**
3157
+ * Creates a distinct full Task-final ReviewRound only when the exact previous
3158
+ * Round failed without producing semantic review evidence. The source Round,
3159
+ * Run, findings, workspace, and terminal report remain immutable history; the
3160
+ * linking Event is both the audit record and the idempotence key.
3161
+ */
3162
+ function forceFreshTaskReviewRound(args, store, options) {
3163
+ const usage = "Task review force-fresh usage: yui task review force-fresh <task>/<review-round>.";
3164
+ exactPositionals(args, 1, usage);
3165
+ const reference = taskRecordReference(args[0], "reviewRound", "ReviewRound reference", options);
3166
+ const now = clock(options);
3167
+ const result = store.transaction((tx) => {
3168
+ const source = tx.getReviewRound(reference.taskId, reference.localId);
3169
+ if (source === null) {
3170
+ throw dataError(`ReviewRound not found: ${reference.taskId}/${reference.localId}.`);
3171
+ }
3172
+ const task = requireTask(tx, reference.taskId);
3173
+ if (task.status !== "active")
3174
+ throw usageError(`Task is not active: ${task.id}.`);
3175
+ if (taskActor(options, task.id) !== "leader") {
3176
+ throw usageError("Only the Task Leader may force a fresh Task-final ReviewRound.");
3177
+ }
3178
+ if ((source.scope ?? "work-item") !== "task") {
3179
+ throw usageError(`ReviewRound ${source.id} is not a Task-final ReviewRound.`);
3180
+ }
3181
+ const replacementEvents = tx.listEvents(task.id).filter((event) => (event.type === TASK_FINAL_FORCE_FRESH_EVENT
3182
+ && event.payload.sourceReviewRoundId === source.id));
3183
+ if (replacementEvents.length > 1) {
3184
+ throw dataError(`ReviewRound ${source.id} has duplicate force-fresh audit events.`);
3185
+ }
3186
+ const replacementEvent = replacementEvents[0];
3187
+ if (replacementEvent !== undefined) {
3188
+ const replacementId = replacementEvent.payload.reviewRoundId;
3189
+ const replacement = replacementId === undefined
3190
+ ? null
3191
+ : tx.getReviewRound(task.id, replacementId);
3192
+ if (replacement === null
3193
+ || replacement.id === source.id
3194
+ || (replacement.scope ?? "work-item") !== "task"
3195
+ || replacement.workItemId !== source.workItemId
3196
+ || replacement.candidateId !== source.candidateId
3197
+ || replacement.reviewerRoleName !== source.reviewerRoleName
3198
+ || replacement.deltaRecheck !== undefined
3199
+ || !sameTaskFinalReviewContract(replacement.taskFinalReviewContract, source.taskFinalReviewContract)
3200
+ || !isSameTaskReviewCandidate(replacement.taskCandidate, source.taskCandidate)
3201
+ || replacementEvent.payload.workItemId !== replacement.workItemId
3202
+ || replacementEvent.payload.candidateId !== replacement.candidateId
3203
+ || replacementEvent.payload.reviewerRoleName !== replacement.reviewerRoleName
3204
+ || replacementEvent.payload.taskCandidate !== JSON.stringify(replacement.taskCandidate)) {
3205
+ throw dataError(`Force-fresh audit for ${source.id} does not match its replacement Round.`);
3206
+ }
3207
+ return { round: replacement, source, created: false };
3208
+ }
3209
+ const semanticBlocker = forceFreshSemanticBlocker(tx, source);
3210
+ if (semanticBlocker !== null) {
3211
+ throw usageError(`ReviewRound ${source.id} is not eligible for force-fresh: ${semanticBlocker}`);
3212
+ }
3213
+ if (source.taskCandidate === undefined) {
3214
+ throw dataError(`ReviewRound ${source.id} has no frozen Task candidate.`);
3215
+ }
3216
+ const taskFinalContract = taskFinalReviewContractForMutation(tx, task.id, options);
3217
+ if (!sameTaskFinalReviewContract(source.taskFinalReviewContract, taskFinalContract)) {
3218
+ throw usageError(`Task final-review contract does not match ReviewRound ${source.id}.`);
3219
+ }
3220
+ if (source.executionGroup !== undefined
3221
+ && (source.executionGroup.strategy.mode !== "fixed"
3222
+ || source.executionGroup.strategy.count !== 1
3223
+ || source.executionGroup.lanes.length !== 1
3224
+ || source.executionGroup.lanes[0].roleName !== source.reviewerRoleName)) {
3225
+ throw usageError(`ReviewRound ${source.id} is not a single-Reviewer full Review; force-fresh is refused.`);
3226
+ }
3227
+ const item = tx.getWorkItem(task.id, source.workItemId);
3228
+ const candidate = item?.candidates.find(({ id }) => id === source.candidateId);
3229
+ if (item === null || item === undefined || candidate === undefined) {
3230
+ throw dataError(`Final Review anchor Candidate is no longer available: `
3231
+ + `${source.workItemId}/${source.candidateId}.`);
3232
+ }
3233
+ const provenance = taskReviewProvenance(tx, task, options);
3234
+ if (!isSameTaskReviewCandidate(source.taskCandidate, provenance.candidate)) {
3235
+ throw usageError(`Final ReviewRound ${source.id} freezes a candidate that is no longer the current Task candidate.`);
3236
+ }
3237
+ const producerCollision = taskReviewProducerCollision(provenance, source.reviewerRoleName);
3238
+ if (producerCollision !== null)
3239
+ throw usageError(producerCollision);
3240
+ const taskRounds = reviewRoundsByIdentity(tx.listReviewRounds(task.id)
3241
+ .filter((entry) => (entry.scope ?? "work-item") === "task"));
3242
+ const sourceIndex = taskRounds.findIndex(({ id }) => id === source.id);
3243
+ if (sourceIndex < 0) {
3244
+ throw dataError(`Final ReviewRound is not in Task history: ${source.id}.`);
3245
+ }
3246
+ const laterRound = taskRounds.slice(sourceIndex + 1).at(-1);
3247
+ if (laterRound !== undefined) {
3248
+ throw usageError(`A newer Task-final ReviewRound already exists after ${source.id}: `
3249
+ + `${laterRound.id}/${laterRound.status}.`);
3250
+ }
3251
+ assertNoConflictingTaskReviewRound(taskRounds, source.id);
3252
+ assertTaskReviewRequestLane(tx, task.id, source.reviewerRoleName);
3253
+ let reviewer = tx.getRole(task.id, source.reviewerRoleName);
3254
+ if (reviewer === null) {
3255
+ if (tx.getGlobalRole(source.reviewerRoleName) === null) {
3256
+ throw usageError(`Global Role not found: ${source.reviewerRoleName}.`);
3257
+ }
3258
+ reviewer = createTaskRole(tx, task, source.reviewerRoleName, undefined, now, source.reviewerRoleName);
3259
+ tx.saveRole(task.id, reviewer);
3260
+ }
3261
+ let created = createTaskReviewRound(tx.nextReviewRoundId(task.id), task.id, source.workItemId, source.candidateId, source.reviewerRoleName, "leader", source.taskCandidate, now, taskFinalContract);
3262
+ const group = createExecutionGroup(`execution-group-${created.id}`, task.id, {
3263
+ purpose: "review",
3264
+ target: executionTargetForReviewRound(task, created, item, candidate),
3265
+ strategy: { mode: "fixed", count: 1 },
3266
+ lanes: [{ roleName: reviewer.name, reviewRoundId: created.id }]
3267
+ }, now);
3268
+ created = { ...created, executionGroup: group };
3269
+ tx.saveReviewRound(task.id, created);
3270
+ recordTaskEvent(tx, task.id, TASK_FINAL_FORCE_FRESH_EVENT, {
3271
+ sourceReviewRoundId: source.id,
3272
+ ...(source.reviewerRunId === undefined ? {} : { sourceReviewerRunId: source.reviewerRunId }),
3273
+ reviewRoundId: created.id,
3274
+ workItemId: created.workItemId,
3275
+ candidateId: created.candidateId,
3276
+ reviewerRoleName: created.reviewerRoleName,
3277
+ taskCandidate: JSON.stringify(created.taskCandidate),
3278
+ reason: "source-round-failed-without-semantic-review",
3279
+ leaderActionRunId: taskLeaderActionRunId(tx, task.id, options.environment, options.yuiHome) ?? "leader"
3280
+ }, now);
3281
+ return { round: created, source, created: true };
3282
+ });
3283
+ return output(result.created
3284
+ ? `Fresh Task-final Review requested as ${result.round.id} after ${result.source.id}\n`
3285
+ : `Fresh Task-final Review already requested as ${result.round.id} after ${result.source.id}\n`, { reviewRound: result.round, sourceReviewRound: result.source });
3286
+ }
3287
+ /** Returns the exact semantic evidence that makes a failed Round ineligible. */
3288
+ function forceFreshSemanticBlocker(store, round) {
3289
+ if (round.status !== "failed")
3290
+ return `source status is ${round.status}, not failed.`;
3291
+ if ((round.checks ?? []).length > 0)
3292
+ return "the Round records review checks.";
3293
+ if (round.evidenceCommit !== undefined)
3294
+ return "the Round records a review evidence commit.";
3295
+ if (round.report !== round.summary) {
3296
+ return "the Round stores a report distinct from its failure summary.";
3297
+ }
3298
+ if (runtimeFailureSummaryHasReviewerOutput(round.report ?? "")) {
3299
+ return "the Round stores non-empty Reviewer output in its runtime failure summary.";
3300
+ }
3301
+ if (round.deltaRecheck?.disposition !== undefined
3302
+ || round.deltaRecheck?.reasoning !== undefined) {
3303
+ return "the Round records a semantic delta-recheck disposition.";
3304
+ }
3305
+ const semanticLane = round.executionGroup?.lanes.find((lane) => (lane.status === "yielded"
3306
+ || lane.status === "completed"
3307
+ || lane.result?.report !== undefined
3308
+ || (lane.result?.checks ?? []).length > 0
3309
+ || (lane.result?.findings ?? []).length > 0
3310
+ || (lane.result?.evidence ?? []).length > 0
3311
+ || lane.result?.evidenceCommit !== undefined));
3312
+ if (semanticLane !== undefined) {
3313
+ return `Reviewer Lane ${semanticLane.id} delivered semantic evidence.`;
3314
+ }
3315
+ const yieldedRun = store.listAgentRuns(round.taskId).find((run) => (run.purpose === "review"
3316
+ && run.reviewRoundId === round.id
3317
+ && run.status === "yielded"));
3318
+ if (yieldedRun !== undefined)
3319
+ return `Reviewer Run ${yieldedRun.id} yielded a report.`;
3320
+ const finding = store.listReviewFindings(round.taskId).find((entry) => (entry.firstReviewRoundId === round.id || entry.lastReviewRoundId === round.id));
3321
+ if (finding !== undefined)
3322
+ return `Review finding ${finding.id} references the Round.`;
3323
+ const semanticEvent = store.listEvents(round.taskId).find((event) => (event.type === "review.completed" && event.payload.reviewRoundId === round.id));
3324
+ if (semanticEvent !== undefined)
3325
+ return `Review completion Event ${semanticEvent.id} exists.`;
3326
+ if (looksLikeStructuredReviewReport(round.report ?? "")) {
3327
+ return "the Round stores a structured reviewer report.";
3328
+ }
3329
+ return null;
3330
+ }
3331
+ function runtimeFailureSummaryHasReviewerOutput(summary) {
3332
+ const match = /(?:^|\n)last_assistant_message:[ \t]*([\s\S]*)$/u.exec(summary);
3333
+ const output = match?.[1];
3334
+ return output !== undefined && output.trim().length > 0;
3335
+ }
3336
+ function looksLikeStructuredReviewReport(report) {
3337
+ let parsed;
3338
+ try {
3339
+ parsed = JSON.parse(report);
3340
+ }
3341
+ catch {
3342
+ return false;
3343
+ }
3344
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
3345
+ return false;
3346
+ const record = parsed;
3347
+ return [
3348
+ "summary",
3349
+ "report",
3350
+ "checks",
3351
+ "findings",
3352
+ "evidence",
3353
+ "evidenceCommit",
3354
+ "deltaDisposition",
3355
+ "deltaReasoning"
3356
+ ].some((key) => Object.hasOwn(record, key));
3357
+ }
3081
3358
  /**
3082
3359
  * Issue 07: re-validates the CLI-computed delta preflight inside the store
3083
3360
  * transaction. The previous Round must be a completed acceptance (a full
@@ -3307,13 +3584,16 @@ function taskRunCommand(args, store, options) {
3307
3584
  function runContextCommand(args, store, options) {
3308
3585
  const [first, ...rest] = args;
3309
3586
  if (first === "expand") {
3310
- if (rest.length < 2 || rest.length > 4 || (rest.length > 2
3311
- && !(rest.length === 4 && rest[2] === "--mode" && rest[3] === "full"))) {
3312
- throw usageError("Task run context expand usage: yui task run context expand <task>/<run> <ref-id> [--mode full].");
3587
+ const usage = "Task run context expand usage: yui task run context expand <task>/<run> <ref-id> [--store <store>] [--mode full].";
3588
+ const parsed = parseTail(rest, new Set(["--store", "--mode"]), usage);
3589
+ exactPositionals(parsed.positionals, 2, usage);
3590
+ const mode = parsed.options.get("--mode");
3591
+ if (mode !== undefined && mode !== "full") {
3592
+ throw usageError("Run Context expansion mode must be full.", usage);
3313
3593
  }
3314
- const { taskId, runId } = parseRunContextReference(rest[0]);
3594
+ const { taskId, runId } = parseRunContextReference(parsed.positionals[0]);
3315
3595
  authorizeRunContext(store, taskId, runId, options.environment);
3316
- const expanded = store.transaction((tx) => expandRunContextRef(tx, taskId, runId, rest[1]));
3596
+ const expanded = store.transaction((tx) => expandRunContextRef(tx, taskId, runId, parsed.positionals[1], optionalNonEmptyOption(parsed.options, "--store")));
3317
3597
  return output(`${JSON.stringify(expanded, null, 2)}\n`, { context: expanded });
3318
3598
  }
3319
3599
  if (first === "delta") {
@@ -4051,15 +4331,20 @@ function assertPendingFinalReviewWorkspaceEvidence(store, task, round) {
4051
4331
  }
4052
4332
  }
4053
4333
  function latestTaskReviewContractAnchor(store, task, taskFinalContract) {
4054
- const item = [...store.listWorkItems(task.id)]
4055
- .filter(({ candidates }) => candidates.some(({ taskFinalReviewContract: candidateContract }) => (sameTaskFinalReviewContract(candidateContract, taskFinalContract))))
4056
- .sort((left, right) => (left.updatedAt.localeCompare(right.updatedAt) || left.id.localeCompare(right.id)))
4334
+ const anchor = store.listWorkItems(task.id)
4335
+ .flatMap((item) => {
4336
+ const candidate = governingWorkItemCandidate(item);
4337
+ return candidate !== undefined && sameTaskFinalReviewContract(candidate.taskFinalReviewContract, taskFinalContract)
4338
+ ? [{ item, candidate }]
4339
+ : [];
4340
+ })
4341
+ .sort((left, right) => (left.item.updatedAt.localeCompare(right.item.updatedAt)
4342
+ || left.item.id.localeCompare(right.item.id)))
4057
4343
  .at(-1);
4058
- const candidate = item?.candidates.filter(({ taskFinalReviewContract: candidateContract }) => (sameTaskFinalReviewContract(candidateContract, taskFinalContract))).at(-1);
4059
- if (item === undefined || candidate === undefined) {
4344
+ if (anchor === undefined) {
4060
4345
  throw usageError(`Task ${task.id} has no WorkItem Candidate to anchor its final Review.`);
4061
4346
  }
4062
- return { item, candidate };
4347
+ return anchor;
4063
4348
  }
4064
4349
  /**
4065
4350
  * Leader-only retry of an exact failed Task-final review Run. The old failed
@@ -5379,6 +5664,44 @@ function recordTaskEventRecord(store, taskId, type, payload, now) {
5379
5664
  store.saveEvent(taskId, event);
5380
5665
  return event;
5381
5666
  }
5667
+ function publishedTreeAuthorizationPayload(actor, proof) {
5668
+ return {
5669
+ by: actor,
5670
+ projectId: proof.projectId,
5671
+ publicationId: proof.publicationId,
5672
+ reviewRoundId: proof.reviewRoundId,
5673
+ localCommit: proof.localCommit,
5674
+ remoteCommit: proof.remoteCommit,
5675
+ tree: proof.tree
5676
+ };
5677
+ }
5678
+ function matchingPublishedTreeAuthorization(store, proof) {
5679
+ const events = store.listEvents(proof.taskId);
5680
+ for (let index = events.length - 1; index >= 0; index -= 1) {
5681
+ const event = events[index];
5682
+ if (event.type === "task.completed" || event.type === "task.reopened")
5683
+ return undefined;
5684
+ if (event.type === TASK_COMPLETION_PUBLISHED_TREE_AUTHORIZED_EVENT
5685
+ && (event.payload.by === "user" || event.payload.by === "operator")
5686
+ && event.payload.projectId === proof.projectId
5687
+ && event.payload.publicationId === proof.publicationId
5688
+ && event.payload.reviewRoundId === proof.reviewRoundId
5689
+ && event.payload.localCommit === proof.localCommit
5690
+ && event.payload.remoteCommit === proof.remoteCommit
5691
+ && event.payload.tree === proof.tree) {
5692
+ return event;
5693
+ }
5694
+ }
5695
+ return undefined;
5696
+ }
5697
+ function requirePublishedTreeAuthorization(store, proof) {
5698
+ const authorization = matchingPublishedTreeAuthorization(store, proof);
5699
+ if (authorization === undefined) {
5700
+ throw usageError(`Published-tree completion requires explicit user or global Operator authorization for `
5701
+ + `${proof.taskId}/${proof.publicationId} at Task-final Review ${proof.reviewRoundId}.`);
5702
+ }
5703
+ return authorization;
5704
+ }
5382
5705
  /** Keeps a free-text run-fact note bounded so an event payload stays compact. */
5383
5706
  function truncateEventNote(note) {
5384
5707
  const normalized = note.trim();
@@ -6327,6 +6650,9 @@ function workItemRef(taskId, id) {
6327
6650
  function messageRef(taskId, id) {
6328
6651
  return { type: "message", taskId, id };
6329
6652
  }
6653
+ function eventRef(taskId, id) {
6654
+ return { type: "event", taskId, id };
6655
+ }
6330
6656
  function notifyMailbox(runtime, target, compatibilityTaskId) {
6331
6657
  if (runtime?.notifyMailboxChanged !== undefined) {
6332
6658
  runtime.notifyMailboxChanged(target);
@@ -2,7 +2,159 @@ import { usageError } from "../errors/cliError.js";
2
2
  import { GitIntegrationService } from "../integration/gitIntegrationService.js";
3
3
  import { createIntegrationAttempt } from "../integration/integrationAttempt.js";
4
4
  import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
5
+ import { publicationExternalKey } from "../task/publicationReference.js";
5
6
  import { workspaceProjectEntry } from "../worktree/managedWorkspace.js";
7
+ /**
8
+ * Verify the one supported ancestry waiver before `task complete` mutates any
9
+ * durable state. The explicit Publication must be the current verified merged
10
+ * record, bind the exact reviewed physical Task head, and name an
11
+ * ancestry-divergent commit with the exact same Git tree.
12
+ */
13
+ export async function verifyTaskCompletionPublishedTree(taskId, publicationId, store, options = {}) {
14
+ const task = store.getTask(taskId);
15
+ if (task === null)
16
+ throw usageError(`Task not found: ${taskId}.`);
17
+ if (task.status !== "active") {
18
+ throw usageError(`Task is not active: ${task.id}.`);
19
+ }
20
+ const publication = requireCurrentVerifiedPublication(store, task.id, publicationId);
21
+ const binding = task.projectBindings.find(({ projectId }) => (projectId === publication.projectId));
22
+ if (binding === undefined) {
23
+ throw usageError(`Publication ${publication.id} Project is not bound to Task ${task.id}: `
24
+ + `${publication.projectId}.`);
25
+ }
26
+ if (publication.localCommit === undefined || publication.remoteCommit === undefined) {
27
+ throw usageError(`Publication ${publication.id} must record exact local and remote commits.`);
28
+ }
29
+ const workspace = requireTaskWorkspace(store, task);
30
+ const latestReview = latestTaskFinalReview(store, task.id);
31
+ if (latestReview === undefined
32
+ || latestReview.status !== "completed"
33
+ || latestReview.taskCandidate === undefined) {
34
+ throw usageError(`Task ${task.id} requires a latest completed Task-final Review before accepting a published tree.`);
35
+ }
36
+ const git = options.git ?? new NodeGitWorkspace();
37
+ const actualHeads = new Map();
38
+ for (const taskBinding of task.projectBindings) {
39
+ const entry = workspaceProjectEntry(workspace, taskBinding.projectId);
40
+ if (entry === undefined || entry.access !== "write") {
41
+ throw usageError(`Task ${task.id} has no writable managed main workspace for Project ${taskBinding.projectId}.`);
42
+ }
43
+ const reviewedCommit = latestReview.taskCandidate.projects.find(({ projectId }) => (projectId === taskBinding.projectId))?.commit;
44
+ if (reviewedCommit === undefined) {
45
+ throw usageError(`Task-final Review ${latestReview.id} omitted Project ${taskBinding.projectId}.`);
46
+ }
47
+ const actualCommit = (await git.inspect(entry.path, "HEAD")).baseCommit;
48
+ if (actualCommit !== reviewedCommit) {
49
+ throw usageError(`Task-final Review ${latestReview.id} does not match Task head `
50
+ + `${taskBinding.projectId}@${actualCommit}.`);
51
+ }
52
+ actualHeads.set(taskBinding.projectId, actualCommit);
53
+ }
54
+ if (actualHeads.size !== latestReview.taskCandidate.projects.length) {
55
+ throw usageError(`Task-final Review ${latestReview.id} Project set does not match Task ${task.id}.`);
56
+ }
57
+ const entry = workspaceProjectEntry(workspace, publication.projectId);
58
+ const localCommit = actualHeads.get(publication.projectId);
59
+ if (publication.localCommit !== localCommit) {
60
+ throw usageError(`Publication ${publication.id} local commit ${publication.localCommit} `
61
+ + `does not match Task head ${localCommit}.`);
62
+ }
63
+ let remoteCommit;
64
+ let localTree;
65
+ let remoteTree;
66
+ try {
67
+ remoteCommit = (await git.inspect(entry.path, publication.remoteCommit)).baseCommit;
68
+ [localTree, remoteTree] = await Promise.all([
69
+ git.resolveTree(entry.path, localCommit),
70
+ git.resolveTree(entry.path, remoteCommit)
71
+ ]);
72
+ }
73
+ catch (error) {
74
+ throw usageError(`Publication ${publication.id} commit/tree evidence is unavailable: `
75
+ + `${error instanceof Error ? error.message : String(error)}`);
76
+ }
77
+ if (remoteCommit !== publication.remoteCommit) {
78
+ throw usageError(`Publication ${publication.id} remote commit changed: expected `
79
+ + `${publication.remoteCommit}, found ${remoteCommit}.`);
80
+ }
81
+ const [localContainsRemote, remoteContainsLocal] = await Promise.all([
82
+ git.isAncestor(entry.path, remoteCommit, localCommit),
83
+ git.isAncestor(entry.path, localCommit, remoteCommit)
84
+ ]);
85
+ if (localContainsRemote || remoteContainsLocal) {
86
+ throw usageError(`Publication ${publication.id} is not ancestry-divergent from Task head ${localCommit}; `
87
+ + "use normal Task completion.");
88
+ }
89
+ if (localTree !== remoteTree) {
90
+ throw usageError(`Publication ${publication.id} Git trees differ: `
91
+ + `${localCommit}^{tree}=${localTree}, ${remoteCommit}^{tree}=${remoteTree}.`);
92
+ }
93
+ return {
94
+ taskId: task.id,
95
+ projectId: publication.projectId,
96
+ publicationId: publication.id,
97
+ reviewRoundId: latestReview.id,
98
+ localCommit,
99
+ remoteCommit,
100
+ tree: localTree
101
+ };
102
+ }
103
+ /** Re-derive every durable half of the asynchronous Git proof under the Task
104
+ * completion transaction. The physical heads are the CLI snapshot captured
105
+ * immediately before mutation; any record or head drift fails closed. */
106
+ export function assertTaskCompletionPublishedTreeProof(store, task, publicationId, proof, actualCandidate) {
107
+ if (proof === undefined
108
+ || proof.taskId !== task.id
109
+ || proof.publicationId !== publicationId) {
110
+ throw usageError(`Published-tree completion proof is missing or mismatched for ${task.id}/${publicationId}.`);
111
+ }
112
+ const publication = requireCurrentVerifiedPublication(store, task.id, publicationId);
113
+ if (publication.projectId !== proof.projectId
114
+ || publication.localCommit !== proof.localCommit
115
+ || publication.remoteCommit !== proof.remoteCommit) {
116
+ throw usageError(`Publication evidence changed before Task completion: ${publication.id}.`);
117
+ }
118
+ const latestReview = latestTaskFinalReview(store, task.id);
119
+ if (latestReview === undefined
120
+ || latestReview.id !== proof.reviewRoundId
121
+ || latestReview.status !== "completed"
122
+ || latestReview.taskCandidate === undefined
123
+ || !sameTaskCandidate(latestReview.taskCandidate, actualCandidate)) {
124
+ throw usageError(`Task-final Review evidence changed before published-tree completion: ${task.id}.`);
125
+ }
126
+ const actualCommit = actualCandidate.projects.find(({ projectId }) => (projectId === proof.projectId))?.commit;
127
+ if (actualCommit !== proof.localCommit) {
128
+ throw usageError(`Task head changed before published-tree completion: `
129
+ + `${proof.projectId}@${actualCommit ?? "missing"}.`);
130
+ }
131
+ return proof;
132
+ }
133
+ function requireCurrentVerifiedPublication(store, taskId, publicationId) {
134
+ const publication = store.getPublicationReference(taskId, publicationId);
135
+ if (publication === null) {
136
+ throw usageError(`Publication reference not found: ${taskId}/${publicationId}.`);
137
+ }
138
+ const current = store.findPublicationReferenceByExternalKey(publicationExternalKey(publication));
139
+ if (current === null || current.taskId !== taskId || current.id !== publication.id) {
140
+ throw usageError(`Publication ${publication.id} is not the current unsuperseded record for its external identity.`);
141
+ }
142
+ if (publication.state !== "merged" || publication.verification !== "verified") {
143
+ throw usageError(`Publication ${publication.id} must be merged and verified before Task completion.`);
144
+ }
145
+ return publication;
146
+ }
147
+ function latestTaskFinalReview(store, taskId) {
148
+ return store.listReviewRounds(taskId)
149
+ .filter((round) => (round.scope ?? "work-item") === "task")
150
+ .sort((left, right) => (left.id.localeCompare(right.id, undefined, { numeric: true })))
151
+ .at(-1);
152
+ }
153
+ function sameTaskCandidate(left, right) {
154
+ return left.projects.length === right.projects.length
155
+ && left.projects.every((project, index) => (project.projectId === right.projects[index]?.projectId
156
+ && project.commit === right.projects[index]?.commit));
157
+ }
6
158
  /**
7
159
  * Reconcile configured remote baselines before a Task completion attempt.
8
160
  *
@@ -1,3 +1,4 @@
1
+ import { TASK_COMPLETION_PUBLISHED_TREE_AUTHORIZED_EVENT } from "../task/publicationReference.js";
1
2
  import { RUN_BOOTSTRAP_MAX_DELTAS } from "./runContextContract.js";
2
3
  import { contextContentDigest, contextSnapshotRef, createContextSnapshot, validateContextSnapshot } from "./contextSnapshot.js";
3
4
  export const RUN_CONTEXT_PACK_SCHEMA_VERSION = 1;
@@ -123,11 +124,12 @@ export function buildRunContextPack(store, taskId, runId) {
123
124
  });
124
125
  return pack;
125
126
  }
126
- export function expandRunContextRef(store, taskId, runId, refId) {
127
+ export function expandRunContextRef(store, taskId, runId, refId, refStore) {
127
128
  const pack = buildRunContextPack(store, taskId, runId);
128
- const authorized = pack.pointers.filter((ref) => ref.refId === refId);
129
+ const authorized = pack.pointers.filter((ref) => (ref.refId === refId && (refStore === undefined || ref.store === refStore)));
130
+ const selector = refStore === undefined ? refId : `${refStore}/${refId}`;
129
131
  if (authorized.length !== 1) {
130
- throw new Error(`Run Context ref is not uniquely authorized: ${refId}.`);
132
+ throw new Error(`Run Context ref is not uniquely authorized: ${selector}.`);
131
133
  }
132
134
  const run = requireExactRun(store, taskId, runId);
133
135
  const snapshotRef = run.assignment.contextSnapshotRef;
@@ -135,7 +137,7 @@ export function expandRunContextRef(store, taskId, runId, refId) {
135
137
  ? collectAuthorizedContext(store, run).find(({ ref }) => (contextRefIdentity(ref) === contextRefIdentity(authorized[0])))
136
138
  : store.getContextSnapshot(taskId, snapshotRef.id)?.resources.find(({ ref }) => (contextRefIdentity(ref) === contextRefIdentity(authorized[0])));
137
139
  if (materialized === undefined || materialized.ref.digest !== authorized[0].digest) {
138
- throw new Error(`Run Context ref is unavailable or drifted: ${refId}.`);
140
+ throw new Error(`Run Context ref is unavailable or drifted: ${selector}.`);
139
141
  }
140
142
  const bytes = Buffer.byteLength(JSON.stringify(materialized.value), "utf8");
141
143
  if (bytes > RUN_CONTEXT_EXPAND_MAX_BYTES) {
@@ -261,6 +263,19 @@ function collectAuthorizedContext(store, run) {
261
263
  for (const message of store.listMessages(task.id).slice(-16)) {
262
264
  result.push(materialize("L4", "task-message", message.id, message));
263
265
  }
266
+ const publishedTreeAuthorizations = [];
267
+ const events = store.listEvents(task.id);
268
+ for (let index = events.length - 1; index >= 0; index -= 1) {
269
+ const event = events[index];
270
+ if (event.type === "task.completed" || event.type === "task.reopened")
271
+ break;
272
+ if (event.type === TASK_COMPLETION_PUBLISHED_TREE_AUTHORIZED_EVENT) {
273
+ publishedTreeAuthorizations.push(event);
274
+ }
275
+ }
276
+ for (const event of publishedTreeAuthorizations.reverse().slice(-16)) {
277
+ result.push(materialize("L4", "task-event", event.id, event));
278
+ }
264
279
  for (const request of store.listOpenInputRequests([task.id])) {
265
280
  result.push(materialize("L4", "input-request", request.id, request));
266
281
  }
@@ -57,7 +57,7 @@ export function materializeSessionBootstrap(input) {
57
57
  }
58
58
  : {
59
59
  loadCommand: `\"${sessionCliPath}\" task run context \"$YUI_TASK_ID/<run-id>\" --json`,
60
- expandCommand: `\"${sessionCliPath}\" task run context expand \"$YUI_TASK_ID/<run-id>\" <ref-id> --mode full --json`
60
+ expandCommand: `\"${sessionCliPath}\" task run context expand \"$YUI_TASK_ID/<run-id>\" <ref-id> --store <store> --mode full --json`
61
61
  }
62
62
  };
63
63
  const manifest = Object.freeze({ ...body, digest: digest(body) });
@@ -15,15 +15,18 @@ export class RemoteBaselineConflictError extends Error {
15
15
  }
16
16
  /** The small Git boundary used by project registration and Task workspaces. */
17
17
  export class NodeGitWorkspace {
18
+ async resolveTree(repositoryPath, commit) {
19
+ return gitLine([
20
+ "-C", repositoryPath,
21
+ "rev-parse", "--verify", "--end-of-options", `${commit}^{tree}`
22
+ ]);
23
+ }
18
24
  async findCommitWithSameTreeInHistory(input) {
19
25
  const source = (await this.inspect(input.repositoryPath, input.sourceCommit)).baseCommit;
20
26
  const history = (await this.inspect(input.repositoryPath, input.historyHead)).baseCommit;
21
27
  if (await this.isAncestor(input.repositoryPath, source, history))
22
28
  return source;
23
- const sourceTree = await gitLine([
24
- "-C", input.repositoryPath,
25
- "rev-parse", "--verify", "--end-of-options", `${source}^{tree}`
26
- ]);
29
+ const sourceTree = await this.resolveTree(input.repositoryPath, source);
27
30
  const pageSize = 1000;
28
31
  for (let skip = 0;; skip += pageSize) {
29
32
  const output = await git([
@@ -107,10 +107,12 @@ export async function inspectTaskBaseFreshness(taskId, store, options = {}) {
107
107
  }));
108
108
  return { taskId, refreshed: options.refresh === true, entries };
109
109
  }
110
- export function assertTaskBaseFreshnessForCompletion(report) {
110
+ export function assertTaskBaseFreshnessForCompletion(report, options = {}) {
111
111
  const warnings = [];
112
112
  for (const entry of report.entries) {
113
- if (entry.status === "behind" || entry.status === "diverged") {
113
+ const acceptedPublishedTree = options.acceptedPublishedTreeProjectId === entry.projectId;
114
+ if ((entry.status === "behind" || entry.status === "diverged")
115
+ && !acceptedPublishedTree) {
114
116
  throw usageError(`Task ${report.taskId} Project ${entry.projectId} base is ${entry.status}; `
115
117
  + `run 'yui task base status ${report.taskId} --refresh' and choose an explicit delivery base. `
116
118
  + "Safe resolutions are to rebase or merge the Task workspace onto the refreshed remote base, "
@@ -67,11 +67,11 @@ export function withAgentRunContextSnapshot(run, snapshot, deltaRefIds = []) {
67
67
  if (run.status !== "active" || run.pushedAt !== undefined || run.deliveredAt !== undefined) {
68
68
  throw new Error(`Cannot bind Context Snapshot after Run delivery: ${run.id}.`);
69
69
  }
70
- const assignment = validateRunAssignment(Object.freeze({
70
+ const assignment = createRunAssignment({
71
71
  ...run.assignment,
72
72
  contextSnapshotRef: snapshot,
73
73
  deltaRefIds
74
- }));
74
+ });
75
75
  return validateAgentRun(Object.freeze({
76
76
  ...run,
77
77
  assignment,
@@ -22,6 +22,7 @@ export const WAKE_REASON_KINDS = Object.freeze([
22
22
  "leader-run-failed",
23
23
  "role-run-failed",
24
24
  "job-finished",
25
+ "published-tree-authorized",
25
26
  "force-wake"
26
27
  ]);
27
28
  const IMMEDIATE_KINDS = new Set([
@@ -732,6 +732,8 @@ export class SqliteTaskStore {
732
732
  return null;
733
733
  return {
734
734
  ...base,
735
+ agentRuns: this.listAgentRuns(taskId),
736
+ roleSessionSets: this.listRoleSessionSets(taskId),
735
737
  managedWorkspaces: this.#sortById(this.#listPayload("managed_workspaces", "task_id = ?", [taskId]), (workspace) => managedWorkspaceKey(workspace.owner)),
736
738
  durableJobs: this.#sortById(this.#listPayload("durable_jobs", "task_id = ?", [taskId]), (job) => job.id),
737
739
  integrationQueueEntries: this.#sortById(this.#listPayload("integration_queue", "task_id = ?", [taskId]), (entry) => entry.id),
@@ -442,6 +442,8 @@ export class FileTaskStore {
442
442
  }
443
443
  return {
444
444
  ...base,
445
+ agentRuns: this.listAgentRuns(taskId),
446
+ roleSessionSets: this.listRoleSessionSets(taskId),
445
447
  managedWorkspaces: values(aggregate.managedWorkspaces, (workspace) => managedWorkspaceKey(workspace.owner)),
446
448
  durableJobs: values(aggregate.durableJobs, "id"),
447
449
  integrationQueueEntries: values(aggregate.integrationQueue, "id"),
@@ -142,6 +142,8 @@ export function projectCompletionReadiness(facts, options = {}) {
142
142
  }
143
143
  // Provider continuations that may still write the Workspace.
144
144
  for (const continuation of blockingProviderContinuations(facts.events)) {
145
+ if (!providerContinuationBlocksCompletion(continuation, facts))
146
+ continue;
145
147
  const identity = continuation.identity;
146
148
  blockers.push({
147
149
  code: "blocking-provider-continuation",
@@ -209,6 +211,47 @@ export function projectCompletionReadiness(facts, options = {}) {
209
211
  });
210
212
  return { taskId: task.id, ready: sorted.length === 0, blockers: sorted };
211
213
  }
214
+ /**
215
+ * A terminal Run releases only its completion blocker, never its immutable
216
+ * continuation audit. Missing or inconsistent ownership remains ambiguous and
217
+ * therefore fail-closed. A live exact native Session can still deliver or
218
+ * write for a terminal Run, so it retains the blocker until that Session is
219
+ * stopped, broken, or replaced by another Conversation identity.
220
+ */
221
+ function providerContinuationBlocksCompletion(continuation, facts) {
222
+ if (continuation.identityConflict)
223
+ return true;
224
+ const ownerRun = facts.agentRuns.find(({ id }) => id === continuation.runId);
225
+ if (ownerRun === undefined
226
+ || ownerRun.taskId !== continuation.taskId
227
+ || ownerRun.roleName !== continuation.roleName
228
+ || ownerRun.effective.agentId !== continuation.identity.accountScope) {
229
+ return true;
230
+ }
231
+ if (ownerRun.status !== "failed" && ownerRun.status !== "yielded")
232
+ return true;
233
+ const sessions = facts.roleSessionSets.find(({ owner }) => (owner.taskId === continuation.taskId && owner.roleName === continuation.roleName));
234
+ const session = sessions?.sessions[continuation.identity.accountScope];
235
+ if (session !== undefined
236
+ && session.status !== "stopped"
237
+ && session.status !== "broken"
238
+ && session.nativeSessionId === continuation.identity.conversationId) {
239
+ return true;
240
+ }
241
+ const binding = sessions?.providerBinding;
242
+ if (binding === undefined || binding === null
243
+ || binding.providerNamespace !== continuation.identity.providerNamespace
244
+ || binding.accountScope !== continuation.identity.accountScope) {
245
+ return false;
246
+ }
247
+ const conversationIsCurrent = binding.conversations.some((conversation) => (conversation.conversationId === continuation.identity.conversationId
248
+ && conversation.epoch === binding.currentConversationEpoch
249
+ && conversation.status === "current"));
250
+ const activationIsLive = binding.activations.some((activation) => (activation.activationId === continuation.identity.activationId
251
+ && activation.conversationId === continuation.identity.conversationId
252
+ && activation.status === "active"));
253
+ return conversationIsCurrent && activationIsLive;
254
+ }
212
255
  function workspaceBlocker(facts, taskId, workspace) {
213
256
  const owner = workspace.owner;
214
257
  switch (owner.type) {
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { sameTaskFinalReviewContract, validateTaskFinalReviewContract } from "../review/taskFinalReviewContract.js";
3
- import { currentWorkItemCandidate } from "../workItem/workItem.js";
3
+ import { currentWorkItemCandidate, governingWorkItemCandidate } from "../workItem/workItem.js";
4
4
  const OPEN_WORK_ITEM_STATUSES = new Set(["pending", "running", "awaiting_acceptance"]);
5
5
  export function projectNextAction(facts) {
6
6
  const { task } = facts;
@@ -602,8 +602,7 @@ function selectOpenWorkItem(workItems) {
602
602
  }
603
603
  function taskFinalReviewContract(facts) {
604
604
  const contract = facts.workItems
605
- .flatMap((item) => item.candidates)
606
- .map((candidate) => candidate.taskFinalReviewContract)
605
+ .map((item) => governingWorkItemCandidate(item)?.taskFinalReviewContract)
607
606
  .find((contract) => contract !== undefined);
608
607
  return contract === undefined ? undefined : validateTaskFinalReviewContract(contract);
609
608
  }
@@ -680,7 +679,10 @@ function detectProtocolInconsistency(facts) {
680
679
  const changeSetIds = new Set(facts.changeSets.map((changeSet) => changeSet.id));
681
680
  const workItemById = new Map(facts.workItems.map((item) => [item.id, item]));
682
681
  const contractedCandidates = facts.workItems
683
- .flatMap((item) => item.candidates.map((candidate) => ({ item, candidate })))
682
+ .flatMap((item) => {
683
+ const candidate = governingWorkItemCandidate(item);
684
+ return candidate === undefined ? [] : [{ item, candidate }];
685
+ })
684
686
  .filter(({ candidate }) => candidate.taskFinalReviewContract !== undefined);
685
687
  if (contractedCandidates.length > 1) {
686
688
  const first = validateTaskFinalReviewContract(contractedCandidates[0].candidate.taskFinalReviewContract);
@@ -1,6 +1,7 @@
1
1
  import { requireIdentity, requireText, requireTimestamp } from "../domain/validation.js";
2
2
  import { validateTaskRecordReference } from "./taskRecordReference.js";
3
3
  export const PUBLICATION_REFERENCE_SCHEMA_VERSION = 1;
4
+ export const TASK_COMPLETION_PUBLISHED_TREE_AUTHORIZED_EVENT = "task.completion-published-tree-authorized";
4
5
  const PROVIDERS = new Set(["github", "gitlab"]);
5
6
  const EXTERNAL_KINDS = new Set(["pull-request", "merge-request"]);
6
7
  const STATES = new Set(["open", "merged", "closed"]);
@@ -567,6 +567,18 @@ export function currentWorkItemCandidate(workItem) {
567
567
  ? workItem.candidates.at(-1)
568
568
  : undefined;
569
569
  }
570
+ /**
571
+ * Resolve the one Candidate that still governs Task delivery semantics.
572
+ * Awaiting Candidates remain under Leader disposition, while completed
573
+ * Candidates freeze accepted delivery evidence. Failed, retired, pending,
574
+ * and running WorkItems retain Candidate history only for audit; older
575
+ * Candidates on the same WorkItem are superseded by its latest Candidate.
576
+ */
577
+ export function governingWorkItemCandidate(workItem) {
578
+ return workItem.status === "awaiting_acceptance" || workItem.status === "completed"
579
+ ? workItem.candidates.at(-1)
580
+ : undefined;
581
+ }
570
582
  export function updateWorkItemWriteProjects(workItem, writeProjectIds, now) {
571
583
  validateWorkItem(workItem);
572
584
  if (isTerminalStatus(workItem.status)) {
@@ -4,6 +4,7 @@ import { createChangeSetManifest } from "../integration/changeSetManifest.js";
4
4
  import { deriveManifestTags } from "../integration/manifestTags.js";
5
5
  import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
6
6
  import { sameTaskFinalReviewContract } from "../review/taskFinalReviewContract.js";
7
+ import { governingWorkItemCandidate } from "../workItem/workItem.js";
7
8
  import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
8
9
  import { captureManagedGitChanges } from "./gitChangeSetCapture.js";
9
10
  const CAPTURABLE_WORK_ITEM_STATUSES = new Set([
@@ -427,7 +428,7 @@ function latestExactDirectAnchorId(store, taskId, contract) {
427
428
  return undefined;
428
429
  return store.listWorkItems(taskId)
429
430
  .filter((item) => {
430
- const candidate = item.candidates.at(-1);
431
+ const candidate = governingWorkItemCandidate(item);
431
432
  return candidate?.source.type === "direct"
432
433
  && sameTaskFinalReviewContract(candidate.taskFinalReviewContract, contract);
433
434
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zq-silk/yui",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Local control plane for long-running native agent CLI sessions backed by tmux.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -521,9 +521,14 @@ authorized expansions.
521
521
  completion; routine retries and routing do not need an InputRequest.
522
522
  - A failed review is terminal evidence, not an automatic retry. Retry a
523
523
  WorkItem review with a new `task work review`, accept with an explicit
524
- rationale, or ask the user. For an exact failed Task-scoped final Review Run,
525
- `yui task run retry <run-id>` requests one independent ReviewRound over the
526
- same frozen Task candidate; repeating the same exact retry reuses that Round.
524
+ rationale, or ask the user. `yui task run retry <run-id>` retries an exact
525
+ failed Task-final Reviewer execution under the same semantic ReviewRound. If
526
+ that immutable Round itself failed without any semantic report, checks,
527
+ yield, evidence, or finding, the Leader may run
528
+ `yui task review force-fresh <task>/<review-round>` to create one distinct
529
+ full Round over the identical frozen heads. It fails closed for every
530
+ semantic or ambiguous prior result; target the new failed Round explicitly
531
+ if another non-semantic failure occurs.
527
532
  - If the same non-resource user choice or unavailable external fact repeats,
528
533
  persist context and create an InputRequest instead of looping. Never use an
529
534
  InputRequest to solicit authorization for an unrequested real-resource test;
@@ -23,12 +23,17 @@ For every managed Task Run:
23
23
  context-load failure if the pack is missing, stale, unauthorized, malformed,
24
24
  or mismatched. Never request an inline/full-prompt fallback.
25
25
  4. Use pack summaries and pointers first. Expand only an authorized ref when
26
- its full value is needed:
26
+ its full value is needed, selecting it by the pointer's exact `store` and
27
+ `refId`:
27
28
 
28
29
  ```sh
29
- "$YUI_SESSION_CLI" task run context expand "$YUI_TASK_ID/<run-id>" <ref-id> --mode full --json
30
+ "$YUI_SESSION_CLI" task run context expand "$YUI_TASK_ID/<run-id>" <ref-id> --store <store> --mode full --json
30
31
  ```
31
32
 
33
+ A bare `<ref-id>` remains supported only when it identifies exactly one
34
+ authorized pointer. If multiple stores use that id, bare expansion fails
35
+ closed; never guess which store was intended.
36
+
32
37
  5. On a later wake, request only the declared delta after the last pack cursor.
33
38
  If no cursor is available, reload the exact pack; do not reconstruct state
34
39
  from transcript memory.