@staix/agent-hub 0.12.35 → 0.12.37
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 +25 -0
- package/README.md +15 -8
- package/docs/agent-notes/adapters.md +9 -2
- package/docs/agent-notes/budget.md +3 -2
- package/docs/agent-notes/bus.md +3 -0
- package/docs/agent-notes/daemon.md +11 -3
- package/docs/agent-notes/tasks.md +14 -1
- package/docs/agent-notes/tests.md +6 -2
- package/docs/memory.md +3 -1
- package/docs/operations.md +74 -18
- package/docs/quickstart.md +7 -9
- package/docs/sdlc.md +72 -5
- package/docs/security.md +11 -3
- package/docs/smoke.md +18 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +108 -3
- package/docs/verification/2026-10-19-0.12.36.md +63 -0
- package/docs/verification/2026-10-19-0.12.37.md +60 -0
- package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
- package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
- package/docs/verified.json +23 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +2843 -1632
- package/src/adapters/acp.ts +3 -2
- package/src/adapters/claude-print.ts +92 -0
- package/src/adapters/codex-review-isolation.ts +276 -0
- package/src/adapters/definitions/builtin-metadata.ts +20 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
- package/src/adapters/definitions/native-cli-entry-jcode.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +24 -0
- package/src/adapters/definitions/native-policy-aux.ts +1 -1
- package/src/adapters/definitions/reviewer-variants.ts +32 -0
- package/src/adapters/jcode.ts +20 -5
- package/src/adapters/registry-types.ts +13 -3
- package/src/adapters/registry.ts +29 -5
- package/src/adapters/reviewer-print.ts +319 -0
- package/src/cli/help.ts +7 -0
- package/src/cli/identity.ts +2 -1
- package/src/cli/launch.ts +19 -6
- package/src/cli/main.ts +57 -4
- package/src/cli/memory.ts +13 -12
- package/src/cli/reset.ts +4 -2
- package/src/cli/statusline-tee.ts +5 -2
- package/src/cli/terminal-recovery.ts +7 -2
- package/src/cli/upgrade-runtime.ts +18 -15
- package/src/hub/board.ts +61 -11
- package/src/hub/bus.ts +11 -5
- package/src/hub/claude-state.ts +37 -0
- package/src/hub/config-apply.ts +3 -2
- package/src/hub/control-client.ts +1 -1
- package/src/hub/cost-governance.ts +76 -0
- package/src/hub/daemon.ts +269 -87
- package/src/hub/envelope.ts +1 -1
- package/src/hub/host-executable.ts +1 -1
- package/src/hub/hub-tools.ts +25 -1
- package/src/hub/instances.ts +29 -5
- package/src/hub/memory-profile.ts +67 -11
- package/src/hub/planning.ts +2 -0
- package/src/hub/release-admission.ts +64 -0
- package/src/hub/review-brief.ts +175 -0
- package/src/hub/reviewer-pool-config.ts +11 -0
- package/src/hub/reviewer-pool.ts +70 -0
- package/src/hub/routing.ts +58 -4
- package/src/hub/sdlc-release.ts +113 -0
- package/src/hub/sdlc.ts +205 -4
- package/src/hub/status-roster.ts +10 -0
- package/src/hub/task-sweep.ts +143 -0
- package/src/hub/tasks.ts +788 -52
- package/src/hub/usage.ts +57 -20
package/src/hub/tasks.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
|
|
2
|
+
import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
|
|
3
|
+
import { specRefs } from "./spec-read.ts";
|
|
1
4
|
import type { InstancesConfig } from "./instances.ts";
|
|
2
5
|
import { composeRememberText } from "./memory-fail.ts";
|
|
3
6
|
import { VERSION } from "../version.ts";
|
|
@@ -16,7 +19,7 @@ import { isAbsolute, relative } from "node:path";
|
|
|
16
19
|
import type { Briefs, NativeItem } from "../memory/brief.ts";
|
|
17
20
|
import type { MemoryClient } from "../memory/client.ts";
|
|
18
21
|
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";
|
|
19
|
-
import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
|
|
22
|
+
import { 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 } from "./board.ts";
|
|
20
23
|
import type { Bus } from "./bus.ts";
|
|
21
24
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
22
25
|
import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
|
|
@@ -25,13 +28,18 @@ import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecisi
|
|
|
25
28
|
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
26
29
|
import { realPath } from "./project.ts";
|
|
27
30
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
+
import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief.ts";
|
|
32
|
+
import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf } from "../adapters/registry.ts";
|
|
33
|
+
import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
34
|
+
import { readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
|
|
31
35
|
|
|
32
36
|
export type TaskAssignmentRole = "owner" | "reviewer";
|
|
33
37
|
|
|
38
|
+
export interface ReleaseWindow { at: number; host: boolean; quota: boolean; idle: boolean; reasons: string[]; current?: () => boolean; close?: () => Promise<void>; }
|
|
39
|
+
|
|
34
40
|
export interface TasksDeps {
|
|
41
|
+
/** Host-bound evidence only; absent/expired observations never admit a release cut. */
|
|
42
|
+
releaseWindow?: () => Promise<ReleaseWindow>;
|
|
35
43
|
/** Effective per-peer planner capacity, taken from the existing settings loader. */
|
|
36
44
|
wipLimit?: (peer: PeerId) => number;
|
|
37
45
|
recordPlanning?: (change: { by: string; task?: number; release?: number; fields: string[]; revision: number }) => void;
|
|
@@ -52,6 +60,8 @@ export interface TasksDeps {
|
|
|
52
60
|
sessionRoles?: (peer: PeerId) => string[];
|
|
53
61
|
/** Optional bounded snapshot presentation; never stored in task history or memory. */
|
|
54
62
|
projectedReview?: (task: Task) => string | undefined;
|
|
63
|
+
/** #520: the review brief for a task's commit (git only, bounded); undefined when the task names no commit. */
|
|
64
|
+
reviewBrief?: (task: Task) => ReviewBrief | { unavailable: string } | undefined;
|
|
55
65
|
sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
|
|
56
66
|
briefs?: Briefs;
|
|
57
67
|
/** Screened live fail notes directly intersecting the task plan or refs; read-only advice. */
|
|
@@ -60,6 +70,12 @@ export interface TasksDeps {
|
|
|
60
70
|
candidateScreen?: MemoryScreen;
|
|
61
71
|
/** Authenticated live turn context, not a model-supplied flag. */
|
|
62
72
|
privateTurn?: (peer: PeerId) => boolean;
|
|
73
|
+
/** #223 R4: the hub's reading of a verdict's context (`fresh` only for a reviewer session started for this review). */
|
|
74
|
+
reviewContext?: (by: PeerId, task: number) => "fresh" | "continuing" | undefined;
|
|
75
|
+
/** #223 R2: a reviewer-only instance gets review facts, not the builder's done summary. */
|
|
76
|
+
reviewOnly?: (peer: PeerId) => boolean;
|
|
77
|
+
/** #223: captured when a verdict call starts; a non-undefined result refuses the verdict at every later check. */
|
|
78
|
+
reviewAuthority?: (by: PeerId, task: number) => (() => string | undefined) | undefined;
|
|
63
79
|
memory?: MemoryClient;
|
|
64
80
|
/** Captured native profile fence at our memory operation boundaries; no board mutation. */
|
|
65
81
|
memoryCheck?: () => void;
|
|
@@ -82,7 +98,7 @@ export interface TasksDeps {
|
|
|
82
98
|
/** Optional persisted task/run execution limits (issue #102). */
|
|
83
99
|
executionBudget?: ExecutionBudget;
|
|
84
100
|
/** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
|
|
85
|
-
review?: { adaptive: boolean; min_reviews: number };
|
|
101
|
+
review?: { adaptive: boolean; min_reviews: number; pool?: readonly string[]; rerotate?: boolean };
|
|
86
102
|
/** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
|
|
87
103
|
capabilities?: () => Record<PeerId, PeerCapabilities>;
|
|
88
104
|
repository?: string;
|
|
@@ -93,6 +109,8 @@ export interface TasksDeps {
|
|
|
93
109
|
failing?: () => Record<PeerId, string>;
|
|
94
110
|
/** Peers whose queue a needs_review delivery holds, with the hold detail (issue #90); routing explains the hold, never rejects. */
|
|
95
111
|
held?: () => Record<PeerId, string>;
|
|
112
|
+
/** Closed host facts only; approval text and quota reasons never enter the grooming projection. */
|
|
113
|
+
plannerBlocks?: () => Record<PeerId, ("approvals" | "quota")[]>;
|
|
96
114
|
/** Roles from `.agenthub/config.json` (issue #92): peers with the reviewer role join the reviewer candidates. */
|
|
97
115
|
roles?: Record<string, string[]>;
|
|
98
116
|
/** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
|
|
@@ -213,7 +231,9 @@ function cleanRefs(input: unknown): TaskRefs {
|
|
|
213
231
|
/** The same for a plan (issue #31): only its four lists, each of short strings. */
|
|
214
232
|
function cleanPlan(input: unknown, profile: ProfileSelection = "dev"): TaskPlan {
|
|
215
233
|
const r = fields(input);
|
|
216
|
-
|
|
234
|
+
let scope: CostPlan;
|
|
235
|
+
try { scope = cleanCostPlan(r); } catch { throw new PreEffectToolRefusal("invalid review or CI cost scope"); }
|
|
236
|
+
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)), ...scope };
|
|
217
237
|
}
|
|
218
238
|
|
|
219
239
|
/** A plan as one line for other owners; the whole plan is on the board. */
|
|
@@ -344,7 +364,7 @@ export class Tasks {
|
|
|
344
364
|
if (finding.step === 2 || !planners.length) this.releaseNotice(text);
|
|
345
365
|
for (const peer of planners) this.releaseNotice(`task #${task.id}: sweep notice unavailable`, () => this.sweepNotice(task, peer, `${text} Check the board and assign, re-plan or cancel it.`, "planner"));
|
|
346
366
|
} else {
|
|
347
|
-
const admission = this.plannerPullRecord(task)?.pull ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.weights(task.class) });
|
|
367
|
+
const admission = this.plannerPullRecord(task)?.pull ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.weights(task.class, task) });
|
|
348
368
|
this.releaseNotice(`${text} Owner reassignment suggestion: ${admission.owner ?? "no eligible peer"}.`);
|
|
349
369
|
if (admission.owner && config.auto_reassign && this.sweepAvailable(admission.owner)) {
|
|
350
370
|
try { await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [admission.owner], event: "reassigned", reason: "idle", note: "ready-unassigned idle sweep" }); }
|
|
@@ -355,7 +375,7 @@ export class Tasks {
|
|
|
355
375
|
}
|
|
356
376
|
const responsible = finding.kind === "review-pending" ? task.reviewer! : task.owner!;
|
|
357
377
|
if (responsible !== USER && !this.sweepAvailable(responsible)) continue;
|
|
358
|
-
if (finding.step === 3 && finding.kind !== "review-pending" && config.auto_reassign && this.plannerPullRecord(task)?.pull && !assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), responsible], excludeOwners: this.plannerFull(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) }).owner) continue;
|
|
378
|
+
if (finding.step === 3 && finding.kind !== "review-pending" && config.auto_reassign && this.plannerPullRecord(task)?.pull && !assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), responsible], excludeOwners: this.plannerFull(task), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) }).owner) continue;
|
|
359
379
|
const title = this.publicTitle(task);
|
|
360
380
|
const message = `Task ${title}: ${finding.kind}, idle escalation ${finding.step}/3.`;
|
|
361
381
|
// Only identifiers, kind and step are kept in the ladder event. A crash after this write and before publish
|
|
@@ -371,7 +391,7 @@ export class Tasks {
|
|
|
371
391
|
}
|
|
372
392
|
} else {
|
|
373
393
|
const reviewer = finding.kind === "review-pending";
|
|
374
|
-
const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), responsible], excludeOwners: finding.kind !== "review-pending" && this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], waitsFor: this.waitsFor(task), ...(reviewer ? { candidates: [], notReviewer: task.owner ?? undefined } : {}), ...this.weights(task.class) });
|
|
394
|
+
const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), responsible], excludeOwners: finding.kind !== "review-pending" && this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], waitsFor: this.waitsFor(task), ...(reviewer ? { candidates: [], notReviewer: task.owner ?? undefined } : {}), ...this.weights(task.class, task) });
|
|
375
395
|
const candidate = reviewer ? a.reviewer : a.owner;
|
|
376
396
|
this.passedOver(task, a);
|
|
377
397
|
this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
|
|
@@ -443,6 +463,10 @@ export class Tasks {
|
|
|
443
463
|
...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
|
|
444
464
|
...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
|
|
445
465
|
...(h.owner === undefined ? {} : { owner: h.owner }),
|
|
466
|
+
...(h.reviewerFamily ? { reviewerFamily: screenHistoryText(h.reviewerFamily, "reviewer family") } : {}),
|
|
467
|
+
...(h.reviewerReason ? { reviewerReason: h.reviewerReason } : {}),
|
|
468
|
+
...(h.reviewBlocked ? { reviewBlocked: true } : {}),
|
|
469
|
+
...(h.reviewerFailure ? { reviewerFailure: { peer: screenHistoryText(h.reviewerFailure.peer, "reviewer"), cause: h.reviewerFailure.cause, ...(h.reviewerFailure.head ? { head: screenHistoryText(h.reviewerFailure.head, "review head") } : {}) } } : {}),
|
|
446
470
|
...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
|
|
447
471
|
...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
|
|
448
472
|
...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
|
|
@@ -454,11 +478,13 @@ export class Tasks {
|
|
|
454
478
|
resultLine(task: Task): string {
|
|
455
479
|
const base = `task #${task.id}: ${task.state}, owner ${task.owner ?? "none"}, reviewer ${task.reviewer ?? "none"}`;
|
|
456
480
|
const noReview = task.class !== "review" && !task.reviewer && OPEN.includes(task.state) && !this.waitsFor(task).length
|
|
457
|
-
? this.noReviewer(assign(task, this.states(), this.d.routing(), { candidates: task.owner ? [task.owner] : [], notReviewer: task.owner ?? undefined, ...this.weights(task.class) }))
|
|
481
|
+
? this.noReviewer(assign(task, this.states(), this.d.routing(), { candidates: task.owner ? [task.owner] : [], notReviewer: task.owner ?? undefined, ...this.weights(task.class, task) }))
|
|
458
482
|
: undefined;
|
|
459
483
|
const held = task.owner ? peerEntry(this.d.held?.(), task.owner) : undefined;
|
|
460
484
|
const holdText = held ? `${task.owner}'s queue is held (${held}); it receives the task once the hold is resolved` : undefined;
|
|
461
|
-
|
|
485
|
+
const request = task.history.findLast(entry => ["done", "reviewer changed", "reviewer unavailable"].includes(entry.event));
|
|
486
|
+
const poolBlocked = task.state === "in_review" && task.reviewer === USER && request?.reviewBlocked ? "blocked: no reviewer can run" : undefined;
|
|
487
|
+
return [base, noReview, poolBlocked, holdText].filter(Boolean).join("; ");
|
|
462
488
|
}
|
|
463
489
|
|
|
464
490
|
private states(): Record<PeerId, PeerState> {
|
|
@@ -528,7 +554,37 @@ export class Tasks {
|
|
|
528
554
|
const held = this.d.held?.();
|
|
529
555
|
const workload = this.instanceRouting();
|
|
530
556
|
const peers = workload.instances ? [...new Set([...ADAPTERS.map(adapter => adapter.kind), ...Object.keys(workload.instances), ...this.d.bus.peers.keys()])] : ADAPTERS.map(adapter => adapter.kind);
|
|
531
|
-
return { ...workload, ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => definitionOf(peer.id, workload.instances)?.attach?.pullOnlyForms?.length && peer.pullOnly).map(peer => peer.id), offRoster: peers.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
|
|
557
|
+
return { ...workload, reviewPool: { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, required: this.d.review?.pool !== undefined }, ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => definitionOf(peer.id, workload.instances)?.attach?.pullOnlyForms?.length && peer.pullOnly).map(peer => peer.id), offRoster: peers.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private reviewHead(task: Task): string | undefined { return task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; }
|
|
561
|
+
|
|
562
|
+
private reviewPoolOptions(task: Task) {
|
|
563
|
+
const head = this.reviewHead(task);
|
|
564
|
+
const round = task.state === "in_review" ? task.history.slice(task.history.findLastIndex(entry => entry.event === "done") + 1) : [];
|
|
565
|
+
const failed = [...new Set(round.flatMap(entry => entry.reviewerFailure?.head === head && entry.reviewerFailure ? [entry.reviewerFailure.peer] : []))];
|
|
566
|
+
return { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, failed, required: this.d.review?.pool !== undefined };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private manuallyNamedReviewer(task: Task): boolean {
|
|
570
|
+
const event = task.history.findLast(entry => ["reviewer changed", "assigned", "reassigned", "escalated"].includes(entry.event));
|
|
571
|
+
return event?.event === "reviewer changed" && event.reason === "manual";
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private poolReview(task: Task, refs?: TaskRefs): Assignment | undefined {
|
|
575
|
+
if (this.manuallyNamedReviewer(task) || this.isPii(task) || task.class === "review") return undefined;
|
|
576
|
+
const draft = { ...task, refs: { ...task.refs, ...cleanRefs(refs) } };
|
|
577
|
+
const selection = assign(draft, this.states(), this.d.routing(), { ...this.weights(task.class, draft), candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}) });
|
|
578
|
+
return selection.reviewSelection ? selection : undefined;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private reviewMetadata(assignment?: Assignment): ReviewerHistory {
|
|
582
|
+
const choice = assignment?.reviewSelection;
|
|
583
|
+
const family = choice && assignment?.plannerFallback && assignment.reviewer ? familyOf(assignment.reviewer, this.d.instances?.()) : choice?.family;
|
|
584
|
+
const reason = choice && assignment?.plannerFallback
|
|
585
|
+
? assignment.sameFamilyFallback ? "same-family fallback" as const : "unavailable skip" as const
|
|
586
|
+
: choice?.reason;
|
|
587
|
+
return { ...(family ? { reviewerFamily: family } : {}), ...(reason ? { reviewerReason: reason } : {}), ...(assignment?.reviewBlocked ? { reviewBlocked: true } : {}) };
|
|
532
588
|
}
|
|
533
589
|
|
|
534
590
|
/** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
|
|
@@ -543,10 +599,10 @@ export class Tasks {
|
|
|
543
599
|
}
|
|
544
600
|
|
|
545
601
|
/** What assignment weighs besides states and policy: quota, demotion and the review record. */
|
|
546
|
-
private weights(cls: TaskClass) {
|
|
602
|
+
private weights(cls: TaskClass, task?: Task) {
|
|
547
603
|
const now = Date.now();
|
|
548
604
|
const quota = this.d.quota?.();
|
|
549
|
-
return { ...this.health(), now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
|
|
605
|
+
return { ...this.health(), ...(task ? { reviewPool: this.reviewPoolOptions(task) } : {}), now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
|
|
550
606
|
}
|
|
551
607
|
|
|
552
608
|
/**
|
|
@@ -644,6 +700,11 @@ export class Tasks {
|
|
|
644
700
|
if (by !== USER && !this.d.roles?.[by]?.includes("planner")) throw new PreEffectToolRefusal("planning requires a person or configured planner role");
|
|
645
701
|
}
|
|
646
702
|
|
|
703
|
+
private plannerWriteAuthority(by: PeerId): void {
|
|
704
|
+
if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("planner writes are unavailable during a private turn");
|
|
705
|
+
this.planningAuthority(by);
|
|
706
|
+
}
|
|
707
|
+
|
|
647
708
|
wipLimit(peer: PeerId): number {
|
|
648
709
|
const limit = this.d.wipLimit?.(peer) ?? 1;
|
|
649
710
|
if (!Number.isSafeInteger(limit) || limit < 0 || limit > 32) throw new PreEffectToolRefusal("invalid planner WIP capacity");
|
|
@@ -671,7 +732,7 @@ export class Tasks {
|
|
|
671
732
|
const states = this.states();
|
|
672
733
|
const full = this.plannerFull(task);
|
|
673
734
|
// Preview the To Do transition without routing or offering the persisted backlog row.
|
|
674
|
-
return assign({ ...task, state: "proposed" }, states, this.d.routing(), { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved && !this.excluded(task).includes(task.reserved) ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class) });
|
|
735
|
+
return assign({ ...task, state: "proposed" }, states, this.d.routing(), { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved && !this.excluded(task).includes(task.reserved) ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
|
|
675
736
|
}
|
|
676
737
|
|
|
677
738
|
async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
|
|
@@ -681,7 +742,7 @@ export class Tasks {
|
|
|
681
742
|
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0 || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("planner pull needs an id, revision and bounded reason");
|
|
682
743
|
const validate = () => {
|
|
683
744
|
const task = this.d.board.get(input.id);
|
|
684
|
-
if (!task || (task.planningRevision ?? 0) !== input.revision || task.state !== "backlog" || task.owner ||
|
|
745
|
+
if (!task || (task.planningRevision ?? 0) !== input.revision || task.state !== "backlog" || task.owner || !taskCanPlan(task) || task.pinned) throw new PreEffectToolRefusal("planner pull changed, pinned or already started");
|
|
685
746
|
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
686
747
|
if (task.release != null && (!release || ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull requires unfrozen release scope");
|
|
687
748
|
if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
|
|
@@ -719,6 +780,7 @@ export class Tasks {
|
|
|
719
780
|
for (const id of ids) if (this.d.board.get(id)?.state === "backlog") pending.add(id);
|
|
720
781
|
if (pending.size) this.plannerBacklog.set(peer, pending);
|
|
721
782
|
}
|
|
783
|
+
private readonly groomingNotified = new Map<string, number>();
|
|
722
784
|
/** One bounded ids-only batch per attached planner; failed or evicted copies stay pending. */
|
|
723
785
|
flushPlannerNotices(): number {
|
|
724
786
|
const configured = Object.entries(this.d.roles ?? {}).filter(([, roles]) => roles.includes("planner")).map(([peer]) => peer);
|
|
@@ -732,6 +794,18 @@ export class Tasks {
|
|
|
732
794
|
}
|
|
733
795
|
const planners = configured.filter(peer => this.d.bus.isRostered(peer) && this.d.bus.peers.has(peer) && this.d.bus.stateOf(peer) !== "offline");
|
|
734
796
|
if (!planners.length) return 0;
|
|
797
|
+
if ((this.d.sdlc?.config().mode ?? "off") !== "off") {
|
|
798
|
+
const revision = this.d.board.statusRevision();
|
|
799
|
+
const body = "The board changed. Before offering or accepting build work, read hub_planner_groom, consolidate dependencies and priorities, and record hub_plan_review against the current planning revision.";
|
|
800
|
+
for (const peer of planners) if (this.groomingNotified.get(peer) !== revision) {
|
|
801
|
+
const queued = this.d.bus.pendingEnvelopes(peer).some(env => env.from === HUB && env.kind === "task" && env.body === body);
|
|
802
|
+
if (queued) continue;
|
|
803
|
+
this.releaseNotice(`could not notify planner ${peer} about grooming`, () => {
|
|
804
|
+
const recipients = this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task" }));
|
|
805
|
+
if (recipients.includes(peer)) this.groomingNotified.set(peer, revision);
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
}
|
|
735
809
|
for (const id of this.incomingBacklog) if (this.d.board.get(id)?.state !== "backlog") this.incomingBacklog.delete(id);
|
|
736
810
|
const incoming = [...this.incomingBacklog].sort((a, b) => a - b).slice(0, 64);
|
|
737
811
|
if (incoming.length) {
|
|
@@ -793,7 +867,7 @@ export class Tasks {
|
|
|
793
867
|
const own = owned.get(release.id) ?? [], ownKnown = own.filter(task => task.doneAt !== null).sort(compare);
|
|
794
868
|
const cutStart = release.cut === null ? known.length : after(known, release.cut);
|
|
795
869
|
const unknownPool = release.cut === null ? approved : unknown;
|
|
796
|
-
return { ...release,
|
|
870
|
+
return { ...this.publicRelease(release),
|
|
797
871
|
name: this.nameable(release.name) ? release.name : "[withheld]",
|
|
798
872
|
goal: this.nameable(release.goal) ? release.goal : "[withheld]",
|
|
799
873
|
tasks: scopes.get(release.id) ?? [],
|
|
@@ -815,11 +889,14 @@ export class Tasks {
|
|
|
815
889
|
const task = this.d.board.get(input.id);
|
|
816
890
|
if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("planning revision changed");
|
|
817
891
|
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
|
|
818
|
-
if (by !== USER && (task.state !== "backlog" ||
|
|
892
|
+
if (by !== USER && (task.state !== "backlog" || !taskCanPlan(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
|
|
819
893
|
if (by !== USER && patch.rank !== undefined && task.history.some(entry => entry.by === USER && entry.planning?.fields.includes("rank"))) throw new PreEffectToolRefusal("a person's backlog order is retained");
|
|
820
894
|
if (by !== USER && Object.hasOwn(patch, "reserved") && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained");
|
|
821
895
|
if (Object.hasOwn(patch, "deps") || Object.hasOwn(patch, "reserved")) {
|
|
822
|
-
|
|
896
|
+
const returnedUntouched = task.state === "backlog" && !task.owner && taskHasStarted(task) && taskCanPlan(task);
|
|
897
|
+
if (!returnedUntouched) {
|
|
898
|
+
if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
|
|
899
|
+
}
|
|
823
900
|
if (patch.reserved && !definitionOf(patch.reserved, this.d.instances?.()) && !this.d.bus.knownPeers().includes(patch.reserved) && !this.d.routing().classes[task.class]?.peers.includes(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is not a supported or configured peer");
|
|
824
901
|
if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
|
|
825
902
|
if (patch.reserved) this.requireCapabilities(task, patch.reserved);
|
|
@@ -980,12 +1057,122 @@ export class Tasks {
|
|
|
980
1057
|
return result;
|
|
981
1058
|
}
|
|
982
1059
|
|
|
983
|
-
|
|
1060
|
+
/** Release assignment changes no execution role, dependency, reservation or state. */
|
|
1061
|
+
async assignRelease(by: PeerId, input: { id: number; revision: number; release: number | null; reason: string }): Promise<Task> {
|
|
1062
|
+
this.plannerWriteAuthority(by);
|
|
1063
|
+
if (typeof input.reason !== "string" || !input.reason.trim() || Buffer.byteLength(input.reason) > 1000) throw new PreEffectToolRefusal("release assignment needs a bounded reason");
|
|
1064
|
+
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0 || input.release !== null && (!Number.isSafeInteger(input.release) || input.release <= 0)) throw new PreEffectToolRefusal("invalid release assignment");
|
|
1065
|
+
const validate = () => {
|
|
1066
|
+
this.plannerWriteAuthority(by);
|
|
1067
|
+
const task = this.d.board.get(input.id);
|
|
1068
|
+
if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("release assignment changed");
|
|
1069
|
+
if (by !== USER && (this.isPii(task) || task.pinned)) throw new PreEffectToolRefusal("private or pinned release scope requires the person");
|
|
1070
|
+
for (const id of new Set([task.release, input.release])) if (id != null) {
|
|
1071
|
+
const release = this.d.board.release(id);
|
|
1072
|
+
if (!release || ["frozen", "shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("release scope is frozen or final");
|
|
1073
|
+
}
|
|
1074
|
+
return task;
|
|
1075
|
+
};
|
|
1076
|
+
const before = validate(), signature = JSON.stringify(before), privateScope = this.isPii(before);
|
|
1077
|
+
if (!privateScope && (!this.nameable(input.reason) || this.screenOn() && (await this.screenCall(input.reason)).label !== "clear")) throw new PreEffectToolRefusal("release reason was not cleared for public planning");
|
|
1078
|
+
if (JSON.stringify(validate()) !== signature) throw new PreEffectToolRefusal("release assignment changed during screening");
|
|
1079
|
+
const result = this.d.board.plan(input.id, by, { release: input.release }, input.revision, input.reason.trim(), privateScope);
|
|
1080
|
+
this.planningRecorded({ by, task: input.id, fields: ["release"], revision: result.planningRevision! });
|
|
1081
|
+
return result;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
async configureRelease(by: PeerId, input: { id: number; revision: number; hotfix?: unknown; state?: "active"; cut?: number }): Promise<Release> {
|
|
1085
|
+
this.plannerWriteAuthority(by);
|
|
1086
|
+
const validate = () => {
|
|
1087
|
+
this.plannerWriteAuthority(by);
|
|
1088
|
+
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid release identity or revision");
|
|
1089
|
+
const release = this.d.board.release(input.id);
|
|
1090
|
+
if (!release || release.revision !== input.revision || !["planning", "active"].includes(release.state)) throw new PreEffectToolRefusal("release revision changed or scope frozen");
|
|
1091
|
+
if (input.state !== undefined && input.state !== "active") throw new PreEffectToolRefusal("invalid train state");
|
|
1092
|
+
if (input.cut !== undefined && (!Number.isSafeInteger(input.cut) || input.cut <= 0 || input.cut > 8_640_000_000_000_000)) throw new PreEffectToolRefusal("release cut must be a positive safe UTC timestamp");
|
|
1093
|
+
const dates = [release.plannedStart, input.cut ?? release.cut, release.ship].filter((date): date is number => date !== null);
|
|
1094
|
+
if (dates.some((date, index) => index > 0 && dates[index - 1]! > date)) throw new PreEffectToolRefusal("invalid release planned dates");
|
|
1095
|
+
if ((input.state ?? release.state) === "active" && this.d.board.releases().some(row => row.id !== input.id && ["active", "frozen"].includes(row.state))) throw new PreEffectToolRefusal("one open release train at a time");
|
|
1096
|
+
return release;
|
|
1097
|
+
};
|
|
1098
|
+
const release = validate();
|
|
1099
|
+
let train;
|
|
1100
|
+
try { train = releaseTrainData({ ...(release.train ?? { history: [] }), ...(input.hotfix === undefined ? {} : { hotfix: input.hotfix }) }); }
|
|
1101
|
+
catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
1102
|
+
if (train.hotfix && (!this.nameable(train.hotfix.reason) || this.screenOn() && (await this.screenCall(train.hotfix.reason)).label !== "clear")) throw new PreEffectToolRefusal("hotfix reason was not cleared");
|
|
1103
|
+
const validatedRelease = validate();
|
|
1104
|
+
if (JSON.stringify(validatedRelease) !== JSON.stringify(release)) throw new PreEffectToolRefusal("release changed during screening");
|
|
1105
|
+
if (release.train && JSON.stringify(train) === JSON.stringify(release.train) && (input.state ?? release.state) === release.state && (input.cut ?? release.cut) === release.cut) return validatedRelease;
|
|
1106
|
+
if (train.history.length >= RELEASE_TRAIN_HISTORY_LIMIT - 2) throw new PreEffectToolRefusal("release train history full; keep the remaining slots for cut and ship");
|
|
1107
|
+
train.history.push({ at: Date.now(), by, event: "configured" });
|
|
1108
|
+
const updated = this.d.board.updateRelease(input.id, { train: releaseTrainData(train), ...(input.state ? { state: input.state } : {}), ...(input.cut === undefined ? {} : { cut: input.cut }) }, input.revision);
|
|
1109
|
+
this.planningRecorded({ by, release: input.id, fields: ["train"], revision: updated.revision });
|
|
1110
|
+
return updated;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
publicRelease(release: Release): Release {
|
|
1114
|
+
return { ...release, name: this.nameable(release.name) ? release.name : "[withheld]", goal: this.nameable(release.goal) ? release.goal : "[withheld]", ...(release.train?.hotfix && !this.nameable(release.train.hotfix.reason) ? { train: { ...release.train, hotfix: { ...release.train.hotfix, reason: "[withheld]" } } } : {}) };
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
releaseReport(by: PeerId, id: number) {
|
|
984
1118
|
this.planningAuthority(by);
|
|
1119
|
+
if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("release report unavailable during a private turn");
|
|
1120
|
+
const release = Number.isSafeInteger(id) && id > 0 ? this.d.board.release(id) : undefined;
|
|
1121
|
+
if (!release) throw new PreEffectToolRefusal("no such release");
|
|
1122
|
+
const all = this.d.board.list(), publicTasks = all.filter(task => !this.isPii(task));
|
|
1123
|
+
if (all.some(task => task.release === id && this.isPii(task))) throw new PreEffectToolRefusal("release scope contains private work; public readiness unavailable");
|
|
1124
|
+
return { release: this.publicRelease(release), readiness: releaseTrainReadiness(release, publicTasks), metrics: releaseMetrics(this.d.board.releases().filter(row => !all.some(task => task.release === row.id && this.isPii(task))), publicTasks), native: "unverified" as const };
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/** Scope freeze only. This never tags, publishes, upgrades, or authorizes a release. */
|
|
1128
|
+
async cutRelease(by: PeerId, input: { id: number; revision: number }): Promise<Release> {
|
|
1129
|
+
const validate = () => {
|
|
1130
|
+
this.plannerWriteAuthority(by);
|
|
1131
|
+
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid release identity or revision");
|
|
1132
|
+
const release = this.d.board.release(input.id);
|
|
1133
|
+
if (!release || release.revision !== input.revision || release.state !== "active") throw new PreEffectToolRefusal("release revision changed or train is not active");
|
|
1134
|
+
if (this.d.board.releases().some(row => row.id !== input.id && ["active", "frozen"].includes(row.state))) throw new PreEffectToolRefusal("one open release train at a time");
|
|
1135
|
+
if ((release.train?.history.length ?? 0) > RELEASE_TRAIN_HISTORY_LIMIT - 2) throw new PreEffectToolRefusal("release train history full; cut needs slots for cut and ship, drop this train and create a replacement");
|
|
1136
|
+
const report = this.releaseReport(by, input.id);
|
|
1137
|
+
if (report.readiness.blockers.length) throw new PreEffectToolRefusal(`release cut blocked: ${report.readiness.blockers.join("; ")}`);
|
|
1138
|
+
return { release, scope: this.d.board.list().filter(task => task.release === input.id) };
|
|
1139
|
+
};
|
|
1140
|
+
const before = validate(), signature = JSON.stringify(before);
|
|
1141
|
+
const expected = new Set(before.scope.flatMap(task => task.sdlc?.repo && task.sdlc.pr ? [`${task.sdlc.repo.toLowerCase()}#${task.sdlc.pr}`] : []));
|
|
1142
|
+
let ciRuns: number | undefined;
|
|
1143
|
+
if (expected.size && before.scope.every(task => task.sdlc?.repo && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(task.sdlc.repo) && !task.sdlc.repo.startsWith("-") && Number.isSafeInteger(task.sdlc.pr) && task.sdlc.pr! > 0)) {
|
|
1144
|
+
const repositories = [...new Set(before.scope.flatMap(task => task.sdlc?.repo ? [task.sdlc.repo] : []))];
|
|
1145
|
+
if (repositories.length === 1 && expected.size <= 16) {
|
|
1146
|
+
try {
|
|
1147
|
+
const costs = await this.costReport(by, repositories[0], before.scope.map(task => task.sdlc!.pr!)), rows = costs.rows as import("./cost-governance.ts").CostRow[];
|
|
1148
|
+
const selected = rows.filter(row => expected.has(`${row.repo.toLowerCase()}#${row.pr}`));
|
|
1149
|
+
if (!costs.truncated && selected.length === expected.size && selected.every(row => row.ciRuns !== null && row.ciReruns !== null)) ciRuns = selected.reduce((sum, row) => sum + row.ciRuns! + row.ciReruns!, 0);
|
|
1150
|
+
} catch { /* Optional cost evidence stays unknown; validation below still rechecks scope and authority. */ }
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
if (JSON.stringify(validate()) !== signature) throw new PreEffectToolRefusal("release scope changed during cost readback");
|
|
1154
|
+
const window = await this.d.releaseWindow?.();
|
|
1155
|
+
try {
|
|
1156
|
+
if (JSON.stringify({ release: this.d.board.release(input.id), scope: this.d.board.list().filter(task => task.release === input.id) }) !== signature) throw new PreEffectToolRefusal("release scope changed during admission");
|
|
1157
|
+
const freshScope = validate();
|
|
1158
|
+
if (!window || !Number.isFinite(window.at) || Date.now() - window.at > 5000 || window.at > Date.now() || window.host !== true || window.quota !== true || window.current?.() === false) throw new PreEffectToolRefusal(`release cut deferred: ${window?.reasons?.join("; ") || "host or peer quota evidence unavailable"}; inspect ahub budget and ahub budget set <peer> <0..1>; stop or resume the paused peer; the person can use ahub release drop or explicitly disable lifecycle mode before retrying`);
|
|
1159
|
+
const scope = freshScope.scope.map(task => task.id).sort((a, b) => a - b), train = releaseTrainData(freshScope.release.train ?? { history: [] });
|
|
1160
|
+
train.scope = scope; train.history.push({ at: Date.now(), by, event: "cut", scope, reviews: freshScope.scope.reduce((sum, task) => sum + task.history.filter(row => ["approved", "changes_requested"].includes(row.event)).length, 0), ...(ciRuns === undefined ? {} : { ciRuns }) });
|
|
1161
|
+
const updated = this.d.board.freezeRelease(input.id, input.revision, freshScope.scope, releaseTrainData(train));
|
|
1162
|
+
this.planningRecorded({ by, release: input.id, fields: ["state", "train"], revision: updated.revision });
|
|
1163
|
+
return updated;
|
|
1164
|
+
} finally {
|
|
1165
|
+
try { await window?.close?.(); }
|
|
1166
|
+
catch { this.releaseNotice(`release #${input.id}: admission cleanup incomplete; inspect the host lease before retrying`); }
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
async newRelease(by: PeerId, input: { name: string; goal: string; order: number }): Promise<Release> {
|
|
1171
|
+
this.plannerWriteAuthority(by);
|
|
985
1172
|
if (typeof input.name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(input.name) || typeof input.goal !== "string" || !input.goal.trim() || input.goal.length > 1000 || !Number.isSafeInteger(input.order) || input.order < 0) throw new PreEffectToolRefusal("invalid release name, goal or order");
|
|
986
1173
|
const text = input.name + "\n" + input.goal;
|
|
987
1174
|
if (!this.nameable(text) || this.screenOn() && (await this.screenCall(text)).label !== "clear") throw new PreEffectToolRefusal("release text was not cleared for public planning");
|
|
988
|
-
this.
|
|
1175
|
+
this.plannerWriteAuthority(by); // Privacy and planner identity may change while screening.
|
|
989
1176
|
if (this.d.board.releases().some(release => release.name === input.name)) throw new PreEffectToolRefusal("release name already exists");
|
|
990
1177
|
const release = this.d.board.newRelease(input.name, input.goal.trim(), input.order);
|
|
991
1178
|
this.planningRecorded({ by, release: release.id, fields: ["created"], revision: release.revision });
|
|
@@ -999,9 +1186,17 @@ export class Tasks {
|
|
|
999
1186
|
if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release is final");
|
|
1000
1187
|
if (by !== USER && (release.state === "frozen" || input.state === "frozen" || input.state === "shipped" || input.state === "dropped")) throw new PreEffectToolRefusal("freeze, ship, drop and frozen changes require a person");
|
|
1001
1188
|
if (input.state === "shipped" && this.d.board.list().some(task => task.release === release.id && !isClosedTaskState(task.state))) throw new PreEffectToolRefusal("every release task must be approved, cancelled, or moved out before ship");
|
|
1189
|
+
if (input.state === "active" && (release.train || this.d.sdlc?.config().mode !== undefined && this.d.sdlc.config().mode !== "off" || this.d.board.releases().some(row => row.train && ["active", "frozen"].includes(row.state))) && this.d.board.releases().some(row => row.id !== release.id && ["active", "frozen"].includes(row.state))) throw new PreEffectToolRefusal("one open release train at a time");
|
|
1190
|
+
if (input.state === "frozen" && (release.train || this.d.sdlc?.config().mode !== undefined && this.d.sdlc.config().mode !== "off")) throw new PreEffectToolRefusal("use the resource-admitted release cut operation");
|
|
1191
|
+
if (input.state === "shipped" && release.train) {
|
|
1192
|
+
if (release.train.history.length >= RELEASE_TRAIN_HISTORY_LIMIT) throw new PreEffectToolRefusal("release train history full; ship needs one history slot, drop this train and create a replacement");
|
|
1193
|
+
const readiness = releaseTrainReadiness(release, this.d.board.list());
|
|
1194
|
+
if (readiness.blockers.length) throw new PreEffectToolRefusal(`release ship blocked: ${readiness.blockers.join("; ")}`);
|
|
1195
|
+
}
|
|
1002
1196
|
const { id, revision, ...patch } = input;
|
|
1003
1197
|
if (!Object.keys(patch).length || Object.keys(patch).some(key => !["state", "plannedStart", "cut", "ship"].includes(key))) throw new PreEffectToolRefusal("unknown or empty release fields");
|
|
1004
|
-
const
|
|
1198
|
+
const trained = release.train && input.state === "shipped" ? releaseTrainData({ ...release.train, history: [...release.train.history, { at: Date.now(), by, event: "shipped" }] }) : undefined;
|
|
1199
|
+
const updated = this.d.board.updateRelease(id, { ...patch, ...(trained ? { train: trained } : {}) }, revision);
|
|
1005
1200
|
this.planningRecorded({ by, release: id, fields: Object.keys(patch), revision: updated.revision });
|
|
1006
1201
|
return updated; // a failed readback may follow a durable release write
|
|
1007
1202
|
}
|
|
@@ -1011,6 +1206,12 @@ export class Tasks {
|
|
|
1011
1206
|
if (external && (by !== USER || input.backlog !== true)) throw new PreEffectToolRefusal("external intake requires person-owned backlog staging");
|
|
1012
1207
|
if (input.backlog !== undefined && typeof input.backlog !== "boolean") throw new PreEffectToolRefusal("backlog must be a boolean");
|
|
1013
1208
|
const backlog = input.backlog === true;
|
|
1209
|
+
const claiming = !backlog && input.owner === by;
|
|
1210
|
+
if (claiming) {
|
|
1211
|
+
this.acceptanceCapacity({ id: -1, owner: by, state: "proposed" });
|
|
1212
|
+
this.pendingClaims.set(by, (this.pendingClaims.get(by) ?? 0) + 1);
|
|
1213
|
+
}
|
|
1214
|
+
try {
|
|
1014
1215
|
if (backlog && input.owner && !this.d.bus.isRostered(input.owner)) throw new PreEffectToolRefusal(`${input.owner} is off roster; add it before reserving work`);
|
|
1015
1216
|
// Callers are models: a title is one line (it is part of console and hub.log lines), not a document.
|
|
1016
1217
|
const title = String(input.title ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
|
|
@@ -1060,6 +1261,7 @@ export class Tasks {
|
|
|
1060
1261
|
const defaulted = !backlog && !cls && input.owner === by;
|
|
1061
1262
|
if (defaulted) cls = "implement";
|
|
1062
1263
|
if (!cls) throw new PreEffectToolRefusal(`class is required (one of ${CLASSES.join(", ")}); ${fenced ? "the task is handled as PII and the hub's model is not reached on campus, so it was not asked to name one" : "the hub could not name one for you"}`);
|
|
1264
|
+
if (claiming) this.acceptanceCapacity({ id: -1, owner: by, state: "proposed" }, 1);
|
|
1063
1265
|
let selected;
|
|
1064
1266
|
try { selected = taskRole(input.role, cls, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
|
|
1065
1267
|
catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
@@ -1086,7 +1288,40 @@ export class Tasks {
|
|
|
1086
1288
|
return this.d.board.update(task.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
|
|
1087
1289
|
}
|
|
1088
1290
|
// Naming yourself is a claim: the work is already yours, so no offer comes back to you (paper: Agensh CLAIM, #68).
|
|
1089
|
-
return this.assignOwner(task, by, input.owner ? { candidates: [input.owner], namedOwner: input.owner, claim: input.owner === by } : {});
|
|
1291
|
+
return await this.assignOwner(task, by, input.owner ? { candidates: [input.owner], namedOwner: input.owner, claim: input.owner === by } : {});
|
|
1292
|
+
} finally {
|
|
1293
|
+
if (claiming) {
|
|
1294
|
+
const remaining = (this.pendingClaims.get(by) ?? 1) - 1;
|
|
1295
|
+
if (remaining) this.pendingClaims.set(by, remaining); else this.pendingClaims.delete(by);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
async returnUntouchedToBacklog(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
|
|
1301
|
+
this.plannerWriteAuthority(by);
|
|
1302
|
+
if (!Number.isSafeInteger(input.id) || input.id < 1 || !Number.isSafeInteger(input.revision) || input.revision < 0
|
|
1303
|
+
|| typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("untouched return needs an id, revision and bounded reason");
|
|
1304
|
+
const validate = () => {
|
|
1305
|
+
const task = this.need(input.id, false, true);
|
|
1306
|
+
if (!["proposed", "in_progress"].includes(task.state) || (task.planningRevision ?? 0) !== input.revision || taskHasWorkEvidence(task)) throw new PreEffectToolRefusal("work has execution evidence or its planning revision changed");
|
|
1307
|
+
if (by !== USER && task.pinned) throw new PreEffectToolRefusal("pinned work requires the person");
|
|
1308
|
+
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
1309
|
+
if (release && (["shipped", "dropped"].includes(release.state) || by !== USER && release.state === "frozen")) throw new PreEffectToolRefusal("settled or frozen release scope cannot return");
|
|
1310
|
+
return task;
|
|
1311
|
+
};
|
|
1312
|
+
const task = validate(), flow = task.history.length;
|
|
1313
|
+
const withheld = await this.withholds("note", input.reason, task);
|
|
1314
|
+
this.plannerWriteAuthority(by);
|
|
1315
|
+
const confirmed = validate();
|
|
1316
|
+
if (confirmed.history.length !== flow) throw new PreEffectToolRefusal("task changed during untouched return");
|
|
1317
|
+
const returned = this.d.board.update(confirmed.id, by, "backlog returned untouched", { state: "backlog", owner: null, reviewer: null }, input.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
|
|
1318
|
+
this.offered.delete(confirmed.id);
|
|
1319
|
+
this.cohorts.leave(confirmed.id);
|
|
1320
|
+
this.withdrawRoleRequest(confirmed, returned, "owner");
|
|
1321
|
+
this.withdrawRoleRequest(confirmed, returned, "reviewer");
|
|
1322
|
+
this.incomingBacklog.add(confirmed.id);
|
|
1323
|
+
this.planningRecorded({ by, task: confirmed.id, fields: ["return"], revision: confirmed.planningRevision ?? 0 });
|
|
1324
|
+
return returned;
|
|
1090
1325
|
}
|
|
1091
1326
|
|
|
1092
1327
|
/** Person-owned staging only. Flow transitions remain with their existing owners and reviewers. */
|
|
@@ -1413,7 +1648,7 @@ export class Tasks {
|
|
|
1413
1648
|
const task = this.d.board.get(target);
|
|
1414
1649
|
if (!task) throw new Error(`no task #${target}`);
|
|
1415
1650
|
if (isClosedTaskState(task.state)) return [...workflow, `task ${this.publicTitle(task)} (${task.state}): closed; not routed`];
|
|
1416
|
-
const a = assign(task, this.states(), routing, { exclude: this.excluded(task), excludeOwners: this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], waitsFor: this.waitsFor(task), ...this.weights(task.class) });
|
|
1651
|
+
const a = assign(task, this.states(), routing, { exclude: this.excluded(task), excludeOwners: this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
|
|
1417
1652
|
// The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
|
|
1418
1653
|
return [...workflow, `task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"}${task.reserved ? `, reserved for ${task.reserved}` : ""})`, ...(task.profileClass ? [`profile class ${profileTaskLabel(task)}`] : []), ...(task.role ? [`task role ${task.role} (guidance only)`] : []), "if it were assigned now:", ...a.trace, ...(this.splitShadow(task, task.owner ?? a.owner)?.trace ?? [])];
|
|
1419
1654
|
}
|
|
@@ -1466,6 +1701,7 @@ export class Tasks {
|
|
|
1466
1701
|
|
|
1467
1702
|
/** Why the task will approve directly at done: no reviewer could be picked, and who was skipped (issue #92). */
|
|
1468
1703
|
private noReviewer(a: Assignment): string {
|
|
1704
|
+
if (a.reviewBlocked) return "blocked: no reviewer can run";
|
|
1469
1705
|
const skipped = a.trace.flatMap((l) => {
|
|
1470
1706
|
const m = /^ {2}reviewer candidate (\S+): skipped, (.*)$/.exec(l);
|
|
1471
1707
|
return m ? [`${m[1]} (${m[2]})`] : [];
|
|
@@ -1480,7 +1716,14 @@ export class Tasks {
|
|
|
1480
1716
|
}
|
|
1481
1717
|
|
|
1482
1718
|
private assignmentNote(note: string, assignment: Assignment): string {
|
|
1483
|
-
|
|
1719
|
+
const tagged = assignment.sameFamilyFallback ? `${note}; sameFamilyFallback=true` : note;
|
|
1720
|
+
const pool = assignment.reviewSelection, metadata = this.reviewMetadata(assignment);
|
|
1721
|
+
const walk = pool?.blocked ? pool.walk.slice(0, 16).map(row => {
|
|
1722
|
+
const reason = this.nameable(row.reason) ? Array.from(sanitize(row.reason).replace(/\s+/g, " ")).slice(0, 80).join("") : "[withheld]";
|
|
1723
|
+
return `${row.id || row.kind}: ${reason}`;
|
|
1724
|
+
}).join("; ") + (pool.walk.length > 16 ? `; +${pool.walk.length - 16} walk entries` : "") : "";
|
|
1725
|
+
const recorded = pool ? `${tagged}; reviewerFamily=${metadata.reviewerFamily ?? "none"}; reviewerReason=${metadata.reviewerReason ?? pool.blocked}${pool.blocked ? `; pool=${pool.blocked}; walk: ${walk}` : ""}` : tagged;
|
|
1726
|
+
return assignment.plannerFallback ? `${recorded}; reviewer=planner (fallback)` : recorded;
|
|
1484
1727
|
}
|
|
1485
1728
|
|
|
1486
1729
|
/** A later move to another owner means the caller's assignment was superseded, so its success tail is not authoritative. */
|
|
@@ -1495,10 +1738,28 @@ export class Tasks {
|
|
|
1495
1738
|
/** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
|
|
1496
1739
|
private async assignOwner(task: Task, by: PeerId, opts: { 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 } = {}): Promise<Task> {
|
|
1497
1740
|
if (task.state === "backlog") throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
|
|
1741
|
+
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
1742
|
+
let stagedClaimGap: string | undefined;
|
|
1743
|
+
if (mode !== "off" && this.isBuildTask(task)) {
|
|
1744
|
+
const gap = task.sdlc?.planReview ? await this.planReviewGap(task) : "plan not reviewed since proposal";
|
|
1745
|
+
const current = this.d.board.get(task.id);
|
|
1746
|
+
if (!current || !this.ownerAssignmentCurrent(task, current)) return current ?? task;
|
|
1747
|
+
task = current; // Reviewer-only and quiet bookkeeping updates do not supersede ownership authority.
|
|
1748
|
+
if (gap) {
|
|
1749
|
+
this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}; planner: call hub_planner_groom then hub_plan_review`);
|
|
1750
|
+
if (mode === "enforce") {
|
|
1751
|
+
if (opts.claim && opts.namedOwner === by) stagedClaimGap = gap;
|
|
1752
|
+
else {
|
|
1753
|
+
this.reportStall(current, { kind: "no-eligible-peer", code: "policy" });
|
|
1754
|
+
return current;
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1498
1759
|
const waits = this.waitsFor(task);
|
|
1499
1760
|
const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
|
|
1500
1761
|
const plannerFull = plannedMove ? this.plannerFull(task) : [];
|
|
1501
|
-
const a = assign(task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}) });
|
|
1762
|
+
const a = assign(task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class, task), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}) });
|
|
1502
1763
|
|
|
1503
1764
|
if (waits.length) {
|
|
1504
1765
|
this.releaseNotice(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
|
|
@@ -1521,7 +1782,7 @@ export class Tasks {
|
|
|
1521
1782
|
// A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
|
|
1522
1783
|
// out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
|
|
1523
1784
|
const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
|
|
1524
|
-
const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a), { ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) }, opts.expectedFlowRevision);
|
|
1785
|
+
const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a), { ...this.reviewMetadata(a), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) }, opts.expectedFlowRevision);
|
|
1525
1786
|
// What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
|
|
1526
1787
|
// escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
|
|
1527
1788
|
// yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
|
|
@@ -1566,6 +1827,13 @@ export class Tasks {
|
|
|
1566
1827
|
}
|
|
1567
1828
|
// A claim is its own hand-over, with no delivery: its accepted write and its line stay as they were (#297).
|
|
1568
1829
|
if (opts.claim && a.owner === by) {
|
|
1830
|
+
// #481 S1: a claim of build work starts it only from an accepted spec. In enforce the claim stays an assignment
|
|
1831
|
+
// (owner set, still proposed); hub_task_accept starts it once the person accepted the spec.
|
|
1832
|
+
if (this.specGated(next) && specState(next.sdlc, undefined) === "missing") {
|
|
1833
|
+
if (this.d.sdlc?.config().mode === "enforce") { this.releaseNotice(`${this.specText(next, "missing", "blocked")}; the claim stays assigned to ${by}`); return next; }
|
|
1834
|
+
this.releaseNotice(this.specText(next, "missing", "advice"));
|
|
1835
|
+
}
|
|
1836
|
+
if (stagedClaimGap) return next; // An explicit claim may reserve its owner, but no offer or acceptance bypasses review.
|
|
1569
1837
|
const claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" });
|
|
1570
1838
|
this.releaseNotice(`task ${this.publicTitle(claimed)} claimed by ${by}`);
|
|
1571
1839
|
return claimed;
|
|
@@ -1835,6 +2103,160 @@ export class Tasks {
|
|
|
1835
2103
|
if (by !== USER && task[role] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
|
|
1836
2104
|
}
|
|
1837
2105
|
|
|
2106
|
+
private planIssueIdentity(task: Task): { repo: string; issue: number } | undefined {
|
|
2107
|
+
const source = task.external ?? task.sdlc;
|
|
2108
|
+
if (source?.repo && source.issue) return { repo: source.repo, issue: source.issue };
|
|
2109
|
+
const repository = task.refs.repo ?? this.d.repository ?? "";
|
|
2110
|
+
let issues: number[];
|
|
2111
|
+
try { issues = specRefs(task, repository); }
|
|
2112
|
+
catch { throw new PreEffectToolRefusal("plan review issue identity unavailable"); }
|
|
2113
|
+
if (issues.length > 1) throw new PreEffectToolRefusal("plan review needs one explicit issue identity");
|
|
2114
|
+
return issues[0] ? { repo: repository, issue: issues[0] } : undefined;
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
async groomingDigest(by: PeerId): Promise<Record<string, unknown>> {
|
|
2118
|
+
this.planningAuthority(by);
|
|
2119
|
+
const included = (task: Task) => !isClosedTaskState(task.state) || task.state === "cancelled" && task.history.some(entry => !!entry.supersededBy);
|
|
2120
|
+
const snapshot = this.d.board.list().filter(included), issues = new Map<string, PlanIssueMetadata>(), attempted = new Set<string>();
|
|
2121
|
+
const deadline = performance.now() + 4_000;
|
|
2122
|
+
let reads = 0, incomplete = false;
|
|
2123
|
+
// ponytail: at most eight metadata reads within four seconds; add paginated revision-bound readback for larger boards.
|
|
2124
|
+
for (const listed of snapshot) {
|
|
2125
|
+
this.planningAuthority(by);
|
|
2126
|
+
const task = this.d.board.get(listed.id);
|
|
2127
|
+
if (!task || this.isPii(task) || isClosedTaskState(task.state)) continue;
|
|
2128
|
+
let source: { repo: string; issue: number } | undefined;
|
|
2129
|
+
try { source = this.planIssueIdentity(task); } catch { continue; }
|
|
2130
|
+
if (!source) continue;
|
|
2131
|
+
const key = `${source.repo.toLowerCase()}#${source.issue}`;
|
|
2132
|
+
if (attempted.has(key)) continue;
|
|
2133
|
+
attempted.add(key);
|
|
2134
|
+
if (reads >= 8 || performance.now() >= deadline) { incomplete = true; break; }
|
|
2135
|
+
reads++;
|
|
2136
|
+
const run = this.d.sdlc?.gh ?? sdlcGh;
|
|
2137
|
+
const issue = await readPlanIssueMetadata(source.repo, source.issue, (args, timeout, cwd, cap) => run(args, Math.max(1, Math.min(timeout ?? 10_000, deadline - performance.now())), cwd, cap));
|
|
2138
|
+
if (issue) issues.set(key, issue);
|
|
2139
|
+
}
|
|
2140
|
+
this.planningAuthority(by);
|
|
2141
|
+
const current = this.d.board.list().filter(included), visible = current.filter(task => !this.isPii(task));
|
|
2142
|
+
const peers = [...new Set([...Object.keys(this.states()), ...current.flatMap(task => task.owner ? [task.owner] : [])])];
|
|
2143
|
+
const reviewedIssues = new Map(visible.flatMap(task => task.sdlc?.planReview?.issue ? [[task.id, task.sdlc.planReview.issue] as const] : []));
|
|
2144
|
+
const acceptedIssues = new Map(visible.flatMap(task => task.sdlc?.acceptance ? [[task.id, task.sdlc.acceptance] as const] : []));
|
|
2145
|
+
const blockers: { task: number; code: string }[] = this.d.board.stalls().map(stall => ({ task: stall.task, code: stall.cause.code }));
|
|
2146
|
+
const health = this.health(), states = this.states(), routing = this.d.routing(), hostBlocks = this.d.plannerBlocks?.() ?? {};
|
|
2147
|
+
for (const task of visible.filter(task => !isClosedTaskState(task.state)).slice(0, 500)) {
|
|
2148
|
+
const owner = task.owner ?? task.reserved;
|
|
2149
|
+
if (owner) for (const code of peerEntry(hostBlocks, owner) ?? []) blockers.push({ task: task.id, code });
|
|
2150
|
+
const result = assign(task.state === "backlog" ? { ...task, state: "proposed" } : task, states, routing, { ...health, waitsFor: this.waitsFor(task), ...(owner ? { candidates: [owner], namedOwner: owner } : {}) });
|
|
2151
|
+
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 });
|
|
2152
|
+
if (this.waitsFor(task).length) blockers.push({ task: task.id, code: "dependency" });
|
|
2153
|
+
}
|
|
2154
|
+
const digest = plannerGrooming(visible, { repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
|
|
2155
|
+
limits: Object.fromEntries(peers.map(peer => [peer, this.wipLimit(peer)])), wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])),
|
|
2156
|
+
states, issues, reviewedIssues, acceptedIssues, blockers });
|
|
2157
|
+
return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
/** S10 read-only planner report; S7 owns rolling bands and automatic board proposals. */
|
|
2161
|
+
async costReport(by: PeerId, repository?: unknown, scopePrs?: readonly number[]): Promise<Record<string, unknown>> {
|
|
2162
|
+
const authority = () => {
|
|
2163
|
+
this.planningAuthority(by);
|
|
2164
|
+
if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("cost report unavailable during private work");
|
|
2165
|
+
};
|
|
2166
|
+
authority();
|
|
2167
|
+
const repo = repository ?? this.d.repository;
|
|
2168
|
+
if (typeof repo !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || repo.startsWith("-") || !this.nameable(repo)) throw new PreEffectToolRefusal("cost report requires a public owner/repository identity");
|
|
2169
|
+
const deadline = performance.now() + 20_000;
|
|
2170
|
+
const bounded = (args: string[], timeout?: number, cwd?: string, cap?: number) => performance.now() < deadline
|
|
2171
|
+
? (this.d.sdlc?.gh ?? sdlcGh)(args, Math.max(1, Math.min(timeout ?? 10_000, deadline - performance.now())), cwd, cap)
|
|
2172
|
+
: Promise.resolve({ code: 1, stdout: "" });
|
|
2173
|
+
const requested = scopePrs === undefined ? undefined : new Set(scopePrs);
|
|
2174
|
+
const linked = () => this.d.board.list().filter(task => task.sdlc?.repo?.toLowerCase() === repo.toLowerCase() && Number.isSafeInteger(task.sdlc.pr) && task.sdlc.pr! > 0 && (!requested || requested.has(task.sdlc.pr!)));
|
|
2175
|
+
const ids = [...new Set(linked().filter(task => !this.isPii(task)).map(task => task.sdlc!.pr!))].sort((a, b) => a - b);
|
|
2176
|
+
const observations: import("./cost-governance.ts").CostPr[] = [];
|
|
2177
|
+
let incomplete = ids.length > 16;
|
|
2178
|
+
for (const pr of ids.slice(0, 16)) {
|
|
2179
|
+
authority();
|
|
2180
|
+
if (linked().some(task => task.sdlc?.pr === pr && this.isPii(task))) continue;
|
|
2181
|
+
if (performance.now() >= deadline) { incomplete = true; break; }
|
|
2182
|
+
const evidence = await readPrCostHistory(repo, pr, (args, timeout, cwd, cap) => {
|
|
2183
|
+
authority();
|
|
2184
|
+
const fresh = linked().filter(task => task.sdlc?.pr === pr);
|
|
2185
|
+
if (!fresh.length || fresh.some(task => this.isPii(task))) return Promise.resolve({ code: 1, stdout: "" });
|
|
2186
|
+
return bounded(args, timeout, cwd, cap);
|
|
2187
|
+
});
|
|
2188
|
+
if (evidence) observations.push(evidence); else incomplete = true;
|
|
2189
|
+
}
|
|
2190
|
+
authority();
|
|
2191
|
+
const current = linked();
|
|
2192
|
+
const visiblePrs = new Set(current.filter(task => !this.isPii(task)).map(task => task.sdlc!.pr!));
|
|
2193
|
+
for (const task of current) if (this.isPii(task)) visiblePrs.delete(task.sdlc!.pr!);
|
|
2194
|
+
if (JSON.stringify([...visiblePrs].sort((a, b) => a - b)) !== JSON.stringify(ids)) incomplete = true;
|
|
2195
|
+
const report = costMetrics(current.map(task => ({ repo, pr: task.sdlc!.pr!, private: this.isPii(task), history: task.history })), observations.filter(pr => visiblePrs.has(pr.pr)));
|
|
2196
|
+
return { ...report, truncated: report.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}), bands: "S7 pending" };
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
async planReviewGap(task: Task): Promise<string | undefined> {
|
|
2200
|
+
const receipt = task.sdlc?.planReview;
|
|
2201
|
+
if (!receipt) return "plan not reviewed since proposal";
|
|
2202
|
+
if (!receipt.planner || !Number.isFinite(receipt.recordedAt)) return "plan not reviewed since planning revision change";
|
|
2203
|
+
const revision = task.planningRevision ?? 0;
|
|
2204
|
+
if (receipt.planningRevision !== revision) {
|
|
2205
|
+
const changes = task.history.filter(entry => entry.planning && entry.planning.revision > receipt.planningRevision);
|
|
2206
|
+
if (receipt.planningRevision > revision || changes.length !== revision - receipt.planningRevision || changes.some((entry, index) => entry.event !== "planned" || entry.planning!.revision !== receipt.planningRevision + index + 1 || entry.planning!.fields.length !== 1 || entry.planning!.fields[0] !== "release")) return "plan not reviewed since planning revision change";
|
|
2207
|
+
}
|
|
2208
|
+
if (receipt.scope && JSON.stringify(receipt.scope) !== JSON.stringify(cleanCostPlan(task.plan ?? {}))) return "plan not reviewed since cost scope change";
|
|
2209
|
+
const identity = this.planIssueIdentity(task);
|
|
2210
|
+
if (identity?.issue && this.isPii(task)) return "private issue plan review evidence unavailable";
|
|
2211
|
+
if (!identity?.issue) return receipt.issue ? "plan not reviewed since issue identity change" : undefined;
|
|
2212
|
+
if (!identity.repo || typeof receipt.issue?.repo !== "string" || receipt.issue.repo.toLowerCase() !== identity.repo.toLowerCase() || receipt.issue.issue !== identity.issue) return "plan not reviewed since issue identity change";
|
|
2213
|
+
const current = await readPlanIssueMetadata(identity.repo, identity.issue, this.d.sdlc?.gh ?? sdlcGh);
|
|
2214
|
+
if (!current) return "plan review issue metadata unavailable";
|
|
2215
|
+
if (current.state !== "OPEN") return "plan review issue is closed";
|
|
2216
|
+
return current.editedAt !== receipt.issue.editedAt ? "plan not reviewed since issue edit" : undefined;
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
/** Planner authority and issue edit evidence are rechecked across every external await. */
|
|
2220
|
+
async recordPlanReview(by: PeerId, id: unknown, input: unknown): Promise<Task> {
|
|
2221
|
+
this.plannerWriteAuthority(by);
|
|
2222
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw new PreEffectToolRefusal("plan review needs a revision and reason");
|
|
2223
|
+
const raw = input as Record<string, unknown>;
|
|
2224
|
+
if (Object.keys(raw).some(key => !["revision", "reason", "review", "ci"].includes(key)) || !Number.isSafeInteger(raw.revision) || (raw.revision as number) < 0
|
|
2225
|
+
|| typeof raw.reason !== "string" || !raw.reason.trim() || raw.reason.length > 1000) throw new PreEffectToolRefusal("plan review needs a current revision and bounded reason");
|
|
2226
|
+
const task = this.need(id, false, true, true), revision = raw.revision as number;
|
|
2227
|
+
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
|
|
2228
|
+
if ((task.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("planning revision changed");
|
|
2229
|
+
let scope: CostPlan;
|
|
2230
|
+
try { scope = cleanCostPlan({ ...task.plan, ...("review" in raw ? { review: raw.review } : {}), ...("ci" in raw ? { ci: raw.ci } : {}) }); }
|
|
2231
|
+
catch { throw new PreEffectToolRefusal("invalid review or CI cost scope"); }
|
|
2232
|
+
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
2233
|
+
if (!scope.review || !scope.ci) {
|
|
2234
|
+
if (mode === "enforce") throw new PreEffectToolRefusal("review and CI cost scope required for a new plan receipt");
|
|
2235
|
+
if (mode === "advise") this.releaseNotice(`task ${this.publicTitle(task)}: cost scope unspecified`);
|
|
2236
|
+
}
|
|
2237
|
+
const flow = task.history.length, withheld = await this.withholds("note", raw.reason, task);
|
|
2238
|
+
this.plannerWriteAuthority(by);
|
|
2239
|
+
const observed = this.need(task.id, false, true, true);
|
|
2240
|
+
if (observed.history.length !== flow || (observed.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
|
|
2241
|
+
const identity = this.planIssueIdentity(observed);
|
|
2242
|
+
if (identity?.issue && this.isPii(observed)) throw new PreEffectToolRefusal("private issue plan review readback unavailable");
|
|
2243
|
+
const issue = identity?.repo && identity.issue ? await readPlanIssueMetadata(identity.repo, identity.issue, this.d.sdlc?.gh ?? sdlcGh) : undefined;
|
|
2244
|
+
if (identity?.issue && !issue) throw new PreEffectToolRefusal("plan review issue metadata unavailable");
|
|
2245
|
+
if (issue && !this.isPii(task) && !this.nameable(JSON.stringify(issue))) throw new PreEffectToolRefusal("plan review metadata matches a PII pattern");
|
|
2246
|
+
this.plannerWriteAuthority(by);
|
|
2247
|
+
const current = this.need(task.id, false, true, true);
|
|
2248
|
+
if (current.history.length !== flow || (current.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
|
|
2249
|
+
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2250
|
+
const updated = this.d.board.update(current.id, by, "plan reviewed", { ...(Object.keys(scope).length ? { plan: { ...current.plan, ...scope } } : {}), sdlc: { ...current.sdlc, stage: current.sdlc?.stage ?? lifecycleStage(config, "plan"),
|
|
2251
|
+
planReview: { planner: by, planningRevision: revision, recordedAt: Date.now(), ...(Object.keys(scope).length ? { scope } : {}), ...(issue ? { issue } : {}) } } }, raw.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
|
|
2252
|
+
if (updated.state === "proposed" && !updated.owner) {
|
|
2253
|
+
this.offered.delete(updated.id);
|
|
2254
|
+
try { await this.offerReady(updated, "planner review recorded"); }
|
|
2255
|
+
catch { this.releaseNotice(`task #${updated.id}: plan review saved; inspect its owner delivery before retrying`); }
|
|
2256
|
+
}
|
|
2257
|
+
return this.d.board.get(updated.id) ?? updated;
|
|
2258
|
+
}
|
|
2259
|
+
|
|
1838
2260
|
/** Artifact links and claimed local gates stay on the board; no outside text is accepted. */
|
|
1839
2261
|
recordLifecycle(by: PeerId, id: unknown, input: unknown): Task {
|
|
1840
2262
|
const task = this.need(id, false, true);
|
|
@@ -1842,6 +2264,8 @@ export class Tasks {
|
|
|
1842
2264
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
1843
2265
|
const next = sdlcReceipt(input, task.sdlc, config, VERSION);
|
|
1844
2266
|
const definition = stageDefinition(config, next.stage);
|
|
2267
|
+
// #481 S1: entering a build stage in enforce needs the person's spec acceptance; the person may move any stage.
|
|
2268
|
+
if (config.mode === "enforce" && by !== USER && next.stage !== task.sdlc?.stage && definition && canonicalStage(definition.base) === "build" && specState(next, undefined) === "missing") throw new PreEffectToolRefusal(this.specText(task, "missing", "blocked"));
|
|
1845
2269
|
if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
|
|
1846
2270
|
const actor = definition?.actor;
|
|
1847
2271
|
const acts = actor === "owner" ? by === task.owner : actor === "reviewer" ? by === task.reviewer : actor === "planner" ? this.d.roles?.[by]?.includes("planner") : actor === "owner/planner" && (by === task.owner || this.d.roles?.[by]?.includes("planner"));
|
|
@@ -1881,6 +2305,15 @@ export class Tasks {
|
|
|
1881
2305
|
if (d.path && (!this.nameable(d.path) || matchesPolicy(this.d.cwd, d.path, this.d.piiPaths ?? [], true))) { const { path: _privatePath, ...metadata } = d; return metadata; }
|
|
1882
2306
|
return d;
|
|
1883
2307
|
});
|
|
2308
|
+
const trains = this.d.board.releases().filter(release => release.train && ["active", "frozen"].includes(release.state));
|
|
2309
|
+
if (trains.length) {
|
|
2310
|
+
const planned = trains.map(release => {
|
|
2311
|
+
try { return this.releaseReport(USER, release.id); }
|
|
2312
|
+
catch { return { release: { id: release.id }, readiness: { trigger: "waiting", tasks: [], blockers: ["release scope unavailable"] } }; }
|
|
2313
|
+
});
|
|
2314
|
+
if (planned.some(row => row.readiness.blockers.length)) { report.sourceReady = false; report.blockers.push("planned release scope is not approved or available"); }
|
|
2315
|
+
return { ...report, trains: planned };
|
|
2316
|
+
}
|
|
1884
2317
|
return report;
|
|
1885
2318
|
}
|
|
1886
2319
|
|
|
@@ -1900,20 +2333,93 @@ export class Tasks {
|
|
|
1900
2333
|
async lifecycleStatus(id: unknown, refresh = true): Promise<SdlcStatus> {
|
|
1901
2334
|
const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
1902
2335
|
const evidence = task.sdlc && !this.isPii(task) && refresh && config.mode !== "off" ? await readSdlcPr(task.sdlc, this.d.sdlc?.gh) : undefined;
|
|
1903
|
-
|
|
2336
|
+
const accepted = task.sdlc?.acceptance;
|
|
2337
|
+
const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue, this.d.sdlc?.gh) : undefined;
|
|
2338
|
+
return sdlcStatus(config, task.sdlc, evidence, this.workflowView(task), edit);
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
/** #481 S1: only build work starts from an accepted spec. */
|
|
2342
|
+
private specGated(task: Task): boolean {
|
|
2343
|
+
return (this.d.sdlc?.config().mode ?? "off") !== "off" && (task.class === "implement" || task.class === "bulk_edit");
|
|
2344
|
+
}
|
|
2345
|
+
private specText(task: Task, state: Exclude<SpecState, "current">, verb: "blocked" | "advice"): string {
|
|
2346
|
+
const issue = task.sdlc?.issue;
|
|
2347
|
+
const why = issue ? SPEC_STATE_TEXT[state].replace("<task>", String(task.id)).replace("<n>", String(issue)) : "spec issue unknown: record repo and issue with hub_sdlc_record, then the person accepts it with ahub sdlc accept";
|
|
2348
|
+
return `task #${task.id}: SDLC build entry ${verb}: ${why}`;
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
/** The person's acceptance of the task's spec issue at its current body (#481 S1); identity and edit times only. */
|
|
2352
|
+
acceptSpec(by: PeerId, id: unknown, spec: { repo?: unknown; issue: unknown; edit: SpecEdit }): Task {
|
|
2353
|
+
if (by !== USER) throw new PreEffectToolRefusal("spec acceptance is the person's: run ahub sdlc accept in a terminal");
|
|
2354
|
+
const task = this.need(id, false, true, true), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2355
|
+
const repo = spec.repo ?? task.sdlc?.repo;
|
|
2356
|
+
if (task.sdlc?.issue !== undefined && task.sdlc.issue !== spec.issue) throw new PreEffectToolRefusal(`task #${task.id} links issue #${task.sdlc.issue}; relink it with hub_sdlc_record before accepting another`);
|
|
2357
|
+
const next = sdlcReceipt({ repo, issue: spec.issue }, task.sdlc, config);
|
|
2358
|
+
if (!next.repo || !next.issue) throw new PreEffectToolRefusal("spec acceptance needs the repository (owner/repository) and the issue number");
|
|
2359
|
+
next.acceptance = { repo: next.repo, issue: next.issue, lastEditedAt: spec.edit.lastEditedAt, updatedAt: spec.edit.updatedAt, acceptedAt: Date.now() };
|
|
2360
|
+
return this.d.board.update(task.id, USER, "spec accepted", { sdlc: next }, `issue #${next.issue}, body last edited ${spec.edit.lastEditedAt ?? "never"}`);
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
/** Build entry with a live readback of the spec's body edit time (#481 S1): enforce refuses, advise notifies, off does nothing. */
|
|
2364
|
+
async specGate(by: PeerId, id: unknown): Promise<void> {
|
|
2365
|
+
const task = this.need(id, false, true);
|
|
2366
|
+
if (!this.specGated(task) || task.owner !== by && by !== USER) return; // accept reports its own refusals
|
|
2367
|
+
const accepted = task.sdlc?.acceptance;
|
|
2368
|
+
const edit = accepted && !this.isPii(task) ? await readSpecEdit(accepted.repo, accepted.issue, this.d.sdlc?.gh) : undefined;
|
|
2369
|
+
if (this.specMoved(task)) return;
|
|
2370
|
+
this.specVerdict(task, task.sdlc?.issue ? specState(task.sdlc, edit) : "missing");
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
/** #481 S1: a build stage change by an agent re-reads the body edit time first, as accept does. */
|
|
2374
|
+
async specStageGate(by: PeerId, id: unknown, input: unknown): Promise<void> {
|
|
2375
|
+
const task = this.need(id, false, true), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2376
|
+
if (config.mode === "off" || by === USER || !input || typeof input !== "object" || !("stage" in input)) return;
|
|
2377
|
+
let next: ReturnType<typeof sdlcReceipt>;
|
|
2378
|
+
try { next = sdlcReceipt(input, task.sdlc, config, VERSION); } catch { return; } // recordLifecycle reports an invalid receipt
|
|
2379
|
+
const definition = stageDefinition(config, next.stage);
|
|
2380
|
+
if (next.stage === task.sdlc?.stage || !definition || canonicalStage(definition.base) !== "build") return;
|
|
2381
|
+
const accepted = next.acceptance;
|
|
2382
|
+
const edit = accepted && !this.isPii(task) ? await readSpecEdit(accepted.repo, accepted.issue, this.d.sdlc?.gh) : undefined;
|
|
2383
|
+
if (this.specMoved(task)) return;
|
|
2384
|
+
this.specVerdict(task, next.issue ? specState(next, edit) : "missing");
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
/**
|
|
2388
|
+
* The task a spec readback started from changed while it was awaited (flow, link, acceptance, owner or privacy): the
|
|
2389
|
+
* reading belongs to other work. Enforce refuses before any write; advise drops the stale advice.
|
|
2390
|
+
*/
|
|
2391
|
+
private specMoved(before: Task): boolean {
|
|
2392
|
+
const now = this.need(before.id, false, true);
|
|
2393
|
+
if (now.history.length === before.history.length && now.owner === before.owner && this.isPii(now) === this.isPii(before)
|
|
2394
|
+
&& JSON.stringify(now.sdlc ?? null) === JSON.stringify(before.sdlc ?? null)) return false;
|
|
2395
|
+
if (this.d.sdlc?.config().mode === "enforce") throw new PreEffectToolRefusal(`task #${before.id}: changed during the spec readback; nothing was recorded, retry on current work`);
|
|
2396
|
+
return true;
|
|
2397
|
+
}
|
|
2398
|
+
private specVerdict(task: Task, state: SpecState): void {
|
|
2399
|
+
if (state === "current") return;
|
|
2400
|
+
if (this.d.sdlc?.config().mode === "enforce") throw new PreEffectToolRefusal(this.specText(task, state, "blocked"));
|
|
2401
|
+
this.d.notify(this.specText(task, state, "advice"));
|
|
1904
2402
|
}
|
|
1905
2403
|
|
|
1906
|
-
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string): Promise<
|
|
2404
|
+
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string): Promise<PrEvidence | undefined> {
|
|
1907
2405
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
1908
2406
|
if (config.mode === "off") return;
|
|
1909
2407
|
const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
|
|
1910
|
-
const
|
|
2408
|
+
const read: SdlcGh = (args, timeout, cwd, cap) => {
|
|
2409
|
+
const current = this.need(task.id, false, true);
|
|
2410
|
+
if (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || this.isPii(current) !== this.isPii(task)) return Promise.resolve({ code: 1, stdout: "" });
|
|
2411
|
+
return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap);
|
|
2412
|
+
};
|
|
2413
|
+
const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read) : undefined;
|
|
1911
2414
|
const current = this.need(task.id, false, true);
|
|
1912
2415
|
if (config.mode === "enforce" && (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || current.state !== task.state)) throw new PreEffectToolRefusal(`task #${task.id}: changed during lifecycle readback; retry on current work`);
|
|
2416
|
+
// Advise may continue on the current task, but evidence from its previous link cannot admit that task's review.
|
|
2417
|
+
if (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || current.state !== task.state || this.isPii(current) !== this.isPii(task)) return;
|
|
1913
2418
|
const status = sdlcStatus(config, receipt, evidence, this.workflowView(current));
|
|
1914
2419
|
const blockers = transition === "review" ? [...new Set([...status.blockers.filter(blocker => ["lifecycle stage definition unknown", "issue identity missing", "PR identity missing", "current PR evidence unknown", "PR is not open", "local gate on current head missing or failed"].includes(blocker)), ...(status.entry ?? []), ...(status.exit ?? [])])] : status.blockers;
|
|
1915
2420
|
if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
|
|
1916
2421
|
if (config.mode === "advise" && blockers.length) this.d.notify(`task ${this.publicTitle(task)}: SDLC ${transition} advice: ${this.isPii(task) ? "private lifecycle evidence unknown" : blockers.join("; ")}`);
|
|
2422
|
+
return evidence;
|
|
1917
2423
|
}
|
|
1918
2424
|
|
|
1919
2425
|
private need(id: unknown, open = false, preEffect = false, allowBacklog = false): Task {
|
|
@@ -1928,6 +2434,62 @@ export class Tasks {
|
|
|
1928
2434
|
return task;
|
|
1929
2435
|
}
|
|
1930
2436
|
|
|
2437
|
+
private readonly pendingClaims = new Map<PeerId, number>();
|
|
2438
|
+
private readonly reviewedAdmissions = new Map<number, { flow: number; owner: string | null }>();
|
|
2439
|
+
private isBuildTask(task: Task): boolean {
|
|
2440
|
+
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2441
|
+
if (["implement", "bulk_edit"].includes(task.class)) return true;
|
|
2442
|
+
const base = task.sdlc ? stageDefinition(config, task.sdlc.stage)?.base : undefined;
|
|
2443
|
+
return base === "build" || base === "implement";
|
|
2444
|
+
}
|
|
2445
|
+
async acceptReviewed(by: PeerId, id: unknown, plan?: unknown): Promise<Task> {
|
|
2446
|
+
const task = this.need(id, false, true);
|
|
2447
|
+
this.mine(task, by, "owner", true);
|
|
2448
|
+
this.acceptanceCapacity(task);
|
|
2449
|
+
if ((this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode === "off" || !this.isBuildTask(task)) return this.accept(by, id, plan);
|
|
2450
|
+
await this.specGate(by, id);
|
|
2451
|
+
await this.planEntryGate(by, task);
|
|
2452
|
+
const current = this.need(id, false, true);
|
|
2453
|
+
this.mine(current, by, "owner", true);
|
|
2454
|
+
if (current.history.length !== task.history.length || current.owner !== task.owner) throw new PreEffectToolRefusal("task changed during build admission");
|
|
2455
|
+
this.reviewedAdmissions.set(current.id, { flow: current.history.length, owner: current.owner });
|
|
2456
|
+
try { return this.accept(by, id, plan); }
|
|
2457
|
+
finally { this.reviewedAdmissions.delete(current.id); }
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
/** Spec and plan readbacks share the caller's original task snapshot until the final acceptance write. */
|
|
2461
|
+
private async planEntryGate(by: PeerId, task: Task): Promise<void> {
|
|
2462
|
+
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
2463
|
+
if (mode === "off" || !this.isBuildTask(task)) return;
|
|
2464
|
+
const gap = await this.planReviewGap(task);
|
|
2465
|
+
if (mode === "advise" && !task.sdlc?.planReview?.scope) this.releaseNotice(`task ${this.publicTitle(task)}: cost scope unspecified`);
|
|
2466
|
+
const current = this.need(task.id, false, true);
|
|
2467
|
+
this.mine(current, by, "owner", true);
|
|
2468
|
+
if (current.history.length !== task.history.length || current.owner !== task.owner) throw new PreEffectToolRefusal("task changed during plan readback");
|
|
2469
|
+
if (gap && mode === "enforce") throw new PreEffectToolRefusal(gap);
|
|
2470
|
+
if (gap) this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}`);
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
private acceptanceCapacity(task: Pick<Task, "id" | "owner" | "reserved" | "state"> & Partial<Pick<Task, "history">>, excludePending = 0): void {
|
|
2474
|
+
const owner = task.owner ?? task.reserved;
|
|
2475
|
+
if (!owner) return;
|
|
2476
|
+
if ((task.state === "in_progress" || task.state === "changes_requested") && task.history) {
|
|
2477
|
+
let boundary = 0, previous: PeerId | null | undefined;
|
|
2478
|
+
task.history.forEach((entry, index) => {
|
|
2479
|
+
if (entry.owner !== undefined) {
|
|
2480
|
+
if (entry.owner !== previous) boundary = index;
|
|
2481
|
+
previous = entry.owner;
|
|
2482
|
+
} else if (OWNERSHIP_EVENTS.has(entry.event)) { boundary = index; previous = undefined; }
|
|
2483
|
+
});
|
|
2484
|
+
if (task.history.slice(boundary).some(entry => entry.event === "accepted" && (entry.by === owner || entry.by === USER))) return;
|
|
2485
|
+
}
|
|
2486
|
+
const limit = this.wipLimit(owner), pending = Math.max(0, (this.pendingClaims.get(owner) ?? 0) - excludePending);
|
|
2487
|
+
if (this.wip(owner, task.id) + pending < limit) return;
|
|
2488
|
+
const open = this.d.board.list().filter(other => other.id !== task.id && !["backlog", "approved", "cancelled"].includes(other.state)
|
|
2489
|
+
&& (other.owner === owner || !other.owner && other.reserved === owner && this.plannerPullRecord(other)?.pull)).map(other => `#${other.id}`);
|
|
2490
|
+
throw new PreEffectToolRefusal(`planner WIP capacity for ${owner}: limit ${limit}; open tasks ${open.join(", ") || "none"}${pending ? `; pending claims ${pending}` : ""}`);
|
|
2491
|
+
}
|
|
2492
|
+
|
|
1931
2493
|
/**
|
|
1932
2494
|
* With a plan, the owners of overlapping tasks get it as a ride-along line, and an overlap only the plan reveals is
|
|
1933
2495
|
* announced like one found at assignment (issue #31).
|
|
@@ -1937,9 +2499,24 @@ export class Tasks {
|
|
|
1937
2499
|
this.mine(task, by, "owner", true);
|
|
1938
2500
|
this.ready(task, true);
|
|
1939
2501
|
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`);
|
|
2502
|
+
this.acceptanceCapacity(task);
|
|
2503
|
+
// S1 spec acceptance and S9 plan review are independent prerequisites; retain S1's missing-spec refusal first.
|
|
2504
|
+
if (this.specGated(task) && this.d.sdlc?.config().mode === "enforce" && specState(task.sdlc, undefined) === "missing") throw new PreEffectToolRefusal(this.specText(task, "missing", "blocked"));
|
|
2505
|
+
if ((this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode === "enforce" && this.isBuildTask(task)) {
|
|
2506
|
+
const readback = this.reviewedAdmissions.get(task.id);
|
|
2507
|
+
this.reviewedAdmissions.delete(task.id);
|
|
2508
|
+
if (!readback || readback.flow !== task.history.length || readback.owner !== task.owner) throw new PreEffectToolRefusal("current plan-review readback required before acceptance");
|
|
2509
|
+
}
|
|
1940
2510
|
// Models send null or {} for an optional field they leave empty: neither replaces a plan.
|
|
1941
2511
|
const given = plan == null ? undefined : cleanPlan(plan, (task.profile ?? "dev") === (this.d.profile ?? "dev") ? this.d.profileData ?? task.profile ?? "dev" : task.profile ?? "dev");
|
|
1942
|
-
|
|
2512
|
+
// File-list intent still replaces the old lists; omitted budget keys retain the planner-reviewed scope.
|
|
2513
|
+
const cleaned = given && Object.keys(given).length ? { ...cleanCostPlan(task.plan ?? {}), ...given } : undefined;
|
|
2514
|
+
if (this.d.sdlc?.config().mode === "enforce" && task.sdlc?.planReview && !task.sdlc.planReview.scope
|
|
2515
|
+
&& given && Object.keys(cleanCostPlan(given)).length) throw new PreEffectToolRefusal("legacy plan cost scope is unspecified; record a scoped planner review before acceptance");
|
|
2516
|
+
if (this.d.sdlc?.config().mode === "enforce" && task.sdlc?.planReview?.scope
|
|
2517
|
+
&& JSON.stringify(cleanCostPlan(cleaned ?? task.plan ?? {})) !== JSON.stringify(task.sdlc.planReview.scope)) {
|
|
2518
|
+
throw new PreEffectToolRefusal("plan cost scope changed; record a new planner review before acceptance");
|
|
2519
|
+
}
|
|
1943
2520
|
// A task's signals are fixed when it is proposed: text that matches a PII pattern cannot be let in afterwards.
|
|
1944
2521
|
if (cleaned && !this.isPii(task) && this.isPii({ signals: this.signalsFor({ title: "", detail: planText(cleaned), refs: {}, plan: cleaned }) })) {
|
|
1945
2522
|
throw new PreEffectToolRefusal("this plan matches a PII pattern and is not kept: other owners would see it");
|
|
@@ -2029,6 +2606,7 @@ export class Tasks {
|
|
|
2029
2606
|
this.mine(t, by, "owner", true);
|
|
2030
2607
|
if (t.state === "in_review" || isClosedTaskState(t.state)) throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
|
|
2031
2608
|
this.ready(t, true);
|
|
2609
|
+
if (t.state === "proposed" || t.state === "changes_requested") this.acceptanceCapacity(t);
|
|
2032
2610
|
if (this.checking.has(t.id)) {
|
|
2033
2611
|
// The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
|
|
2034
2612
|
throw new PreEffectToolRefusal(this.checking.get(t.id) === t.owner ? `task #${t.id}: its check is still running; its result comes as a task message` : `task #${t.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
|
|
@@ -2038,7 +2616,17 @@ export class Tasks {
|
|
|
2038
2616
|
// The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
|
|
2039
2617
|
const withheld = await this.withholds("summary", summary, valid());
|
|
2040
2618
|
let task = valid();
|
|
2041
|
-
|
|
2619
|
+
// #481 S1: done without a separate accept starts build work too: the same live spec check as accept, before any write.
|
|
2620
|
+
if (task.state === "proposed" || task.state === "changes_requested") {
|
|
2621
|
+
const admission = task;
|
|
2622
|
+
await this.specGate(by, task.id);
|
|
2623
|
+
await this.planEntryGate(by, admission);
|
|
2624
|
+
task = valid();
|
|
2625
|
+
}
|
|
2626
|
+
let reviewEvidence: PrEvidence | undefined;
|
|
2627
|
+
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task = valid(); }
|
|
2628
|
+
const reviewAdmission = task.reviewer ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence) : undefined;
|
|
2629
|
+
task = valid();
|
|
2042
2630
|
// A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
|
|
2043
2631
|
const cohort = this.cohorts.of(task.id);
|
|
2044
2632
|
const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
|
|
@@ -2069,7 +2657,7 @@ export class Tasks {
|
|
|
2069
2657
|
}
|
|
2070
2658
|
if (task.state === "proposed" || task.state === "changes_requested") task = this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE); // done without a separate accept
|
|
2071
2659
|
const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
|
|
2072
|
-
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review");
|
|
2660
|
+
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission);
|
|
2073
2661
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
2074
2662
|
this.checking.set(task.id, task.owner);
|
|
2075
2663
|
const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary, { withheld: withheld || undefined });
|
|
@@ -2196,17 +2784,32 @@ export class Tasks {
|
|
|
2196
2784
|
}
|
|
2197
2785
|
|
|
2198
2786
|
/** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
|
|
2199
|
-
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review"): Promise<Task> {
|
|
2200
|
-
let
|
|
2787
|
+
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness): Promise<Task> {
|
|
2788
|
+
let selection = this.poolReview(task, refs);
|
|
2789
|
+
let reviewer = selection?.reviewer ?? task.reviewer, reviewEvidence: PrEvidence | undefined;
|
|
2201
2790
|
if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
|
|
2202
|
-
await this.lifecycleGate(task, reviewer ? "review" : "approval");
|
|
2791
|
+
reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
|
|
2203
2792
|
task = this.need(task.id, false, true);
|
|
2204
2793
|
this.mine(task, by, "owner", true);
|
|
2205
2794
|
// In every mode: another done may have landed during the readback.
|
|
2206
2795
|
if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2796
|
+
selection = this.poolReview(task, refs);
|
|
2797
|
+
reviewer = selection?.reviewer ?? task.reviewer;
|
|
2798
|
+
}
|
|
2799
|
+
// A delayed local check needs a new readback; the immediate done path already admitted before effects.
|
|
2800
|
+
if (reviewer && !admission && (this.d.sdlc?.config().mode ?? "off") !== "off") {
|
|
2801
|
+
const observed = task;
|
|
2802
|
+
admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence);
|
|
2803
|
+
task = this.need(task.id);
|
|
2804
|
+
this.mine(task, by, "owner");
|
|
2805
|
+
if (task.history.length !== observed.history.length || task.state === "in_review" || isClosedTaskState(task.state)) throw new Error("task changed during review admission");
|
|
2806
|
+
}
|
|
2807
|
+
selection = this.poolReview(task, refs);
|
|
2808
|
+
reviewer = selection?.reviewer ?? task.reviewer;
|
|
2809
|
+
const reviewCost: ReviewCost | undefined = reviewer && task.plan && (task.plan.review || task.plan.ci)
|
|
2810
|
+
? { ...cleanCostPlan(task.plan), ...(admission?.ready && admission.head ? { head: admission.head } : {}) } : undefined;
|
|
2811
|
+
const routedSummary = selection?.reviewSelection?.blocked ? `${summary ?? ""}\nReview routing: ${this.assignmentNote("pool unavailable", selection)}`.trim() : summary;
|
|
2812
|
+
const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: cleanRefs(refs) }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { withheld: withheld || undefined, ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}) });
|
|
2210
2813
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
2211
2814
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
|
|
2212
2815
|
this.tellCompleted(next, summary, withheld);
|
|
@@ -2217,7 +2820,7 @@ export class Tasks {
|
|
|
2217
2820
|
await this.releaseDependents(next);
|
|
2218
2821
|
}
|
|
2219
2822
|
else if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}: review it with ahub task show ${next.id}, then ahub review ${next.id} approved|changes_requested [note]`);
|
|
2220
|
-
else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer));
|
|
2823
|
+
else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, "", admission));
|
|
2221
2824
|
return next;
|
|
2222
2825
|
}
|
|
2223
2826
|
|
|
@@ -2291,7 +2894,44 @@ export class Tasks {
|
|
|
2291
2894
|
}
|
|
2292
2895
|
|
|
2293
2896
|
/** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
|
|
2294
|
-
|
|
2897
|
+
/** Review admission is separate from brief construction (#228). All refusals precede the requesting done's writes. */
|
|
2898
|
+
private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean): Promise<ReviewReadiness | undefined> {
|
|
2899
|
+
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
2900
|
+
if (mode === "off") return undefined;
|
|
2901
|
+
const guard = () => {
|
|
2902
|
+
if (actor !== USER && actor !== task.owner) {
|
|
2903
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
|
|
2904
|
+
if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
|
|
2905
|
+
}
|
|
2906
|
+
const current = this.need(task.id, false, preEffect);
|
|
2907
|
+
if ((this.d.sdlc?.config().mode ?? "off") !== mode || current.history.length !== task.history.length && !sameRequest?.() || current.owner !== task.owner || current.reviewer !== task.reviewer
|
|
2908
|
+
|| this.isPii(current) !== this.isPii(task) || JSON.stringify(current.sdlc ?? null) !== JSON.stringify(task.sdlc ?? null)
|
|
2909
|
+
|| actor !== USER && this.d.privateTurn?.(actor) && !this.isPii(current)) throw new (preEffect ? PreEffectToolRefusal : Error)("task or authority changed during review admission");
|
|
2910
|
+
return current;
|
|
2911
|
+
};
|
|
2912
|
+
guard();
|
|
2913
|
+
const expected = cleanRefs(refs).commit ?? task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead;
|
|
2914
|
+
const read: SdlcGh = (args, timeout, cwd, cap) => { guard(); return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap); };
|
|
2915
|
+
const evidence: ReviewReadiness = this.isPii(task)
|
|
2916
|
+
? { ready: false, blockers: ["private review readiness unavailable"] }
|
|
2917
|
+
: 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"] };
|
|
2918
|
+
const current = guard();
|
|
2919
|
+
if (!evidence.ready) {
|
|
2920
|
+
const message = `task ${this.publicTitle(current)}: premature review: ${evidence.blockers.join("; ")}`;
|
|
2921
|
+
if (mode === "enforce") throw new (preEffect ? PreEffectToolRefusal : Error)(message);
|
|
2922
|
+
this.releaseNotice(message);
|
|
2923
|
+
}
|
|
2924
|
+
return evidence;
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness): void {
|
|
2928
|
+
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
2929
|
+
if (mode === "enforce" && !admission?.ready) {
|
|
2930
|
+
// Background roster/quota role changes are retained, but never invent a remotely admitted review request.
|
|
2931
|
+
this.releaseNotice(`task ${this.publicTitle(task)}: premature review held; retry the reviewer assignment after current CI and bot replies are verified`);
|
|
2932
|
+
return;
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2295
2935
|
const r = task.refs;
|
|
2296
2936
|
const last = [...task.history].reverse().find((h) => h.event === "done");
|
|
2297
2937
|
const where = [r.branch ? `branch ${r.branch}` : "", r.commit ? `commit ${r.commit}` : "", r.paths?.length ? `paths ${r.paths.join(", ")}` : ""].filter(Boolean).join("; ");
|
|
@@ -2305,18 +2945,69 @@ export class Tasks {
|
|
|
2305
2945
|
`- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
|
|
2306
2946
|
`- List what is unmet in hub_review's unmet, one item each.`,
|
|
2307
2947
|
].join("\n");
|
|
2308
|
-
|
|
2948
|
+
// #223 R2: a reviewer-only instance judges from facts; the builder's narrative stays out, earlier findings go in.
|
|
2949
|
+
const reviewOnly = this.d.reviewOnly?.(reviewer) === true;
|
|
2950
|
+
const summary = reviewOnly ? "(summary withheld from a review-only reviewer; judge the change itself)" : last?.note ? this.screen(task, last.note, "summary", reviewer, last.withheld) : "(no summary)";
|
|
2951
|
+
const specRepo = task.sdlc?.repo ?? task.external?.repo, specIssue = task.sdlc?.issue ?? task.external?.issue;
|
|
2952
|
+
const spec = reviewOnly && specRepo && specIssue ? `Spec issue: ${specRepo}#${specIssue}\n` : "";
|
|
2953
|
+
const findings = reviewOnly ? task.history.filter(h => h.event === "changes_requested" && h.note).map(h => `- ${h.by}: ${this.screen(task, h.note!, "review note", reviewer, h.withheld)}`) : [];
|
|
2309
2954
|
const guidance = this.roleGuidance(task, reviewer, true);
|
|
2310
2955
|
let projection = "";
|
|
2311
2956
|
try { const text = this.d.projectedReview?.(task); if (text) projection = this.screen(task, sanitize(text), "document projection", reviewer); }
|
|
2312
2957
|
catch { projection = "(document projection unavailable)"; }
|
|
2313
|
-
const
|
|
2314
|
-
|
|
2958
|
+
const brief = this.briefText(task);
|
|
2959
|
+
const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
|
|
2960
|
+
this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) }));
|
|
2315
2961
|
}
|
|
2316
2962
|
|
|
2317
|
-
|
|
2963
|
+
private reviewRequestRevision(task: Task): number {
|
|
2964
|
+
// Role changes fence older requests; bookkeeping appended after the request does not.
|
|
2965
|
+
return task.history.findLastIndex(entry => ["done", "reviewer changed", "reviewer unavailable"].includes(entry.event) || OWNERSHIP_EVENTS.has(entry.event)) + 1;
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
/** Host-only failure report, bound to the exact review request and its still-current child generation. */
|
|
2969
|
+
async retryReviewer(id: number, failedPeer: PeerId, failure: { head?: string; flowRevision?: number; cause: "start_failed" | "quota" | "no_verdict" }, current: () => boolean): Promise<Task> {
|
|
2970
|
+
if (!failure || !["start_failed", "quota", "no_verdict"].includes(failure.cause) || !/^[a-z][a-z0-9-]{0,63}$/.test(failedPeer) || !Number.isSafeInteger(failure.flowRevision) || failure.flowRevision! < 1 || failure.head !== undefined && (typeof failure.head !== "string" || text(failure.head) !== failure.head || Buffer.byteLength(failure.head) > 1200 || /\p{Cc}/u.test(failure.head))) throw new PreEffectToolRefusal("invalid reviewer failure binding");
|
|
2971
|
+
const valid = () => {
|
|
2972
|
+
const task = this.need(id, false, true);
|
|
2973
|
+
if (!current() || task.state !== "in_review" || task.reviewer !== failedPeer || this.reviewRequestRevision(task) !== failure.flowRevision || this.reviewHead(task) !== failure.head || this.isPii(task) || this.manuallyNamedReviewer(task)) throw new PreEffectToolRefusal("reviewer failure is no longer current");
|
|
2974
|
+
return task;
|
|
2975
|
+
};
|
|
2976
|
+
const before = valid();
|
|
2977
|
+
const admission = await this.reviewRequestAdmission(before, undefined, true, before.owner ?? USER, undefined, () => { valid(); return true; });
|
|
2978
|
+
const task = valid();
|
|
2979
|
+
const options = this.reviewPoolOptions(task);
|
|
2980
|
+
const selection = assign(task, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), reviewPool: { ...options, failed: [...options.failed, failedPeer] } });
|
|
2981
|
+
if (!selection.reviewSelection) throw new PreEffectToolRefusal("no declared reviewer pool can receive the fallback");
|
|
2982
|
+
const reviewer = selection.reviewer ?? USER;
|
|
2983
|
+
valid();
|
|
2984
|
+
const next = this.d.board.update(task.id, HUB, "reviewer unavailable", { reviewer }, this.assignmentNote(`reviewer unavailable: ${failure.cause}`, selection), { ...this.reviewMetadata(selection), reviewerFailure: { peer: failedPeer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } }, task.history.length);
|
|
2985
|
+
this.releaseNotice(`task ${this.publicTitle(next)}: former review request withdrawal incomplete`, () => this.withdrawRoleRequest(task, next, "reviewer"));
|
|
2986
|
+
if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)}: blocked: no reviewer can run; the planner or person must review`);
|
|
2987
|
+
else this.releaseNotice(`task ${this.publicTitle(next)}: fallback review request unavailable`, () => this.sendReview(next, reviewer, "", admission));
|
|
2988
|
+
return next;
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
/** #520: the rendered review brief of a non-PII task's commit, or why there is none; empty without a commit. */
|
|
2992
|
+
briefText(task: Task): string {
|
|
2993
|
+
if (this.isPii(task) || !task.refs.commit) return "";
|
|
2994
|
+
try {
|
|
2995
|
+
const brief = this.d.reviewBrief?.(task);
|
|
2996
|
+
if (!brief) return "";
|
|
2997
|
+
return "unavailable" in brief ? `Review brief unavailable: ${brief.unavailable}.` : sanitize(renderReviewBrief(brief));
|
|
2998
|
+
} catch { return "Review brief unavailable: it could not be built."; }
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown): Promise<Task> {
|
|
3002
|
+
const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
|
|
3003
|
+
// #520: what the coverage was checked against, pinned through every await: the commit and the lifecycle receipt (the
|
|
3004
|
+
// inherited reviewedHead lives there).
|
|
3005
|
+
let covered: { commit: string | undefined; receipt: string } | undefined;
|
|
2318
3006
|
const valid = (): Task => {
|
|
3007
|
+
const refused = authority?.();
|
|
3008
|
+
if (refused) throw new PreEffectToolRefusal(refused); // rechecked after every await, right before each write
|
|
2319
3009
|
const t = this.need(id, false, true);
|
|
3010
|
+
if (covered && (t.refs.commit !== covered.commit || JSON.stringify(t.sdlc ?? null) !== covered.receipt)) throw new PreEffectToolRefusal(`task #${t.id}'s commit or lifecycle receipt changed during the verdict: review the new head`);
|
|
2320
3011
|
this.mine(t, by, "reviewer", true);
|
|
2321
3012
|
if (verdict !== "approved" && verdict !== "changes_requested") throw new PreEffectToolRefusal('verdict must be "approved" or "changes_requested"');
|
|
2322
3013
|
if (t.state !== "in_review") throw new PreEffectToolRefusal(`task #${t.id} is ${t.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
|
|
@@ -2324,6 +3015,34 @@ export class Tasks {
|
|
|
2324
3015
|
};
|
|
2325
3016
|
const items = textList(unmet);
|
|
2326
3017
|
if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
|
|
3018
|
+
// #520: an approval accounts for every reviewable path of the brief. enforce refuses before any effect, advise records
|
|
3019
|
+
// the gap in the verdict note, off and a person's own verdict skip it.
|
|
3020
|
+
const mode = this.d.sdlc?.config().mode ?? "off";
|
|
3021
|
+
// A Tasks without a brief provider (library use; the daemon always has one) has no coverage gate.
|
|
3022
|
+
if (verdict === "approved" && by !== USER && mode !== "off" && this.d.reviewBrief) {
|
|
3023
|
+
const t = valid(), enforce = mode === "enforce", list = (paths: string[]) => `${paths.slice(0, 20).join(", ")}${paths.length > 20 ? ", ..." : ""}`;
|
|
3024
|
+
// Code work (a commit, branch or PR) needs a brief; work with none of them is exempt and has nothing to account for.
|
|
3025
|
+
const code = !this.isPii(t) && !!(t.refs.commit || t.refs.branch || t.sdlc?.pr);
|
|
3026
|
+
// The commit under review: the task's, else the head the verdict names, else the lifecycle receipt's.
|
|
3027
|
+
const commit = t.refs.commit ?? (typeof head === "string" ? head : undefined) ?? t.sdlc?.reviewedHead ?? t.sdlc?.localHead;
|
|
3028
|
+
const brief: ReviewBrief | { unavailable: string } | undefined = !code ? undefined
|
|
3029
|
+
: !commit ? { unavailable: "the task links a branch or PR but names no commit" }
|
|
3030
|
+
: this.d.reviewBrief({ ...t, refs: { ...t.refs, commit } }) ?? { unavailable: "the review brief could not be built" };
|
|
3031
|
+
if (brief && "unavailable" in brief) {
|
|
3032
|
+
// An unknown inventory is never an empty one.
|
|
3033
|
+
if (enforce) throw new PreEffectToolRefusal(`coverage: the review brief is unavailable (${brief.unavailable}); an approval needs a complete brief of the task's commit`);
|
|
3034
|
+
note = `${note ?? ""}\nCoverage unknown: the review brief is unavailable (${brief.unavailable})`.trim();
|
|
3035
|
+
} else if (brief) {
|
|
3036
|
+
// One head: the brief's, the task's commit, and the head the verdict names or inherits from an earlier review (with a
|
|
3037
|
+
// PR, the lifecycle gate binds that head to the PR's).
|
|
3038
|
+
const effective = typeof head === "string" ? head : t.sdlc?.reviewedHead;
|
|
3039
|
+
if (effective !== undefined && effective !== brief.head && enforce) throw new PreEffectToolRefusal(`coverage: the verdict ${typeof head === "string" ? "names" : "inherits"} head ${effective}, but the review brief is for ${brief.head} (the commit under review)`);
|
|
3040
|
+
covered = { commit: t.refs.commit, receipt: JSON.stringify(t.sdlc ?? null) };
|
|
3041
|
+
const gap = coverageGap(brief, coverage);
|
|
3042
|
+
if (gap.length && enforce) throw new PreEffectToolRefusal(`coverage: ${gap.length} reviewable path(s) of the review brief are not accounted for: ${list(gap)}. Pass coverage {reviewed, skipped: [{path, reason}]}.`);
|
|
3043
|
+
if (gap.length) note = `${note ?? ""}\nCoverage gap (${gap.length}): ${list(gap)}`.trim();
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
2327
3046
|
// The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
|
|
2328
3047
|
const withheld = await this.withholds("review note", note, valid());
|
|
2329
3048
|
let task = valid();
|
|
@@ -2345,7 +3064,12 @@ export class Tasks {
|
|
|
2345
3064
|
if (cleared && context === JSON.stringify({ title: task.title, detail: task.detail, refs: task.refs, plan: task.plan, signals: task.signals })) candidate = input; // Async screening cannot authorize a verdict under an obsolete role/state.
|
|
2346
3065
|
}
|
|
2347
3066
|
const pii = this.isPii(task);
|
|
2348
|
-
const
|
|
3067
|
+
const reviewContext = by === USER ? undefined : this.d.reviewContext?.(by, task.id);
|
|
3068
|
+
const poolRequest = task.history.findLast(entry => ["done", "reviewer changed", "reviewer unavailable"].includes(entry.event));
|
|
3069
|
+
const family = by === USER ? undefined : familyOf(by, this.d.instances?.());
|
|
3070
|
+
const reviewerFamily = poolRequest?.reviewerFamily === family ? family : undefined;
|
|
3071
|
+
const reviewerReason = reviewerFamily ? poolRequest?.reviewerReason : undefined;
|
|
3072
|
+
const flag = { withheld: withheld || undefined, ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
|
|
2349
3073
|
if (verdict === "approved") {
|
|
2350
3074
|
const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0, ...(reviewedHead && task.sdlc ? { sdlc: { ...task.sdlc, reviewedHead, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), "merge") } } : {}) }, note, flag);
|
|
2351
3075
|
if (next.owner) {
|
|
@@ -2473,13 +3197,25 @@ export class Tasks {
|
|
|
2473
3197
|
if (role === "reviewer") {
|
|
2474
3198
|
if (!OPEN.includes(task.state) && task.state !== "in_review") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its reviewer can no longer change`);
|
|
2475
3199
|
this.ready(task, preEffect);
|
|
2476
|
-
const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
|
|
3200
|
+
const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class, task) });
|
|
2477
3201
|
if (a.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot name ${peer} as reviewer: ${a.trace.filter(line => line.includes("reviewer") || line.includes("pii")).join("; ")}`);
|
|
3202
|
+
const admission = task.state === "in_review" ? await this.reviewRequestAdmission(task, undefined, preEffect, by) : undefined;
|
|
3203
|
+
// Every runtime authority check precedes the final write, including revocations during the metadata await.
|
|
3204
|
+
const current = this.need(task.id, false, preEffect);
|
|
3205
|
+
if (current.history.length !== task.history.length) throw new (preEffect ? PreEffectToolRefusal : Error)("task changed during reviewer admission");
|
|
3206
|
+
if (by !== USER) {
|
|
3207
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
3208
|
+
if (holders.length !== 1 || holders[0] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the configured conductor may name a reviewer");
|
|
3209
|
+
}
|
|
3210
|
+
if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster`);
|
|
3211
|
+
this.requireCapabilities(current, peer, role, preEffect);
|
|
3212
|
+
const eligible = assign(current, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(current.owner ? { notReviewer: current.owner } : {}), ...this.weights(current.class, current) });
|
|
3213
|
+
if (eligible.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)("reviewer is no longer eligible");
|
|
2478
3214
|
const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, this.assignmentNote(`named reviewer ${peer}`, a), { reason: "manual" }, expectedFlowRevision);
|
|
2479
3215
|
if (next.state === "in_review" && task.reviewer && task.reviewer !== peer) {
|
|
2480
3216
|
this.withdrawRoleRequest(task, next, "reviewer");
|
|
2481
3217
|
}
|
|
2482
|
-
if (next.state === "in_review" && peer !== USER) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${peer}`, () => this.sendReview(next, peer));
|
|
3218
|
+
if (next.state === "in_review" && peer !== USER) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${peer}`, () => this.sendReview(next, peer, "", admission));
|
|
2483
3219
|
return next;
|
|
2484
3220
|
}
|
|
2485
3221
|
// Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
|
|
@@ -2525,7 +3261,7 @@ export class Tasks {
|
|
|
2525
3261
|
const routing = this.d.routing();
|
|
2526
3262
|
return this.d.board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)).map(task => {
|
|
2527
3263
|
const owner = task.owner === peer;
|
|
2528
|
-
const weights = this.weights(task.class);
|
|
3264
|
+
const weights = this.weights(task.class, task);
|
|
2529
3265
|
const pii = this.isPii(task);
|
|
2530
3266
|
const candidates = owner ? relayCandidates(routing.classes[task.class]?.peers ?? [], pii, { ...this.instanceRouting(), states: this.states() }) : [];
|
|
2531
3267
|
const a = assign(task, this.states(), routing, { ...weights, offRoster: [...(weights.offRoster ?? []), peer], exclude: [peer], excludeOwners: owner && this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], candidates, ...(owner ? { waitsFor: this.waitsFor(task) } : task.owner ? { notReviewer: task.owner } : {}) });
|
|
@@ -2549,7 +3285,7 @@ export class Tasks {
|
|
|
2549
3285
|
if (task.owner === peer && (OPEN.includes(task.state) || roster && task.state === "in_review")) {
|
|
2550
3286
|
const pii = this.isPii(task);
|
|
2551
3287
|
const candidates = relayCandidates(routing.classes[task.class]?.peers ?? [], pii, { ...this.instanceRouting(), states: this.states() });
|
|
2552
|
-
if (this.plannerPullRecord(task)?.pull && !assign(task, this.states(), routing, { candidates, exclude: [...this.excluded(task), peer], excludeOwners: this.plannerFull(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) }).owner) continue;
|
|
3288
|
+
if (this.plannerPullRecord(task)?.pull && !assign(task, this.states(), routing, { candidates, exclude: [...this.excluded(task), peer], excludeOwners: this.plannerFull(task), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) }).owner) continue;
|
|
2553
3289
|
// Budget keeps its original release/unassign behaviour for ordinary work. Roster changes the state in
|
|
2554
3290
|
// assignOwner's same owner write only after routing has found somebody to take it.
|
|
2555
3291
|
const back = !roster && task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
|
|
@@ -2570,13 +3306,13 @@ export class Tasks {
|
|
|
2570
3306
|
}
|
|
2571
3307
|
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
2572
3308
|
} else if (task.reviewer === peer && !isClosedTaskState(task.state)) {
|
|
2573
|
-
const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
|
|
3309
|
+
const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class, task) });
|
|
2574
3310
|
const reviewer = a.reviewer && a.reviewer !== peer && a.reviewer !== task.owner ? a.reviewer : null;
|
|
2575
3311
|
if (roster && !reviewer) {
|
|
2576
3312
|
this.releaseNotice(`task ${this.publicTitle(task)}: its reviewer ${peer} is off roster and nobody else can review; the reviewer stays ${peer}; use ahub review ${task.id}`);
|
|
2577
3313
|
continue;
|
|
2578
3314
|
}
|
|
2579
|
-
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a));
|
|
3315
|
+
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a), this.reviewMetadata(a));
|
|
2580
3316
|
moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
|
|
2581
3317
|
if (next.state === "in_review" && reviewer && reviewer !== USER) {
|
|
2582
3318
|
if (roster) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, `(${peer} was reviewing this and is off roster.)`));
|