@zq-silk/yui 0.8.3 → 0.8.7

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 (73) hide show
  1. package/ARCHITECTURE.md +40 -22
  2. package/README.md +66 -19
  3. package/dist/cli/commandCatalog.js +43 -14
  4. package/dist/cli/operatorWizard.js +10 -20
  5. package/dist/cli/updatePorts.js +6 -0
  6. package/dist/cli.js +252 -37
  7. package/dist/commands/executionAuditCommands.js +30 -0
  8. package/dist/commands/globalRoleCommands.js +8 -4
  9. package/dist/commands/operatorCommands.js +42 -1
  10. package/dist/commands/taskCommands.js +527 -147
  11. package/dist/commands/taskCompletionGate.js +36 -24
  12. package/dist/commands/taskContextCommand.js +11 -4
  13. package/dist/commands/taskInputCommands.js +48 -10
  14. package/dist/commands/taskNextActionCommand.js +38 -3
  15. package/dist/commands/taskOverviewCommand.js +2 -1
  16. package/dist/commands/taskRoleRuntimeStatus.js +2 -1
  17. package/dist/context/runContextPack.js +9 -5
  18. package/dist/context/sessionBootstrapManifest.js +158 -11
  19. package/dist/context/wakeNotification.js +5 -3
  20. package/dist/controller/clientRuntime.js +15 -15
  21. package/dist/controller/controller.js +16 -8
  22. package/dist/controller/fileSchedulerStoreAdapter.js +67 -7
  23. package/dist/controller/handoverCandidate.js +10 -3
  24. package/dist/controller/sessionNotify.js +4 -22
  25. package/dist/executor/agentAdapter.js +2 -2
  26. package/dist/executor/agentExecutor.js +29 -9
  27. package/dist/executor/fileRoleLaunchPlanner.js +37 -45
  28. package/dist/integration/gitIntegrationService.js +50 -2
  29. package/dist/integration/integrationCheckEvidenceReuse.js +53 -0
  30. package/dist/observability/executionAudit.js +47 -1
  31. package/dist/observability/faultClassification.js +6 -4
  32. package/dist/observability/orchestrationMetrics.js +196 -0
  33. package/dist/operator/operatorSessionHistory.js +36 -0
  34. package/dist/release/releaseHandover.js +7 -5
  35. package/dist/release/runtimeRelease.js +15 -0
  36. package/dist/repository/taskWorkspaceCoordinator.js +13 -10
  37. package/dist/review/deltaRecheck.js +3 -2
  38. package/dist/review/reviewFindingLedger.js +5 -4
  39. package/dist/review/reviewOutcomeClassifier.js +263 -54
  40. package/dist/review/taskFinalReviewContractEvent.js +1 -0
  41. package/dist/review/taskFinalReviewContractRebind.js +367 -0
  42. package/dist/run/runIdentity.js +10 -70
  43. package/dist/runtime/agentHost.js +3 -4
  44. package/dist/runtime/codexAppServerRuntime.js +6 -0
  45. package/dist/runtime/exactControlPlane.js +47 -37
  46. package/dist/runtime/firstProgressStopLoss.js +54 -0
  47. package/dist/runtime/launchBroker.js +10 -2
  48. package/dist/runtime/runtimeDeadlines.js +14 -0
  49. package/dist/runtime/sessionTitle.js +24 -12
  50. package/dist/runtime/structuredProviderHost.js +7 -1
  51. package/dist/runtime/tmuxAdapters.js +10 -3
  52. package/dist/scheduler/actionability.js +4 -2
  53. package/dist/scheduler/activeRoleRunDelivery.js +20 -18
  54. package/dist/scheduler/activeTaskProgress.js +2 -1
  55. package/dist/scheduler/leaderWakeupProcessor.js +33 -2
  56. package/dist/scheduler/taskExecutionProjection.js +13 -4
  57. package/dist/scheduler/wakeReason.js +1 -0
  58. package/dist/storage/sqliteStore.js +18 -3
  59. package/dist/storage/taskStore.js +14 -3
  60. package/dist/task/completionReadiness.js +48 -19
  61. package/dist/task/deliveryGuard.js +3 -1
  62. package/dist/task/nextAction.js +153 -55
  63. package/dist/task/repairWave.js +14 -1
  64. package/dist/task/task.js +10 -0
  65. package/dist/task/taskRecordRetirement.js +72 -0
  66. package/dist/web/webSnapshot.js +7 -1
  67. package/dist/workItem/workItem.js +6 -4
  68. package/i18n/README.zh-CN.md +48 -9
  69. package/package.json +1 -1
  70. package/skills/yui-leader/SKILL.md +73 -31
  71. package/skills/yui-operator/SKILL.md +58 -10
  72. package/skills/yui-reviewer/SKILL.md +23 -0
  73. package/skills/yui-runtime/SKILL.md +6 -6
