@staix/agent-hub 0.12.45 → 0.14.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 +31 -0
- package/README.md +85 -116
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +10 -7
- package/docs/agent-notes/budget.md +7 -1
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +14 -2
- package/docs/agent-notes/local-worker.md +2 -1
- package/docs/agent-notes/tasks.md +16 -3
- package/docs/agent-notes/tests.md +5 -3
- package/docs/dogfood.md +95 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +290 -27
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +13 -3
- package/docs/sdlc.md +59 -8
- package/docs/security.md +26 -13
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +797 -8
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
- package/docs/verification/2026-10-22-code-facts.md +78 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +61 -0
- package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
- package/docs/verification/2026-10-23-0.13.0.md +89 -0
- package/docs/verification/2026-10-23-0.14.0.md +69 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +22 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +702 -347
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +20 -9
- package/src/adapters/codex-appserver.ts +18 -2
- package/src/adapters/codex-review-isolation.ts +29 -11
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/definitions/builtin-metadata.ts +3 -2
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
- package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
- package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
- package/src/adapters/definitions/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +2 -1
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/registry-types.ts +3 -1
- package/src/adapters/registry.ts +1 -0
- package/src/adapters/reviewer-print.ts +34 -7
- package/src/cli/agents.ts +40 -0
- package/src/cli/console-review.ts +54 -0
- package/src/cli/console-state.ts +124 -29
- package/src/cli/console-symbols.ts +74 -0
- package/src/cli/console-usage.ts +89 -0
- package/src/cli/console.ts +26 -1
- package/src/cli/help.ts +17 -6
- package/src/cli/identity.ts +29 -6
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +127 -11
- package/src/cli/output.ts +91 -3
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/cli/status-lines.ts +1 -1
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +65 -9
- package/src/hub/budget.ts +71 -2
- package/src/hub/bus.ts +43 -3
- package/src/hub/checks.ts +80 -31
- package/src/hub/code-facts.ts +223 -0
- package/src/hub/config-apply.ts +10 -1
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/daemon.ts +319 -34
- package/src/hub/dogfood-read.ts +199 -0
- package/src/hub/dogfood.ts +163 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +11 -2
- package/src/hub/hub-tools.ts +26 -3
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instances.ts +87 -5
- package/src/hub/manager.ts +2 -2
- package/src/hub/profiles.ts +7 -2
- package/src/hub/release-worktree.ts +56 -0
- package/src/hub/review-brief.ts +36 -0
- package/src/hub/sdlc-metrics.ts +260 -0
- package/src/hub/sdlc-queue.ts +31 -9
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc.ts +54 -14
- package/src/hub/settings.ts +5 -2
- package/src/hub/snapshots.ts +93 -8
- package/src/hub/spec-read.ts +31 -0
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-group.ts +52 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +455 -67
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/ui.ts +3 -0
- package/src/hub/usage-rollup.ts +419 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +274 -2
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/dev.json +6 -1
- package/templates/profiles/general.json +6 -1
- package/templates/profiles/research.json +6 -1
- package/templates/profiles/writing.json +6 -1
- package/templates/release-guard.block.md +6 -0
package/src/hub/snapshots.ts
CHANGED
|
@@ -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
|
|
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
|
|
332
|
-
// descendant commit
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
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
|
}
|
package/src/hub/spec-read.ts
CHANGED
|
@@ -48,6 +48,37 @@ export function specRefs(task: Pick<Task, "title" | "detail" | "refs"> & Partial
|
|
|
48
48
|
return [...issues];
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* #723: issue numbers a title names as a sibling repository (`agent-mem#13`) when the task's `refs.repo` is that repository.
|
|
53
|
+
* The bare name must equal the repository part of `refs.repo`; nothing else is guessed, and nothing is read. A bare
|
|
54
|
+
* `#N` and an `owner/repo#N` form are specRefs' business, never this function's.
|
|
55
|
+
*/
|
|
56
|
+
export function siblingTitleRefs(title: string, repo: string | undefined): number[] {
|
|
57
|
+
if (!repo || !isGitHubRepository(repo)) return [];
|
|
58
|
+
const name = repo.split("/")[1]!.toLowerCase(), found = new Set<number>();
|
|
59
|
+
for (const match of title.matchAll(/(?<![A-Za-z0-9_./-])([A-Za-z0-9_.-]+)#([1-9][0-9]*)\b/g)) {
|
|
60
|
+
if (match[1]!.toLowerCase() === name) found.add(Number(match[2]));
|
|
61
|
+
}
|
|
62
|
+
return [...found].slice(0, 4);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Words that precede `#N` without naming a repository (`PR#12`); never worth a refs.repo suggestion. */
|
|
66
|
+
const NOT_A_REPOSITORY = new Set(["pr", "pull", "issue", "issues", "task", "tasks", "bug", "fix", "phase", "step", "item", "ticket", "gh", "id", "no", "nr", "p", "s", "t", "u"]);
|
|
67
|
+
/**
|
|
68
|
+
* #723: the qualified repository names a title carries (`owner/repo#N` as `owner/repo`, `agent-mem#13` as `agent-mem`), so
|
|
69
|
+
* propose can suggest `refs.repo`. Advice only: a hit is a hint, never an identity.
|
|
70
|
+
*/
|
|
71
|
+
export function qualifiedTitleRepos(title: string): string[] {
|
|
72
|
+
const found = new Set<string>();
|
|
73
|
+
for (const match of title.matchAll(/(?<![A-Za-z0-9_./-])(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)|([A-Za-z0-9_.-]*[A-Za-z][A-Za-z0-9_.-]*))#[1-9][0-9]*\b/g)) {
|
|
74
|
+
const name = match[1] ?? match[2]!;
|
|
75
|
+
if (match[1] ? !isGitHubRepository(name) : NOT_A_REPOSITORY.has(name.toLowerCase())) continue;
|
|
76
|
+
found.add(name);
|
|
77
|
+
if (found.size >= 4) break;
|
|
78
|
+
}
|
|
79
|
+
return [...found];
|
|
80
|
+
}
|
|
81
|
+
|
|
51
82
|
interface GhResult { code: number; stdout: string; }
|
|
52
83
|
/** One fixed-argv authenticated boundary, no shell, no retries and no retained stderr. */
|
|
53
84
|
function gh(args: string[], opts: SpecOptions): Promise<GhResult> {
|
|
@@ -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,52 @@
|
|
|
1
|
+
import { symbolsOverlap } from "./symbol-normalizer.ts";
|
|
2
|
+
|
|
3
|
+
// #684 2c: the dashboard task sheet's "Code" group, classified on the hub so the page never matches names itself. Pure data in, ids and
|
|
4
|
+
// names out. The observation (`codeSymbols`) is the receipt `Tasks.publicView` already re-screened; the plan names are `plan.symbols`, the same
|
|
5
|
+
// list the review brief reads as `planned`. Advisory only: ripwire does not report deleted symbols, so neither list proves a plan was followed.
|
|
6
|
+
|
|
7
|
+
export interface CodeGroupSymbol { id: string; name: string; path: string; kind: string; mark: "matching" | "unplanned" }
|
|
8
|
+
export interface CodeGroup {
|
|
9
|
+
state: "known" | "unknown";
|
|
10
|
+
complete: boolean;
|
|
11
|
+
head?: string;
|
|
12
|
+
symbols: CodeGroupSymbol[];
|
|
13
|
+
/** Plan names nothing changed under. */
|
|
14
|
+
untouched: string[];
|
|
15
|
+
/** How many more changed symbols exist than are listed, so a cap is never read as the whole. */
|
|
16
|
+
more: number;
|
|
17
|
+
/** Totals before the cap, so the sheet's counts are the whole and not the listed part. */
|
|
18
|
+
counts: { matching: number; unplanned: number; untouched: number };
|
|
19
|
+
/** Plan names the hub would not show (screened out or malformed). While any is withheld a symbol marked unplanned may be planned, and one marked untouched may not be. */
|
|
20
|
+
planWithheld: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const CODE_GROUP_MAX = 40;
|
|
24
|
+
/** The stored plan's own bounds (`cleanPlan`: 300 characters a name, 50 names), so no stored name is dropped for its length alone. */
|
|
25
|
+
export const PLAN_NAME_MAX = 300, PLAN_NAMES_MAX = 50;
|
|
26
|
+
interface Receipt { state?: unknown; complete?: unknown; head?: unknown; symbols?: unknown }
|
|
27
|
+
const word = (value: unknown, max: number): value is string => typeof value === "string" && value.length > 0 && value.length <= max && !/[\p{Cc}\p{Cf}]/u.test(value);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `receipt` is the public `codeSymbols` (never the stored one, which carries `repositoryKey`); `plan` the task's `plan.symbols`; `screen`
|
|
31
|
+
* the hub's `publicMetadataText`. Returns undefined for a private task: the sheet then shows no Code group at all.
|
|
32
|
+
*/
|
|
33
|
+
export function codeGroup(task: { id: number }, receipt: unknown, plan: unknown, screen: (text: string) => boolean, privateTask: boolean): CodeGroup | undefined {
|
|
34
|
+
if (privateTask) return undefined;
|
|
35
|
+
const r = (receipt && typeof receipt === "object" && !Array.isArray(receipt) ? receipt : undefined) as Receipt | undefined;
|
|
36
|
+
if (!r || r.state !== "known" || !Array.isArray(r.symbols) || Object.hasOwn(r, "repositoryKey")) return { state: "unknown", complete: false, symbols: [], untouched: [], more: 0, counts: { matching: 0, unplanned: 0, untouched: 0 }, planWithheld: 0 };
|
|
37
|
+
// Every stored plan name is read; one that is malformed or fails the screen is counted, never silently dropped, because dropping it would
|
|
38
|
+
// turn a symbol the plan names into an "unplanned" one and the sheet would show a departure that never happened.
|
|
39
|
+
const given = Array.isArray(plan) ? plan.slice(0, PLAN_NAMES_MAX + 1) : [];
|
|
40
|
+
const planned = given.slice(0, PLAN_NAMES_MAX).filter((name): name is string => word(name, PLAN_NAME_MAX)).filter(name => { try { return screen(name); } catch { return false; } });
|
|
41
|
+
const planWithheld = (given.length > PLAN_NAMES_MAX ? given.length - PLAN_NAMES_MAX : 0) + (Math.min(given.length, PLAN_NAMES_MAX) - planned.length);
|
|
42
|
+
const changed: CodeGroupSymbol[] = [];
|
|
43
|
+
for (const item of r.symbols as Record<string, unknown>[]) {
|
|
44
|
+
if (!item || !word(item.id, 512) || !word(item.name, 512) || !word(item.path, 512) || !word(item.kind, 64) || item.id !== `symbol:${item.name}@${item.path}`) continue;
|
|
45
|
+
changed.push({ id: item.id, name: item.name, path: item.path, kind: item.kind, mark: planned.some(name => symbolsOverlap(name, item.name as string)) ? "matching" : "unplanned" });
|
|
46
|
+
}
|
|
47
|
+
const untouched = planned.filter(name => !changed.some(symbol => symbolsOverlap(name, symbol.name)));
|
|
48
|
+
const shown = changed.slice(0, CODE_GROUP_MAX);
|
|
49
|
+
return { state: "known", complete: r.complete === true && changed.length === r.symbols.length && planWithheld === 0, ...(typeof r.head === "string" && /^[a-f0-9]{40}$/.test(r.head) ? { head: r.head.slice(0, 8) } : {}),
|
|
50
|
+
symbols: shown, untouched: untouched.slice(0, CODE_GROUP_MAX), more: Math.max(0, changed.length - shown.length) + Math.max(0, untouched.length - CODE_GROUP_MAX),
|
|
51
|
+
counts: { matching: changed.filter(s => s.mark === "matching").length, unplanned: changed.filter(s => s.mark === "unplanned").length, untouched: untouched.length }, planWithheld };
|
|
52
|
+
}
|
|
@@ -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
|
+
}
|
package/src/hub/task-sweep.ts
CHANGED
|
@@ -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.
|
|
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)) {
|