@zq-silk/yui 0.11.2 → 0.12.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 (91) hide show
  1. package/ARCHITECTURE.md +23 -4
  2. package/README.md +80 -13
  3. package/dist/cli/commandCatalog.js +30 -5
  4. package/dist/cli.js +105 -43
  5. package/dist/commands/configCommands.js +12 -45
  6. package/dist/commands/executionAuditCommands.js +1 -1
  7. package/dist/commands/jobCommands.js +4 -10
  8. package/dist/commands/projectCommands.js +581 -2
  9. package/dist/commands/taskCommands.js +159 -172
  10. package/dist/commands/taskCompletionGate.js +29 -54
  11. package/dist/commands/taskContextCommand.js +55 -11
  12. package/dist/commands/taskInputCommands.js +8 -5
  13. package/dist/commands/taskIntegrationCommands.js +2 -1
  14. package/dist/commands/taskNextActionCommand.js +56 -6
  15. package/dist/commands/taskOverviewCommand.js +17 -31
  16. package/dist/commands/taskRoleRuntimeStatus.js +24 -0
  17. package/dist/commands/taskWorkspaceCommands.js +5 -2
  18. package/dist/config/configCatalog.js +2 -2
  19. package/dist/config/yuiConfig.js +3 -7
  20. package/dist/context/wakeNotification.js +20 -5
  21. package/dist/controller/agentRuntimeObserver.js +247 -51
  22. package/dist/controller/clientRuntime.js +1 -1
  23. package/dist/controller/controller.js +8 -12
  24. package/dist/controller/fileSchedulerStoreAdapter.js +146 -198
  25. package/dist/controller/runtime.js +3 -3
  26. package/dist/controller/runtimeEventInbox.js +17 -7
  27. package/dist/controller/runtimeEventProcessor.js +12 -19
  28. package/dist/controller/runtimeObservationHook.js +45 -0
  29. package/dist/execution/resourceBroker.js +5 -4
  30. package/dist/executor/agentExecutor.js +4 -4
  31. package/dist/executor/effectiveLaunch.js +8 -48
  32. package/dist/executor/fileRoleLaunchPlanner.js +36 -16
  33. package/dist/executor/workspacePreflightClassification.js +23 -2
  34. package/dist/interaction/operatorPresentation.js +33 -89
  35. package/dist/lifecycle/exactRunTerminalization.js +1 -2
  36. package/dist/lifecycle/taskRoleSessionReset.js +5 -13
  37. package/dist/observability/orchestrationMetrics.js +8 -3
  38. package/dist/profile/agentProfile.js +1 -1
  39. package/dist/release/cliHomeReleaseFence.js +123 -0
  40. package/dist/repository/checkoutSwap.js +61 -0
  41. package/dist/repository/gitWorkspace.js +36 -0
  42. package/dist/repository/project.js +53 -3
  43. package/dist/repository/taskWorkspacePreparer.js +176 -60
  44. package/dist/resources/sqliteResourceRegistry.js +1 -1
  45. package/dist/review/deltaRecheck.js +12 -51
  46. package/dist/review/reviewAcceptance.js +26 -0
  47. package/dist/review/reviewConfig.js +0 -31
  48. package/dist/review/reviewDecision.js +113 -0
  49. package/dist/review/reviewOutcomeClassifier.js +1 -1
  50. package/dist/review/reviewRound.js +1 -1
  51. package/dist/review/reviewerAvailability.js +69 -0
  52. package/dist/runtime/agentDriverObservation.js +24 -10
  53. package/dist/runtime/builtinAgentDrivers.js +4 -3
  54. package/dist/runtime/builtinTranscriptObserver.js +301 -64
  55. package/dist/runtime/builtinTranscriptUsage.js +9 -7
  56. package/dist/runtime/index.js +1 -0
  57. package/dist/runtime/runtimeObservation.js +13 -0
  58. package/dist/runtime/runtimeProjection.js +16 -25
  59. package/dist/runtime/sessionTokenMetrics.js +181 -0
  60. package/dist/runtime/structuredProviderHost.js +7 -1
  61. package/dist/scheduler/activeRoleRunDelivery.js +4 -4
  62. package/dist/scheduler/activeTaskProgress.js +7 -6
  63. package/dist/scheduler/leaderWakeupProcessor.js +3 -5
  64. package/dist/scheduler/operatorEvent.js +34 -0
  65. package/dist/scheduler/operatorInputNotificationProcessor.js +54 -94
  66. package/dist/scheduler/roleRunStall.js +0 -14
  67. package/dist/scheduler/taskExecutionProjection.js +97 -76
  68. package/dist/scheduler/taskObservabilityProjection.js +10 -11
  69. package/dist/storage/migration/productionRegistry.js +467 -0
  70. package/dist/storage/sqliteSchema.js +18 -8
  71. package/dist/storage/sqliteStore.js +61 -21
  72. package/dist/storage/storeRpc.js +0 -1
  73. package/dist/storage/taskStore.js +47 -31
  74. package/dist/storage/upgrade/recordVersions.js +3 -2
  75. package/dist/storage/upgrade/sqliteMigrationTarget.js +30 -8
  76. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +23 -11
  77. package/dist/storage/upgrade/sqliteStateMigration.js +66 -8
  78. package/dist/task/completionReadiness.js +10 -6
  79. package/dist/task/nextAction.js +82 -24
  80. package/dist/telemetry/sqliteTelemetryStore.js +1 -1
  81. package/dist/web/assets/client/components.js +13 -3
  82. package/dist/web/assets/client/i18n.js +6 -2
  83. package/dist/web/webSnapshot.js +4 -0
  84. package/i18n/README.zh-CN.md +2 -2
  85. package/package.json +1 -1
  86. package/skills/yui-leader/SKILL.md +53 -16
  87. package/skills/yui-operator/SKILL.md +9 -0
  88. package/skills/yui-reviewer/SKILL.md +28 -7
  89. package/dist/context/sessionContextBudget.js +0 -71
  90. package/dist/lifecycle/contextBudgetRollover.js +0 -81
  91. package/dist/scheduler/operatorNotification.js +0 -59
