@staix/agent-hub 0.8.1 → 0.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.
package/src/hub/tasks.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Briefs } from "../memory/brief.ts";
2
2
  import type { MemoryClient } from "../memory/client.ts";
3
- import { CLASSES, PLAN_KEYS, type Board, type Task, type TaskClass, type TaskPlan, type TaskRefs } from "./board.ts";
3
+ import { CLASSES, OUTCOMES_KEPT_MS, PLAN_KEYS, type Board, type Task, type TaskClass, type TaskPlan, type TaskRefs } from "./board.ts";
4
4
  import type { Bus } from "./bus.ts";
5
5
  import { HUB, newEnvelope, NOTE_KINDS, noteLine, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
6
6
  import { assign, detectSignals, LOCAL, PI, type Assignment, type Routing } from "./routing.ts";
@@ -25,17 +25,28 @@ export interface TasksDeps {
25
25
  tell?: (peer: PeerId, line: string) => void;
26
26
  /** Structured overlap records for telemetry (issue #40); the notice line stays for the human. */
27
27
  recordOverlap?: (task: number, owner: PeerId, others: { task: number; owner: PeerId; paths: string[]; symbols?: string[] }[]) => void;
28
+ /** Quota per peer from fresh readings (issue #36): routing drains the windows that reset soonest first. */
29
+ quota?: () => Record<PeerId, { headroom: number; resetsAt?: number }>;
30
+ /** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
31
+ review?: { adaptive: boolean; min_reviews: number };
28
32
  /** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
29
33
  triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
30
34
  }
31
35
 
32
36
  const ESCALATE_AFTER = 2;
37
+ // Demotion (issue #36): a failure counts half after a day. A peer is demoted for a class once its decayed failures
38
+ // reach 1.5 (two within about half a day, three within two days) and outweigh its decayed successes there.
39
+ // ponytail: fixed constants; make them config when someone needs to tune them.
40
+ const DEMOTION_HALF_LIFE_MS = 24 * 3_600_000;
41
+ const DEMOTE_AT = 1.5;
42
+ /** An approval counts as contradicted when work on the same places fails within this window (issue #35). */
43
+ const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
33
44
  const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
34
45
  /** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
35
46
  const QUIET_EVENTS = new Set(["answer", "reviewer changed"]);
36
47
 
37
48
  /** Same path, or one is a directory of the other; the project root (`.`) holds everything. */
38
- const samePlace = (a: string, b: string) => {
49
+ export const samePlace = (a: string, b: string) => {
39
50
  const norm = (p: string) => p.replace(/^\.\//, "").replace(/\/+$/, "") || ".";
40
51
  const [x, y] = [norm(a), norm(b)];
41
52
  return x === "." || y === "." || x === y || x.startsWith(`${y}/`) || y.startsWith(`${x}/`);
@@ -99,16 +110,102 @@ export class Tasks {
99
110
  return Object.fromEntries([...this.d.bus.peers.keys()].map((id) => [id, this.d.bus.stateOf(id)]));
100
111
  }
101
112
 
102
- async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId }): Promise<Task> {
113
+ /** Dependencies of a task that are not approved yet: while there are any, it is offered to nobody (issue #34). */
114
+ waitsFor = (task: Pick<Task, "deps">): number[] => (task.deps ?? []).filter((id) => this.d.board.get(id)?.state !== "approved");
115
+
116
+ /** Decayed failure weights of the peers demoted for a class: failures count half after a day (issue #36). */
117
+ demoted(cls: TaskClass, now = Date.now()): Record<PeerId, number> {
118
+ const sums = new Map<PeerId, { bad: number; good: number }>();
119
+ for (const o of this.d.board.outcomes(cls, now - OUTCOMES_KEPT_MS)) {
120
+ const w = 0.5 ** ((now - o.at) / DEMOTION_HALF_LIFE_MS);
121
+ const s = sums.get(o.peer) ?? { bad: 0, good: 0 };
122
+ if (o.ok) s.good += w;
123
+ else s.bad += w;
124
+ sums.set(o.peer, s);
125
+ }
126
+ return Object.fromEntries([...sums].filter(([, s]) => s.bad >= DEMOTE_AT && s.bad > s.good).map(([p, s]) => [p, s.bad]));
127
+ }
128
+
129
+ /** How each reviewer's reviews of each implementer's work in a class held up (issue #35). */
130
+ reviewRecord(cls: TaskClass): Record<PeerId, Record<PeerId, { score: number; n: number }>> {
131
+ // Counted per task: a review that asked for changes and then approved is one review, not two.
132
+ const c = new Map<string, { reviewed: Set<number>; contradicted: Set<number> }>();
133
+ for (const r of this.d.board.reviews({ class: cls })) {
134
+ const key = `${r.implementer}\0${r.reviewer}`;
135
+ const s = c.get(key) ?? { reviewed: new Set<number>(), contradicted: new Set<number>() };
136
+ (r.kind === "contradicted" ? s.contradicted : s.reviewed).add(r.task);
137
+ c.set(key, s);
138
+ }
139
+ const out: Record<PeerId, Record<PeerId, { score: number; n: number }>> = {};
140
+ for (const [key, s] of c) {
141
+ const [implementer, reviewer] = key.split("\0") as [PeerId, PeerId];
142
+ const n = s.reviewed.size;
143
+ if (n) (out[implementer] ??= {})[reviewer] = { score: (n - [...s.contradicted].filter((t) => s.reviewed.has(t)).length) / n, n };
144
+ }
145
+ return out;
146
+ }
147
+
148
+ /** Who asked for changes on the current owner's work: requests made before the task changed hands were about someone else's. */
149
+ private requestedChanges(task: Task): Set<PeerId> {
150
+ const since = task.history.findLastIndex((h) => ["assigned", "escalated", "reassigned", "unassigned"].includes(h.event));
151
+ return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested").map((h) => h.by));
152
+ }
153
+
154
+ /** What assignment weighs besides states and policy: quota, demotion and the review record. */
155
+ private weights(cls: TaskClass) {
156
+ const now = Date.now();
157
+ const quota = this.d.quota?.();
158
+ return { now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
159
+ }
160
+
161
+ /**
162
+ * Work on these places failed (a check failure, or changes requested): approvals of other tasks on the same places
163
+ * within the window were contradicted. Each approval counts once.
164
+ */
165
+ private contradict(failed: Task): void {
166
+ if (this.isPii(failed)) return; // its places are left out everywhere else too
167
+ const now = Date.now();
168
+ const mine = this.places(failed);
169
+ for (const t of this.d.board.list("approved")) {
170
+ if (t.id === failed.id || !t.owner || this.isPii(t)) continue;
171
+ const approval = [...t.history].reverse().find((h) => h.event === "approved");
172
+ if (!approval || now - approval.at > CONTRADICTION_WINDOW_MS || approval.by === USER) continue;
173
+ const theirs = this.places(t);
174
+ // Blame needs the same file or symbol: a directory or `.` would contradict every approval under it.
175
+ const same = mine.paths.some((p) => theirs.paths.includes(p)) || mine.symbols.some((x) => theirs.symbols.includes(x));
176
+ if (!same || this.d.board.reviews({ task: t.id }).some((r) => r.kind === "contradicted")) continue;
177
+ this.d.board.recordReview({ implementer: t.owner, reviewer: approval.by, class: t.class, kind: "contradicted", task: t.id });
178
+ }
179
+ }
180
+
181
+ async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown }): Promise<Task> {
103
182
  // Callers are models: a title is one line (it is part of console and hub.log lines), not a document.
104
183
  const title = String(input.title ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
105
184
  if (!title) throw new Error("title is required");
185
+ // Dependencies can only name tasks that exist, so the new task closes no cycle: nothing can depend on it yet.
186
+ // Callers are models: a missing or null `after` is no dependency, and an id is an integer or a digit string.
187
+ let afterIds: unknown[] = [];
188
+ if (Array.isArray(input.after)) afterIds = input.after;
189
+ else if (input.after != null) afterIds = [input.after];
190
+ const deps: number[] = [];
191
+ for (const v of afterIds) {
192
+ const id = typeof v === "number" || (typeof v === "string" && /^\s*\d+\s*$/.test(v)) ? Number(v) : NaN;
193
+ if (!Number.isInteger(id)) throw new Error(`after: ${JSON.stringify(v)} is not a task id`);
194
+ if (!this.d.board.get(id)) throw new Error(`after: no task #${id}`);
195
+ if (!deps.includes(id)) deps.push(id);
196
+ }
197
+ const waits = this.waitsFor({ deps });
198
+ if (waits.length && input.owner) {
199
+ throw new Error(`this task would wait for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet: ${input.owner === by ? "claim it once they are" : "propose it without an owner; routing offers it when they are approved"}`);
200
+ }
106
201
  const given = input.class === undefined || input.class === "" ? undefined : input.class;
107
202
  if (given !== undefined && !CLASSES.includes(given as TaskClass)) throw new Error(`class must be one of ${CLASSES.join(", ")}`);
108
203
  const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
109
204
  const plan = cleanPlan(input.plan);
110
205
  // The plan reaches other owners, so a PII pattern in it makes the task a PII task like one in the detail would.
111
206
  const signals = detectSignals({ ...text, detail: [text.detail, planText(plan)].join("\n") }, this.d.routing(), this.d.cwd);
207
+ // Urgent work is handed over even when its paused owner's window resets soon (issue #36).
208
+ if (input.urgent === true && !signals.includes("urgent")) signals.push("urgent");
112
209
  let cls = given as TaskClass | undefined;
113
210
  let triaged = false;
114
211
  if (!cls && this.d.triage) {
@@ -122,10 +219,16 @@ export class Tasks {
122
219
  if (defaulted) cls = "implement";
123
220
  if (!cls) throw new Error(`class is required (one of ${CLASSES.join(", ")}); the hub could not name one for you`);
124
221
  const draft = { ...text, class: cls };
125
- let task = this.d.board.propose(by, { ...draft, plan, signals });
222
+ let task = this.d.board.propose(by, { ...draft, plan, ...(deps.length ? { deps } : {}), signals });
126
223
  if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
127
224
  if (defaulted) task = this.d.board.update(task.id, "hub", "class defaulted", {}, "class implement for a claim without one");
128
225
  this.d.notify(`task ${this.publicTitle(task)} proposed by ${by} [${task.class}]${task.signals.length ? ` signals: ${task.signals.join(", ")}` : ""}`);
226
+ // Re-read: a dependency approved while triage was awaited looked for its dependents before this row existed.
227
+ const still = this.waitsFor(task);
228
+ if (still.length) {
229
+ this.d.notify(`task ${this.publicTitle(task)} waits for ${still.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
230
+ return this.d.board.update(task.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
231
+ }
129
232
  // Naming yourself is a claim: the work is already yours, so no offer comes back to you (paper: Agensh CLAIM, #68).
130
233
  return this.assignOwner(task, by, input.owner ? { candidates: [input.owner], claim: input.owner === by } : {});
131
234
  }
@@ -146,7 +249,7 @@ export class Tasks {
146
249
  private releasing: PeerId | undefined;
147
250
 
148
251
  /** Whether a model-written name may be shown to other peers and in the log: one matching a PII pattern may be PII. */
149
- private nameable = (t: string): boolean => !this.isPii({ signals: detectSignals({ title: "", detail: t, refs: {} }, this.d.routing(), this.d.cwd) });
252
+ nameable = (t: string): boolean => !this.isPii({ signals: detectSignals({ title: "", detail: t, refs: {} }, this.d.routing(), this.d.cwd) });
150
253
 
151
254
  /** Where two tasks meet: shared paths, then shared symbols, leaving out any name that matches a PII pattern. */
152
255
  private where(hit: { paths: string[]; symbols: string[] }): string {
@@ -227,16 +330,21 @@ export class Tasks {
227
330
  if (typeof target === "number") {
228
331
  const task = this.d.board.get(target);
229
332
  if (!task) throw new Error(`no task #${target}`);
230
- return [`task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"})`, "if it were assigned now:", ...assign(task, this.states(), routing, { exclude: this.declined(task) }).trace];
333
+ return [`task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"})`, "if it were assigned now:", ...assign(task, this.states(), routing, { exclude: this.declined(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) }).trace];
231
334
  }
232
335
  const draft = { title: target.title, detail: target.detail ?? "", refs: target.refs ?? {} };
233
- return assign({ class: target.class, signals: detectSignals(draft, routing, this.d.cwd) }, this.states(), routing).trace;
336
+ return assign({ class: target.class, signals: detectSignals(draft, routing, this.d.cwd) }, this.states(), routing, this.weights(target.class)).trace;
234
337
  }
235
338
 
236
339
  private declined = (task: Task) => task.history.filter((h) => h.event === "declined").map((h) => h.by);
237
340
 
238
341
  private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; event?: string; note?: string; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; claim?: boolean } = {}): Promise<Task> {
239
- const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], ...(opts.candidates ? { candidates: opts.candidates } : {}) });
342
+ const waits = this.waitsFor(task);
343
+ const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class) });
344
+ if (waits.length) {
345
+ this.d.notify(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
346
+ return task;
347
+ }
240
348
  if (!a.owner) {
241
349
  this.d.notify(`task ${this.publicTitle(task)}: no peer can take it (${a.trace.filter((l) => l.includes("skipped")).length} skipped); assign with: ahub task assign ${task.id} <peer>`);
242
350
  // Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
@@ -278,6 +386,46 @@ export class Tasks {
278
386
  this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner!], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id) }, ...(pii ? { private: true } : {}) }));
