@staix/agent-hub 0.12.35 → 0.12.36
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 +14 -0
- package/README.md +8 -6
- package/docs/agent-notes/adapters.md +4 -1
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +3 -0
- package/docs/agent-notes/daemon.md +6 -2
- package/docs/agent-notes/tasks.md +11 -1
- package/docs/agent-notes/tests.md +5 -1
- package/docs/operations.md +40 -16
- package/docs/quickstart.md +6 -8
- package/docs/sdlc.md +46 -5
- package/docs/security.md +5 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +76 -1
- package/docs/verification/2026-10-19-0.12.36.md +63 -0
- package/docs/verified.json +18 -14
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +385 -29
- package/src/adapters/claude-print.ts +230 -0
- package/src/adapters/definitions/builtin-metadata.ts +17 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
- package/src/adapters/definitions/native-cli-entry-jcode.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +7 -0
- package/src/adapters/registry-types.ts +8 -2
- package/src/adapters/registry.ts +13 -1
- package/src/cli/help.ts +5 -0
- package/src/cli/identity.ts +2 -1
- package/src/cli/launch.ts +19 -6
- package/src/cli/main.ts +38 -4
- package/src/cli/reset.ts +4 -2
- package/src/cli/statusline-tee.ts +5 -2
- package/src/cli/terminal-recovery.ts +7 -2
- package/src/cli/upgrade-runtime.ts +18 -15
- package/src/hub/board.ts +29 -7
- package/src/hub/bus.ts +11 -5
- package/src/hub/claude-state.ts +37 -0
- package/src/hub/cost-governance.ts +76 -0
- package/src/hub/daemon.ts +181 -73
- package/src/hub/hub-tools.ts +20 -1
- package/src/hub/instances.ts +17 -3
- package/src/hub/review-brief.ts +175 -0
- package/src/hub/routing.ts +17 -2
- package/src/hub/sdlc.ts +205 -4
- package/src/hub/status-roster.ts +10 -0
- package/src/hub/task-sweep.ts +143 -0
- package/src/hub/tasks.ts +544 -26
- package/src/hub/usage.ts +57 -20
package/src/hub/hub-tools.ts
CHANGED
|
@@ -17,6 +17,10 @@ const refs = {
|
|
|
17
17
|
additionalProperties: false,
|
|
18
18
|
} as const;
|
|
19
19
|
const list = (description: string) => ({ type: "array", items: str, description }) as const;
|
|
20
|
+
const costPlanProperties = {
|
|
21
|
+
review: { type: "object", properties: { scope: { type: "string", enum: ["none", "delta", "full"] }, reviewers: { type: "integer", minimum: 0, maximum: 8 } }, required: ["scope", "reviewers"], additionalProperties: false },
|
|
22
|
+
ci: { type: "string", enum: ["focused", "full", "reuse"] },
|
|
23
|
+
} as const;
|
|
20
24
|
const plan = {
|
|
21
25
|
type: "object",
|
|
22
26
|
description: "what you will change, before you start. Owners of open tasks on the same files or symbols see it, and get a notice when your task is done.",
|
|
@@ -66,7 +70,7 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
66
70
|
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 the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
|
|
67
71
|
tool("hub_sdlc_record", "Record artifact ids or claimed local checks on your task. This grants no authority or external-check evidence. Only its owner/reviewer or the person may record; only its reviewer/person may name a reviewed head or phase checklist. Phase evidence refs are content hashes, not external verification.", { id, receipt: { type: "object", properties: { stage: { type: "string", enum: ["spec", "plan", "implement", "review", "verify", "merge", "release"] }, repo: str, issue: id, pr: id, localHead: str, localResult: { type: "string", enum: ["passed", "failed", "unknown"] }, reviewedHead: str, phase: { type: "object", properties: { id: str, head: str, tasks: { type: "array", maxItems: 64, items: { type: "object", properties: { id, after: { type: "array", maxItems: 16, items: id } }, required: ["id", "after"], additionalProperties: false } }, criteria: { type: "array", maxItems: 32, items: { type: "object", properties: { id: str, required: { type: "boolean" }, result: { type: "string", enum: ["verified", "failed", "unverified"] }, refs: { type: "array", maxItems: 8, items: str } }, required: ["id", "required", "result", "refs"], additionalProperties: false } } }, required: ["id", "tasks", "criteria"], additionalProperties: false } }, additionalProperties: false } }, ["id", "receipt"]),
|
|
68
72
|
tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested", "cancelled"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
|
|
69
|
-
tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. The verdict notifies the task owner; no duplicate hub_send chat is needed. Consecutive changes_requested verdicts (three by default, `tasks.escalate_after`) move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
|
|
73
|
+
tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. The verdict notifies the task owner; no duplicate hub_send chat is needed. Consecutive changes_requested verdicts (three by default, `tasks.escalate_after`) move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" }, coverage: { type: "object", description: "the review brief's reviewable paths you read (reviewed) and any you did not (skipped, each with a reason); in enforce an approval must account for every one", properties: { reviewed: { type: "array", items: str }, skipped: { type: "array", items: { type: "object", properties: { path: str, reason: str }, required: ["path", "reason"] } } } } }, ["id", "verdict"]),
|
|
70
74
|
tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
|
|
71
75
|
tool("hub_memory_search", "Search daemon-scoped visible memory, fails first. Query <=200 UTF-8 bytes; default 8, max 20 lines; whole output <=4 KB. Incomplete excerpts are marked. No project argument.", { query: { type: "string", maxLength: 200, description: "At most 200 UTF-8 bytes, enforced by the daemon" }, limit: { type: "integer", minimum: 1, maximum: 20 } }, ["query"]),
|
|
72
76
|
tool("hub_memory_show", "Read up to 8 visible memory ids or unique prefixes (6..64 UTF-8 bytes each). Original ids redirect; unavailable ids share one refusal. Whole output <=8 KB.", { ids: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 6, maxLength: 64, description: "6 to 64 UTF-8 bytes" } } }, ["ids"]),
|
|
@@ -75,6 +79,13 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
75
79
|
tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (the hub's memory and claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter. For kind fail, optional approach, symptom, cause, evidence and retry_when strings compose fixed labeled text; text may be omitted when structured fields are supplied. The complete text must fit 4000 UTF-8 bytes and is refused, never truncated, when oversized. A note on an open task is short-term until the task is approved, when its decisions, contracts and fails become long-term; horizon long keeps it now, scope shared lets the person's other projects find it.", { text: str, approach: str, symptom: str, cause: str, evidence: str, retry_when: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id, horizon: { type: "string", enum: ["short", "long"] }, scope: { type: "string", enum: ["project", "shared"] } }),
|
|
76
80
|
];
|
|
77
81
|
|
|
82
|
+
export const PLANNER_REVIEW_TOOLS: HubTool[] = [
|
|
83
|
+
tool("hub_planner_groom", "Read a bounded planner grooming digest of dependencies, repository-scoped overlaps, WIP and blockers. No board change; unavailable evidence stays unknown.", {}),
|
|
84
|
+
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"]),
|
|
85
|
+
tool("hub_sdlc_costs", "Read bounded review and CI cost metrics for linked merged PRs. Planner/person only; metadata only, no board writes. S7 owns automatic band proposals.", { repo: str }),
|
|
86
|
+
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"]),
|
|
87
|
+
];
|
|
88
|
+
TASK_TOOLS.push(...PLANNER_REVIEW_TOOLS);
|
|
78
89
|
export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
|
|
79
90
|
|
|
80
91
|
/** Separately published: serving a schema grants no authority, only the daemon's explicit role does. */
|
|
@@ -134,6 +145,14 @@ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: rea
|
|
|
134
145
|
const selectedPlan = { ...plan, properties: Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) };
|
|
135
146
|
properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: selectedPlan } } };
|
|
136
147
|
}
|
|
148
|
+
if (criteria && properties.plan) {
|
|
149
|
+
const current = properties.plan as { properties: Record<string, unknown> };
|
|
150
|
+
properties.plan = { ...current, properties: { ...current.properties, ...costPlanProperties } };
|
|
151
|
+
}
|
|
152
|
+
if (criteria && tool.name === "hub_backlog_split") {
|
|
153
|
+
const parts = properties.parts as { items: { properties: { plan: { properties: Record<string, unknown> } } } };
|
|
154
|
+
properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: { ...parts.items.properties.plan, properties: { ...parts.items.properties.plan.properties, ...costPlanProperties } } } } };
|
|
155
|
+
}
|
|
137
156
|
return { ...tool, ...(customProfile && tool.name === "hub_task_propose" ? { description: tool.description + ` Selected profile ${data.name}: ${Object.keys(data.classes).join(", ")}. Routing uses their existing base classes.` } : {}), inputSchema: { ...tool.inputSchema, properties } };
|
|
138
157
|
});
|
|
139
158
|
}
|
package/src/hub/instances.ts
CHANGED
|
@@ -54,7 +54,20 @@ export function validateInstances(raw: unknown): InstancesConfig {
|
|
|
54
54
|
const definition = definitionOf(kind), prefix = `${kind}-`;
|
|
55
55
|
if (!definition) return fail(`${path}.kind`, "unknown native kind");
|
|
56
56
|
if (!id.startsWith(prefix) || !/^[a-z0-9][a-z0-9-]*$/.test(id.slice(prefix.length))) return fail(path, "id must be <kind>-<name>");
|
|
57
|
-
|
|
57
|
+
// #450 I3: a channel-plugin kind (Claude) runs only in the person's terminal, so it reviews or plans and never owns writes.
|
|
58
|
+
const channel = definition.identity.mcp === "channel-plugin";
|
|
59
|
+
if (!channel && (!definition.start.create || !definition.start.forms.includes("headless") || !["env", "acp-session"].includes(definition.identity.mcp))) return fail(`${path}.kind`, "unsupported; a headless factory with env or ACP-session identity, or a channel-plugin kind, is required, not project-file identity");
|
|
60
|
+
if (channel) {
|
|
61
|
+
const declared = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
|
|
62
|
+
// #223: a headless one is the kind's reviewer variant, one fresh hub-run session per review request.
|
|
63
|
+
if (entry.start_mode && definition.reviewerVariant) {
|
|
64
|
+
if (entry.worktree) return fail(path, "a headless reviewer reads the project checkout: worktree is not supported");
|
|
65
|
+
if (declared.length !== 1 || declared[0] !== "reviewer") return fail(`${path}.roles`, "a headless channel-kind instance is a reviewer: roles must be exactly [\"reviewer\"]");
|
|
66
|
+
} else {
|
|
67
|
+
if (entry.start_mode || entry.worktree) return fail(path, "a channel instance starts only from the person's terminal (ahub <kind> --as <id>): start_mode and worktree are not supported; a headless reviewer declares roles [\"reviewer\"] and start_mode \"headless\"");
|
|
68
|
+
if (!declared.length || declared.some(role => role !== "reviewer" && role !== "planner")) return fail(`${path}.roles`, "a channel instance must declare its roles: reviewer and/or planner");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
58
71
|
const value: InstanceConfig = { kind };
|
|
59
72
|
if (entry.roles) { value.roles = roles(entry.roles.value, `${path}.roles`); Object.freeze(value.roles); }
|
|
60
73
|
if (entry.start_mode) {
|
|
@@ -80,13 +93,14 @@ export function instanceCatalog(declarations?: InstancesConfig): readonly Instan
|
|
|
80
93
|
const instances = validateInstances(declarations);
|
|
81
94
|
return Object.freeze([
|
|
82
95
|
...ADAPTERS.map(definition => Object.freeze({ id: definition.kind, kind: definition.kind, definition })),
|
|
83
|
-
...Object.entries(instances).map(([id, config]) => Object.freeze({ id, kind: config.kind, definition: definitionOf(
|
|
96
|
+
...Object.entries(instances).map(([id, config]) => Object.freeze({ id, kind: config.kind, definition: definitionOf(id, instances)!, config })),
|
|
84
97
|
]);
|
|
85
98
|
}
|
|
86
99
|
export function instancePeerIds(declarations?: InstancesConfig): string[] { return instanceCatalog(declarations).map(entry => entry.id); }
|
|
87
100
|
export function instanceDefaultRoles(declarations?: InstancesConfig, includeOptional = true): Record<string, string[]> {
|
|
88
101
|
const result = defaultPeerRoles(includeOptional);
|
|
89
|
-
|
|
102
|
+
const instances = validateInstances(declarations);
|
|
103
|
+
for (const [id, config] of Object.entries(instances)) result[id] = [...(config.roles ?? definitionOf(id, instances)!.roles)];
|
|
90
104
|
return result;
|
|
91
105
|
}
|
|
92
106
|
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { childEnv } from "./child-process.ts";
|
|
2
|
+
import { hubGitSync } from "./git.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* #520: the review brief ahub builds itself, in place of `ocr delegate preview` and `rule`. Every reviewer gets the same
|
|
6
|
+
* one: the changed files of the head against its merge base with the checkout's HEAD, the files left out and why, the
|
|
7
|
+
* review rules that apply to each file, and the files the plain name list shows that the brief does not. Only git is
|
|
8
|
+
* read, bounded; nothing here touches the board.
|
|
9
|
+
*/
|
|
10
|
+
export interface BriefFile { path: string; status: string; oldPath?: string; insertions?: number; deletions?: number }
|
|
11
|
+
export interface BriefExcluded { path: string; status: string; oldPath?: string; reason: string }
|
|
12
|
+
export interface ReviewBrief {
|
|
13
|
+
head: string;
|
|
14
|
+
/** The default branch the head is compared with: origin/HEAD, origin/main or origin/master. */
|
|
15
|
+
base: string;
|
|
16
|
+
mergeBase: string;
|
|
17
|
+
reviewable: BriefFile[];
|
|
18
|
+
excluded: BriefExcluded[];
|
|
19
|
+
/** Changes left out by the privacy policy, both sides of a move included; counted, never named. */
|
|
20
|
+
withheld: number;
|
|
21
|
+
/** Rule files by name (REVIEW.md, then `ahub-review-rules` attribute values) with their text at the merge base. */
|
|
22
|
+
rules: { source: string; text: string }[];
|
|
23
|
+
/** Configured rule files the brief could not load, with why: the reviewer reads them in the repository. */
|
|
24
|
+
rulesMissing: { source: string; reason: string }[];
|
|
25
|
+
/** Reviewable paths grouped by the rule sources that apply to them. */
|
|
26
|
+
ruleGroups: { sources: string[]; paths: string[] }[];
|
|
27
|
+
/** Paths in the plain name list of the range that the brief does not list: read them directly. */
|
|
28
|
+
unlisted: string[];
|
|
29
|
+
}
|
|
30
|
+
export interface BriefPolicy { privatePath(path: string): boolean; nameable(text: string): boolean }
|
|
31
|
+
|
|
32
|
+
const MAX_FILES = 500, RULE_BYTES = 32_000, MAX_RULE_FILES = 8;
|
|
33
|
+
const LOCKFILES = new Set(["bun.lock", "bun.lockb", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "Cargo.lock", "poetry.lock", "uv.lock", "go.sum", "composer.lock", "Gemfile.lock"]);
|
|
34
|
+
const HEX = /^[0-9a-f]{7,40}$/i;
|
|
35
|
+
|
|
36
|
+
/** Every diff of the brief reads attributes from the trusted tree, never the checkout (a head's `* -diff` cannot mark its own files binary). */
|
|
37
|
+
const attrFrom = (source?: string) => source ? [`--attr-source=${source}`] : [];
|
|
38
|
+
const git = (cwd: string, args: string[]) => hubGitSync(["-C", cwd, ...args], { encoding: "utf8", env: childEnv(process.env), maxBuffer: 16 * 1024 * 1024, timeout: 15_000 });
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The changes of `HEAD...head` as groups: one path, or both sides of a rename or copy. A caller that must not show a
|
|
42
|
+
* private file withholds a whole group when any side is private, so content never shows up under a public name.
|
|
43
|
+
* ponytail: git's 50% similarity decides what counts as a move.
|
|
44
|
+
*/
|
|
45
|
+
export function changedPathGroups(cwd: string, head: string, base = "HEAD", attrSource?: string): { status: string; paths: string[] }[] | undefined {
|
|
46
|
+
const r = git(cwd, [...attrFrom(attrSource), "diff", "--no-ext-diff", "--no-textconv", "--relative", "-M", "-C", "--find-copies-harder", "--name-status", "-z", `${base}...${head}`, "--"]);
|
|
47
|
+
if (r.status !== 0 || typeof r.stdout !== "string") return undefined;
|
|
48
|
+
const fields = r.stdout.split("\0"), groups: { status: string; paths: string[] }[] = [];
|
|
49
|
+
for (let i = 0; i < fields.length - 1;) {
|
|
50
|
+
const status = fields[i++]!, sides = /^[RC]/.test(status) ? 2 : 1;
|
|
51
|
+
groups.push({ status, paths: fields.slice(i, i + sides) }); i += sides;
|
|
52
|
+
}
|
|
53
|
+
return groups;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function buildReviewBrief(cwd: string, head: unknown, policy: BriefPolicy): ReviewBrief | { unavailable: string } {
|
|
57
|
+
if (typeof head !== "string" || !HEX.test(head)) return { unavailable: "the task names no commit" };
|
|
58
|
+
const full = git(cwd, ["rev-parse", "--verify", "-q", `${head}^{commit}`]);
|
|
59
|
+
if (full.status !== 0 || typeof full.stdout !== "string") return { unavailable: `commit ${head} is not in this checkout` };
|
|
60
|
+
const commit = full.stdout.trim();
|
|
61
|
+
// A reliable base only: the remote's default branch. The checkout's HEAD may be the head itself (an empty inventory)
|
|
62
|
+
// or another session's branch.
|
|
63
|
+
const from = ["origin/HEAD", "origin/main", "origin/master"].find(ref => git(cwd, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]).status === 0);
|
|
64
|
+
if (!from) return { unavailable: "no default branch (origin/HEAD, origin/main or origin/master) to compare with" };
|
|
65
|
+
const base = git(cwd, ["merge-base", from, commit]);
|
|
66
|
+
if (base.status !== 0 || typeof base.stdout !== "string") return { unavailable: `commit ${head} shares no history with ${from}` };
|
|
67
|
+
const mergeBase = base.stdout.trim();
|
|
68
|
+
const groups = changedPathGroups(cwd, commit, from, mergeBase);
|
|
69
|
+
if (!groups) return { unavailable: `the changes of ${head} could not be read` };
|
|
70
|
+
if (!groups.length) return { unavailable: `commit ${head} has no changes against ${from}` };
|
|
71
|
+
const shown = groups.filter(g => !g.paths.some(path => policy.privatePath(path) || !policy.nameable(path)));
|
|
72
|
+
if (shown.length > MAX_FILES) return { unavailable: `${shown.length} changed files, over the brief's ${MAX_FILES}` };
|
|
73
|
+
const withheld = groups.length - shown.length;
|
|
74
|
+
const counts = numstat(cwd, commit, from, mergeBase);
|
|
75
|
+
// Attributes and rules come from the merge base: a change cannot lift its own review obligations (a head that marks
|
|
76
|
+
// everything generated, or edits REVIEW.md, is still reviewed under the rules it is merging into).
|
|
77
|
+
// Both sides of a move: renaming a reviewed file to a generated or unscoped name must not shed its obligations.
|
|
78
|
+
const attrs = attributes(cwd, mergeBase, [...new Set(shown.flatMap(g => g.paths))]);
|
|
79
|
+
if (!counts || !attrs) return { unavailable: `the file details of ${head} could not be read` };
|
|
80
|
+
const reviewable: BriefFile[] = [], excluded: BriefExcluded[] = [];
|
|
81
|
+
for (const g of shown) {
|
|
82
|
+
const path = g.paths.at(-1)!, count = counts.get(path);
|
|
83
|
+
const sides = { status: g.status[0]!, ...(g.paths.length === 2 ? { oldPath: g.paths[0]! } : {}) };
|
|
84
|
+
// Excluded only when every side is: a move keeps the source's obligation.
|
|
85
|
+
const sideReason = (p: string) => attrs.get(p)?.generated ? "generated (linguist-generated)" : LOCKFILES.has(p.split("/").at(-1)!) ? "lockfile" : undefined;
|
|
86
|
+
const reasons = g.paths.map(sideReason);
|
|
87
|
+
const reason = count?.binary ? "binary" : reasons.every(Boolean) ? reasons.at(-1) : undefined;
|
|
88
|
+
if (reason) { excluded.push({ path, ...sides, reason }); continue; }
|
|
89
|
+
reviewable.push({ path, ...sides, ...(count && !count.binary ? { insertions: count.insertions, deletions: count.deletions } : {}) });
|
|
90
|
+
}
|
|
91
|
+
const ruleText = new Map<string, string>(), rulesMissing = new Map<string, string>();
|
|
92
|
+
const readRule = (source: string): boolean => {
|
|
93
|
+
if (ruleText.has(source)) return true;
|
|
94
|
+
if (rulesMissing.has(source)) return false;
|
|
95
|
+
// A private or non-nameable rule path is not named either: it is left out silently.
|
|
96
|
+
if (policy.privatePath(source) || !policy.nameable(source)) return false;
|
|
97
|
+
if (ruleText.size >= MAX_RULE_FILES) { rulesMissing.set(source, `over the brief's ${MAX_RULE_FILES} rule files`); return false; }
|
|
98
|
+
const r = git(cwd, ["show", `${mergeBase}:${source}`]);
|
|
99
|
+
if (r.status !== 0 || typeof r.stdout !== "string") { rulesMissing.set(source, "not found at the merge base"); return false; }
|
|
100
|
+
// Screened whole, before any cut: a rule file that matches a private-data pattern is named but not shown.
|
|
101
|
+
if (!policy.nameable(r.stdout)) { ruleText.set(source, "(withheld: it matches a private-data pattern; read it in the repository)"); return true; }
|
|
102
|
+
const bytes = Buffer.from(r.stdout, "utf8");
|
|
103
|
+
ruleText.set(source, bytes.length > RULE_BYTES ? `${bytes.subarray(0, RULE_BYTES).toString("utf8")}\n[cut at ${RULE_BYTES} bytes]` : r.stdout);
|
|
104
|
+
return true;
|
|
105
|
+
};
|
|
106
|
+
const root = readRule("REVIEW.md");
|
|
107
|
+
const byKey = new Map<string, { sources: string[]; paths: string[] }>();
|
|
108
|
+
for (const file of reviewable) {
|
|
109
|
+
// The rules of every side apply, so a move out of a scoped directory keeps its rule file.
|
|
110
|
+
const scoped = [...new Set([file.oldPath, file.path].flatMap(p => { const r = p ? attrs.get(p)?.rules : undefined; return r ? [r] : []; }))];
|
|
111
|
+
const sources = [...(root ? ["REVIEW.md"] : []), ...scoped.filter(readRule)];
|
|
112
|
+
const key = sources.join("\0");
|
|
113
|
+
(byKey.get(key) ?? byKey.set(key, { sources, paths: [] }).get(key)!).paths.push(file.path);
|
|
114
|
+
}
|
|
115
|
+
// Cross-check with the plain name list (no rename detection), as OCR's list is checked against the PR's.
|
|
116
|
+
const plain = git(cwd, [...attrFrom(mergeBase), "diff", "--relative", "--no-renames", "--name-only", "-z", `${from}...${commit}`, "--"]);
|
|
117
|
+
if (plain.status !== 0 || typeof plain.stdout !== "string") return { unavailable: `the name list of ${head} could not be read` };
|
|
118
|
+
const listed = new Set(shown.flatMap(g => g.paths)), hidden = new Set(groups.filter(g => !shown.includes(g)).flatMap(g => g.paths));
|
|
119
|
+
const unlisted = plain.stdout.split("\0").filter(path => path && !listed.has(path) && !hidden.has(path));
|
|
120
|
+
return { head: commit, base: from, mergeBase, reviewable, excluded, withheld, rules: [...ruleText].map(([source, text]) => ({ source, text })), rulesMissing: [...rulesMissing].map(([source, reason]) => ({ source, reason })), ruleGroups: [...byKey.values()], unlisted };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function numstat(cwd: string, head: string, base: string, attrSource: string): Map<string, { insertions: number; deletions: number; binary: boolean }> | undefined {
|
|
124
|
+
const out = new Map<string, { insertions: number; deletions: number; binary: boolean }>();
|
|
125
|
+
const r = git(cwd, [...attrFrom(attrSource), "diff", "--no-ext-diff", "--no-textconv", "--relative", "-M", "-C", "--find-copies-harder", "--numstat", "-z", `${base}...${head}`, "--"]);
|
|
126
|
+
if (r.status !== 0 || typeof r.stdout !== "string") return undefined;
|
|
127
|
+
// -z: "ins\tdel\tpath\0", or for a move "ins\tdel\t\0old\0new\0"; a binary file has "-" counts.
|
|
128
|
+
const fields = r.stdout.split("\0");
|
|
129
|
+
for (let i = 0; i < fields.length - 1;) {
|
|
130
|
+
const [ins = "", del = "", path = ""] = fields[i++]!.split("\t");
|
|
131
|
+
const target = path || (i++, fields[i++] ?? "");
|
|
132
|
+
out.set(target, { insertions: Number(ins) || 0, deletions: Number(del) || 0, binary: ins === "-" });
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function attributes(cwd: string, source: string, paths: string[]): Map<string, { generated?: boolean; rules?: string }> | undefined {
|
|
138
|
+
const out = new Map<string, { generated?: boolean; rules?: string }>();
|
|
139
|
+
if (!paths.length) return out;
|
|
140
|
+
const r = hubGitSync(["-C", cwd, "check-attr", "-z", "--stdin", `--source=${source}`, "linguist-generated", "ahub-review-rules"], { input: paths.join("\0") + "\0", encoding: "utf8", env: childEnv(process.env), maxBuffer: 4 * 1024 * 1024, timeout: 15_000 });
|
|
141
|
+
if (r.status !== 0 || typeof r.stdout !== "string") return undefined;
|
|
142
|
+
const f = r.stdout.split("\0");
|
|
143
|
+
for (let i = 0; i + 2 < f.length; i += 3) {
|
|
144
|
+
const [path, attr, value] = [f[i]!, f[i + 1]!, f[i + 2]!], entry = out.get(path) ?? {};
|
|
145
|
+
if (attr === "linguist-generated" && (value === "set" || value === "true")) entry.generated = true;
|
|
146
|
+
if (attr === "ahub-review-rules" && value !== "unspecified" && value !== "unset" && value !== "set" && !value.startsWith("/") && !value.split("/").includes("..")) entry.rules = value;
|
|
147
|
+
out.set(path, entry);
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The brief as review text: what every reviewer reads and how it accounts for its coverage. */
|
|
153
|
+
export function renderReviewBrief(brief: ReviewBrief): string {
|
|
154
|
+
const file = (f: BriefFile) => `- ${f.oldPath ? `${f.oldPath} -> ` : ""}${f.path} (${f.status}${f.insertions !== undefined ? `, +${f.insertions} -${f.deletions}` : ""})`;
|
|
155
|
+
return [
|
|
156
|
+
`Review brief: head ${brief.head} against ${brief.base} (merge base ${brief.mergeBase.slice(0, 12)}); ${brief.reviewable.length} reviewable, ${brief.excluded.length} excluded${brief.withheld ? `, ${brief.withheld} withheld by the privacy policy` : ""}.`,
|
|
157
|
+
...(brief.reviewable.length ? ["Reviewable:", ...brief.reviewable.map(file)] : ["Reviewable: none (record why in the note)."]),
|
|
158
|
+
...(brief.excluded.length ? ["Excluded (still read their diff when it matters):", ...brief.excluded.map(e => `- ${e.oldPath ? `${e.oldPath} -> ` : ""}${e.path} (${e.status}): ${e.reason}`)] : []),
|
|
159
|
+
...(brief.unlisted.length ? ["Not in the brief's list, read directly:", ...brief.unlisted.map(path => `- ${path}`)] : []),
|
|
160
|
+
...brief.ruleGroups.filter(g => g.sources.length).map(g => `Rules ${g.sources.join(" + ")} apply to: ${brief.ruleGroups.length === 1 ? "every reviewable file" : g.paths.join(", ")}`),
|
|
161
|
+
...(brief.rulesMissing.length ? ["Configured rules not loaded (read them in the repository):", ...brief.rulesMissing.map(m => `- ${m.source}: ${m.reason}`)] : []),
|
|
162
|
+
...brief.rules.map(r => `--- ${r.source} ---\n${r.text.trimEnd()}`),
|
|
163
|
+
`Coverage: pass hub_review coverage {reviewed: [paths], skipped: [{path, reason}]} naming every reviewable path above.`,
|
|
164
|
+
].join("\n");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The reviewable paths a verdict's coverage does not account for: reviewed, or skipped with a reason. */
|
|
168
|
+
export function coverageGap(brief: ReviewBrief, coverage: unknown): string[] {
|
|
169
|
+
const c = coverage && typeof coverage === "object" && !Array.isArray(coverage) ? coverage as { reviewed?: unknown; skipped?: unknown } : {};
|
|
170
|
+
const accounted = new Set<string>([
|
|
171
|
+
...(Array.isArray(c.reviewed) ? c.reviewed.filter((p): p is string => typeof p === "string") : []),
|
|
172
|
+
...(Array.isArray(c.skipped) ? c.skipped.flatMap(s => s && typeof s === "object" && typeof (s as { path?: unknown }).path === "string" && typeof (s as { reason?: unknown }).reason === "string" && (s as { reason: string }).reason.trim() ? [(s as { path: string }).path] : []) : []),
|
|
173
|
+
]);
|
|
174
|
+
return brief.reviewable.map(f => f.path).filter(path => !accounted.has(path));
|
|
175
|
+
}
|
package/src/hub/routing.ts
CHANGED
|
@@ -290,6 +290,8 @@ export interface RoutingRefusal { peer: PeerId; role: "owner" | "reviewer"; code
|
|
|
290
290
|
export interface Assignment {
|
|
291
291
|
/** A same-family reviewer was chosen only because no other family was eligible. */
|
|
292
292
|
sameFamilyFallback?: true;
|
|
293
|
+
/** #223 R5: the planner reviews because the declared reviewer-only instance was not eligible. */
|
|
294
|
+
plannerFallback?: true;
|
|
293
295
|
refusals?: RoutingRefusal[];
|
|
294
296
|
owner?: PeerId;
|
|
295
297
|
reviewer?: PeerId;
|
|
@@ -374,6 +376,7 @@ export function assign(
|
|
|
374
376
|
if (opts.exclude?.includes(peer)) return deny("excluded", "excluded (declined or replaced)");
|
|
375
377
|
if (role === "owner" && opts.excludeOwners?.includes(peer)) return deny("owner-admission", "owner admission: WIP or delivery hold");
|
|
376
378
|
const adapter = definitionOf(peer, opts.instances);
|
|
379
|
+
if (role === "owner" && adapter?.routing?.reviewOnly) return deny("owner-admission", "owner admission: a review-only instance owns no task");
|
|
377
380
|
if (role === "owner" && needsTests && adapter && adapter.kind !== peer) {
|
|
378
381
|
if (!peerEntry(opts.roles, peer)?.includes("implementer")) return deny("owner-admission", "owner admission: declared write owner requires the current implementer role");
|
|
379
382
|
if (peerEntry(opts.ownerIsolation, peer) !== true) return deny("owner-admission", "owner admission: declared write owner requires a verified isolated execution root");
|
|
@@ -458,6 +461,7 @@ export function assign(
|
|
|
458
461
|
|
|
459
462
|
let reviewer: PeerId | undefined;
|
|
460
463
|
let sameFamilyFallback = false;
|
|
464
|
+
let plannerFallback = false;
|
|
461
465
|
if (task.class !== "review") {
|
|
462
466
|
if (pii) trace.push("reviewer: user (pii: no second on-prem peer; ahub review <id> <verdict>)");
|
|
463
467
|
else {
|
|
@@ -475,7 +479,18 @@ export function assign(
|
|
|
475
479
|
const reason = `same family as ${reviewOwner}; eligible other-family reviewer available`;
|
|
476
480
|
refusals.set(`reviewer:${named}`, { peer: named, role: "reviewer", code: "family", reason });
|
|
477
481
|
trace.push(` reviewer candidate ${named}: skipped, ${reason}`);
|
|
478
|
-
} else
|
|
482
|
+
} else {
|
|
483
|
+
// #223 R1: a declared instance that reviews and does not plan goes first, busy or not.
|
|
484
|
+
const holds = (peer: PeerId, role: string) => opts.roles?.[peer]?.includes(role) === true;
|
|
485
|
+
const dedicated = (peer: PeerId) => !!opts.instances && Object.hasOwn(opts.instances, peer) && holds(peer, "reviewer") && !holds(peer, "planner");
|
|
486
|
+
const pool = independent.length ? independent : automatic;
|
|
487
|
+
const ready = named === undefined ? pool.filter(peer => dedicated(peer) && peer !== reviewOwner && !blocked(peer, "reviewer")) : [];
|
|
488
|
+
reviewer = pick(named === undefined ? ready.length ? ready : pool : [named], "reviewer", reviewOwner, named === undefined);
|
|
489
|
+
if (named === undefined && !ready.length && reviewer && holds(reviewer, "planner") && Object.keys(opts.roles ?? {}).some(dedicated)) {
|
|
490
|
+
plannerFallback = true;
|
|
491
|
+
trace.push(` planner fallback: no declared reviewer-only instance is eligible; ${reviewer} reviews`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
479
494
|
if (reviewer && sameFamily(reviewer)) {
|
|
480
495
|
sameFamilyFallback = true;
|
|
481
496
|
trace.push(` same-family fallback: ${reviewer} and ${reviewOwner} share ${ownerFamily}; no eligible other family`);
|
|
@@ -490,7 +505,7 @@ export function assign(
|
|
|
490
505
|
const piBackend = policy?.pi_backend;
|
|
491
506
|
if (owner && definitionOf(owner, opts.instances)?.routing?.trace === "route") trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
|
|
492
507
|
if (owner && definitionOf(owner, opts.instances)?.routing?.trace === "backend") trace.push(`${definitionOf(owner, opts.instances)!.routing!.backendLabel} decision: backend ${piBackend ?? "dgx"}${task.signals.includes("long_context") ? `, context limit ${piBackend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens}` : ""}`);
|
|
493
|
-
return { ...(sameFamilyFallback ? { sameFamilyFallback: true as const } : {}), ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
|
|
508
|
+
return { ...(sameFamilyFallback ? { sameFamilyFallback: true as const } : {}), ...(plannerFallback ? { plannerFallback: true as const } : {}), ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
|
|
494
509
|
}
|
|
495
510
|
|
|
496
511
|
/**
|
package/src/hub/sdlc.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CostPlan } from "./cost-governance.ts";
|
|
1
2
|
import { criteriaReceipt, type CriteriaReceipt, type CriteriaView, type LegacyPhaseReceipt } from "./workflow.ts";
|
|
2
3
|
import { constants, openSync, fstatSync, readSync, closeSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
@@ -29,11 +30,21 @@ export interface StageDefinition { base: SdlcBaseStage; actor: string; entry_gat
|
|
|
29
30
|
export type SdlcMode = "off" | "advise" | "enforce";
|
|
30
31
|
/** `workflow: "gsd-lite"` marks the retired setting during its window (#481 S8): every task then needs a criteria checklist. */
|
|
31
32
|
export interface SdlcConfig { workflow?: "gsd-lite"; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
|
|
33
|
+
export interface PlanReviewReceipt {
|
|
34
|
+
planner: string;
|
|
35
|
+
planningRevision: number;
|
|
36
|
+
recordedAt: number;
|
|
37
|
+
scope?: CostPlan;
|
|
38
|
+
issue?: PlanIssueMetadata;
|
|
39
|
+
}
|
|
32
40
|
export interface SdlcReceipt {
|
|
33
41
|
stage: SdlcStage;
|
|
42
|
+
planReview?: PlanReviewReceipt;
|
|
34
43
|
criteria?: CriteriaReceipt;
|
|
35
44
|
/** Stored before #481 S8 and read as `criteria`; never written again. */
|
|
36
45
|
phase?: LegacyPhaseReceipt;
|
|
46
|
+
/** The person's spec acceptance (#481 S1): set only by Tasks.acceptSpec, never by hub_sdlc_record. */
|
|
47
|
+
acceptance?: SpecAcceptance;
|
|
37
48
|
repo?: string;
|
|
38
49
|
issue?: number;
|
|
39
50
|
pr?: number;
|
|
@@ -181,6 +192,8 @@ export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined =
|
|
|
181
192
|
if (!["passed", "failed", "unknown"].includes(String(raw.localResult))) throw new Error("sdlc receipt.localResult: expected passed, failed or unknown");
|
|
182
193
|
next.localResult = raw.localResult as SdlcReceipt["localResult"];
|
|
183
194
|
}
|
|
195
|
+
// An acceptance names one spec issue; relinking the receipt to another drops it (#481 S1).
|
|
196
|
+
if (next.repo !== previous.repo || next.issue !== previous.issue) delete next.acceptance;
|
|
184
197
|
// A new artifact cannot inherit another PR's review or local gate.
|
|
185
198
|
if (next.repo !== previous.repo || next.pr !== previous.pr) {
|
|
186
199
|
delete next.head; delete next.observed;
|
|
@@ -199,7 +212,7 @@ export function parsePrEvidence(value: unknown): PrEvidence {
|
|
|
199
212
|
const name = c.name ?? c.context;
|
|
200
213
|
if (typeof name !== "string" || !name || name.length > 120) throw new Error("PR readback: invalid check identity");
|
|
201
214
|
const native = c.conclusion ?? c.state;
|
|
202
|
-
const state = native === "SUCCESS" ? "passed" : ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED"].includes(String(native)) ? "failed" :
|
|
215
|
+
const state = ["PENDING", "QUEUED", "IN_PROGRESS"].includes(String(c.status ?? native)) ? "pending" : native === "SUCCESS" ? "passed" : ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED"].includes(String(native)) ? "failed" : "unknown";
|
|
203
216
|
return { name, state } as PrEvidence["checks"][number];
|
|
204
217
|
});
|
|
205
218
|
const paths = p.files.map((f: Record<string, unknown>) => { if (typeof f.path !== "string" || !f.path || f.path.length > 1000) throw new Error("PR readback: invalid file identity"); return f.path; });
|
|
@@ -210,10 +223,26 @@ export function parsePrEvidence(value: unknown): PrEvidence {
|
|
|
210
223
|
...(sha(p.mergeCommit?.oid) ? { mergeCommit: p.mergeCommit.oid } : {}) };
|
|
211
224
|
}
|
|
212
225
|
|
|
213
|
-
|
|
226
|
+
/** Issue identity and edit times only; no body is fetched or stored. `lastEditedAt` moves on a body edit, not on comments. */
|
|
227
|
+
export interface SpecEdit { lastEditedAt: string | null; updatedAt: string }
|
|
228
|
+
export interface SpecAcceptance extends SpecEdit { repo: string; issue: number; acceptedAt: number }
|
|
229
|
+
export type SpecState = "missing" | "current" | "changed" | "unknown";
|
|
230
|
+
/** Whether the receipt's spec issue is accepted at its current body. `edit` undefined means the readback failed. */
|
|
231
|
+
export function specState(receipt: SdlcReceipt | undefined, edit: SpecEdit | undefined): SpecState {
|
|
232
|
+
const accepted = receipt?.acceptance;
|
|
233
|
+
if (!accepted || accepted.repo !== receipt?.repo || accepted.issue !== receipt?.issue) return "missing";
|
|
234
|
+
if (!edit) return "unknown";
|
|
235
|
+
return edit.lastEditedAt === accepted.lastEditedAt ? "current" : "changed";
|
|
236
|
+
}
|
|
237
|
+
export const SPEC_STATE_TEXT: Record<Exclude<SpecState, "current">, string> = {
|
|
238
|
+
missing: "spec acceptance missing: the person runs ahub sdlc accept <task> --issue <n>",
|
|
239
|
+
changed: "spec changed after acceptance: the person re-accepts with ahub sdlc accept <task> --issue <n>",
|
|
240
|
+
unknown: "spec edit time unknown: the issue could not be read",
|
|
241
|
+
};
|
|
242
|
+
export interface SdlcStatus { spec?: { issue: number; state: SpecState }; playbook?: string; criteria?: CriteriaView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
|
|
214
243
|
/** The acceptance-criteria checklist (#481 S8, Compliance pass) counts once recorded, and on every task under the retired gsd-lite setting. Off keeps #257's output. */
|
|
215
244
|
export const criteriaRequired = (config: SdlcConfig, receipt: SdlcReceipt | undefined): boolean => config.workflow === "gsd-lite" || (config.mode !== "off" && !!checklistOf(receipt));
|
|
216
|
-
export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView): SdlcStatus {
|
|
245
|
+
export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView, specEdit?: SpecEdit): SdlcStatus {
|
|
217
246
|
const stage = receipt?.stage ?? config.stages[0] ?? "spec", blockers: string[] = [];
|
|
218
247
|
const definition = stageDefinition(config, stage);
|
|
219
248
|
if (!definition) blockers.push("lifecycle stage definition unknown");
|
|
@@ -239,7 +268,9 @@ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined,
|
|
|
239
268
|
const custom = config.definitions && definition ? stageGates(definition, blockers, [...viewBlockers, ...staleHead]) : undefined;
|
|
240
269
|
if (custom) for (const blocker of [...custom.entry, ...custom.exit]) if (!blockers.includes(blocker)) blockers.push(blocker);
|
|
241
270
|
const shown = criteria && (criteriaRequired(config, receipt) || definition && [...definition.entry_gates, ...definition.exit_gates].includes("criteria"));
|
|
242
|
-
|
|
271
|
+
// Shown, not counted as a readiness blocker: the spec gate is build entry (#481 S1).
|
|
272
|
+
const spec = config.mode !== "off" && receipt?.issue ? { spec: { issue: receipt.issue, state: specState(receipt, specEdit) } } : {};
|
|
273
|
+
return { ...spec, ...(config.mode !== "off" && definition && playbookStage(definition.base) ? { playbook: playbookStage(definition.base) } : {}), ...(shown ? { criteria } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
|
|
243
274
|
actor: !definition ? "person" : shown && criteria.blockers.length ? "reviewer/person" : config.definitions ? definition.actor : baseActor(definition.base),
|
|
244
275
|
next: blockers[0] ?? "person may inspect merge readiness; no automatic merge" };
|
|
245
276
|
}
|
|
@@ -279,6 +310,20 @@ export const sdlcGh: SdlcGh = (args, timeoutMs = 10_000, cwd, maxOutputBytes = 2
|
|
|
279
310
|
child.once("close", code => { clearTimeout(timer); resolve({ code: failed ? 1 : code ?? 1, stdout: failed ? "" : stdout }); });
|
|
280
311
|
});
|
|
281
312
|
|
|
313
|
+
/** Read-only, fixed argv: the issue's edit times, never its body. */
|
|
314
|
+
export async function readSpecEdit(repoName: string, issue: number, run: SdlcGh = sdlcGh): Promise<SpecEdit | undefined> {
|
|
315
|
+
if (!repo(repoName) || !id(issue)) return undefined;
|
|
316
|
+
const [owner, name] = repoName.split("/");
|
|
317
|
+
try {
|
|
318
|
+
const out = await run(["api", "graphql", "-f", "query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){issue(number:$number){number lastEditedAt updatedAt}}}", "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${issue}`]);
|
|
319
|
+
if (out.code !== 0) return undefined;
|
|
320
|
+
const found = JSON.parse(out.stdout)?.data?.repository?.issue;
|
|
321
|
+
const stamp = (v: unknown) => typeof v === "string" && v.length <= 40 && !Number.isNaN(Date.parse(v));
|
|
322
|
+
if (found?.number !== issue || !stamp(found.updatedAt) || (found.lastEditedAt !== null && !stamp(found.lastEditedAt))) return undefined;
|
|
323
|
+
return { lastEditedAt: found.lastEditedAt, updatedAt: found.updatedAt };
|
|
324
|
+
} catch { return undefined; }
|
|
325
|
+
}
|
|
326
|
+
|
|
282
327
|
export async function readSdlcPr(receipt: SdlcReceipt, run: SdlcGh = sdlcGh): Promise<PrEvidence | undefined> {
|
|
283
328
|
if (!repo(receipt.repo) || !id(receipt.pr)) return undefined;
|
|
284
329
|
try {
|
|
@@ -298,3 +343,159 @@ export async function readSdlcPr(receipt: SdlcReceipt, run: SdlcGh = sdlcGh): Pr
|
|
|
298
343
|
return parsePrEvidence(p);
|
|
299
344
|
} catch { return undefined; }
|
|
300
345
|
}
|
|
346
|
+
|
|
347
|
+
export interface PlanIssueMetadata {
|
|
348
|
+
repo: string;
|
|
349
|
+
issue: number;
|
|
350
|
+
state: "OPEN" | "CLOSED";
|
|
351
|
+
/** Body edit time; comment activity must never invalidate a plan review. */
|
|
352
|
+
editedAt: string;
|
|
353
|
+
/** Nullable GitHub body edit field, retained to compare independent S1 acceptance evidence. */
|
|
354
|
+
lastEditedAt?: string | null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Metadata only: never request issue bodies or comments for a planning receipt. */
|
|
358
|
+
export async function readPlanIssueMetadata(repository: string, number: number, run: SdlcGh = sdlcGh): Promise<PlanIssueMetadata | undefined> {
|
|
359
|
+
if (!repo(repository) || !id(number)) return undefined;
|
|
360
|
+
const [owner, name] = repository.split("/");
|
|
361
|
+
try {
|
|
362
|
+
const result = await run(["api", "graphql", "-f", "query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){issue(number:$number){number state createdAt lastEditedAt}}}", "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${number}`], 10_000, undefined, 8 * 1024);
|
|
363
|
+
if (result.code !== 0 || Buffer.byteLength(result.stdout) > 8 * 1024) return undefined;
|
|
364
|
+
const response = JSON.parse(result.stdout);
|
|
365
|
+
if (response.errors !== undefined) return undefined;
|
|
366
|
+
const issue = response.data?.repository?.issue;
|
|
367
|
+
if (!issue || issue.number !== number || !["OPEN", "CLOSED"].includes(issue.state)) return undefined;
|
|
368
|
+
const editedAt = issue.lastEditedAt === null ? issue.createdAt : issue.lastEditedAt;
|
|
369
|
+
if (typeof editedAt !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(editedAt) || !Number.isFinite(Date.parse(editedAt))) return undefined;
|
|
370
|
+
return { repo: repository, issue: number, state: issue.state, editedAt, lastEditedAt: issue.lastEditedAt };
|
|
371
|
+
} catch { return undefined; }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** A complete resolved-thread snapshot already proves there is no pending unanswered bot thread. */
|
|
375
|
+
export function reviewReadinessFromEvidence(evidence: PrEvidence | undefined, expected: string | undefined, required: readonly string[]): ReviewReadiness | undefined {
|
|
376
|
+
if (!evidence || evidence.unresolved !== 0) return undefined;
|
|
377
|
+
const checks = required.length ? [...required] : ["check (ubuntu-latest)", "check (macos-latest)", "seeded guards"];
|
|
378
|
+
if (!checks.every(name => evidence.checks.some(check => check.name === name)) || evidence.checks.some(check => check.state === "unknown")) return undefined;
|
|
379
|
+
const blockers: string[] = [];
|
|
380
|
+
if (!expected || !sha(expected)) blockers.push("review head missing");
|
|
381
|
+
else if (evidence.head !== expected) blockers.push("review head changed");
|
|
382
|
+
if (evidence.state !== "OPEN") blockers.push("review PR is not open");
|
|
383
|
+
if (!checks.every(name => { const matched = evidence.checks.filter(check => check.name === name); return matched.length === 1 && matched[0]?.state === "passed"; })) blockers.push("exact-head full CI is not green");
|
|
384
|
+
return { head: evidence.head, ready: blockers.length === 0, blockers };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export interface ReviewReadiness { head?: string; ready: boolean; blockers: string[]; reusedRun?: number }
|
|
388
|
+
/** Metadata-only pre-review admission; no remote body or comment text is requested. */
|
|
389
|
+
export async function readReviewReadiness(receipt: SdlcReceipt, expected: string | undefined, run: SdlcGh = sdlcGh, requiredChecks: readonly string[] = []): Promise<ReviewReadiness> {
|
|
390
|
+
const unknown = (): ReviewReadiness => ({ ready: false, blockers: ["review readiness unavailable"] });
|
|
391
|
+
if (!repo(receipt.repo) || !id(receipt.pr)) return unknown();
|
|
392
|
+
const [owner, name] = receipt.repo.split("/");
|
|
393
|
+
const query = "query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){headRefOid state commits(last:1){nodes{commit{oid tree{oid} statusCheckRollup{contexts(first:100){nodes{... on CheckRun{name status conclusion} ... on StatusContext{context state}} pageInfo{hasNextPage}}}}}} reviewThreads(first:100){nodes{isResolved comments(first:100){nodes{author{__typename} createdAt} pageInfo{hasNextPage}}} pageInfo{hasNextPage}}}}}";
|
|
394
|
+
try {
|
|
395
|
+
const deadline = performance.now() + 20_000;
|
|
396
|
+
const bounded: SdlcGh = (args, timeout, cwd, cap) => performance.now() < deadline
|
|
397
|
+
? run(args, Math.max(1, Math.min(timeout ?? 10_000, deadline - performance.now())), cwd, cap)
|
|
398
|
+
: Promise.resolve({ code: 1, stdout: "" });
|
|
399
|
+
const args = ["api", "graphql", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${receipt.pr}`];
|
|
400
|
+
const response = await bounded(args, 10_000, undefined, 512 * 1024);
|
|
401
|
+
if (response.code !== 0 || Buffer.byteLength(response.stdout) > 512 * 1024) return unknown();
|
|
402
|
+
const parsed = JSON.parse(response.stdout), pr = parsed.data?.repository?.pullRequest;
|
|
403
|
+
if (parsed.errors || !pr || !sha(pr.headRefOid)) return unknown();
|
|
404
|
+
const blockers: string[] = [];
|
|
405
|
+
if (!expected || !sha(expected)) blockers.push("review head missing");
|
|
406
|
+
else if (pr.headRefOid !== expected) blockers.push("review head changed");
|
|
407
|
+
if (pr.state !== "OPEN") blockers.push("review PR is not open");
|
|
408
|
+
const commit = pr.commits?.nodes?.[0]?.commit;
|
|
409
|
+
if (!commit || commit.oid !== pr.headRefOid) blockers.push("review head changed");
|
|
410
|
+
const contexts = commit?.statusCheckRollup?.contexts;
|
|
411
|
+
const nodes: unknown[] | undefined = contexts?.nodes;
|
|
412
|
+
const full = requiredChecks.length ? [...requiredChecks] : ["check (ubuntu-latest)", "check (macos-latest)", "seeded guards"];
|
|
413
|
+
let reusedRun: number | undefined;
|
|
414
|
+
if (contexts?.pageInfo?.hasNextPage !== false || !Array.isArray(nodes) || nodes.length > 100
|
|
415
|
+
|| !full.every(name => {
|
|
416
|
+
const matches = (nodes as { name?: string; context?: string; status?: string; conclusion?: string; state?: string }[]).filter(node => (node?.name ?? node?.context) === name);
|
|
417
|
+
return matches.length === 1 && (matches[0]?.name ? matches[0].status === "COMPLETED" && matches[0].conclusion === "SUCCESS" : matches[0]?.state === "SUCCESS");
|
|
418
|
+
})) blockers.push("exact-head full CI is not green");
|
|
419
|
+
if (blockers.includes("exact-head full CI is not green") && contexts?.pageInfo?.hasNextPage === false && Array.isArray(nodes)
|
|
420
|
+
&& full.every(name => { const matches = (nodes as { name?: string; conclusion?: string }[]).filter(node => node?.name === name); return matches.length === 1 && matches[0]?.conclusion === "SKIPPED"; }) && sha(commit?.tree?.oid)) {
|
|
421
|
+
reusedRun = await identicalTreeGate(receipt.repo, commit.tree.oid, bounded, full);
|
|
422
|
+
if (reusedRun !== undefined) {
|
|
423
|
+
// Proof reads can await several API calls: recheck the complete head/check/thread snapshot before admission.
|
|
424
|
+
const after = await bounded(args, 10_000, undefined, 512 * 1024);
|
|
425
|
+
try { if (after.code !== 0 || JSON.stringify(JSON.parse(after.stdout)?.data?.repository?.pullRequest) !== JSON.stringify(pr)) reusedRun = undefined; }
|
|
426
|
+
catch { reusedRun = undefined; }
|
|
427
|
+
if (reusedRun !== undefined) blockers.splice(blockers.indexOf("exact-head full CI is not green"), 1);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const threads = pr.reviewThreads;
|
|
431
|
+
if (threads?.pageInfo?.hasNextPage !== false || !Array.isArray(threads.nodes) || threads.nodes.length > 100) blockers.push("bot review thread evidence unavailable");
|
|
432
|
+
else for (const thread of threads.nodes) {
|
|
433
|
+
if (thread?.isResolved === true) continue;
|
|
434
|
+
if (thread?.isResolved !== false) { blockers.push("bot review thread evidence unavailable"); continue; }
|
|
435
|
+
const comments = thread?.comments;
|
|
436
|
+
if (comments?.pageInfo?.hasNextPage !== false || !Array.isArray(comments.nodes) || comments.nodes.length > 100
|
|
437
|
+
|| comments.nodes.some((comment: { author?: { __typename?: string }; createdAt?: string }) => !["Bot", "User"].includes(comment?.author?.__typename ?? "") || !Number.isFinite(Date.parse(comment.createdAt ?? "")))) {
|
|
438
|
+
blockers.push("bot review thread evidence unavailable"); continue;
|
|
439
|
+
}
|
|
440
|
+
let unanswered = false;
|
|
441
|
+
for (const comment of comments.nodes) {
|
|
442
|
+
if (comment.author.__typename === "Bot") unanswered = true;
|
|
443
|
+
else if (comment.author.__typename === "User") unanswered = false;
|
|
444
|
+
}
|
|
445
|
+
if (unanswered) blockers.push("bot review threads unanswered");
|
|
446
|
+
}
|
|
447
|
+
return { head: pr.headRefOid, ready: blockers.length === 0, blockers: [...new Set(blockers)], ...(reusedRun !== undefined ? { reusedRun } : {}) };
|
|
448
|
+
} catch { return unknown(); }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Same proof floor as scripts/ci-gates.mjs: the identical tree includes the workflow itself. No skipped proof. */
|
|
452
|
+
async function identicalTreeGate(repository: string, tree: string, run: SdlcGh, required: readonly string[]): Promise<number | undefined> {
|
|
453
|
+
try {
|
|
454
|
+
const list = await run(["api", `repos/${repository}/actions/workflows/check.yml/runs?status=success&per_page=20`, "--jq", "{total_count,workflow_runs:[.workflow_runs[]|{id,event,conclusion,head_sha}]}"], 10_000, undefined, 64 * 1024);
|
|
455
|
+
if (list.code !== 0) return;
|
|
456
|
+
const runs = JSON.parse(list.stdout)?.workflow_runs;
|
|
457
|
+
if (!Array.isArray(runs) || runs.length > 20) return;
|
|
458
|
+
for (const candidate of runs) {
|
|
459
|
+
if (!id(candidate?.id) || candidate.conclusion !== "success" || !["pull_request", "push"].includes(candidate.event) || !sha(candidate.head_sha)) continue;
|
|
460
|
+
const commit = await run(["api", `repos/${repository}/git/commits/${candidate.head_sha}`, "--jq", "{tree:{sha:.tree.sha}}"], 10_000, undefined, 4096);
|
|
461
|
+
if (commit.code !== 0 || JSON.parse(commit.stdout)?.tree?.sha !== tree) continue;
|
|
462
|
+
const jobs = await run(["api", `repos/${repository}/actions/runs/${candidate.id}/jobs?per_page=100`, "--jq", '{total_count,jobs:[.jobs[]|{name,conclusion,reused:any(.steps[]?; .name=="Reuse verified identical-tree full gate" and .conclusion=="success")}]}'], 10_000, undefined, 32 * 1024);
|
|
463
|
+
if (jobs.code !== 0) continue;
|
|
464
|
+
const proof = JSON.parse(jobs.stdout);
|
|
465
|
+
if (!Array.isArray(proof.jobs) || proof.jobs.length > 100 || proof.total_count !== proof.jobs.length) continue;
|
|
466
|
+
if (required.every(name => {
|
|
467
|
+
const found = proof.jobs.filter((job: { name?: string }) => job?.name === name); return found.length === 1 && found[0]?.conclusion === "success" && found[0]?.reused !== true;
|
|
468
|
+
})) return candidate.id;
|
|
469
|
+
}
|
|
470
|
+
} catch { return; }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** PR cost evidence excludes titles, bodies and comment text; unknown/truncated coverage never becomes zero. */
|
|
474
|
+
export async function readPrCostHistory(repository: string, number: number, run: SdlcGh = sdlcGh): Promise<import("./cost-governance.ts").CostPr | undefined> {
|
|
475
|
+
if (!repo(repository) || !id(number)) return;
|
|
476
|
+
const [owner, name] = repository.split("/");
|
|
477
|
+
try {
|
|
478
|
+
const query = "query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){number state headRefOid mergedAt commits(first:100){nodes{commit{oid}} pageInfo{hasNextPage}} reviews(first:100){nodes{databaseId commit{oid}} pageInfo{hasNextPage}}}}}";
|
|
479
|
+
const reply = await run(["api", "graphql", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${number}`], 10_000, undefined, 128 * 1024);
|
|
480
|
+
if (reply.code !== 0) return;
|
|
481
|
+
const parsed = JSON.parse(reply.stdout), pr = parsed.data?.repository?.pullRequest;
|
|
482
|
+
if (parsed.errors || pr?.number !== number || !["OPEN", "CLOSED", "MERGED"].includes(pr.state)) return;
|
|
483
|
+
const result: import("./cost-governance.ts").CostPr = { repo: repository, pr: number, state: pr.state, complete: false,
|
|
484
|
+
...(typeof pr.mergedAt === "string" && Number.isFinite(Date.parse(pr.mergedAt)) ? { mergedAt: Date.parse(pr.mergedAt) } : {}) };
|
|
485
|
+
if (pr.state !== "MERGED") return result; // Cost metrics count merged PRs, not an active work queue.
|
|
486
|
+
const reviews = pr.reviews, commits = pr.commits;
|
|
487
|
+
const reviewComplete = reviews?.pageInfo?.hasNextPage === false && Array.isArray(reviews.nodes) && reviews.nodes.length <= 100 && reviews.nodes.every((node: { databaseId?: number }) => id(node?.databaseId));
|
|
488
|
+
const commitComplete = commits?.pageInfo?.hasNextPage === false && Array.isArray(commits.nodes) && commits.nodes.length <= 100 && sha(pr.headRefOid) && commits.nodes.every((node: { commit?: { oid?: string } }) => sha(node?.commit?.oid));
|
|
489
|
+
if (reviewComplete) result.reviews = new Set(reviews.nodes.map((review: { databaseId?: unknown }) => review.databaseId).filter((id: unknown) => Number.isSafeInteger(id))).size;
|
|
490
|
+
const heads = new Set<string>([pr.headRefOid, ...(commits?.nodes ?? []).map((node: { commit?: { oid?: string } }) => node?.commit?.oid), ...(reviews?.nodes ?? []).map((node: { commit?: { oid?: string } }) => node?.commit?.oid)].filter(sha));
|
|
491
|
+
const checks = await run(["api", `repos/${repository}/actions/workflows/check.yml/runs?per_page=100`, "--jq", "{total_count,workflow_runs:[.workflow_runs[]|{id,run_attempt,head_sha,pull_requests:[.pull_requests[].number]}]}"], 10_000, undefined, 128 * 1024);
|
|
492
|
+
if (checks.code !== 0) return result;
|
|
493
|
+
const runs = JSON.parse(checks.stdout);
|
|
494
|
+
if (!Array.isArray(runs.workflow_runs) || runs.workflow_runs.length > 100 || !Number.isSafeInteger(runs.total_count) || runs.total_count < runs.workflow_runs.length) return result;
|
|
495
|
+
const linked = runs.workflow_runs.filter((row: { head_sha?: string; pull_requests?: number[] }) => heads.has(row.head_sha ?? "") || Array.isArray(row.pull_requests) && row.pull_requests.includes(number));
|
|
496
|
+
if (linked.some((row: { id?: number; run_attempt?: number }) => !id(row.id) || !id(row.run_attempt))) return result;
|
|
497
|
+
result.ciRuns = linked.map((row: { id: number; run_attempt: number }) => ({ id: row.id, attempt: row.run_attempt }));
|
|
498
|
+
result.complete = reviewComplete && commitComplete && runs.total_count === runs.workflow_runs.length;
|
|
499
|
+
return result;
|
|
500
|
+
} catch { return; }
|
|
501
|
+
}
|