@zq-silk/yui 0.8.9 → 0.9.0

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 (35) hide show
  1. package/ARCHITECTURE.md +48 -46
  2. package/README.md +33 -31
  3. package/dist/cli/commandCatalog.js +7 -7
  4. package/dist/cli.js +1 -31
  5. package/dist/commands/executionAuditCommands.js +2 -2
  6. package/dist/commands/taskCommands.js +110 -307
  7. package/dist/commands/taskCompletionGate.js +15 -12
  8. package/dist/commands/taskContextCommand.js +18 -11
  9. package/dist/commands/taskNextActionCommand.js +4 -5
  10. package/dist/commands/taskWorkspaceCommands.js +2 -2
  11. package/dist/execution/executionGroup.js +0 -3
  12. package/dist/executor/agentExecutor.js +4 -2
  13. package/dist/executor/effectiveLaunch.js +33 -3
  14. package/dist/integration/gitIntegrationService.js +1 -1
  15. package/dist/lifecycle/exactRunTerminalization.js +12 -8
  16. package/dist/observability/orchestrationMetrics.js +5 -19
  17. package/dist/profile/agentProfile.js +1 -1
  18. package/dist/repository/taskWorkspaceCoordinator.js +2 -0
  19. package/dist/repository/taskWorkspacePreparer.js +173 -26
  20. package/dist/review/reviewRound.js +41 -24
  21. package/dist/storage/migration/productionRegistry.js +138 -0
  22. package/dist/storage/sqliteStore.js +1 -1
  23. package/dist/storage/taskStore.js +26 -27
  24. package/dist/task/completionReadiness.js +24 -22
  25. package/dist/task/nextAction.js +47 -61
  26. package/dist/task/task.js +12 -19
  27. package/dist/web/assets/client/components.js +3 -1
  28. package/dist/web/assets/client/i18n.js +6 -0
  29. package/dist/web/webSnapshot.js +0 -3
  30. package/i18n/README.zh-CN.md +18 -19
  31. package/package.json +1 -1
  32. package/skills/yui-leader/SKILL.md +47 -43
  33. package/skills/yui-operator/SKILL.md +24 -33
  34. package/skills/yui-reviewer/SKILL.md +18 -12
  35. package/skills/yui-worker/SKILL.md +5 -3
