@staix/agent-hub 0.12.29 → 0.12.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +92 -218
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +3 -2
  5. package/docs/agent-notes/budget.md +1 -1
  6. package/docs/agent-notes/bus.md +7 -1
  7. package/docs/agent-notes/daemon.md +14 -1
  8. package/docs/agent-notes/local-worker.md +4 -2
  9. package/docs/agent-notes/tasks.md +14 -0
  10. package/docs/agent-notes/tests.md +6 -3
  11. package/docs/memory.md +70 -152
  12. package/docs/operations.md +317 -25
  13. package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
  14. package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
  15. package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
  16. package/docs/plans/amem-cases.json +482 -0
  17. package/docs/quickstart.md +24 -2
  18. package/docs/security.md +42 -2
  19. package/docs/smoke.md +11 -0
  20. package/docs/specs/2026-09-19-agent-hub-design.md +142 -7
  21. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
  22. package/docs/verification/2026-10-17-0.12.30.md +70 -0
  23. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  24. package/docs/verification/2026-10-17-418-probes.md +21 -0
  25. package/docs/verified.json +13 -13
  26. package/package.json +5 -2
  27. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  28. package/plugins/agent-hub/server.js +129 -62
  29. package/src/adapters/claude-channel.ts +29 -11
  30. package/src/adapters/local-worker.ts +2 -2
  31. package/src/adapters/pi.ts +5 -0
  32. package/src/cli/attention.ts +5 -1
  33. package/src/cli/console-state.ts +145 -29
  34. package/src/cli/console.ts +40 -10
  35. package/src/cli/help.ts +3 -2
  36. package/src/cli/identity.ts +4 -3
  37. package/src/cli/jcode-mcp.ts +1 -1
  38. package/src/cli/main.js +2 -2
  39. package/src/cli/main.ts +43 -14
  40. package/src/cli/memory.ts +87 -187
  41. package/src/cli/output.ts +19 -2
  42. package/src/cli/recovery-package.ts +59 -10
  43. package/src/cli/upgrade-interactive.ts +2 -1
  44. package/src/cli/upgrade-restore-state.ts +133 -0
  45. package/src/cli/upgrade-runtime.ts +204 -63
  46. package/src/cli/upgrade.ts +57 -24
  47. package/src/hub/board.ts +83 -24
  48. package/src/hub/bus.ts +108 -38
  49. package/src/hub/check-runner.ts +514 -0
  50. package/src/hub/child-process.ts +1 -0
  51. package/src/hub/control-client.ts +5 -1
  52. package/src/hub/daemon.ts +529 -89
  53. package/src/hub/delivery-hints.ts +30 -0
  54. package/src/hub/delivery-journal.ts +185 -12
  55. package/src/hub/delivery-superseded.ts +52 -0
  56. package/src/hub/envelope.ts +31 -1
  57. package/src/hub/git.ts +2 -38
  58. package/src/hub/host-executable.ts +11 -0
  59. package/src/hub/hub-tools.ts +14 -2
  60. package/src/hub/manager.ts +1 -1
  61. package/src/hub/memory-candidates.ts +103 -0
  62. package/src/hub/memory-fail.ts +25 -0
  63. package/src/hub/memory-observations.ts +47 -0
  64. package/src/hub/memory-plan-guard.ts +33 -0
  65. package/src/hub/memory-policy.ts +42 -0
  66. package/src/hub/memory-profile-check.ts +170 -0
  67. package/src/hub/memory-profile.ts +192 -0
  68. package/src/hub/memory-staleness.ts +55 -0
  69. package/src/hub/path-policy.ts +8 -0
  70. package/src/hub/peer-catalog.ts +3 -0
  71. package/src/hub/project.ts +4 -37
  72. package/src/hub/read-tools.ts +127 -0
  73. package/src/hub/routing.ts +35 -16
  74. package/src/hub/settings.ts +2 -1
  75. package/src/hub/spec-read.ts +120 -0
  76. package/src/hub/supervision.ts +21 -6
  77. package/src/hub/task-sweep.ts +7 -6
  78. package/src/hub/tasks.ts +370 -71
  79. package/src/local/deny.ts +4 -17
  80. package/src/memory/brief.ts +8 -5
  81. package/src/memory/capture.ts +3 -1
  82. package/src/memory/recall.ts +50 -110
  83. package/src/pi/process-signature.ts +2 -42
  84. package/src/ui/index.html +12 -9
  85. package/src/ui/needs.ts +7 -5
  86. package/src/ui/office-model.ts +4 -1
  87. package/src/ui/task-board.ts +1 -1
  88. package/src/ui/task-progress.ts +6 -2
  89. package/templates/AGENTS.block.md +1 -1
  90. package/templates/profiles/general.json +1 -1
  91. package/templates/profiles/research.json +1 -1
  92. package/templates/profiles/writing.json +1 -1
  93. package/src/memory/import.ts +0 -90
  94. package/src/memory/store.ts +0 -805
  95. package/src/memory/sync.ts +0 -145
@@ -0,0 +1,30 @@
1
+ import { sanitize, type Kind } from "./envelope.ts";
2
+
3
+ export const DELIVERY_RETRY_WARNING = sanitize("Hub redelivery warning: An earlier attempt of this delivery is uncertain; check for its effects before acting.");
4
+ export interface HeldDeliveryHints {
5
+ count: number;
6
+ rows: { id: string; senders: string[]; kinds: Kind[]; createdAt: number }[];
7
+ truncated: boolean;
8
+ }
9
+ export function heldDeliveryHints(rows: readonly HeldDeliveryHints["rows"][number][], count = rows.length): HeldDeliveryHints {
10
+ const result: HeldDeliveryHints = { count, rows: [], truncated: count > rows.length };
11
+ for (const row of rows) {
12
+ const value = { ...row, senders: row.senders.map(sanitize) };
13
+ if (result.rows.length >= 32 || Buffer.byteLength(JSON.stringify({ ...result, rows: [...result.rows, value] }), "utf8") > 3500) { result.truncated = true; break; }
14
+ result.rows.push(value);
15
+ }
16
+ return result;
17
+ }
18
+ export function renderHeldHint(value: unknown, now = Date.now(), recipientRetry = true): string {
19
+ if (!value || typeof value !== "object" || Array.isArray(value)) return "";
20
+ const held = value as HeldDeliveryHints;
21
+ const kinds = new Set(["chat", "task", "review", "status", "budget", "presence"]);
22
+ try {
23
+ if (!Number.isSafeInteger(held.count) || held.count < 1 || !Array.isArray(held.rows) || held.rows.length > 32 || held.rows.length > held.count || typeof held.truncated !== "boolean" || Buffer.byteLength(JSON.stringify(held), "utf8") > 4096) return "";
24
+ if (held.rows.some(row => !row || typeof row !== "object" || typeof row.id !== "string" || !row.id.length || row.id.length > 256 || !Number.isFinite(row.createdAt) || !Array.isArray(row.senders) || !Array.isArray(row.kinds) || row.senders.some(sender => typeof sender !== "string" || sender.length > 256) || row.kinds.some(kind => !kinds.has(kind)))) return "";
25
+ } catch { return ""; }
26
+ const rows = held.rows.map(row => `${sanitize(row.id)} from ${row.senders.map(sanitize).join(",")} kind ${row.kinds.join(",")} age ${Math.max(0, Math.floor((now - row.createdAt) / 1000))}s`);
27
+ const action = recipientRetry ? "Use hub_delivery_retry {delivery_id} for one retry of your own uncertain row; check for earlier effects. A repeated uncertain retry needs the person." : "Ask the person to resolve these held deliveries; this delivery-only peer cannot initiate recipient retry.";
28
+ const out = `Held hub deliveries: ${held.count}${held.truncated ? " (metadata truncated)" : ""}\n${rows.join("\n")}\n${action}`;
29
+ return Buffer.byteLength(out, "utf8") <= 4096 ? out : `Held hub deliveries: ${held.count} (metadata truncated; ask the person to inspect the queue).`;
30
+ }
@@ -2,7 +2,8 @@ import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { Database } from "bun:sqlite";
4
4
  import type { BusSnapshot } from "./bus.ts";
