@staix/agent-hub 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +4 -4
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +10 -6
- package/docs/agent-notes/benchmarks.md +1 -0
- package/docs/agent-notes/daemon.md +6 -2
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +11 -2
- package/docs/agent-notes/tests.md +7 -3
- package/docs/dogfood.md +22 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +162 -35
- package/docs/profiles.md +36 -0
- package/docs/quickstart.md +15 -5
- package/docs/sdlc.md +29 -3
- package/docs/security.md +44 -13
- package/docs/smoke.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +31 -10
- package/docs/verification/2026-10-25-0.18.0.md +76 -0
- package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
- package/docs/verification/2026-10-25-remote-docker-transport.md +56 -0
- package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
- package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
- package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
- package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
- package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
- package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
- package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
- package/docs/verification/2026-10-27-0.19.0.md +86 -0
- package/docs/verification/claude-permission-transport-native.py +187 -0
- package/docs/verification/claude-shell-wrapper-host.ts +20 -0
- package/docs/verification/claude-shell-wrapper-native.py +145 -0
- package/docs/verification/pi-140-model-free.ts +57 -0
- package/docs/verification/pi-140-native.mjs +21 -0
- package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
- package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
- package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
- package/docs/verified.json +24 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +364 -98
- package/src/adapters/agy/entry.ts +1 -1
- package/src/adapters/claude-channel.ts +7 -3
- package/src/adapters/claude-implementer.ts +5 -3
- package/src/adapters/compat.ts +32 -18
- package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
- package/src/adapters/definitions/builtin-metadata.ts +3 -3
- package/src/adapters/definitions/native-factories.ts +7 -2
- package/src/adapters/definitions/reviewer-variants.ts +2 -2
- package/src/adapters/local-worker.ts +5 -3
- package/src/adapters/pi.ts +39 -3
- package/src/adapters/qwen-dual.ts +25 -0
- package/src/adapters/registry-types.ts +6 -2
- package/src/adapters/registry.ts +1 -0
- package/src/cli/agents.ts +69 -7
- package/src/cli/claude-shell-wrapper.ts +54 -0
- package/src/cli/console-state.ts +9 -2
- package/src/cli/help.ts +29 -28
- package/src/cli/identity.ts +87 -6
- package/src/cli/main.ts +198 -71
- package/src/cli/output.ts +11 -2
- package/src/cli/sdlc-output.ts +242 -0
- package/src/cli/task-output.ts +212 -0
- package/src/cli/upgrade-output.ts +57 -0
- package/src/hub/agent-drift.ts +61 -0
- package/src/hub/board.ts +31 -2
- package/src/hub/check-runner.ts +385 -25
- package/src/hub/claude-shell-wrapper.ts +349 -0
- package/src/hub/conductor.ts +5 -0
- package/src/hub/config-apply.ts +16 -7
- package/src/hub/config-edit-host.ts +1 -1
- package/src/hub/config-edit.ts +2 -2
- package/src/hub/config-tier.ts +3 -3
- package/src/hub/daemon.ts +530 -87
- package/src/hub/dogfood-profile.ts +248 -0
- package/src/hub/dogfood-read.ts +77 -14
- package/src/hub/dogfood.ts +5 -1
- package/src/hub/events.ts +1 -1
- package/src/hub/host-executable.ts +21 -1
- package/src/hub/hub-tools.ts +2 -2
- package/src/hub/instances.ts +18 -2
- package/src/hub/lifecycle.ts +1 -1
- package/src/hub/operational-authority.ts +67 -0
- package/src/hub/operational-view.ts +137 -0
- package/src/hub/orphan-process.ts +69 -0
- package/src/hub/permission-mode.ts +1 -1
- package/src/hub/read-only-shell-proof.ts +81 -0
- package/src/hub/read-only-shell.ts +162 -0
- package/src/hub/review-scope.ts +13 -0
- package/src/hub/reviewer-pool-config.ts +3 -2
- package/src/hub/reviewer-pool.ts +3 -0
- package/src/hub/routing.ts +23 -6
- package/src/hub/sdlc-merge.ts +299 -0
- package/src/hub/sdlc-metrics.ts +6 -4
- package/src/hub/sdlc-mirror.ts +129 -0
- package/src/hub/sdlc.ts +58 -12
- package/src/hub/settings.ts +11 -5
- package/src/hub/snapshots.ts +60 -0
- package/src/hub/task-repository.ts +31 -5
- package/src/hub/task-shell-command.ts +85 -0
- package/src/hub/task-shell-default.ts +78 -0
- package/src/hub/task-shell-proof.ts +171 -0
- package/src/hub/task-shell-sandbox.ts +61 -0
- package/src/hub/tasks.ts +378 -27
- package/src/local/deny.ts +91 -11
- package/src/local/sandbox.ts +67 -28
- package/src/local/shell-environment.ts +10 -0
- package/src/local/tools.ts +17 -4
- package/src/ui/index.html +3 -1
package/src/hub/tasks.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { canonicalMemorySymbolNode, normalizeMemorySymbolInput, MEMORY_SYMBOL_LIMIT, type MemorySymbolNodes } from "./memory-symbols.ts";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { taskShellBindingValid, type TaskShellBinding } from "./task-shell-command.ts";
|
|
2
4
|
import { buildTaskCodeSymbols, publicTaskCodeSymbols, taskCodeSymbolsDisjoint, HOST_CODE_SYMBOLS } from "./task-code-symbols.ts";
|
|
3
5
|
import { normalizePlanSymbol, symbolsOverlap } from "./symbol-normalizer.ts";
|
|
4
6
|
import { publicTestIntegrity, readTestIntegrity, testIntegrityBlockers, type TestIntegrity } from "./test-integrity.ts";
|
|
@@ -13,7 +15,10 @@ import { VERSION } from "../version.ts";
|
|
|
13
15
|
import { NoWriteTurns } from "./no-writes.ts";
|
|
14
16
|
import { readGitHubImport, type GitHubSource, type GitHubImportSelection } from "./github-import.ts";
|
|
15
17
|
import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
|
|
16
|
-
import { readMergeQueue } from "./sdlc-queue.ts";
|
|
18
|
+
import { readMergeQueue, type DeliveryTask } from "./sdlc-queue.ts";
|
|
19
|
+
import { guardedMerge, mergeModeFor, mergeView, type MergeView } from "./sdlc-merge.ts";
|
|
20
|
+
import { mirrorVerdict as mirrorToGitHub, type MirrorVerdict } from "./sdlc-mirror.ts";
|
|
21
|
+
import { CODE_SCOPE_LINE, evidenceOnlyNotice, type ReviewScope } from "./review-scope.ts";
|
|
17
22
|
import { DEFAULT_MAX_NITS, parseReviewFindings, findingsForHead, findingHead as resolveFindingHead, findingTally, type ReviewFinding, type ReviewFindingSet } from "./review-findings.ts";
|
|
18
23
|
import { readReleaseReadiness } from "./sdlc-release.ts";
|
|
19
24
|
import { loadRoleCatalog, taskRole, taskRoleText, type RoleCatalog } from "./roles.ts";
|
|
@@ -44,7 +49,7 @@ import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief
|
|
|
44
49
|
import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
|
|
45
50
|
import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
46
51
|
import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
47
|
-
import { planningIssueReceipt, linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
|
|
52
|
+
import { planningIssueReceipt, linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type MergeRecord, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
|
|
48
53
|
|
|
49
54
|
export type TaskAssignmentRole = "owner" | "reviewer" | "planner";
|
|
50
55
|
|
|
@@ -102,6 +107,8 @@ export interface TasksDeps {
|
|
|
102
107
|
privateTurn?: (peer: PeerId) => boolean;
|
|
103
108
|
/** #223 R4: the hub's reading of a verdict's context (`fresh` only for a reviewer session started for this review). */
|
|
104
109
|
reviewContext?: (by: PeerId, task: number) => "fresh" | "continuing" | undefined;
|
|
110
|
+
/** #852: what a reviewer reads. `code` (the fixed sandboxed reviewers by default, or a local `review_scope`) reads the code only; unset or `full` also runs tests and reads CI. */
|
|
111
|
+
reviewScope?: (peer: PeerId) => ReviewScope;
|
|
105
112
|
/** #223 R2: a reviewer-only instance gets review facts, not the builder's done summary. */
|
|
106
113
|
reviewOnly?: (peer: PeerId) => boolean;
|
|
107
114
|
/** #223: captured when a verdict call starts; a non-undefined result refuses the verdict at every later check. */
|
|
@@ -130,7 +137,7 @@ export interface TasksDeps {
|
|
|
130
137
|
/** Optional persisted task/run execution limits (issue #102). */
|
|
131
138
|
executionBudget?: ExecutionBudget;
|
|
132
139
|
/** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
|
|
133
|
-
review?: { adaptive: boolean; min_reviews: number; pool?: readonly string[]; rerotate?: boolean };
|
|
140
|
+
review?: { adaptive: boolean; min_reviews: number; pool?: readonly string[]; rerotate?: boolean; same_family?: "fallback" | "never" };
|
|
134
141
|
/** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
|
|
135
142
|
capabilities?: () => Record<PeerId, PeerCapabilities>;
|
|
136
143
|
repository?: string;
|
|
@@ -696,6 +703,8 @@ export class Tasks {
|
|
|
696
703
|
...(h.selfReview ? { selfReview: true } : {}),
|
|
697
704
|
...(h.reviewerFamily ? { reviewerFamily: screenHistoryText(h.reviewerFamily, "reviewer family") } : {}),
|
|
698
705
|
...(h.reviewerReason ? { reviewerReason: h.reviewerReason } : {}),
|
|
706
|
+
...(h.reviewScope === "code" ? { reviewScope: "code" as const } : {}),
|
|
707
|
+
...(h.evidenceOnly ? { evidenceOnly: true as const } : {}),
|
|
699
708
|
...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
|
|
700
709
|
...(h.deliverables?.length ? { deliverables: publicDeliverables(h.deliverables, this.nameable) } : {}),
|
|
701
710
|
...(h.reviewBlocked ? { reviewBlocked: true } : {}),
|
|
@@ -773,7 +782,7 @@ export class Tasks {
|
|
|
773
782
|
if (h.owner === undefined || h.owner !== owner) since = i;
|
|
774
783
|
owner = h.owner;
|
|
775
784
|
});
|
|
776
|
-
return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested").map((h) => h.by));
|
|
785
|
+
return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested" && h.evidenceOnly !== true).map((h) => h.by));
|
|
777
786
|
}
|
|
778
787
|
|
|
779
788
|
/** Workload is board/queue data only: wip() never calls health() or weights(). */
|
|
@@ -790,7 +799,7 @@ export class Tasks {
|
|
|
790
799
|
const held = this.d.held?.();
|
|
791
800
|
const workload = this.instanceRouting();
|
|
792
801
|
const peers = workload.instances ? [...new Set([...(this.d.adapterCatalog?.definitions ?? ADAPTERS).map(adapter => adapter.kind), ...Object.keys(workload.instances), ...this.d.bus.peers.keys()])] : (this.d.adapterCatalog?.definitions ?? ADAPTERS).map(adapter => adapter.kind);
|
|
793
|
-
return { ...workload, reviewPool: { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, required: this.d.review?.pool !== undefined }, ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => (this.d.adapterCatalog?.definitionOf ?? 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 } : {}) };
|
|
802
|
+
return { ...workload, reviewPool: { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, required: this.d.review?.pool !== undefined, ...(this.d.review?.same_family === "never" ? { sameFamily: "never" as const } : {}) }, ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => (this.d.adapterCatalog?.definitionOf ?? 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 } : {}) };
|
|
794
803
|
}
|
|
795
804
|
|
|
796
805
|
private reviewHead(task: Task): string | undefined { return task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; }
|
|
@@ -799,7 +808,7 @@ export class Tasks {
|
|
|
799
808
|
const head = this.reviewHead(task);
|
|
800
809
|
const round = task.state === "in_review" ? task.history.slice(task.history.findLastIndex(entry => entry.event === "done") + 1) : [];
|
|
801
810
|
const failed = [...new Set(round.flatMap(entry => entry.reviewerFailure?.head === head && entry.reviewerFailure ? [entry.reviewerFailure.peer] : []))];
|
|
802
|
-
return { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, failed, required: this.d.review?.pool !== undefined };
|
|
811
|
+
return { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, failed, required: this.d.review?.pool !== undefined, ...(this.d.review?.same_family === "never" ? { sameFamily: "never" as const } : {}) };
|
|
803
812
|
}
|
|
804
813
|
|
|
805
814
|
private manuallyNamedReviewer(task: Task): boolean {
|
|
@@ -870,7 +879,7 @@ export class Tasks {
|
|
|
870
879
|
if (!OWNERSHIP_EVENTS.has(given.event) || given.event === "unassigned" || given.owner !== peer || given.profile !== profile || given.by === peer) return [];
|
|
871
880
|
const next = t.history.findIndex((h, j) => j > i && OWNERSHIP_EVENTS.has(h.event));
|
|
872
881
|
const span = t.history.slice(i + 1, next < 0 ? undefined : next + 1);
|
|
873
|
-
if (span.some((h) => failed.includes(h.event))) return [{ outcome: "failed" }];
|
|
882
|
+
if (span.some((h) => failed.includes(h.event) && h.evidenceOnly !== true)) return [{ outcome: "failed" }];
|
|
874
883
|
if (next >= 0 || t.state !== "approved") return []; // handed on without a failure (a relay), or still open
|
|
875
884
|
const accepted = span.find((h) => h.event === "accepted" && h.by === peer);
|
|
876
885
|
// The work stage ends at its first done call: checks and an integration step are not the work itself.
|
|
@@ -948,10 +957,31 @@ export class Tasks {
|
|
|
948
957
|
return task.planner && holders.includes(task.planner) && this.d.bus.isRostered(task.planner) ? [task.planner] : holders;
|
|
949
958
|
}
|
|
950
959
|
|
|
960
|
+
/** Current planner rulings amend review context, never person-only plan-departure acceptance. */
|
|
961
|
+
plannerRulings(task: Task, recipient: PeerId): { historyIndex: number; by: PeerId; at: number; note: string }[] {
|
|
962
|
+
if (this.isPii(task)) return [];
|
|
963
|
+
const boundary = task.history.findLastIndex(entry => entry.event === "accepted" || entry.event === "claimed");
|
|
964
|
+
if (boundary < 0) return [];
|
|
965
|
+
const planners = new Set(this.taskPlanner(task));
|
|
966
|
+
const rulings: { historyIndex: number; by: PeerId; at: number; note: string }[] = [];
|
|
967
|
+
for (let historyIndex = boundary + 1; historyIndex < task.history.length && rulings.length < 5; historyIndex++) {
|
|
968
|
+
const entry = task.history[historyIndex]!;
|
|
969
|
+
if (entry.event !== "plan reviewed" || !planners.has(entry.by) || !this.d.roles?.[entry.by]?.includes("planner")
|
|
970
|
+
|| typeof entry.note !== "string" || !entry.note.trim() || !Number.isFinite(entry.at)) continue;
|
|
971
|
+
// Screen the entire original note before the display bound, including a sensitive suffix past 1000 characters.
|
|
972
|
+
const note = sanitize(this.screen(task, entry.note, "planner ruling", recipient, entry.withheld)).slice(0, 1000);
|
|
973
|
+
rulings.push({ historyIndex, by: entry.by, at: entry.at, note });
|
|
974
|
+
}
|
|
975
|
+
return rulings;
|
|
976
|
+
}
|
|
977
|
+
|
|
951
978
|
private planningAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
|
|
952
979
|
if (by !== USER && (!this.d.roles?.[by]?.includes("planner") || task !== undefined && !this.taskPlanner(task).includes(by))) throw new PreEffectToolRefusal(`planning requires a person or configured planner role${task ? "; only the task's effective planner may act" : ""}`);
|
|
953
980
|
}
|
|
954
981
|
|
|
982
|
+
/** #812 AC5: a peer holding the configured planner role. The conductor's task reads accept it; it grants no write. */
|
|
983
|
+
isConfiguredPlanner(peer: PeerId): boolean { return peer !== USER && peer !== HUB && !!this.d.roles?.[peer]?.includes("planner"); }
|
|
984
|
+
|
|
955
985
|
private plannerWriteAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
|
|
956
986
|
if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("planner writes are unavailable during a private turn");
|
|
957
987
|
this.planningAuthority(by, task);
|
|
@@ -1015,6 +1045,93 @@ export class Tasks {
|
|
|
1015
1045
|
wip(peer: PeerId, except?: number): number {
|
|
1016
1046
|
return this.d.board.plannerWip(peer, except);
|
|
1017
1047
|
}
|
|
1048
|
+
|
|
1049
|
+
/** Host-observed task branch/root binding (#845), never a tool-call branch claim or an execution grant. */
|
|
1050
|
+
bindShellBranch(peer: PeerId, id: number, binding: TaskShellBinding & { worktree: string }, current: () => boolean): boolean {
|
|
1051
|
+
try {
|
|
1052
|
+
if (!Number.isSafeInteger(id) || id < 1 || !taskShellBindingValid(binding) || !this.publicMetadataText(binding.branch)
|
|
1053
|
+
|| !current() || this.d.privateTurn?.(peer)) return false;
|
|
1054
|
+
const task = this.d.board.get(id), root = this.d.executionRoot?.(peer);
|
|
1055
|
+
if (!task || task.owner !== peer || !["in_progress", "changes_requested"].includes(task.state) || this.isPii(task)
|
|
1056
|
+
|| !root || realPath(root) === realPath(this.d.cwd) || realPath(root) !== realPath(binding.worktree)) return false;
|
|
1057
|
+
const worktree = createHash("sha256").update(realPath(root)).digest("hex");
|
|
1058
|
+
const previous = task.history.findLast(entry => entry.by === HUB && entry.event === "shell branch bound");
|
|
1059
|
+
if (previous) {
|
|
1060
|
+
const saved: unknown = JSON.parse(previous.note ?? "");
|
|
1061
|
+
if (!saved || typeof saved !== "object" || Array.isArray(saved)) return false;
|
|
1062
|
+
const record = saved as Record<string, unknown>;
|
|
1063
|
+
if (record.branch !== binding.branch || record.worktree !== worktree) return false;
|
|
1064
|
+
}
|
|
1065
|
+
if (task.refs.branch !== undefined && task.refs.branch !== binding.branch) return false;
|
|
1066
|
+
const scope = this.repositories.scope(task.refs);
|
|
1067
|
+
const others = this.d.board.list().filter(row => row.id !== id && !isClosedTaskState(row.state))
|
|
1068
|
+
.map(row => ({ row, scope: this.repositories.scope(row.refs) }));
|
|
1069
|
+
if (scope.key !== this.repositories.project.key || others.some(other => other.scope.key === scope.key && other.row.refs.branch === binding.branch)) return false;
|
|
1070
|
+
if (!current() || this.d.privateTurn?.(peer) || this.d.board.get(id)?.history.length !== task.history.length) return false;
|
|
1071
|
+
if (previous) return true;
|
|
1072
|
+
let recorded = false;
|
|
1073
|
+
this.releaseNotice(`task #${id}: execution branch binding unavailable`, () => {
|
|
1074
|
+
this.d.board.update(id, HUB, "shell branch bound", { refs: { branch: binding.branch } }, JSON.stringify({ branch: binding.branch, worktree }), {}, task.history.length);
|
|
1075
|
+
recorded = true;
|
|
1076
|
+
});
|
|
1077
|
+
return recorded;
|
|
1078
|
+
} catch { return false; }
|
|
1079
|
+
}
|
|
1080
|
+
private capacityBefore(task: Task): Map<PeerId, number> {
|
|
1081
|
+
return new Map([...new Set([task.owner, task.reserved])].filter((peer): peer is PeerId => !!peer).map(peer => [peer, this.wip(peer)]));
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/** Write-driven wake-ups (#785): one unsettled ids-only task notice per planner, never a polling pull. */
|
|
1085
|
+
private writePlannerNotices(task: Task, before: ReadonlyMap<PeerId, number>, approved = false): void {
|
|
1086
|
+
this.releaseNotice(`task #${task.id}: could not notify planner about ready work`, () => {
|
|
1087
|
+
const changes = new Map<PeerId, { capacity: Set<PeerId>; ready: Set<number> }>();
|
|
1088
|
+
const add = (planner: PeerId, peer?: PeerId, id?: number) => {
|
|
1089
|
+
if (!this.d.bus.isRostered(planner)) return;
|
|
1090
|
+
const change = changes.get(planner) ?? { capacity: new Set<PeerId>(), ready: new Set<number>() };
|
|
1091
|
+
if (peer) change.capacity.add(peer);
|
|
1092
|
+
if (id !== undefined) change.ready.add(id);
|
|
1093
|
+
changes.set(planner, change);
|
|
1094
|
+
};
|
|
1095
|
+
for (const [peer, used] of before) if (this.wip(peer) < used) for (const planner of this.taskPlanner(task)) add(planner, peer);
|
|
1096
|
+
if (approved) for (const card of this.d.board.list("backlog")) {
|
|
1097
|
+
if (card.deps?.includes(task.id) && !this.waitsFor(card).length) for (const planner of this.taskPlanner(card)) add(planner, undefined, card.id);
|
|
1098
|
+
}
|
|
1099
|
+
for (const [planner, change] of changes) this.releaseNotice(`could not notify planner ${planner} about ready work`, () => {
|
|
1100
|
+
const pending = this.d.bus.pendingEnvelopes(planner).filter(env => env.from === HUB && env.kind === "task" && env.body.startsWith("Planner work ready:"));
|
|
1101
|
+
// The bus retains these hub-written lines across restart; an in-memory cache would lose unsettled events.
|
|
1102
|
+
for (const env of pending) for (const line of env.body.split("\n")) {
|
|
1103
|
+
const capacity = /^capacity_released: (\S+) WIP \d+; reserved ready (?:#\d+|none)$/.exec(line);
|
|
1104
|
+
if (capacity) change.capacity.add(capacity[1]!);
|
|
1105
|
+
const ready = /^backlog_ready: #(\d+)$/.exec(line);
|
|
1106
|
+
if (ready && Number.isSafeInteger(Number(ready[1]))) change.ready.add(Number(ready[1]));
|
|
1107
|
+
}
|
|
1108
|
+
const lines: string[] = [];
|
|
1109
|
+
for (const peer of change.capacity) {
|
|
1110
|
+
const top = this.d.board.list().filter(card => ["backlog", "proposed"].includes(card.state) && !card.owner && card.reserved === peer
|
|
1111
|
+
&& this.taskPlanner(card).includes(planner) && !card.pinned && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(peer)
|
|
1112
|
+
&& (card.release == null || ["planning", "active"].includes(this.d.board.release(card.release)?.state ?? ""))).sort(this.readyOrder)[0];
|
|
1113
|
+
lines.push(`capacity_released: ${peer} WIP ${this.wip(peer)}; reserved ready ${top ? `#${top.id}` : "none"}`);
|
|
1114
|
+
}
|
|
1115
|
+
for (const id of change.ready) {
|
|
1116
|
+
const card = this.d.board.get(id);
|
|
1117
|
+
if (card?.state === "backlog" && this.taskPlanner(card).includes(planner) && !this.waitsFor(card).length) lines.push(`backlog_ready: #${id}`);
|
|
1118
|
+
else change.ready.delete(id);
|
|
1119
|
+
}
|
|
1120
|
+
for (const env of pending) this.d.bus.withdraw(env.id);
|
|
1121
|
+
const env = newEnvelope(HUB, sanitize(`Planner work ready:\n${lines.join("\n")}\nRead the current board before planning or pulling work.`), { to: [planner], kind: "task" });
|
|
1122
|
+
if (!this.d.bus.publish(env).includes(planner)) throw new Error("the notice was not queued");
|
|
1123
|
+
});
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/** #784 AC3: the read-only breakdown of `wip` for status, the console and the dashboard: one grouped query for every peer. Admission still reads `wip()`. */
|
|
1128
|
+
wipBreakdown(peers: readonly PeerId[]): Record<string, { wip: number; active: number; reviewWait: number; reserved: number; wipLimit: number }> {
|
|
1129
|
+
const grouped = this.d.board.plannerWipBreakdown();
|
|
1130
|
+
return Object.fromEntries(peers.map(peer => {
|
|
1131
|
+
const row = grouped[peer] ?? { active: 0, reviewWait: 0, reserved: 0 };
|
|
1132
|
+
return [peer, { wip: row.active + row.reviewWait + row.reserved, ...row, wipLimit: this.wipLimit(peer) }];
|
|
1133
|
+
}));
|
|
1134
|
+
}
|
|
1018
1135
|
private prerequisiteOf(task: Task, prerequisite: number): boolean {
|
|
1019
1136
|
const pending = [...(task.deps ?? [])], seen = new Set<number>();
|
|
1020
1137
|
while (pending.length) { const id = pending.pop()!; if (id === prerequisite) return true; if (seen.has(id)) continue; seen.add(id); pending.push(...(this.d.board.get(id)?.deps ?? [])); }
|
|
@@ -1349,7 +1466,9 @@ export class Tasks {
|
|
|
1349
1466
|
if (entry.planning.pull) {
|
|
1350
1467
|
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
1351
1468
|
if (task.state !== "proposed" || task.owner || taskHasStarted(task) || by !== USER && (task.pinned || release && ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull undo is no longer eligible");
|
|
1469
|
+
const capacity = this.capacityBefore(task);
|
|
1352
1470
|
const returned = this.d.board.undoPlannedPull(id, by, revision);
|
|
1471
|
+
this.writePlannerNotices(returned, capacity);
|
|
1353
1472
|
this.offered.delete(id); this.incomingBacklog.add(id);
|
|
1354
1473
|
this.planningRecorded({ by, task: id, fields: ["pull", "reserved"], revision: returned.planningRevision! });
|
|
1355
1474
|
return returned;
|
|
@@ -1729,7 +1848,9 @@ export class Tasks {
|
|
|
1729
1848
|
this.plannerWriteAuthority(by);
|
|
1730
1849
|
const confirmed = validate();
|
|
1731
1850
|
if (confirmed.history.length !== flow) throw new PreEffectToolRefusal("task changed during untouched return");
|
|
1851
|
+
const capacity = this.capacityBefore(confirmed);
|
|
1732
1852
|
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);
|
|
1853
|
+
this.writePlannerNotices(returned, capacity);
|
|
1733
1854
|
this.offered.delete(confirmed.id);
|
|
1734
1855
|
this.cohorts.leave(confirmed.id);
|
|
1735
1856
|
this.withdrawRoleRequest(confirmed, returned, "owner");
|
|
@@ -1752,7 +1873,9 @@ export class Tasks {
|
|
|
1752
1873
|
if (!pull && taskHasStarted(task)) throw new PreEffectToolRefusal(`task #${n}: started work cannot return to backlog`);
|
|
1753
1874
|
const pulled = task.history.findLast(entry => entry.event === "backlog pulled" || entry.event === "backlog returned");
|
|
1754
1875
|
const restoreReservation = !pull && pulled?.planning?.pull && !task.history.slice(task.history.lastIndexOf(pulled) + 1).some(entry => entry.event === "reserved" || entry.planning?.fields.includes("reserved"));
|
|
1876
|
+
const capacity = this.capacityBefore(task);
|
|
1755
1877
|
const next = this.d.board.update(n, by, pull ? "backlog pulled" : "backlog returned", { state: pull ? "proposed" : "backlog", owner: null, reviewer: null, ...(restoreReservation ? { reserved: pulled.planning!.previous.reserved ?? null } : {}) });
|
|
1878
|
+
this.writePlannerNotices(next, capacity);
|
|
1756
1879
|
this.offered.delete(n);
|
|
1757
1880
|
if (!pull) { this.incomingBacklog.add(n); return next; }
|
|
1758
1881
|
const waits = this.waitsFor(next);
|
|
@@ -2202,12 +2325,14 @@ export class Tasks {
|
|
|
2202
2325
|
}
|
|
2203
2326
|
|
|
2204
2327
|
/** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
|
|
2205
|
-
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; refuseNamed?: "pre" | "plain"; plannerPull?: { revision: number; note: string; withheld: boolean; guard: () => void } } = {}): Promise<Task> {
|
|
2328
|
+
private async assignOwner(task: Task, by: PeerId, opts: { current?: () => void; candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number; refuseNamed?: "pre" | "plain"; plannerPull?: { revision: number; note: string; withheld: boolean; guard: () => void } } = {}): Promise<Task> {
|
|
2329
|
+
opts.current?.();
|
|
2206
2330
|
if (task.state === "backlog" && !opts.plannerPull) throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
|
|
2207
2331
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
2208
2332
|
let stagedClaimGap: string | undefined;
|
|
2209
2333
|
if (mode !== "off" && this.isBuildTask(task)) {
|
|
2210
2334
|
const gap = task.sdlc?.planReview ? await this.planReviewGap(task) : "plan not reviewed since proposal";
|
|
2335
|
+
opts.current?.();
|
|
2211
2336
|
const current = this.d.board.get(task.id);
|
|
2212
2337
|
if (!current || !this.ownerAssignmentCurrent(task, current)) {
|
|
2213
2338
|
if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull changed during admission");
|
|
@@ -2263,6 +2388,7 @@ export class Tasks {
|
|
|
2263
2388
|
const assignmentNote = this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a);
|
|
2264
2389
|
const binding = this.plannerBinding(task, a, !!opts.plannerPull);
|
|
2265
2390
|
const assignmentExtra = { ...this.reviewMetadata(a), ...(binding ? { plannerReason: binding.plannerReason } : {}), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) };
|
|
2391
|
+
opts.current?.();
|
|
2266
2392
|
const next = opts.plannerPull
|
|
2267
2393
|
? this.d.board.pullAssigned(task.id, by, a.owner, opts.plannerPull.revision, opts.plannerPull.note, opts.plannerPull.withheld, a.reviewer ?? null, assignmentNote, assignmentExtra, task.history.length, binding)
|
|
2268
2394
|
: this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(binding ? { planner: binding.planner } : {}), ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, assignmentNote, assignmentExtra, opts.expectedFlowRevision);
|
|
@@ -2280,6 +2406,7 @@ export class Tasks {
|
|
|
2280
2406
|
// The cohort record formed just below reads it, and so does `route explain` while that turn lasts.
|
|
2281
2407
|
if (opts.claim && a.owner === by && this.d.bus.peers.get(by)?.state === "busy") this.sent.set(handOver(next, by), by);
|
|
2282
2408
|
try {
|
|
2409
|
+
opts.current?.();
|
|
2283
2410
|
if (opts.plannerPull && task.offer) {
|
|
2284
2411
|
const prior = { ...task, owner: task.offer.owner };
|
|
2285
2412
|
this.withdrawRoleRequest(prior, next, "owner");
|
|
@@ -2293,7 +2420,7 @@ export class Tasks {
|
|
|
2293
2420
|
this.tellEarlierOwners(next, hits);
|
|
2294
2421
|
}
|
|
2295
2422
|
this.announceRouting(next, a);
|
|
2296
|
-
if (!opts.claim || a.owner !== by) await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits), opts.contextWithheld);
|
|
2423
|
+
if (!opts.claim || a.owner !== by) await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits), opts.contextWithheld, opts.current);
|
|
2297
2424
|
} catch (e) {
|
|
2298
2425
|
// Everything between the write and the end of the delivery is in here: the move is fact, and a failure is a
|
|
2299
2426
|
// delivery failure (#297). The one console notice is printed here, once, for every caller; callers decide
|
|
@@ -2371,8 +2498,10 @@ export class Tasks {
|
|
|
2371
2498
|
}
|
|
2372
2499
|
|
|
2373
2500
|
/** `contextWithheld`: the PII screen did not clear a budget hand-off (#198); an escalation's reason is the hub's own words. */
|
|
2374
|
-
private async sendTask(task: Task, a: Assignment, context?: string, overlap = "", contextWithheld = false): Promise<void> {
|
|
2501
|
+
private async sendTask(task: Task, a: Assignment, context?: string, overlap = "", contextWithheld = false, currentAuthority?: () => void): Promise<void> {
|
|
2502
|
+
currentAuthority?.();
|
|
2375
2503
|
const brief = this.isPii(task) ? undefined : await this.d.briefs?.forTask(task.owner!, task, this.planGuardItems(task)).catch(() => undefined);
|
|
2504
|
+
currentAuthority?.();
|
|
2376
2505
|
const current = this.d.board.get(task.id);
|
|
2377
2506
|
if (!current || !this.ownerAssignmentCurrent(task, current)) throw new AssignmentSupersededError(current ?? task);
|
|
2378
2507
|
task = current;
|
|
@@ -2402,7 +2531,9 @@ export class Tasks {
|
|
|
2402
2531
|
const owner = task.owner!;
|
|
2403
2532
|
const idle = this.d.bus.stateOf(owner) === "idle";
|
|
2404
2533
|
const env = newEnvelope(HUB, body, { to: [owner], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id), taskRecipient: "owner", taskOffer: task.history.length }, ...(pii ? { private: true } : {}) });
|
|
2534
|
+
currentAuthority?.();
|
|
2405
2535
|
this.d.board.recordTaskOffer(task.id, { envelopeId: env.id, owner, assignmentFlowRevision: task.history.length, ...(this.d.offerPullOnly ? { pullOnly: this.d.offerPullOnly(owner) } : {}) }, task.history.length);
|
|
2536
|
+
currentAuthority?.();
|
|
2406
2537
|
this.d.bus.publish(env);
|
|
2407
2538
|
if (idle && this.d.bus.stateOf(owner) === "busy" && !this.d.bus.queueIds(owner).includes(env.id)) this.sent.set(handOver(task, owner), owner);
|
|
2408
2539
|
}
|
|
@@ -2562,7 +2693,8 @@ export class Tasks {
|
|
|
2562
2693
|
if (by !== USER) {
|
|
2563
2694
|
this.planningAuthority(by, task);
|
|
2564
2695
|
const proposer = task.history.find(h => h.event === "proposed" || h.event === "backlogged")?.by;
|
|
2565
|
-
|
|
2696
|
+
// #812 AC5: back in proposed or backlog with no execution evidence (the `hub_backlog_return_untouched` test: no branch, commit, PR, local check, done, verdict or reopen), an accepted-then-declined task is cancellable; any such evidence keeps today's refusal.
|
|
2697
|
+
if (proposer !== by || !["backlog", "proposed"].includes(task.state) || taskHasWorkEvidence(task)) throw new PreEffectToolRefusal("planner may cancel only its own historically unaccepted backlog or proposed work");
|
|
2566
2698
|
}
|
|
2567
2699
|
return task;
|
|
2568
2700
|
};
|
|
@@ -2572,7 +2704,9 @@ export class Tasks {
|
|
|
2572
2704
|
const task = validate();
|
|
2573
2705
|
if (task.history.length !== revision) throw new PreEffectToolRefusal("task flow revision changed");
|
|
2574
2706
|
const cancelled = { reason: input.reason.trim(), ...(input.supersededBy ? { supersededBy: input.supersededBy.trim() } : {}), flowRevision: revision + 1, ...(withheld ? { withheld: true as const } : {}) };
|
|
2707
|
+
const capacity = this.capacityBefore(task);
|
|
2575
2708
|
const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
|
|
2709
|
+
this.writePlannerNotices(next, capacity);
|
|
2576
2710
|
this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
|
|
2577
2711
|
this.offered.delete(task.id);
|
|
2578
2712
|
this.releaseNotice(`task #${task.id}: cancellation stall cleanup unavailable`, () => this.d.board.clearStall(task.id));
|
|
@@ -2823,7 +2957,7 @@ export class Tasks {
|
|
|
2823
2957
|
}
|
|
2824
2958
|
const rows = this.d.board.list();
|
|
2825
2959
|
const tasks: MetricTask[] = rows.map(task => ({ id: task.id, private: this.isPii(task), history: task.history.map(entry => ({ at: entry.at, event: entry.event,
|
|
2826
|
-
...(entry.reviewCost?.head ? { requestHead: entry.reviewCost.head } : {}), ...(entry.reviewFindings?.head ? { verdictHead: entry.reviewFindings.head } : {}) })) }));
|
|
2960
|
+
...(entry.reviewCost?.head ? { requestHead: entry.reviewCost.head } : {}), ...(entry.reviewFindings?.head ? { verdictHead: entry.reviewFindings.head } : {}), ...(entry.evidenceOnly ? { evidenceOnly: true as const } : {}) })) }));
|
|
2827
2961
|
const visibleIds = new Set(rows.filter(task => !this.isPii(task)).map(task => task.id));
|
|
2828
2962
|
const byKind: Record<string, number> = {};
|
|
2829
2963
|
for (const stall of this.stalled()) if (visibleIds.has(stall.task)) byKind[stall.cause.kind] = (byKind[stall.cause.kind] ?? 0) + 1;
|
|
@@ -3052,10 +3186,15 @@ export class Tasks {
|
|
|
3052
3186
|
return this.d.board.update(task.id, by, "lifecycle", { sdlc: next }, undefined, { ...this.planObservation({ ...task, sdlc: next }), ...(invalidatesDeparture ? { planDepartureInvalidated: true } : {}), ...(deliverables ? { deliverables } : {}) });
|
|
3053
3187
|
}
|
|
3054
3188
|
|
|
3189
|
+
/** The board as the merge queue and the guarded merge read it (#786): ids, dependencies, privacy, receipts, findings and plans. */
|
|
3190
|
+
private deliveryLinks(config: SdlcConfig): DeliveryTask[] {
|
|
3191
|
+
return this.d.board.list().map(t => ({ id: t.id, after: t.deps ?? [], private: this.isPii(t), receipt: t.sdlc, phase: this.isPii(t) ? undefined : this.workflowView(t), findings: this.isPii(t) ? undefined : this.reviewFindingSets(t), plan: config.mode === "off" || this.isPii(t) ? undefined : { paths: this.planPaths(t), revision: t.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(t.plan ?? {}), accepted: this.validPlanDepartures(t) } }));
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3055
3194
|
async deliveryQueue(repo: string) {
|
|
3056
3195
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3057
3196
|
const before = workflowHash(this.d.board.list().map(t => ({ id: t.id, state: t.state, after: t.deps, sdlc: t.sdlc, history: t.history.length })));
|
|
3058
|
-
const links = this.
|
|
3197
|
+
const links = this.deliveryLinks(config);
|
|
3059
3198
|
const report = await readMergeQueue(repo, config, links, this.d.sdlc?.gh, this.publicMetadataText);
|
|
3060
3199
|
// Policy can change across remote awaits without any board write. Never return its stale public projection.
|
|
3061
3200
|
const currentPrivacy = this.d.board.list().map(task => ({ id: task.id, private: this.isPii(task) }));
|
|
@@ -3081,6 +3220,150 @@ export class Tasks {
|
|
|
3081
3220
|
}
|
|
3082
3221
|
return report;
|
|
3083
3222
|
}
|
|
3223
|
+
private readonly mergesInFlight = new Set<number>();
|
|
3224
|
+
/** #786: who may ask for a guarded merge. The hub's own approval trigger acts as HUB; a peer must be the one configured conductor. */
|
|
3225
|
+
private mergeAuthority(by: PeerId): boolean {
|
|
3226
|
+
if (by === USER || by === HUB) return true;
|
|
3227
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
3228
|
+
return holders.length === 1 && holders[0] === by;
|
|
3229
|
+
}
|
|
3230
|
+
/**
|
|
3231
|
+
* #786 T1/T2: the guarded merge of one approved task. `sdlc.auto_merge` (or the repository's table) says whether it only reports (`dry-run`) or acts (`on`);
|
|
3232
|
+
* `opts.dryRun` forces a report. Every gate is re-read right now (`sdlc-merge.ts`); this method owns authority, privacy and the notices, which name the task
|
|
3233
|
+
* and the gates only (a PII task shows its stub and is never merged by the hub). Off/dry-run write nothing to the board; on-mode records gate refusals
|
|
3234
|
+
* and post-run host observations, claiming eligible post-MERGED remote cleanup and owner notices durably.
|
|
3235
|
+
*/
|
|
3236
|
+
async guardedMerge(by: PeerId, id: unknown, opts: { dryRun?: boolean; trigger?: "review" | "command" } = {}): Promise<{ task: number } & MergeView> {
|
|
3237
|
+
if (!this.mergeAuthority(by)) throw new Error("only the console user or the configured conductor may merge");
|
|
3238
|
+
const task = this.need(id), label = `#${task.id}`; // the number only: a notice about a merge never carries a title (a PII task has none to show)
|
|
3239
|
+
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3240
|
+
const mode = opts.dryRun ? "dry-run" as const : mergeModeFor(config, task.sdlc?.repo);
|
|
3241
|
+
const refuse = (gate: string) => {
|
|
3242
|
+
if(mode==="on")this.recordMergeRefusal(task.id,by,[gate]);
|
|
3243
|
+
return { task: task.id, ...mergeView({ mode, decision: { refused: [gate] }, ran: false }) };
|
|
3244
|
+
};
|
|
3245
|
+
if (this.isPii(task)) return refuse("private task: a person merges");
|
|
3246
|
+
if (mode === "off") return refuse("auto_merge is off for this repository");
|
|
3247
|
+
if (this.mergesInFlight.has(task.id)) return refuse("a merge check for this task is already running");
|
|
3248
|
+
const previous=task.sdlc?.merge;
|
|
3249
|
+
if(previous && !(opts.trigger==="command" && previous.state==="not_merged") && previous.repo===task.sdlc?.repo && previous.pr===task.sdlc.pr && previous.head===task.sdlc.reviewedHead)
|
|
3250
|
+
return refuse("merge attempt already recorded: inspect its result and any uncertain cleanup before acting");
|
|
3251
|
+
// Ephemeral identity excludes merge progress so our own durable writes cannot rebind the observation.
|
|
3252
|
+
const artifactIdentity=(row:Task)=>JSON.stringify([row.sdlc?.repo,row.sdlc?.pr,row.sdlc?.issue,row.sdlc?.head,row.sdlc?.reviewedHead,row.sdlc?.localHead,row.refs.repo,row.refs.commit]);
|
|
3253
|
+
const initialArtifact=artifactIdentity(task);
|
|
3254
|
+
const artifactCurrent=()=>{const row=this.d.board.get(task.id);return !!row && artifactIdentity(row)===initialArtifact;};
|
|
3255
|
+
this.mergesInFlight.add(task.id);
|
|
3256
|
+
try {
|
|
3257
|
+
const links = this.deliveryLinks(config), subject = links.find(link => link.id === task.id)!;
|
|
3258
|
+
const queueIdentity = workflowHash({ config, links });
|
|
3259
|
+
const outcome = await guardedMerge({ mode, subject, state: task.state, taskState: n => this.d.board.get(n)?.state, config, tasks: links, run: this.d.sdlc?.gh ?? sdlcGh, readableMetadata: this.publicMetadataText,
|
|
3260
|
+
recordMerge: record=>this.recordMergeResult(task.id,by,record,artifactCurrent),
|
|
3261
|
+
beforeMerge: () => {
|
|
3262
|
+
if (!this.mergeAuthority(by)) return "merge authority changed during the merge check: nothing was run";
|
|
3263
|
+
// The queue used every link, not only the subject. A changed dependency artifact or ordering edge invalidates that remote evidence.
|
|
3264
|
+
const currentConfig = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3265
|
+
return workflowHash({ config: currentConfig, links: this.deliveryLinks(currentConfig) }) === queueIdentity
|
|
3266
|
+
? undefined : "merge queue inputs changed during the merge check: nothing was run";
|
|
3267
|
+
},
|
|
3268
|
+
// Read synchronously at dispatch, after the last PR read: the task (state, receipt, privacy, dependencies, findings), the configuration and mode in force, and whether
|
|
3269
|
+
// the asker may still act and the hub may still write. Nothing is awaited between this read and the gh call.
|
|
3270
|
+
fresh: () => {
|
|
3271
|
+
const now = this.d.board.get(task.id), cfg = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3272
|
+
const current = now && this.deliveryLinks(cfg).find(link => link.id === now.id);
|
|
3273
|
+
return now && current ? { subject: current, state: now.state, config: cfg, mode: opts.dryRun ? "dry-run" : mergeModeFor(cfg, now.sdlc?.repo), allowed: this.mergeAuthority(by) && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError } : undefined;
|
|
3274
|
+
} });
|
|
3275
|
+
if(mode==="on" && !outcome.ran && "refused" in outcome.decision)this.recordMergeRefusal(task.id,by,outcome.decision.refused);
|
|
3276
|
+
if(outcome.result?.state==="merged") {
|
|
3277
|
+
try {this.mergeOwnerNotice(task.id,by,artifactCurrent);} catch {this.releaseNotice(`task ${label}: merge owner notice unconfirmed`);}
|
|
3278
|
+
}
|
|
3279
|
+
const view = mergeView(outcome);
|
|
3280
|
+
const head = (text?: string) => text ? text.slice(0, 8) : "unknown";
|
|
3281
|
+
if (outcome.result && (this.isPii(this.d.board.get(task.id) ?? task) || this.d.privateTurn?.(by))) this.releaseNotice(`task ${label}: merge result withheld`);
|
|
3282
|
+
else if (outcome.result) this.releaseNotice(outcome.result.state === "merged" ? `task ${label}: merged (squash, commit ${head(outcome.result.mergeCommit)})` : `task ${label}: gh pr merge exited ${outcome.result.exit}; GitHub reports it ${outcome.result.state === "unknown" ? "unreadable" : "not merged"}`);
|
|
3283
|
+
else if (opts.trigger === "review") this.releaseNotice("action" in outcome.decision ? `task ${label}: auto-merge dry-run: would merge at ${head(outcome.decision.head)}` : `task ${label}: auto-merge refused: ${outcome.decision.refused.join("; ")}`);
|
|
3284
|
+
return { task: task.id, ...view };
|
|
3285
|
+
} finally { this.mergesInFlight.delete(task.id); }
|
|
3286
|
+
}
|
|
3287
|
+
private recordMergeRefusal(id:number,by:PeerId,gates:string[]):void {
|
|
3288
|
+
const current=this.d.board.get(id);
|
|
3289
|
+
if(!current)return;
|
|
3290
|
+
const note=JSON.stringify({gates:gates.slice(0,32).map(g=>this.publicMetadataText(g)?g.slice(0,256):"merge gate withheld")});
|
|
3291
|
+
if(!current.history.some(h=>h.by===HUB && h.event==="merge refused" && h.note===note))
|
|
3292
|
+
this.d.board.update(id,HUB,"merge refused",{},note,{...(this.isPii(current)||this.d.privateTurn?.(by)?{withheld:true}:{})});
|
|
3293
|
+
}
|
|
3294
|
+
private mergeRecordCurrent(id:number,by:PeerId,record:MergeRecord,artifactCurrent:()=>boolean):Task|undefined {
|
|
3295
|
+
const now=this.d.board.get(id);
|
|
3296
|
+
return artifactCurrent() && now && now.sdlc?.repo===record.repo && now.sdlc.pr===record.pr && now.sdlc.reviewedHead===record.head
|
|
3297
|
+
&& now.state==="approved" && !this.isPii(now) && !this.d.privateTurn?.(by) && this.mergeAuthority(by)
|
|
3298
|
+
&& !this.d.bus.isRecoveryHeld && !this.d.bus.storageError ? now : undefined;
|
|
3299
|
+
}
|
|
3300
|
+
/** Save host facts without ever rebinding an old result to a replacement artifact. */
|
|
3301
|
+
private recordMergeResult(id: number, by: PeerId, record: MergeRecord,artifactCurrent:()=>boolean): boolean {
|
|
3302
|
+
const now=this.d.board.get(id);
|
|
3303
|
+
if(!now)return false;
|
|
3304
|
+
const allowed=this.mergeRecordCurrent(id,by,record,artifactCurrent)!==undefined;
|
|
3305
|
+
// A bounded old-attempt fact remains history even after a relink or privacy/authority change. No remote prose or branch name is retained.
|
|
3306
|
+
const note=JSON.stringify(record);
|
|
3307
|
+
if(allowed) {
|
|
3308
|
+
if(JSON.stringify(now.sdlc!.merge)!==note)this.d.board.update(id,HUB,"merge result",{sdlc:{...now.sdlc!,merge:record}},note);
|
|
3309
|
+
} else if(!now.history.some(h=>h.by===HUB && h.event==="merge result (detached)" && h.note===note)) {
|
|
3310
|
+
this.d.board.update(id,HUB,"merge result (detached)",{},note,{...(this.isPii(now)||this.d.privateTurn?.(by)?{withheld:true}:{})});
|
|
3311
|
+
}
|
|
3312
|
+
// Board observers run synchronously. A claim write can itself revoke authority, privacy or artifact identity.
|
|
3313
|
+
return this.mergeRecordCurrent(id,by,record,artifactCurrent)!==undefined && JSON.stringify(this.d.board.get(id)?.sdlc?.merge)===note;
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
/** Claim before publishing: a lost acknowledgement remains unknown and is never automatically sent twice. */
|
|
3317
|
+
private mergeOwnerNotice(id:number,by:PeerId,artifactCurrent:()=>boolean):void {
|
|
3318
|
+
const task=this.d.board.get(id),record=task?.sdlc?.merge;
|
|
3319
|
+
if(!task || !record || record.state!=="merged" || !record.mergeCommit || record.ownerNotice!=="not_started")return;
|
|
3320
|
+
const claimed:MergeRecord={...record,ownerNotice:"claimed"};
|
|
3321
|
+
if(!this.recordMergeResult(id,by,claimed,artifactCurrent))return;
|
|
3322
|
+
const current=this.mergeRecordCurrent(id,by,claimed,artifactCurrent);
|
|
3323
|
+
if(!current)return;
|
|
3324
|
+
const view=this.publicView(current);
|
|
3325
|
+
if(view.title==="[pii]")return;
|
|
3326
|
+
const owner=typeof view.owner==="string"?view.owner:undefined;
|
|
3327
|
+
const body=`Task #${id}: GitHub reports the PR merged (commit ${record.mergeCommit.slice(0,8)}). Remote branch cleanup: ${record.cleanup}. Remove your local worktree and local branch when safe; the hub leaves them in place.`;
|
|
3328
|
+
// Public projection and a synchronous claim observer must not let stale owner text escape.
|
|
3329
|
+
const last=this.mergeRecordCurrent(id,by,claimed,artifactCurrent);
|
|
3330
|
+
if(!last || (last.owner??undefined)!==owner)return;
|
|
3331
|
+
let sent=false;
|
|
3332
|
+
try {
|
|
3333
|
+
if(owner && owner!==USER)sent=this.d.bus.publish(newEnvelope(HUB,body,{to:[owner],kind:"task",refs:{task:String(id),taskRecipient:"owner"}})).includes(owner);
|
|
3334
|
+
else {this.d.notify(body);sent=true;}
|
|
3335
|
+
} catch { /* The durable claim keeps an uncertain publication from being repeated. */ }
|
|
3336
|
+
this.recordMergeResult(id,by,{...claimed,ownerNotice:sent?"sent":"unknown"},artifactCurrent);
|
|
3337
|
+
}
|
|
3338
|
+
/**
|
|
3339
|
+
* #786 T3 (AC1): mirror a saved verdict as ONE `COMMENT` review on the head it was given for (`sdlc-mirror.ts`). Gated by the same `auto_merge` setting as the merge: off posts
|
|
3340
|
+
* nothing, dry-run only reports, on posts. Never awaited by the verdict and never throws: the returned promise always resolves, and a failure is a notice naming the task number.
|
|
3341
|
+
* The note goes out only when it is public: not a PII task, not withheld by the review screen, not in a private turn and matching no private pattern; otherwise the review is its headline alone.
|
|
3342
|
+
*/
|
|
3343
|
+
private mirrorVerdict(task: Task, by: PeerId, verdict: MirrorVerdict, head: string | undefined, note: string | undefined, withheld: boolean): Promise<void> {
|
|
3344
|
+
try {
|
|
3345
|
+
const repo = task.sdlc?.repo, pr = task.sdlc?.pr;
|
|
3346
|
+
if (!repo || !pr || !head) return Promise.resolve();
|
|
3347
|
+
const mode = mergeModeFor(this.d.sdlc?.config() ?? sdlcConfig(undefined), repo);
|
|
3348
|
+
if (mode === "off") return Promise.resolve();
|
|
3349
|
+
const text = note && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by) && this.publicMetadataText(note) ? note : undefined;
|
|
3350
|
+
const label = `#${task.id}`;
|
|
3351
|
+
return Promise.resolve().then(() => mirrorToGitHub({ repo, pr, head, verdict, task: task.id, reviewer: by, ...(text !== undefined ? { note: text } : {}), mode, run: this.d.sdlc?.gh ?? sdlcGh })).then(out => {
|
|
3352
|
+
if (out.reason === "posted") this.releaseNotice(`task ${label}: ${verdict} mirrored to GitHub as a comment review`);
|
|
3353
|
+
else if (out.reason === "post failed") this.releaseNotice(`task ${label}: verdict mirror could not be posted`);
|
|
3354
|
+
else if (out.reason === "dry-run") this.releaseNotice(`task ${label}: verdict mirror dry-run: would post one ${verdict} comment review at ${head.slice(0, 8)}`);
|
|
3355
|
+
}).catch(() => this.releaseNotice(`task ${label}: verdict mirror unavailable`));
|
|
3356
|
+
} catch { return Promise.resolve(); }
|
|
3357
|
+
}
|
|
3358
|
+
/** After an approving verdict, never blocking or failing it: the check runs on a later tick and reports through a notice. `after` (the verdict mirror) settles first, so the review lands before the PR can merge. */
|
|
3359
|
+
private autoMerge(task: Task, after?: Promise<void>): void {
|
|
3360
|
+
try {
|
|
3361
|
+
if (!task.sdlc?.repo || !task.sdlc.pr || mergeModeFor(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc.repo) === "off") return;
|
|
3362
|
+
const label = `#${task.id}`;
|
|
3363
|
+
// Promise.resolve().then also catches a synchronous throw, which would otherwise escape a microtask.
|
|
3364
|
+
void Promise.resolve(after).then(() => this.guardedMerge(HUB, task.id, { trigger: "review" })).catch(() => this.releaseNotice(`task ${label}: auto-merge check unavailable`));
|
|
3365
|
+
} catch { /* an approval never fails because of the merge check */ }
|
|
3366
|
+
}
|
|
3084
3367
|
async releaseReadiness(repo: string, liveIssues: number[]) {
|
|
3085
3368
|
const report = await readReleaseReadiness(this.d.cwd, repo, liveIssues, { gh: this.d.sdlc?.gh });
|
|
3086
3369
|
if (report.documents) report.documents.documents = report.documents.documents.map(d => {
|
|
@@ -3499,12 +3782,17 @@ export class Tasks {
|
|
|
3499
3782
|
const task = this.need(id, true, true);
|
|
3500
3783
|
this.mine(task, by, "owner", true);
|
|
3501
3784
|
// The owner it is declined for goes on the entry, also when the console declines for it: `excluded()` keeps it out (#207).
|
|
3785
|
+
const capacityBefore = this.capacityBefore(task);
|
|
3502
3786
|
const planned = this.plannerPullRecord(task)?.pull === true;
|
|
3503
3787
|
const back = this.d.board.update(task.id, by, "declined", { ...(["in_progress", "changes_requested"].includes(task.state) ? { state: "proposed" as const } : {}), ...(planned ? { reserved: null } : {}) }, reason, task.owner ? { from: task.owner, ...(capacity === true ? { capacity: true as const } : {}) } : {});
|
|
3504
3788
|
if (planned) this.offered.delete(task.id);
|
|
3505
3789
|
this.d.notify(`task ${this.publicTitle(back)} declined${capacity === true ? " (capacity only)" : ""} by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
|
|
3506
3790
|
// A capacity decline is not in excluded(), so this move's own reroute leaves the decliner out explicitly.
|
|
3507
|
-
|
|
3791
|
+
try {
|
|
3792
|
+
return await this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
|
|
3793
|
+
} finally {
|
|
3794
|
+
this.writePlannerNotices(this.d.board.get(task.id) ?? back, capacityBefore);
|
|
3795
|
+
}
|
|
3508
3796
|
}
|
|
3509
3797
|
|
|
3510
3798
|
/**
|
|
@@ -3848,7 +4136,9 @@ export class Tasks {
|
|
|
3848
4136
|
const completedRefs = cleanRefs(refs);
|
|
3849
4137
|
this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
3850
4138
|
const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
4139
|
+
const capacity = this.capacityBefore(task);
|
|
3851
4140
|
const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
4141
|
+
this.writePlannerNotices(next, capacity, next.state === "approved");
|
|
3852
4142
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
3853
4143
|
const memorySymbols = this.completionMemorySymbols(next);
|
|
3854
4144
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\nChanged-symbol memory anchors: ${memorySymbols.state === "known" ? memorySymbols.nodes.length : "unknown (no complete code facts)"}\n${summary ?? ""}`, withheld, memorySymbols);
|
|
@@ -4006,6 +4296,21 @@ export class Tasks {
|
|
|
4006
4296
|
return admitted;
|
|
4007
4297
|
}
|
|
4008
4298
|
|
|
4299
|
+
/**
|
|
4300
|
+
* #824: one `Hub-verified at <head>` line from the review admission, for every reviewer. Ready: the required checks that passed at this exact head (and an identical-tree run when the hub
|
|
4301
|
+
* reused one) and that no bot review thread is waiting for a reply (the readiness rule: an unresolved thread a person already answered does not block, so the line says exactly that). Not ready (advise mode sends anyway): its blockers. No admission (sdlc off, or a request sent without one) and a PII task: no block, the
|
|
4302
|
+
* admission of a PII task being only a stub. Every word comes from the hub's own readiness read; blockers are fixed hub strings, never PR text.
|
|
4303
|
+
*/
|
|
4304
|
+
private hubVerifiedBlock(task: Task, admission?: ReviewReadiness): string {
|
|
4305
|
+
if (!admission || this.isPii(task)) return "";
|
|
4306
|
+
const head = admission.head ?? this.reviewHead(task) ?? "unknown head";
|
|
4307
|
+
if (admission.ready) {
|
|
4308
|
+
const names = admission.checks?.length ? admission.checks.join(", ") : "the required checks";
|
|
4309
|
+
return `Hub-verified at ${head}: required checks ${names} passed at this exact head${admission.reusedRun !== undefined ? ` via identical-tree run ${admission.reusedRun}` : ""}; no bot review thread is waiting for a reply.\n`;
|
|
4310
|
+
}
|
|
4311
|
+
return `Hub-verified at ${head}: not ready: ${admission.blockers.join("; ")}.\n`;
|
|
4312
|
+
}
|
|
4313
|
+
|
|
4009
4314
|
private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness): boolean {
|
|
4010
4315
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
4011
4316
|
if (mode === "enforce" && !admission?.ready) {
|
|
@@ -4030,7 +4335,15 @@ export class Tasks {
|
|
|
4030
4335
|
: profile.name === "general"
|
|
4031
4336
|
? "No check ran for this class: verify the outcome yourself."
|
|
4032
4337
|
: "No check ran for this class: run the affected tests yourself.";
|
|
4033
|
-
|
|
4338
|
+
// #824: the hub's own exact-head CI evidence, from the admission this request already carries (no new gh call). Nothing here comes from the builder's summary or a recorded claim.
|
|
4339
|
+
const codeScoped = this.d.reviewScope?.(reviewer) === "code";
|
|
4340
|
+
const verified = codeScoped ? "" : this.hubVerifiedBlock(task, admission);
|
|
4341
|
+
const ciPassed = admission?.ready === true && !this.isPii(task);
|
|
4342
|
+
// #852 AC2: a code-scope reviewer's sandbox cannot run tests or read CI, so its Hub-verified and check lines are replaced by one fixed line.
|
|
4343
|
+
const checkResult = codeScoped ? CODE_SCOPE_LINE
|
|
4344
|
+
: check ? `Check result: ${(check.note ?? "").split("\n")[0]}`
|
|
4345
|
+
: ciPassed ? "CI passed at this exact head (see the Hub-verified line above); do not list test execution or CI evidence as unmet: name the specific missing test or behaviour instead."
|
|
4346
|
+
: noCheckWording;
|
|
4034
4347
|
const checklistTemplates = profile.review_checklist ?? [
|
|
4035
4348
|
"Map the changed signatures and call sites to {target}, and name each one that does not match.",
|
|
4036
4349
|
"{check}",
|
|
@@ -4051,10 +4364,16 @@ export class Tasks {
|
|
|
4051
4364
|
? ["Deliverables (the hub hashed these files when the task was done; approval binds these hashes):",
|
|
4052
4365
|
...delivered.map(d => `- ${d.id}: ${d.path ?? "(path withheld)"} [${d.format}${d.version ? ` ${d.version}` : ""}] sha256 ${d.sha256.slice(0, 16)}, ${d.bytes} bytes`)].join("\n") + "\n"
|
|
4053
4366
|
: "";
|
|
4367
|
+
const rulings = this.plannerRulings(task, reviewer);
|
|
4368
|
+
const rulingsBlock = rulings.length ? rulings.map(ruling => [
|
|
4369
|
+
`Planner rulings (recorded by the configured planner ${ruling.by}; they amend the plan for this review):`,
|
|
4370
|
+
`- ${new Date(ruling.at).toISOString()}: ${ruling.note.replace(/[\r\n]+/g, " ")}`,
|
|
4371
|
+
].join("\n")).join("\n") + "\n" : "";
|
|
4054
4372
|
const checklist = deliverablesBlock + criteriaBlock + [
|
|
4055
4373
|
"Checklist:",
|
|
4056
4374
|
...this.planGuardItems(task).map(item => `- Check the plan against this live fail before the remaining checks: ${item.line}`),
|
|
4057
4375
|
...checklistLines.map(line => `- ${line}`),
|
|
4376
|
+
...(rulings.length ? ["- a departure a listed ruling names is not an unmet item by itself; judge its code."] : []),
|
|
4058
4377
|
].join("\n");
|
|
4059
4378
|
// #223 R2: a reviewer-only instance judges from facts; the builder's narrative stays out, earlier findings go in.
|
|
4060
4379
|
const reviewOnly = this.d.reviewOnly?.(reviewer) === true;
|
|
@@ -4072,7 +4391,7 @@ export class Tasks {
|
|
|
4072
4391
|
const brief = this.briefText(task);
|
|
4073
4392
|
// #572 AC3: advisory only, from git state never from task text, so it is safe for a PII task too.
|
|
4074
4393
|
const checkout = mainCheckoutWarning(this.d.cwd, this.publicMetadataText);
|
|
4075
|
-
const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${last?.noWrites ? "no writes recorded since acceptance\n" : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${checkout ? `Note: ${checkout}.\n` : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${carriedText}${conformanceText}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet, findings: [{id, pass: "bugs" | "security" | "compliance", severity: "important" | "nit", path?, disposition: "open" | "fixed" | "false_positive"}]}. Reuse finding ids when updating their disposition; only the person may accept a finding with a rationale.`;
|
|
4394
|
+
const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${last?.noWrites ? "no writes recorded since acceptance\n" : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${checkout ? `Note: ${checkout}.\n` : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${carriedText}${conformanceText}${rulingsBlock}${verified}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet, findings: [{id, pass: "bugs" | "security" | "compliance", severity: "important" | "nit", path?, disposition: "open" | "fixed" | "false_positive"}]}. Reuse finding ids when updating their disposition; only the person may accept a finding with a rationale.`;
|
|
4076
4395
|
return this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) })).includes(reviewer);
|
|
4077
4396
|
}
|
|
4078
4397
|
|
|
@@ -4216,6 +4535,11 @@ export class Tasks {
|
|
|
4216
4535
|
let parsed: ReviewFinding[];
|
|
4217
4536
|
try { parsed = parseReviewFindings(findings, { person: by === USER, maxNits: this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS }); }
|
|
4218
4537
|
catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid structured findings"); }
|
|
4538
|
+
// #852 AC3 (amended): a code-scope reviewer's changes_requested is always recorded, never refused. Without an open structured finding it is
|
|
4539
|
+
// evidence-only: it does not count toward rejections or escalation, the task returns to its owner as usual, and the conductor gets one ids-only
|
|
4540
|
+
// notice. The rule is structural (a finding with disposition open), never a reading of the unmet text.
|
|
4541
|
+
const codeScope = by !== USER && this.d.reviewScope?.(by) === "code";
|
|
4542
|
+
const evidenceOnly = codeScope && verdict === "changes_requested" && !parsed.some(finding => finding.disposition === "open");
|
|
4219
4543
|
const resolvedHead = this.findingHead(initial);
|
|
4220
4544
|
let findingHead = resolvedHead;
|
|
4221
4545
|
if (head !== undefined) {
|
|
@@ -4297,7 +4621,7 @@ export class Tasks {
|
|
|
4297
4621
|
}
|
|
4298
4622
|
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid(); }
|
|
4299
4623
|
let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
|
|
4300
|
-
if (verdict === "changes_requested" && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|
|
4624
|
+
if (verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|
|
4301
4625
|
const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
|
|
4302
4626
|
cause: "unknown: review unmet items do not establish a cause", retryWhen: "unknown: a person must review the unmet items before accepting",
|
|
4303
4627
|
evidenceRefs: [`task:${task.id}`, `reviewed-head:${reviewedHead ?? task.refs.commit ?? "unknown"}`] };
|
|
@@ -4316,9 +4640,11 @@ export class Tasks {
|
|
|
4316
4640
|
// #507b: unmet items that start with a recorded criterion id name that criterion. The ids come from the checklist on the board as it is at this
|
|
4317
4641
|
// write (the `task` just rechecked by valid()), never from the caller; a PII task carries none, its checklist being private.
|
|
4318
4642
|
const unmetCriteria = pii ? [] : unmetCriteriaIds(items, checklistOf(task.sdlc));
|
|
4319
|
-
const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
|
|
4643
|
+
const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(codeScope ? { reviewScope: "code" as const } : {}), ...(evidenceOnly ? { evidenceOnly: true as const } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
|
|
4320
4644
|
if (verdict === "approved") {
|
|
4645
|
+
const capacity = this.capacityBefore(task);
|
|
4321
4646
|
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);
|
|
4647
|
+
this.writePlannerNotices(next, capacity, next.state === "approved");
|
|
4322
4648
|
if (next.owner) {
|
|
4323
4649
|
this.d.board.recordOutcome(next.owner, next.class, true);
|
|
4324
4650
|
this.d.board.recordReview({ implementer: next.owner, reviewer: by, class: next.class, kind: "approved", task: next.id });
|
|
@@ -4332,20 +4658,24 @@ export class Tasks {
|
|
|
4332
4658
|
this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
|
|
4333
4659
|
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
|
|
4334
4660
|
await this.releaseDependents(next);
|
|
4661
|
+
this.autoMerge(next, this.mirrorVerdict(next, by, "approved", reviewedHead ?? next.sdlc?.reviewedHead, note, withheld));
|
|
4335
4662
|
// A proposal storage failure cannot roll back the saved verdict; another matching verdict retries it.
|
|
4336
4663
|
this.releaseNotice(`task #${next.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(next, by, parsed, withheld));
|
|
4337
4664
|
return next;
|
|
4338
4665
|
}
|
|
4339
|
-
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note, flag);
|
|
4340
|
-
this.
|
|
4666
|
+
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: evidenceOnly ? task.rejections : task.rejections + 1 }, note, flag);
|
|
4667
|
+
void this.mirrorVerdict(rejected, by, "changes_requested", reviewedHead ?? rejected.sdlc?.reviewedHead, note, withheld);
|
|
4668
|
+
if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
|
|
4341
4669
|
if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
|
|
4342
4670
|
try { this.d.memoryCheck?.(); this.d.candidates?.put({ ...candidate, evidenceRefs: [...candidate.evidenceRefs, `review:${rejected.id}:${rejected.history.length}`] }, "public"); } catch { /* a saved review remains authoritative if the optional sidecar fails */ }
|
|
4343
4671
|
}
|
|
4344
4672
|
this.cohorts.withdraw(rejected.id); // open again: its done intent no longer counts (issue #107)
|
|
4345
|
-
|
|
4346
|
-
this.
|
|
4673
|
+
// Evidence-only is not a verdict on the owner's work: no failed outcome, no contradiction of an earlier approval, and no escalation count.
|
|
4674
|
+
if (rejected.owner && !evidenceOnly) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
|
|
4675
|
+
if (!evidenceOnly) this.contradict(rejected);
|
|
4347
4676
|
this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
|
|
4348
|
-
if (
|
|
4677
|
+
if (evidenceOnly) this.tellConductorEvidenceOnly(rejected, by);
|
|
4678
|
+
if (!evidenceOnly && rejected.rejections >= (this.d.escalateAfter?.() ?? ESCALATE_AFTER)) {
|
|
4349
4679
|
let moved: Task;
|
|
4350
4680
|
try {
|
|
4351
4681
|
moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
|
|
@@ -4370,6 +4700,19 @@ export class Tasks {
|
|
|
4370
4700
|
return reopened;
|
|
4371
4701
|
}
|
|
4372
4702
|
|
|
4703
|
+
/**
|
|
4704
|
+
* #852 AC3: one task-weight notice to the configured conductor for a verdict recorded as evidence-only. Ids only (task and reviewer), so nothing of
|
|
4705
|
+
* the review or the task reaches it; a PII task's notice is private. With no conductor the person sees one console notice instead.
|
|
4706
|
+
*/
|
|
4707
|
+
private tellConductorEvidenceOnly(task: Task, reviewer: PeerId): void {
|
|
4708
|
+
const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor).filter(actor => actor !== reviewer);
|
|
4709
|
+
const text = evidenceOnlyNotice(task.id, reviewer);
|
|
4710
|
+
if (!conductors.length) { this.releaseNotice(text); return; }
|
|
4711
|
+
this.releaseNotice(`task #${task.id}: could not tell the conductor a code-scope review was evidence-only`, () => {
|
|
4712
|
+
this.d.bus.publish(newEnvelope(HUB, sanitize(text), { to: conductors, kind: "task", refs: { task: String(task.id) }, ...(this.isPii(task) ? { private: true } : {}) }));
|
|
4713
|
+
});
|
|
4714
|
+
}
|
|
4715
|
+
|
|
4373
4716
|
/** Task-level escalation: the next attached peer in the class's escalate_to takes over, with the history. */
|
|
4374
4717
|
async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual", preEffect = false): Promise<Task> {
|
|
4375
4718
|
let task = this.need(id, true, preEffect);
|
|
@@ -4531,17 +4874,20 @@ export class Tasks {
|
|
|
4531
4874
|
}
|
|
4532
4875
|
|
|
4533
4876
|
/** A roster removal offers work through the quota relay's constraints, preserving assignments nobody can take. */
|
|
4534
|
-
async reassignForRoster(peer: PeerId): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
4535
|
-
return this.reassignWork(peer, undefined, false, true);
|
|
4877
|
+
async reassignForRoster(peer: PeerId, current?: () => void): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
4878
|
+
return this.reassignWork(peer, undefined, false, true, current);
|
|
4536
4879
|
}
|
|
4537
4880
|
|
|
4538
|
-
private async reassignWork(peer: PeerId, context: string | undefined, urgentOnly: boolean, roster: boolean): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
4881
|
+
private async reassignWork(peer: PeerId, context: string | undefined, urgentOnly: boolean, roster: boolean, current?: () => void): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
4882
|
+
current?.();
|
|
4539
4883
|
const moved: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[] = [];
|
|
4540
4884
|
const routing = this.d.routing(), why = roster ? `roster removal of ${peer}` : `budget pause of ${peer}`;
|
|
4541
4885
|
// Budget context is screened exactly as before; a roster removal supplies no agent-written context.
|
|
4542
4886
|
const moves = (t: Task) => (!urgentOnly || t.signals.includes("urgent")) && t.owner === peer && OPEN.includes(t.state);
|
|
4543
4887
|
const contextWithheld = context && this.d.board.list().some((t) => moves(t) && !this.isPii(t)) ? await this.withholds("handoff", context) : false;
|
|
4888
|
+
current?.();
|
|
4544
4889
|
for (const task of this.d.board.list()) {
|
|
4890
|
+
current?.();
|
|
4545
4891
|
if (urgentOnly && !task.signals.includes("urgent")) continue;
|
|
4546
4892
|
if (task.owner === peer && (OPEN.includes(task.state) || roster && task.state === "in_review")) {
|
|
4547
4893
|
const pii = this.isPii(task);
|
|
@@ -4552,19 +4898,22 @@ export class Tasks {
|
|
|
4552
4898
|
const back = !roster && task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
|
|
4553
4899
|
let next: Task;
|
|
4554
4900
|
try {
|
|
4555
|
-
next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", reason: roster ? "manual" : "budget", note: why, ...(roster ? { roster: true } : { clearOnFail: true }), ...(context ? { context, contextWithheld } : {}) });
|
|
4901
|
+
next = await this.assignOwner(back, HUB, { current, candidates, exclude: [peer], event: "reassigned", reason: roster ? "manual" : "budget", note: why, ...(roster ? { roster: true } : { clearOnFail: true }), ...(context ? { context, contextWithheld } : {}) });
|
|
4556
4902
|
} catch (e) {
|
|
4557
4903
|
// The move is saved and its central #297 notice already spoke; relay the
|
|
4558
4904
|
// remaining tasks and run the roster tail on the saved row as on success.
|
|
4559
4905
|
if (!(e instanceof AssignmentUndeliveredError)) throw e;
|
|
4560
4906
|
next = e.task;
|
|
4561
4907
|
}
|
|
4908
|
+
// A saved assignment failure is retained, but never permits a revoked initiating operation to continue.
|
|
4909
|
+
current?.();
|
|
4562
4910
|
if (!this.ownOwnerMove(back, next, HUB, "reassigned") && !this.ownOwnerMove(back, next, HUB, "unassigned")) continue;
|
|
4563
4911
|
if (roster) {
|
|
4564
4912
|
if (!next.owner || next.owner === peer) continue;
|
|
4565
4913
|
this.releaseNotice(`task ${this.publicTitle(next)} moved from ${peer} to ${next.owner} (off roster)`);
|
|
4566
4914
|
if (next.state === "in_review" && next.reviewer && next.reviewer !== task.reviewer && next.reviewer !== USER) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${next.reviewer}`, () => this.sendReview(next, next.reviewer!, `(${peer} owned this and is off roster.)`));
|
|
4567
4915
|
}
|
|
4916
|
+
current?.();
|
|
4568
4917
|
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
4569
4918
|
} else if (task.reviewer === peer && !isClosedTaskState(task.state)) {
|
|
4570
4919
|
const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class, task) });
|
|
@@ -4573,8 +4922,10 @@ export class Tasks {
|
|
|
4573
4922
|
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}`);
|
|
4574
4923
|
continue;
|
|
4575
4924
|
}
|
|
4925
|
+
current?.();
|
|
4576
4926
|
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a), this.reviewMetadata(a));
|
|
4577
4927
|
moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
|
|
4928
|
+
current?.();
|
|
4578
4929
|
if (next.state === "in_review" && reviewer && reviewer !== USER) {
|
|
4579
4930
|
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.)`));
|
|
4580
4931
|
else this.sendReview(next, reviewer, `(${peer} was reviewing this and is paused for quota.)`);
|