279
387
  }
280
388
 
389
+ /** Nobody, the console user included, works on a task before what it waits for is approved. */
390
+ private ready(task: Task): void {
391
+ const waits = this.waitsFor(task);
392
+ if (waits.length) throw new Error(`task #${task.id} waits for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet`);
393
+ }
394
+
395
+ /** An approved task may be the last thing others waited for: those go through assignment now (issue #34). */
396
+ private async releaseDependents(approved: Task): Promise<void> {
397
+ for (const t of this.d.board.list("proposed")) {
398
+ if (!t.deps?.includes(approved.id) || t.owner || this.waitsFor(t).length) continue;
399
+ await this.offerReady(t, `#${approved.id} approved`);
400
+ }
401
+ }
402
+
403
+ private readonly offered = new Set<number>(); // ready tasks offered in this hub run
404
+
405
+ /**
406
+ * A stop between an approval and the assignment of its dependents (both are saved on their own) leaves them ownerless
407
+ * with nothing left to wait for, and no later approval to offer them. The daemon calls this on its release timer:
408
+ * each such task is offered once per hub run, once an attached peer can take it (peers attach one by one).
409
+ */
410
+ async releaseReady(): Promise<void> {
411
+ for (const t of this.d.board.list("proposed")) {
412
+ const last = t.history.at(-1)?.event;
413
+ if (!t.deps?.length || t.owner || this.offered.has(t.id) || (last !== "blocked" && last !== "ready") || this.waitsFor(t).length) continue;
414
+ if (!assign(t, this.states(), this.d.routing(), { exclude: this.declined(t) }).owner) continue; // nobody attached can take it yet
415
+ await this.offerReady(t, "nothing left to wait for");
416
+ }
417
+ }
418
+
419
+ /** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
420
+ private async offerReady(stale: Task, why: string): Promise<void> {
421
+ const t = this.d.board.get(stale.id);
422
+ if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
423
+ this.offered.add(t.id);
424
+ const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
425
+ this.d.notify(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
426
+ await this.assignOwner(ready, HUB).catch((e: Error) => this.d.notify(`task ${this.publicTitle(ready)}: could not be assigned: ${e.message}`));
427
+ }
428
+
281
429
  private mine(task: Task, by: PeerId, role: "owner" | "reviewer"): void {
282
430
  if (by !== USER && task[role] !== by) throw new Error(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
283
431
  }
@@ -297,6 +445,7 @@ export class Tasks {
297
445
  accept(by: PeerId, id: unknown, plan?: unknown): Task {
298
446
  const task = this.need(id);
299
447
  this.mine(task, by, "owner");
448
+ this.ready(task);
300
449
  // Models send null or {} for an optional field they leave empty: neither replaces a plan.
301
450
  const given = plan == null ? undefined : cleanPlan(plan);
302
451
  const cleaned = given && Object.keys(given).length ? given : undefined;
@@ -327,6 +476,9 @@ export class Tasks {
327
476
  /** Tasks whose check is queued or running, and the owner it was started for. */
