@staix/agent-hub 0.12.45 → 0.13.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 (99) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +83 -116
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +10 -7
  5. package/docs/agent-notes/budget.md +6 -1
  6. package/docs/agent-notes/bus.md +6 -4
  7. package/docs/agent-notes/daemon.md +6 -1
  8. package/docs/agent-notes/local-worker.md +2 -1
  9. package/docs/agent-notes/tasks.md +10 -0
  10. package/docs/agent-notes/tests.md +4 -2
  11. package/docs/dogfood.md +92 -0
  12. package/docs/events.md +14 -1
  13. package/docs/operations.md +250 -19
  14. package/docs/profiles.md +2 -2
  15. package/docs/quickstart.md +8 -2
  16. package/docs/sdlc.md +36 -6
  17. package/docs/security.md +20 -11
  18. package/docs/smoke.md +9 -4
  19. package/docs/specs/2026-09-19-agent-hub-design.md +759 -8
  20. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  21. package/docs/verification/2026-10-22-code-facts.md +78 -0
  22. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
  23. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  24. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  25. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  26. package/docs/verified.json +14 -14
  27. package/package.json +1 -1
  28. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  29. package/plugins/agent-hub/server.js +613 -329
  30. package/src/adapters/agy/entry.ts +5 -4
  31. package/src/adapters/claude-channel.ts +20 -9
  32. package/src/adapters/codex-appserver.ts +18 -2
  33. package/src/adapters/codex-review-isolation.ts +12 -5
  34. package/src/adapters/compat.ts +140 -0
  35. package/src/adapters/definitions/builtin-metadata.ts +2 -1
  36. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  37. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  38. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  39. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  40. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  41. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  42. package/src/adapters/definitions/qwen-native.ts +7 -3
  43. package/src/adapters/definitions/qwen.ts +2 -1
  44. package/src/adapters/jcode.ts +72 -31
  45. package/src/adapters/kimi-review.ts +6 -3
  46. package/src/adapters/registry-types.ts +3 -1
  47. package/src/adapters/registry.ts +1 -0
  48. package/src/cli/agents.ts +40 -0
  49. package/src/cli/console-review.ts +54 -0
  50. package/src/cli/console-state.ts +124 -29
  51. package/src/cli/console-symbols.ts +74 -0
  52. package/src/cli/console-usage.ts +89 -0
  53. package/src/cli/console.ts +26 -1
  54. package/src/cli/help.ts +16 -6
  55. package/src/cli/identity.ts +29 -6
  56. package/src/cli/init.ts +109 -11
  57. package/src/cli/main.ts +122 -11
  58. package/src/cli/output.ts +44 -1
  59. package/src/cli/preview.ts +1 -1
  60. package/src/cli/sdlc-release-guard.ts +82 -0
  61. package/src/hub/action-class.ts +362 -0
  62. package/src/hub/action-policy.ts +106 -0
  63. package/src/hub/board.ts +43 -8
  64. package/src/hub/budget.ts +71 -2
  65. package/src/hub/bus.ts +43 -3
  66. package/src/hub/checks.ts +80 -31
  67. package/src/hub/code-facts.ts +223 -0
  68. package/src/hub/config-apply.ts +10 -1
  69. package/src/hub/config-trust.ts +7 -4
  70. package/src/hub/daemon.ts +237 -22
  71. package/src/hub/dogfood-read.ts +199 -0
  72. package/src/hub/dogfood.ts +163 -0
  73. package/src/hub/envelope.ts +1 -1
  74. package/src/hub/events.ts +11 -2
  75. package/src/hub/hub-tools.ts +24 -2
  76. package/src/hub/inbox-polling.ts +25 -0
  77. package/src/hub/instances.ts +73 -1
  78. package/src/hub/profiles.ts +7 -2
  79. package/src/hub/release-worktree.ts +56 -0
  80. package/src/hub/review-brief.ts +36 -0
  81. package/src/hub/sdlc-queue.ts +31 -9
  82. package/src/hub/sdlc-release-authorization.ts +173 -0
  83. package/src/hub/sdlc.ts +49 -11
  84. package/src/hub/settings.ts +5 -2
  85. package/src/hub/snapshots.ts +93 -8
  86. package/src/hub/symbol-normalizer.ts +26 -0
  87. package/src/hub/task-code-symbols.ts +90 -0
  88. package/src/hub/task-sweep.ts +3 -2
  89. package/src/hub/tasks.ts +292 -54
  90. package/src/hub/test-integrity.ts +162 -0
  91. package/src/hub/usage-rollup.ts +361 -0
  92. package/src/local/sandbox.ts +5 -3
  93. package/src/ui/index.html +1 -0
  94. package/templates/AGENTS.block.md +1 -1
  95. package/templates/profiles/dev.json +6 -1
  96. package/templates/profiles/general.json +6 -1
  97. package/templates/profiles/research.json +6 -1
  98. package/templates/profiles/writing.json +6 -1
  99. package/templates/release-guard.block.md +6 -0
