@zq-silk/yui 0.6.0 → 0.6.2

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 (150) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/commandCatalog.js +251 -13
  4. package/dist/cli/updateOrchestrator.js +8 -0
  5. package/dist/cli/updatePorts.js +76 -22
  6. package/dist/cli.js +264 -20
  7. package/dist/commands/configCommands.js +83 -9
  8. package/dist/commands/controllerCommands.js +103 -0
  9. package/dist/commands/deliveryGuardPreflight.js +30 -0
  10. package/dist/commands/durableJobCommands.js +231 -0
  11. package/dist/commands/executionAuditCommands.js +193 -0
  12. package/dist/commands/grantCommands.js +374 -0
  13. package/dist/commands/projectCommands.js +119 -81
  14. package/dist/commands/releaseCommands.js +444 -0
  15. package/dist/commands/resourcesCommands.js +274 -0
  16. package/dist/commands/sessionCommands.js +104 -0
  17. package/dist/commands/taskActor.js +117 -0
  18. package/dist/commands/taskChangeSetCommands.js +60 -0
  19. package/dist/commands/taskCommands.js +618 -202
  20. package/dist/commands/taskCompletionGate.js +78 -1
  21. package/dist/commands/taskContextCommand.js +33 -6
  22. package/dist/commands/taskInputCommands.js +1 -1
  23. package/dist/commands/taskIntegrationCommands.js +136 -33
  24. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  25. package/dist/commands/taskNextActionCommand.js +100 -0
  26. package/dist/commands/taskOverlapCommands.js +120 -0
  27. package/dist/commands/taskOverviewCommand.js +36 -8
  28. package/dist/commands/telemetryCommands.js +330 -0
  29. package/dist/commands/workflowCommands.js +415 -0
  30. package/dist/config/yuiConfig.js +62 -0
  31. package/dist/controller/clientRuntime.js +42 -1
  32. package/dist/controller/controller.js +402 -56
  33. package/dist/controller/controllerMain.js +25 -2
  34. package/dist/controller/domainIdentity.js +16 -8
  35. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  36. package/dist/controller/handoverCandidate.js +168 -0
  37. package/dist/controller/jobClient.js +102 -0
  38. package/dist/controller/jobControl.js +613 -0
  39. package/dist/controller/jobSupervisor.js +498 -0
  40. package/dist/controller/providerHookRunFence.js +34 -5
  41. package/dist/controller/resourceCleanupLinux.js +18 -9
  42. package/dist/controller/resourceInventoryLinux.js +90 -39
  43. package/dist/controller/runtime.js +165 -15
  44. package/dist/controller/runtimeEventInbox.js +234 -57
  45. package/dist/controller/runtimeEventProcessor.js +297 -58
  46. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  47. package/dist/core/controllerServer.js +416 -27
  48. package/dist/core/controllerTelemetry.js +167 -0
  49. package/dist/doctor/doctor.js +113 -16
  50. package/dist/domain/validation.js +9 -0
  51. package/dist/execution/executionGroup.js +40 -3
  52. package/dist/executor/agentExecutor.js +6 -3
  53. package/dist/executor/effectiveLaunch.js +52 -0
  54. package/dist/executor/executorRegistry.js +50 -0
  55. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  56. package/dist/grant/capabilityGrant.js +282 -0
  57. package/dist/integration/changeSet.js +16 -3
  58. package/dist/integration/changeSetManifest.js +46 -0
  59. package/dist/integration/gitIntegrationService.js +528 -147
  60. package/dist/integration/integrationAttempt.js +54 -5
  61. package/dist/integration/integrationQueueEntry.js +221 -0
  62. package/dist/integration/integrationQueueService.js +955 -0
  63. package/dist/integration/manifestTags.js +99 -0
  64. package/dist/integration/overlapDiagnostics.js +211 -0
  65. package/dist/job/durableJob.js +449 -0
  66. package/dist/job/jobRunner.js +350 -0
  67. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  68. package/dist/lifecycle/providerErrorClass.js +126 -0
  69. package/dist/message/message.js +16 -3
  70. package/dist/observability/executionAudit.js +545 -0
  71. package/dist/observability/faultClassification.js +160 -0
  72. package/dist/observability/runtimeIdentity.js +367 -0
  73. package/dist/release/fakeReleasePorts.js +55 -0
  74. package/dist/release/releaseHandover.js +475 -0
  75. package/dist/release/releaseIdempotencyStore.js +165 -0
  76. package/dist/release/releaseWorkflow.js +459 -0
  77. package/dist/release/releaseWorkflowEngine.js +688 -0
  78. package/dist/release/releaseWorkflowPorts.js +1720 -0
  79. package/dist/release/runtimeRelease.js +495 -0
  80. package/dist/release/workflowFileLock.js +218 -0
  81. package/dist/repository/gitWorkspace.js +177 -1
  82. package/dist/repository/projectMaintenanceLock.js +315 -0
  83. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  84. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  85. package/dist/resources/autoResourceGc.js +116 -0
  86. package/dist/resources/liveReferences.js +574 -0
  87. package/dist/resources/resourceDiscovery.js +477 -0
  88. package/dist/resources/resourceGc.js +645 -0
  89. package/dist/resources/resourceRegistrar.js +256 -0
  90. package/dist/resources/resourceRegistry.js +150 -0
  91. package/dist/resources/resourceRegistryStore.js +41 -0
  92. package/dist/resources/resourceTypes.js +42 -0
  93. package/dist/resources/sqliteResourceRegistry.js +111 -0
  94. package/dist/review/reviewConfig.js +10 -0
  95. package/dist/review/reviewFinding.js +240 -0
  96. package/dist/review/reviewFindingLedger.js +545 -0
  97. package/dist/review/reviewOutcomeClassifier.js +61 -0
  98. package/dist/review/reviewRound.js +56 -4
  99. package/dist/run/agentRun.js +80 -4
  100. package/dist/run/providerRetry.js +84 -0
  101. package/dist/run/providerRetryConfig.js +63 -0
  102. package/dist/run/yieldReceipt.js +65 -0
  103. package/dist/runtime/exactControlPlane.js +79 -2
  104. package/dist/runtime/index.js +4 -0
  105. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  106. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  107. package/dist/runtime/sessionReconciliation.js +93 -0
  108. package/dist/runtime/sessionTerminationGuard.js +211 -0
  109. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  110. package/dist/runtime/tmuxAdapters.js +34 -1
  111. package/dist/scheduler/actionability.js +155 -0
  112. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  113. package/dist/scheduler/activeTaskProgress.js +60 -0
  114. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  115. package/dist/scheduler/roleRunStall.js +135 -29
  116. package/dist/scheduler/taskExecutionProjection.js +11 -0
  117. package/dist/storage/compatibleTaskStore.js +112 -5
  118. package/dist/storage/migration/productionRegistry.js +736 -1
  119. package/dist/storage/sqliteSchema.js +264 -3
  120. package/dist/storage/sqliteStore.js +487 -13
  121. package/dist/storage/storeRpc.js +21 -0
  122. package/dist/storage/taskStore.js +974 -21
  123. package/dist/storage/upgrade/homeClassification.js +120 -2
  124. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  125. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  126. package/dist/storage/upgrade/recordVersions.js +10 -1
  127. package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
  128. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  129. package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
  130. package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
  131. package/dist/task/deliveryGuard.js +226 -0
  132. package/dist/task/nextAction.js +738 -0
  133. package/dist/task/repairWave.js +137 -0
  134. package/dist/task/taskRecordReference.js +6 -1
  135. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  136. package/dist/telemetry/telemetryCompaction.js +251 -0
  137. package/dist/telemetry/telemetryConfig.js +64 -0
  138. package/dist/telemetry/telemetryRouter.js +32 -0
  139. package/dist/telemetry/telemetryStore.js +19 -0
  140. package/dist/telemetry/telemetryWiring.js +33 -0
  141. package/dist/tmux/tmuxManager.js +20 -1
  142. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  143. package/dist/verification/gateArtifact.js +216 -0
  144. package/dist/verification/gateArtifactStore.js +87 -0
  145. package/dist/verification/verificationGateService.js +414 -0
  146. package/dist/verification/verificationPlan.js +308 -0
  147. package/dist/workspace/gitChangeSetCapture.js +12 -2
  148. package/dist/workspace/workItemChangeSetManager.js +60 -3
  149. package/package.json +1 -1
  150. package/skills/yui-leader/SKILL.md +8 -0
