@zq-silk/yui 2.9.0 → 2.10.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 (65) hide show
  1. package/README.md +5 -3
  2. package/dist/cli/commandCatalog.js +21 -9
  3. package/dist/cli/helpRenderer.js +3 -1
  4. package/dist/commands/globalRoleCommands.js +30 -9
  5. package/dist/commands/inboxCommands.js +79 -22
  6. package/dist/commands/taskCommands.js +6 -1
  7. package/dist/commands/taskProjectDeliveryCommand.js +56 -0
  8. package/dist/commands/taskRemoteDeliveryCommand.js +5 -3
  9. package/dist/context/runContextPack.js +1 -0
  10. package/dist/context/runInputContract.js +1 -0
  11. package/dist/context/taskContext.js +1 -1
  12. package/dist/context/taskPhase.js +1 -1
  13. package/dist/controlPlaneCli.js +32 -17
  14. package/dist/controller/fileSchedulerStoreAdapter.js +59 -15
  15. package/dist/controller/globalInputDelivery.js +17 -7
  16. package/dist/controller/inboxTurnCheck.js +64 -0
  17. package/dist/controller/providerRetryAdmission.js +2 -0
  18. package/dist/external/gitlabPublicationVerifier.js +10 -28
  19. package/dist/grant/taskAuthorization.js +7 -2
  20. package/dist/interaction/notificationPresentation.js +9 -0
  21. package/dist/message/globalInboxRecovery.js +44 -0
  22. package/dist/message/globalInterrupt.js +1 -1
  23. package/dist/message/inboxReceipt.js +14 -6
  24. package/dist/message/inboxTurn.js +86 -0
  25. package/dist/message/message.js +11 -0
  26. package/dist/message/taskInboxOwnership.js +20 -6
  27. package/dist/repository/workspaceCleanupInspection.js +6 -1
  28. package/dist/storage/sqliteSchema.js +21 -0
  29. package/dist/storage/sqliteStore.js +13 -1
  30. package/dist/storage/storageVersions.js +1 -1
  31. package/dist/task/archiveDiagnostics.js +1 -1
  32. package/dist/task/archivePreflight.js +1 -1
  33. package/dist/task/projectDeliveryScope.js +20 -0
  34. package/dist/task/publicationToolObservation.js +4 -6
  35. package/dist/task/remoteDelivery.js +19 -8
  36. package/dist/task/remoteDeliveryService.js +5 -4
  37. package/dist/web/assets/client/i18n/en.js +4 -2
  38. package/dist/web/assets/client/i18n/zh-CN.js +4 -2
  39. package/dist/web/assets/client/views/task/deliveryResults.js +6 -2
  40. package/dist/web/controllerWeb.js +73 -21
  41. package/dist/web/taskPreviews.js +21 -13
  42. package/dist/web/webAccess.js +109 -0
  43. package/dist/web/webServer.js +45 -44
  44. package/docs/architecture/capabilities-and-resources.md +6 -4
  45. package/docs/architecture/capabilities-and-resources.zh-CN.md +5 -4
  46. package/docs/cli-information-contract.md +14 -2
  47. package/docs/cli-information-contract.zh-CN.md +9 -1
  48. package/docs/project-delivery-scope.md +59 -0
  49. package/docs/release-workflow.md +43 -0
  50. package/docs/release-workflow.zh-CN.md +35 -0
  51. package/docs/storage-baseline.md +14 -1
  52. package/docs/storage-baseline.zh-CN.md +11 -1
  53. package/docs/task-delivery.md +8 -2
  54. package/docs/task-delivery.zh-CN.md +6 -0
  55. package/docs/testing/verification-levels.md +2 -2
  56. package/docs/testing/verification-levels.zh-CN.md +2 -2
  57. package/i18n/README.zh-CN.md +6 -3
  58. package/package.json +1 -1
  59. package/skills/yui-leader/SKILL.md +26 -0
  60. package/skills/yui-leader/VERSION +1 -1
  61. package/skills/yui-operator/SKILL.md +31 -0
  62. package/skills/yui-operator/VERSION +1 -1
  63. package/skills/yui-runtime/SKILL.md +58 -5
  64. package/skills/yui-runtime/VERSION +1 -1
  65. package/skills/yui-runtime/references/recovery.md +21 -0
@@ -3,7 +3,8 @@ import { isDeepStrictEqual } from "node:util";
3
3
  /** Semantic handling and transport delivery are independent facts. Projections
4
4
  * must not infer handling from delivery, a native terminal, or an empty queue. */
5
5
  export function inboxHandlingState(receipt) {
6
- return receipt?.ack !== undefined ? "handled" : receipt !== undefined ? "claimed-unacknowledged" : "unclaimed";
6
+ return receipt?.retirement !== undefined ? "retired" : receipt?.ack !== undefined ? "handled"
7
+ : receipt !== undefined ? "claimed-unacknowledged" : "unclaimed";
7
8
  }
