@zq-silk/yui 0.8.2 → 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 (68) hide show
  1. package/ARCHITECTURE.md +40 -22
  2. package/README.md +61 -9
  3. package/dist/cli/commandCatalog.js +31 -14
  4. package/dist/cli/operatorWizard.js +10 -20
  5. package/dist/cli.js +187 -22
  6. package/dist/commands/executionAuditCommands.js +30 -0
  7. package/dist/commands/operatorCommands.js +42 -1
  8. package/dist/commands/taskCommands.js +648 -74
  9. package/dist/commands/taskCompletionGate.js +166 -2
  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/runContextPack.js +19 -4
  14. package/dist/context/sessionBootstrapManifest.js +83 -2
  15. package/dist/controller/clientRuntime.js +7 -7
  16. package/dist/controller/controller.js +16 -8
  17. package/dist/controller/fileSchedulerStoreAdapter.js +64 -5
  18. package/dist/controller/handoverCandidate.js +10 -3
  19. package/dist/controller/sessionNotify.js +4 -22
  20. package/dist/executor/agentAdapter.js +2 -2
  21. package/dist/executor/agentExecutor.js +25 -5
  22. package/dist/executor/fileRoleLaunchPlanner.js +16 -11
  23. package/dist/integration/gitIntegrationService.js +50 -2
  24. package/dist/integration/integrationCheckEvidenceReuse.js +53 -0
  25. package/dist/observability/executionAudit.js +47 -1
  26. package/dist/observability/faultClassification.js +6 -4
  27. package/dist/observability/orchestrationMetrics.js +196 -0
  28. package/dist/operator/operatorSessionHistory.js +36 -0
  29. package/dist/release/releaseHandover.js +7 -5
  30. package/dist/release/runtimeRelease.js +15 -0
  31. package/dist/repository/gitWorkspace.js +7 -4
  32. package/dist/repository/taskBaseFreshness.js +4 -2
  33. package/dist/repository/taskWorkspaceCoordinator.js +13 -10
  34. package/dist/review/deltaRecheck.js +3 -2
  35. package/dist/review/reviewFindingLedger.js +5 -4
  36. package/dist/review/reviewOutcomeClassifier.js +252 -54
  37. package/dist/review/taskFinalReviewContractEvent.js +1 -0
  38. package/dist/review/taskFinalReviewContractRebind.js +350 -0
  39. package/dist/run/agentRun.js +2 -2
  40. package/dist/run/runIdentity.js +10 -70
  41. package/dist/runtime/agentHost.js +3 -4
  42. package/dist/runtime/codexAppServerRuntime.js +6 -0
  43. package/dist/runtime/firstProgressStopLoss.js +52 -0
  44. package/dist/runtime/launchBroker.js +10 -2
  45. package/dist/runtime/runtimeDeadlines.js +14 -0
  46. package/dist/runtime/sessionTitle.js +24 -12
  47. package/dist/runtime/structuredProviderHost.js +7 -1
  48. package/dist/runtime/tmuxAdapters.js +10 -3
  49. package/dist/scheduler/activeRoleRunDelivery.js +20 -18
  50. package/dist/scheduler/leaderWakeupProcessor.js +33 -2
  51. package/dist/scheduler/wakeReason.js +2 -0
  52. package/dist/storage/sqliteStore.js +10 -1
  53. package/dist/storage/taskStore.js +12 -1
  54. package/dist/task/completionReadiness.js +91 -19
  55. package/dist/task/deliveryGuard.js +3 -1
  56. package/dist/task/nextAction.js +146 -51
  57. package/dist/task/publicationReference.js +1 -0
  58. package/dist/task/repairWave.js +14 -1
  59. package/dist/task/task.js +10 -0
  60. package/dist/web/webSnapshot.js +7 -1
  61. package/dist/workItem/workItem.js +12 -0
  62. package/dist/workspace/workItemChangeSetManager.js +2 -1
  63. package/i18n/README.zh-CN.md +28 -8
  64. package/package.json +1 -1
  65. package/skills/yui-leader/SKILL.md +79 -32
  66. package/skills/yui-operator/SKILL.md +51 -10
  67. package/skills/yui-reviewer/SKILL.md +23 -0
  68. package/skills/yui-runtime/SKILL.md +7 -2
@@ -1,5 +1,6 @@
1
1
  import { isReviewFindingBlocking } from "../review/reviewFinding.js";
2
2
  import { deltaRecheckBlocksAcceptance } from "../review/reviewRound.js";
