@zq-silk/yui 0.8.3 → 0.8.6

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.
Files changed (60) hide show
  1. package/ARCHITECTURE.md +40 -22
  2. package/README.md +46 -16
  3. package/dist/cli/commandCatalog.js +23 -11
  4. package/dist/cli/operatorWizard.js +10 -20
  5. package/dist/cli.js +154 -16
  6. package/dist/commands/executionAuditCommands.js +30 -0
  7. package/dist/commands/operatorCommands.js +42 -1
  8. package/dist/commands/taskCommands.js +386 -138
  9. package/dist/commands/taskCompletionGate.js +36 -24
  10. package/dist/commands/taskContextCommand.js +6 -1
  11. package/dist/commands/taskInputCommands.js +48 -10
  12. package/dist/commands/taskNextActionCommand.js +36 -3
  13. package/dist/context/sessionBootstrapManifest.js +82 -1
  14. package/dist/controller/clientRuntime.js +7 -7
  15. package/dist/controller/controller.js +16 -8
  16. package/dist/controller/fileSchedulerStoreAdapter.js +64 -5
  17. package/dist/controller/handoverCandidate.js +10 -3
  18. package/dist/controller/sessionNotify.js +4 -22
  19. package/dist/executor/agentAdapter.js +2 -2
  20. package/dist/executor/agentExecutor.js +25 -5
  21. package/dist/executor/fileRoleLaunchPlanner.js +16 -11
  22. package/dist/integration/gitIntegrationService.js +50 -2
  23. package/dist/integration/integrationCheckEvidenceReuse.js +53 -0
  24. package/dist/observability/executionAudit.js +47 -1
  25. package/dist/observability/faultClassification.js +6 -4
  26. package/dist/observability/orchestrationMetrics.js +196 -0
  27. package/dist/operator/operatorSessionHistory.js +36 -0
  28. package/dist/release/releaseHandover.js +7 -5
  29. package/dist/release/runtimeRelease.js +15 -0
  30. package/dist/repository/taskWorkspaceCoordinator.js +13 -10
  31. package/dist/review/deltaRecheck.js +3 -2
  32. package/dist/review/reviewFindingLedger.js +5 -4
  33. package/dist/review/reviewOutcomeClassifier.js +252 -54
  34. package/dist/review/taskFinalReviewContractEvent.js +1 -0
  35. package/dist/review/taskFinalReviewContractRebind.js +350 -0
  36. package/dist/run/runIdentity.js +10 -70
  37. package/dist/runtime/agentHost.js +3 -4
  38. package/dist/runtime/codexAppServerRuntime.js +6 -0
  39. package/dist/runtime/firstProgressStopLoss.js +52 -0
  40. package/dist/runtime/launchBroker.js +10 -2
  41. package/dist/runtime/runtimeDeadlines.js +14 -0
  42. package/dist/runtime/sessionTitle.js +24 -12
  43. package/dist/runtime/structuredProviderHost.js +7 -1
  44. package/dist/runtime/tmuxAdapters.js +10 -3
  45. package/dist/scheduler/activeRoleRunDelivery.js +20 -18
  46. package/dist/scheduler/leaderWakeupProcessor.js +33 -2
  47. package/dist/scheduler/wakeReason.js +1 -0
  48. package/dist/storage/sqliteStore.js +8 -1
  49. package/dist/storage/taskStore.js +10 -1
  50. package/dist/task/completionReadiness.js +48 -19
  51. package/dist/task/deliveryGuard.js +3 -1
  52. package/dist/task/nextAction.js +145 -52
  53. package/dist/task/repairWave.js +14 -1
  54. package/dist/task/task.js +10 -0
  55. package/dist/web/webSnapshot.js +7 -1
  56. package/i18n/README.zh-CN.md +28 -8
  57. package/package.json +1 -1
  58. package/skills/yui-leader/SKILL.md +73 -31
  59. package/skills/yui-operator/SKILL.md +51 -10
  60. package/skills/yui-reviewer/SKILL.md +23 -0