328
477
  private readonly checking = new Map<number, PeerId | null>();
329
478
  private checkQueue: Promise<void> = Promise.resolve();
479
+ private pendingChecks = 0;
480
+ /** Checks queued or running, until their result is on the board: a recovery commit waits for them. */
481
+ checksPending = () => this.pendingChecks;
330
482
 
331
483
  /** Whether a completion check is still running for this task. */
332
484
  isChecking = (id: number) => this.checking.has(id);
@@ -335,6 +487,7 @@ export class Tasks {
335
487
  let task = this.need(id);
336
488
  this.mine(task, by, "owner");
337
489
  if (task.state === "in_review" || task.state === "approved") throw new Error(`task #${task.id} is already ${task.state}`);
490
+ this.ready(task);
338
491
  if (this.checking.has(task.id)) {
339
492
  // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
340
493
  throw new Error(this.checking.get(task.id) === task.owner ? `task #${task.id}: its check is still running; its result comes as a task message` : `task #${task.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
@@ -347,9 +500,11 @@ export class Tasks {
347
500
  const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary);
348
501
  this.d.notify(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
349
502
  const seen = { events: pending.history.length, owner: pending.owner };
503
+ this.pendingChecks++;
350
504
  this.checkQueue = this.checkQueue
351
505
  .then(() => this.finishChecked(pending.id, by, summary, command, seen))
352
- .catch((e: Error) => this.d.notify(`task #${pending.id}: its check result could not be recorded: ${e.message}`));
506
+ .catch((e: Error) => this.d.notify(`task #${pending.id}: its check result could not be recorded: ${e.message}`))
507
+ .finally(() => this.pendingChecks--);
353
508
  return pending;
354
509
  }
355
510
 
@@ -375,6 +530,8 @@ export class Tasks {
375
530
  return;
376
531
  }
377
532
  this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim());
533
+ if (task.owner) this.d.board.recordOutcome(task.owner, task.class, false);
534
+ this.contradict(task);
378
535
  this.d.notify(`task ${this.publicTitle(task)}: its check failed (${outcome}); it stays with ${task.owner ?? by}`);
379
536
  this.tell(task, `Task #${id}: its check failed.\n$ ${outcome}${result.tail ? `\n${result.tail}` : ""}\nFix it and call hub_task_done again.`, pii);
380
537
  }