3
+ import { isSemanticReviewRound } from "../review/reviewOutcomeClassifier.js";
3
4
  import { reviewFindingLedgerWriteFailedFromEvents } from "../review/reviewFindingLedger.js";
4
5
  import { blockingProviderContinuations } from "../runtime/runtimeContinuationProjection.js";
5
6
  const ACTIVE_JOB_STATUSES = new Set([
@@ -16,6 +17,7 @@ const TERMINAL_REVIEW_STATUSES = new Set(["completed", "failed"]);
16
17
  const TERMINAL_LANE_STATUSES = new Set(["completed", "failed", "yielded"]);
17
18
  export function projectCompletionReadiness(facts, options = {}) {
18
19
  const blockers = [];
20
+ const advisories = [];
19
21
  const { task } = facts;
20
22
  const findingsGate = options.findingsGate ?? true;
21
23
  // A pending/running Task-final Review must be resumed or blocked first.
@@ -38,7 +40,11 @@ export function projectCompletionReadiness(facts, options = {}) {
38
40
  // escalations remain audit evidence, but a later completed full Review (or
39
41
  // accepted delta) supersedes them instead of blocking completion forever.
40
42
  const latestCompletedTaskReview = facts.reviewRounds
41
- .filter((round) => ((round.scope ?? "work-item") === "task" && round.status === "completed"))
43
+ .filter((round) => ((round.scope ?? "work-item") === "task" && isSemanticReviewRound(round, {
44
+ listAgentRuns: () => facts.agentRuns,
45
+ listReviewFindings: () => facts.reviewFindings,
46
+ listEvents: () => facts.events
47
+ })))
42
48
  .slice()
43
49
  .sort((left, right) => (left.createdAt.localeCompare(right.createdAt)
44
50
  || left.id.localeCompare(right.id, undefined, { numeric: true })))
@@ -142,6 +148,8 @@ export function projectCompletionReadiness(facts, options = {}) {
142
148
  }
143
149
  // Provider continuations that may still write the Workspace.
144
150
  for (const continuation of blockingProviderContinuations(facts.events)) {
151
+ if (!providerContinuationBlocksCompletion(continuation, facts))
152
+ continue;
145
153
  const identity = continuation.identity;
146
154
  blockers.push({
147
155
  code: "blocking-provider-continuation",
@@ -151,12 +159,15 @@ export function projectCompletionReadiness(facts, options = {}) {
151
159
  fix: `wait for or recover the Provider turn on run ${continuation.runId}`
152
160
  });
153
161
  }
154
- // Undisposed managed workspaces. The Task-owned main workspace is excluded
155
- // (it is cleaned at archive, not at completion).
162
+ // Terminal child workspaces are cleanup advisories: Task completion is the
163
+ // semantic delivery boundary, while archive remains the fail-closed resource
164
+ // reclamation boundary. Missing/non-terminal ownership stays conservative.
156
165
  for (const workspace of facts.managedWorkspaces) {
157
- const blocker = workspaceBlocker(facts, task.id, workspace);
158
- if (blocker !== null)
159
- blockers.push(blocker);
166
+ const disposition = workspaceCompletionDisposition(facts, task.id, workspace);
167
+ if (disposition?.kind === "blocker")
168
+ blockers.push(disposition.value);
169
+ if (disposition?.kind === "advisory")
170
+ advisories.push(disposition.value);
160
171
  }
161
172
  // Active non-Leader Runs must finish. The Leader's own Run is terminalized
162
173
  // by the completion transaction itself, so it is not a readiness blocker.
@@ -207,63 +218,124 @@ export function projectCompletionReadiness(facts, options = {}) {
207
218
  return kindOrder;
208
219
  return left.ref.id.localeCompare(right.ref.id, undefined, { numeric: true });
209
220
  });
210
- return { taskId: task.id, ready: sorted.length === 0, blockers: sorted };
221
+ const sortedAdvisories = [...advisories].sort((left, right) => {
222
+ const codeOrder = left.code.localeCompare(right.code);
223
+ if (codeOrder !== 0)
224
+ return codeOrder;
225
+ const kindOrder = left.ref.kind.localeCompare(right.ref.kind);
226
+ if (kindOrder !== 0)
227
+ return kindOrder;
228
+ return left.ref.id.localeCompare(right.ref.id, undefined, { numeric: true });
229
+ });
230
+ return {
231
+ taskId: task.id,
232
+ ready: sorted.length === 0,
233
+ blockers: sorted,
234
+ advisories: sortedAdvisories
235
+ };
236
+ }
237
+ /**
238
+ * A terminal Run releases only its completion blocker, never its immutable
239
+ * continuation audit. Missing or inconsistent ownership remains ambiguous and
240
+ * therefore fail-closed. A live exact native Session can still deliver or
241
+ * write for a terminal Run, so it retains the blocker until that Session is
242
+ * stopped, broken, or replaced by another Conversation identity.
243
+ */
244
+ function providerContinuationBlocksCompletion(continuation, facts) {
245
+ if (continuation.identityConflict)
246
+ return true;
247
+ const ownerRun = facts.agentRuns.find(({ id }) => id === continuation.runId);
248
+ if (ownerRun === undefined
249
+ || ownerRun.taskId !== continuation.taskId
250
+ || ownerRun.roleName !== continuation.roleName
251
+ || ownerRun.effective.agentId !== continuation.identity.accountScope) {
252
+ return true;
253
+ }
254
+ if (ownerRun.status !== "failed" && ownerRun.status !== "yielded")
255
+ return true;
256
+ const sessions = facts.roleSessionSets.find(({ owner }) => (owner.taskId === continuation.taskId && owner.roleName === continuation.roleName));
257
+ const session = sessions?.sessions[continuation.identity.accountScope];
258
+ if (session !== undefined
259
+ && session.status !== "stopped"
260
+ && session.status !== "broken"
261
+ && session.nativeSessionId === continuation.identity.conversationId) {
262
+ return true;
263
+ }
264
+ const binding = sessions?.providerBinding;
265
+ if (binding === undefined || binding === null
266
+ || binding.providerNamespace !== continuation.identity.providerNamespace
267
+ || binding.accountScope !== continuation.identity.accountScope) {
268
+ return false;
269
+ }
270
+ const conversationIsCurrent = binding.conversations.some((conversation) => (conversation.conversationId === continuation.identity.conversationId
271
+ && conversation.epoch === binding.currentConversationEpoch
272
+ && conversation.status === "current"));
273
+ const activationIsLive = binding.activations.some((activation) => (activation.activationId === continuation.identity.activationId
274
+ && activation.conversationId === continuation.identity.conversationId
275
+ && activation.status === "active"));
276
+ return conversationIsCurrent && activationIsLive;
211
277
  }
212
- function workspaceBlocker(facts, taskId, workspace) {
278
+ function workspaceCompletionDisposition(facts, taskId, workspace) {
213
279
  const owner = workspace.owner;
214
280
  switch (owner.type) {
215
281
  case "task":
216
282
  // The Task main workspace is cleaned at archive, not completion.
217
283
  return null;
218
284
  case "work-item": {
219
- // The preflight has always blocked on WorkItem workspaces. Project the
220
- // blocker regardless of the Work Item status: if the item is still open
221
- // the incomplete-work-item blocker fires too, and once it is terminal
222
- // the workspace is the exact cleanup target.
223
- return {
285
+ const item = facts.workItems.find((entry) => entry.id === owner.workItemId);
286
+ const value = {
224
287
  code: "work-item-workspace-undisposed",
225
288
  ref: ref("work-item", owner.workItemId),
226
289
  reason: `Work Item ${owner.workItemId} has an isolated workspace that is not disposed.`,
227
290
  fix: `yui task work cleanup ${taskId}/${owner.workItemId} --integrated|--abandon`
228
291
  };
292
+ return item !== undefined && (item.status === "completed" || item.status === "retired")
293
+ ? { kind: "advisory", value }
294
+ : { kind: "blocker", value };
229
295
  }
230
296
  case "review-round": {
231
297
  const round = facts.reviewRounds.find((entry) => entry.id === owner.reviewRoundId);
232
- // A workspace for a still-active Review is covered by the
233
- // active-task-review / wait-for-owned-execution path; only project the
234
- // cleanup blocker once the Round is terminal.
235
298
  if (round !== undefined && !TERMINAL_REVIEW_STATUSES.has(round.status))
236
299
  return null;
237
- return {
300
+ const value = {
238
301
  code: "review-workspace-undisposed",
239
302
  ref: ref("review-round", owner.reviewRoundId),
240
303
  reason: `ReviewRound ${owner.reviewRoundId} is terminal but its workspace is not cleaned up.`,
241
304
  fix: `yui task work review cleanup ${taskId}/${owner.reviewRoundId}`
242
305
  };
306
+ return round === undefined
307
+ ? { kind: "blocker", value }
308
+ : { kind: "advisory", value };
243
309
  }
244
310
  case "integration-attempt": {
245
311
  const integration = facts.integrations.find((entry) => entry.id === owner.integrationAttemptId);
246
312
  if (integration !== undefined && UNRESOLVED_INTEGRATION_STATUSES.has(integration.status)) {
247
313
  return null;
248
314
  }
249
- return {
315
+ const value = {
250
316
  code: "integration-workspace-undisposed",
251
317
  ref: ref("integration-attempt", owner.integrationAttemptId),
252
318
  reason: `Integration Attempt ${owner.integrationAttemptId} is terminal but its workspace is not cleaned up.`,
253
319
  fix: `retry or continue Integration ${owner.integrationAttemptId} so its workspace is reclaimed`
254
320
  };
321
+ return integration === undefined
322
+ ? { kind: "blocker", value }
323
+ : { kind: "advisory", value };
255
324
  }
256
325
  case "execution-lane": {
257
326
  const lane = findExecutionLane(facts, owner.executionGroupId, owner.executionLaneId);
258
327
  if (lane !== undefined && !TERMINAL_LANE_STATUSES.has(lane.status))
259
328
  return null;
260
- return {
329
+ const value = {
261
330
  code: "execution-lane-workspace-undisposed",
262
331
  ref: ref("execution-lane", `${owner.executionGroupId}/${owner.executionLaneId}`),
263
332
  reason: `Execution Lane ${owner.executionGroupId}/${owner.executionLaneId} `
264
333
  + "is terminal but its workspace is not cleaned up.",
265
334
  fix: `clean up Execution Lane ${owner.executionGroupId}/${owner.executionLaneId}`
266
335
  };
336
+ return lane === undefined
337
+ ? { kind: "blocker", value }
338
+ : { kind: "advisory", value };
267
339
  }
268
340
  }
269
341
  }
@@ -1,3 +1,5 @@
1
+ import { nextActionReviewOutcomeEvidence } from "./nextAction.js";
2
+ import { isSemanticReviewRound } from "../review/reviewOutcomeClassifier.js";
1
3
  const OPEN_WORK_ITEM_STATUSES = new Set(["pending", "running", "awaiting_acceptance"]);
2
4
  export function detectDeliveryDuplicates(facts, intent) {
3
5
  switch (intent.kind) {
@@ -127,7 +129,7 @@ function detectReviewDuplicates(facts, intent) {
127
129
  if (!sameCandidate)
128
130
  continue;
129
131
  const ref = { kind: "review-round", id: round.id };
130
- if (round.status === "completed") {
132
+ if (isSemanticReviewRound(round, nextActionReviewOutcomeEvidence(facts))) {
131
133
  duplicates.push({
132
134
  severity: "exact",
133
135
  reason: `Task-final Review ${round.id} already attests this exact head`,
@@ -1,6 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
- import { sameTaskFinalReviewContract, validateTaskFinalReviewContract } from "../review/taskFinalReviewContract.js";
3
- import { currentWorkItemCandidate } from "../workItem/workItem.js";
2
+ import { deltaRecheckBlocksAcceptance } from "../review/reviewRound.js";
3
+ import { classifyReviewRoundOutcome, isSemanticReviewRound } from "../review/reviewOutcomeClassifier.js";
4
+ import { resolveRecordedTaskFinalReviewContract } from "../review/taskFinalReviewContractRebind.js";
5
+ import { taskDeliveryPath } from "./task.js";
6
+ import { currentWorkItemCandidate, governingWorkItemCandidate } from "../workItem/workItem.js";
4
7
  const OPEN_WORK_ITEM_STATUSES = new Set(["pending", "running", "awaiting_acceptance"]);
5
8
  export function projectNextAction(facts) {
6
9
  const { task } = facts;
@@ -252,16 +255,41 @@ export function projectNextAction(facts) {
252
255
  });
253
256
  }
254
257
  if (facts.workItems.length === 0) {
255
- return buildAction(facts, {
256
- kind: "implement-current-work-item",
257
- reason: `Task ${task.id} has no Work Item; create the first unit of work.`,
258
- refs: [],
259
- preconditions: [
260
- { fact: "At least one Work Item exists", satisfied: false },
261
- { fact: "Task is active", satisfied: task.status === "active" }
262
- ],
263
- recommendedCommand: `yui task work create ${task.id} \"<objective>\"`
264
- });
258
+ if (taskDeliveryPath(task) === "direct" && !taskFinalReviewRequired(facts)) {
259
+ return buildAction(facts, {
260
+ kind: "complete-task",
261
+ reason: `Task ${task.id} uses direct delivery; implement and verify the managed Task main, then complete without creating a WorkItem.`,
262
+ refs: [ref("task", task.id)],
263
+ preconditions: [
264
+ { fact: "Task is active", satisfied: task.status === "active", ref: ref("task", task.id) },
265
+ { fact: "Task main is clean, committed, and verified", satisfied: false }
266
+ ],
267
+ recommendedCommand: `yui task complete ${task.id} --summary-file -`,
268
+ ...(facts.reviewConfig === null
269
+ ? {}
270
+ : {
271
+ alternatives: [{
272
+ kind: "promote-to-integrated-delivery",
273
+ reason: "Before Task main advances, promote to integrated delivery when risk warrants an independently managed ReviewRound.",
274
+ recommendedCommand: `yui task update ${task.id} --delivery integrated`,
275
+ refs: [ref("task", task.id)]
276
+ }],
277
+ judgmentRequired: "Leader must decide whether the work still fits direct delivery or should be promoted before completing it."
278
+ })
279
+ });
280
+ }
281
+ if (taskDeliveryPath(task) === "integrated") {
282
+ return buildAction(facts, {
283
+ kind: "implement-current-work-item",
284
+ reason: `Task ${task.id} has no Work Item; create the first unit of work.`,
285
+ refs: [],
286
+ preconditions: [
287
+ { fact: "At least one Work Item exists", satisfied: false },
288
+ { fact: "Task is active", satisfied: task.status === "active" }
289
+ ],
290
+ recommendedCommand: `yui task work create ${task.id} \"<objective>\"`
291
+ });
292
+ }
265
293
  }
266
294
  const uncaptured = facts.workItems.find((item) => needsChangeSetCapture(facts, item));
267
295
  if (uncaptured !== undefined) {
@@ -291,10 +319,38 @@ export function projectNextAction(facts) {
291
319
  });
292
320
  }
293
321
  const failedFinal = latestTaskFinalReview(facts.reviewRounds);
294
- if (failedFinal !== undefined && failedFinal.status === "failed") {
322
+ const failedFinalOutcome = failedFinal === undefined
323
+ ? null
324
+ : classifyReviewRoundOutcome(failedFinal, nextActionReviewOutcomeEvidence(facts));
325
+ if (failedFinal !== undefined && failedFinalOutcome?.kind === "non-semantic") {
326
+ return buildAction(facts, {
327
+ kind: "resume-review",
328
+ reason: `Task-final Review ${failedFinal.id} ended before a semantic review was proven.`,
329
+ refs: [ref("review-round", failedFinal.id)],
330
+ preconditions: [
331
+ { fact: "Task-final Review has semantic evidence", satisfied: false, ref: ref("review-round", failedFinal.id) }
332
+ ],
333
+ recommendedCommand: `yui task review force-fresh ${task.id}/${failedFinal.id}`
334
+ });
335
+ }
336
+ if (failedFinal !== undefined && failedFinalOutcome?.kind === "ambiguous") {
337
+ return buildAction(facts, {
338
+ kind: "repair-protocol-inconsistency",
339
+ reason: `Task-final Review ${failedFinal.id} has ambiguous semantic and infrastructure evidence: ${failedFinalOutcome.reason}`,
340
+ refs: [ref("review-round", failedFinal.id)],
341
+ conflicts: [ref("review-round", failedFinal.id)],
342
+ preconditions: [
343
+ { fact: "Review outcome is unambiguously semantic or non-semantic", satisfied: false, ref: ref("review-round", failedFinal.id) }
344
+ ]
345
+ });
346
+ }
347
+ if (failedFinal !== undefined
348
+ && failedFinalOutcome?.kind === "semantic"
349
+ && ((failedFinal.checks ?? []).some(({ outcome }) => outcome === "failed")
350
+ || deltaRecheckBlocksAcceptance(failedFinal))) {
295
351
  return buildAction(facts, {
296
352
  kind: "route-review-findings",
297
- reason: `Task-final Review ${failedFinal.id} failed; route its open findings into a repair wave on one frozen head.`,
353
+ reason: `Task-final Review ${failedFinal.id} delivered semantic negative evidence; route its open findings into a repair wave on one frozen head.`,
298
354
  refs: [ref("review-round", failedFinal.id)],
299
355
  preconditions: [
300
356
  { fact: "Task-final Review is failed", satisfied: true, ref: ref("review-round", failedFinal.id) }
@@ -372,19 +428,27 @@ export function projectNextAction(facts) {
372
428
  && finalReviewRequired
373
429
  && !hasValidFinalReview(facts)) {
374
430
  const reviewerRole = taskFinalReviewRole(facts);
431
+ const directWithoutWorkItems = taskDeliveryPath(task) === "direct"
432
+ && facts.workItems.length === 0;
375
433
  return buildAction(facts, {
376
434
  kind: "request-final-review",
377
- reason: "All Work Items are delivered but no valid Task-final Review attests the integrated head.",
435
+ reason: directWithoutWorkItems
436
+ ? "This direct Task already owns a final-Review obligation; completion must prepare or resume a Review of its frozen Task head."
437
+ : "All Work Items are delivered but no valid Task-final Review attests the integrated head.",
378
438
  refs: [ref("task", task.id)],
379
- preconditions: [
380
- { fact: "All Work Items are terminal", satisfied: true },
381
- { fact: "Every ChangeSet is committed", satisfied: true },
382
- { fact: "Valid Task-final Review at the integrated head", satisfied: false }
383
- ],
384
- recommendedCommand: `yui task review request ${task.id} --role ${reviewerRole ?? "<reviewer-role>"}`
439
+ preconditions: directWithoutWorkItems
440
+ ? [{ fact: "Valid established Task-final Review at the direct head", satisfied: false }]
441
+ : [
442
+ { fact: "All Work Items are terminal", satisfied: true },
443
+ { fact: "Every ChangeSet is committed", satisfied: true },
444
+ { fact: "Valid Task-final Review at the integrated head", satisfied: false }
445
+ ],
446
+ recommendedCommand: directWithoutWorkItems
447
+ ? `yui task complete ${task.id} --summary-file -`
448
+ : `yui task review request ${task.id} --role ${reviewerRole ?? "<reviewer-role>"}`
385
449
  });
386
450
  }
387
- const finalReviewOptional = task.projectBindings.length > 0
451
+ const finalReviewOptional = taskDeliveryPath(task) === "integrated"
388
452
  && !finalReviewRequired
389
453
  && !hasValidFinalReview(facts);
390
454
  const finalReviewAlternative = finalReviewOptional && facts.reviewConfig !== null
@@ -397,19 +461,26 @@ export function projectNextAction(facts) {
397
461
  : [];
398
462
  return buildAction(facts, {
399
463
  kind: "complete-task",
400
- reason: "The delivery chain is complete; converge the Task instead of creating successor work.",
464
+ reason: taskDeliveryPath(task) === "direct"
465
+ ? "The direct Task head and its established obligations are ready; converge the Task without creating successor work."
466
+ : "The delivery chain is complete; converge the Task instead of creating successor work.",
401
467
  refs: [ref("task", task.id)],
402
468
  preconditions: [
403
469
  { fact: "All Work Items are terminal", satisfied: true },
404
470
  ...(task.projectBindings.length === 0
405
471
  ? []
406
- : [
407
- { fact: "Every ChangeSet is committed", satisfied: true },
408
- {
409
- fact: "Valid Task-final Review at the integrated head",
410
- satisfied: hasValidFinalReview(facts)
411
- }
412
- ])
472
+ : taskDeliveryPath(task) === "direct"
473
+ ? [{
474
+ fact: "Valid established Task-final Review at the direct head",
475
+ satisfied: hasValidFinalReview(facts)
476
+ }]
477
+ : [
478
+ { fact: "Every ChangeSet is committed", satisfied: true },
479
+ {
480
+ fact: "Valid Task-final Review at the integrated head",
481
+ satisfied: hasValidFinalReview(facts)
482
+ }
483
+ ])
413
484
  ],
414
485
  ...(finalReviewAlternative.length === 0 ? {} : { alternatives: finalReviewAlternative }),
415
486
  ...(!finalReviewOptional
@@ -431,7 +502,8 @@ export function durableStateFingerprint(facts) {
431
502
  ...facts.workItems.map((item) => `work:${item.id}:${item.status}:${item.revision}:${item.updatedAt}`),
432
503
  ...facts.changeSets.map((changeSet) => `change-set:${changeSet.id}:${changeSet.headCommit}`),
433
504
  ...facts.integrations.map((attempt) => `integration:${attempt.id}:${attempt.status}:${attempt.updatedAt}`),
434
- ...facts.reviewRounds.map((round) => `review:${round.id}:${round.status}:${round.endedAt ?? ""}`)
505
+ ...facts.reviewRounds.map((round) => `review:${round.id}:${round.status}:${round.endedAt ?? ""}`),
506
+ ...facts.taskFinalReviewContractEvents.map((event) => `task-final-review-event:${event.id}:${event.createdAt}`)
435
507
  ];
436
508
  return createHash("sha256").update(parts.join("\n")).digest("hex");
437
509
  }
@@ -601,16 +673,16 @@ function selectOpenWorkItem(workItems) {
601
673
  return { kind: "none" };
602
674
  }
603
675
  function taskFinalReviewContract(facts) {
604
- const contract = facts.workItems
605
- .flatMap((item) => item.candidates)
606
- .map((candidate) => candidate.taskFinalReviewContract)
607
- .find((contract) => contract !== undefined);
608
- return contract === undefined ? undefined : validateTaskFinalReviewContract(contract);
676
+ return taskFinalReviewContractResolution(facts)?.effective;
677
+ }
678
+ function taskFinalReviewContractResolution(facts) {
679
+ return resolveRecordedTaskFinalReviewContract(facts.task.id, facts.workItems, facts.reviewRounds, facts.taskFinalReviewContractEvents);
609
680
  }
610
681
  function taskFinalReviewRequired(facts) {
611
682
  return taskFinalReviewContract(facts) !== undefined
612
683
  || latestTaskFinalReview(facts.reviewRounds) !== undefined
613
- || facts.reviewConfig?.trigger === "final";
684
+ || (taskDeliveryPath(facts.task) === "integrated"
685
+ && facts.reviewConfig?.trigger === "final");
614
686
  }
615
687
  function taskFinalReviewRole(facts) {
616
688
  return taskFinalReviewContract(facts)?.reviewerRoleName
@@ -658,7 +730,11 @@ function needsChangeSetCapture(facts, item) {
658
730
  }
659
731
  function hasValidFinalReview(facts) {
660
732
  const final = latestTaskFinalReview(facts.reviewRounds);
661
- if (final === undefined || final.status !== "completed")
733
+ if (final === undefined
734
+ || !isSemanticReviewRound(final, nextActionReviewOutcomeEvidence(facts)))
735
+ return false;
736
+ if ((final.checks ?? []).some(({ outcome }) => outcome === "failed")
737
+ || deltaRecheckBlocksAcceptance(final))
662
738
  return false;
663
739
  const reviewedCommits = new Set((final.taskCandidate?.projects ?? []).map((project) => project.commit));
664
740
  if (reviewedCommits.size === 0)
@@ -676,22 +752,41 @@ function hasValidFinalReview(facts) {
676
752
  }
677
753
  return true;
678
754
  }
755
+ /** Adapts the serializable next-action evidence bundle to the shared classifier. */
756
+ export function nextActionReviewOutcomeEvidence(facts) {
757
+ const evidence = facts.reviewOutcomeEvidence;
758
+ if (evidence === undefined)
759
+ return undefined;
760
+ return {
761
+ listAgentRuns: () => evidence.agentRuns,
762
+ listReviewFindings: () => evidence.reviewFindings,
763
+ listEvents: () => evidence.events
764
+ };
765
+ }
679
766
  function detectProtocolInconsistency(facts) {
680
767
  const changeSetIds = new Set(facts.changeSets.map((changeSet) => changeSet.id));
681
768
  const workItemById = new Map(facts.workItems.map((item) => [item.id, item]));
682
- const contractedCandidates = facts.workItems
683
- .flatMap((item) => item.candidates.map((candidate) => ({ item, candidate })))
684
- .filter(({ candidate }) => candidate.taskFinalReviewContract !== undefined);
685
- if (contractedCandidates.length > 1) {
686
- const first = validateTaskFinalReviewContract(contractedCandidates[0].candidate.taskFinalReviewContract);
687
- const conflict = contractedCandidates.find(({ candidate }) => (!sameTaskFinalReviewContract(first, candidate.taskFinalReviewContract)));
688
- if (conflict !== undefined) {
689
- const refs = contractedCandidates.map(({ item, candidate }) => ref("candidate", `${item.id}/${candidate.id}`));
690
- return {
691
- reason: "Task-final Review contracts conflict across Candidates; the completion gate is ambiguous.",
692
- conflicts: refs
693
- };
694
- }
769
+ try {
770
+ taskFinalReviewContractResolution(facts);
771
+ }
772
+ catch (error) {
773
+ const candidateRefs = facts.workItems.flatMap((item) => {
774
+ const candidate = governingWorkItemCandidate(item);
775
+ return candidate?.taskFinalReviewContract === undefined
776
+ ? []
777
+ : [ref("candidate", `${item.id}/${candidate.id}`)];
778
+ });
779
+ const reviewRefs = facts.reviewRounds
780
+ .filter((round) => ((round.scope ?? "work-item") === "task"
781
+ && round.taskFinalReviewContract !== undefined))
782
+ .map((round) => ref("review-round", round.id));
783
+ const eventRefs = facts.taskFinalReviewContractEvents
784
+ .map((event) => ref("task-event", event.id));
785
+ return {
786
+ reason: "Task-final Review contract history is inconsistent: "
787
+ + (error instanceof Error ? error.message : String(error)),
788
+ conflicts: [...candidateRefs, ...reviewRefs, ...eventRefs]
789
+ };
695
790
  }
696
791
  for (const round of facts.reviewRounds) {
697
792
  const reviewConflict = reviewRoundConflict(round, facts.activeRuns);
@@ -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"]);
@@ -27,10 +27,23 @@ export function extractReviewFindings(round) {
27
27
  }
28
28
  return parseReportFindings(round.report ?? "");
29
29
  }
30
- export function planRepairWave(reviewRoundId, findings) {
30
+ export function planRepairWave(reviewRoundId, findings, strategy = "consolidated") {
31
31
  if (findings.length === 0) {
32
32
  return { reviewRoundId, openFindingCount: 0, groups: [] };
33
33
  }
34
+ if (strategy === "consolidated") {
35
+ const ordered = [...findings].sort((left, right) => (left.id.localeCompare(right.id, undefined, { numeric: true })));
36
+ return {
37
+ reviewRoundId,
38
+ openFindingCount: findings.length,
39
+ groups: [{
40
+ id: "repair-1",
41
+ findingIds: ordered.map(({ id }) => id),
42
+ paths: [...new Set(ordered.flatMap(({ paths }) => paths))].sort(),
43
+ reason: "Consolidated by default to minimize repair, integration, and review churn"
44
+ }]
45
+ };
46
+ }
34
47
  const parent = new Array(findings.length).fill(0).map((_, index) => index);
35
48
  const find = (index) => {
36
49
  let current = index;
package/dist/task/task.js CHANGED
@@ -319,6 +319,16 @@ export function taskProjectBinding(task, projectId) {
319
319
  export function taskHasProjects(task) {
320
320
  return task.projectBindings.length > 0;
321
321
  }
322
+ /**
323
+ * Delivery is a projection of the existing Task contract, not a second source
324
+ * of truth. Keeping it derived preserves every stored Task schema while giving
325
+ * callers one product-level name for the three supported paths.
326
+ */
327
+ export function taskDeliveryPath(task) {
328
+ if (task.projectBindings.length === 0)
329
+ return "no-project";
330
+ return task.requireIntegration === true ? "integrated" : "direct";
331
+ }
322
332
  export function taskProjectIds(task) {
323
333
  return task.projectBindings.map(({ projectId }) => projectId);
324
334
  }
@@ -1,3 +1,4 @@
1
+ import { taskDeliveryPath } from "../task/task.js";
1
2
  import { isRoleRunStalled, latestRunDurableProgressAt } from "../scheduler/roleRunStall.js";
2
3
  import { buildTaskExecutionProjection } from "../scheduler/taskExecutionProjection.js";
3
4
  import { summarizeExecutionGroup } from "../execution/executionGroup.js";
@@ -40,6 +41,7 @@ export function buildWebDashboardSnapshot(store, now = new Date()) {
40
41
  });
41
42
  return {
42
43
  ...task,
44
+ deliveryPath: taskDeliveryPath(task),
43
45
  ...(names.length === 0 ? {} : { projectNames: names }),
44
46
  workItems: countWorkItems(reader.listWorkItems(task.id)),
45
47
  roleCount: reader.listRoles(task.id).length,
@@ -106,7 +108,11 @@ export function buildWebTaskDetail(store, taskId, now = new Date()) {
106
108
  };
107
109
  });
108
110
  return {
109
- task: projectNames.length === 0 ? task : { ...task, projectNames },
111
+ task: {
112
+ ...task,
113
+ deliveryPath: taskDeliveryPath(task),
114
+ ...(projectNames.length === 0 ? {} : { projectNames })
115
+ },
110
116
  execution: buildTaskExecutionProjection(reader, taskId),
111
117
  brief: reader.getTaskBrief(taskId),
112
118
  roles,
@@ -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
  })