@staix/agent-hub 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +5 -3
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +2 -1
  5. package/docs/agent-notes/bus.md +1 -0
  6. package/docs/agent-notes/daemon.md +2 -1
  7. package/docs/agent-notes/tasks.md +4 -2
  8. package/docs/agent-notes/tests.md +2 -0
  9. package/docs/design/adapters.md +56 -0
  10. package/docs/design/agent-hub-architecture.md +12 -0
  11. package/docs/design/bus.md +8 -0
  12. package/docs/design/task-board.md +18 -0
  13. package/docs/dogfood.md +7 -4
  14. package/docs/operations.md +106 -11
  15. package/docs/quickstart.md +10 -3
  16. package/docs/sdlc.md +1 -1
  17. package/docs/security.md +28 -2
  18. package/docs/smoke.md +13 -2
  19. package/docs/specs/2026-10-12-office-view-design.md +86 -3
  20. package/docs/verification/2026-11-03-0.30.0.md +54 -0
  21. package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
  22. package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
  23. package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
  24. package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
  25. package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
  26. package/docs/verification/2026-11-03-pi-title-api.md +26 -0
  27. package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
  28. package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
  29. package/docs/verification/dogfood/2026-11-03.md +3095 -0
  30. package/docs/verification/pi-150-model-free.ts +57 -0
  31. package/docs/verification/pi-150-native.mjs +21 -0
  32. package/docs/verification/pi-150-title-api.mjs +31 -0
  33. package/docs/verified.json +14 -14
  34. package/package.json +2 -2
  35. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  36. package/plugins/agent-hub/server.js +193 -29
  37. package/src/adapters/compat.ts +10 -10
  38. package/src/adapters/definitions/builtin-metadata.ts +2 -2
  39. package/src/adapters/local-worker.ts +22 -6
  40. package/src/adapters/pi.ts +92 -10
  41. package/src/adapters/registry-types.ts +4 -0
  42. package/src/adapters/registry.ts +2 -0
  43. package/src/cli/autonomy.ts +66 -1
  44. package/src/cli/console-state.ts +6 -2
  45. package/src/cli/help.ts +2 -0
  46. package/src/cli/identity.ts +8 -1
  47. package/src/cli/main.ts +39 -0
  48. package/src/cli/worktree-tidy.ts +158 -0
  49. package/src/cli/worktrees-output.ts +43 -0
  50. package/src/hub/board.ts +28 -3
  51. package/src/hub/bus.ts +4 -4
  52. package/src/hub/conductor.ts +4 -3
  53. package/src/hub/config-apply.ts +2 -1
  54. package/src/hub/daemon.ts +237 -19
  55. package/src/hub/dogfood-read.ts +62 -9
  56. package/src/hub/hub-tools.ts +11 -5
  57. package/src/hub/manager.ts +9 -1
  58. package/src/hub/peer-doing.ts +33 -3
  59. package/src/hub/peers.ts +7 -0
  60. package/src/hub/settings.ts +3 -0
  61. package/src/hub/tasks.ts +30 -8
  62. package/src/hub/ui.ts +2 -0
  63. package/src/hub/worktree-prs.ts +100 -0
  64. package/src/hub/worktree-shelf.ts +246 -0
  65. package/src/memory/capture.ts +1 -1
  66. package/src/pi/extension.ts +43 -3
  67. package/src/ui/index.html +133 -10
  68. package/src/ui/office-model.ts +64 -8
  69. package/src/ui/office-space.ts +1 -0
  70. package/src/ui/plugin.ts +14 -3
@@ -12,7 +12,8 @@ import type { ProfileName } from "./profiles.ts";
12
12
  import type { StampedEvent } from "./events.ts";
13
13
 
14
14
  export const DOGFOOD_BYTE_CAP = 16 * 1024 * 1024;
15
- const MAX_TASKS = 500, MAX_PRS = 16, MAX_CI_HEADS = 8, MAX_CI_READS = 32;
15
+ /** MAX_PII bounds the PII inventory (ids and PR refs only), separately from the windowed task rows (#1147). */
16
+ const MAX_TASKS = 500, MAX_PII = 2000, MAX_PRS = 16, MAX_CI_HEADS = 8, MAX_CI_READS = 32;
16
17
  const date = (value: unknown): number | undefined => typeof value === "string" && Number.isFinite(Date.parse(value)) ? Date.parse(value) : undefined;
17
18
  const id = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) > 0;
18
19
  const repoName = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith("-");
@@ -51,13 +52,59 @@ export function dogfoodEvents(lines: Source<string[]>, since: number, until: num
51
52
  }
52
53
  return { data: out, available: lines.available, complete };
53
54
  }
