ac-teams 0.0.14 → 0.0.16

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.
package/src/store.ts CHANGED
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { stateRoot } from "./config.ts";
5
5
  import { validatePolicy } from "./egress.ts";
6
6
  import { stripVTControlCharacters } from "node:util";
7
- import { isDescendant, isLive, isMain, memberName, memberNamePattern, memberNameMaxLength, taskLabelMaxLength, taskLabelPattern } from "./types.ts";
7
+ import { isDescendant, isMain, memberName, memberNamePattern, memberNameMaxLength, taskLabelMaxLength, taskLabelPattern } from "./types.ts";
8
8
  import type { TeamState, TaskArgs, MessageArgs, Task, TaskView, Message, Worktree } from "./types.ts";
9
9
 
10
10
  // UI envelopes add task context after the public 16,000-character input limit.
@@ -58,7 +58,15 @@ function taskLabel(title: string): string {
58
58
  }
59
59
 
60
60
  function migrate(state: unknown): boolean {
61
- if (!object(state) || ![1, 2, 3].includes(state.version as number)) return false;
61
+ if (!object(state) || ![1, 2, 3, 4].includes(state.version as number)) return false;
62
+ if (state.version !== 4) migrateRoles(state);
63
+ // Schema 5: a post's former text becomes its summary, unchanged.
64
+ requireValue(Array.isArray(state.messages), "Invalid legacy messages");
65
+ for (const message of state.messages) if (object(message) && Object.hasOwn(message, "text")) { message.summary = message.text; delete message.text; }
66
+ state.version = 5;
67
+ return true;
68
+ }
69
+ function migrateRoles(state: Record<string, unknown>): void {
62
70
  if (state.version !== 3) migrateTasks(state);
63
71
  // Schema 4: roles replace the special root ID; the creator's record becomes a main session.
64
72
  const root = state.root ?? "root";
@@ -67,7 +75,6 @@ function migrate(state: unknown): boolean {
67
75
  delete state.root;
68
76
  state.key = randomUUID();
69
77
  state.version = 4;
70
- return true;
71
78
  }
72
79
  function migrateTasks(state: Record<string, unknown>): void {
73
80
  requireValue(object(state.tasks), "Invalid legacy state tasks");
@@ -89,7 +96,7 @@ function migrateTasks(state: Record<string, unknown>): void {
89
96
  function validate(state: unknown): asserts state is TeamState {
90
97
  requireValue(object(state), "Invalid state snapshot");
91
98
  id(state.id);
92
- requireValue(state.version === 4 && text(state.key) && integer(state.limit, 1) && integer(state.depth) && object(state.members) && object(state.tasks) && Array.isArray(state.messages), "Invalid state snapshot schema");
99
+ requireValue(state.version === 5 && text(state.key) && integer(state.limit, 1) && integer(state.depth) && object(state.members) && object(state.tasks) && Array.isArray(state.messages), "Invalid state snapshot schema");
93
100
  const members = state.members;
94
101
  const names = new Set<string>(["main", "root", ...Object.keys(members)]);
95
102
  for (const [key, member] of Object.entries(members)) {
@@ -108,6 +115,7 @@ function validate(state: unknown): asserts state is TeamState {
108
115
  requireValue(member.role !== "main" || member.parent === undefined, "A main session has no parent");
109
116
  requireValue(member.handover === undefined || ["busy", "idle", "input"].includes(String(member.handover)), "Invalid member handover state");
110
117
  requireValue(member.promotion === undefined || (object(member.promotion) && ["stopped", "moved", "role", "entry"].includes(String(member.promotion.step)) && text(member.promotion.sessionFile)), "Invalid member promotion");
118
+ requireValue(member.waiting === undefined || (object(member.waiting) && Array.isArray(member.waiting.tasks) && member.waiting.tasks.every(task => typeof task === "string" && Object.hasOwn(state.tasks as object, task))), "Invalid member wait");
111
119
  requireValue(member.terminal === undefined || typeof member.terminal === "boolean", "Invalid member terminal flag");
112
120
  requireValue(member.needsInput === undefined || typeof member.needsInput === "boolean", "Invalid member input flag");
113
121
  requireValue(member.pid === undefined || integer(member.pid, 1), "Invalid member PID");
@@ -132,7 +140,8 @@ function validate(state: unknown): asserts state is TeamState {
132
140
  requireValue(task.completedAt === undefined || integer(task.completedAt), "Invalid task completion time");
133
141
  requireValue(task.dependencies.every(dep => typeof dep === "string" && dep !== key && Object.hasOwn(tasks, dep)) && new Set(task.dependencies).size === task.dependencies.length, "Invalid task dependencies");
134
142
  requireValue(task.owner === undefined || (typeof task.owner === "string" && Object.hasOwn(members, task.owner)), "Invalid task owner");
135
- requireValue(task.creator === undefined || (typeof task.creator === "string" && Object.hasOwn(members, task.creator)), "Invalid task creator");
143
+ requireValue([task.creator, task.delegator].every(member => member === undefined || (typeof member === "string" && Object.hasOwn(members, member))), "Invalid task creator or delegator");
144
+ requireValue(task.outcome === undefined || (task.status === "done" && state.messages.some(message => object(message) && message.id === task.outcome && message.task === key)), "Invalid task outcome");
136
145
  requireValue(task.status !== "in_progress" || task.owner !== undefined, "In-progress task requires an owner");
137
146
  requireValue(task.status === "todo" || task.dependencies.every(dep => (tasks[dep] as Record<string, unknown>).status === "done"), "Active or done task has unfinished dependencies");
138
147
  edges[key] = task.dependencies;
@@ -150,11 +159,44 @@ function validate(state: unknown): asserts state is TeamState {
150
159
  let last = 0;
151
160
  const messageIds = new Set<string>();
152
161
  for (const message of state.messages) {
153
- requireValue(object(message) && integer(message.seq, last + 1) && text(message.id) && !messageIds.has(message.id) && typeof message.from === "string" && Object.hasOwn(members, message.from) && Array.isArray(message.recipients) && message.recipients.every(recipient => typeof recipient === "string" && Object.hasOwn(members, recipient)) && new Set(message.recipients).size === message.recipients.length && text(message.text, maxMessageText) && typeof message.time === "string" && Number.isFinite(Date.parse(message.time)), "Invalid state message");
162
+ requireValue(object(message) && integer(message.seq, last + 1) && text(message.id) && !messageIds.has(message.id) && typeof message.from === "string" && Object.hasOwn(members, message.from) && Array.isArray(message.recipients) && message.recipients.every(recipient => typeof recipient === "string" && Object.hasOwn(members, recipient)) && new Set(message.recipients).size === message.recipients.length && text(message.summary, maxMessageText) && typeof message.time === "string" && Number.isFinite(Date.parse(message.time)), "Invalid state message");
163
+ requireValue((message.detail === undefined || text(message.detail)) && (message.task === undefined || (typeof message.task === "string" && Object.hasOwn(tasks, message.task))) && (message.replyTo === undefined || (typeof message.replyTo === "string" && messageIds.has(message.replyTo))) && (message.kind === undefined || (message.kind === "outcome" && message.task !== undefined)), "Invalid message detail or link");
154
164
  last = message.seq; messageIds.add(message.id);
155
165
  }
156
166
  }
157
167
 
168
+ /**
169
+ * Appends a post. Attention goes to explicit recipients, the linked task's owner and delegator (else creator),
170
+ * and the replied-to post's author; never the sender, and never an implicit broadcast.
171
+ */
172
+ function post(state: TeamState, actor: string, args: Pick<MessageArgs, "recipients" | "summary" | "detail" | "task" | "replyTo">, user = false): Message {
173
+ // A user's request is authored in a session but addressed to that session's own model.
174
+ const limit = user ? maxMessageText : 16000;
175
+ requireValue(text(args.summary, limit), `A post needs a nonempty summary of at most ${limit} characters`);
176
+ requireValue(args.detail === undefined || text(args.detail), "Detail must be nonempty and at most 16000 characters");
177
+ const original = args.replyTo === undefined ? undefined : state.messages.find(message => message.id === args.replyTo);
178
+ requireValue(args.replyTo === undefined || original, "Unknown post to reply to");
179
+ requireValue(!args.task || !original?.task || args.task === original.task, "A reply stays on the replied-to post's task");
180
+ const taskId = args.task ?? original?.task;
181
+ const task = taskId === undefined ? undefined : state.tasks[taskId];
182
+ requireValue(taskId === undefined || task, "Unknown task");
183
+ requireValue(args.recipients === undefined || Array.isArray(args.recipients), "Recipients must be unique known member names or IDs");
184
+ const members = Object.values(state.members);
185
+ // Names resolve to durable IDs; a stopped member still receives its post on resume.
186
+ const explicit = (args.recipients ?? []).map(recipient => typeof recipient === "string" ? members.find(member => member.id === recipient || member.name === recipient)?.id : undefined);
187
+ requireValue(new Set(explicit).size === explicit.length && explicit.every((recipient): recipient is string => recipient !== undefined), "Recipients must be unique known member names or IDs");
188
+ requireValue(explicit.length > 0 || task || original, "Name recipients, a task, or a post to reply to; posts are never broadcast");
189
+ const waiters = taskId === undefined ? [] : Object.values(state.members).filter(member => member.waiting?.tasks.includes(taskId)).map(member => member.id);
190
+ const routed = [...explicit, task?.owner, task?.delegator ?? task?.creator, original?.from, ...waiters];
191
+ const recipients = [...new Set(routed.filter((id): id is string => !!id && (id !== actor || (user && explicit.includes(id)))))];
192
+ const message: Message = { seq: (state.messages.at(-1)?.seq ?? 0) + 1, id: randomUUID(), from: actor, recipients, summary: args.summary, time: new Date().toISOString() };
193
+ if (args.detail !== undefined) message.detail = args.detail;
194
+ if (taskId !== undefined) message.task = taskId;
195
+ if (original) message.replyTo = original.id;
196
+ state.messages.push(message);
197
+ return message;
198
+ }
199
+
158
200
  // Check every component rather than following a symlink into another home.
159
201
  function ensureHome(path: string): void {
160
202
  const parent = dirname(path);
@@ -183,6 +225,11 @@ export function readState(dir: string): TeamState {
183
225
  } finally { closeSync(fd); }
184
226
  }
185
227
 
228
+ /** Display names for members and tasks; their IDs are distinct UUIDs, so one map names both. */
229
+ export function stateLabels(state: TeamState): Record<string, string> {
230
+ return Object.fromEntries([...Object.values(state.members).map(member => [member.id, memberName(member)]), ...Object.values(state.tasks).map(task => [task.id, `#${task.number} ${task.title}`])]);
231
+ }
232
+
186
233
  /** Cache only display names, not histories; atomic snapshot replacements invalidate the cache. */
187
234
  export function createMemberLabels(): (dir: string) => Record<string, string> {
188
235
  let key: string | undefined, labels: Record<string, string>;
@@ -190,7 +237,7 @@ export function createMemberLabels(): (dir: string) => Record<string, string> {
190
237
  const file = join(dir, "state.json"), stat = lstatSync(file, { bigint: true });
191
238
  const next = `${file}:${stat.dev}:${stat.ino}:${stat.mtimeNs}:${stat.size}:${stat.mode}:${stat.uid}:${stat.nlink}`;
192
239
  if (next !== key) {
193
- labels = Object.fromEntries(Object.values(readState(dir).members).map(member => [member.id, memberName(member)]));
240
+ labels = stateLabels(readState(dir));
194
241
  key = next;
195
242
  }
196
243
  return labels;
@@ -325,6 +372,12 @@ export class Store {
325
372
  }
326
373
  requireValue(args.id && Object.hasOwn(state.tasks, args.id), "Unknown task");
327
374
  const task = state.tasks[args.id];
375
+ if (args.action === "complete" && task.outcome) {
376
+ // A retried completion returns the original; a different outcome is a conflict, not a second post.
377
+ const published = state.messages.find(message => message.id === task.outcome)!;
378
+ requireValue(published.from === actor && published.summary === args.summary && published.detail === args.detail, "Task is already done with a different outcome; reopen it before completing again");
379
+ return taskView(state, task);
380
+ }
328
381
  requireValue(args.revision === undefined || (integer(args.revision) && args.revision === task.revision), "Stale or invalid task revision; read the task before retrying");
329
382
  if (args.action === "edit") {
330
383
  requireValue(task.status === "todo", "Only to-do tasks can be edited");
@@ -348,6 +401,10 @@ export class Store {
348
401
  requireValue(task.owner === actor, "Only the owner can complete this task");
349
402
  requireValue(task.status === "in_progress", "Only in-progress tasks can be completed; start it first");
350
403
  requireValue(args.acceptance === true, "Completion requires acceptance=true after checking acceptance criteria");
404
+ requireValue(text(args.summary), "Completion requires an outcome summary: what was done, how it was checked, and any caveats");
405
+ const outcome = post(state, actor, { summary: args.summary, detail: args.detail, task: task.id });
406
+ outcome.kind = "outcome";
407
+ task.outcome = outcome.id;
351
408
  task.status = "done";
352
409
  task.completedAt = Date.now();
353
410
  } else if (args.action === "release") {
@@ -362,7 +419,7 @@ export class Store {
362
419
  const dependents = Object.values(state.tasks).filter(other => other.dependencies.includes(task.id) && other.status !== "todo");
363
420
  requireValue(dependents.length === 0, `Cannot reopen task with active or done dependents: ${dependents.map(t => t.id).join(", ")}`);
364
421
  task.status = "todo";
365
- delete task.completedAt;
422
+ delete task.completedAt; delete task.outcome;
366
423
  } else throw new Error("Unknown task action");
367
424
  task.revision++;
368
425
  return taskView(state, task);
@@ -371,17 +428,16 @@ export class Store {
371
428
 
372
429
  message(actor: string, args: MessageArgs, enveloped = false): Message | Message[] {
373
430
  requireValue(!this.closed && Object.hasOwn(this.state.members, actor), "Unknown actor or closed store");
374
- if (args.action === "send") return this.update(state => {
375
- const limit = enveloped ? maxMessageText : 16000;
376
- requireValue(text(args.text, limit), `Message text must be nonempty and at most ${limit} characters`);
377
- const members = Object.values(state.members);
378
- requireValue(args.recipients === undefined || Array.isArray(args.recipients), "Recipients must be unique live member names or IDs");
379
- const recipients = args.recipients === undefined ? members.filter(member => member.id !== actor && isLive(member)).map(member => member.id)
380
- : args.recipients.map(recipient => typeof recipient === "string" ? members.find(member => member.id === recipient || member.name === recipient)?.id : undefined);
381
- requireValue((args.recipients === undefined || recipients.length > 0) && new Set(recipients).size === recipients.length && recipients.every((recipient): recipient is string => recipient !== undefined && isLive(state.members[recipient])), "Recipients must be unique live member names or IDs");
382
- const message = { seq: (state.messages.at(-1)?.seq ?? 0) + 1, id: randomUUID(), from: actor, recipients: [...recipients], text: args.text, time: new Date().toISOString() };
383
- state.messages.push(message);
384
- return structuredClone(message);
431
+ if (args.action === "send") return this.update(state => structuredClone(post(state, actor, args, enveloped)));
432
+ if (args.action === "wait") return this.update(state => {
433
+ const tasks = args.tasks ?? [];
434
+ requireValue(Array.isArray(tasks) && tasks.length <= 100 && tasks.every(task => Object.hasOwn(state.tasks, task)), "Wait tasks must be existing task IDs");
435
+ // Register and recheck in one mutation, so a post committed after the caller last looked still wakes it.
436
+ const after = args.after ?? state.members[actor].notified;
437
+ const ready = state.messages.filter(message => message.seq > after && (message.recipients.includes(actor) || (message.task !== undefined && tasks.includes(message.task))));
438
+ if (ready.length) delete state.members[actor].waiting;
439
+ else state.members[actor].waiting = { tasks: [...new Set(tasks)] };
440
+ return structuredClone(ready);
385
441
  });
386
442
  requireValue(args.action === "history" || args.action === "search", "Unknown message action");
387
443
  const after = args.after ?? 0, limit = args.limit ?? 20;
@@ -389,7 +445,7 @@ export class Store {
389
445
  if (args.action === "search") requireValue(text(args.query, 500), "Search requires a nonempty query (max 500)");
390
446
  const results = [];
391
447
  for (const message of this.state.messages) {
392
- if (message.seq > after && (args.action !== "search" || message.text.includes(args.query!))) results.push(message);
448
+ if (message.seq > after && (args.action !== "search" || (message.summary.includes(args.query!) || !!message.detail?.includes(args.query!)))) results.push(message);
393
449
  if (results.length === limit) break;
394
450
  }
395
451
  return structuredClone(results);
package/src/types.ts CHANGED
@@ -47,6 +47,8 @@ export interface Member {
47
47
  role?: "main" | "member";
48
48
  /** State recorded when the coordinator owner stopped this member; the next owner's resume pass consumes it. */
49
49
  handover?: "busy" | "idle" | "input";
50
+ /** Registered by a yield: posts on these tasks reach this member and wake it. Cleared when it next works. */
51
+ waiting?: { tasks: string[] };
50
52
  /** Promotion in progress: the last completed step and what a rollback restores. */
51
53
  promotion?: { step: "stopped" | "moved" | "role" | "entry"; sessionFile: string; parent?: string; access?: Access; provider?: string };
52
54
  }
@@ -74,6 +76,10 @@ export interface Task {
74
76
  completedAt?: number;
75
77
  owner?: string;
76
78
  creator?: string;
79
+ /** Who handed this task to its owner; receives its activity. Routing falls back to the creator. */
80
+ delegator?: string;
81
+ /** The post that published this task's completion outcome. */
82
+ outcome?: string;
77
83
  }
78
84
  export interface TaskView extends Task {
79
85
  blockedBy: string[];
@@ -83,11 +89,18 @@ export interface Message {
83
89
  id: string;
84
90
  from: string;
85
91
  recipients: string[];
86
- text: string;
92
+ /** Delivered directly in notices; complete enough to act on. */
93
+ summary: string;
94
+ /** Read on demand. */
95
+ detail?: string;
96
+ task?: string;
97
+ replyTo?: string;
98
+ /** "outcome": the post that completed its task. */
99
+ kind?: "outcome";
87
100
  time: string;
88
101
  }
89
102
  export interface TeamState {
90
- version: 4;
103
+ version: 5;
91
104
  id: string;
92
105
  /** Lets an unsandboxed session of the same user add itself as a main session. */
93
106
  key: string;
@@ -115,11 +128,19 @@ export interface TaskArgs {
115
128
  label?: string;
116
129
  description?: string;
117
130
  dependencies?: string[];
131
+ /** Complete: the outcome published with the completion. */
132
+ summary?: string;
133
+ detail?: string;
118
134
  }
119
135
  export interface MessageArgs {
120
- action: "send" | "history" | "search";
136
+ action: "send" | "history" | "search" | "wait";
137
+ /** Wait: also wake on posts linked to these tasks. */
138
+ tasks?: string[];
121
139
  recipients?: string[];
122
- text?: string;
140
+ summary?: string;
141
+ detail?: string;
142
+ task?: string;
143
+ replyTo?: string;
123
144
  query?: string;
124
145
  after?: number;
125
146
  limit?: number;
@@ -138,6 +159,8 @@ export interface AgentArgs {
138
159
  limit?: number;
139
160
  depth?: number;
140
161
  access?: AccessRequest;
162
+ /** Launch: an existing task the new member owns, with the requester as its delegator. */
163
+ task?: string;
141
164
  }
142
165
  export interface UserAction {
143
166
  action: "task_request" | "feedback" | "resume" | "promote" | "leave";