8
9
  export function isGlobalInboxMessage(message) {
9
10
  return message.inputControl !== undefined || message.interruptThen !== undefined;
@@ -11,7 +12,8 @@ export function isGlobalInboxMessage(message) {
11
12
  /** Unhandled own-role input, including accepted and unknown delivery. This is
12
13
  * NOT the scheduler's deliverable queue; selecting it never authorizes replay. */
13
14
  export function pendingGlobalInboxMessages(messages) {
14
- return messages.filter(message => isGlobalInboxMessage(message) && inboxHandlingState(message.inbox) !== "handled");
15
+ return messages.filter(message => isGlobalInboxMessage(message)
16
+ && !["handled", "retired"].includes(inboxHandlingState(message.inbox)));
15
17
  }
16
18
  /** Original message evidence only. A missing receipt is not proof of
17
19
  * non-submission; admission additionally inspects the exact native attempt. */
@@ -22,20 +24,22 @@ export function globalInboxDeliveryState(message) {
22
24
  export function validateInboxReceipt(receipt) {
23
25
  if (receipt === undefined)
24
26
  return;
25
- requireKnownFields(receipt, ["claim", "ack"], "Inbox receipt");
27
+ requireKnownFields(receipt, ["claim", "ack", "retirement"], "Inbox receipt");
28
+ if (receipt.ack && receipt.retirement)
29
+ throw new Error("Inbox disposition cannot be both acknowledgement and retirement.");
26
30
  for (const [kind, entry] of Object.entries(receipt)) {
27
31
  requireKnownFields(entry, kind === "claim" ? ["requestId", "actor", "at"]
28
32
  : ["requestId", "actor", "at", "reason"], "Inbox action");
29
33
  requireText(entry.requestId, "Inbox request id");
30
34
  requireTimestamp(entry.at, "Inbox timestamp");
31
35
  const actor = entry.actor;
32
- requireKnownFields(actor, ["scope", "taskId", "roleName", "agentId", "nativeSessionId", "runId"], "Inbox actor");
36
+ requireKnownFields(actor, ["scope", "taskId", "roleName", "agentId", "nativeSessionId", "runId", "attemptId"], "Inbox actor");
33
37
  if (!["task", "global"].includes(actor.scope)
34
38
  || (actor.scope === "task") !== (actor.taskId !== undefined))
35
39
  throw new Error("Inbox actor scope is invalid.");
36
40
  for (const value of Object.values(actor))
37
41
  requireText(value, "Inbox actor identity");
38
- if (kind === "ack")
42
+ if (kind !== "claim")
39
43
  requireText(entry.reason, "Inbox disposition");
40
44
  }
41
45
  if (!receipt.claim)
@@ -45,7 +49,8 @@ export function validateInboxReceipt(receipt) {
45
49
  * Host admission. The original claim and any ack are immutable audit facts. */
46
50
  export function assertInboxReceiptPreserved(previous, next) {
47
51
  if (previous !== undefined && (!isDeepStrictEqual(previous.claim, next?.claim)
48
- || previous.ack !== undefined && !isDeepStrictEqual(previous.ack, next?.ack))) {
52
+ || previous.ack !== undefined && !isDeepStrictEqual(previous.ack, next?.ack)
53
+ || previous.retirement !== undefined && !isDeepStrictEqual(previous.retirement, next?.retirement))) {
49
54
  throw new Error("Inbox handling ownership changed; reread the original instead of overwriting its receipt.");
50
55
  }
51
56
  }
@@ -55,6 +60,9 @@ export function assertInboxReceiptPreserved(previous, next) {
55
60
  */
56
61
  export function assertInboxOriginalPreserved(previous, next) {
57
62
  assertInboxReceiptPreserved(previous?.inbox, next.inbox);
63
+ if (previous?.control?.target !== undefined && !isDeepStrictEqual(previous.control.target, next.control?.target)) {
64
+ throw new Error("The exact admitted control target cannot be replaced or erased.");
65
+ }
58
66
  if (previous?.inbox !== undefined && (previous.body !== next.body || previous.kind !== next.kind
59
67
  || !isDeepStrictEqual(previous.author, next.author) || !isDeepStrictEqual(previous.inputControl, next.inputControl))) {
60
68
  throw new Error("Inbox receipt binds the exact original Message; submit changed content as a new Message.");
@@ -0,0 +1,86 @@
1
+ import { globalInboxDeliveryState } from "./inboxReceipt.js";
2
+ export const INBOX_CHECK_PREFIX = "inbox-check:";
3
+ export const INBOX_CHECK_REASON = "inbox-handling-missing";
4
+ export const isInboxWake = (entry) => "fromCursor" in entry;
5
+ export const isHandlingReminder = (entry) => isInboxWake(entry)
6
+ ? entry.reasons.every(reason => reason === INBOX_CHECK_REASON)
7
+ : entry.kind === "system" && entry.inputControl?.requestId.startsWith(INBOX_CHECK_PREFIX) === true;
8
+ /** Correlation uses existing input identities, not timestamps, output text or
9
+ * Task state. Active consumption adds only the exact attempt to its claim. */
10
+ export function belongsToInboxTurn(entry, actor, turn) {
11
+ if (!turn)
12
+ return false;
13
+ const claim = entry.inbox?.claim.actor;
14
+ if (claim?.attemptId === turn.attemptId && claim.scope === actor.scope && claim.taskId === actor.taskId
15
+ && claim.roleName === actor.roleName && claim.agentId === actor.agentId
16
+ && claim.nativeSessionId === actor.nativeSessionId)
17
+ return true;
18
+ if (isInboxWake(entry))
19
+ return actor.scope === "task" && actor.roleName === "leader"
20
+ && entry.taskId === actor.taskId && (turn.input?.kind === "wake" && turn.input.wakeId === entry.id
21
+ && turn.input.taskId === entry.taskId
22
+ || turn.attemptId.startsWith(`notification:${entry.taskId}/${entry.id}/`));
23
+ if ("roleName" in entry) {
24
+ if (actor.scope !== "global" || entry.roleName !== actor.roleName)
25
+ return false;
26
+ if (entry.deliveryTarget && (entry.deliveryTarget.agentId !== actor.agentId
27
+ || entry.deliveryTarget.nativeSessionId !== actor.nativeSessionId))
28
+ return false;
29
+ if (turn.input?.kind === "message" && turn.input.roleName === actor.roleName
30
+ && turn.input.messageId === entry.id || turn.attemptId === `global-input:${actor.roleName}/${entry.id}`)
31
+ return true;
32
+ }
33
+ else {
34
+ if (actor.scope !== "task" || entry.taskId !== actor.taskId)
35
+ return false;
36
+ if (turn.runId !== undefined && entry.recipient?.roleName === actor.roleName
37
+ && entry.continuation?.runId === turn.runId)
38
+ return true;
39
+ }
40
+ const target = entry.control?.target;
41
+ return entry.control?.outcome === "accepted" && target?.agentId === actor.agentId
42
+ && target.nativeSessionId === actor.nativeSessionId && target.attemptId === turn.attemptId;
43
+ }
44
+ /** Historical absence of ACK is not fresh work. Current unsubmitted input
45
+ * remains discoverable, but never steals an unsettled transport owner. */
46
+ export function inboxEntryCategory(entry, actor, turn) {
47
+ if (belongsToInboxTurn(entry, actor, turn))
48
+ return "current";
49
+ if (entry.inbox !== undefined)
50
+ return "history";
51
+ if (isInboxWake(entry))
52
+ return "history";
53
+ if ("roleName" in entry) {
54
+ if (entry.delivery !== undefined || entry.notDelivered !== undefined
55
+ || entry.deliveryTarget && (entry.deliveryTarget.agentId !== actor.agentId
56
+ || entry.deliveryTarget.nativeSessionId !== actor.nativeSessionId))
57
+ return "history";
58
+ }
59
+ else if (entry.continuation?.runId !== undefined)
60
+ return "history";
61
+ if (entry.inputControl?.expectedSessionId !== undefined
62
+ && entry.inputControl.expectedSessionId !== actor.nativeSessionId)
63
+ return "history";
64
+ if (entry.control?.outcome === "accepted")
65
+ return "history";
66
+ return "available";
67
+ }
68
+ export function inboxSettled(receipt) {
69
+ return receipt?.ack !== undefined || receipt?.retirement !== undefined;
70
+ }
71
+ export function missingTurnHandling(entries, actor, turn) {
72
+ return entries.filter(entry => !inboxSettled(entry.inbox) && !isHandlingReminder(entry)
73
+ && belongsToInboxTurn(entry, actor, turn));
74
+ }
75
+ /** Accepted historical input and explicit nondelivery do not impose an input
76
+ * order on independent new messages. Unknown delivery still does. */
77
+ export function blocksInboxOrder(entry, actor, turn) {
78
+ if (isInboxWake(entry) || inboxSettled(entry.inbox))
79
+ return false;
80
+ if ("roleName" in entry && (globalInboxDeliveryState(entry) === "accepted" || entry.notDelivered !== undefined))
81
+ return false;
82
+ if (entry.control?.outcome === "accepted")
83
+ return false;
84
+ return inboxEntryCategory(entry, actor, turn) !== "history"
85
+ || entry.control !== undefined && ["pending", "delivery-unknown"].includes(entry.control.outcome);
86
+ }
@@ -147,6 +147,7 @@ export function recordTaskMessageControlOutcome(message, record) {
147
147
  requestId: requireSafeIdentity(record.requestId, "Message control requestId"),
148
148
  receiptId: requireText(record.receiptId, "Message control receiptId"),
149
149
  outcome: record.outcome,
150
+ ...(existing?.target ?? record.target ? { target: existing?.target ?? record.target } : {}),
150
151
  observedAt: record.observedAt.toISOString()
151
152
  }
152
153
  };
@@ -282,6 +283,7 @@ export function validateTaskMessage(message) {
282
283
  }
283
284
  }
284
285
  if (message.control !== undefined) {
286
+ validateControlTarget(message.control);
285
287
  requireSafeIdentity(message.control.requestId, "Message control requestId");
286
288
  requireText(message.control.receiptId, "Message control receiptId");
287
289
  if (!TASK_MESSAGE_INPUT_CONTROL_OUTCOMES.includes(message.control.outcome)) {
@@ -408,6 +410,7 @@ export function validateGlobalRoleMessage(message) {
408
410
  requireText(message.deliveryTarget.nativeSessionId, "Global input Session");
409
411
  }
410
412
  if (message.control !== undefined) {
413
+ validateControlTarget(message.control);
411
414
  requireSafeIdentity(message.control.requestId, "Global input requestId");
412
415
  requireText(message.control.receiptId, "Global input receiptId");
413
416
  if (!TASK_MESSAGE_INPUT_CONTROL_OUTCOMES.includes(message.control.outcome)
@@ -479,6 +482,14 @@ export function validateGlobalRoleMessage(message) {
479
482
  throw new Error("Message createdAt is invalid.");
480
483
  }
481
484
  }
485
+ function validateControlTarget(control) {
486
+ if (control.target === undefined)
487
+ return;
488
+ requireKnownFields(control.target, ["agentId", "nativeSessionId", "attemptId"], "Control target");
489
+ requireText(control.target.agentId, "Control target Agent");
490
+ requireText(control.target.nativeSessionId, "Control target Session");
491
+ requireText(control.target.attemptId, "Control target attempt");
492
+ }
482
493
  /** A Global message local id is `global-message-<n>`; it carries no Task id. */
483
494
  export function validateGlobalRoleMessageId(localId) {
484
495
  const normalized = requireSafeIdentity(localId, "Global message id");
@@ -26,10 +26,15 @@ export function isLeaderSteer(message) {
26
26
  export function taskMessageInboxReceipt(store, message) {
27
27
  return message.inbox ?? messageRevisionWakes(store, message).find(wake => wake.inbox !== undefined)?.inbox;
28
28
  }
29
- /** Delivery suppresses repeat notification of that revision, but remains
30
- * distinct from handling. The original fixed window stays in the own inbox. */
31
- export function deliveredMessageWake(store, message) {
32
- return messageRevisionWakes(store, message).find(wake => wake.status === "consumed");
29
+ /** Acceptance or explicit no-replay release suppresses repeat notification,
30
+ * never handling. The original fixed window stays in the own inbox. */
31
+ export function nonReplayableMessageWake(store, message) {
32
+ return messageRevisionWakes(store, message).find(wake => wake.status === "consumed" || releasedNotificationWake(store, message.taskId, wake.id));
33
+ }
34
+ /** Operational release is not acceptance. Keep its originals out of automatic
35
+ * delivery while permitting explicit inspection and settlement in the inbox. */
36
+ export function releasedNotificationWake(store, taskId, wakeId) {
37
+ return store.listEventsByType(taskId, ["notification.resolved"]).some(event => event.payload.wakeId === wakeId && event.payload.outcome === "released-without-replay");
33
38
  }
34
39
  function messageRevisionWakes(store, message) {
35
40
  const revision = latestMessageUpdate(store, message);
@@ -37,7 +42,16 @@ function messageRevisionWakes(store, message) {
37
42
  && wake.refs?.some(ref => ref.type === "message" && ref.taskId === message.taskId && ref.id === message.id));
38
43
  }
39
44
  export function unfinishedLeaderInbox(store, taskId) {
40
- return store.listTaskWakes(taskId).some(wake => wake.inbox !== undefined && wake.inbox.ack === undefined)
45
+ const sessions = store.getTaskRoleSessionSet(taskId, "leader");
46
+ const binding = sessions?.providerBinding;
47
+ const turn = binding?.run;
48
+ const nativeSessionId = binding?.conversations.find(c => c.epoch === binding.currentConversationEpoch)?.conversationId;
49
+ const active = (receipt) => receipt !== undefined && receipt.ack === undefined
50
+ && (receipt.claim.actor.attemptId === undefined || turn?.attemptId === receipt.claim.actor.attemptId
51
+ && receipt.claim.actor.agentId === sessions?.activeAgentId
52
+ && receipt.claim.actor.nativeSessionId === nativeSessionId
53
+ && ["submitting", "accepted", "delivery-unknown"].includes(turn.status));
54
+ return store.listTaskWakes(taskId).some(wake => active(wake.inbox))
41
55
  || operationalTaskRecords(store.listMessages(taskId), store.listEvents(taskId), "message").some(message => isLeaderSteer(message)
42
- && message.inbox !== undefined && message.inbox.ack === undefined);
56
+ && active(message.inbox));
43
57
  }
@@ -157,7 +157,12 @@ export async function inspectWorkspaceCleanup(store, git, workspace, disposition
157
157
  const observed = (await git.inspect(entry.path, "HEAD")).baseCommit;
158
158
  const baseline = task.projectBindings.find(binding => binding.projectId === entry.projectId)?.baseCommit;
159
159
  const unchangedAbandonment = disposition === "abandoned" && baseline !== undefined && observed === baseline;
160
- if (!unchangedAbandonment && disposition === "abandoned" && (!covered?.merged || !covered.verified)) {
160
+ if (covered?.deliveryRequirement === "excluded"
161
+ && (baseline === undefined || observed !== baseline)
162
+ && (!covered.merged || !covered.verified)) {
163
+ check("excluded-project-retained", "Excluding delivery does not authorize discarding unique or unverified local commits.", { baseline: baseline ?? null, verifiedDelivery: true }, observed);
164
+ }
165
+ else if (!unchangedAbandonment && disposition === "abandoned" && (!covered?.merged || !covered.verified)) {
161
166
  check("delivery-coverage", "Abandonment does not discard unverified local commits; only the exact unchanged baseline is disposable.", { baseline: baseline ?? null, verifiedDelivery: true }, observed);
162
167
  }
163
168
  else if (!unchangedAbandonment && expected != null && expected !== observed && covered?.deliveryLocalCommit !== observed) {
@@ -133,6 +133,27 @@ const MINOR_UPGRADES = Object.freeze([{
133
133
  // New Task Job semantic writes also append job.state-changed events; no old
134
134
  // Job history is backfilled and the SQL layout remains unchanged.
135
135
  sql: ""
136
+ }, {
137
+ fromVersion: "1.11",
138
+ toVersion: "1.12",
139
+ name: "project-delivery-scope",
140
+ introducedIn: "next",
141
+ sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
142
+ targetChecksum: CURRENT_SCHEMA_CHECKSUM,
143
+ // New append-only Project scope decisions use Task events. Preserve all old
144
+ // completion, binding and publication records; absence still means required.
145
+ sql: ""
146
+ }, {
147
+ fromVersion: "1.12",
148
+ toVersion: "1.13",
149
+ name: "inbox-turn-handling",
150
+ introducedIn: "next",
151
+ sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
152
+ targetChecksum: CURRENT_SCHEMA_CHECKSUM,
153
+ // Claims may name their exact current attempt; explicit Global retirement
154
+ // and terminal-check notices preserve original records. Never backfill
155
+ // historical turn ownership, handling evidence or acknowledgements.
156
+ sql: ""
136
157
  }]);
137
158
  export function storageMinorUpgradePlan(from) {
138
159
  if (from === CURRENT_STORAGE_VERSION)
@@ -989,10 +989,22 @@ export class SqliteTaskStore {
989
989
  const params = { name: roleName };
990
990
  if (query.handling)
991
991
  where.push(`json_extract(payload, '$.inbox.claim') IS NOT NULL
992
- AND json_extract(payload, '$.inbox.ack') IS NULL`);
992
+ AND json_extract(payload, '$.inbox.ack') IS NULL
993
+ AND json_extract(payload, '$.inbox.retirement') IS NULL`);
994
+ if (query.unclaimed)
995
+ where.push("json_extract(payload, '$.inbox') IS NULL");
996
+ if (query.handlingExceptSession !== undefined) {
997
+ if (!query.handling)
998
+ throw new Error("A handling Session filter requires a handling query.");
999
+ where.push(`(json_extract(payload, '$.inbox.claim.actor.agentId') != @handlingAgent
1000
+ OR json_extract(payload, '$.inbox.claim.actor.nativeSessionId') != @handlingSession)`);
1001
+ params.handlingAgent = query.handlingExceptSession.agentId;
1002
+ params.handlingSession = query.handlingExceptSession.nativeSessionId;
1003
+ }
993
1004
  if (query.pending)
994
1005
  where.push(`json_extract(payload, '$.delivery') IS NULL
995
1006
  AND json_extract(payload, '$.inbox.ack') IS NULL
1007
+ AND json_extract(payload, '$.inbox.retirement') IS NULL
996
1008
  AND json_extract(payload, '$.notDelivered') IS NULL
997
1009
  AND (json_extract(payload, '$.inputControl.action') = 'queue'
998
1010
  OR json_extract(payload, '$.interruptThen') IS NOT NULL)`);
@@ -1,4 +1,4 @@
1
- export const CURRENT_STORAGE_VERSION = "1.11";
1
+ export const CURRENT_STORAGE_VERSION = "1.13";
2
2
  export const MIN_SUPPORTED_STORAGE_VERSION = "1.0";
3
3
  export const STORAGE_FORMAT = "yui-home";
4
4
  export function isStorageVersion(value) {
@@ -76,7 +76,7 @@ export function archiveRetainedResources(store, task) {
76
76
  return resources;
77
77
  }
78
78
  export function archiveDeliveryWarnings(delivery) {
79
- return delivery.projects.filter(p => p.codeDelivery !== "none" && (!p.merged || !p.verified))
79
+ return delivery.projects.filter(p => p.deliveryRequired && (!p.merged || !p.verified))
80
80
  .map(p => ({ resource: `delivery:${delivery.taskId}/${p.projectId}`,
81
81
  detail: `${p.coverage}: ${p.reason} Merged=${p.merged}; verified=${p.verified}.` }));
82
82
  }
@@ -65,7 +65,7 @@ export async function inspectTaskArchive(coordinator, request) {
65
65
  throw new Error(`Task not found: ${request.taskId}.`);
66
66
  const settlement = archiveSettlementChecks(store, task);
67
67
  const delivery = await readArchiveDelivery(store, preparer.git, task, request.force);
68
- const deliveryChecks = delivery.projects.filter(p => p.codeDelivery !== "none" && (!p.merged || !p.verified))
68
+ const deliveryChecks = delivery.projects.filter(p => p.deliveryRequired && (!p.merged || !p.verified))
69
69
  .map(p => ({ resource: `delivery:${task.id}/${p.projectId}`, reason: "delivery-coverage", status: "blocked",
70
70
  detail: p.reason, expected: { merged: true, verified: true, localCommit: p.expectedLocalCommit },
71
71
  observed: { coverage: p.coverage, merged: p.merged, verified: p.verified, localCommit: p.publication?.localCommit ?? null },
@@ -0,0 +1,20 @@
1
+ /** An append-only Task decision, not a Project permission or a merge fact. */
2
+ export const PROJECT_DELIVERY_DECIDED_EVENT = "task.project-delivery-decided";
3
+ export function projectDeliveryDecision(events, taskId, projectId) {
4
+ return events.filter(event => event.taskId === taskId
5
+ && event.type === PROJECT_DELIVERY_DECIDED_EVENT && event.payload.projectId === projectId)
6
+ // Task event IDs are monotonic durable sequence numbers; wall clocks can move backwards.
7
+ .sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true })).at(-1) ?? null;
8
+ }
9
+ export function projectDeliveryRequirement(decision) {
10
+ if (decision === null)
11
+ return "required";
12
+ const p = decision.payload;
13
+ if (!["required", "excluded"].includes(p.requirement ?? "")
14
+ || !p.reason?.trim() || !p.sourceMessageId?.trim()
15
+ || !/^[a-f0-9]{64}$/u.test(p.sourceDigest ?? "") || !p.purpose?.trim()
16
+ || !["user", "operator", "leader"].includes(p.by ?? "")) {
17
+ throw new Error(`Project delivery decision ${decision.id} has incomplete evidence; preserve it for diagnosis.`);
18
+ }
19
+ return p.requirement;
20
+ }
@@ -74,12 +74,10 @@ export function assertPublicationToolObservation(publication, observation, now,
74
74
  throw usageError("Tool observation externalUrl must match the recorded Publication service and object.");
75
75
  }
76
76
  const url = new URL(canonicalPublicationUrl(observation.externalUrl, publication.provider));
77
- const path = publication.provider === "gitlab"
78
- ? `/${publication.repository}/-/merge_requests/${publication.externalId}`
79
- : `/${publication.repository.toLowerCase()}/pull/${publication.externalId}`;
80
- // The registered URL comparison above pins the host AND any service prefix.
81
- // GitLab may be served below a relative root such as /gitlab.
82
- if (publication.provider === "gitlab" ? !url.pathname.endsWith(path) : url.pathname !== path) {
77
+ // GitLab UI routes are deployment-specific. Structured identity above and
78
+ // the complete registered URL bind the object without inventing a web route.
79
+ if (publication.provider === "github"
80
+ && url.pathname !== `/${publication.repository.toLowerCase()}/pull/${publication.externalId}`) {
83
81
  throw usageError("Tool observation URL does not match the repository and PR/MR identity.");
84
82
  }
85
83
  const observed = Date.parse(observation.observedAt);
@@ -1,4 +1,5 @@
1
1
  import { publicationAdoption } from "./publicationAdoption.js";
2
+ import { projectDeliveryDecision, projectDeliveryRequirement } from "./projectDeliveryScope.js";
2
3
  /**
3
4
  * One read-only delivery projection over existing Task, workspace, completion,
4
5
  * and Publication facts. It never writes a merged flag or queries a provider.
@@ -10,6 +11,8 @@ export function projectTaskRemoteDelivery(facts) {
10
11
  const currentPublications = currentPublicationReferences(facts.publications);
11
12
  const projects = facts.task.projectBindings.map((binding) => {
12
13
  const expectedLocalCommit = expected.commits.get(binding.projectId) ?? null;
14
+ const scopeDecision = projectDeliveryDecision(facts.events, facts.task.id, binding.projectId);
15
+ const deliveryRequirement = projectDeliveryRequirement(scopeDecision);
13
16
  const baseCommit = baseCommits.get(binding.projectId) ?? null;
14
17
  const codeDelivery = expectedLocalCommit === null
15
18
  ? "unknown"
@@ -44,6 +47,9 @@ export function projectTaskRemoteDelivery(facts) {
44
47
  deliveryLocalCommit: covers ? publication.localCommit ?? null : null,
45
48
  adoption,
46
49
  codeDelivery,
50
+ deliveryRequirement,
51
+ scopeDecision,
52
+ deliveryRequired: deliveryRequirement === "required" && codeDelivery !== "none",
47
53
  publication,
48
54
  state: publication?.state ?? null,
49
55
  verification: publication?.verification ?? null,
@@ -51,23 +57,26 @@ export function projectTaskRemoteDelivery(facts) {
51
57
  coverage,
52
58
  merged,
53
59
  verified,
54
- reason: projectCoverageReason(expectedLocalCommit, publication, coverage, adoption) + (coverage === "stale" && adoptionEvidence?.reason
60
+ reason: (deliveryRequirement === "excluded"
61
+ ? `No delivery obligation: ${scopeDecision.payload.reason} (${scopeDecision.id}). Merge and cleanup facts remain separate. `
62
+ : "") + projectCoverageReason(expectedLocalCommit, publication, coverage, adoption) + (coverage === "stale" && adoptionEvidence?.reason
55
63
  ? ` ${adoptionEvidence.reason}` : "")
56
64
  };
57
65
  });
58
66
  const codeProjects = projects.filter((project) => project.codeDelivery !== "none");
59
- const mergedProjectCount = codeProjects.filter((project) => project.merged).length;
60
- const verifiedProjectCount = codeProjects.filter((project) => project.verified).length;
61
- const allMerged = codeProjects.every((project) => project.merged);
62
- const allVerified = codeProjects.every((project) => project.verified);
63
- const status = codeProjects.length === 0
67
+ const requiredProjects = projects.filter(project => project.deliveryRequired);
68
+ const mergedProjectCount = requiredProjects.filter((project) => project.merged).length;
69
+ const verifiedProjectCount = requiredProjects.filter((project) => project.verified).length;
70
+ const allMerged = requiredProjects.every((project) => project.merged);
71
+ const allVerified = requiredProjects.every((project) => project.verified);
72
+ const status = requiredProjects.length === 0
64
73
  ? "none"
65
- : codeProjects.some((project) => project.coverage === "head-unavailable")
74
+ : requiredProjects.some((project) => project.coverage === "head-unavailable")
66
75
  ? "unavailable"
67
76
  : allMerged
68
77
  ? allVerified ? "merged" : "unverified"
69
78
  : mergedProjectCount > 0 ? "partial"
70
- : codeProjects.some(p => p.state === "merged") ? "uncovered" : "pending";
79
+ : requiredProjects.some(p => p.state === "merged") ? "uncovered" : "pending";
71
80
  return {
72
81
  taskId: facts.task.id,
73
82
  source: expected.source,
@@ -81,6 +90,8 @@ export function projectTaskRemoteDelivery(facts) {
81
90
  allVerified,
82
91
  totalProjectCount: projects.length,
83
92
  codeProjectCount: codeProjects.length,
93
+ requiredCodeProjectCount: requiredProjects.length,
94
+ excludedProjectCount: projects.filter(project => project.deliveryRequirement === "excluded").length,
84
95
  mergedProjectCount,
85
96
  verifiedProjectCount,
86
97
  integratedCoverageSatisfied: allMerged && allVerified,
@@ -2,6 +2,7 @@ import { isDeepStrictEqual } from "node:util";
2
2
  import { usageError } from "../errors/cliError.js";
3
3
  import { projectTaskRemoteDelivery } from "../task/remoteDelivery.js";
4
4
  import { PUBLICATION_ADOPTED_EVENT } from "./publicationAdoption.js";
5
+ import { PROJECT_DELIVERY_DECIDED_EVENT } from "./projectDeliveryScope.js";
5
6
  export function projectTaskRemoteDeliveryFromStore(store, task, currentCandidate = null) {
6
7
  return createTaskRemoteDeliveryProof(store, task, currentCandidate).delivery;
7
8
  }
@@ -10,7 +11,7 @@ export function createTaskRemoteDeliveryProof(store, task, currentCandidate = nu
10
11
  const delivery = projectTaskRemoteDelivery({
11
12
  task,
12
13
  events: store.listEventsByType(task.id, [
13
- "task.completed", "task.archived", PUBLICATION_ADOPTED_EVENT
14
+ "task.completed", "task.archived", PUBLICATION_ADOPTED_EVENT, PROJECT_DELIVERY_DECIDED_EVENT
14
15
  ]),
15
16
  publications,
16
17
  managedWorkspaces: store.listManagedWorkspaces(task.id),
@@ -49,13 +50,13 @@ export function assertTaskRemoteDeliveryProof(store, task, proof) {
49
50
  }
50
51
  export function assertTaskRemoteDeliveryIntegrated(delivery) {
51
52
  if (!delivery.allMerged) {
52
- const headUnavailable = delivery.projects.filter((project) => (project.codeDelivery !== "none"
53
+ const headUnavailable = delivery.projects.filter((project) => (project.deliveryRequired
53
54
  && project.coverage === "head-unavailable"));
54
55
  if (headUnavailable.length > 0 && delivery.source === "task-completed") {
55
56
  throw usageError(`Task ${delivery.taskId} has no frozen completion heads for: ${headUnavailable.map(({ projectId }) => projectId).join(", ")}. Historical acceptance cannot be inferred from a merge or archive.`);
56
57
  }
57
58
  const uncovered = delivery.projects
58
- .filter((project) => project.codeDelivery !== "none" && !project.merged)
59
+ .filter((project) => project.deliveryRequired && !project.merged)
59
60
  .map((project) => (`${project.projectId}/${project.coverage}`
60
61
  + (project.publication === null ? "" : `/${project.publication.id}`)))
61
62
  .join(", ");
@@ -65,7 +66,7 @@ export function assertTaskRemoteDeliveryIntegrated(delivery) {
65
66
  if (delivery.allVerified)
66
67
  return;
67
68
  const unverified = delivery.projects
68
- .filter((project) => (project.codeDelivery !== "none"
69
+ .filter((project) => (project.deliveryRequired
69
70
  && project.merged
70
71
  && !project.verified))
71
72
  .map((project) => (`${project.projectId}/${project.coverage}`
@@ -1052,7 +1052,7 @@ export const EN = {
1052
1052
  "messageKind.operator": "Operator",
1053
1053
  "messageKind.role-result": "Role result",
1054
1054
  "messageKind.system": "System",
1055
- "remoteDelivery.none": "No code delivery",
1055
+ "remoteDelivery.none": "No delivery obligation",
1056
1056
  "remoteDelivery.unavailable": "Exact heads unavailable",
1057
1057
  "remoteDelivery.pending": "Not delivered",
1058
1058
  "remoteDelivery.uncovered": "Merged, acceptance not covered",
@@ -1084,7 +1084,9 @@ export const EN = {
1084
1084
  "delivery.title": "Integration and delivery",
1085
1085
  "delivery.hint": "Integration checks, PR/MR and remote merge are separate facts; none of them is acceptance.",
1086
1086
  "delivery.readAt": "Evidence read at",
1087
- "delivery.counts": "{merged}/{total} code projects merged · {verified}/{total} verified",
1087
+ "delivery.counts": "{merged}/{total} required code projects merged · {verified}/{total} verified",
1088
+ "delivery.excluded": "Excluded from delivery",
1089
+ "delivery.scopeDecision": "Scope decision",
1088
1090
  "delivery.noProjects": "No project is bound to this Task.",
1089
1091
  "evidence.loading": "Reading original evidence…",
1090
1092
  "evidence.report": "Original review report",
@@ -1048,7 +1048,7 @@ export const ZH_CN = {
1048
1048
  "messageKind.operator": "Operator",
1049
1049
  "messageKind.role-result": "Role 结果",
1050
1050
  "messageKind.system": "系统",
1051
- "remoteDelivery.none": "无代码交付",
1051
+ "remoteDelivery.none": "无交付义务",
1052
1052
  "remoteDelivery.unavailable": "精确 head 不可用",
1053
1053
  "remoteDelivery.pending": "尚未交付",
1054
1054
  "remoteDelivery.uncovered": "已合并,未覆盖验收",
@@ -1080,7 +1080,9 @@ export const ZH_CN = {
1080
1080
  "delivery.title": "集成与交付",
1081
1081
  "delivery.hint": "集成检查、PR/MR 与远程合并是不同事实,都不等于验收。",
1082
1082
  "delivery.readAt": "证据读取于",
1083
- "delivery.counts": "已合入 {merged}/{total} 个代码项目 · 已验证 {verified}/{total}",
1083
+ "delivery.counts": "已合入 {merged}/{total} 个应交付代码项目 · 已验证 {verified}/{total}",
1084
+ "delivery.excluded": "已排除交付",
1085
+ "delivery.scopeDecision": "交付范围决定",
1084
1086
  "delivery.noProjects": "此任务未绑定项目。",
1085
1087
  "evidence.loading": "正在读取原始证据…",
1086
1088
  "evidence.report": "原始评审报告",
@@ -53,9 +53,9 @@ function drawResults(body, scope) {
53
53
  function drawRemote(body, remote, t) {
54
54
  body.append(h("h4.sub-head", null, icon("package", "icon-sm"), h("span", null, t("evidence.remote")),
55
55
  badge(t("remoteDelivery." + remote.status, remote.status), DELIVERY_TONES[remote.status] || "idle", { dot: true })));
56
- if (remote.codeProjectCount) {
56
+ if (remote.requiredCodeProjectCount) {
57
57
  body.append(h("p.faint.small", null, fill(t("delivery.counts"), {
58
- merged: remote.mergedProjectCount, verified: remote.verifiedProjectCount, total: remote.codeProjectCount
58
+ merged: remote.mergedProjectCount, verified: remote.verifiedProjectCount, total: remote.requiredCodeProjectCount
59
59
  })));
60
60
  }
61
61
  (remote.projects || []).forEach(function (project) { body.append(projectRow(project, t)); });
@@ -85,6 +85,7 @@ function projectRow(project, t) {
85
85
  const href = publication && publication.externalUrl && safeUrl(publication.externalUrl);
86
86
  const element = h("article.record.delivery-project");
87
87
  element.append(h("header.record-head", null, icon("folder", "icon-sm"), h("strong", null, project.directory || project.projectId),
88
+ project.deliveryRequirement === "excluded" ? chip(t("delivery.excluded")) : null,
88
89
  project.coverage ? chip(label(t, "coverage", project.coverage)) : null,
89
90
  h("span.spacer"),
90
91
  publication ? (href
@@ -97,6 +98,9 @@ function projectRow(project, t) {
97
98
  project.state ? chip(label(t, "publicationState", project.state)) : null,
98
99
  project.verification ? chip(t("evidence.verification") + " · " + label(t, "verification", project.verification)) : null,
99
100
  project.adoption ? h("span", null, t("evidence.adoption") + " ", mono(project.adoption.id)) : null));
101
+ if (project.scopeDecision) element.append(h("p.faint.small", null,
102
+ t("delivery.scopeDecision") + " " + project.scopeDecision.id + " · "
103
+ + project.scopeDecision.payload.sourceMessageId + " · " + project.scopeDecision.payload.reason));
100
104
  return element;
101
105
  }
102
106