@staix/agent-hub 0.12.28 → 0.12.30

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 (68) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +79 -219
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +1 -1
  5. package/docs/agent-notes/bus.md +3 -1
  6. package/docs/agent-notes/daemon.md +13 -0
  7. package/docs/agent-notes/local-worker.md +2 -2
  8. package/docs/agent-notes/tasks.md +9 -0
  9. package/docs/agent-notes/tests.md +1 -1
  10. package/docs/memory.md +70 -152
  11. package/docs/operations.md +237 -14
  12. package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
  13. package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
  14. package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
  15. package/docs/plans/amem-cases.json +482 -0
  16. package/docs/quickstart.md +9 -2
  17. package/docs/security.md +39 -2
  18. package/docs/smoke.md +65 -0
  19. package/docs/specs/2026-09-19-agent-hub-design.md +113 -5
  20. package/docs/verification/2026-10-16-0.12.29.md +25 -0
  21. package/docs/verification/2026-10-16-273-live-verification.md +150 -0
  22. package/docs/verification/2026-10-17-0.12.30.md +70 -0
  23. package/docs/verified.json +13 -13
  24. package/package.json +5 -2
  25. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  26. package/plugins/agent-hub/server.js +64 -44
  27. package/src/cli/console-state.ts +92 -20
  28. package/src/cli/console.ts +25 -6
  29. package/src/cli/help.ts +4 -2
  30. package/src/cli/identity-audit.ts +2 -2
  31. package/src/cli/identity.ts +15 -1
  32. package/src/cli/main.js +2 -2
  33. package/src/cli/main.ts +78 -39
  34. package/src/cli/memory.ts +87 -187
  35. package/src/cli/output.ts +6 -0
  36. package/src/cli/recovery-package.ts +59 -10
  37. package/src/cli/upgrade-runtime.ts +60 -18
  38. package/src/cli/upgrade.ts +8 -0
  39. package/src/hub/board.ts +2 -1
  40. package/src/hub/bus.ts +33 -12
  41. package/src/hub/daemon.ts +141 -31
  42. package/src/hub/envelope.ts +30 -0
  43. package/src/hub/events.ts +1 -1
  44. package/src/hub/git.ts +2 -38
  45. package/src/hub/hub-tools.ts +5 -1
  46. package/src/hub/lifecycle.ts +2 -2
  47. package/src/hub/manager.ts +15 -11
  48. package/src/hub/memory-candidates.ts +103 -0
  49. package/src/hub/memory-fail.ts +25 -0
  50. package/src/hub/memory-observations.ts +47 -0
  51. package/src/hub/memory-plan-guard.ts +33 -0
  52. package/src/hub/memory-policy.ts +42 -0
  53. package/src/hub/memory-profile-check.ts +170 -0
  54. package/src/hub/memory-profile.ts +192 -0
  55. package/src/hub/memory-staleness.ts +55 -0
  56. package/src/hub/path-policy.ts +8 -0
  57. package/src/hub/project.ts +4 -37
  58. package/src/hub/read-tools.ts +127 -0
  59. package/src/hub/tasks.ts +201 -41
  60. package/src/hub/ui.ts +10 -7
  61. package/src/local/deny.ts +4 -17
  62. package/src/memory/brief.ts +8 -5
  63. package/src/memory/capture.ts +2 -1
  64. package/src/memory/recall.ts +50 -110
  65. package/src/pi/process-signature.ts +2 -42
  66. package/src/memory/import.ts +0 -90
  67. package/src/memory/store.ts +0 -805
  68. package/src/memory/sync.ts +0 -145
package/src/hub/tasks.ts CHANGED
@@ -1,16 +1,19 @@
1
+ import { composeRememberText } from "./memory-fail.ts";
1
2
  import { readGitHubImport, type GitHubSource, type GitHubImportSelection } from "./github-import.ts";
2
3
  import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
3
4
  import { readMergeQueue } from "./sdlc-queue.ts";
4
5
  import { readReleaseReadiness } from "./sdlc-release.ts";
5
6
  import { loadRoleCatalog, taskRole, taskRoleText, type RoleCatalog } from "./roles.ts";
6
7
  import { matchesPolicy } from "./path-policy.ts";
