@zq-silk/yui 0.15.8 → 0.15.9

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 (74) hide show
  1. package/ARCHITECTURE.md +2 -0
  2. package/ARCHITECTURE.zh-CN.md +151 -0
  3. package/README.md +211 -14
  4. package/dist/artifacts/artifactCapability.js +74 -0
  5. package/dist/artifacts/artifactCommitLock.js +249 -0
  6. package/dist/artifacts/artifactPaths.js +151 -0
  7. package/dist/artifacts/gitArtifactRef.js +146 -0
  8. package/dist/artifacts/managedGit.js +332 -0
  9. package/dist/artifacts/taskArtifactRepository.js +277 -0
  10. package/dist/cli/commandCatalog.js +14 -10
  11. package/dist/cli.js +67 -0
  12. package/dist/commands/operatorCommands.js +33 -2
  13. package/dist/commands/taskActivationCommands.js +22 -0
  14. package/dist/commands/taskCommands.js +342 -79
  15. package/dist/context/runContextPack.js +28 -16
  16. package/dist/context/taskContext.js +26 -3
  17. package/dist/controller/controller.js +8 -2
  18. package/dist/kernel/builtinCapabilities.js +32 -24
  19. package/dist/message/message.js +56 -0
  20. package/dist/plugins/pluginService.js +11 -3
  21. package/dist/resources/projectResource.js +0 -48
  22. package/dist/resources/projectResourceService.js +3 -81
  23. package/dist/setup/setupCommand.js +3 -8
  24. package/dist/storage/migrations/artifactsToGit.js +338 -0
  25. package/dist/storage/migrations/submitIntent.js +126 -0
  26. package/dist/storage/sqliteSchema.js +37 -3
  27. package/dist/storage/sqliteStore.js +1 -21
  28. package/dist/storage/storageVersions.js +1 -1
  29. package/dist/storage/storeRpc.js +1 -1
  30. package/dist/task/taskActivation.js +26 -0
  31. package/dist/task/taskActivationService.js +85 -69
  32. package/dist/task/taskSubmission.js +236 -0
  33. package/dist/web/assets/client/app.js +3 -2
  34. package/dist/web/assets/client/taskSurface.js +96 -7
  35. package/dist/web/webServer.js +18 -3
  36. package/dist/web/webTaskSurface.js +6 -6
  37. package/dist/workItem/workItem.js +14 -10
  38. package/docs/agent-result-consumption.md +2 -0
  39. package/docs/agent-result-consumption.zh-CN.md +81 -0
  40. package/docs/agent-runtime-drivers.md +2 -0
  41. package/docs/agent-runtime-drivers.zh-CN.md +77 -0
  42. package/docs/architecture/README.md +44 -32
  43. package/docs/architecture/README.zh-CN.md +43 -0
  44. package/docs/architecture/capabilities-and-resources.md +118 -79
  45. package/docs/architecture/capabilities-and-resources.zh-CN.md +83 -0
  46. package/docs/managed-turn-and-session-runtime.md +2 -0
  47. package/docs/managed-turn-and-session-runtime.zh-CN.md +180 -0
  48. package/docs/observability/README.md +2 -0
  49. package/docs/observability/README.zh-CN.md +71 -0
  50. package/docs/plugin-sdk.md +320 -217
  51. package/docs/plugin-sdk.zh-CN.md +293 -0
  52. package/docs/provider-runtime.md +2 -0
  53. package/docs/provider-runtime.zh-CN.md +132 -0
  54. package/docs/release-workflow.md +2 -0
  55. package/docs/release-workflow.zh-CN.md +237 -0
  56. package/docs/roles-and-configuration.md +2 -0
  57. package/docs/roles-and-configuration.zh-CN.md +96 -0
  58. package/docs/sqlite-control-plane-design.md +2 -0
  59. package/docs/sqlite-control-plane-design.zh-CN.md +62 -0
  60. package/docs/task-dag-semantics.md +80 -57
  61. package/docs/task-dag-semantics.zh-CN.md +59 -0
  62. package/docs/task-delivery.md +2 -0
  63. package/docs/task-delivery.zh-CN.md +82 -0
  64. package/docs/task-local-identity.md +2 -0
  65. package/docs/task-local-identity.zh-CN.md +58 -0
  66. package/docs/testing/verification-levels.md +2 -0
  67. package/docs/testing/verification-levels.zh-CN.md +69 -0
  68. package/i18n/README.zh-CN.md +199 -10
  69. package/package.json +2 -1
  70. package/skills/yui-leader/SKILL.md +88 -331
  71. package/skills/yui-leader/references/execution.md +303 -0
  72. package/skills/yui-leader/references/planning.md +109 -0
  73. package/skills/yui-leader/references/task-plugins.md +8 -4
  74. package/skills/yui-operator/SKILL.md +16 -3