54
- /** Bounded read-only board snapshot. Select no task title/detail or history note into the result. */
55
- export function dogfoodTasks(file: string): Source<DogfoodTask[]> {
55
+ /** One PII task as the privacy inventory knows it: an id and its PR ref, never any text. */
56
+ export interface PiiRef { id: number; repo?: string; pr?: number }
57
+ /**
58
+ * #1147: every PII task of the board, read completely and independently of the scorecard window. Privacy depends only on a task's
59
+ * signals and its sdlc receipt, so only those are read. A row whose signals cannot be read (null, oversized, not a JSON array)
60
+ * could be a PII task, and a PII receipt that cannot be parsed hides its PR: both make the inventory incomplete, as does overflowing MAX_PII.
61
+ */
62
+ export function dogfoodPiiInventory(file: string): Source<PiiRef[]> {
63
+ let db: Database | undefined;
64
+ try {
65
+ db = new Database(file, { readonly: true });
66
+ db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
67
+ const rows = db.query(`SELECT id, unreadable, receipt_cut, sdlc FROM (
68
+ SELECT id,
69
+ CASE WHEN signals IS NULL OR length(signals) > 65536 THEN 1 WHEN NOT json_valid(signals) THEN 1 WHEN json_type(signals) <> 'array' THEN 1 ELSE 0 END AS unreadable,
70
+ CASE WHEN signals IS NOT NULL AND length(signals) <= 65536 AND json_valid(signals) AND json_type(signals) = 'array'
71
+ THEN EXISTS (SELECT 1 FROM json_each(signals) WHERE value IN ('pii','pii_path')) ELSE 0 END AS pii,
72
+ (sdlc IS NOT NULL AND length(sdlc) > 65536) AS receipt_cut,
73
+ CASE WHEN length(sdlc) <= 65536 THEN sdlc END AS sdlc FROM tasks)
74
+ WHERE unreadable = 1 OR pii = 1 ORDER BY id DESC LIMIT ?`).all(MAX_PII + 1) as { id: number; unreadable: number; receipt_cut: number; sdlc: string | null }[];
75
+ let complete = rows.length <= MAX_PII;
76
+ const refs: PiiRef[] = [];
77
+ for (const row of rows.slice(0, MAX_PII)) {
78
+ if (row.unreadable || row.receipt_cut || !id(row.id)) { complete = false; continue; }
79
+ try {
80
+ const receipt = JSON.parse(row.sdlc ?? "{}");
81
+ refs.push({ id: row.id, ...(repoName(receipt?.repo) ? { repo: receipt.repo } : {}), ...(id(receipt?.pr) ? { pr: receipt.pr } : {}) });
82
+ } catch { complete = false; }
83
+ }
84
+ return source(refs, complete);
85
+ } catch { return missing([]); }
86
+ finally { db?.close(); }
87
+ }
88
+ /**
89
+ * Bounded read-only board snapshot. Select no task title/detail or history note into the result.
90
+ * With a window (#1147) only tasks that overlap [since, until] are read (updated >= since and created <= until), so a board over
91
+ * MAX_TASKS rows still reads completely when the window holds few of them. A board without those columns reads its newest MAX_TASKS rows.
92
+ * `linkedPrs` adds every task whose receipt links one of those PR numbers, window or not: a PR's lead time, review rounds and findings
93
+ * need all its tasks, so a sibling last updated before `since` must not be dropped. Their overflow shares the MAX_TASKS bound and
94
+ * makes the source incomplete, which turns every board-derived PR metric unknown.
95
+ */
96
+ export function dogfoodTasks(file: string, window?: { since: number; until: number }, linkedPrs: number[] = []): Source<DogfoodTask[]> {
56
97
  let db: Database | undefined;
57
98
  try {
58
99
  db = new Database(file, { readonly: true });
59
100
  db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
60
101
  const columns = new Set((db.query("PRAGMA table_info(tasks)").all() as {name:string}[]).map(c=>c.name));
102
+ const windowed = window !== undefined && Number.isFinite(window.since) && Number.isFinite(window.until) && columns.has("created") && columns.has("updated");
103
+ const linked = windowed && columns.has("sdlc") ? [...new Set(linkedPrs.filter(n => Number.isSafeInteger(n) && n > 0))].slice(0, MAX_PRS) : [];
104
+ // json_extract raises on malformed JSON, so a row is only asked when its receipt is valid JSON.
105
+ const where = !windowed ? "" : linked.length
106
+ ? `WHERE (updated >= ? AND created <= ?) OR (CASE WHEN json_valid(sdlc) THEN json_extract(sdlc,'$.pr') END) IN (${linked.map(() => "?").join(",")})`
107
+ : "WHERE updated >= ? AND created <= ?";
61
108
  const metadata = columns.has("class") ? "CASE WHEN length(class) <= 48 THEN class END" : "NULL";
62
109
  const profile = columns.has("project_profile") ? "CASE WHEN length(project_profile) <= 65536 THEN project_profile END" : "NULL";
63
110
  const plan = columns.has("plan") ? "CASE WHEN length(plan) <= 65536 THEN plan END" : "NULL";
@@ -67,7 +114,7 @@ export function dogfoodTasks(file: string): Source<DogfoodTask[]> {
67
114
  const profileCut = columns.has("project_profile") ? "(project_profile IS NOT NULL AND length(project_profile) > 65536)" : "0";
68
115
  const rows = db.query(`SELECT (${planCut}) AS plan_cut, ${plan} AS plan, (${metadataCut}) AS metadata_cut, ${state} AS current_state, ${metadata} AS base_class, ${profileCut} AS profile_cut, ${profile} AS project_profile, id, (sdlc IS NOT NULL AND length(sdlc) > 65536) AS receipt_cut, CASE WHEN length(sdlc) <= 65536 THEN sdlc END AS sdlc,
69
116
  CASE WHEN length(signals) <= 65536 THEN signals END AS signals,
70
- CASE WHEN length(history) <= 131072 THEN history END AS history FROM tasks ORDER BY id DESC LIMIT ?`).all(MAX_TASKS + 1) as { plan_cut: number; plan: string | null; metadata_cut: number; current_state: string | null; profile_cut: number; base_class: string | null; project_profile: string | null; id: number; receipt_cut: number; sdlc: string | null; signals: string | null; history: string | null }[];
117
+ CASE WHEN length(history) <= 131072 THEN history END AS history FROM tasks ${where} ORDER BY id DESC LIMIT ?`).all(...(windowed ? [window!.since, window!.until, ...linked] : []), MAX_TASKS + 1) as { plan_cut: number; plan: string | null; metadata_cut: number; current_state: string | null; profile_cut: number; base_class: string | null; project_profile: string | null; id: number; receipt_cut: number; sdlc: string | null; signals: string | null; history: string | null }[];
71
118
  let complete = rows.length <= MAX_TASKS, bytes = 0;
72
119
  const tasks: DogfoodTask[] = [];
73
120
  for (const row of rows.slice(0,MAX_TASKS)) {
@@ -144,15 +191,18 @@ export function dogfoodJournal(file: string, since: number, until: number): Sour
144
191
  }
145
192
  const connection = (value: any): any[] | undefined => value?.pageInfo?.hasNextPage === false && Array.isArray(value.nodes) && value.nodes.length <= 100 ? value.nodes : undefined;
146
193
  /** Reuse S10 cost readback; supplementary reads carry timestamps/ids only, never comment or issue bodies. */
147
- export async function dogfoodPrs(repo: string, tasks: Source<DogfoodTask[]>, since: number, until: number, run: SdlcGh = sdlcGh): Promise<Source<DogfoodPr[]>> {
148
- if (!repoName(repo) || !tasks.available || !tasks.complete) return missing([]); // unknown privacy inventory forbids remote per-PR reads
194
+ export async function dogfoodPrs(repo: string, tasks: Source<DogfoodTask[]>, since: number, until: number, run: SdlcGh = sdlcGh, pii?: Source<PiiRef[]>): Promise<Source<DogfoodPr[]>> {
195
+ // An unknown privacy inventory forbids remote per-PR reads. With the PII inventory (#1147) only it decides privacy, so an incomplete
196
+ // windowed task list no longer blocks them (its links stay unknown elsewhere); without it the task list is the inventory, as before.
197
+ const inventory: Source<PiiRef[]> = pii ?? { ...tasks, data: tasks.data.filter(t => t.pii).map(t => ({ id: t.id, ...(t.repo ? { repo: t.repo } : {}), ...(t.pr ? { pr: t.pr } : {}) })) };
198
+ if (!repoName(repo) || !inventory.available || !inventory.complete) return missing([]);
149
199
  const deadline = performance.now() + 20000;
150
200
  const gh: SdlcGh = (args, timeout, cwd, cap) => performance.now() < deadline
151
201
  ? run(args, Math.max(1,Math.min(timeout ?? 5000,deadline-performance.now())),cwd,Math.min(cap ?? 128*1024,128*1024)) : Promise.resolve({code:1,stdout:""});
152
202
  const parse = async (args: string[]) => { try { const r = await gh(args); return r.code === 0 ? JSON.parse(r.stdout) : undefined; } catch { return undefined; } };
153
203
  const list = await parse(["pr","list","--repo",repo,"--state","merged","--search",`merged:>=${new Date(since).toISOString()}`,"--limit","100","--json","number,mergedAt,createdAt"]);
154
204
  if (!Array.isArray(list)) return missing([]);
155
- const hidden = new Set(tasks.data.filter(t => t.pii && (!t.repo || t.repo.toLowerCase() === repo.toLowerCase())).map(t => t.pr));
205
+ const hidden = new Set(inventory.data.filter(t => !t.repo || t.repo.toLowerCase() === repo.toLowerCase()).map(t => t.pr));
156
206
  const visible = list.filter(p => id(p?.number) && date(p.mergedAt) !== undefined && date(p.mergedAt)! <= until && !hidden.has(p.number));
157
207
  let complete = list.length < 100 && list.every(p => id(p?.number) && date(p.mergedAt) !== undefined) && visible.length <= MAX_PRS;
158
208
  // Read recent PRs first: their complete recorded head sets usually fit before
@@ -252,15 +302,18 @@ export function dogfoodProjectProfile(root: string): Source<ProfileName | null>
252
302
  return source(selected);
253
303
  }
254
304
  export async function readDogfood(root: string, stateDir: string, since: number, until = Date.now(), run: SdlcGh = sdlcGh) {
255
- const tasks = dogfoodTasks(join(stateDir,"hub.db"));
305
+ const tasks = dogfoodTasks(join(stateDir,"hub.db"),{since,until}), pii = dogfoodPiiInventory(join(stateDir,"hub.db"));
256
306
  let repo = "";
257
307
  try { const r = await run(["repo","view","--json","nameWithOwner"],5000,root,8192); if (r.code === 0) repo = JSON.parse(r.stdout).nameWithOwner; } catch { /* unknown */ }
258
308
  const events = dogfoodEvents(dogfoodLines(join(stateDir,"events.jsonl")),since,until);
259
309
  const log = dogfoodLines(join(stateDir,"hub.log"));
310
+ const prs = await dogfoodPrs(repo,tasks,since,until,run,pii);
260
311
  log.data = log.data.filter(line => {
261
312
  const at=date(line.slice(0,24));
262
313
  if (at === undefined) { log.complete = false; return false; }
263
314
  return at >= since && at <= until;
264
315
  });
265
- return dogfoodScorecard({since,until,tasks,events,log,projectProfile:dogfoodProjectProfile(root),journal:dogfoodJournal(join(stateDir,"hub.db"),since,until),prs:await dogfoodPrs(repo,tasks,since,until,run),features:dogfoodFeatures(join(root,"docs/dogfood.md"))});
316
+ const merged = prs.available ? prs.data.map(p => p.pr) : [];
317
+ const board = merged.length ? dogfoodTasks(join(stateDir,"hub.db"),{since,until},merged) : tasks;
318
+ return dogfoodScorecard({since,until,tasks:board,events,log,projectProfile:dogfoodProjectProfile(root),journal:dogfoodJournal(join(stateDir,"hub.db"),since,until),prs,features:dogfoodFeatures(join(root,"docs/dogfood.md"))});
266
319
  }
@@ -69,7 +69,7 @@ const plan = {
69
69
  } as const;
70
70
 
71
71
  // Accept may target a task from an older project profile; Tasks normalizes by the stored task profile.
72
- const acceptPlan = { ...plan, properties: { ...plan.properties, sections: list("document heading anchors, such as report.md#methods"), sources: list("citation ids, DOI or source URL ids"), claims: list("short claim ids to verify") } } as const;
72
+ const acceptPlan = { ...plan, properties: { ...plan.properties, sections: list("document heading anchors, such as report.md#methods"), sources: list("registered src:<16 hex> source ids; list available ids with ahub source list"), claims: list("short claim ids to verify") } } as const;
73
73
 
74
74
  export interface HubTool {
75
75
  name: string;
@@ -154,10 +154,16 @@ export const PLANNER_REVIEW_TOOLS: HubTool[] = [
154
154
  tool("hub_plan_review", "Record a planner review against the task planning revision and host-read issue edit time. Configured planner or person only; never accepts caller issue timestamps.", { id, revision: { type: "integer", minimum: 0 }, reason: { ...str, maxLength: 1000 }, ...costPlanProperties }, ["id", "revision", "reason"]),
155
155
  tool("hub_sdlc_costs", "Read bounded review and CI cost metrics for linked merged PRs. Planner/person only; metadata only, no board writes.", { repo: str }),
156
156
  tool("hub_sdlc_metrics", "Read SDLC metrics from board history: time from acceptance to plan, first review and merge, first-pass approval and rework rounds, first-pass CI and same-head reruns, stale-head re-reviews, stalls, and the control band tiers when sdlc.toml sets [bands]. Planner/person only; read-only, no model and no GitHub issue.", { since: str }),
157
- tool("hub_backlog_return_untouched", "Return accepted but untouched work to the backlog with its history and planning retained. Refuses execution/branch/commit evidence; planner or person only.", { id, revision: { type: "integer", minimum: 0 }, reason: { ...str, maxLength: 1000 } }, ["id", "revision", "reason"]),
157
+ tool("hub_backlog_return_untouched", "Return accepted but untouched work to the backlog with its history and planning retained. Optional fields.reserved changes the reservation atomically before capacity is released; null clears it. Refuses execution/branch/commit evidence; planner or person only, and a person's reservation requires the person.", { id, revision: { type: "integer", minimum: 0 }, reason: { ...str, maxLength: 1000 }, fields: { type: "object", properties: { reserved: { type: ["string", "null"], minLength: 1, maxLength: 32 } }, additionalProperties: false } }, ["id", "revision", "reason"]),
158
158
  ];
159
159
  TASK_TOOLS.push(...PLANNER_REVIEW_TOOLS);
160
- export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
160
+ /** Only definitions declaring a hub-owned title slot publish this tool. It is absent from global schema lists; native approval names must exclude it too. */
161
+ export const SESSION_TITLE_TOOL: HubTool = tool("hub_set_title", "Name your own session once the task is clear. Only an unnamed session accepts the first agent name; the person may replace it. Private contexts refuse the write. No task, branch or instance is renamed.", { title: { type: "string", description: "1-80 characters after doingText normalization (one line, controls removed, surrounding whitespace trimmed)" } }, ["title"]);
162
+ export function sessionTitleTools(tools: HubTool[], allowed = false): HubTool[] {
163
+ const filtered = tools.filter(tool => tool.name !== SESSION_TITLE_TOOL.name);
164
+ return allowed ? [...filtered, SESSION_TITLE_TOOL] : filtered;
165
+ }
166
+ export const TASK_TOOL_NAMES = new Set([...TASK_TOOLS.map((t) => t.name), SESSION_TITLE_TOOL.name]);
161
167
 
162
168
  /** Separately published: serving a schema grants no authority, only the daemon's explicit role does. */
163
169
  export const CONDUCTOR_TOOLS: HubTool[] = [
@@ -246,7 +252,7 @@ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: rea
246
252
  properties.receipt = { ...receipt, properties: fields };
247
253
  }
248
254
  if (customProfile && tool.name === "hub_task_propose") properties.class = { type: "string", enum: [...new Set([...Object.keys(data.classes), ...PROFILE_CLASS_NAMES.filter(v => projectProfile("dev").classes[v])])] };
249
- if (customProfile && properties.plan && tool.name !== "hub_task_accept" && tool.name !== "hub_experiment_record") properties.plan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) } };
255
+ if (customProfile && properties.plan && tool.name !== "hub_task_accept" && tool.name !== "hub_experiment_record") properties.plan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "registered src:<16 hex> source ids; list available ids with ahub source list" : "short claim ids to verify")])) } };
250
256
  if (customProfile && tool.name === "hub_task_done" && data.name !== "dev") {
251
257
  const summaryProp = properties.summary as { type: string; description: string };
252
258
  properties.summary = {
@@ -258,7 +264,7 @@ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: rea
258
264
  }
259
265
  if (customProfile && tool.name === "hub_backlog_split") {
260
266
  const parts = properties.parts as { items: { properties: Record<string, unknown> } };
261
- const selectedPlan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) } };
267
+ const selectedPlan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "registered src:<16 hex> source ids; list available ids with ahub source list" : "short claim ids to verify")])) } };
262
268
  properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: selectedPlan } } };
263
269
  }
264
270
  if (criteria && properties.plan && tool.name !== "hub_experiment_record") {
@@ -128,19 +128,27 @@ export function projectConsole(options: { registry: Registry; lifecycle: Lifecyc
128
128
  await bounded(lifecycle.stopProject(project, input.instanceId), 20_000);
129
129
  return { ok: true };
130
130
  }
131
+ // #1057: the worktree shelf's person actions. The refresh is the one that reads GitHub, so it gets the delivery reads' deadline in its own branch below.
132
+ const worktreeAction = ["worktree_override_set", "worktree_override_clear", "worktrees_refresh_prs"].includes(String(input.action));
131
133
  // The settings actions (#269) reach the project hub as an ordinary session's: this dashboard opens no settings session.
132
- if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "config_edit_preview", "config_edit_apply", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
134
+ if (!["snapshot", "send", "pause", "resume", "permit", "propose", "intake", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "config_edit_preview", "config_edit_apply", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action)) && !worktreeAction) return { ok: false, error: "invalid dashboard action" };
133
135
  client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId: input.instanceId });
134
136
  const deliveryRead = input.action === "sdlc_queue" || input.action === "sdlc_release";
135
137
  let nominalMs = 10_000;
136
138
  if (input.action === "start_peer") nominalMs = MAX_COMMAND_MS + 5_000;
137
139
  else if (input.action === "backlog_split") nominalMs = BACKLOG_SPLIT_SCREEN_TIMEOUT_MS + HUB_DB_BUSY_TIMEOUT_MS + 5_000;
140
+ else if (input.action === "intake") nominalMs = 90_000; // the CLI intake cap, including the hub model's bounded split
138
141
  else if (deliveryRead) nominalMs = 30_000;
142
+ else if (input.action === "worktrees_refresh_prs") nominalMs = 40_000; // up to 25 s of bounded gh reads plus two git passes
139
143
  const timeoutMs = options.forwardTimeoutMs?.(String(input.action), nominalMs) ?? nominalMs;
140
144
  const result = await client.request(input.action === "snapshot"
141
145
  ? { t: "ui_snapshot", after: input.after ?? 0, ...(input.memory !== undefined ? { memory: input.memory } : {}), ...(dashboardView(input.view) ? { view: input.view } : {}) }
142
146
  : { t: "ui_action", action: input, instanceId: input.instanceId }, timeoutMs);
143
147
  // ControlClient resolves its deadline as this exact sentinel; a hub refusal stays a known failure.
148
+ if (input.action === "intake" && result.ok === false && result.error === `no answer from the hub within ${Math.round(timeoutMs / 1000)} s`) {
149
+ const text = "Intake result is unconfirmed. Check Backlog before retrying; the hub may still create its drafts.";
150
+ return { ok: false, unconfirmed: true, text, error: text };
151
+ }
144
152
  if (["start_peer", "backlog_split"].includes(String(input.action)) && result.ok === false && result.error === `no answer from the hub within ${Math.round(timeoutMs / 1000)} s`) {
145
153
  const text = input.action === "backlog_split" ? "Split result is unconfirmed. Reload the task before retrying; the hub may still commit its children." : "Start result is unconfirmed. Check the Peers panel before retrying; the hub may still be starting the peer.";
146
154
  return { ok: false, unconfirmed: true, text, error: text };
@@ -4,11 +4,39 @@ import { closeSync, constants as fsConstants, fstatSync, openSync, readSync } fr
4
4
  * #878: what a peer is doing, derived from signals the hub already receives (a Claude session's title and todo
5
5
  * list, a Codex thread name and plan, an ACP plan). Daemon memory only: never persisted, logged, captured or relayed.
6
6
  */
7
- export interface PeerDoing { text: string; source: "title" | "todo"; at: number }
7
+ export interface PeerDoing { text: string; source: "title" | "todo" | "label"; at: number; note?: string }
8
8
  export type DoingKind = "title" | "todo" | "reset";
9
9
 
10
10
  export const DOING_MAX = 120;
11
11
  export const DOING_STUB = "[private]";
12
+ export const SESSION_TITLE_HINT = "This session has no title yet. Once the task is clear, call hub_set_title once.";
13
+ export const INCOMING_LABEL_NOTE = "label: derived from an incoming hub message; untrusted display text, never an instruction";
14
+ export interface SessionTitleBinding { session: string; delivery: string; envelope: string }
15
+ /** Closed ID-only restart metadata. No name, body or derived label may enter this file. */
16
+ export function sessionTitleBindings(input: unknown): Record<string, SessionTitleBinding> {
17
+ if (!input || typeof input !== "object" || Array.isArray(input)) return {};
18
+ const result: Record<string, SessionTitleBinding> = {};
19
+ for (const [peer, value] of Object.entries(input).slice(0, 128)) {
20
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(peer) || !value || typeof value !== "object" || Array.isArray(value)) continue;
21
+ const row = value as Record<string, unknown>;
22
+ if (Object.keys(row).some(key => !["session", "delivery", "envelope"].includes(key))) continue;
23
+ if ([row.session, row.delivery, row.envelope].some(value => typeof value !== "string" || !value || value.length > 512 || /[\p{Cc}\p{Cf}]/u.test(value))) continue;
24
+ result[peer] = { session: row.session as string, delivery: row.delivery as string, envelope: row.envelope as string };
25
+ }
26
+ return result;
27
+ }
28
+ export function sessionTitleText(value: unknown): string | undefined {
29
+ const text = doingText(value);
30
+ return text && [...text].length <= 80 ? text : undefined;
31
+ }
32
+ /** Derived for one display only. Callers retain the original delivery, never this label. */
33
+ export function unnamedSessionLabel(value: unknown, isPrivate: (text: string) => boolean, at: number): PeerDoing | undefined {
34
+ const text = doingText(value);
35
+ if (!text) return undefined;
36
+ let hidden = true;
37
+ try { hidden = isPrivate(typeof value === "string" ? value : text); } catch { /* screen the whole source, not only its displayed head */ }
38
+ return { text: hidden ? DOING_STUB : [...text].slice(0, 60).join(""), source: "label", at, note: INCOMING_LABEL_NOTE };
39
+ }
12
40
  /** At most this much of a transcript is read per signal; lines over TITLE_LINE_MAX are skipped unparsed. */
13
41
  export const TITLE_READ_MAX = 1024 * 1024;
14
42
  export const TITLE_LINE_MAX = 64 * 1024;
@@ -37,10 +65,12 @@ export class DoingTracker {
37
65
  }
38
66
 
39
67
  /** `privateNow`: the peer owns or reviews a PII task, or is in a private turn. */
40
- view(privateNow: boolean): PeerDoing | undefined {
68
+ view(privateNow: boolean, isPrivate?: (text: string) => boolean): PeerDoing | undefined {
41
69
  const value = this.item ?? this.title;
42
70
  if (!value) return undefined;
43
- return { text: value.private || privateNow ? DOING_STUB : value.text, source: value.source, at: value.at };
71
+ let hidden = value.private || privateNow;
72
+ if (!hidden && isPrivate) try { hidden = isPrivate(value.text); } catch { hidden = true; }
73
+ return { text: hidden ? DOING_STUB : value.text, source: value.source, at: value.at };
44
74
  }
45
75
  }
46
76
 
package/src/hub/peers.ts CHANGED
@@ -2,6 +2,7 @@ import type { Envelope, EnvelopeOpts, PeerId, PeerState } from "./envelope.ts";
2
2
  import type { PermissionMode } from "./permission-mode.ts";
3
3
  import type { StartForm } from "../adapters/registry-types.ts";
4
4
  import type { DoingKind } from "./peer-doing.ts";
5
+ export type SessionTitleResult = { ok: true } | { ok: false; reason: "already_named" | "invalid"; error?: string; mayHaveApplied?: true };
5
6
  import type { CapabilityManifest } from "./capabilities.ts";
6
7
 
7
8
  export interface DeliveryReceipt {
@@ -61,6 +62,12 @@ export interface PeerAdapter {
61
62
  captureResume?(): Promise<Record<string, unknown>>;
62
63
  /** Native session binding; absence or undefined is unbound, never inferred from a different reader. */
63
64
  readonly nativeSession?: string;
65
+ /** Verified native name, or the local session's own field. Empty means verified unnamed; undefined is unknown. */
66
+ readonly sessionTitle?: string;
67
+ /** Opaque title-session identity, separate from context/provenance's nativeSession authority. */
68
+ readonly titleSession?: string;
69
+ /** Native adapters check the live name at the actual write. The host guard is checked immediately before effects. */
70
+ setSessionTitle?(title: string, onlyIfUnnamed: boolean, current?: () => boolean): Promise<SessionTitleResult>;
64
71
  /** Fixed-builtin native proof only; present undefined is unknown, not an absent proxy. */
65
72
  readonly frontendProxy?: FrontendProxyPort;
66
73
  /** Fixed operator notices from builtin native adapters, never provider text or native ids. */
@@ -85,6 +85,9 @@ export const SETTINGS: readonly SettingDef[] = [
85
85
  { key: "specs.review", group: "Switches", label: "Covering spec review", type: "enum", values: ["off", "report", "require"], store: "config", applies: "live", risk: "safe", path: ["specs", "review"] },
86
86
  { key: "approvals.notify", group: "Switches", label: "Desktop notice for a waiting approval", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["approvals", "notify"] },
87
87
  { key: "snapshots.enabled", group: "Switches", label: "Per-turn snapshots", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["snapshots", "enabled"] },
88
+ // #1057: the worktree shelf. Both only choose what the shelf shows and when it raises one inbox alert; neither deletes anything, so they are safe and live.
89
+ { key: "worktrees.quiet_days", group: "Switches", label: "Worktrees: shelve one idle this many days (0 never)", type: "integer", min: 0, max: 3650, store: "config", applies: "live", risk: "safe", path: ["worktrees", "quiet_days"] },
90
+ { key: "worktrees.alert_over", group: "Switches", label: "Worktrees: inbox alert above this many settled (0 never)", type: "integer", min: 0, max: 100000, store: "config", applies: "live", risk: "safe", path: ["worktrees", "alert_over"] },
88
91
  { key: "coordination", group: "Switches", label: "Coordination of overlapping owners", type: "enum", values: ["advisory", "turn-free"], store: "config", applies: "next use", risk: "safe", path: ["coordination"] },
89
92
  ];
90
93
 
package/src/hub/tasks.ts CHANGED
@@ -3319,6 +3319,8 @@ export class Tasks {
3319
3319
 
3320
3320
  async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; role?: unknown; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown; needs_network?: unknown; needs_worktree?: unknown; backlog?: unknown; draft?: unknown; goal?: unknown }, external?: GitHubSource, created?: () => void, fence?: () => string | undefined): Promise<Task> {
3321
3321
  const requestCheck = this.requestCheck(by, fence);
3322
+ const plan = cleanPlan(input.plan, this.d.profileData ?? this.d.profile ?? "dev");
3323
+ this.validatePlanSources(plan);
3322
3324
  requireLocalTaskPeer(input.owner);
3323
3325
  external = external ? { ...external } : undefined;
3324
3326
  if (external && (!this.personOrConductor(by) || input.backlog !== true)) throw new PreEffectToolRefusal("external intake requires person-owned backlog staging");
@@ -3355,7 +3357,6 @@ export class Tasks {
3355
3357
  let baseGiven: TaskClass | undefined;
3356
3358
  try { if (given !== undefined) baseGiven = profileClass(given, this.d.profileData ?? profile); } catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
3357
3359
  const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
3358
- const plan = cleanPlan(input.plan, this.d.profileData ?? profile);
3359
3360
  // The plan reaches other owners, so a PII pattern in it makes the task a PII task like one in the detail would.
3360
3361
  const screenDetail = [text.detail, text.refs.repo, external?.repo, external?.url].filter(value => value !== undefined && value !== "").join("\n");
3361
3362
  const signals = this.signalsFor({ ...text, detail: [screenDetail, planText(plan)].join("\n"), plan });
@@ -3431,10 +3432,14 @@ export class Tasks {
3431
3432
  }
3432
3433
  }
3433
3434
 
3434
- async returnUntouchedToBacklog(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
3435
+ async returnUntouchedToBacklog(by: PeerId, input: { id: number; revision: number; reason: string; fields?: unknown }): Promise<Task> {
3435
3436
  this.plannerWriteAuthority(by);
3436
3437
  if (!Number.isSafeInteger(input.id) || input.id < 1 || !Number.isSafeInteger(input.revision) || input.revision < 0
3437
3438
  || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("untouched return needs an id, revision and bounded reason");
3439
+ let parsed: PlanningFields;
3440
+ try { parsed = planningPatch(input.fields); } catch (error) { throw new PreEffectToolRefusal(`${(error as Error).message}; use fields.reserved with an agent peer id or null, or omit fields`); }
3441
+ if (Object.keys(parsed).some(key => key !== "reserved")) throw new PreEffectToolRefusal("untouched return fields may contain only reserved; remove every other field, or omit fields");
3442
+ const patch: Pick<PlanningFields, "reserved"> = Object.hasOwn(parsed, "reserved") ? { reserved: parsed.reserved } : {};
3438
3443
  const validate = () => {
3439
3444
  const task = this.need(input.id, false, true);
3440
3445
  this.plannerWriteAuthority(by, task);
@@ -3442,6 +3447,14 @@ export class Tasks {
3442
3447
  if (by !== USER && task.pinned) throw new PreEffectToolRefusal("pinned work requires the person");
3443
3448
  const release = task.release == null ? undefined : this.d.board.release(task.release);
3444
3449
  if (release && (["shipped", "dropped"].includes(release.state) || by !== USER && release.state === "frozen")) throw new PreEffectToolRefusal("settled or frozen release scope cannot return");
3450
+ if (Object.hasOwn(patch, "reserved")) {
3451
+ if (by !== USER && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained; ask the person to change it");
3452
+ const reserved = patch.reserved, definitionOf = this.d.adapterCatalog?.definitionOf ?? builtinDefinitionOf;
3453
+ if (reserved && !definitionOf(reserved, this.d.instances?.()) && !this.d.bus.knownPeers().includes(reserved) && !this.d.routing().classes[task.class]?.peers.includes(reserved)) throw new PreEffectToolRefusal("reserved owner is not a supported or configured peer; choose a peer from ahub status");
3454
+ if (reserved && !this.d.bus.isRostered(reserved)) throw new PreEffectToolRefusal("reserved owner is off roster; choose a rostered peer from ahub status or ask the person to update the roster");
3455
+ if (reserved && this.excluded(task).includes(reserved)) throw new PreEffectToolRefusal(`${reserved} is excluded from this task (it declined it or was escalated away from it for good); a reservation for it can never be honoured. Only a person's ahub task assign lifts the exclusion`);
3456
+ if (reserved) this.requireCapabilities(task, reserved);
3457
+ }
3445
3458
  return task;
3446
3459
  };
3447
3460
  const task = validate(), flow = task.history.length;
@@ -3450,14 +3463,14 @@ export class Tasks {
3450
3463
  const confirmed = validate();
3451
3464
  if (confirmed.history.length !== flow) throw new PreEffectToolRefusal("task changed during untouched return");
3452
3465
  const capacity = this.capacityBefore(confirmed);
3453
- const returned = this.d.board.update(confirmed.id, by, "backlog returned untouched", { state: "backlog", owner: null, reviewer: null }, input.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
3466
+ const returned = this.d.board.update(confirmed.id, by, "backlog returned untouched", { state: "backlog", owner: null, reviewer: null, ...patch }, input.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
3454
3467
  this.writePlannerNotices(returned, capacity);
3455
3468
  this.offered.delete(confirmed.id);
3456
3469
  this.cohorts.leave(confirmed.id);
3457
3470
  this.withdrawRoleRequest(confirmed, returned, "owner");
3458
3471
  this.withdrawRoleRequest(confirmed, returned, "reviewer");
3459
3472
  this.incomingBacklog.add(confirmed.id);
3460
- this.planningRecorded({ by, task: confirmed.id, fields: ["return"], revision: confirmed.planningRevision ?? 0 });
3473
+ this.planningRecorded({ by, task: confirmed.id, fields: ["return", ...Object.keys(patch)], revision: returned.planningRevision ?? 0 });
3461
3474
  await this.releaseHeld(confirmed.owner); // its owner's slot is free (#655)
3462
3475
  return returned;
3463
3476
  }
@@ -5776,6 +5789,8 @@ export class Tasks {
5776
5789
  const metadata = acceptRefs(refs);
5777
5790
  const task = this.need(id, false, true);
5778
5791
  this.mine(task, by, "owner", true);
5792
+ const proposedPlan = plan == null ? undefined : cleanPlan(plan, (task.profile ?? "dev") === (this.d.profile ?? "dev") ? this.d.profileData ?? task.profile ?? "dev" : task.profile ?? "dev");
5793
+ this.validatePlanSources(proposedPlan && Object.keys(proposedPlan).length ? proposedPlan : task.plan);
5779
5794
  this.acceptanceCapacity(task, 0, "check"); // #1080: an urgent task may still take unaccepted offers; `accept` does that, after its gates
5780
5795
  if (metadata && !this.isPii(task) && (!this.nameable(metadata.repo!) || looksSecret(metadata.repo!) || this.aboutPrivatePath("", metadata.repo!, task))) throw new PreEffectToolRefusal("this repository metadata is private or sensitive and is not kept");
5781
5796
  const draft = metadata ? { ...task, refs: { ...task.refs, ...metadata } } : task;
@@ -5890,6 +5905,12 @@ export class Tasks {
5890
5905
  throw new PreEffectToolRefusal(`planner WIP capacity for ${owner}: limit ${limit}; open tasks ${ids(open)} (${parts.join("; ")})${pending ? `; pending claims ${pending}` : ""}; ${next}`);
5891
5906
  }
5892
5907
 
5908
+ private validatePlanSources(plan: TaskPlan = {}): void {
5909
+ for (const id of plan.sources ?? []) if (!this.d.intake?.sourceKnown(id)) {
5910
+ throw new PreEffectToolRefusal(`unknown source ${id}; list registered ids with ahub source list`);
5911
+ }
5912
+ }
5913
+
5893
5914
  /**
5894
5915
  * With a plan, the owners of overlapping tasks get it as a ride-along line, and an overlap only the plan reveals is
5895
5916
  * announced like one found at assignment (issue #31).
@@ -5901,6 +5922,11 @@ export class Tasks {
5901
5922
  this.mine(task, by, "owner", true);
5902
5923
  this.ready(task, true);
5903
5924
  if (task.state !== "proposed" && task.state !== "changes_requested" && task.state !== "in_progress") throw new PreEffectToolRefusal(`task #${task.id} is ${task.state}: cannot move to in_progress`);
5925
+ // Models send null or {} for an optional field they leave empty: neither replaces a plan.
5926
+ const given = plan == null ? undefined : cleanPlan(plan, (task.profile ?? "dev") === (this.d.profile ?? "dev") ? this.d.profileData ?? task.profile ?? "dev" : task.profile ?? "dev");
5927
+ // File-list intent still replaces the old lists; omitted budget keys retain the planner-reviewed scope.
5928
+ const cleaned = given && Object.keys(given).length ? { ...cleanCostPlan(task.plan ?? {}), ...given } : undefined;
5929
+ this.validatePlanSources(cleaned ?? task.plan);
5904
5930
  this.studyDispatchCheck(task, task.owner ?? undefined);
5905
5931
  this.declaredNeedCheck(task, task.owner ?? by); // the owner's capability, also when the console user accepts on its behalf (`mine` admits USER)
5906
5932
  this.acceptanceCapacity(task, 0, "check");
@@ -5911,10 +5937,6 @@ export class Tasks {
5911
5937
  this.reviewedAdmissions.delete(task.id);
5912
5938
  if (!readback || readback.flow !== task.history.length || readback.owner !== task.owner) throw new PreEffectToolRefusal("current plan-review readback required before acceptance");
5913
5939
  }
5914
- // Models send null or {} for an optional field they leave empty: neither replaces a plan.
5915
- const given = plan == null ? undefined : cleanPlan(plan, (task.profile ?? "dev") === (this.d.profile ?? "dev") ? this.d.profileData ?? task.profile ?? "dev" : task.profile ?? "dev");
5916
- // File-list intent still replaces the old lists; omitted budget keys retain the planner-reviewed scope.
5917
- const cleaned = given && Object.keys(given).length ? { ...cleanCostPlan(task.plan ?? {}), ...given } : undefined;
5918
5940
  if (this.d.sdlc?.config().mode === "enforce" && task.sdlc?.planReview && !task.sdlc.planReview.scope
5919
5941
  && given && Object.keys(cleanCostPlan(given)).length) throw new PreEffectToolRefusal("legacy plan cost scope is unspecified; record a scoped planner review before acceptance");
5920
5942
  if (this.d.sdlc?.config().mode === "enforce" && task.sdlc?.planReview?.scope
package/src/hub/ui.ts CHANGED
@@ -149,7 +149,9 @@ export function startDashboard(options: DashboardOptions) {
149
149
  return reject(403, "live-test dashboard cannot write person-attributed content; use peer tools");
150
150
  }
151
151
  if (input.action === "sdlc_queue" || input.action === "sdlc_release") server.timeout(req, 35);
152
+ else if (input.action === "worktrees_refresh_prs") server.timeout(req, 45); // #1057: bounded gh reads (25 s) and two git passes
152
153
  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);
154
+ else if (input.action === "intake") server.timeout(req, 95); // the CLI's 90 s intake cap plus the HTTP response margin
153
155
  return json(await options.action(input, sessionOf(cookie)));
154
156
  } catch {
155
157
  // Exceptions may quote private task text or backend details. Never echo them to the page.
@@ -0,0 +1,100 @@
1
+ import { readSdlcPr, type SdlcGh } from "./sdlc.ts";
2
+ import type { PrObserver, WorktreeFact } from "./worktree-shelf.ts";
3
+
4
+ /**
5
+ * #1057 D1: the explicit "refresh PR states" action. It is the ONLY code that lets the worktree shelf learn a PR state from GitHub, through `readSdlcPr`
6
+ * (the same readback the guarded merge uses), and it runs only when a person asks. A panel refresh, `ahub worktrees` and the inbox alert never reach it, so
7
+ * they make no GitHub call (AC5): `worktree-shelf.ts` imports nothing from here and holds no GitHub access.
8
+ *
9
+ * What it reads is kept in memory, keyed by repository and PR number, and offered back to the shelf as a `PrObserver`.
10
+ * ponytail: the observations live in the daemon's memory, so a restart forgets them and those rows read `unknown` again until the next refresh; the upgrade is a
11
+ * `worktree_pr` table beside `worktree_override`, which is not worth a schema change before live use shows people refresh often.
12
+ */
13
+ export const REFRESH_MAX_PRS = 8, REFRESH_CALL_MS = 8_000, REFRESH_TOTAL_MS = 25_000, OBSERVATIONS_MAX = 512;
14
+
15
+ /**
16
+ * When GitHub says a PR that is no longer open was merged or closed, in ms. A second, narrow read (`pr view <n> --json mergedAt,closedAt`) that only a refresh makes and
17
+ * only for a PR `readSdlcPr` found merged or closed, so the shared readback the guarded merge and the queue use is unchanged. A missing, malformed or non-finite time is undefined,
18
+ * never 0: a merged PR with no change time is `unknown` to the partition, not settled.
19
+ */
20
+ export async function readPrChangeTime(repo: string, pr: number, state: "merged" | "closed", gh: SdlcGh): Promise<number | undefined> {
21
+ // An owner and a name, each starting with a letter or digit: `..`, `.x` and `-x` are never a repository, so a hostile slug cannot reach gh as a path or a flag.
22
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9_.][A-Za-z0-9_.-]*$/.test(repo) || repo.split("/").some(part => part === "." || part === "..") || !Number.isSafeInteger(pr) || pr < 1) return undefined;
23
+ try {
24
+ const view = await gh(["pr", "view", String(pr), "--repo", repo, "--json", "mergedAt,closedAt"], 8_000, undefined, 4096);
25
+ if (view.code !== 0) return undefined;
26
+ const value = JSON.parse(view.stdout) as { mergedAt?: unknown; closedAt?: unknown } | null;
27
+ const text = state === "merged" ? value?.mergedAt ?? value?.closedAt : value?.closedAt;
28
+ const at = typeof text === "string" && text.length <= 40 ? Date.parse(text) : NaN;
29
+ return Number.isFinite(at) && at > 0 ? at : undefined;
30
+ } catch { return undefined; }
31
+ }
32
+
33
+ export interface PrObservation { state: "open" | "merged" | "closed"; changedAt?: number; at: number }
34
+ export interface RefreshResult {
35
+ /** PRs read and stored. */
36
+ read: number;
37
+ /** PRs GitHub could not answer for (unreadable, no access, a gh failure): their state stays what it was. */
38
+ failed: number;
39
+ /** PRs not asked about because the cap or the time budget was reached. */
40
+ skipped: number;
41
+ }
42
+
43
+ /** A bounded, insertion-ordered map of what the last refreshes read. */
44
+ export class PrObservations {
45
+ private readonly seen = new Map<string, PrObservation>();
46
+ record(repo: string, pr: number, observation: PrObservation): void {
47
+ const key = `${repo}#${pr}`;
48
+ this.seen.delete(key); this.seen.set(key, observation);
49
+ while (this.seen.size > OBSERVATIONS_MAX) this.seen.delete(this.seen.keys().next().value!);
50
+ }
51
+ /** The shelf's view of it: state and change time only. */
52
+ observer: PrObserver = (repo, pr) => {
53
+ const found = this.seen.get(`${repo}#${pr}`);
54
+ return found ? { state: found.state, ...(found.changedAt !== undefined ? { changedAt: found.changedAt } : {}) } : undefined;
55
+ };
56
+ /** When the newest observation was read, or undefined when none was. */
57
+ get lastAt(): number | undefined { let at: number | undefined; for (const value of this.seen.values()) at = Math.max(at ?? 0, value.at); return at; }
58
+ get size(): number { return this.seen.size; }
59
+ }
60
+
61
+ /** The PRs a refresh would ask about: bound tasks with a repository and a PR that the board does not already show as merged, oldest activity first. */
62
+ export function refreshTargets(facts: readonly WorktreeFact[], limit = REFRESH_MAX_PRS): { repo: string; pr: number }[] {
63
+ const seen = new Set<string>(), out: { repo: string; pr: number }[] = [];
64
+ const candidates = facts.filter(f => !f.main && !f.instance && f.task?.repo !== undefined && f.task.pr !== undefined && f.pr.state !== "merged")
65
+ .sort((a, b) => a.lastActiveAt - b.lastActiveAt);
66
+ for (const fact of candidates) {
67
+ const key = `${fact.task!.repo}#${fact.task!.pr}`;
68
+ if (seen.has(key)) continue;
69
+ seen.add(key); out.push({ repo: fact.task!.repo!, pr: fact.task!.pr! });
70
+ }
71
+ return out.slice(0, limit);
72
+ }
73
+
74
+ /**
75
+ * Read each target once, one at a time, under one total budget. `gh` is the host's runner (`sdlcGh` bound to the project), never an argument a request supplies.
76
+ * `readSdlcPr` validates the repository and PR number itself and builds a fixed read-only argv. Every failure is counted and the old state stands.
77
+ */
78
+ export async function refreshPrStates(
79
+ targets: readonly { repo: string; pr: number }[],
80
+ gh: SdlcGh,
81
+ store: PrObservations,
82
+ options: { now?: () => number; totalMs?: number; callMs?: number; max?: number } = {},
83
+ ): Promise<RefreshResult> {
84
+ const now = options.now ?? Date.now, started = now(), total = options.totalMs ?? REFRESH_TOTAL_MS, perCall = options.callMs ?? REFRESH_CALL_MS;
85
+ const left = () => Math.max(0, started + total - now());
86
+ const bounded: SdlcGh = async (args, timeoutMs, cwd, maxOutputBytes) => left() <= 0 ? { code: 1, stdout: "" } : gh(args, Math.max(1, Math.min(timeoutMs ?? perCall, perCall, left())), cwd, maxOutputBytes);
87
+ const asked = targets.slice(0, options.max ?? REFRESH_MAX_PRS);
88
+ let read = 0, failed = 0, done = 0;
89
+ for (const target of asked) {
90
+ if (left() <= 0) break;
91
+ done++;
92
+ const evidence = await readSdlcPr({ stage: "pr", repo: target.repo, pr: target.pr }, bounded).catch(() => undefined);
93
+ if (!evidence) { failed++; continue; }
94
+ const state = evidence.state === "MERGED" ? "merged" : evidence.state === "CLOSED" ? "closed" : "open";
95
+ const changedAt = state === "open" ? undefined : await readPrChangeTime(target.repo, target.pr, state, bounded);
96
+ store.record(target.repo, target.pr, { state, ...(changedAt !== undefined ? { changedAt } : {}), at: now() });
97
+ read++;
98
+ }
99
+ return { read, failed, skipped: targets.length - done };
100
+ }