@staix/agent-hub 0.12.44 → 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.
- package/CHANGELOG.md +40 -0
- package/README.md +83 -116
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +102 -14
- package/docs/agent-notes/README.md +15 -0
- package/docs/agent-notes/adapters.md +63 -46
- package/docs/agent-notes/budget.md +7 -2
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +15 -8
- package/docs/agent-notes/local-worker.md +6 -4
- package/docs/agent-notes/tasks.md +18 -5
- package/docs/agent-notes/tests.md +11 -9
- package/docs/dogfood.md +92 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +349 -26
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +12 -2
- package/docs/sdlc.md +61 -13
- package/docs/security.md +24 -12
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
- package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
- package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
- package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
- package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
- package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
- package/docs/verification/2026-10-22-0.12.45.md +60 -0
- package/docs/verification/2026-10-22-code-facts.md +78 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -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/assets/469-r1-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-board-768-light.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-board-390-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
- package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
- package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +19 -16
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10120 -9623
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +3 -1
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +32 -12
- package/src/adapters/codex-appserver.ts +19 -2
- package/src/adapters/codex-review-isolation.ts +12 -5
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/conformance/acp.ts +114 -0
- package/src/adapters/conformance/codex.ts +191 -0
- package/src/adapters/conformance/enrollment.ts +43 -0
- package/src/adapters/conformance/factories.ts +17 -0
- package/src/adapters/conformance/fake-paths.ts +12 -0
- package/src/adapters/conformance/fakes/acp-server.ts +267 -0
- package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
- package/src/adapters/conformance/fakes/app-server.ts +119 -0
- package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
- package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
- package/src/adapters/conformance/fakes/model-server.ts +39 -0
- package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
- package/src/adapters/conformance/index.ts +11 -0
- package/src/adapters/conformance/kit.ts +50 -0
- package/src/adapters/conformance/local-pi.ts +100 -0
- package/src/adapters/conformance/owned-acp.ts +44 -0
- package/src/adapters/definitions/builtin-metadata.ts +25 -7
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
- package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- 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/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +5 -1
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-module-scan.ts +126 -0
- package/src/adapters/external-modules.ts +240 -0
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/pi.ts +2 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +13 -1
- package/src/adapters/registry.ts +52 -15
- 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 +36 -21
- package/src/cli/identity.ts +30 -7
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +145 -16
- package/src/cli/output.ts +44 -1
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +4 -1
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +72 -11
- 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 +28 -11
- package/src/hub/config-reload.ts +8 -5
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +332 -61
- 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/facts.ts +76 -13
- package/src/hub/hub-tools.ts +45 -7
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instance-config.ts +17 -5
- package/src/hub/instance-execution.ts +20 -8
- package/src/hub/instances.ts +75 -2
- package/src/hub/peers.ts +9 -0
- package/src/hub/plan-conformance.ts +83 -0
- 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/review-findings.ts +104 -0
- package/src/hub/roster.ts +31 -5
- package/src/hub/sdlc-queue.ts +48 -11
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +71 -18
- package/src/hub/settings.ts +10 -3
- package/src/hub/snapshots.ts +101 -14
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +556 -88
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/ui.ts +2 -2
- package/src/hub/usage-rollup.ts +361 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +560 -78
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
- 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/sdlc.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { parseIssueLabels, parseRedFirst, testIntegrityBlockers, type RedFirstReceipt, type TestIntegrity } from "./test-integrity.ts";
|
|
2
|
+
import { planConformanceBlockers, type PlanConformance } from "./plan-conformance.ts";
|
|
3
|
+
import { DEFAULT_MAX_NITS, findingsForHead, findingHead, findingTally, type ReviewFindingSet, type ReviewFindingTally } from "./review-findings.ts";
|
|
1
4
|
import type { CostPlan } from "./cost-governance.ts";
|
|
2
5
|
import { criteriaReceipt, type CriteriaReceipt, type CriteriaView, type LegacyPhaseReceipt } from "./workflow.ts";
|
|
3
6
|
import { constants, openSync, fstatSync, readSync, closeSync } from "node:fs";
|
|
@@ -29,7 +32,7 @@ export type StageGate = "issue" | "pr" | "local" | "review" | "checks" | "criter
|
|
|
29
32
|
export interface StageDefinition { base: SdlcBaseStage; actor: string; entry_gates: StageGate[]; exit_gates: StageGate[]; after: string[]; }
|
|
30
33
|
export type SdlcMode = "off" | "advise" | "enforce";
|
|
31
34
|
/** `workflow: "gsd-lite"` marks the retired setting during its window (#481 S8): every task then needs a criteria checklist. */
|
|
32
|
-
export interface SdlcConfig { workflow?: "gsd-lite"; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
|
|
35
|
+
export interface SdlcConfig { workflow?: "gsd-lite"; maxNits?: number; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
|
|
33
36
|
export interface PlanReviewReceipt {
|
|
34
37
|
planner: string;
|
|
35
38
|
planningRevision: number;
|
|
@@ -53,6 +56,7 @@ export interface SdlcReceipt {
|
|
|
53
56
|
localHead?: string;
|
|
54
57
|
localResult?: "passed" | "failed" | "unknown";
|
|
55
58
|
observed?: number;
|
|
59
|
+
redFirst?: RedFirstReceipt;
|
|
56
60
|
}
|
|
57
61
|
export interface PrEvidence {
|
|
58
62
|
head: string;
|
|
@@ -62,7 +66,11 @@ export interface PrEvidence {
|
|
|
62
66
|
unresolved: number | null;
|
|
63
67
|
pendingReviews: number | null;
|
|
64
68
|
paths: string[];
|
|
69
|
+
fileChanges?: { path: string; change: string }[];
|
|
70
|
+
issueLabels?: { issue: number; labels: string[] | null };
|
|
65
71
|
pathsComplete?: boolean;
|
|
72
|
+
/** All affected path identities are represented; rename/copy destinations alone cannot certify S2. */
|
|
73
|
+
typesComplete?: boolean;
|
|
66
74
|
draft?: boolean;
|
|
67
75
|
base?: string;
|
|
68
76
|
baseHead?: string;
|
|
@@ -125,7 +133,10 @@ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
|
|
|
125
133
|
if (value === undefined) return { mode: "off", requiredChecks: [], stages: [...SDLC_STAGES] };
|
|
126
134
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc: expected a table");
|
|
127
135
|
const raw = value as Record<string, unknown>;
|
|
128
|
-
for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
|
|
136
|
+
for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions", "max_nits"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
|
|
137
|
+
const maxNits = raw.max_nits === undefined ? DEFAULT_MAX_NITS : raw.max_nits;
|
|
138
|
+
if (typeof maxNits !== "number" || !Number.isSafeInteger(maxNits) || maxNits < 0 || maxNits > 64) throw new Error("sdlc.max_nits: expected an integer from 0 to 64");
|
|
139
|
+
const findingConfig = raw.max_nits === undefined ? {} : { maxNits };
|
|
129
140
|
const mode = raw.mode ?? "off";
|
|
130
141
|
if (mode !== "off" && mode !== "advise" && mode !== "enforce") throw new Error("sdlc.mode: expected off, advise or enforce");
|
|
131
142
|
// ponytail: the #481 S8 window is a fixed version; delete `workflow`, the phase aliases and their tests once it passes.
|
|
@@ -140,7 +151,7 @@ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
|
|
|
140
151
|
if (raw.definitions !== undefined) {
|
|
141
152
|
const custom = definitions(raw.definitions, raw.stages, phaseAlias);
|
|
142
153
|
if (phaseAlias.used) notes.push(`the sdlc phase gate is deprecated and stops loading in ${RETIRED_WORKFLOW_FROM} (#481): name it criteria`);
|
|
143
|
-
return { mode, requiredChecks: [...checks] as string[], ...custom, ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
|
|
154
|
+
return { mode, ...findingConfig, requiredChecks: [...checks] as string[], ...custom, ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
|
|
144
155
|
}
|
|
145
156
|
// Off keeps the #257 seven stages byte for byte; an enabled lifecycle defaults to the playbook (#481 S0).
|
|
146
157
|
const given = raw.stages ?? (mode === "off" ? [...SDLC_STAGES] : [...PLAYBOOK_STAGES]);
|
|
@@ -148,7 +159,7 @@ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
|
|
|
148
159
|
// An enabled #257 list keeps its order under canonical ids, so implement/verify stay read-only aliases there too.
|
|
149
160
|
const stages = mode !== "off" && exactly(SDLC_STAGES) ? SDLC_STAGES.map(canonicalStage) : given;
|
|
150
161
|
if (!exactly(SDLC_STAGES) && !exactly(PLAYBOOK_STAGES)) throw new Error(`sdlc.stages: use ${PLAYBOOK_STAGES.join(", ")} in that order (or the #257 list ${SDLC_STAGES.join(", ")}); other lists need definitions`);
|
|
151
|
-
return { mode, requiredChecks: [...checks] as string[], stages: [...stages as string[]], ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
|
|
162
|
+
return { mode, ...findingConfig, requiredChecks: [...checks] as string[], stages: [...stages as string[]], ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
|
|
152
163
|
}
|
|
153
164
|
|
|
154
165
|
export function loadSdlc(cwd: string, version?: string): SdlcConfig {
|
|
@@ -171,7 +182,7 @@ export function loadSdlc(cwd: string, version?: string): SdlcConfig {
|
|
|
171
182
|
export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined = undefined, config: SdlcConfig = sdlcConfig(undefined), version?: string): SdlcReceipt {
|
|
172
183
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc receipt: expected an object");
|
|
173
184
|
const raw = value as Record<string, unknown>;
|
|
174
|
-
for (const key of Object.keys(raw)) if (!["stage", "repo", "issue", "pr", "reviewedHead", "localHead", "localResult", "criteria", "phase"].includes(key)) throw new Error(`sdlc receipt.${key}: unsupported or externally observed field`);
|
|
185
|
+
for (const key of Object.keys(raw)) if (!["stage", "repo", "issue", "pr", "reviewedHead", "localHead", "localResult", "criteria", "phase", ...(config.mode !== "off" ? ["redFirst"] : [])].includes(key)) throw new Error(`sdlc receipt.${key}: unsupported or externally observed field`);
|
|
175
186
|
if (raw.criteria !== undefined && raw.phase !== undefined) throw new Error("sdlc receipt: give criteria only; phase is its retired name");
|
|
176
187
|
if (raw.phase !== undefined && version !== undefined && !olderThan(version, RETIRED_WORKFLOW_FROM)) throw new Error(`sdlc receipt.phase: retired in ${RETIRED_WORKFLOW_FROM} (#481); use criteria`);
|
|
177
188
|
previous ??= { stage: config.stages[0] ?? "spec" };
|
|
@@ -192,6 +203,9 @@ export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined =
|
|
|
192
203
|
if (!["passed", "failed", "unknown"].includes(String(raw.localResult))) throw new Error("sdlc receipt.localResult: expected passed, failed or unknown");
|
|
193
204
|
next.localResult = raw.localResult as SdlcReceipt["localResult"];
|
|
194
205
|
}
|
|
206
|
+
if (raw.redFirst !== undefined) next.redFirst = parseRedFirst(raw.redFirst);
|
|
207
|
+
// A test receipt never carries across a different linked issue or artifact.
|
|
208
|
+
if (raw.redFirst === undefined && (next.repo !== previous.repo || next.issue !== previous.issue || next.pr !== previous.pr)) delete next.redFirst;
|
|
195
209
|
// An acceptance names one spec issue; relinking the receipt to another drops it (#481 S1).
|
|
196
210
|
if (next.repo !== previous.repo || next.issue !== previous.issue) delete next.acceptance;
|
|
197
211
|
// A new artifact cannot inherit another PR's review or local gate.
|
|
@@ -216,7 +230,9 @@ export function parsePrEvidence(value: unknown): PrEvidence {
|
|
|
216
230
|
return { name, state } as PrEvidence["checks"][number];
|
|
217
231
|
});
|
|
218
232
|
const paths = p.files.map((f: Record<string, unknown>) => { if (typeof f.path !== "string" || !f.path || f.path.length > 1000) throw new Error("PR readback: invalid file identity"); return f.path; });
|
|
219
|
-
|
|
233
|
+
const completeTypes = p.files.every((file: Record<string, unknown>) => typeof file.changeType === "string" && ["ADDED", "DELETED", "MODIFIED", "CHANGED"].includes(file.changeType));
|
|
234
|
+
// Rename/copy metadata exposes only its destination, never a complete inventory of affected identities.
|
|
235
|
+
return { head: p.headRefOid, state: p.state, checks, paths, fileChanges: p.files.map((file: Record<string, unknown>) => ({ path: file.path as string, change: typeof file.changeType === "string" ? file.changeType : "UNKNOWN" })), pathsComplete: Number.isSafeInteger(p.changedFiles) && p.changedFiles >= 0 && p.changedFiles === paths.length, typesComplete: completeTypes, ...(typeof p.isDraft === "boolean" ? { draft: p.isDraft } : {}), ...(typeof p.baseRefName === "string" && p.baseRefName && p.baseRefName.length <= 200 && !/[\x00-\x1f\x7f]/.test(p.baseRefName) ? { base: p.baseRefName } : {}), ...(sha(p.baseRefOid) ? { baseHead: p.baseRefOid } : {}),
|
|
220
236
|
review: p.reviewDecision === "APPROVED" ? "approved" : p.reviewDecision === "CHANGES_REQUESTED" ? "changes_requested" : "unknown",
|
|
221
237
|
pendingReviews: Array.isArray(p.reviewRequests) && p.reviewRequests.length <= 100 && p.reviewRequests.every((r: unknown) => !!r && typeof r === "object") ? p.reviewRequests.length : null,
|
|
222
238
|
unresolved: Number.isSafeInteger(p.unresolvedThreads) && p.unresolvedThreads >= 0 ? p.unresolvedThreads : null,
|
|
@@ -239,12 +255,16 @@ export const SPEC_STATE_TEXT: Record<Exclude<SpecState, "current">, string> = {
|
|
|
239
255
|
changed: "spec changed after acceptance: the person re-accepts with ahub sdlc accept <task> --issue <n>",
|
|
240
256
|
unknown: "spec edit time unknown: the issue could not be read",
|
|
241
257
|
};
|
|
242
|
-
export interface SdlcStatus { spec?: { issue: number; state: SpecState }; playbook?: string; criteria?: CriteriaView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
|
|
258
|
+
export interface SdlcStatus { testIntegrity?: TestIntegrity; planConformance?: PlanConformance & { accepted?: boolean; earlierPushes?: "unknown"; revisionsAfterFirstObservedPush?: number }; findings?: ReviewFindingTally; spec?: { issue: number; state: SpecState }; playbook?: string; criteria?: CriteriaView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
|
|
243
259
|
/** The acceptance-criteria checklist (#481 S8, Compliance pass) counts once recorded, and on every task under the retired gsd-lite setting. Off keeps #257's output. */
|
|
244
260
|
export const criteriaRequired = (config: SdlcConfig, receipt: SdlcReceipt | undefined): boolean => config.workflow === "gsd-lite" || (config.mode !== "off" && !!checklistOf(receipt));
|
|
245
|
-
export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView, specEdit?: SpecEdit): SdlcStatus {
|
|
261
|
+
export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView, specEdit?: SpecEdit, sets: readonly ReviewFindingSet[] = [], commit?: string, plan?: SdlcStatus["planConformance"], integrity?: TestIntegrity): SdlcStatus {
|
|
246
262
|
const stage = receipt?.stage ?? config.stages[0] ?? "spec", blockers: string[] = [];
|
|
247
263
|
const definition = stageDefinition(config, stage);
|
|
264
|
+
const items = findingsForHead(sets, findingHead(receipt, evidence, commit));
|
|
265
|
+
const findings = items.length ? findingTally(items) : undefined;
|
|
266
|
+
if (config.mode === "enforce" && findings?.openImportant) blockers.push("open important review findings: " + findings.openImportant);
|
|
267
|
+
if (config.mode === "enforce") blockers.push(...planConformanceBlockers(plan), ...testIntegrityBlockers(integrity));
|
|
248
268
|
if (!definition) blockers.push("lifecycle stage definition unknown");
|
|
249
269
|
if (!receipt?.repo || !receipt.issue) blockers.push("issue identity missing");
|
|
250
270
|
if (!receipt?.pr) blockers.push("PR identity missing");
|
|
@@ -270,9 +290,10 @@ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined,
|
|
|
270
290
|
const shown = criteria && (criteriaRequired(config, receipt) || definition && [...definition.entry_gates, ...definition.exit_gates].includes("criteria"));
|
|
271
291
|
// Shown, not counted as a readiness blocker: the spec gate is build entry (#481 S1).
|
|
272
292
|
const spec = config.mode !== "off" && receipt?.issue ? { spec: { issue: receipt.issue, state: specState(receipt, specEdit) } } : {};
|
|
273
|
-
return { ...spec, ...(config.mode !== "off" && definition && playbookStage(definition.base) ? { playbook: playbookStage(definition.base) } : {}), ...(shown ? { criteria } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
|
|
293
|
+
return { ...spec, ...(config.mode !== "off" && integrity ? { testIntegrity: integrity } : {}), ...(config.mode !== "off" && plan ? { planConformance: plan } : {}), ...(findings ? { findings } : {}), ...(config.mode !== "off" && definition && playbookStage(definition.base) ? { playbook: playbookStage(definition.base) } : {}), ...(shown ? { criteria } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
|
|
274
294
|
actor: !definition ? "person" : shown && criteria.blockers.length ? "reviewer/person" : config.definitions ? definition.actor : baseActor(definition.base),
|
|
275
|
-
|
|
295
|
+
// #694: the identity blocker keeps its exact text for gate matching; the next step says how to supply it.
|
|
296
|
+
next: blockers[0] === "issue identity missing" ? "issue identity missing: name one issue (title #N, refs or hub_sdlc_record)" : blockers[0] ?? "person may inspect merge readiness; no automatic merge" };
|
|
276
297
|
}
|
|
277
298
|
|
|
278
299
|
function stageGates(def: StageDefinition, blockers: string[], checklist: string[]): { entry: string[]; exit: string[] } {
|
|
@@ -324,23 +345,53 @@ export async function readSpecEdit(repoName: string, issue: number, run: SdlcGh
|
|
|
324
345
|
} catch { return undefined; }
|
|
325
346
|
}
|
|
326
347
|
|
|
327
|
-
|
|
348
|
+
/**
|
|
349
|
+
* #694: the one open PR whose head is `commit`, or, with no commit, whose branch is `branch`. Zero or several matches, a
|
|
350
|
+
* failed read or malformed output give undefined: the hub links only what it verified, never text an agent wrote.
|
|
351
|
+
*/
|
|
352
|
+
export async function openPrFor(repository: string, head: { commit?: string; branch?: string }, run: SdlcGh = sdlcGh): Promise<number | undefined> {
|
|
353
|
+
if (!repo(repository) || head.commit !== undefined && !sha(head.commit) || !head.commit && !head.branch) return undefined;
|
|
354
|
+
try {
|
|
355
|
+
const listed = await run(["pr", "list", "--repo", repository, "--state", "open", "--limit", "100", "--json", "number,headRefOid,headRefName"], 10_000, undefined, 256 * 1024);
|
|
356
|
+
if (listed.code !== 0) return undefined;
|
|
357
|
+
const rows: unknown = JSON.parse(listed.stdout);
|
|
358
|
+
// A full page may be truncated: uniqueness is unknown, so nothing is linked. Every row must be well formed.
|
|
359
|
+
if (!Array.isArray(rows) || rows.length >= 100) return undefined;
|
|
360
|
+
if (!rows.every((row: { number?: unknown; headRefOid?: unknown; headRefName?: unknown }) => id(row?.number) && sha(row.headRefOid) && typeof row.headRefName === "string")) return undefined;
|
|
361
|
+
const hits = rows.filter((row: { headRefOid: string; headRefName: string }) => head.commit ? row.headRefOid === head.commit : row.headRefName === head.branch);
|
|
362
|
+
return hits.length === 1 ? (hits[0] as { number: number }).number : undefined;
|
|
363
|
+
} catch { return undefined; }
|
|
364
|
+
}
|
|
365
|
+
/** #694: the first link of an issue or PR is not a relink: it keeps everything the receipt already holds. */
|
|
366
|
+
export function linkedReceipt(previous: SdlcReceipt | undefined, link: { repo: string; issue?: number; pr?: number }, config: SdlcConfig): SdlcReceipt | undefined {
|
|
367
|
+
if (!repo(link.repo) || link.issue !== undefined && !id(link.issue) || link.pr !== undefined && !id(link.pr)) return undefined;
|
|
368
|
+
if (previous?.repo && previous.repo.toLowerCase() !== link.repo.toLowerCase()) return undefined;
|
|
369
|
+
if (link.issue !== undefined && previous?.issue !== undefined || link.pr !== undefined && previous?.pr !== undefined) return undefined;
|
|
370
|
+
return { ...(previous ?? { stage: config.stages[0] ?? "spec" }), repo: previous?.repo ?? link.repo, ...(link.issue !== undefined ? { issue: link.issue } : {}), ...(link.pr !== undefined ? { pr: link.pr } : {}) };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export async function readSdlcPr(receipt: SdlcReceipt, run: SdlcGh = sdlcGh, withIntegrity = false): Promise<PrEvidence | undefined> {
|
|
328
374
|
if (!repo(receipt.repo) || !id(receipt.pr)) return undefined;
|
|
329
375
|
try {
|
|
330
376
|
const view = await run(["pr", "view", String(receipt.pr), "--repo", receipt.repo, "--json", "headRefOid,state,statusCheckRollup,reviewDecision,reviewRequests,files,changedFiles,mergeCommit,isDraft,baseRefName,baseRefOid"]);
|
|
331
377
|
if (view.code !== 0) return undefined;
|
|
332
378
|
const [owner, name] = receipt.repo.split("/");
|
|
333
|
-
|
|
379
|
+
// The enabled S4 label read shares the existing thread snapshot, never a second readback for a known non-bug.
|
|
380
|
+
const labels = withIntegrity && id(receipt.issue) ? { issue: receipt.issue, labels: null as string[] | null } : undefined;
|
|
381
|
+
const query = `query($owner:String!,$name:String!,$number:Int!${labels ? ",$issue:Int!" : ""}){repository(owner:$owner,name:$name){pullRequest(number:$number){headRefOid reviewThreads(first:100){nodes{isResolved} pageInfo{hasNextPage}}}${labels ? " issue(number:$issue){number labels(first:100){nodes{name} pageInfo{hasNextPage}}}" : ""}}}`;
|
|
382
|
+
const threads = await run(["api", "graphql", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`, ...(labels ? ["-F", `issue=${labels.issue}`] : []), "-F", `number=${receipt.pr}`]);
|
|
334
383
|
const p = JSON.parse(view.stdout);
|
|
335
384
|
delete p.unresolvedThreads; // only the complete thread query can establish this fact
|
|
336
385
|
if (threads.code === 0) {
|
|
337
|
-
const
|
|
386
|
+
const response = JSON.parse(threads.stdout);
|
|
387
|
+
const current = response?.data?.repository?.pullRequest;
|
|
388
|
+
if (labels && response.errors === undefined) labels.labels = parseIssueLabels(response?.data?.repository?.issue, labels.issue) ?? null;
|
|
338
389
|
const nodes = current?.reviewThreads?.nodes;
|
|
339
390
|
// Paginated or malformed thread data is unknown, never a clean review.
|
|
340
391
|
if (current?.headRefOid === p.headRefOid && current?.reviewThreads?.pageInfo?.hasNextPage === false && Array.isArray(nodes) && nodes.every(n => typeof n?.isResolved === "boolean")) p.unresolvedThreads = nodes.filter(n => !n.isResolved).length;
|
|
341
392
|
else if (current?.headRefOid && current.headRefOid !== p.headRefOid) return undefined;
|
|
342
393
|
}
|
|
343
|
-
return parsePrEvidence(p);
|
|
394
|
+
return { ...parsePrEvidence(p), ...(labels ? { issueLabels: labels } : {}) };
|
|
344
395
|
} catch { return undefined; }
|
|
345
396
|
}
|
|
346
397
|
|
|
@@ -434,13 +485,15 @@ export async function readReviewReadiness(receipt: SdlcReceipt, expected: string
|
|
|
434
485
|
if (thread?.isResolved !== false) { blockers.push("bot review thread evidence unavailable"); continue; }
|
|
435
486
|
const comments = thread?.comments;
|
|
436
487
|
if (comments?.pageInfo?.hasNextPage !== false || !Array.isArray(comments.nodes) || comments.nodes.length > 100
|
|
437
|
-
|| comments.nodes.some((comment: {
|
|
488
|
+
|| comments.nodes.some((comment: { createdAt?: string }) => !Number.isFinite(Date.parse(comment?.createdAt ?? "")))) {
|
|
438
489
|
blockers.push("bot review thread evidence unavailable"); continue;
|
|
439
490
|
}
|
|
491
|
+
// #230 item 3: only a `User` comment answers. Every other or missing author kind (Bot, null, Organization, Mannequin,
|
|
492
|
+
// EnterpriseUserAccount, a kind GitHub adds later) is a pending bot comment, so an unresolved thread settles by
|
|
493
|
+
// resolution or a later User comment. No weaker than before, and still no body or login is read.
|
|
440
494
|
let unanswered = false;
|
|
441
|
-
for (const comment of comments.nodes) {
|
|
442
|
-
|
|
443
|
-
else if (comment.author.__typename === "User") unanswered = false;
|
|
495
|
+
for (const comment of comments.nodes as { author?: { __typename?: string | null } | null }[]) {
|
|
496
|
+
unanswered = comment?.author?.__typename !== "User";
|
|
444
497
|
}
|
|
445
498
|
if (unanswered) blockers.push("bot review threads unanswered");
|
|
446
499
|
}
|
package/src/hub/settings.ts
CHANGED
|
@@ -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"] })),
|
|
@@ -75,7 +78,7 @@ export const SETTINGS: readonly SettingDef[] = [
|
|
|
75
78
|
* Never editable from settings, whatever the session: these choose what the hub executes or where secrets and task
|
|
76
79
|
* text go. A key equal to an entry, or below it, is refused by name.
|
|
77
80
|
*/
|
|
78
|
-
export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal", "instances", "adapters"] as const;
|
|
81
|
+
export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal", "instances", "adapters", "adapter_modules"] as const;
|
|
79
82
|
|
|
80
83
|
/** Derive project-bound rows from the same builtin templates used by lookup, without registering aliases globally. */
|
|
81
84
|
export function projectSettings(instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): readonly SettingDef[] {
|
|
@@ -384,6 +387,10 @@ export function undoSetting(cwd: string, stateDir: string, validate: (scratch: s
|
|
|
384
387
|
export function storedLocally(cwd: string, def: SettingDef): boolean {
|
|
385
388
|
try { return valueAt(JSON.parse(readFileSync(join(cwd, ".agenthub", CONFIG_LOCAL), "utf8")), def.path) !== undefined; } catch { return false; }
|
|
386
389
|
}
|
|
390
|
+
/** What the shared `.agenthub/config.json` holds for the key, or undefined: a removal here only ever touches the machine-local file. */
|
|
391
|
+
export function sharedValue(cwd: string, def: SettingDef): unknown {
|
|
392
|
+
try { return valueAt(JSON.parse(readFileSync(join(cwd, ".agenthub", "config.json"), "utf8")), def.path); } catch { return undefined; }
|
|
393
|
+
}
|
|
387
394
|
/**
|
|
388
395
|
* For a config setting: the value the config files would hold for it after the pending undo (the previous version of
|
|
389
396
|
* the machine-local file, else the shared file), or, with `inherit`, after the machine-local value is removed (the
|
|
@@ -466,7 +473,7 @@ export function settingRows(input: {
|
|
|
466
473
|
const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
|
|
467
474
|
return projectSettings(input.instances, input.adapterCatalog).map((def): SettingRow => {
|
|
468
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 }),
|
|
469
|
-
...(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) } : {}) };
|
|
470
477
|
if (def.locked) return { ...base, values: def.values, file: def.store === "runtime" ? "this hub run" : CONFIG_LOCAL,
|
|
471
478
|
source: "instance declaration", value: "read-only", fileValue: "read-only", locked: def.locked, note: def.locked };
|
|
472
479
|
if (def.store === "runtime") {
|
|
@@ -481,7 +488,7 @@ export function settingRows(input: {
|
|
|
481
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." }; }
|
|
482
489
|
const value = scalar(input.running(def.path));
|
|
483
490
|
const stored = input.next ? scalar(input.next(def.path)) : value;
|
|
484
|
-
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";
|
|
485
492
|
const dropped = ignoredLine(input.ignored, def);
|
|
486
493
|
return { ...base, ...(def.values ? { values: def.values } : {}), file: CONFIG_LOCAL, value, fileValue: stored, source, ...(same(value, stored) ? {} : { pending: stored }),
|
|
487
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}` } : {}) };
|
package/src/hub/snapshots.ts
CHANGED
|
@@ -20,19 +20,22 @@ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
|
20
20
|
* ponytail: the trees are unreferenced, so `git gc` may prune them after gc.pruneExpire (two weeks by default); undo
|
|
21
21
|
* of an older turn says so. Refs under refs/agenthub/ would keep them, at the cost of showing up in the user's repo.
|
|
22
22
|
*/
|
|
23
|
-
|
|
23
|
+
/** `now` is a host-only clock (#661): a test holds it fixed so the budget never runs out whatever the runner's speed. Production leaves it unset (performance.now). */
|
|
24
|
+
export interface SnapshotBudget { deadline: number; now?: () => number; }
|
|
25
|
+
export const budgetNow = (budget: SnapshotBudget): number => budget.now ? budget.now() : performance.now();
|
|
24
26
|
export class SnapshotBudgetExceeded extends Error {
|
|
25
27
|
constructor() { super("snapshot burst budget exhausted; boundary evidence is unknown"); }
|
|
26
28
|
}
|
|
27
|
-
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) => {
|
|
28
30
|
const argv = repoGitArgs(top, args);
|
|
29
|
-
const remaining = budget ? budget.deadline -
|
|
31
|
+
const remaining = budget ? budget.deadline - budgetNow(budget) : SNAPSHOT_TIMEOUT_MS;
|
|
30
32
|
if (remaining <= 0) throw new SnapshotBudgetExceeded();
|
|
31
33
|
const result = hubGitSync(argv, {
|
|
32
34
|
encoding: "utf8", env: { ...repoGitEnv(top), ...env }, ...(maxBuffer === undefined ? {} : { maxBuffer }),
|
|
35
|
+
...(input === undefined ? {} : { input }),
|
|
33
36
|
timeout: Math.max(1, Math.min(SNAPSHOT_TIMEOUT_MS, Math.floor(remaining))),
|
|
34
37
|
});
|
|
35
|
-
if (budget && (
|
|
38
|
+
if (budget && (budgetNow(budget) >= budget.deadline || (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT")) throw new SnapshotBudgetExceeded();
|
|
36
39
|
return result;
|
|
37
40
|
};
|
|
38
41
|
const SNAPSHOT_TIMEOUT_MS = 10_000;
|
|
@@ -146,7 +149,7 @@ export function branchHeads(repo: Repo, budget?: SnapshotBudget): string | undef
|
|
|
146
149
|
const result = git(repo, ["for-each-ref", "--sort=refname", "--format=%(refname) %(objectname)", "refs/heads/", "refs/remotes/"], {}, budget, 1024 * 1024);
|
|
147
150
|
if (result.status !== 0 || result.error) return undefined;
|
|
148
151
|
const digest = createHash("sha256").update(result.stdout).digest("hex");
|
|
149
|
-
if (budget &&
|
|
152
|
+
if (budget && budgetNow(budget) >= budget.deadline) throw new SnapshotBudgetExceeded();
|
|
150
153
|
return digest;
|
|
151
154
|
}
|
|
152
155
|
|
|
@@ -158,7 +161,7 @@ export function soleWorktree(repo: Repo, budget?: SnapshotBudget): boolean | und
|
|
|
158
161
|
const registeredRoot = repo.shadow ? repo.dir : repo.top;
|
|
159
162
|
let count = 0;
|
|
160
163
|
for (const field of result.stdout.split("\0")) {
|
|
161
|
-
if (budget &&
|
|
164
|
+
if (budget && budgetNow(budget) >= budget.deadline) throw new SnapshotBudgetExceeded();
|
|
162
165
|
if (!field.startsWith("worktree ")) continue;
|
|
163
166
|
if (field.slice(9) !== registeredRoot || ++count > 1) return false;
|
|
164
167
|
}
|
|
@@ -179,7 +182,7 @@ export interface HeadMoveCursor {
|
|
|
179
182
|
const HEAD_LOG_TAIL_BYTES = 4096;
|
|
180
183
|
const HEAD_LOG_MAX_APPEND_BYTES = 128 * 1024;
|
|
181
184
|
const HEAD_LOG_MAX_MOVES = 64;
|
|
182
|
-
const checkSnapshotBudget = (budget?: SnapshotBudget) => { if (budget &&
|
|
185
|
+
const checkSnapshotBudget = (budget?: SnapshotBudget) => { if (budget && budgetNow(budget) >= budget.deadline) throw new SnapshotBudgetExceeded(); };
|
|
183
186
|
const sameLogFile = (a: Stats, b: Stats) => a.dev === b.dev && a.ino === b.ino;
|
|
184
187
|
function readHeadLog<T>(repo: Repo, budget: SnapshotBudget | undefined, read: (fd: number, stat: Stats) => T | undefined): T | undefined {
|
|
185
188
|
let fd: number | undefined;
|
|
@@ -309,11 +312,81 @@ export function incomingHeadPaths(repo: Repo, start: HeadMoveCursor, end: HeadMo
|
|
|
309
312
|
if (appended[appended.length - 1] !== 10) return undefined;
|
|
310
313
|
const lines = appended.toString("utf8").slice(0, -1).split("\n");
|
|
311
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
|
+
|
|
312
386
|
const own = new Set<string>(), incoming = new Set<string>();
|
|
313
387
|
let previous = start.head;
|
|
314
|
-
for (const
|
|
388
|
+
for (const row of parsedRows) {
|
|
315
389
|
checkSnapshotBudget(budget);
|
|
316
|
-
const row = headLogRow(line);
|
|
317
390
|
if (!row || (previous === null ? !/^0+$/.test(row.old) : row.old !== previous)) return undefined;
|
|
318
391
|
// Git preserves the invoking command in sequencer actions (for example pull -q --rebase (pick)).
|
|
319
392
|
// Classify the native phase before considering ordinary command-shaped incoming actions.
|
|
@@ -326,10 +399,11 @@ export function incomingHeadPaths(repo: Repo, start: HeadMoveCursor, end: HeadMo
|
|
|
326
399
|
const incomingCommand = !action.includes("(") && /^(?:merge|pull)(?: [^:]+)?$/.test(action) && !!detail;
|
|
327
400
|
let incomingMove = !ownMove && (incomingStart || incomingCommand || action === "checkout" && /^moving from .+ to .+$/.test(detail));
|
|
328
401
|
if (action === "reset" && /^moving to .+/.test(detail)) {
|
|
329
|
-
// Policy: a positively fast-forward reset
|
|
330
|
-
// descendant commit
|
|
331
|
-
|
|
332
|
-
|
|
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;
|
|
333
407
|
incomingMove = true;
|
|
334
408
|
}
|
|
335
409
|
if (!ownMove && !incomingMove) return undefined;
|
|
@@ -337,7 +411,19 @@ export function incomingHeadPaths(repo: Repo, start: HeadMoveCursor, end: HeadMo
|
|
|
337
411
|
if (/^0+$/.test(row.old)) {
|
|
338
412
|
const root = git(repo, ["diff-tree", "--root", "--no-commit-id", "-r", "--no-renames", "--name-only", "-z", row.next, "--", scope(repo)], {}, budget);
|
|
339
413
|
paths = root.status === 0 ? root.stdout.split("\0").filter(Boolean) : undefined;
|
|
340
|
-
} 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
|
+
}
|
|
341
427
|
if (!paths) return undefined;
|
|
342
428
|
for (const path of paths) { checkSnapshotBudget(budget); (ownMove ? own : incoming).add(path); }
|
|
343
429
|
previous = row.next;
|
|
@@ -392,6 +478,7 @@ export function snapshot(repo: Repo, budget?: SnapshotBudget): string | undefine
|
|
|
392
478
|
|
|
393
479
|
/** Project paths (relative to the top level) that differ between two trees. */
|
|
394
480
|
export function knownChangedPaths(repo: Repo, from: string, to: string, budget?: SnapshotBudget): string[] | undefined {
|
|
481
|
+
if (from === to) return [];
|
|
395
482
|
const r = git(repo, ["diff-tree", "-r", "--no-renames", "--name-only", "-z", from, to, "--", scope(repo)], {}, budget);
|
|
396
483
|
return r.status === 0 ? r.stdout.split("\0").filter(Boolean) : undefined;
|
|
397
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
|
+
}
|
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)) {
|