@@ -0,0 +1,545 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createTaskEvent } from "../event/taskEvent.js";
3
+ import { isSemanticReviewRound } from "./reviewOutcomeClassifier.js";
4
+ import { createReviewFinding, disposeReviewFinding, isReviewFindingBlocking, normalizeReviewFindingSeverity, redetectReviewFinding, resolveReviewFinding, reviewFindingStableKey, touchReviewFinding, validateReviewFinding } from "./reviewFinding.js";
5
+ /** Resolves the durable feature flag; absent config defaults to shadow. */
6
+ export function reviewFindingLedgerMode(config) {
7
+ const mode = config.review?.findingLedger;
8
+ return mode === "enforce" ? "enforce" : "shadow";
9
+ }
10
+ export const REVIEW_FINDINGS_RECONCILE_FAILED_EVENT = "review.findings-reconcile-failed";
11
+ /**
12
+ * Extracts reported findings from a completed Round. The reviewer's JSON
13
+ * report is the authoritative source; panel Rounds fall back to the findings
14
+ * attached to their ExecutionGroup lane results. A malformed or free-text
15
+ * report yields no findings (the raw report stays authoritative evidence).
16
+ */
17
+ export function extractReportedFindings(round) {
18
+ const fromReport = extractFindingsFromReportJson(round.report ?? "");
19
+ if (fromReport.length > 0)
20
+ return fromReport;
21
+ return extractFindingsFromLanes(round);
22
+ }
23
+ function extractFindingsFromReportJson(report) {
24
+ let parsed;
25
+ try {
26
+ parsed = JSON.parse(report);
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
32
+ return [];
33
+ const record = parsed;
34
+ if (!Array.isArray(record.findings))
35
+ return [];
36
+ return record.findings.flatMap((entry) => {
37
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry))
38
+ return [];
39
+ const finding = entry;
40
+ const summary = typeof finding.summary === "string" && finding.summary.trim().length > 0
41
+ ? finding.summary.trim()
42
+ : typeof finding.title === "string" && finding.title.trim().length > 0
43
+ ? finding.title.trim()
44
+ : null;
45
+ if (summary === null)
46
+ return [];
47
+ let severity;
48
+ try {
49
+ severity = normalizeReviewFindingSeverity(finding.severity);
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ const status = finding.status === "resolved" ? "resolved" : "open";
55
+ const title = typeof finding.title === "string" && finding.title.trim().length > 0
56
+ ? finding.title.trim()
57
+ : summary;
58
+ const invariant = typeof finding.invariant === "string" && finding.invariant.trim().length > 0
59
+ ? finding.invariant.trim()
60
+ : typeof finding.category === "string" && finding.category.trim().length > 0
61
+ ? finding.category.trim()
62
+ : "review-finding";
63
+ return [{
64
+ ...(typeof finding.id === "string" && finding.id.trim().length > 0
65
+ ? { sourceId: finding.id.trim() }
66
+ : {}),
67
+ severity,
68
+ status,
69
+ invariant,
70
+ title,
71
+ affectedPaths: stringList(finding.paths ?? finding.affectedPaths),
72
+ affectedSymbols: stringList(finding.symbols ?? finding.affectedSymbols),
73
+ evidence: stringList(finding.evidence)
74
+ }];
75
+ });
76
+ }
77
+ function extractFindingsFromLanes(round) {
78
+ const lanes = round.executionGroup?.lanes ?? [];
79
+ return lanes.flatMap((lane) => {
80
+ const findings = lane.result?.findings ?? [];
81
+ return findings.flatMap((finding) => {
82
+ let severity;
83
+ try {
84
+ severity = normalizeReviewFindingSeverity(finding.severity);
85
+ }
86
+ catch {
87
+ return [];
88
+ }
89
+ return [{
90
+ sourceId: finding.id,
91
+ severity,
92
+ status: finding.status === "resolved" ? "resolved" : "open",
93
+ invariant: "review-finding",
94
+ title: finding.summary,
95
+ affectedPaths: [],
96
+ affectedSymbols: [],
97
+ evidence: []
98
+ }];
99
+ });
100
+ });
101
+ }
102
+ function stringList(value) {
103
+ if (!Array.isArray(value))
104
+ return [];
105
+ return value
106
+ .filter((entry) => typeof entry === "string" && entry.trim().length > 0)
107
+ .map((entry) => entry.trim());
108
+ }
109
+ /**
110
+ * Reconciles one completed Round's findings into the Task ledger. Stable-key
111
+ * matches update the existing record; ambiguous matches (multiple records with
112
+ * the same key) create a `mergeRequired` record instead of silently merging.
113
+ * Infra (failed) Rounds are skipped without touching the ledger.
114
+ */
115
+ export function reconcileReviewFindings(store, taskId, roundId, now) {
116
+ const round = store.getReviewRound(taskId, roundId);
117
+ if (round === null) {
118
+ return { roundId, skipped: true, reason: "ReviewRound not found.", created: [], updated: [], conflicts: [] };
119
+ }
120
+ if (!isSemanticReviewRound(round)) {
121
+ return {
122
+ roundId,
123
+ skipped: true,
124
+ reason: "ReviewRound is an execution-attempt failure, not a semantic report.",
125
+ created: [],
126
+ updated: [],
127
+ conflicts: []
128
+ };
129
+ }
130
+ const extracted = extractReportedFindings(round);
131
+ if (extracted.length === 0) {
132
+ return { roundId, skipped: true, reason: "ReviewRound report carries no findings.", created: [], updated: [], conflicts: [] };
133
+ }
134
+ const ledger = store.listReviewFindings(taskId);
135
+ const created = [];
136
+ const updated = [];
137
+ const conflicts = [];
138
+ for (const entry of extracted) {
139
+ const stableKey = reviewFindingStableKey({
140
+ invariant: entry.invariant,
141
+ primaryPath: entry.affectedPaths[0],
142
+ primarySymbol: entry.affectedSymbols[0],
143
+ title: entry.title
144
+ });
145
+ const matches = ledger.filter((finding) => finding.stableKey === stableKey);
146
+ if (matches.length === 1) {
147
+ const existing = matches[0];
148
+ const next = entry.status === "resolved"
149
+ ? resolveReviewFinding(existing, {
150
+ reviewRoundId: round.id,
151
+ evidence: entry.evidence,
152
+ now
153
+ })
154
+ : existing.disposition === "accepted-risk"
155
+ || existing.disposition === "not-actionable"
156
+ || existing.disposition === "superseded"
157
+ ? touchReviewFinding(existing, {
158
+ reviewRoundId: round.id,
159
+ evidence: entry.evidence,
160
+ now
161
+ })
162
+ : redetectReviewFinding(existing, {
163
+ reviewRoundId: round.id,
164
+ evidence: entry.evidence,
165
+ now
166
+ });
167
+ if (next !== existing) {
168
+ store.saveReviewFinding(taskId, next);
169
+ updated.push(next);
170
+ }
171
+ continue;
172
+ }
173
+ if (matches.length > 1) {
174
+ // Stable-key collision: keep every evidence trail and require an explicit
175
+ // Leader merge instead of silently overwriting either record.
176
+ const existingConflict = matches.find((finding) => finding.mergeRequired === true);
177
+ if (existingConflict !== undefined) {
178
+ // A merge-required record already exists for this stable key; refresh
179
+ // its evidence and last-Round pointer instead of creating unbounded
180
+ // duplicate conflict records.
181
+ const refreshed = touchReviewFinding(existingConflict, {
182
+ reviewRoundId: round.id,
183
+ evidence: entry.evidence,
184
+ now
185
+ });
186
+ if (refreshed !== existingConflict) {
187
+ store.saveReviewFinding(taskId, refreshed);
188
+ updated.push(refreshed);
189
+ }
190
+ continue;
191
+ }
192
+ const conflict = createReviewFinding(store.nextReviewFindingId(taskId), taskId, {
193
+ ...(entry.sourceId === undefined ? {} : { sourceId: entry.sourceId }),
194
+ stableKey,
195
+ severity: entry.severity,
196
+ invariant: entry.invariant,
197
+ title: entry.title,
198
+ affectedPaths: entry.affectedPaths,
199
+ affectedSymbols: entry.affectedSymbols,
200
+ evidence: entry.evidence,
201
+ reviewRoundId: round.id
202
+ }, now);
203
+ const flagged = validateReviewFinding({ ...conflict, mergeRequired: true });
204
+ store.saveReviewFinding(taskId, flagged);
205
+ conflicts.push(flagged);
206
+ continue;
207
+ }
208
+ const createdFinding = createReviewFinding(store.nextReviewFindingId(taskId), taskId, {
209
+ ...(entry.sourceId === undefined ? {} : { sourceId: entry.sourceId }),
210
+ stableKey,
211
+ severity: entry.severity,
212
+ invariant: entry.invariant,
213
+ title: entry.title,
214
+ affectedPaths: entry.affectedPaths,
215
+ affectedSymbols: entry.affectedSymbols,
216
+ evidence: entry.evidence,
217
+ reviewRoundId: round.id
218
+ }, now);
219
+ store.saveReviewFinding(taskId, createdFinding);
220
+ created.push(createdFinding);
221
+ }
222
+ store.saveEvent(taskId, createTaskEvent(store.nextEventId(taskId), taskId, "review.findings-reconciled", {
223
+ reviewRoundId: round.id,
224
+ created: String(created.length),
225
+ updated: String(updated.length),
226
+ conflicts: String(conflicts.length)
227
+ }, now));
228
+ return { roundId, skipped: false, created, updated, conflicts };
229
+ }
230
+ /**
231
+ * Reconciles a delivered Review without making ledger availability a
232
+ * precondition for preserving the reviewer's free-form report. A ledger
233
+ * read/write failure is recorded as a Task event; `enforce` completion fails
234
+ * closed until the Leader recovers the ledger.
235
+ */
236
+ export function reconcileReviewFindingsAfterReview(store, taskId, roundId, now) {
237
+ try {
238
+ return reconcileReviewFindings(store, taskId, roundId, now);
239
+ }
240
+ catch (error) {
241
+ const reason = `Review finding ledger is unavailable: ${error instanceof Error ? error.message : String(error)}`;
242
+ store.saveEvent(taskId, createTaskEvent(store.nextEventId(taskId), taskId, REVIEW_FINDINGS_RECONCILE_FAILED_EVENT, { reviewRoundId: roundId, reason }, now));
243
+ return {
244
+ roundId,
245
+ skipped: true,
246
+ reason,
247
+ created: [],
248
+ updated: [],
249
+ conflicts: []
250
+ };
251
+ }
252
+ }
253
+ /** True when a semantic Review could not be reconciled into the ledger. */
254
+ export function reviewFindingLedgerWriteFailed(store, taskId) {
255
+ const latestByRound = new Map();
256
+ for (const event of store.listEvents(taskId)) {
257
+ if (event.type !== REVIEW_FINDINGS_RECONCILE_FAILED_EVENT
258
+ && event.type !== "review.findings-reconciled") {
259
+ continue;
260
+ }
261
+ const reviewRoundId = event.payload.reviewRoundId;
262
+ if (reviewRoundId !== undefined)
263
+ latestByRound.set(reviewRoundId, event);
264
+ }
265
+ return [...latestByRound.values()]
266
+ .some((event) => event.type === REVIEW_FINDINGS_RECONCILE_FAILED_EVENT);
267
+ }
268
+ /** Applies one explicit Leader disposition to a finding. */
269
+ export function dispositionReviewFinding(store, taskId, findingId, command) {
270
+ const existing = store.getReviewFinding(taskId, findingId);
271
+ if (existing === null) {
272
+ throw new Error(`ReviewFinding not found: ${taskId}/${findingId}.`);
273
+ }
274
+ const next = disposeReviewFinding(existing, {
275
+ disposition: command.disposition,
276
+ by: command.by,
277
+ ...(command.note === undefined ? {} : { note: command.note }),
278
+ ...(command.workItemId === undefined
279
+ && command.commit === undefined
280
+ && command.verification === undefined
281
+ ? {}
282
+ : {
283
+ repair: {
284
+ ...(command.workItemId === undefined ? {} : { workItemId: command.workItemId }),
285
+ ...(command.commit === undefined ? {} : { commit: command.commit }),
286
+ ...(command.verification === undefined ? {} : { verification: command.verification })
287
+ }
288
+ }),
289
+ ...(command.supersededBy === undefined ? {} : { supersededBy: command.supersededBy }),
290
+ now: command.now
291
+ });
292
+ store.saveReviewFinding(taskId, next);
293
+ store.saveEvent(taskId, createTaskEvent(store.nextEventId(taskId), taskId, "review.finding-dispositioned", {
294
+ reviewFindingId: next.id,
295
+ disposition: next.disposition,
296
+ by: command.by
297
+ }, command.now));
298
+ return next;
299
+ }
300
+ /**
301
+ * Groups open P1/P2 findings into repair waves by file/symbol/invariant
302
+ * overlap. Findings sharing an affected path, symbol, or invariant land in
303
+ * the same group (union-find), so one WorkItem repairs one overlapping set
304
+ * while disjoint groups can run in parallel.
305
+ */
306
+ export function planRepairGroups(store, taskId) {
307
+ const open = store.listReviewFindings(taskId)
308
+ .filter((finding) => finding.disposition === "open"
309
+ && (finding.severity === "p1" || finding.severity === "p2"))
310
+ .sort((left, right) => left.id.localeCompare(right.id, undefined, { numeric: true }));
311
+ const parent = new Map(open.map((finding) => [finding.id, finding.id]));
312
+ const root = (id) => {
313
+ let current = id;
314
+ while (parent.get(current) !== current) {
315
+ current = parent.get(current);
316
+ }
317
+ let cursor = id;
318
+ while (parent.get(cursor) !== cursor) {
319
+ const next = parent.get(cursor);
320
+ parent.set(cursor, current);
321
+ cursor = next;
322
+ }
323
+ return current;
324
+ };
325
+ const union = (left, right) => {
326
+ const leftRoot = root(left);
327
+ const rightRoot = root(right);
328
+ if (leftRoot !== rightRoot)
329
+ parent.set(rightRoot, leftRoot);
330
+ };
331
+ const byPath = new Map();
332
+ const bySymbol = new Map();
333
+ const byInvariant = new Map();
334
+ for (const finding of open) {
335
+ for (const path of finding.affectedPaths) {
336
+ const seen = byPath.get(path);
337
+ if (seen === undefined)
338
+ byPath.set(path, finding.id);
339
+ else
340
+ union(seen, finding.id);
341
+ }
342
+ for (const symbol of finding.affectedSymbols) {
343
+ const seen = bySymbol.get(symbol);
344
+ if (seen === undefined)
345
+ bySymbol.set(symbol, finding.id);
346
+ else
347
+ union(seen, finding.id);
348
+ }
349
+ // Issue 06: only union on a real invariant. Findings that lack an
350
+ // explicit invariant/category all share the fallback "review-finding"
351
+ // label; unioning on it would collapse sparse reports into one group and
352
+ // defeat parallel repair.
353
+ if (finding.invariant !== "review-finding") {
354
+ const seen = byInvariant.get(finding.invariant);
355
+ if (seen === undefined)
356
+ byInvariant.set(finding.invariant, finding.id);
357
+ else
358
+ union(seen, finding.id);
359
+ }
360
+ }
361
+ const groups = new Map();
362
+ for (const finding of open) {
363
+ const key = root(finding.id);
364
+ const members = groups.get(key) ?? [];
365
+ members.push(finding);
366
+ groups.set(key, members);
367
+ }
368
+ return [...groups.values()]
369
+ .map((findings) => ({
370
+ groupKey: findings.map(({ id }) => id).join("+"),
371
+ findings,
372
+ findingIds: findings.map(({ id }) => id),
373
+ affectedPaths: [...new Set(findings.flatMap(({ affectedPaths }) => affectedPaths))].sort(),
374
+ affectedSymbols: [...new Set(findings.flatMap(({ affectedSymbols }) => affectedSymbols))].sort(),
375
+ invariants: [...new Set(findings.map(({ invariant }) => invariant))].sort()
376
+ }))
377
+ .sort((left, right) => left.groupKey.localeCompare(right.groupKey, undefined, { numeric: true }));
378
+ }
379
+ /**
380
+ * Completion gate: open (or repair-pending) P1/P2 findings block Task
381
+ * completion under `enforce`. P3/backlog findings never block.
382
+ */
383
+ export function blockingOpenFindings(store, taskId) {
384
+ return store.listReviewFindings(taskId)
385
+ .filter(isReviewFindingBlocking)
386
+ .sort((left, right) => left.id.localeCompare(right.id, undefined, { numeric: true }));
387
+ }
388
+ /** True when the completion gate must fail closed for this Task. */
389
+ export function completionGateBlocked(store, taskId) {
390
+ if (reviewFindingLedgerMode(store.getConfig()) !== "enforce")
391
+ return false;
392
+ return reviewFindingLedgerWriteFailed(store, taskId)
393
+ || blockingOpenFindings(store, taskId).length > 0;
394
+ }
395
+ export function summarizeFindingLedger(store, taskId) {
396
+ const findings = store.listReviewFindings(taskId)
397
+ .sort((left, right) => left.id.localeCompare(right.id, undefined, { numeric: true }));
398
+ const byDisposition = (disposition) => findings.filter((finding) => finding.disposition === disposition);
399
+ return {
400
+ total: findings.length,
401
+ open: byDisposition("open"),
402
+ fixedPendingReview: byDisposition("fixed-pending-review"),
403
+ verifiedFixed: byDisposition("verified-fixed"),
404
+ acceptedRisk: byDisposition("accepted-risk"),
405
+ notActionable: byDisposition("not-actionable"),
406
+ superseded: byDisposition("superseded"),
407
+ blocking: findings.filter(isReviewFindingBlocking),
408
+ mergeRequired: findings.filter((finding) => finding.mergeRequired === true)
409
+ };
410
+ }
411
+ /**
412
+ * Renders the ledger summary as the incremental Review context block: it
413
+ * replaces repeated full-history restatement while still listing every
414
+ * disposition class the final report must account for.
415
+ */
416
+ export function renderFindingLedgerContext(summary) {
417
+ const lines = ["Finding ledger:"];
418
+ lines.push(`- total: ${summary.total}`);
419
+ const renderList = (label, findings) => {
420
+ if (findings.length === 0)
421
+ return;
422
+ lines.push(`- ${label}:`);
423
+ for (const finding of findings) {
424
+ lines.push(` - ${finding.id} [${finding.severity}] ${finding.title}`
425
+ + ` (invariant: ${finding.invariant}; first: ${finding.firstReviewRoundId}; last: ${finding.lastReviewRoundId})`);
426
+ if (finding.repair !== undefined) {
427
+ const repairParts = [
428
+ finding.repair.workItemId === undefined ? "" : `work-item=${finding.repair.workItemId}`,
429
+ finding.repair.commit === undefined ? "" : `commit=${finding.repair.commit}`,
430
+ finding.repair.verification === undefined ? "" : `verification=${finding.repair.verification}`
431
+ ].filter((part) => part.length > 0);
432
+ if (repairParts.length > 0)
433
+ lines.push(` repair: ${repairParts.join("; ")}`);
434
+ }
435
+ }
436
+ };
437
+ renderList("verified-fixed", summary.verifiedFixed);
438
+ renderList("open (new/repair pending)", summary.open);
439
+ renderList("fixed-pending-review", summary.fixedPendingReview);
440
+ renderList("accepted-risk", summary.acceptedRisk);
441
+ renderList("not-actionable (backlog)", summary.notActionable);
442
+ renderList("superseded", summary.superseded);
443
+ if (summary.mergeRequired.length > 0) {
444
+ lines.push(`- merge-required: ${summary.mergeRequired.map(({ id }) => id).join(", ")}`);
445
+ }
446
+ if (summary.blocking.length > 0) {
447
+ lines.push(`- residual blocking P1/P2: ${summary.blocking.map(({ id }) => id).join(", ")}`);
448
+ }
449
+ else {
450
+ lines.push("- residual blocking P1/P2: none");
451
+ }
452
+ return lines.join("\n");
453
+ }
454
+ /**
455
+ * Fallback gate-evidence reuse (Issue 08 artifacts are optional): a completed
456
+ * Task-final Round whose evidence commit exactly matches the candidate's
457
+ * primary head may donate its checks by digest. A changed head returns null,
458
+ * so an old GREEN can never be reused against a different tree.
459
+ */
460
+ export function reusableTaskReviewEvidence(store, taskId, candidate) {
461
+ const rounds = store.listReviewRounds(taskId)
462
+ .filter((round) => (round.scope ?? "work-item") === "task"
463
+ && round.status === "completed"
464
+ && round.evidenceCommit !== undefined
465
+ && isSameTaskReviewCandidate(round.taskCandidate, candidate))
466
+ .sort((left, right) => left.id.localeCompare(right.id, undefined, { numeric: true }));
467
+ const latest = rounds.at(-1);
468
+ if (latest === undefined || latest.evidenceCommit === undefined)
469
+ return null;
470
+ const checks = latest.checks ?? [];
471
+ if (checks.length === 0 || checks.some((check) => check.outcome !== "passed"))
472
+ return null;
473
+ const digest = createHash("sha256")
474
+ .update(JSON.stringify(checks))
475
+ .digest("hex");
476
+ return {
477
+ reviewRoundId: latest.id,
478
+ evidenceCommit: latest.evidenceCommit,
479
+ digest,
480
+ checks
481
+ };
482
+ }
483
+ /**
484
+ * Builds the Issue 06 incremental Review context. The Reviewer receives the
485
+ * cross-Round ledger, exact old/new frozen heads, repair evidence, and any
486
+ * reusable exact-head checks. A changed head or failed/absent checks returns
487
+ * no reusable evidence, so an old GREEN can never be borrowed for a new tree.
488
+ */
489
+ export function buildTaskFinalReviewFindingContext(store, taskId, candidate) {
490
+ const previousSemanticRound = store.listReviewRounds(taskId)
491
+ .filter((round) => (round.scope ?? "work-item") === "task" && round.status === "completed")
492
+ .sort((left, right) => left.id.localeCompare(right.id, undefined, { numeric: true }))
493
+ .at(-1) ?? null;
494
+ const reusableEvidence = reusableTaskReviewEvidence(store, taskId, candidate);
495
+ // dbonly: the finding ledger is SQLite-native. A file-only Home (no yui.db)
496
+ // cannot serve findings; the Review dispatch must still proceed, so the
497
+ // ledger context degrades to an explicit "unavailable" note.
498
+ let ledgerContext;
499
+ try {
500
+ ledgerContext = renderFindingLedgerContext(summarizeFindingLedger(store, taskId));
501
+ }
502
+ catch {
503
+ ledgerContext = "Finding ledger: unavailable (SQLite backend required; run `yui update` to migrate this Home).";
504
+ }
505
+ const lines = [
506
+ "Review convergence context:",
507
+ ledgerContext,
508
+ ...(previousSemanticRound === null
509
+ ? ["Previous semantic Task-final ReviewRound: none."]
510
+ : [
511
+ `Previous semantic Task-final ReviewRound: ${previousSemanticRound.id}`,
512
+ ...candidate.projects.map((project) => {
513
+ const previous = previousSemanticRound.taskCandidate?.projects
514
+ .find((entry) => entry.projectId === project.projectId)?.commit;
515
+ return previous === undefined
516
+ ? `Exact review boundary for ${project.projectId}: new head ${project.commit}`
517
+ : previous === project.commit
518
+ ? `Exact review boundary for ${project.projectId}: unchanged at ${project.commit}`
519
+ : `Exact diff for ${project.projectId}: ${previous}..${project.commit}`;
520
+ })
521
+ ]),
522
+ ...(reusableEvidence === null
523
+ ? [
524
+ "Reusable gate evidence: none (head changed, checks are absent, or a check did not pass); rerun the checks needed for this exact tree."
525
+ ]
526
+ : [
527
+ `Reusable gate evidence: ${reusableEvidence.reviewRoundId}@${reusableEvidence.evidenceCommit}`,
528
+ `Reusable check digest: ${reusableEvidence.digest}`,
529
+ `Reusable checks: ${reusableEvidence.checks.map(({ name, outcome }) => `${name}=${outcome}`).join(", ")}`
530
+ ]),
531
+ "Reuse each listed ledger finding id when it is still valid; explain why a new finding is not already covered by the ledger.",
532
+ "Your final report must clearly list verified-fixed findings, new findings, accepted risks, and residual verification gaps."
533
+ ];
534
+ return {
535
+ context: lines.join("\n"),
536
+ previousSemanticRound,
537
+ reusableEvidence
538
+ };
539
+ }
540
+ function isSameTaskReviewCandidate(left, right) {
541
+ return left !== undefined
542
+ && left.projects.length === right.projects.length
543
+ && left.projects.every((project, index) => (project.projectId === right.projects[index]?.projectId
544
+ && project.commit === right.projects[index]?.commit));
545
+ }
@@ -0,0 +1,61 @@
1
+ const INFRA_SIGNATURES = [
2
+ {
3
+ kind: "session-not-stopped",
4
+ pattern: /session must be stopped before workspace migration/iu
5
+ },
6
+ {
7
+ kind: "run-start",
8
+ pattern: /role run could not start|could not start (?:the )?(?:reviewer|role) run/iu
9
+ },
10
+ {
11
+ kind: "storage-lock",
12
+ pattern: /\.state\.lock|state lock|lock timeout|storage lock/iu
13
+ },
14
+ {
15
+ kind: "tmux-exit",
16
+ pattern: /tmux[^\n]{0,80}(?:exited|exit|died|vanished)|pane (?:exited|died)/iu
17
+ },
18
+ {
19
+ kind: "yield-timeout",
20
+ pattern: /yield timeout|controller yield timeout|yield timed out/iu
21
+ },
22
+ {
23
+ kind: "run-identity",
24
+ pattern: /wrong run id|unknown run id|run id (?:is )?(?:invalid|unknown|mismatch)/iu
25
+ },
26
+ {
27
+ kind: "policy",
28
+ pattern: /cyber_?policy|policy denial|permission denied by policy/iu
29
+ },
30
+ {
31
+ kind: "baseline-contamination",
32
+ pattern: /cross[-\s]?baseline|baseline pollution|contaminated baseline|wrong base sha/iu
33
+ }
34
+ ];
35
+ /**
36
+ * Classifies a terminal Round. `completed` is always semantic (the reviewer
37
+ * delivered a report); `failed` is always infra. Non-terminal Rounds have no
38
+ * outcome yet.
39
+ */
40
+ export function classifyReviewRoundOutcome(round) {
41
+ if (round.status === "completed") {
42
+ return { kind: "semantic", reason: "Reviewer delivered a report." };
43
+ }
44
+ if (round.status !== "failed")
45
+ return null;
46
+ const text = `${round.summary ?? ""}\n${round.report ?? ""}`;
47
+ for (const { kind, pattern } of INFRA_SIGNATURES) {
48
+ if (pattern.test(text)) {
49
+ return { kind: "infra", infraKind: kind, reason: `Review execution attempt failed (${kind}).` };
50
+ }
51
+ }
52
+ return {
53
+ kind: "infra",
54
+ infraKind: "other-infra",
55
+ reason: "Review execution attempt failed before a semantic report."
56
+ };
57
+ }
58
+ /** True when this Round may feed the finding ledger. */
59
+ export function isSemanticReviewRound(round) {
60
+ return classifyReviewRoundOutcome(round)?.kind === "semantic";
61
+ }
@@ -2,7 +2,7 @@ import { requireIdentity, requireText, requireTimestamp } from "../domain/valida
2
2
  import { validateTaskRecordReference } from "../task/taskRecordReference.js";
