@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
package/README.md CHANGED
@@ -30,7 +30,7 @@ not from terminal windows you juggle or details you have to remember.
30
30
  - **Bring your own Agent** — Codex CLI, Claude Code CLI and ACP peers run behind
31
31
  one boundary and stay replaceable without losing the Task.
32
32
  - **Local-first and private** — everything runs on your machine for one trusted
33
- user; Web is loopback-only, with read-only views and authenticated user controls.
33
+ user; Web defaults to loopback, with explicitly enabled authenticated LAN access.
34
34
  - **Isolated by default** — repository work happens in managed Git worktrees;
35
35
  the stable checkout stays read-only.
36
36
 
@@ -181,6 +181,8 @@ For a visual overview, run `yui web` in another terminal. The local Web view
181
181
  shows the same tasks and pending questions and lets you send messages, answer
182
182
  questions and explicitly queue, steer or interrupt Task input. These authenticated
183
183
  Task controls use the same operations as the CLI; Web is not a separate task system.
184
+ For explicit trusted-LAN binding, password protection, access URLs and Operator
185
+ start/status/stop, see [Controlled Web access](docs/web-access.md). HTTP is not encrypted.
184
186
  The task list remains at the left, task details occupy the primary reading area,
185
187
  and recorded Task messages live in a secondary discussion pane. You can resize,
186
188
  swap or hide that pane; on narrow screens it opens over the detail. Native Sessions
@@ -311,7 +313,7 @@ the Controller are what touch the store:
311
313
  it moves work and records facts — it never judges an answer
312
314
 
313
315
  Agents work in Projects: read-only checkout + isolated worktrees.
314
- Web (yui web): loopback views + authenticated user controls.
316
+ Web (yui web): local by default + explicitly authenticated LAN access.
315
317
  ```
316
318
 
317
319
  ### Layered design
@@ -321,7 +323,7 @@ never a fixed workflow:
321
323
 
322
324
  ```text
323
325
  Experience — how you interact
324
- CLI (Operator) · Web (loopback, authenticated) · native Agent sessions
326
+ CLI (Operator) · Web (local / authenticated LAN) · native Agent sessions
325
327
  collect input · show facts · confirm actions · invoke capabilities
326
328
  ▼
327
329
  Intelligence — who decides