@@ -384,7 +541,11 @@ export class Tasks {
384
541
  const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary);
385
542
  this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`);
386
543
  this.tellCompleted(next, summary);
387
- if (!reviewer) this.d.notify(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
544
+ if (!reviewer) {
545
+ if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true); // approved without a review is a success too
546
+ this.d.notify(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
547
+ await this.releaseDependents(next);
548
+ }
388
549
  else if (reviewer === USER) this.d.notify(`task ${this.publicTitle(next)} done by ${by}: review it with ahub task show ${next.id}, then ahub review ${next.id} approved|changes_requested [note]`);
389
550
  else this.sendReview(next, reviewer);
390
551
  return next;
@@ -420,23 +581,45 @@ export class Tasks {
420
581
  const r = task.refs;
421
582
  const last = [...task.history].reverse().find((h) => h.event === "done");
422
583
  const where = [r.branch ? `branch ${r.branch}` : "", r.commit ? `commit ${r.commit}` : "", r.paths?.length ? `paths ${r.paths.join(", ")}` : ""].filter(Boolean).join("; ");
423
- const body = `Review task #${task.id} [${task.class}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${last?.note ?? "(no summary)"}\n${where ? `Where: ${where}\n` : ""}${why ? `${why}\n` : ""}Give your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note}.`;
584
+ // A checklist that maps the change to its contract (issue #35): reviewers who check against the written plan catch more.
585
+ const plan = planText(task.plan);
586
+ const check = [...task.history].reverse().find((h) => h.event === "check passed");
587
+ const checklist = [
588
+ "Checklist:",
589
+ `- Map the changed signatures and call sites to ${plan ? `the plan (${plan})` : task.detail ? "the task detail above" : "the task title"}, and name each one that does not match.`,
590
+ `- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
591
+ `- List what is unmet in hub_review's unmet, one item each.`,
592
+ ].join("\n");
593
+ const body = `Review task #${task.id} [${task.class}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${last?.note ?? "(no summary)"}\n${where ? `Where: ${where}\n` : ""}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
424
594
  this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, task: String(task.id) }, ...(this.isPii(task) ? { private: true } : {}) }));
425
595
  }
426
596
 
427
- async review(by: PeerId, id: unknown, verdict: unknown, note?: string): Promise<Task> {
597
+ async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown): Promise<Task> {
428
598
  const task = this.need(id);
429
599
  this.mine(task, by, "reviewer");
430
600
  if (verdict !== "approved" && verdict !== "changes_requested") throw new Error('verdict must be "approved" or "changes_requested"');
431
601
  if (task.state !== "in_review") throw new Error(`task #${task.id} is ${task.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
