@staix/agent-hub 0.15.0 → 0.17.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 +43 -0
- package/README.md +12 -8
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +24 -8
- package/docs/agent-notes/budget.md +4 -2
- package/docs/agent-notes/daemon.md +11 -4
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +15 -3
- package/docs/agent-notes/tests.md +8 -3
- package/docs/dogfood.md +18 -6
- package/docs/events.md +1 -1
- package/docs/memory.md +78 -10
- package/docs/operations.md +160 -47
- package/docs/quickstart.md +13 -5
- package/docs/roles.md +1 -0
- package/docs/sdlc.md +11 -11
- package/docs/security.md +94 -22
- package/docs/smoke.md +58 -6
- package/docs/specs/2026-09-19-agent-hub-design.md +124 -12
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
- package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
- package/docs/verification/2026-10-24-amem-runtime.md +69 -0
- package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
- package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
- package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
- package/docs/verification/2026-10-25-0.16.0.md +82 -0
- package/docs/verification/2026-10-25-0.17.0.md +74 -0
- package/docs/verification/2026-10-25-codex-0164-reviewer-tool-call.md +52 -0
- package/docs/verification/2026-10-25-kimi-231-reviewer-compat.md +48 -0
- package/docs/verification/2026-10-25-kimi-usage-ac11.md +48 -0
- package/docs/verification/2026-10-25-qwen-dual-output-hub-e2e.md +23 -0
- package/docs/verification/2026-10-25-qwen-dual-output-t0.md +20 -0
- package/docs/verification/2026-10-25-reviewer-no-verdict-diagnostics.md +35 -0
- package/docs/verified.json +30 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3421 -2176
- package/src/adapters/acp.ts +5 -2
- package/src/adapters/codex-appserver.ts +69 -1
- package/src/adapters/codex-review-isolation.ts +10 -1
- package/src/adapters/compat.ts +9 -4
- package/src/adapters/conformance/enrollment.ts +18 -4
- package/src/adapters/conformance/factories.ts +4 -1
- package/src/adapters/conformance/kit.ts +11 -0
- package/src/adapters/definitions/builtin-metadata.ts +3 -3
- package/src/adapters/definitions/native-cli-entry-qwen.ts +32 -4
- package/src/adapters/definitions/native-cli-preview-qwen.ts +9 -1
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/definitions/native-recovery.ts +8 -0
- package/src/adapters/definitions/qwen.ts +7 -4
- package/src/adapters/local-worker.ts +5 -4
- package/src/adapters/qwen-dual.ts +578 -0
- package/src/adapters/registry-types.ts +6 -2
- package/src/adapters/registry.ts +9 -1
- package/src/adapters/reviewer-mcp.ts +12 -6
- package/src/adapters/reviewer-print.ts +100 -17
- package/src/cli/console-state.ts +20 -2
- package/src/cli/console-symbols.ts +3 -2
- package/src/cli/console-usage.ts +5 -2
- package/src/cli/console.ts +5 -1
- package/src/cli/help.ts +7 -5
- package/src/cli/identity.ts +3 -1
- package/src/cli/main.ts +44 -10
- package/src/cli/memory.ts +119 -7
- package/src/cli/output.ts +12 -4
- package/src/cli/upgrade.ts +4 -3
- package/src/hub/action-class.ts +211 -17
- package/src/hub/action-enforce.ts +13 -5
- package/src/hub/action-policy.ts +19 -8
- package/src/hub/ask.ts +16 -4
- package/src/hub/board.ts +86 -31
- package/src/hub/budget-notes.ts +65 -0
- package/src/hub/budget.ts +42 -10
- package/src/hub/bus.ts +1 -1
- package/src/hub/code-facts.ts +56 -16
- package/src/hub/conductor.ts +5 -2
- package/src/hub/config-apply.ts +7 -0
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +384 -71
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +2 -2
- package/src/hub/hub-tools.ts +3 -3
- package/src/hub/kimi-usage.ts +12 -0
- package/src/hub/lifecycle.ts +10 -5
- package/src/hub/memory-code-head.ts +44 -0
- package/src/hub/memory-code-overlay.ts +119 -0
- package/src/hub/memory-plan-guard.ts +11 -27
- package/src/hub/memory-stale-anchors.ts +159 -0
- package/src/hub/memory-symbols.ts +74 -0
- package/src/hub/planning.ts +10 -4
- package/src/hub/read-tools.ts +21 -3
- package/src/hub/sdlc.ts +34 -0
- package/src/hub/settings.ts +17 -2
- package/src/hub/settled-roles.ts +15 -3
- package/src/hub/task-sweep.ts +25 -3
- package/src/hub/tasks.ts +378 -76
- package/src/hub/token-window.ts +15 -2
- package/src/hub/window-kind.ts +13 -0
- package/src/local/tools.ts +5 -5
- package/src/memory/brief.ts +66 -16
- package/src/memory/capture.ts +26 -40
- package/src/memory/client.ts +137 -65
- package/src/memory/recall.ts +13 -22
- package/src/ui/index.html +100 -18
package/src/hub/read-tools.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { MemoryCodeOverlay } from "./memory-code-overlay.ts";
|
|
1
2
|
import type { MemoryStore, View, Graph } from "@staix/agent-mem";
|
|
2
3
|
import { looksSecret } from "@staix/agent-mem";
|
|
3
4
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
@@ -29,7 +30,7 @@ export function validateMemoryRead(op: string, a: Record<string, unknown>): void
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
/** Read-only model projection. Counts and caps are computed only after screening whole records and metadata. */
|
|
32
|
-
export function memoryRead(store: MemoryStore | undefined, project: string, screen: MemoryScreen, op: string, a: Record<string, unknown>, stale: (about: readonly string[]) => boolean = () => false): string {
|
|
33
|
+
export function memoryRead(store: MemoryStore | undefined, project: string, screen: MemoryScreen, op: string, a: Record<string, unknown>, stale: (about: readonly string[]) => boolean = () => false, code?: MemoryCodeOverlay): string {
|
|
33
34
|
validateMemoryRead(op, a);
|
|
34
35
|
const safe = (v: unknown): boolean => {
|
|
35
36
|
try {
|
|
@@ -93,7 +94,9 @@ export function memoryRead(store: MemoryStore | undefined, project: string, scre
|
|
|
93
94
|
if (n.startsWith("goal:") && !goals.has(n)) reject();
|
|
94
95
|
return n;
|
|
95
96
|
});
|
|
96
|
-
|
|
97
|
+
type Node = Graph["nodes"][number] & { derived?: true; head?: string; codeNeighbour?: true };
|
|
98
|
+
type Edge = Omit<Graph["edges"][number], "rel"> & { rel: string; derived?: true; head?: string };
|
|
99
|
+
const nodes = new Map<string, Node>(), edges = new Map<string, Edge>();
|
|
97
100
|
for (const start of starts) {
|
|
98
101
|
const graph = store.graph(project, start, Number(a.hops ?? 1), Number.MAX_SAFE_INTEGER);
|
|
99
102
|
for (const n of graph.nodes) {
|
|
@@ -105,12 +108,27 @@ export function memoryRead(store: MemoryStore | undefined, project: string, scre
|
|
|
105
108
|
}
|
|
106
109
|
for (const e of graph.edges) if (safe(e)) edges.set(JSON.stringify(e), e);
|
|
107
110
|
}
|
|
111
|
+
const overlay = code && /^[a-f0-9]{40}$/.test(code.head ?? "") && starts.some(id => id.startsWith("symbol:")) ? code : undefined;
|
|
112
|
+
if (overlay) {
|
|
113
|
+
for (const node of overlay.nodes) if (!nodes.has(node.id) && node.head === overlay.head && node.derived && safe(node)) nodes.set(node.id, node);
|
|
114
|
+
for (const edge of overlay.edges) if (edge.head === overlay.head && edge.derived && edge.rel === "calls" && safe(edge)) edges.set(JSON.stringify(edge), edge);
|
|
115
|
+
for (const note of overlay.notes) {
|
|
116
|
+
const value = visible.get(note.id);
|
|
117
|
+
if (!value || !safe(note)) continue;
|
|
118
|
+
nodes.set(note.id, { id: note.id, type: "memory", label: excerpt(value.title ?? value.text), kind: value.kind, codeNeighbour: true });
|
|
119
|
+
for (const about of value.about) if (overlay.nodes.some(node => node.id === about)) {
|
|
120
|
+
const edge = { from: note.id, to: about, rel: value.kind === "fail" ? "rules_out" : "about" };
|
|
121
|
+
edges.set(JSON.stringify(edge), edge);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
108
125
|
const screenedEdges = [...edges.values()].filter(e => nodes.has(e.from) && nodes.has(e.to));
|
|
109
126
|
const connected = new Set(screenedEdges.flatMap(e => [e.from, e.to]));
|
|
110
127
|
const rank = (n: Graph["nodes"][number]) => n.kind === "fail" ? 0 : starts.includes(n.id) ? 1 : n.type !== "memory" ? 2 : 3;
|
|
111
128
|
const eligibleNodes = [...nodes.values()].filter(n => connected.has(n.id) || starts.includes(n.id)).sort((x, y) => rank(x) - rank(y) || x.id.localeCompare(y.id));
|
|
112
129
|
const eligibleEdges = screenedEdges.sort((x, y) => JSON.stringify(x).localeCompare(JSON.stringify(y)));
|
|
113
|
-
const out:
|
|
130
|
+
const out: { nodes: Node[]; edges: Edge[]; truncated: number; code?: { state: "known" | "unknown"; head?: string; truncated: boolean; countsFloor: boolean } } = { nodes: [], edges: [], truncated: eligibleNodes.length + eligibleEdges.length,
|
|
131
|
+
...(code ? { code: { state: overlay?.state ?? "unknown", ...(overlay?.head ? { head: overlay.head } : {}), truncated: code.truncated, countsFloor: code.countsFloor } } : {}) };
|
|
114
132
|
for (const n of eligibleNodes) {
|
|
115
133
|
if (out.nodes.length >= Number(a.limit ?? 60)) break;
|
|
116
134
|
out.nodes.push(n); out.truncated--;
|
package/src/hub/sdlc.ts
CHANGED
|
@@ -36,6 +36,8 @@ export type SdlcMode = "off" | "advise" | "enforce";
|
|
|
36
36
|
export interface SdlcConfig { workflow?: "gsd-lite"; maxNits?: number; /** #481 S7: optional control bands per metric; absent means none. */ bands?: BandsConfig; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
|
|
37
37
|
export interface PlanReviewReceipt {
|
|
38
38
|
planner: string;
|
|
39
|
+
planAuthor?: string | null;
|
|
40
|
+
selfReview?: true;
|
|
39
41
|
planningRevision: number;
|
|
40
42
|
recordedAt: number;
|
|
41
43
|
scope?: CostPlan;
|
|
@@ -206,6 +208,11 @@ export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined =
|
|
|
206
208
|
next.localResult = raw.localResult as SdlcReceipt["localResult"];
|
|
207
209
|
}
|
|
208
210
|
if (raw.redFirst !== undefined) next.redFirst = parseRedFirst(raw.redFirst);
|
|
211
|
+
return receiptIdentity(next, previous, raw);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function receiptIdentity(next: SdlcReceipt, previous: SdlcReceipt, raw: Record<string, unknown>): SdlcReceipt {
|
|
215
|
+
const checklist = raw.criteria ?? raw.phase;
|
|
209
216
|
// A test receipt never carries across a different linked issue or artifact.
|
|
210
217
|
if (raw.redFirst === undefined && (next.repo !== previous.repo || next.issue !== previous.issue || next.pr !== previous.pr)) delete next.redFirst;
|
|
211
218
|
// An acceptance names one spec issue; relinking the receipt to another drops it (#481 S1).
|
|
@@ -221,6 +228,33 @@ export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined =
|
|
|
221
228
|
return next;
|
|
222
229
|
}
|
|
223
230
|
|
|
231
|
+
export interface PlanningIssueIdentity { repo?: string | null; issue?: number | null }
|
|
232
|
+
/** Planning can change only the linked issue identity, never lifecycle evidence. */
|
|
233
|
+
export function planningIssueIdentity(value: unknown): PlanningIssueIdentity {
|
|
234
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("planning sdlc identity must be an object");
|
|
235
|
+
const raw = value as Record<string, unknown>, keys = Object.keys(raw);
|
|
236
|
+
if (!keys.length || keys.some(key => key !== "repo" && key !== "issue")) throw new Error("planning sdlc identity takes repo/issue only");
|
|
237
|
+
const result: PlanningIssueIdentity = {};
|
|
238
|
+
if (Object.hasOwn(raw, "repo")) {
|
|
239
|
+
if (raw.repo !== null && (!repo(raw.repo) || raw.repo.length > 400)) throw new Error("planning sdlc.repo: expected owner/repository or null");
|
|
240
|
+
result.repo = raw.repo as string | null;
|
|
241
|
+
}
|
|
242
|
+
if (Object.hasOwn(raw, "issue")) {
|
|
243
|
+
if (raw.issue !== null && !id(raw.issue)) throw new Error("planning sdlc.issue: expected a positive integer or null");
|
|
244
|
+
result.issue = raw.issue as number | null;
|
|
245
|
+
}
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
export function planningIssueReceipt(value: unknown, previous: SdlcReceipt | undefined, config: SdlcConfig): SdlcReceipt {
|
|
249
|
+
const identity = planningIssueIdentity(value);
|
|
250
|
+
if (identity.repo !== null && identity.issue !== null) return sdlcReceipt(identity, previous, config);
|
|
251
|
+
const old = previous ?? { stage: config.stages[0] ?? "spec" }, next = { ...old };
|
|
252
|
+
if (Object.hasOwn(identity, "repo")) { if (identity.repo === null) delete next.repo; else next.repo = identity.repo!; }
|
|
253
|
+
if (Object.hasOwn(identity, "issue")) { if (identity.issue === null) delete next.issue; else next.issue = identity.issue!; }
|
|
254
|
+
return receiptIdentity(next, old, {});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
224
258
|
export function parsePrEvidence(value: unknown): PrEvidence {
|
|
225
259
|
const p = value as Record<string, any>;
|
|
226
260
|
if (!p || !sha(p.headRefOid) || !["OPEN", "CLOSED", "MERGED"].includes(p.state) || !Array.isArray(p.statusCheckRollup) || !Array.isArray(p.files) || p.files.length > 1000) throw new Error("PR readback: invalid or incomplete structured evidence");
|
package/src/hub/settings.ts
CHANGED
|
@@ -56,7 +56,7 @@ export const SETTINGS: readonly SettingDef[] = [
|
|
|
56
56
|
...["interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"].map((key): SettingDef => ({ key: `task_sweep.${key}`, group: "Switches", label: `Task sweep: ${key}`, type: "number", min: 1, max: 2_147_483_647 / (key === "interval_s" ? 1000 : 60_000), store: "config", applies: "live", risk: "raises", path: ["task_sweep", key] })),
|
|
57
57
|
...SETTING_PEERS.map((peer): SettingDef => ({ key: `tasks.wip_limits.${peer}`, group: "Routing", label: `${peer}: planner WIP limit`, type: "integer", min: 0, max: 32, store: "config", applies: "live", risk: "raises", floor: 0, path: ["tasks", "wip_limits", peer] })),
|
|
58
58
|
{ key: "tasks.escalate_after", group: "Routing", label: "Escalate after N consecutive changes_requested", type: "integer", min: 1, max: 10, store: "config", applies: "live", risk: "raises", path: ["tasks", "escalate_after"] },
|
|
59
|
-
{ key: "planner", group: "Roles", label: "
|
|
59
|
+
{ key: "planner", group: "Roles", label: "Planners (task-local selection)", type: "peers", values: PLANNER_PEERS, store: "config", applies: "live", risk: "raises", path: ["planner"] },
|
|
60
60
|
...SETTING_PEERS.map((peer): SettingDef => ({ key: `catalog_roles.${peer}`, group: "Roles", label: `${peer}: catalog guidance`, type: "roles", store: "config", applies: "next use", risk: "safe", path: ["catalog_roles", peer] })),
|
|
61
61
|
{ key: "scoped_grants.protect_instructions", group: "Permissions", label: "Ask before writing instruction files", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["protect_instruction_files"] },
|
|
62
62
|
...SETTING_PEERS.map((peer): SettingDef => ({ key: `roster.${peer}`, group: "Roster", label: `${peer}: participates`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", peer] })),
|
|
@@ -84,6 +84,7 @@ export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home"
|
|
|
84
84
|
export function projectSettings(instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): readonly SettingDef[] {
|
|
85
85
|
const resolve = adapterCatalog?.definitionOf ?? definitionOf;
|
|
86
86
|
const extra: SettingDef[] = [];
|
|
87
|
+
const plannerValues = [...PLANNER_PEERS];
|
|
87
88
|
const headless = (peer: string): SettingDef => Object.freeze({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (headless only)`,
|
|
88
89
|
type: "enum", values: Object.freeze(["headless"]), store: "config", applies: "next use", risk: "raises", floor: "headless", path: Object.freeze(["peers", peer, "start_mode"]) });
|
|
89
90
|
for (const adapter of adapterCatalog?.definitions ?? []) {
|
|
@@ -108,6 +109,10 @@ export function projectSettings(instances?: KindMap, adapterCatalog?: ProjectAda
|
|
|
108
109
|
const adapter = resolve(peer, instances);
|
|
109
110
|
if (!adapter) continue;
|
|
110
111
|
const entry = Object.getOwnPropertyDescriptor(instances!, peer)?.value;
|
|
112
|
+
if (PEER.test(peer) && peer.startsWith(`${adapter.kind}-`) && /^[a-z0-9][a-z0-9-]*$/.test(peer.slice(adapter.kind.length + 1))
|
|
113
|
+
&& adapter.projection?.plannerEligible === true
|
|
114
|
+
&& (!adapter.instances?.roles || adapter.instances.roles.includes("planner"))
|
|
115
|
+
&& (!adapter.routing?.allowedRoles || adapter.routing.allowedRoles.includes("planner"))) plannerValues.push(peer);
|
|
111
116
|
const readOnly = entry && typeof entry === "object" && !types.isProxy(entry) && Object.getOwnPropertyDescriptor(entry, "readOnly")?.value === true && !!adapter.permission.genericAcp?.readOnly;
|
|
112
117
|
const copy = (base: SettingDef, key: string): SettingDef => Object.freeze({ ...base, key,
|
|
113
118
|
label: base.label.replace(`${adapter.kind}:`, `${peer}:`),
|
|
@@ -126,7 +131,9 @@ export function projectSettings(instances?: KindMap, adapterCatalog?: ProjectAda
|
|
|
126
131
|
} satisfies SettingDef));
|
|
127
132
|
extra.push(headless(peer));
|
|
128
133
|
}
|
|
129
|
-
|
|
134
|
+
const settings = plannerValues.length === PLANNER_PEERS.length ? SETTINGS : SETTINGS.map(def => def.key === "planner"
|
|
135
|
+
? Object.freeze({ ...def, values: Object.freeze(plannerValues) }) : def);
|
|
136
|
+
return extra.length ? Object.freeze([...settings, ...extra]) : settings;
|
|
130
137
|
}
|
|
131
138
|
|
|
132
139
|
/** The definition of a key, or why there is none. */
|
|
@@ -159,6 +166,14 @@ export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonl
|
|
|
159
166
|
return [...raw] as string[];
|
|
160
167
|
}
|
|
161
168
|
if (def.type === "peers") {
|
|
169
|
+
if (def.key === "planner") {
|
|
170
|
+
const peers = typeof raw === "string" ? [raw] : raw;
|
|
171
|
+
if (!Array.isArray(peers) || peers.length === 0 || peers.length > PEERS_MAX || new Set(peers).size !== peers.length
|
|
172
|
+
|| peers.some(peer => typeof peer !== "string" || !PEER.test(peer) || !(def.values ?? PLANNER_PEERS).includes(peer))) {
|
|
173
|
+
throw new Error(`${def.key} must be one of ${(def.values ?? PLANNER_PEERS).join(", ")}, or a list of one to ${PEERS_MAX} distinct eligible planner peer ids`);
|
|
174
|
+
}
|
|
175
|
+
return peers.length === 1 ? peers[0] as string : [...peers] as string[];
|
|
176
|
+
}
|
|
162
177
|
if (!Array.isArray(raw) || raw.length > PEERS_MAX || raw.some((p) => typeof p !== "string" || !PEER.test(p)) || new Set(raw).size !== raw.length) throw new Error(`${def.key} takes up to ${PEERS_MAX} distinct peer ids`);
|
|
163
178
|
return [...(raw as string[])];
|
|
164
179
|
}
|
package/src/hub/settled-roles.ts
CHANGED
|
@@ -11,15 +11,27 @@ export interface SettledRoles {
|
|
|
11
11
|
/** Resolve the runtime planner fallback without changing the configured policy role map. */
|
|
12
12
|
export function resolveSettledRoles(
|
|
13
13
|
policyRoles: Readonly<Record<string, readonly string[]>>,
|
|
14
|
-
planner: string | undefined,
|
|
14
|
+
planner: string | readonly string[] | undefined,
|
|
15
15
|
rostered: (peer: string) => boolean,
|
|
16
|
+
/** Extra ids are eligible only through the host's validated startup catalog, never a kind-name fallback. */
|
|
17
|
+
plannerCapable?: (peer: string) => boolean,
|
|
16
18
|
): SettledRoles {
|
|
17
19
|
const roles = Object.fromEntries(Object.entries(policyRoles).map(([peer, list]) => [peer, [...list]]));
|
|
18
|
-
const
|
|
20
|
+
const eligible = (peer: unknown): peer is string => typeof peer === "string" && /^[a-z][a-z0-9-]{0,31}$/.test(peer)
|
|
21
|
+
&& !["user", "hub", "digest"].includes(peer) && ((PLANNER_PEERS as readonly string[]).includes(peer) || plannerCapable?.(peer) === true);
|
|
22
|
+
let pinned: readonly string[] | undefined;
|
|
23
|
+
if (typeof planner === "string") {
|
|
24
|
+
// Direct callers historically ignored an unknown scalar; configuration validation rejects it before this resolver.
|
|
25
|
+
if (eligible(planner)) pinned = [planner];
|
|
26
|
+
} else if (planner !== undefined) {
|
|
27
|
+
if (!Array.isArray(planner) || planner.length < 1 || planner.length > 8 || new Set(planner).size !== planner.length || !planner.every(eligible))
|
|
28
|
+
throw new Error("planner list must contain 1..8 distinct eligible peer ids");
|
|
29
|
+
pinned = planner;
|
|
30
|
+
}
|
|
19
31
|
let source: SettledRoles["source"];
|
|
20
32
|
if (pinned) {
|
|
21
33
|
for (const peer of Object.keys(roles)) roles[peer] = roles[peer]!.filter(role => role !== "planner");
|
|
22
|
-
roles[
|
|
34
|
+
for (const peer of pinned) roles[peer] = ["planner", ...(roles[peer] ?? [])];
|
|
23
35
|
source = "setting";
|
|
24
36
|
} else if (Object.entries(roles).some(([peer, list]) => list.includes("planner") && rostered(peer))) source = "roles";
|
|
25
37
|
else {
|
package/src/hub/task-sweep.ts
CHANGED
|
@@ -78,6 +78,9 @@ export interface PlannerGroomingOptions {
|
|
|
78
78
|
limits: Record<string, number>;
|
|
79
79
|
wip: Record<string, number>;
|
|
80
80
|
states: Record<string, string>;
|
|
81
|
+
/** Public ID/dependency context may include approved intermediate phases. */
|
|
82
|
+
dependencies?: ReadonlyMap<number, readonly number[]>;
|
|
83
|
+
dependencyTruncated?: boolean;
|
|
81
84
|
blockers?: readonly { task: number; code: string }[];
|
|
82
85
|
issues?: ReadonlyMap<string, { repo: string; issue: number; state: "OPEN" | "CLOSED"; editedAt: string; lastEditedAt?: string | null }>;
|
|
83
86
|
reviewedIssues?: ReadonlyMap<number, { editedAt: string }>;
|
|
@@ -105,10 +108,28 @@ function groomingPath(value: string): string | undefined {
|
|
|
105
108
|
/** Read-only ids/codes projection. Callers retain authority, privacy screening and the current metadata snapshot. */
|
|
106
109
|
export function plannerGrooming(tasks: readonly Task[], options: PlannerGroomingOptions): { items: GroomingItem[]; unknown: number[]; truncated: boolean } {
|
|
107
110
|
const items: GroomingItem[] = [], unknown = new Set<number>(), seen = new Set<string>();
|
|
108
|
-
let truncated = tasks.length > GROOMING_TASK_LIMIT;
|
|
111
|
+
let truncated = tasks.length > GROOMING_TASK_LIMIT || options.dependencyTruncated === true;
|
|
109
112
|
const repository = groomingRepo(options.repository);
|
|
110
113
|
// ponytail: bounded O(500²) snapshot; add revision-keyed paging/indexing before supporting larger complete boards.
|
|
111
|
-
const
|
|
114
|
+
const graphRows = tasks.slice(0, GROOMING_TASK_LIMIT);
|
|
115
|
+
const graph = new Map<number, readonly number[]>(graphRows.map(task => [task.id, task.deps ?? []]));
|
|
116
|
+
let context = 0;
|
|
117
|
+
for (const [id, deps] of options.dependencies ?? []) {
|
|
118
|
+
if (context++ >= GROOMING_TASK_LIMIT) { truncated = true; break; }
|
|
119
|
+
if (!graph.has(id)) graph.set(id, deps);
|
|
120
|
+
}
|
|
121
|
+
const reach = new Map<number, Set<number>>();
|
|
122
|
+
const dependencies = (id: number): Set<number> => {
|
|
123
|
+
const cached = reach.get(id); if (cached) return cached;
|
|
124
|
+
const found = new Set<number>(), pending = [...graph.get(id) ?? []];
|
|
125
|
+
while (pending.length) {
|
|
126
|
+
const next = pending.pop()!;
|
|
127
|
+
if (found.has(next) || !graph.has(next)) continue;
|
|
128
|
+
found.add(next); pending.push(...graph.get(next) ?? []);
|
|
129
|
+
}
|
|
130
|
+
reach.set(id, found); return found;
|
|
131
|
+
};
|
|
132
|
+
const snapshot = graphRows.filter(task => task.state !== "approved" && (task.state !== "cancelled" || task.history.some(entry => entry.supersededBy))).sort((a, b) => a.id - b.id);
|
|
112
133
|
const add = (item: GroomingItem) => {
|
|
113
134
|
const key = JSON.stringify(item);
|
|
114
135
|
if (seen.has(key)) return;
|
|
@@ -198,7 +219,8 @@ export function plannerGrooming(tasks: readonly Task[], options: PlannerGrooming
|
|
|
198
219
|
if (repositories.get(task.id) !== repositories.get(right.id)) continue;
|
|
199
220
|
const a = positions.get(task.id)!, b = positions.get(right.id)!;
|
|
200
221
|
if ([...a.paths].some(path => [...b.paths].some(other => overlaps(path, other))) || [...a.symbols].some(symbol => [...b.symbols].some(other => symbolsOverlap(symbol, other)))) add({ kind: "overlap", task: task.id, related: [right.id] });
|
|
201
|
-
if (
|
|
222
|
+
if (!dependencies(task.id).has(right.id) && !dependencies(right.id).has(task.id)
|
|
223
|
+
&& [...issueIds.get(task.id)!].some(issue => issueIds.get(right.id)!.has(issue))) add({ kind: "duplicate", task: task.id, related: [right.id] });
|
|
202
224
|
}
|
|
203
225
|
for (const blocker of (options.blockers ?? []).slice(0, GROOMING_TASK_LIMIT)) {
|
|
204
226
|
if (!positions.has(blocker.task)) continue;
|