7
- import { mentionsPrivatePath } from "../memory/recall.ts";
8
+ import { correctionInput, resolveVisibleMemory } from "./memory-candidates.ts";
9
+ import type { MemoryScreen } from "./read-tools.ts";
10
+ import { withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
8
11
  import { profileClass, profileData, profileTaskLabel, projectProfile, type ProfileName, type ProfileSelection, type ProjectProfile } from "./profiles.ts";
9
12
  import { phaseView, validatePhaseLinks, workflowHash } from "./workflow.ts";
10
13
  import { isAbsolute, relative } from "node:path";
11
- import type { Briefs } from "../memory/brief.ts";
14
+ import type { Briefs, NativeItem } from "../memory/brief.ts";
12
15
  import type { MemoryClient } from "../memory/client.ts";
13
- import { extractNodes, looksSecret, PROMOTED_KINDS, taskKey, type GoalView, type Kind, type MemoryStore, type Scope, type Tier } from "../memory/store.ts";
16
+ import { candidateTargetRevision, type CandidateInput, type CandidateStore, extractNodes, looksSecret, PROMOTED_KINDS, taskKey, type GoalView, type Kind, type MemoryStore, type Scope, type Tier } from "@staix/agent-mem";
14
17
  import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskHasStarted, planningUndoEntry, type HistoryEntry, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
15
18
  import type { Bus } from "./bus.ts";
16
19
  import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
@@ -45,7 +48,15 @@ export interface TasksDeps {
45
48
  projectedReview?: (task: Task) => string | undefined;
46
49
  sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
47
50
  briefs?: Briefs;
51
+ /** Screened live fail notes directly intersecting the task plan or refs; read-only advice. */
52
+ planFails?: (task: Task) => NativeItem[];
53
+ candidates?: CandidateStore;
54
+ candidateScreen?: MemoryScreen;
55
+ /** Authenticated live turn context, not a model-supplied flag. */
56
+ privateTurn?: (peer: PeerId) => boolean;
48
57
  memory?: MemoryClient;
58
+ /** Captured native profile fence at our memory operation boundaries; no board mutation. */
59
+ memoryCheck?: () => void;
49
60
  /** #258: the hub's own store, the project name it files entries under (the git chain) and the repo `#N` issues belong to. */
50
61
  store?: { store: MemoryStore; project: string; repo: string; deny?: string[]; onNote?: (id: string) => void };
51
62
  /** a line for the human: console tail and hub.log */
@@ -138,6 +149,14 @@ export class AssignmentUndeliveredError extends Error {
138
149
  }
139
150
  }
140
151
 
152
+ /** A later task-flow event won before the assignment could be published, so the stale envelope is suppressed. */
153
+ export class AssignmentSupersededError extends Error {
154
+ constructor(task: Task) {
155
+ super(`task #${task.id}: assignment was superseded before publication`);
156
+ this.name = "AssignmentSupersededError";
157
+ }
158
+ }
159
+
141
160
  /** A project path as one spelling (#67): no leading `./`, no repeated or trailing `/`; the root is `.`. */
142
161
  export const normPath = (p: string) => p.replace(/^(\.\/)+/, "").replace(/\/{2,}/g, "/").replace(/\/+$/, "") || ".";
143
162
 
@@ -771,7 +790,7 @@ export class Tasks {
771
790
  const childIds = new Set(entry.planning.split.children.map(child => child.id)), budgets = this.d.executionBudget?.status();
772
791
  if (Array.isArray(budgets) && budgets.some(budget => budget.kind === "task" && budget.taskId !== undefined && childIds.has(budget.taskId))) throw new PreEffectToolRefusal("split children are referenced by execution budgets");
773
792
  if (this.d.store) {
774
- try { this.d.store.store.refresh(); } catch { throw new PreEffectToolRefusal("split undo requires current memory link evidence"); }
793
+ try { this.d.memoryCheck?.(); this.d.store.store.refresh(); } catch { throw new PreEffectToolRefusal("split undo requires current memory link evidence"); }
775
794
  const children = new Set(entry.planning.split.children.map(child => this.d.board.get(child.id)).filter((child): child is Task => !!child).map(child => this.memoryKey(child)));
776
795
  if ([...children].some(key => this.d.store!.store.hasTask(key)) || this.d.store.store.goals().some(goal => goal.links.some(link => children.has(link.task)))) throw new PreEffectToolRefusal("split children are referenced by long-term tasks");
777
796
  }
@@ -1048,6 +1067,7 @@ export class Tasks {
1048
1067
  if (v?.label === "unknown") return;
1049
1068
  if (v?.label !== "clear") return void this.d.board.update(task.id, HUB, "screened", {}, v ? `pii: screen, ${v.category}` : "pii: regex");
1050
1069
  const lifted = this.d.board.update(task.id, HUB, "screened", { signals: now.signals.filter((s) => s !== "pii") }, "clear, screened again");
1070
+ if (this.personUnassigned(lifted)) return;
1051
1071
  this.d.notify(`task ${this.publicTitle(lifted)}: the PII screen cleared it on a second look; it is routed as usual`);
1052
1072
  const still = this.waitsFor(lifted);
1053
1073
  if (still.length) return void this.d.board.update(lifted.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
@@ -1282,7 +1302,7 @@ export class Tasks {
1282
1302
  try {
1283
1303
  const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
1284
1304
  const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", reason: "offline", note: why });
1285
- moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner });
1305
+ if (this.ownOwnerMove(back, next, HUB, "reassigned")) moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner });
1286
1306
  // The gone owner hears it on its next delivery, if it comes back mid-work.
1287
1307
  if (next.owner && next.owner !== peer) this.d.tell?.(peer, noteLine(HUB, "decision", `task #${task.id} moved to ${next.owner} while you were offline; stop working on it`));
1288
1308
  } catch (e) {
@@ -1329,6 +1349,19 @@ export class Tasks {
1329
1349
  */
1330
1350
  private excluded = (task: Task) => task.history.flatMap((h) => (h.event === "declined" ? [h.from ?? h.by] : h.event === "escalated" && h.from ? [h.from] : []));
1331
1351
 
1352
+ private checkFlowRevision(task: Task, expected: number | undefined, preEffect = false): void {
1353
+ if (expected !== undefined && (!Number.isSafeInteger(expected) || expected < 0 || task.history.length !== expected)) {
1354
+ throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: flow revision changed; reload and retry`);
1355
+ }
1356
+ }
1357
+
1358
+ /** A person's owner removal sticks until a later explicit assignment or reservation, across hub runs. */
1359
+ private personUnassigned(task: Task): boolean {
1360
+ const removed = task.history.findLastIndex((h) => h.event === "unassigned" && h.by === USER);
1361
+ if (removed < 0) return false;
1362
+ return !task.history.slice(removed + 1).some((h) => h.by === USER && ["assigned", "reassigned", "escalated", "reserved", "backlog pulled"].includes(h.event));
1363
+ }
1364
+
1332
1365
  /**
1333
1366
  * Says on the console and hub.log that routing passed over the reserved owner (#207); returns the history note's suffix.
1334
1367
  * Only news is said: not when the reserved owner is the one the task moves away from, nor once it refused the task for
@@ -1356,8 +1389,17 @@ export class Tasks {
1356
1389
  if (hold) this.d.notify(`task ${this.publicTitle(task)}: ${a.owner}'s queue is held (${hold}); the task arrives once the hold is resolved`);
1357
1390
  }
1358
1391
 
1392
+ /** A later move to another owner means the caller's assignment was superseded, so its success tail is not authoritative. */
1393
+ private ownOwnerMove(before: Task, after: Task, by: PeerId, event: string): boolean {
1394
+ const moves = (task: Task) => task.history.filter(entry => ["assigned", "reassigned", "escalated", "unassigned"].includes(entry.event));
1395
+ const prior = moves(before).length, entries = moves(after).slice(prior), own = entries[0];
1396
+ if (!own || own.by !== by || own.event !== event) return false;
1397
+ if (own.event === "unassigned") return entries.length === 1;
1398
+ return entries.every(entry => entry.owner === own.owner);
1399
+ }
1400
+
1359
1401
  /** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
1360
- private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean } = {}): Promise<Task> {
1402
+ private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number } = {}): Promise<Task> {
1361
1403
  if (task.state === "backlog") throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
1362
1404
  const waits = this.waitsFor(task);
1363
1405
  const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
@@ -1384,7 +1426,7 @@ export class Tasks {
1384
1426
  // A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
1385
1427
  // out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
1386
1428
  const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
1387
- const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, `${opts.note ?? `to ${a.owner}`}${passed}`, { ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) });
1429
+ const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, `${opts.note ?? `to ${a.owner}`}${passed}`, { ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) }, opts.expectedFlowRevision);
1388
1430
  // What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
1389
1431
  // escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
1390
1432
  // yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
@@ -1398,6 +1440,7 @@ export class Tasks {
1398
1440
  // The cohort record formed just below reads it, and so does `route explain` while that turn lasts.
1399
1441
  if (opts.claim && a.owner === by && this.d.bus.peers.get(by)?.state === "busy") this.sent.set(handOver(next, by), by);
1400
1442
  try {
1443
+ if (by === USER && opts.event === "reassigned") this.withdrawRoleRequest(task, next, "owner");
1401
1444
  const hits = this.overlapHits(next);
1402
1445
  this.formCohort(next, hits);
1403
1446
  if (hits.length) {
@@ -1419,7 +1462,10 @@ export class Tasks {
1419
1462
  // result landing during the brief) leave the assignment exactly as undelivered as before: the notice and
1420
1463
  // the typed error stand (#297 Decisions).
1421
1464
  const moves = (entries: Task["history"]) => entries.filter((h) => !QUIET_EVENTS.has(h.event) && !h.event.startsWith("check "));
1422
- if (moves(saved.history).length !== moves(next.history).length) throw e;
1465
+ if (moves(saved.history).length !== moves(next.history).length) {
1466
+ if (e instanceof AssignmentSupersededError) return saved;
1467
+ throw e;
1468
+ }
1423
1469
  this.releaseNotice(this.undeliveredNotice(saved, e));
1424
1470
  throw new AssignmentUndeliveredError(saved, e);
1425
1471
  }
@@ -1465,13 +1511,22 @@ export class Tasks {
1465
1511
  return `Task role ${task.role}: definition unavailable or exceeds the task guidance limit; grants no authority.`;
1466
1512
  }
1467
1513
 
1514
+ private ownerAssignmentCurrent(assigned: Task, current: Task): boolean {
1515
+ if (current.owner !== assigned.owner) return false;
1516
+ const moves = (entries: Task["history"]) => entries.filter((h) => !QUIET_EVENTS.has(h.event) && !h.event.startsWith("check "));
1517
+ return moves(current.history).length === moves(assigned.history).length;
1518
+ }
1519
+
1468
1520
  /** `contextWithheld`: the PII screen did not clear a budget hand-off (#198); an escalation's reason is the hub's own words. */
1469
1521
  private async sendTask(task: Task, a: Assignment, context?: string, overlap = "", contextWithheld = false): Promise<void> {
1470
- const pii = this.isPii(task);
1471
- const brief = pii ? undefined : await this.d.briefs?.forTask(task.owner!, task).catch(() => undefined);
1472
- const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", a.owner, h.withheld)}`);
1473
- const handoff = context && !pii ? this.screen(task, context, "handoff", a.owner, contextWithheld).slice(0, 3000) : "";
1474
- const facts = [`class ${profileTaskLabel(task)}`, 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}` : task.class === "review" ? "no reviewer" : this.noReviewer(a)].filter(Boolean).join("; ");
1522
+ const brief = this.isPii(task) ? undefined : await this.d.briefs?.forTask(task.owner!, task, this.planGuardItems(task)).catch(() => undefined);
1523
+ const current = this.d.board.get(task.id);
1524
+ if (!current || !this.ownerAssignmentCurrent(task, current)) throw new AssignmentSupersededError(current ?? task);
1525
+ task = current;
1526
+ const pii = this.isPii(task), delivery = { ...a, reviewer: task.reviewer ?? undefined };
1527
+ const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", delivery.owner, h.withheld)}`);
1528
+ const handoff = context && !pii ? this.screen(task, context, "handoff", delivery.owner, contextWithheld).slice(0, 3000) : "";
1529
+ const facts = [`class ${profileTaskLabel(task)}`, delivery.owner === PI ? `backend pi/${delivery.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", delivery.reviewer ? `reviewer ${delivery.reviewer}` : task.class === "review" ? "no reviewer" : this.noReviewer(delivery)].filter(Boolean).join("; ");
1475
1530
  const plan = planText(task.plan);
1476
1531
  const body = [
1477
1532
  `Task #${task.id} [${profileTaskLabel(task)}] ${task.title}`,
@@ -1554,7 +1609,7 @@ export class Tasks {
1554
1609
  async releaseReady(): Promise<void> {
1555
1610
  for (const t of this.d.board.list("proposed")) {
1556
1611
  const last = t.history.findLast(entry => !QUIET_EVENTS.has(entry.event))?.event, planned = this.plannerPullRecord(t)?.pull === true;
1557
- if ((!t.deps?.length && !t.history.some(h => h.event === "backlog pulled")) || t.owner || this.offered.has(t.id) || (!planned && last !== "blocked" && last !== "ready" && last !== "reserved") || this.waitsFor(t).length) continue;
1612
+ if ((!t.deps?.length && !t.history.some(h => h.event === "backlog pulled")) || t.owner || this.personUnassigned(t) || this.offered.has(t.id) || (!planned && last !== "blocked" && last !== "ready" && last !== "reserved") || this.waitsFor(t).length) continue;
1558
1613
  if (planned && !this.plannerAssignment(t, true).owner) continue;
1559
1614
  // ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
1560
1615
  // an offline one; hold reserved tasks for a grace period if restarts show that up.
@@ -1568,7 +1623,7 @@ export class Tasks {
1568
1623
  let offering = false, undelivered: AssignmentUndeliveredError | undefined;
1569
1624
  try {
1570
1625
  const t = this.d.board.get(stale.id);
1571
- if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
1626
+ if (!t || t.state !== "proposed" || t.owner || this.personUnassigned(t) || this.offered.has(t.id)) return;
1572
1627
  const planned = this.plannerPullRecord(t)?.pull === true;
1573
1628
  const selected = planned ? this.plannerAssignment(t, true).owner : undefined;
1574
1629
  if (planned && !selected) return;
@@ -2026,6 +2081,25 @@ export class Tasks {
2026
2081
  return lines.length ? lines.join("\n") : undefined;
2027
2082
  }
2028
2083
 
2084
+ private planGuardItems(task: Task): NativeItem[] {
2085
+ if (this.isPii(task)) return [];
2086
+ try {
2087
+ const items: NativeItem[] = [];
2088
+ for (const item of this.d.planFails?.(task) ?? []) {
2089
+ const line = sanitize(item.line.replace(/\s+/g, " ").trim());
2090
+ if (item.kind === "fail" && this.nameable(line) && !items.some(existing => existing.id === item.id)) items.push({ ...item, line });
2091
+ if (items.length === 8) break;
2092
+ }
2093
+ return items;
2094
+ } catch { return []; } // Optional memory cannot refuse an assignment or review.
2095
+ }
2096
+
2097
+ /** Current plan advice for an accepted plan's tool result, which has no new assignment envelope. */
2098
+ planGuardText(task: Task): string {
2099
+ const items = this.planGuardItems(task);
2100
+ return items.length ? ["Live fail notes for this plan (advice, not a block):", ...items.map(item => item.line)].join("\n") : "";
2101
+ }
2102
+
2029
2103
  /** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
2030
2104
  private sendReview(task: Task, reviewer: PeerId, why = ""): void {
2031
2105
  const r = task.refs;
@@ -2036,6 +2110,7 @@ export class Tasks {
2036
2110
  const check = [...task.history].reverse().find((h) => h.event === "check passed");
2037
2111
  const checklist = [
2038
2112
  "Checklist:",
2113
+ ...this.planGuardItems(task).map(item => `- Check the plan against this live fail before the remaining checks: ${item.line}`),
2039
2114
  `- 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.`,
2040
2115
  `- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
2041
2116
  `- List what is unmet in hub_review's unmet, one item each.`,
@@ -2068,6 +2143,17 @@ export class Tasks {
2068
2143
  reviewedHead = checked.reviewedHead;
2069
2144
  }
2070
2145
  if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead); task = valid(); }
2146
+ let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
2147
+ if (verdict === "changes_requested" && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
2148
+ const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
2149
+ cause: "unknown: review unmet items do not establish a cause", retryWhen: "unknown: a person must review the unmet items before accepting",
2150
+ evidenceRefs: [`task:${task.id}`, `reviewed-head:${reviewedHead ?? task.refs.commit ?? "unknown"}`] };
2151
+ const context = JSON.stringify({ title: task.title, detail: task.detail, refs: task.refs, plan: task.plan, signals: task.signals });
2152
+ const payload = JSON.stringify({ input, note, context });
2153
+ const cleared = this.candidateTextAllowed(payload, task) && !await this.withholds("review note", payload, task);
2154
+ task = valid();
2155
+ if (cleared && context === JSON.stringify({ title: task.title, detail: task.detail, refs: task.refs, plan: task.plan, signals: task.signals })) candidate = input; // Async screening cannot authorize a verdict under an obsolete role/state.
2156
+ }
2071
2157
  const pii = this.isPii(task);
2072
2158
  const flag = { withheld: withheld || undefined };
2073
2159
  if (verdict === "approved") {
@@ -2088,6 +2174,9 @@ export class Tasks {
2088
2174
  return next;
2089
2175
  }
2090
2176
  const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note, flag);
2177
+ if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
2178
+ try { this.d.memoryCheck?.(); this.d.candidates?.put({ ...candidate, evidenceRefs: [...candidate.evidenceRefs, `review:${rejected.id}:${rejected.history.length}`] }, "public"); } catch { /* a saved review remains authoritative if the optional sidecar fails */ }
2179
+ }
2091
2180
  this.cohorts.withdraw(rejected.id); // open again: its done intent no longer counts (issue #107)
2092
2181
  if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
2093
2182
  this.contradict(rejected);
@@ -2128,13 +2217,16 @@ export class Tasks {
2128
2217
  if (from && by !== HUB) this.d.board.recordOutcome(from, task.class, false);
2129
2218
  // Only a reviewer that asked for changes on this work saw it fail: not an escalation of unreviewed work (a Pi failure).
2130
2219
  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 });
2131
- // The guarded tail of a landed move: the console notice, the decision note and the old owner's stop message,
2132
- // each behind its own releaseNotice. It runs on the success path and on a saved move whose delivery failed.
2220
+ const stopFormerOwner = (moved: Task) => {
2221
+ if (from && moved.owner && moved.owner !== from) this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${from} it moved to ${moved.owner}`, () =>
2222
+ this.tell({ ...moved, owner: from }, `Task #${moved.id} moved to ${moved.owner} (${why}). Stop working on it.`, this.isPii(moved)));
2223
+ };
2224
+ // Only our landed move authorizes a success notice and decision note. The former owner's stop
2225
+ // message still names the current owner when a later assignment superseded this move.
2133
2226
  const tail = (moved: Task) => {
2134
2227
  this.releaseNotice(`task ${this.publicTitle(moved)} escalated from ${from} to ${moved.owner} (${why})`);
2135
2228
  this.note(moved, by, "decision", `Task #${moved.id} escalated from ${from} to ${moved.owner}: ${why}`);
2136
- if (from) this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${from} it moved to ${moved.owner}`, () =>
2137
- this.tell({ ...moved, owner: from }, `Task #${moved.id} moved to ${moved.owner} (${why}). Stop working on it.`, this.isPii(moved)));
2229
+ stopFormerOwner(moved);
2138
2230
  };
2139
2231
  let next: Task;
2140
2232
  try {
@@ -2143,52 +2235,91 @@ export class Tasks {
2143
2235
  // A saved move whose delivery failed arrives as AssignmentUndeliveredError carrying the saved task (#297);
2144
2236
  // the tail still runs on it (its parts are guarded), and the caller still gets the throw. Anything before
2145
2237
  // the write gets neither.
2146
- if (e instanceof AssignmentUndeliveredError) tail(e.task);
2238
+ if (e instanceof AssignmentUndeliveredError && this.ownOwnerMove(task, e.task, by, "escalated")) tail(e.task);
2147
2239
  throw e;
2148
2240
  }
2149
2241
  if (next.owner && next.owner !== from) {
2150
2242
  // The move is on the board (#243): review() reaches here after its changes_requested write.
2151
- tail(next);
2243
+ if (this.ownOwnerMove(task, next, by, "escalated")) tail(next);
2244
+ else stopFormerOwner(next);
2152
2245
  } else this.releaseNotice(`task ${this.publicTitle(task)}: escalation found nobody in [${list.join(", ")}]; it stays with ${from ?? "nobody"}`);
2153
2246
  return this.d.board.get(next.id)!;
2154
2247
  }
2155
2248
 
2249
+ /** Withdraw only a former role's queued request, then tell it that its authority changed. */
2250
+ private withdrawRoleRequest(previous: Task, next: Task, role: TaskAssignmentRole): void {
2251
+ const peer = previous[role];
2252
+ if (!peer || peer === next[role]) return;
2253
+ const kind = role === "owner" ? "task" : "review";
2254
+ this.releaseNotice(`task ${this.publicTitle(next)}: could not notify previous ${role} ${peer} that its request is withdrawn`, () => {
2255
+ const matches = (env: Envelope) => (role === "reviewer" || env.body.startsWith(`Task #${previous.id} [`)) && env.from === HUB && env.kind === kind && env.refs?.task === String(previous.id);
2256
+ const assignment = previous.history.map(entry => OWNERSHIP_EVENTS.has(entry.event)).lastIndexOf(true);
2257
+ // A self-claim also records accepted, after assigned. State can belong to an earlier owner.
2258
+ const acceptance = previous.history.slice(assignment + 1).some(entry => entry.event === "accepted" && entry.by === peer);
2259
+ const boardEvidence = role === "owner" && acceptance;
2260
+ const mayHaveReceived = boardEvidence || this.d.bus.lastDeliveredTo(peer).some(matches) || this.d.bus.queueList(peer).some(row => ["dispatching", "accepted", "needs_review", "completed"].includes(row.state) && row.originals.some(matches));
2261
+ for (const env of this.d.bus.snapshot(false).queues[peer] ?? []) if (matches(env)) this.d.bus.withdraw(env.id);
2262
+ if (!mayHaveReceived) return;
2263
+ const body = role === "owner"
2264
+ ? `Task ${this.publicTitle(next)} is no longer assigned to you${next.owner ? `; its owner is now ${next.owner}` : "; it is unassigned"}. Do not accept or complete it; late calls under your former assignment are refused.`
2265
+ : `Review request for task #${previous.id} ${this.publicTitle(next)} is withdrawn: ${next.reviewer ? `its reviewer is now ${next.reviewer}` : "the task has no reviewer"}. Do not review this task; a late verdict from the previous reviewer is refused.`;
2266
+ if (peer === USER) this.d.notify(body);
2267
+ else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "status", priority: "important", refs: { task: String(previous.id) }, ...(this.isPii(next) ? { private: true } : {}) }));
2268
+ });
2269
+ }
2270
+
2156
2271
  /** Caller authority is checked by the daemon; keep the real actor in task history. */
2157
- async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner"): Promise<Task> {
2272
+ async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner", expectedFlowRevision?: number): Promise<Task> {
2158
2273
  if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or reviewer");
2159
2274
  if (role === "reviewer" && by !== USER) {
2160
2275
  const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
2161
2276
  if (conductors.length !== 1 || conductors[0] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the configured conductor may name a reviewer");
2162
2277
  }
2163
2278
  const task = this.need(id, role === "owner", preEffect);
2279
+ this.checkFlowRevision(task, expectedFlowRevision, preEffect);
2164
2280
  if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster; add it to the roster before assigning work`);
2165
2281
  if (role === "reviewer") {
2166
2282
  if (!OPEN.includes(task.state) && task.state !== "in_review") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its reviewer can no longer change`);
2167
2283
  this.ready(task, preEffect);
2168
2284
  const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
2169
2285
  if (a.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot name ${peer} as reviewer: ${a.trace.filter(line => line.includes("reviewer") || line.includes("pii")).join("; ")}`);
2170
- const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, `named reviewer ${peer}`, { reason: "manual" });
2286
+ const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, `named reviewer ${peer}`, { reason: "manual" }, expectedFlowRevision);
2171
2287
  if (next.state === "in_review" && task.reviewer && task.reviewer !== peer) {
2172
- const previous = task.reviewer;
2173
- this.releaseNotice(`task ${this.publicTitle(next)}: could not notify previous reviewer ${previous} that its review request is withdrawn`, () => {
2174
- for (const env of this.d.bus.snapshot(false).queues[previous] ?? []) {
2175
- if (env.from === HUB && env.kind === "review" && env.refs?.task === String(task.id)) this.d.bus.withdraw(env.id);
2176
- }
2177
- const body = `Review request for task #${task.id} ${this.publicTitle(next)} is withdrawn: its reviewer is now ${peer}. Do not review this task; a late verdict from the previous reviewer is refused.`;
2178
- if (previous === USER) this.d.notify(body);
2179
- else this.d.bus.publish(newEnvelope(HUB, body, { to: [previous], kind: "status", priority: "important", refs: { task: String(task.id) }, ...(this.isPii(next) ? { private: true } : {}) }));
2180
- });
2288
+ this.withdrawRoleRequest(task, next, "reviewer");
2181
2289
  }
2182
2290
  if (next.state === "in_review" && peer !== USER) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${peer}`, () => this.sendReview(next, peer));
2183
2291
  return next;
2184
2292
  }
2185
2293
  // Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
2186
- if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], namedOwner: peer, event: "reassigned", reason: "manual", override: by === USER });
2187
- const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" });
2294
+ if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], namedOwner: peer, event: "reassigned", reason: "manual", override: by === USER, expectedFlowRevision });
2295
+ const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" }, expectedFlowRevision);
2188
2296
  this.releaseNotice(`task ${this.publicTitle(next)} reserved for ${peer} by ${by}; it is offered to ${peer} first once what it waits for is approved`);
2189
2297
  return next;
2190
2298
  }
2191
2299
 
2300
+ /** Person-only removal without routing a replacement. The history marker suppresses later automatic owner offers. */
2301
+ async unassign(id: unknown, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner", expectedFlowRevision?: number): Promise<Task> {
2302
+ if (by !== USER) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user may unassign a task role");
2303
+ if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or reviewer");
2304
+ const task = this.need(id, false, preEffect);
2305
+ this.checkFlowRevision(task, expectedFlowRevision, preEffect);
2306
+ if (task.state === "approved") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is approved: its ${role} is historical`);
2307
+ if (role === "owner") {
2308
+ if (!OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its owner cannot be unassigned`);
2309
+ if (!task.owner && !task.reserved) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: no owner or owner reservation to unassign`);
2310
+ const next = this.d.board.update(task.id, by, "unassigned", { owner: null, reserved: null, ...(task.state === "proposed" ? {} : { state: "proposed" as const }) }, `person unassigned ${[task.owner, task.reserved].filter(Boolean).join(" and ")}`, { reason: "manual" }, expectedFlowRevision);
2311
+ this.cohorts.leave(task.id);
2312
+ this.withdrawRoleRequest(task, next, "owner");
2313
+ return next;
2314
+ }
2315
+ if (!OPEN.includes(task.state) && task.state !== "in_review") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its reviewer cannot be unassigned`);
2316
+ if (!task.reviewer) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: no reviewer to unassign`);
2317
+ if (task.reviewer === USER && this.isPii(task)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: the human reviewer is required for PII work`);
2318
+ const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: null }, `person unassigned reviewer ${task.reviewer}`, { reason: "manual" }, expectedFlowRevision);
2319
+ this.withdrawRoleRequest(task, next, "reviewer");
2320
+ return next;
2321
+ }
2322
+
2192
2323
  /**
2193
2324
  * Budget relay: a paused peer's open work moves on. `local` first (it has no quota), then the class list, all through
2194
2325
  * the usual constraints. Tasks it was reviewing get another reviewer. Moves are reported, never taken back automatically.
@@ -2239,6 +2370,7 @@ export class Tasks {
2239
2370
  if (!(e instanceof AssignmentUndeliveredError)) throw e;
2240
2371
  next = e.task;
2241
2372
  }
2373
+ if (!this.ownOwnerMove(back, next, HUB, "reassigned") && !this.ownOwnerMove(back, next, HUB, "unassigned")) continue;
2242
2374
  if (roster) {
2243
2375
  if (!next.owner || next.owner === peer) continue;
2244
2376
  this.releaseNotice(`task ${this.publicTitle(next)} moved from ${peer} to ${next.owner} (off roster)`);
@@ -2274,9 +2406,33 @@ export class Tasks {
2274
2406
  return { ...(policy?.route ? { route: policy.route } : {}), ...(policy?.fixed_model ? { fixedModel: policy.fixed_model } : {}), pii, task: String(about.id) };
2275
2407
  }
2276
2408
 
2277
- async remember(by: PeerId, input: { text?: string; title?: string; kind?: string; task?: unknown; horizon?: unknown; scope?: unknown }): Promise<string> {
2278
- const text = String(input.text ?? "").trim();
2279
- if (!text) throw new Error("text is required");
2409
+ private candidateTextAllowed(text: string, task?: Task): boolean {
2410
+ return (!task || !this.isPii(task)) && this.nameable(text) && !looksSecret(text) && !this.aboutPrivatePath("", text, task);
2411
+ }
2412
+
2413
+ /** Candidate only: the person remains the sole acceptance authority. */
2414
+ async correct(by: PeerId, input: unknown): Promise<string> {
2415
+ const args = correctionInput(input);
2416
+ const st = this.d.store;
2417
+ if (!st || !this.d.candidates) throw new PreEffectToolRefusal("memory correction unavailable");
2418
+ const screen: MemoryScreen = rows => this.d.candidateScreen?.(rows) ?? withoutPii(rows, this.d.routing(), this.d.cwd, this.d.piiPaths ?? []);
2419
+ const valid = () => {
2420
+ this.d.memoryCheck?.();
2421
+ if (this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("memory correction unavailable during a private turn");
2422
+ if (!this.candidateTextAllowed(JSON.stringify(args))) throw new PreEffectToolRefusal("memory correction withheld by privacy policy");
2423
+ return resolveVisibleMemory(st.store, st.project, screen, args.id);
2424
+ };
2425
+ const target = valid(), revision = candidateTargetRevision(target);
2426
+ if (await this.withholds("note", `${args.text}\n${args.reason}`)) throw new PreEffectToolRefusal("memory correction withheld by privacy policy");
2427
+ const current = valid();
2428
+ if (current.id !== target.id || candidateTargetRevision(current) !== revision) throw new PreEffectToolRefusal("memory correction target changed; read it again");
2429
+ const id = this.d.candidates.put({ project: st.project, kind: "correction", target: { id: current.id, revision }, proposedText: args.text, reason: args.reason,
2430
+ ...(current.kind === "decision" || current.kind === "contract" || current.kind === "fail" ? { noteKind: current.kind } : {}) }, "public");
2431
+ return `candidate:${id}; a person reviews it with amem review; memory was not edited`;
2432
+ }
2433
+
2434
+ async remember(by: PeerId, input: { text?: string; title?: string; kind?: string; task?: unknown; horizon?: unknown; scope?: unknown; approach?: unknown; symptom?: unknown; cause?: unknown; evidence?: unknown; retry_when?: unknown }): Promise<string> {
2435
+ const text = composeRememberText(input);
2280
2436
  const task = input.task !== undefined ? this.d.board.get(Number(input.task)) : undefined;
2281
2437
  if (task && this.isPii(task)) throw new Error("notes about a PII task are not saved: claude-mem processes what it stores with a cloud model");
2282
2438
  // Shared with every other peer as well, so text that matches a PII pattern is refused like a note about a PII task.
@@ -2296,7 +2452,8 @@ export class Tasks {
2296
2452
  if (!res && !native) return this.d.memory ? "memory worker unavailable; nothing saved" : "memory store unavailable; nothing saved";
2297
2453
  const line = noteLine(by, kind, title ? `${title}: ${text}` : text);
2298
2454
  this.d.notify(line);
2299
- this.d.share?.(by, line);
2455
+ // Display identity only: a queued recall may already contain this saved entry. Stored/captured text stays unchanged.
2456
+ this.d.share?.(by, native ? noteLine(by, kind, `mem:${native.id} ${title ? `${title}: ` : ""}${text}`) : line);
2300
2457
  return `saved to shared memory${native ? ` (hub memory ${native.id.slice(0, 8)}, ${native.tier}-term${this.d.memory && !res ? "; claude-mem unavailable" : ""})` : ""}; the other agents get it with their next message`;
2301
2458
  }
2302
2459
 
@@ -2309,6 +2466,8 @@ export class Tasks {
2309
2466
  */
2310
2467
  private goalFor(by: PeerId, ref: unknown): GoalView {
2311
2468
  if (!this.d.store) throw new PreEffectToolRefusal("goal: hub memory is off for this project");
2469
+ try { this.d.memoryCheck?.(); }
2470
+ catch { throw new PreEffectToolRefusal("goal: memory profile unavailable; inspect the selector and generation state before linking a long-term task"); }
2312
2471
  const g = typeof ref === "string" && ref.trim() ? this.d.store.store.goalOf(ref.trim().replace(/^goal:/, "")) : undefined;
2313
2472
  if (!g) throw new PreEffectToolRefusal(`goal: no long-term task ${JSON.stringify(ref).slice(0, 40)} (ahub goal list shows them)`);
2314
2473
  if (by !== USER && g.project !== this.d.store.project) throw new PreEffectToolRefusal("goal: that long-term task belongs to another project; linking across projects is a person's");
@@ -2322,7 +2481,7 @@ export class Tasks {
2322
2481
  /** Record a long-term link, never for PII; failures remain notices. */
2323
2482
  private linkNative(task: Task, goal: GoalView, by: PeerId): string {
2324
2483
  if (this.isPii(task)) { this.releaseNotice(`task #${task.id} is not linked to a long-term task: nothing about a PII task enters hub memory`); return "not linked (PII task)"; }
2325
- try { this.markMemoryReference(task, by); this.d.store!.store.goalLink(goal.id, this.memoryKey(task), by); return `part of long-term task ${goal.id.slice(0, 8)}`; }
2484
+ try { this.d.memoryCheck?.(); this.markMemoryReference(task, by); this.d.store!.store.goalLink(goal.id, this.memoryKey(task), by); return `part of long-term task ${goal.id.slice(0, 8)}`; }
2326
2485
  catch (e) { this.releaseNotice(`task #${task.id}: the link to long-term task ${goal.id.slice(0, 8)} was not saved (${(e as Error).message})`); return "not linked"; }
2327
2486
  }