432
602
  const pii = this.isPii(task);
603
+ const items = textList(unmet);
604
+ if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
433
605
  if (verdict === "approved") {
434
606
  const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0 }, note);
607
+ if (next.owner) {
608
+ this.d.board.recordOutcome(next.owner, next.class, true);
609
+ this.d.board.recordReview({ implementer: next.owner, reviewer: by, class: next.class, kind: "approved", task: next.id });
610
+ // Changes requested on this owner's work and the redo passed: those reviews caught something.
611
+ for (const r of this.requestedChanges(task)) {
612
+ this.d.board.recordReview({ implementer: next.owner, reviewer: r, class: next.class, kind: "caught", task: next.id });
613
+ }
614
+ }
435
615
  this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`);
436
616
  this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${note}` : ""}`, pii);
617
+ await this.releaseDependents(next);
437
618
  return next;
438
619
  }
439
620
  const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note);
621
+ if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
622
+ this.contradict(rejected);
440
623
  this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`);
441
624
  if (rejected.rejections >= ESCALATE_AFTER) {
442
625
  const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`);
@@ -456,6 +639,11 @@ export class Tasks {
456
639
  const list = this.d.routing().classes[task.class]?.escalate_to ?? [];
457
640
  if (task.state === "changes_requested") task = this.d.board.update(task.id, HUB, "reopened", { state: "in_progress" });
458
641
  const from = task.owner;
642
+ // The hub's own escalations are not counted: after repeated changes_requested each one already was, and after a
643
+ // Pi inference failure the backend failed, not the work. An escalation by hand counts on its own.
644
+ if (from && by !== HUB) this.d.board.recordOutcome(from, task.class, false);
645
+ // Only a reviewer that asked for changes on this work saw it fail: not an escalation of unreviewed work (a Pi failure).
646
+ if (from && task.reviewer && task.reviewer !== USER && this.requestedChanges(task).has(task.reviewer)) this.d.board.recordReview({ implementer: from, reviewer: task.reviewer, class: task.class, kind: "escalated", task: task.id });
459
647
  const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", note: `${why}; from ${from ?? "none"}`, context: why });
460
648
  if (next.owner && next.owner !== from) {
461
649
  this.d.notify(`task ${this.publicTitle(next)} escalated from ${from} to ${next.owner} (${why})`);
@@ -474,10 +662,12 @@ export class Tasks {
474
662
  * Budget relay: a paused peer's open work moves on. `local` first (it has no quota), then the class list, all through
475
663
  * the usual constraints. Tasks it was reviewing get another reviewer. Moves are reported, never taken back automatically.
476
664
  */
477
- async reassignForPause(peer: PeerId, context: string | undefined): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
665
+ async reassignForPause(peer: PeerId, context: string | undefined, urgentOnly = false): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
478
666
  const moved: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[] = [];
479
667
  const routing = this.d.routing();
480
668
  for (const task of this.d.board.list()) {
669
+ // Waiting out a window that resets soon (issue #36): only urgent work moves.
670
+ if (urgentOnly && !task.signals.includes("urgent")) continue;
481
671
  if (task.owner === peer && OPEN.includes(task.state)) {
482
672
  const pii = this.isPii(task);
483
673
  const candidates = [pii ? LOCAL : PI, pii ? undefined : LOCAL, ...(routing.classes[task.class]?.peers ?? []).filter((p) => p !== LOCAL && p !== PI)].filter((p): p is PeerId => !!p);
@@ -486,7 +676,7 @@ export class Tasks {
486
676
  moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
487
677
  } else if (task.reviewer === peer && task.state !== "approved") {
488
678
  // No owner candidates: only the reviewer is wanted, and it must be neither the paused peer nor the task's owner.
489
- const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}) });
679
+ const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
490
680
  const reviewer = a.reviewer && a.reviewer !== peer && a.reviewer !== task.owner ? a.reviewer : null;
491
681
  const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, `budget pause of ${peer}`);
492
682
  moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
@@ -18,7 +18,19 @@ const q = sbplString;
18
18
  * no network unless asked. In SBPL the last matching rule wins, so the denies come last.
19
19
  */
20
20
  /** Toolchains and git identity: the only parts of the home directory a sandboxed command may read besides the project. */
21
- const HOME_READABLE = [".bun", ".cargo", ".rustup", ".local", ".npm", ".cache", ".pyenv", ".nvm", ".deno", "go", ".gitconfig", ".config/git", "Library/Caches"];
21
+ const HOME_READABLE = [".bun", ".cargo", ".rustup", ".local", ".npm", ".cache", ".pyenv", ".nvm", ".deno", "go", ".gitconfig", ".config/git", "Library/Caches", ".volta", ".asdf", ".nodenv", ".rbenv", ".sdkman", "Library/Application Support/fnm", "Library/pnpm"];
22
+
23
+ /**
24
+ * Public CA bundles: the name denies (`*.pem` for keys) also match these, and TLS needs them once network is on.
25
+ * Allowed after the denies, by exact path.
26
+ */
27
+ const CA_BUNDLES = ["/private/etc/ssl/cert.pem", "/opt/homebrew/etc/ca-certificates/cert.pem", "/opt/homebrew/etc/openssl@3/cert.pem", "/usr/local/etc/ca-certificates/cert.pem", "/usr/local/etc/openssl@3/cert.pem"];
28
+
29
+ /** The selected Xcode or Command Line Tools dir: the `/usr/bin` shims (git, clang, make, python3) run what is in it. */
30
+ function developerDir(): string | undefined {
31
+ const out = spawnSync("xcode-select", ["-p"], { encoding: "utf8" });
32
+ return out.status === 0 && out.stdout.trim() ? out.stdout.trim() : undefined;
33
+ }
22
34
 
23
35
  /** A submodule or worktree keeps its git dir outside the project; git needs it, minus the parts that execute or reconfigure. */
24
36
  function externalGitDirs(root: string): string[] {
@@ -28,28 +40,67 @@ function externalGitDirs(root: string): string[] {
28
40
  return [...new Set(dirs)].filter((d) => !d.startsWith(`${root}/`));
29
41
  }
30
42
 
31
- export function profile(cwd: string, network: boolean, readAllow: string[] = [], deny: string[] = []): string {
43
+ /** The system directories a deny-default profile lets commands read and run from: dyld, frameworks, toolchains. */
44
+ const SYSTEM_READABLE = ["/usr", "/bin", "/sbin", "/System", "/Library", "/opt", "/private/etc", "/private/var/db/timezone", "/private/var/db/dyld"];
45
+ /**
46
+ * Directory lookups (users, groups), logging and notifications; plus name resolution and TLS trust when network is on.
47
+ * No brokers that act outside the sandbox: LaunchServices would let `open` start a browser with network, and
48
+ * SecurityServer would answer Keychain queries the credential-path denies are there to stop.
49
+ */
50
+ const MACH_SERVICES = ["com.apple.system.opendirectoryd.libinfo", "com.apple.system.DirectoryService.libinfo_v1", "com.apple.system.logger", "com.apple.system.notification_center"];
51
+ const NETWORK_MACH_SERVICES = ["com.apple.dnssd.service", "com.apple.trustd", "com.apple.trustd.agent", "com.apple.networkd"];
52
+
53
+ /**
54
+ * `base` "deny" (issue #39) starts from `(deny default)` and allows only what commands need: running and reading the
55
+ * system, toolchain and project directories, writing the project and temp. "allow" is the profile of 0.9 and earlier,
56
+ * kept for one release behind `local.sandbox: "allow-default"`. The denies at the end apply to both.
57
+ */
58
+ export function profile(cwd: string, network: boolean, readAllow: string[] = [], deny: string[] = [], base: "deny" | "allow" = "deny"): string {
32
59
  const home = homedir();
33
60
  const inHome = (p: string) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
34
61
  const root = realPath(cwd);
35
62
  const tmp = realPath(tmpdir());
36
63
  const gitDirs = externalGitDirs(root);
37
64
  const creds = [".ssh", ".aws", ".gnupg", ".config/gh", ".config/gcloud", ".kube", ".docker", ".netrc", ".npmrc", ".omniroute", ".claude", ".codex", ".kimi-code", "Library/Keychains"];
65
+ const dev = developerDir();
66
+ const readable = [root, ...HOME_READABLE.map((p) => join(home, p)), ...readAllow.map(inHome), ...gitDirs, ...(dev ? [dev] : [])];
67
+ const subpaths = (paths: string[]) => paths.map((p) => `(subpath ${q(p)})`).join(" ");
68
+ const globals = (names: string[]) => names.map((n) => `(global-name ${q(n)})`).join(" ");
69
+ const start = base === "deny"
70
+ ? [
71
+ "(deny default)",
72
+ "(allow process-fork)",
73
+ // Runs from the system, toolchain and project directories only; a script runs through an allowed interpreter.
74
+ `(allow process-exec ${subpaths([...SYSTEM_READABLE, ...readable, tmp, "/private/tmp"])})`,
75
+ "(allow signal (target same-sandbox))",
76
+ "(allow process-info* (target same-sandbox))",
77
+ "(allow sysctl-read)",
78
+ `(allow mach-lookup ${globals([...MACH_SERVICES, ...(network ? NETWORK_MACH_SERVICES : [])])})`,
79
+ '(allow ipc-posix-shm-read-data ipc-posix-shm-read-metadata (ipc-posix-name "apple.shm.notification_center"))',
80
+ "(allow file-read-metadata)",
81
+ `(allow file-read* (literal "/") ${subpaths([...SYSTEM_READABLE, tmp, "/private/tmp", "/dev"])} ${subpaths(readable)})`,
82
+ '(allow file-ioctl (regex #"^/dev/"))',
83
+ ...(network ? ["(allow network*)"] : []),
84
+ ]
85
+ : [
86
+ "(allow default)",
87
+ ...(network ? [] : ["(deny network*)"]),
88
+ // Home is default-deny for reads: whatever a command reads can end up in the model's answer, and that answer
89
+ // is shared with agents that run on cloud subscriptions (~/.claude.json, app tokens, browser profiles ...).
90
+ `(deny file-read* (subpath ${q(home)}))`,
91
+ `(allow file-read-metadata (subpath ${q(home)}))`,
92
+ `(allow file-read* ${subpaths(readable)})`,
93
+ ];
38
94
  return [
39
95
  "(version 1)",
40
- "(allow default)",
41
- ...(network ? [] : ["(deny network*)"]),
42
- // Home is default-deny for reads: whatever a command reads can end up in the model's answer, and that answer
43
- // is shared with agents that run on cloud subscriptions (~/.claude.json, app tokens, browser profiles ...).
44
- `(deny file-read* (subpath ${q(home)}))`,
45
- `(allow file-read-metadata (subpath ${q(home)}))`,
46
- `(allow file-read* (subpath ${q(root)}) ${[...HOME_READABLE.map((p) => join(home, p)), ...readAllow.map(inHome), ...gitDirs].map((p) => `(subpath ${q(p)})`).join(" ")})`,
96
+ ...start,
47
97
  "(deny file-write*)",
48
98
  `(allow file-write* (subpath ${q(root)}) (subpath ${q(tmp)}) (subpath "/private/tmp") (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`,
49
99
  // Inside cwd: nothing that runs later outside the sandbox, nothing that reconfigures the hub.
50
100
  `(deny file-write* (subpath ${q(join(root, ".agenthub"))}) ${[join(root, ".git"), ...gitDirs].map((d) => `(subpath ${q(join(d, "hooks"))}) (literal ${q(join(d, "config"))})`).join(" ")})`,
51
101
  `(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")})`,
52
102
  `(deny file-read* file-write* ${denyRegexes(root, deny).join(" ")})`,
103
+ ...(network ? [`(allow file-read* ${CA_BUNDLES.map((p) => `(literal ${q(p)})`).join(" ")})`] : []),
53
104
  ].join("\n");
54
105
  }
55
106
 
@@ -0,0 +1,10 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "Edit|Write|MultiEdit|NotebookEdit",
6
+ "hooks": [{ "type": "command", "command": "ahub check-path --hook", "timeout": 10 }]
7
+ }
8
+ ]
9
+ }
10
+ }
@@ -14,7 +14,7 @@
14
14
  "local": ["implementer", "verifier"],