@@ -275,7 +275,7 @@ const taskChildren = [
275
275
  {
276
276
  name: "project", discovery: { surface: "managed", audiences: ["operator", "leader"] },
277
277
  summary: "Manage Projects bound to a Task.",
278
- sections: [{ id: "manage", title: "Commands", entries: ["list", "add"] }],
278
+ sections: [{ id: "manage", title: "Commands", entries: ["list", "add", "delivery"] }],
279
279
  children: [
280
280
  {
281
281
  name: "list", discovery: { surface: "public", audiences: ["public", "operator", "leader"] },
@@ -287,6 +287,13 @@ const taskChildren = [
287
287
  summary: "Add a Project to a Task.",
288
288
  usage: "yui task project add <task> <project> [--base <ref>] [--directory <name>]",
289
289
  options: ["--base", "--directory"]
290
+ },
291
+ {
292
+ name: "delivery", discovery: { surface: "public", audiences: ["public", "operator", "leader"] },
293
+ summary: "Record a sourced Task Project delivery requirement; never grants cleanup or execution authority.",
294
+ usage: "yui task project delivery <task> <project-id> --requirement <required|excluded> --reason <text> --source-message <message-id> --purpose <exact-quote> --if-current <event-id|none>",
295
+ options: ["--requirement", "--reason", "--source-message", "--purpose", "--if-current"],
296
+ optionValues: { "--requirement": ["required", "excluded"] }
290
297
  }
291
298
  ]
292
299
  },
@@ -739,7 +746,7 @@ const taskChildren = [
739
746
  },
740
747
  {
741
748
  name: "new", discovery: { surface: "managed", audiences: ["operator", "leader"] },
742
- summary: "Request replacement even for active or ended Sessions; stop exact execution and preserve Task context, results and workspaces.",
749
+ summary: "Stop exact execution, then initialize a successor without replaying retained inbox obligations; preserve claims, context and workspaces.",
743
750
  usage: "yui task role session new <task> <role> --reason <text>",
744
751
  options: ["--reason"]
745
752
  }
@@ -1164,11 +1171,12 @@ export const ROOT_COMMAND = buildNode({
1164
1171
  {
1165
1172
  name: "inbox",
1166
1173
  summary: "Read and explicitly handle the current managed Agent's own inbox.",
1167
- sections: [{ id: "commands", title: "Commands", entries: ["list", "show", "claim", "ack"] }],
1174
+ sections: [{ id: "commands", title: "Commands", entries: ["list", "show", "claim", "ack", "retire"] }],
1168
1175
  children: [
1169
1176
  { name: "list", discovery: { surface: "managed", audiences: ["operator", "global", "leader", "assignment"] },
1170
1177
  summary: "Discover unhandled own-address entries, including accepted delivery, with current identity and separate handling receipts.",
1171
- usage: "yui inbox list [--limit <1..100>] [--cursor <cursor>]", options: ["--limit", "--cursor"] },
1178
+ usage: "yui inbox list [--view current|history|all] [--limit <1..100>] [--cursor <cursor>]",
1179
+ options: ["--view", "--limit", "--cursor"], optionValues: { "--view": ["current", "history", "all"] } },
1172
1180
  { name: "show", discovery: { surface: "managed", audiences: ["operator", "global", "leader", "assignment"] },
1173
1181
  summary: "Read an exact original or fixed notification window without acknowledgement.",
1174
1182
  usage: "yui inbox show <entry> [--cursor <cursor>]", options: ["--cursor"] },
@@ -1176,8 +1184,12 @@ export const ROOT_COMMAND = buildNode({
1176
1184
  summary: "Reserve 1..16 exact entries for handling in this turn; never starts a model turn.",
1177
1185
  usage: "yui inbox claim <entry> [<entry> ...] --request-id <id>", options: ["--request-id"] },
1178
1186
  { name: "ack", discovery: { surface: "managed", audiences: ["operator", "global", "leader", "assignment"] },
1179
- summary: "Confirm handling of claimed entries with an auditable disposition, not Provider acceptance.",
1187
+ summary: "Record handling, including a rightful successor's disposition of an unchanged prior claim; never proves native acceptance or authorizes replay.",
1180
1188
  usage: "yui inbox ack <entry> [<entry> ...] --request-id <id> --reason <disposition>",
1189
+ options: ["--request-id", "--reason"] },
1190
+ { name: "retire", discovery: { surface: "managed", audiences: ["operator", "global"] },
1191
+ summary: "Explicitly retire obsolete proven-unsubmitted Global input, preserving its original target and evidence.",
1192
+ usage: "yui inbox retire <entry> [<entry> ...] --request-id <id> --reason <disposition>",
1181
1193
  options: ["--request-id", "--reason"] }
1182
1194
  ]
1183
1195
  },
@@ -1192,8 +1204,8 @@ export const ROOT_COMMAND = buildNode({
1192
1204
  children: [
1193
1205
  {
1194
1206
  name: "list", discovery: { surface: "public", audiences: ["public", "operator"] }, summary: "Discover Global Messages without consuming input.",
1195
- usage: "yui role message list <role> [--pending|--diagnostics|--diagnostic <key>] [--limit <1..100>] [--cursor <cursor>]",
1196
- options: ["--pending", "--diagnostics", "--diagnostic", "--limit", "--cursor"]
1207
+ usage: "yui role message list <role> [--current|--pending|--diagnostics|--diagnostic <key>] [--limit <1..100>] [--cursor <cursor>]",
1208
+ options: ["--current", "--pending", "--diagnostics", "--diagnostic", "--limit", "--cursor"]
1197
1209
  },
1198
1210
  {
1199
1211
  name: "show", discovery: { surface: "public", audiences: ["public", "operator"] }, summary: "Read one exact Global Message; follow contentPage.nextCursor for long content.",
@@ -1243,8 +1255,8 @@ export const ROOT_COMMAND = buildNode({
1243
1255
  {
1244
1256
  name: "web", discovery: { surface: "public", audiences: ["public", "operator"] },
1245
1257
  summary: "Serve the control room using the running Controller's capability Host.",
1246
- usage: "yui web [--host <loopback>] [--port <port>] | --status | --stop",
1247
- options: ["--host", "--port", "--status", "--stop"]
1258
+ usage: "yui web [--host <IP|localhost>] [--port <port>] [--auth-file <absolute-path>] [--detach] | --status | --stop",
1259
+ options: ["--host", "--port", "--auth-file", "--detach", "--status", "--stop"]
1248
1260
  },
1249
1261
  {
1250
1262
  name: "controller", discovery: { surface: "public", audiences: ["public", "operator"] },
@@ -25,7 +25,9 @@ function describeOption(option) {
25
25
  "--agent": "Select an Agent.",
26
26
  "--base": "Set the base Git reference.",
27
27
  "--input": "Set dispatch input.",
28
- "--host": "Set the loopback listen address.",
28
+ "--host": "Set the listen IP (default 127.0.0.1); non-loopback requires --auth-file.",
29
+ "--auth-file": "Owner-only file with a unique 32–128 character password; browser username is yui. HTTP is not encrypted.",
30
+ "--detach": "Return after Controller starts Web; use --status / --stop. Operator and --json starts always return.",
29
31
  "--port": "Set the local listen port.",
30
32
  "--json": "Emit machine-readable JSON.",
31
33
  "--project": "Select a Project.",
@@ -6,6 +6,7 @@ import { controlProviderRetry, providerRetryProjection, providerRetrySummary, re
6
6
  import { pendingGlobalInboxMessages, inboxHandlingState, globalInboxDeliveryState } from "../message/inboxReceipt.js";
7
7
  import { settleGlobalRetryInput } from "../message/globalProviderRetry.js";
8
8
  import { globalInputBacklog } from "../message/globalInputBacklog.js";
9
+ import { inboxEntryCategory } from "../message/inboxTurn.js";
9
10
  import { checkCurrentSkills, SKILL_REUSE_RULE } from "../context/currentSkills.js";
10
11
  import { runtimeDiagnosticGroups, runtimeDiagnosticEvidence } from "../controller/globalRuntimeAttention.js";
11
12
  import { roleSkillIds } from "../context/roleSessionContext.js";
@@ -131,10 +132,16 @@ function roleContext(args, store, options) {
131
132
  // through distinct receipts; a context read does neither.
132
133
  const messages = store.listGlobalRoleMessages(name);
133
134
  const queued = messages.filter((message) => message.delivery === undefined
134
- && message.inbox?.ack === undefined
135
+ && message.inbox === undefined
135
136
  && message.notDelivered === undefined
136
137
  && (message.interruptThen !== undefined || message.inputControl?.action === "queue"));
137
- const pending = recordPage(pendingGlobalInboxMessages(messages).map(globalMessageSummary), `global:${name}/messages/pending`, { limit: 8 });
138
+ const unhandled = pendingGlobalInboxMessages(messages);
139
+ const current = currentGlobalInboxMessages(messages, name, sessions);
140
+ const pending = recordPage(current.map(globalMessageSummary), `global:${name}/messages/current`, { limit: 8 }, {
141
+ historyCount: unhandled.length - current.length,
142
+ historyRead: "yui inbox list --view history --json",
143
+ note: "Historical absence of handling evidence is not new work or permission to replay effects."
144
+ });
138
145
  const observedAt = new Date();
139
146
  const { oldestAgeMs, messages: _backlogMessages, ...backlog } = globalInputBacklog(queued, sessions, observedAt, {
140
147
  attention: name === "operator" ? store.getWorkMailbox({ kind: "operator" }) : null,
@@ -175,9 +182,9 @@ function roleContext(args, store, options) {
175
182
  pending,
176
183
  backlog,
177
184
  reads: {
178
- ownInbox: "yui inbox list --json",
185
+ ownInbox: "yui inbox list --view current --json",
179
186
  messages: `role message list ${name}`,
180
- pending: `role message list ${name} --pending`,
187
+ pending: `role message list ${name} --current`,
181
188
  detail: `role message show ${name} <message-id>`,
182
189
  diagnostics: `role message list ${name} --diagnostics`,
183
190
  retry: `session retry ${name} show`
@@ -213,6 +220,16 @@ function roleContext(args, store, options) {
213
220
  ""
214
221
  ].join("\n") };
215
222
  }
223
+ function currentGlobalInboxMessages(messages, name, sessions) {
224
+ const actor = { scope: "global", roleName: name, agentId: sessions?.activeAgentId ?? "",
225
+ nativeSessionId: sessions?.sessions[sessions.activeAgentId]?.nativeSessionId ?? "" };
226
+ const binding = sessions?.providerBinding;
227
+ const turn = binding?.conversations.find(c => c.epoch === binding.currentConversationEpoch)?.conversationId === actor.nativeSessionId
228
+ ? binding?.run : undefined;
229
+ const category = (message) => inboxEntryCategory(message, actor, turn);
230
+ return pendingGlobalInboxMessages(messages).filter(message => category(message) !== "history")
231
+ .sort((a, b) => Number(category(b) === "current") - Number(category(a) === "current"));
232
+ }
216
233
  function globalMessageSummary(message) {
217
234
  return {
218
235
  id: message.id, createdAt: message.createdAt, kind: message.kind,
@@ -626,17 +643,18 @@ function globalRoleMessage(args, store, options) {
626
643
  }
627
644
  requireRole(name, store);
628
645
  const pending = tail.includes("--pending");
646
+ const current = tail.includes("--current");
629
647
  const diagnostics = tail.includes("--diagnostics");
630
- const parsed = readOptions(tail.filter(arg => arg !== "--pending" && arg !== "--diagnostics"), action === "list"
648
+ const parsed = readOptions(tail.filter(arg => arg !== "--pending" && arg !== "--current" && arg !== "--diagnostics"), action === "list"
631
649
  ? ["--cursor", "--limit", "--diagnostic"] : ["--cursor"]);
632
650
  const diagnostic = parsed.values.get("--diagnostic");
633
- if (Number(pending) + Number(diagnostics) + Number(diagnostic !== undefined) > 1
651
+ if (Number(pending) + Number(current) + Number(diagnostics) + Number(diagnostic !== undefined) > 1
634
652
  || action === "show" && diagnostics)
635
653
  throw usageError("Choose one Message discovery view.");
636
654
  const messages = store.listGlobalRoleMessages(name);
637
655
  let data;
638
656
  if (action === "show") {
639
- if (parsed.positionals.length !== 1 || pending)
657
+ if (parsed.positionals.length !== 1 || pending || current)
640
658
  throw usageError("Role message show <role> <message-id> [--cursor <cursor>].");
641
659
  const message = messages.find(m => m.id === parsed.positionals[0]);
642
660
  if (message === undefined)
@@ -654,8 +672,9 @@ function globalRoleMessage(args, store, options) {
654
672
  return { kind: "output", output: `${JSON.stringify(data)}\n`, data };
655
673
  }
656
674
  const selected = diagnostic !== undefined ? runtimeDiagnosticEvidence(messages, diagnostic)
657
- : pending ? pendingGlobalInboxMessages(messages) : [...messages].reverse();
658
- data = recordPage(selected.map(globalMessageSummary), `global:${name}/messages/${diagnostic === undefined ? pending ? "pending" : "all" : `diagnostic/${diagnostic}`}`, parsed);
675
+ : current ? currentGlobalInboxMessages(messages, name, store.getGlobalRoleSessionSet(name))
676
+ : pending ? pendingGlobalInboxMessages(messages) : [...messages].reverse();
677
+ data = recordPage(selected.map(globalMessageSummary), `global:${name}/messages/${diagnostic === undefined ? current ? "current" : pending ? "pending" : "all" : `diagnostic/${diagnostic}`}`, parsed);
659
678
  }
660
679
  return { kind: "output", output: `${JSON.stringify(data, null, 2)}\n`, data };
661
680
  }
@@ -711,6 +730,8 @@ function messageGlobalInput(name, input, store, options) {
711
730
  }
712
731
  store.updateGlobalRoleMessage({ ...persisted.message, control: {
713
732
  requestId, receiptId: `steer:${name}/${persisted.message.id}`,
733
+ target: { agentId: resolution.target.agentId, nativeSessionId: resolution.target.nativeSessionId,
734
+ attemptId: resolution.target.attemptId },
714
735
  outcome: "pending", observedAt: now.toISOString()
715
736
  } });
716
737
  return {
@@ -5,9 +5,10 @@ import { createTaskWake, latestTaskWake } from "../scheduler/taskWake.js";
5
5
  import { expandTaskMessageResult } from "../message/message.js";
6
6
  import { messageAssignmentBlocker } from "../message/messageContinuation.js";
7
7
  import { globalInboxDeliveryState, inboxHandlingState, isGlobalInboxMessage, pendingGlobalInboxMessages } from "../message/inboxReceipt.js";
8
- import { deliveredMessageWake, isLeaderSteer, pinMessageRevisions, taskMessageInboxReceipt, unfinishedLeaderInbox } from "../message/taskInboxOwnership.js";
8
+ import { nonReplayableMessageWake, isLeaderSteer, pinMessageRevisions, releasedNotificationWake, taskMessageInboxReceipt, unfinishedLeaderInbox } from "../message/taskInboxOwnership.js";
9
9
  import { hasRuntimeCleanupObligation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
10
10
  import { isTaskRecordRetired, retiredTaskRecordIds } from "../task/taskRecordRetirement.js";
11
+ import { blocksInboxOrder, inboxEntryCategory, inboxSettled } from "../message/inboxTurn.js";
11
12
  const target = (address) => ({ kind: "role", taskId: address.taskId, roleName: address.roleName });
12
13
  const source = (address) => `inbox:${address.scope}:${address.taskId ?? ""}:${address.roleName}`;
13
14
  const pendingId = (pending) => `pending-${readDigest(pending)}`;
@@ -15,15 +16,27 @@ const sameSession = (a, b) => a.scope === b.scope && a.taskId === b.taskId
15
16
  && a.roleName === b.roleName && a.agentId === b.agentId && a.nativeSessionId === b.nativeSessionId;
16
17
  function addressOf(store, env) {
17
18
  const caller = requireManagedCaller(store, env);
19
+ const binding = caller.scope === "global" ? store.getGlobalRoleSessionSet(caller.roleName)?.providerBinding
20
+ : store.getTaskRoleSessionSet(caller.taskId, caller.roleName)?.providerBinding;
21
+ const turn = binding?.conversations.find(c => c.epoch === binding.currentConversationEpoch)?.conversationId === caller.nativeSessionId
22
+ ? binding?.run : undefined;
23
+ const attempt = turn && ["submitting", "accepted", "delivery-unknown"].includes(turn.status)
24
+ ? { attemptId: turn.attemptId } : {};
18
25
  if (caller.scope === "global") {
19
- return { scope: "global", roleName: caller.roleName, agentId: caller.agentId, nativeSessionId: caller.nativeSessionId };
26
+ return { scope: "global", roleName: caller.roleName, agentId: caller.agentId, nativeSessionId: caller.nativeSessionId, ...attempt };
20
27
  }
21
28
  if (caller.roleName !== "leader" && caller.currentRunId === undefined) {
22
29
  throw new Error("An Assignment inbox requires its current active AgentRun.");
23
30
  }
24
31
  return { scope: "task", taskId: caller.taskId, roleName: caller.roleName,
25
32
  agentId: caller.agentId, nativeSessionId: caller.nativeSessionId,
26
- ...(caller.currentRunId === undefined ? {} : { runId: caller.currentRunId }) };
33
+ ...(caller.currentRunId === undefined ? {} : { runId: caller.currentRunId }), ...attempt };
34
+ }
35
+ function currentTurn(store, address) {
36
+ const binding = address.scope === "global" ? store.getGlobalRoleSessionSet(address.roleName)?.providerBinding
37
+ : store.getTaskRoleSessionSet(address.taskId, address.roleName)?.providerBinding;
38
+ return binding?.conversations.find(c => c.epoch === binding.currentConversationEpoch)?.conversationId === address.nativeSessionId
39
+ ? binding?.run : undefined;
27
40
  }
28
41
  function ownsMessage(store, address, message) {
29
42
  if (address.roleName === "leader" && message.taskId === address.taskId && isLeaderSteer(message))
@@ -80,15 +93,15 @@ function blocker(store, address, entry) {
80
93
  if (!isWake(entry) && address.scope === "task"
81
94
  && isTaskRecordRetired(store.listEvents(address.taskId), "message", entry.id))
82
95
  return "message-retired";
83
- if (entry.inbox?.ack !== undefined)
84
- return "acknowledged";
96
+ if (inboxSettled(entry.inbox))
97
+ return entry.inbox?.retirement ? "retired" : "acknowledged";
85
98
  if (entry.inbox !== undefined) {
86
99
  return sameSession(entry.inbox.claim.actor, address) ? undefined : "claimed-by-previous-session";
87
100
  }
88
101
  if (isWake(entry)) {
89
102
  // Native acceptance releases transport ownership, not semantic handling.
90
103
  // The current Leader may explicitly claim the fixed delivered window.
91
- if (entry.status === "consumed")
104
+ if (entry.status === "consumed" || releasedNotificationWake(store, entry.taskId, entry.id))
92
105
  return undefined;
93
106
  return "automatic-notification-owns-window";
94
107
  }
@@ -150,9 +163,12 @@ function checkMutation(store, address) {
150
163
  }
151
164
  function summary(store, address, entry) {
152
165
  return { id: entry.id, kind: isWake(entry) ? "notification-window" : "message",
166
+ category: inboxEntryCategory(entry, address, currentTurn(store, address)),
153
167
  createdAt: entry.createdAt, summary: summaryText(isWake(entry) ? entry.reasons.join(", ") : entry.body),
154
168
  blocked: blocker(store, address, entry) ?? null, inbox: summaryMetadata(entry.inbox ?? null),
155
169
  handling: inboxHandlingState(entry.inbox),
170
+ ...(entry.inbox !== undefined && entry.inbox.ack === undefined && !sameSession(entry.inbox.claim.actor, address)
171
+ ? { recovery: "Read this original and prior effects under current authority; finish handling, then inbox ack this exact entry. Retain the original claim; do not claim again or replay business." } : {}),
156
172
  delivery: isWake(entry) ? entry.status : summaryMetadata({
157
173
  control: entry.control ?? null,
158
174
  ...(address.scope === "global" ? { receipt: entry.delivery ?? null,
@@ -173,23 +189,33 @@ export function runInboxCommand(args, store, env, now = new Date()) {
173
189
  const [action, ...tail] = args;
174
190
  const address = addressOf(store, env);
175
191
  if (action === "list") {
176
- const parsed = readOptions(tail);
177
- if (parsed.positionals.length)
178
- throw new Error("Inbox list accepts only --limit and --cursor.");
192
+ const parsed = readOptions(tail, ["--limit", "--cursor", "--view"]);
193
+ const view = parsed.values.get("--view") ?? "all";
194
+ if (!["all", "current", "history"].includes(view) || parsed.positionals.length) {
195
+ throw new Error("Inbox list accepts --view current|history|all, --limit and --cursor.");
196
+ }
179
197
  const retired = address.scope === "task" ? retiredTaskRecordIds(store.listEvents(address.taskId), "message") : new Set();
180
198
  const selected = address.scope === "global" ? pendingGlobalInboxMessages(store.listGlobalRoleMessages(address.roleName))
181
199
  : entries(store, address).filter(entry => (isWake(entry) || !retired.has(entry.id))
182
- && inboxHandlingState(entry.inbox) !== "handled");
183
- const items = selected.map(entry => summary(store, address, entry));
200
+ && !inboxSettled(entry.inbox));
201
+ const turn = currentTurn(store, address);
202
+ const categories = selected.map(entry => ({ entry, category: inboxEntryCategory(entry, address, turn) }));
203
+ const rank = { current: 0, available: 1, history: 2 };
204
+ categories.sort((a, b) => rank[a.category] - rank[b.category]);
205
+ const items = categories.filter(item => view === "all"
206
+ || (view === "history") === (item.category === "history")).map(({ entry }) => summary(store, address, entry));
184
207
  if (address.scope === "task" && address.roleName === "leader") {
185
208
  const mailbox = store.getWorkMailbox(target(address));
186
- if (mailbox?.pending != null)
187
- items.push({ id: pendingId(mailbox.pending), kind: "notification-window",
209
+ if (mailbox?.pending != null && view !== "history")
210
+ items.splice(categories.filter(item => item.category === "current").length, 0, { id: pendingId(mailbox.pending), kind: "notification-window",
211
+ category: "available",
188
212
  summary: summaryText(mailbox.pending.reasons.join(", ")),
189
213
  blocked: mailbox.processing === null && !unfinishedLeaderInbox(store, address.taskId) ? null : "earlier-window-owned",
190
214
  read: `yui inbox show ${pendingId(mailbox.pending)}` });
191
215
  }
192
- return recordPage(items, source(address), parsed, { identity: address });
216
+ return recordPage(items, `${source(address)}/${view}`, parsed, { identity: address, view,
217
+ historyCount: categories.filter(item => item.category === "history").length,
218
+ note: "History lacks handling evidence; it is not unexecuted work. Inspect original effects before disposition." });
193
219
  }
194
220
  if (action === "show") {
195
221
  const parsed = readOptions(tail, ["--cursor"]);
@@ -208,8 +234,8 @@ export function runInboxCommand(args, store, env, now = new Date()) {
208
234
  ? expandTaskMessageResult(entry, (task, run) => store.getRun(task, run)) : entry;
209
235
  return boundedDocument(value, `${source(address)}/${id}`, parsed.cursor);
210
236
  }
211
- if (action !== "claim" && action !== "ack")
212
- throw new Error("Inbox commands: list, show, claim, ack.");
237
+ if (action !== "claim" && action !== "ack" && action !== "retire")
238
+ throw new Error("Inbox commands: list, show, claim, ack, retire.");
213
239
  const parsed = readOptions(tail, ["--request-id", "--reason"]);
214
240
  const ids = parsed.positionals;
215
241
  const requestId = parsed.values.get("--request-id");
@@ -220,7 +246,7 @@ export function runInboxCommand(args, store, env, now = new Date()) {
220
246
  if (!ids.length || ids.length > 16 || new Set(ids).size !== ids.length) {
221
247
  throw new Error("Select 1..16 distinct exact inbox entries; no wildcard acknowledgement.");
222
248
  }
223
- if (action === "ack" && (!reason?.trim() || Buffer.byteLength(reason) > 2048)) {
249
+ if (action !== "claim" && (!reason?.trim() || Buffer.byteLength(reason) > 2048)) {
224
250
  throw new Error("Acknowledgement requires --reason describing handling or why no action remains (up to 2048 bytes).");
225
251
  }
226
252
  if (action === "claim" && reason !== undefined)
@@ -251,7 +277,7 @@ export function runInboxCommand(args, store, env, now = new Date()) {
251
277
  continue;
252
278
  const message = tx.listMessages(current.taskId).find(message => message.id === ref.id);
253
279
  if (message !== undefined && (taskMessageInboxReceipt(tx, message) !== undefined
254
- || deliveredMessageWake(tx, message) !== undefined)
280
+ || nonReplayableMessageWake(tx, message) !== undefined)
255
281
  || message?.interruptThen !== undefined
256
282
  || message?.control !== undefined && message.control.outcome !== "rejected"
257
283
  || message?.inputControl?.expectedSessionId !== undefined
@@ -274,7 +300,36 @@ export function runInboxCommand(args, store, env, now = new Date()) {
274
300
  }
275
301
  else {
276
302
  entry = getEntry(tx, current, id);
277
- if (action === "claim") {
303
+ if (action === "retire") {
304
+ if (current.scope !== "global" || isWake(entry)) {
305
+ throw new Error("Task input retirement uses the Leader's existing task message retire operation.");
306
+ }
307
+ const message = entry;
308
+ if (message.inbox?.retirement) {
309
+ const saved = message.inbox.retirement;
310
+ if (saved.requestId !== requestId || saved.reason !== reason || !sameSession(saved.actor, current)) {
311
+ throw new Error("Conflicting retirement; original disposition retained.");
312
+ }
313
+ return receipt(entry);
314
+ }
315
+ // A retained attempt in an older conversation is still evidence of
316
+ // submission; changing the active Session cannot erase that boundary.
317
+ const turn = tx.getGlobalRoleSessionSet(current.roleName)?.providerBinding?.run;
318
+ const foreignTarget = message.deliveryTarget && (message.deliveryTarget.agentId !== current.agentId
319
+ || message.deliveryTarget.nativeSessionId !== current.nativeSessionId)
320
+ || message.inputControl?.expectedSessionId !== undefined
321
+ && message.inputControl.expectedSessionId !== current.nativeSessionId;
322
+ if (message.inbox?.ack || message.delivery !== undefined || message.interruptThen !== undefined
323
+ || message.control !== undefined && message.control.outcome !== "rejected"
324
+ || turn?.attemptId === `global-input:${current.roleName}/${message.id}`
325
+ || !foreignTarget && message.notDelivered === undefined) {
326
+ throw new Error("Retirement requires obsolete, proven-unsubmitted input; unknown/accepted/interrupt-owned input must retain its exact handling path.");
327
+ }
328
+ entry = { ...message, inbox: { ...message.inbox,
329
+ claim: message.inbox?.claim ?? { requestId, actor: current, at: now.toISOString() },
330
+ retirement: { requestId, actor: current, at: now.toISOString(), reason: reason } } };
331
+ }
332
+ else if (action === "claim") {
278
333
  if (entry.inbox !== undefined) {
279
334
  if (!sameSession(entry.inbox.claim.actor, current))
280
335
  throw new Error("Claim belongs to a previous Session; read the original and prior effects, then explicitly acknowledge only after finishing its handling.");
@@ -285,7 +340,7 @@ export function runInboxCommand(args, store, env, now = new Date()) {
285
340
  const blocked = blocker(tx, current, entry);
286
341
  if (blocked !== undefined)
287
342
  throw new Error(`Inbox entry cannot be claimed: ${blocked}.`);
288
- if (isWake(entry) && !acceptedInput(entry)) {
343
+ if (isWake(entry) && !acceptedInput(entry) && !releasedNotificationWake(tx, entry.taskId, entry.id)) {
289
344
  for (const ref of entry.refs ?? []) {
290
345
  if (ref.type !== "message")
291
346
  continue;
@@ -299,7 +354,7 @@ export function runInboxCommand(args, store, env, now = new Date()) {
299
354
  for (const previous of entries(tx, current)) {
300
355
  if (previous.id === entry.id)
301
356
  break;
302
- if (isWake(previous) || previous.inbox?.ack !== undefined)
357
+ if (!blocksInboxOrder(previous, current, currentTurn(tx, current)))
303
358
  continue;
304
359
  if (current.scope === "task"
305
360
  && isTaskRecordRetired(tx.listEvents(current.taskId), "message", previous.id))
@@ -315,11 +370,13 @@ export function runInboxCommand(args, store, env, now = new Date()) {
315
370
  else {
316
371
  if (!entry.inbox)
317
372
  throw new Error("Claim the exact entry before handling and acknowledgement.");
373
+ if (entry.inbox.retirement)
374
+ throw new Error("Retirement is not acknowledgement; original disposition retained.");
318
375
  // Ack settles already-owned handling; it never submits or retargets
319
376
  // the original. The current rightful successor may settle a claim
320
377
  // acquired by the exact selected Session, retaining both identities.
321
378
  const claimant = entry.inbox.claim.actor;
322
- if (isWake(entry) && !acceptedInput(entry)) {
379
+ if (isWake(entry) && !acceptedInput(entry) && !releasedNotificationWake(tx, entry.taskId, entry.id)) {
323
380
  const messageIds = new Set(entry.refs?.filter(ref => ref.type === "message").map(ref => ref.id));
324
381
  for (const message of tx.listMessages(current.taskId)) {
325
382
  if (messageIds.has(message.id))
@@ -83,6 +83,7 @@ import { assertTaskOpen, clock, exactPositionals, leaderActionEventPayload, lead
83
83
  import { assertTaskCompletionPublishedTreeProof } from "./taskCompletionGate.js";
84
84
  import { runTaskContextCommand, taskRecordList } from "./taskContextCommand.js";
85
85
  import { taskBriefCommand, taskDecisionCommand, taskEventCommand, taskMilestoneCommand } from "./taskFactCommands.js";
86
+ import { taskProjectDeliveryCommand } from "./taskProjectDeliveryCommand.js";
86
87
  import { taskUsageCommand } from "./taskUsageCommand.js";
87
88
  import { openInputRequestCount, runTaskInputCommand } from "./taskInputCommands.js";
88
89
  import { runTaskNextActionCommand } from "./taskNextActionCommand.js";
@@ -456,6 +457,8 @@ function assertCandidateReviewRoleIsolation(producerRoles, reviewerRoleName, lan
456
457
  }
457
458
  function taskProjectCommand(args, store, options) {
458
459
  const [command, ...rest] = args;
460
+ if (command === "delivery")
461
+ return taskProjectDeliveryCommand(rest, store, options);
459
462
  if (command === "list") {
460
463
  exactPositionals(rest, 1, "Task project list usage: yui task project list <task>.");
461
464
  const task = requireTask(store, rest[0]);
@@ -1880,7 +1883,9 @@ function steerTaskInput(taskId, input, store, options) {
1880
1883
  if (saved === undefined || requestId === undefined || taskMessageInboxReceipt(tx, saved) !== undefined)
1881
1884
  return false;
1882
1885
  tx.updateMessage(result.task.id, recordTaskMessageControlOutcome(saved, {
1883
- requestId, receiptId, outcome: "pending", observedAt: clock(options)
1886
+ requestId, receiptId, outcome: "pending", observedAt: clock(options),
1887
+ target: { agentId: resolution.target.agentId, nativeSessionId: resolution.target.nativeSessionId,
1888
+ attemptId: resolution.target.attemptId }
1884
1889
  }));
1885
1890
  return true;
1886
1891
  });
@@ -0,0 +1,56 @@
1
+ import { readTaskAuthorizationMessage, taskAuthorizationSource } from "../grant/taskAuthorization.js";
2
+ import { assertTaskDeliveryAuthority } from "../task/taskAuthority.js";
3
+ import { PROJECT_DELIVERY_DECIDED_EVENT, projectDeliveryDecision } from "../task/projectDeliveryScope.js";
4
+ import { enqueueWork } from "../coordination/workMailboxQueue.js";
5
+ import { usageError } from "../errors/cliError.js";
6
+ import { clock, exactPositionals, leaderMailbox, notifyMailbox, output, parseTail, recordTaskEvent, requiredOption, requireTask, taskMailbox, taskRef } from "./taskCommandSupport.js";
7
+ export function taskProjectDeliveryCommand(args, store, options) {
8
+ const usage = "yui task project delivery <task> <project-id> --requirement <required|excluded> "
9
+ + "--reason <text> --source-message <message-id> --purpose <exact-quote> --if-current <event-id|none>";
10
+ const parsed = parseTail(args, new Set([
11
+ "--requirement", "--reason", "--source-message", "--purpose", "--if-current"
12
+ ]), usage);
13
+ exactPositionals(parsed.positionals, 2, usage);
14
+ const requirement = requiredOption(parsed.options, "--requirement");
15
+ if (requirement !== "required" && requirement !== "excluded")
16
+ throw usageError(usage);
17
+ const reason = requiredOption(parsed.options, "--reason");
18
+ const sourceMessage = requiredOption(parsed.options, "--source-message");
19
+ const purpose = requiredOption(parsed.options, "--purpose");
20
+ const guard = requiredOption(parsed.options, "--if-current");
21
+ const now = clock(options);
22
+ const result = store.transaction(tx => {
23
+ const task = requireTask(tx, parsed.positionals[0]);
24
+ const actor = assertTaskDeliveryAuthority(tx, options.environment, task.id);
25
+ if (!["active", "completed"].includes(task.status) || task.executionGate.state !== "enabled") {
26
+ throw usageError("Delivery scope requires an active or completed, unarchived Task with execution enabled; it does not reopen or resume work.");
27
+ }
28
+ const projectId = parsed.positionals[1];
29
+ if (!task.projectBindings.some(binding => binding.projectId === projectId)) {
30
+ throw usageError(`Project is not bound to this Task: ${projectId}.`);
31
+ }
32
+ const source = actor === "leader"
33
+ ? taskAuthorizationSource(tx, task.id, options.environment ?? {}, sourceMessage, purpose)
34
+ : readTaskAuthorizationMessage(tx, task.id, sourceMessage, purpose);
35
+ const previous = projectDeliveryDecision(tx.listEventsByType(task.id, [PROJECT_DELIVERY_DECIDED_EVENT]), task.id, projectId);
36
+ if (guard !== (previous?.id ?? "none")) {
37
+ throw usageError("Project delivery scope changed; read task remote-delivery and reconcile before retrying with its scopeDecision.id (or none).");
38
+ }
39
+ const event = recordTaskEvent(tx, task.id, PROJECT_DELIVERY_DECIDED_EVENT, {
40
+ projectId, requirement, reason, by: actor,
41
+ sourceMessageId: source.messageId, sourceDigest: source.digest, purpose: source.purpose,
42
+ ...("nativeSessionId" in source && typeof source.nativeSessionId === "string"
43
+ ? { nativeSessionId: source.nativeSessionId } : {}),
44
+ ...(previous === null ? {} : { supersedes: previous.id })
45
+ }, now);
46
+ enqueueWork(tx, taskMailbox(task.id), "project-delivery-decided", now, [taskRef(task.id)]);
47
+ if (task.status === "active" && actor !== "leader") {
48
+ enqueueWork(tx, leaderMailbox(task.id), "project-delivery-decided", now, [taskRef(task.id)]);
49
+ }
50
+ return { task, event };
51
+ });
52
+ notifyMailbox(options.runtime, taskMailbox(result.task.id));
53
+ if (result.task.status === "active")
54
+ notifyMailbox(options.runtime, leaderMailbox(result.task.id));
55
+ return output(`Recorded Project delivery requirement (${requirement}); merge facts and cleanup authority are unchanged.\n`, result.event);
56
+ }
@@ -27,15 +27,17 @@ export function renderTaskRemoteDelivery(delivery, indent = "") {
27
27
  const lines = [
28
28
  `${indent}Remote delivery: ${delivery.status}`,
29
29
  `${indent}Expected heads: ${source}`,
30
- `${indent}All merged: ${delivery.allMerged ? "yes" : "no"}`,
31
- `${indent}All verified: ${delivery.allVerified ? "yes" : "no"}`,
32
- `${indent}Code Projects: ${delivery.mergedProjectCount}/${delivery.codeProjectCount} merged; ${delivery.verifiedProjectCount}/${delivery.codeProjectCount} verified`,
30
+ `${indent}Required code Projects all merged: ${delivery.allMerged ? "yes" : "no"}`,
31
+ `${indent}Required code Projects all verified: ${delivery.allVerified ? "yes" : "no"}`,
32
+ `${indent}Required code Projects: ${delivery.mergedProjectCount}/${delivery.requiredCodeProjectCount} merged; ${delivery.verifiedProjectCount}/${delivery.requiredCodeProjectCount} verified; excluded=${delivery.excludedProjectCount}`,
33
+ ...(delivery.requiredCodeProjectCount === 0 ? [`${indent}No delivery obligation.`] : []),
33
34
  `${indent}Archive --integrated coverage: ${delivery.integratedCoverageSatisfied ? "satisfied" : "blocked"}`,
34
35
  ...(delivery.archiveDisposition === null
35
36
  ? []
36
37
  : [`${indent}Archive disposition: ${delivery.archiveDisposition}`]),
37
38
  `${indent}Projects:${delivery.projects.length === 0 ? " none" : ""}`,
38
39
  ...delivery.projects.map((project) => (`${indent}- ${project.directory} (${project.projectId}): `
40
+ + `requirement=${project.deliveryRequirement}; scopeDecision=${project.scopeDecision?.id ?? "none"}; `
39
41
  + `expected=${shortCommit(project.expectedLocalCommit)}; `
40
42
  + `candidate=${shortCommit(project.deliveryLocalCommit)}; `
41
43
  + `adoption=${project.adoption?.id ?? "none"}; `
@@ -385,6 +385,7 @@ export function runContextEntry(pack) {
385
385
  activeRuns: liveTaskState.activeRuns.length, activeTaskReviews: liveTaskState.activeTaskReviews.length }
386
386
  },
387
387
  reads: {
388
+ inbox: "yui inbox list --view current --json",
388
389
  refs: `yui task run context ${pack.identity.taskId}/${pack.identity.runId} --view refs`,
389
390
  full: `yui task run context ${pack.identity.taskId}/${pack.identity.runId} --view full`,
390
391
  input: `yui task run context ${pack.identity.taskId}/${pack.identity.runId} --view input`,
@@ -90,6 +90,7 @@ export function serializeRunInputEnvelope(value) {
90
90
  ]),
91
91
  "Use the Session Manifest's CLI entry. Fail closed if the exact Context is unavailable or mismatched.",
92
92
  "Then read the relevant referenced requirements and perform the assigned work. Context loading is not the deliverable.",
93
+ "Current continuation/steer inputs: yui inbox list --view current --json. Read and claim exact originals, then ack their disposition before the final report; this does not accept the assignment.",
93
94
  "During planning, fulfill the user's Draft discussion request without starting delivery before it is requested."
94
95
  ];
95
96
  const serialized = lines.join("\n");
@@ -93,7 +93,7 @@ export function readTaskContext(store, taskId, environment = {}) {
93
93
  commandDiscovery: {
94
94
  help: "yui help", describe: "yui --json help",
95
95
  skillCheck: "yui session skills --json",
96
- ownInbox: "yui inbox list --json",
96
+ ownInbox: "yui inbox list --view current --json",
97
97
  note: "Help and completion share the current caller's offline command view. Follow that view for operation-specific discovery; execution always revalidates authority."
98
98
  },
99
99
  detail: `task context inspect ${taskId} --store <store> --ref <refId> --digest <digest>`,
@@ -61,7 +61,7 @@ export function activationWakeRefs(store, task) {
61
61
  }
62
62
  const BRIEF_RELEVANT_EVENTS = [
63
63
  "message.sent", "message.updated", "message.native-user-input", "brief.updated",
64
- "decision.recorded", "decision.superseded", "input.requested", "input.answered", "input.cancelled", "input.auto-answered",
64
+ "decision.recorded", "decision.superseded", "task.project-delivery-decided", "input.requested", "input.answered", "input.cancelled", "input.auto-answered",
65
65
  "run.completed", "run.failed", "run.cancelled", "job.state-changed", "work.accepted",
66
66
  "runtime.role-delivery-failed", "run.retired",
67
67
  "work.updated", "work.edited", "work.scope-updated", "work.rejected", "work.retired",