@@ -40,80 +40,96 @@ export function taskActivationOperationRef(taskId, requestId) {
40
40
  * unchanged, so a retried tool call cannot produce a second activation.
41
41
  */
42
42
  export function requestTaskActivation(store, input, now) {
43
- return store.transaction((tx) => {
44
- const task = requireOpenDraft(tx, input.taskId);
45
- const plan = canonicalEnvironmentPlan(input.environmentPlan);
46
- const planningRun = resolveCallerPlanningRun(tx, task.id, input.callerRunId);
47
- const startMode = planningRun === undefined
48
- ? "immediate"
49
- : "after-planning-turn";
50
- const existing = task.activationRequest;
51
- // A requestId whose outcome was already decided is never re-opened, whether
52
- // it still holds the slot or a later request displaced it. Cancelling A,
53
- // requesting B and then retrying A must not resurrect A: the withdrawal is
54
- // explicit authority, and adoption is exactly the effect it withdrew. An
55
- // adopted id is equally final its environment and status change happened.
56
- // Only `failed` stays replayable, which is its documented contract.
57
- //
58
- // The authority is the durable activation event ledger, not the bounded
59
- // display payload: the payload trims its oldest entries once it overflows,
60
- // but the terminal events are never compacted, so an evicted cancellation is
61
- // still refused with its original outcome instead of silently resurrecting.
62
- const settled = settledActivationRequest(tx, task, input.requestId);
63
- if (settled !== undefined) {
64
- throw new Error(`Activation request ${input.requestId} was already ${settled.disposition} for ${task.id}`
65
- + `${settled.outcome === undefined ? "" : ` (${settled.outcome})`}. `
66
- + "Use a new requestId.");
67
- }
68
- if (existing?.operation.requestId === input.requestId) {
69
- const digest = taskActivationInputDigest(task.id, {
70
- startMode,
71
- ...(planningRun === undefined ? {} : { afterPlanningRun: planningRun }),
72
- environmentPlan: plan
73
- });
74
- if (existing.operation.inputDigest !== digest) {
75
- throw new Error(`Activation requestId ${input.requestId} was already used for different inputs.`);
76
- }
77
- return {
78
- operationRef: taskActivationOperationRef(task.id, existing.operation.requestId),
79
- request: existing,
80
- startMode: existing.startMode,
81
- created: false,
82
- ...(existing.afterPlanningRun === undefined
83
- ? {}
84
- : { afterPlanningRun: existing.afterPlanningRun })
85
- };
86
- }
87
- if (existing?.disposition === "pending") {
88
- throw new Error(`Task already has a pending activation request: ${task.id}/${existing.operation.requestId}. `
89
- + "Cancel it before requesting different inputs.");
90
- }
91
- const request = createTaskActivationRequest(task.id, {
92
- requestId: input.requestId,
93
- actorId: input.actorId,
94
- authorityRef: input.authorityRef,
43
+ return store.transaction((tx) => recordTaskActivationRequestInTransaction(tx, input, now));
44
+ }
45
+ /**
46
+ * The transactional core of {@link requestTaskActivation}, callable from inside
47
+ * a caller's own open transaction.
48
+ *
49
+ * The shared submission service (task-32 §2.3) records a develop route's
50
+ * activation request in the very transaction that saves the message, so the
51
+ * message and its activation intent commit together and no "submit then
52
+ * activate" two-command window exists. It reuses this exact logic rather than a
53
+ * second activation queue: the same idempotency, settled-id refusal and
54
+ * pending-collision rules apply whether the request arrives from the explicit
55
+ * boundary or from a develop submission. The caller owns transaction lifetime
56
+ * and any post-commit mailbox notification.
57
+ */
58
+ export function recordTaskActivationRequestInTransaction(tx, input, now) {
59
+ const task = requireOpenDraft(tx, input.taskId);
60
+ const plan = canonicalEnvironmentPlan(input.environmentPlan);
61
+ const planningRun = resolveCallerPlanningRun(tx, task.id, input.callerRunId);
62
+ const startMode = planningRun === undefined
63
+ ? "immediate"
64
+ : "after-planning-turn";
65
+ const existing = task.activationRequest;
66
+ // A requestId whose outcome was already decided is never re-opened, whether
67
+ // it still holds the slot or a later request displaced it. Cancelling A,
68
+ // requesting B and then retrying A must not resurrect A: the withdrawal is
69
+ // explicit authority, and adoption is exactly the effect it withdrew. An
70
+ // adopted id is equally final — its environment and status change happened.
71
+ // Only `failed` stays replayable, which is its documented contract.
72
+ //
73
+ // The authority is the durable activation event ledger, not the bounded
74
+ // display payload: the payload trims its oldest entries once it overflows,
75
+ // but the terminal events are never compacted, so an evicted cancellation is
76
+ // still refused with its original outcome instead of silently resurrecting.
77
+ const settled = settledActivationRequest(tx, task, input.requestId);
78
+ if (settled !== undefined) {
79
+ throw new Error(`Activation request ${input.requestId} was already ${settled.disposition} for ${task.id}`
80
+ + `${settled.outcome === undefined ? "" : ` (${settled.outcome})`}. `
81
+ + "Use a new requestId.");
82
+ }
83
+ if (existing?.operation.requestId === input.requestId) {
84
+ const digest = taskActivationInputDigest(task.id, {
95
85
  startMode,
96
86
  ...(planningRun === undefined ? {} : { afterPlanningRun: planningRun }),
97
87
  environmentPlan: plan
98
- }, now);
99
- tx.saveTask(setTaskActivationRequest(task, request, now));
100
- tx.saveEvent(task.id, createTaskEvent(tx.nextEventId(task.id), task.id, TASK_ACTIVATION_EVENT.requested, {
101
- requestId: request.operation.requestId,
102
- startMode: request.startMode,
103
- environmentPlan: describeEnvironmentPlan(request.environmentPlan),
104
- actor: request.operation.actorId,
105
- ...(request.afterPlanningRun === undefined
106
- ? {}
107
- : { afterPlanningRun: request.afterPlanningRun })
108
- }, now));
88
+ });
89
+ if (existing.operation.inputDigest !== digest) {
90
+ throw new Error(`Activation requestId ${input.requestId} was already used for different inputs.`);
91
+ }
109
92
  return {
110
- operationRef: taskActivationOperationRef(task.id, request.operation.requestId),
111
- request,
112
- startMode,
113
- created: true,
114
- ...(planningRun === undefined ? {} : { afterPlanningRun: planningRun })
93
+ operationRef: taskActivationOperationRef(task.id, existing.operation.requestId),
94
+ request: existing,
95
+ startMode: existing.startMode,
96
+ created: false,
97
+ ...(existing.afterPlanningRun === undefined
98
+ ? {}
99
+ : { afterPlanningRun: existing.afterPlanningRun })
115
100
  };
116
- });
101
+ }
102
+ if (existing?.disposition === "pending") {
103
+ throw new Error(`Task already has a pending activation request: ${task.id}/${existing.operation.requestId}. `
104
+ + "Cancel it before requesting different inputs.");
105
+ }
106
+ const request = createTaskActivationRequest(task.id, {
107
+ requestId: input.requestId,
108
+ actorId: input.actorId,
109
+ authorityRef: input.authorityRef,
110
+ startMode,
111
+ ...(input.origin === undefined ? {} : { origin: input.origin }),
112
+ ...(planningRun === undefined ? {} : { afterPlanningRun: planningRun }),
113
+ environmentPlan: plan
114
+ }, now);
115
+ tx.saveTask(setTaskActivationRequest(task, request, now));
116
+ tx.saveEvent(task.id, createTaskEvent(tx.nextEventId(task.id), task.id, TASK_ACTIVATION_EVENT.requested, {
117
+ requestId: request.operation.requestId,
118
+ startMode: request.startMode,
119
+ environmentPlan: describeEnvironmentPlan(request.environmentPlan),
120
+ actor: request.operation.actorId,
121
+ ...(request.origin === undefined ? {} : { origin: request.origin }),
122
+ ...(request.afterPlanningRun === undefined
123
+ ? {}
124
+ : { afterPlanningRun: request.afterPlanningRun })
125
+ }, now));
126
+ return {
127
+ operationRef: taskActivationOperationRef(task.id, request.operation.requestId),
128
+ request,
129
+ startMode,
130
+ created: true,
131
+ ...(planningRun === undefined ? {} : { afterPlanningRun: planningRun })
132
+ };
117
133
  }
118
134
  /** Explicit user or Operator cancellation. A cancelled request never adopts. */
119
135
  export function cancelTaskActivation(store, taskId, requestId, reason, now) {
@@ -0,0 +1,236 @@
1
+ import { SYSTEM_LEADER_ROLE } from "../role/systemRoles.js";
2
+ /**
3
+ * Requirement A phase and routing derivation (task-32 §2.1, §2.2).
4
+ *
5
+ * `Task.status` stays the only lifecycle statement. "Entered planning" is a
6
+ * derived fact, never a writable second state: this module reads it from the
7
+ * durable evidence a legitimate planning entry leaves behind, so idleness, a
8
+ * failed or replaced Session, a Controller restart, or a trimmed Context can
9
+ * never reset it to "not planned".
10
+ */
11
+ /**
12
+ * The never-compacted event that records a Draft was routed into planning by an
13
+ * accepted user/operator submission, inside the same transaction that saved the
14
+ * message. It is one of the three independent derivation sources below, and the
15
+ * only one the shared submission service itself writes; the others are produced
16
+ * by the planning Run and its Session. Migration appends the same event for
17
+ * legitimate historical discussions so a post-upgrade develop cannot snatch a
18
+ * mid-discussion Draft into auto-activation.
19
+ */
20
+ export const TASK_PLANNING_ENTERED_EVENT = "task.planning-entered";
21
+ /**
22
+ * Whether this Draft has already entered planning (task-32 §2.2).
23
+ *
24
+ * Derived — never a stored flag — from any of three independent, monotonic
25
+ * sources, each of which only ever comes into existence and never legitimately
26
+ * disappears:
27
+ *
28
+ * (a) a persisted planning-entered event, written in the message-save
29
+ * transaction the first time an accepted submission routed to planning;
30
+ * (b) a Leader planning AgentRun in history (the first-discuss Run, once the
31
+ * Controller has created it);
32
+ * (c) a Leader Session whose frozen launch carried planning authority.
33
+ *
34
+ * A role being configured, a non-empty body, or a non-empty Brief are
35
+ * deliberately NOT evidence: none of them proves the user's submission was ever
36
+ * accepted and routed. Only a Draft can be "in planning"; an active or terminal
37
+ * Task is past the question and answers false.
38
+ */
39
+ export function draftHasEnteredPlanning(store, task) {
40
+ if (task.status !== "draft")
41
+ return false;
42
+ return hasPlanningEnteredEvent(store.listEvents(task.id))
43
+ || hasLeaderPlanningRun(store, task.id)
44
+ || hasLeaderPlanningSession(store, task.id);
45
+ }
46
+ function hasPlanningEnteredEvent(events) {
47
+ return events.some((event) => event.type === TASK_PLANNING_ENTERED_EVENT);
48
+ }
49
+ function hasLeaderPlanningRun(store, taskId) {
50
+ return store.listRuns(taskId).some((run) => run.roleName === SYSTEM_LEADER_ROLE && run.purpose === "planning");
51
+ }
52
+ function hasLeaderPlanningSession(store, taskId) {
53
+ const sessions = store.getTaskRoleSessionSet(taskId, SYSTEM_LEADER_ROLE);
54
+ if (sessions === null)
55
+ return false;
56
+ const candidates = [
57
+ ...Object.values(sessions.sessions ?? {}),
58
+ ...(sessions.history ?? [])
59
+ ];
60
+ return candidates.some((session) => session.effective.executionAuthority === "planning");
61
+ }
62
+ export function draftActivationState(request) {
63
+ if (request === undefined)
64
+ return "none";
65
+ switch (request.disposition) {
66
+ case "pending": return "pending";
67
+ case "failed": return "failed";
68
+ case "adopted": return "adopted";
69
+ case "cancelled": return "none";
70
+ default: return "none";
71
+ }
72
+ }
73
+ /**
74
+ * Decide how a user/operator submission routes, given its normalized intent and
75
+ * the phase/activation facts read inside the save transaction.
76
+ *
77
+ * This is a pure decision over already-read facts, kept separate from the
78
+ * transaction that saves the message and enqueues work so it can be unit-tested
79
+ * exhaustively against the §2.1 table without a Store. The winner of a race is
80
+ * decided by which submission's transaction commits first: the second one reads
81
+ * the phase/activation the first one already wrote and routes accordingly.
82
+ *
83
+ * `executionEnabled` is the Task's own gate, read in the same transaction. It
84
+ * only ever gates a *fresh* develop activation: recording an activation request
85
+ * against a stopped Draft is refused at the activation boundary, so routing that
86
+ * case to `activate` would abort the whole transaction and lose the saved
87
+ * message. Planning and already-open activation obligations are unaffected — the
88
+ * gate is enforced later at dispatch, exactly as it is for a plain message.
89
+ */
90
+ export function decideSubmissionRouting(input) {
91
+ // record never wakes anything, in any state (§2.1 first column).
92
+ if (input.intent === "record")
93
+ return { kind: "record" };
94
+ if (input.status === "active") {
95
+ // Neither discuss nor develop re-activates or downgrades an active Task.
96
+ return { kind: "active-context" };
97
+ }
98
+ // Only Draft remains: terminal statuses are refused before routing (§2.1 last
99
+ // row) by the caller's assertTaskOpen, so this function is never asked about
100
+ // them.
101
+ if (input.activation === "pending") {
102
+ return input.intent === "develop"
103
+ // A compatible pending request already exists; reference it, never a
104
+ // second request (§2.3 "重复 develop 不产生第二个兼容激活请求").
105
+ ? { kind: "await-activation", state: "pending" }
106
+ // discuss becomes post-activation input; it does not start new planning.
107
+ : { kind: "await-activation", state: "pending" };
108
+ }
109
+ if (input.activation === "failed") {
110
+ // develop never auto-retries or downgrades to planning; discuss reports the
111
+ // original failure and the explicit retry/cancel next step. Both save only.
112
+ return { kind: "await-activation", state: "failed" };
113
+ }
114
+ if (input.intent === "develop") {
115
+ // develop on an already-planned Draft never auto-activates — it needs
116
+ // explicit manual activation. On an unplanned Draft with no open activation
117
+ // it records the request and routes to activation, unless execution is
118
+ // stopped: then the message is saved but no activation is recorded.
119
+ if (input.enteredPlanning)
120
+ return { kind: "planned-needs-manual-activation" };
121
+ return input.executionEnabled
122
+ ? { kind: "activate", environmentPlan: input.developEnvironmentPlan }
123
+ : { kind: "activation-blocked-execution-stopped" };
124
+ }
125
+ // discuss (the default): first discussion records the planning entry; later
126
+ // discussion continues the existing planning conversation.
127
+ return input.enteredPlanning
128
+ ? { kind: "continue-planning" }
129
+ : { kind: "enter-planning" };
130
+ }
131
+ /**
132
+ * Normalize a possibly-absent submission intent to its effective value.
133
+ *
134
+ * An absent intent is `discuss` (task-32 §2.5: "默认/旧客户端都按 discuss"), so
135
+ * every old client and every message saved before the field existed keeps its
136
+ * historical Leader-waking behaviour. Body text is never inspected.
137
+ *
138
+ * A legacy `--wake-policy none` carried the "save only, do not wake" meaning that
139
+ * `record` now names, so a caller that supplied only that older signal maps to
140
+ * `record` rather than silently becoming a Leader-waking `discuss`. An explicit
141
+ * intent always wins over the wake-policy shorthand.
142
+ */
143
+ export function normalizeSubmissionIntent(intent, wakePolicy) {
144
+ if (intent !== undefined)
145
+ return intent;
146
+ if (wakePolicy === "none")
147
+ return "record";
148
+ return "discuss";
149
+ }
150
+ /**
151
+ * Whether a retry aims at the same target the key was first bound to (§2.3). An
152
+ * absent prior target (a keyed Message from before receipts existed) never
153
+ * matches, so such a Message is treated as a conflict rather than replayed from
154
+ * a fabricated disposition.
155
+ */
156
+ export function sameSubmissionTarget(prior, next) {
157
+ if (prior === undefined || prior.kind !== next.kind)
158
+ return false;
159
+ if (prior.kind === "task" && next.kind === "task")
160
+ return prior.taskId === next.taskId;
161
+ return true;
162
+ }
163
+ /**
164
+ * Derive the §2.5 feedback for one committed submission from its routing and the
165
+ * facts read in the save transaction.
166
+ *
167
+ * Pure over already-read facts, so it is unit-testable against the whole table
168
+ * without a Store. `enteredPlanning` and `activationState` are the values read
169
+ * before routing executed; the routing itself names what the transaction then
170
+ * did, and phase is derived to reflect the post-commit truth (a just-entered
171
+ * planning Draft reports `draft-planning`).
172
+ */
173
+ export function describeSubmissionFeedback(input) {
174
+ const { routing } = input;
175
+ const phase = input.status === "active"
176
+ ? "active"
177
+ : routing.kind === "enter-planning"
178
+ || routing.kind === "continue-planning"
179
+ || routing.kind === "planned-needs-manual-activation"
180
+ || input.enteredPlanning
181
+ ? "draft-planning"
182
+ : "draft-unplanned";
183
+ const planning = routing.kind === "enter-planning"
184
+ ? "entered"
185
+ : routing.kind === "continue-planning"
186
+ ? "continued"
187
+ : "none";
188
+ const activation = routing.kind === "activate"
189
+ ? "requested"
190
+ : routing.kind === "planned-needs-manual-activation"
191
+ ? "manual-required"
192
+ : routing.kind === "activation-blocked-execution-stopped"
193
+ ? "execution-stopped"
194
+ : routing.kind === "await-activation"
195
+ ? routing.state
196
+ // record and the planning routes report the existing obligation, if
197
+ // any, truthfully without acting on it.
198
+ : input.activationState === "pending"
199
+ ? "pending"
200
+ : input.activationState === "failed"
201
+ ? "failed"
202
+ : "none";
203
+ const delivery = routing.kind === "enter-planning"
204
+ || routing.kind === "continue-planning"
205
+ || routing.kind === "active-context"
206
+ ? "queued"
207
+ : "none";
208
+ const nextStep = submissionNextStep(input);
209
+ return {
210
+ saved: { taskId: input.taskId, messageId: input.messageId },
211
+ phase,
212
+ planning,
213
+ activation,
214
+ delivery,
215
+ ...(nextStep === undefined ? {} : { nextStep })
216
+ };
217
+ }
218
+ function submissionNextStep(input) {
219
+ if (input.routing.kind === "planned-needs-manual-activation") {
220
+ return { kind: "activate-manually", taskId: input.taskId };
221
+ }
222
+ if (input.routing.kind === "activation-blocked-execution-stopped") {
223
+ return { kind: "start-execution", taskId: input.taskId };
224
+ }
225
+ if (input.routing.kind === "await-activation") {
226
+ if (input.routing.state === "failed") {
227
+ return {
228
+ kind: "resolve-failed-activation",
229
+ activationRef: input.activationRef ?? "",
230
+ failure: input.activationFailure ?? "activation failed"
231
+ };
232
+ }
233
+ return { kind: "await-pending-activation", activationRef: input.activationRef ?? "" };
234
+ }
235
+ return undefined;
236
+ }
@@ -128,8 +128,9 @@ function detailActions() {
128
128
  answerInput: answerInput,
129
129
  openTerminal: openTerminal,
130
130
  inspect: inspectRecord,
131
- sendMessage: (taskId, body, requestId) => submitMutation(taskId + "/messages",
132
- "/api/tasks/" + encodeURIComponent(taskId) + "/messages", { body, requestId }),
131
+ sendMessage: (taskId, body, requestId, intent) => submitMutation(taskId + "/messages",
132
+ "/api/tasks/" + encodeURIComponent(taskId) + "/messages",
133
+ { body, requestId, ...(intent === undefined ? {} : { intent }) }),
133
134
  updateTask: (taskId, patch, requestId) => submitMutation(taskId + "/metadata",
134
135
  "/api/tasks/" + encodeURIComponent(taskId) + "/metadata", { patch, requestId }),
135
136
  panels: (taskId) => requestJson("/api/tasks/" + encodeURIComponent(taskId) + "/panels",
@@ -33,27 +33,47 @@ export function renderTaskSurface(container, data, t, locale, actions) {
33
33
  message.required = true;
34
34
  message.maxLength = 8000;
35
35
  chatLabel.append(message);
36
+ // Submission intent is an explicit user choice, never inferred from the body
37
+ // (task-32 §2.5); discuss is the default so the control matches the service.
38
+ const intentLabel = node("label", "", say("Intent", "提交意图"));
39
+ const intentSelect = node("select", "");
40
+ const intentOptions = [
41
+ ["discuss", say("Discuss — route to planning", "讨论 — 进入规划")],
42
+ ["record", say("Record — save only", "记录 — 仅保存")],
43
+ ["develop", say("Develop — request activation", "开发 — 请求激活")]
44
+ ];
45
+ for (const [value, text] of intentOptions) {
46
+ const option = node("option", "", text);
47
+ option.value = value;
48
+ intentSelect.append(option);
49
+ }
50
+ intentSelect.value = "discuss";
51
+ intentLabel.append(intentSelect);
36
52
  chat.addEventListener("input", () => { chat.dataset.unsent = message.value ? "true" : "false"; });
37
53
  const send = node("button", "record-open", say("Send", "发送"));
38
54
  send.type = "submit";
39
55
  send.disabled = !["active", "draft"].includes(task.status);
40
56
  const sent = node("p", "muted", say("Not submitted", "未提交"));
41
57
  sent.setAttribute("role", "status");
42
- chat.append(chatLabel, send, sent);
58
+ // §2.5 facets render here, one per line; cleared and repopulated on every submit.
59
+ const facets = node("div", "section-body");
60
+ facets.dataset.submissionFacets = "";
61
+ chat.append(chatLabel, intentLabel, send, sent, facets);
43
62
  chat.addEventListener("submit", async (event) => {
44
63
  event.preventDefault();
45
64
  if (send.disabled) return;
46
65
  send.disabled = true;
47
66
  chat.dataset.unsent = "true";
48
67
  const requestId = crypto.randomUUID();
68
+ const intent = intentSelect.value;
69
+ clear(facets);
49
70
  sent.textContent = say("Waiting for receipt · ", "等待回执 · ") + requestId;
50
71
  try {
51
- const receipt = await actions.sendMessage(task.id, message.value, requestId);
52
- sent.textContent = (receipt.disposition === "queued"
53
- ? say("Queued for Leader; not proof of execution · ", "已为 Leader 排队,不代表已执行 · ")
54
- : say("Saved to Task context; this Task's Leader is not waking · ",
55
- "已保存到 Task 上下文;该 Task 的 Leader 不会被唤醒 · "))
56
- + receipt.record.id;
72
+ // requestId is the submission key: the same key retried returns the original
73
+ // Message and routing rather than a second submission (task-32 §2.3).
74
+ const receipt = await actions.sendMessage(task.id, message.value, requestId, intent);
75
+ sent.textContent = say("Saved · ", "已保存 · ") + receipt.record.id;
76
+ renderSubmissionFacets(facets, receipt.submission, say);
57
77
  message.value = "";
58
78
  chat.dataset.unsent = "false";
59
79
  send.disabled = false;
@@ -63,6 +83,9 @@ export function renderTaskSurface(container, data, t, locale, actions) {
63
83
  send.disabled = false;
64
84
  return;
65
85
  }
86
+ // A lost response must never auto-replay a write; the same requestId is safe
87
+ // to resend by hand because the server dedups on it (§2.3), but the user
88
+ // reloads and inspects first rather than blindly resending.
66
89
  sent.textContent = say("Unknown submission outcome. Reload and inspect saved messages before sending again · ",
67
90
  "提交结果未知。请重新加载并检查已保存消息,不要盲目重发 · ") + requestId;
68
91
  }
@@ -350,4 +373,70 @@ export function renderTaskSurface(container, data, t, locale, actions) {
350
373
  scaffold.append(panels);
351
374
  container.append(scaffold);
352
375
  }
376
+
377
+ // Render each task-32 §2.5 submission facet on its own line, never collapsed into
378
+ // a single "started". This repo has one current server contract: every submission
379
+ // returns its faceted receipt, so there is no old-server flat-disposition path to
380
+ // fall back to. A missing facet block is a contract violation, reported as a
381
+ // limited diagnostic rather than reinterpreted as a plausible flat result.
382
+ function renderSubmissionFacets(container, submission, say) {
383
+ clear(container);
384
+ if (!submission) {
385
+ container.append(node("p", "muted",
386
+ say("Submission receipt is missing its §2.5 facets.", "提交回执缺少 §2.5 分面字段。")));
387
+ return;
388
+ }
389
+ const phaseText = {
390
+ "active": say("Active — delivery context", "进行中 — 交付上下文"),
391
+ "draft-planning": say("Draft, in planning", "草稿,规划中"),
392
+ "draft-unplanned": say("Draft, not yet planned", "草稿,尚未规划")
393
+ };
394
+ const planningText = {
395
+ "entered": say("Entered planning", "已进入规划"),
396
+ "continued": say("Continued planning", "继续规划"),
397
+ "none": say("No planning change", "规划无变化")
398
+ };
399
+ const activationText = {
400
+ "requested": say("Activation requested", "已请求激活"),
401
+ "pending": say("Activation pending", "激活待处理"),
402
+ "failed": say("Activation failed", "激活失败"),
403
+ "manual-required": say("Manual activation required", "需手动激活"),
404
+ "execution-stopped": say("Execution stopped; not activated", "执行已停止;未激活"),
405
+ "none": say("No activation", "无激活")
406
+ };
407
+ const deliveryText = {
408
+ "queued": say("Leader queued to act now", "已为 Leader 排队处理"),
409
+ "none": say("Leader not woken", "未唤醒 Leader")
410
+ };
411
+ const facts = [
412
+ [say("Phase", "阶段"), phaseText[submission.phase] ?? submission.phase],
413
+ [say("Planning", "规划"), planningText[submission.planning] ?? submission.planning],
414
+ [say("Activation", "激活"), activationText[submission.activation] ?? submission.activation],
415
+ [say("Delivery", "投递"), deliveryText[submission.delivery] ?? submission.delivery]
416
+ ];
417
+ for (const [label, value] of facts) {
418
+ container.append(node("p", "muted", label + ": " + value));
419
+ }
420
+ const step = submission.nextStep;
421
+ if (step) {
422
+ const nextText = step.kind === "activate-manually"
423
+ ? say("Next: activate this Task explicitly (yui task activate " + step.taskId + ").",
424
+ "下一步:显式激活该 Task(yui task activate " + step.taskId + ")。")
425
+ : step.kind === "start-execution"
426
+ ? say("Next: start execution first, then activate (" + step.taskId + ").",
427
+ "下一步:先启动执行,再激活(" + step.taskId + ")。")
428
+ : step.kind === "await-pending-activation"
429
+ ? say("Next: waiting on the pending activation " + step.activationRef + ".",
430
+ "下一步:等待待处理的激活 " + step.activationRef + "。")
431
+ : step.kind === "resolve-failed-activation"
432
+ ? say("Next: the prior activation failed (" + step.failure + "); retry with a new request or cancel " + step.activationRef + ".",
433
+ "下一步:之前的激活失败(" + step.failure + ");请用新请求重试或取消 " + step.activationRef + "。")
434
+ : "";
435
+ if (nextText) {
436
+ const next = node("p", "", nextText);
437
+ next.dataset.nextStep = step.kind;
438
+ container.append(next);
439
+ }
440
+ }
441
+ }
353
442
  `;
@@ -3,6 +3,7 @@ import { createServer } from "node:http";
3
3
  import WebSocket, { WebSocketServer } from "ws";
4
4
  import { usageError } from "../errors/cliError.js";
5
5
  import { WebRequestRejected } from "./webMutation.js";
6
+ import { TASK_SUBMISSION_INTENTS } from "../message/message.js";
6
7
  import { DASHBOARD_HTML, findWebAsset } from "./assets/assetManifest.js";
7
8
  import { buildWebDashboardSnapshot, buildWebTaskDetail } from "./webSnapshot.js";
8
9
  const MAX_JSON_BODY_BYTES = 16 * 1024;
@@ -160,11 +161,13 @@ async function handleHttpRequest(request, response, store, dependencies, token,
160
161
  else if (method === "POST" && action === "messages") {
161
162
  const body = await readMutationBody(request);
162
163
  if (typeof body !== "object" || body === null || Array.isArray(body)
163
- || Object.keys(body).some((key) => !["body", "requestId"].includes(key))
164
+ || Object.keys(body).some((key) => !["body", "requestId", "intent"].includes(key))
164
165
  || !("requestId" in body) || typeof body.requestId !== "string" || !body.requestId.trim()
165
166
  || !("body" in body) || typeof body.body !== "string")
166
- throw new WebRequestRejected("Expected body and requestId only.");
167
- value = { ...dependencies.surface.message(taskId, body.body), requestId: body.requestId };
167
+ throw new WebRequestRejected("Expected body, requestId and optional intent.");
168
+ const intent = webSubmissionIntent(body);
169
+ // requestId is threaded as the submission key (§2.3) and echoed back on the receipt.
170
+ value = { ...dependencies.surface.message(taskId, body.body, intent, body.requestId), requestId: body.requestId };
168
171
  }
169
172
  else if (method === "POST" && action === "metadata") {
170
173
  const body = await readMutationBody(request);
@@ -450,6 +453,18 @@ async function readMutationBody(request) {
450
453
  throw new WebRequestRejected(error instanceof Error ? error.message : "Invalid request body.");
451
454
  }
452
455
  }
456
+ /** Validate an optional submission intent from a Web message body. Absent leaves
457
+ * it undefined so the shared service applies the discuss default (task-32 §2.5).
458
+ * A present-but-invalid value is rejected rather than silently downgraded. */
459
+ function webSubmissionIntent(body) {
460
+ if (!("intent" in body) || body.intent === undefined)
461
+ return undefined;
462
+ if (typeof body.intent === "string"
463
+ && TASK_SUBMISSION_INTENTS.includes(body.intent)) {
464
+ return body.intent;
465
+ }
466
+ throw new WebRequestRejected(`intent must be one of ${TASK_SUBMISSION_INTENTS.join(", ")}.`);
467
+ }
453
468
  async function readJsonBody(request) {
454
469
  const chunks = [];
455
470
  let size = 0;
@@ -22,16 +22,16 @@ export function createWebTaskSurface(store, options = {}, observations = []) {
22
22
  options.runtime?.notifyStateChanged(taskId);
23
23
  };
24
24
  return {
25
- message: (taskId, body) => {
26
- // `queuedForLeader` is the fact the shared transaction actually committed,
27
- // not a re-derivation from Task status. A Draft queues its Leader exactly
28
- // like an active Task (planning is a Leader conversation), so reporting
29
- // "saved" here would have understated a wake that really did happen.
30
- const { message, task, queuedForLeader } = webLocalMutation(store, (tx) => sendTaskMessageCommand(tx, taskId, body, "leader", commandOptions));
25
+ message: (taskId, body, intent, requestId) => {
26
+ // With no explicit intent the Web surface submits `discuss` like every other
27
+ // client (§2.5), through the one shared service; the requestId is threaded as
28
+ // the submission key (§2.3) and the structured feedback is returned verbatim.
29
+ const { message, task, queuedForLeader, feedback } = webLocalMutation(store, (tx) => sendTaskMessageCommand(tx, taskId, body, undefined, commandOptions, undefined, intent, requestId));
31
30
  notify(taskId, queuedForLeader);
32
31
  return { record: message, revision: message.createdAt,
33
32
  disposition: queuedForLeader ? "queued" : "saved",
34
33
  planning: task.status === "draft",
34
+ ...(feedback === undefined ? {} : { submission: feedback }),
35
35
  target: { scope: "task", taskId, roleName: "leader" } };
36
36
  },
37
37
  read: async (taskId) => withContextObservations(readTaskContext(store, taskId, environment), observations),