@@ -1,3 +1,4 @@
1
+ import { ACTION_CLASSES } from "./action-class.ts";
1
2
  import { definitionOf, type KindMap, type ProjectAdapterCatalog, ADAPTERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
2
3
  import { createHash } from "node:crypto";
3
4
  import { types } from "node:util";
@@ -59,6 +60,8 @@ export const SETTINGS: readonly SettingDef[] = [
59
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] })),
60
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"] },
61
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] })),
63
+ { key: "action_policy.enabled", group: "Permissions", label: "Always-ask action policy", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["action_policy", "enabled"] },
64
+ ...ACTION_CLASSES.map((kind): SettingDef => ({ key: `action_policy.budgets.${kind}`, group: "Permissions", label: `${kind}: approved session budget`, type: "integer", min: 0, max: 1000, store: "config", applies: "live", risk: "raises", floor: 0, path: ["action_policy", "budgets", kind] })),
62
65
  ...SETTING_PEERS.filter(peer => !definitionOf(peer)?.permission.unmanaged).map((peer): SettingDef => ({ key: `permission.${peer}`, group: "Permissions", label: `${peer}: mode now`, type: "enum", values: PERMISSION_MODES, store: "runtime", applies: "live", risk: "raises", floor: "ask", path: [peer] })),
63
66
  ...SETTING_PEERS.filter(peer => !definitionOf(peer)?.permission.unmanaged).map((peer): SettingDef => ({ key: `permission_modes.${peer}`, group: "Permissions", label: `${peer}: mode at next peer start`, type: "enum", values: PERMISSION_MODES, store: "config", applies: "next use", risk: "raises", floor: "ask", path: ["permission_modes", peer] })),