@@ -0,0 +1,196 @@
1
+ import { classifyReviewRoundOutcome } from "../review/reviewOutcomeClassifier.js";
2
+ import { projectFirstProgressStopLoss } from "../runtime/firstProgressStopLoss.js";
3
+ import { taskDeliveryPath } from "../task/task.js";
4
+ /** One Task's orchestration cost and advisory projection, with no writes. */
5
+ export function projectTaskOrchestration(facts) {
6
+ const evidence = {
7
+ listAgentRuns: () => facts.runs,
8
+ listReviewFindings: () => facts.reviewFindings,
9
+ listEvents: () => facts.events
10
+ };
11
+ const fullRounds = facts.reviewRounds.filter((round) => round.deltaRecheck === undefined);
12
+ const deltaRounds = facts.reviewRounds.filter((round) => round.deltaRecheck !== undefined);
13
+ const classifications = new Map(facts.reviewRounds.map((round) => [round.id, classifyReviewRoundOutcome(round, evidence)]));
14
+ const semanticRounds = facts.reviewRounds.filter((round) => (classifications.get(round.id)?.kind === "semantic"));
15
+ const p1P2Findings = facts.reviewFindings.filter((finding) => ((finding.severity === "p1" || finding.severity === "p2")
16
+ && semanticRounds.some((round) => round.id === finding.firstReviewRoundId))).length;
17
+ const candidateTimes = [
18
+ ...facts.changeSets.map(({ createdAt }) => createdAt),
19
+ ...facts.workItems.flatMap((item) => item.candidates
20
+ .filter((candidate) => candidate.gitSnapshot !== undefined
21
+ || candidate.taskMainSnapshot?.projects.some((project) => (project.baseCommit !== project.headCommit)))
22
+ .map(({ createdAt }) => createdAt))
23
+ ].sort();
24
+ const firstCommitAt = candidateTimes[0];
25
+ const integrationIdentities = new Map();
26
+ for (const job of facts.durableJobs) {
27
+ if (job.owner.kind !== "integration-attempt")
28
+ continue;
29
+ const integrationAttemptId = job.owner.integrationAttemptId;
30
+ const attempt = facts.integrations.find(({ id }) => id === integrationAttemptId);
31
+ if (attempt === undefined || attempt.jobId !== job.id)
32
+ continue;
33
+ const identity = `${attempt.projectId}\0${job.head}\0${JSON.stringify(attempt.checkCommands)}`;
34
+ integrationIdentities.set(identity, (integrationIdentities.get(identity) ?? 0) + 1);
35
+ }
36
+ const repeatedIdentities = [...integrationIdentities.values()]
37
+ .reduce((total, count) => total + Math.max(0, count - 1), 0);
38
+ const leaderSessions = facts.roleSessionSets.find(({ owner }) => owner.roleName === "leader") ?? null;
39
+ const firstProgress = projectFirstProgressStopLoss({
40
+ sessions: leaderSessions,
41
+ events: facts.events,
42
+ workItems: facts.workItems,
43
+ reviewRounds: facts.reviewRounds,
44
+ integrations: facts.integrations
45
+ });
46
+ const advisories = projectAdvisories(facts, classifications, fullRounds, repeatedIdentities, firstProgress.exhausted);
47
+ const publicationAt = facts.task.completedAt === undefined
48
+ ? undefined
49
+ : facts.publications
50
+ .map((reference) => reference.mergedAt ?? reference.createdAt)
51
+ .filter((timestamp) => timestamp <= facts.task.completedAt)
52
+ .sort()
53
+ .at(-1);
54
+ return Object.freeze({
55
+ taskId: facts.task.id,
56
+ deliveryPath: taskDeliveryPath(facts.task),
57
+ timeToFirstProjectCommitMs: firstCommitAt === undefined
58
+ ? null
59
+ : Math.max(0, Date.parse(firstCommitAt) - Date.parse(facts.task.createdAt)),
60
+ runs: {
61
+ total: facts.runs.length,
62
+ byStatus: counts(facts.runs.map(({ status }) => status)),
63
+ byRole: counts(facts.runs.map(({ roleName }) => roleName))
64
+ },
65
+ workItems: facts.workItems.length,
66
+ reviews: {
67
+ full: fullRounds.length,
68
+ delta: deltaRounds.length,
69
+ nonSemantic: [...classifications.values()].filter((value) => value?.kind === "non-semantic").length,
70
+ ambiguous: [...classifications.values()].filter((value) => value?.kind === "ambiguous").length,
71
+ p1P2Findings,
72
+ p1P2FindingsPerSemanticReview: semanticRounds.length === 0
73
+ ? 0
74
+ : p1P2Findings / semanticRounds.length
75
+ },
76
+ integrations: {
77
+ attempts: facts.integrations.length,
78
+ failed: facts.integrations.filter(({ status }) => status === "failed").length,
79
+ repeatedIdentities,
80
+ evidenceReuses: facts.integrations.filter((attempt) => ((attempt.checks ?? []).some(({ details }) => details?.startsWith("Reused successful check evidence from ")))).length
81
+ },
82
+ providerGenerationsBeforeFirstProgress: firstProgress.generationsBeforeFirstProgress,
83
+ publicationToCompletionMs: publicationAt === undefined || facts.task.completedAt === undefined
84
+ ? null
85
+ : Math.max(0, Date.parse(facts.task.completedAt) - Date.parse(publicationAt)),
86
+ terminalWorkspaceCount: terminalWorkspaceCount(facts),
87
+ advisories
88
+ });
89
+ }
90
+ function projectAdvisories(facts, classifications, fullRounds, repeatedIdentities, stopLoss) {
91
+ const result = [];
92
+ if (taskDeliveryPath(facts.task) === "direct"
93
+ && (facts.workItems.length > 0 || facts.reviewRounds.length > 0 || facts.integrations.length > 0)) {
94
+ result.push({
95
+ code: "direct-protocol-overhead",
96
+ reason: "Direct delivery accumulated WorkItem, Review, or Integration protocol overhead; keep the fix Leader-direct or explicitly promote it to integrated delivery.",
97
+ refs: [
98
+ ...facts.workItems.map(({ id }) => `work-item:${id}`),
99
+ ...facts.reviewRounds.map(({ id }) => `review-round:${id}`),
100
+ ...facts.integrations.map(({ id }) => `integration-attempt:${id}`)
101
+ ]
102
+ });
103
+ }
104
+ const initial = facts.workItems.filter(({ dependsOn }) => dependsOn.length === 0);
105
+ if (taskDeliveryPath(facts.task) === "integrated" && initial.length > 1) {
106
+ result.push({
107
+ code: "guarded-workitem-fanout",
108
+ reason: `${initial.length} initial WorkItems were created for an integrated Task; start with one bounded fix unless independence is explicit.`,
109
+ refs: initial.map(({ id }) => `work-item:${id}`)
110
+ });
111
+ }
112
+ const repairItems = facts.workItems.filter((item) => (item.acceptance.some((line) => line.startsWith("review-finding:"))));
113
+ const byRound = new Map();
114
+ for (const item of repairItems) {
115
+ const roundIds = new Set(item.acceptance.flatMap((line) => {
116
+ const findingId = line.startsWith("review-finding:") ? line.slice("review-finding:".length) : "";
117
+ const finding = facts.reviewFindings.find(({ id }) => id === findingId);
118
+ return finding === undefined ? [] : [finding.firstReviewRoundId];
119
+ }));
120
+ for (const roundId of roundIds)
121
+ byRound.set(roundId, [...(byRound.get(roundId) ?? []), item]);
122
+ }
123
+ for (const [roundId, items] of byRound) {
124
+ if (items.length < 2 || hasRepairFanoutDecision(facts.decisions, roundId, items))
125
+ continue;
126
+ result.push({
127
+ code: "review-repair-fanout",
128
+ reason: `Findings from Review ${roundId} were split across ${items.length} WorkItems without a durable Decision explaining independent ownership.`,
129
+ refs: [`review-round:${roundId}`, ...items.map(({ id }) => `work-item:${id}`)]
130
+ });
131
+ }
132
+ if (repeatedIdentities > 0) {
133
+ result.push({
134
+ code: "repeated-integration-check",
135
+ reason: `${repeatedIdentities} Integration DurableJob(s) reran the same candidate commit and ordered checks.`,
136
+ refs: facts.durableJobs
137
+ .filter(({ owner }) => owner.kind === "integration-attempt")
138
+ .map(({ id }) => `durable-job:${id}`)
139
+ });
140
+ }
141
+ const semanticFull = fullRounds.filter((round) => classifications.get(round.id)?.kind === "semantic")
142
+ .sort((left, right) => left.createdAt.localeCompare(right.createdAt));
143
+ const recent = semanticFull.slice(-3);
144
+ const recentIds = new Set(recent.map(({ id }) => id));
145
+ const newFinding = facts.reviewFindings.some(({ firstReviewRoundId }) => recentIds.has(firstReviewRoundId));
146
+ if (semanticFull.length > 2 && !newFinding) {
147
+ result.push({
148
+ code: "review-budget-exhausted",
149
+ reason: `${semanticFull.length} full semantic Reviews ran and the latest three produced no new finding; stop repeating full rounds without a changed head or new risk.`,
150
+ refs: recent.map(({ id }) => `review-round:${id}`)
151
+ });
152
+ }
153
+ if (stopLoss) {
154
+ result.push({
155
+ code: "provider-first-progress-stop-loss",
156
+ reason: "Two fresh Leader generations produced no first durable progress; hand off to the unique Operator before another generation.",
157
+ refs: [facts.task.id]
158
+ });
159
+ }
160
+ return result;
161
+ }
162
+ function hasRepairFanoutDecision(decisions, roundId, items) {
163
+ return decisions.some((decision) => {
164
+ const text = `${decision.title}\n${decision.rationale}`;
165
+ return text.includes(roundId) && items.every(({ id }) => text.includes(id));
166
+ });
167
+ }
168
+ function terminalWorkspaceCount(facts) {
169
+ return facts.managedWorkspaces.filter(({ owner }) => {
170
+ if (owner.type === "task")
171
+ return false;
172
+ if (owner.type === "work-item") {
173
+ return terminalStatus(facts.workItems.find(({ id }) => id === owner.workItemId)?.status);
174
+ }
175
+ if (owner.type === "review-round") {
176
+ return terminalStatus(facts.reviewRounds.find(({ id }) => id === owner.reviewRoundId)?.status);
177
+ }
178
+ if (owner.type === "integration-attempt") {
179
+ return terminalStatus(facts.integrations.find(({ id }) => id === owner.integrationAttemptId)?.status);
180
+ }
181
+ if (owner.workItemId !== undefined) {
182
+ return terminalStatus(facts.workItems.find(({ id }) => id === owner.workItemId)?.status);
183
+ }
184
+ return owner.reviewRoundId !== undefined
185
+ && terminalStatus(facts.reviewRounds.find(({ id }) => id === owner.reviewRoundId)?.status);
186
+ }).length;
187
+ }
188
+ function terminalStatus(status) {
189
+ return status !== undefined && !["pending", "running", "awaiting_acceptance", "blocked", "validating"].includes(status);
190
+ }
191
+ function counts(values) {
192
+ const result = {};
193
+ for (const value of values)
194
+ result[value] = (result[value] ?? 0) + 1;
195
+ return Object.freeze(result);
196
+ }
@@ -1,4 +1,40 @@
1
1
  import { normalizeRoleAgentSessionText, roleAgentSessionRef, validateRoleSessionSet } from "../executor/agentExecutor.js";
