@zq-silk/yui 0.0.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 (95) hide show
  1. package/ARCHITECTURE.md +141 -0
  2. package/LICENSE +21 -0
  3. package/README.md +211 -0
  4. package/dist/agent/adapterCatalog.js +10 -0
  5. package/dist/agent/agent.js +89 -0
  6. package/dist/agent/agentRegistry.js +10 -0
  7. package/dist/agent/argumentPolicy.js +80 -0
  8. package/dist/brief/taskBrief.js +37 -0
  9. package/dist/cli/commandCatalog.js +647 -0
  10. package/dist/cli/completion.js +111 -0
  11. package/dist/cli/completionWizard.js +143 -0
  12. package/dist/cli/dynamicCompletion.js +48 -0
  13. package/dist/cli/helpRenderer.js +32 -0
  14. package/dist/cli/interactionCandidates.js +139 -0
  15. package/dist/cli/interactionPolicy.js +389 -0
  16. package/dist/cli/interactiveSelection.js +185 -0
  17. package/dist/cli/invocationRouter.js +51 -0
  18. package/dist/cli/roleOptionCatalog.js +67 -0
  19. package/dist/cli/roleWizard.js +546 -0
  20. package/dist/cli/selectionPorts.js +1 -0
  21. package/dist/cli/updateCommand.js +22 -0
  22. package/dist/cli.js +402 -0
  23. package/dist/commands/agentCommands.js +196 -0
  24. package/dist/commands/globalRoleCommands.js +367 -0
  25. package/dist/commands/jobCommands.js +100 -0
  26. package/dist/commands/operatorCommands.js +38 -0
  27. package/dist/commands/repositoryCommands.js +86 -0
  28. package/dist/commands/roleConfiguration.js +201 -0
  29. package/dist/commands/taskCommands.js +1344 -0
  30. package/dist/commands/taskContextCommand.js +215 -0
  31. package/dist/commands/taskInputCommands.js +423 -0
  32. package/dist/commands/taskRoleRuntimeStatus.js +152 -0
  33. package/dist/completion/completionInstaller.js +168 -0
  34. package/dist/completion/completionPort.js +1 -0
  35. package/dist/completion/completionState.js +137 -0
  36. package/dist/completion/completionWizard.js +125 -0
  37. package/dist/completion/fileCompletionManager.js +51 -0
  38. package/dist/config/yuiConfig.js +17 -0
  39. package/dist/context/dispatchContext.js +74 -0
  40. package/dist/controller/clientRuntime.js +215 -0
  41. package/dist/controller/controller.js +158 -0
  42. package/dist/controller/controllerMain.js +37 -0
  43. package/dist/controller/fileSchedulerStoreAdapter.js +322 -0
  44. package/dist/controller/runtime.js +31 -0
  45. package/dist/controller/sessionNotify.js +136 -0
  46. package/dist/core/controllerClient.js +127 -0
  47. package/dist/core/controllerServer.js +269 -0
  48. package/dist/core/protocol.js +169 -0
  49. package/dist/decision/decision.js +42 -0
  50. package/dist/doctor/doctor.js +229 -0
  51. package/dist/errors/cliError.js +38 -0
  52. package/dist/event/taskEvent.js +44 -0
  53. package/dist/executor/agentAdapter.js +338 -0
  54. package/dist/executor/agentExecutor.js +144 -0
  55. package/dist/executor/executorRegistry.js +101 -0
  56. package/dist/executor/fileRoleLaunchPlanner.js +156 -0
  57. package/dist/executor/launchPlan.js +16 -0
  58. package/dist/input/inputRequest.js +326 -0
  59. package/dist/message/message.js +69 -0
  60. package/dist/milestone/milestone.js +27 -0
  61. package/dist/operator/operatorContext.js +66 -0
  62. package/dist/output/rolePresentation.js +82 -0
  63. package/dist/output/table.js +77 -0
  64. package/dist/output/terminal.js +198 -0
  65. package/dist/repository/gitWorkspace.js +210 -0
  66. package/dist/repository/repository.js +55 -0
  67. package/dist/repository/taskWorkspacePreparer.js +256 -0
  68. package/dist/role/role.js +246 -0
  69. package/dist/role/systemRoles.js +20 -0
  70. package/dist/run/agentRun.js +102 -0
  71. package/dist/scheduler/activeRoleRunDelivery.js +94 -0
  72. package/dist/scheduler/archivedTaskRuntime.js +12 -0
  73. package/dist/scheduler/leaderFailure.js +18 -0
  74. package/dist/scheduler/leaderWakeupProcessor.js +143 -0
  75. package/dist/scheduler/operatorInputNotificationProcessor.js +85 -0
  76. package/dist/scheduler/operatorNotification.js +17 -0
  77. package/dist/scheduler/pendingWakeup.js +33 -0
  78. package/dist/scheduler/ports.js +1 -0
  79. package/dist/scheduler/roleRunLiveness.js +41 -0
  80. package/dist/scheduler/wakeupQueue.js +13 -0
  81. package/dist/setup/setupCommand.js +317 -0
  82. package/dist/storage/durableFile.js +38 -0
  83. package/dist/storage/storageSchema.js +259 -0
  84. package/dist/storage/taskStore.js +1032 -0
  85. package/dist/task/task.js +216 -0
  86. package/dist/tmux/commandExecutor.js +69 -0
  87. package/dist/tmux/terminalHandoff.js +17 -0
  88. package/dist/tmux/tmuxManager.js +408 -0
  89. package/dist/workItem/workItem.js +45 -0
  90. package/dist/worktree/roleWorkspace.js +62 -0
  91. package/i18n/README.zh-CN.md +205 -0
  92. package/package.json +47 -0
  93. package/skills/yui-leader/SKILL.md +72 -0
  94. package/skills/yui-operator/SKILL.md +57 -0
  95. package/skills/yui-worker/SKILL.md +31 -0
