@staix/agent-hub 0.26.0 → 0.28.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 +30 -0
- package/README.md +13 -4
- package/docs/agent-notes/adapters.md +4 -2
- package/docs/agent-notes/bus.md +4 -4
- package/docs/agent-notes/daemon.md +8 -4
- package/docs/agent-notes/tasks.md +7 -2
- package/docs/agent-notes/tests.md +6 -5
- package/docs/design/adapters.md +76 -0
- package/docs/design/agent-hub-architecture.md +67 -0
- package/docs/design/agent-hub.md +52 -0
- package/docs/design/bus.md +48 -0
- package/docs/design/task-board.md +86 -0
- package/docs/dogfood.md +19 -2
- package/docs/operations.md +337 -15
- package/docs/profiles.md +2 -0
- package/docs/quickstart.md +15 -3
- package/docs/sdlc.md +4 -4
- package/docs/security.md +9 -4
- package/docs/smoke.md +2 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +3 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +12 -1
- package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
- package/docs/verification/2026-11-01-0.27.0.md +49 -0
- package/docs/verification/2026-11-02-0.28.0.md +54 -0
- package/docs/verification/2026-11-02-autonomy-t4.md +39 -0
- package/docs/verification/2026-11-02-codex-0166-reviewer-tool-call.md +53 -0
- package/docs/verification/2026-11-02-control-write-after.json +139 -0
- package/docs/verification/2026-11-02-control-write-baseline.json +127 -0
- package/docs/verification/2026-11-02-control-write-guarded.json +127 -0
- package/docs/verification/2026-11-02-control-write-outcomes.md +65 -0
- package/docs/verification/2026-11-02-orca-1.4.245-cli-contract.md +51 -0
- package/docs/verified.json +13 -13
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +344 -116
- package/src/adapters/claude-channel.ts +12 -2
- package/src/adapters/compat.ts +1 -1
- package/src/adapters/definitions/native-cli-claude-setup.ts +31 -9
- package/src/adapters/definitions/native-cli-entry-jcode.ts +33 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +2 -2
- package/src/adapters/jcode.ts +37 -4
- package/src/adapters/qwen-dual.ts +3 -2
- package/src/cli/agents.ts +4 -2
- package/src/cli/bench.ts +4 -2
- package/src/cli/board-read.ts +25 -0
- package/src/cli/claude-homes.ts +2 -2
- package/src/cli/console-state.ts +8 -3
- package/src/cli/help.ts +8 -6
- package/src/cli/identity.ts +14 -6
- package/src/cli/init-prompt.ts +179 -0
- package/src/cli/init-settings.ts +229 -0
- package/src/cli/init.ts +102 -9
- package/src/cli/launch.ts +3 -1
- package/src/cli/main.ts +112 -36
- package/src/cli/output.ts +14 -0
- package/src/cli/setup-homes.ts +18 -3
- package/src/cli/specs.ts +150 -0
- package/src/cli/study-output.ts +56 -0
- package/src/cli/study.ts +26 -3
- package/src/cli/terminal-recovery.ts +35 -11
- package/src/hub/action-policy.ts +5 -3
- package/src/hub/board.ts +56 -3
- package/src/hub/bus.ts +54 -7
- package/src/hub/child-process.ts +6 -4
- package/src/hub/config-apply.ts +4 -1
- package/src/hub/config-edit.ts +1 -1
- package/src/hub/config-tier.ts +1 -0
- package/src/hub/control-client.ts +28 -4
- package/src/hub/cost-governance.ts +22 -6
- package/src/hub/crash.ts +20 -0
- package/src/hub/daemon.ts +324 -64
- package/src/hub/delivery-hints.ts +6 -3
- package/src/hub/delivery-journal.ts +14 -3
- package/src/hub/events.ts +2 -1
- package/src/hub/hub-tools.ts +15 -9
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/peers.ts +1 -1
- package/src/hub/project-runtime-state.ts +38 -13
- package/src/hub/review-brief.ts +18 -0
- package/src/hub/routing.ts +7 -3
- package/src/hub/sdlc-release-authorization.ts +11 -5
- package/src/hub/session-identity.ts +1 -1
- package/src/hub/settings.ts +2 -0
- package/src/hub/specs-config.ts +43 -0
- package/src/hub/specs-task.ts +108 -0
- package/src/hub/specs.ts +1008 -0
- package/src/hub/task-list-page.ts +75 -0
- package/src/hub/task-sweep.ts +10 -1
- package/src/hub/tasks.ts +448 -40
- package/src/hub/terminal-open.ts +10 -5
- package/src/hub/tool-request.ts +131 -0
- package/src/ui/office-model.ts +17 -1
- package/src/ui/office-space.ts +1 -1
- package/templates/config.json +1 -0
- package/templates/presets/confirmation-matrix.json +5 -5
- package/templates/profiles/dev.json +1 -0
- package/templates/profiles/general.json +1 -0
- package/templates/profiles/research.json +1 -0
- package/templates/profiles/writing.json +1 -0
- package/templates/release-guard.block.md +2 -1
- package/templates/specs.block.md +5 -0
package/src/hub/tasks.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { publicTestIntegrity, readTestIntegrity, testIntegrityBlockers, type Tes
|
|
|
20
20
|
import { evalAdvice, evalGapBlockers, evalRequiredChecks, readEvalFor } from "./eval-gate.ts";
|
|
21
21
|
import { completePlanInventory, planConformance, planConformanceBlockers, planFingerprint, type PlanConformance } from "./plan-conformance.ts";
|
|
22
22
|
import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
|
|
23
|
-
import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
|
|
23
|
+
import { cleanCostPlan, costMetrics, CostScopeError, type CostPlan, type ReviewCost } from "./cost-governance.ts";
|
|
24
24
|
import { qualifiedTitleRepos, siblingTitleRefs, specRefs } from "./spec-read.ts";
|
|
25
25
|
import { folderDetail, MAX_NEW_PER_POLL, scheduleNext, SOURCE_ID, scheduledDetail, scheduleKeyOf, type FolderEntry, type FolderRunResult, type FolderScan, type FolderStorePort, type ScannedFile, type ScheduleEntry, type ScheduleRunResult, type ScheduleStore } from "./schedules.ts";
|
|
26
26
|
import { INTAKE_MAX_SOURCES, INTAKE_MAX_TEXT, intakeDetail, intakeSystemPrompt, intakeUserMessage, parseIntakeDetail, parseIntakeSplit, plannerRequestBody } from "./intake.ts";
|
|
@@ -64,7 +64,7 @@ import { isAbsolute, relative, resolve } from "node:path";
|
|
|
64
64
|
import type { Briefs, NativeItem } from "../memory/brief.ts";
|
|
65
65
|
import type { MemoryClient } from "../memory/client.ts";
|
|
66
66
|
import { candidateTargetRevision, type CandidateInput, type CandidateStore, extractNodes, looksSecret, PROMOTED_KINDS, taskKey, type GoalView, type Kind, type MemoryStore, type Scope, type Tier } from "@staix/agent-mem";
|
|
67
|
-
import { parseClaimVerdict, claimListId, CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskCanPlan, taskHasWorkEvidence, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type ReviewerHistory, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs, type PlannerBinding } from "./board.ts";
|
|
67
|
+
import { parseClaimVerdict, claimListId, CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskCanPlan, taskHasWorkEvidence, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type ReviewerHistory, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs, type PlannerBinding, type SpecAck, type SpecTaskReport } from "./board.ts";
|
|
68
68
|
import type { Bus } from "./bus.ts";
|
|
69
69
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
70
70
|
import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
|
|
@@ -75,10 +75,12 @@ import { realPath } from "./project.ts";
|
|
|
75
75
|
import { TaskRepositories } from "./task-repository.ts";
|
|
76
76
|
import { mainCheckoutWarning } from "./main-checkout.ts";
|
|
77
77
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
78
|
-
import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief.ts";
|
|
78
|
+
import { coverageGap, renderReviewBrief, renderSpecTaskReport, type BriefFile, type ReviewBrief } from "./review-brief.ts";
|
|
79
|
+
import { DEFAULT_SPECS_CONFIG, type SpecsConfig } from "./specs-config.ts";
|
|
80
|
+
import type { SpecsCheckReport, SpecsCoverReport } from "./specs.ts";
|
|
79
81
|
import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
|
|
80
82
|
import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
81
|
-
import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
83
|
+
import { plannerGrooming, nextSweep, plannedStartPending, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
82
84
|
import { CiWatchers, pollCi, type CiOutcome, type CiWatch } from "./ci-watch.ts";
|
|
83
85
|
import { planningIssueReceipt, linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type MergeRecord, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
|
|
84
86
|
|
|
@@ -88,6 +90,15 @@ function requireLocalTaskPeer(peer: unknown): void {
|
|
|
88
90
|
|
|
89
91
|
export type TaskAssignmentRole = "owner" | "reviewer" | "planner";
|
|
90
92
|
|
|
93
|
+
interface PreparedSpecInspection {
|
|
94
|
+
report?: SpecTaskReport;
|
|
95
|
+
policyKey: string;
|
|
96
|
+
repo?: string;
|
|
97
|
+
head: string;
|
|
98
|
+
privateTurn: boolean;
|
|
99
|
+
pii: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
91
102
|
export interface ReleaseWindow { at: number; host: boolean; quota: boolean; idle: boolean; reasons: string[]; current?: () => boolean; close?: () => Promise<void>; }
|
|
92
103
|
|
|
93
104
|
export interface TasksDeps {
|
|
@@ -134,6 +145,14 @@ export interface TasksDeps {
|
|
|
134
145
|
/** #520: the review brief for a task's commit (git only, bounded); undefined when the task names no commit. */
|
|
135
146
|
prepareReviewBrief?: (task: Task) => Promise<void>;
|
|
136
147
|
reviewBrief?: (task: Task) => ReviewBrief | { unavailable: string } | undefined;
|
|
148
|
+
/** Current public coverage for a task's declared paths; absent when notices are disabled or no specs exist. */
|
|
149
|
+
specNotice?: (task: Task) => SpecsCoverReport | undefined;
|
|
150
|
+
/** Inspect spec metadata at a verified task head; undefined is authoritative quiet (no specs/stamps or review off). */
|
|
151
|
+
inspectSpecs?: (task: Task, head: string, changedFiles: readonly BriefFile[] | undefined, acks: readonly SpecAck[]) => Promise<SpecTaskReport | undefined>;
|
|
152
|
+
/** Effective shared and machine-local specs policy. */
|
|
153
|
+
specsConfig?: () => SpecsConfig;
|
|
154
|
+
/** Explicit, read-only inspection remains available when task notices are disabled. */
|
|
155
|
+
specsRead?: (action: "check" | "covers", paths: readonly string[]) => SpecsCheckReport | SpecsCoverReport;
|
|
137
156
|
/** Host-verified existing symbols at this task's admitted completion head; never plan guesses. */
|
|
138
157
|
memorySymbolNodes?: (task: Task) => MemorySymbolNodes | undefined;
|
|
139
158
|
sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
|
|
@@ -375,7 +394,7 @@ function cleanPlan(input: unknown, profile: ProfileSelection = "dev"): TaskPlan
|
|
|
375
394
|
if (items.length) evidence[key] = items;
|
|
376
395
|
}
|
|
377
396
|
let scope: CostPlan;
|
|
378
|
-
try { scope = cleanCostPlan(r); } catch { throw new PreEffectToolRefusal("invalid review or CI cost scope"); }
|
|
397
|
+
try { scope = cleanCostPlan(r); } catch (error) { throw new PreEffectToolRefusal(error instanceof CostScopeError ? error.message : "invalid review or CI cost scope"); }
|
|
379
398
|
return { ...Object.fromEntries((profileData(profile).plan_keys as (keyof TaskPlan)[]).map((k) => [k, k === "paths" ? [...new Set(textList(r[k]).map(normPath))] : textList(r[k])] as const).filter(([, v]) => v.length)), ...evidence, ...scope };
|
|
380
399
|
}
|
|
381
400
|
|
|
@@ -563,7 +582,7 @@ export class Tasks {
|
|
|
563
582
|
for (const task of this.d.board.list()) {
|
|
564
583
|
if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) break;
|
|
565
584
|
if (!task.owner && this.personUnassigned(task)) continue;
|
|
566
|
-
const finding = nextSweep(task, config, now, this.waitsFor(task).length === 0);
|
|
585
|
+
const finding = nextSweep(task, config, now, this.waitsFor(task).length === 0 && !plannedStartPending(task, now));
|
|
567
586
|
if (!finding || this.waitsFor(task).length || this.isChecking(task.id) || this.silentFor(task.id)) continue;
|
|
568
587
|
if (finding.kind === "ready-unassigned") {
|
|
569
588
|
this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
|
|
@@ -906,6 +925,8 @@ export class Tasks {
|
|
|
906
925
|
if (rest.sdlc?.redFirst && !this.publicMetadataText(JSON.stringify(rest.sdlc.redFirst))) {
|
|
907
926
|
const { redFirst: _withheld, ...receipt } = rest.sdlc; rest.sdlc = receipt;
|
|
908
927
|
}
|
|
928
|
+
const currentSpecReport = this.currentSpecReport(task);
|
|
929
|
+
const currentSpecEntry = currentSpecReport ? history.findLast(entry => entry.event === "done" || entry.event === "done (checking)") : undefined;
|
|
909
930
|
const screenHistoryText = (text: string, field: string, withheld = false): string => !withheld && this.nameable(text) ? text : `[history ${field} withheld: ${this.whyWithheld(text)}; ahub task show ${task.id}]`;
|
|
910
931
|
const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
|
|
911
932
|
at: h.at, by: h.by, event: h.event,
|
|
@@ -933,16 +954,23 @@ export class Tasks {
|
|
|
933
954
|
...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
|
|
934
955
|
...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
|
|
935
956
|
...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
|
|
957
|
+
...(h === currentSpecEntry && currentSpecReport ? { specReport: currentSpecReport } : {}),
|
|
936
958
|
}));
|
|
937
959
|
const sets = this.reviewFindingSets(task), current = findingsForHead(sets, this.findingHead(task));
|
|
938
960
|
const symbolRepository = codeSymbols && this.codeSymbolsRepository(task);
|
|
939
961
|
const publicSymbols = codeSymbols && symbolRepository ? publicTaskCodeSymbols(codeSymbols, this.reviewHead(task), symbolRepository.key, this.publicMetadataText) : undefined;
|
|
940
962
|
const handedOff = this.d.board.handoffOrigin(task.id); // #604: where another project's handoff came from; two ids, never text
|
|
941
|
-
return { ...rest, ...(approvalBinding(task) ? { approval: approvalBinding(task) } : {}), ...(handedOff ? { handoffOrigin: { projectId: handedOff.projectId, peer: handedOff.peer, ...(handedOff.originTask === null ? {} : { task: handedOff.originTask }) } } : {}), ...(publicSymbols ? { codeSymbols: publicSymbols } : {}), ...(current.length ? { findings: findingTally(current) } : {}), ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task), undefined, sets, task.refs.commit) } : {}), title, detail, history: publicHistory };
|
|
963
|
+
return { ...rest, ...(currentSpecReport ? { specReport: currentSpecReport } : {}), ...(approvalBinding(task) ? { approval: approvalBinding(task) } : {}), ...(handedOff ? { handoffOrigin: { projectId: handedOff.projectId, peer: handedOff.peer, ...(handedOff.originTask === null ? {} : { task: handedOff.originTask }) } } : {}), ...(publicSymbols ? { codeSymbols: publicSymbols } : {}), ...(current.length ? { findings: findingTally(current) } : {}), ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task), undefined, sets, task.refs.commit) } : {}), title, detail, history: publicHistory };
|
|
942
964
|
}
|
|
943
965
|
|
|
944
966
|
/** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
|
|
945
|
-
|
|
967
|
+
specReportText(task: Task, by?: PeerId): string {
|
|
968
|
+
if (by && this.d.privateTurn?.(by)) return "";
|
|
969
|
+
const report = this.currentSpecReport(task);
|
|
970
|
+
return report ? renderSpecTaskReport(report) : "";
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
resultLine(task: Task, by?: PeerId): string {
|
|
946
974
|
const base = `task #${task.id}: ${task.state}, owner ${task.owner ?? "none"}, reviewer ${task.reviewer ?? "none"}`;
|
|
947
975
|
const noReview = task.class !== "review" && !task.reviewer && OPEN.includes(task.state) && !this.waitsFor(task).length
|
|
948
976
|
? this.noReviewer(assign(task, this.states(), this.d.routing(), { candidates: task.owner ? [task.owner] : [], notReviewer: task.owner ?? undefined, ...this.weights(task.class, task) }))
|
|
@@ -951,7 +979,250 @@ export class Tasks {
|
|
|
951
979
|
const holdText = held ? `${task.owner}'s queue is held (${held}); it receives the task once the hold is resolved` : undefined;
|
|
952
980
|
const request = task.history.findLast(entry => ["done", "reviewer changed", "reviewer unavailable"].includes(entry.event));
|
|
953
981
|
const poolBlocked = task.state === "in_review" && task.reviewer === USER && request?.reviewBlocked ? "blocked: no reviewer can run" : undefined;
|
|
954
|
-
|
|
982
|
+
const specText = this.specReportText(task, by) || undefined;
|
|
983
|
+
return [base, noReview, poolBlocked, holdText, specText].filter(Boolean).join("; ");
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
private currentSpecReport(task: Task): SpecTaskReport | undefined {
|
|
987
|
+
const config = this.d.specsConfig?.() ?? DEFAULT_SPECS_CONFIG;
|
|
988
|
+
if (this.isPii(task) || !config.enabled || config.review === "off") return;
|
|
989
|
+
const head = task.refs.commit && /^[0-9a-f]{40}$/.test(task.refs.commit) ? task.refs.commit : "";
|
|
990
|
+
const binding = this.specRefBinding(task);
|
|
991
|
+
const latest = task.history.findLast(row => row.event === "done" || row.event === "done (checking)");
|
|
992
|
+
const report = latest?.specReport;
|
|
993
|
+
if (!latest || !report || !(report.head === head || report.head === "" && !report.complete) || latest.specRefBinding !== binding) return;
|
|
994
|
+
return this.publicSpecReport(report);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
private specRefBinding(task: Pick<Task, "refs">): string {
|
|
998
|
+
return createHash("sha256").update(JSON.stringify([task.refs.repo ?? "", task.refs.branch ?? "", task.refs.commit ?? ""])).digest("hex");
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
private publicSpecReport(report: SpecTaskReport): SpecTaskReport | undefined {
|
|
1002
|
+
if (!report || typeof report.head !== "string" || report.head !== "" && !/^[0-9a-f]{40}$/.test(report.head)
|
|
1003
|
+
|| report.complete && !/^[0-9a-f]{40}$/.test(report.head)
|
|
1004
|
+
|| typeof report.complete !== "boolean" || !Number.isSafeInteger(report.errors) || report.errors < 0
|
|
1005
|
+
|| !Array.isArray(report.unchanged) || !Array.isArray(report.changed)) return;
|
|
1006
|
+
const ref = (value: unknown): SpecTaskReport["unchanged"][number] | undefined => {
|
|
1007
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
1008
|
+
const row = value as Record<string, unknown>;
|
|
1009
|
+
if (typeof row.id !== "string" || typeof row.path !== "string" || typeof row.title !== "string"
|
|
1010
|
+
|| !row.id.trim() || row.id.length > 160 || !row.path.trim()) return;
|
|
1011
|
+
// Missing titles are schema diagnostics, not a report-mode completion gate.
|
|
1012
|
+
const title = row.title.trim() ? row.title : row.id;
|
|
1013
|
+
const strings = [row.id, row.path, title, ...(typeof row.reason === "string" ? [row.reason] : [])];
|
|
1014
|
+
if (strings.some(text => text.length > 1000 || /[\p{Cc}\p{Cf}]/u.test(text) || !this.publicMetadataText(text))) return;
|
|
1015
|
+
return { id: row.id, path: row.path, title, ...(typeof row.reason === "string" ? { reason: row.reason } : {}) };
|
|
1016
|
+
};
|
|
1017
|
+
const unchanged = report.unchanged.slice(0, 20).map(ref).filter((row): row is NonNullable<typeof row> => !!row);
|
|
1018
|
+
const changed = report.changed.slice(0, 20).map(ref).filter((row): row is NonNullable<typeof row> => !!row);
|
|
1019
|
+
const privacyFiltered = unchanged.length < Math.min(report.unchanged.length, 20) || changed.length < Math.min(report.changed.length, 20);
|
|
1020
|
+
const reason = privacyFiltered ? "some spec metadata could not be safely shown"
|
|
1021
|
+
: typeof report.reason === "string" && report.reason.length <= 500 && this.publicMetadataText(report.reason) ? report.reason : undefined;
|
|
1022
|
+
return { head: report.head, complete: report.complete && !privacyFiltered, errors: report.errors, unchanged, changed,
|
|
1023
|
+
...(report.truncated || report.unchanged.length > 20 || report.changed.length > 20 ? { truncated: true } : {}), ...(reason ? { reason } : {}) };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
private parseSpecAcks(value: unknown): SpecAck[] {
|
|
1027
|
+
if (value === undefined) return [];
|
|
1028
|
+
if (!Array.isArray(value) || value.length > 20) throw new PreEffectToolRefusal("spec_ack must contain at most 20 {id, reason} entries");
|
|
1029
|
+
const seen = new Set<string>();
|
|
1030
|
+
return value.map(raw => {
|
|
1031
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new PreEffectToolRefusal("each spec_ack entry needs an id and reason");
|
|
1032
|
+
const row = raw as Record<string, unknown>;
|
|
1033
|
+
if (Object.keys(row).some(key => key !== "id" && key !== "reason") || typeof row.id !== "string" || !row.id.trim() || row.id.length > 160 || /[\p{Cc}\p{Cf}]/u.test(row.id)
|
|
1034
|
+
|| typeof row.reason !== "string" || !row.reason.trim() || row.reason.length > 1000 || /[\p{Cc}\p{Cf}]/u.test(row.reason)) throw new PreEffectToolRefusal("each spec_ack entry needs a bounded id and reason");
|
|
1035
|
+
const id = row.id.trim();
|
|
1036
|
+
if (!this.publicMetadataText(id)) throw new PreEffectToolRefusal("spec_ack id is private or sensitive");
|
|
1037
|
+
if (seen.has(id)) throw new PreEffectToolRefusal(`spec_ack repeats ${id}`);
|
|
1038
|
+
seen.add(id);
|
|
1039
|
+
return { id, reason: row.reason.trim() };
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
private specsPolicyKey(config = this.d.specsConfig?.() ?? DEFAULT_SPECS_CONFIG): string {
|
|
1044
|
+
return JSON.stringify([config.enabled, config.review, config.roots, config.stamps_file]);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
private assertSpecInspection(inspection: PreparedSpecInspection, task: Task, by: PeerId): void {
|
|
1048
|
+
this.assertSpecTaskBinding(inspection, task);
|
|
1049
|
+
if (this.specsPolicyKey() !== inspection.policyKey || (this.d.privateTurn?.(by) === true) !== inspection.privateTurn || this.isPii(task) !== inspection.pii) {
|
|
1050
|
+
throw new PreEffectToolRefusal(`task #${task.id}: specs policy, task head or privacy context changed during inspection; retry hub_task_done`);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
private assertSpecTaskBinding(inspection: PreparedSpecInspection, task: Task): void {
|
|
1055
|
+
const head = task.refs.commit && /^[0-9a-f]{40}$/.test(task.refs.commit) ? task.refs.commit : "";
|
|
1056
|
+
if (task.refs.repo !== inspection.repo || head !== inspection.head) {
|
|
1057
|
+
throw new PreEffectToolRefusal(`task #${task.id}: task head or repository changed during spec inspection; retry hub_task_done`);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
private refreshAsyncSpecInspection(inspection: PreparedSpecInspection, task: Task, by: PeerId): boolean {
|
|
1062
|
+
this.assertSpecTaskBinding(inspection, task);
|
|
1063
|
+
const config = this.d.specsConfig?.() ?? DEFAULT_SPECS_CONFIG;
|
|
1064
|
+
const privateTurn = this.d.privateTurn?.(by) === true;
|
|
1065
|
+
const policyKey = this.specsPolicyKey(config), pii = this.isPii(task);
|
|
1066
|
+
if (inspection.report && policyKey !== inspection.policyKey && config.enabled && config.review === "require" && !privateTurn && !pii) return true;
|
|
1067
|
+
if (policyKey !== inspection.policyKey || privateTurn !== inspection.privateTurn || pii !== inspection.pii) {
|
|
1068
|
+
delete inspection.report;
|
|
1069
|
+
inspection.policyKey = policyKey;
|
|
1070
|
+
inspection.privateTurn = privateTurn;
|
|
1071
|
+
inspection.pii = pii;
|
|
1072
|
+
}
|
|
1073
|
+
return false;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
private requestSpecRefile(task: Task): void {
|
|
1077
|
+
this.tell(task, `Task #${task.id}: its class check passed, but specs policy changed to require while it ran. Refile hub_task_done so coverage is checked under the current policy.`, this.isPii(task));
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
private specInspectionReason(cause: string): string {
|
|
1081
|
+
const safe = this.publicMetadataText(cause) ? sanitize(cause).replace(/[\r\n\t]+/g, " ").trim().slice(0, 350) : "verified spec evidence is unavailable";
|
|
1082
|
+
return `${safe}; split the task scope or ask the person to change specs.review`;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
private async inspectTaskSpecs(task: Task, by: PeerId, rawAcks: unknown, validate: () => Task): Promise<PreparedSpecInspection> {
|
|
1086
|
+
const config = this.d.specsConfig?.() ?? DEFAULT_SPECS_CONFIG;
|
|
1087
|
+
const initialPrivate = this.d.privateTurn?.(by) === true, pii = this.isPii(task);
|
|
1088
|
+
const declaredHead = task.refs.commit && /^[0-9a-f]{40}$/.test(task.refs.commit) ? task.refs.commit : "";
|
|
1089
|
+
const inspection: PreparedSpecInspection = { policyKey: this.specsPolicyKey(config), ...(task.refs.repo ? { repo: task.refs.repo } : {}), head: declaredHead, privateTurn: initialPrivate, pii };
|
|
1090
|
+
const ackInput = Array.isArray(rawAcks) && rawAcks.length === 0 ? undefined : rawAcks;
|
|
1091
|
+
if (ackInput !== undefined && (pii || initialPrivate)) throw new PreEffectToolRefusal("spec_ack is unavailable for private work");
|
|
1092
|
+
if (ackInput !== undefined && (!config.enabled || config.review === "off")) throw new PreEffectToolRefusal("spec_ack is unavailable while specs review is disabled");
|
|
1093
|
+
if (!config.enabled || config.review === "off" || pii || initialPrivate) return inspection;
|
|
1094
|
+
if (!this.d.inspectSpecs) {
|
|
1095
|
+
if (config.review === "require" && declaredHead) throw new PreEffectToolRefusal(`task #${task.id}: spec coverage is unknown because the spec inspector is unavailable at the submitted head; restore specs support or explicitly change specs.review`);
|
|
1096
|
+
if (ackInput !== undefined) throw new PreEffectToolRefusal("spec inspection is unavailable; cannot accept spec_ack");
|
|
1097
|
+
return inspection;
|
|
1098
|
+
}
|
|
1099
|
+
const acks = this.parseSpecAcks(ackInput);
|
|
1100
|
+
let changedFiles: BriefFile[] | undefined;
|
|
1101
|
+
let changedEvidenceReason: string | undefined;
|
|
1102
|
+
if (declaredHead) {
|
|
1103
|
+
const brief = this.d.reviewBrief?.({ ...task, refs: { ...task.refs, commit: declaredHead } });
|
|
1104
|
+
if (!brief) changedEvidenceReason = "the verified review brief is unavailable";
|
|
1105
|
+
else if ("unavailable" in brief) changedEvidenceReason = `review brief unavailable: ${brief.unavailable}`;
|
|
1106
|
+
else if (brief.head !== declaredHead) changedEvidenceReason = "the verified review brief does not match the submitted full head";
|
|
1107
|
+
else if (brief.withheld > 0) changedEvidenceReason = `the verified review brief withheld ${brief.withheld} changed file(s)`;
|
|
1108
|
+
else if (brief.unlisted.length > 0) changedEvidenceReason = `the verified review brief has ${brief.unlisted.length} unlisted changed file(s)`;
|
|
1109
|
+
else {
|
|
1110
|
+
const files = [...brief.reviewable, ...brief.excluded].flatMap(file => [file, ...(file.oldPath ? [{ path: file.oldPath, status: file.status }] : [])]);
|
|
1111
|
+
if (files.every(file => this.publicMetadataText(file.path))) {
|
|
1112
|
+
const byPath = new Map<string, BriefFile>();
|
|
1113
|
+
for (const file of files) if (!byPath.has(file.path)) byPath.set(file.path, file);
|
|
1114
|
+
changedFiles = [...byPath.values()];
|
|
1115
|
+
} else changedEvidenceReason = "the verified changed-file inventory includes private paths";
|
|
1116
|
+
}
|
|
1117
|
+
} else changedEvidenceReason = "a full task commit head was not supplied";
|
|
1118
|
+
const screenedAcks: SpecAck[] = [];
|
|
1119
|
+
for (const ack of acks) {
|
|
1120
|
+
if (!this.publicMetadataText(ack.reason)) throw new PreEffectToolRefusal("spec_ack reason is private or sensitive");
|
|
1121
|
+
if (await this.withholds("note", ack.reason, task)) throw new PreEffectToolRefusal(`spec_ack reason for ${ack.id} was withheld; use a public, non-sensitive rationale`);
|
|
1122
|
+
screenedAcks.push(ack);
|
|
1123
|
+
this.assertSpecInspection(inspection, validate(), by);
|
|
1124
|
+
}
|
|
1125
|
+
this.assertSpecInspection(inspection, validate(), by);
|
|
1126
|
+
const report = await this.d.inspectSpecs(task, declaredHead, changedFiles, screenedAcks);
|
|
1127
|
+
this.assertSpecInspection(inspection, validate(), by);
|
|
1128
|
+
if (!report) {
|
|
1129
|
+
if (acks.length) throw new PreEffectToolRefusal("spec_ack was supplied but no spec requires acknowledgement");
|
|
1130
|
+
return inspection;
|
|
1131
|
+
}
|
|
1132
|
+
const safe = this.publicSpecReport(report);
|
|
1133
|
+
if (!safe) throw new PreEffectToolRefusal("spec inspection returned invalid or private metadata");
|
|
1134
|
+
if (safe.unchanged.length < Math.min(report.unchanged.length, 20) || safe.changed.length < Math.min(report.changed.length, 20)) {
|
|
1135
|
+
safe.complete = false;
|
|
1136
|
+
safe.reason = this.specInspectionReason("some spec metadata could not be safely shown");
|
|
1137
|
+
}
|
|
1138
|
+
if (safe.head !== declaredHead) {
|
|
1139
|
+
safe.complete = false;
|
|
1140
|
+
safe.head = "";
|
|
1141
|
+
safe.reason = this.specInspectionReason("the spec report does not match the verified task head");
|
|
1142
|
+
safe.unchanged = [];
|
|
1143
|
+
safe.changed = [];
|
|
1144
|
+
}
|
|
1145
|
+
if (report.truncated) {
|
|
1146
|
+
safe.complete = false;
|
|
1147
|
+
safe.reason = this.specInspectionReason("the submitted spec scan reached its safety limit; narrow specs.roots");
|
|
1148
|
+
} else if (changedFiles === undefined && changedEvidenceReason && safe.head === declaredHead) {
|
|
1149
|
+
safe.complete = false;
|
|
1150
|
+
safe.reason = this.specInspectionReason(changedEvidenceReason);
|
|
1151
|
+
} else if (!safe.complete && safe.reason) safe.reason = this.specInspectionReason(safe.reason);
|
|
1152
|
+
else if (!safe.complete) safe.reason = this.specInspectionReason(report.reason ?? "spec coverage could not be verified at the submitted task head");
|
|
1153
|
+
if (config.review === "require" && report.unchanged.length > 20) throw new PreEffectToolRefusal(`task #${task.id}: ${report.unchanged.length} covering specs are unchanged; update the specs or split the task scope so each task covers at most 20 specs`);
|
|
1154
|
+
const pendingIds = new Set(safe.unchanged.map(spec => spec.id));
|
|
1155
|
+
for (const ack of screenedAcks) if (!pendingIds.has(ack.id)) throw new PreEffectToolRefusal(`spec_ack ${ack.id} does not name a covering spec that is unchanged at this head`);
|
|
1156
|
+
const reasons = new Map(screenedAcks.map(ack => [ack.id, ack.reason]));
|
|
1157
|
+
safe.unchanged = safe.unchanged.map(spec => ({ ...spec, ...(reasons.has(spec.id) ? { reason: reasons.get(spec.id)! } : {}) }));
|
|
1158
|
+
if (config.review === "require") {
|
|
1159
|
+
if (!safe.complete || report.truncated) throw new PreEffectToolRefusal(`task #${task.id}: ${safe.reason ?? this.specInspectionReason("spec coverage is unknown at this submitted task head")}`);
|
|
1160
|
+
const unacknowledged = safe.unchanged.filter(spec => !reasons.has(spec.id));
|
|
1161
|
+
if (unacknowledged.length) throw new PreEffectToolRefusal(`task #${task.id}: spec not updated: ${unacknowledged.map(spec => spec.id).join(", ")}; update the covering spec or add spec_ack [{id, reason}]`);
|
|
1162
|
+
}
|
|
1163
|
+
inspection.report = safe;
|
|
1164
|
+
return inspection;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/** Narrow public task-path notice used by the tool response after propose/accept. */
|
|
1168
|
+
specNotice(task: Task, by?: PeerId): string {
|
|
1169
|
+
const config = this.d.specsConfig?.() ?? DEFAULT_SPECS_CONFIG;
|
|
1170
|
+
if (!config.enabled || config.review === "off" || this.isPii(task) || by && this.d.privateTurn?.(by)) return "";
|
|
1171
|
+
let report: SpecsCoverReport | undefined;
|
|
1172
|
+
try { report = this.d.specNotice?.(task); } catch { return ""; }
|
|
1173
|
+
const current = this.d.board.get(task.id);
|
|
1174
|
+
if (!current || this.isPii(current) || by && this.d.privateTurn?.(by)
|
|
1175
|
+
|| current.history.length !== task.history.length || current.owner !== task.owner || current.refs.repo !== task.refs.repo
|
|
1176
|
+
|| JSON.stringify(current.refs.paths ?? []) !== JSON.stringify(task.refs.paths ?? [])) return "";
|
|
1177
|
+
if (!report) return "";
|
|
1178
|
+
const refs = report.specs.slice(0, 20).filter(spec => [spec.id, spec.path, spec.title].every(value => this.publicMetadataText(value)));
|
|
1179
|
+
const lines = refs.length ? [`Specs covering these paths: ${refs.map(spec => `${spec.id} (${spec.path})`).join(", ")}`] : [];
|
|
1180
|
+
if (report.errors) lines.push(`Spec coverage check reports ${report.errors} error${report.errors === 1 ? "" : "s"}; run ahub specs check.`);
|
|
1181
|
+
if (report.truncated || report.specs.length > 20 || refs.length < Math.min(report.specs.length, 20)) lines.push("Spec coverage list truncated; run ahub specs covers for the full list.");
|
|
1182
|
+
return sanitize(lines.join("\n"));
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
specsRead(by: PeerId, action: "check" | "covers", rawPaths: unknown = []): Record<string, unknown> {
|
|
1186
|
+
if (this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("spec inspection unavailable during a private turn");
|
|
1187
|
+
if (!this.d.specsRead) throw new PreEffectToolRefusal("spec inspection unavailable");
|
|
1188
|
+
if (action !== "check" && action !== "covers") throw new PreEffectToolRefusal('hub_specs action must be "check" or "covers"');
|
|
1189
|
+
if (!Array.isArray(rawPaths) || rawPaths.length > 32 || rawPaths.some(path => typeof path !== "string" || !path.trim() || path.length > 2000 || /[\p{Cc}\p{Cf}]/u.test(path)
|
|
1190
|
+
|| path.startsWith("/") || path.includes("\\") || path.split("/").includes("..") || !this.publicMetadataText(path))) throw new PreEffectToolRefusal("hub_specs paths must be bounded, public repository-relative paths using forward slashes");
|
|
1191
|
+
const paths = rawPaths as string[];
|
|
1192
|
+
if (action === "check" && paths.length) throw new PreEffectToolRefusal('hub_specs "check" takes no paths');
|
|
1193
|
+
if (action === "covers" && paths.length === 0) throw new PreEffectToolRefusal('hub_specs "covers" needs at least one repository-relative path');
|
|
1194
|
+
const safe = (value: string) => sanitize(value);
|
|
1195
|
+
const diagnostic = (raw: SpecsCheckReport["items"][number]): Record<string, unknown> | undefined => {
|
|
1196
|
+
const fields = [raw.code, raw.message, raw.cause, raw.nextSteps, ...(raw.path ? [raw.path] : []), ...(raw.id ? [raw.id] : []), ...(raw.field ? [raw.field] : []), ...(raw.relatedId ? [raw.relatedId] : [])];
|
|
1197
|
+
if (fields.some(value => !this.publicMetadataText(value))) return;
|
|
1198
|
+
return { severity: raw.severity, code: safe(raw.code), message: safe(raw.message), cause: safe(raw.cause), nextSteps: safe(raw.nextSteps),
|
|
1199
|
+
...(raw.path ? { path: safe(raw.path) } : {}), ...(raw.id ? { id: safe(raw.id) } : {}), ...(raw.field ? { field: safe(raw.field) } : {}), ...(raw.relatedId ? { relatedId: safe(raw.relatedId) } : {}) };
|
|
1200
|
+
};
|
|
1201
|
+
let report: SpecsCheckReport | SpecsCoverReport;
|
|
1202
|
+
try { report = this.d.specsRead(action, paths); }
|
|
1203
|
+
catch { throw new PreEffectToolRefusal("spec inspection unavailable; retry ahub specs check"); }
|
|
1204
|
+
if (this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("spec inspection unavailable during a private turn");
|
|
1205
|
+
try {
|
|
1206
|
+
if ("freshness" in report) {
|
|
1207
|
+
const visibleItems = report.items.map(diagnostic).filter((row): row is Record<string, unknown> => !!row);
|
|
1208
|
+
const items = visibleItems.slice(0, 20);
|
|
1209
|
+
const remaining = Math.max(0, 20 - items.length);
|
|
1210
|
+
const visibleFreshness = report.freshness.filter(row => this.publicMetadataText(row.path) && (row.id === undefined || this.publicMetadataText(row.id)));
|
|
1211
|
+
const freshness = visibleFreshness.slice(0, remaining)
|
|
1212
|
+
.map(row => ({ path: safe(row.path), ...(row.id ? { id: safe(row.id) } : {}), state: row.state, commitsSince: row.commitsSince, dirty: row.dirty }));
|
|
1213
|
+
return { specCount: report.specCount, errors: report.errors, warnings: report.warnings, stale: report.stale,
|
|
1214
|
+
truncated: report.truncated || visibleItems.length > 20 || visibleItems.length !== report.items.length
|
|
1215
|
+
|| visibleFreshness.length > freshness.length || visibleFreshness.length !== report.freshness.length, freshness, items };
|
|
1216
|
+
}
|
|
1217
|
+
const visibleSpecs = report.specs.filter(row => [row.id, row.path, row.title].every(value => this.publicMetadataText(value)));
|
|
1218
|
+
const specs = visibleSpecs.slice(0, 20)
|
|
1219
|
+
.map(({ id, path, title }) => ({ id: safe(id), path: safe(path), title: safe(title) }));
|
|
1220
|
+
const visibleItems = report.items.map(diagnostic).filter((row): row is Record<string, unknown> => !!row);
|
|
1221
|
+
const remaining = Math.max(0, 20 - specs.length);
|
|
1222
|
+
const items = visibleItems.slice(0, remaining);
|
|
1223
|
+
return { specs, errors: report.errors, truncated: report.truncated || visibleSpecs.length > specs.length || visibleSpecs.length !== report.specs.length
|
|
1224
|
+
|| visibleItems.length > remaining || visibleItems.length !== report.items.length, items };
|
|
1225
|
+
} catch { throw new PreEffectToolRefusal("spec inspection unavailable; retry ahub specs check"); }
|
|
955
1226
|
}
|
|
956
1227
|
|
|
957
1228
|
private states(): Record<PeerId, PeerState> {
|
|
@@ -1100,6 +1371,25 @@ export class Tasks {
|
|
|
1100
1371
|
return { ...(family ? { reviewerFamily: family } : {}), ...(reason ? { reviewerReason: reason } : {}), ...(assignment?.reviewBlocked ? { reviewBlocked: true } : {}), ...(queueReviewHeld ? { queueReviewHeld: true as const, ...(queueReviewBinding !== undefined ? { queueReviewBinding } : {}) } : {}) };
|
|
1101
1372
|
}
|
|
1102
1373
|
|
|
1374
|
+
/**
|
|
1375
|
+
* #1081: a task that declares `needs_network` or `needs_worktree` is refused at accept when its owner cannot meet the need, before any write. Routing
|
|
1376
|
+
* already keeps such a task away from that peer; this covers an offer made before the sandbox network was switched off, and it is what stops a
|
|
1377
|
+
* sandboxed peer from accepting work, failing at `git worktree add` (#270) and leaving an empty branch ref behind (#621, #565). Unknown capability is
|
|
1378
|
+
* not a refusal, and a PII task skips the check as `requireCapabilities` does.
|
|
1379
|
+
*/
|
|
1380
|
+
private declaredNeedCheck(task: Pick<Task, "signals" | "id"> & Partial<Pick<Task, "signals">>, peer: PeerId): void {
|
|
1381
|
+
if (!this.d.capabilities || this.isPii(task as Task)) return;
|
|
1382
|
+
const wantsNetwork = task.signals?.includes("needs_network") === true, wantsWorktree = task.signals?.includes("needs_worktree") === true;
|
|
1383
|
+
if (!wantsNetwork && !wantsWorktree) return;
|
|
1384
|
+
const caps = peerEntry(this.d.capabilities(), peer);
|
|
1385
|
+
if (wantsNetwork && caps?.network === false) {
|
|
1386
|
+
throw new PreEffectToolRefusal(`task #${task.id} needs the network and ${peer} has none: ${caps.networkReason ?? "its shell cannot reach the network"}; decline it so the hub offers it to a peer with network, or ask the person to enable it`);
|
|
1387
|
+
}
|
|
1388
|
+
if (wantsWorktree && caps?.worktree === false) {
|
|
1389
|
+
throw new PreEffectToolRefusal(`task #${task.id} needs its own git worktree and ${peer} cannot create one: ${caps.worktreeReason ?? "it cannot create the task worktree"}; nothing was created, so no branch ref is left behind; decline it so the hub offers it to a peer that can, this will not change by waiting`);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1103
1393
|
/** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
|
|
1104
1394
|
private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: Exclude<TaskAssignmentRole, "planner"> = "owner", preEffect = true): void {
|
|
1105
1395
|
requireLocalTaskPeer(peer);
|
|
@@ -1108,7 +1398,7 @@ export class Tasks {
|
|
|
1108
1398
|
if ((!this.d.capabilities || this.isPii(task)) && !declaredExtra) return;
|
|
1109
1399
|
const { state: _state, ...draft } = task; // backlog/readiness must not bypass an explicit reservation's admission
|
|
1110
1400
|
const admission = assign(draft, this.states(), this.d.routing(), { candidates: role === "owner" ? [peer] : [], ...(role === "owner" ? { namedOwner: peer } : { namedReviewer: peer }), ...this.weights(task.class) });
|
|
1111
|
-
const missing = (admission.refusals ?? []).filter(row => row.peer === peer && row.role === role && (row.code === "tests" || row.code === "spec" || declaredExtra && role === "owner" && row.code === "owner-admission")).map(row => ` ${role} candidate ${peer}: skipped, ${row.reason}`);
|
|
1401
|
+
const missing = (admission.refusals ?? []).filter(row => row.peer === peer && row.role === role && (row.code === "tests" || row.code === "spec" || row.code === "network" || row.code === "worktree" || declaredExtra && role === "owner" && row.code === "owner-admission")).map(row => ` ${role} candidate ${peer}: skipped, ${row.reason}`);
|
|
1112
1402
|
if (missing.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot assign ${peer}: ${missing.join("; ")}`);
|
|
1113
1403
|
}
|
|
1114
1404
|
|
|
@@ -2188,6 +2478,7 @@ export class Tasks {
|
|
|
2188
2478
|
/** Write-driven wake-ups (#785): one unsettled ids-only task notice per planner, never a polling pull. */
|
|
2189
2479
|
private writePlannerNotices(task: Task, before: ReadonlyMap<PeerId, number>, approved = false): void {
|
|
2190
2480
|
this.releaseNotice(`task #${task.id}: could not notify planner about ready work`, () => {
|
|
2481
|
+
const now = this.d.now?.() ?? Date.now();
|
|
2191
2482
|
const changes = new Map<PeerId, { capacity: Set<PeerId>; ready: Set<number> }>();
|
|
2192
2483
|
const add = (planner: PeerId, peer?: PeerId, id?: number) => {
|
|
2193
2484
|
if (!this.d.bus.isRostered(planner)) return;
|
|
@@ -2199,7 +2490,7 @@ export class Tasks {
|
|
|
2199
2490
|
const released = new Set<PeerId>();
|
|
2200
2491
|
for (const [peer, used] of before) if (this.wip(peer) < used) { released.add(peer); for (const planner of this.taskPlanner(task)) add(planner, peer); }
|
|
2201
2492
|
if (approved) for (const card of this.d.board.list("backlog")) {
|
|
2202
|
-
if (card.deps?.includes(task.id) && !this.waitsFor(card).length) for (const planner of this.taskPlanner(card)) add(planner, undefined, card.id);
|
|
2493
|
+
if (card.deps?.includes(task.id) && !this.waitsFor(card).length && !plannedStartPending(card, now)) for (const planner of this.taskPlanner(card)) add(planner, undefined, card.id);
|
|
2203
2494
|
}
|
|
2204
2495
|
for (const [planner, change] of changes) this.releaseNotice(`could not notify planner ${planner} about ready work`, () => {
|
|
2205
2496
|
const pending = this.d.bus.pendingEnvelopes(planner).filter(env => env.from === HUB && env.kind === "task" && env.body.startsWith("Planner work ready:"));
|
|
@@ -2217,7 +2508,7 @@ export class Tasks {
|
|
|
2217
2508
|
}
|
|
2218
2509
|
for (const id of change.ready) {
|
|
2219
2510
|
const card = this.d.board.get(id);
|
|
2220
|
-
if (card?.state === "backlog" && this.taskPlanner(card).includes(planner) && !this.waitsFor(card).length) lines.push(`backlog_ready: #${id}`);
|
|
2511
|
+
if (card?.state === "backlog" && this.taskPlanner(card).includes(planner) && !this.waitsFor(card).length && !plannedStartPending(card, now)) lines.push(`backlog_ready: #${id}`);
|
|
2221
2512
|
else change.ready.delete(id);
|
|
2222
2513
|
}
|
|
2223
2514
|
for (const env of pending) this.d.bus.withdraw(env.id);
|
|
@@ -2231,8 +2522,9 @@ export class Tasks {
|
|
|
2231
2522
|
|
|
2232
2523
|
/** The top card explicitly reserved for `peer` that this planner may pull now; the notice and the opt-in pull read the same rule. `pullable` keeps the staged backlog cards only. */
|
|
2233
2524
|
private topReservedReady(peer: PeerId, planner?: PeerId, pullable = false): Task | undefined {
|
|
2525
|
+
const now = this.d.now?.() ?? Date.now();
|
|
2234
2526
|
return this.d.board.list().filter(card => (pullable ? card.state === "backlog" : ["backlog", "proposed"].includes(card.state)) && !card.owner && card.reserved === peer
|
|
2235
|
-
&& (planner === undefined || this.taskPlanner(card).includes(planner)) && !(pullable && this.isPii(card)) && !card.pinned && !card.draft && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(peer)
|
|
2527
|
+
&& (planner === undefined || this.taskPlanner(card).includes(planner)) && !plannedStartPending(card, now) && !(pullable && this.isPii(card)) && !card.pinned && !card.draft && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(peer)
|
|
2236
2528
|
&& (card.release == null || ["planning", "active"].includes(this.d.board.release(card.release)?.state ?? ""))).sort(this.readyOrder)[0];
|
|
2237
2529
|
}
|
|
2238
2530
|
|
|
@@ -2264,10 +2556,15 @@ export class Tasks {
|
|
|
2264
2556
|
if (!planner || !this.d.bus.isRostered(planner)) return;
|
|
2265
2557
|
const reason = `auto_pull: capacity released for ${peer}; hub acted for planner ${planner}`;
|
|
2266
2558
|
try {
|
|
2267
|
-
await this.pullPlanned(HUB, { id: card.id, revision: card.planningRevision ?? 0, reason }, { onBehalfOf: planner, guard: () => {
|
|
2559
|
+
await this.pullPlanned(HUB, { id: card.id, revision: card.planningRevision ?? 0, reason }, { onBehalfOf: planner, guard: () => {
|
|
2560
|
+
if (!this.autoPullAdmitted()) throw new PreEffectToolRefusal("auto pull is no longer admitted: tasks.auto_pull was turned off or a recovery, shutdown or storage hold began");
|
|
2561
|
+
const current = this.d.board.get(card.id);
|
|
2562
|
+
if (current && plannedStartPending(current, this.d.now?.() ?? Date.now())) throw new PreEffectToolRefusal("auto pull is waiting for the planned start");
|
|
2563
|
+
} });
|
|
2268
2564
|
} catch (e) {
|
|
2269
2565
|
// A guard refusal is the setting or a hold doing its job, not a failure to report.
|
|
2270
|
-
|
|
2566
|
+
const current = this.d.board.get(card.id);
|
|
2567
|
+
if (!this.autoPullAdmitted() || current && plannedStartPending(current, this.d.now?.() ?? Date.now())) return;
|
|
2271
2568
|
this.releaseNotice(`task ${this.publicTitle(card)}: auto_pull for ${peer} refused: ${e instanceof Error ? e.message : String(e)}`);
|
|
2272
2569
|
}
|
|
2273
2570
|
}
|
|
@@ -2982,7 +3279,7 @@ export class Tasks {
|
|
|
2982
3279
|
return updated; // a failed readback may follow a durable release write
|
|
2983
3280
|
}
|
|
2984
3281
|
|
|
2985
|
-
async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; role?: unknown; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown; backlog?: unknown; draft?: unknown; goal?: unknown }, external?: GitHubSource, created?: () => void, fence?: () => string | undefined): Promise<Task> {
|
|
3282
|
+
async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; role?: unknown; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown; needs_network?: unknown; needs_worktree?: unknown; backlog?: unknown; draft?: unknown; goal?: unknown }, external?: GitHubSource, created?: () => void, fence?: () => string | undefined): Promise<Task> {
|
|
2986
3283
|
const requestCheck = this.requestCheck(by, fence);
|
|
2987
3284
|
requireLocalTaskPeer(input.owner);
|
|
2988
3285
|
external = external ? { ...external } : undefined;
|
|
@@ -3026,6 +3323,12 @@ export class Tasks {
|
|
|
3026
3323
|
const signals = this.signalsFor({ ...text, detail: [screenDetail, planText(plan)].join("\n"), plan });
|
|
3027
3324
|
// Urgent work is handed over even when its paused owner's window resets soon (issue #36).
|
|
3028
3325
|
if (input.urgent === true && !signals.includes("urgent")) signals.push("urgent");
|
|
3326
|
+
// #1081: a declared need is a signal like urgent, so routing and every later capability check read the same field. Only an explicit true sets it.
|
|
3327
|
+
if (input.needs_network !== undefined && typeof input.needs_network !== "boolean") throw new PreEffectToolRefusal("needs_network must be true or false; omit it when the work needs no network");
|
|
3328
|
+
if (input.needs_network === true && !signals.includes("needs_network")) signals.push("needs_network");
|
|
3329
|
+
// #1081 (amended AC1): the same for a task worktree. Only an explicit true sets it.
|
|
3330
|
+
if (input.needs_worktree !== undefined && typeof input.needs_worktree !== "boolean") throw new PreEffectToolRefusal("needs_worktree must be true or false; omit it when the work needs no task worktree");
|
|
3331
|
+
if (input.needs_worktree === true && !signals.includes("needs_worktree")) signals.push("needs_worktree");
|
|
3029
3332
|
// The PII screen (issue #198) reads the same text before anything else does: until its verdict the task is on no
|
|
3030
3333
|
// board, in no envelope and before no other model. A pattern match is PII without a call; no verdict is PII too.
|
|
3031
3334
|
const screening = this.screenOn();
|
|
@@ -3599,6 +3902,7 @@ export class Tasks {
|
|
|
3599
3902
|
/** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
|
|
3600
3903
|
private async assignOwner(task: Task, by: PeerId, opts: { current?: () => void; conductorManual?: true; queueOverride?: "owner"; candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number; refuseNamed?: "pre" | "plain"; plannerPull?: { revision: number; note: string; withheld: boolean; guard: () => void } } = {}): Promise<Task> {
|
|
3601
3904
|
opts.current?.();
|
|
3905
|
+
if (by === HUB && !task.owner && ["backlog", "proposed"].includes(task.state) && plannedStartPending(task, this.d.now?.() ?? Date.now())) throw new PreEffectToolRefusal("planned start is in the future");
|
|
3602
3906
|
if (task.state === "backlog" && !opts.plannerPull) throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
|
|
3603
3907
|
const studyReason = this.studyDispatchReason(task, opts.namedOwner);
|
|
3604
3908
|
if (studyReason) {
|
|
@@ -3616,6 +3920,7 @@ export class Tasks {
|
|
|
3616
3920
|
if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull changed during admission");
|
|
3617
3921
|
return current ?? task;
|
|
3618
3922
|
}
|
|
3923
|
+
if (by === HUB && !current.owner && ["backlog", "proposed"].includes(current.state) && plannedStartPending(current, this.d.now?.() ?? Date.now())) throw new PreEffectToolRefusal("planned start is in the future");
|
|
3619
3924
|
task = current; // Reviewer-only and quiet bookkeeping updates do not supersede ownership authority.
|
|
3620
3925
|
if (gap) {
|
|
3621
3926
|
this.requestIndependentPlanReview(current);
|
|
@@ -3673,7 +3978,9 @@ export class Tasks {
|
|
|
3673
3978
|
const binding = this.plannerBinding(task, a, !!opts.plannerPull);
|
|
3674
3979
|
const assignmentExtra = { ...this.reviewMetadata(a, task), ...(binding ? { plannerReason: binding.plannerReason } : {}), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}), ...(opts.conductorManual ? { conductorManual: true as const } : {}) };
|
|
3675
3980
|
opts.current?.();
|
|
3676
|
-
this.
|
|
3981
|
+
const current = this.d.board.get(task.id) ?? task;
|
|
3982
|
+
if (by === HUB && !current.owner && ["backlog", "proposed"].includes(current.state) && plannedStartPending(current, this.d.now?.() ?? Date.now())) throw new PreEffectToolRefusal("planned start is in the future");
|
|
3983
|
+
this.studyDispatchCheck(current, a.owner);
|
|
3677
3984
|
const next = opts.plannerPull
|
|
3678
3985
|
? this.d.board.pullAssigned(task.id, by, a.owner, opts.plannerPull.revision, opts.plannerPull.note, opts.plannerPull.withheld, a.reviewer ?? null, assignmentNote, assignmentExtra, task.history.length, binding)
|
|
3679
3986
|
: this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(binding ? { planner: binding.planner } : {}), ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, assignmentNote, assignmentExtra, opts.expectedFlowRevision);
|
|
@@ -4038,6 +4345,12 @@ export class Tasks {
|
|
|
4038
4345
|
if (!this.queuePressureAdmitted()) return;
|
|
4039
4346
|
const task = this.d.board.get(stall.task);
|
|
4040
4347
|
if (!this.ordinaryQueueProposal(stall, task)) { this.d.board.clearStall(stall.task); continue; }
|
|
4348
|
+
if (plannedStartPending(task, this.d.now?.() ?? Date.now())) {
|
|
4349
|
+
const scheduledOrigin = this.d.board.queuePressureStallPage(task.id - 1, 1).rows.find(row => row.task === task.id);
|
|
4350
|
+
if (scheduledOrigin) this.d.board.recordStall({ ...scheduledOrigin, replanRevision: task.planningRevision ?? 0 });
|
|
4351
|
+
pass.retained = true;
|
|
4352
|
+
continue;
|
|
4353
|
+
}
|
|
4041
4354
|
// A planning revision asks for fresh admission, never erases an automatic offer.
|
|
4042
4355
|
const namedOwner = stall.cause.peer;
|
|
4043
4356
|
const retry = namedOwner ? { candidates: [namedOwner], namedOwner } : {};
|
|
@@ -4203,6 +4516,7 @@ export class Tasks {
|
|
|
4203
4516
|
}
|
|
4204
4517
|
|
|
4205
4518
|
async releaseReady(): Promise<void> {
|
|
4519
|
+
const now = this.d.now?.() ?? Date.now();
|
|
4206
4520
|
const haveQueueOrigins = this.queuePressureKnown !== false && this.d.board.queuePressureStallPage(0, 1).afterTask > 0;
|
|
4207
4521
|
for (const t of [...this.d.board.list("proposed")].sort(this.readyOrder)) {
|
|
4208
4522
|
if (t.owner) { this.d.board.clearStall(t.id); continue; }
|
|
@@ -4214,6 +4528,7 @@ export class Tasks {
|
|
|
4214
4528
|
const origin = this.d.board.queuePressureStallPage(t.id - 1, 1).rows.find(row => row.task === t.id);
|
|
4215
4529
|
if (origin && this.ordinaryQueueProposal(origin, t)) { this.scheduleQueuePressureRecheck(); continue; }
|
|
4216
4530
|
}
|
|
4531
|
+
if (plannedStartPending(t, now)) continue;
|
|
4217
4532
|
const admission = planned ? this.plannerAssignment(t, true) : assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.weights(t.class, t) });
|
|
4218
4533
|
if (!admission.owner) {
|
|
4219
4534
|
// A diagnostic legacy pass is not evidence that an automatic proposal was attempted.
|
|
@@ -4235,6 +4550,7 @@ export class Tasks {
|
|
|
4235
4550
|
try {
|
|
4236
4551
|
const t = this.d.board.get(stale.id);
|
|
4237
4552
|
if (!t || t.state !== "proposed" || t.owner || this.personUnassigned(t) || this.offered.has(t.id)) return;
|
|
4553
|
+
if (plannedStartPending(t, this.d.now?.() ?? Date.now())) return;
|
|
4238
4554
|
const planned = this.plannerPullRecord(t)?.pull === true;
|
|
4239
4555
|
const selected = planned ? this.plannerAssignment(t, true).owner : undefined;
|
|
4240
4556
|
if (planned && !selected) return;
|
|
@@ -4251,6 +4567,7 @@ export class Tasks {
|
|
|
4251
4567
|
} catch (e) {
|
|
4252
4568
|
// A saved move whose delivery failed is not "could not be assigned" (#297); the console notice was printed
|
|
4253
4569
|
// where the error was thrown. The offer stays consumed by the error's own evidence, without the readback.
|
|
4570
|
+
if (e instanceof PreEffectToolRefusal && e.message === "planned start is in the future") return;
|
|
4254
4571
|
if (e instanceof AssignmentUndeliveredError) undelivered = e;
|
|
4255
4572
|
else this.releaseNotice(`task ${this.publicTitle(stale)}: could not be assigned: ${e instanceof Error ? e.message : String(e)}`);
|
|
4256
4573
|
} finally {
|
|
@@ -4419,7 +4736,7 @@ export class Tasks {
|
|
|
4419
4736
|
if (!result.owner) for (const refusal of result.refusals ?? []) if (refusal.role === "owner") blockers.push({ task: task.id, code: refusal.code === "instance" || refusal.code === "family" ? "policy" : refusal.code });
|
|
4420
4737
|
if (this.waitsFor(task).length) blockers.push({ task: task.id, code: "dependency" });
|
|
4421
4738
|
}
|
|
4422
|
-
const digest = plannerGrooming(visible, { dependencies, dependencyTruncated, repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
|
|
4739
|
+
const digest = plannerGrooming(visible, { dependencies, dependencyTruncated, repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`, now: this.d.now?.() ?? Date.now(),
|
|
4423
4740
|
limits: Object.fromEntries(peers.map(peer => [peer, this.wipLimit(peer)])), wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])),
|
|
4424
4741
|
states, issues, reviewedIssues, acceptedIssues, blockers });
|
|
4425
4742
|
return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
|
|
@@ -4819,8 +5136,8 @@ export class Tasks {
|
|
|
4819
5136
|
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
|
|
4820
5137
|
if ((task.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("planning revision changed");
|
|
4821
5138
|
let scope: CostPlan;
|
|
4822
|
-
try { scope = cleanCostPlan({ ...task.plan, ...("review" in raw ? { review: raw.review } : {}), ...("ci" in raw ? { ci: raw.ci } : {}) }); }
|
|
4823
|
-
catch { throw new PreEffectToolRefusal("invalid review or CI cost scope"); }
|
|
5139
|
+
try { scope = cleanCostPlan({ ...task.plan, ...("review" in raw ? { review: raw.review } : {}), ...("ci" in raw ? { ci: raw.ci } : {}) }, { review: "review" in raw ? "" : "plan", ci: "ci" in raw ? "" : "plan" }); }
|
|
5140
|
+
catch (error) { throw new PreEffectToolRefusal(error instanceof CostScopeError ? error.message : "invalid review or CI cost scope"); }
|
|
4824
5141
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
4825
5142
|
if (!scope.review || !scope.ci) {
|
|
4826
5143
|
if (mode === "enforce") throw new PreEffectToolRefusal("review and CI cost scope required for a new plan receipt");
|
|
@@ -5391,7 +5708,7 @@ export class Tasks {
|
|
|
5391
5708
|
const metadata = acceptRefs(refs);
|
|
5392
5709
|
const task = this.need(id, false, true);
|
|
5393
5710
|
this.mine(task, by, "owner", true);
|
|
5394
|
-
this.acceptanceCapacity(task);
|
|
5711
|
+
this.acceptanceCapacity(task, 0, "check"); // #1080: an urgent task may still take unaccepted offers; `accept` does that, after its gates
|
|
5395
5712
|
if (metadata && !this.isPii(task) && (!this.nameable(metadata.repo!) || looksSecret(metadata.repo!) || this.aboutPrivatePath("", metadata.repo!, task))) throw new PreEffectToolRefusal("this repository metadata is private or sensitive and is not kept");
|
|
5396
5713
|
const draft = metadata ? { ...task, refs: { ...task.refs, ...metadata } } : task;
|
|
5397
5714
|
this.warmRepository(draft);
|
|
@@ -5420,7 +5737,52 @@ export class Tasks {
|
|
|
5420
5737
|
if (gap) this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}`);
|
|
5421
5738
|
}
|
|
5422
5739
|
|
|
5423
|
-
|
|
5740
|
+
/**
|
|
5741
|
+
* What counts toward `owner`'s WIP, split for a refusal and for #1080: `offered` is work handed to the owner that it has not accepted
|
|
5742
|
+
* (state proposed), `reserved` is ownerless planner-pulled work held for it, and `accepted` is everything the owner is actually carrying
|
|
5743
|
+
* (in progress, changes requested, in review).
|
|
5744
|
+
*/
|
|
5745
|
+
private wipSplit(owner: PeerId, except: number): { accepted: Task[]; offered: Task[]; reserved: Task[] } {
|
|
5746
|
+
const open = this.d.board.list().filter(other => other.id !== except && !["backlog", "approved", "cancelled"].includes(other.state)
|
|
5747
|
+
&& (other.owner === owner || !other.owner && other.reserved === owner && this.plannerPullRecord(other)?.pull));
|
|
5748
|
+
return {
|
|
5749
|
+
accepted: open.filter(other => other.owner === owner && other.state !== "proposed"),
|
|
5750
|
+
offered: open.filter(other => other.owner === owner && other.state === "proposed"),
|
|
5751
|
+
reserved: open.filter(other => !other.owner),
|
|
5752
|
+
};
|
|
5753
|
+
}
|
|
5754
|
+
private static isUrgent(task: Pick<Task, "priority" | "signals">): boolean {
|
|
5755
|
+
return (task.priority ?? (task.signals?.includes("urgent") ? 0 : 2)) === 0;
|
|
5756
|
+
}
|
|
5757
|
+
/** #1080: an offer an urgent accept may take back. Never accepted or started work, never another urgent task, never person-pinned work. */
|
|
5758
|
+
private displaceable(offer: Task): boolean {
|
|
5759
|
+
return offer.state === "proposed" && !!offer.owner && !offer.pinned && !Tasks.isUrgent(offer) && !taskHasStarted(offer);
|
|
5760
|
+
}
|
|
5761
|
+
/**
|
|
5762
|
+
* #1080: the owner is at its limit only because of offers it never accepted, so an urgent task takes the slot. The newest offers go back to
|
|
5763
|
+
* routing (ownerless, in To Do) and the planners are told. Called only from `accept`, immediately before its write.
|
|
5764
|
+
*/
|
|
5765
|
+
private displaceOffers(urgent: Task, owner: PeerId, offers: readonly Task[]): void {
|
|
5766
|
+
for (const offer of offers) {
|
|
5767
|
+
// A planner-pulled offer also holds a counted reservation (`plannerWip` counts an ownerless proposed task reserved for the owner with pull history), so clearing the owner alone frees no slot. `decline` clears it the same way.
|
|
5768
|
+
const pulled = this.plannerPullRecord(offer)?.pull === true;
|
|
5769
|
+
const next = this.d.board.update(offer.id, HUB, "unassigned", { owner: null, reviewer: null, ...(pulled ? { reserved: null } : {}) }, `displaced by urgent task #${urgent.id}: ${owner} was at its WIP limit with this offer unaccepted`, {}, offer.history.length);
|
|
5770
|
+
this.offered.delete(offer.id);
|
|
5771
|
+
this.releaseNotice(`task #${offer.id}: displacement cohort cleanup unavailable`, () => this.cohorts.leave(offer.id));
|
|
5772
|
+
this.withdrawRoleRequest(offer, next, "owner");
|
|
5773
|
+
this.withdrawRoleRequest(offer, next, "reviewer");
|
|
5774
|
+
this.releaseNotice(`task #${offer.id}: displacement notice unavailable`, () => {
|
|
5775
|
+
const body = `Task #${offer.id} was taken back from ${owner}: urgent task #${urgent.id} (priority 0 or marked urgent) needed ${owner}'s WIP slot and #${offer.id} was only offered, never accepted. It is ownerless in To Do and routes again; plan it again if it should wait for ${owner}.`;
|
|
5776
|
+
this.d.notify(body);
|
|
5777
|
+
for (const planner of this.taskPlanner(offer)) this.d.bus.publish(newEnvelope(HUB, sanitize(body), { to: [planner], kind: "task", refs: { task: String(offer.id) }, ...(this.isPii(offer) ? { private: true } : {}) }));
|
|
5778
|
+
});
|
|
5779
|
+
}
|
|
5780
|
+
}
|
|
5781
|
+
/**
|
|
5782
|
+
* `urgent`: "refuse" (default) never displaces; "check" only tells whether an urgent accept could (a pre-check before a later write); "displace"
|
|
5783
|
+
* performs it. Only `accept` displaces.
|
|
5784
|
+
*/
|
|
5785
|
+
private acceptanceCapacity(task: Pick<Task, "id" | "owner" | "reserved" | "state"> & Partial<Pick<Task, "history" | "priority" | "signals">>, excludePending = 0, urgent: "refuse" | "check" | "displace" = "refuse"): void {
|
|
5424
5786
|
const owner = task.owner ?? task.reserved;
|
|
5425
5787
|
if (!owner) return;
|
|
5426
5788
|
if ((task.state === "in_progress" || task.state === "changes_requested") && task.history) {
|
|
@@ -5434,10 +5796,30 @@ export class Tasks {
|
|
|
5434
5796
|
if (task.history.slice(boundary).some(entry => entry.event === "accepted" && (entry.by === owner || entry.by === USER))) return;
|
|
5435
5797
|
}
|
|
5436
5798
|
const limit = this.wipLimit(owner), pending = Math.max(0, (this.pendingClaims.get(owner) ?? 0) - excludePending);
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
|
|
5799
|
+
const used = this.wip(owner, task.id) + pending;
|
|
5800
|
+
if (used < limit) return;
|
|
5801
|
+
const split = this.wipSplit(owner, task.id), ids = (rows: readonly Task[]) => rows.map(row => `#${row.id}`).join(", ") || "none";
|
|
5802
|
+
// #1080: an urgent task that is the owner's own offer may take back only as many never-accepted offers as it needs, newest first. Anything else refuses.
|
|
5803
|
+
if (urgent !== "refuse" && task.state === "proposed" && task.owner === owner && Tasks.isUrgent(task as Pick<Task, "priority" | "signals">)) {
|
|
5804
|
+
const need = used + 1 - limit;
|
|
5805
|
+
const takeable = split.offered.filter(offer => this.displaceable(offer))
|
|
5806
|
+
.sort((a, b) => handedAt(b) - handedAt(a) || b.id - a.id);
|
|
5807
|
+
if (need > 0 && takeable.length >= need) {
|
|
5808
|
+
if (urgent === "displace") {
|
|
5809
|
+
this.displaceOffers(task as Task, owner, takeable.slice(0, need));
|
|
5810
|
+
// Defense in depth (#1080): no displacement path may leave the owner over its limit. Clearing the reservation above makes this unreachable today; if it ever fires, the offers are already back in To Do and only the accept is refused.
|
|
5811
|
+
const after = this.wip(owner, task.id) + pending;
|
|
5812
|
+
if (after >= limit) throw new Error(`planner WIP capacity for ${owner}: limit ${limit}; the offers taken back for urgent task #${task.id} are back in To Do but did not free a slot (WIP is now ${after}), so it stays offered; ask the planner to check ${owner}'s reservations, then accept it again`);
|
|
5813
|
+
}
|
|
5814
|
+
return;
|
|
5815
|
+
}
|
|
5816
|
+
}
|
|
5817
|
+
const open = [...split.accepted, ...split.offered, ...split.reserved].sort((a, b) => a.id - b.id);
|
|
5818
|
+
const parts = [`accepted ${ids(split.accepted)}`, `offered, not yet accepted ${ids(split.offered)}`, ...(split.reserved.length ? [`reserved, not yet offered ${ids(split.reserved)}`] : [])];
|
|
5819
|
+
const next = split.accepted.length >= limit
|
|
5820
|
+
? `finish or hand back an accepted task first`
|
|
5821
|
+
: `decline an offered task you will not take, or finish an accepted one; an urgent task may take back an offer you have not accepted`;
|
|
5822
|
+
throw new PreEffectToolRefusal(`planner WIP capacity for ${owner}: limit ${limit}; open tasks ${ids(open)} (${parts.join("; ")})${pending ? `; pending claims ${pending}` : ""}; ${next}`);
|
|
5441
5823
|
}
|
|
5442
5824
|
|
|
5443
5825
|
/**
|
|
@@ -5452,7 +5834,8 @@ export class Tasks {
|
|
|
5452
5834
|
this.ready(task, true);
|
|
5453
5835
|
if (task.state !== "proposed" && task.state !== "changes_requested" && task.state !== "in_progress") throw new PreEffectToolRefusal(`task #${task.id} is ${task.state}: cannot move to in_progress`);
|
|
5454
5836
|
this.studyDispatchCheck(task, task.owner ?? undefined);
|
|
5455
|
-
this.
|
|
5837
|
+
this.declaredNeedCheck(task, task.owner ?? by); // the owner's capability, also when the console user accepts on its behalf (`mine` admits USER)
|
|
5838
|
+
this.acceptanceCapacity(task, 0, "check");
|
|
5456
5839
|
// S1 spec acceptance and S9 plan review are independent prerequisites; retain S1's missing-spec refusal first.
|
|
5457
5840
|
if (this.specGated(task) && this.d.sdlc?.config().mode === "enforce" && specState(task.sdlc, undefined) === "missing") throw new PreEffectToolRefusal(this.specText(task, "missing", "blocked"));
|
|
5458
5841
|
if ((this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode === "enforce" && this.isBuildTask(task)) {
|
|
@@ -5480,6 +5863,7 @@ export class Tasks {
|
|
|
5480
5863
|
const repositoryChanged = this.repositories.scope(task.refs).key !== this.repositories.scope(draft.refs).key;
|
|
5481
5864
|
const before = new Set(this.overlapHits(task).map((h) => h.task.id));
|
|
5482
5865
|
this.studyDispatchCheck(task, task.owner ?? undefined);
|
|
5866
|
+
this.acceptanceCapacity(task, 0, "displace"); // #1080: every gate has passed; synchronous from here to the accept write
|
|
5483
5867
|
const next = this.linkOnWrite(this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}), ...(metadata ? { refs: metadata } : {}) })); // #723: refs.repo may have just arrived
|
|
5484
5868
|
this.captureOwnerAcceptance(next);
|
|
5485
5869
|
this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
|
|
@@ -5731,7 +6115,7 @@ export class Tasks {
|
|
|
5731
6115
|
if (state !== "OPEN" && state !== "CLOSED") throw new PreEffectToolRefusal(`task #${task.id}: PR merge state unavailable; cannot reopen approval`);
|
|
5732
6116
|
}
|
|
5733
6117
|
|
|
5734
|
-
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs, deliverables?: unknown): Promise<Task> {
|
|
6118
|
+
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs, deliverables?: unknown, specAck?: unknown): Promise<Task> {
|
|
5735
6119
|
const requestCheck = this.requestCheck(by);
|
|
5736
6120
|
const initial = this.need(id, false, true);
|
|
5737
6121
|
this.mine(initial, by, "owner", true);
|
|
@@ -5769,6 +6153,10 @@ export class Tasks {
|
|
|
5769
6153
|
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task = current(); }
|
|
5770
6154
|
const reviewAdmission = task.reviewer || this.d.prepareReviewBrief ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence, undefined, staged => this.withLink(this.completionDraft(staged, draft), link, refs), !task.reviewer) : undefined;
|
|
5771
6155
|
task = current();
|
|
6156
|
+
const currentSpecTask = () => { const fresh = current(); return { ...fresh, refs: { ...fresh.refs, ...cleanRefs(refs) } }; };
|
|
6157
|
+
const specInspection = await this.inspectTaskSpecs(currentSpecTask(), by, specAck, currentSpecTask);
|
|
6158
|
+
task = current(); // the host head and task authority are revalidated after spec inspection
|
|
6159
|
+
this.assertSpecInspection(specInspection, { ...task, refs: { ...task.refs, ...cleanRefs(refs) } }, by);
|
|
5772
6160
|
// A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
|
|
5773
6161
|
const cohort = this.cohorts.of(task.id);
|
|
5774
6162
|
const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
|
|
@@ -5799,19 +6187,23 @@ export class Tasks {
|
|
|
5799
6187
|
}
|
|
5800
6188
|
if (task.state === "proposed" || task.state === "changes_requested") task = this.withLink(this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE), link, refs); // done without a separate accept
|
|
5801
6189
|
const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
|
|
5802
|
-
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link, records, requestCheck, refresh);
|
|
6190
|
+
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link, records, requestCheck, refresh, specInspection);
|
|
5803
6191
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
5804
6192
|
if (refresh?.state === "approved") { await this.unmergedForReopen(task, valid); task = current(); }
|
|
5805
6193
|
this.checking.set(task.id, task.owner);
|
|
5806
6194
|
const checkedRefs = cleanRefs(refs);
|
|
6195
|
+
if (specInspection.report?.head && !checkedRefs.commit) checkedRefs.commit = specInspection.report.head;
|
|
6196
|
+
if (specInspection.report && specInspection.repo && !checkedRefs.repo) checkedRefs.repo = specInspection.repo;
|
|
6197
|
+
const storedRefs = { ...task.refs, ...checkedRefs };
|
|
5807
6198
|
this.warmRepository({ ...task, refs: { ...task.refs, ...checkedRefs } });
|
|
5808
|
-
|
|
6199
|
+
this.assertSpecInspection(specInspection, { ...task, refs: storedRefs }, by);
|
|
6200
|
+
const pending = this.d.board.update(task.id, by, "done (checking)", { ...(refresh ? { state: "in_progress" as const } : {}), ...(refresh?.state === "approved" && !task.reviewer ? { reviewer: USER } : {}), codeSymbols: this.observedCodeSymbols({ ...task, refs: storedRefs }), refs: checkedRefs, ...(task.sdlc ? { sdlc: task.sdlc } : {}) }, summary, { withheld: withheld || undefined, ...(records ? { deliverables: records } : {}), ...(specInspection.report ? { specReport: specInspection.report, specRefBinding: this.specRefBinding({ refs: storedRefs }) } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
5809
6201
|
if (refresh) { this.ciWatchers.cancel(pending.id); this.heldReviews.delete(pending.id); }
|
|
5810
6202
|
this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
|
|
5811
6203
|
const seen = { events: pending.history.length, owner: pending.owner };
|
|
5812
6204
|
this.pendingChecks++;
|
|
5813
6205
|
this.checkQueue = this.checkQueue
|
|
5814
|
-
.then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld))
|
|
6206
|
+
.then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld, specInspection))
|
|
5815
6207
|
.catch((e: Error) => {
|
|
5816
6208
|
this.releaseNotice(`task #${pending.id}: its check result could not be recorded: ${e.message}`);
|
|
5817
6209
|
this.releaseNotice(`task #${pending.id}: its owner retry notice could not be published`, () => {
|
|
@@ -5914,7 +6306,7 @@ export class Tasks {
|
|
|
5914
6306
|
return { ...(key ? { checkKey: key } : {}), ...(match ? { missingKeys: Number(match[2]) } : {}) };
|
|
5915
6307
|
}
|
|
5916
6308
|
|
|
5917
|
-
private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }, withheld: boolean): Promise<void> {
|
|
6309
|
+
private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }, withheld: boolean, specInspection: PreparedSpecInspection): Promise<void> {
|
|
5918
6310
|
const result = await this.d.runCheck!(command).catch((e: Error) => ({ code: null, timedOut: false, interrupted: false, tail: e.message }));
|
|
5919
6311
|
this.checking.delete(id);
|
|
5920
6312
|
const task = this.d.board.get(id);
|
|
@@ -5930,6 +6322,7 @@ export class Tasks {
|
|
|
5930
6322
|
return;
|
|
5931
6323
|
}
|
|
5932
6324
|
if (result.code === 0 && !result.timedOut) {
|
|
6325
|
+
const refileForSpecs = this.refreshAsyncSpecInspection(specInspection, task, by);
|
|
5933
6326
|
// An integrating member's check counts only for the target it confirmed (issue #107).
|
|
5934
6327
|
const cohort = this.cohorts.of(id);
|
|
5935
6328
|
if (cohort?.silent && !this.cohorts.holds(id, ownerGen(task), this.tree(cohort))) {
|
|
@@ -5938,8 +6331,9 @@ export class Tasks {
|
|
|
5938
6331
|
return;
|
|
5939
6332
|
}
|
|
5940
6333
|
this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim(), this.checkProvenance(task, result.tail));
|
|
6334
|
+
if (refileForSpecs) { this.requestSpecRefile(task); return; }
|
|
5941
6335
|
// The output goes to the reviewer with the done note, not into shared memory: nobody screened it.
|
|
5942
|
-
await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail, withheld);
|
|
6336
|
+
await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail, withheld, undefined, undefined, undefined, undefined, undefined, undefined, specInspection, true);
|
|
5943
6337
|
return;
|
|
5944
6338
|
}
|
|
5945
6339
|
this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim(), this.checkProvenance(task, result.tail));
|
|
@@ -5951,8 +6345,9 @@ export class Tasks {
|
|
|
5951
6345
|
}
|
|
5952
6346
|
|
|
5953
6347
|
/** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
|
|
5954
|
-
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink, records?: DeliverableRecord[], requestCheck: () => void = () => {}, refresh?: CompletionRefresh): Promise<Task> {
|
|
6348
|
+
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink, records?: DeliverableRecord[], requestCheck: () => void = () => {}, refresh?: CompletionRefresh, specInspection?: PreparedSpecInspection, asyncCheckCompletion = false): Promise<Task> {
|
|
5955
6349
|
requestCheck();
|
|
6350
|
+
if (specInspection && asyncCheckCompletion && this.refreshAsyncSpecInspection(specInspection, task, by)) { this.requestSpecRefile(task); return task; }
|
|
5956
6351
|
// A check's completion carries the records its `done (checking)` bound: the same bytes the owner delivered, not a later read.
|
|
5957
6352
|
records ??= latestPendingDeliverables(task);
|
|
5958
6353
|
const submittedCommit = cleanRefs(refs).commit;
|
|
@@ -5964,6 +6359,7 @@ export class Tasks {
|
|
|
5964
6359
|
reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
|
|
5965
6360
|
requestCheck();
|
|
5966
6361
|
task = this.withLink(this.completionDraft(this.completionCurrent(task.id, by, false, refresh), draft), link, refs);
|
|
6362
|
+
if (specInspection && asyncCheckCompletion && this.refreshAsyncSpecInspection(specInspection, task, by)) { this.requestSpecRefile(task); return task; }
|
|
5967
6363
|
selection = this.poolReview(task, refs);
|
|
5968
6364
|
reviewer = selection?.reviewer ?? task.reviewer ?? (refresh?.state === "approved" ? USER : undefined);
|
|
5969
6365
|
}
|
|
@@ -5972,6 +6368,7 @@ export class Tasks {
|
|
|
5972
6368
|
admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence, undefined, staged => this.withLink(this.completionDraft(staged, draft), link, refs), !reviewer);
|
|
5973
6369
|
requestCheck();
|
|
5974
6370
|
task = this.withLink(this.completionDraft(this.completionCurrent(task.id, by, false, refresh), draft), link, refs);
|
|
6371
|
+
if (specInspection && asyncCheckCompletion && this.refreshAsyncSpecInspection(specInspection, task, by)) { this.requestSpecRefile(task); return task; }
|
|
5975
6372
|
}
|
|
5976
6373
|
selection = this.poolReview(task, refs);
|
|
5977
6374
|
reviewer = selection?.reviewer ?? task.reviewer ?? (refresh?.state === "approved" ? USER : undefined);
|
|
@@ -5981,12 +6378,21 @@ export class Tasks {
|
|
|
5981
6378
|
if (refresh?.state === "approved") {
|
|
5982
6379
|
await this.unmergedForReopen(task, () => { requestCheck(); return this.completionCurrent(task.id, by, true, refresh); });
|
|
5983
6380
|
task = this.withLink(this.completionDraft(this.completionCurrent(task.id, by, true, refresh), draft), link, refs);
|
|
6381
|
+
if (specInspection && asyncCheckCompletion && this.refreshAsyncSpecInspection(specInspection, task, by)) { this.requestSpecRefile(task); return task; }
|
|
5984
6382
|
}
|
|
5985
6383
|
const completedRefs = cleanRefs(refs);
|
|
5986
|
-
|
|
5987
|
-
|
|
6384
|
+
if (specInspection?.report?.head && !completedRefs.commit) completedRefs.commit = specInspection.report.head;
|
|
6385
|
+
if (specInspection?.report && specInspection.repo && !completedRefs.repo) completedRefs.repo = specInspection.repo;
|
|
6386
|
+
const storedRefs = { ...task.refs, ...completedRefs };
|
|
6387
|
+
if (specInspection) {
|
|
6388
|
+
if (asyncCheckCompletion) {
|
|
6389
|
+
if (this.refreshAsyncSpecInspection(specInspection, { ...task, refs: storedRefs }, by)) { this.requestSpecRefile(task); return task; }
|
|
6390
|
+
} else this.assertSpecInspection(specInspection, { ...task, refs: storedRefs }, by);
|
|
6391
|
+
}
|
|
6392
|
+
this.warmRepository({ ...task, refs: storedRefs });
|
|
6393
|
+
const noWrites = this.noWritesRecorded({ ...task, refs: storedRefs });
|
|
5988
6394
|
const capacity = this.capacityBefore(task);
|
|
5989
|
-
const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs:
|
|
6395
|
+
const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: storedRefs }), state: reviewer ? "in_review" : "approved", ...(selection || refresh?.state === "approved" ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(specInspection?.report ? { specReport: specInspection.report, specRefBinding: this.specRefBinding({ refs: storedRefs }) } : {}), ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection, { ...task, refs: storedRefs }), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
5990
6396
|
this.queueReviewWritten(next);
|
|
5991
6397
|
this.writePlannerNotices(next, capacity, next.state === "approved");
|
|
5992
6398
|
this.ciWatchers.cancel(next.id); this.heldReviews.delete(next.id); // a newer head or a move ends the old head's watcher (#785 P1b)
|
|
@@ -6417,17 +6823,19 @@ export class Tasks {
|
|
|
6417
6823
|
briefText(task: Task): string {
|
|
6418
6824
|
if (this.isPii(task)) return "";
|
|
6419
6825
|
const approval = approvalText(approvalBinding(task));
|
|
6420
|
-
const
|
|
6421
|
-
|
|
6826
|
+
const spec = this.currentSpecReport(task);
|
|
6827
|
+
const prefix = (brief: string) => [approval, spec ? renderSpecTaskReport(spec) : "", brief].filter(Boolean).join("\n");
|
|
6828
|
+
if (!task.refs.commit) return prefix("");
|
|
6422
6829
|
try {
|
|
6423
6830
|
const brief = this.d.reviewBrief?.(task);
|
|
6424
|
-
if (!brief) return
|
|
6831
|
+
if (!brief) return prefix("");
|
|
6425
6832
|
return prefix("unavailable" in brief ? `Review brief unavailable: ${brief.unavailable}.` : sanitize(renderReviewBrief(brief)));
|
|
6426
6833
|
} catch { return prefix("Review brief unavailable: it could not be built."); }
|
|
6427
6834
|
}
|
|
6428
6835
|
|
|
6429
6836
|
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown, findings?: unknown): Promise<Task> {
|
|
6430
6837
|
const requestCheck = this.requestCheck(by);
|
|
6838
|
+
if (typeof id !== "number" && !(typeof id === "string" && /^\s*\d+\s*$/.test(id))) this.need(id, false, true);
|
|
6431
6839
|
const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
|
|
6432
6840
|
let findingGuard: ((task: Task) => void) | undefined;
|
|
6433
6841
|
// #520: what the coverage was checked against, pinned through every await: the commit and the lifecycle receipt (the
|