@staix/agent-hub 0.9.0 → 0.11.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.
@@ -135,6 +135,9 @@ export function assign(
135
135
  now?: number;
136
136
  /** Peers demoted for this task's class, with their recent failure weight (issue #36). */
137
137
  demoted?: Record<PeerId, number>;
138
+ /** Review record per implementer, then per reviewer, for this class (issue #35); followed only when `adaptive`. */
139
+ reviews?: Record<PeerId, Record<PeerId, { score: number; n: number }>>;
140
+ adaptive?: { min: number };
138
141
  } = {},
139
142
  ): Assignment {
140
143
  const policy = routing.classes[task.class];
@@ -177,10 +180,25 @@ export function assign(
177
180
  return out;
178
181
  };
179
182
  /** Demoted peers go behind the rest for this class (owners only); each group is then ordered by quota. */
180
- const rank = (ok: PeerId[], role: "owner" | "reviewer"): PeerId[] => {
183
+ /** Reviewers with enough recorded reviews of this owner's work swap places by how those reviews held up. */
184
+ const byRecord = (list: PeerId[], owner: PeerId | undefined): PeerId[] => {
185
+ const record = owner ? opts.reviews?.[owner] : undefined;
186
+ if (!record || !Object.keys(record).length) return list;
187
+ trace.push(` review record with ${owner} in ${task.class}: ${Object.entries(record).map(([r, s]) => `${r} ${s.n} reviews, ${Math.round(s.score * 100)}% held`).join("; ")}${opts.adaptive ? "" : " (review.adaptive is off)"}`);
188
+ if (!opts.adaptive) return list;
189
+ const known = (p: PeerId) => (record[p] && record[p].n >= opts.adaptive!.min ? record[p].score : undefined);
190
+ const slots = list.flatMap((p, i) => (known(p) === undefined ? [] : [i]));
191
+ const sorted = slots.map((i) => list[i]!).sort((a, b) => known(b)! - known(a)!);
192
+ const out = [...list];
193
+ slots.forEach((i, k) => (out[i] = sorted[k]!));
194
+ return out;
195
+ };
196
+ const rank = (ok: PeerId[], role: "owner" | "reviewer", owner?: PeerId): PeerId[] => {
181
197
  const down = role === "owner" ? ok.filter((p) => opts.demoted?.[p]) : [];
182
198
  if (down.length) trace.push(` demoted for ${task.class}: ${down.map((p) => `${p} (${opts.demoted![p]!.toFixed(1)} recent failures)`).join(", ")}`);
183
- return [...byDrain(ok.filter((p) => !down.includes(p))), ...byDrain(down)];
199
+ const ranked = [...byDrain(ok.filter((p) => !down.includes(p))), ...byDrain(down)];
200
+ // The review record comes after quota, so it decides among reviewers that have one: idle, then record, then quota.
201
+ return role === "reviewer" ? byRecord(ranked, owner) : ranked;
184
202
  };
185
203
 
186
204
  const pick = (list: PeerId[], role: "owner" | "reviewer", not?: PeerId): PeerId | undefined => {
@@ -190,7 +208,7 @@ export function assign(
190
208
  trace.push(` ${role} candidate ${peer}: ${why ? `skipped, ${why}` : states[peer]}`);
191
209
  if (!why) ok.push(peer);
192
210
  }
193
- const ranked = rank(ok, role);
211
+ const ranked = rank(ok, role, not);
194
212
  // Demotion applies to local and Pi too: a demoted one loses its place ahead of the cloud peers.
195
213
  const localTier = ranked.filter((p) => (p === LOCAL || p === PI) && !(role === "owner" && opts.demoted?.[p]));
196
214
  if (localTier.length) return localTier.find((p) => states[p] === "idle") ?? localTier[0]; // local/Pi stays ahead of an idle cloud peer
package/src/hub/tasks.ts CHANGED
@@ -27,6 +27,8 @@ export interface TasksDeps {
27
27
  recordOverlap?: (task: number, owner: PeerId, others: { task: number; owner: PeerId; paths: string[]; symbols?: string[] }[]) => void;
28
28
  /** Quota per peer from fresh readings (issue #36): routing drains the windows that reset soonest first. */
29
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 };
30
32
  /** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
31
33
  triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
32
34
  }
@@ -37,17 +39,24 @@ const ESCALATE_AFTER = 2;
37
39
  // ponytail: fixed constants; make them config when someone needs to tune them.
38
40
  const DEMOTION_HALF_LIFE_MS = 24 * 3_600_000;
39
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;
40
44
  const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
41
45
  /** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
42
46
  const QUIET_EVENTS = new Set(["answer", "reviewer changed"]);
43
47
 
48
+ /** A project path as one spelling (#67): no leading `./`, no repeated or trailing `/`; the root is `.`. */
49
+ export const normPath = (p: string) => p.replace(/^(\.\/)+/, "").replace(/\/{2,}/g, "/").replace(/\/+$/, "") || ".";
50
+
44
51
  /** Same path, or one is a directory of the other; the project root (`.`) holds everything. */
45
52
  export const samePlace = (a: string, b: string) => {
46
- const norm = (p: string) => p.replace(/^\.\//, "").replace(/\/+$/, "") || ".";
47
- const [x, y] = [norm(a), norm(b)];
53
+ const [x, y] = [normPath(a), normPath(b)];
48
54
  return x === "." || y === "." || x === y || x.startsWith(`${y}/`) || y.startsWith(`${x}/`);
49
55
  };
50
56
 
57
+ /** Events that hand a task to an owner (or take it away); newer history records that owner on them (#67). */
58
+ const OWNERSHIP_EVENTS = new Set(["assigned", "escalated", "reassigned", "unassigned"]);
59
+
51
60
  /** One line of model-written text: whitespace (newlines included) collapses, so it can never start a forged log line. */
52
61
  const text = (v: unknown) => (typeof v === "string" && v.trim() ? v.replace(/\s+/g, " ").trim().slice(0, 300) : undefined);
53
62
  /** A list of short strings; a lone string counts as a list of one. */
@@ -57,7 +66,7 @@ const fields = (input: unknown) => (input && typeof input === "object" && !Array
57
66
  /** Tool callers are models: inputSchema is not enforced on the way in, so refs are normalized before they reach the board. */
58
67
  function cleanRefs(input: unknown): TaskRefs {
59
68
  const r = fields(input);
60
- const paths = textList(r.paths);
69
+ const paths = [...new Set(textList(r.paths).map(normPath))];
61
70
  const out: TaskRefs = {};
62
71
  for (const k of ["repo", "branch", "commit"] as const) if (text(r[k])) out[k] = text(r[k])!;
63
72
  if (paths.length) out.paths = paths;
@@ -67,7 +76,7 @@ function cleanRefs(input: unknown): TaskRefs {
67
76
  /** The same for a plan (issue #31): only its four lists, each of short strings. */
68
77
  function cleanPlan(input: unknown): TaskPlan {
69
78
  const r = fields(input);
70
- return Object.fromEntries(PLAN_KEYS.map((k) => [k, textList(r[k])] as const).filter(([, v]) => v.length));
79
+ return Object.fromEntries(PLAN_KEYS.map((k) => [k, k === "paths" ? [...new Set(textList(r[k]).map(normPath))] : textList(r[k])] as const).filter(([, v]) => v.length));
71
80
  }
72
81
 
73
82
  /** A plan as one line for other owners; the whole plan is on the board. */
@@ -122,11 +131,66 @@ export class Tasks {
122
131
  return Object.fromEntries([...sums].filter(([, s]) => s.bad >= DEMOTE_AT && s.bad > s.good).map(([p, s]) => [p, s.bad]));
123
132
  }
124
133
 
125
- /** What assignment weighs besides states and policy: quota and demotion. */
134
+ /** How each reviewer's reviews of each implementer's work in a class held up (issue #35). */
135
+ reviewRecord(cls: TaskClass): Record<PeerId, Record<PeerId, { score: number; n: number }>> {
136
+ // Counted per task: a review that asked for changes and then approved is one review, not two.
137
+ const c = new Map<string, { reviewed: Set<number>; contradicted: Set<number> }>();
138
+ for (const r of this.d.board.reviews({ class: cls })) {
139
+ const key = `${r.implementer}\0${r.reviewer}`;
140
+ const s = c.get(key) ?? { reviewed: new Set<number>(), contradicted: new Set<number>() };
141
+ (r.kind === "contradicted" ? s.contradicted : s.reviewed).add(r.task);
142
+ c.set(key, s);
143
+ }
144
+ const out: Record<PeerId, Record<PeerId, { score: number; n: number }>> = {};
145
+ for (const [key, s] of c) {
146
+ const [implementer, reviewer] = key.split("\0") as [PeerId, PeerId];
147
+ const n = s.reviewed.size;
148
+ if (n) (out[implementer] ??= {})[reviewer] = { score: (n - [...s.contradicted].filter((t) => s.reviewed.has(t)).length) / n, n };
149
+ }
150
+ return out;
151
+ }
152
+
153
+ /** Who asked for changes on the current owner's work: requests made before the task changed hands were about someone else's. */
154
+ private requestedChanges(task: Task): Set<PeerId> {
155
+ // The window starts where the owner last changed. Newer entries carry the owner, so handing a task to the owner it
156
+ // already had (`ahub task assign <id> <owner>`) keeps the window; older ones do not, and any of them starts it.
157
+ let since = -1;
158
+ let owner: PeerId | null | undefined = null;
159
+ task.history.forEach((h, i) => {
160
+ if (!OWNERSHIP_EVENTS.has(h.event)) return;
161
+ if (h.owner === undefined || h.owner !== owner) since = i;
162
+ owner = h.owner;
163
+ });
164
+ return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested").map((h) => h.by));
165
+ }
166
+
167
+ /** What assignment weighs besides states and policy: quota, demotion and the review record. */
126
168
  private weights(cls: TaskClass) {
127
169
  const now = Date.now();
128
170
  const quota = this.d.quota?.();
129
- return { now, demoted: this.demoted(cls, now), ...(quota ? { quota } : {}) };
171
+ return { now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
172
+ }
173
+
174
+ /**
175
+ * Work on these places failed (a check failure, or changes requested): approvals of other tasks on the same places
176
+ * within the window were contradicted. Each approval counts once.
177
+ */
178
+ private contradict(failed: Task): void {
179
+ if (this.isPii(failed)) return; // its places are left out everywhere else too
180
+ const now = Date.now();
181
+ const mine = this.places(failed);
182
+ for (const t of this.d.board.list("approved")) {
183
+ if (t.id === failed.id || !t.owner || this.isPii(t)) continue;
184
+ const approval = [...t.history].reverse().find((h) => h.event === "approved");
185
+ if (!approval || now - approval.at > CONTRADICTION_WINDOW_MS || approval.by === USER) continue;
186
+ const theirs = this.places(t);
187
+ // Blame needs the same file or symbol: a directory or `.` would contradict every approval under it. Older rows
188
+ // were stored as written, so both sides are compared in one spelling.
189
+ const file = (p: string) => normPath(p) !== "." && theirs.paths.some((q) => normPath(q) === normPath(p));
190
+ const same = mine.paths.some(file) || mine.symbols.some((x) => theirs.symbols.includes(x));
191
+ if (!same || this.d.board.reviews({ task: t.id }).some((r) => r.kind === "contradicted")) continue;
192
+ this.d.board.recordReview({ implementer: t.owner, reviewer: approval.by, class: t.class, kind: "contradicted", task: t.id });
193
+ }
130
194
  }
131
195
 
132
196
  async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown }): Promise<Task> {
@@ -202,6 +266,18 @@ export class Tasks {
202
266
  /** Whether a model-written name may be shown to other peers and in the log: one matching a PII pattern may be PII. */
203
267
  nameable = (t: string): boolean => !this.isPii({ signals: detectSignals({ title: "", detail: t, refs: {} }, this.d.routing(), this.d.cwd) });
204
268
 
269
+ /**
270
+ * Model-written free text about an ordinary task (a done summary, a review note with its unmet items, a handoff)
271
+ * that matches a PII pattern stays on this machine (#69): every peer gets a stub, `local` too, which handles an
272
+ * ordinary task's turn like any other (gateway off campus allowed, memory capture on). The board keeps the text for
273
+ * `ahub task show`. A PII task's messages are private already.
274
+ */
275
+ private screen(task: Task, text: string, what: string, to: PeerId | null | undefined): string {
276
+ if (!text || this.isPii(task) || to === USER || this.nameable(text)) return text;
277
+ this.d.notify(`task #${task.id}: the ${what} was withheld from ${to ?? "a peer"} (it matches a PII pattern)`);
278
+ return `[${what} withheld: it matches a PII pattern; ahub task show ${task.id}]`;
279
+ }
280
+
205
281
  /** Where two tasks meet: shared paths, then shared symbols, leaving out any name that matches a PII pattern. */
206
282
  private where(hit: { paths: string[]; symbols: string[] }): string {
207
283
  const shown = [...hit.paths.filter(this.nameable), ...hit.symbols.filter(this.nameable).map((s) => `symbol ${s}`)];
@@ -319,7 +395,7 @@ export class Tasks {
319
395
  private async sendTask(task: Task, a: Assignment, context?: string, overlap = ""): Promise<void> {
320
396
  const pii = this.isPii(task);
321
397
  const brief = pii ? undefined : await this.d.briefs?.forTask(task.owner!, task).catch(() => undefined);
322
- const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${h.note ?? ""}`);
398
+ const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", a.owner)}`);
323
399
  const facts = [`class ${task.class}`, a.owner === PI ? `backend pi/${a.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", a.reviewer ? `reviewer ${a.reviewer}` : "no reviewer"].filter(Boolean).join("; ");
324
400
  const plan = planText(task.plan);
325
401
  const body = [
@@ -331,7 +407,7 @@ export class Tasks {
331
407
  rejected.length ? `Earlier review notes:\n${rejected.join("\n")}` : "",
332
408
  brief ?? "",
333
409
  // What the previous owner left behind. Peer-written free text: never attached to a PII task.
334
- context && !pii ? `Handoff from the previous owner:\n${context.slice(0, 3000)}` : "",
410
+ context && !pii ? `Handoff from the previous owner:\n${this.screen(task, context, "handoff", a.owner).slice(0, 3000)}` : "",
335
411
  `Take it with hub_task_accept {id: ${task.id}, plan: {paths, symbols, signatures, insertion_points}} (what you will change, before you start${pii ? "" : "; owners of overlapping tasks see it"}) or pass with hub_task_decline. When finished: hub_task_done {id: ${task.id}, summary: what changed, why, and the check you ran with its result, refs}.`,
336
412
  ].filter(Boolean).join("\n\n");
337
413
  this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner!], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id) }, ...(pii ? { private: true } : {}) }));
@@ -382,8 +458,11 @@ export class Tasks {
382
458
  }
383
459
 
384
460
  private need(id: unknown, open = false): Task {
385
- const task = this.d.board.get(Number(id));
386
- if (!task) throw new Error(`no task #${id}`);
461
+ // Callers are models (#70): an id is a whole number or a digit string, never an array that Number() would accept.
462
+ const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
463
+ if (!Number.isInteger(n)) throw new Error(`id must be a task number, not ${(typeof id === "number" ? String(id) : JSON.stringify(id ?? null)).slice(0, 60)}`);
464
+ const task = this.d.board.get(n);
465
+ if (!task) throw new Error(`no task #${n}`);
387
466
  // Handing a task to someone else only makes sense while there is work left on it.
388
467
  if (open && !OPEN.includes(task.state)) throw new Error(`task #${task.id} is ${task.state}: it can no longer change hands`);
389
468
  return task;
@@ -482,6 +561,7 @@ export class Tasks {
482
561
  }
483
562
  this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim());
484
563
  if (task.owner) this.d.board.recordOutcome(task.owner, task.class, false);
564
+ this.contradict(task);
485
565
  this.d.notify(`task ${this.publicTitle(task)}: its check failed (${outcome}); it stays with ${task.owner ?? by}`);
486
566
  this.tell(task, `Task #${id}: its check failed.\n$ ${outcome}${result.tail ? `\n${result.tail}` : ""}\nFix it and call hub_task_done again.`, pii);
487
567
  }
@@ -531,36 +611,56 @@ export class Tasks {
531
611
  const r = task.refs;
532
612
  const last = [...task.history].reverse().find((h) => h.event === "done");
533
613
  const where = [r.branch ? `branch ${r.branch}` : "", r.commit ? `commit ${r.commit}` : "", r.paths?.length ? `paths ${r.paths.join(", ")}` : ""].filter(Boolean).join("; ");
534
- 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}.`;
614
+ // A checklist that maps the change to its contract (issue #35): reviewers who check against the written plan catch more.
615
+ const plan = planText(task.plan);
616
+ const check = [...task.history].reverse().find((h) => h.event === "check passed");
617
+ const checklist = [
618
+ "Checklist:",
619
+ `- 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.`,
620
+ `- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
621
+ `- List what is unmet in hub_review's unmet, one item each.`,
622
+ ].join("\n");
623
+ const summary = last?.note ? this.screen(task, last.note, "summary", reviewer) : "(no summary)";
624
+ const body = `Review task #${task.id} [${task.class}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${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}.`;
535
625
  this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, task: String(task.id) }, ...(this.isPii(task) ? { private: true } : {}) }));
536
626
  }
537
627
 
538
- async review(by: PeerId, id: unknown, verdict: unknown, note?: string): Promise<Task> {
628
+ async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown): Promise<Task> {
539
629
  const task = this.need(id);
540
630
  this.mine(task, by, "reviewer");
541
631
  if (verdict !== "approved" && verdict !== "changes_requested") throw new Error('verdict must be "approved" or "changes_requested"');
542
632
  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`);
543
633
  const pii = this.isPii(task);
634
+ const items = textList(unmet);
635
+ if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
544
636
  if (verdict === "approved") {
545
637
  const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0 }, note);
546
- if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true);
638
+ if (next.owner) {
639
+ this.d.board.recordOutcome(next.owner, next.class, true);
640
+ this.d.board.recordReview({ implementer: next.owner, reviewer: by, class: next.class, kind: "approved", task: next.id });
641
+ // Changes requested on this owner's work and the redo passed: those reviews caught something.
642
+ for (const r of this.requestedChanges(task)) {
643
+ this.d.board.recordReview({ implementer: next.owner, reviewer: r, class: next.class, kind: "caught", task: next.id });
644
+ }
645
+ }
547
646
  this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`);
548
- this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${note}` : ""}`, pii);
647
+ this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner)}` : ""}`, pii);
549
648
  await this.releaseDependents(next);