@@ -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 })))
@@ -153,12 +159,15 @@ export function projectCompletionReadiness(facts, options = {}) {
153
159
  fix: `wait for or recover the Provider turn on run ${continuation.runId}`
154
160
  });
155
161
  }
156
- // Undisposed managed workspaces. The Task-owned main workspace is excluded
157
- // (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.
158
165
  for (const workspace of facts.managedWorkspaces) {
159
- const blocker = workspaceBlocker(facts, task.id, workspace);
160
- if (blocker !== null)
161
- 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);
162
171
  }
163
172
  // Active non-Leader Runs must finish. The Leader's own Run is terminalized
164
173
  // by the completion transaction itself, so it is not a readiness blocker.
@@ -209,7 +218,21 @@ export function projectCompletionReadiness(facts, options = {}) {
209
218
  return kindOrder;
210
219
  return left.ref.id.localeCompare(right.ref.id, undefined, { numeric: true });
211
220
  });
212
- 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
+ };
213
236
  }
214
237
  /**
215
238
  * A terminal Run releases only its completion blocker, never its immutable
@@ -252,61 +275,67 @@ function providerContinuationBlocksCompletion(continuation, facts) {
252
275
  && activation.status === "active"));
253
276
  return conversationIsCurrent && activationIsLive;
254
277
  }
255
- function workspaceBlocker(facts, taskId, workspace) {
278
+ function workspaceCompletionDisposition(facts, taskId, workspace) {
256
279
  const owner = workspace.owner;
257
280
  switch (owner.type) {
258
281
  case "task":
259
282
  // The Task main workspace is cleaned at archive, not completion.
260
283
  return null;
261
284
  case "work-item": {
262
- // The preflight has always blocked on WorkItem workspaces. Project the
263
- // blocker regardless of the Work Item status: if the item is still open
264
- // the incomplete-work-item blocker fires too, and once it is terminal
265
- // the workspace is the exact cleanup target.
266
- return {
285
+ const item = facts.workItems.find((entry) => entry.id === owner.workItemId);
286
+ const value = {
267
287
  code: "work-item-workspace-undisposed",
268
288
  ref: ref("work-item", owner.workItemId),
269
289
  reason: `Work Item ${owner.workItemId} has an isolated workspace that is not disposed.`,
270
290
  fix: `yui task work cleanup ${taskId}/${owner.workItemId} --integrated|--abandon`
271
291
  };
292
+ return item !== undefined && (item.status === "completed" || item.status === "retired")
293
+ ? { kind: "advisory", value }
294
+ : { kind: "blocker", value };
272
295
  }
273
296
  case "review-round": {
274
297
  const round = facts.reviewRounds.find((entry) => entry.id === owner.reviewRoundId);
275
- // A workspace for a still-active Review is covered by the
276
- // active-task-review / wait-for-owned-execution path; only project the
277
- // cleanup blocker once the Round is terminal.
278
298
  if (round !== undefined && !TERMINAL_REVIEW_STATUSES.has(round.status))
279
299
  return null;
280
- return {
300
+ const value = {
281
301
  code: "review-workspace-undisposed",
282
302
  ref: ref("review-round", owner.reviewRoundId),
283
303
  reason: `ReviewRound ${owner.reviewRoundId} is terminal but its workspace is not cleaned up.`,
284
304
  fix: `yui task work review cleanup ${taskId}/${owner.reviewRoundId}`
285
305
  };
306
+ return round === undefined
307
+ ? { kind: "blocker", value }
308
+ : { kind: "advisory", value };
286
309
  }
287
310
  case "integration-attempt": {
288
311
  const integration = facts.integrations.find((entry) => entry.id === owner.integrationAttemptId);
289
312
  if (integration !== undefined && UNRESOLVED_INTEGRATION_STATUSES.has(integration.status)) {
290
313
  return null;
291
314
  }
292
- return {
315
+ const value = {
293
316
  code: "integration-workspace-undisposed",
294
317
  ref: ref("integration-attempt", owner.integrationAttemptId),
295
318
  reason: `Integration Attempt ${owner.integrationAttemptId} is terminal but its workspace is not cleaned up.`,
296
319
  fix: `retry or continue Integration ${owner.integrationAttemptId} so its workspace is reclaimed`
297
320
  };
321
+ return integration === undefined
322
+ ? { kind: "blocker", value }
323
+ : { kind: "advisory", value };
298
324
  }
299
325
  case "execution-lane": {
300
326
  const lane = findExecutionLane(facts, owner.executionGroupId, owner.executionLaneId);
301
327
  if (lane !== undefined && !TERMINAL_LANE_STATUSES.has(lane.status))
302
328
  return null;
303
- return {
329
+ const value = {
304
330
  code: "execution-lane-workspace-undisposed",
305
331
  ref: ref("execution-lane", `${owner.executionGroupId}/${owner.executionLaneId}`),
306
332
  reason: `Execution Lane ${owner.executionGroupId}/${owner.executionLaneId} `
307
333
  + "is terminal but its workspace is not cleaned up.",
308
334
  fix: `clean up Execution Lane ${owner.executionGroupId}/${owner.executionLaneId}`
309
335
  };
336
+ return lane === undefined
337
+ ? { kind: "blocker", value }
338
+ : { kind: "advisory", value };
310
339
  }
311
340
  }
312
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,5 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
- import { sameTaskFinalReviewContract, validateTaskFinalReviewContract } from "../review/taskFinalReviewContract.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";
3
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) {
@@ -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
  }
@@ -578,7 +650,10 @@ function selectOpenWorkItem(workItems) {
578
650
  const openItems = workItems.filter((item) => OPEN_WORK_ITEM_STATUSES.has(item.status));
579
651
  if (openItems.length === 0)
580
652
  return { kind: "none" };
581
- const eligible = openItems.find((item) => (item.dependsOn.every((dependencyId) => byId.get(dependencyId)?.status === "completed")));
653
+ const eligible = openItems.find((item) => (item.dependsOn.every((dependencyId) => {
654
+ const status = byId.get(dependencyId)?.status;
655
+ return status === "completed" || status === "retired";
656
+ })));
582
657
  if (eligible !== undefined)
583
658
  return { kind: "ready", item: eligible };
584
659
  let current = openItems[0];
@@ -588,8 +663,10 @@ function selectOpenWorkItem(workItems) {
588
663
  return { kind: "blocked", itemId: current.id, blockedBy: current.id };
589
664
  }
590
665
  visited.add(current.id);
591
- const blockedBy = current.dependsOn
592
- .find((dependencyId) => byId.get(dependencyId)?.status !== "completed");
666
+ const blockedBy = current.dependsOn.find((dependencyId) => {
667
+ const status = byId.get(dependencyId)?.status;
668
+ return status !== "completed" && status !== "retired";
669
+ });
593
670
  if (blockedBy === undefined)
594
671
  return { kind: "ready", item: current };
595
672
  const dependency = byId.get(blockedBy);
@@ -601,15 +678,16 @@ function selectOpenWorkItem(workItems) {
601
678
  return { kind: "none" };
602
679
  }
603
680
  function taskFinalReviewContract(facts) {
604
- const contract = facts.workItems
605
- .map((item) => governingWorkItemCandidate(item)?.taskFinalReviewContract)
606
- .find((contract) => contract !== undefined);
607
- return contract === undefined ? undefined : validateTaskFinalReviewContract(contract);
681
+ return taskFinalReviewContractResolution(facts)?.effective;
682
+ }
683
+ function taskFinalReviewContractResolution(facts) {
684
+ return resolveRecordedTaskFinalReviewContract(facts.task.id, facts.workItems, facts.reviewRounds, facts.taskFinalReviewContractEvents);
608
685
  }
609
686
  function taskFinalReviewRequired(facts) {
610
687
  return taskFinalReviewContract(facts) !== undefined
611
688
  || latestTaskFinalReview(facts.reviewRounds) !== undefined
612
- || facts.reviewConfig?.trigger === "final";
689
+ || (taskDeliveryPath(facts.task) === "integrated"
690
+ && facts.reviewConfig?.trigger === "final");
613
691
  }
614
692
  function taskFinalReviewRole(facts) {
615
693
  return taskFinalReviewContract(facts)?.reviewerRoleName
@@ -657,7 +735,11 @@ function needsChangeSetCapture(facts, item) {
657
735
  }
658
736
  function hasValidFinalReview(facts) {
659
737
  const final = latestTaskFinalReview(facts.reviewRounds);
660
- if (final === undefined || final.status !== "completed")
738
+ if (final === undefined
739
+ || !isSemanticReviewRound(final, nextActionReviewOutcomeEvidence(facts)))
740
+ return false;
741
+ if ((final.checks ?? []).some(({ outcome }) => outcome === "failed")
742
+ || deltaRecheckBlocksAcceptance(final))
661
743
  return false;
662
744
  const reviewedCommits = new Set((final.taskCandidate?.projects ?? []).map((project) => project.commit));
663
745
  if (reviewedCommits.size === 0)
@@ -675,25 +757,41 @@ function hasValidFinalReview(facts) {
675
757
  }
676
758
  return true;
677
759
  }
760
+ /** Adapts the serializable next-action evidence bundle to the shared classifier. */
761
+ export function nextActionReviewOutcomeEvidence(facts) {
762
+ const evidence = facts.reviewOutcomeEvidence;
763
+ if (evidence === undefined)
764
+ return undefined;
765
+ return {
766
+ listAgentRuns: () => evidence.agentRuns,
767
+ listReviewFindings: () => evidence.reviewFindings,
768
+ listEvents: () => evidence.events
769
+ };
770
+ }
678
771
  function detectProtocolInconsistency(facts) {
679
772
  const changeSetIds = new Set(facts.changeSets.map((changeSet) => changeSet.id));
680
773
  const workItemById = new Map(facts.workItems.map((item) => [item.id, item]));
681
- const contractedCandidates = facts.workItems
682
- .flatMap((item) => {
683
- const candidate = governingWorkItemCandidate(item);
684
- return candidate === undefined ? [] : [{ item, candidate }];
685
- })
686
- .filter(({ candidate }) => candidate.taskFinalReviewContract !== undefined);
687
- if (contractedCandidates.length > 1) {
688
- const first = validateTaskFinalReviewContract(contractedCandidates[0].candidate.taskFinalReviewContract);
689
- const conflict = contractedCandidates.find(({ candidate }) => (!sameTaskFinalReviewContract(first, candidate.taskFinalReviewContract)));
690
- if (conflict !== undefined) {
691
- const refs = contractedCandidates.map(({ item, candidate }) => ref("candidate", `${item.id}/${candidate.id}`));
692
- return {
693
- reason: "Task-final Review contracts conflict across Candidates; the completion gate is ambiguous.",
694
- conflicts: refs
695
- };
696
- }
774
+ try {
775
+ taskFinalReviewContractResolution(facts);
776
+ }
777
+ catch (error) {
778
+ const candidateRefs = facts.workItems.flatMap((item) => {
779
+ const candidate = governingWorkItemCandidate(item);
780
+ return candidate?.taskFinalReviewContract === undefined
781
+ ? []
782
+ : [ref("candidate", `${item.id}/${candidate.id}`)];
783
+ });
784
+ const reviewRefs = facts.reviewRounds
785
+ .filter((round) => ((round.scope ?? "work-item") === "task"
786
+ && round.taskFinalReviewContract !== undefined))
787
+ .map((round) => ref("review-round", round.id));
788
+ const eventRefs = facts.taskFinalReviewContractEvents
789
+ .map((event) => ref("task-event", event.id));
790
+ return {
791
+ reason: "Task-final Review contract history is inconsistent: "
792
+ + (error instanceof Error ? error.message : String(error)),
793
+ conflicts: [...candidateRefs, ...reviewRefs, ...eventRefs]
794
+ };
697
795
  }
698
796
  for (const round of facts.reviewRounds) {
699
797
  const reviewConflict = reviewRoundConflict(round, facts.activeRuns);
@@ -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
  }
@@ -0,0 +1,72 @@
1
+ import { createTaskEvent } from "../event/taskEvent.js";
2
+ export const TASK_RECORD_RETIRED_EVENT = "task.record-retired";
3
+ export const RETIRABLE_TASK_RECORD_KINDS = [
4
+ "work-item",
5
+ "message",
6
+ "agent-run"
7
+ ];
8
+ /**
9
+ * Appends a tombstone fact without rewriting the original record. Operational
10
+ * projections ignore the retired identity; audit and list surfaces can still
11
+ * render both the original bytes and this reasoned retirement event.
12
+ */
13
+ export function createTaskRecordRetirement(input, now) {
14
+ return createTaskEvent(input.eventId, input.taskId, TASK_RECORD_RETIRED_EVENT, {
15
+ recordKind: requireRecordKind(input.recordKind),
16
+ recordId: requireText(input.recordId, "Task record id"),
17
+ reason: requireText(input.reason, "Task record retirement reason"),
18
+ retiredBy: requireRetiredBy(input.retiredBy)
19
+ }, now);
20
+ }
21
+ export function taskRecordRetirement(event) {
22
+ if (event.type !== TASK_RECORD_RETIRED_EVENT)
23
+ return null;
24
+ const recordKind = event.payload.recordKind;
25
+ const retiredBy = event.payload.retiredBy;
26
+ if (!RETIRABLE_TASK_RECORD_KINDS.includes(recordKind)
27
+ || (retiredBy !== "user" && retiredBy !== "operator" && retiredBy !== "leader")
28
+ || event.payload.recordId?.trim().length === 0
29
+ || event.payload.reason?.trim().length === 0) {
30
+ return null;
31
+ }
32
+ return Object.freeze({
33
+ recordKind: recordKind,
34
+ recordId: event.payload.recordId,
35
+ reason: event.payload.reason,
36
+ retiredBy
37
+ });
38
+ }
39
+ export function retiredTaskRecordIds(events, recordKind) {
40
+ const ids = new Set();
41
+ for (const event of events) {
42
+ const retirement = taskRecordRetirement(event);
43
+ if (retirement?.recordKind === recordKind)
44
+ ids.add(retirement.recordId);
45
+ }
46
+ return ids;
47
+ }
48
+ export function isTaskRecordRetired(events, recordKind, recordId) {
49
+ return retiredTaskRecordIds(events, recordKind).has(recordId);
50
+ }
51
+ export function operationalTaskRecords(records, events, recordKind) {
52
+ const retired = retiredTaskRecordIds(events, recordKind);
53
+ return retired.size === 0 ? [...records] : records.filter(({ id }) => !retired.has(id));
54
+ }
55
+ function requireRecordKind(value) {
56
+ if (!RETIRABLE_TASK_RECORD_KINDS.includes(value)) {
57
+ throw new Error(`Task record retirement kind is invalid: ${String(value)}.`);
58
+ }
59
+ return value;
60
+ }
61
+ function requireRetiredBy(value) {
62
+ if (value !== "user" && value !== "operator" && value !== "leader") {
63
+ throw new Error(`Task record retirement actor is invalid: ${String(value)}.`);
64
+ }
65
+ return value;
66
+ }
67
+ function requireText(value, label) {
68
+ if (typeof value !== "string" || value.includes("\0") || value.trim().length === 0) {
69
+ throw new Error(`${label} is required.`);
70
+ }
71
+ return value.trim();
72
+ }
@@ -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,
@@ -638,8 +638,8 @@ function normalizeProjectBaseRefs(baseRefs) {
638
638
  });
639
639
  }
