@staix/agent-hub 0.28.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 +22 -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 +4 -1
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +7 -3
- package/docs/agent-notes/tests.md +3 -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 +19 -1
- package/docs/dogfood.md +13 -7
- package/docs/operations.md +177 -20
- package/docs/quickstart.md +18 -3
- package/docs/sdlc.md +2 -2
- package/docs/security.md +45 -4
- 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.29.0.md +59 -0
- 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-control-write-stall-windows.json +241 -0
- package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
- package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
- package/docs/verification/2026-11-03-live-board-latency.json +556 -0
- package/docs/verification/2026-11-03-live-board-latency.md +64 -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 +715 -45
- 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 +3 -1
- package/src/cli/identity.ts +8 -1
- package/src/cli/main.ts +39 -0
- package/src/cli/output.ts +5 -0
- package/src/cli/recovery-package.ts +9 -0
- package/src/cli/worktree-tidy.ts +158 -0
- package/src/cli/worktrees-output.ts +43 -0
- package/src/hub/board.ts +31 -5
- 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/control-client.ts +16 -1
- package/src/hub/control-write-trace.ts +409 -0
- package/src/hub/daemon.ts +408 -66
- package/src/hub/dogfood-read.ts +62 -9
- package/src/hub/hub-tools.ts +12 -6
- 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/routing.ts +9 -2
- package/src/hub/runtime-capture-prune.ts +4 -1
- package/src/hub/sdlc-release-authorization.ts +13 -4
- package/src/hub/sdlc-release.ts +23 -10
- package/src/hub/sdlc.ts +15 -2
- package/src/hub/settings.ts +3 -0
- package/src/hub/task-worktree.ts +331 -0
- package/src/hub/tasks.ts +192 -25
- 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/local/deny.ts +49 -0
- package/src/local/tools.ts +4 -2
- package/src/memory/brief.ts +5 -0
- package/src/memory/capture.ts +1 -1
- package/src/memory/client.ts +5 -2
- 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;
|
|
@@ -122,7 +122,7 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
122
122
|
tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it. Set refs.repo for work in another repository; acceptance may correct only that repository metadata.", { id, plan: acceptPlan, refs: { ...refs, properties: { repo: refs.properties.repo } } }, ["id"]),
|
|
123
123
|
tool("hub_task_cancel", "Cancel proposed/backlog work you proposed as its configured planner, including work an owner accepted and then declined while it has no execution, branch, commit or PR evidence; work with any such evidence is person-only. Keeps history and does not stop a native turn or delete files.", { id, reason: str, superseded_by: str, revision: { type: "integer", minimum: 0 } }, ["id", "reason"]),
|
|
124
124
|
tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer. A plain decline is a lasting refusal: you are never offered this task again. Pass capacity: true when you only have no room right now: you are skipped for this move only, and a later reroute or planner pull may offer it to you again.", { id, reason: str, capacity: { type: "boolean" } }, ["id"]),
|
|
125
|
-
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs; no duplicate hub_send chat is needed. If a covering spec is unchanged, update it or provide spec_ack with a public reason for each unchanged spec. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs, deliverables: deliverablesSchema, spec_ack: specAck }, ["id", "summary"]),
|
|
125
|
+
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs; no duplicate hub_send chat is needed. When re-filing work after review, set refs.commit to the new full SHA; summary text does not change the review head. If a covering spec is unchanged, update it or provide spec_ack with a public reason for each unchanged spec. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs, deliverables: deliverablesSchema, spec_ack: specAck }, ["id", "summary"]),
|
|
126
126
|
tool("hub_sdlc_record", "Record artifact ids or claimed local checks on your task. This grants no authority or external-check evidence. Only its owner/reviewer or the person may record; only its reviewer/person may name a reviewed head or phase checklist. Phase evidence refs are content hashes, not external verification.", { id, receipt: { type: "object", properties: { deliverables: deliverablesSchema, stage: { type: "string", enum: ["spec", "plan", "implement", "review", "verify", "merge", "release"] }, repo: str, issue: id, pr: id, localHead: str, localResult: { type: "string", enum: ["passed", "failed", "unknown"] }, reviewedHead: str, phase: { type: "object", properties: { id: str, head: str, tasks: { type: "array", maxItems: 64, items: { type: "object", properties: { id, after: { type: "array", maxItems: 16, items: id } }, required: ["id", "after"], additionalProperties: false } }, criteria: { type: "array", maxItems: 32, items: { type: "object", properties: { id: str, required: { type: "boolean" }, result: { type: "string", enum: ["verified", "failed", "unverified"] }, refs: { type: "array", maxItems: 8, items: str } }, required: ["id", "required", "result", "refs"], additionalProperties: false } } }, required: ["id", "tasks", "criteria"], additionalProperties: false } }, additionalProperties: false } }, ["id", "receipt"]),
|
|
127
127
|
tool("hub_task_list", "The task board, newest updated first, 50 compact rows (id, state, owner, reviewer, title) per call. The reply is {tasks, total, next?}: pass next as cursor for the following page. Use ids for full rows of specific tasks, or detail: true for full rows of a page. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested", "cancelled"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" }, limit: { type: "integer", minimum: 1, maximum: 200, description: "rows per page, default 50" }, cursor: { type: "string", description: "the next value from the previous reply, unchanged" }, ids: { type: "array", minItems: 1, maxItems: 200, items: { type: "integer", minimum: 1 }, description: "full rows for exactly these task numbers; no paging" }, detail: { type: "boolean", description: "full rows instead of compact rows" } }),
|
|
128
128
|
tool("hub_specs", "Read the project's design-spec checks or find specs covering repository-relative paths. Read-only; at most 20 result items are returned. Does not reveal private task data.", { action: { type: "string", enum: ["check", "covers"] }, paths: { type: "array", maxItems: 32, items: { type: "string", minLength: 1, maxLength: 2000 } } }, ["action"]),
|
|
@@ -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/routing.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { join, resolve } from "node:path";
|
|
|
7
7
|
import { CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
8
8
|
import { isGitHubRepository, specRefs } from "./spec-read.ts";
|
|
9
9
|
|
|
10
|
-
export interface PeerCapabilities { tests: boolean; spec: boolean; testsReason?: string; specReason?: string; /** #1081: the peer's shell can reach the network (through the egress proxy or directly). Absent means unknown, never refused. */ network?: boolean; networkReason?: string; /** #1081: the peer can
|
|
10
|
+
export interface PeerCapabilities { tests: boolean; spec: boolean; testsReason?: string; specReason?: string; /** #1081: the peer's shell can reach the network (through the egress proxy or directly). Absent means unknown, never refused. */ network?: boolean; networkReason?: string; /** #1081: the peer can have the task worktree made for it: it makes its own, or (a hub sandbox, #270) the hub makes it (#1115) where it can. Absent means unknown, never refused. */ worktree?: boolean; worktreeReason?: string; }
|
|
11
11
|
import type { PeerId, PeerState } from "./envelope.ts";
|
|
12
12
|
|
|
13
13
|
import { parseHubRoutes, type HubRoute } from "../models/route/config.ts";
|
|
@@ -343,6 +343,12 @@ export function ownerRoleOfClass(cls: TaskClass): "planner" | "implementer" | "r
|
|
|
343
343
|
return cls === "review" ? "reviewer" : cls === "plan" ? "planner" : "implementer";
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
+
/**
|
|
347
|
+
* #1129: the classes whose owner changes the repository, so a hub-sandboxed owner needs the hub's task worktree and commit (#1115) whether or not the task declares
|
|
348
|
+
* `needs_worktree`. `test`, `summarize`, `triage`, `review` and `plan` read and report: they get none. One definition, so routing and `Tasks` cannot disagree.
|
|
349
|
+
*/
|
|
350
|
+
export const writesRepository = (cls: TaskClass): boolean => cls === "implement" || cls === "bulk_edit";
|
|
351
|
+
|
|
346
352
|
/** Why a planner was chosen: the person named it, rotation reached it, the task's recorded planner is kept, or there is none (PII, no holder). */
|
|
347
353
|
export type PlannerReason = "rotation" | "named" | "kept" | "none";
|
|
348
354
|
|
|
@@ -493,7 +499,8 @@ export function assign(
|
|
|
493
499
|
// #1081: work that declares it needs the network is never offered to a peer whose shell has none. Unknown (no `network` entry) is not a refusal.
|
|
494
500
|
if (task.signals.includes("needs_network") && caps?.network === false) return deny("network", `no network: ${caps.networkReason ?? "its shell cannot reach the network"}`);
|
|
495
501
|
// #1081: work that declares it needs a task worktree is never offered to a peer that cannot create one, so no branch ref is left behind by a refused accept.
|
|
496
|
-
|
|
502
|
+
// #1129: write-class work needs one whether or not it declares it, because a hub-sandboxed owner cannot commit and the hub commits in its worktree. Unknown capability is not a refusal.
|
|
503
|
+
if ((task.signals.includes("needs_worktree") || writesRepository(task.class)) && caps?.worktree === false) return deny("worktree", `no worktree: ${caps.worktreeReason ?? "it cannot create the task worktree"}`);
|
|
497
504
|
}
|
|
498
505
|
if (opts.offRoster?.includes(peer)) return deny("roster", "off roster");
|
|
499
506
|
const named = role === "owner" ? opts.namedOwner ?? reserved : opts.namedReviewer;
|
|
@@ -246,7 +246,10 @@ function knownLayout(root: string, directories: readonly string[], files: readon
|
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
function captureLayout(root: string): void {
|
|
249
|
-
knownLayout(root, ["src", "plugins", "templates", ".claude-plugin"], ["package.json", "README.md", "LICENSE", "CHANGELOG.md"], ["node_modules"]);
|
|
249
|
+
knownLayout(root, ["src", "plugins", "templates", ".claude-plugin"], ["package.json", "README.md", "LICENSE", "CHANGELOG.md"], ["node_modules", "scripts"]);
|
|
250
|
+
// #1120: captures made before the release checker was carried have no scripts directory; when present it holds exactly the checker.
|
|
251
|
+
// The name is repeated here on purpose: a pruner copied into a capture runs beside that capture's own verifier and imports nothing but verifyPackage from it. test/runtime-capture-checker.test.ts pins this name to RELEASE_CHECKER.
|
|
252
|
+
if (optionalInfo(join(root, "scripts"))) knownLayout(join(root, "scripts"), [], ["check-docs.mjs"]);
|
|
250
253
|
const modules = join(root, "node_modules");
|
|
251
254
|
if (optionalInfo(modules)) {
|
|
252
255
|
knownLayout(modules, ["@staix"], []); const scope = join(modules, "@staix"); knownLayout(scope, ["agent-mem"], []);
|
|
@@ -2,7 +2,7 @@ import { releaseTrackedClean } from "./release-worktree.ts";
|
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { USER, type PeerId } from "./envelope.ts";
|
|
4
4
|
import { hubGitEnv, hubGitSync } from "./git.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { readDocEvidenceDetailed, type DocEvidence, type ReleaseGit, type ReleaseReadiness } from "./sdlc-release.ts";
|
|
6
6
|
import type { SdlcGh } from "./sdlc.ts";
|
|
7
7
|
import type { ReleaseGuardRequest, ReleasePushUpdate } from "../cli/sdlc-release-guard.ts";
|
|
8
8
|
|
|
@@ -17,6 +17,8 @@ export interface ReleaseAuthorization { schemaVersion: 1; id: string; by: PeerId
|
|
|
17
17
|
export interface ReleaseAuthorizationPorts {
|
|
18
18
|
git?: ReleaseGit;
|
|
19
19
|
docs?: (root: string) => Promise<DocEvidence | undefined>;
|
|
20
|
+
/** Test seam: the checker program the default reader spawns (#1120). Never set from configuration. */
|
|
21
|
+
checker?: string;
|
|
20
22
|
gh?: SdlcGh;
|
|
21
23
|
now?: () => number;
|
|
22
24
|
current?: () => boolean;
|
|
@@ -46,6 +48,13 @@ export class ReleaseAuthorizations {
|
|
|
46
48
|
private latest?: Entry;
|
|
47
49
|
constructor(private readonly root: string, private readonly ports: ReleaseAuthorizationPorts) {}
|
|
48
50
|
private current(): void { if (this.ports.current?.() === false) throw new ReleaseAuthorizationError("release authorization unavailable during lifecycle transition"); }
|
|
51
|
+
/** #1120: a checker that is missing or failed is its own refusal; only evidence that is read and not fresh is "stale documentation". An injected reader keeps its undefined meaning (no evidence). */
|
|
52
|
+
private async docs(commit: string, version: string): Promise<ReleaseDocsVerdict | undefined> {
|
|
53
|
+
if (this.ports.docs) return docsVerdict(await this.ports.docs(this.root), commit, version);
|
|
54
|
+
const read = await readDocEvidenceDetailed(this.root, undefined, this.ports.checker);
|
|
55
|
+
if ("unavailable" in read) throw new ReleaseAuthorizationError(`release authorization needs the documentation checker: ${read.unavailable}`);
|
|
56
|
+
return docsVerdict(read.evidence, commit, version);
|
|
57
|
+
}
|
|
49
58
|
private actor(by: PeerId): void {
|
|
50
59
|
if (by !== USER && (typeof by !== "string" || !by.trim() || this.ports.conductor?.() !== by)) throw new ReleaseAuthorizationError("only the person or the configured conductor may authorize a release");
|
|
51
60
|
}
|
|
@@ -95,8 +104,8 @@ export class ReleaseAuthorizations {
|
|
|
95
104
|
this.current();
|
|
96
105
|
const git = this.git(), commit = this.commit(git, "HEAD"), origin = this.origin(git);
|
|
97
106
|
if (commit !== expectedCommit || this.version(git, commit) !== version) throw new ReleaseAuthorizationError("candidate commit or version differs from the person's request");
|
|
98
|
-
const docs =
|
|
99
|
-
if (!docs) throw new ReleaseAuthorizationError("release authorization requires fresh committed documentation evidence");
|
|
107
|
+
const docs = await this.docs(commit, version);
|
|
108
|
+
if (!docs) throw new ReleaseAuthorizationError("release authorization requires fresh committed documentation evidence: run `node scripts/check-docs.mjs --committed` in the candidate checkout, restamp the stale documents in a committed change, then authorize again");
|
|
100
109
|
this.current(); this.actor(by);
|
|
101
110
|
if (this.commit(git, "HEAD") !== commit || this.version(git, commit) !== version || this.origin(git).hash !== origin.hash) throw new ReleaseAuthorizationError("release candidate changed during authorization");
|
|
102
111
|
this.current(); this.actor(by);
|
|
@@ -127,7 +136,7 @@ export class ReleaseAuthorizations {
|
|
|
127
136
|
if (!releaseTrackedClean(this.root, git)) throw new ReleaseAuthorizationError("npm publish requires a clean tracked worktree");
|
|
128
137
|
const commit = this.commit(git, "HEAD"), version = this.version(git, commit);
|
|
129
138
|
const receipt = this.matched(git, version, commit);
|
|
130
|
-
const docs =
|
|
139
|
+
const docs = await this.docs(commit, version);
|
|
131
140
|
this.current();
|
|
132
141
|
if (!docs || docs.digest !== receipt.docs.digest || this.commit(git, "HEAD") !== commit || !releaseTrackedClean(this.root, git)) throw new ReleaseAuthorizationError("publish checkout or documentation evidence changed");
|
|
133
142
|
return { ok: true, receipts: [this.matched(git, version, commit)] };
|
package/src/hub/sdlc-release.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Task } from "./board.ts";
|
|
2
2
|
import type { Release } from "./planning.ts";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { childEnv, stopOwnedProcess } from "./child-process.ts";
|
|
6
7
|
import { hubGitSync } from "./git.ts";
|
|
@@ -23,7 +24,7 @@ const sdlcGh: SdlcGh = async (...args) => {
|
|
|
23
24
|
export interface DocEvidence { schemaVersion: 1; head?: string; version?: string; documents: { path?: string; state: "fresh" | "stale" | "nonancestor" | "unknown"; verifiedAgainst?: string; commits?: number; dirty?: boolean }[]; errors: number; fresh: boolean; }
|
|
24
25
|
export interface ReleaseReadiness { /** #481 AC12: interval, approval-to-release lead time and hotfix rate over shipped releases, filled by Tasks.releaseReadiness. */ releaseMetrics?: ReturnType<typeof releaseMetrics>; repo: string; head?: string; version?: string; tag?: string; tagCommit?: string; commits: string[]; mergedPrs: { pr: number; commit: string }[]; documents?: DocEvidence; liveIssues: { id: number; state: "open" | "closed" | "unknown" }[]; blockers: string[]; sourceReady: boolean; native: "unverified"; ready: false; }
|
|
25
26
|
export type ReleaseGit = (args: string[]) => { code: number; stdout: string };
|
|
26
|
-
export interface ReleaseReaders { gh?: SdlcGh; git?: ReleaseGit; docs?: (root: string, timeoutMs: number) => Promise<DocEvidence | undefined>; }
|
|
27
|
+
export interface ReleaseReaders { gh?: SdlcGh; git?: ReleaseGit; docs?: (root: string, timeoutMs: number) => Promise<DocEvidence | undefined>; /** Test seam (#1120): the checker program the default docs reader spawns. */ checker?: string; }
|
|
27
28
|
const SHA = /^[0-9a-f]{40}$/;
|
|
28
29
|
const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
29
30
|
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
@@ -34,21 +35,31 @@ function tagMatches(target: unknown, commit: string): boolean {
|
|
|
34
35
|
return typeof oid === "string" && SHA.test(oid) && oid === commit;
|
|
35
36
|
}
|
|
36
37
|
/** Package-owned checker only; no caller-supplied program or document text is executed. */
|
|
37
|
-
|
|
38
|
+
/** #1120: why no evidence could be read, apart from evidence that says the documents are stale. */
|
|
39
|
+
export type DocEvidenceRead = { evidence: DocEvidence } | { unavailable: string };
|
|
40
|
+
const CHECKER_PATH = fileURLToPath(new URL("../../scripts/check-docs.mjs", import.meta.url));
|
|
41
|
+
const CHECKER_MISSING = "the documentation checker scripts/check-docs.mjs is missing from the running hub, which predates the fix that carries it into captured runtimes; record consent from a hub started from a checkout or a release that carries the checker (docs/sdlc.md, release preparation)";
|
|
42
|
+
const CHECKER_FAILED = "the documentation checker in the running hub did not finish with readable evidence (it timed out, crashed or printed unreadable output); run `node scripts/check-docs.mjs --json --committed` in the candidate checkout to see why, then retry";
|
|
43
|
+
export async function readDocEvidence(root: string, timeoutMs = 10_000): Promise<DocEvidence | undefined> {
|
|
44
|
+
const read = await readDocEvidenceDetailed(root, timeoutMs);
|
|
45
|
+
return "evidence" in read ? read.evidence : undefined;
|
|
46
|
+
}
|
|
47
|
+
export function readDocEvidenceDetailed(root: string, timeoutMs = 10_000, checker = CHECKER_PATH): Promise<DocEvidenceRead> {
|
|
48
|
+
if (!existsSync(checker)) return Promise.resolve({ unavailable: CHECKER_MISSING });
|
|
38
49
|
return new Promise(resolve => {
|
|
39
|
-
const child = spawn(process.execPath, [
|
|
50
|
+
const child = spawn(process.execPath, [checker, root, "--json", "--committed"], { cwd: root, env: childEnv(), detached: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
40
51
|
let output = "", invalid = false;
|
|
41
52
|
const stop = () => { invalid = true; void stopOwnedProcess(child, { group: true }).catch(() => {}); };
|
|
42
53
|
const timer = setTimeout(stop, Math.max(1, Math.min(10_000, timeoutMs)));
|
|
43
54
|
child.stdout?.setEncoding("utf8"); child.stdout?.on("data", chunk => { output += chunk; if (Buffer.byteLength(output) > 256 * 1024) { output = ""; stop(); } });
|
|
44
|
-
child.once("error", () => { invalid = true; clearTimeout(timer); resolve(
|
|
55
|
+
child.once("error", () => { invalid = true; clearTimeout(timer); resolve({ unavailable: CHECKER_FAILED }); });
|
|
45
56
|
child.once("close", (code, signal) => {
|
|
46
|
-
clearTimeout(timer); if (invalid || signal || (code !== 0 && code !== 1)) return resolve(
|
|
57
|
+
clearTimeout(timer); if (invalid || signal || (code !== 0 && code !== 1)) return resolve({ unavailable: CHECKER_FAILED });
|
|
47
58
|
try {
|
|
48
59
|
const data = JSON.parse(output);
|
|
49
|
-
if (data.schemaVersion !== 1 || !Array.isArray(data.documents) || data.documents.length > 128 || !Number.isSafeInteger(data.errors) || data.errors < 0 || typeof data.fresh !== "boolean" || (data.head !== undefined && !SHA.test(data.head)) || (data.version !== undefined && !VERSION.test(data.version)) || data.documents.some((d: {state?:string}) => !["fresh","stale","nonancestor","unknown"].includes(d.state ?? ""))) return resolve(
|
|
50
|
-
resolve(data);
|
|
51
|
-
} catch { resolve(
|
|
60
|
+
if (data.schemaVersion !== 1 || !Array.isArray(data.documents) || data.documents.length > 128 || !Number.isSafeInteger(data.errors) || data.errors < 0 || typeof data.fresh !== "boolean" || (data.head !== undefined && !SHA.test(data.head)) || (data.version !== undefined && !VERSION.test(data.version)) || data.documents.some((d: {state?:string}) => !["fresh","stale","nonancestor","unknown"].includes(d.state ?? ""))) return resolve({ unavailable: CHECKER_FAILED });
|
|
61
|
+
resolve({ evidence: data });
|
|
62
|
+
} catch { resolve({ unavailable: CHECKER_FAILED }); }
|
|
52
63
|
});
|
|
53
64
|
});
|
|
54
65
|
}
|
|
@@ -101,8 +112,10 @@ export async function readReleaseReadiness(root: string, repo: string, liveIssue
|
|
|
101
112
|
} catch { report.blockers.push("merged PR coverage unknown or truncated"); break; }
|
|
102
113
|
}
|
|
103
114
|
}
|
|
104
|
-
const left = deadline - performance.now(); report.documents = left > 0 ? await (readers.docs ??
|
|
105
|
-
|
|
115
|
+
const left = deadline - performance.now(); report.documents = left > 0 ? await (readers.docs ?? ((dir, ms) => readDocEvidenceDetailed(dir, ms, readers.checker).then(read => "evidence" in read ? read.evidence : undefined)))(root,Math.min(10_000,left)) : undefined;
|
|
116
|
+
const checkerMissing = !readers.docs && !existsSync(readers.checker ?? CHECKER_PATH); // #1120: a hub without the checker says so instead of calling the documents stale
|
|
117
|
+
if (checkerMissing) report.blockers.push("documentation checker unavailable in the running hub: " + CHECKER_MISSING);
|
|
118
|
+
else if (!report.documents || report.documents.head !== head || !report.documents.fresh || report.documents.errors || report.documents.documents.some(d => d.state !== "fresh")) report.blockers.push("documentation freshness or ancestry is unknown or stale");
|
|
106
119
|
if (!liveIssues.length) report.blockers.push("native verification ledger unspecified");
|
|
107
120
|
for (const id of liveIssues) {
|
|
108
121
|
const reply = await gh(["issue","view",String(id),"--repo",repo,"--json","number,state"]); let state: "open"|"closed"|"unknown" = "unknown";
|
package/src/hub/sdlc.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseIssueLabels, parseRedFirst, testIntegrityBlockers, type RedFirstReceipt, type TestIntegrity } from "./test-integrity.ts";
|
|
2
2
|
import { planConformanceBlockers, type PlanConformance } from "./plan-conformance.ts";
|
|
3
|
+
import { controlWriteSpan } from "./control-write-trace.ts";
|
|
3
4
|
import { DEFAULT_MAX_NITS, findingsForHead, findingHead, findingTally, type ReviewFindingSet, type ReviewFindingTally } from "./review-findings.ts";
|
|
4
5
|
import type { CostPlan } from "./cost-governance.ts";
|
|
5
6
|
import { bandsConfig, type BandsConfig } from "./sdlc-metrics.ts";
|
|
@@ -427,7 +428,7 @@ export function touchingPrs(prs: { pr: number; paths: string[] }[]): { before: n
|
|
|
427
428
|
|
|
428
429
|
export type SdlcGh = (args: string[], timeoutMs?: number, cwd?: string, maxOutputBytes?: number) => Promise<{ code: number; stdout: string }>;
|
|
429
430
|
/** Fixed argv and bounded output and time; diagnostics never expose credentials or remote text. Reads, except the one guarded `gh pr merge` that `sdlc-merge.ts` builds (#786). */
|
|
430
|
-
export const sdlcGh: SdlcGh = (args, timeoutMs = 10_000, cwd, maxOutputBytes = 256 * 1024) => new Promise((resolve) => {
|
|
431
|
+
export const sdlcGh: SdlcGh = (args, timeoutMs = 10_000, cwd, maxOutputBytes = 256 * 1024) => controlWriteSpan("external", () => new Promise<{ code: number; stdout: string }>((resolve) => {
|
|
431
432
|
const outputLimit = Number.isSafeInteger(maxOutputBytes) ? Math.min(2 * 1024 * 1024, Math.max(1, maxOutputBytes)) : 256 * 1024;
|
|
432
433
|
let child: ReturnType<typeof spawn>;
|
|
433
434
|
try {
|
|
@@ -440,7 +441,7 @@ export const sdlcGh: SdlcGh = (args, timeoutMs = 10_000, cwd, maxOutputBytes = 2
|
|
|
440
441
|
child.stdout?.on("data", chunk => { if (failed) return; stdout += chunk.toString(); if (Buffer.byteLength(stdout) > outputLimit) { stdout = ""; stop(); } });
|
|
441
442
|
child.once("error", () => { failed = true; clearTimeout(timer); resolve({ code: 1, stdout: "" }); });
|
|
442
443
|
child.once("close", code => { clearTimeout(timer); resolve({ code: failed ? 1 : code ?? 1, stdout: failed ? "" : stdout }); });
|
|
443
|
-
});
|
|
444
|
+
}));
|
|
444
445
|
|
|
445
446
|
/** Read-only, fixed argv: the issue's edit times, never its body. */
|
|
446
447
|
export async function readSpecEdit(repoName: string, issue: number, run: SdlcGh = sdlcGh): Promise<SpecEdit | undefined> {
|
|
@@ -506,6 +507,18 @@ export async function readSdlcPr(receipt: SdlcReceipt, run: SdlcGh = sdlcGh, wit
|
|
|
506
507
|
} catch { return undefined; }
|
|
507
508
|
}
|
|
508
509
|
|
|
510
|
+
/** Read only the current PR head when broader PR evidence is unavailable. */
|
|
511
|
+
export async function readSdlcPrHead(receipt: SdlcReceipt, run: SdlcGh = sdlcGh): Promise<string | undefined> {
|
|
512
|
+
if (!repo(receipt.repo) || !id(receipt.pr)) return undefined;
|
|
513
|
+
try {
|
|
514
|
+
const result = await run(["pr", "view", String(receipt.pr), "--repo", receipt.repo, "--json", "headRefOid"], 10_000, undefined, 1024);
|
|
515
|
+
if (result.code !== 0 || Buffer.byteLength(result.stdout) > 1024) return undefined;
|
|
516
|
+
const value: unknown = JSON.parse(result.stdout);
|
|
517
|
+
const head = (value as { headRefOid?: unknown } | null)?.headRefOid;
|
|
518
|
+
return sha(head) ? head : undefined;
|
|
519
|
+
} catch { return undefined; }
|
|
520
|
+
}
|
|
521
|
+
|
|
509
522
|
export interface PlanIssueMetadata {
|
|
510
523
|
repo: string;
|
|
511
524
|
issue: number;
|
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
|
|