2
+ /** Separates the one selected writer authority from retained conversations. */
3
+ export function projectOperatorStatus(sessions, activeAgentId, activeAdapterId) {
4
+ if (sessions === null) {
5
+ return Object.freeze({
6
+ writer: { state: "unrecorded", agentId: activeAgentId, adapterId: activeAdapterId },
7
+ historicalConversations: []
8
+ });
9
+ }
10
+ validateRoleSessionSet(sessions);
11
+ // The GlobalRole binding is the writer authority. The SessionSet pointer is
12
+ // retained operational state and may lag a Role update, so it cannot select
13
+ // a second Operator writer.
14
+ const active = sessions.sessions[activeAgentId];
15
+ const matchingActive = active?.adapterId === activeAdapterId ? active : undefined;
16
+ const activeRef = matchingActive === undefined ? undefined : operatorSessionRef(matchingActive);
17
+ const historicalConversations = listOperatorSessions(sessions)
18
+ .filter((entry) => entry.ref !== activeRef)
19
+ .map((entry) => ({ ...entry, state: "history" }));
20
+ if (matchingActive === undefined) {
21
+ return Object.freeze({
22
+ writer: { state: "unrecorded", agentId: activeAgentId, adapterId: activeAdapterId },
23
+ historicalConversations
24
+ });
25
+ }
26
+ return Object.freeze({
27
+ writer: {
28
+ state: matchingActive.status === "stopped" || matchingActive.status === "broken" ? "inactive" : "active",
29
+ agentId: matchingActive.agentId,
30
+ adapterId: matchingActive.adapterId,
31
+ sessionRef: activeRef,
32
+ nativeSessionId: matchingActive.nativeSessionId,
33
+ sessionStatus: matchingActive.status
34
+ },
35
+ historicalConversations
36
+ });
37
+ }
2
38
  export function operatorSessionRef(session) {
3
39
  return roleAgentSessionRef(session);
4
40
  }