5
- import type { Envelope } from "./envelope.ts";
5
+ import { HUB, USER, type Envelope, type Kind } from "./envelope.ts";
6
+ import { heldDeliveryHints } from "./delivery-hints.ts";
6
7
  import { processLiveness } from "../pi/process-signature.ts";
7
8
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
8
9
 
@@ -15,6 +16,7 @@ export type JournalDeliveryState =
15
16
  | "failed"
16
17
  | "discarded";
17
18
  export type JournalResolution = "completed" | "retry" | "discard";
19
+ export const MAX_ENVELOPE_BODY = 1_000_000;
18
20
  const SCHEMA_VERSION = 1,
19
21
  TERMINAL_CAP = 2048,
20
22
  MAX_ROWS = 100_000,
@@ -29,6 +31,8 @@ const STATES = new Set<JournalDeliveryState>([
29
31
  "discarded",
30
32
  ]);
31
33
  const ACTIONS = new Set<JournalResolution>(["completed", "retry", "discard"]);
34
+ // Hub presence ids survive context merges; they are not immutable attempted work.
35
+ const retryOriginals = (originals: readonly Envelope[]): Envelope[] => originals.filter(env => env.from !== HUB || env.kind !== "presence");
32
36
 
33
37
  export interface JournalDelivery {
34
38
  id: string;
@@ -108,7 +112,7 @@ function validateEnvelope(value: unknown, name: string, seen = false): Envelope
108
112
  text(e.trace, `${name}.trace`, 256);
109
113
  text(e.from, `${name}.from`, 256);
110
114
  integer(e.hop, `${name}.hop`, 0, seen ? Number.MAX_SAFE_INTEGER : 3);
111
- if (typeof e.body !== "string" || e.body.length > 1_000_000) fail(`${name}.body is invalid`);
115
+ if (typeof e.body !== "string" || e.body.length > MAX_ENVELOPE_BODY) fail(`${name}.body is invalid`);
112
116
  integer(e.ts, `${name}.ts`);
113
117
  if (
114
118
  !new Set(["chat", "task", "review", "status", "budget", "presence"]).has(
@@ -249,7 +253,7 @@ export class DeliveryJournal {
249
253
  if (!meta)
250
254
  this.db
251
255
  .query(
252
- "INSERT INTO delivery_meta (project_id, project_root, revision, instance_id, operation_id) VALUES (?, ?, 0, ?, ?)",
256
+ "INSERT INTO delivery_meta (project_id, project_root, revision, instance_id, operation_id, retry_history_cursor) VALUES (?, ?, 0, ?, ?, 0)",
253
257
  )
254
258
  .run(
255
259
  this.projectId,
@@ -273,6 +277,9 @@ export class DeliveryJournal {
273
277
  );
274
278
  // A new daemon cannot infer completion of an old process's handoffs, including
275
279
  // controlled restarts with silent native/bridge receipts still outstanding.
280
+ for (const row of this.db.query("SELECT * FROM task_offer_reads WHERE project_id = ?").all(this.projectId) as Record<string, unknown>[]) this.validateOfferRead(row);
281
+ this.recoverRetryHistory(meta);
282
+ this.recoverRetryEvidence();
276
283
  this.recoverInterrupted();
277
284
  })();
278
285
  }
@@ -296,6 +303,8 @@ export class DeliveryJournal {
296
303
  ["project_root", "TEXT"],
297
304
  ["schema_version", "INTEGER NOT NULL DEFAULT 1"],
298
305
  ["operation_id", "TEXT"],
306
+ ["retry_unknown_ids", "TEXT NOT NULL DEFAULT '{}'"],
307
+ ["retry_history_cursor", "INTEGER NOT NULL DEFAULT 0"],
299
308
  ] as const) {
300
309
  const columns = this.db
301
310
  .query("PRAGMA table_info(delivery_meta)")
@@ -308,9 +317,58 @@ export class DeliveryJournal {
308
317
  this.db.run(
309
318
  `CREATE TABLE IF NOT EXISTS deliveries (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, peer TEXT NOT NULL, state TEXT NOT NULL, revision INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, originals TEXT NOT NULL, out_json TEXT NOT NULL, reason TEXT, attempt INTEGER, previous_id TEXT)`,
310
319
  );
320
+ this.db.run("CREATE TABLE IF NOT EXISTS task_offer_reads (project_id TEXT NOT NULL, peer TEXT NOT NULL, envelope_id TEXT NOT NULL, delivery_id TEXT NOT NULL, source TEXT NOT NULL CHECK(source IN ('adapter','reply','pull')), offer_revision INTEGER NOT NULL CHECK(offer_revision >= 1), PRIMARY KEY(project_id,peer,envelope_id))");
311
321
  this.db.run(
312
322
  `CREATE TABLE IF NOT EXISTS resolution_history (id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL, delivery_id TEXT NOT NULL, observed_revision INTEGER NOT NULL, action TEXT NOT NULL, reason TEXT NOT NULL, result_revision INTEGER NOT NULL, result_state TEXT NOT NULL, UNIQUE(project_id, delivery_id, observed_revision, action, reason))`,
313
323
  );
324
+ this.db.run("CREATE TABLE IF NOT EXISTS delivery_retry_evidence (project_id TEXT NOT NULL, peer TEXT NOT NULL, original_id TEXT NOT NULL, proof_id TEXT NOT NULL, warning INTEGER NOT NULL CHECK(warning IN (0,1)), PRIMARY KEY(project_id, peer, original_id))");
325
+ this.db.transaction(() => {
326
+ const resolutionColumns = this.db.query("PRAGMA table_info(resolution_history)").all() as { name: string }[];
327
+ if (!resolutionColumns.some((column) => column.name === "actor")) this.db.run("ALTER TABLE resolution_history ADD COLUMN actor TEXT NOT NULL DEFAULT 'user'");
328
+ if (!resolutionColumns.some((column) => column.name === "peer")) this.db.run("ALTER TABLE resolution_history ADD COLUMN peer TEXT NOT NULL DEFAULT ''");
329
+ if (!resolutionColumns.some((column) => column.name === "original_ids")) this.db.run("ALTER TABLE resolution_history ADD COLUMN original_ids TEXT NOT NULL DEFAULT '[]'");
330
+ this.db.run("CREATE INDEX IF NOT EXISTS resolution_retry_peer ON resolution_history(project_id, peer, action)");
331
+ this.db.run("CREATE INDEX IF NOT EXISTS deliveries_peer ON deliveries(project_id, peer, state)");
332
+ })();
333
+ }
334
+ private recoverRetryHistory(meta: Record<string, unknown>): void {
335
+ const cursor = Number(meta.retry_history_cursor ?? 0);
336
+ const latest = Number((this.db.query("SELECT COALESCE(MAX(id), 0) AS n FROM resolution_history WHERE project_id = ?").get(this.projectId) as { n: number }).n);
337
+ if (latest > cursor) {
338
+ const legacy = this.db.query("SELECT r.id,d.peer,d.originals FROM resolution_history r LEFT JOIN deliveries d ON d.id = r.delivery_id AND d.project_id = r.project_id WHERE r.project_id = ? AND r.peer = '' AND r.id > ?").all(this.projectId, cursor) as { id: number; peer: string | null; originals: string | null }[];
339
+ for (const row of legacy) if (row.peer && row.originals) this.db.query("UPDATE resolution_history SET peer = ?, original_ids = ? WHERE project_id = ? AND id = ?").run(row.peer, JSON.stringify(retryOriginals(JSON.parse(row.originals) as Envelope[]).map(env => env.id)), this.projectId, row.id);
340
+ // A pruned pre-feature retry cannot be mapped back to its originals. Fence current legacy work only.
341
+ if (this.db.query("SELECT 1 FROM resolution_history WHERE project_id = ? AND action = 'retry' AND peer = '' AND id > ? LIMIT 1").get(this.projectId, cursor)) {
342
+ this.markRetryUnknown(this.list(), JSON.parse(String(meta.bus_snapshot || '{}')));
343
+ }
344
+ this.db.query("UPDATE delivery_meta SET retry_history_cursor = ? WHERE project_id = ?").run(latest, this.projectId);
345
+ }
346
+ }
347
+ private validateRetryProof(row: Record<string, unknown>): void {
348
+ text(row.peer, "retry proof peer", 256); text(row.original_id, "retry proof original", 256); text(row.proof_id, "retry proof id", 256);
349
+ integer(row.warning, "retry proof warning", 0, 1);
350
+ }
351
+ private retryProof(peer: string, originals: readonly Envelope[]): { proof: string; warning: boolean } | undefined {
352
+ originals = retryOriginals(originals);
353
+ if (!originals.length) return undefined;
354
+ const rows = this.db.query("SELECT peer,original_id,proof_id,warning FROM delivery_retry_evidence WHERE project_id = ? AND peer = ? AND original_id IN (SELECT value FROM json_each(?))").all(this.projectId, peer, JSON.stringify(originals.map(env => env.id))) as Record<string, unknown>[];
355
+ for (const row of rows) this.validateRetryProof(row);
356
+ return rows.length ? { proof: String(rows[0]!.proof_id), warning: rows.some(row => row.warning === 1) } : undefined;
357
+ }
358
+ private recordRetryEvidence(peer: string, originals: readonly Envelope[], proof: string, warning: boolean): void {
359
+ const put = this.db.query("INSERT INTO delivery_retry_evidence (project_id,peer,original_id,proof_id,warning) VALUES (?,?,?,?,?) ON CONFLICT(project_id,peer,original_id) DO UPDATE SET warning = MAX(warning,excluded.warning)");
360
+ for (const id of new Set(retryOriginals(originals).map(env => env.id))) put.run(this.projectId, peer, id, proof, warning ? 1 : 0);
361
+ }
362
+ private recoverRetryEvidence(): void {
363
+ for (const row of this.db.query("SELECT peer,original_id,proof_id,warning FROM delivery_retry_evidence WHERE project_id = ?").all(this.projectId) as Record<string, unknown>[]) this.validateRetryProof(row);
364
+ // An older writer can append attempt-bearing rows while this writer is absent.
365
+ const rows = this.db.query("SELECT d.id,d.peer,d.originals FROM deliveries d WHERE d.project_id = ? AND d.attempt > 0 AND EXISTS (SELECT 1 FROM json_each(d.originals) env WHERE NOT EXISTS (SELECT 1 FROM delivery_retry_evidence e WHERE e.project_id = d.project_id AND e.peer = d.peer AND e.original_id = json_extract(env.value,'$.id')))").all(this.projectId) as { id: string; peer: string; originals: string }[];
366
+ for (const row of rows) {
367
+ const originals = retryOriginals(JSON.parse(row.originals) as Envelope[]);
368
+ if (!originals.length) continue;
369
+ const known = this.retryProof(row.peer, originals) || this.db.query("SELECT 1 FROM resolution_history r, json_each(r.original_ids) old, json_each(?) current WHERE r.project_id = ? AND r.peer = ? AND r.action = 'retry' AND old.value = current.value LIMIT 1").get(JSON.stringify(originals.map(env => env.id)), this.projectId, row.peer);
370
+ this.recordRetryEvidence(row.peer, originals, row.id, this.retryWarning(row.peer, originals) || !known);
371
+ }
314
372
  }
315
373
  private readMeta(): Record<string, unknown> | null {
316
374
  return this.db
@@ -536,11 +594,20 @@ export class DeliveryJournal {
536
594
  updatedAt: delivery.createdAt,
537
595
  });
538
596
  return this.db.transaction(() => {
539
- const ids = new Set(delivery.originals.map((env) => env.id));
597
+ const evidence = this.retryEvidence(delivery.peer, delivery.originals);
598
+ if (evidence === "used") {
599
+ // Inheritance commits with the handoff, before safe failure or pruning can erase a grouped parent.
600
+ this.recordRetryEvidence(delivery.peer, delivery.originals, delivery.id, this.retryWarning(delivery.peer, delivery.originals));
601
+ delivery = { ...delivery, attempt: Math.max(1, delivery.attempt ?? 0) };
602
+ } else if (evidence === "unknown") this.markRetryUnknown([{ ...delivery, revision: 0, updatedAt: delivery.createdAt }], {});
603
+ const ids = new Set(delivery.originals.map((env) => env.id)), immutableIds = new Set(retryOriginals(delivery.originals).map(env => env.id));
540
604
  for (const row of this.list(delivery.peer)) {
541
605
  if (row.state !== "queued" || !row.originals.some((env) => ids.has(env.id))) continue;
542
606
  this.transition(row.id, "discarded", `grouped into delivery ${delivery.id}`);
543
- delivery = { ...delivery, previousId: delivery.previousId ?? row.id };
607
+ if (retryOriginals(row.originals).some(env => immutableIds.has(env.id))) {
608
+ delivery = { ...delivery, previousId: delivery.previousId ?? row.id };
609
+ if ((row.attempt ?? 0) > 0) delivery = { ...delivery, attempt: Math.max(delivery.attempt ?? 0, row.attempt!) };
610
+ }
544
611
  }
545
612
  const revision = this.bump();
546
613
  const now = Date.now();
@@ -611,6 +678,32 @@ export class DeliveryJournal {
611
678
  return { ...input, revision, updatedAt };
612
679
  })();
613
680
  }
681
+ private validateOfferRead(row: Record<string, unknown>): void {
682
+ text(row.peer, "offer read peer", 256); text(row.envelope_id, "offer read envelope", 256); text(row.delivery_id, "offer read delivery", 256);
683
+ integer(row.offer_revision, "offer read revision", 1);
684
+ if (!["adapter", "reply", "pull"].includes(String(row.source))) fail("offer read source is invalid");
685
+ }
686
+ /** Positive delivery provenance only; generic transitions and operator resolutions never call this. */
687
+ completeDelivered(deliveryId: string, peer: string, source: "adapter" | "reply" | "pull", reason?: string): JournalDelivery {
688
+ this.ensureOpen(); text(deliveryId, "delivery id", 256); text(peer, "delivery peer", 256);
689
+ if (peer === HUB || peer === USER || !["adapter", "reply", "pull"].includes(source)) throw new Error("invalid delivered completion source");
690
+ if (reason !== undefined) text(reason, "delivery completion reason", 1_000_000);
691
+ return this.db.transaction((): JournalDelivery => {
692
+ const row = this.get(deliveryId);
693
+ if (!row || row.peer !== peer || !["dispatching", "accepted"].includes(row.state)) throw new Error("delivery is not active for this peer");
694
+ const completed = this.transition(deliveryId, "completed", reason, row.revision);
695
+ const put = this.db.query("INSERT OR IGNORE INTO task_offer_reads (project_id,peer,envelope_id,delivery_id,source,offer_revision) VALUES (?,?,?,?,?,?)");
696
+ for (const env of row.originals) if (env.from === HUB && env.kind === "task" && Number.isSafeInteger(env.refs?.taskOffer) && env.refs!.taskOffer! >= 1) put.run(this.projectId, peer, env.id, deliveryId, source, env.refs!.taskOffer!);
697
+ return completed;
698
+ })();
699
+ }
700
+ /** Exact immutable offer id, independent of queue snapshots or generic completed rows. */
701
+ offerRead(peer: string, envelopeId: string): boolean {
702
+ this.ensureOpen(); text(peer, "offer read peer", 256); text(envelopeId, "offer read envelope", 256);
703
+ const row = this.db.query("SELECT * FROM task_offer_reads WHERE project_id = ? AND peer = ? AND envelope_id = ?").get(this.projectId, peer, envelopeId) as Record<string, unknown> | null;
704
+ if (!row) return false;
705
+ this.validateOfferRead(row); return true;
706
+ }
614
707
  transition(
615
708
  id: string,
616
709
  state: JournalDeliveryState,
@@ -663,12 +756,85 @@ export class DeliveryJournal {
663
756
  const rows = this.db.query("SELECT * FROM deliveries WHERE project_id = ? AND peer = ? AND state IN ('queued', 'dispatching', 'accepted', 'needs_review') ORDER BY created_at, id").all(this.projectId, peer) as Record<string, unknown>[];
664
757
  return rows.map(parseDelivery);
665
758
  }
759
+ heldDeliveryHints(peer: string) {
760
+ this.ensureOpen();
761
+ const count = Number((this.db.query("SELECT COUNT(*) AS n FROM deliveries WHERE project_id = ? AND peer = ? AND state = 'needs_review'").get(this.projectId, peer) as { n: number }).n);
762
+ const rows = this.db.query("SELECT d.id, d.created_at, (SELECT json_group_array(DISTINCT json_extract(env.value, '$.from')) FROM json_each(d.originals) env) AS senders, (SELECT json_group_array(DISTINCT json_extract(env.value, '$.kind')) FROM json_each(d.originals) env) AS kinds FROM deliveries d WHERE d.project_id = ? AND d.peer = ? AND d.state = 'needs_review' ORDER BY d.created_at, d.id LIMIT 33").all(this.projectId, peer) as { id: string; created_at: number; senders: string; kinds: string }[];
763
+ return heldDeliveryHints(rows.map(row => ({ id: row.id, createdAt: row.created_at, senders: JSON.parse(row.senders) as string[], kinds: JSON.parse(row.kinds) as Kind[] })), count);
764
+ }
765
+ /** Explicit resolutions, not automatic safe failures, consume the immutable originals' retry lineage. */
766
+ retryEvidence(peer: string, originals: readonly Envelope[]): "unused" | "used" | "unknown" {
767
+ this.ensureOpen();
768
+ originals = retryOriginals(originals);
769
+ if (!originals.length) return "unused";
770
+ const ids = JSON.stringify([...new Set(originals.map(env => env.id))]);
771
+ const proof = this.retryProof(peer, originals);
772
+ if (this.retryUnknown(peer, new Set(originals.map(env => env.id)))) return "unknown";
773
+ if (proof) return "used";
774
+ if (this.db.query("SELECT 1 FROM resolution_history r, json_each(r.original_ids) old, json_each(?) current WHERE r.project_id = ? AND r.peer = ? AND r.action = 'retry' AND old.value = current.value LIMIT 1").get(ids, this.projectId, peer)) return "used";
775
+ const wanted = new Set(originals.map(env => env.id));
776
+ type Ancestor = { id: string; peer: string; attempt: number | null; previous_id: string | null; originals: string };
777
+ const related = this.db.query("SELECT d.id, d.peer, d.attempt, d.previous_id, d.originals FROM deliveries d WHERE d.project_id = ? AND d.peer = ? AND d.state IN ('queued','dispatching','accepted','needs_review') AND EXISTS (SELECT 1 FROM json_each(d.originals) env, json_each(?) ids WHERE json_extract(env.value, '$.id') = ids.value)").all(this.projectId, peer, ids) as Ancestor[];
778
+ const retried = (id: string) => !!this.db.query("SELECT 1 FROM resolution_history WHERE project_id = ? AND delivery_id = ? AND action = 'retry' LIMIT 1").get(this.projectId, id);
779
+ const inspected = new Set<string>();
780
+ let unknown = false;
781
+ for (const row of related) {
782
+ const chain = new Set<string>();
783
+ let current: Ancestor | undefined = row;
784
+ while (current) {
785
+ if (!retryOriginals(JSON.parse(current.originals) as Envelope[]).some(env => wanted.has(env.id))) break;
786
+ if (chain.has(current.id)) { unknown = true; break; }
787
+ if (inspected.has(current.id)) break;
788
+ chain.add(current.id); inspected.add(current.id);
789
+ if ((current.attempt ?? 0) > 0 || retried(current.id)) return "used";
790
+ if (!current.previous_id) break;
791
+ const parent = current.previous_id;
792
+ current = this.db.query("SELECT id, peer, attempt, previous_id, originals FROM deliveries WHERE project_id = ? AND id = ?").get(this.projectId, parent) as Ancestor | undefined;
793
+ if (!current || current.peer !== peer) { unknown = true; break; }
794
+ }
795
+ }
796
+ return unknown || this.retryUnknown(peer, wanted) ? "unknown" : "unused";
797
+ }
798
+ /** Console retries consume the allowance too, but only recipient-requested/unknown lineage adds a warning. */
799
+ retryWarning(peer: string, originals: readonly Envelope[]): boolean {
800
+ this.ensureOpen();
801
+ originals = retryOriginals(originals);
802
+ if (!originals.length) return false;
803
+ const wanted = new Set(originals.map(env => env.id));
804
+ const ids = JSON.stringify([...wanted]);
805
+ if (this.retryProof(peer, originals)?.warning) return true;
806
+ if (this.db.query("SELECT 1 FROM resolution_history r, json_each(r.original_ids) old, json_each(?) current WHERE r.project_id = ? AND r.peer = ? AND r.action = 'retry' AND r.actor = ? AND old.value = current.value LIMIT 1").get(ids, this.projectId, peer, `peer:${peer}`)) return true;
807
+ return this.retryUnknown(peer, wanted) || this.retryEvidence(peer, originals) === "unknown";
808
+ }
809
+ private retryUnknown(peer: string, wanted: ReadonlySet<string>): boolean {
810
+ try {
811
+ const map = JSON.parse(String(this.readMeta()?.retry_unknown_ids ?? '{}')) as Record<string, string[]>;
812
+ if (!map || typeof map !== "object" || Array.isArray(map)) return true;
813
+ const own = Object.hasOwn(map, peer) ? map[peer] : [];
814
+ return !Array.isArray(own) || own.some(id => wanted.has(id));
815
+ } catch { return true; }
816
+ }
817
+ private markRetryUnknown(deliveries: readonly JournalDelivery[], bus: Partial<BusSnapshot>): void {
818
+ const map: Record<string, Set<string>> = Object.create(null);
819
+ const existing = JSON.parse(String(this.readMeta()?.retry_unknown_ids ?? '{}')) as Record<string, unknown>;
820
+ if (!existing || typeof existing !== "object" || Array.isArray(existing)) throw new Error("retry lineage metadata unavailable");
821
+ for (const [peer, ids] of Object.entries(existing)) {
822
+ if (!Array.isArray(ids) || ids.some(id => typeof id !== "string")) throw new Error("retry lineage metadata unavailable");
823
+ map[peer] = new Set(ids as string[]);
824
+ }
825
+ const add = (peer: string, envs: readonly Envelope[]) => { for (const env of retryOriginals(envs)) (map[peer] ??= new Set()).add(env.id); };
826
+ for (const row of deliveries) add(row.peer, row.originals);
827
+ for (const [peer, envs] of Object.entries(bus.queues ?? {})) add(peer, envs);
828
+ for (const [peer, env] of Object.entries(bus.prefaces ?? {})) add(peer, [env]);
829
+ this.db.query("UPDATE delivery_meta SET retry_unknown_ids = ? WHERE project_id = ?").run(JSON.stringify(Object.fromEntries(Object.entries(map).map(([peer, ids]) => [peer, [...ids]]))), this.projectId);
830
+ }
666
831
  resolve(
667
832
  id: string,
668
833
  revision: number,
669
834
  action: JournalResolution,
670
835
  reason: string,
671
836
  requestRevision = revision,
837
+ actor: string = "user",
672
838
  ): JournalDelivery {
673
839
  this.ensureOpen();
674
840
  text(reason, "resolution reason", 1_000_000);
@@ -678,19 +844,20 @@ export class DeliveryJournal {
678
844
  const resolved = this.resolution(id);
679
845
  if (resolved) {
680
846
  const current = this.get(id);
681
- if (current && resolved.action === action && resolved.reason === reason && (requestRevision === resolved.observedRevision || requestRevision === current.revision)) return current;
847
+ if (current && resolved.action === action && resolved.reason === reason && resolved.actor === actor && (requestRevision === resolved.observedRevision || requestRevision === current.revision)) return current;
682
848
  throw new Error("stale delivery revision or conflicting operator resolution");
683
849
  }
684
850
  const prior = this.db
685
851
  .query(
686
- "SELECT result_revision FROM resolution_history WHERE project_id = ? AND delivery_id = ? AND observed_revision = ? AND action = ? AND reason = ?",
852
+ "SELECT result_revision FROM resolution_history WHERE project_id = ? AND delivery_id = ? AND observed_revision = ? AND action = ? AND reason = ? AND actor = ?",
687
853
  )
688
- .get(this.projectId, id, requestRevision, action, reason) as {
854
+ .get(this.projectId, id, requestRevision, action, reason, actor) as {
689
855
  result_revision: number;
690
856
  } | null;
691
857
  if (prior) return this.get(id)!;
692
858
  const current = this.get(id);
693
859
  if (!current) throw new Error(`unknown delivery ${id}`);
860
+ if (actor.startsWith("peer:") && (action !== "retry" || current.peer !== actor.slice(5) || current.state !== "needs_review" || this.retryEvidence(current.peer, current.originals) !== "unused")) throw new Error("recipient retry unavailable; inspect the held delivery or ask the person");
694
861
  if (current.revision !== revision)
695
862
  throw new Error(`stale delivery revision for ${id}`);
696
863
  if (
@@ -715,7 +882,7 @@ export class DeliveryJournal {
715
882
  }
716
883
  this.db
717
884
  .query(
718
- "INSERT INTO resolution_history (project_id, delivery_id, observed_revision, action, reason, result_revision, result_state) VALUES (?, ?, ?, ?, ?, ?, ?)",
885
+ "INSERT INTO resolution_history (project_id, delivery_id, observed_revision, action, reason, result_revision, result_state, actor, peer, original_ids) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
719
886
  )
720
887
  .run(
721
888
  this.projectId,
@@ -725,14 +892,18 @@ export class DeliveryJournal {
725
892
  reason,
726
893
  result.revision,
727
894
  result.state,
895
+ actor,
896
+ current.peer,
897
+ JSON.stringify(retryOriginals(current.originals).map(env => env.id)),
728
898
  );
899
+ if (action === "retry") this.recordRetryEvidence(current.peer, current.originals, id, actor === `peer:${current.peer}`);
729
900
  this.pruneTerminal();
730
901
  return result;
731
902
  })();
732
903
  }
733
- resolution(id: string): { action: JournalResolution; reason: string; observedRevision: number } | undefined {
734
- const row = this.db.query("SELECT action,reason,observed_revision FROM resolution_history WHERE project_id=? AND delivery_id=? ORDER BY id DESC LIMIT 1").get(this.projectId, id) as { action: JournalResolution; reason: string; observed_revision: number } | null;
735
- return row ? { action: row.action, reason: row.reason, observedRevision: row.observed_revision } : undefined;
904
+ resolution(id: string): { action: JournalResolution; reason: string; observedRevision: number; actor: string } | undefined {
905
+ const row = this.db.query("SELECT action,reason,observed_revision,actor FROM resolution_history WHERE project_id=? AND delivery_id=? ORDER BY id DESC LIMIT 1").get(this.projectId, id) as { action: JournalResolution; reason: string; observed_revision: number; actor: string } | null;
906
+ return row ? { action: row.action, reason: row.reason, observedRevision: row.observed_revision, actor: row.actor } : undefined;
736
907
  }
737
908
  private pruneTerminal(): void {
738
909
  const count = Number(
@@ -801,6 +972,8 @@ export class DeliveryJournal {
801
972
  row.attempt ?? null,
802
973
  row.previousId ?? null,
803
974
  );
975
+ // Snapshots deliberately omit resolution history; only these imported originals have unknown retry rights.
976
+ this.markRetryUnknown(snapshot.deliveries, snapshot.bus);
804
977
  return true;
805
978
  })();
806
979
  }
@@ -0,0 +1,52 @@
1
+ import type { Envelope } from "./envelope.ts";
2
+ import type { Task } from "./board.ts";
3
+
4
+ /** Evidence only, never task text: a mixed/unknown/live row requires a person's decision. */
5
+ export function supersededDeliveryReason(
6
+ originals: readonly Envelope[],
7
+ board: { get(id: number): Pick<Task, "id" | "state" | "owner" | "reviewer" | "cancelled"> | undefined },
8
+ recipient: string,
9
+ ): string | undefined {
10
+ if (!originals.length) return undefined;
11
+ const reasons = new Set<string>();
12
+ for (const env of originals) {
13
+ if (env.from !== "hub") return undefined;
14
+ if (env.kind === "presence") { reasons.add("presence"); continue; }
15
+ if (env.kind !== "task" && env.kind !== "review") return undefined;
16
+ if (!env.refs?.task || !/^[1-9][0-9]*$/.test(env.refs.task)) return undefined;
17
+ const id = Number(env.refs.task);
18
+ if (!Number.isSafeInteger(id)) return undefined;
19
+ const task = board.get(id);
20
+ if (!task) return undefined;
21
+ if (task.state === "cancelled") {
22
+ if (typeof env.refs.taskCancelled === "number" && env.refs.taskCancelled === task.cancelled?.flowRevision) return undefined;
23
+ reasons.add(`task #${task.id} cancelled`);
24
+ } else if (task.state === "approved") reasons.add(`task #${task.id} approved`);
25
+ else if ((env.kind === "review" || env.refs.taskRecipient === "reviewer") && (task.state !== "in_review" || task.reviewer !== recipient)) reasons.add(`task #${task.id} review moved`);
26
+ else if (env.kind === "task" && env.refs.taskRecipient === "owner" && task.owner !== recipient) reasons.add(`task #${task.id} owner moved`);
27
+ else return undefined; // Untagged legacy task notices and planner coordination carry no proof of supersession.
28
+ }
29
+ return `superseded: ${[...reasons].join("; ")}`;
30
+ }
31
+
32
+
33
+ /** Informational context for every referenced task, including peer/user and mixed rows. Never authorizes settlement. */
34
+ export function deliveryTaskContext(
35
+ originals: readonly Envelope[],
36
+ board: { get(id: number): Pick<Task, "id" | "state" | "owner" | "reviewer" | "cancelled"> | undefined },
37
+ recipient: string,
38
+ ): string | undefined {
39
+ const context = new Set<string>();
40
+ for (const env of originals) {
41
+ if (!env.refs?.task || !/^[1-9][0-9]*$/.test(env.refs.task)) continue;
42
+ const id = Number(env.refs.task);
43
+ if (!Number.isSafeInteger(id)) continue;
44
+ const task = board.get(id);
45
+ if (!task) continue;
46
+ if (task.state === "cancelled") context.add(`task #${task.id} cancelled`);
47
+ else if (task.state === "approved") context.add(`task #${task.id} approved`);
48
+ else if (task.owner !== recipient && task.reviewer !== recipient) context.add(`task #${task.id} moved`);
49
+ else if ((env.kind === "review" || env.refs.taskRecipient === "reviewer") && (task.state !== "in_review" || task.reviewer !== recipient)) context.add(`task #${task.id} review moved`);
50
+ }
51
+ return context.size ? [...context].join("; ") : undefined;
52
+ }
@@ -14,7 +14,7 @@ export interface Envelope {
14
14
  kind: Kind;
15
15
  priority: Priority;
16
16
  body: string; // agent conclusions only, never tool noise
17
- refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; backlog?: number[]; supervision?: boolean; supervisionKey?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
17
+ refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; backlog?: number[]; supervision?: boolean; supervisionKey?: string; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
18
18
  /** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
19
19
  private?: boolean;
20
20
  ts: number;
@@ -133,6 +133,36 @@ export function keepNotes(body: string, max: number): string {
133
133
  return extra > 0 ? lines.filter((l) => !(NOTE_LINE.test(l) && extra-- > 0)).join("\n") : body;
134
134
  }
135
135
 
136
+ /** The same rendered memory reference rides only once in a recipient's preface, including shared note lines. */
137
+ export function uniqueMemoryLines(body: string): string {
138
+ const reference = (line: string) => /^(?:note from \S+ \[(?:decision|finding|contract|fail)\]: )?mem:([a-f0-9]{8,64})(?=\s|:|$)/i.exec(line)?.[1]?.toLowerCase();
139
+ const references = [...new Set(body.split("\n").flatMap(line => reference(line) ?? []))];
140
+ const identity = (id: string) => {
141
+ const extensions = references.filter(other => other.length > id.length && other.startsWith(id));
142
+ const terminals = extensions.filter(other => !extensions.some(longer => longer.length > other.length && longer.startsWith(other)));
143
+ // An ambiguous prefix cannot identify either full entry; keep both full references.
144
+ return terminals.length === 1 ? terminals[0]! : id;
145
+ };
146
+ const ids = new Set<string>(), headers = new Set<string>();
147
+ let removed = false;
148
+ const lines = body.split("\n").filter((line) => {
149
+ const memory = reference(line);
150
+ const seen = memory ? ids : line.startsWith("Hub memory:") ? headers : undefined;
151
+ const key = memory ? identity(memory) : line;
152
+ if (!seen) return true;
153
+ if (seen.has(key)) { removed = true; return false; }
154
+ seen.add(key);
155
+ return true;
156
+ });
157
+ return removed ? lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() : body;
158
+ }
159
+
160
+ /** Recovery and a new session can regenerate the same context. Keep distinct context and every new note. */
161
+ export function mergePrefaces(older: string, newer: string): string {
162
+ const contains = (whole: string, part: string) => `\n\n${whole}\n\n`.includes(`\n\n${part}\n\n`);
163
+ return uniqueMemoryLines(contains(older, newer) ? older : contains(newer, older) ? newer : `${older}\n\n${newer}`);
164
+ }
165
+
136
166
  /** Notes follow each other line by line; the first one after other text starts a paragraph. */
137
167
  export function appendNote(body: string, line: string): string {
138
168
  return `${body}${NOTE_LINE.test(body.slice(body.lastIndexOf("\n") + 1)) ? "\n" : "\n\n"}${line}`;
package/src/hub/git.ts CHANGED
@@ -1,38 +1,2 @@
1
- import { spawnSync, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns } from "node:child_process";
2
-
3
- /**
4
- * The one helper for every git call the hub makes outside the sandbox (#281): the repository's own configuration
5
- * must not name a program the hub then runs with its own rights (defense in depth behind the seatbelt profile,
6
- * which refuses a sandboxed command the writes that would plant one). Every call gets:
7
- * - core.fsmonitor=false: the file-system monitor is an arbitrary command read from the repo config.
8
- * - core.hooksPath=/dev/null: hooks come from a non-directory, so every hook lookup fails closed.
9
- * - diff.external= (empty): no external diff driver.
10
- * - core.pager=cat and --no-pager: no pager, whatever the config says (stdout is a pipe everywhere anyway).
11
- * - credential.helper= (empty resets the list) and core.askpass= (empty fails closed), with GIT_TERMINAL_PROMPT=0:
12
- * no hub command authenticates, so any prompt is a bug and must fail instead of running a configured helper.
13
- * git has no global switch for filter drivers or textconv: both are per-driver config keys. Of the hub's commands
14
- * only the snapshot's `add`/`restore` would run a filter driver, and only a content diff would run textconv — the
15
- * hub's one content diff is `diff --no-index`, which DOES read the repository's config (verified: a planted
16
- * diff.external runs for it), so that call passes `--no-ext-diff` and `--no-textconv` on top of the config here,
17
- * and the driver command of a filter lives in the `.git/config` the seatbelt profile refuses to a sandboxed
18
- * command. GIT_CONFIG_NOSYSTEM/global are left alone: those files are the user's own.
19
- */
20
- const SAFE_CONFIG = ["core.fsmonitor=false", "core.hooksPath=/dev/null", "diff.external=", "core.pager=cat", "credential.helper=", "core.askpass="];
21
- /** Hub memory sync (#258) pushes to the person's own remote: their global credential helper stays, the rest does not. */
22
- const CREDENTIAL_KEYS = new Set(["credential.helper=", "core.askpass="]);
23
-
24
- /**
25
- * `["git", ...safe options, ...args]`: use instead of a literal "git" argv anywhere under src/. `credentials` keeps the
26
- * person's credential helper, for a repository the hub created itself (the memory store), never a project's.
27
- */
28
- export const hubGitArgv = (args: string[], opts: { credentials?: boolean } = {}): string[] =>
29
- ["git", "--no-pager", ...SAFE_CONFIG.filter((c) => !opts.credentials || !CREDENTIAL_KEYS.has(c)).flatMap((c) => ["-c", c]), ...args];
30
-
31
- /** The caller's env plus the no-prompt switch; existing keys (childEnv, GIT_OPTIONAL_LOCKS=0) pass through. */
32
- export const hubGitEnv = (env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv => ({ ...env, GIT_TERMINAL_PROMPT: "0" });
33
-
34
- /** spawnSync form of the same call: the env option is merged with hubGitEnv, everything else passes through. */
35
- export function hubGitSync(args: string[], options: SpawnSyncOptionsWithStringEncoding): SpawnSyncReturns<string> {
36
- const argv = hubGitArgv(args);
37
- return spawnSync(argv[0]!, argv.slice(1), { ...options, env: hubGitEnv(options.env ?? process.env) });
38
- }
1
+ // The portable core owns hardened git invocation; retain the hub names as identical exports.
2
+ export { memoryGitArgv as hubGitArgv, memoryGitEnv as hubGitEnv, memoryGitSync as hubGitSync } from "@staix/agent-mem/platform";
@@ -0,0 +1,11 @@
1
+ import { isAbsolute, relative } from "node:path";
2
+ import { realPath } from "./project.ts";
3
+
4
+ /** A repo-writable PATH shim is not a host-owned authenticated capability. No caller-selected binary. */
5
+ export function hostExecutable(name: "gh" | "container" | "git", projectDir: string, path = process.env.PATH ?? ""): string {
6
+ const found = Bun.which(name, { PATH: path });
7
+ if (!found || !isAbsolute(found)) throw new Error(`${name} unavailable`);
8
+ const target = realPath(found), inside = relative(realPath(projectDir), target);
9
+ if (!inside || inside !== ".." && !inside.startsWith("../") && !isAbsolute(inside)) throw new Error(`${name} unavailable`);
10
+ return target;
11
+ }
@@ -41,7 +41,14 @@ const tool = (name: string, description: string, properties: Record<string, unkn
41
41
  inputSchema: { type: "object", properties, required, additionalProperties: false },
42
42
  });
43
43
 
44
+ export const BOUNDED_TASK_TOOLS = new Set(["hub_task_spec", "hub_check_start", "hub_check_result", "hub_check_cancel"]);
45
+
44
46
  export const TASK_TOOLS: HubTool[] = [
47
+ tool("hub_task_spec", "Read complete assigned GitHub issue contracts through the bounded hub boundary. Owner/reviewer only; no repository or URL input. Refuses withheld or oversized specs.", { id }, ["id"]),
48
+ tool("hub_check_start", "Run an exact-head isolated Linux check for your in-progress task. Trusted opt-in runner only; no host shell or credentials. Tests require one to 32 test/*.test.ts paths. Receipt is Linux evidence.", { id, check: { type: "string", enum: ["test", "typecheck"] }, files: { type: "array", minItems: 1, maxItems: 32, items: str } }, ["id", "check"]),
49
+ tool("hub_check_result", "Read your current task-bound isolated check receipt. Stale ownership/generation refuses; output is policy screened.", { run: str }, ["run"]),
50
+ tool("hub_check_cancel", "Cancel your task-bound isolated check; only its owner or the person may cancel.", { run: str }, ["run"]),
51
+ tool("hub_delivery_retry", "Request one redelivery of your own needs_review delivery. An earlier attempt may have effects: inspect those effects before acting. Refuses another peer's row or any lineage already explicitly retried; a repeated uncertain row needs the person. Never completes/discards a delivery or changes task state.", { delivery_id: { type: "string", minLength: 1, maxLength: 256 } }, ["delivery_id"]),
45
52
  tool("hub_backlog_list", "Read public backlog cards with planning revisions. Configured planners only; PII remains a stub.", {}),
46
53
  tool("hub_backlog_plan", "Plan an unpinned backlog item outside frozen releases with the revision you read and a bounded reason. A person's rank is retained. Actual dates cannot be set.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", maxLength: 1000 }, fields: { type: "object", properties: { rank: { type: ["integer", "null"], minimum: 0 }, priority: { type: ["integer", "null"], minimum: 0, maximum: 3 }, release: { type: ["integer", "null"], minimum: 1 }, plannedStart: { type: ["integer", "null"], minimum: 1 }, due: { type: ["integer", "null"], minimum: 1 }, deps: { type: "array", maxItems: 64, uniqueItems: true, items: { type: "integer", minimum: 1 } }, reserved: { type: ["string", "null"], minLength: 1, maxLength: 32 } }, additionalProperties: false } }, ["id", "revision", "reason", "fields"]),
47
54
  tool("hub_backlog_pull", "Reserve a WIP slot and pull ready unpinned backlog work into To Do at its current revision. Preserves person order/reservations and frozen scope; pending unowned work can be undone with hub_backlog_undo.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, ["id", "revision", "reason"]),
@@ -51,13 +58,18 @@ export const TASK_TOOLS: HubTool[] = [
51
58
 
52
59
  tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, role: { type: "string", description: "catalog role id for this task class; guidance only, no routing or authority change" }, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it. With after, the owner is reserved: offered the task first once it is ready" }, after: { type: "array", items: { type: "integer" }, description: "ids of tasks that must be approved first; until then this one waits, offered to nobody, and cannot be claimed" }, backlog: { type: "boolean", description: "stage unoffered work; owner is only a reservation, even when naming yourself" }, urgent: { type: "boolean", description: "hand it over even when its owner is paused for quota only briefly" }, goal: { type: "string", description: "id of this project's long-term task (ahub goal list) the work is part of" } }, ["title"]),
53
60
  tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it.", { id, plan: acceptPlan }, ["id"]),
61
+ tool("hub_task_cancel", "Cancel unaccepted proposed/backlog work you proposed as its configured planner; accepted work is person-only. Keeps history and does not stop a native turn or delete files.", { id, reason: str, superseded_by: str, revision: { type: "integer", minimum: 0 } }, ["id", "reason"]),
54
62
  tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer.", { id, reason: str }, ["id"]),
55
63
  tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
56
64
  tool("hub_sdlc_record", "Record artifact ids or claimed local checks on your task. This grants no authority or external-check evidence. Only its owner/reviewer or the person may record; only its reviewer/person may name a reviewed head or phase checklist. Phase evidence refs are content hashes, not external verification.", { id, receipt: { type: "object", properties: { stage: { type: "string", enum: ["spec", "plan", "implement", "review", "verify", "merge", "release"] }, repo: str, issue: id, pr: id, localHead: str, localResult: { type: "string", enum: ["passed", "failed", "unknown"] }, reviewedHead: str, phase: { type: "object", properties: { id: str, head: str, tasks: { type: "array", maxItems: 64, items: { type: "object", properties: { id, after: { type: "array", maxItems: 16, items: id } }, required: ["id", "after"], additionalProperties: false } }, criteria: { type: "array", maxItems: 32, items: { type: "object", properties: { id: str, required: { type: "boolean" }, result: { type: "string", enum: ["verified", "failed", "unverified"] }, refs: { type: "array", maxItems: 8, items: str } }, required: ["id", "required", "result", "refs"], additionalProperties: false } } }, required: ["id", "tasks", "criteria"], additionalProperties: false } }, additionalProperties: false } }, ["id", "receipt"]),
57
- tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
65
+ tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested", "cancelled"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
58
66
  tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. Two changes_requested in a row move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
59
67
  tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
60
- tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (the hub's memory and claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter. A note on an open task is short-term until the task is approved, when its decisions, contracts and fails become long-term; horizon long keeps it now, scope shared lets the person's other projects find it.", { text: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id, horizon: { type: "string", enum: ["short", "long"] }, scope: { type: "string", enum: ["project", "shared"] } }, ["text"]),
68
+ tool("hub_memory_search", "Search daemon-scoped visible memory, fails first. Query <=200 UTF-8 bytes; default 8, max 20 lines; whole output <=4 KB. Incomplete excerpts are marked. No project argument.", { query: { type: "string", maxLength: 200, description: "At most 200 UTF-8 bytes, enforced by the daemon" }, limit: { type: "integer", minimum: 1, maximum: 20 } }, ["query"]),
69
+ tool("hub_memory_show", "Read up to 8 visible memory ids or unique prefixes (6..64 UTF-8 bytes each). Original ids redirect; unavailable ids share one refusal. Whole output <=8 KB.", { ids: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 6, maxLength: 64, description: "6 to 64 UTF-8 bytes" } } }, ["ids"]),
70
+ tool("hub_memory_graph", "Read screened memory neighbourhoods, fails first. Up to 8 starts (<=200 UTF-8 bytes each), 1 or 2 hops; default 60/max 120 nodes, max 240 edges, whole output <=6 KB. No project argument.", { starts: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 1, maxLength: 200, description: "At most 200 UTF-8 bytes" } }, hops: { type: "integer", enum: [1, 2] }, limit: { type: "integer", minimum: 1, maximum: 120 } }, ["starts"]),
71
+ tool("hub_memory_correct", "Propose a correction to a visible memory entry. This creates a machine-local candidate only; a person accepts or rejects it with amem review. It never edits a note. id is an exact id or unique visible prefix; text and reason must each fit 4000 UTF-8 bytes.", { id: str, text: str, reason: str }, ["id", "text", "reason"]),
72
+ tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (the hub's memory and claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter. For kind fail, optional approach, symptom, cause, evidence and retry_when strings compose fixed labeled text; text may be omitted when structured fields are supplied. The complete text must fit 4000 UTF-8 bytes and is refused, never truncated, when oversized. A note on an open task is short-term until the task is approved, when its decisions, contracts and fails become long-term; horizon long keeps it now, scope shared lets the person's other projects find it.", { text: str, approach: str, symptom: str, cause: str, evidence: str, retry_when: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id, horizon: { type: "string", enum: ["short", "long"] }, scope: { type: "string", enum: ["project", "shared"] } }),
61
73
  ];
62
74
 
63
75
  export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
@@ -9,7 +9,7 @@ import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS } from "./control-cl
9
9
  import { Registry, type Project } from "./registry.ts";
10
10
  import { hubHome } from "./project.ts";
11
11
  import { startDashboard } from "./ui.ts";
12
- import { projectChain } from "../memory/recall.ts";
12
+ import { projectChain } from "@staix/agent-mem";
13
13
  import type { ProjectInspection } from "./lifecycle.ts";
14
14
  import { MAX_COMMAND_MS } from "../cli/terminal-recovery.ts";
15
15
  import { processLiveness, processSignature } from "../pi/process-signature.ts";