@@ -1168,19 +1168,22 @@ export class FileTaskWorkspacePreparer {
1168
1168
  if (round.status !== "pending") {
1169
1169
  throw new Error(`ReviewRound workspace can only prepare while pending: ${round.id}.`);
1170
1170
  }
1171
- const item = requireWorkItem(this.store, task.id, round.workItemId);
1172
- const candidate = item.candidates.find(({ id }) => id === round.candidateId);
1173
- if (candidate === undefined) {
1171
+ const taskScope = (round.scope ?? "work-item") === "task";
1172
+ const item = taskScope || round.workItemId === undefined
1173
+ ? undefined
1174
+ : requireWorkItem(this.store, task.id, round.workItemId);
1175
+ const candidate = taskScope
1176
+ ? undefined
1177
+ : item?.candidates.find(({ id }) => id === round.candidateId);
1178
+ if (!taskScope && candidate === undefined) {
1174
1179
  throw new Error(`ReviewRound Candidate not found: ${round.candidateId}.`);
1175
1180
  }
1176
- const taskScope = (round.scope ?? "work-item") === "task";
1177
1181
  // A WorkItem ReviewRound is an immutable snapshot of Develop. A Task
1178
- // ReviewRound intentionally uses the latest committed Integration heads
1179
- // instead, while retaining the WorkItem/Candidate anchor for storage and
1180
- // lifecycle compatibility.
1181
- const develop = candidate.workspace;
1182
- if (!taskScope && (develop === undefined || candidate.gitSnapshot === undefined)) {
1183
- throw new Error(`Candidate has no frozen managed Git snapshot: ${candidate.id}.`);
1182
+ // ReviewRound instead freezes every bound Project at the unified Task
1183
+ // heads and deliberately has no WorkItem/Candidate anchor.
1184
+ const develop = candidate?.workspace;
1185
+ if (!taskScope && (develop === undefined || candidate?.gitSnapshot === undefined)) {
1186
+ throw new Error(`Candidate has no frozen managed Git snapshot: ${candidate?.id ?? "unknown"}.`);
1184
1187
  }
1185
1188
  if (!taskScope && candidate.gitSnapshot.reviewBaseCommit !== round.reviewBaseCommit) {
1186
1189
  throw new Error(`ReviewRound base no longer matches its Candidate: ${round.id}.`);
@@ -1199,7 +1202,7 @@ export class FileTaskWorkspacePreparer {
1199
1202
  throw new Error(`Task Review candidate Project is missing: ${binding.projectId}.`);
1200
1203
  }
1201
1204
  const project = requireProject(this.store, binding.projectId);
1202
- const identity = worktreeIdentity(taskSegment, round.id);
1205
+ const identity = worktreeIdentity(taskSegment, this.#reviewWorktreeName(round));
1203
1206
  return {
1204
1207
  projectId: binding.projectId,
1205
1208
  directory: binding.directory,
@@ -1222,7 +1225,12 @@ export class FileTaskWorkspacePreparer {
1222
1225
  }
1223
1226
  const expectedEntries = new Map(frozenEntries.map((entry) => [entry.projectId, entry]));
1224
1227
  const existing = this.store.getReviewRoundWorkspace(task.id, round.id);
1225
- const reviewRoot = this.#reviewRoundWorkspaceRoot(task.id, round.id);
1228
+ const reviewRoot = this.#reviewRoundWorkspaceRoot(task.id, round);
1229
+ if (taskScope && existing === null) {
1230
+ const reassigned = await this.#reassignTaskReviewWorkspace(task, round, reviewer, reviewRoot, frozenEntries);
1231
+ if (reassigned !== null)
1232
+ return reassigned;
1233
+ }
1226
1234
  const retained = new Map();
1227
1235
  const missing = new Set();
1228
1236
  const adopted = existing?.root === reviewRoot;
@@ -1251,7 +1259,7 @@ export class FileTaskWorkspacePreparer {
1251
1259
  throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace metadata changed for ${round.id}/${entry.projectId}.`);
1252
1260
  }
1253
1261
  const project = requireProject(this.store, entry.projectId);
1254
- const identity = worktreeIdentity(taskSegment, round.id);
1262
+ const identity = worktreeIdentity(taskSegment, this.#reviewWorktreeName(round));
1255
1263
  const expectedPath = join(this.#projectContainer(project.name), identity.directory);
1256
1264
  if (entry.path !== expectedPath || entry.branch !== identity.branch) {
1257
1265
  throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed identity mismatch for ${round.id}/${entry.projectId}.`);
@@ -1339,7 +1347,7 @@ export class FileTaskWorkspacePreparer {
1339
1347
  repositoryPath: project.path,
1340
1348
  container: this.#projectContainer(project.name),
1341
1349
  taskSegment,
1342
- roleName: round.id,
1350
+ roleName: this.#reviewWorktreeName(round),
1343
1351
  baseRef: source.baseCommit
1344
1352
  });
1345
1353
  const entry = {
@@ -1404,10 +1412,14 @@ export class FileTaskWorkspacePreparer {
1404
1412
  this.#registerWorkspace(stored);
1405
1413
  return this.store.transaction((tx) => {
1406
1414
  const currentRound = tx.getReviewRound(task.id, round.id);
1407
- const currentItem = tx.getWorkItem(task.id, item.id);
1415
+ const currentItem = item === undefined ? null : tx.getWorkItem(task.id, item.id);
1416
+ const candidateChanged = taskScope
1417
+ ? currentRound === null
1418
+ || !isDeepStrictEqual(currentRound.taskCandidate, round.taskCandidate)
1419
+ : currentItem === null
1420
+ || !isDeepStrictEqual(currentItem.candidates.find(({ id }) => id === candidate.id), candidate);
1408
1421
  if (currentRound === null || currentRound.status !== "pending"
1409
- || currentItem === null
1410
- || !isDeepStrictEqual(currentItem.candidates.find(({ id }) => id === candidate.id), candidate)) {
1422
+ || candidateChanged) {
1411
1423
  throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound changed while preparing its workspace: ${round.id}.`);
1412
1424
  }
1413
1425
  if (tx.getActiveAgentRun(task.id, reviewer.name) !== null) {
@@ -1441,7 +1453,7 @@ export class FileTaskWorkspacePreparer {
1441
1453
  });
1442
1454
  }
1443
1455
  catch (error) {
1444
- await this.#discardUnadoptedEntries(task, taskSegment, prepared, round.id, existing === null, new Set([...retained.values()].map(({ entry }) => entry.path)));
1456
+ await this.#discardUnadoptedEntries(task, taskSegment, prepared, this.#reviewWorktreeName(round), existing === null, new Set([...retained.values()].map(({ entry }) => entry.path)));
1445
1457
  throw error;
1446
1458
  }
1447
1459
  }
@@ -1449,6 +1461,135 @@ export class FileTaskWorkspacePreparer {
1449
1461
  release();
1450
1462
  }
1451
1463
  }
1464
+ /**
1465
+ * Keep one physical workspace per Task Reviewer Role. A new semantic Round
1466
+ * receives a new immutable ManagedWorkspace owner and frozen base record,
1467
+ * while a clean, unmodified terminal workspace is reset in place so the
1468
+ * provider-native Reviewer Session can continue at the same cwd.
1469
+ */
1470
+ async #reassignTaskReviewWorkspace(task, round, reviewer, reviewRoot, frozenEntries) {
1471
+ const previous = this.store.listReviewRounds(task.id)
1472
+ .filter((candidate) => (candidate.id !== round.id
1473
+ && (candidate.scope ?? "work-item") === "task"
1474
+ && candidate.reviewerRoleName === round.reviewerRoleName
1475
+ && (candidate.status === "completed" || candidate.status === "failed")
1476
+ && candidate.workspaceDisposition?.kind !== "removed"
1477
+ && candidate.workspaceDisposition?.kind !== "reassigned"))
1478
+ .sort((left, right) => (left.createdAt.localeCompare(right.createdAt)
1479
+ || left.id.localeCompare(right.id, undefined, { numeric: true })))
1480
+ .at(-1);
1481
+ if (previous === undefined)
1482
+ return null;
1483
+ const previousWorkspace = this.store.getReviewRoundWorkspace(task.id, previous.id);
1484
+ if (previousWorkspace === null)
1485
+ return null;
1486
+ if (previous.workspace === undefined
1487
+ || !isDeepStrictEqual(previous.workspace, previousWorkspace)
1488
+ || previousWorkspace.root !== reviewRoot) {
1489
+ throw new ReviewRoundWorkspaceEvidenceError(`Previous Task-final Review workspace cannot be continued: ${previous.id}.`);
1490
+ }
1491
+ if (this.store.getActiveAgentRun(task.id, reviewer.name) !== null) {
1492
+ throw new ReviewRoundWorkspaceEvidenceError(`Reviewer Role has an active Run: ${task.id}/${reviewer.name}.`);
1493
+ }
1494
+ const expected = new Map(frozenEntries.map((entry) => [entry.projectId, entry]));
1495
+ if (previousWorkspace.entries.length !== expected.size) {
1496
+ throw new ReviewRoundWorkspaceEvidenceError(`Task-final Review Project scope changed for Reviewer ${reviewer.name}.`);
1497
+ }
1498
+ const nextEntries = [];
1499
+ const reset = [];
1500
+ for (const prior of previousWorkspace.entries) {
1501
+ const next = expected.get(prior.projectId);
1502
+ if (next === undefined
1503
+ || prior.path !== next.path
1504
+ || prior.branch !== next.branch
1505
+ || prior.directory !== next.directory
1506
+ || prior.access !== "write") {
1507
+ throw new ReviewRoundWorkspaceEvidenceError(`Task-final Review workspace identity changed for ${reviewer.name}/${prior.projectId}.`);
1508
+ }
1509
+ const physical = await this.git.inspect(prior.path, "HEAD");
1510
+ if (!await this.git.isClean(prior.path)
1511
+ || !sameCommit(physical.baseCommit, prior.baseCommit)) {
1512
+ throw new ReviewRoundWorkspaceEvidenceError(`Previous Task-final Review workspace contains retained diagnostics: `
1513
+ + `${previous.id}/${prior.projectId}; preserve or clean it before continuing the Reviewer Session.`);
1514
+ }
1515
+ nextEntries.push({
1516
+ ...next,
1517
+ path: prior.path,
1518
+ branch: prior.branch,
1519
+ baseRef: next.baseCommit,
1520
+ baseCommit: next.baseCommit
1521
+ });
1522
+ if (!sameCommit(physical.baseCommit, next.baseCommit)) {
1523
+ reset.push({
1524
+ path: prior.path,
1525
+ previousHead: physical.baseCommit,
1526
+ nextHead: next.baseCommit
1527
+ });
1528
+ }
1529
+ }
1530
+ const stored = createManagedWorkspace({
1531
+ owner: { type: "review-round", taskId: task.id, reviewRoundId: round.id },
1532
+ root: reviewRoot,
1533
+ entries: nextEntries
1534
+ }, this.now());
1535
+ try {
1536
+ for (const entry of reset) {
1537
+ await this.git.resetWorktree({
1538
+ targetPath: entry.path,
1539
+ expectedHead: entry.previousHead,
1540
+ restoreHead: entry.nextHead
1541
+ });
1542
+ }
1543
+ await ensureWorkspaceView(reviewRoot, nextEntries);
1544
+ const reassigned = this.store.transaction((tx) => {
1545
+ const currentPrevious = tx.getReviewRound(task.id, previous.id);
1546
+ const currentRound = tx.getReviewRound(task.id, round.id);
1547
+ const currentPreviousWorkspace = tx.getReviewRoundWorkspace(task.id, previous.id);
1548
+ if (currentPrevious === null
1549
+ || currentPrevious.status !== previous.status
1550
+ || currentPrevious.workspaceDisposition?.kind === "removed"
1551
+ || currentPrevious.workspaceDisposition?.kind === "reassigned"
1552
+ || currentPreviousWorkspace === null
1553
+ || !sameManagedWorkspace(currentPreviousWorkspace, previousWorkspace)
1554
+ || currentRound === null
1555
+ || currentRound.status !== "pending"
1556
+ || currentRound.workspace !== undefined
1557
+ || !isDeepStrictEqual(currentRound.taskCandidate, round.taskCandidate)
1558
+ || tx.getReviewRoundWorkspace(task.id, round.id) !== null
1559
+ || tx.getActiveAgentRun(task.id, reviewer.name) !== null) {
1560
+ throw new ReviewRoundWorkspaceEvidenceError(`Task-final Review workspace changed before reassignment: ${previous.id}/${round.id}.`);
1561
+ }
1562
+ const latestReviewer = tx.getRole(task.id, reviewer.name);
1563
+ if (latestReviewer === null || latestReviewer.workspace !== reviewRoot) {
1564
+ throw new ReviewRoundWorkspaceEvidenceError(`Reviewer Role workspace changed before reassignment: ${reviewer.name}.`);
1565
+ }
1566
+ tx.removeManagedWorkspace(previousWorkspace.owner);
1567
+ tx.saveReviewRound(task.id, recordReviewWorkspaceDisposition(currentPrevious, "reassigned", this.now()));
1568
+ tx.saveManagedWorkspace(stored);
1569
+ tx.saveReviewRound(task.id, attachReviewRoundWorkspace(currentRound, stored));
1570
+ return stored;
1571
+ });
1572
+ this.#registerWorkspace(reassigned);
1573
+ return reassigned;
1574
+ }
1575
+ catch (error) {
1576
+ for (const entry of [...reset].reverse()) {
1577
+ try {
1578
+ await this.git.resetWorktree({
1579
+ targetPath: entry.path,
1580
+ expectedHead: entry.nextHead,
1581
+ restoreHead: entry.previousHead
1582
+ });
1583
+ }
1584
+ catch {
1585
+ // Keep the original failure; the durable owner still names the
1586
+ // previous Round and exposes any compensation problem for cleanup.
1587
+ }
1588
+ }
1589
+ await ensureWorkspaceView(reviewRoot, previousWorkspace.entries);
1590
+ throw error;
1591
+ }
1592
+ }
1452
1593
  async inspectReviewRoundWorkspace(taskId, reviewRoundId) {
1453
1594
  const task = requireTask(this.store, taskId);
1454
1595
  const round = this.store.getReviewRound(taskId, reviewRoundId);
@@ -1461,7 +1602,7 @@ export class FileTaskWorkspacePreparer {
1461
1602
  if (round.workspace !== undefined && !isDeepStrictEqual(round.workspace, workspace)) {
1462
1603
  throw new Error(`ReviewRound workspace record diverged: ${round.id}.`);
1463
1604
  }
1464
- return this.#inspectEntries(this.#taskSegment(task), round.id, workspace.entries);
1605
+ return this.#inspectEntries(this.#taskSegment(task), this.#reviewWorktreeName(round), workspace.entries);
1465
1606
  }
1466
1607
  async snapshotReviewRoundResult(taskId, reviewRoundId) {
1467
1608
  const round = this.store.getReviewRound(taskId, reviewRoundId);
@@ -1548,7 +1689,8 @@ export class FileTaskWorkspacePreparer {
1548
1689
  if (round.status !== "completed" && round.status !== "failed") {
1549
1690
  throw new Error(`ReviewRound must be terminal before cleanup: ${round.id}.`);
1550
1691
  }
1551
- if (round.workspaceDisposition?.kind === "removed")
1692
+ if (round.workspaceDisposition?.kind === "removed"
1693
+ || round.workspaceDisposition?.kind === "reassigned")
1552
1694
  return "missing";
1553
1695
  const workspace = this.store.getReviewRoundWorkspace(task.id, round.id);
1554
1696
  if (workspace === null || round.workspace === undefined) {
@@ -1562,7 +1704,7 @@ export class FileTaskWorkspacePreparer {
1562
1704
  for (const lane of round.executionGroup?.lanes ?? []) {
1563
1705
  assertWorkspaceSessionsRetirable(this.store, task.id, lane.roleName, this.now());
1564
1706
  }
1565
- if (await this.#inspectEntries(this.#taskSegment(task), managedWorktreeName(workspace.owner), workspace.entries) === "dirty")
1707
+ if (await this.#inspectEntries(this.#taskSegment(task), this.#reviewWorktreeName(round), workspace.entries) === "dirty")
1566
1708
  return "dirty";
1567
1709
  let removed = false;
1568
1710
  for (const entry of workspace.entries) {
@@ -1571,7 +1713,7 @@ export class FileTaskWorkspacePreparer {
1571
1713
  repositoryPath: project.path,
1572
1714
  container: this.#projectContainer(project.name),
1573
1715
  taskSegment: this.#taskSegment(task),
1574
- roleName: managedWorktreeName(workspace.owner),
1716
+ roleName: this.#reviewWorktreeName(round),
1575
1717
  deleteBranch: true
1576
1718
  });
1577
1719
  if (result === "dirty") {
@@ -2064,8 +2206,13 @@ export class FileTaskWorkspacePreparer {
2064
2206
  #workItemWorkspaceRoot(taskId, workItemId) {
2065
2207
  return join(resolveTaskRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(taskId), "work-items", safePathSegment(workItemId));
2066
2208
  }
2067
- #reviewRoundWorkspaceRoot(taskId, reviewRoundId) {
2068
- return join(resolveTaskRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(taskId), "reviews", safePathSegment(reviewRoundId));
2209
+ #reviewRoundWorkspaceRoot(taskId, round) {
2210
+ return join(resolveTaskRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(taskId), "reviews", safePathSegment(this.#reviewWorktreeName(round)));
2211
+ }
2212
+ #reviewWorktreeName(round) {
2213
+ return (round.scope ?? "work-item") === "task"
2214
+ ? `reviewer-${round.reviewerRoleName}`
2215
+ : round.id;
2069
2216
  }
2070
2217
  #executionLaneWorkspaceRoot(taskId, groupId, laneId) {
2071
2218
  return join(resolveTaskRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(taskId), "execution-lanes", safePathSegment(groupId), safePathSegment(laneId));
@@ -2409,7 +2556,7 @@ function executionLaneLineage(store, task, executionGroupId, executionLaneId, hi
2409
2556
  const round = store.getReviewRound(task.id, hint.reviewRoundId);
2410
2557
  if (round === null)
2411
2558
  throw new Error(`ReviewRound not found: ${hint.reviewRoundId}.`);
2412
- return { purpose: "review", workItemId: round.workItemId, reviewRoundId: round.id };
2559
+ return { purpose: "review", reviewRoundId: round.id };
2413
2560
  }
2414
2561
  // Prefer the active Run's exact WorkItem for this Lane; fall back to the
2415
2562
  // first queued WorkItem only when no active Run owns the Lane.
@@ -2430,7 +2577,7 @@ function executionLaneLineage(store, task, executionGroupId, executionLaneId, hi
2430
2577
  for (const round of store.listReviewRounds(task.id)) {
2431
2578
  if (round.executionGroup?.id === executionGroupId
2432
2579
  && round.executionGroup.lanes.some(({ id }) => id === executionLaneId)) {
2433
- return { purpose: "review", workItemId: round.workItemId, reviewRoundId: round.id };
2580
+ return { purpose: "review", reviewRoundId: round.id };
2434
2581
  }
2435
2582
  }
2436
2583
  throw new Error(`Execution Lane lineage not found: ${task.id}/${executionGroupId}/${executionLaneId}.`);
@@ -5,7 +5,7 @@ import { validateManagedWorkspace } from "../worktree/managedWorkspace.js";
5
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
- schemaVersion: 4,
8
+ schemaVersion: 5,
9
9
  id: requireIdentity(id, "ReviewRound id"),
10
10
  taskId: requireIdentity(taskId, "Task id"),
11
11
  workItemId: requireIdentity(workItemId, "Work Item id"),
@@ -18,14 +18,12 @@ export function createReviewRound(id, taskId, workItemId, candidateId, reviewerR
18
18
  createdAt: now.toISOString()
19
19
  });
20
20
  }
21
- export function createTaskReviewRound(id, taskId, workItemId, candidateId, reviewerRoleName, requestedBy, taskCandidate, now, taskFinalReviewContract, executionGroup) {
21
+ export function createTaskReviewRound(id, taskId, reviewerRoleName, requestedBy, taskCandidate, now, taskFinalReviewContract, executionGroup) {
22
22
  const candidate = validateTaskReviewCandidate(taskCandidate);
23
23
  return validateReviewRound({
24
- schemaVersion: 4,
24
+ schemaVersion: 5,
25
25
  id: requireIdentity(id, "ReviewRound id"),
26
26
  taskId: requireIdentity(taskId, "Task id"),
27
- workItemId: requireIdentity(workItemId, "Work Item id"),
28
- candidateId: requireIdentity(candidateId, "Candidate id"),
29
27
  reviewerRoleName: requireIdentity(reviewerRoleName, "Reviewer Role"),
30
28
  reviewBaseCommit: candidate.projects[0].commit,
31
29
  scope: "task",
@@ -46,14 +44,12 @@ export function createTaskReviewRound(id, taskId, workItemId, candidateId, revie
46
44
  * diff so the Reviewer can prove equivalence instead of reloading every
47
45
  * first-round evidence.
48
46
  */
49
- export function createTaskDeltaReviewRound(id, taskId, workItemId, candidateId, reviewerRoleName, requestedBy, taskCandidate, deltaRecheck, now, taskFinalReviewContract, executionGroup) {
47
+ export function createTaskDeltaReviewRound(id, taskId, reviewerRoleName, requestedBy, taskCandidate, deltaRecheck, now, taskFinalReviewContract, executionGroup) {
50
48
  const candidate = validateTaskReviewCandidate(taskCandidate);
51
49
  return validateReviewRound({
52
- schemaVersion: 4,
50
+ schemaVersion: 5,
53
51
  id: requireIdentity(id, "ReviewRound id"),
54
52
  taskId: requireIdentity(taskId, "Task id"),
55
- workItemId: requireIdentity(workItemId, "Work Item id"),
56
- candidateId: requireIdentity(candidateId, "Candidate id"),
57
53
  reviewerRoleName: requireIdentity(reviewerRoleName, "Reviewer Role"),
58
54
  reviewBaseCommit: candidate.projects[0].commit,
59
55
  scope: "task",
@@ -157,8 +153,7 @@ export function retryTaskReviewRound(round) {
157
153
  schemaVersion: round.schemaVersion,
158
154
  id: round.id,
159
155
  taskId: round.taskId,
160
- workItemId: round.workItemId,
161
- candidateId: round.candidateId,
156
+ ...(round.legacyAnchor === undefined ? {} : { legacyAnchor: round.legacyAnchor }),
162
157
  reviewerRoleName: round.reviewerRoleName,
163
158
  reviewBaseCommit: round.reviewBaseCommit,
164
159
  scope: "task",
@@ -293,7 +288,9 @@ export function recordReviewWorkspaceDisposition(round, disposition, now) {
293
288
  if (round.workspace === undefined) {
294
289
  throw new Error(`ReviewRound has no managed workspace: ${round.id}.`);
295
290
  }
296
- if (disposition !== "preserved" && disposition !== "removed") {
291
+ if (disposition !== "preserved"
292
+ && disposition !== "reassigned"
293
+ && disposition !== "removed") {
297
294
  throw new Error(`Review workspace disposition is invalid: ${String(disposition)}.`);
298
295
  }
299
296
  if (round.workspaceDisposition?.kind === disposition)
@@ -341,13 +338,9 @@ export function updateReviewExecutionGroup(round, executionGroup) {
341
338
  return validateReviewRound({ ...round, executionGroup });
342
339
  }
343
340
  export function validateReviewRound(round) {
344
- if (round.schemaVersion !== 4)
345
- throw new Error("ReviewRound must use schemaVersion 4.");
341
+ if (round.schemaVersion !== 5)
342
+ throw new Error("ReviewRound must use schemaVersion 5.");
346
343
  validateTaskRecordReference({ taskId: round.taskId, localId: round.id }, "reviewRound");
347
- validateTaskRecordReference({ taskId: round.taskId, localId: round.workItemId }, "workItem");
348
- if (!/^candidate-[1-9]\d*$/.test(round.candidateId)) {
349
- throw new Error(`Candidate local id is invalid: ${round.candidateId}.`);
350
- }
351
344
  requireIdentity(round.reviewerRoleName, "Reviewer Role");
352
345
  requireCommit(round.reviewBaseCommit, "Review base commit");
353
346
  const scope = round.scope ?? "work-item";
@@ -355,6 +348,18 @@ export function validateReviewRound(round) {
355
348
  throw new Error(`ReviewRound scope is invalid: ${String(round.scope)}.`);
356
349
  }
357
350
  if (scope === "task") {
351
+ if (round.workItemId !== undefined || round.candidateId !== undefined) {
352
+ throw new Error(`Task ReviewRound cannot use a WorkItem Candidate anchor: ${round.id}.`);
353
+ }
354
+ if (round.legacyAnchor !== undefined) {
355
+ validateTaskRecordReference({
356
+ taskId: round.taskId,
357
+ localId: round.legacyAnchor.workItemId
358
+ }, "workItem");
359
+ if (!/^candidate-[1-9]\d*$/.test(round.legacyAnchor.candidateId)) {
360
+ throw new Error(`Legacy Candidate local id is invalid: ${round.legacyAnchor.candidateId}.`);
361
+ }
362
+ }
358
363
  if (round.taskCandidate === undefined) {
359
364
  throw new Error(`Task ReviewRound requires a frozen Task candidate: ${round.id}.`);
360
365
  }
@@ -372,9 +377,19 @@ export function validateReviewRound(round) {
372
377
  }
373
378
  }
374
379
  }
375
- else if (round.taskCandidate !== undefined
376
- || round.taskFinalReviewContract !== undefined) {
377
- throw new Error(`WorkItem ReviewRound cannot carry a Task candidate or contract: ${round.id}.`);
380
+ else {
381
+ if (round.workItemId === undefined || round.candidateId === undefined) {
382
+ throw new Error(`WorkItem ReviewRound requires a Candidate anchor: ${round.id}.`);
383
+ }
384
+ validateTaskRecordReference({ taskId: round.taskId, localId: round.workItemId }, "workItem");
385
+ if (!/^candidate-[1-9]\d*$/.test(round.candidateId)) {
386
+ throw new Error(`Candidate local id is invalid: ${round.candidateId}.`);
387
+ }
388
+ if (round.taskCandidate !== undefined
389
+ || round.taskFinalReviewContract !== undefined
390
+ || round.legacyAnchor !== undefined) {
391
+ throw new Error(`WorkItem ReviewRound cannot carry Task-final metadata: ${round.id}.`);
392
+ }
378
393
  }
379
394
  if (round.executionGroup !== undefined)
380
395
  validateReviewExecutionGroup(round.executionGroup, round);
@@ -428,6 +443,7 @@ export function validateReviewRound(round) {
428
443
  throw new Error("Only a terminal ReviewRound workspace can have a disposition.");
429
444
  }
430
445
  if (round.workspaceDisposition.kind !== "preserved"
446
+ && round.workspaceDisposition.kind !== "reassigned"
431
447
  && round.workspaceDisposition.kind !== "removed") {
432
448
  throw new Error("Review workspace disposition is invalid.");
433
449
  }
@@ -511,11 +527,12 @@ function validateReviewExecutionGroup(group, round) {
511
527
  ? "task-final-review"
512
528
  : "work-item";
513
529
  if (group.target.kind !== expectedKind
514
- || group.target.taskId !== round.taskId
515
- || group.target.candidateId !== round.candidateId) {
530
+ || group.target.taskId !== round.taskId) {
516
531
  throw new Error(`ReviewRound ExecutionGroup target is invalid: ${round.id}.`);
517
532
  }
518
- if (expectedKind === "work-item" && group.target.workItemId !== round.workItemId) {
533
+ if (expectedKind === "work-item"
534
+ && (group.target.workItemId !== round.workItemId
535
+ || group.target.candidateId !== round.candidateId)) {
519
536
  throw new Error(`ReviewRound ExecutionGroup WorkItem target is invalid: ${round.id}.`);
520
537
  }
521
538
  return group;
@@ -20,6 +20,8 @@ const PROJECT_KNOWLEDGE_PROPOSALS_FROM_VERSION = 3;
20
20
  const PROJECT_KNOWLEDGE_PROPOSALS_TO_VERSION = 4;
21
21
  const TASK_FROM_VERSION = 3;
22
22
  const TASK_TO_VERSION = 4;
23
+ const TASK_INTENT_FROM_VERSION = 4;
24
+ const TASK_INTENT_TO_VERSION = 5;
23
25
  const WORK_ITEM_FROM_VERSION = 6;
24
26
  const WORK_ITEM_TO_VERSION = 7;
25
27
  const WORK_ITEM_GIT_SNAPSHOT_FROM_VERSION = 7;
@@ -45,6 +47,8 @@ const REVIEW_ROUND_FROM_VERSION = 2;
45
47
  const REVIEW_ROUND_TO_VERSION = 3;
46
48
  const REVIEW_ROUND_GIT_SNAPSHOT_FROM_VERSION = 3;
47
49
  const REVIEW_ROUND_GIT_SNAPSHOT_TO_VERSION = 4;
50
+ const REVIEW_ROUND_TASK_ANCHOR_FROM_VERSION = 4;
51
+ const REVIEW_ROUND_TASK_ANCHOR_TO_VERSION = 5;
48
52
  const ACTIVE_RUN_POINTER_FROM_VERSION = 1;
49
53
  const ACTIVE_RUN_POINTER_TO_VERSION = 2;
50
54
  const ACTIVE_RUN_POINTER_NAMESPACE_FROM_VERSION = 2;
@@ -136,6 +140,7 @@ export function createProductionStorageRegistry() {
136
140
  .registerOfflineMigration(configV2Step())
137
141
  .registerCompatible(projectKnowledgeProposalsStep())
138
142
  .registerOfflineMigration(taskWorkspaceIdentityStep())
143
+ .registerOfflineMigration(taskIntentStep())
139
144
  .registerOfflineMigration(recordFamilyStep("workItem", WORK_ITEM_FROM_VERSION, WORK_ITEM_TO_VERSION, "workItems"))
140
145
  .registerOfflineMigration(recordFamilyStep("workItem", WORK_ITEM_GIT_SNAPSHOT_FROM_VERSION, WORK_ITEM_GIT_SNAPSHOT_TO_VERSION, "workItems"))
141
146
  .registerOfflineMigration(workItemExecutionGroupHistoryStep())
@@ -146,6 +151,7 @@ export function createProductionStorageRegistry() {
146
151
  .registerOfflineMigration(messageWakePolicyStep())
147
152
  .registerOfflineMigration(recordFamilyStep("reviewRound", REVIEW_ROUND_FROM_VERSION, REVIEW_ROUND_TO_VERSION, "reviewRounds"))
148
153
  .registerOfflineMigration(recordFamilyStep("reviewRound", REVIEW_ROUND_GIT_SNAPSHOT_FROM_VERSION, REVIEW_ROUND_GIT_SNAPSHOT_TO_VERSION, "reviewRounds"))
154
+ .registerOfflineMigration(reviewRoundTaskAnchorStep())
149
155
  .registerOfflineMigration(recordFamilyStep("activeRunPointer", ACTIVE_RUN_POINTER_FROM_VERSION, ACTIVE_RUN_POINTER_TO_VERSION, "activeRuns"))
150
156
  .registerOfflineMigration(managedWorkspaceFamilyStep())
151
157
  .registerOfflineMigration(activeRunPointerNamespaceStep())
@@ -1546,6 +1552,65 @@ function recordFamilyStep(recordKind, fromVersion, toVersion, taskMapKey) {
1546
1552
  declaredEffects: []
1547
1553
  };
1548
1554
  }
1555
+ function reviewRoundTaskAnchorStep() {
1556
+ return {
1557
+ axis: "record",
1558
+ recordKind: "reviewRound",
1559
+ fromVersion: REVIEW_ROUND_TASK_ANCHOR_FROM_VERSION,
1560
+ toVersion: REVIEW_ROUND_TASK_ANCHOR_TO_VERSION,
1561
+ preconditions: (snapshot) => requireRecordFamilyVersion(snapshot, "reviewRound", REVIEW_ROUND_TASK_ANCHOR_FROM_VERSION, "reviewRounds"),
1562
+ transform: migrateReviewRoundTaskAnchors,
1563
+ declaredEffects: []
1564
+ };
1565
+ }
1566
+ function migrateReviewRoundTaskAnchors(snapshot) {
1567
+ requireRecordFamilyVersion(snapshot, "reviewRound", REVIEW_ROUND_TASK_ANCHOR_FROM_VERSION, "reviewRounds");
1568
+ const manifestVersions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
1569
+ const schemaManifest = {
1570
+ ...snapshot.schemaManifest,
1571
+ recordVersions: {
1572
+ ...manifestVersions,
1573
+ reviewRound: REVIEW_ROUND_TASK_ANCHOR_TO_VERSION
1574
+ }
1575
+ };
1576
+ if (snapshot.state === null)
1577
+ return { schemaManifest, state: null };
1578
+ const tasks = asObject(snapshot.state.tasks, "state tasks");
1579
+ const nextTasks = {};
1580
+ for (const [taskId, rawTask] of Object.entries(tasks)) {
1581
+ const aggregate = asObject(rawTask, `Task aggregate ${taskId}`);
1582
+ const rawRounds = aggregate.reviewRounds;
1583
+ if (rawRounds === undefined) {
1584
+ nextTasks[taskId] = { ...aggregate };
1585
+ continue;
1586
+ }
1587
+ const rounds = asObject(rawRounds, `reviewRound map ${taskId}`);
1588
+ const nextRounds = {};
1589
+ for (const [roundId, rawRound] of Object.entries(rounds)) {
1590
+ const round = asObject(rawRound, `reviewRound ${taskId}/${roundId}`);
1591
+ if (round.scope !== "task") {
1592
+ nextRounds[roundId] = {
1593
+ ...round,
1594
+ schemaVersion: REVIEW_ROUND_TASK_ANCHOR_TO_VERSION
1595
+ };
1596
+ continue;
1597
+ }
1598
+ const workItemId = requiredMigrationText(round.workItemId, `reviewRound ${taskId}/${roundId} WorkItem id`);
1599
+ const candidateId = requiredMigrationText(round.candidateId, `reviewRound ${taskId}/${roundId} Candidate id`);
1600
+ const { workItemId: _workItemId, candidateId: _candidateId, schemaVersion: _schemaVersion, ...retained } = round;
1601
+ nextRounds[roundId] = {
1602
+ ...retained,
1603
+ schemaVersion: REVIEW_ROUND_TASK_ANCHOR_TO_VERSION,
1604
+ legacyAnchor: { workItemId, candidateId }
1605
+ };
1606
+ }
1607
+ nextTasks[taskId] = { ...aggregate, reviewRounds: nextRounds };
1608
+ }
1609
+ return {
1610
+ schemaManifest,
1611
+ state: { ...snapshot.state, tasks: nextTasks }
1612
+ };
1613
+ }
1549
1614
  function agentRunContextProtocolStep() {
1550
1615
  return {
1551
1616
  axis: "record",
@@ -2747,6 +2812,79 @@ function migrateTaskV3ToV4(snapshot) {
2747
2812
  state: { ...snapshot.state, tasks: nextTasks }
2748
2813
  };
2749
2814
  }
2815
+ /**
2816
+ * Task v5 replaces the stored delivery topology switch with an optional,
2817
+ * Project-defined intent. Historical topology is retained as audit context,
2818
+ * but no current workflow decision reads it.
2819
+ */
2820
+ function taskIntentStep() {
2821
+ return {
2822
+ axis: "record",
2823
+ recordKind: "task",
2824
+ fromVersion: TASK_INTENT_FROM_VERSION,
2825
+ toVersion: TASK_INTENT_TO_VERSION,
2826
+ preconditions: requireTaskV4Family,
2827
+ transform: migrateTaskV4ToV5,
2828
+ declaredEffects: []
2829
+ };
2830
+ }
2831
+ function requireTaskV4Family(snapshot) {
2832
+ const manifestVersions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
2833
+ if (manifestVersions.task !== TASK_INTENT_FROM_VERSION) {
2834
+ throw new Error(`Record task migration requires manifest version ${TASK_INTENT_FROM_VERSION}.`);
2835
+ }
2836
+ if (snapshot.state === null)
2837
+ return;
2838
+ const tasks = asObject(snapshot.state.tasks, "state tasks");
2839
+ for (const [taskId, rawTask] of Object.entries(tasks)) {
2840
+ const aggregate = asObject(rawTask, `Task aggregate ${taskId}`);
2841
+ if (aggregate.task === undefined)
2842
+ continue;
2843
+ const record = asObject(aggregate.task, `Task ${taskId}`);
2844
+ if (record.schemaVersion !== TASK_INTENT_FROM_VERSION) {
2845
+ throw new Error(`Task ${taskId} must use schemaVersion ${TASK_INTENT_FROM_VERSION}.`);
2846
+ }
2847
+ }
2848
+ }
2849
+ function migrateTaskV4ToV5(snapshot) {
2850
+ requireTaskV4Family(snapshot);
2851
+ const manifestVersions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
2852
+ const schemaManifest = {
2853
+ ...snapshot.schemaManifest,
2854
+ recordVersions: { ...manifestVersions, task: TASK_INTENT_TO_VERSION }
2855
+ };
2856
+ if (snapshot.state === null)
2857
+ return { schemaManifest, state: null };
2858
+ const tasks = asObject(snapshot.state.tasks, "state tasks");
2859
+ const nextTasks = {};
2860
+ for (const [taskId, rawTask] of Object.entries(tasks)) {
2861
+ const aggregate = asObject(rawTask, `Task aggregate ${taskId}`);
2862
+ if (aggregate.task === undefined) {
2863
+ nextTasks[taskId] = { ...aggregate };
2864
+ continue;
2865
+ }
2866
+ const task = asObject(aggregate.task, `Task ${taskId}`);
2867
+ const { requireIntegration, schemaVersion: _schemaVersion, ...retained } = task;
2868
+ const projectBindings = Array.isArray(task.projectBindings)
2869
+ ? task.projectBindings
2870
+ : [];
2871
+ const legacyDeliveryPath = projectBindings.length === 0
2872
+ ? undefined
2873
+ : requireIntegration === true ? "integrated" : "direct";
2874
+ nextTasks[taskId] = {
2875
+ ...aggregate,
2876
+ task: {
2877
+ ...retained,
2878
+ schemaVersion: TASK_INTENT_TO_VERSION,
2879
+ ...(legacyDeliveryPath === undefined ? {} : { legacyDeliveryPath })
2880
+ }
2881
+ };
2882
+ }
2883
+ return {
2884
+ schemaManifest,
2885
+ state: { ...snapshot.state, tasks: nextTasks }
2886
+ };
2887
+ }
2750
2888
  /**
2751
2889
  * A version bump is deliverable only when the shared planner resolves the full
2752
2890
  * adjacent path. This also covers target-only record families as explicit 0->1
@@ -722,7 +722,7 @@ export class SqliteTaskStore {
722
722
  id: task.id,
723
723
  status: task.status,
724
724
  projectBindings: task.projectBindings,
725
- requireIntegration: task.requireIntegration
725
+ type: task.type
726
726
  },
727
727
  workItems: this.#sortById(this.#listPayload("work_items", "task_id = ?", [taskId]), (item) => item.id),
728
728
  changeSets: this.#sortById(this.#listPayload("change_sets", "task_id = ?", [taskId]), (changeSet) => changeSet.id),