@staix/agent-hub 0.12.36 → 0.12.38
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 +22 -0
- package/README.md +18 -11
- package/docs/agent-notes/adapters.md +19 -6
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +3 -2
- package/docs/agent-notes/daemon.md +9 -3
- package/docs/agent-notes/tasks.md +8 -4
- package/docs/agent-notes/tests.md +2 -2
- package/docs/memory.md +3 -1
- package/docs/operations.md +57 -10
- package/docs/quickstart.md +15 -7
- package/docs/sdlc.md +29 -3
- package/docs/security.md +10 -2
- package/docs/smoke.md +38 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +58 -4
- package/docs/verification/2026-10-19-0.12.37.md +60 -0
- package/docs/verification/2026-10-19-agy-p1-t0.md +55 -0
- package/docs/verification/2026-10-19-bus-defer-reset.md +9 -0
- package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
- package/docs/verification/2026-10-19-kimi-agy-reviewer-isolation.md +220 -0
- package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
- package/docs/verification/2026-10-20-0.12.38.md +56 -0
- package/docs/verified.json +27 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5627 -3670
- package/src/adapters/acp.ts +3 -2
- package/src/adapters/agy/definition.ts +61 -0
- package/src/adapters/agy/entry.ts +76 -0
- package/src/adapters/agy/hooks.ts +81 -0
- package/src/adapters/claude-channel.ts +1 -0
- package/src/adapters/claude-print.ts +18 -156
- package/src/adapters/codex-review-isolation.ts +290 -0
- package/src/adapters/definitions/builtin-metadata.ts +9 -6
- package/src/adapters/definitions/native-cli-entry-claude.ts +3 -0
- package/src/adapters/definitions/native-cli-entry-codex.ts +5 -1
- package/src/adapters/definitions/native-cli-entry-jcode.ts +2 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +12 -4
- package/src/adapters/definitions/native-cli-entry-local.ts +3 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -0
- package/src/adapters/definitions/native-cli-entry.ts +2 -0
- package/src/adapters/definitions/native-cli-retained.ts +15 -0
- package/src/adapters/definitions/native-factories.ts +17 -0
- package/src/adapters/definitions/native-policy-aux.ts +1 -1
- package/src/adapters/definitions/qwen.ts +1 -1
- package/src/adapters/definitions/reviewer-variants.ts +33 -0
- package/src/adapters/jcode.ts +20 -5
- package/src/adapters/kimi-review.ts +161 -0
- package/src/adapters/registry-types.ts +36 -4
- package/src/adapters/registry.ts +28 -7
- package/src/adapters/reviewer-mcp.ts +85 -0
- package/src/adapters/reviewer-print.ts +382 -0
- package/src/cli/agy-mcp.ts +370 -0
- package/src/cli/console-state.ts +6 -0
- package/src/cli/console.ts +2 -0
- package/src/cli/facts-hook.ts +15 -2
- package/src/cli/help.ts +2 -0
- package/src/cli/identity.ts +1 -1
- package/src/cli/init.ts +7 -0
- package/src/cli/launch.ts +3 -1
- package/src/cli/main.ts +20 -0
- package/src/cli/memory.ts +13 -12
- package/src/cli/output.ts +5 -0
- package/src/hub/board.ts +34 -6
- package/src/hub/bus.ts +17 -6
- package/src/hub/config-apply.ts +3 -2
- package/src/hub/control-client.ts +7 -1
- package/src/hub/daemon.ts +266 -46
- package/src/hub/envelope.ts +1 -1
- package/src/hub/host-executable.ts +1 -1
- package/src/hub/hub-tools.ts +5 -0
- package/src/hub/instances.ts +13 -3
- package/src/hub/memory-profile.ts +67 -11
- package/src/hub/native-hook-launch.ts +37 -0
- package/src/hub/peer-catalog.ts +2 -3
- package/src/hub/planning.ts +2 -0
- package/src/hub/release-admission.ts +64 -0
- package/src/hub/reviewer-pool-config.ts +11 -0
- package/src/hub/reviewer-pool.ts +76 -0
- package/src/hub/routing.ts +45 -3
- package/src/hub/sdlc-release.ts +113 -0
- package/src/hub/tasks.ts +261 -36
- package/src/ui/index.html +6 -1
- package/templates/AGENTS.block.md +1 -1
package/src/hub/tasks.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
|
|
1
2
|
import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
|
|
2
3
|
import { specRefs } from "./spec-read.ts";
|
|
3
4
|
import type { InstancesConfig } from "./instances.ts";
|
|
@@ -18,7 +19,7 @@ import { isAbsolute, relative } from "node:path";
|
|
|
18
19
|
import type { Briefs, NativeItem } from "../memory/brief.ts";
|
|
19
20
|
import type { MemoryClient } from "../memory/client.ts";
|
|
20
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";
|
|
21
|
-
import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskCanPlan, taskHasWorkEvidence, 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";
|
|
22
23
|
import type { Bus } from "./bus.ts";
|
|
23
24
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
24
25
|
import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
|
|
@@ -28,13 +29,17 @@ import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.t
|
|
|
28
29
|
import { realPath } from "./project.ts";
|
|
29
30
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
30
31
|
import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief.ts";
|
|
31
|
-
import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
|
|
32
|
+
import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf } from "../adapters/registry.ts";
|
|
32
33
|
import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
33
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";
|
|
34
35
|
|
|
35
36
|
export type TaskAssignmentRole = "owner" | "reviewer";
|
|
36
37
|
|
|
38
|
+
export interface ReleaseWindow { at: number; host: boolean; quota: boolean; idle: boolean; reasons: string[]; current?: () => boolean; close?: () => Promise<void>; }
|
|
39
|
+
|
|
37
40
|
export interface TasksDeps {
|
|
41
|
+
/** Host-bound evidence only; absent/expired observations never admit a release cut. */
|
|
42
|
+
releaseWindow?: () => Promise<ReleaseWindow>;
|
|
38
43
|
/** Effective per-peer planner capacity, taken from the existing settings loader. */
|
|
39
44
|
wipLimit?: (peer: PeerId) => number;
|
|
40
45
|
recordPlanning?: (change: { by: string; task?: number; release?: number; fields: string[]; revision: number }) => void;
|
|
@@ -93,7 +98,7 @@ export interface TasksDeps {
|
|
|
93
98
|
/** Optional persisted task/run execution limits (issue #102). */
|
|
94
99
|
executionBudget?: ExecutionBudget;
|
|
95
100
|
/** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
|
|
96
|
-
review?: { adaptive: boolean; min_reviews: number };
|
|
101
|
+
review?: { adaptive: boolean; min_reviews: number; pool?: readonly string[]; rerotate?: boolean };
|
|
97
102
|
/** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
|
|
98
103
|
capabilities?: () => Record<PeerId, PeerCapabilities>;
|
|
99
104
|
repository?: string;
|
|
@@ -359,7 +364,7 @@ export class Tasks {
|
|
|
359
364
|
if (finding.step === 2 || !planners.length) this.releaseNotice(text);
|
|
360
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"));
|
|
361
366
|
} else {
|
|
362
|
-
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) });
|
|
363
368
|
this.releaseNotice(`${text} Owner reassignment suggestion: ${admission.owner ?? "no eligible peer"}.`);
|
|
364
369
|
if (admission.owner && config.auto_reassign && this.sweepAvailable(admission.owner)) {
|
|
365
370
|
try { await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [admission.owner], event: "reassigned", reason: "idle", note: "ready-unassigned idle sweep" }); }
|
|
@@ -370,7 +375,7 @@ export class Tasks {
|
|
|
370
375
|
}
|
|
371
376
|
const responsible = finding.kind === "review-pending" ? task.reviewer! : task.owner!;
|
|
372
377
|
if (responsible !== USER && !this.sweepAvailable(responsible)) continue;
|
|
373
|
-
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;
|
|
374
379
|
const title = this.publicTitle(task);
|
|
375
380
|
const message = `Task ${title}: ${finding.kind}, idle escalation ${finding.step}/3.`;
|
|
376
381
|
// Only identifiers, kind and step are kept in the ladder event. A crash after this write and before publish
|
|
@@ -386,7 +391,7 @@ export class Tasks {
|
|
|
386
391
|
}
|
|
387
392
|
} else {
|
|
388
393
|
const reviewer = finding.kind === "review-pending";
|
|
389
|
-
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) });
|
|
390
395
|
const candidate = reviewer ? a.reviewer : a.owner;
|
|
391
396
|
this.passedOver(task, a);
|
|
392
397
|
this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
|
|
@@ -458,6 +463,10 @@ export class Tasks {
|
|
|
458
463
|
...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
|
|
459
464
|
...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
|
|
460
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") } : {}) } } : {}),
|
|
461
470
|
...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
|
|
462
471
|
...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
|
|
463
472
|
...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
|
|
@@ -469,11 +478,13 @@ export class Tasks {
|
|
|
469
478
|
resultLine(task: Task): string {
|
|
470
479
|
const base = `task #${task.id}: ${task.state}, owner ${task.owner ?? "none"}, reviewer ${task.reviewer ?? "none"}`;
|
|
471
480
|
const noReview = task.class !== "review" && !task.reviewer && OPEN.includes(task.state) && !this.waitsFor(task).length
|
|
472
|
-
? 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) }))
|
|
473
482
|
: undefined;
|
|
474
483
|
const held = task.owner ? peerEntry(this.d.held?.(), task.owner) : undefined;
|
|
475
484
|
const holdText = held ? `${task.owner}'s queue is held (${held}); it receives the task once the hold is resolved` : undefined;
|
|
476
|
-
|
|
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("; ");
|
|
477
488
|
}
|
|
478
489
|
|
|
479
490
|
private states(): Record<PeerId, PeerState> {
|
|
@@ -543,7 +554,37 @@ export class Tasks {
|
|
|
543
554
|
const held = this.d.held?.();
|
|
544
555
|
const workload = this.instanceRouting();
|
|
545
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);
|
|
546
|
-
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 } : {}) };
|
|
547
588
|
}
|
|
548
589
|
|
|
549
590
|
/** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
|
|
@@ -558,10 +599,10 @@ export class Tasks {
|
|
|
558
599
|
}
|
|
559
600
|
|
|
560
601
|
/** What assignment weighs besides states and policy: quota, demotion and the review record. */
|
|
561
|
-
private weights(cls: TaskClass) {
|
|
602
|
+
private weights(cls: TaskClass, task?: Task) {
|
|
562
603
|
const now = Date.now();
|
|
563
604
|
const quota = this.d.quota?.();
|
|
564
|
-
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 } } : {}) };
|
|
565
606
|
}
|
|
566
607
|
|
|
567
608
|
/**
|
|
@@ -691,7 +732,7 @@ export class Tasks {
|
|
|
691
732
|
const states = this.states();
|
|
692
733
|
const full = this.plannerFull(task);
|
|
693
734
|
// Preview the To Do transition without routing or offering the persisted backlog row.
|
|
694
|
-
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) });
|
|
695
736
|
}
|
|
696
737
|
|
|
697
738
|
async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
|
|
@@ -826,7 +867,7 @@ export class Tasks {
|
|
|
826
867
|
const own = owned.get(release.id) ?? [], ownKnown = own.filter(task => task.doneAt !== null).sort(compare);
|
|
827
868
|
const cutStart = release.cut === null ? known.length : after(known, release.cut);
|
|
828
869
|
const unknownPool = release.cut === null ? approved : unknown;
|
|
829
|
-
return { ...release,
|
|
870
|
+
return { ...this.publicRelease(release),
|
|
830
871
|
name: this.nameable(release.name) ? release.name : "[withheld]",
|
|
831
872
|
goal: this.nameable(release.goal) ? release.goal : "[withheld]",
|
|
832
873
|
tasks: scopes.get(release.id) ?? [],
|
|
@@ -1016,12 +1057,122 @@ export class Tasks {
|
|
|
1016
1057
|
return result;
|
|
1017
1058
|
}
|
|
1018
1059
|
|
|
1019
|
-
|
|
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) {
|
|
1020
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);
|
|
1021
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");
|
|
1022
1173
|
const text = input.name + "\n" + input.goal;
|
|
1023
1174
|
if (!this.nameable(text) || this.screenOn() && (await this.screenCall(text)).label !== "clear") throw new PreEffectToolRefusal("release text was not cleared for public planning");
|
|
1024
|
-
this.
|
|
1175
|
+
this.plannerWriteAuthority(by); // Privacy and planner identity may change while screening.
|
|
1025
1176
|
if (this.d.board.releases().some(release => release.name === input.name)) throw new PreEffectToolRefusal("release name already exists");
|
|
1026
1177
|
const release = this.d.board.newRelease(input.name, input.goal.trim(), input.order);
|
|
1027
1178
|
this.planningRecorded({ by, release: release.id, fields: ["created"], revision: release.revision });
|
|
@@ -1035,9 +1186,17 @@ export class Tasks {
|
|
|
1035
1186
|
if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release is final");
|
|
1036
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");
|
|
1037
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
|
+
}
|
|
1038
1196
|
const { id, revision, ...patch } = input;
|
|
1039
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");
|
|
1040
|
-
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);
|
|
1041
1200
|
this.planningRecorded({ by, release: id, fields: Object.keys(patch), revision: updated.revision });
|
|
1042
1201
|
return updated; // a failed readback may follow a durable release write
|
|
1043
1202
|
}
|
|
@@ -1489,7 +1648,7 @@ export class Tasks {
|
|
|
1489
1648
|
const task = this.d.board.get(target);
|
|
1490
1649
|
if (!task) throw new Error(`no task #${target}`);
|
|
1491
1650
|
if (isClosedTaskState(task.state)) return [...workflow, `task ${this.publicTitle(task)} (${task.state}): closed; not routed`];
|
|
1492
|
-
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) });
|
|
1493
1652
|
// The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
|
|
1494
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 ?? [])];
|
|
1495
1654
|
}
|
|
@@ -1542,6 +1701,7 @@ export class Tasks {
|
|
|
1542
1701
|
|
|
1543
1702
|
/** Why the task will approve directly at done: no reviewer could be picked, and who was skipped (issue #92). */
|
|
1544
1703
|
private noReviewer(a: Assignment): string {
|
|
1704
|
+
if (a.reviewBlocked) return "blocked: no reviewer can run";
|
|
1545
1705
|
const skipped = a.trace.flatMap((l) => {
|
|
1546
1706
|
const m = /^ {2}reviewer candidate (\S+): skipped, (.*)$/.exec(l);
|
|
1547
1707
|
return m ? [`${m[1]} (${m[2]})`] : [];
|
|
@@ -1557,7 +1717,13 @@ export class Tasks {
|
|
|
1557
1717
|
|
|
1558
1718
|
private assignmentNote(note: string, assignment: Assignment): string {
|
|
1559
1719
|
const tagged = assignment.sameFamilyFallback ? `${note}; sameFamilyFallback=true` : note;
|
|
1560
|
-
|
|
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;
|
|
1561
1727
|
}
|
|
1562
1728
|
|
|
1563
1729
|
/** A later move to another owner means the caller's assignment was superseded, so its success tail is not authoritative. */
|
|
@@ -1593,7 +1759,7 @@ export class Tasks {
|
|
|
1593
1759
|
const waits = this.waitsFor(task);
|
|
1594
1760
|
const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
|
|
1595
1761
|
const plannerFull = plannedMove ? this.plannerFull(task) : [];
|
|
1596
|
-
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 } : {}) });
|
|
1597
1763
|
|
|
1598
1764
|
if (waits.length) {
|
|
1599
1765
|
this.releaseNotice(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
|
|
@@ -1616,7 +1782,7 @@ export class Tasks {
|
|
|
1616
1782
|
// A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
|
|
1617
1783
|
// out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
|
|
1618
1784
|
const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
|
|
1619
|
-
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);
|
|
1620
1786
|
// What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
|
|
1621
1787
|
// escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
|
|
1622
1788
|
// yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
|
|
@@ -1992,7 +2158,7 @@ export class Tasks {
|
|
|
1992
2158
|
}
|
|
1993
2159
|
|
|
1994
2160
|
/** S10 read-only planner report; S7 owns rolling bands and automatic board proposals. */
|
|
1995
|
-
async costReport(by: PeerId, repository?: unknown): Promise<Record<string, unknown>> {
|
|
2161
|
+
async costReport(by: PeerId, repository?: unknown, scopePrs?: readonly number[]): Promise<Record<string, unknown>> {
|
|
1996
2162
|
const authority = () => {
|
|
1997
2163
|
this.planningAuthority(by);
|
|
1998
2164
|
if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("cost report unavailable during private work");
|
|
@@ -2004,7 +2170,8 @@ export class Tasks {
|
|
|
2004
2170
|
const bounded = (args: string[], timeout?: number, cwd?: string, cap?: number) => performance.now() < deadline
|
|
2005
2171
|
? (this.d.sdlc?.gh ?? sdlcGh)(args, Math.max(1, Math.min(timeout ?? 10_000, deadline - performance.now())), cwd, cap)
|
|
2006
2172
|
: Promise.resolve({ code: 1, stdout: "" });
|
|
2007
|
-
const
|
|
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!)));
|
|
2008
2175
|
const ids = [...new Set(linked().filter(task => !this.isPii(task)).map(task => task.sdlc!.pr!))].sort((a, b) => a - b);
|
|
2009
2176
|
const observations: import("./cost-governance.ts").CostPr[] = [];
|
|
2010
2177
|
let incomplete = ids.length > 16;
|
|
@@ -2032,7 +2199,12 @@ export class Tasks {
|
|
|
2032
2199
|
async planReviewGap(task: Task): Promise<string | undefined> {
|
|
2033
2200
|
const receipt = task.sdlc?.planReview;
|
|
2034
2201
|
if (!receipt) return "plan not reviewed since proposal";
|
|
2035
|
-
if (!receipt.planner || !Number.isFinite(receipt.recordedAt)
|
|
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
|
+
}
|
|
2036
2208
|
if (receipt.scope && JSON.stringify(receipt.scope) !== JSON.stringify(cleanCostPlan(task.plan ?? {}))) return "plan not reviewed since cost scope change";
|
|
2037
2209
|
const identity = this.planIssueIdentity(task);
|
|
2038
2210
|
if (identity?.issue && this.isPii(task)) return "private issue plan review evidence unavailable";
|
|
@@ -2133,6 +2305,15 @@ export class Tasks {
|
|
|
2133
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; }
|
|
2134
2306
|
return d;
|
|
2135
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
|
+
}
|
|
2136
2317
|
return report;
|
|
2137
2318
|
}
|
|
2138
2319
|
|
|
@@ -2604,14 +2785,16 @@ export class Tasks {
|
|
|
2604
2785
|
|
|
2605
2786
|
/** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
|
|
2606
2787
|
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness): Promise<Task> {
|
|
2607
|
-
let
|
|
2788
|
+
let selection = this.poolReview(task, refs);
|
|
2789
|
+
let reviewer = selection?.reviewer ?? task.reviewer, reviewEvidence: PrEvidence | undefined;
|
|
2608
2790
|
if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
|
|
2609
2791
|
reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
|
|
2610
2792
|
task = this.need(task.id, false, true);
|
|
2611
2793
|
this.mine(task, by, "owner", true);
|
|
2612
2794
|
// In every mode: another done may have landed during the readback.
|
|
2613
2795
|
if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
|
|
2614
|
-
|
|
2796
|
+
selection = this.poolReview(task, refs);
|
|
2797
|
+
reviewer = selection?.reviewer ?? task.reviewer;
|
|
2615
2798
|
}
|
|
2616
2799
|
// A delayed local check needs a new readback; the immediate done path already admitted before effects.
|
|
2617
2800
|
if (reviewer && !admission && (this.d.sdlc?.config().mode ?? "off") !== "off") {
|
|
@@ -2621,9 +2804,12 @@ export class Tasks {
|
|
|
2621
2804
|
this.mine(task, by, "owner");
|
|
2622
2805
|
if (task.history.length !== observed.history.length || task.state === "in_review" || isClosedTaskState(task.state)) throw new Error("task changed during review admission");
|
|
2623
2806
|
}
|
|
2807
|
+
selection = this.poolReview(task, refs);
|
|
2808
|
+
reviewer = selection?.reviewer ?? task.reviewer;
|
|
2624
2809
|
const reviewCost: ReviewCost | undefined = reviewer && task.plan && (task.plan.review || task.plan.ci)
|
|
2625
2810
|
? { ...cleanCostPlan(task.plan), ...(admission?.ready && admission.head ? { head: admission.head } : {}) } : undefined;
|
|
2626
|
-
const
|
|
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 } : {}) });
|
|
2627
2813
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
2628
2814
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
|
|
2629
2815
|
this.tellCompleted(next, summary, withheld);
|
|
@@ -2709,7 +2895,7 @@ export class Tasks {
|
|
|
2709
2895
|
|
|
2710
2896
|
/** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
|
|
2711
2897
|
/** Review admission is separate from brief construction (#228). All refusals precede the requesting done's writes. */
|
|
2712
|
-
private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence): Promise<ReviewReadiness | undefined> {
|
|
2898
|
+
private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean): Promise<ReviewReadiness | undefined> {
|
|
2713
2899
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
2714
2900
|
if (mode === "off") return undefined;
|
|
2715
2901
|
const guard = () => {
|
|
@@ -2718,7 +2904,7 @@ export class Tasks {
|
|
|
2718
2904
|
if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
|
|
2719
2905
|
}
|
|
2720
2906
|
const current = this.need(task.id, false, preEffect);
|
|
2721
|
-
if ((this.d.sdlc?.config().mode ?? "off") !== mode || current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer
|
|
2907
|
+
if ((this.d.sdlc?.config().mode ?? "off") !== mode || current.history.length !== task.history.length && !sameRequest?.() || current.owner !== task.owner || current.reviewer !== task.reviewer
|
|
2722
2908
|
|| this.isPii(current) !== this.isPii(task) || JSON.stringify(current.sdlc ?? null) !== JSON.stringify(task.sdlc ?? null)
|
|
2723
2909
|
|| actor !== USER && this.d.privateTurn?.(actor) && !this.isPii(current)) throw new (preEffect ? PreEffectToolRefusal : Error)("task or authority changed during review admission");
|
|
2724
2910
|
return current;
|
|
@@ -2771,7 +2957,42 @@ export class Tasks {
|
|
|
2771
2957
|
catch { projection = "(document projection unavailable)"; }
|
|
2772
2958
|
const brief = this.briefText(task);
|
|
2773
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}.`;
|
|
2774
|
-
this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, task: String(task.id) }, ...(this.isPii(task) ? { private: true } : {}) }));
|
|
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 } : {}) }));
|
|
2961
|
+
}
|
|
2962
|
+
|
|
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 admission for a queued review after asynchronous native preparation. */
|
|
2969
|
+
reviewBindingCurrent(by: PeerId, id: number, binding: { head?: string; flowRevision?: number }): boolean {
|
|
2970
|
+
const task = this.d.board.get(id);
|
|
2971
|
+
return !!task && task.state === "in_review" && task.reviewer === by && !this.isPii(task)
|
|
2972
|
+
&& this.reviewRequestRevision(task) === binding.flowRevision && this.reviewHead(task) === binding.head;
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
/** Host-only failure report, bound to the exact review request and its still-current child generation. */
|
|
2976
|
+
async retryReviewer(id: number, failedPeer: PeerId, failure: { head?: string; flowRevision?: number; cause: "start_failed" | "quota" | "no_verdict" }, current: () => boolean): Promise<Task> {
|
|
2977
|
+
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");
|
|
2978
|
+
const valid = () => {
|
|
2979
|
+
const task = this.need(id, false, true);
|
|
2980
|
+
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");
|
|
2981
|
+
return task;
|
|
2982
|
+
};
|
|
2983
|
+
const before = valid();
|
|
2984
|
+
const admission = await this.reviewRequestAdmission(before, undefined, true, before.owner ?? USER, undefined, () => { valid(); return true; });
|
|
2985
|
+
const task = valid();
|
|
2986
|
+
const options = this.reviewPoolOptions(task);
|
|
2987
|
+
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] } });
|
|
2988
|
+
if (!selection.reviewSelection) throw new PreEffectToolRefusal("no declared reviewer pool can receive the fallback");
|
|
2989
|
+
const reviewer = selection.reviewer ?? USER;
|
|
2990
|
+
valid();
|
|
2991
|
+
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);
|
|
2992
|
+
this.releaseNotice(`task ${this.publicTitle(next)}: former review request withdrawal incomplete`, () => this.withdrawRoleRequest(task, next, "reviewer"));
|
|
2993
|
+
if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)}: blocked: no reviewer can run; the planner or person must review`);
|
|
2994
|
+
else this.releaseNotice(`task ${this.publicTitle(next)}: fallback review request unavailable`, () => this.sendReview(next, reviewer, "", admission));
|
|
2995
|
+
return next;
|
|
2775
2996
|
}
|
|
2776
2997
|
|
|
2777
2998
|
/** #520: the rendered review brief of a non-PII task's commit, or why there is none; empty without a commit. */
|
|
@@ -2851,7 +3072,11 @@ export class Tasks {
|
|
|
2851
3072
|
}
|
|
2852
3073
|
const pii = this.isPii(task);
|
|
2853
3074
|
const reviewContext = by === USER ? undefined : this.d.reviewContext?.(by, task.id);
|
|
2854
|
-
const
|
|
3075
|
+
const poolRequest = task.history.findLast(entry => ["done", "reviewer changed", "reviewer unavailable"].includes(entry.event));
|
|
3076
|
+
const family = by === USER ? undefined : familyOf(by, this.d.instances?.());
|
|
3077
|
+
const reviewerFamily = poolRequest?.reviewerFamily === family ? family : undefined;
|
|
3078
|
+
const reviewerReason = reviewerFamily ? poolRequest?.reviewerReason : undefined;
|
|
3079
|
+
const flag = { withheld: withheld || undefined, ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
|
|
2855
3080
|
if (verdict === "approved") {
|
|
2856
3081
|
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);
|
|
2857
3082
|
if (next.owner) {
|
|
@@ -2979,7 +3204,7 @@ export class Tasks {
|
|
|
2979
3204
|
if (role === "reviewer") {
|
|
2980
3205
|
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`);
|
|
2981
3206
|
this.ready(task, preEffect);
|
|
2982
|
-
const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
|
|
3207
|
+
const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class, task) });
|
|
2983
3208
|
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("; ")}`);
|
|
2984
3209
|
const admission = task.state === "in_review" ? await this.reviewRequestAdmission(task, undefined, preEffect, by) : undefined;
|
|
2985
3210
|
// Every runtime authority check precedes the final write, including revocations during the metadata await.
|
|
@@ -2991,7 +3216,7 @@ export class Tasks {
|
|
|
2991
3216
|
}
|
|
2992
3217
|
if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster`);
|
|
2993
3218
|
this.requireCapabilities(current, peer, role, preEffect);
|
|
2994
|
-
const eligible = assign(current, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(current.owner ? { notReviewer: current.owner } : {}), ...this.weights(current.class) });
|
|
3219
|
+
const eligible = assign(current, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(current.owner ? { notReviewer: current.owner } : {}), ...this.weights(current.class, current) });
|
|
2995
3220
|
if (eligible.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)("reviewer is no longer eligible");
|
|
2996
3221
|
const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, this.assignmentNote(`named reviewer ${peer}`, a), { reason: "manual" }, expectedFlowRevision);
|
|
2997
3222
|
if (next.state === "in_review" && task.reviewer && task.reviewer !== peer) {
|
|
@@ -3043,7 +3268,7 @@ export class Tasks {
|
|
|
3043
3268
|
const routing = this.d.routing();
|
|
3044
3269
|
return this.d.board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)).map(task => {
|
|
3045
3270
|
const owner = task.owner === peer;
|
|
3046
|
-
const weights = this.weights(task.class);
|
|
3271
|
+
const weights = this.weights(task.class, task);
|
|
3047
3272
|
const pii = this.isPii(task);
|
|
3048
3273
|
const candidates = owner ? relayCandidates(routing.classes[task.class]?.peers ?? [], pii, { ...this.instanceRouting(), states: this.states() }) : [];
|
|
3049
3274
|
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 } : {}) });
|
|
@@ -3067,7 +3292,7 @@ export class Tasks {
|
|
|
3067
3292
|
if (task.owner === peer && (OPEN.includes(task.state) || roster && task.state === "in_review")) {
|
|
3068
3293
|
const pii = this.isPii(task);
|
|
3069
3294
|
const candidates = relayCandidates(routing.classes[task.class]?.peers ?? [], pii, { ...this.instanceRouting(), states: this.states() });
|
|
3070
|
-
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;
|
|
3295
|
+
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;
|
|
3071
3296
|
// Budget keeps its original release/unassign behaviour for ordinary work. Roster changes the state in
|
|
3072
3297
|
// assignOwner's same owner write only after routing has found somebody to take it.
|
|
3073
3298
|
const back = !roster && task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
|
|
@@ -3088,13 +3313,13 @@ export class Tasks {
|
|
|
3088
3313
|
}
|
|
3089
3314
|
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
3090
3315
|
} else if (task.reviewer === peer && !isClosedTaskState(task.state)) {
|
|
3091
|
-
const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
|
|
3316
|
+
const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class, task) });
|
|
3092
3317
|
const reviewer = a.reviewer && a.reviewer !== peer && a.reviewer !== task.owner ? a.reviewer : null;
|
|
3093
3318
|
if (roster && !reviewer) {
|
|
3094
3319
|
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}`);
|
|
3095
3320
|
continue;
|
|
3096
3321
|
}
|
|
3097
|
-
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a));
|
|
3322
|
+
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a), this.reviewMetadata(a));
|
|
3098
3323
|
moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
|
|
3099
3324
|
if (next.state === "in_review" && reviewer && reviewer !== USER) {
|
|
3100
3325
|
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.)`));
|
package/src/ui/index.html
CHANGED
|
@@ -408,7 +408,7 @@ function renderSettings(settings, roster = []) {
|
|
|
408
408
|
}
|
|
409
409
|
});
|
|
410
410
|
const reloadSlot = settingSlots.get('$reload'), reloadSupported = Array.isArray(settings.pendingRestart);
|
|
411
|
-
const reloadSignature = JSON.stringify([settings.pendingRestart, managerMode && !snapshotValid]);
|
|
411
|
+
const reloadSignature = JSON.stringify([settings.pendingRestart, settings.retainedConfig, managerMode && !snapshotValid]);
|
|
412
412
|
if (reloadSlot && reloadSlot.signature !== reloadSignature) {
|
|
413
413
|
reloadSlot.signature = reloadSignature; reloadSlot.node.replaceChildren();
|
|
414
414
|
if (reloadSupported) {
|
|
@@ -417,6 +417,11 @@ function renderSettings(settings, roster = []) {
|
|
|
417
417
|
reloadSlot.node.append(el('p', 'Pending restart', 'label'));
|
|
418
418
|
for (const pending of settings.pendingRestart) reloadSlot.node.append(el('p', pending.key + ': ' + pending.reason, 'meta'));
|
|
419
419
|
}
|
|
420
|
+
if (Array.isArray(settings.retainedConfig) && settings.retainedConfig.length) {
|
|
421
|
+
reloadSlot.node.append(el('p', 'Config retained', 'label'));
|
|
422
|
+
for (const pending of settings.retainedConfig) reloadSlot.node.append(el('p', pending.key + ': ' + pending.reason, 'meta'));
|
|
423
|
+
reloadSlot.node.append(el('p', 'Run ahub settings reload to review and apply these values.', 'meta'));
|
|
424
|
+
}
|
|
420
425
|
}
|
|
421
426
|
}
|
|
422
427
|
const undoSlot = settingSlots.get(''), undoKey = settings.undo || '';
|