3
3
  import { validateTaskFinalReviewContract } from "./taskFinalReviewContract.js";
4
4
  import { validateManagedWorkspace } from "../worktree/managedWorkspace.js";
5
- import { assertExecutionGroupTransition, validateExecutionGroup } from "../execution/executionGroup.js";
5
+ import { assertExecutionGroupTransition, resetReviewExecutionLane, validateExecutionGroup } from "../execution/executionGroup.js";
6
6
  export function createReviewRound(id, taskId, workItemId, candidateId, reviewerRoleName, requestedBy, reviewBaseCommit, now, executionGroup) {
7
7
  return validateReviewRound({
8
8
  schemaVersion: 4,
@@ -84,6 +84,56 @@ export function finishReviewRound(round, status, summary, now, result = {}) {
84
84
  endedAt: now.toISOString()
85
85
  });
86
86
  }
87
+ /**
88
+ * Issue 06: retry a failed Task-final execution attempt under the same semantic
89
+ * Round identity. AgentRun history remains the attempt trail; the Round itself
90
+ * returns to pending so infrastructure retries do not manufacture a new
91
+ * semantic ReviewRound or duplicate findings.
92
+ */
93
+ export function retryTaskReviewRound(round) {
94
+ validateReviewRound(round);
95
+ if ((round.scope ?? "work-item") !== "task") {
96
+ throw new Error(`Only a Task-final ReviewRound can be retried in place: ${round.id}.`);
97
+ }
98
+ if (round.status !== "failed") {
99
+ throw new Error(`ReviewRound ${round.id} is not retryable from ${round.status}.`);
100
+ }
101
+ const retryExecutionGroup = round.executionGroup === undefined
102
+ ? undefined
103
+ : retryReviewExecutionGroup(round);
104
+ return validateReviewRound({
105
+ schemaVersion: round.schemaVersion,
106
+ id: round.id,
107
+ taskId: round.taskId,
108
+ workItemId: round.workItemId,
109
+ candidateId: round.candidateId,
110
+ reviewerRoleName: round.reviewerRoleName,
111
+ reviewBaseCommit: round.reviewBaseCommit,
112
+ scope: "task",
113
+ ...(round.taskCandidate === undefined ? {} : { taskCandidate: round.taskCandidate }),
114
+ ...(round.taskFinalReviewContract === undefined
115
+ ? {}
116
+ : { taskFinalReviewContract: round.taskFinalReviewContract }),
117
+ // Keep the historical attempt Group and Lane addressable from AgentRun
118
+ // history while resetting the Lane for another dispatch attempt.
119
+ ...(retryExecutionGroup === undefined ? {} : { executionGroup: retryExecutionGroup }),
120
+ requestedBy: "leader",
121
+ status: "pending",
122
+ ...(round.workspace === undefined ? {} : { workspace: round.workspace }),
123
+ createdAt: round.createdAt
124
+ });
125
+ }
126
+ function retryReviewExecutionGroup(round) {
127
+ const previous = round.executionGroup;
128
+ const attemptTime = Date.parse(round.endedAt ?? round.createdAt);
129
+ const now = new Date(attemptTime);
130
+ const lanes = previous.lanes.map((lane) => (resetReviewExecutionLane(previous, lane.id, now)));
131
+ return validateExecutionGroup({
132
+ ...previous,
133
+ lanes,
134
+ updatedAt: now.toISOString()
135
+ });
136
+ }
87
137
  /** Accepts the Reviewer's complete report and extracts only optional known evidence. */
88
138
  export function parseReviewYieldReport(value) {
89
139
  const report = requireText(value, "Review report");
@@ -265,10 +315,12 @@ export function validateReviewRound(round) {
265
315
  requireText(round.report ?? "", "Review report");
266
316
  validateChecks(round.checks ?? []);
267
317
  if (round.evidenceCommit !== undefined) {
318
+ // The exact commit on which the review's checks ran. Equals the base
319
+ // when the reviewer ran checks on the frozen candidate tree; differs
320
+ // when the reviewer committed diagnostics on top of it. A dirty
321
+ // review with uncommitted changes records no evidenceCommit, since no
322
+ // single commit captures the checked tree.
268
323
  requireCommit(round.evidenceCommit, "Review evidence commit");
269
- if (round.evidenceCommit === round.reviewBaseCommit) {
270
- throw new Error("Review evidence commit must differ from its review base.");
271
- }
272
324
  }
273
325
  requireTimestamp(round.endedAt ?? "", "ReviewRound endedAt");
274
326
  }