@staix/agent-hub 0.27.0 → 0.29.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 +33 -0
- package/README.md +11 -3
- package/docs/agent-notes/adapters.md +4 -2
- package/docs/agent-notes/bus.md +4 -4
- package/docs/agent-notes/daemon.md +9 -3
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +7 -0
- package/docs/agent-notes/tests.md +7 -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 +21 -3
- package/docs/operations.md +370 -12
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +19 -3
- package/docs/sdlc.md +2 -2
- package/docs/security.md +24 -4
- package/docs/smoke.md +2 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +2 -0
- package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -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/verification/2026-11-03-0.29.0.md +59 -0
- package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
- package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
- package/docs/verification/2026-11-03-live-board-latency.json +556 -0
- package/docs/verification/2026-11-03-live-board-latency.md +64 -0
- package/docs/verified.json +14 -14
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +857 -125
- 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 +6 -5
- package/src/cli/identity.ts +9 -1
- 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 +106 -35
- package/src/cli/output.ts +19 -0
- package/src/cli/recovery-package.ts +9 -0
- package/src/cli/setup-homes.ts +18 -3
- package/src/cli/specs.ts +150 -0
- package/src/cli/terminal-recovery.ts +35 -11
- package/src/hub/board.ts +59 -5
- 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 +43 -4
- package/src/hub/control-write-trace.ts +409 -0
- package/src/hub/cost-governance.ts +22 -6
- package/src/hub/crash.ts +20 -0
- package/src/hub/daemon.ts +470 -98
- package/src/hub/delivery-hints.ts +6 -3
- package/src/hub/delivery-journal.ts +14 -3
- package/src/hub/hub-tools.ts +10 -6
- package/src/hub/peers.ts +1 -1
- package/src/hub/review-brief.ts +18 -0
- package/src/hub/routing.ts +14 -3
- package/src/hub/runtime-capture-prune.ts +4 -1
- package/src/hub/sdlc-release-authorization.ts +13 -4
- package/src/hub/sdlc-release.ts +23 -10
- package/src/hub/sdlc.ts +15 -2
- 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/task-worktree.ts +331 -0
- package/src/hub/tasks.ts +606 -53
- package/src/hub/terminal-open.ts +10 -5
- package/src/hub/tool-request.ts +131 -0
- package/src/local/deny.ts +49 -0
- package/src/local/tools.ts +4 -2
- package/src/memory/brief.ts +5 -0
- package/src/memory/client.ts +5 -2
- package/src/ui/office-model.ts +17 -1
- package/src/ui/office-space.ts +1 -1
- package/templates/config.json +1 -0
- 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/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,23 +64,26 @@ 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";
|
|
71
|
-
import { assign, isRemotePeerId, detectSignals, peerEntry, predictSplit, relayCandidates, type Assignment, type InstanceRoutingOptions, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
71
|
+
import { assign, isRemotePeerId, detectSignals, peerEntry, predictSplit, relayCandidates, writesRepository, type Assignment, type InstanceRoutingOptions, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
72
72
|
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
73
73
|
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
74
74
|
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 {
|
|
78
|
+
import type { TaskWorktreePort } from "./task-worktree.ts";
|
|
79
|
+
import { coverageGap, renderReviewBrief, renderSpecTaskReport, type BriefFile, type ReviewBrief } from "./review-brief.ts";
|
|
80
|
+
import { DEFAULT_SPECS_CONFIG, type SpecsConfig } from "./specs-config.ts";
|
|
81
|
+
import type { SpecsCheckReport, SpecsCoverReport } from "./specs.ts";
|
|
79
82
|
import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
|
|
80
83
|
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";
|
|
84
|
+
import { plannerGrooming, nextSweep, plannedStartPending, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
82
85
|
import { CiWatchers, pollCi, type CiOutcome, type CiWatch } from "./ci-watch.ts";
|
|
83
|
-
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";
|
|
86
|
+
import { planningIssueReceipt, linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSdlcPrHead, 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
87
|
|
|
85
88
|
function requireLocalTaskPeer(peer: unknown): void {
|
|
86
89
|
if (isRemotePeerId(peer)) throw new PreEffectToolRefusal("remote peers have no local task authority");
|
|
@@ -88,6 +91,15 @@ function requireLocalTaskPeer(peer: unknown): void {
|
|
|
88
91
|
|
|
89
92
|
export type TaskAssignmentRole = "owner" | "reviewer" | "planner";
|
|
90
93
|
|
|
94
|
+
interface PreparedSpecInspection {
|
|
95
|
+
report?: SpecTaskReport;
|
|
96
|
+
policyKey: string;
|
|
97
|
+
repo?: string;
|
|
98
|
+
head: string;
|
|
99
|
+
privateTurn: boolean;
|
|
100
|
+
pii: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
91
103
|
export interface ReleaseWindow { at: number; host: boolean; quota: boolean; idle: boolean; reasons: string[]; current?: () => boolean; close?: () => Promise<void>; }
|
|
92
104
|
|
|
93
105
|
export interface TasksDeps {
|
|
@@ -134,6 +146,14 @@ export interface TasksDeps {
|
|
|
134
146
|
/** #520: the review brief for a task's commit (git only, bounded); undefined when the task names no commit. */
|
|
135
147
|
prepareReviewBrief?: (task: Task) => Promise<void>;
|
|
136
148
|
reviewBrief?: (task: Task) => ReviewBrief | { unavailable: string } | undefined;
|
|
149
|
+
/** Current public coverage for a task's declared paths; absent when notices are disabled or no specs exist. */
|
|
150
|
+
specNotice?: (task: Task) => SpecsCoverReport | undefined;
|
|
151
|
+
/** Inspect spec metadata at a verified task head; undefined is authoritative quiet (no specs/stamps or review off). */
|
|
152
|
+
inspectSpecs?: (task: Task, head: string, changedFiles: readonly BriefFile[] | undefined, acks: readonly SpecAck[]) => Promise<SpecTaskReport | undefined>;
|
|
153
|
+
/** Effective shared and machine-local specs policy. */
|
|
154
|
+
specsConfig?: () => SpecsConfig;
|
|
155
|
+
/** Explicit, read-only inspection remains available when task notices are disabled. */
|
|
156
|
+
specsRead?: (action: "check" | "covers", paths: readonly string[]) => SpecsCheckReport | SpecsCoverReport;
|
|
137
157
|
/** Host-verified existing symbols at this task's admitted completion head; never plan guesses. */
|
|
138
158
|
memorySymbolNodes?: (task: Task) => MemorySymbolNodes | undefined;
|
|
139
159
|
sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
|
|
@@ -198,6 +218,8 @@ export interface TasksDeps {
|
|
|
198
218
|
review?: { adaptive: boolean; min_reviews: number; pool?: readonly string[]; rerotate?: boolean; same_family?: "fallback" | "never" };
|
|
199
219
|
/** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
|
|
200
220
|
capabilities?: () => Record<PeerId, PeerCapabilities>;
|
|
221
|
+
/** #1115: the hub makes, commits and clears the task worktree of a hub-sandboxed peer. Absent: such a peer keeps the #1081 refusal. */
|
|
222
|
+
taskWorktree?: TaskWorktreePort;
|
|
201
223
|
repository?: string;
|
|
202
224
|
offerRead?: (peer: PeerId, envelopeId: string) => boolean;
|
|
203
225
|
offerPullOnly?: (peer: PeerId) => boolean;
|
|
@@ -375,7 +397,7 @@ function cleanPlan(input: unknown, profile: ProfileSelection = "dev"): TaskPlan
|
|
|
375
397
|
if (items.length) evidence[key] = items;
|
|
376
398
|
}
|
|
377
399
|
let scope: CostPlan;
|
|
378
|
-
try { scope = cleanCostPlan(r); } catch { throw new PreEffectToolRefusal("invalid review or CI cost scope"); }
|
|
400
|
+
try { scope = cleanCostPlan(r); } catch (error) { throw new PreEffectToolRefusal(error instanceof CostScopeError ? error.message : "invalid review or CI cost scope"); }
|
|
379
401
|
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
402
|
}
|
|
381
403
|
|
|
@@ -410,6 +432,7 @@ export interface ExperimentView { attempts: ExperimentAttemptView[]; counts: { a
|
|
|
410
432
|
comparison?: { planHash: string; baselineAttemptId: string; variantAttemptId: string; approved: boolean; private: boolean; projection: ReturnType<typeof comparisonProjection> | "[pii]" } }
|
|
411
433
|
interface ExperimentContext { task: Task; campaign: ResearchCampaignRecord; private: boolean; key: string; fence: ExperimentWriteFence }
|
|
412
434
|
interface CompletionRefresh { revision: number; commit: string; state: "in_review" | "approved" }
|
|
435
|
+
interface RefileHeadGuard { recorded?: string }
|
|
413
436
|
interface IdentityLink { repo: string; pr: number; basis: { commit?: string; branch?: string } }
|
|
414
437
|
|
|
415
438
|
/** What one live check of a task's files says as a whole (#317): `unchanged` only when EVERY file measured unchanged. A change outranks a missing file, which outranks one the hub could not read. */
|
|
@@ -563,7 +586,7 @@ export class Tasks {
|
|
|
563
586
|
for (const task of this.d.board.list()) {
|
|
564
587
|
if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) break;
|
|
565
588
|
if (!task.owner && this.personUnassigned(task)) continue;
|
|
566
|
-
const finding = nextSweep(task, config, now, this.waitsFor(task).length === 0);
|
|
589
|
+
const finding = nextSweep(task, config, now, this.waitsFor(task).length === 0 && !plannedStartPending(task, now));
|
|
567
590
|
if (!finding || this.waitsFor(task).length || this.isChecking(task.id) || this.silentFor(task.id)) continue;
|
|
568
591
|
if (finding.kind === "ready-unassigned") {
|
|
569
592
|
this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
|
|
@@ -906,6 +929,8 @@ export class Tasks {
|
|
|
906
929
|
if (rest.sdlc?.redFirst && !this.publicMetadataText(JSON.stringify(rest.sdlc.redFirst))) {
|
|
907
930
|
const { redFirst: _withheld, ...receipt } = rest.sdlc; rest.sdlc = receipt;
|
|
908
931
|
}
|
|
932
|
+
const currentSpecReport = this.currentSpecReport(task);
|
|
933
|
+
const currentSpecEntry = currentSpecReport ? history.findLast(entry => entry.event === "done" || entry.event === "done (checking)") : undefined;
|
|
909
934
|
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
935
|
const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
|
|
911
936
|
at: h.at, by: h.by, event: h.event,
|
|
@@ -933,16 +958,23 @@ export class Tasks {
|
|
|
933
958
|
...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
|
|
934
959
|
...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
|
|
935
960
|
...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
|
|
961
|
+
...(h === currentSpecEntry && currentSpecReport ? { specReport: currentSpecReport } : {}),
|
|
936
962
|
}));
|
|
937
963
|
const sets = this.reviewFindingSets(task), current = findingsForHead(sets, this.findingHead(task));
|
|
938
964
|
const symbolRepository = codeSymbols && this.codeSymbolsRepository(task);
|
|
939
965
|
const publicSymbols = codeSymbols && symbolRepository ? publicTaskCodeSymbols(codeSymbols, this.reviewHead(task), symbolRepository.key, this.publicMetadataText) : undefined;
|
|
940
966
|
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 };
|
|
967
|
+
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
968
|
}
|
|
943
969
|
|
|
944
970
|
/** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
|
|
945
|
-
|
|
971
|
+
specReportText(task: Task, by?: PeerId): string {
|
|
972
|
+
if (by && this.d.privateTurn?.(by)) return "";
|
|
973
|
+
const report = this.currentSpecReport(task);
|
|
974
|
+
return report ? renderSpecTaskReport(report) : "";
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
resultLine(task: Task, by?: PeerId): string {
|
|
946
978
|
const base = `task #${task.id}: ${task.state}, owner ${task.owner ?? "none"}, reviewer ${task.reviewer ?? "none"}`;
|
|
947
979
|
const noReview = task.class !== "review" && !task.reviewer && OPEN.includes(task.state) && !this.waitsFor(task).length
|
|
948
980
|
? 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 +983,250 @@ export class Tasks {
|
|
|
951
983
|
const holdText = held ? `${task.owner}'s queue is held (${held}); it receives the task once the hold is resolved` : undefined;
|
|
952
984
|
const request = task.history.findLast(entry => ["done", "reviewer changed", "reviewer unavailable"].includes(entry.event));
|
|
953
985
|
const poolBlocked = task.state === "in_review" && task.reviewer === USER && request?.reviewBlocked ? "blocked: no reviewer can run" : undefined;
|
|
954
|
-
|
|
986
|
+
const specText = this.specReportText(task, by) || undefined;
|
|
987
|
+
return [base, noReview, poolBlocked, holdText, specText].filter(Boolean).join("; ");
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
private currentSpecReport(task: Task): SpecTaskReport | undefined {
|
|
991
|
+
const config = this.d.specsConfig?.() ?? DEFAULT_SPECS_CONFIG;
|
|
992
|
+
if (this.isPii(task) || !config.enabled || config.review === "off") return;
|
|
993
|
+
const head = task.refs.commit && /^[0-9a-f]{40}$/.test(task.refs.commit) ? task.refs.commit : "";
|
|
994
|
+
const binding = this.specRefBinding(task);
|
|
995
|
+
const latest = task.history.findLast(row => row.event === "done" || row.event === "done (checking)");
|
|
996
|
+
const report = latest?.specReport;
|
|
997
|
+
if (!latest || !report || !(report.head === head || report.head === "" && !report.complete) || latest.specRefBinding !== binding) return;
|
|
998
|
+
return this.publicSpecReport(report);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
private specRefBinding(task: Pick<Task, "refs">): string {
|
|
1002
|
+
return createHash("sha256").update(JSON.stringify([task.refs.repo ?? "", task.refs.branch ?? "", task.refs.commit ?? ""])).digest("hex");
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
private publicSpecReport(report: SpecTaskReport): SpecTaskReport | undefined {
|
|
1006
|
+
if (!report || typeof report.head !== "string" || report.head !== "" && !/^[0-9a-f]{40}$/.test(report.head)
|
|
1007
|
+
|| report.complete && !/^[0-9a-f]{40}$/.test(report.head)
|
|
1008
|
+
|| typeof report.complete !== "boolean" || !Number.isSafeInteger(report.errors) || report.errors < 0
|
|
1009
|
+
|| !Array.isArray(report.unchanged) || !Array.isArray(report.changed)) return;
|
|
1010
|
+
const ref = (value: unknown): SpecTaskReport["unchanged"][number] | undefined => {
|
|
1011
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
1012
|
+
const row = value as Record<string, unknown>;
|
|
1013
|
+
if (typeof row.id !== "string" || typeof row.path !== "string" || typeof row.title !== "string"
|
|
1014
|
+
|| !row.id.trim() || row.id.length > 160 || !row.path.trim()) return;
|
|
1015
|
+
// Missing titles are schema diagnostics, not a report-mode completion gate.
|
|
1016
|
+
const title = row.title.trim() ? row.title : row.id;
|
|
1017
|
+
const strings = [row.id, row.path, title, ...(typeof row.reason === "string" ? [row.reason] : [])];
|
|
1018
|
+
if (strings.some(text => text.length > 1000 || /[\p{Cc}\p{Cf}]/u.test(text) || !this.publicMetadataText(text))) return;
|
|
1019
|
+
return { id: row.id, path: row.path, title, ...(typeof row.reason === "string" ? { reason: row.reason } : {}) };
|
|
1020
|
+
};
|
|
1021
|
+
const unchanged = report.unchanged.slice(0, 20).map(ref).filter((row): row is NonNullable<typeof row> => !!row);
|
|
1022
|
+
const changed = report.changed.slice(0, 20).map(ref).filter((row): row is NonNullable<typeof row> => !!row);
|
|
1023
|
+
const privacyFiltered = unchanged.length < Math.min(report.unchanged.length, 20) || changed.length < Math.min(report.changed.length, 20);
|
|
1024
|
+
const reason = privacyFiltered ? "some spec metadata could not be safely shown"
|
|
1025
|
+
: typeof report.reason === "string" && report.reason.length <= 500 && this.publicMetadataText(report.reason) ? report.reason : undefined;
|
|
1026
|
+
return { head: report.head, complete: report.complete && !privacyFiltered, errors: report.errors, unchanged, changed,
|
|
1027
|
+
...(report.truncated || report.unchanged.length > 20 || report.changed.length > 20 ? { truncated: true } : {}), ...(reason ? { reason } : {}) };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
private parseSpecAcks(value: unknown): SpecAck[] {
|
|
1031
|
+
if (value === undefined) return [];
|
|
1032
|
+
if (!Array.isArray(value) || value.length > 20) throw new PreEffectToolRefusal("spec_ack must contain at most 20 {id, reason} entries");
|
|
1033
|
+
const seen = new Set<string>();
|
|
1034
|
+
return value.map(raw => {
|
|
1035
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new PreEffectToolRefusal("each spec_ack entry needs an id and reason");
|
|
1036
|
+
const row = raw as Record<string, unknown>;
|
|
1037
|
+
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)
|
|
1038
|
+
|| 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");
|
|
1039
|
+
const id = row.id.trim();
|
|
1040
|
+
if (!this.publicMetadataText(id)) throw new PreEffectToolRefusal("spec_ack id is private or sensitive");
|
|
1041
|
+
if (seen.has(id)) throw new PreEffectToolRefusal(`spec_ack repeats ${id}`);
|
|
1042
|
+
seen.add(id);
|
|
1043
|
+
return { id, reason: row.reason.trim() };
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
private specsPolicyKey(config = this.d.specsConfig?.() ?? DEFAULT_SPECS_CONFIG): string {
|
|
1048
|
+
return JSON.stringify([config.enabled, config.review, config.roots, config.stamps_file]);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
private assertSpecInspection(inspection: PreparedSpecInspection, task: Task, by: PeerId): void {
|
|
1052
|
+
this.assertSpecTaskBinding(inspection, task);
|
|
1053
|
+
if (this.specsPolicyKey() !== inspection.policyKey || (this.d.privateTurn?.(by) === true) !== inspection.privateTurn || this.isPii(task) !== inspection.pii) {
|
|
1054
|
+
throw new PreEffectToolRefusal(`task #${task.id}: specs policy, task head or privacy context changed during inspection; retry hub_task_done`);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
private assertSpecTaskBinding(inspection: PreparedSpecInspection, task: Task): void {
|
|
1059
|
+
const head = task.refs.commit && /^[0-9a-f]{40}$/.test(task.refs.commit) ? task.refs.commit : "";
|
|
1060
|
+
if (task.refs.repo !== inspection.repo || head !== inspection.head) {
|
|
1061
|
+
throw new PreEffectToolRefusal(`task #${task.id}: task head or repository changed during spec inspection; retry hub_task_done`);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
private refreshAsyncSpecInspection(inspection: PreparedSpecInspection, task: Task, by: PeerId): boolean {
|
|
1066
|
+
this.assertSpecTaskBinding(inspection, task);
|
|
1067
|
+
const config = this.d.specsConfig?.() ?? DEFAULT_SPECS_CONFIG;
|
|
1068
|
+
const privateTurn = this.d.privateTurn?.(by) === true;
|
|
1069
|
+
const policyKey = this.specsPolicyKey(config), pii = this.isPii(task);
|
|
1070
|
+
if (inspection.report && policyKey !== inspection.policyKey && config.enabled && config.review === "require" && !privateTurn && !pii) return true;
|
|
1071
|
+
if (policyKey !== inspection.policyKey || privateTurn !== inspection.privateTurn || pii !== inspection.pii) {
|
|
1072
|
+
delete inspection.report;
|
|
1073
|
+
inspection.policyKey = policyKey;
|
|
1074
|
+
inspection.privateTurn = privateTurn;
|
|
1075
|
+
inspection.pii = pii;
|
|
1076
|
+
}
|
|
1077
|
+
return false;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
private requestSpecRefile(task: Task): void {
|
|
1081
|
+
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));
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
private specInspectionReason(cause: string): string {
|
|
1085
|
+
const safe = this.publicMetadataText(cause) ? sanitize(cause).replace(/[\r\n\t]+/g, " ").trim().slice(0, 350) : "verified spec evidence is unavailable";
|
|
1086
|
+
return `${safe}; split the task scope or ask the person to change specs.review`;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
private async inspectTaskSpecs(task: Task, by: PeerId, rawAcks: unknown, validate: () => Task): Promise<PreparedSpecInspection> {
|
|
1090
|
+
const config = this.d.specsConfig?.() ?? DEFAULT_SPECS_CONFIG;
|
|
1091
|
+
const initialPrivate = this.d.privateTurn?.(by) === true, pii = this.isPii(task);
|
|
1092
|
+
const declaredHead = task.refs.commit && /^[0-9a-f]{40}$/.test(task.refs.commit) ? task.refs.commit : "";
|
|
1093
|
+
const inspection: PreparedSpecInspection = { policyKey: this.specsPolicyKey(config), ...(task.refs.repo ? { repo: task.refs.repo } : {}), head: declaredHead, privateTurn: initialPrivate, pii };
|
|
1094
|
+
const ackInput = Array.isArray(rawAcks) && rawAcks.length === 0 ? undefined : rawAcks;
|
|
1095
|
+
if (ackInput !== undefined && (pii || initialPrivate)) throw new PreEffectToolRefusal("spec_ack is unavailable for private work");
|
|
1096
|
+
if (ackInput !== undefined && (!config.enabled || config.review === "off")) throw new PreEffectToolRefusal("spec_ack is unavailable while specs review is disabled");
|
|
1097
|
+
if (!config.enabled || config.review === "off" || pii || initialPrivate) return inspection;
|
|
1098
|
+
if (!this.d.inspectSpecs) {
|
|
1099
|
+
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`);
|
|
1100
|
+
if (ackInput !== undefined) throw new PreEffectToolRefusal("spec inspection is unavailable; cannot accept spec_ack");
|
|
1101
|
+
return inspection;
|
|
1102
|
+
}
|
|
1103
|
+
const acks = this.parseSpecAcks(ackInput);
|
|
1104
|
+
let changedFiles: BriefFile[] | undefined;
|
|
1105
|
+
let changedEvidenceReason: string | undefined;
|
|
1106
|
+
if (declaredHead) {
|
|
1107
|
+
const brief = this.d.reviewBrief?.({ ...task, refs: { ...task.refs, commit: declaredHead } });
|
|
1108
|
+
if (!brief) changedEvidenceReason = "the verified review brief is unavailable";
|
|
1109
|
+
else if ("unavailable" in brief) changedEvidenceReason = `review brief unavailable: ${brief.unavailable}`;
|
|
1110
|
+
else if (brief.head !== declaredHead) changedEvidenceReason = "the verified review brief does not match the submitted full head";
|
|
1111
|
+
else if (brief.withheld > 0) changedEvidenceReason = `the verified review brief withheld ${brief.withheld} changed file(s)`;
|
|
1112
|
+
else if (brief.unlisted.length > 0) changedEvidenceReason = `the verified review brief has ${brief.unlisted.length} unlisted changed file(s)`;
|
|
1113
|
+
else {
|
|
1114
|
+
const files = [...brief.reviewable, ...brief.excluded].flatMap(file => [file, ...(file.oldPath ? [{ path: file.oldPath, status: file.status }] : [])]);
|
|
1115
|
+
if (files.every(file => this.publicMetadataText(file.path))) {
|
|
1116
|
+
const byPath = new Map<string, BriefFile>();
|
|
1117
|
+
for (const file of files) if (!byPath.has(file.path)) byPath.set(file.path, file);
|
|
1118
|
+
changedFiles = [...byPath.values()];
|
|
1119
|
+
} else changedEvidenceReason = "the verified changed-file inventory includes private paths";
|
|
1120
|
+
}
|
|
1121
|
+
} else changedEvidenceReason = "a full task commit head was not supplied";
|
|
1122
|
+
const screenedAcks: SpecAck[] = [];
|
|
1123
|
+
for (const ack of acks) {
|
|
1124
|
+
if (!this.publicMetadataText(ack.reason)) throw new PreEffectToolRefusal("spec_ack reason is private or sensitive");
|
|
1125
|
+
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`);
|
|
1126
|
+
screenedAcks.push(ack);
|
|
1127
|
+
this.assertSpecInspection(inspection, validate(), by);
|
|
1128
|
+
}
|
|
1129
|
+
this.assertSpecInspection(inspection, validate(), by);
|
|
1130
|
+
const report = await this.d.inspectSpecs(task, declaredHead, changedFiles, screenedAcks);
|
|
1131
|
+
this.assertSpecInspection(inspection, validate(), by);
|
|
1132
|
+
if (!report) {
|
|
1133
|
+
if (acks.length) throw new PreEffectToolRefusal("spec_ack was supplied but no spec requires acknowledgement");
|
|
1134
|
+
return inspection;
|
|
1135
|
+
}
|
|
1136
|
+
const safe = this.publicSpecReport(report);
|
|
1137
|
+
if (!safe) throw new PreEffectToolRefusal("spec inspection returned invalid or private metadata");
|
|
1138
|
+
if (safe.unchanged.length < Math.min(report.unchanged.length, 20) || safe.changed.length < Math.min(report.changed.length, 20)) {
|
|
1139
|
+
safe.complete = false;
|
|
1140
|
+
safe.reason = this.specInspectionReason("some spec metadata could not be safely shown");
|
|
1141
|
+
}
|
|
1142
|
+
if (safe.head !== declaredHead) {
|
|
1143
|
+
safe.complete = false;
|
|
1144
|
+
safe.head = "";
|
|
1145
|
+
safe.reason = this.specInspectionReason("the spec report does not match the verified task head");
|
|
1146
|
+
safe.unchanged = [];
|
|
1147
|
+
safe.changed = [];
|
|
1148
|
+
}
|
|
1149
|
+
if (report.truncated) {
|
|
1150
|
+
safe.complete = false;
|
|
1151
|
+
safe.reason = this.specInspectionReason("the submitted spec scan reached its safety limit; narrow specs.roots");
|
|
1152
|
+
} else if (changedFiles === undefined && changedEvidenceReason && safe.head === declaredHead) {
|
|
1153
|
+
safe.complete = false;
|
|
1154
|
+
safe.reason = this.specInspectionReason(changedEvidenceReason);
|
|
1155
|
+
} else if (!safe.complete && safe.reason) safe.reason = this.specInspectionReason(safe.reason);
|
|
1156
|
+
else if (!safe.complete) safe.reason = this.specInspectionReason(report.reason ?? "spec coverage could not be verified at the submitted task head");
|
|
1157
|
+
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`);
|
|
1158
|
+
const pendingIds = new Set(safe.unchanged.map(spec => spec.id));
|
|
1159
|
+
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`);
|
|
1160
|
+
const reasons = new Map(screenedAcks.map(ack => [ack.id, ack.reason]));
|
|
1161
|
+
safe.unchanged = safe.unchanged.map(spec => ({ ...spec, ...(reasons.has(spec.id) ? { reason: reasons.get(spec.id)! } : {}) }));
|
|
1162
|
+
if (config.review === "require") {
|
|
1163
|
+
if (!safe.complete || report.truncated) throw new PreEffectToolRefusal(`task #${task.id}: ${safe.reason ?? this.specInspectionReason("spec coverage is unknown at this submitted task head")}`);
|
|
1164
|
+
const unacknowledged = safe.unchanged.filter(spec => !reasons.has(spec.id));
|
|
1165
|
+
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}]`);
|
|
1166
|
+
}
|
|
1167
|
+
inspection.report = safe;
|
|
1168
|
+
return inspection;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/** Narrow public task-path notice used by the tool response after propose/accept. */
|
|
1172
|
+
specNotice(task: Task, by?: PeerId): string {
|
|
1173
|
+
const config = this.d.specsConfig?.() ?? DEFAULT_SPECS_CONFIG;
|
|
1174
|
+
if (!config.enabled || config.review === "off" || this.isPii(task) || by && this.d.privateTurn?.(by)) return "";
|
|
1175
|
+
let report: SpecsCoverReport | undefined;
|
|
1176
|
+
try { report = this.d.specNotice?.(task); } catch { return ""; }
|
|
1177
|
+
const current = this.d.board.get(task.id);
|
|
1178
|
+
if (!current || this.isPii(current) || by && this.d.privateTurn?.(by)
|
|
1179
|
+
|| current.history.length !== task.history.length || current.owner !== task.owner || current.refs.repo !== task.refs.repo
|
|
1180
|
+
|| JSON.stringify(current.refs.paths ?? []) !== JSON.stringify(task.refs.paths ?? [])) return "";
|
|
1181
|
+
if (!report) return "";
|
|
1182
|
+
const refs = report.specs.slice(0, 20).filter(spec => [spec.id, spec.path, spec.title].every(value => this.publicMetadataText(value)));
|
|
1183
|
+
const lines = refs.length ? [`Specs covering these paths: ${refs.map(spec => `${spec.id} (${spec.path})`).join(", ")}`] : [];
|
|
1184
|
+
if (report.errors) lines.push(`Spec coverage check reports ${report.errors} error${report.errors === 1 ? "" : "s"}; run ahub specs check.`);
|
|
1185
|
+
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.");
|
|
1186
|
+
return sanitize(lines.join("\n"));
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
specsRead(by: PeerId, action: "check" | "covers", rawPaths: unknown = []): Record<string, unknown> {
|
|
1190
|
+
if (this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("spec inspection unavailable during a private turn");
|
|
1191
|
+
if (!this.d.specsRead) throw new PreEffectToolRefusal("spec inspection unavailable");
|
|
1192
|
+
if (action !== "check" && action !== "covers") throw new PreEffectToolRefusal('hub_specs action must be "check" or "covers"');
|
|
1193
|
+
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)
|
|
1194
|
+
|| 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");
|
|
1195
|
+
const paths = rawPaths as string[];
|
|
1196
|
+
if (action === "check" && paths.length) throw new PreEffectToolRefusal('hub_specs "check" takes no paths');
|
|
1197
|
+
if (action === "covers" && paths.length === 0) throw new PreEffectToolRefusal('hub_specs "covers" needs at least one repository-relative path');
|
|
1198
|
+
const safe = (value: string) => sanitize(value);
|
|
1199
|
+
const diagnostic = (raw: SpecsCheckReport["items"][number]): Record<string, unknown> | undefined => {
|
|
1200
|
+
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] : [])];
|
|
1201
|
+
if (fields.some(value => !this.publicMetadataText(value))) return;
|
|
1202
|
+
return { severity: raw.severity, code: safe(raw.code), message: safe(raw.message), cause: safe(raw.cause), nextSteps: safe(raw.nextSteps),
|
|
1203
|
+
...(raw.path ? { path: safe(raw.path) } : {}), ...(raw.id ? { id: safe(raw.id) } : {}), ...(raw.field ? { field: safe(raw.field) } : {}), ...(raw.relatedId ? { relatedId: safe(raw.relatedId) } : {}) };
|
|
1204
|
+
};
|
|
1205
|
+
let report: SpecsCheckReport | SpecsCoverReport;
|
|
1206
|
+
try { report = this.d.specsRead(action, paths); }
|
|
1207
|
+
catch { throw new PreEffectToolRefusal("spec inspection unavailable; retry ahub specs check"); }
|
|
1208
|
+
if (this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("spec inspection unavailable during a private turn");
|
|
1209
|
+
try {
|
|
1210
|
+
if ("freshness" in report) {
|
|
1211
|
+
const visibleItems = report.items.map(diagnostic).filter((row): row is Record<string, unknown> => !!row);
|
|
1212
|
+
const items = visibleItems.slice(0, 20);
|
|
1213
|
+
const remaining = Math.max(0, 20 - items.length);
|
|
1214
|
+
const visibleFreshness = report.freshness.filter(row => this.publicMetadataText(row.path) && (row.id === undefined || this.publicMetadataText(row.id)));
|
|
1215
|
+
const freshness = visibleFreshness.slice(0, remaining)
|
|
1216
|
+
.map(row => ({ path: safe(row.path), ...(row.id ? { id: safe(row.id) } : {}), state: row.state, commitsSince: row.commitsSince, dirty: row.dirty }));
|
|
1217
|
+
return { specCount: report.specCount, errors: report.errors, warnings: report.warnings, stale: report.stale,
|
|
1218
|
+
truncated: report.truncated || visibleItems.length > 20 || visibleItems.length !== report.items.length
|
|
1219
|
+
|| visibleFreshness.length > freshness.length || visibleFreshness.length !== report.freshness.length, freshness, items };
|
|
1220
|
+
}
|
|
1221
|
+
const visibleSpecs = report.specs.filter(row => [row.id, row.path, row.title].every(value => this.publicMetadataText(value)));
|
|
1222
|
+
const specs = visibleSpecs.slice(0, 20)
|
|
1223
|
+
.map(({ id, path, title }) => ({ id: safe(id), path: safe(path), title: safe(title) }));
|
|
1224
|
+
const visibleItems = report.items.map(diagnostic).filter((row): row is Record<string, unknown> => !!row);
|
|
1225
|
+
const remaining = Math.max(0, 20 - specs.length);
|
|
1226
|
+
const items = visibleItems.slice(0, remaining);
|
|
1227
|
+
return { specs, errors: report.errors, truncated: report.truncated || visibleSpecs.length > specs.length || visibleSpecs.length !== report.specs.length
|
|
1228
|
+
|| visibleItems.length > remaining || visibleItems.length !== report.items.length, items };
|
|
1229
|
+
} catch { throw new PreEffectToolRefusal("spec inspection unavailable; retry ahub specs check"); }
|
|
955
1230
|
}
|
|
956
1231
|
|
|
957
1232
|
private states(): Record<PeerId, PeerState> {
|
|
@@ -1064,6 +1339,38 @@ export class Tasks {
|
|
|
1064
1339
|
|
|
1065
1340
|
private reviewHead(task: Task): string | undefined { return task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; }
|
|
1066
1341
|
|
|
1342
|
+
private recordedReviewHead(task: Task): string | undefined {
|
|
1343
|
+
const isFullHead = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{40}$/.test(value);
|
|
1344
|
+
const historyHead = task.history.findLast(entry => (entry.event === "approved" || entry.event === "changes_requested") && isFullHead(entry.reviewedHead))?.reviewedHead;
|
|
1345
|
+
return [task.sdlc?.reviewedHead, historyHead].find(isFullHead);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/** An owner ref, when present, outranks host-derived receipt.head values for review identity. */
|
|
1349
|
+
private ownerReviewHead(task: Task): string | undefined {
|
|
1350
|
+
return /^[a-f0-9]{40}$/.test(task.refs.commit ?? "") ? task.refs.commit : this.recordedReviewHead(task);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
private hasReviewHistory(task: Task): boolean {
|
|
1354
|
+
return task.state === "changes_requested" || !!task.sdlc?.reviewedHead || task.history.some(entry => entry.event === "approved" || entry.event === "changes_requested");
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
private reviewHeadMoveRefusal(taskId: number, recorded: string | undefined, current?: string): PreEffectToolRefusal {
|
|
1358
|
+
const known = recorded ? `recorded review head ${recorded}` : "a recorded full review head";
|
|
1359
|
+
const cause = current ? `${known} differs from current head of PR ${current}`
|
|
1360
|
+
: `current PR head could not be verified against ${known}`;
|
|
1361
|
+
const next = current ? `refs.commit set to ${current}` : "refs.commit set to the current full SHA";
|
|
1362
|
+
return new PreEffectToolRefusal(`task #${taskId}: ${cause}; owner must re-file hub_task_done with ${next} before review`);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
private async currentPrHeadForReview(task: Task, validate: () => Task): Promise<string | undefined> {
|
|
1366
|
+
const receipt = task.sdlc, gh = this.d.sdlc?.gh;
|
|
1367
|
+
if (this.isPii(task) || !receipt?.repo || !receipt.pr || !gh) return undefined;
|
|
1368
|
+
const identity = (value: Task) => JSON.stringify([value.id, value.state, value.owner, value.reviewer, value.history.length, this.isPii(value), value.refs, value.sdlc]);
|
|
1369
|
+
const before = identity(task), head = await readSdlcPrHead(receipt, gh), current = validate();
|
|
1370
|
+
if (identity(current) !== before) throw new PreEffectToolRefusal(`task #${task.id}: task or review head changed during PR readback; reload and retry`);
|
|
1371
|
+
return head;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1067
1374
|
private reviewPoolOptions(task: Task) {
|
|
1068
1375
|
const head = this.reviewHead(task);
|
|
1069
1376
|
const round = task.state === "in_review" ? task.history.slice(task.history.findLastIndex(entry => entry.event === "done") + 1) : [];
|
|
@@ -1100,6 +1407,27 @@ export class Tasks {
|
|
|
1100
1407
|
return { ...(family ? { reviewerFamily: family } : {}), ...(reason ? { reviewerReason: reason } : {}), ...(assignment?.reviewBlocked ? { reviewBlocked: true } : {}), ...(queueReviewHeld ? { queueReviewHeld: true as const, ...(queueReviewBinding !== undefined ? { queueReviewBinding } : {}) } : {}) };
|
|
1101
1408
|
}
|
|
1102
1409
|
|
|
1410
|
+
/**
|
|
1411
|
+
* #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
|
|
1412
|
+
* 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
|
|
1413
|
+
* sandboxed peer from accepting work, failing at `git worktree add` (#270) and leaving an empty branch ref behind (#621, #565). Unknown capability is
|
|
1414
|
+
* not a refusal, and a PII task skips the check as `requireCapabilities` does.
|
|
1415
|
+
*/
|
|
1416
|
+
private declaredNeedCheck(task: Pick<Task, "signals" | "id"> & Partial<Pick<Task, "signals" | "class">>, peer: PeerId): void {
|
|
1417
|
+
if (!this.d.capabilities || this.isPii(task as Task)) return;
|
|
1418
|
+
// #1129: write-class work needs the hub's worktree whether or not it says so, because a hub-sandboxed owner cannot commit.
|
|
1419
|
+
const declaredWorktree = task.signals?.includes("needs_worktree") === true;
|
|
1420
|
+
const wantsNetwork = task.signals?.includes("needs_network") === true, wantsWorktree = declaredWorktree || (task.class !== undefined && writesRepository(task.class));
|
|
1421
|
+
if (!wantsNetwork && !wantsWorktree) return;
|
|
1422
|
+
const caps = peerEntry(this.d.capabilities(), peer);
|
|
1423
|
+
if (wantsNetwork && caps?.network === false) {
|
|
1424
|
+
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`);
|
|
1425
|
+
}
|
|
1426
|
+
if (wantsWorktree && caps?.worktree === false) {
|
|
1427
|
+
throw new PreEffectToolRefusal(`task #${task.id} ${declaredWorktree ? "needs its own git worktree" : "changes the repository, so the hub must make its git worktree and commit for it"} 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`);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1103
1431
|
/** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
|
|
1104
1432
|
private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: Exclude<TaskAssignmentRole, "planner"> = "owner", preEffect = true): void {
|
|
1105
1433
|
requireLocalTaskPeer(peer);
|
|
@@ -1108,7 +1436,7 @@ export class Tasks {
|
|
|
1108
1436
|
if ((!this.d.capabilities || this.isPii(task)) && !declaredExtra) return;
|
|
1109
1437
|
const { state: _state, ...draft } = task; // backlog/readiness must not bypass an explicit reservation's admission
|
|
1110
1438
|
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}`);
|
|
1439
|
+
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
1440
|
if (missing.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot assign ${peer}: ${missing.join("; ")}`);
|
|
1113
1441
|
}
|
|
1114
1442
|
|
|
@@ -2188,6 +2516,7 @@ export class Tasks {
|
|
|
2188
2516
|
/** Write-driven wake-ups (#785): one unsettled ids-only task notice per planner, never a polling pull. */
|
|
2189
2517
|
private writePlannerNotices(task: Task, before: ReadonlyMap<PeerId, number>, approved = false): void {
|
|
2190
2518
|
this.releaseNotice(`task #${task.id}: could not notify planner about ready work`, () => {
|
|
2519
|
+
const now = this.d.now?.() ?? Date.now();
|
|
2191
2520
|
const changes = new Map<PeerId, { capacity: Set<PeerId>; ready: Set<number> }>();
|
|
2192
2521
|
const add = (planner: PeerId, peer?: PeerId, id?: number) => {
|
|
2193
2522
|
if (!this.d.bus.isRostered(planner)) return;
|
|
@@ -2199,7 +2528,7 @@ export class Tasks {
|
|
|
2199
2528
|
const released = new Set<PeerId>();
|
|
2200
2529
|
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
2530
|
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);
|
|
2531
|
+
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
2532
|
}
|
|
2204
2533
|
for (const [planner, change] of changes) this.releaseNotice(`could not notify planner ${planner} about ready work`, () => {
|
|
2205
2534
|
const pending = this.d.bus.pendingEnvelopes(planner).filter(env => env.from === HUB && env.kind === "task" && env.body.startsWith("Planner work ready:"));
|
|
@@ -2217,7 +2546,7 @@ export class Tasks {
|
|
|
2217
2546
|
}
|
|
2218
2547
|
for (const id of change.ready) {
|
|
2219
2548
|
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}`);
|
|
2549
|
+
if (card?.state === "backlog" && this.taskPlanner(card).includes(planner) && !this.waitsFor(card).length && !plannedStartPending(card, now)) lines.push(`backlog_ready: #${id}`);
|
|
2221
2550
|
else change.ready.delete(id);
|
|
2222
2551
|
}
|
|
2223
2552
|
for (const env of pending) this.d.bus.withdraw(env.id);
|
|
@@ -2231,8 +2560,9 @@ export class Tasks {
|
|
|
2231
2560
|
|
|
2232
2561
|
/** 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
2562
|
private topReservedReady(peer: PeerId, planner?: PeerId, pullable = false): Task | undefined {
|
|
2563
|
+
const now = this.d.now?.() ?? Date.now();
|
|
2234
2564
|
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)
|
|
2565
|
+
&& (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
2566
|
&& (card.release == null || ["planning", "active"].includes(this.d.board.release(card.release)?.state ?? ""))).sort(this.readyOrder)[0];
|
|
2237
2567
|
}
|
|
2238
2568
|
|
|
@@ -2264,10 +2594,15 @@ export class Tasks {
|
|
|
2264
2594
|
if (!planner || !this.d.bus.isRostered(planner)) return;
|
|
2265
2595
|
const reason = `auto_pull: capacity released for ${peer}; hub acted for planner ${planner}`;
|
|
2266
2596
|
try {
|
|
2267
|
-
await this.pullPlanned(HUB, { id: card.id, revision: card.planningRevision ?? 0, reason }, { onBehalfOf: planner, guard: () => {
|
|
2597
|
+
await this.pullPlanned(HUB, { id: card.id, revision: card.planningRevision ?? 0, reason }, { onBehalfOf: planner, guard: () => {
|
|
2598
|
+
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");
|
|
2599
|
+
const current = this.d.board.get(card.id);
|
|
2600
|
+
if (current && plannedStartPending(current, this.d.now?.() ?? Date.now())) throw new PreEffectToolRefusal("auto pull is waiting for the planned start");
|
|
2601
|
+
} });
|
|
2268
2602
|
} catch (e) {
|
|
2269
2603
|
// A guard refusal is the setting or a hold doing its job, not a failure to report.
|
|
2270
|
-
|
|
2604
|
+
const current = this.d.board.get(card.id);
|
|
2605
|
+
if (!this.autoPullAdmitted() || current && plannedStartPending(current, this.d.now?.() ?? Date.now())) return;
|
|
2271
2606
|
this.releaseNotice(`task ${this.publicTitle(card)}: auto_pull for ${peer} refused: ${e instanceof Error ? e.message : String(e)}`);
|
|
2272
2607
|
}
|
|
2273
2608
|
}
|
|
@@ -2982,7 +3317,7 @@ export class Tasks {
|
|
|
2982
3317
|
return updated; // a failed readback may follow a durable release write
|
|
2983
3318
|
}
|
|
2984
3319
|
|
|
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> {
|
|
3320
|
+
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
3321
|
const requestCheck = this.requestCheck(by, fence);
|
|
2987
3322
|
requireLocalTaskPeer(input.owner);
|
|
2988
3323
|
external = external ? { ...external } : undefined;
|
|
@@ -3026,6 +3361,12 @@ export class Tasks {
|
|
|
3026
3361
|
const signals = this.signalsFor({ ...text, detail: [screenDetail, planText(plan)].join("\n"), plan });
|
|
3027
3362
|
// Urgent work is handed over even when its paused owner's window resets soon (issue #36).
|
|
3028
3363
|
if (input.urgent === true && !signals.includes("urgent")) signals.push("urgent");
|
|
3364
|
+
// #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.
|
|
3365
|
+
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");
|
|
3366
|
+
if (input.needs_network === true && !signals.includes("needs_network")) signals.push("needs_network");
|
|
3367
|
+
// #1081 (amended AC1): the same for a task worktree. Only an explicit true sets it.
|
|
3368
|
+
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");
|
|
3369
|
+
if (input.needs_worktree === true && !signals.includes("needs_worktree")) signals.push("needs_worktree");
|
|
3029
3370
|
// The PII screen (issue #198) reads the same text before anything else does: until its verdict the task is on no
|
|
3030
3371
|
// board, in no envelope and before no other model. A pattern match is PII without a call; no verdict is PII too.
|
|
3031
3372
|
const screening = this.screenOn();
|
|
@@ -3599,6 +3940,7 @@ export class Tasks {
|
|
|
3599
3940
|
/** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
|
|
3600
3941
|
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
3942
|
opts.current?.();
|
|
3943
|
+
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
3944
|
if (task.state === "backlog" && !opts.plannerPull) throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
|
|
3603
3945
|
const studyReason = this.studyDispatchReason(task, opts.namedOwner);
|
|
3604
3946
|
if (studyReason) {
|
|
@@ -3616,6 +3958,7 @@ export class Tasks {
|
|
|
3616
3958
|
if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull changed during admission");
|
|
3617
3959
|
return current ?? task;
|
|
3618
3960
|
}
|
|
3961
|
+
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
3962
|
task = current; // Reviewer-only and quiet bookkeeping updates do not supersede ownership authority.
|
|
3620
3963
|
if (gap) {
|
|
3621
3964
|
this.requestIndependentPlanReview(current);
|
|
@@ -3673,7 +4016,9 @@ export class Tasks {
|
|
|
3673
4016
|
const binding = this.plannerBinding(task, a, !!opts.plannerPull);
|
|
3674
4017
|
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
4018
|
opts.current?.();
|
|
3676
|
-
this.
|
|
4019
|
+
const current = this.d.board.get(task.id) ?? task;
|
|
4020
|
+
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");
|
|
4021
|
+
this.studyDispatchCheck(current, a.owner);
|
|
3677
4022
|
const next = opts.plannerPull
|
|
3678
4023
|
? 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
4024
|
: 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);
|
|
@@ -3737,7 +4082,17 @@ export class Tasks {
|
|
|
3737
4082
|
}
|
|
3738
4083
|
if (stagedClaimGap) return next; // An explicit claim may reserve its owner, but no offer or acceptance bypasses review.
|
|
3739
4084
|
this.studyDispatchCheck(next, by);
|
|
3740
|
-
|
|
4085
|
+
// #1129: a self-claim of write-class work is an accept too, so a hub-sandboxed claimant gets the hub's worktree here, before the write. When the hub cannot make one the claim stays an assignment, like the missing-spec case above: `hub_task_accept` starts it and refuses with the same cause.
|
|
4086
|
+
let undoWorktree: (() => void) | undefined;
|
|
4087
|
+
try { undoWorktree = this.hubWorktreeFor(next, by); }
|
|
4088
|
+
catch (error) {
|
|
4089
|
+
if (!(error instanceof PreEffectToolRefusal)) throw error;
|
|
4090
|
+
this.releaseNotice(`${error.message}; the claim stays assigned to ${by} and is not started`);
|
|
4091
|
+
return next;
|
|
4092
|
+
}
|
|
4093
|
+
let claimed: Task;
|
|
4094
|
+
try { claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" }); }
|
|
4095
|
+
catch (error) { undoWorktree?.(); throw error; }
|
|
3741
4096
|
this.captureOwnerAcceptance(claimed);
|
|
3742
4097
|
this.releaseNotice(`task ${this.publicTitle(claimed)} claimed by ${by}`);
|
|
3743
4098
|
return claimed;
|
|
@@ -3773,7 +4128,8 @@ export class Tasks {
|
|
|
3773
4128
|
/** A self-claim has no assignment envelope; its tool result carries the same bounded guidance. */
|
|
3774
4129
|
claimRoleGuidance(task: Task, peer: PeerId): string {
|
|
3775
4130
|
requireLocalTaskPeer(peer);
|
|
3776
|
-
|
|
4131
|
+
// #1129: a claim by a hub-sandboxed peer carries the worktree note as an accept does, since the claim result is all it reads.
|
|
4132
|
+
return task.owner === peer && task.state === "in_progress" ? [this.roleGuidance(task, peer), this.worktreeNote(task, peer)].filter(Boolean).join("\n") : "";
|
|
3777
4133
|
}
|
|
3778
4134
|
|
|
3779
4135
|
private roleGuidance(task: Task, peer: PeerId, review = false): string {
|
|
@@ -4038,6 +4394,12 @@ export class Tasks {
|
|
|
4038
4394
|
if (!this.queuePressureAdmitted()) return;
|
|
4039
4395
|
const task = this.d.board.get(stall.task);
|
|
4040
4396
|
if (!this.ordinaryQueueProposal(stall, task)) { this.d.board.clearStall(stall.task); continue; }
|
|
4397
|
+
if (plannedStartPending(task, this.d.now?.() ?? Date.now())) {
|
|
4398
|
+
const scheduledOrigin = this.d.board.queuePressureStallPage(task.id - 1, 1).rows.find(row => row.task === task.id);
|
|
4399
|
+
if (scheduledOrigin) this.d.board.recordStall({ ...scheduledOrigin, replanRevision: task.planningRevision ?? 0 });
|
|
4400
|
+
pass.retained = true;
|
|
4401
|
+
continue;
|
|
4402
|
+
}
|
|
4041
4403
|
// A planning revision asks for fresh admission, never erases an automatic offer.
|
|
4042
4404
|
const namedOwner = stall.cause.peer;
|
|
4043
4405
|
const retry = namedOwner ? { candidates: [namedOwner], namedOwner } : {};
|
|
@@ -4180,6 +4542,7 @@ export class Tasks {
|
|
|
4180
4542
|
const capacity = this.capacityBefore(task);
|
|
4181
4543
|
this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
|
|
4182
4544
|
const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
|
|
4545
|
+
this.releaseHubWorktree(task, task.owner);
|
|
4183
4546
|
this.writePlannerNotices(next, capacity);
|
|
4184
4547
|
this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
|
|
4185
4548
|
this.offered.delete(task.id);
|
|
@@ -4203,6 +4566,7 @@ export class Tasks {
|
|
|
4203
4566
|
}
|
|
4204
4567
|
|
|
4205
4568
|
async releaseReady(): Promise<void> {
|
|
4569
|
+
const now = this.d.now?.() ?? Date.now();
|
|
4206
4570
|
const haveQueueOrigins = this.queuePressureKnown !== false && this.d.board.queuePressureStallPage(0, 1).afterTask > 0;
|
|
4207
4571
|
for (const t of [...this.d.board.list("proposed")].sort(this.readyOrder)) {
|
|
4208
4572
|
if (t.owner) { this.d.board.clearStall(t.id); continue; }
|
|
@@ -4214,6 +4578,7 @@ export class Tasks {
|
|
|
4214
4578
|
const origin = this.d.board.queuePressureStallPage(t.id - 1, 1).rows.find(row => row.task === t.id);
|
|
4215
4579
|
if (origin && this.ordinaryQueueProposal(origin, t)) { this.scheduleQueuePressureRecheck(); continue; }
|
|
4216
4580
|
}
|
|
4581
|
+
if (plannedStartPending(t, now)) continue;
|
|
4217
4582
|
const admission = planned ? this.plannerAssignment(t, true) : assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.weights(t.class, t) });
|
|
4218
4583
|
if (!admission.owner) {
|
|
4219
4584
|
// A diagnostic legacy pass is not evidence that an automatic proposal was attempted.
|
|
@@ -4235,6 +4600,7 @@ export class Tasks {
|
|
|
4235
4600
|
try {
|
|
4236
4601
|
const t = this.d.board.get(stale.id);
|
|
4237
4602
|
if (!t || t.state !== "proposed" || t.owner || this.personUnassigned(t) || this.offered.has(t.id)) return;
|
|
4603
|
+
if (plannedStartPending(t, this.d.now?.() ?? Date.now())) return;
|
|
4238
4604
|
const planned = this.plannerPullRecord(t)?.pull === true;
|
|
4239
4605
|
const selected = planned ? this.plannerAssignment(t, true).owner : undefined;
|
|
4240
4606
|
if (planned && !selected) return;
|
|
@@ -4251,6 +4617,7 @@ export class Tasks {
|
|
|
4251
4617
|
} catch (e) {
|
|
4252
4618
|
// A saved move whose delivery failed is not "could not be assigned" (#297); the console notice was printed
|
|
4253
4619
|
// where the error was thrown. The offer stays consumed by the error's own evidence, without the readback.
|
|
4620
|
+
if (e instanceof PreEffectToolRefusal && e.message === "planned start is in the future") return;
|
|
4254
4621
|
if (e instanceof AssignmentUndeliveredError) undelivered = e;
|
|
4255
4622
|
else this.releaseNotice(`task ${this.publicTitle(stale)}: could not be assigned: ${e instanceof Error ? e.message : String(e)}`);
|
|
4256
4623
|
} finally {
|
|
@@ -4419,7 +4786,7 @@ export class Tasks {
|
|
|
4419
4786
|
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
4787
|
if (this.waitsFor(task).length) blockers.push({ task: task.id, code: "dependency" });
|
|
4421
4788
|
}
|
|
4422
|
-
const digest = plannerGrooming(visible, { dependencies, dependencyTruncated, repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
|
|
4789
|
+
const digest = plannerGrooming(visible, { dependencies, dependencyTruncated, repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`, now: this.d.now?.() ?? Date.now(),
|
|
4423
4790
|
limits: Object.fromEntries(peers.map(peer => [peer, this.wipLimit(peer)])), wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])),
|
|
4424
4791
|
states, issues, reviewedIssues, acceptedIssues, blockers });
|
|
4425
4792
|
return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
|
|
@@ -4819,8 +5186,8 @@ export class Tasks {
|
|
|
4819
5186
|
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
|
|
4820
5187
|
if ((task.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("planning revision changed");
|
|
4821
5188
|
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"); }
|
|
5189
|
+
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" }); }
|
|
5190
|
+
catch (error) { throw new PreEffectToolRefusal(error instanceof CostScopeError ? error.message : "invalid review or CI cost scope"); }
|
|
4824
5191
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
4825
5192
|
if (!scope.review || !scope.ci) {
|
|
4826
5193
|
if (mode === "enforce") throw new PreEffectToolRefusal("review and CI cost scope required for a new plan receipt");
|
|
@@ -5331,10 +5698,18 @@ export class Tasks {
|
|
|
5331
5698
|
|
|
5332
5699
|
/** The eval record the last approval-time lifecycle read produced for a task; consumed by the verdict that follows it. */
|
|
5333
5700
|
private readonly evalRecords = new Map<number, string>();
|
|
5334
|
-
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[]): Promise<PrEvidence | undefined> {
|
|
5701
|
+
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[], refileHead?: RefileHeadGuard): Promise<PrEvidence | undefined> {
|
|
5335
5702
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
5336
5703
|
if (config.mode === "off") return;
|
|
5337
5704
|
const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
|
|
5705
|
+
const approvalHead = transition === "approval" ? reviewedHead ?? this.ownerReviewHead(task) ?? this.findingHead(task) : undefined;
|
|
5706
|
+
const guardedHead = !!refileHead || !!approvalHead && !!receipt?.repo && !!receipt.pr && !this.isPii(task);
|
|
5707
|
+
// Compare raw head identity on both sides; `task` may carry an uncommitted identityLink.
|
|
5708
|
+
// Completion state/history races stay with completionCurrent's existing post-readback fence.
|
|
5709
|
+
const identity = (value: Task) => JSON.stringify([value.id, value.owner, value.reviewer, this.isPii(value),
|
|
5710
|
+
value.refs.repo, value.refs.commit, value.refs.branch, value.sdlc?.repo, value.sdlc?.pr,
|
|
5711
|
+
value.sdlc?.head, value.sdlc?.localHead, value.sdlc?.reviewedHead]);
|
|
5712
|
+
const beforeHeadRead = guardedHead ? identity(this.need(task.id, false, true)) : undefined;
|
|
5338
5713
|
const deadline = performance.now() + 20_000;
|
|
5339
5714
|
const read: SdlcGh = (args, timeout, cwd, cap) => {
|
|
5340
5715
|
const current = this.need(task.id, false, true);
|
|
@@ -5343,6 +5718,16 @@ export class Tasks {
|
|
|
5343
5718
|
return remaining > 0 ? (this.d.sdlc?.gh ?? sdlcGh)(args, Math.min(timeout ?? 10_000, remaining), cwd, cap) : Promise.resolve({ code: 1, stdout: "" });
|
|
5344
5719
|
};
|
|
5345
5720
|
const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read, true) : undefined;
|
|
5721
|
+
const currentHead = receipt && !this.isPii(task) && (refileHead || approvalHead) && !evidence?.head
|
|
5722
|
+
? await readSdlcPrHead(receipt, read) : evidence?.head;
|
|
5723
|
+
if (beforeHeadRead !== undefined) {
|
|
5724
|
+
const current = this.need(task.id, false, true);
|
|
5725
|
+
if (identity(current) !== beforeHeadRead) throw new PreEffectToolRefusal(`task #${task.id}: task or review head changed during PR readback; reload and retry`);
|
|
5726
|
+
}
|
|
5727
|
+
if (refileHead) {
|
|
5728
|
+
if (!refileHead.recorded || !currentHead) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded);
|
|
5729
|
+
if (currentHead !== refileHead.recorded) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded, currentHead);
|
|
5730
|
+
} else if (approvalHead && currentHead && currentHead !== approvalHead) throw this.reviewHeadMoveRefusal(task.id, approvalHead, currentHead);
|
|
5346
5731
|
const integrity = publicTestIntegrity(receipt && evidence && !this.isPii(task) ? await readTestIntegrity(receipt, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText);
|
|
5347
5732
|
const evalResult = receipt && !this.isPii(task) ? await readEvalFor(config, evidence, receipt.repo, read) : undefined;
|
|
5348
5733
|
const current = this.need(task.id, false, true);
|
|
@@ -5391,7 +5776,7 @@ export class Tasks {
|
|
|
5391
5776
|
const metadata = acceptRefs(refs);
|
|
5392
5777
|
const task = this.need(id, false, true);
|
|
5393
5778
|
this.mine(task, by, "owner", true);
|
|
5394
|
-
this.acceptanceCapacity(task);
|
|
5779
|
+
this.acceptanceCapacity(task, 0, "check"); // #1080: an urgent task may still take unaccepted offers; `accept` does that, after its gates
|
|
5395
5780
|
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
5781
|
const draft = metadata ? { ...task, refs: { ...task.refs, ...metadata } } : task;
|
|
5397
5782
|
this.warmRepository(draft);
|
|
@@ -5420,7 +5805,52 @@ export class Tasks {
|
|
|
5420
5805
|
if (gap) this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}`);
|
|
5421
5806
|
}
|
|
5422
5807
|
|
|
5423
|
-
|
|
5808
|
+
/**
|
|
5809
|
+
* 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
|
|
5810
|
+
* (state proposed), `reserved` is ownerless planner-pulled work held for it, and `accepted` is everything the owner is actually carrying
|
|
5811
|
+
* (in progress, changes requested, in review).
|
|
5812
|
+
*/
|
|
5813
|
+
private wipSplit(owner: PeerId, except: number): { accepted: Task[]; offered: Task[]; reserved: Task[] } {
|
|
5814
|
+
const open = this.d.board.list().filter(other => other.id !== except && !["backlog", "approved", "cancelled"].includes(other.state)
|
|
5815
|
+
&& (other.owner === owner || !other.owner && other.reserved === owner && this.plannerPullRecord(other)?.pull));
|
|
5816
|
+
return {
|
|
5817
|
+
accepted: open.filter(other => other.owner === owner && other.state !== "proposed"),
|
|
5818
|
+
offered: open.filter(other => other.owner === owner && other.state === "proposed"),
|
|
5819
|
+
reserved: open.filter(other => !other.owner),
|
|
5820
|
+
};
|
|
5821
|
+
}
|
|
5822
|
+
private static isUrgent(task: Pick<Task, "priority" | "signals">): boolean {
|
|
5823
|
+
return (task.priority ?? (task.signals?.includes("urgent") ? 0 : 2)) === 0;
|
|
5824
|
+
}
|
|
5825
|
+
/** #1080: an offer an urgent accept may take back. Never accepted or started work, never another urgent task, never person-pinned work. */
|
|
5826
|
+
private displaceable(offer: Task): boolean {
|
|
5827
|
+
return offer.state === "proposed" && !!offer.owner && !offer.pinned && !Tasks.isUrgent(offer) && !taskHasStarted(offer);
|
|
5828
|
+
}
|
|
5829
|
+
/**
|
|
5830
|
+
* #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
|
|
5831
|
+
* routing (ownerless, in To Do) and the planners are told. Called only from `accept`, immediately before its write.
|
|
5832
|
+
*/
|
|
5833
|
+
private displaceOffers(urgent: Task, owner: PeerId, offers: readonly Task[]): void {
|
|
5834
|
+
for (const offer of offers) {
|
|
5835
|
+
// 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.
|
|
5836
|
+
const pulled = this.plannerPullRecord(offer)?.pull === true;
|
|
5837
|
+
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);
|
|
5838
|
+
this.offered.delete(offer.id);
|
|
5839
|
+
this.releaseNotice(`task #${offer.id}: displacement cohort cleanup unavailable`, () => this.cohorts.leave(offer.id));
|
|
5840
|
+
this.withdrawRoleRequest(offer, next, "owner");
|
|
5841
|
+
this.withdrawRoleRequest(offer, next, "reviewer");
|
|
5842
|
+
this.releaseNotice(`task #${offer.id}: displacement notice unavailable`, () => {
|
|
5843
|
+
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}.`;
|
|
5844
|
+
this.d.notify(body);
|
|
5845
|
+
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 } : {}) }));
|
|
5846
|
+
});
|
|
5847
|
+
}
|
|
5848
|
+
}
|
|
5849
|
+
/**
|
|
5850
|
+
* `urgent`: "refuse" (default) never displaces; "check" only tells whether an urgent accept could (a pre-check before a later write); "displace"
|
|
5851
|
+
* performs it. Only `accept` displaces.
|
|
5852
|
+
*/
|
|
5853
|
+
private acceptanceCapacity(task: Pick<Task, "id" | "owner" | "reserved" | "state"> & Partial<Pick<Task, "history" | "priority" | "signals">>, excludePending = 0, urgent: "refuse" | "check" | "displace" = "refuse"): void {
|
|
5424
5854
|
const owner = task.owner ?? task.reserved;
|
|
5425
5855
|
if (!owner) return;
|
|
5426
5856
|
if ((task.state === "in_progress" || task.state === "changes_requested") && task.history) {
|
|
@@ -5434,10 +5864,30 @@ export class Tasks {
|
|
|
5434
5864
|
if (task.history.slice(boundary).some(entry => entry.event === "accepted" && (entry.by === owner || entry.by === USER))) return;
|
|
5435
5865
|
}
|
|
5436
5866
|
const limit = this.wipLimit(owner), pending = Math.max(0, (this.pendingClaims.get(owner) ?? 0) - excludePending);
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
|
|
5867
|
+
const used = this.wip(owner, task.id) + pending;
|
|
5868
|
+
if (used < limit) return;
|
|
5869
|
+
const split = this.wipSplit(owner, task.id), ids = (rows: readonly Task[]) => rows.map(row => `#${row.id}`).join(", ") || "none";
|
|
5870
|
+
// #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.
|
|
5871
|
+
if (urgent !== "refuse" && task.state === "proposed" && task.owner === owner && Tasks.isUrgent(task as Pick<Task, "priority" | "signals">)) {
|
|
5872
|
+
const need = used + 1 - limit;
|
|
5873
|
+
const takeable = split.offered.filter(offer => this.displaceable(offer))
|
|
5874
|
+
.sort((a, b) => handedAt(b) - handedAt(a) || b.id - a.id);
|
|
5875
|
+
if (need > 0 && takeable.length >= need) {
|
|
5876
|
+
if (urgent === "displace") {
|
|
5877
|
+
this.displaceOffers(task as Task, owner, takeable.slice(0, need));
|
|
5878
|
+
// 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.
|
|
5879
|
+
const after = this.wip(owner, task.id) + pending;
|
|
5880
|
+
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`);
|
|
5881
|
+
}
|
|
5882
|
+
return;
|
|
5883
|
+
}
|
|
5884
|
+
}
|
|
5885
|
+
const open = [...split.accepted, ...split.offered, ...split.reserved].sort((a, b) => a.id - b.id);
|
|
5886
|
+
const parts = [`accepted ${ids(split.accepted)}`, `offered, not yet accepted ${ids(split.offered)}`, ...(split.reserved.length ? [`reserved, not yet offered ${ids(split.reserved)}`] : [])];
|
|
5887
|
+
const next = split.accepted.length >= limit
|
|
5888
|
+
? `finish or hand back an accepted task first`
|
|
5889
|
+
: `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`;
|
|
5890
|
+
throw new PreEffectToolRefusal(`planner WIP capacity for ${owner}: limit ${limit}; open tasks ${ids(open)} (${parts.join("; ")})${pending ? `; pending claims ${pending}` : ""}; ${next}`);
|
|
5441
5891
|
}
|
|
5442
5892
|
|
|
5443
5893
|
/**
|
|
@@ -5452,7 +5902,8 @@ export class Tasks {
|
|
|
5452
5902
|
this.ready(task, true);
|
|
5453
5903
|
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
5904
|
this.studyDispatchCheck(task, task.owner ?? undefined);
|
|
5455
|
-
this.
|
|
5905
|
+
this.declaredNeedCheck(task, task.owner ?? by); // the owner's capability, also when the console user accepts on its behalf (`mine` admits USER)
|
|
5906
|
+
this.acceptanceCapacity(task, 0, "check");
|
|
5456
5907
|
// S1 spec acceptance and S9 plan review are independent prerequisites; retain S1's missing-spec refusal first.
|
|
5457
5908
|
if (this.specGated(task) && this.d.sdlc?.config().mode === "enforce" && specState(task.sdlc, undefined) === "missing") throw new PreEffectToolRefusal(this.specText(task, "missing", "blocked"));
|
|
5458
5909
|
if ((this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode === "enforce" && this.isBuildTask(task)) {
|
|
@@ -5480,7 +5931,13 @@ export class Tasks {
|
|
|
5480
5931
|
const repositoryChanged = this.repositories.scope(task.refs).key !== this.repositories.scope(draft.refs).key;
|
|
5481
5932
|
const before = new Set(this.overlapHits(task).map((h) => h.task.id));
|
|
5482
5933
|
this.studyDispatchCheck(task, task.owner ?? undefined);
|
|
5483
|
-
|
|
5934
|
+
// #1115: a hub-sandboxed peer cannot run `git worktree add`, so for work that declares a worktree the hub makes it here, before any write. Anything that throws below clears it again.
|
|
5935
|
+
const undoWorktree = this.hubWorktreeFor(task, task.owner ?? by);
|
|
5936
|
+
let next: Task;
|
|
5937
|
+
try {
|
|
5938
|
+
this.acceptanceCapacity(task, 0, "displace"); // #1080: every gate has passed; synchronous from here to the accept write
|
|
5939
|
+
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
|
|
5940
|
+
} catch (error) { undoWorktree?.(); throw error; }
|
|
5484
5941
|
this.captureOwnerAcceptance(next);
|
|
5485
5942
|
this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
|
|
5486
5943
|
if (repositoryChanged) this.cohorts.leave(next.id);
|
|
@@ -5541,6 +5998,51 @@ export class Tasks {
|
|
|
5541
5998
|
/** #572 AC3: what the owner's `hub_task_done` result adds while the project root checkout is on a task branch. */
|
|
5542
5999
|
checkoutNote = (): string | undefined => { const warning = mainCheckoutWarning(this.d.cwd, this.publicMetadataText); return warning ? `Note: ${warning}.` : undefined; };
|
|
5543
6000
|
|
|
6001
|
+
/**
|
|
6002
|
+
* #1115, #1129: the hub makes the worktree of a hub-sandboxed owner for write-class work (`implement`, `bulk_edit`) and for any work that declares `needs_worktree`. Returns a function that clears it again, or
|
|
6003
|
+
* undefined when there is nothing to clear (another peer, a read-only class that did not declare it, a PII task, or a worktree an earlier accept already made). A failure refuses the
|
|
6004
|
+
* accept before any board write and names the cause and the next step; nothing is left behind.
|
|
6005
|
+
*/
|
|
6006
|
+
private hubWorktreeFor(task: Task, peer: PeerId): (() => void) | undefined {
|
|
6007
|
+
const port = this.d.taskWorktree;
|
|
6008
|
+
if (!port || peer === USER || peer === HUB || !port.applies(peer) || this.isPii(task) || !(task.signals.includes("needs_worktree") || writesRepository(task.class))) return undefined;
|
|
6009
|
+
const made = port.create({ peer, task: task.id });
|
|
6010
|
+
if (!made.ok) throw new PreEffectToolRefusal(`task #${task.id} needs its own git worktree and the hub could not make one for ${peer}: ${made.reason}; nothing was created, so no branch ref is left behind. Decline the task so the hub offers it to a peer that makes its own worktree`);
|
|
6011
|
+
return made.created ? () => { try { port.remove({ peer, task: task.id }); } catch { /* the accept error that got us here is the one reported */ } } : undefined;
|
|
6012
|
+
}
|
|
6013
|
+
|
|
6014
|
+
/** The hub-made worktree of the task's owner, for the accept answer: where to edit, and what the hub does at `hub_task_done`. */
|
|
6015
|
+
worktreeNote(task: Task, peer: PeerId): string | undefined {
|
|
6016
|
+
const found = this.d.taskWorktree?.describe(peer, task.id);
|
|
6017
|
+
return found ? `Worktree: the hub made ${found.path} on branch ${found.branch} for this task. Edit files there only; do not run git. At hub_task_done the hub stages, commits and pushes it for you.` : undefined;
|
|
6018
|
+
}
|
|
6019
|
+
|
|
6020
|
+
/**
|
|
6021
|
+
* #1115: at `hub_task_done` the hub commits and pushes a hub-sandboxed peer's worktree and answers the refs the done carries. Undefined when the peer made no
|
|
6022
|
+
* hub worktree for this task (it commits itself). A refused commit or push is a pre-effect refusal that names the cause and the next step: the board is untouched.
|
|
6023
|
+
*/
|
|
6024
|
+
async worktreeHandoff(by: PeerId, id: unknown, refs: unknown): Promise<TaskRefs | undefined> {
|
|
6025
|
+
const port = this.d.taskWorktree;
|
|
6026
|
+
if (!port || by === USER || by === HUB || !port.applies(by)) return undefined;
|
|
6027
|
+
const task = this.need(id, false, true);
|
|
6028
|
+
this.mine(task, by, "owner", true);
|
|
6029
|
+
if (this.isPii(task) || !port.describe(by, task.id)) return undefined;
|
|
6030
|
+
const result = await port.commitAndPush({ peer: by, task: task.id, title: task.title });
|
|
6031
|
+
if (!result.ok) throw new PreEffectToolRefusal(`hub_task_done for task #${task.id}: ${result.reason}`);
|
|
6032
|
+
if (result.note) this.doneNotes.set(task.id, result.note);
|
|
6033
|
+
return { ...cleanRefs(refs), commit: result.commit, branch: result.branch };
|
|
6034
|
+
}
|
|
6035
|
+
|
|
6036
|
+
/** Decline or cancel: clear the hub-made worktree and its empty branch. Uncommitted work is kept and reported, never force-removed. */
|
|
6037
|
+
private releaseHubWorktree(task: Task, peer: PeerId | null | undefined): void {
|
|
6038
|
+
const port = this.d.taskWorktree;
|
|
6039
|
+
if (!port || !peer || peer === USER || peer === HUB || !port.applies(peer)) return;
|
|
6040
|
+
this.releaseNotice(`task #${task.id}: the hub-made worktree could not be cleared`, () => {
|
|
6041
|
+
const removal = port.remove({ peer, task: task.id });
|
|
6042
|
+
if (removal.kept) this.d.notify(`task #${task.id}: ${removal.kept}`);
|
|
6043
|
+
});
|
|
6044
|
+
}
|
|
6045
|
+
|
|
5544
6046
|
/**
|
|
5545
6047
|
* `capacity` (#531): the owner has no room right now, so the decline excludes it only from this move's own reroute
|
|
5546
6048
|
* (below), never from a later reroute or planner pull. Without it, a decline is a lasting refusal (`excluded()`), and so
|
|
@@ -5555,6 +6057,7 @@ export class Tasks {
|
|
|
5555
6057
|
const back = this.d.board.update(task.id, by, "declined", { ...(["in_progress", "changes_requested"].includes(task.state) ? { state: "proposed" as const } : {}), ...(planned ? { reserved: null } : {}) }, reason, task.owner ? { from: task.owner, ...(capacity === true ? { capacity: true as const } : {}) } : {});
|
|
5556
6058
|
if (planned) this.offered.delete(task.id);
|
|
5557
6059
|
this.d.notify(`task ${this.publicTitle(back)} declined${capacity === true ? " (capacity only)" : ""} by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
|
|
6060
|
+
this.releaseHubWorktree(task, task.owner ?? by);
|
|
5558
6061
|
// A capacity decline is not in excluded(), so this move's own reroute leaves the decliner out explicitly.
|
|
5559
6062
|
try {
|
|
5560
6063
|
return await this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
|
|
@@ -5731,18 +6234,27 @@ export class Tasks {
|
|
|
5731
6234
|
if (state !== "OPEN" && state !== "CLOSED") throw new PreEffectToolRefusal(`task #${task.id}: PR merge state unavailable; cannot reopen approval`);
|
|
5732
6235
|
}
|
|
5733
6236
|
|
|
5734
|
-
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs, deliverables?: unknown): Promise<Task> {
|
|
6237
|
+
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs, deliverables?: unknown, specAck?: unknown): Promise<Task> {
|
|
5735
6238
|
const requestCheck = this.requestCheck(by);
|
|
5736
6239
|
const initial = this.need(id, false, true);
|
|
5737
6240
|
this.mine(initial, by, "owner", true);
|
|
5738
6241
|
let refresh: CompletionRefresh | undefined;
|
|
5739
6242
|
if (initial.state === "in_review" || initial.state === "approved") {
|
|
5740
6243
|
const commit = cleanRefs(refs).commit;
|
|
5741
|
-
|
|
5742
|
-
|
|
6244
|
+
const recorded = this.ownerReviewHead(initial) ?? this.findingHead(initial);
|
|
6245
|
+
const currentReviewHead = this.ownerReviewHead(initial) ?? this.reviewHead(initial);
|
|
6246
|
+
if (!commit || !/^[a-f0-9]{40}$/.test(commit) || commit === currentReviewHead) {
|
|
6247
|
+
throw new PreEffectToolRefusal(`task #${initial.id} is already ${initial.state}${recorded ? ` with recorded review head ${recorded}` : ""}; re-file requires a different full head SHA. Owner must re-file hub_task_done with refs.commit set to the new full SHA`);
|
|
6248
|
+
}
|
|
5743
6249
|
refresh = { revision: initial.history.length, commit, state: initial.state };
|
|
5744
6250
|
}
|
|
5745
6251
|
const submittedCommit = cleanRefs(refs).commit;
|
|
6252
|
+
if (submittedCommit && this.hasReviewHistory(initial) && !/^[a-f0-9]{40}$/.test(submittedCommit)) {
|
|
6253
|
+
const recorded = this.ownerReviewHead(initial);
|
|
6254
|
+
throw new PreEffectToolRefusal(`task #${initial.id}: re-file after review needs refs.commit to be a full lowercase SHA${recorded ? ` (recorded review head ${recorded})` : ""}; owner must re-file hub_task_done with the new full SHA in refs.commit`);
|
|
6255
|
+
}
|
|
6256
|
+
const refileHead: RefileHeadGuard | undefined = !submittedCommit && initial.sdlc?.repo && initial.sdlc.pr && this.hasReviewHistory(initial) && !this.isPii(initial)
|
|
6257
|
+
? { recorded: this.ownerReviewHead(initial) } : undefined;
|
|
5746
6258
|
const draft = refresh ?? (submittedCommit && /^[a-f0-9]{40}$/.test(submittedCommit) ? { commit: submittedCommit } : undefined);
|
|
5747
6259
|
const valid = () => { requestCheck(); return this.completionDraft(this.completionCurrent(id, by, true, refresh), draft); };
|
|
5748
6260
|
if (initial.state === "approved") { await this.unmergedForReopen(initial, valid); valid(); }
|
|
@@ -5766,9 +6278,19 @@ export class Tasks {
|
|
|
5766
6278
|
const current = () => this.withLink(valid(), link, refs);
|
|
5767
6279
|
task = current();
|
|
5768
6280
|
let reviewEvidence: PrEvidence | undefined;
|
|
5769
|
-
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task = current(); }
|
|
5770
|
-
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;
|
|
6281
|
+
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review", undefined, undefined, refileHead); task = current(); }
|
|
6282
|
+
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, refileHead) : undefined;
|
|
5771
6283
|
task = current();
|
|
6284
|
+
if (refileHead && (this.d.sdlc?.config().mode ?? "off") === "off") {
|
|
6285
|
+
const currentHead = await this.currentPrHeadForReview(task, current);
|
|
6286
|
+
task = current();
|
|
6287
|
+
if (!refileHead.recorded || !currentHead) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded);
|
|
6288
|
+
if (currentHead !== refileHead.recorded) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded, currentHead);
|
|
6289
|
+
}
|
|
6290
|
+
const currentSpecTask = () => { const fresh = current(); return { ...fresh, refs: { ...fresh.refs, ...cleanRefs(refs) } }; };
|
|
6291
|
+
const specInspection = await this.inspectTaskSpecs(currentSpecTask(), by, specAck, currentSpecTask);
|
|
6292
|
+
task = current(); // the host head and task authority are revalidated after spec inspection
|
|
6293
|
+
this.assertSpecInspection(specInspection, { ...task, refs: { ...task.refs, ...cleanRefs(refs) } }, by);
|
|
5772
6294
|
// A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
|
|
5773
6295
|
const cohort = this.cohorts.of(task.id);
|
|
5774
6296
|
const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
|
|
@@ -5799,19 +6321,23 @@ export class Tasks {
|
|
|
5799
6321
|
}
|
|
5800
6322
|
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
6323
|
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);
|
|
6324
|
+
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link, records, requestCheck, refresh, specInspection);
|
|
5803
6325
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
5804
6326
|
if (refresh?.state === "approved") { await this.unmergedForReopen(task, valid); task = current(); }
|
|
5805
6327
|
this.checking.set(task.id, task.owner);
|
|
5806
6328
|
const checkedRefs = cleanRefs(refs);
|
|
6329
|
+
if (specInspection.report?.head && !checkedRefs.commit) checkedRefs.commit = specInspection.report.head;
|
|
6330
|
+
if (specInspection.report && specInspection.repo && !checkedRefs.repo) checkedRefs.repo = specInspection.repo;
|
|
6331
|
+
const storedRefs = { ...task.refs, ...checkedRefs };
|
|
5807
6332
|
this.warmRepository({ ...task, refs: { ...task.refs, ...checkedRefs } });
|
|
5808
|
-
|
|
6333
|
+
this.assertSpecInspection(specInspection, { ...task, refs: storedRefs }, by);
|
|
6334
|
+
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
6335
|
if (refresh) { this.ciWatchers.cancel(pending.id); this.heldReviews.delete(pending.id); }
|
|
5810
6336
|
this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
|
|
5811
6337
|
const seen = { events: pending.history.length, owner: pending.owner };
|
|
5812
6338
|
this.pendingChecks++;
|
|
5813
6339
|
this.checkQueue = this.checkQueue
|
|
5814
|
-
.then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld))
|
|
6340
|
+
.then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld, specInspection))
|
|
5815
6341
|
.catch((e: Error) => {
|
|
5816
6342
|
this.releaseNotice(`task #${pending.id}: its check result could not be recorded: ${e.message}`);
|
|
5817
6343
|
this.releaseNotice(`task #${pending.id}: its owner retry notice could not be published`, () => {
|
|
@@ -5914,7 +6440,7 @@ export class Tasks {
|
|
|
5914
6440
|
return { ...(key ? { checkKey: key } : {}), ...(match ? { missingKeys: Number(match[2]) } : {}) };
|
|
5915
6441
|
}
|
|
5916
6442
|
|
|
5917
|
-
private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }, withheld: boolean): Promise<void> {
|
|
6443
|
+
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
6444
|
const result = await this.d.runCheck!(command).catch((e: Error) => ({ code: null, timedOut: false, interrupted: false, tail: e.message }));
|
|
5919
6445
|
this.checking.delete(id);
|
|
5920
6446
|
const task = this.d.board.get(id);
|
|
@@ -5930,6 +6456,7 @@ export class Tasks {
|
|
|
5930
6456
|
return;
|
|
5931
6457
|
}
|
|
5932
6458
|
if (result.code === 0 && !result.timedOut) {
|
|
6459
|
+
const refileForSpecs = this.refreshAsyncSpecInspection(specInspection, task, by);
|
|
5933
6460
|
// An integrating member's check counts only for the target it confirmed (issue #107).
|
|
5934
6461
|
const cohort = this.cohorts.of(id);
|
|
5935
6462
|
if (cohort?.silent && !this.cohorts.holds(id, ownerGen(task), this.tree(cohort))) {
|
|
@@ -5938,8 +6465,9 @@ export class Tasks {
|
|
|
5938
6465
|
return;
|
|
5939
6466
|
}
|
|
5940
6467
|
this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim(), this.checkProvenance(task, result.tail));
|
|
6468
|
+
if (refileForSpecs) { this.requestSpecRefile(task); return; }
|
|
5941
6469
|
// 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);
|
|
6470
|
+
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
6471
|
return;
|
|
5944
6472
|
}
|
|
5945
6473
|
this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim(), this.checkProvenance(task, result.tail));
|
|
@@ -5951,8 +6479,9 @@ export class Tasks {
|
|
|
5951
6479
|
}
|
|
5952
6480
|
|
|
5953
6481
|
/** `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> {
|
|
6482
|
+
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
6483
|
requestCheck();
|
|
6484
|
+
if (specInspection && asyncCheckCompletion && this.refreshAsyncSpecInspection(specInspection, task, by)) { this.requestSpecRefile(task); return task; }
|
|
5956
6485
|
// A check's completion carries the records its `done (checking)` bound: the same bytes the owner delivered, not a later read.
|
|
5957
6486
|
records ??= latestPendingDeliverables(task);
|
|
5958
6487
|
const submittedCommit = cleanRefs(refs).commit;
|
|
@@ -5964,6 +6493,7 @@ export class Tasks {
|
|
|
5964
6493
|
reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
|
|
5965
6494
|
requestCheck();
|
|
5966
6495
|
task = this.withLink(this.completionDraft(this.completionCurrent(task.id, by, false, refresh), draft), link, refs);
|
|
6496
|
+
if (specInspection && asyncCheckCompletion && this.refreshAsyncSpecInspection(specInspection, task, by)) { this.requestSpecRefile(task); return task; }
|
|
5967
6497
|
selection = this.poolReview(task, refs);
|
|
5968
6498
|
reviewer = selection?.reviewer ?? task.reviewer ?? (refresh?.state === "approved" ? USER : undefined);
|
|
5969
6499
|
}
|
|
@@ -5972,6 +6502,7 @@ export class Tasks {
|
|
|
5972
6502
|
admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence, undefined, staged => this.withLink(this.completionDraft(staged, draft), link, refs), !reviewer);
|
|
5973
6503
|
requestCheck();
|
|
5974
6504
|
task = this.withLink(this.completionDraft(this.completionCurrent(task.id, by, false, refresh), draft), link, refs);
|
|
6505
|
+
if (specInspection && asyncCheckCompletion && this.refreshAsyncSpecInspection(specInspection, task, by)) { this.requestSpecRefile(task); return task; }
|
|
5975
6506
|
}
|
|
5976
6507
|
selection = this.poolReview(task, refs);
|
|
5977
6508
|
reviewer = selection?.reviewer ?? task.reviewer ?? (refresh?.state === "approved" ? USER : undefined);
|
|
@@ -5981,12 +6512,21 @@ export class Tasks {
|
|
|
5981
6512
|
if (refresh?.state === "approved") {
|
|
5982
6513
|
await this.unmergedForReopen(task, () => { requestCheck(); return this.completionCurrent(task.id, by, true, refresh); });
|
|
5983
6514
|
task = this.withLink(this.completionDraft(this.completionCurrent(task.id, by, true, refresh), draft), link, refs);
|
|
6515
|
+
if (specInspection && asyncCheckCompletion && this.refreshAsyncSpecInspection(specInspection, task, by)) { this.requestSpecRefile(task); return task; }
|
|
5984
6516
|
}
|
|
5985
6517
|
const completedRefs = cleanRefs(refs);
|
|
5986
|
-
|
|
5987
|
-
|
|
6518
|
+
if (specInspection?.report?.head && !completedRefs.commit) completedRefs.commit = specInspection.report.head;
|
|
6519
|
+
if (specInspection?.report && specInspection.repo && !completedRefs.repo) completedRefs.repo = specInspection.repo;
|
|
6520
|
+
const storedRefs = { ...task.refs, ...completedRefs };
|
|
6521
|
+
if (specInspection) {
|
|
6522
|
+
if (asyncCheckCompletion) {
|
|
6523
|
+
if (this.refreshAsyncSpecInspection(specInspection, { ...task, refs: storedRefs }, by)) { this.requestSpecRefile(task); return task; }
|
|
6524
|
+
} else this.assertSpecInspection(specInspection, { ...task, refs: storedRefs }, by);
|
|
6525
|
+
}
|
|
6526
|
+
this.warmRepository({ ...task, refs: storedRefs });
|
|
6527
|
+
const noWrites = this.noWritesRecorded({ ...task, refs: storedRefs });
|
|
5988
6528
|
const capacity = this.capacityBefore(task);
|
|
5989
|
-
const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs:
|
|
6529
|
+
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
6530
|
this.queueReviewWritten(next);
|
|
5991
6531
|
this.writePlannerNotices(next, capacity, next.state === "approved");
|
|
5992
6532
|
this.ciWatchers.cancel(next.id); this.heldReviews.delete(next.id); // a newer head or a move ends the old head's watcher (#785 P1b)
|
|
@@ -6101,7 +6641,7 @@ export class Tasks {
|
|
|
6101
6641
|
|
|
6102
6642
|
/** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
|
|
6103
6643
|
/** Review admission is separate from brief construction (#228). All refusals precede the requesting done's writes. */
|
|
6104
|
-
private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean, stage: (task: Task) => Task = task => task, prepareOnly = false): Promise<ReviewReadiness | undefined> {
|
|
6644
|
+
private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean, stage: (task: Task) => Task = task => task, prepareOnly = false, refileHead?: RefileHeadGuard): Promise<ReviewReadiness | undefined> {
|
|
6105
6645
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
6106
6646
|
if (mode === "off" && !this.d.prepareReviewBrief) return undefined;
|
|
6107
6647
|
let quietDrift = false;
|
|
@@ -6138,8 +6678,13 @@ export class Tasks {
|
|
|
6138
6678
|
const evidence: ReviewReadiness = this.isPii(task)
|
|
6139
6679
|
? { ready: false, blockers: ["private review readiness unavailable"] }
|
|
6140
6680
|
: task.sdlc ? reviewReadinessFromEvidence(previous, expected, this.d.sdlc?.config().requiredChecks ?? []) ?? await readReviewReadiness(task.sdlc, expected, read, this.d.sdlc?.config().requiredChecks ?? []) : { ready: false, blockers: ["review PR identity missing"] };
|
|
6681
|
+
const currentHead = refileHead && !evidence.head && task.sdlc ? await readSdlcPrHead(task.sdlc, read) : evidence.head;
|
|
6141
6682
|
const current = guard();
|
|
6142
6683
|
const admitted: ReviewReadiness = quietDrift ? { ready: false, blockers: ["review readiness unknown: task changed during readback"] } : evidence;
|
|
6684
|
+
if (refileHead) {
|
|
6685
|
+
if (!refileHead.recorded || !currentHead) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded);
|
|
6686
|
+
if (currentHead !== refileHead.recorded) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded, currentHead);
|
|
6687
|
+
}
|
|
6143
6688
|
if (!admitted.ready) {
|
|
6144
6689
|
const message = `task ${this.publicTitle(current)}: premature review: ${admitted.blockers.join("; ")}`;
|
|
6145
6690
|
if (mode === "enforce") throw new (preEffect ? PreEffectToolRefusal : Error)(message);
|
|
@@ -6417,17 +6962,19 @@ export class Tasks {
|
|
|
6417
6962
|
briefText(task: Task): string {
|
|
6418
6963
|
if (this.isPii(task)) return "";
|
|
6419
6964
|
const approval = approvalText(approvalBinding(task));
|
|
6420
|
-
const
|
|
6421
|
-
|
|
6965
|
+
const spec = this.currentSpecReport(task);
|
|
6966
|
+
const prefix = (brief: string) => [approval, spec ? renderSpecTaskReport(spec) : "", brief].filter(Boolean).join("\n");
|
|
6967
|
+
if (!task.refs.commit) return prefix("");
|
|
6422
6968
|
try {
|
|
6423
6969
|
const brief = this.d.reviewBrief?.(task);
|
|
6424
|
-
if (!brief) return
|
|
6970
|
+
if (!brief) return prefix("");
|
|
6425
6971
|
return prefix("unavailable" in brief ? `Review brief unavailable: ${brief.unavailable}.` : sanitize(renderReviewBrief(brief)));
|
|
6426
6972
|
} catch { return prefix("Review brief unavailable: it could not be built."); }
|
|
6427
6973
|
}
|
|
6428
6974
|
|
|
6429
6975
|
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown, findings?: unknown): Promise<Task> {
|
|
6430
6976
|
const requestCheck = this.requestCheck(by);
|
|
6977
|
+
if (typeof id !== "number" && !(typeof id === "string" && /^\s*\d+\s*$/.test(id))) this.need(id, false, true);
|
|
6431
6978
|
const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
|
|
6432
6979
|
let findingGuard: ((task: Task) => void) | undefined;
|
|
6433
6980
|
// #520: what the coverage was checked against, pinned through every await: the commit and the lifecycle receipt (the
|
|
@@ -6471,7 +7018,7 @@ export class Tasks {
|
|
|
6471
7018
|
// notice. The rule is structural (a finding with disposition open), never a reading of the unmet text.
|
|
6472
7019
|
const codeScope = by !== USER && this.d.reviewScope?.(by) === "code";
|
|
6473
7020
|
const evidenceOnly = codeScope && verdict === "changes_requested" && !parsed.some(finding => finding.disposition === "open");
|
|
6474
|
-
const resolvedHead = this.findingHead(initial);
|
|
7021
|
+
const resolvedHead = this.ownerReviewHead(initial) ?? this.findingHead(initial);
|
|
6475
7022
|
let findingHead = resolvedHead;
|
|
6476
7023
|
if (head !== undefined) {
|
|
6477
7024
|
try { findingHead = sdlcReceipt({ reviewedHead: head }, initial.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined)).reviewedHead; }
|
|
@@ -6518,7 +7065,7 @@ export class Tasks {
|
|
|
6518
7065
|
if (gap.length) note = `${note ?? ""}\nCoverage gap (${gap.length}): ${list(gap)}`.trim();
|
|
6519
7066
|
}
|
|
6520
7067
|
}
|
|
6521
|
-
if (head !== undefined && resolvedHead && findingHead !== resolvedHead) throw new PreEffectToolRefusal(
|
|
7068
|
+
if (head !== undefined && resolvedHead && findingHead !== resolvedHead) throw new PreEffectToolRefusal(`task #${initial.id}: supplied review head ${findingHead} differs from recorded task head ${resolvedHead}; owner must re-file hub_task_done with refs.commit set to the supplied full SHA before review`);
|
|
6522
7069
|
// The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
|
|
6523
7070
|
const withheld = await this.withholds("review note", parsed.length ? JSON.stringify({ note, findings: parsed }) : note, valid())
|
|
6524
7071
|
|| parsed.length > 0 && !this.findingTextAllowed(initial, parsed);
|
|
@@ -6555,6 +7102,12 @@ export class Tasks {
|
|
|
6555
7102
|
await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid();
|
|
6556
7103
|
const record = this.evalRecords.get(task.id); this.evalRecords.delete(task.id);
|
|
6557
7104
|
if (record) note = `${note ?? ""}\n${record}`.trim();
|
|
7105
|
+
} else if (verdict === "approved" && this.d.sdlc?.config().mode === "off" && this.d.sdlc?.gh && task.sdlc?.repo && task.sdlc.pr
|
|
7106
|
+
&& this.ownerReviewHead(task) && !this.isPii(task)) {
|
|
7107
|
+
const expected = reviewedHead ?? this.ownerReviewHead(task) ?? this.findingHead(task);
|
|
7108
|
+
const currentHead = await this.currentPrHeadForReview(task, valid);
|
|
7109
|
+
task = valid();
|
|
7110
|
+
if (expected && currentHead && currentHead !== expected) throw this.reviewHeadMoveRefusal(task.id, expected, currentHead);
|
|
6558
7111
|
}
|
|
6559
7112
|
let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
|
|
6560
7113
|
if (verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|