2328
2487
  /** A person links a board task of this project to a long-term task (`ahub goal link`). */
@@ -2342,6 +2501,7 @@ export class Tasks {
2342
2501
  const st = this.d.store;
2343
2502
  if (!st || (task && this.isPii(task))) return undefined;
2344
2503
  try { // never throws: callers run after a board write
2504
+ this.d.memoryCheck?.();
2345
2505
  // Approved, or settled in the store already: the snapshot the caller holds can predate an approval made while it awaited.
2346
2506
  const approved = !!task && (task.state === "approved" || st.store.settled(this.memoryKey(task)));
2347
2507
  const tier: Tier = horizon ?? (approved && PROMOTED_KINDS.includes(kind) ? "long" : "short");
@@ -2369,7 +2529,7 @@ export class Tasks {
2369
2529
  let n = 0;
2370
2530
  for (const t of this.d.board.list("approved")) {
2371
2531
  if (this.isPii(t)) continue;
2372
- try { const key = this.memoryKey(t); if (this.d.store.store.hasTask(key) && !this.d.store.store.settled(key)) { this.settleMemory(t); n++; } }
2532
+ try { this.d.memoryCheck?.(); const key = this.memoryKey(t); if (this.d.store.store.hasTask(key) && !this.d.store.store.settled(key)) { this.settleMemory(t); n++; } }
2373
2533
  catch { /* fail-open: tried again at the next start */ }
2374
2534
  }
2375
2535
  return n;
@@ -2378,7 +2538,7 @@ export class Tasks {
2378
2538
  private settleMemory(task: Task): void {
2379
2539
  if (!this.d.store || this.isPii(task)) return;
2380
2540
  // The store screens each note as it moves to the synced log (the promotion screen the daemon gives it).
2381
- try { this.d.store.store.settle(this.memoryKey(task), task.reviewer ?? task.owner ?? HUB); }
2541
+ try { this.d.memoryCheck?.(); this.d.store.store.settle(this.memoryKey(task), task.reviewer ?? task.owner ?? HUB); }
2382
2542
  catch (e) { this.releaseNotice(`task #${task.id}: hub memory could not settle its notes (${(e as Error).message})`); }
2383
2543
  }
2384
2544
 
package/src/hub/ui.ts CHANGED
@@ -10,7 +10,7 @@ import { createHash, randomBytes } from "node:crypto";
10
10
  import { readFileSync } from "node:fs";
11
11
 
12
12
  /** What a request's session may do (#269): `settings` while a session opened by `ahub ui --settings` is inside its window. */
13
- export interface DashboardSession { settings: boolean; settingsUntil?: number }
13
+ export interface DashboardSession { settings: boolean; settingsUntil?: number; liveTest?: true }
14
14
  export interface DashboardOptions {
15
15
  snapshot: (after: number, input?: Record<string, unknown>, session?: DashboardSession) => unknown | Promise<unknown>;
16
16
  action: (input: Record<string, unknown>, session?: DashboardSession) => unknown | Promise<unknown>;
@@ -30,8 +30,8 @@ const secret = () => randomBytes(32).toString("hex");
30
30
  /** Separate browser surface. Only the authenticated control console may construct it and issue tickets. */
31
31
  export function startDashboard(options: DashboardOptions) {
32
32
  const now = options.now ?? Date.now;
33
- const tickets = new Map<string, { expires: number; settings: boolean }>();
34
- const sessions = new Map<string, { expires: number; settingsUntil?: number }>();
33
+ const tickets = new Map<string, { expires: number; settings: boolean; liveTest?: true }>();
34
+ const sessions = new Map<string, { expires: number; settingsUntil?: number; liveTest?: true }>();
35
35
  // Inject the same pure public-board model used by the console before computing CSP hashes.
36
36
  const html = readFileSync(new URL("../ui/index.html", import.meta.url), "utf8")
37
37
  .replace("/* BOARD_MODEL */", () => `const boardColumns = ${boardColumns.toString()}; const peerPalette = ${JSON.stringify(PEER_PALETTE)};`)
@@ -57,7 +57,7 @@ export function startDashboard(options: DashboardOptions) {
57
57
  }
58
58
  const sessionOf = (cookie: string | undefined): DashboardSession => {
59
59
  const until = cookie ? sessions.get(cookie)?.settingsUntil : undefined;
60
- return until !== undefined && until > now() ? { settings: true, settingsUntil: until } : { settings: false };
60
+ return { ...(until !== undefined && until > now() ? { settings: true, settingsUntil: until } : { settings: false }), ...(cookie && sessions.get(cookie)?.liveTest ? { liveTest: true as const } : {}) };
61
61
  };
62
62
  const server = Bun.serve({
63
63
  hostname: "127.0.0.1",
@@ -99,7 +99,7 @@ export function startDashboard(options: DashboardOptions) {
99
99
  if (!issued || !tickets.delete(ticket)) return reject(401, "ticket expired or used; run ahub ui");
100
100
  if (sessions.size >= LIMIT) return reject(429, "too many sessions; try later");
101
101
  const session = secret();
102
- sessions.set(session, { expires: now() + SESSION_MS, ...(issued.settings ? { settingsUntil: now() + SETTINGS_MS } : {}) });
102
+ sessions.set(session, { expires: now() + SESSION_MS, ...(issued.settings ? { settingsUntil: now() + SETTINGS_MS } : {}), ...(issued.liveTest ? { liveTest: true } : {}) });
103
103
  return json({ ok: true }, 200, { "Set-Cookie": `${cookieName}=${session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${SESSION_MS / 1000}` });
104
104
  }
105
105
  if (path === "/projects") {
@@ -131,6 +131,9 @@ export function startDashboard(options: DashboardOptions) {
131
131
  }
132
132
  }
133
133
  try {
134
+ if (sessionOf(cookie).liveTest && !["start", "stop", "pause", "resume", "permit", "start_peer", "setting", "setting_undo", "setting_preview", "sdlc_queue", "sdlc_release"].includes(String(input.action))) {
135
+ return reject(403, "live-test dashboard cannot write person-attributed content; use peer tools");
136
+ }
134
137
  if (input.action === "sdlc_queue" || input.action === "sdlc_release") server.timeout(req, 35);
135
138
  else if (input.action === "backlog_split") server.timeout(req, Math.ceil((BACKLOG_SPLIT_SCREEN_TIMEOUT_MS + HUB_DB_BUSY_TIMEOUT_MS + 5_000) / 1000) + 5);
136
139
  return json(await options.action(input, sessionOf(cookie)));
@@ -149,11 +152,11 @@ export function startDashboard(options: DashboardOptions) {
149
152
  return {
150
153
  origin,
151
154
  /** `settings`: the ticket of `ahub ui --settings`, whose session may raise authority for SETTINGS_MS (#269). */
152
- issue(settings = false): string {
155
+ issue(settings = false, liveTest = false): string {
153
156
  prune(tickets);
154
157
  if (tickets.size >= LIMIT) tickets.delete(tickets.keys().next().value!);
155
158
  const ticket = secret();
156
- tickets.set(ticket, { expires: now() + TICKET_MS, settings });
159
+ tickets.set(ticket, { expires: now() + TICKET_MS, settings, ...(liveTest ? { liveTest: true } : {}) });
157
160
  return `${origin}/#${ticket}`;
158
161
  },
159
162
  stop() {
package/src/local/deny.ts CHANGED
@@ -1,21 +1,8 @@
1
- import { basename, dirname, sep } from "node:path";
1
+ import { basename, dirname } from "node:path";
2
+ import { DENY_NAMES, DENY_SEGMENTS } from "@staix/agent-mem/platform";
2
3
 
3
- /**
4
- * The one denylist. tools.ts checks paths against it, sandbox.ts turns it into seatbelt rules, capture.ts keeps
5
- * matching calls out of memory. Two lists would drift, and the gap is where a secret leaks.
6
- */
7
- // `.agenthub/archive` holds the state directories `ahub reset --all` moved there: hub.db with task text, PII included.
8
- export const DENY_SEGMENTS = [".maru/secrets", ".agenthub/state", ".agenthub/archive"];
9
- /** Regex sources, matched against a file's basename. */
10
- export const DENY_NAMES = ["^\\.env", "\\.pem$", "\\.key$", "^id_rsa", "^id_ed25519", "^credentials(\\.|$)", "^auth\\.json$", "^\\.netrc$", "^\\.npmrc$", "^\\.pypirc$"];
11
-
12
- const hasSegment = (rel: string, seg: string) => `/${rel}/`.includes(`/${seg}/`);
13
-
14
- /** True when a path names something the worker must never read, write or report. `extra`: substrings from `local.deny`. */
15
- export function isDenied(path: string, extra: string[] = []): boolean {
16
- const norm = path.split(sep).join("/");
17
- return DENY_SEGMENTS.some((s) => hasSegment(norm, s)) || DENY_NAMES.some((re) => new RegExp(re).test(basename(norm))) || extra.some((e) => e && norm.includes(e));
18
- }
4
+ // One core deny policy for path guards, sandbox rules, capture and memory extraction.
5
+ export { DENY_NAMES, DENY_SEGMENTS, isDenied } from "@staix/agent-mem/platform";
19
6
 
20
7
  /** Regex metacharacters only; `sbplString` quotes the finished regex. */
21
8
  const sbplEscape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");