@@ -13,17 +13,19 @@
13
13
  * the candidate read-only and reports dual-owner; a crashed activator resumes
14
14
  * from the recorded phase.
15
15
  */
16
+ import { RELEASE_HANDOVER_PROMOTION_TIMEOUT_MS } from "../runtime/runtimeDeadlines.js";
16
17
  import { acquireHandoverLock, isOwnerLive, newHandoverId, readActiveReleasePointer, readCandidateDiscovery, readHandoverFence, readHandoverReceipt, readRuntimeIdentity, removeCandidateDiscovery, removeHandoverFence, writeActiveReleasePointer, writeHandoverFence, writeHandoverReceipt } from "./runtimeRelease.js";
17
18
  export const DEFAULT_CANDIDATE_READY_TIMEOUT_MS = 30_000;
18
- export const DEFAULT_PROMOTION_TIMEOUT_MS = 45_000;
19
+ export const DEFAULT_PROMOTION_TIMEOUT_MS = RELEASE_HANDOVER_PROMOTION_TIMEOUT_MS;
19
20
  export const DEFAULT_POLL_INTERVAL_MS = 100;
20
21
  /**
21
22
  * Optional confirmation debounce after the candidate latches `dualOwner:
22
23
  * true`. Defaults to 0: the candidate's own exit grace
23
- * (`DEFAULT_DUAL_OWNER_GRACE_MS` in `handoverCandidate.ts`, 30s) is the single
24
- * authoritative old-owner exit window, and the activator trusts the candidate's
25
- * latched signal. A non-zero value only adds a short extra confirmation before
26
- * reporting dual-owner; it must never be used to re-litigate the exit grace.
24
+ * (`DEFAULT_DUAL_OWNER_GRACE_MS` in `handoverCandidate.ts`) is the single
25
+ * authoritative old-owner exit window. It outlives the complete Controller
26
+ * shutdown/drain boundary, and the activator trusts the candidate's latched
27
+ * signal. A non-zero value only adds a short extra confirmation before reporting
28
+ * dual-owner; it must never be used to re-litigate the exit grace.
27
29
  */
