@staix/agent-hub 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +12 -8
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +6 -3
  5. package/docs/agent-notes/budget.md +4 -2
  6. package/docs/agent-notes/daemon.md +9 -3
  7. package/docs/agent-notes/local-worker.md +2 -2
  8. package/docs/agent-notes/tasks.md +13 -1
  9. package/docs/agent-notes/tests.md +3 -0
  10. package/docs/dogfood.md +13 -4
  11. package/docs/events.md +1 -1
  12. package/docs/memory.md +78 -10
  13. package/docs/operations.md +116 -30
  14. package/docs/quickstart.md +8 -4
  15. package/docs/security.md +80 -18
  16. package/docs/smoke.md +7 -3
  17. package/docs/specs/2026-09-19-agent-hub-design.md +84 -8
  18. package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
  19. package/docs/verification/2026-10-24-amem-runtime.md +69 -0
  20. package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
  21. package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
  22. package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
  23. package/docs/verification/2026-10-25-0.16.0.md +82 -0
  24. package/docs/verified.json +26 -17
  25. package/package.json +2 -2
  26. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  27. package/plugins/agent-hub/server.js +2085 -1702
  28. package/src/adapters/acp.ts +5 -2
  29. package/src/adapters/codex-appserver.ts +69 -1
  30. package/src/adapters/compat.ts +2 -2
  31. package/src/adapters/definitions/builtin-metadata.ts +3 -3
  32. package/src/adapters/local-worker.ts +5 -4
  33. package/src/adapters/registry-types.ts +1 -1
  34. package/src/cli/console-state.ts +20 -2
  35. package/src/cli/console-symbols.ts +3 -2
  36. package/src/cli/console-usage.ts +5 -2
  37. package/src/cli/console.ts +5 -1
  38. package/src/cli/help.ts +7 -5
  39. package/src/cli/identity.ts +3 -1
  40. package/src/cli/main.ts +44 -10
  41. package/src/cli/memory.ts +119 -7
  42. package/src/cli/output.ts +12 -4
  43. package/src/hub/action-class.ts +211 -17
  44. package/src/hub/action-enforce.ts +13 -5
  45. package/src/hub/action-policy.ts +19 -8
  46. package/src/hub/ask.ts +16 -4
  47. package/src/hub/board.ts +86 -31
  48. package/src/hub/budget-notes.ts +65 -0
  49. package/src/hub/budget.ts +42 -10
  50. package/src/hub/bus.ts +1 -1
  51. package/src/hub/code-facts.ts +56 -16
  52. package/src/hub/conductor.ts +5 -2
  53. package/src/hub/config-apply.ts +7 -0
  54. package/src/hub/control-client.ts +1 -1
  55. package/src/hub/daemon.ts +250 -49
  56. package/src/hub/envelope.ts +1 -1
  57. package/src/hub/events.ts +2 -2
  58. package/src/hub/hub-tools.ts +3 -3
  59. package/src/hub/kimi-usage.ts +12 -0
  60. package/src/hub/lifecycle.ts +10 -5
  61. package/src/hub/memory-code-head.ts +44 -0
  62. package/src/hub/memory-code-overlay.ts +119 -0
  63. package/src/hub/memory-plan-guard.ts +10 -1
  64. package/src/hub/memory-stale-anchors.ts +159 -0
  65. package/src/hub/memory-symbols.ts +74 -0
  66. package/src/hub/planning.ts +10 -4
  67. package/src/hub/read-tools.ts +21 -3
  68. package/src/hub/sdlc.ts +34 -0
  69. package/src/hub/settings.ts +17 -2
  70. package/src/hub/settled-roles.ts +15 -3
  71. package/src/hub/task-sweep.ts +25 -3
  72. package/src/hub/tasks.ts +353 -64
  73. package/src/hub/token-window.ts +15 -2
  74. package/src/hub/window-kind.ts +13 -0
  75. package/src/local/tools.ts +5 -5
  76. package/src/memory/brief.ts +22 -12
  77. package/src/memory/capture.ts +26 -40
  78. package/src/memory/client.ts +137 -65
  79. package/src/memory/recall.ts +13 -22
  80. package/src/ui/index.html +100 -18
@@ -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
- const nodes = new Map<string, Graph["nodes"][number]>(), edges = new Map<string, Graph["edges"][number]>();
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: Graph & { truncated: number } = { nodes: [], edges: [], truncated: eligibleNodes.length + eligibleEdges.length };
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");
@@ -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: "Planner (the one peer that plans)", type: "enum", values: PLANNER_PEERS, store: "config", applies: "live", risk: "raises", path: ["planner"] },
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
- return extra.length ? Object.freeze([...SETTINGS, ...extra]) : SETTINGS;
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
  }
@@ -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 pinned = (PLANNER_PEERS as readonly string[]).includes(planner ?? "") ? planner! : undefined;
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[pinned] = ["planner", ...(roles[pinned] ?? [])];
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 {
@@ -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 snapshot = tasks.slice(0, GROOMING_TASK_LIMIT).filter(task => task.state !== "approved" && (task.state !== "cancelled" || task.history.some(entry => entry.supersededBy))).sort((a, b) => a.id - b.id);
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 ([...issueIds.get(task.id)!].some(issue => issueIds.get(right.id)!.has(issue))) add({ kind: "duplicate", task: task.id, related: [right.id] });
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;