550
649
  return next;
551
650
  }
552
651
  const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note);
553
652
  if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
653
+ this.contradict(rejected);
554
654
  this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`);
555
655
  if (rejected.rejections >= ESCALATE_AFTER) {
556
656
  const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`);
557
657
  if (moved.owner !== rejected.owner) return moved;
558
658
  // Nobody to escalate to: the owner still has to hear the verdict and the note.
559
- this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${note}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii);
659
+ this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${this.screen(moved, note, "review note", moved.owner)}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii);
560
660
  return moved;
561
661
  }
562
662
  const reopened = this.d.board.update(rejected.id, HUB, "reopened", { state: "in_progress" });
563
- this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${note}` : ""} Fix it and call hub_task_done again.`, pii);
663
+ this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${this.screen(reopened, note, "review note", reopened.owner)}` : ""} Fix it and call hub_task_done again.`, pii);
564
664
  return reopened;
565
665
  }
566
666
 
@@ -573,6 +673,8 @@ export class Tasks {
573
673
  // The hub's own escalations are not counted: after repeated changes_requested each one already was, and after a
574
674
  // Pi inference failure the backend failed, not the work. An escalation by hand counts on its own.
575
675
  if (from && by !== HUB) this.d.board.recordOutcome(from, task.class, false);
676
+ // Only a reviewer that asked for changes on this work saw it fail: not an escalation of unreviewed work (a Pi failure).
677
+ 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 });
576
678
  const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", note: `${why}; from ${from ?? "none"}`, context: why });