640
640
  function normalizeDisposition(input, now) {
641
- if (input.by !== "leader") {
642
- throw new Error("Only the Task Leader may retire a Work Item.");
641
+ if (input.by !== "leader" && input.by !== "operator" && input.by !== "user") {
642
+ throw new Error("Work Item retirement actor is invalid.");
643
643
  }
644
644
  const summary = requireText(input.summary, "Work item disposition summary");
645
645
  const replacementWorkItemId = input.replacementWorkItemId;
@@ -648,7 +648,7 @@ function normalizeDisposition(input, now) {
648
648
  }
649
649
  const result = {
650
650
  schemaVersion: 1,
651
- by: "leader",
651
+ by: input.by,
652
652
  summary,
653
653
  retiredAt: now.toISOString(),
654
654
  ...(replacementWorkItemId === undefined ? {} : { replacementWorkItemId })
@@ -659,7 +659,9 @@ function validateDisposition(disposition) {
659
659
  if (disposition.schemaVersion !== 1) {
660
660
  throw new Error("Work Item disposition must use schemaVersion 1.");
661
661
  }
662
- if (disposition.by !== "leader") {
662
+ if (disposition.by !== "leader"
663
+ && disposition.by !== "operator"
664
+ && disposition.by !== "user") {
663
665
  throw new Error("Work Item disposition actor is invalid.");
664
666
  }
665
667
  requireText(disposition.summary, "Work item disposition summary");