@@ -2,13 +2,16 @@ import { lstat, mkdir, readlink, readdir, rmdir, symlink, unlink } from "node:fs
2
2
  import { isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { isDeepStrictEqual } from "node:util";
4
4
  import { retireTaskRoleSessionsForWorkspace } from "../executor/agentExecutor.js";
5
+ import { formatWorkspacePreflightError } from "../executor/workspacePreflightClassification.js";
5
6
  import { updateRole } from "../role/role.js";
6
7
  import { hasRuntimeCleanupObligation, isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
7
8
  import { attachReviewRoundWorkspace, recordReviewWorkspaceDisposition } from "../review/reviewRound.js";
8
9
  import { StorageConflictError } from "../storage/taskStore.js";
9
- import { bindTaskWorkspaceIdentity } from "../task/task.js";
10
+ import { activateTask, bindTaskWorkspaceIdentity } from "../task/task.js";
11
+ import { createTaskEvent } from "../event/taskEvent.js";
12
+ import { enqueueWork } from "../coordination/workMailboxQueue.js";
10
13
  import { createCandidateGitSnapshot, createDirectTaskMainSnapshot, workItemExecutionGroupById, recordWorkItemWorkspaceDisposition } from "../workItem/workItem.js";
11
- import { createManagedWorkspace, managedWorkspaceKey, managedWorktreeName, sameManagedWorkspaceIdentity } from "../worktree/managedWorkspace.js";
14
+ import { createManagedWorkspace, isTaskOwnedWorkspace, managedWorkspaceKey, managedWorktreeName, sameManagedWorkspaceIdentity } from "../worktree/managedWorkspace.js";
12
15
  import { agentRunDeliveryReceiptId } from "../run/agentRun.js";
13
16
  import { NodeGitWorkspace, worktreeIdentity } from "./gitWorkspace.js";
14
17
  import { acquireProjectMaintenanceLocks } from "./projectMaintenanceLock.js";
@@ -80,7 +83,47 @@ export class FileTaskWorkspacePreparer {
80
83
  const task = requireTask(this.store, taskId);
81
84
  const { release, current } = this.#acquireTaskProjectMaintenanceLocks(task);
82
85
  try {
83
- return await this.#prepareTaskWorkspaceLocked(current.id);
86
+ return await this.#prepareTaskWorkspaceLocked(current.id, false);
87
+ }
88
+ finally {
89
+ release();
90
+ }
91
+ }
92
+ catch (error) {
93
+ if (error instanceof StorageConflictError
94
+ && attempt < TASK_WORKSPACE_PREPARE_MAX_CONFLICT_RETRIES) {
95
+ continue;
96
+ }
97
+ throw error;
98
+ }
99
+ }
100
+ }
101
+ /**
102
+ * Prepare every physical Task-main resource and adopt it in the same store
103
+ * transaction that moves the Task from Draft to active. A Draft never owns a
104
+ * durable writable workspace before this boundary, and any failed attempt
105
+ * discards its unadopted refs/worktrees.
106
+ */
107
+ async activateTaskWorkspace(taskId) {
108
+ for (let attempt = 0;; attempt += 1) {
109
+ try {
110
+ const task = requireTask(this.store, taskId);
111
+ if (task.status === "active") {
112
+ const workspace = this.store.getTaskWorkspace(task.id);
113
+ if (!isTaskOwnedWorkspace(workspace, task.id, task.cwd, task.projectBindings.map(({ projectId, directory }) => ({ projectId, directory })))) {
114
+ throw new Error(`Active Task workspace adoption is invalid: ${task.id}.`);
115
+ }
116
+ return {
117
+ task,
118
+ taskId: task.id,
119
+ status: "ready",
120
+ ...(task.cwd === undefined ? {} : { path: task.cwd }),
121
+ changed: false
122
+ };
123
+ }
124
+ const { release, current } = this.#acquireTaskProjectMaintenanceLocks(task);
125
+ try {
126
+ return await this.#prepareTaskWorkspaceLocked(current.id, true);
84
127
  }
85
128
  finally {
86
129
  release();
@@ -137,59 +180,94 @@ export class FileTaskWorkspacePreparer {
137
180
  acquireTaskProjectMaintenanceLocks(taskId) {
138
181
  return this.#acquireTaskProjectMaintenanceLocks(requireTask(this.store, taskId));
139
182
  }
140
- async #prepareTaskWorkspaceLocked(taskId) {
183
+ async #prepareTaskWorkspaceLocked(taskId, activate) {
141
184
  const task = requireTask(this.store, taskId);
142
- if (!["draft", "active"].includes(task.status)) {
185
+ if (activate && task.status !== "draft") {
186
+ throw new Error(`Only a Draft Task can atomically adopt a workspace: ${task.id}/${task.status}.`);
187
+ }
188
+ if (!activate && task.status === "draft") {
189
+ throw new Error(`Draft Task workspace must be adopted by task activation: ${task.id}.`);
190
+ }
191
+ if (!activate && task.status !== "active") {
143
192
  throw new Error(`Task is not open for workspace preparation: ${task.id}.`);
144
193
  }
194
+ const existing = this.store.getTaskWorkspace(task.id);
195
+ if (activate && (task.workspaceIdentity !== undefined
196
+ || task.cwd !== undefined
197
+ || existing !== null)) {
198
+ throw new Error(`Draft Task already has adopted workspace state: ${task.id}. `
199
+ + "Inspect it with `yui task base status`, then retire and recreate the Draft.");
200
+ }
201
+ if (!activate && existing === null) {
202
+ throw new Error(`Active Task has no durable workspace owner: ${task.id}. `
203
+ + "Use `yui task base status` to diagnose the incomplete activation.");
204
+ }
145
205
  if (task.projectBindings.length === 0) {
146
- const existing = this.store.getTaskWorkspace(task.id);
147
206
  const root = this.#taskWorkspaceRoot(task.id);
148
- if (existing !== null && (existing.owner.type !== "task"
149
- || existing.owner.taskId !== task.id
150
- || existing.root !== root
151
- || existing.entries.length !== 0)) {
152
- throw new Error(`Gitless Task workspace ownership is invalid: ${task.id}.`);
153
- }
154
- // Gitless Tasks still need a durable runtime owner. The empty view is
155
- // Task-specific, so launches cannot fall back to a global workspace or
156
- // repository root while no Project is bound.
157
- await ensureWorkspaceView(root, []);
158
- const workspace = createManagedWorkspace({
159
- owner: { type: "task", taskId: task.id },
160
- root,
161
- entries: []
162
- }, this.now());
163
- this.#registerWorkspace(workspace);
164
- this.store.transaction((tx) => {
165
- const latest = requireTask(tx, task.id);
166
- if (!['draft', 'active'].includes(latest.status)
167
- || latest.projectBindings.length !== 0) {
168
- throw new Error(`Task changed while preparing its Gitless workspace: ${task.id}.`);
169
- }
170
- const current = tx.getTaskWorkspace(task.id);
171
- // `createManagedWorkspace` stamps a fresh updatedAt on every call.
172
- // Gitless preparation is idempotent, so compare only stable identity
173
- // and retain the existing durable record verbatim.
174
- if (current !== null && !sameManagedWorkspaceIdentity(current, workspace)) {
175
- throw new Error(`Gitless Task workspace changed during preparation: ${task.id}.`);
176
- }
177
- const timestamp = this.now();
178
- if (latest.cwd !== root) {
179
- tx.saveTask({ ...latest, cwd: root, updatedAt: timestamp.toISOString() });
180
- }
181
- if (current === null)
182
- tx.saveManagedWorkspace(workspace);
183
- for (const role of tx.listRoles(task.id)) {
184
- if (role.workspace !== root) {
185
- retireWorkspaceBoundSession(tx, task.id, role.name, timestamp);
186
- tx.saveRole(task.id, updateRole(role, { workspace: root }, timestamp));
207
+ try {
208
+ if (existing !== null && (existing.owner.type !== "task"
209
+ || existing.owner.taskId !== task.id
210
+ || existing.root !== root
211
+ || existing.entries.length !== 0)) {
212
+ throw new Error(`Gitless Task workspace ownership is invalid: ${task.id}.`);
213
+ }
214
+ // Gitless Tasks still need a durable runtime owner. The empty view is
215
+ // Task-specific, so launches cannot fall back to a global workspace or
216
+ // repository root while no Project is bound.
217
+ await ensureWorkspaceView(root, []);
218
+ const workspace = createManagedWorkspace({
219
+ owner: { type: "task", taskId: task.id },
220
+ root,
221
+ entries: []
222
+ }, this.now());
223
+ this.#registerWorkspace(workspace);
224
+ const persisted = this.store.transaction((tx) => {
225
+ const latest = requireTask(tx, task.id);
226
+ if (latest.status !== task.status
227
+ || latest.projectBindings.length !== 0) {
228
+ throw new Error(`Task changed while preparing its Gitless workspace: ${task.id}.`);
187
229
  }
188
- }
189
- });
190
- return { taskId, status: "ready", path: root };
230
+ const current = tx.getTaskWorkspace(task.id);
231
+ // `createManagedWorkspace` stamps a fresh updatedAt on every call.
232
+ // Gitless preparation is idempotent, so compare only stable identity
233
+ // and retain the existing durable record verbatim.
234
+ if (current !== null && !sameManagedWorkspaceIdentity(current, workspace)) {
235
+ throw new Error(`Gitless Task workspace changed during preparation: ${task.id}.`);
236
+ }
237
+ const timestamp = this.now();
238
+ let next = latest.cwd === root
239
+ ? latest
240
+ : { ...latest, cwd: root, updatedAt: timestamp.toISOString() };
241
+ if (activate)
242
+ next = activateTask(next, timestamp);
243
+ if (!isDeepStrictEqual(next, latest))
244
+ tx.saveTask(next);
245
+ if (current === null)
246
+ tx.saveManagedWorkspace(workspace);
247
+ for (const role of tx.listRoles(task.id)) {
248
+ if (role.workspace !== root) {
249
+ retireWorkspaceBoundSession(tx, task.id, role.name, timestamp);
250
+ tx.saveRole(task.id, updateRole(role, { workspace: root }, timestamp));
251
+ }
252
+ }
253
+ if (activate)
254
+ recordTaskActivation(tx, latest, next, timestamp);
255
+ return next;
256
+ });
257
+ return {
258
+ task: persisted,
259
+ taskId,
260
+ status: "ready",
261
+ path: root,
262
+ changed: activate
263
+ };
264
+ }
265
+ catch (error) {
266
+ if (activate)
267
+ await removeWorkspaceView(root);
268
+ throw error;
269
+ }
191
270
  }
192
- const existing = this.store.getTaskWorkspace(task.id);
193
271
  if (existing !== null && existing.owner.type !== "task") {
194
272
  throw new Error(`Task main workspace ownership is invalid: ${task.id}.`);
195
273
  }
@@ -268,6 +346,18 @@ export class FileTaskWorkspacePreparer {
268
346
  roleName: MAIN_WORKTREE,
269
347
  baseRef: previous?.baseCommit ?? baseline.baseRef
270
348
  });
349
+ if (previous !== undefined
350
+ && !await this.git.isAncestor(physical.path, previous.baseCommit, physical.baseCommit)) {
351
+ throw new Error(formatWorkspacePreflightError({
352
+ kind: "physical-drift",
353
+ reason: `Task main physical HEAD left its recorded lineage: ${task.id}/${project.id}.`,
354
+ taskId: task.id,
355
+ roleName: LEADER_ROLE,
356
+ projectId: project.id,
357
+ expectedCommit: previous.baseCommit,
358
+ physicalCommit: physical.baseCommit
359
+ }));
360
+ }
271
361
  if (baseline.expectedCommit !== undefined
272
362
  && physical.baseCommit !== baseline.expectedCommit) {
273
363
  throw new Error(`Task Project workspace did not start at the fetched remote baseline: ${project.id}.`);
@@ -289,7 +379,7 @@ export class FileTaskWorkspacePreparer {
289
379
  path: physical.path,
290
380
  branch: physical.branch,
291
381
  baseRef: previous?.baseRef ?? baseline.recordedBaseRef,
292
- baseCommit: physical.baseCommit
382
+ baseCommit: previous?.baseCommit ?? physical.baseCommit
293
383
  },
294
384
  provenance
295
385
  });
@@ -301,9 +391,9 @@ export class FileTaskWorkspacePreparer {
301
391
  entries: prepared.map(({ entry }) => entry)
302
392
  }, this.now());
303
393
  this.#registerWorkspace(workspace);
304
- this.store.transaction((tx) => {
394
+ const persisted = this.store.transaction((tx) => {
305
395
  const latest = requireTask(tx, task.id);
306
- if (!["draft", "active"].includes(latest.status)) {
396
+ if (latest.status !== task.status) {
307
397
  throw new Error(`Task changed while preparing its workspace: ${task.id}.`);
308
398
  }
309
399
  if (!isDeepStrictEqual(latest.projectBindings, task.projectBindings)) {
@@ -358,6 +448,8 @@ export class FileTaskWorkspacePreparer {
358
448
  if (persistedTask.cwd !== root) {
359
449
  persistedTask = { ...persistedTask, cwd: root, updatedAt: timestamp.toISOString() };
360
450
  }
451
+ if (activate)
452
+ persistedTask = activateTask(persistedTask, timestamp);
361
453
  if (!isDeepStrictEqual(persistedTask, latest)) {
362
454
  tx.saveTask(persistedTask);
363
455
  }
@@ -366,9 +458,10 @@ export class FileTaskWorkspacePreparer {
366
458
  recordTaskBaseProvenanceEvents(tx, task.id, prepared.map(({ provenance }) => provenance), timestamp);
367
459
  }
368
460
  for (const role of tx.listRoles(task.id)) {
369
- // The Role field is only a cwd/snapshot hint. Preserve the hint for
370
- // an active WorkItem assignment; the durable owner is the WorkItem,
371
- // not this Role record. Other Roles use Task main.
461
+ // The Role field is only a cwd/snapshot hint. Preserve it while a
462
+ // durable WorkItem or ReviewRound workspace owns that cwd; Task-main
463
+ // preparation must not move a Reviewer Session out from under an
464
+ // active or retained ReviewRound. Other Roles use Task main.
372
465
  // Prefer the active Run's exact WorkItem for this Role; fall back
373
466
  // to the first queued WorkItem only when no active Run owns the Role.
374
467
  const activeRoleRun = tx.getActiveAgentRun(task.id, role.name);
@@ -387,7 +480,14 @@ export class FileTaskWorkspacePreparer {
387
480
  const assignedWorkspace = assignedItem === undefined
388
481
  ? null
389
482
  : tx.getWorkItemWorkspace(task.id, assignedItem.id);
390
- const target = assignedWorkspace?.root ?? root;
483
+ const reviewWorkspace = tx.listManagedWorkspaces(task.id).find((candidate) => {
484
+ if (candidate.owner.type !== "review-round"
485
+ || candidate.root !== role.workspace)
486
+ return false;
487
+ return tx.getReviewRound(task.id, candidate.owner.reviewRoundId)
488
+ ?.reviewerRoleName === role.name;
489
+ });
490
+ const target = assignedWorkspace?.root ?? reviewWorkspace?.root ?? root;
391
491
  if (role.workspace !== target) {
392
492
  if (assignedItem === undefined
393
493
  || assignedWorkspace === null
@@ -397,14 +497,25 @@ export class FileTaskWorkspacePreparer {
397
497
  tx.saveRole(task.id, updateRole(role, { workspace: target }, timestamp));
398
498
  }
399
499
  }
500
+ if (activate)
501
+ recordTaskActivation(tx, latest, persistedTask, timestamp);
502
+ return persistedTask;
400
503
  });
401
- return { taskId, status: "ready", path: root };
504
+ return {
505
+ task: persisted,
506
+ taskId,
507
+ status: "ready",
508
+ path: root,
509
+ changed: activate
510
+ };
402
511
  }
403
512
  catch (error) {
404
513
  // A failed or conflicted preparation owns no durable record: drop the
405
514
  // branches too, so a retry mints a clean identity without half-created
406
515
  // refs behind. Adopted (already catalogued) worktrees are never touched.
407
516
  await this.#discardUnadoptedEntries(task, taskSegment, prepared, MAIN_WORKTREE, true);
517
+ if (activate)
518
+ await removeWorkspaceView(root);
408
519
  throw error;
409
520
  }
410
521
  }
@@ -418,7 +529,7 @@ export class FileTaskWorkspacePreparer {
418
529
  async #prepareTaskWorkspaceWithRetries(taskId, lockedProjectIds) {
419
530
  for (let attempt = 0;; attempt += 1) {
420
531
  try {
421
- return await this.#prepareTaskWorkspaceLocked(taskId);
532
+ return await this.#prepareTaskWorkspaceLocked(taskId, false);
422
533
  }
423
534
  catch (error) {
424
535
  if (!(error instanceof StorageConflictError)
@@ -1869,8 +1980,8 @@ export class FileTaskWorkspacePreparer {
1869
1980
  */
1870
1981
  async rebuildTaskWorkspace(taskId, options = {}) {
1871
1982
  const task = requireTask(this.store, taskId);
1872
- if (!["draft", "active"].includes(task.status)) {
1873
- throw new Error(`Only a draft or active Task can be rebuilt in place: ${task.id}/${task.status}.`);
1983
+ if (task.status !== "active") {
1984
+ throw new Error(`Only an active Task can rebuild its adopted workspace: ${task.id}/${task.status}.`);
1874
1985
  }
1875
1986
  // The rebuild holds every touched Project's maintenance fence for its
1876
1987
  // whole duration, so the Controller defers preparation and no other
@@ -2521,6 +2632,11 @@ export class FileTaskWorkspacePreparer {
2521
2632
  });
2522
2633
  }
2523
2634
  }
2635
+ function recordTaskActivation(store, previous, active, now) {
2636
+ enqueueWork(store, { kind: "role", taskId: active.id, roleName: LEADER_ROLE }, "task-created", now, [{ type: "task", id: active.id }]);
2637
+ enqueueWork(store, { kind: "task", taskId: active.id }, "task-activated", now, [{ type: "task", id: active.id }]);
2638
+ store.saveEvent(active.id, createTaskEvent(store.nextEventId(active.id), active.id, "task.activated", { fromStatus: previous.status, status: active.status }, now));
2639
+ }
2524
2640
  function requireTask(store, taskId) {
2525
2641
  const task = store.getTask(taskId);
2526
2642
  if (task === null)
@@ -34,7 +34,7 @@ export class SqliteResourceRegistry {
34
34
  this.#db.pragma("journal_mode = WAL");
35
35
  this.#db.pragma("foreign_keys = ON");
36
36
  this.#db.pragma("busy_timeout = 5000");
37
- migrateSqliteSchema(this.#db);
37
+ migrateSqliteSchema(this.#db, { mode: "validate" });
38
38
  }
39
39
  load() {
40
40
  const rows = this.#db.prepare(`SELECT payload FROM ${SQLITE_RESOURCE_REGISTRY_TABLE}`).all();
@@ -1,14 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
- import { deltaRecheckMaxChangedFiles, deltaRecheckMaxChangedLines } from "./reviewConfig.js";
3
2
  import { validateDeltaRecheckRecord } from "./reviewRound.js";
4
3
  import { isSemanticReviewRound } from "./reviewOutcomeClassifier.js";
5
4
  /**
6
- * Assesses whether a delta-recheck may be attempted. Every deterministic
7
- * gate fails closed here; semantic equivalence is always left to the
8
- * Reviewer's explicit disposition.
5
+ * Assesses whether a trustworthy delta can be constructed. Only technical
6
+ * unavailability fails closed; Agents retain the semantic judgment.
9
7
  */
10
8
  export async function assessDeltaRecheck(input) {
11
- const { repositoryPaths, previousRound, candidate, git, config } = input;
9
+ const { repositoryPaths, previousRound, candidate, git } = input;
12
10
  if (!isSemanticReviewRound(previousRound)
13
11
  || (previousRound.scope ?? "work-item") !== "task") {
14
12
  return {
@@ -23,6 +21,12 @@ export async function assessDeltaRecheck(input) {
23
21
  };
24
22
  }
25
23
  const previousByProject = new Map(previousRound.taskCandidate.projects.map((project) => [project.projectId, project.commit]));
24
+ if (previousByProject.size !== candidate.projects.length) {
25
+ return {
26
+ kind: "ineligible",
27
+ reason: "Delta recheck Project scope differs from the accepted baseline."
28
+ };
29
+ }
26
30
  const diffByProject = {};
27
31
  const changedFiles = [];
28
32
  let addedLines = 0;
@@ -91,31 +95,6 @@ export async function assessDeltaRecheck(input) {
91
95
  reason: "Frozen heads are unchanged; the previous acceptance already covers this candidate."
92
96
  };
93
97
  }
94
- const maxFiles = deltaRecheckMaxChangedFiles(config);
95
- if (changedFiles.length > maxFiles) {
96
- return {
97
- kind: "ineligible",
98
- reason: `Delta recheck attempt threshold exceeded: ${changedFiles.length} changed file(s) > ${maxFiles}.`
99
- };
100
- }
101
- const maxLines = deltaRecheckMaxChangedLines(config);
102
- if (addedLines + deletedLines > maxLines) {
103
- return {
104
- kind: "ineligible",
105
- reason: `Delta recheck attempt threshold exceeded: ${addedLines + deletedLines} changed line(s) > ${maxLines}.`
106
- };
107
- }
108
- // Evidence overlap: a diff touching a file the previous Round cited as
109
- // evidence cannot be proven equivalent by a delta recheck.
110
- const evidencePaths = extractEvidencePaths(previousRound);
111
- const overlapping = changedFiles.filter((file) => (evidencePaths.some((evidence) => pathEvidenceMatches(evidence, file))));
112
- if (overlapping.length > 0) {
113
- return {
114
- kind: "ineligible",
115
- reason: "Delta recheck cannot prove evidence validity for changed evidence file(s): "
116
- + `${overlapping.join(", ")}.`
117
- };
118
- }
119
98
  const record = validateDeltaRecheckRecord({
120
99
  schemaVersion: 1,
121
100
  previousReviewRoundId: previousRound.id,
@@ -148,7 +127,7 @@ export function buildDeltaRecheckDispatchContext(input) {
148
127
  "- finding: the diff introduces a material problem; report it as a finding.",
149
128
  "- requires-full-review: you cannot prove equivalence (uncertainty, cross-scope,",
150
129
  " semantic change, evidence doubt). This is the safe default.",
151
- "Path/line thresholds only allowed this attempt; they never imply safety.",
130
+ "Yui verified only the technical delta boundary; the Leader selected this mode.",
152
131
  `Previous accepted ReviewRound: ${previousRound.id}@${record.previousBaseCommit}`,
153
132
  `Previous acceptance summary: ${compact(previousRound.summary ?? previousRound.report ?? "")}`,
154
133
  ...(round.taskCandidate?.projects.map((project) => {
@@ -187,15 +166,6 @@ function digestDiff(diffByProject) {
187
166
  .map(([projectId, diff]) => [projectId, diff]));
188
167
  return createHash("sha256").update(canonical).digest("hex");
189
168
  }
190
- /**
191
- * Extracts path-like evidence references from the previous Round's report.
192
- * Free-form evidence that is not a path is still passed to the Reviewer in
193
- * the dispatch context; only path-like entries drive the deterministic gate.
194
- */
195
- function extractEvidencePaths(round) {
196
- return previousEvidenceReferences(round)
197
- .filter((entry) => isPathLike(entry));
198
- }
199
169
  /** All path-like evidence references from Markdown or JSON Review reports. */
200
170
  function previousEvidenceReferences(round) {
201
171
  const report = round.report ?? "";
@@ -222,9 +192,8 @@ function extractDeclaredEvidence(report) {
222
192
  }
223
193
  /**
224
194
  * Reviewer reports intentionally accept clear Markdown or JSON without a
225
- * fixed schema. Extract conservative repo-relative path tokens from the full
226
- * preserved report so evidence overlap cannot be bypassed by presentation
227
- * format, nested JSON, Markdown links, backticks, or line-qualified paths.
195
+ * fixed schema. Extract likely repo-relative references from the preserved
196
+ * report so the next Reviewer can inspect the earlier evidence directly.
228
197
  */
229
198
  function extractEvidenceReferences(report) {
230
199
  const references = report.match(/(?:\.{0,2}\/)?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+|[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,12}/gu) ?? [];
@@ -244,11 +213,3 @@ function isPathLike(value) {
244
213
  return false;
245
214
  return value.includes("/") || /\.[A-Za-z0-9]{1,12}$/u.test(value);
246
215
  }
247
- function pathEvidenceMatches(evidencePath, changedFile) {
248
- const normalizedEvidence = evidencePath.replace(/^\.\//u, "").replace(/\/+$/u, "");
249
- const normalizedFile = changedFile.replace(/^\.\//u, "").replace(/\/+$/u, "");
250
- if (normalizedEvidence === normalizedFile)
251
- return true;
252
- // A directory-level evidence reference covers every file under it.
253
- return normalizedFile.startsWith(`${normalizedEvidence}/`);
254
- }
@@ -0,0 +1,26 @@
1
+ import { isSemanticReviewRound } from "./reviewOutcomeClassifier.js";
2
+ /**
3
+ * Whether a completed Task-final Round is a trustworthy accepted baseline.
4
+ * Semantic completion alone is insufficient when it returned a non-accepting
5
+ * delta disposition or still owns an open material finding.
6
+ */
7
+ export function isAcceptedTaskReviewBaseline(store, round) {
8
+ return isAcceptedTaskReviewBaselineFromEvidence(round, store);
9
+ }
10
+ /** Pure evidence form shared by read-only projections and mutation paths. */
11
+ export function isAcceptedTaskReviewBaselineFromEvidence(round, evidence) {
12
+ if ((round.scope ?? "work-item") !== "task"
13
+ || round.status !== "completed"
14
+ || round.taskCandidate === undefined
15
+ || round.taskCandidate.projects.length === 0
16
+ || !isSemanticReviewRound(round, evidence))
17
+ return false;
18
+ if ((round.checks ?? []).some(({ outcome }) => outcome === "failed"))
19
+ return false;
20
+ if (round.deltaRecheck !== undefined
21
+ && round.deltaRecheck.disposition !== "equivalent-and-accepted")
22
+ return false;
23
+ return !(evidence?.listReviewFindings(round.taskId) ?? []).some((finding) => (finding.lastReviewRoundId === round.id
24
+ && (finding.severity === "p1" || finding.severity === "p2")
25
+ && (finding.disposition === "open" || finding.disposition === "fixed-pending-review")));
26
+ }
@@ -6,11 +6,6 @@ export const REVIEW_TRIGGERS = ["always", "leader", "final"];
6
6
  * Task completion closed on undispositioned open P1/P2 findings.
7
7
  */
8
8
  export const REVIEW_FINDING_LEDGER_MODES = ["shadow", "enforce"];
9
- /** Issue 07: delta-recheck is opt-in per Project review policy. */
10
- export const REVIEW_DELTA_RECHECK_MODES = ["enabled", "disabled"];
11
- /** Issue 07: conservative defaults for whether a delta attempt is allowed. */
12
- export const DEFAULT_DELTA_RECHECK_MAX_CHANGED_LINES = 200;
13
- export const DEFAULT_DELTA_RECHECK_MAX_CHANGED_FILES = 5;
14
9
  export function validateReviewConfig(config) {
15
10
  requireIdentity(config.roleName, "Review Role");
16
11
  if (!REVIEW_TRIGGERS.includes(config.trigger)) {
@@ -20,31 +15,5 @@ export function validateReviewConfig(config) {
20
15
  && !REVIEW_FINDING_LEDGER_MODES.includes(config.findingLedger)) {
21
16
  throw new Error(`Review finding ledger mode is invalid: ${String(config.findingLedger)}.`);
22
17
  }
23
- if (config.deltaRecheck !== undefined
24
- && !REVIEW_DELTA_RECHECK_MODES.includes(config.deltaRecheck)) {
25
- throw new Error(`Review delta-recheck mode is invalid: ${String(config.deltaRecheck)}.`);
26
- }
27
- if (config.deltaRecheckMaxChangedLines !== undefined
28
- && (!Number.isInteger(config.deltaRecheckMaxChangedLines)
29
- || config.deltaRecheckMaxChangedLines < 1)) {
30
- throw new Error("Review delta-recheck max changed lines must be a positive integer.");
31
- }
32
- if (config.deltaRecheckMaxChangedFiles !== undefined
33
- && (!Number.isInteger(config.deltaRecheckMaxChangedFiles)
34
- || config.deltaRecheckMaxChangedFiles < 1)) {
35
- throw new Error("Review delta-recheck max changed files must be a positive integer.");
36
- }
37
18
  return config;
38
19
  }
39
- /** Issue 07: resolves the delta-recheck policy; absent config defaults to disabled. */
40
- export function deltaRecheckEnabled(config) {
41
- return config?.deltaRecheck === "enabled";
42
- }
43
- /** Issue 07: resolves the line threshold with its conservative default. */
44
- export function deltaRecheckMaxChangedLines(config) {
45
- return config?.deltaRecheckMaxChangedLines ?? DEFAULT_DELTA_RECHECK_MAX_CHANGED_LINES;
46
- }
47
- /** Issue 07: resolves the file threshold with its conservative default. */
48
- export function deltaRecheckMaxChangedFiles(config) {
49
- return config?.deltaRecheckMaxChangedFiles ?? DEFAULT_DELTA_RECHECK_MAX_CHANGED_FILES;
50
- }
@@ -0,0 +1,113 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { isAcceptedTaskReviewBaseline } from "./reviewAcceptance.js";
3
+ import { projectReviewerAvailability } from "./reviewerAvailability.js";
4
+ /** Read-only facts for Leader judgment; this projection never blocks an action. */
5
+ export function projectReviewDecision(input) {
6
+ const { store, task, roles, runs, rounds, reviewConfig, currentCandidate } = input;
7
+ const taskRounds = rounds.filter((round) => (round.scope ?? "work-item") === "task");
8
+ const accepted = [...taskRounds]
9
+ .filter((round) => isAcceptedTaskReviewBaseline(store, round))
10
+ .sort(compareRoundIdentity)
11
+ .at(-1);
12
+ const latestAcceptedBaseline = accepted?.taskCandidate === undefined
13
+ ? null
14
+ : {
15
+ reviewRoundId: accepted.id,
16
+ reviewerRoleName: accepted.reviewerRoleName,
17
+ candidate: accepted.taskCandidate,
18
+ relationToCurrent: candidateRelation(accepted.taskCandidate, currentCandidate)
19
+ };
20
+ const activeReviews = taskRounds
21
+ .filter((round) => (round.status === "pending" || round.status === "running"))
22
+ .map((round) => {
23
+ const activeRun = runs.find((run) => (run.status === "active" && run.reviewRoundId === round.id));
24
+ const workspaceRoot = round.workspace?.root ?? activeRun?.workspace?.root;
25
+ return {
26
+ reviewRoundId: round.id,
27
+ reviewerRoleName: round.reviewerRoleName,
28
+ mode: round.deltaRecheck === undefined ? "full" : "delta-recheck",
29
+ status: round.status,
30
+ ...(activeRun === undefined ? {} : { activeRunId: activeRun.id }),
31
+ startedAt: activeRun?.createdAt ?? round.createdAt,
32
+ frozenCandidate: round.taskCandidate ?? null,
33
+ candidateRelation: candidateRelation(round.taskCandidate ?? null, currentCandidate),
34
+ ...(workspaceRoot === undefined ? {} : { workspaceRoot })
35
+ };
36
+ });
37
+ const reviewerNames = [...new Set([
38
+ ...(reviewConfig === null ? [] : [reviewConfig.roleName]),
39
+ ...taskRounds.flatMap((round) => [
40
+ round.reviewerRoleName,
41
+ ...(round.executionGroup?.lanes.map(({ roleName }) => roleName) ?? [])
42
+ ])
43
+ ])].sort();
44
+ const reviewers = reviewerNames.map((reviewerRoleName) => {
45
+ const role = roles.find(({ name }) => name === reviewerRoleName);
46
+ if (role === undefined && store.getGlobalRole(reviewerRoleName) === null) {
47
+ return {
48
+ reviewerRoleName,
49
+ status: "unavailable",
50
+ retryable: false
51
+ };
52
+ }
53
+ const availability = projectReviewerAvailability(store, task.id, reviewerRoleName);
54
+ if (availability.kind === "available") {
55
+ return {
56
+ reviewerRoleName,
57
+ status: "available",
58
+ retryable: true
59
+ };
60
+ }
61
+ return {
62
+ reviewerRoleName,
63
+ status: "busy",
64
+ phase: availability.phase,
65
+ ...(availability.activeRunId === undefined
66
+ ? {}
67
+ : { activeRunId: availability.activeRunId }),
68
+ ...(availability.activeReviewRoundId === undefined
69
+ ? {}
70
+ : { activeReviewRoundId: availability.activeReviewRoundId }),
71
+ ...(availability.startedAt === undefined
72
+ ? {}
73
+ : { startedAt: availability.startedAt }),
74
+ retryable: true,
75
+ retryAfterSeconds: availability.retryAfterSeconds
76
+ };
77
+ });
78
+ const delta = latestAcceptedBaseline === null
79
+ ? {
80
+ technicalAvailability: "unavailable",
81
+ reason: "No accepted Task-final Review baseline is available."
82
+ }
83
+ : latestAcceptedBaseline.relationToCurrent === "exact"
84
+ ? {
85
+ technicalAvailability: "unavailable",
86
+ reason: "The accepted baseline already covers the current durable candidate; there is no delta to recheck."
87
+ }
88
+ : currentCandidate === null
89
+ ? {
90
+ technicalAvailability: "unavailable",
91
+ reason: "The current durable Task candidate is unavailable."
92
+ }
93
+ : {
94
+ technicalAvailability: "requires-preflight",
95
+ reason: "An accepted baseline exists; Git ancestry, scope and exact diff must be verified."
96
+ };
97
+ return {
98
+ currentCandidate,
99
+ activeReviews,
100
+ reviewers,
101
+ latestAcceptedBaseline,
102
+ delta
103
+ };
104
+ }
105
+ function candidateRelation(candidate, current) {
106
+ if (candidate === null || current === null)
107
+ return "unavailable";
108
+ return isDeepStrictEqual(candidate, current) ? "exact" : "requires-preflight";
109
+ }
110
+ function compareRoundIdentity(left, right) {
111
+ return left.createdAt.localeCompare(right.createdAt)
112
+ || left.id.localeCompare(right.id, undefined, { numeric: true });
113
+ }