@@ -0,0 +1,408 @@
1
+ import { createHash } from "node:crypto";
2
+ import { resolve } from "node:path";
3
+ import { runtimeError } from "../errors/cliError.js";
4
+ import { handoffTerminal } from "./terminalHandoff.js";
5
+ import { CommandExecutionError } from "./commandExecutor.js";
6
+ const DEFAULT_READINESS_TIMEOUT_MS = 15_000;
7
+ const DEFAULT_READINESS_POLL_MS = 50;
8
+ export class TmuxReadinessTimeoutError extends Error {
9
+ taskId;
10
+ roleName;
11
+ timeoutMs;
12
+ constructor(taskId, roleName, timeoutMs) {
13
+ super(`Role ${roleName} did not become ready within ${timeoutMs} ms.`);
14
+ this.taskId = taskId;
15
+ this.roleName = roleName;
16
+ this.timeoutMs = timeoutMs;
17
+ this.name = "TmuxReadinessTimeoutError";
18
+ }
19
+ }
20
+ export class TmuxReadinessProbeRequiredError extends Error {
21
+ constructor() {
22
+ super("Automated tmux delivery requires an Agent-specific readiness probe.");
23
+ this.name = "TmuxReadinessProbeRequiredError";
24
+ }
25
+ }
26
+ /**
27
+ * Owns tmux lifecycle and input delivery. Interactive attach first releases
28
+ * every Yui reader from the terminal. Automated delivery is accepted only
29
+ * after a bounded readiness probe and is recorded by a pane-local receipt.
30
+ */
31
+ export class TmuxManager {
32
+ tmuxBin;
33
+ executor;
34
+ #yuiHome;
35
+ #terminalInput;
36
+ #closeInteractiveInput;
37
+ #readinessTimeoutMs;
38
+ #readinessPollMs;
39
+ #initialColumns;
40
+ #initialRows;
41
+ #now;
42
+ #sleep;
43
+ constructor(tmuxBin, executor, yuiHomeOrOptions = {}) {
44
+ this.tmuxBin = tmuxBin;
45
+ this.executor = executor;
46
+ const options = typeof yuiHomeOrOptions === "string"
47
+ ? { yuiHome: yuiHomeOrOptions }
48
+ : yuiHomeOrOptions;
49
+ this.#yuiHome = options.yuiHome ?? process.env.YUI_HOME ?? process.cwd();
50
+ this.#terminalInput = options.terminalInput ?? process.stdin;
51
+ this.#closeInteractiveInput = options.closeInteractiveInput ?? (() => { });
52
+ this.#readinessTimeoutMs = positiveInteger(options.readinessTimeoutMs, DEFAULT_READINESS_TIMEOUT_MS, "readiness timeout");
53
+ this.#readinessPollMs = positiveInteger(options.readinessPollMs, DEFAULT_READINESS_POLL_MS, "readiness poll interval");
54
+ this.#initialColumns = positiveInteger(options.initialColumns, 120, "initial tmux columns");
55
+ this.#initialRows = positiveInteger(options.initialRows, 40, "initial tmux rows");
56
+ this.#now = options.now ?? Date.now;
57
+ this.#sleep = options.sleep ?? blockingSleep;
58
+ }
59
+ enterRole(taskId, role, launch) {
60
+ this.ensureRoleWindow(taskId, role, launch);
61
+ this.attachRole(taskId, role.name);
62
+ }
63
+ ensureRoleWindow(taskId, role, launch) {
64
+ if (this.windowNames(taskId).includes(role.name))
65
+ return false;
66
+ if (launch === undefined) {
67
+ throw runtimeError(`Agent launch plan is required to create Role window: ${role.name}.`);
68
+ }
69
+ if (!this.hasSession(taskId)) {
70
+ this.executor.run(this.tmuxBin, [
71
+ "new-session", "-d",
72
+ "-x", String(this.#initialColumns),
73
+ "-y", String(this.#initialRows),
74
+ "-s", this.sessionName(taskId),
75
+ "-n", role.name,
76
+ "-c", safeValue(role.workspace, "Role workspace"),
77
+ "--",
78
+ ...launchCommand(launch)
79
+ ]);
80
+ }
81
+ else {
82
+ this.executor.run(this.tmuxBin, [
83
+ "new-window",
84
+ "-t", this.sessionName(taskId),
85
+ "-n", role.name,
86
+ "-c", safeValue(role.workspace, "Role workspace"),
87
+ "--",
88
+ ...launchCommand(launch)
89
+ ]);
90
+ }
91
+ return true;
92
+ }
93
+ attachRole(taskId, roleName) {
94
+ handoffTerminal(this.#terminalInput, this.#closeInteractiveInput);
95
+ this.executor.run(this.tmuxBin, [
96
+ "attach-session", "-t", this.target(taskId, roleName)
97
+ ], { inheritStdio: true });
98
+ }
99
+ captureRole(taskId, roleName, lines = 80) {
100
+ return this.executor.run(this.tmuxBin, [
101
+ "capture-pane", "-p", "-t", this.target(taskId, roleName), "-S", `-${lines}`
102
+ ]);
103
+ }
104
+ dispatchRole(taskId, role, launch, input, options) {
105
+ if (options.replaceExisting === true) {
106
+ try {
107
+ this.killRole(taskId, role.name);
108
+ }
109
+ catch (error) {
110
+ if (!isExplicitlyAbsentTmuxSession(error))
111
+ throw error;
112
+ }
113
+ }
114
+ this.ensureRoleWindow(taskId, role, launch);
115
+ return this.sendRoleInputOnce(taskId, role.name, options.receiptId, input, options.readinessProbe);
116
+ }
117
+ waitUntilReady(taskId, roleName, readinessProbe) {
118
+ if (readinessProbe === undefined) {
119
+ throw new TmuxReadinessProbeRequiredError();
120
+ }
121
+ const start = this.#now();
122
+ for (;;) {
123
+ const pane = this.inspectPane(taskId, roleName);
124
+ if (readinessProbe(pane))
125
+ return pane;
126
+ if (this.#now() - start >= this.#readinessTimeoutMs) {
127
+ throw new TmuxReadinessTimeoutError(taskId, roleName, this.#readinessTimeoutMs);
128
+ }
129
+ this.#sleep(this.#readinessPollMs);
130
+ }
131
+ }
132
+ inspectPane(taskId, roleName) {
133
+ const target = this.target(taskId, roleName);
134
+ const output = this.executor.run(this.tmuxBin, [
135
+ "display-message", "-p", "-t", target,
136
+ "#{pane_dead}|#{pane_pid}|#{pane_current_command}"
137
+ ]).trim();
138
+ const separator = output.indexOf("|");
139
+ const secondSeparator = separator < 0 ? -1 : output.indexOf("|", separator + 1);
140
+ if (separator < 0 || secondSeparator < 0) {
141
+ throw runtimeError(`Tmux returned an invalid pane state for ${roleName}.`);
142
+ }
143
+ const dead = output.slice(0, separator);
144
+ const pidText = output.slice(separator + 1, secondSeparator);
145
+ const currentCommand = output.slice(secondSeparator + 1);
146
+ const pid = Number(pidText);
147
+ const content = this.executor.run(this.tmuxBin, [
148
+ "capture-pane", "-p", "-t", target, "-S", "-40"
149
+ ]);
150
+ return {
151
+ taskId,
152
+ roleName,
153
+ target,
154
+ dead: dead === "1",
155
+ ...(Number.isSafeInteger(pid) && pid > 0 ? { pid } : {}),
156
+ currentCommand,
157
+ content
158
+ };
159
+ }
160
+ /** Reads every Role pane in one tmux call without capturing terminal output. */
161
+ inspectTaskRolePanes(taskId) {
162
+ const separator = "\u001f";
163
+ let output;
164
+ try {
165
+ output = this.executor.run(this.tmuxBin, [
166
+ "list-panes", "-s", "-t", this.sessionName(taskId), "-F",
167
+ `#{window_name}${separator}#{pane_dead}${separator}#{pane_pid}${separator}#{pane_current_command}`
168
+ ]);
169
+ }
170
+ catch (error) {
171
+ if (isExplicitlyAbsentTmuxSession(error))
172
+ return [];
173
+ throw error;
174
+ }
175
+ return output.split("\n").flatMap((line) => {
176
+ if (line.length === 0)
177
+ return [];
178
+ const [roleName, deadText, pidText, currentCommand, ...extra] = line.split(separator);
179
+ if (roleName === undefined
180
+ || deadText === undefined
181
+ || pidText === undefined
182
+ || currentCommand === undefined
183
+ || extra.length > 0
184
+ || (deadText !== "0" && deadText !== "1")) {
185
+ throw runtimeError(`Tmux returned an invalid Task Role pane state for ${taskId}.`);
186
+ }
187
+ const pid = Number(pidText);
188
+ return [{
189
+ taskId,
190
+ roleName,
191
+ target: this.target(taskId, roleName),
192
+ dead: deadText === "1",
193
+ ...(Number.isSafeInteger(pid) && pid > 0 ? { pid } : {}),
194
+ currentCommand
195
+ }];
196
+ });
197
+ }
198
+ /**
199
+ * The receipt test, receipt write, literal input and Enter all run through a
200
+ * single tmux server command queue. Retrying the same receipt cannot type the
201
+ * input twice into the same pane.
202
+ */
203
+ sendRoleInputOnce(taskId, roleName, receiptId, input, readinessProbe) {
204
+ if (readinessProbe === undefined) {
205
+ throw new TmuxReadinessProbeRequiredError();
206
+ }
207
+ safeValue(receiptId, "tmux delivery receipt id");
208
+ safeValue(input, "tmux input");
209
+ // A delivered run is normally still busy on the next Controller scan. The
210
+ // pane receipt is authoritative for that retry, so do not wait for the
211
+ // composer merely to discover that the input was already sent.
212
+ if (this.hasDeliveryReceipt(taskId, roleName, receiptId)) {
213
+ return "already-sent";
214
+ }
215
+ this.waitUntilReady(taskId, roleName, readinessProbe);
216
+ return this.sendReadyRoleInputOnce(taskId, roleName, receiptId, input);
217
+ }
218
+ sendReadyRoleInputOnce(taskId, roleName, receiptId, input) {
219
+ safeValue(receiptId, "tmux delivery receipt id");
220
+ safeValue(input, "tmux input");
221
+ if (this.hasDeliveryReceipt(taskId, roleName, receiptId)) {
222
+ return "already-sent";
223
+ }
224
+ const target = this.target(taskId, roleName);
225
+ const digest = createHash("sha256").update(receiptId).digest("hex");
226
+ const option = `@yui_delivery_${digest}`;
227
+ const sentMarker = `__YUI_DELIVERY_SENT_${digest}__`;
228
+ const presentMarker = `__YUI_DELIVERY_PRESENT_${digest}__`;
229
+ const apply = [
230
+ `set-option -w -t ${tmuxWord(target)} ${option} 1`,
231
+ `send-keys -l -t ${tmuxWord(target)} -- ${tmuxWord(input)}`,
232
+ `send-keys -t ${tmuxWord(target)} Enter`,
233
+ `display-message -p ${sentMarker}`
234
+ ].join(" ; ");
235
+ const output = this.executor.run(this.tmuxBin, [
236
+ "if-shell", "-t", target, "-F", `#{==:#{${option}},1}`,
237
+ `display-message -p ${presentMarker}`,
238
+ apply
239
+ ]).trim();
240
+ if (output === sentMarker)
241
+ return "sent";
242
+ if (output === presentMarker)
243
+ return "already-sent";
244
+ throw runtimeError(`Tmux did not confirm delivery receipt ${receiptId}.`);
245
+ }
246
+ sendRoleInputOnceIfReady(taskId, roleName, receiptId, input, readinessProbe) {
247
+ if (readinessProbe === undefined) {
248
+ throw new TmuxReadinessProbeRequiredError();
249
+ }
250
+ if (this.hasDeliveryReceipt(taskId, roleName, receiptId)) {
251
+ return "already-sent";
252
+ }
253
+ if (!readinessProbe(this.inspectPane(taskId, roleName)))
254
+ return "not-ready";
255
+ return this.sendReadyRoleInputOnce(taskId, roleName, receiptId, input);
256
+ }
257
+ hasDeliveryReceipt(taskId, roleName, receiptId) {
258
+ safeValue(receiptId, "tmux delivery receipt id");
259
+ const digest = createHash("sha256").update(receiptId).digest("hex");
260
+ const option = `@yui_delivery_${digest}`;
261
+ return this.executor.run(this.tmuxBin, [
262
+ "show-options", "-wqv", "-t", this.target(taskId, roleName), option
263
+ ]).trim() === "1";
264
+ }
265
+ detachRole(taskId) {
266
+ this.executor.run(this.tmuxBin, ["detach-client", "-s", this.sessionName(taskId)]);
267
+ }
268
+ restartRole(taskId, role, launch) {
269
+ try {
270
+ this.killRole(taskId, role.name);
271
+ }
272
+ catch (error) {
273
+ if (!isExplicitlyAbsentTmuxSession(error))
274
+ throw error;
275
+ }
276
+ this.enterRole(taskId, role, launch);
277
+ }
278
+ detectRoleStatus(taskId, roleName, fallback = "exited") {
279
+ try {
280
+ return this.probeRoleStatus(taskId, roleName);
281
+ }
282
+ catch {
283
+ return fallback;
284
+ }
285
+ }
286
+ probeRoleStatus(taskId, roleName) {
287
+ if (!this.hasSession(taskId))
288
+ return "exited";
289
+ return this.windowNames(taskId).includes(roleName) ? "running" : "exited";
290
+ }
291
+ stopTask(taskId) {
292
+ if (!this.hasSession(taskId))
293
+ return false;
294
+ this.executor.run(this.tmuxBin, ["kill-session", "-t", this.sessionName(taskId)]);
295
+ return true;
296
+ }
297
+ stopRole(taskId, roleName) {
298
+ this.executor.run(this.tmuxBin, ["send-keys", "-t", this.target(taskId, roleName), "C-c"]);
299
+ }
300
+ killRole(taskId, roleName) {
301
+ this.executor.run(this.tmuxBin, ["kill-window", "-t", this.target(taskId, roleName)]);
302
+ }
303
+ renameRole(taskId, oldRoleName, newRoleName) {
304
+ this.executor.run(this.tmuxBin, [
305
+ "rename-window", "-t", this.target(taskId, oldRoleName), safeValue(newRoleName, "Role name")
306
+ ]);
307
+ }
308
+ hasSession(taskId) {
309
+ try {
310
+ this.executor.run(this.tmuxBin, ["has-session", "-t", this.sessionName(taskId)]);
311
+ return true;
312
+ }
313
+ catch (error) {
314
+ if (isExplicitlyAbsentTmuxSession(error))
315
+ return false;
316
+ // Older/fake executors do not expose stderr. A failed has-session is the
317
+ // tmux absence contract; other operations remain strict.
318
+ if (!(error instanceof CommandExecutionError))
319
+ return false;
320
+ throw error;
321
+ }
322
+ }
323
+ windowNames(taskId) {
324
+ if (!this.hasSession(taskId))
325
+ return [];
326
+ return this.executor.run(this.tmuxBin, [
327
+ "list-windows", "-t", this.sessionName(taskId), "-F", "#{window_name}"
328
+ ]).split("\n").map((name) => name.trim()).filter(Boolean);
329
+ }
330
+ sessionName(taskId) {
331
+ return yuiTmuxSessionName(this.#yuiHome, taskId);
332
+ }
333
+ target(taskId, roleName) {
334
+ return yuiTmuxTarget(this.#yuiHome, taskId, roleName);
335
+ }
336
+ }
337
+ /** A small FIFO boundary for Controller scheduler passes. */
338
+ export class TmuxDeliveryPump {
339
+ tmux;
340
+ #queue = [];
341
+ constructor(tmux) {
342
+ this.tmux = tmux;
343
+ }
344
+ get pending() {
345
+ return this.#queue.length;
346
+ }
347
+ enqueue(delivery) {
348
+ this.#queue.push(delivery);
349
+ }
350
+ pump(limit = Number.POSITIVE_INFINITY) {
351
+ if (!(limit === Number.POSITIVE_INFINITY || Number.isSafeInteger(limit) && limit > 0)) {
352
+ throw new TypeError("tmux delivery pump limit must be a positive integer");
353
+ }
354
+ const completed = [];
355
+ while (this.#queue.length > 0 && completed.length < limit) {
356
+ const delivery = this.#queue[0];
357
+ const outcome = this.tmux.sendRoleInputOnce(delivery.taskId, delivery.roleName, delivery.receiptId, delivery.input, delivery.readinessProbe);
358
+ this.#queue.shift();
359
+ completed.push({ receiptId: delivery.receiptId, outcome });
360
+ }
361
+ return completed;
362
+ }
363
+ }
364
+ export function yuiTmuxSessionName(yuiHome, taskId) {
365
+ const safeTaskId = safeValue(taskId, "Task id");
366
+ const namespace = createHash("sha256").update(resolve(yuiHome)).digest("hex").slice(0, 12);
367
+ return `yui-${namespace}-${safeTaskId}`;
368
+ }
369
+ export function yuiTmuxTarget(yuiHome, taskId, roleName) {
370
+ return `${yuiTmuxSessionName(yuiHome, taskId)}:${safeValue(roleName, "Role name")}`;
371
+ }
372
+ function launchCommand(launch) {
373
+ const command = safeValue(launch.command, "Agent command");
374
+ const args = launch.args.map((arg) => safeValue(arg, "Agent argument"));
375
+ const environment = Object.entries(launch.env).map(([key, value]) => {
376
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
377
+ throw new TypeError(`Invalid Agent environment variable: ${key}`);
378
+ }
379
+ return `${key}=${safeValue(value, `Agent environment ${key}`)}`;
380
+ });
381
+ return environment.length === 0
382
+ ? [command, ...args]
383
+ : ["env", ...environment, command, ...args];
384
+ }
385
+ function isExplicitlyAbsentTmuxSession(error) {
386
+ if (!(error instanceof CommandExecutionError))
387
+ return false;
388
+ return /can't find (?:session|window|pane)|no server running/i.test(error.stderr);
389
+ }
390
+ function tmuxWord(value) {
391
+ return JSON.stringify(value);
392
+ }
393
+ function safeValue(value, label) {
394
+ if (value.length === 0 || value.includes("\0")) {
395
+ throw new TypeError(`${label} must be non-empty and contain no NUL bytes`);
396
+ }
397
+ return value;
398
+ }
399
+ function positiveInteger(value, fallback, label) {
400
+ const resolved = value ?? fallback;
401
+ if (!Number.isSafeInteger(resolved) || resolved <= 0) {
402
+ throw new TypeError(`${label} must be a positive integer`);
403
+ }
404
+ return resolved;
405
+ }
406
+ function blockingSleep(milliseconds) {
407
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
408
+ }
@@ -0,0 +1,45 @@
1
+ export function createWorkItem(id, taskId, input, now) {
2
+ const timestamp = now.toISOString();
3
+ return {
4
+ schemaVersion: 1,
5
+ id: requireSafeIdentity(id, "Work item id"),
6
+ taskId: requireSafeIdentity(taskId, "Task id"),
7
+ title: requireText(input.title, "Work item title"),
8
+ assignee: requireSafeIdentity(input.assignee, "Work item assignee"),
9
+ status: "pending",
10
+ createdAt: timestamp,
11
+ updatedAt: timestamp
12
+ };
13
+ }
14
+ export function updateWorkItemStatus(workItem, status, outcome, now) {
15
+ const terminal = ["completed", "failed", "cancelled", "superseded"].includes(status);
16
+ const normalizedOutcome = outcome === undefined ? undefined : requireText(outcome, "Work item outcome");
17
+ if (terminal && normalizedOutcome === undefined) {
18
+ throw new Error(`Work item outcome is required for ${status}.`);
19
+ }
20
+ const timestamp = now.toISOString();
21
+ const { outcome: _outcome, endedAt: _endedAt, ...base } = workItem;
22
+ return {
23
+ ...base,
24
+ status,
25
+ ...(normalizedOutcome === undefined ? {} : { outcome: normalizedOutcome }),
26
+ updatedAt: timestamp,
27
+ ...(terminal ? { endedAt: timestamp } : {})
28
+ };
29
+ }
30
+ function requireSafeIdentity(value, label) {
31
+ const normalized = requireText(value, label);
32
+ if (["__proto__", "prototype", "constructor", ".", ".."].includes(normalized)
33
+ || /[\/\\\0]/.test(normalized)) {
34
+ throw new Error(`${label} is invalid.`);
35
+ }
36
+ return normalized;
37
+ }
38
+ function requireText(value, label) {
39
+ if (typeof value !== "string" || value.includes("\0"))
40
+ throw new Error(`${label} is invalid.`);
41
+ const normalized = value.trim();
42
+ if (normalized.length === 0)
43
+ throw new Error(`${label} is required.`);
44
+ return normalized;
45
+ }
@@ -0,0 +1,62 @@
1
+ import { resolve } from "node:path";
2
+ export function createRoleWorkspace(input, now) {
3
+ const timestamp = now.toISOString();
4
+ return validateRoleWorkspace({
5
+ schemaVersion: 1,
6
+ taskId: requireIdentity(input.taskId, "Task id"),
7
+ roleName: requireIdentity(input.roleName, "Role name"),
8
+ repositoryId: requireIdentity(input.repositoryId, "Repository id"),
9
+ path: resolve(requireText(input.path, "Role workspace path")),
10
+ branch: requireText(input.branch, "Role workspace branch"),
11
+ baseRef: requireText(input.baseRef, "Role workspace base ref"),
12
+ baseCommit: requireCommit(input.baseCommit),
13
+ createdAt: timestamp,
14
+ updatedAt: timestamp
15
+ });
16
+ }
17
+ export function validateRoleWorkspace(workspace) {
18
+ if (workspace.schemaVersion !== 1) {
19
+ throw new Error("RoleWorkspace must use schemaVersion 1.");
20
+ }
21
+ requireIdentity(workspace.taskId, "Task id");
22
+ requireIdentity(workspace.roleName, "Role name");
23
+ requireIdentity(workspace.repositoryId, "Repository id");
24
+ if (resolve(requireText(workspace.path, "Role workspace path")) !== workspace.path) {
25
+ throw new Error("Role workspace path must be absolute and normalized.");
26
+ }
27
+ requireText(workspace.branch, "Role workspace branch");
28
+ requireText(workspace.baseRef, "Role workspace base ref");
29
+ requireCommit(workspace.baseCommit);
30
+ requireTimestamp(workspace.createdAt, "RoleWorkspace createdAt");
31
+ requireTimestamp(workspace.updatedAt, "RoleWorkspace updatedAt");
32
+ return workspace;
33
+ }
34
+ function requireIdentity(value, label) {
35
+ const identity = requireText(value, label);
36
+ if ([".", "..", "__proto__", "prototype", "constructor"].includes(identity)
37
+ || /[\/\\\0]/.test(identity)) {
38
+ throw new Error(`${label} is invalid.`);
39
+ }
40
+ return identity;
41
+ }
42
+ function requireCommit(value) {
43
+ const commit = requireText(value, "Role workspace base commit").toLowerCase();
44
+ if (!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(commit)) {
45
+ throw new Error("Role workspace base commit is invalid.");
46
+ }
47
+ return commit;
48
+ }
49
+ function requireText(value, label) {
50
+ if (typeof value !== "string" || value.includes("\0")) {
51
+ throw new Error(`${label} is invalid.`);
52
+ }
53
+ const normalized = value.trim();
54
+ if (normalized.length === 0)
55
+ throw new Error(`${label} is required.`);
56
+ return normalized;
57
+ }
58
+ function requireTimestamp(value, label) {
59
+ if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) {
60
+ throw new Error(`${label} is invalid.`);
61
+ }
62
+ }