577
679
  if (next.owner && next.owner !== from) {
578
680
  this.d.notify(`task ${this.publicTitle(next)} escalated from ${from} to ${next.owner} (${why})`);
@@ -648,6 +750,8 @@ export class Tasks {
648
750
  /** Auto notes: only transitions that carry content, never for PII. */
649
751
  private note(task: Task, by: PeerId, kind: string, text: string): void {
650
752
  if (this.isPii(task) || !this.d.memory) return;
753
+ // claude-mem's observer is a cloud model: model-written text that matches a PII pattern is not sent (#69).
754
+ if (!this.nameable(text)) return this.d.notify(`task #${task.id}: a ${kind} note was not saved to shared memory (it matches a PII pattern)`);
651
755
  void this.d.memory.save({ text, title: `agent-hub task #${task.id}: ${task.title}`.slice(0, 120), project: this.d.project, metadata: { peer: by, task: task.id, kind } });
652
756
  }
653
757
 
package/src/local/deny.ts CHANGED
@@ -30,14 +30,14 @@ export const sbplString = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"
30
30
  * Seatbelt regex filters equivalent to isDenied. Seatbelt sees absolute paths, so the `local.deny` substrings are
31
31
  * anchored under the project root: a bare "private/" would otherwise match /private/var/... and deny the whole temp tree.
32
32
  */
33
- export function denyRegexes(root: string, extra: string[] = []): string[] {
33
+ export function denyRegexes(root: string, extra: string[] = [], withNames = true): string[] {
34
34
  const names = DENY_NAMES.map((re) => {
35
35
  const body = re.startsWith("^") ? re.slice(1) : `[^/]*${re}`;
36
36
  return `/${body}${body.endsWith("$") ? "" : "[^/]*$"}`;
37
37
  });
38
38
  return [
39
39
  ...DENY_SEGMENTS.map((s) => `/${sbplEscape(s)}(/|$)`),
40
- ...names,
40
+ ...(withNames ? names : []),
41
41
  ...extra.filter(Boolean).map((e) => `^${sbplEscape(root)}/.*${sbplEscape(e)}`),
42
42
  ].map((re) => `(regex ${sbplString(re)})`);
43
43
  }
@@ -0,0 +1,142 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { BlockList, isIP, connect, createServer, type Server, type Socket } from "node:net";
3
+
4
+ /**
5
+ * The only way out for the local worker's commands when `local.bash_network` is on (issue #65): an HTTP CONNECT proxy
6
+ * on loopback that opens tunnels to allowlisted hosts only. CONNECT only, so TLS stays end to end; plain HTTP is
7
+ * refused. A name that resolves to a loopback, private or link-local address is refused unless it is listed as an
8
+ * address: claude-mem and the Codex app-server listen on loopback without auth.
9
+ */
10
+
11
+ /** What a project gets unless `local.network_allow` says otherwise: package registries and code hosts. */
12
+ export const DEFAULT_NETWORK_ALLOW = [
13
+ "registry.npmjs.org",
14
+ "registry.yarnpkg.com",
15
+ "pypi.org",
16
+ "files.pythonhosted.org",
17
+ "github.com",
18
+ "codeload.github.com",
19
+ "objects.githubusercontent.com",
20
+ "raw.githubusercontent.com",
21
+ "crates.io",
22
+ "static.crates.io",
23
+ "index.crates.io",
24
+ "proxy.golang.org",
25
+ "sum.golang.org",
26
+ ];
27
+
28
+ export interface EgressProxy {
29
+ port: number;
30
+ url: string;
31
+ close(): Promise<void>;
32
+ }
33
+
34
+ /** `host` or `host:port`; a name also matches its subdomains, an address only itself. Port 443 unless named. */
35
+ export function allowed(list: string[], host: string, port: number): boolean {
36
+ const h = host.toLowerCase().replace(/\.$/, "");
37
+ return list.some((raw) => {
38
+ const entry = raw.trim().toLowerCase();
39
+ const at = entry.lastIndexOf(":");
40
+ const [name, p] = at > 0 && !entry.includes("]") && /^\d+$/.test(entry.slice(at + 1)) ? [entry.slice(0, at), Number(entry.slice(at + 1))] : [entry, 443];
41
+ if (!name || p !== port) return false;
42
+ return h === name || (!isIP(name) && h.endsWith(`.${name}`));
43
+ });
44
+ }
45
+
46
+ // Parsed, not prefix-matched: an IPv4-mapped IPv6 address in any spelling (`::ffff:7f00:1`) checks as its IPv4 address.
47
+ const INTERNAL = new BlockList();
48
+ // IPv4 nets as leading octets: the package check refuses private address literals in published files.
49
+ for (const [a, b, bits] of [[0, 0, 8], [10, 0, 8], [100, 64, 10], [127, 0, 8], [169, 254, 16], [172, 16, 12], [192, 168, 16], [198, 18, 15], [224, 0, 3]] as const) INTERNAL.addSubnet(`${a}.${b}.0.0`, bits, "ipv4");
50
+ for (const [net, bits] of [["::", 96], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8]] as const) INTERNAL.addSubnet(net, bits, "ipv6");
51
+
52
+ /** Loopback, private, link-local, site-local, unspecified, carrier-grade NAT, benchmark, multicast or reserved. */
53
+ export function isInternal(address: string): boolean {
54
+ const family = isIP(address);
55
+ return family !== 0 && INTERNAL.check(address, family === 6 ? "ipv6" : "ipv4");
56
+ }
57
+
58
+ const hostOf = (target: string) => {
59
+ try {
60
+ return ` ${new URL(target).host}`;
61
+ } catch {
62
+ return "";
63
+ }
64
+ };
65
+
66
+ export function startEgressProxy(opts: { allow: string[]; log: (line: string) => void }): Promise<EgressProxy> {
67
+ const sockets = new Set<Socket>();
68
+ // Half-open on both sides: a client that sends its request and shuts its write side still gets the answer.
69
+ const server: Server = createServer({ allowHalfOpen: true }, (client) => {
70
+ sockets.add(client);
71
+ client.on("close", () => sockets.delete(client));
72
+ client.on("error", () => client.destroy());
73
+ let head = Buffer.alloc(0);
74
+ const onData = (chunk: Buffer) => {
75
+ head = Buffer.concat([head, chunk]);
76
+ const end = head.indexOf("\r\n\r\n");
77
+ if (end < 0) {
78
+ if (head.length > 8192) refuse(client, 431, "request header too large");
79
+ return;
80
+ }
81
+ client.off("data", onData);
82
+ client.pause();
83
+ const rest = head.subarray(end + 4);
84
+ const [method = "", target = "", version = ""] = head.subarray(0, end).toString("latin1").split("\r\n")[0]!.split(" ");
85
+ // Answer in the version asked (macOS nc wants HTTP/1.0 back); curl and git take either.
86
+ void tunnel(client, method, target, rest, version === "HTTP/1.0" ? "HTTP/1.0" : "HTTP/1.1");
87
+ };
88
+ client.on("data", onData);
89
+ });
90
+
91
+ const refuse = (client: Socket, code: number, why: string, target = "") => {
92
+ if (target) opts.log(`network: refused ${target} (${why})`);
93
+ client.end(`HTTP/1.1 ${code} ${code === 403 ? "Forbidden" : "Bad Request"}\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nagent-hub: ${why}\n`);
94
+ };
95
+
96
+ const tunnel = async (client: Socket, method: string, target: string, rest: Buffer, http: string) => {
97
+ // The log gets a method and a host, never a path, a query or userinfo: those carry tokens.
98
+ if (method.toUpperCase() !== "CONNECT") return refuse(client, 403, "only CONNECT (https) goes through this proxy", `${/^[A-Za-z]{1,16}$/.test(method) ? method : "a request"}${hostOf(target)}`);
99
+ const m = /^(\[[^\]]+\]|[^:]+):(\d{1,5})$/.exec(target);
100
+ const host = m?.[1]!.replace(/^\[|\]$/g, "") ?? "";
101
+ if (!m || !(isIP(host) || /^[a-z0-9.-]+$/i.test(host))) return refuse(client, 400, "bad CONNECT target", "a CONNECT request");
102
+ const port = Number(m[2]);
103
+ if (!allowed(opts.allow, host, port)) return refuse(client, 403, `${host}:${port} is not in local.network_allow`, `${host}:${port}`);
104
+ let address = host;
105
+ if (!isIP(host)) {
106
+ try {
107
+ address = (await lookup(host)).address;
108
+ } catch {
109
+ return refuse(client, 403, `${host} does not resolve`, `${host}:${port}`);
110
+ }
111
+ // A listed name must not lead back into this machine or its network (DNS can say anything).
112
+ if (isInternal(address)) return refuse(client, 403, `${host} resolves to an internal address`, `${host}:${port}`);
113
+ }
114
+ const upstream = connect({ host: address, port, allowHalfOpen: true });
115
+ sockets.add(upstream);
116
+ upstream.on("close", () => sockets.delete(upstream));
117
+ upstream.once("connect", () => {
118
+ client.write(`${http} 200 Connection Established\r\n\r\n`);
119
+ if (rest.length) upstream.write(rest);
120
+ client.pipe(upstream);
121
+ upstream.pipe(client);
122
+ client.resume();
123
+ });
124
+ upstream.on("error", () => (client.writable && !client.destroyed ? refuse(client, 403, `${host}:${port} is unreachable`) : client.destroy()));
125
+ client.on("close", () => upstream.destroy());
126
+ };
127
+
128
+ return new Promise((resolve, reject) => {
129
+ server.once("error", reject);
130
+ server.listen(0, "127.0.0.1", () => {
131
+ const port = (server.address() as { port: number }).port;
132
+ resolve({
133
+ port,
134
+ url: `http://127.0.0.1:${port}`,
135
+ close: () => new Promise((done) => {
136
+ for (const s of sockets) s.destroy();
137
+ server.close(() => done());
138
+ }),
139
+ });
140
+ });
141
+ });
142
+ }