28
30
  export const DEFAULT_DUAL_OWNER_GRACE_MS = 0;
29
31
  export async function activateRelease(ports, options) {
@@ -251,6 +251,21 @@ export function writeCandidateDiscovery(home, candidate) {
251
251
  export function removeCandidateDiscovery(home) {
252
252
  rmSync(join(resolve(home), CANDIDATE_DISCOVERY_PATH), { force: true });
253
253
  }
254
+ /**
255
+ * Read-only scheduler fence for the short release/rebind critical section.
256
+ * A stale owner does not block work; an unreadable lock fails closed for
257
+ * bounded Operator diagnosis instead of dispatching across an unknown fence.
258
+ */
259
+ export function isHandoverLockHeld(home) {
260
+ const lockPath = join(resolve(home), "runtime", "handover.lock");
261
+ try {
262
+ const owner = JSON.parse(readFileSync(lockPath, "utf8"));
263
+ return isHandoverLockLive(owner);
264
+ }
265
+ catch (error) {
266
+ return !isEnoent(error);
267
+ }
268
+ }
254
269
  export function acquireHandoverLock(home) {
255
270
  const lockPath = join(resolve(home), "runtime", "handover.lock");
256
271
  mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
@@ -108,6 +108,14 @@ export class TaskWorkspaceCoordinator {
108
108
  await this.#stopLiveRoles(item.taskId, this.#workItemRoleNames(item));
109
109
  return "released";
110
110
  }
111
+ /**
112
+ * Stops one Task Role's physical runtime without changing its workspace.
113
+ * The caller owns the subsequent atomic record retirement and wake.
114
+ */
115
+ async cleanupTaskRoleRuntime(taskId, roleName) {
116
+ await this.#stopLiveRoles(taskId, [roleName]);
117
+ return "released";
118
+ }
111
119
  async cleanupReviewRound(taskId, reviewRoundId) {
112
120
  const round = this.store.getReviewRound(taskId, reviewRoundId);
113
121
  if (round === null)
@@ -384,17 +392,12 @@ export class TaskWorkspaceCoordinator {
384
392
  const observedPanes = inspect?.(taskId);
385
393
  const live = targets.filter((roleName) => {
386
394
  const sessions = this.store.getTaskRoleSessionSet(taskId, roleName);
387
- const activeSession = sessions === null
388
- ? undefined
389
- : sessions.activeAgentId === undefined
390
- // Narrow test doubles and restored callers predating activeAgentId
391
- // still conservatively represent any nonterminal record as live.
392
- ? Object.values(sessions.sessions).find(({ status }) => status !== "stopped" && status !== "broken")
393
- : sessions.sessions[sessions.activeAgentId];
394
395
  return observedPanes?.some((pane) => pane.roleName === roleName && !pane.dead) === true
395
- || (sessions !== null && activeSession !== undefined
396
- && activeSession.status !== "stopped"
397
- && activeSession.status !== "broken");
396
+ // A terminal current Session can still carry a resumable native id or
397
+ // Provider binding. Exact cleanup retires both before a workspace or
398
+ // release-control transition is allowed to wake this Role again.
399
+ || (sessions !== null && (Object.keys(sessions.sessions).length > 0
400
+ || sessions.providerBinding !== null));
398
401
  });
399
402
  if (live.length > 0)
400
403
  await this.runtime.stopTaskRoleSessions(taskId, live);
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { deltaRecheckMaxChangedFiles, deltaRecheckMaxChangedLines } from "./reviewConfig.js";
3
3
  import { validateDeltaRecheckRecord } from "./reviewRound.js";
4
+ import { isSemanticReviewRound } from "./reviewOutcomeClassifier.js";
4
5
  /**
5
6
  * Assesses whether a delta-recheck may be attempted. Every deterministic
6
7
  * gate fails closed here; semantic equivalence is always left to the
@@ -8,11 +9,11 @@ import { validateDeltaRecheckRecord } from "./reviewRound.js";
8
9
  */
9
10
  export async function assessDeltaRecheck(input) {
10
11
  const { repositoryPaths, previousRound, candidate, git, config } = input;
11
- if (previousRound.status !== "completed"
12
+ if (!isSemanticReviewRound(previousRound)
12
13
  || (previousRound.scope ?? "work-item") !== "task") {
13
14
  return {
14
15
  kind: "ineligible",
15
- reason: "Delta recheck requires a completed Task-final ReviewRound."
16
+ reason: "Delta recheck requires a semantic completed Task-final ReviewRound."
16
17
  };
17
18
  }
18
19
  if (previousRound.taskCandidate === undefined) {
@@ -117,11 +117,11 @@ export function reconcileReviewFindings(store, taskId, roundId, now) {
117
117
  if (round === null) {
118
118
  return { roundId, skipped: true, reason: "ReviewRound not found.", created: [], updated: [], conflicts: [] };
119
119
  }
120
- if (!isSemanticReviewRound(round)) {
120
+ if (!isSemanticReviewRound(round, store)) {
121
121
  return {
122
122
  roundId,
123
123
  skipped: true,
124
- reason: "ReviewRound is an execution-attempt failure, not a semantic report.",
124
+ reason: "ReviewRound is non-semantic or ambiguous, not a proven semantic report.",
125
125
  created: [],
126
126
  updated: [],
127
127
  conflicts: []
@@ -464,7 +464,7 @@ export function renderFindingLedgerContext(summary) {
464
464
  export function reusableTaskReviewEvidence(store, taskId, candidate) {
465
465
  const rounds = store.listReviewRounds(taskId)
466
466
  .filter((round) => (round.scope ?? "work-item") === "task"
467
- && round.status === "completed"
467
+ && isSemanticReviewRound(round, store)
468
468
  && round.evidenceCommit !== undefined
469
469
  && isSameTaskReviewCandidate(round.taskCandidate, candidate))
470
470
  .sort((left, right) => left.id.localeCompare(right.id, undefined, { numeric: true }));
@@ -492,7 +492,8 @@ export function reusableTaskReviewEvidence(store, taskId, candidate) {
492
492
  */
493
493
  export function buildTaskFinalReviewFindingContext(store, taskId, candidate) {
494
494
  const previousSemanticRound = store.listReviewRounds(taskId)
495
- .filter((round) => (round.scope ?? "work-item") === "task" && round.status === "completed")
495
+ .filter((round) => (round.scope ?? "work-item") === "task"
496
+ && isSemanticReviewRound(round, store))
496
497
  .sort((left, right) => left.id.localeCompare(right.id, undefined, { numeric: true }))
497
498
  .at(-1) ?? null;
498
499
  const reusableEvidence = reusableTaskReviewEvidence(store, taskId, candidate);