15
15
  "pi": ["implementer", "verifier"]
16
16
  },
17
- "budget": { "gate": 0.9, "stale_min": 30, "poll_min": 10, "checkpoint_timeout_s": 90, "kimi_tokens_5h": 0 },
17
+ "budget": { "gate": 0.9, "stale_min": 30, "poll_min": 10, "checkpoint_timeout_s": 90, "kimi_tokens_5h": 0, "wait_max_min": 30 },
18
18
  "inference": { "enabled": true, "digest_min_items": 5, "digest_min_chars": 8000, "triage": true },
19
19
  "omniroute": {
20
20
  "urls": [],
@@ -23,9 +23,13 @@
23
23
  "cf_client_id_file": "",
24
24
  "cf_client_secret_file": ""
25
25
  },
26
- "local": { "deny": [], "bash_network": false, "max_steps": 30, "read_allow": [] },
26
+ "local": { "deny": [], "bash_network": false, "max_steps": 30, "read_allow": [], "sandbox": "deny-default" },
27
27
  "approvals": { "timeout_s": 120 },
28
28
  "tasks": { "release_after_min": 30 },
29
29
  "checks": { "timeout_s": 600 },
30
- "snapshots": { "enabled": true, "keep": 20 }
30
+ "snapshots": { "enabled": true, "keep": 20 },
31
+ "review": { "adaptive": false, "min_reviews": 5 },
32
+ "recovery": { "auto_resume_after_crash": false },
33
+ "capabilities": {},
34
+ "limits": { "sender_per_min": 12, "pair_per_min": 6, "important_per_hour": 6, "repeat_window_s": 120 }
31
35
  }