@staix/agent-hub 0.12.34 → 0.12.36
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 +27 -0
- package/README.md +8 -6
- package/docs/agent-notes/adapters.md +6 -1
- package/docs/agent-notes/budget.md +4 -2
- package/docs/agent-notes/bus.md +5 -1
- package/docs/agent-notes/daemon.md +11 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +15 -2
- package/docs/agent-notes/tests.md +5 -1
- package/docs/gsd-collaboration-analysis.md +5 -1
- package/docs/operations.md +97 -19
- package/docs/quickstart.md +8 -4
- package/docs/sdlc.md +100 -34
- package/docs/security.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +237 -7
- package/docs/verification/2026-10-18-0.12.35.md +49 -0
- package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
- package/docs/verification/2026-10-19-0.12.36.md +63 -0
- package/docs/verified.json +26 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +15781 -14850
- package/src/adapters/acp.ts +3 -1
- package/src/adapters/claude-channel.ts +59 -29
- package/src/adapters/claude-print.ts +230 -0
- package/src/adapters/codex-appserver.ts +1 -1
- package/src/adapters/definitions/builtin-metadata.ts +17 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +6 -2
- package/src/adapters/definitions/native-cli-entry-kimi.ts +18 -12
- package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
- package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +3 -0
- package/src/adapters/definitions/native-factories.ts +7 -0
- package/src/adapters/definitions/native-recovery.ts +2 -2
- package/src/adapters/definitions/qwen-native.ts +2 -2
- package/src/adapters/local-worker.ts +3 -2
- package/src/adapters/pi.ts +3 -1
- package/src/adapters/registry-types.ts +11 -2
- package/src/adapters/registry.ts +36 -8
- package/src/cli/help.ts +5 -0
- package/src/cli/identity.ts +5 -4
- package/src/cli/launch.ts +45 -7
- package/src/cli/main.ts +136 -29
- package/src/cli/preview.ts +16 -5
- package/src/cli/recovery-guidance.ts +39 -22
- package/src/cli/reset.ts +4 -2
- package/src/cli/statusline-tee.ts +5 -2
- package/src/cli/terminal-recovery.ts +73 -16
- package/src/cli/upgrade-restore-state.ts +16 -7
- package/src/cli/upgrade-runtime.ts +83 -52
- package/src/cli/upgrade.ts +43 -27
- package/src/hub/board.ts +29 -7
- package/src/hub/budget.ts +55 -3
- package/src/hub/bus.ts +12 -6
- package/src/hub/claude-state.ts +37 -0
- package/src/hub/conductor.ts +16 -10
- package/src/hub/config-apply.ts +13 -2
- package/src/hub/config-reload.ts +11 -1
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/control-client.ts +1 -1
- package/src/hub/cost-governance.ts +76 -0
- package/src/hub/crash.ts +14 -5
- package/src/hub/daemon.ts +473 -194
- package/src/hub/execution-budget.ts +3 -3
- package/src/hub/hub-tools.ts +36 -6
- package/src/hub/instance-budget.ts +73 -0
- package/src/hub/instance-config.ts +47 -0
- package/src/hub/instance-execution.ts +50 -0
- package/src/hub/instances.ts +114 -0
- package/src/hub/lifecycle-schema.ts +7 -3
- package/src/hub/permission-mode.ts +7 -7
- package/src/hub/review-brief.ts +175 -0
- package/src/hub/roster.ts +7 -6
- package/src/hub/routing.ts +97 -18
- package/src/hub/sdlc-queue.ts +2 -2
- package/src/hub/sdlc.ts +286 -39
- package/src/hub/settings.ts +34 -6
- package/src/hub/snapshots.ts +40 -9
- package/src/hub/start-mode.ts +7 -4
- package/src/hub/status-roster.ts +18 -0
- package/src/hub/task-sweep.ts +143 -0
- package/src/hub/tasks.ts +595 -47
- package/src/hub/text-projection.ts +7 -3
- package/src/hub/usage.ts +57 -20
- package/src/hub/workflow.ts +30 -52
- package/src/pi/launch.ts +4 -3
- package/src/ui/index.html +1 -1
- package/templates/AGENTS.block.md +2 -1
package/src/hub/tasks.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
|
|
2
|
+
import { specRefs } from "./spec-read.ts";
|
|
3
|
+
import type { InstancesConfig } from "./instances.ts";
|
|
1
4
|
import { composeRememberText } from "./memory-fail.ts";
|
|
5
|
+
import { VERSION } from "../version.ts";
|
|
2
6
|
import { readGitHubImport, type GitHubSource, type GitHubImportSelection } from "./github-import.ts";
|
|
3
7
|
import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
|
|
4
8
|
import { readMergeQueue } from "./sdlc-queue.ts";
|
|
@@ -9,23 +13,24 @@ import { correctionInput, resolveVisibleMemory } from "./memory-candidates.ts";
|
|
|
9
13
|
import type { MemoryScreen } from "./read-tools.ts";
|
|
10
14
|
import { withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
11
15
|
import { profileClass, profileData, profileTaskLabel, projectProfile, type ProfileName, type ProfileSelection, type ProjectProfile } from "./profiles.ts";
|
|
12
|
-
import {
|
|
16
|
+
import { criteriaView, workflowHash } from "./workflow.ts";
|
|
13
17
|
import { isAbsolute, relative } from "node:path";
|
|
14
18
|
import type { Briefs, NativeItem } from "../memory/brief.ts";
|
|
15
19
|
import type { MemoryClient } from "../memory/client.ts";
|
|
16
20
|
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";
|
|
17
|
-
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";
|
|
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";
|
|
18
22
|
import type { Bus } from "./bus.ts";
|
|
19
23
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
20
24
|
import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
|
|
21
|
-
import { assign, detectSignals, peerEntry, predictSplit, relayCandidates, type Assignment, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
25
|
+
import { assign, detectSignals, peerEntry, predictSplit, relayCandidates, type Assignment, type InstanceRoutingOptions, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
22
26
|
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
23
27
|
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
24
28
|
import { realPath } from "./project.ts";
|
|
25
29
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
30
|
+
import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief.ts";
|
|
26
31
|
import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
|
|
27
|
-
import { nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
28
|
-
import { readSdlcPr, sdlcConfig, sdlcReceipt, sdlcStatus, stageDefinition, lifecycleStage, type SdlcConfig, sdlcGh, type SdlcGh, type SdlcStatus } from "./sdlc.ts";
|
|
32
|
+
import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
33
|
+
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";
|
|
29
34
|
|
|
30
35
|
export type TaskAssignmentRole = "owner" | "reviewer";
|
|
31
36
|
|
|
@@ -36,6 +41,10 @@ export interface TasksDeps {
|
|
|
36
41
|
board: Board;
|
|
37
42
|
bus: Bus;
|
|
38
43
|
routing: () => Routing;
|
|
44
|
+
/** Startup-bound project declarations; routing never discovers instances from peer names. */
|
|
45
|
+
instances?: () => InstancesConfig;
|
|
46
|
+
/** Host proof of each live extra runtime's actual isolated execution root; config paths alone are not proof. */
|
|
47
|
+
ownerIsolation?: () => Readonly<Record<string, boolean>>;
|
|
39
48
|
cwd: string;
|
|
40
49
|
project: string;
|
|
41
50
|
profile?: ProfileName;
|
|
@@ -46,6 +55,8 @@ export interface TasksDeps {
|
|
|
46
55
|
sessionRoles?: (peer: PeerId) => string[];
|
|
47
56
|
/** Optional bounded snapshot presentation; never stored in task history or memory. */
|
|
48
57
|
projectedReview?: (task: Task) => string | undefined;
|
|
58
|
+
/** #520: the review brief for a task's commit (git only, bounded); undefined when the task names no commit. */
|
|
59
|
+
reviewBrief?: (task: Task) => ReviewBrief | { unavailable: string } | undefined;
|
|
49
60
|
sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
|
|
50
61
|
briefs?: Briefs;
|
|
51
62
|
/** Screened live fail notes directly intersecting the task plan or refs; read-only advice. */
|
|
@@ -54,6 +65,12 @@ export interface TasksDeps {
|
|
|
54
65
|
candidateScreen?: MemoryScreen;
|
|
55
66
|
/** Authenticated live turn context, not a model-supplied flag. */
|
|
56
67
|
privateTurn?: (peer: PeerId) => boolean;
|
|
68
|
+
/** #223 R4: the hub's reading of a verdict's context (`fresh` only for a reviewer session started for this review). */
|
|
69
|
+
reviewContext?: (by: PeerId, task: number) => "fresh" | "continuing" | undefined;
|
|
70
|
+
/** #223 R2: a reviewer-only instance gets review facts, not the builder's done summary. */
|
|
71
|
+
reviewOnly?: (peer: PeerId) => boolean;
|
|
72
|
+
/** #223: captured when a verdict call starts; a non-undefined result refuses the verdict at every later check. */
|
|
73
|
+
reviewAuthority?: (by: PeerId, task: number) => (() => string | undefined) | undefined;
|
|
57
74
|
memory?: MemoryClient;
|
|
58
75
|
/** Captured native profile fence at our memory operation boundaries; no board mutation. */
|
|
59
76
|
memoryCheck?: () => void;
|
|
@@ -87,6 +104,8 @@ export interface TasksDeps {
|
|
|
87
104
|
failing?: () => Record<PeerId, string>;
|
|
88
105
|
/** Peers whose queue a needs_review delivery holds, with the hold detail (issue #90); routing explains the hold, never rejects. */
|
|
89
106
|
held?: () => Record<PeerId, string>;
|
|
107
|
+
/** Closed host facts only; approval text and quota reasons never enter the grooming projection. */
|
|
108
|
+
plannerBlocks?: () => Record<PeerId, ("approvals" | "quota")[]>;
|
|
90
109
|
/** Roles from `.agenthub/config.json` (issue #92): peers with the reviewer role join the reviewer candidates. */
|
|
91
110
|
roles?: Record<string, string[]>;
|
|
92
111
|
/** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
|
|
@@ -207,7 +226,9 @@ function cleanRefs(input: unknown): TaskRefs {
|
|
|
207
226
|
/** The same for a plan (issue #31): only its four lists, each of short strings. */
|
|
208
227
|
function cleanPlan(input: unknown, profile: ProfileSelection = "dev"): TaskPlan {
|
|
209
228
|
const r = fields(input);
|
|
210
|
-
|
|
229
|
+
let scope: CostPlan;
|
|
230
|
+
try { scope = cleanCostPlan(r); } catch { throw new PreEffectToolRefusal("invalid review or CI cost scope"); }
|
|
231
|
+
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 };
|
|
211
232
|
}
|
|
212
233
|
|
|
213
234
|
/** A plan as one line for other owners; the whole plan is on the board. */
|
|
@@ -508,19 +529,31 @@ export class Tasks {
|
|
|
508
529
|
return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested").map((h) => h.by));
|
|
509
530
|
}
|
|
510
531
|
|
|
532
|
+
/** Workload is board/queue data only: wip() never calls health() or weights(). */
|
|
533
|
+
private instanceRouting(): InstanceRoutingOptions {
|
|
534
|
+
const instances = this.d.instances?.();
|
|
535
|
+
if (!instances || !Object.keys(instances).length) return {};
|
|
536
|
+
const peers = [...new Set([...ADAPTERS.map(adapter => adapter.kind), ...Object.keys(instances), ...this.d.bus.peers.keys()])];
|
|
537
|
+
return { instances, ownerIsolation: this.d.ownerIsolation?.() ?? {}, wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])), load: Object.fromEntries(peers.map(peer => [peer, this.d.bus.queued(peer)])) };
|
|
538
|
+
}
|
|
539
|
+
|
|
511
540
|
/** Peer health and roles the daemon feeds routing (issues #89, #90, #92): failing peers are skipped, held queues explained. */
|
|
512
541
|
private health() {
|
|
513
542
|
const failing = this.d.failing?.();
|
|
514
543
|
const held = this.d.held?.();
|
|
515
|
-
|
|
544
|
+
const workload = this.instanceRouting();
|
|
545
|
+
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 } : {}) };
|
|
516
547
|
}
|
|
517
548
|
|
|
518
549
|
/** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
|
|
519
550
|
private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: TaskAssignmentRole = "owner", preEffect = true): void {
|
|
520
|
-
|
|
551
|
+
const instances = this.d.instances?.(), adapter = definitionOf(peer, instances);
|
|
552
|
+
const declaredExtra = !!adapter && adapter.kind !== peer;
|
|
553
|
+
if ((!this.d.capabilities || this.isPii(task)) && !declaredExtra) return;
|
|
521
554
|
const { state: _state, ...draft } = task; // backlog/readiness must not bypass an explicit reservation's admission
|
|
522
555
|
const admission = assign(draft, this.states(), this.d.routing(), { candidates: role === "owner" ? [peer] : [], ...(role === "owner" ? { namedOwner: peer } : { namedReviewer: peer }), ...this.weights(task.class) });
|
|
523
|
-
const missing = (admission.refusals ?? []).filter(row => row.peer === peer && row.role === role && (row.code === "tests" || row.code === "spec")).map(row => ` ${role} candidate ${peer}: skipped, ${row.reason}`);
|
|
556
|
+
const missing = (admission.refusals ?? []).filter(row => row.peer === peer && row.role === role && (row.code === "tests" || row.code === "spec" || declaredExtra && role === "owner" && row.code === "owner-admission")).map(row => ` ${role} candidate ${peer}: skipped, ${row.reason}`);
|
|
524
557
|
if (missing.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot assign ${peer}: ${missing.join("; ")}`);
|
|
525
558
|
}
|
|
526
559
|
|
|
@@ -626,6 +659,11 @@ export class Tasks {
|
|
|
626
659
|
if (by !== USER && !this.d.roles?.[by]?.includes("planner")) throw new PreEffectToolRefusal("planning requires a person or configured planner role");
|
|
627
660
|
}
|
|
628
661
|
|
|
662
|
+
private plannerWriteAuthority(by: PeerId): void {
|
|
663
|
+
if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("planner writes are unavailable during a private turn");
|
|
664
|
+
this.planningAuthority(by);
|
|
665
|
+
}
|
|
666
|
+
|
|
629
667
|
wipLimit(peer: PeerId): number {
|
|
630
668
|
const limit = this.d.wipLimit?.(peer) ?? 1;
|
|
631
669
|
if (!Number.isSafeInteger(limit) || limit < 0 || limit > 32) throw new PreEffectToolRefusal("invalid planner WIP capacity");
|
|
@@ -663,7 +701,7 @@ export class Tasks {
|
|
|
663
701
|
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");
|
|
664
702
|
const validate = () => {
|
|
665
703
|
const task = this.d.board.get(input.id);
|
|
666
|
-
if (!task || (task.planningRevision ?? 0) !== input.revision || task.state !== "backlog" || task.owner ||
|
|
704
|
+
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");
|
|
667
705
|
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
668
706
|
if (task.release != null && (!release || ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull requires unfrozen release scope");
|
|
669
707
|
if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
|
|
@@ -701,6 +739,7 @@ export class Tasks {
|
|
|
701
739
|
for (const id of ids) if (this.d.board.get(id)?.state === "backlog") pending.add(id);
|
|
702
740
|
if (pending.size) this.plannerBacklog.set(peer, pending);
|
|
703
741
|
}
|
|
742
|
+
private readonly groomingNotified = new Map<string, number>();
|
|
704
743
|
/** One bounded ids-only batch per attached planner; failed or evicted copies stay pending. */
|
|
705
744
|
flushPlannerNotices(): number {
|
|
706
745
|
const configured = Object.entries(this.d.roles ?? {}).filter(([, roles]) => roles.includes("planner")).map(([peer]) => peer);
|
|
@@ -714,6 +753,18 @@ export class Tasks {
|
|
|
714
753
|
}
|
|
715
754
|
const planners = configured.filter(peer => this.d.bus.isRostered(peer) && this.d.bus.peers.has(peer) && this.d.bus.stateOf(peer) !== "offline");
|
|
716
755
|
if (!planners.length) return 0;
|
|
756
|
+
if ((this.d.sdlc?.config().mode ?? "off") !== "off") {
|
|
757
|
+
const revision = this.d.board.statusRevision();
|
|
758
|
+
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.";
|
|
759
|
+
for (const peer of planners) if (this.groomingNotified.get(peer) !== revision) {
|
|
760
|
+
const queued = this.d.bus.pendingEnvelopes(peer).some(env => env.from === HUB && env.kind === "task" && env.body === body);
|
|
761
|
+
if (queued) continue;
|
|
762
|
+
this.releaseNotice(`could not notify planner ${peer} about grooming`, () => {
|
|
763
|
+
const recipients = this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task" }));
|
|
764
|
+
if (recipients.includes(peer)) this.groomingNotified.set(peer, revision);
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
}
|
|
717
768
|
for (const id of this.incomingBacklog) if (this.d.board.get(id)?.state !== "backlog") this.incomingBacklog.delete(id);
|
|
718
769
|
const incoming = [...this.incomingBacklog].sort((a, b) => a - b).slice(0, 64);
|
|
719
770
|
if (incoming.length) {
|
|
@@ -797,12 +848,15 @@ export class Tasks {
|
|
|
797
848
|
const task = this.d.board.get(input.id);
|
|
798
849
|
if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("planning revision changed");
|
|
799
850
|
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
|
|
800
|
-
if (by !== USER && (task.state !== "backlog" ||
|
|
851
|
+
if (by !== USER && (task.state !== "backlog" || !taskCanPlan(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
|
|
801
852
|
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");
|
|
802
853
|
if (by !== USER && Object.hasOwn(patch, "reserved") && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained");
|
|
803
854
|
if (Object.hasOwn(patch, "deps") || Object.hasOwn(patch, "reserved")) {
|
|
804
|
-
|
|
805
|
-
if (
|
|
855
|
+
const returnedUntouched = task.state === "backlog" && !task.owner && taskHasStarted(task) && taskCanPlan(task);
|
|
856
|
+
if (!returnedUntouched) {
|
|
857
|
+
if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
|
|
858
|
+
}
|
|
859
|
+
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");
|
|
806
860
|
if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
|
|
807
861
|
if (patch.reserved) this.requireCapabilities(task, patch.reserved);
|
|
808
862
|
const rows = new Map(this.d.board.list().map(row => [row.id, row]));
|
|
@@ -993,6 +1047,12 @@ export class Tasks {
|
|
|
993
1047
|
if (external && (by !== USER || input.backlog !== true)) throw new PreEffectToolRefusal("external intake requires person-owned backlog staging");
|
|
994
1048
|
if (input.backlog !== undefined && typeof input.backlog !== "boolean") throw new PreEffectToolRefusal("backlog must be a boolean");
|
|
995
1049
|
const backlog = input.backlog === true;
|
|
1050
|
+
const claiming = !backlog && input.owner === by;
|
|
1051
|
+
if (claiming) {
|
|
1052
|
+
this.acceptanceCapacity({ id: -1, owner: by, state: "proposed" });
|
|
1053
|
+
this.pendingClaims.set(by, (this.pendingClaims.get(by) ?? 0) + 1);
|
|
1054
|
+
}
|
|
1055
|
+
try {
|
|
996
1056
|
if (backlog && input.owner && !this.d.bus.isRostered(input.owner)) throw new PreEffectToolRefusal(`${input.owner} is off roster; add it before reserving work`);
|
|
997
1057
|
// Callers are models: a title is one line (it is part of console and hub.log lines), not a document.
|
|
998
1058
|
const title = String(input.title ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
|
|
@@ -1042,6 +1102,7 @@ export class Tasks {
|
|
|
1042
1102
|
const defaulted = !backlog && !cls && input.owner === by;
|
|
1043
1103
|
if (defaulted) cls = "implement";
|
|
1044
1104
|
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"}`);
|
|
1105
|
+
if (claiming) this.acceptanceCapacity({ id: -1, owner: by, state: "proposed" }, 1);
|
|
1045
1106
|
let selected;
|
|
1046
1107
|
try { selected = taskRole(input.role, cls, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
|
|
1047
1108
|
catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
@@ -1068,7 +1129,40 @@ export class Tasks {
|
|
|
1068
1129
|
return this.d.board.update(task.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
|
|
1069
1130
|
}
|
|
1070
1131
|
// Naming yourself is a claim: the work is already yours, so no offer comes back to you (paper: Agensh CLAIM, #68).
|
|
1071
|
-
return this.assignOwner(task, by, input.owner ? { candidates: [input.owner], namedOwner: input.owner, claim: input.owner === by } : {});
|
|
1132
|
+
return await this.assignOwner(task, by, input.owner ? { candidates: [input.owner], namedOwner: input.owner, claim: input.owner === by } : {});
|
|
1133
|
+
} finally {
|
|
1134
|
+
if (claiming) {
|
|
1135
|
+
const remaining = (this.pendingClaims.get(by) ?? 1) - 1;
|
|
1136
|
+
if (remaining) this.pendingClaims.set(by, remaining); else this.pendingClaims.delete(by);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
async returnUntouchedToBacklog(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
|
|
1142
|
+
this.plannerWriteAuthority(by);
|
|
1143
|
+
if (!Number.isSafeInteger(input.id) || input.id < 1 || !Number.isSafeInteger(input.revision) || input.revision < 0
|
|
1144
|
+
|| typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("untouched return needs an id, revision and bounded reason");
|
|
1145
|
+
const validate = () => {
|
|
1146
|
+
const task = this.need(input.id, false, true);
|
|
1147
|
+
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");
|
|
1148
|
+
if (by !== USER && task.pinned) throw new PreEffectToolRefusal("pinned work requires the person");
|
|
1149
|
+
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
1150
|
+
if (release && (["shipped", "dropped"].includes(release.state) || by !== USER && release.state === "frozen")) throw new PreEffectToolRefusal("settled or frozen release scope cannot return");
|
|
1151
|
+
return task;
|
|
1152
|
+
};
|
|
1153
|
+
const task = validate(), flow = task.history.length;
|
|
1154
|
+
const withheld = await this.withholds("note", input.reason, task);
|
|
1155
|
+
this.plannerWriteAuthority(by);
|
|
1156
|
+
const confirmed = validate();
|
|
1157
|
+
if (confirmed.history.length !== flow) throw new PreEffectToolRefusal("task changed during untouched return");
|
|
1158
|
+
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);
|
|
1159
|
+
this.offered.delete(confirmed.id);
|
|
1160
|
+
this.cohorts.leave(confirmed.id);
|
|
1161
|
+
this.withdrawRoleRequest(confirmed, returned, "owner");
|
|
1162
|
+
this.withdrawRoleRequest(confirmed, returned, "reviewer");
|
|
1163
|
+
this.incomingBacklog.add(confirmed.id);
|
|
1164
|
+
this.planningRecorded({ by, task: confirmed.id, fields: ["return"], revision: confirmed.planningRevision ?? 0 });
|
|
1165
|
+
return returned;
|
|
1072
1166
|
}
|
|
1073
1167
|
|
|
1074
1168
|
/** Person-owned staging only. Flow transitions remain with their existing owners and reviewers. */
|
|
@@ -1390,7 +1484,7 @@ export class Tasks {
|
|
|
1390
1484
|
|
|
1391
1485
|
/** Same code as assignment, without doing it. `routing`: a candidate policy to explain under (the settings preview, #269). */
|
|
1392
1486
|
explain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }, routing: Routing = this.d.routing()): string[] {
|
|
1393
|
-
const workflow = this.d.sdlc?.config().workflow === "gsd-lite" ? [`
|
|
1487
|
+
const workflow = this.d.sdlc?.config().workflow === "gsd-lite" ? [`criteria checklist required on every task (retired gsd-lite setting, ${this.d.sdlc.config().mode}); board dependencies authoritative`] : [];
|
|
1394
1488
|
if (typeof target === "number") {
|
|
1395
1489
|
const task = this.d.board.get(target);
|
|
1396
1490
|
if (!task) throw new Error(`no task #${target}`);
|
|
@@ -1461,6 +1555,11 @@ export class Tasks {
|
|
|
1461
1555
|
if (hold) this.d.notify(`task ${this.publicTitle(task)}: ${a.owner}'s queue is held (${hold}); the task arrives once the hold is resolved`);
|
|
1462
1556
|
}
|
|
1463
1557
|
|
|
1558
|
+
private assignmentNote(note: string, assignment: Assignment): string {
|
|
1559
|
+
const tagged = assignment.sameFamilyFallback ? `${note}; sameFamilyFallback=true` : note;
|
|
1560
|
+
return assignment.plannerFallback ? `${tagged}; reviewer=planner (fallback)` : tagged;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1464
1563
|
/** A later move to another owner means the caller's assignment was superseded, so its success tail is not authoritative. */
|
|
1465
1564
|
private ownOwnerMove(before: Task, after: Task, by: PeerId, event: string): boolean {
|
|
1466
1565
|
const moves = (task: Task) => task.history.filter(entry => ["assigned", "reassigned", "escalated", "unassigned"].includes(entry.event));
|
|
@@ -1473,6 +1572,24 @@ export class Tasks {
|
|
|
1473
1572
|
/** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
|
|
1474
1573
|
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> {
|
|
1475
1574
|
if (task.state === "backlog") throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
|
|
1575
|
+
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
1576
|
+
let stagedClaimGap: string | undefined;
|
|
1577
|
+
if (mode !== "off" && this.isBuildTask(task)) {
|
|
1578
|
+
const gap = task.sdlc?.planReview ? await this.planReviewGap(task) : "plan not reviewed since proposal";
|
|
1579
|
+
const current = this.d.board.get(task.id);
|
|
1580
|
+
if (!current || !this.ownerAssignmentCurrent(task, current)) return current ?? task;
|
|
1581
|
+
task = current; // Reviewer-only and quiet bookkeeping updates do not supersede ownership authority.
|
|
1582
|
+
if (gap) {
|
|
1583
|
+
this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}; planner: call hub_planner_groom then hub_plan_review`);
|
|
1584
|
+
if (mode === "enforce") {
|
|
1585
|
+
if (opts.claim && opts.namedOwner === by) stagedClaimGap = gap;
|
|
1586
|
+
else {
|
|
1587
|
+
this.reportStall(current, { kind: "no-eligible-peer", code: "policy" });
|
|
1588
|
+
return current;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1476
1593
|
const waits = this.waitsFor(task);
|
|
1477
1594
|
const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
|
|
1478
1595
|
const plannerFull = plannedMove ? this.plannerFull(task) : [];
|
|
@@ -1499,7 +1616,7 @@ export class Tasks {
|
|
|
1499
1616
|
// A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
|
|
1500
1617
|
// out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
|
|
1501
1618
|
const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
|
|
1502
|
-
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 } : {}) }, `${opts.note ?? `to ${a.owner}`}${passed}`, { ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) }, opts.expectedFlowRevision);
|
|
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);
|
|
1503
1620
|
// What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
|
|
1504
1621
|
// escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
|
|
1505
1622
|
// yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
|
|
@@ -1544,6 +1661,13 @@ export class Tasks {
|
|
|
1544
1661
|
}
|
|
1545
1662
|
// A claim is its own hand-over, with no delivery: its accepted write and its line stay as they were (#297).
|
|
1546
1663
|
if (opts.claim && a.owner === by) {
|
|
1664
|
+
// #481 S1: a claim of build work starts it only from an accepted spec. In enforce the claim stays an assignment
|
|
1665
|
+
// (owner set, still proposed); hub_task_accept starts it once the person accepted the spec.
|
|
1666
|
+
if (this.specGated(next) && specState(next.sdlc, undefined) === "missing") {
|
|
1667
|
+
if (this.d.sdlc?.config().mode === "enforce") { this.releaseNotice(`${this.specText(next, "missing", "blocked")}; the claim stays assigned to ${by}`); return next; }
|
|
1668
|
+
this.releaseNotice(this.specText(next, "missing", "advice"));
|
|
1669
|
+
}
|
|
1670
|
+
if (stagedClaimGap) return next; // An explicit claim may reserve its owner, but no offer or acceptance bypasses review.
|
|
1547
1671
|
const claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" });
|
|
1548
1672
|
this.releaseNotice(`task ${this.publicTitle(claimed)} claimed by ${by}`);
|
|
1549
1673
|
return claimed;
|
|
@@ -1599,7 +1723,7 @@ export class Tasks {
|
|
|
1599
1723
|
const pii = this.isPii(task), delivery = { ...a, reviewer: task.reviewer ?? undefined };
|
|
1600
1724
|
const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", delivery.owner, h.withheld)}`);
|
|
1601
1725
|
const handoff = context && !pii ? this.screen(task, context, "handoff", delivery.owner, contextWithheld).slice(0, 3000) : "";
|
|
1602
|
-
const facts = [`class ${profileTaskLabel(task)}`, delivery.owner && definitionOf(delivery.owner)?.routing?.backendLabel ? `backend ${definitionOf(delivery.owner)!.routing!.backendLabel}/${delivery.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", delivery.reviewer ? `reviewer ${delivery.reviewer}` : task.class === "review" ? "no reviewer" : this.noReviewer(delivery)].filter(Boolean).join("; ");
|
|
1726
|
+
const facts = [`class ${profileTaskLabel(task)}`, delivery.owner && definitionOf(delivery.owner, this.d.instances?.())?.routing?.backendLabel ? `backend ${definitionOf(delivery.owner, this.d.instances?.())!.routing!.backendLabel}/${delivery.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", delivery.reviewer ? `reviewer ${delivery.reviewer}` : task.class === "review" ? "no reviewer" : this.noReviewer(delivery)].filter(Boolean).join("; ");
|
|
1603
1727
|
const plan = planText(task.plan);
|
|
1604
1728
|
const body = [
|
|
1605
1729
|
`Task #${task.id} [${profileTaskLabel(task)}] ${task.title}`,
|
|
@@ -1713,7 +1837,9 @@ export class Tasks {
|
|
|
1713
1837
|
}
|
|
1714
1838
|
private ownerStallCause(task: Task, admission: Assignment): TaskStall["cause"] {
|
|
1715
1839
|
const refusal = admission.refusals?.find(row => row.role === "owner" && row.peer === task.reserved);
|
|
1716
|
-
|
|
1840
|
+
// Persist the existing stall vocabulary; the assignment/explain keeps the precise instance/family refusal.
|
|
1841
|
+
const persisted = (code: NonNullable<Assignment["refusals"]>[number]["code"]): TaskStall["cause"]["code"] => code === "instance" || code === "family" ? "policy" : code;
|
|
1842
|
+
return refusal ? { kind: "reservation", peer: refusal.peer, code: persisted(refusal.code) } : { kind: "no-eligible-peer", code: "unavailable", refusals: admission.refusals?.filter(row => row.role === "owner").map(({ peer, code }) => ({ peer, code: persisted(code) })).sort((a, b) => a.peer.localeCompare(b.peer)) ?? [] };
|
|
1717
1843
|
}
|
|
1718
1844
|
private reportStall(task: Task, cause: TaskStall["cause"], now = Date.now()): void {
|
|
1719
1845
|
const stall: TaskStall = { task: task.id, cause, signature: JSON.stringify(cause), replanRevision: task.planningRevision ?? 0, at: now };
|
|
@@ -1811,22 +1937,173 @@ export class Tasks {
|
|
|
1811
1937
|
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`);
|
|
1812
1938
|
}
|
|
1813
1939
|
|
|
1940
|
+
private planIssueIdentity(task: Task): { repo: string; issue: number } | undefined {
|
|
1941
|
+
const source = task.external ?? task.sdlc;
|
|
1942
|
+
if (source?.repo && source.issue) return { repo: source.repo, issue: source.issue };
|
|
1943
|
+
const repository = task.refs.repo ?? this.d.repository ?? "";
|
|
1944
|
+
let issues: number[];
|
|
1945
|
+
try { issues = specRefs(task, repository); }
|
|
1946
|
+
catch { throw new PreEffectToolRefusal("plan review issue identity unavailable"); }
|
|
1947
|
+
if (issues.length > 1) throw new PreEffectToolRefusal("plan review needs one explicit issue identity");
|
|
1948
|
+
return issues[0] ? { repo: repository, issue: issues[0] } : undefined;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
async groomingDigest(by: PeerId): Promise<Record<string, unknown>> {
|
|
1952
|
+
this.planningAuthority(by);
|
|
1953
|
+
const included = (task: Task) => !isClosedTaskState(task.state) || task.state === "cancelled" && task.history.some(entry => !!entry.supersededBy);
|
|
1954
|
+
const snapshot = this.d.board.list().filter(included), issues = new Map<string, PlanIssueMetadata>(), attempted = new Set<string>();
|
|
1955
|
+
const deadline = performance.now() + 4_000;
|
|
1956
|
+
let reads = 0, incomplete = false;
|
|
1957
|
+
// ponytail: at most eight metadata reads within four seconds; add paginated revision-bound readback for larger boards.
|
|
1958
|
+
for (const listed of snapshot) {
|
|
1959
|
+
this.planningAuthority(by);
|
|
1960
|
+
const task = this.d.board.get(listed.id);
|
|
1961
|
+
if (!task || this.isPii(task) || isClosedTaskState(task.state)) continue;
|
|
1962
|
+
let source: { repo: string; issue: number } | undefined;
|
|
1963
|
+
try { source = this.planIssueIdentity(task); } catch { continue; }
|
|
1964
|
+
if (!source) continue;
|
|
1965
|
+
const key = `${source.repo.toLowerCase()}#${source.issue}`;
|
|
1966
|
+
if (attempted.has(key)) continue;
|
|
1967
|
+
attempted.add(key);
|
|
1968
|
+
if (reads >= 8 || performance.now() >= deadline) { incomplete = true; break; }
|
|
1969
|
+
reads++;
|
|
1970
|
+
const run = this.d.sdlc?.gh ?? sdlcGh;
|
|
1971
|
+
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));
|
|
1972
|
+
if (issue) issues.set(key, issue);
|
|
1973
|
+
}
|
|
1974
|
+
this.planningAuthority(by);
|
|
1975
|
+
const current = this.d.board.list().filter(included), visible = current.filter(task => !this.isPii(task));
|
|
1976
|
+
const peers = [...new Set([...Object.keys(this.states()), ...current.flatMap(task => task.owner ? [task.owner] : [])])];
|
|
1977
|
+
const reviewedIssues = new Map(visible.flatMap(task => task.sdlc?.planReview?.issue ? [[task.id, task.sdlc.planReview.issue] as const] : []));
|
|
1978
|
+
const acceptedIssues = new Map(visible.flatMap(task => task.sdlc?.acceptance ? [[task.id, task.sdlc.acceptance] as const] : []));
|
|
1979
|
+
const blockers: { task: number; code: string }[] = this.d.board.stalls().map(stall => ({ task: stall.task, code: stall.cause.code }));
|
|
1980
|
+
const health = this.health(), states = this.states(), routing = this.d.routing(), hostBlocks = this.d.plannerBlocks?.() ?? {};
|
|
1981
|
+
for (const task of visible.filter(task => !isClosedTaskState(task.state)).slice(0, 500)) {
|
|
1982
|
+
const owner = task.owner ?? task.reserved;
|
|
1983
|
+
if (owner) for (const code of peerEntry(hostBlocks, owner) ?? []) blockers.push({ task: task.id, code });
|
|
1984
|
+
const result = assign(task.state === "backlog" ? { ...task, state: "proposed" } : task, states, routing, { ...health, waitsFor: this.waitsFor(task), ...(owner ? { candidates: [owner], namedOwner: owner } : {}) });
|
|
1985
|
+
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 });
|
|
1986
|
+
if (this.waitsFor(task).length) blockers.push({ task: task.id, code: "dependency" });
|
|
1987
|
+
}
|
|
1988
|
+
const digest = plannerGrooming(visible, { repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
|
|
1989
|
+
limits: Object.fromEntries(peers.map(peer => [peer, this.wipLimit(peer)])), wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])),
|
|
1990
|
+
states, issues, reviewedIssues, acceptedIssues, blockers });
|
|
1991
|
+
return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
/** S10 read-only planner report; S7 owns rolling bands and automatic board proposals. */
|
|
1995
|
+
async costReport(by: PeerId, repository?: unknown): Promise<Record<string, unknown>> {
|
|
1996
|
+
const authority = () => {
|
|
1997
|
+
this.planningAuthority(by);
|
|
1998
|
+
if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("cost report unavailable during private work");
|
|
1999
|
+
};
|
|
2000
|
+
authority();
|
|
2001
|
+
const repo = repository ?? this.d.repository;
|
|
2002
|
+
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");
|
|
2003
|
+
const deadline = performance.now() + 20_000;
|
|
2004
|
+
const bounded = (args: string[], timeout?: number, cwd?: string, cap?: number) => performance.now() < deadline
|
|
2005
|
+
? (this.d.sdlc?.gh ?? sdlcGh)(args, Math.max(1, Math.min(timeout ?? 10_000, deadline - performance.now())), cwd, cap)
|
|
2006
|
+
: Promise.resolve({ code: 1, stdout: "" });
|
|
2007
|
+
const linked = () => this.d.board.list().filter(task => task.sdlc?.repo?.toLowerCase() === repo.toLowerCase() && Number.isSafeInteger(task.sdlc.pr) && task.sdlc.pr! > 0);
|
|
2008
|
+
const ids = [...new Set(linked().filter(task => !this.isPii(task)).map(task => task.sdlc!.pr!))].sort((a, b) => a - b);
|
|
2009
|
+
const observations: import("./cost-governance.ts").CostPr[] = [];
|
|
2010
|
+
let incomplete = ids.length > 16;
|
|
2011
|
+
for (const pr of ids.slice(0, 16)) {
|
|
2012
|
+
authority();
|
|
2013
|
+
if (linked().some(task => task.sdlc?.pr === pr && this.isPii(task))) continue;
|
|
2014
|
+
if (performance.now() >= deadline) { incomplete = true; break; }
|
|
2015
|
+
const evidence = await readPrCostHistory(repo, pr, (args, timeout, cwd, cap) => {
|
|
2016
|
+
authority();
|
|
2017
|
+
const fresh = linked().filter(task => task.sdlc?.pr === pr);
|
|
2018
|
+
if (!fresh.length || fresh.some(task => this.isPii(task))) return Promise.resolve({ code: 1, stdout: "" });
|
|
2019
|
+
return bounded(args, timeout, cwd, cap);
|
|
2020
|
+
});
|
|
2021
|
+
if (evidence) observations.push(evidence); else incomplete = true;
|
|
2022
|
+
}
|
|
2023
|
+
authority();
|
|
2024
|
+
const current = linked();
|
|
2025
|
+
const visiblePrs = new Set(current.filter(task => !this.isPii(task)).map(task => task.sdlc!.pr!));
|
|
2026
|
+
for (const task of current) if (this.isPii(task)) visiblePrs.delete(task.sdlc!.pr!);
|
|
2027
|
+
if (JSON.stringify([...visiblePrs].sort((a, b) => a - b)) !== JSON.stringify(ids)) incomplete = true;
|
|
2028
|
+
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)));
|
|
2029
|
+
return { ...report, truncated: report.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}), bands: "S7 pending" };
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
async planReviewGap(task: Task): Promise<string | undefined> {
|
|
2033
|
+
const receipt = task.sdlc?.planReview;
|
|
2034
|
+
if (!receipt) return "plan not reviewed since proposal";
|
|
2035
|
+
if (!receipt.planner || !Number.isFinite(receipt.recordedAt) || receipt.planningRevision !== (task.planningRevision ?? 0)) return "plan not reviewed since planning revision change";
|
|
2036
|
+
if (receipt.scope && JSON.stringify(receipt.scope) !== JSON.stringify(cleanCostPlan(task.plan ?? {}))) return "plan not reviewed since cost scope change";
|
|
2037
|
+
const identity = this.planIssueIdentity(task);
|
|
2038
|
+
if (identity?.issue && this.isPii(task)) return "private issue plan review evidence unavailable";
|
|
2039
|
+
if (!identity?.issue) return receipt.issue ? "plan not reviewed since issue identity change" : undefined;
|
|
2040
|
+
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";
|
|
2041
|
+
const current = await readPlanIssueMetadata(identity.repo, identity.issue, this.d.sdlc?.gh ?? sdlcGh);
|
|
2042
|
+
if (!current) return "plan review issue metadata unavailable";
|
|
2043
|
+
if (current.state !== "OPEN") return "plan review issue is closed";
|
|
2044
|
+
return current.editedAt !== receipt.issue.editedAt ? "plan not reviewed since issue edit" : undefined;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
/** Planner authority and issue edit evidence are rechecked across every external await. */
|
|
2048
|
+
async recordPlanReview(by: PeerId, id: unknown, input: unknown): Promise<Task> {
|
|
2049
|
+
this.plannerWriteAuthority(by);
|
|
2050
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw new PreEffectToolRefusal("plan review needs a revision and reason");
|
|
2051
|
+
const raw = input as Record<string, unknown>;
|
|
2052
|
+
if (Object.keys(raw).some(key => !["revision", "reason", "review", "ci"].includes(key)) || !Number.isSafeInteger(raw.revision) || (raw.revision as number) < 0
|
|
2053
|
+
|| typeof raw.reason !== "string" || !raw.reason.trim() || raw.reason.length > 1000) throw new PreEffectToolRefusal("plan review needs a current revision and bounded reason");
|
|
2054
|
+
const task = this.need(id, false, true, true), revision = raw.revision as number;
|
|
2055
|
+
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
|
|
2056
|
+
if ((task.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("planning revision changed");
|
|
2057
|
+
let scope: CostPlan;
|
|
2058
|
+
try { scope = cleanCostPlan({ ...task.plan, ...("review" in raw ? { review: raw.review } : {}), ...("ci" in raw ? { ci: raw.ci } : {}) }); }
|
|
2059
|
+
catch { throw new PreEffectToolRefusal("invalid review or CI cost scope"); }
|
|
2060
|
+
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
2061
|
+
if (!scope.review || !scope.ci) {
|
|
2062
|
+
if (mode === "enforce") throw new PreEffectToolRefusal("review and CI cost scope required for a new plan receipt");
|
|
2063
|
+
if (mode === "advise") this.releaseNotice(`task ${this.publicTitle(task)}: cost scope unspecified`);
|
|
2064
|
+
}
|
|
2065
|
+
const flow = task.history.length, withheld = await this.withholds("note", raw.reason, task);
|
|
2066
|
+
this.plannerWriteAuthority(by);
|
|
2067
|
+
const observed = this.need(task.id, false, true, true);
|
|
2068
|
+
if (observed.history.length !== flow || (observed.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
|
|
2069
|
+
const identity = this.planIssueIdentity(observed);
|
|
2070
|
+
if (identity?.issue && this.isPii(observed)) throw new PreEffectToolRefusal("private issue plan review readback unavailable");
|
|
2071
|
+
const issue = identity?.repo && identity.issue ? await readPlanIssueMetadata(identity.repo, identity.issue, this.d.sdlc?.gh ?? sdlcGh) : undefined;
|
|
2072
|
+
if (identity?.issue && !issue) throw new PreEffectToolRefusal("plan review issue metadata unavailable");
|
|
2073
|
+
if (issue && !this.isPii(task) && !this.nameable(JSON.stringify(issue))) throw new PreEffectToolRefusal("plan review metadata matches a PII pattern");
|
|
2074
|
+
this.plannerWriteAuthority(by);
|
|
2075
|
+
const current = this.need(task.id, false, true, true);
|
|
2076
|
+
if (current.history.length !== flow || (current.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
|
|
2077
|
+
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2078
|
+
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"),
|
|
2079
|
+
planReview: { planner: by, planningRevision: revision, recordedAt: Date.now(), ...(Object.keys(scope).length ? { scope } : {}), ...(issue ? { issue } : {}) } } }, raw.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
|
|
2080
|
+
if (updated.state === "proposed" && !updated.owner) {
|
|
2081
|
+
this.offered.delete(updated.id);
|
|
2082
|
+
try { await this.offerReady(updated, "planner review recorded"); }
|
|
2083
|
+
catch { this.releaseNotice(`task #${updated.id}: plan review saved; inspect its owner delivery before retrying`); }
|
|
2084
|
+
}
|
|
2085
|
+
return this.d.board.get(updated.id) ?? updated;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
1814
2088
|
/** Artifact links and claimed local gates stay on the board; no outside text is accepted. */
|
|
1815
2089
|
recordLifecycle(by: PeerId, id: unknown, input: unknown): Task {
|
|
1816
2090
|
const task = this.need(id, false, true);
|
|
1817
2091
|
if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
|
|
1818
2092
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
1819
|
-
const next = sdlcReceipt(input, task.sdlc, config);
|
|
2093
|
+
const next = sdlcReceipt(input, task.sdlc, config, VERSION);
|
|
1820
2094
|
const definition = stageDefinition(config, next.stage);
|
|
2095
|
+
// #481 S1: entering a build stage in enforce needs the person's spec acceptance; the person may move any stage.
|
|
2096
|
+
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"));
|
|
1821
2097
|
if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
|
|
1822
2098
|
const actor = definition?.actor;
|
|
1823
2099
|
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"));
|
|
1824
2100
|
if (!acts) throw new PreEffectToolRefusal(`lifecycle stage requires its ${actor ?? "unknown"} actor or the person`);
|
|
1825
2101
|
}
|
|
1826
|
-
if (input && typeof input === "object" && "phase" in input) {
|
|
2102
|
+
if (input && typeof input === "object" && ("criteria" in input || "phase" in input)) {
|
|
1827
2103
|
this.mine(task, by, "reviewer", true);
|
|
1828
|
-
|
|
1829
|
-
|
|
2104
|
+
// Screen whichever name stored the checklist: off keeps the #257 phase key (#481 S8).
|
|
2105
|
+
const checklist = checklistOf(next);
|
|
2106
|
+
if (checklist && !this.isPii(task) && !this.nameable(JSON.stringify(checklist))) throw new PreEffectToolRefusal("checklist metadata matches a PII pattern; use private work");
|
|
1830
2107
|
}
|
|
1831
2108
|
// Recording a head is the reviewer's; a head dropped because the repo or PR changed is not a recording, and a
|
|
1832
2109
|
// head supplied with a new PR identity is one even when its value is unchanged (#382).
|
|
@@ -1860,30 +2137,108 @@ export class Tasks {
|
|
|
1860
2137
|
}
|
|
1861
2138
|
|
|
1862
2139
|
private workflowView(task: Task) {
|
|
1863
|
-
return
|
|
2140
|
+
return criteriaView(this.d.sdlc?.config() ?? sdlcConfig(undefined), task, t => this.isPii(t));
|
|
1864
2141
|
}
|
|
1865
2142
|
workflowExport(id: unknown): Record<string, unknown> {
|
|
1866
|
-
const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
1867
|
-
|
|
1868
|
-
|
|
2143
|
+
const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined), stage = task.sdlc?.stage ?? config.stages[0] ?? "spec";
|
|
2144
|
+
// Off keeps the #257 export bytes; schema 2 (criteria, no workflow framing) is for an enabled or retired-setting lifecycle.
|
|
2145
|
+
if (config.mode === "off" && !config.workflow) {
|
|
2146
|
+
const legacy = { id: task.id, state: task.state, stage, ...(this.isPii(task) ? { private: true } : { phase: null }) };
|
|
2147
|
+
return { schema: 1, workflow: "none", state: legacy, stateHash: workflowHash(legacy), sourceHash: workflowHash(config) };
|
|
2148
|
+
}
|
|
2149
|
+
const state = { id: task.id, state: task.state, stage, ...(this.isPii(task) ? { private: true } : { criteria: this.workflowView(task) ?? null }) };
|
|
2150
|
+
return { schema: 2, state, stateHash: workflowHash(state), sourceHash: workflowHash(config) };
|
|
1869
2151
|
}
|
|
1870
2152
|
async lifecycleStatus(id: unknown, refresh = true): Promise<SdlcStatus> {
|
|
1871
2153
|
const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
1872
2154
|
const evidence = task.sdlc && !this.isPii(task) && refresh && config.mode !== "off" ? await readSdlcPr(task.sdlc, this.d.sdlc?.gh) : undefined;
|
|
1873
|
-
|
|
2155
|
+
const accepted = task.sdlc?.acceptance;
|
|
2156
|
+
const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue, this.d.sdlc?.gh) : undefined;
|
|
2157
|
+
return sdlcStatus(config, task.sdlc, evidence, this.workflowView(task), edit);
|
|
1874
2158
|
}
|
|
1875
2159
|
|
|
1876
|
-
|
|
2160
|
+
/** #481 S1: only build work starts from an accepted spec. */
|
|
2161
|
+
private specGated(task: Task): boolean {
|
|
2162
|
+
return (this.d.sdlc?.config().mode ?? "off") !== "off" && (task.class === "implement" || task.class === "bulk_edit");
|
|
2163
|
+
}
|
|
2164
|
+
private specText(task: Task, state: Exclude<SpecState, "current">, verb: "blocked" | "advice"): string {
|
|
2165
|
+
const issue = task.sdlc?.issue;
|
|
2166
|
+
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";
|
|
2167
|
+
return `task #${task.id}: SDLC build entry ${verb}: ${why}`;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/** The person's acceptance of the task's spec issue at its current body (#481 S1); identity and edit times only. */
|
|
2171
|
+
acceptSpec(by: PeerId, id: unknown, spec: { repo?: unknown; issue: unknown; edit: SpecEdit }): Task {
|
|
2172
|
+
if (by !== USER) throw new PreEffectToolRefusal("spec acceptance is the person's: run ahub sdlc accept in a terminal");
|
|
2173
|
+
const task = this.need(id, false, true, true), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2174
|
+
const repo = spec.repo ?? task.sdlc?.repo;
|
|
2175
|
+
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`);
|
|
2176
|
+
const next = sdlcReceipt({ repo, issue: spec.issue }, task.sdlc, config);
|
|
2177
|
+
if (!next.repo || !next.issue) throw new PreEffectToolRefusal("spec acceptance needs the repository (owner/repository) and the issue number");
|
|
2178
|
+
next.acceptance = { repo: next.repo, issue: next.issue, lastEditedAt: spec.edit.lastEditedAt, updatedAt: spec.edit.updatedAt, acceptedAt: Date.now() };
|
|
2179
|
+
return this.d.board.update(task.id, USER, "spec accepted", { sdlc: next }, `issue #${next.issue}, body last edited ${spec.edit.lastEditedAt ?? "never"}`);
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
/** Build entry with a live readback of the spec's body edit time (#481 S1): enforce refuses, advise notifies, off does nothing. */
|
|
2183
|
+
async specGate(by: PeerId, id: unknown): Promise<void> {
|
|
2184
|
+
const task = this.need(id, false, true);
|
|
2185
|
+
if (!this.specGated(task) || task.owner !== by && by !== USER) return; // accept reports its own refusals
|
|
2186
|
+
const accepted = task.sdlc?.acceptance;
|
|
2187
|
+
const edit = accepted && !this.isPii(task) ? await readSpecEdit(accepted.repo, accepted.issue, this.d.sdlc?.gh) : undefined;
|
|
2188
|
+
if (this.specMoved(task)) return;
|
|
2189
|
+
this.specVerdict(task, task.sdlc?.issue ? specState(task.sdlc, edit) : "missing");
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
/** #481 S1: a build stage change by an agent re-reads the body edit time first, as accept does. */
|
|
2193
|
+
async specStageGate(by: PeerId, id: unknown, input: unknown): Promise<void> {
|
|
2194
|
+
const task = this.need(id, false, true), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2195
|
+
if (config.mode === "off" || by === USER || !input || typeof input !== "object" || !("stage" in input)) return;
|
|
2196
|
+
let next: ReturnType<typeof sdlcReceipt>;
|
|
2197
|
+
try { next = sdlcReceipt(input, task.sdlc, config, VERSION); } catch { return; } // recordLifecycle reports an invalid receipt
|
|
2198
|
+
const definition = stageDefinition(config, next.stage);
|
|
2199
|
+
if (next.stage === task.sdlc?.stage || !definition || canonicalStage(definition.base) !== "build") return;
|
|
2200
|
+
const accepted = next.acceptance;
|
|
2201
|
+
const edit = accepted && !this.isPii(task) ? await readSpecEdit(accepted.repo, accepted.issue, this.d.sdlc?.gh) : undefined;
|
|
2202
|
+
if (this.specMoved(task)) return;
|
|
2203
|
+
this.specVerdict(task, next.issue ? specState(next, edit) : "missing");
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
/**
|
|
2207
|
+
* The task a spec readback started from changed while it was awaited (flow, link, acceptance, owner or privacy): the
|
|
2208
|
+
* reading belongs to other work. Enforce refuses before any write; advise drops the stale advice.
|
|
2209
|
+
*/
|
|
2210
|
+
private specMoved(before: Task): boolean {
|
|
2211
|
+
const now = this.need(before.id, false, true);
|
|
2212
|
+
if (now.history.length === before.history.length && now.owner === before.owner && this.isPii(now) === this.isPii(before)
|
|
2213
|
+
&& JSON.stringify(now.sdlc ?? null) === JSON.stringify(before.sdlc ?? null)) return false;
|
|
2214
|
+
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`);
|
|
2215
|
+
return true;
|
|
2216
|
+
}
|
|
2217
|
+
private specVerdict(task: Task, state: SpecState): void {
|
|
2218
|
+
if (state === "current") return;
|
|
2219
|
+
if (this.d.sdlc?.config().mode === "enforce") throw new PreEffectToolRefusal(this.specText(task, state, "blocked"));
|
|
2220
|
+
this.d.notify(this.specText(task, state, "advice"));
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string): Promise<PrEvidence | undefined> {
|
|
1877
2224
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
1878
2225
|
if (config.mode === "off") return;
|
|
1879
2226
|
const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
|
|
1880
|
-
const
|
|
2227
|
+
const read: SdlcGh = (args, timeout, cwd, cap) => {
|
|
2228
|
+
const current = this.need(task.id, false, true);
|
|
2229
|
+
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: "" });
|
|
2230
|
+
return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap);
|
|
2231
|
+
};
|
|
2232
|
+
const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read) : undefined;
|
|
1881
2233
|
const current = this.need(task.id, false, true);
|
|
1882
2234
|
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`);
|
|
2235
|
+
// Advise may continue on the current task, but evidence from its previous link cannot admit that task's review.
|
|
2236
|
+
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;
|
|
1883
2237
|
const status = sdlcStatus(config, receipt, evidence, this.workflowView(current));
|
|
1884
2238
|
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;
|
|
1885
2239
|
if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
|
|
1886
2240
|
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("; ")}`);
|
|
2241
|
+
return evidence;
|
|
1887
2242
|
}
|
|
1888
2243
|
|
|
1889
2244
|
private need(id: unknown, open = false, preEffect = false, allowBacklog = false): Task {
|
|
@@ -1898,6 +2253,62 @@ export class Tasks {
|
|
|
1898
2253
|
return task;
|
|
1899
2254
|
}
|
|
1900
2255
|
|
|
2256
|
+
private readonly pendingClaims = new Map<PeerId, number>();
|
|
2257
|
+
private readonly reviewedAdmissions = new Map<number, { flow: number; owner: string | null }>();
|
|
2258
|
+
private isBuildTask(task: Task): boolean {
|
|
2259
|
+
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2260
|
+
if (["implement", "bulk_edit"].includes(task.class)) return true;
|
|
2261
|
+
const base = task.sdlc ? stageDefinition(config, task.sdlc.stage)?.base : undefined;
|
|
2262
|
+
return base === "build" || base === "implement";
|
|
2263
|
+
}
|
|
2264
|
+
async acceptReviewed(by: PeerId, id: unknown, plan?: unknown): Promise<Task> {
|
|
2265
|
+
const task = this.need(id, false, true);
|
|
2266
|
+
this.mine(task, by, "owner", true);
|
|
2267
|
+
this.acceptanceCapacity(task);
|
|
2268
|
+
if ((this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode === "off" || !this.isBuildTask(task)) return this.accept(by, id, plan);
|
|
2269
|
+
await this.specGate(by, id);
|
|
2270
|
+
await this.planEntryGate(by, task);
|
|
2271
|
+
const current = this.need(id, false, true);
|
|
2272
|
+
this.mine(current, by, "owner", true);
|
|
2273
|
+
if (current.history.length !== task.history.length || current.owner !== task.owner) throw new PreEffectToolRefusal("task changed during build admission");
|
|
2274
|
+
this.reviewedAdmissions.set(current.id, { flow: current.history.length, owner: current.owner });
|
|
2275
|
+
try { return this.accept(by, id, plan); }
|
|
2276
|
+
finally { this.reviewedAdmissions.delete(current.id); }
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
/** Spec and plan readbacks share the caller's original task snapshot until the final acceptance write. */
|
|
2280
|
+
private async planEntryGate(by: PeerId, task: Task): Promise<void> {
|
|
2281
|
+
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
2282
|
+
if (mode === "off" || !this.isBuildTask(task)) return;
|
|
2283
|
+
const gap = await this.planReviewGap(task);
|
|
2284
|
+
if (mode === "advise" && !task.sdlc?.planReview?.scope) this.releaseNotice(`task ${this.publicTitle(task)}: cost scope unspecified`);
|
|
2285
|
+
const current = this.need(task.id, false, true);
|
|
2286
|
+
this.mine(current, by, "owner", true);
|
|
2287
|
+
if (current.history.length !== task.history.length || current.owner !== task.owner) throw new PreEffectToolRefusal("task changed during plan readback");
|
|
2288
|
+
if (gap && mode === "enforce") throw new PreEffectToolRefusal(gap);
|
|
2289
|
+
if (gap) this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}`);
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
private acceptanceCapacity(task: Pick<Task, "id" | "owner" | "reserved" | "state"> & Partial<Pick<Task, "history">>, excludePending = 0): void {
|
|
2293
|
+
const owner = task.owner ?? task.reserved;
|
|
2294
|
+
if (!owner) return;
|
|
2295
|
+
if ((task.state === "in_progress" || task.state === "changes_requested") && task.history) {
|
|
2296
|
+
let boundary = 0, previous: PeerId | null | undefined;
|
|
2297
|
+
task.history.forEach((entry, index) => {
|
|
2298
|
+
if (entry.owner !== undefined) {
|
|
2299
|
+
if (entry.owner !== previous) boundary = index;
|
|
2300
|
+
previous = entry.owner;
|
|
2301
|
+
} else if (OWNERSHIP_EVENTS.has(entry.event)) { boundary = index; previous = undefined; }
|
|
2302
|
+
});
|
|
2303
|
+
if (task.history.slice(boundary).some(entry => entry.event === "accepted" && (entry.by === owner || entry.by === USER))) return;
|
|
2304
|
+
}
|
|
2305
|
+
const limit = this.wipLimit(owner), pending = Math.max(0, (this.pendingClaims.get(owner) ?? 0) - excludePending);
|
|
2306
|
+
if (this.wip(owner, task.id) + pending < limit) return;
|
|
2307
|
+
const open = this.d.board.list().filter(other => other.id !== task.id && !["backlog", "approved", "cancelled"].includes(other.state)
|
|
2308
|
+
&& (other.owner === owner || !other.owner && other.reserved === owner && this.plannerPullRecord(other)?.pull)).map(other => `#${other.id}`);
|
|
2309
|
+
throw new PreEffectToolRefusal(`planner WIP capacity for ${owner}: limit ${limit}; open tasks ${open.join(", ") || "none"}${pending ? `; pending claims ${pending}` : ""}`);
|
|
2310
|
+
}
|
|
2311
|
+
|
|
1901
2312
|
/**
|
|
1902
2313
|
* With a plan, the owners of overlapping tasks get it as a ride-along line, and an overlap only the plan reveals is
|
|
1903
2314
|
* announced like one found at assignment (issue #31).
|
|
@@ -1907,9 +2318,24 @@ export class Tasks {
|
|
|
1907
2318
|
this.mine(task, by, "owner", true);
|
|
1908
2319
|
this.ready(task, true);
|
|
1909
2320
|
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`);
|
|
2321
|
+
this.acceptanceCapacity(task);
|
|
2322
|
+
// S1 spec acceptance and S9 plan review are independent prerequisites; retain S1's missing-spec refusal first.
|
|
2323
|
+
if (this.specGated(task) && this.d.sdlc?.config().mode === "enforce" && specState(task.sdlc, undefined) === "missing") throw new PreEffectToolRefusal(this.specText(task, "missing", "blocked"));
|
|
2324
|
+
if ((this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode === "enforce" && this.isBuildTask(task)) {
|
|
2325
|
+
const readback = this.reviewedAdmissions.get(task.id);
|
|
2326
|
+
this.reviewedAdmissions.delete(task.id);
|
|
2327
|
+
if (!readback || readback.flow !== task.history.length || readback.owner !== task.owner) throw new PreEffectToolRefusal("current plan-review readback required before acceptance");
|
|
2328
|
+
}
|
|
1910
2329
|
// Models send null or {} for an optional field they leave empty: neither replaces a plan.
|
|
1911
2330
|
const given = plan == null ? undefined : cleanPlan(plan, (task.profile ?? "dev") === (this.d.profile ?? "dev") ? this.d.profileData ?? task.profile ?? "dev" : task.profile ?? "dev");
|
|
1912
|
-
|
|
2331
|
+
// File-list intent still replaces the old lists; omitted budget keys retain the planner-reviewed scope.
|
|
2332
|
+
const cleaned = given && Object.keys(given).length ? { ...cleanCostPlan(task.plan ?? {}), ...given } : undefined;
|
|
2333
|
+
if (this.d.sdlc?.config().mode === "enforce" && task.sdlc?.planReview && !task.sdlc.planReview.scope
|
|
2334
|
+
&& given && Object.keys(cleanCostPlan(given)).length) throw new PreEffectToolRefusal("legacy plan cost scope is unspecified; record a scoped planner review before acceptance");
|
|
2335
|
+
if (this.d.sdlc?.config().mode === "enforce" && task.sdlc?.planReview?.scope
|
|
2336
|
+
&& JSON.stringify(cleanCostPlan(cleaned ?? task.plan ?? {})) !== JSON.stringify(task.sdlc.planReview.scope)) {
|
|
2337
|
+
throw new PreEffectToolRefusal("plan cost scope changed; record a new planner review before acceptance");
|
|
2338
|
+
}
|
|
1913
2339
|
// A task's signals are fixed when it is proposed: text that matches a PII pattern cannot be let in afterwards.
|
|
1914
2340
|
if (cleaned && !this.isPii(task) && this.isPii({ signals: this.signalsFor({ title: "", detail: planText(cleaned), refs: {}, plan: cleaned }) })) {
|
|
1915
2341
|
throw new PreEffectToolRefusal("this plan matches a PII pattern and is not kept: other owners would see it");
|
|
@@ -1999,6 +2425,7 @@ export class Tasks {
|
|
|
1999
2425
|
this.mine(t, by, "owner", true);
|
|
2000
2426
|
if (t.state === "in_review" || isClosedTaskState(t.state)) throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
|
|
2001
2427
|
this.ready(t, true);
|
|
2428
|
+
if (t.state === "proposed" || t.state === "changes_requested") this.acceptanceCapacity(t);
|
|
2002
2429
|
if (this.checking.has(t.id)) {
|
|
2003
2430
|
// The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
|
|
2004
2431
|
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`);
|
|
@@ -2008,7 +2435,17 @@ export class Tasks {
|
|
|
2008
2435
|
// The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
|
|
2009
2436
|
const withheld = await this.withholds("summary", summary, valid());
|
|
2010
2437
|
let task = valid();
|
|
2011
|
-
|
|
2438
|
+
// #481 S1: done without a separate accept starts build work too: the same live spec check as accept, before any write.
|
|
2439
|
+
if (task.state === "proposed" || task.state === "changes_requested") {
|
|
2440
|
+
const admission = task;
|
|
2441
|
+
await this.specGate(by, task.id);
|
|
2442
|
+
await this.planEntryGate(by, admission);
|
|
2443
|
+
task = valid();
|
|
2444
|
+
}
|
|
2445
|
+
let reviewEvidence: PrEvidence | undefined;
|
|
2446
|
+
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task = valid(); }
|
|
2447
|
+
const reviewAdmission = task.reviewer ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence) : undefined;
|
|
2448
|
+
task = valid();
|
|
2012
2449
|
// A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
|
|
2013
2450
|
const cohort = this.cohorts.of(task.id);
|
|
2014
2451
|
const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
|
|
@@ -2039,7 +2476,7 @@ export class Tasks {
|
|
|
2039
2476
|
}
|
|
2040
2477
|
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
|
|
2041
2478
|
const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
|
|
2042
|
-
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review");
|
|
2479
|
+
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission);
|
|
2043
2480
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
2044
2481
|
this.checking.set(task.id, task.owner);
|
|
2045
2482
|
const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary, { withheld: withheld || undefined });
|
|
@@ -2166,17 +2603,27 @@ export class Tasks {
|
|
|
2166
2603
|
}
|
|
2167
2604
|
|
|
2168
2605
|
/** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
|
|
2169
|
-
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review"): Promise<Task> {
|
|
2170
|
-
let reviewer = task.reviewer;
|
|
2606
|
+
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness): Promise<Task> {
|
|
2607
|
+
let reviewer = task.reviewer, reviewEvidence: PrEvidence | undefined;
|
|
2171
2608
|
if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
|
|
2172
|
-
await this.lifecycleGate(task, reviewer ? "review" : "approval");
|
|
2609
|
+
reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
|
|
2173
2610
|
task = this.need(task.id, false, true);
|
|
2174
2611
|
this.mine(task, by, "owner", true);
|
|
2175
2612
|
// In every mode: another done may have landed during the readback.
|
|
2176
2613
|
if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
|
|
2177
2614
|
reviewer = task.reviewer;
|
|
2178
2615
|
}
|
|
2179
|
-
|
|
2616
|
+
// A delayed local check needs a new readback; the immediate done path already admitted before effects.
|
|
2617
|
+
if (reviewer && !admission && (this.d.sdlc?.config().mode ?? "off") !== "off") {
|
|
2618
|
+
const observed = task;
|
|
2619
|
+
admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence);
|
|
2620
|
+
task = this.need(task.id);
|
|
2621
|
+
this.mine(task, by, "owner");
|
|
2622
|
+
if (task.history.length !== observed.history.length || task.state === "in_review" || isClosedTaskState(task.state)) throw new Error("task changed during review admission");
|
|
2623
|
+
}
|
|
2624
|
+
const reviewCost: ReviewCost | undefined = reviewer && task.plan && (task.plan.review || task.plan.ci)
|
|
2625
|
+
? { ...cleanCostPlan(task.plan), ...(admission?.ready && admission.head ? { head: admission.head } : {}) } : undefined;
|
|
2626
|
+
const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary, { withheld: withheld || undefined, ...(reviewCost ? { reviewCost } : {}) });
|
|
2180
2627
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
2181
2628
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
|
|
2182
2629
|
this.tellCompleted(next, summary, withheld);
|
|
@@ -2187,7 +2634,7 @@ export class Tasks {
|
|
|
2187
2634
|
await this.releaseDependents(next);
|
|
2188
2635
|
}
|
|
2189
2636
|
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]`);
|
|
2190
|
-
else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer));
|
|
2637
|
+
else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, "", admission));
|
|
2191
2638
|
return next;
|
|
2192
2639
|
}
|
|
2193
2640
|
|
|
@@ -2261,7 +2708,44 @@ export class Tasks {
|
|
|
2261
2708
|
}
|
|
2262
2709
|
|
|
2263
2710
|
/** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
|
|
2264
|
-
|
|
2711
|
+
/** 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> {
|
|
2713
|
+
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
2714
|
+
if (mode === "off") return undefined;
|
|
2715
|
+
const guard = () => {
|
|
2716
|
+
if (actor !== USER && actor !== task.owner) {
|
|
2717
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
|
|
2718
|
+
if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
|
|
2719
|
+
}
|
|
2720
|
+
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
|
|
2722
|
+
|| this.isPii(current) !== this.isPii(task) || JSON.stringify(current.sdlc ?? null) !== JSON.stringify(task.sdlc ?? null)
|
|
2723
|
+
|| actor !== USER && this.d.privateTurn?.(actor) && !this.isPii(current)) throw new (preEffect ? PreEffectToolRefusal : Error)("task or authority changed during review admission");
|
|
2724
|
+
return current;
|
|
2725
|
+
};
|
|
2726
|
+
guard();
|
|
2727
|
+
const expected = cleanRefs(refs).commit ?? task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead;
|
|
2728
|
+
const read: SdlcGh = (args, timeout, cwd, cap) => { guard(); return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap); };
|
|
2729
|
+
const evidence: ReviewReadiness = this.isPii(task)
|
|
2730
|
+
? { ready: false, blockers: ["private review readiness unavailable"] }
|
|
2731
|
+
: 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"] };
|
|
2732
|
+
const current = guard();
|
|
2733
|
+
if (!evidence.ready) {
|
|
2734
|
+
const message = `task ${this.publicTitle(current)}: premature review: ${evidence.blockers.join("; ")}`;
|
|
2735
|
+
if (mode === "enforce") throw new (preEffect ? PreEffectToolRefusal : Error)(message);
|
|
2736
|
+
this.releaseNotice(message);
|
|
2737
|
+
}
|
|
2738
|
+
return evidence;
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness): void {
|
|
2742
|
+
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
2743
|
+
if (mode === "enforce" && !admission?.ready) {
|
|
2744
|
+
// Background roster/quota role changes are retained, but never invent a remotely admitted review request.
|
|
2745
|
+
this.releaseNotice(`task ${this.publicTitle(task)}: premature review held; retry the reviewer assignment after current CI and bot replies are verified`);
|
|
2746
|
+
return;
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2265
2749
|
const r = task.refs;
|
|
2266
2750
|
const last = [...task.history].reverse().find((h) => h.event === "done");
|
|
2267
2751
|
const where = [r.branch ? `branch ${r.branch}` : "", r.commit ? `commit ${r.commit}` : "", r.paths?.length ? `paths ${r.paths.join(", ")}` : ""].filter(Boolean).join("; ");
|
|
@@ -2275,18 +2759,41 @@ export class Tasks {
|
|
|
2275
2759
|
`- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
|
|
2276
2760
|
`- List what is unmet in hub_review's unmet, one item each.`,
|
|
2277
2761
|
].join("\n");
|
|
2278
|
-
|
|
2762
|
+
// #223 R2: a reviewer-only instance judges from facts; the builder's narrative stays out, earlier findings go in.
|
|
2763
|
+
const reviewOnly = this.d.reviewOnly?.(reviewer) === true;
|
|
2764
|
+
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)";
|
|
2765
|
+
const specRepo = task.sdlc?.repo ?? task.external?.repo, specIssue = task.sdlc?.issue ?? task.external?.issue;
|
|
2766
|
+
const spec = reviewOnly && specRepo && specIssue ? `Spec issue: ${specRepo}#${specIssue}\n` : "";
|
|
2767
|
+
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)}`) : [];
|
|
2279
2768
|
const guidance = this.roleGuidance(task, reviewer, true);
|
|
2280
2769
|
let projection = "";
|
|
2281
2770
|
try { const text = this.d.projectedReview?.(task); if (text) projection = this.screen(task, sanitize(text), "document projection", reviewer); }
|
|
2282
2771
|
catch { projection = "(document projection unavailable)"; }
|
|
2283
|
-
const
|
|
2772
|
+
const brief = this.briefText(task);
|
|
2773
|
+
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}.`;
|
|
2284
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 } : {}) }));
|
|
2285
2775
|
}
|
|
2286
2776
|
|
|
2287
|
-
|
|
2777
|
+
/** #520: the rendered review brief of a non-PII task's commit, or why there is none; empty without a commit. */
|
|
2778
|
+
briefText(task: Task): string {
|
|
2779
|
+
if (this.isPii(task) || !task.refs.commit) return "";
|
|
2780
|
+
try {
|
|
2781
|
+
const brief = this.d.reviewBrief?.(task);
|
|
2782
|
+
if (!brief) return "";
|
|
2783
|
+
return "unavailable" in brief ? `Review brief unavailable: ${brief.unavailable}.` : sanitize(renderReviewBrief(brief));
|
|
2784
|
+
} catch { return "Review brief unavailable: it could not be built."; }
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown): Promise<Task> {
|
|
2788
|
+
const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
|
|
2789
|
+
// #520: what the coverage was checked against, pinned through every await: the commit and the lifecycle receipt (the
|
|
2790
|
+
// inherited reviewedHead lives there).
|
|
2791
|
+
let covered: { commit: string | undefined; receipt: string } | undefined;
|
|
2288
2792
|
const valid = (): Task => {
|
|
2793
|
+
const refused = authority?.();
|
|
2794
|
+
if (refused) throw new PreEffectToolRefusal(refused); // rechecked after every await, right before each write
|
|
2289
2795
|
const t = this.need(id, false, true);
|
|
2796
|
+
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`);
|
|
2290
2797
|
this.mine(t, by, "reviewer", true);
|
|
2291
2798
|
if (verdict !== "approved" && verdict !== "changes_requested") throw new PreEffectToolRefusal('verdict must be "approved" or "changes_requested"');
|
|
2292
2799
|
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`);
|
|
@@ -2294,6 +2801,34 @@ export class Tasks {
|
|
|
2294
2801
|
};
|
|
2295
2802
|
const items = textList(unmet);
|
|
2296
2803
|
if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
|
|
2804
|
+
// #520: an approval accounts for every reviewable path of the brief. enforce refuses before any effect, advise records
|
|
2805
|
+
// the gap in the verdict note, off and a person's own verdict skip it.
|
|
2806
|
+
const mode = this.d.sdlc?.config().mode ?? "off";
|
|
2807
|
+
// A Tasks without a brief provider (library use; the daemon always has one) has no coverage gate.
|
|
2808
|
+
if (verdict === "approved" && by !== USER && mode !== "off" && this.d.reviewBrief) {
|
|
2809
|
+
const t = valid(), enforce = mode === "enforce", list = (paths: string[]) => `${paths.slice(0, 20).join(", ")}${paths.length > 20 ? ", ..." : ""}`;
|
|
2810
|
+
// Code work (a commit, branch or PR) needs a brief; work with none of them is exempt and has nothing to account for.
|
|
2811
|
+
const code = !this.isPii(t) && !!(t.refs.commit || t.refs.branch || t.sdlc?.pr);
|
|
2812
|
+
// The commit under review: the task's, else the head the verdict names, else the lifecycle receipt's.
|
|
2813
|
+
const commit = t.refs.commit ?? (typeof head === "string" ? head : undefined) ?? t.sdlc?.reviewedHead ?? t.sdlc?.localHead;
|
|
2814
|
+
const brief: ReviewBrief | { unavailable: string } | undefined = !code ? undefined
|
|
2815
|
+
: !commit ? { unavailable: "the task links a branch or PR but names no commit" }
|
|
2816
|
+
: this.d.reviewBrief({ ...t, refs: { ...t.refs, commit } }) ?? { unavailable: "the review brief could not be built" };
|
|
2817
|
+
if (brief && "unavailable" in brief) {
|
|
2818
|
+
// An unknown inventory is never an empty one.
|
|
2819
|
+
if (enforce) throw new PreEffectToolRefusal(`coverage: the review brief is unavailable (${brief.unavailable}); an approval needs a complete brief of the task's commit`);
|
|
2820
|
+
note = `${note ?? ""}\nCoverage unknown: the review brief is unavailable (${brief.unavailable})`.trim();
|
|
2821
|
+
} else if (brief) {
|
|
2822
|
+
// One head: the brief's, the task's commit, and the head the verdict names or inherits from an earlier review (with a
|
|
2823
|
+
// PR, the lifecycle gate binds that head to the PR's).
|
|
2824
|
+
const effective = typeof head === "string" ? head : t.sdlc?.reviewedHead;
|
|
2825
|
+
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)`);
|
|
2826
|
+
covered = { commit: t.refs.commit, receipt: JSON.stringify(t.sdlc ?? null) };
|
|
2827
|
+
const gap = coverageGap(brief, coverage);
|
|
2828
|
+
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}]}.`);
|
|
2829
|
+
if (gap.length) note = `${note ?? ""}\nCoverage gap (${gap.length}): ${list(gap)}`.trim();
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2297
2832
|
// The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
|
|
2298
2833
|
const withheld = await this.withholds("review note", note, valid());
|
|
2299
2834
|
let task = valid();
|
|
@@ -2315,7 +2850,8 @@ export class Tasks {
|
|
|
2315
2850
|
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.
|
|
2316
2851
|
}
|
|
2317
2852
|
const pii = this.isPii(task);
|
|
2318
|
-
const
|
|
2853
|
+
const reviewContext = by === USER ? undefined : this.d.reviewContext?.(by, task.id);
|
|
2854
|
+
const flag = { withheld: withheld || undefined, ...(reviewContext ? { reviewContext } : {}) };
|
|
2319
2855
|
if (verdict === "approved") {
|
|
2320
2856
|
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);
|
|
2321
2857
|
if (next.owner) {
|
|
@@ -2445,11 +2981,23 @@ export class Tasks {
|
|
|
2445
2981
|
this.ready(task, preEffect);
|
|
2446
2982
|
const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
|
|
2447
2983
|
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("; ")}`);
|
|
2448
|
-
const
|
|
2984
|
+
const admission = task.state === "in_review" ? await this.reviewRequestAdmission(task, undefined, preEffect, by) : undefined;
|
|
2985
|
+
// Every runtime authority check precedes the final write, including revocations during the metadata await.
|
|
2986
|
+
const current = this.need(task.id, false, preEffect);
|
|
2987
|
+
if (current.history.length !== task.history.length) throw new (preEffect ? PreEffectToolRefusal : Error)("task changed during reviewer admission");
|
|
2988
|
+
if (by !== USER) {
|
|
2989
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
2990
|
+
if (holders.length !== 1 || holders[0] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the configured conductor may name a reviewer");
|
|
2991
|
+
}
|
|
2992
|
+
if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster`);
|
|
2993
|
+
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) });
|
|
2995
|
+
if (eligible.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)("reviewer is no longer eligible");
|
|
2996
|
+
const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, this.assignmentNote(`named reviewer ${peer}`, a), { reason: "manual" }, expectedFlowRevision);
|
|
2449
2997
|
if (next.state === "in_review" && task.reviewer && task.reviewer !== peer) {
|
|
2450
2998
|
this.withdrawRoleRequest(task, next, "reviewer");
|
|
2451
2999
|
}
|
|
2452
|
-
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));
|
|
3000
|
+
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));
|
|
2453
3001
|
return next;
|
|
2454
3002
|
}
|
|
2455
3003
|
// Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
|
|
@@ -2497,7 +3045,7 @@ export class Tasks {
|
|
|
2497
3045
|
const owner = task.owner === peer;
|
|
2498
3046
|
const weights = this.weights(task.class);
|
|
2499
3047
|
const pii = this.isPii(task);
|
|
2500
|
-
const candidates = owner ? relayCandidates(routing.classes[task.class]?.peers ?? [], pii) : [];
|
|
3048
|
+
const candidates = owner ? relayCandidates(routing.classes[task.class]?.peers ?? [], pii, { ...this.instanceRouting(), states: this.states() }) : [];
|
|
2501
3049
|
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 } : {}) });
|
|
2502
3050
|
return { id: task.id, title: this.publicTitle(task), role: owner ? "owner" : "reviewer", to: (owner ? a.owner : a.reviewer) ?? null };
|
|
2503
3051
|
});
|
|
@@ -2518,7 +3066,7 @@ export class Tasks {
|
|
|
2518
3066
|
if (urgentOnly && !task.signals.includes("urgent")) continue;
|
|
2519
3067
|
if (task.owner === peer && (OPEN.includes(task.state) || roster && task.state === "in_review")) {
|
|
2520
3068
|
const pii = this.isPii(task);
|
|
2521
|
-
const candidates = relayCandidates(routing.classes[task.class]?.peers ?? [], pii);
|
|
3069
|
+
const candidates = relayCandidates(routing.classes[task.class]?.peers ?? [], pii, { ...this.instanceRouting(), states: this.states() });
|
|
2522
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;
|
|
2523
3071
|
// Budget keeps its original release/unassign behaviour for ordinary work. Roster changes the state in
|
|
2524
3072
|
// assignOwner's same owner write only after routing has found somebody to take it.
|
|
@@ -2546,7 +3094,7 @@ export class Tasks {
|
|
|
2546
3094
|
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}`);
|
|
2547
3095
|
continue;
|
|
2548
3096
|
}
|
|
2549
|
-
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, why);
|
|
3097
|
+
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a));
|
|
2550
3098
|
moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
|
|
2551
3099
|
if (next.state === "in_review" && reviewer && reviewer !== USER) {
|
|
2552
3100
|
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.)`));
|