@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.
- package/CHANGELOG.md +12 -0
- package/README.md +5 -3
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +2 -1
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +2 -1
- package/docs/agent-notes/tasks.md +4 -2
- package/docs/agent-notes/tests.md +2 -0
- package/docs/design/adapters.md +56 -0
- package/docs/design/agent-hub-architecture.md +12 -0
- package/docs/design/bus.md +8 -0
- package/docs/design/task-board.md +18 -0
- package/docs/dogfood.md +7 -4
- package/docs/operations.md +106 -11
- package/docs/quickstart.md +10 -3
- package/docs/sdlc.md +1 -1
- package/docs/security.md +28 -2
- package/docs/smoke.md +13 -2
- package/docs/specs/2026-10-12-office-view-design.md +86 -3
- package/docs/verification/2026-11-03-0.30.0.md +54 -0
- package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
- package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
- package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
- package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
- package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
- package/docs/verification/2026-11-03-pi-title-api.md +26 -0
- package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
- package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
- package/docs/verification/dogfood/2026-11-03.md +3095 -0
- package/docs/verification/pi-150-model-free.ts +57 -0
- package/docs/verification/pi-150-native.mjs +21 -0
- package/docs/verification/pi-150-title-api.mjs +31 -0
- package/docs/verified.json +14 -14
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +193 -29
- package/src/adapters/compat.ts +10 -10
- package/src/adapters/definitions/builtin-metadata.ts +2 -2
- package/src/adapters/local-worker.ts +22 -6
- package/src/adapters/pi.ts +92 -10
- package/src/adapters/registry-types.ts +4 -0
- package/src/adapters/registry.ts +2 -0
- package/src/cli/autonomy.ts +66 -1
- package/src/cli/console-state.ts +6 -2
- package/src/cli/help.ts +2 -0
- package/src/cli/identity.ts +8 -1
- package/src/cli/main.ts +39 -0
- package/src/cli/worktree-tidy.ts +158 -0
- package/src/cli/worktrees-output.ts +43 -0
- package/src/hub/board.ts +28 -3
- package/src/hub/bus.ts +4 -4
- package/src/hub/conductor.ts +4 -3
- package/src/hub/config-apply.ts +2 -1
- package/src/hub/daemon.ts +237 -19
- package/src/hub/dogfood-read.ts +62 -9
- package/src/hub/hub-tools.ts +11 -5
- package/src/hub/manager.ts +9 -1
- package/src/hub/peer-doing.ts +33 -3
- package/src/hub/peers.ts +7 -0
- package/src/hub/settings.ts +3 -0
- package/src/hub/tasks.ts +30 -8
- package/src/hub/ui.ts +2 -0
- package/src/hub/worktree-prs.ts +100 -0
- package/src/hub/worktree-shelf.ts +246 -0
- package/src/memory/capture.ts +1 -1
- package/src/pi/extension.ts +43 -3
- package/src/ui/index.html +133 -10
- package/src/ui/office-model.ts +64 -8
- package/src/ui/office-space.ts +1 -0
- package/src/ui/plugin.ts +14 -3
package/src/hub/dogfood-read.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
55
|
-
export
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -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("
|
|
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
|
-
|
|
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" ? "
|
|
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" ? "
|
|
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") {
|
package/src/hub/manager.ts
CHANGED
|
@@ -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 };
|
package/src/hub/peer-doing.ts
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/src/hub/settings.ts
CHANGED
|
@@ -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:
|
|
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
|
+
}
|