64
67
  ...START_MODE_PEERS.map((peer): SettingDef => ({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (tui unless set)`, type: "enum", values: START_MODES, store: "config", applies: "next use", risk: "raises", floor: "tui", path: ["peers", peer, "start_mode"] })),
@@ -470,7 +473,7 @@ export function settingRows(input: {
470
473
  const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
471
474
  return projectSettings(input.instances, input.adapterCatalog).map((def): SettingRow => {
472
475
  const base = { key: def.key, group: def.group, label: def.label, type: def.type, ...(["integer", "number"].includes(def.type) ? { min: def.min, max: def.max } : {}), applies: def.applies, ...(def.restartReason ? { restartReason: def.restartReason } : {}), risk: def.risk, ...(def.floor === undefined ? {} : { floor: def.floor }),
473
- ...(def.key === "scoped_grants.protect_instructions" ? { hint: "AGENTS.md, CLAUDE.md and QWEN.md remain grantable by default; enabling this asks before scoped writes and edits" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!, input.instances, input.adapterCatalog) } : {}) };
476
+ ...(def.key === "scoped_grants.protect_instructions" ? { hint: "AGENTS.md, CLAUDE.md and QWEN.md remain grantable by default; enabling this asks before scoped writes and edits" } : def.key.startsWith("action_policy.") ? { hint: "not enforced yet: adapter integration pending" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!, input.instances, input.adapterCatalog) } : {}) };
474
477
  if (def.locked) return { ...base, values: def.values, file: def.store === "runtime" ? "this hub run" : CONFIG_LOCAL,
475
478
  source: "instance declaration", value: "read-only", fileValue: "read-only", locked: def.locked, note: def.locked };
476
479
  if (def.store === "runtime") {
@@ -485,7 +488,7 @@ export function settingRows(input: {
485
488
  if (def.type === "roles" && input.roles) { const roles = input.roles(def.path[1]!); return { ...base, ...roles, fileValue: roles.pending !== undefined ? roles.pending : roles.value, file: CONFIG_LOCAL, source: valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : valueAt(shared, def.path) !== undefined ? "config.json" : "profile/default", hint: "Guidance only; grants no tool or permission. At most two roles, applied at the next peer start." }; }
486
489
  const value = scalar(input.running(def.path));
487
490
  const stored = input.next ? scalar(input.next(def.path)) : value;
488
- const source = valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : valueAt(shared, def.path) !== undefined ? "config.json" : "default";
491
+ const source = valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : !def.key.startsWith("action_policy.") && valueAt(shared, def.path) !== undefined ? "config.json" : "default";
489
492
  const dropped = ignoredLine(input.ignored, def);
490
493
  return { ...base, ...(def.values ? { values: def.values } : {}), file: CONFIG_LOCAL, value, fileValue: stored, source, ...(same(value, stored) ? {} : { pending: stored }),
491
494
  ...(!input.next ? { note: "the config files do not load; fix them in your editor" } : dropped ? { note: `the hub does not read this value: ${dropped}` } : {}) };
@@ -26,12 +26,13 @@ export const budgetNow = (budget: SnapshotBudget): number => budget.now ? budget
26
26
  export class SnapshotBudgetExceeded extends Error {
27
27
  constructor() { super("snapshot burst budget exhausted; boundary evidence is unknown"); }
28
28
  }
29
- const git = (top: string | Repo, args: string[], env: Record<string, string> = {}, budget?: SnapshotBudget, maxBuffer?: number) => {
29
+ const git = (top: string | Repo, args: string[], env: Record<string, string> = {}, budget?: SnapshotBudget, maxBuffer?: number, input?: string | Buffer) => {
30
30
  const argv = repoGitArgs(top, args);
31
31
  const remaining = budget ? budget.deadline - budgetNow(budget) : SNAPSHOT_TIMEOUT_MS;
32
32
  if (remaining <= 0) throw new SnapshotBudgetExceeded();
33
33
  const result = hubGitSync(argv, {
34
34
  encoding: "utf8", env: { ...repoGitEnv(top), ...env }, ...(maxBuffer === undefined ? {} : { maxBuffer }),
35
+ ...(input === undefined ? {} : { input }),
35
36
  timeout: Math.max(1, Math.min(SNAPSHOT_TIMEOUT_MS, Math.floor(remaining))),
36
37
  });
37
38
  if (budget && (budgetNow(budget) >= budget.deadline || (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT")) throw new SnapshotBudgetExceeded();
@@ -311,11 +312,81 @@ export function incomingHeadPaths(repo: Repo, start: HeadMoveCursor, end: HeadMo
311
312
  if (appended[appended.length - 1] !== 10) return undefined;
312
313
  const lines = appended.toString("utf8").slice(0, -1).split("\n");
313
314
  if (lines.length > HEAD_LOG_MAX_MOVES) return undefined;
315
+
316
+ const diffCache = new Map<string, string[] | undefined>();
317
+ const ancestorCache = new Map<string, boolean>();
318
+ const diffOf = (from: string, to: string): string[] | undefined => {
319
+ if (from === to) return [];
320
+ const key = `${from}..${to}`;
321
+ if (diffCache.has(key)) return diffCache.get(key);
322
+ const res = knownChangedPaths(repo, from, to, budget);
323
+ diffCache.set(key, res);
324
+ return res;
325
+ };
326
+ const isAncestor = (oldCommit: string, nextCommit: string): boolean => {
327
+ if (oldCommit === nextCommit) return true;
328
+ const key = `${oldCommit}..${nextCommit}`;
329
+ if (ancestorCache.has(key)) return ancestorCache.get(key)!;
330
+ const ancestor = git(repo, ["merge-base", "--is-ancestor", oldCommit, nextCommit], {}, budget);
331
+ const ok = ancestor.status === 0;
332
+ ancestorCache.set(key, ok);
333
+ return ok;
334
+ };
335
+
336
+ const parsedRows: Array<ReturnType<typeof headLogRow>> = [];
337
+ const batchCandidates = new Set<string>();
338
+ for (const line of lines) {
339
+ const row = headLogRow(line);
340
+ parsedRows.push(row);
341
+ if (!row) continue;
342
+ const separator = row.action.indexOf(": ");
343
+ const action = separator < 0 ? "" : row.action.slice(0, separator);
344
+ // #612: diff-tree --stdin compares a commit to its first parent. In git sequencer, only pick
345
+ // and initial/regular commits have row.old as the parent of row.next. Sequencer squash, fixup,
346
+ // reword and edit amend the commit, so row.next's parent is row.old's parent. Only batch rows
347
+ // where row.old is the parent (pick and commit); other rows are diffed individually.
348
+ const isPickOrCommit = /^.+ \(pick\)$/.test(action) || action === "commit";
349
+ if (isPickOrCommit && row.old !== row.next && !/^0+$/.test(row.old)) {
350
+ batchCandidates.add(row.next);
351
+ }
352
+ }
353
+
354
+ // Batch single-parent pick/commit diffs across the boundary to stay within burst budget (#612).
355
+ if (batchCandidates.size > 1) {
356
+ const commitList = [...batchCandidates];
357
+ const stdinInput = commitList.join("\n") + "\n";
358
+ const batchRes = git(repo, ["diff-tree", "--stdin", "--always", "-r", "--no-renames", "--raw", "-z", "--", scope(repo)], {}, budget, undefined, stdinInput);
359
+ if (batchRes.status === 0) {
360
+ const parts = batchRes.stdout.split("\0");
361
+ if (parts.length && parts[parts.length - 1] === "") parts.pop();
362
+ let i = 0;
363
+ while (i < parts.length) {
364
+ const commit = parts[i++];
365
+ if (!commit) continue;
366
+ const paths: string[] = [];
367
+ while (i < parts.length && parts[i]?.startsWith(":")) {
368
+ i++; // skip raw metadata: :oldmode newmode oldsha newsha status
369
+ if (i < parts.length && parts[i]) {
370
+ paths.push(parts[i++]!);
371
+ }
372
+ }
373
+ for (const r of parsedRows) {
374
+ if (r && r.next === commit && r.old !== r.next) {
375
+ const sep = r.action.indexOf(": ");
376
+ const act = sep < 0 ? "" : r.action.slice(0, sep);
377
+ if (/^.+ \(pick\)$/.test(act) || act === "commit") {
378
+ diffCache.set(`${r.old}..${r.next}`, [...paths]);
379
+ }
380
+ }
381
+ }
382
+ }
383
+ }
384
+ }
385
+
314
386
  const own = new Set<string>(), incoming = new Set<string>();
315
387
  let previous = start.head;
316
- for (const line of lines) {
388
+ for (const row of parsedRows) {
317
389
  checkSnapshotBudget(budget);
318
- const row = headLogRow(line);
319
390
  if (!row || (previous === null ? !/^0+$/.test(row.old) : row.old !== previous)) return undefined;
320
391
  // Git preserves the invoking command in sequencer actions (for example pull -q --rebase (pick)).
321
392
  // Classify the native phase before considering ordinary command-shaped incoming actions.
@@ -328,10 +399,11 @@ export function incomingHeadPaths(repo: Repo, start: HeadMoveCursor, end: HeadMo
328
399
  const incomingCommand = !action.includes("(") && /^(?:merge|pull)(?: [^:]+)?$/.test(action) && !!detail;
329
400
  let incomingMove = !ownMove && (incomingStart || incomingCommand || action === "checkout" && /^moving from .+ to .+$/.test(detail));
330
401
  if (action === "reset" && /^moving to .+/.test(detail)) {
331
- // Policy: a positively fast-forward reset is incoming, even when its target contains the peer's own
332
- // descendant commit; reflog ancestry alone cannot distinguish that intent. Rewinds/unknown resets refuse.
333
- const ancestor = git(repo, ["merge-base", "--is-ancestor", row.old, row.next], {}, budget);
334
- if (ancestor.status !== 0) return undefined;
402
+ // Policy: a positively fast-forward reset stays incoming, even when its target contains the peer's own
403
+ // descendant commit made in another worktree during the turn. Reflog ancestry alone cannot distinguish
404
+ // local worktree coordination from external branch fast-forwards; classifying it as incoming safely
405
+ // excludes external commits from local turn attribution and undo. Rewinds/unknown resets refuse.
406
+ if (!isAncestor(row.old, row.next)) return undefined;
335
407
  incomingMove = true;
336
408
  }
337
409
  if (!ownMove && !incomingMove) return undefined;
@@ -339,7 +411,19 @@ export function incomingHeadPaths(repo: Repo, start: HeadMoveCursor, end: HeadMo
339
411
  if (/^0+$/.test(row.old)) {
340
412
  const root = git(repo, ["diff-tree", "--root", "--no-commit-id", "-r", "--no-renames", "--name-only", "-z", row.next, "--", scope(repo)], {}, budget);
341
413
  paths = root.status === 0 ? root.stdout.split("\0").filter(Boolean) : undefined;
342
- } else paths = knownChangedPaths(repo, row.old, row.next, budget);
414
+ } else if (incomingStart) {
415
+ // #612: take incoming start paths as merge-base(old, upstream)..upstream instead of old..upstream,
416
+ // so the peer's replayed own commits do not overlap and only true upstream paths are excluded.
417
+ // Any remaining ambiguity (no merge base, criss-cross multiple merge bases) stays unknown.
418
+ const mb = git(repo, ["merge-base", "--all", row.old, row.next], {}, budget);
419
+ if (mb.status !== 0) return undefined;
420
+ const bases = mb.stdout.trim().split("\n").map(s => s.trim()).filter(Boolean);
421
+ const base = bases[0];
422
+ if (bases.length !== 1 || !base || !/^[0-9a-f]{40,64}$/.test(base)) return undefined;
423
+ paths = base === row.next ? [] : diffOf(base, row.next);
424
+ } else {
425
+ paths = diffOf(row.old, row.next);
426
+ }
343
427
  if (!paths) return undefined;
344
428
  for (const path of paths) { checkSnapshotBudget(budget); (ownMove ? own : incoming).add(path); }
345
429
  previous = row.next;
@@ -394,6 +478,7 @@ export function snapshot(repo: Repo, budget?: SnapshotBudget): string | undefine
394
478
 
395
479
  /** Project paths (relative to the top level) that differ between two trees. */
396
480
  export function knownChangedPaths(repo: Repo, from: string, to: string, budget?: SnapshotBudget): string[] | undefined {
481
+ if (from === to) return [];
397
482
  const r = git(repo, ["diff-tree", "-r", "--no-renames", "--name-only", "-z", from, to, "--", scope(repo)], {}, budget);
398
483
  return r.status === 0 ? r.stdout.split("\0").filter(Boolean) : undefined;
399
484
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Strip parentheses and arguments from a plan symbol name.
3
+ * Section and source keys (`section ...`, `source ...`) are preserved unchanged.
4
+ */
5
+ export function normalizePlanSymbol(name: string): string {
6
+ if (typeof name !== "string") return "";
7
+ if (name.startsWith("section ") || name.startsWith("source ")) return name;
8
+ return name.replace(/\s*\(.*$/, "").trim();
9
+ }
10
+
11
+ /**
12
+ * Compare two plan symbols for overlap (#682 phase 1b).
13
+ * - Section and source keys must match exactly.
14
+ * - Otherwise, names are compared by their last segment; when both names are qualified
15
+ * (e.g. `A.run` vs `B.run`), the qualifiers must match as well.
16
+ */
17
+ export function symbolsOverlap(left: string, right: string): boolean {
18
+ if (typeof left !== "string" || typeof right !== "string") return false;
19
+ if (left.startsWith("section ") || left.startsWith("source ") || right.startsWith("section ") || right.startsWith("source ")) {
20
+ return left === right;
21
+ }
22
+ const a = normalizePlanSymbol(left);
23
+ const b = normalizePlanSymbol(right);
24
+ if (!a || !b) return false;
25
+ return a.includes(".") && b.includes(".") ? a === b : a.split(".").at(-1) === b.split(".").at(-1);
26
+ }
@@ -0,0 +1,90 @@
1
+ import type { ReviewBrief } from "./review-brief.ts";
2
+
3
+ export interface TaskCodeSymbol { id: string; path: string; name: string; kind: string }
4
+ export interface TaskCodeSymbols {
5
+ head: string; mergeBase: string; repositoryKey: string;
6
+ state: "known" | "unknown"; complete: boolean; countsFloor: true; symbols: TaskCodeSymbol[];
7
+ }
8
+ export const HOST_CODE_SYMBOLS = Symbol("host task code symbols");
9
+ const sha = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{40}$/.test(value);
10
+ const text = (value: unknown): value is string => typeof value === "string" && value.length > 0 && Buffer.byteLength(value) <= 512 && !/[\p{Cc}\p{Cf}]/u.test(value);
11
+ const path = (value: unknown): value is string => text(value) && !value.startsWith("/") && !value.includes("\\") && !value.split("/").some(part => !part || part === "." || part === ".." || part.toLowerCase() === ".git");
12
+ const object = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
13
+ const bounded = (value: unknown) => Buffer.byteLength(JSON.stringify(value)) <= 64 * 1024;
14
+
15
+ /** Stored observations are host-derived metadata, not code or caller-provided spans. */
16
+ export function parseTaskCodeSymbols(value: unknown): TaskCodeSymbols | undefined {
17
+ try {
18
+ if (!object(value) || !sha(value.head) || !sha(value.mergeBase) || !text(value.repositoryKey)
19
+ || (value.state !== "known" && value.state !== "unknown") || typeof value.complete !== "boolean" || value.countsFloor !== true
20
+ || !Array.isArray(value.symbols) || value.symbols.length > 500 || !bounded(value)) return;
21
+ const symbols: TaskCodeSymbol[] = [], ids = new Set<string>();
22
+ for (const item of value.symbols) {
23
+ if (!object(item) || !text(item.id) || !path(item.path) || !text(item.name) || !text(item.kind)
24
+ || !/^[a-z_]+$/.test(item.kind) || item.id !== `symbol:${item.name}@${item.path}` || ids.has(item.id)) return;
25
+ ids.add(item.id); symbols.push({ id: item.id, path: item.path, name: item.name, kind: item.kind });
26
+ }
27
+ if (value.state === "unknown" && (value.complete || symbols.length)) return;
28
+ return { head: value.head, mergeBase: value.mergeBase, repositoryKey: value.repositoryKey, state: value.state as "known" | "unknown", complete: value.complete, countsFloor: true, symbols };
29
+ } catch { return; }
30
+ }
31
+
32
+ /** Caller must certify that the earlier preparation did not filter or truncate any symbols. */
33
+ export function buildTaskCodeSymbols(brief: ReviewBrief | undefined, repositoryKey: string, screen: (text: string) => boolean, privateTask = false): TaskCodeSymbols | undefined {
34
+ if (privateTask || !brief || !sha(brief.head) || !sha(brief.mergeBase) || !text(repositoryKey)) return;
35
+ const unknown: TaskCodeSymbols = { head: brief.head, mergeBase: brief.mergeBase, repositoryKey, state: "unknown", complete: false, countsFloor: true, symbols: [] };
36
+ try {
37
+ if (brief.symbols?.state !== "known") return unknown;
38
+ if (!Array.isArray(brief.symbols.files) || brief.symbols.files.length > 500 || !Array.isArray(brief.reviewable) || brief.reviewable.length > 500) return unknown;
39
+ const reviewable = new Set(brief.reviewable.map(file => file.path)), files = new Set<string>();
40
+ if ([...reviewable].some(value => !path(value))) return unknown;
41
+ let complete = brief.symbols.complete === true && brief.withheld === 0 && brief.unlisted.length === 0;
42
+ const symbols: TaskCodeSymbol[] = [], ids = new Set<string>();
43
+ let observed = 0;
44
+ for (const file of brief.symbols.files) {
45
+ if (!path(file.path) || !reviewable.has(file.path) || files.has(file.path) || !Array.isArray(file.symbols)) return unknown;
46
+ files.add(file.path);
47
+ for (const symbol of file.symbols) {
48
+ if (++observed > 500) return unknown;
49
+ const id = `symbol:${symbol.name}@${file.path}`;
50
+ if (symbol.ambiguous || symbol.path !== file.path || !text(symbol.name) || !text(symbol.kind) || !/^[a-z_]+$/.test(symbol.kind)
51
+ || !text(id) || !screen(file.path) || !screen(symbol.name) || !screen(symbol.kind) || !screen(id)) { complete = false; continue; }
52
+ if (ids.has(id)) { complete = false; continue; }
53
+ ids.add(id); symbols.push({ id, path: file.path, name: symbol.name, kind: symbol.kind });
54
+ }
55
+ }
56
+ if (files.size !== reviewable.size) complete = false;
57
+ const result: TaskCodeSymbols = { ...unknown, state: "known", complete, symbols };
58
+ return bounded(result) ? result : unknown;
59
+ } catch { return unknown; }
60
+ }
61
+
62
+ /** Re-screen every public read; repository place keys are internal and never exported. */
63
+ export function publicTaskCodeSymbols(receipt: TaskCodeSymbols | undefined, head: string | undefined, repositoryKey: string, screen: (text: string) => boolean, privateTask = false): Omit<TaskCodeSymbols, "repositoryKey"> | undefined {
64
+ const valid = parseTaskCodeSymbols(receipt);
65
+ if (privateTask || !valid || !sha(head) || valid.head !== head || valid.repositoryKey !== repositoryKey) return;
66
+ let complete = valid.complete;
67
+ const symbols = valid.symbols.filter(symbol => {
68
+ try { if ([symbol.id, symbol.path, symbol.name, symbol.kind].every(value => screen(value))) return true; } catch { /* failed policy is withheld */ }
69
+ complete = false; return false;
70
+ });
71
+ const { repositoryKey: _repositoryKey, ...result } = valid;
72
+ return { ...result, complete, symbols };
73
+ }
74
+
75
+ /** Advisory observed disjointness only. Floors/deleted-symbol limitations never justify a gate or lost notice. */
76
+ export function taskCodeSymbolsDisjoint(left: TaskCodeSymbols | undefined, right: TaskCodeSymbols | undefined, leftHead: string | undefined, rightHead: string | undefined, repositoryKey: string, sharedPaths: readonly string[], privateData = false, screen?: (text: string) => boolean): boolean {
77
+ const current = (receipt: TaskCodeSymbols | undefined, head: string | undefined) => {
78
+ if (!screen) return parseTaskCodeSymbols(receipt);
79
+ const projected = publicTaskCodeSymbols(receipt, head, repositoryKey, screen, privateData);
80
+ return projected ? { ...projected, repositoryKey } : undefined;
81
+ };
82
+ const a = current(left, leftHead), b = current(right, rightHead);
83
+ if (privateData || !a || !b || !sha(leftHead) || !sha(rightHead) || a.head !== leftHead || b.head !== rightHead
84
+ || a.repositoryKey !== repositoryKey || b.repositoryKey !== repositoryKey || a.state !== "known" || b.state !== "known" || !a.complete || !b.complete
85
+ || !sharedPaths.length || sharedPaths.length > 500 || sharedPaths.some(value => !path(value))) return false;
86
+ return sharedPaths.every(file => {
87
+ const xs = a.symbols.filter(s => s.path === file), ys = b.symbols.filter(s => s.path === file);
88
+ return xs.length > 0 && ys.length > 0 && !xs.some(x => ys.some(y => x.id === y.id || (!x.name.includes(".") && y.name.endsWith(`.${x.name}`)) || (!y.name.includes(".") && x.name.endsWith(`.${y.name}`))));
89
+ });
90
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Task } from "./board.ts";
2
+ import { normalizePlanSymbol, symbolsOverlap } from "./symbol-normalizer.ts";
2
3
 
3
4
  export type SweepKind = "unaccepted-assignment" | "idle-owner" | "review-pending" | "ready-unassigned";
4
5
  export interface SweepRecord {
@@ -187,7 +188,7 @@ export function plannerGrooming(tasks: readonly Task[], options: PlannerGrooming
187
188
  if (!reviewed && !accepted) unknown.add(task.id);
188
189
  }
189
190
  }
190
- const places = (task: Task) => ({ paths: new Set([...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].map(groomingPath).filter((path): path is string => path !== undefined)), symbols: new Set(task.plan?.symbols ?? []) });
191
+ const places = (task: Task) => ({ paths: new Set([...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].map(groomingPath).filter((path): path is string => path !== undefined)), symbols: new Set((task.plan?.symbols ?? []).map(normalizePlanSymbol)) });
191
192
  const open = scoped.filter(task => task.state !== "cancelled");
192
193
  const positions = new Map(open.map(task => [task.id, places(task)]));
193
194
  const overlaps = (left: string, right: string): boolean => left === right || left === "." || right === "."
@@ -196,7 +197,7 @@ export function plannerGrooming(tasks: readonly Task[], options: PlannerGrooming
196
197
  const task = open[left]!;
197
198
  if (repositories.get(task.id) !== repositories.get(right.id)) continue;
198
199
  const a = positions.get(task.id)!, b = positions.get(right.id)!;
199
- if ([...a.paths].some(path => [...b.paths].some(other => overlaps(path, other))) || [...a.symbols].some(symbol => b.symbols.has(symbol))) add({ kind: "overlap", task: task.id, related: [right.id] });
200
+ 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] });
200
201
  if ([...issueIds.get(task.id)!].some(issue => issueIds.get(right.id)!.has(issue))) add({ kind: "duplicate", task: task.id, related: [right.id] });
201
202
  }
202
203
  for (const blocker of (options.blockers ?? []).slice(0, GROOMING_TASK_LIMIT)) {