@staix/agent-hub 0.17.0 → 0.18.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 +16 -0
- package/README.md +2 -2
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +2 -0
- package/docs/agent-notes/benchmarks.md +1 -0
- package/docs/agent-notes/daemon.md +1 -0
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +7 -1
- package/docs/agent-notes/tests.md +1 -0
- package/docs/dogfood.md +15 -1
- package/docs/memory.md +1 -1
- package/docs/operations.md +105 -16
- package/docs/profiles.md +36 -0
- package/docs/quickstart.md +9 -3
- package/docs/sdlc.md +29 -3
- package/docs/security.md +26 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +16 -6
- 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 +46 -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 +94 -33
- package/src/adapters/definitions/builtin-metadata.ts +1 -1
- package/src/adapters/local-worker.ts +5 -3
- 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 +190 -69
- package/src/cli/output.ts +7 -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/board.ts +17 -0
- package/src/hub/check-runner.ts +252 -22
- 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-tier.ts +1 -1
- package/src/hub/daemon.ts +353 -72
- 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 +2 -1
- package/src/hub/hub-tools.ts +2 -2
- package/src/hub/operational-authority.ts +60 -0
- package/src/hub/operational-view.ts +141 -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 +80 -0
- package/src/hub/read-only-shell.ts +162 -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-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/tasks.ts +241 -16
- package/src/local/tools.ts +17 -4
- package/src/ui/index.html +2 -1
package/src/hub/tasks.ts
CHANGED
|
@@ -13,7 +13,9 @@ import { VERSION } from "../version.ts";
|
|
|
13
13
|
import { NoWriteTurns } from "./no-writes.ts";
|
|
14
14
|
import { readGitHubImport, type GitHubSource, type GitHubImportSelection } from "./github-import.ts";
|
|
15
15
|
import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
|
|
16
|
-
import { readMergeQueue } from "./sdlc-queue.ts";
|
|
16
|
+
import { readMergeQueue, type DeliveryTask } from "./sdlc-queue.ts";
|
|
17
|
+
import { guardedMerge, mergeModeFor, mergeView, type MergeView } from "./sdlc-merge.ts";
|
|
18
|
+
import { mirrorVerdict as mirrorToGitHub, type MirrorVerdict } from "./sdlc-mirror.ts";
|
|
17
19
|
import { DEFAULT_MAX_NITS, parseReviewFindings, findingsForHead, findingHead as resolveFindingHead, findingTally, type ReviewFinding, type ReviewFindingSet } from "./review-findings.ts";
|
|
18
20
|
import { readReleaseReadiness } from "./sdlc-release.ts";
|
|
19
21
|
import { loadRoleCatalog, taskRole, taskRoleText, type RoleCatalog } from "./roles.ts";
|
|
@@ -44,7 +46,7 @@ import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief
|
|
|
44
46
|
import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
|
|
45
47
|
import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
46
48
|
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";
|
|
49
|
+
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
50
|
|
|
49
51
|
export type TaskAssignmentRole = "owner" | "reviewer" | "planner";
|
|
50
52
|
|
|
@@ -130,7 +132,7 @@ export interface TasksDeps {
|
|
|
130
132
|
/** Optional persisted task/run execution limits (issue #102). */
|
|
131
133
|
executionBudget?: ExecutionBudget;
|
|
132
134
|
/** 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 };
|
|
135
|
+
review?: { adaptive: boolean; min_reviews: number; pool?: readonly string[]; rerotate?: boolean; same_family?: "fallback" | "never" };
|
|
134
136
|
/** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
|
|
135
137
|
capabilities?: () => Record<PeerId, PeerCapabilities>;
|
|
136
138
|
repository?: string;
|
|
@@ -790,7 +792,7 @@ export class Tasks {
|
|
|
790
792
|
const held = this.d.held?.();
|
|
791
793
|
const workload = this.instanceRouting();
|
|
792
794
|
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 } : {}) };
|
|
795
|
+
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
796
|
}
|
|
795
797
|
|
|
796
798
|
private reviewHead(task: Task): string | undefined { return task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; }
|
|
@@ -799,7 +801,7 @@ export class Tasks {
|
|
|
799
801
|
const head = this.reviewHead(task);
|
|
800
802
|
const round = task.state === "in_review" ? task.history.slice(task.history.findLastIndex(entry => entry.event === "done") + 1) : [];
|
|
801
803
|
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 };
|
|
804
|
+
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
805
|
}
|
|
804
806
|
|
|
805
807
|
private manuallyNamedReviewer(task: Task): boolean {
|
|
@@ -948,10 +950,31 @@ export class Tasks {
|
|
|
948
950
|
return task.planner && holders.includes(task.planner) && this.d.bus.isRostered(task.planner) ? [task.planner] : holders;
|
|
949
951
|
}
|
|
950
952
|
|
|
953
|
+
/** Current planner rulings amend review context, never person-only plan-departure acceptance. */
|
|
954
|
+
plannerRulings(task: Task, recipient: PeerId): { historyIndex: number; by: PeerId; at: number; note: string }[] {
|
|
955
|
+
if (this.isPii(task)) return [];
|
|
956
|
+
const boundary = task.history.findLastIndex(entry => entry.event === "accepted" || entry.event === "claimed");
|
|
957
|
+
if (boundary < 0) return [];
|
|
958
|
+
const planners = new Set(this.taskPlanner(task));
|
|
959
|
+
const rulings: { historyIndex: number; by: PeerId; at: number; note: string }[] = [];
|
|
960
|
+
for (let historyIndex = boundary + 1; historyIndex < task.history.length && rulings.length < 5; historyIndex++) {
|
|
961
|
+
const entry = task.history[historyIndex]!;
|
|
962
|
+
if (entry.event !== "plan reviewed" || !planners.has(entry.by) || !this.d.roles?.[entry.by]?.includes("planner")
|
|
963
|
+
|| typeof entry.note !== "string" || !entry.note.trim() || !Number.isFinite(entry.at)) continue;
|
|
964
|
+
// Screen the entire original note before the display bound, including a sensitive suffix past 1000 characters.
|
|
965
|
+
const note = sanitize(this.screen(task, entry.note, "planner ruling", recipient, entry.withheld)).slice(0, 1000);
|
|
966
|
+
rulings.push({ historyIndex, by: entry.by, at: entry.at, note });
|
|
967
|
+
}
|
|
968
|
+
return rulings;
|
|
969
|
+
}
|
|
970
|
+
|
|
951
971
|
private planningAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
|
|
952
972
|
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
973
|
}
|
|
954
974
|
|
|
975
|
+
/** #812 AC5: a peer holding the configured planner role. The conductor's task reads accept it; it grants no write. */
|
|
976
|
+
isConfiguredPlanner(peer: PeerId): boolean { return peer !== USER && peer !== HUB && !!this.d.roles?.[peer]?.includes("planner"); }
|
|
977
|
+
|
|
955
978
|
private plannerWriteAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
|
|
956
979
|
if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("planner writes are unavailable during a private turn");
|
|
957
980
|
this.planningAuthority(by, task);
|
|
@@ -1015,6 +1038,14 @@ export class Tasks {
|
|
|
1015
1038
|
wip(peer: PeerId, except?: number): number {
|
|
1016
1039
|
return this.d.board.plannerWip(peer, except);
|
|
1017
1040
|
}
|
|
1041
|
+
/** #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()`. */
|
|
1042
|
+
wipBreakdown(peers: readonly PeerId[]): Record<string, { wip: number; active: number; reviewWait: number; reserved: number; wipLimit: number }> {
|
|
1043
|
+
const grouped = this.d.board.plannerWipBreakdown();
|
|
1044
|
+
return Object.fromEntries(peers.map(peer => {
|
|
1045
|
+
const row = grouped[peer] ?? { active: 0, reviewWait: 0, reserved: 0 };
|
|
1046
|
+
return [peer, { wip: row.active + row.reviewWait + row.reserved, ...row, wipLimit: this.wipLimit(peer) }];
|
|
1047
|
+
}));
|
|
1048
|
+
}
|
|
1018
1049
|
private prerequisiteOf(task: Task, prerequisite: number): boolean {
|
|
1019
1050
|
const pending = [...(task.deps ?? [])], seen = new Set<number>();
|
|
1020
1051
|
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 ?? [])); }
|
|
@@ -2202,12 +2233,14 @@ export class Tasks {
|
|
|
2202
2233
|
}
|
|
2203
2234
|
|
|
2204
2235
|
/** `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> {
|
|
2236
|
+
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> {
|
|
2237
|
+
opts.current?.();
|
|
2206
2238
|
if (task.state === "backlog" && !opts.plannerPull) throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
|
|
2207
2239
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
2208
2240
|
let stagedClaimGap: string | undefined;
|
|
2209
2241
|
if (mode !== "off" && this.isBuildTask(task)) {
|
|
2210
2242
|
const gap = task.sdlc?.planReview ? await this.planReviewGap(task) : "plan not reviewed since proposal";
|
|
2243
|
+
opts.current?.();
|
|
2211
2244
|
const current = this.d.board.get(task.id);
|
|
2212
2245
|
if (!current || !this.ownerAssignmentCurrent(task, current)) {
|
|
2213
2246
|
if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull changed during admission");
|
|
@@ -2263,6 +2296,7 @@ export class Tasks {
|
|
|
2263
2296
|
const assignmentNote = this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a);
|
|
2264
2297
|
const binding = this.plannerBinding(task, a, !!opts.plannerPull);
|
|
2265
2298
|
const assignmentExtra = { ...this.reviewMetadata(a), ...(binding ? { plannerReason: binding.plannerReason } : {}), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) };
|
|
2299
|
+
opts.current?.();
|
|
2266
2300
|
const next = opts.plannerPull
|
|
2267
2301
|
? 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
2302
|
: 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 +2314,7 @@ export class Tasks {
|
|
|
2280
2314
|
// The cohort record formed just below reads it, and so does `route explain` while that turn lasts.
|
|
2281
2315
|
if (opts.claim && a.owner === by && this.d.bus.peers.get(by)?.state === "busy") this.sent.set(handOver(next, by), by);
|
|
2282
2316
|
try {
|
|
2317
|
+
opts.current?.();
|
|
2283
2318
|
if (opts.plannerPull && task.offer) {
|
|
2284
2319
|
const prior = { ...task, owner: task.offer.owner };
|
|
2285
2320
|
this.withdrawRoleRequest(prior, next, "owner");
|
|
@@ -2293,7 +2328,7 @@ export class Tasks {
|
|
|
2293
2328
|
this.tellEarlierOwners(next, hits);
|
|
2294
2329
|
}
|
|
2295
2330
|
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);
|
|
2331
|
+
if (!opts.claim || a.owner !== by) await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits), opts.contextWithheld, opts.current);
|
|
2297
2332
|
} catch (e) {
|
|
2298
2333
|
// Everything between the write and the end of the delivery is in here: the move is fact, and a failure is a
|
|
2299
2334
|
// delivery failure (#297). The one console notice is printed here, once, for every caller; callers decide
|
|
@@ -2371,8 +2406,10 @@ export class Tasks {
|
|
|
2371
2406
|
}
|
|
2372
2407
|
|
|
2373
2408
|
/** `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> {
|
|
2409
|
+
private async sendTask(task: Task, a: Assignment, context?: string, overlap = "", contextWithheld = false, currentAuthority?: () => void): Promise<void> {
|
|
2410
|
+
currentAuthority?.();
|
|
2375
2411
|
const brief = this.isPii(task) ? undefined : await this.d.briefs?.forTask(task.owner!, task, this.planGuardItems(task)).catch(() => undefined);
|
|
2412
|
+
currentAuthority?.();
|
|
2376
2413
|
const current = this.d.board.get(task.id);
|
|
2377
2414
|
if (!current || !this.ownerAssignmentCurrent(task, current)) throw new AssignmentSupersededError(current ?? task);
|
|
2378
2415
|
task = current;
|
|
@@ -2402,7 +2439,9 @@ export class Tasks {
|
|
|
2402
2439
|
const owner = task.owner!;
|
|
2403
2440
|
const idle = this.d.bus.stateOf(owner) === "idle";
|
|
2404
2441
|
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 } : {}) });
|
|
2442
|
+
currentAuthority?.();
|
|
2405
2443
|
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);
|
|
2444
|
+
currentAuthority?.();
|
|
2406
2445
|
this.d.bus.publish(env);
|
|
2407
2446
|
if (idle && this.d.bus.stateOf(owner) === "busy" && !this.d.bus.queueIds(owner).includes(env.id)) this.sent.set(handOver(task, owner), owner);
|
|
2408
2447
|
}
|
|
@@ -2562,7 +2601,8 @@ export class Tasks {
|
|
|
2562
2601
|
if (by !== USER) {
|
|
2563
2602
|
this.planningAuthority(by, task);
|
|
2564
2603
|
const proposer = task.history.find(h => h.event === "proposed" || h.event === "backlogged")?.by;
|
|
2565
|
-
|
|
2604
|
+
// #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.
|
|
2605
|
+
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
2606
|
}
|
|
2567
2607
|
return task;
|
|
2568
2608
|
};
|
|
@@ -3052,10 +3092,15 @@ export class Tasks {
|
|
|
3052
3092
|
return this.d.board.update(task.id, by, "lifecycle", { sdlc: next }, undefined, { ...this.planObservation({ ...task, sdlc: next }), ...(invalidatesDeparture ? { planDepartureInvalidated: true } : {}), ...(deliverables ? { deliverables } : {}) });
|
|
3053
3093
|
}
|
|
3054
3094
|
|
|
3095
|
+
/** The board as the merge queue and the guarded merge read it (#786): ids, dependencies, privacy, receipts, findings and plans. */
|
|
3096
|
+
private deliveryLinks(config: SdlcConfig): DeliveryTask[] {
|
|
3097
|
+
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) } }));
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3055
3100
|
async deliveryQueue(repo: string) {
|
|
3056
3101
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3057
3102
|
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.
|
|
3103
|
+
const links = this.deliveryLinks(config);
|
|
3059
3104
|
const report = await readMergeQueue(repo, config, links, this.d.sdlc?.gh, this.publicMetadataText);
|
|
3060
3105
|
// Policy can change across remote awaits without any board write. Never return its stale public projection.
|
|
3061
3106
|
const currentPrivacy = this.d.board.list().map(task => ({ id: task.id, private: this.isPii(task) }));
|
|
@@ -3081,6 +3126,150 @@ export class Tasks {
|
|
|
3081
3126
|
}
|
|
3082
3127
|
return report;
|
|
3083
3128
|
}
|
|
3129
|
+
private readonly mergesInFlight = new Set<number>();
|
|
3130
|
+
/** #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. */
|
|
3131
|
+
private mergeAuthority(by: PeerId): boolean {
|
|
3132
|
+
if (by === USER || by === HUB) return true;
|
|
3133
|
+
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
3134
|
+
return holders.length === 1 && holders[0] === by;
|
|
3135
|
+
}
|
|
3136
|
+
/**
|
|
3137
|
+
* #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`);
|
|
3138
|
+
* `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
|
|
3139
|
+
* 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
|
|
3140
|
+
* and post-run host observations, claiming eligible post-MERGED remote cleanup and owner notices durably.
|
|
3141
|
+
*/
|
|
3142
|
+
async guardedMerge(by: PeerId, id: unknown, opts: { dryRun?: boolean; trigger?: "review" | "command" } = {}): Promise<{ task: number } & MergeView> {
|
|
3143
|
+
if (!this.mergeAuthority(by)) throw new Error("only the console user or the configured conductor may merge");
|
|
3144
|
+
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)
|
|
3145
|
+
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3146
|
+
const mode = opts.dryRun ? "dry-run" as const : mergeModeFor(config, task.sdlc?.repo);
|
|
3147
|
+
const refuse = (gate: string) => {
|
|
3148
|
+
if(mode==="on")this.recordMergeRefusal(task.id,by,[gate]);
|
|
3149
|
+
return { task: task.id, ...mergeView({ mode, decision: { refused: [gate] }, ran: false }) };
|
|
3150
|
+
};
|
|
3151
|
+
if (this.isPii(task)) return refuse("private task: a person merges");
|
|
3152
|
+
if (mode === "off") return refuse("auto_merge is off for this repository");
|
|
3153
|
+
if (this.mergesInFlight.has(task.id)) return refuse("a merge check for this task is already running");
|
|
3154
|
+
const previous=task.sdlc?.merge;
|
|
3155
|
+
if(previous && !(opts.trigger==="command" && previous.state==="not_merged") && previous.repo===task.sdlc?.repo && previous.pr===task.sdlc.pr && previous.head===task.sdlc.reviewedHead)
|
|
3156
|
+
return refuse("merge attempt already recorded: inspect its result and any uncertain cleanup before acting");
|
|
3157
|
+
// Ephemeral identity excludes merge progress so our own durable writes cannot rebind the observation.
|
|
3158
|
+
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]);
|
|
3159
|
+
const initialArtifact=artifactIdentity(task);
|
|
3160
|
+
const artifactCurrent=()=>{const row=this.d.board.get(task.id);return !!row && artifactIdentity(row)===initialArtifact;};
|
|
3161
|
+
this.mergesInFlight.add(task.id);
|
|
3162
|
+
try {
|
|
3163
|
+
const links = this.deliveryLinks(config), subject = links.find(link => link.id === task.id)!;
|
|
3164
|
+
const queueIdentity = workflowHash({ config, links });
|
|
3165
|
+
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,
|
|
3166
|
+
recordMerge: record=>this.recordMergeResult(task.id,by,record,artifactCurrent),
|
|
3167
|
+
beforeMerge: () => {
|
|
3168
|
+
if (!this.mergeAuthority(by)) return "merge authority changed during the merge check: nothing was run";
|
|
3169
|
+
// The queue used every link, not only the subject. A changed dependency artifact or ordering edge invalidates that remote evidence.
|
|
3170
|
+
const currentConfig = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3171
|
+
return workflowHash({ config: currentConfig, links: this.deliveryLinks(currentConfig) }) === queueIdentity
|
|
3172
|
+
? undefined : "merge queue inputs changed during the merge check: nothing was run";
|
|
3173
|
+
},
|
|
3174
|
+
// Read synchronously at dispatch, after the last PR read: the task (state, receipt, privacy, dependencies, findings), the configuration and mode in force, and whether
|
|
3175
|
+
// the asker may still act and the hub may still write. Nothing is awaited between this read and the gh call.
|
|
3176
|
+
fresh: () => {
|
|
3177
|
+
const now = this.d.board.get(task.id), cfg = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3178
|
+
const current = now && this.deliveryLinks(cfg).find(link => link.id === now.id);
|
|
3179
|
+
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;
|
|
3180
|
+
} });
|
|
3181
|
+
if(mode==="on" && !outcome.ran && "refused" in outcome.decision)this.recordMergeRefusal(task.id,by,outcome.decision.refused);
|
|
3182
|
+
if(outcome.result?.state==="merged") {
|
|
3183
|
+
try {this.mergeOwnerNotice(task.id,by,artifactCurrent);} catch {this.releaseNotice(`task ${label}: merge owner notice unconfirmed`);}
|
|
3184
|
+
}
|
|
3185
|
+
const view = mergeView(outcome);
|
|
3186
|
+
const head = (text?: string) => text ? text.slice(0, 8) : "unknown";
|
|
3187
|
+
if (outcome.result && (this.isPii(this.d.board.get(task.id) ?? task) || this.d.privateTurn?.(by))) this.releaseNotice(`task ${label}: merge result withheld`);
|
|
3188
|
+
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"}`);
|
|
3189
|
+
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("; ")}`);
|
|
3190
|
+
return { task: task.id, ...view };
|
|
3191
|
+
} finally { this.mergesInFlight.delete(task.id); }
|
|
3192
|
+
}
|
|
3193
|
+
private recordMergeRefusal(id:number,by:PeerId,gates:string[]):void {
|
|
3194
|
+
const current=this.d.board.get(id);
|
|
3195
|
+
if(!current)return;
|
|
3196
|
+
const note=JSON.stringify({gates:gates.slice(0,32).map(g=>this.publicMetadataText(g)?g.slice(0,256):"merge gate withheld")});
|
|
3197
|
+
if(!current.history.some(h=>h.by===HUB && h.event==="merge refused" && h.note===note))
|
|
3198
|
+
this.d.board.update(id,HUB,"merge refused",{},note,{...(this.isPii(current)||this.d.privateTurn?.(by)?{withheld:true}:{})});
|
|
3199
|
+
}
|
|
3200
|
+
private mergeRecordCurrent(id:number,by:PeerId,record:MergeRecord,artifactCurrent:()=>boolean):Task|undefined {
|
|
3201
|
+
const now=this.d.board.get(id);
|
|
3202
|
+
return artifactCurrent() && now && now.sdlc?.repo===record.repo && now.sdlc.pr===record.pr && now.sdlc.reviewedHead===record.head
|
|
3203
|
+
&& now.state==="approved" && !this.isPii(now) && !this.d.privateTurn?.(by) && this.mergeAuthority(by)
|
|
3204
|
+
&& !this.d.bus.isRecoveryHeld && !this.d.bus.storageError ? now : undefined;
|
|
3205
|
+
}
|
|
3206
|
+
/** Save host facts without ever rebinding an old result to a replacement artifact. */
|
|
3207
|
+
private recordMergeResult(id: number, by: PeerId, record: MergeRecord,artifactCurrent:()=>boolean): boolean {
|
|
3208
|
+
const now=this.d.board.get(id);
|
|
3209
|
+
if(!now)return false;
|
|
3210
|
+
const allowed=this.mergeRecordCurrent(id,by,record,artifactCurrent)!==undefined;
|
|
3211
|
+
// A bounded old-attempt fact remains history even after a relink or privacy/authority change. No remote prose or branch name is retained.
|
|
3212
|
+
const note=JSON.stringify(record);
|
|
3213
|
+
if(allowed) {
|
|
3214
|
+
if(JSON.stringify(now.sdlc!.merge)!==note)this.d.board.update(id,HUB,"merge result",{sdlc:{...now.sdlc!,merge:record}},note);
|
|
3215
|
+
} else if(!now.history.some(h=>h.by===HUB && h.event==="merge result (detached)" && h.note===note)) {
|
|
3216
|
+
this.d.board.update(id,HUB,"merge result (detached)",{},note,{...(this.isPii(now)||this.d.privateTurn?.(by)?{withheld:true}:{})});
|
|
3217
|
+
}
|
|
3218
|
+
// Board observers run synchronously. A claim write can itself revoke authority, privacy or artifact identity.
|
|
3219
|
+
return this.mergeRecordCurrent(id,by,record,artifactCurrent)!==undefined && JSON.stringify(this.d.board.get(id)?.sdlc?.merge)===note;
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
/** Claim before publishing: a lost acknowledgement remains unknown and is never automatically sent twice. */
|
|
3223
|
+
private mergeOwnerNotice(id:number,by:PeerId,artifactCurrent:()=>boolean):void {
|
|
3224
|
+
const task=this.d.board.get(id),record=task?.sdlc?.merge;
|
|
3225
|
+
if(!task || !record || record.state!=="merged" || !record.mergeCommit || record.ownerNotice!=="not_started")return;
|
|
3226
|
+
const claimed:MergeRecord={...record,ownerNotice:"claimed"};
|
|
3227
|
+
if(!this.recordMergeResult(id,by,claimed,artifactCurrent))return;
|
|
3228
|
+
const current=this.mergeRecordCurrent(id,by,claimed,artifactCurrent);
|
|
3229
|
+
if(!current)return;
|
|
3230
|
+
const view=this.publicView(current);
|
|
3231
|
+
if(view.title==="[pii]")return;
|
|
3232
|
+
const owner=typeof view.owner==="string"?view.owner:undefined;
|
|
3233
|
+
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.`;
|
|
3234
|
+
// Public projection and a synchronous claim observer must not let stale owner text escape.
|
|
3235
|
+
const last=this.mergeRecordCurrent(id,by,claimed,artifactCurrent);
|
|
3236
|
+
if(!last || (last.owner??undefined)!==owner)return;
|
|
3237
|
+
let sent=false;
|
|
3238
|
+
try {
|
|
3239
|
+
if(owner && owner!==USER)sent=this.d.bus.publish(newEnvelope(HUB,body,{to:[owner],kind:"task",refs:{task:String(id),taskRecipient:"owner"}})).includes(owner);
|
|
3240
|
+
else {this.d.notify(body);sent=true;}
|
|
3241
|
+
} catch { /* The durable claim keeps an uncertain publication from being repeated. */ }
|
|
3242
|
+
this.recordMergeResult(id,by,{...claimed,ownerNotice:sent?"sent":"unknown"},artifactCurrent);
|
|
3243
|
+
}
|
|
3244
|
+
/**
|
|
3245
|
+
* #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
|
|
3246
|
+
* 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.
|
|
3247
|
+
* 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.
|
|
3248
|
+
*/
|
|
3249
|
+
private mirrorVerdict(task: Task, by: PeerId, verdict: MirrorVerdict, head: string | undefined, note: string | undefined, withheld: boolean): Promise<void> {
|
|
3250
|
+
try {
|
|
3251
|
+
const repo = task.sdlc?.repo, pr = task.sdlc?.pr;
|
|
3252
|
+
if (!repo || !pr || !head) return Promise.resolve();
|
|
3253
|
+
const mode = mergeModeFor(this.d.sdlc?.config() ?? sdlcConfig(undefined), repo);
|
|
3254
|
+
if (mode === "off") return Promise.resolve();
|
|
3255
|
+
const text = note && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by) && this.publicMetadataText(note) ? note : undefined;
|
|
3256
|
+
const label = `#${task.id}`;
|
|
3257
|
+
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 => {
|
|
3258
|
+
if (out.reason === "posted") this.releaseNotice(`task ${label}: ${verdict} mirrored to GitHub as a comment review`);
|
|
3259
|
+
else if (out.reason === "post failed") this.releaseNotice(`task ${label}: verdict mirror could not be posted`);
|
|
3260
|
+
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)}`);
|
|
3261
|
+
}).catch(() => this.releaseNotice(`task ${label}: verdict mirror unavailable`));
|
|
3262
|
+
} catch { return Promise.resolve(); }
|
|
3263
|
+
}
|
|
3264
|
+
/** 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. */
|
|
3265
|
+
private autoMerge(task: Task, after?: Promise<void>): void {
|
|
3266
|
+
try {
|
|
3267
|
+
if (!task.sdlc?.repo || !task.sdlc.pr || mergeModeFor(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc.repo) === "off") return;
|
|
3268
|
+
const label = `#${task.id}`;
|
|
3269
|
+
// Promise.resolve().then also catches a synchronous throw, which would otherwise escape a microtask.
|
|
3270
|
+
void Promise.resolve(after).then(() => this.guardedMerge(HUB, task.id, { trigger: "review" })).catch(() => this.releaseNotice(`task ${label}: auto-merge check unavailable`));
|
|
3271
|
+
} catch { /* an approval never fails because of the merge check */ }
|
|
3272
|
+
}
|
|
3084
3273
|
async releaseReadiness(repo: string, liveIssues: number[]) {
|
|
3085
3274
|
const report = await readReleaseReadiness(this.d.cwd, repo, liveIssues, { gh: this.d.sdlc?.gh });
|
|
3086
3275
|
if (report.documents) report.documents.documents = report.documents.documents.map(d => {
|
|
@@ -4006,6 +4195,21 @@ export class Tasks {
|
|
|
4006
4195
|
return admitted;
|
|
4007
4196
|
}
|
|
4008
4197
|
|
|
4198
|
+
/**
|
|
4199
|
+
* #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
|
|
4200
|
+
* 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
|
|
4201
|
+
* 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.
|
|
4202
|
+
*/
|
|
4203
|
+
private hubVerifiedBlock(task: Task, admission?: ReviewReadiness): string {
|
|
4204
|
+
if (!admission || this.isPii(task)) return "";
|
|
4205
|
+
const head = admission.head ?? this.reviewHead(task) ?? "unknown head";
|
|
4206
|
+
if (admission.ready) {
|
|
4207
|
+
const names = admission.checks?.length ? admission.checks.join(", ") : "the required checks";
|
|
4208
|
+
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`;
|
|
4209
|
+
}
|
|
4210
|
+
return `Hub-verified at ${head}: not ready: ${admission.blockers.join("; ")}.\n`;
|
|
4211
|
+
}
|
|
4212
|
+
|
|
4009
4213
|
private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness): boolean {
|
|
4010
4214
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
4011
4215
|
if (mode === "enforce" && !admission?.ready) {
|
|
@@ -4030,7 +4234,12 @@ export class Tasks {
|
|
|
4030
4234
|
: profile.name === "general"
|
|
4031
4235
|
? "No check ran for this class: verify the outcome yourself."
|
|
4032
4236
|
: "No check ran for this class: run the affected tests yourself.";
|
|
4033
|
-
|
|
4237
|
+
// #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.
|
|
4238
|
+
const verified = this.hubVerifiedBlock(task, admission);
|
|
4239
|
+
const ciPassed = admission?.ready === true && !this.isPii(task);
|
|
4240
|
+
const checkResult = check ? `Check result: ${(check.note ?? "").split("\n")[0]}`
|
|
4241
|
+
: 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."
|
|
4242
|
+
: noCheckWording;
|
|
4034
4243
|
const checklistTemplates = profile.review_checklist ?? [
|
|
4035
4244
|
"Map the changed signatures and call sites to {target}, and name each one that does not match.",
|
|
4036
4245
|
"{check}",
|
|
@@ -4051,10 +4260,16 @@ export class Tasks {
|
|
|
4051
4260
|
? ["Deliverables (the hub hashed these files when the task was done; approval binds these hashes):",
|
|
4052
4261
|
...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
4262
|
: "";
|
|
4263
|
+
const rulings = this.plannerRulings(task, reviewer);
|
|
4264
|
+
const rulingsBlock = rulings.length ? rulings.map(ruling => [
|
|
4265
|
+
`Planner rulings (recorded by the configured planner ${ruling.by}; they amend the plan for this review):`,
|
|
4266
|
+
`- ${new Date(ruling.at).toISOString()}: ${ruling.note.replace(/[\r\n]+/g, " ")}`,
|
|
4267
|
+
].join("\n")).join("\n") + "\n" : "";
|
|
4054
4268
|
const checklist = deliverablesBlock + criteriaBlock + [
|
|
4055
4269
|
"Checklist:",
|
|
4056
4270
|
...this.planGuardItems(task).map(item => `- Check the plan against this live fail before the remaining checks: ${item.line}`),
|
|
4057
4271
|
...checklistLines.map(line => `- ${line}`),
|
|
4272
|
+
...(rulings.length ? ["- a departure a listed ruling names is not an unmet item by itself; judge its code."] : []),
|
|
4058
4273
|
].join("\n");
|
|
4059
4274
|
// #223 R2: a reviewer-only instance judges from facts; the builder's narrative stays out, earlier findings go in.
|
|
4060
4275
|
const reviewOnly = this.d.reviewOnly?.(reviewer) === true;
|
|
@@ -4072,7 +4287,7 @@ export class Tasks {
|
|
|
4072
4287
|
const brief = this.briefText(task);
|
|
4073
4288
|
// #572 AC3: advisory only, from git state never from task text, so it is safe for a PII task too.
|
|
4074
4289
|
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.`;
|
|
4290
|
+
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
4291
|
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
4292
|
}
|
|
4078
4293
|
|
|
@@ -4332,11 +4547,13 @@ export class Tasks {
|
|
|
4332
4547
|
this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
|
|
4333
4548
|
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
|
|
4334
4549
|
await this.releaseDependents(next);
|
|
4550
|
+
this.autoMerge(next, this.mirrorVerdict(next, by, "approved", reviewedHead ?? next.sdlc?.reviewedHead, note, withheld));
|
|
4335
4551
|
// A proposal storage failure cannot roll back the saved verdict; another matching verdict retries it.
|
|
4336
4552
|
this.releaseNotice(`task #${next.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(next, by, parsed, withheld));
|
|
4337
4553
|
return next;
|
|
4338
4554
|
}
|
|
4339
4555
|
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note, flag);
|
|
4556
|
+
void this.mirrorVerdict(rejected, by, "changes_requested", reviewedHead ?? rejected.sdlc?.reviewedHead, note, withheld);
|
|
4340
4557
|
this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
|
|
4341
4558
|
if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
|
|
4342
4559
|
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 */ }
|
|
@@ -4531,17 +4748,20 @@ export class Tasks {
|
|
|
4531
4748
|
}
|
|
4532
4749
|
|
|
4533
4750
|
/** 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);
|
|
4751
|
+
async reassignForRoster(peer: PeerId, current?: () => void): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
4752
|
+
return this.reassignWork(peer, undefined, false, true, current);
|
|
4536
4753
|
}
|
|
4537
4754
|
|
|
4538
|
-
private async reassignWork(peer: PeerId, context: string | undefined, urgentOnly: boolean, roster: boolean): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
4755
|
+
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" }[]> {
|
|
4756
|
+
current?.();
|
|
4539
4757
|
const moved: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[] = [];
|
|
4540
4758
|
const routing = this.d.routing(), why = roster ? `roster removal of ${peer}` : `budget pause of ${peer}`;
|
|
4541
4759
|
// Budget context is screened exactly as before; a roster removal supplies no agent-written context.
|
|
4542
4760
|
const moves = (t: Task) => (!urgentOnly || t.signals.includes("urgent")) && t.owner === peer && OPEN.includes(t.state);
|
|
4543
4761
|
const contextWithheld = context && this.d.board.list().some((t) => moves(t) && !this.isPii(t)) ? await this.withholds("handoff", context) : false;
|
|
4762
|
+
current?.();
|
|
4544
4763
|
for (const task of this.d.board.list()) {
|
|
4764
|
+
current?.();
|
|
4545
4765
|
if (urgentOnly && !task.signals.includes("urgent")) continue;
|
|
4546
4766
|
if (task.owner === peer && (OPEN.includes(task.state) || roster && task.state === "in_review")) {
|
|
4547
4767
|
const pii = this.isPii(task);
|
|
@@ -4552,19 +4772,22 @@ export class Tasks {
|
|
|
4552
4772
|
const back = !roster && task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
|
|
4553
4773
|
let next: Task;
|
|
4554
4774
|
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 } : {}) });
|
|
4775
|
+
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
4776
|
} catch (e) {
|
|
4557
4777
|
// The move is saved and its central #297 notice already spoke; relay the
|
|
4558
4778
|
// remaining tasks and run the roster tail on the saved row as on success.
|
|
4559
4779
|
if (!(e instanceof AssignmentUndeliveredError)) throw e;
|
|
4560
4780
|
next = e.task;
|
|
4561
4781
|
}
|
|
4782
|
+
// A saved assignment failure is retained, but never permits a revoked initiating operation to continue.
|
|
4783
|
+
current?.();
|
|
4562
4784
|
if (!this.ownOwnerMove(back, next, HUB, "reassigned") && !this.ownOwnerMove(back, next, HUB, "unassigned")) continue;
|
|
4563
4785
|
if (roster) {
|
|
4564
4786
|
if (!next.owner || next.owner === peer) continue;
|
|
4565
4787
|
this.releaseNotice(`task ${this.publicTitle(next)} moved from ${peer} to ${next.owner} (off roster)`);
|
|
4566
4788
|
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
4789
|
}
|
|
4790
|
+
current?.();
|
|
4568
4791
|
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
4569
4792
|
} else if (task.reviewer === peer && !isClosedTaskState(task.state)) {
|
|
4570
4793
|
const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class, task) });
|
|
@@ -4573,8 +4796,10 @@ export class Tasks {
|
|
|
4573
4796
|
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
4797
|
continue;
|
|
4575
4798
|
}
|
|
4799
|
+
current?.();
|
|
4576
4800
|
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a), this.reviewMetadata(a));
|
|
4577
4801
|
moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
|
|
4802
|
+
current?.();
|
|
4578
4803
|
if (next.state === "in_review" && reviewer && reviewer !== USER) {
|
|
4579
4804
|
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
4805
|
else this.sendReview(next, reviewer, `(${peer} was reviewing this and is paused for quota.)`);
|
package/src/local/tools.ts
CHANGED
|
@@ -17,6 +17,8 @@ const approvalError = (picked: ToolApproval, denied: string): string | undefined
|
|
|
17
17
|
: picked === "expired" ? APPROVAL_EXPIRED_TEXT
|
|
18
18
|
: picked === "aborted" ? "error: approval withdrawn because the turn ended; no operation was executed" : denied;
|
|
19
19
|
|
|
20
|
+
export type ShellPermitPolicy = { forceAsk?: true };
|
|
21
|
+
export type ShellExecutionCheck = "valid" | "ask" | "aborted";
|
|
20
22
|
export interface ToolContext {
|
|
21
23
|
readOnly?: string[];
|
|
22
24
|
cwd: string;
|
|
@@ -29,7 +31,9 @@ export interface ToolContext {
|
|
|
29
31
|
/** Turn cancellation signal, including the execution-budget wall cap. */
|
|
30
32
|
signal?: AbortSignal;
|
|
31
33
|
/** Only true permits an effect; expiry and abort remain distinct from a person's denial. */
|
|
32
|
-
permit: (title: string, tool?: string, signal?: AbortSignal, canonicalTarget?: string, observe?: ApprovalObserver, originalPath?: string, input?: unknown) => Promise<ToolApproval>;
|
|
34
|
+
permit: (title: string, tool?: string, signal?: AbortSignal, canonicalTarget?: string, observe?: ApprovalObserver, originalPath?: string, input?: unknown, policy?: ShellPermitPolicy) => Promise<ToolApproval>;
|
|
35
|
+
/** Host-only synchronous recheck, immediately before a shell process can be spawned. */
|
|
36
|
+
shellExecutionCheck?: (input: unknown) => ShellExecutionCheck;
|
|
33
37
|
/** Trusted process status, independent of what its stdout says. */
|
|
34
38
|
onProcessExit?: (code: number | null) => void;
|
|
35
39
|
/** Publish a message to other peers mid-turn. Returns a one-line receipt. */
|
|
@@ -102,7 +106,7 @@ export const TOOL_SCHEMAS = [
|
|
|
102
106
|
fn("read", "Read a text file inside the project. Returns numbered lines.", { path: str, offset: { type: "integer", minimum: 1 }, limit: { type: "integer", minimum: 1 } }, ["path"]),
|
|
103
107
|
fn("write", "Create or overwrite a file inside the project. Needs the user's approval.", { path: str, content: str }, ["path", "content"]),
|
|
104
108
|
fn("edit", "Replace one exact, unique occurrence of `old` with `new` in a file. Needs the user's approval.", { path: str, old: str, new: str }, ["path", "old", "new"]),
|
|
105
|
-
fn("bash", "Run a shell command in the project directory, sandboxed: no writes outside the project, no credential reads, no network unless the hub enables it.
|
|
109
|
+
fn("bash", "Run a shell command in the project directory, sandboxed: no writes outside the project, no credential reads, no network unless the hub enables it. Follows hub permission policy: proven bounded read-only commands may run automatically in ask-when-needed; other commands ask.", { command: str, timeout_s: { type: "integer", minimum: 1, maximum: 600 } }, ["command"]),
|
|
106
110
|
fn("git", "Run git in the project. status, diff, log, show, ls-files, blame, rev-parse run freely; add, commit, restore, checkout, switch, stash, mv, rm, branch, merge, rebase, cherry-pick, revert, tag need approval; everything else (push, fetch, config, remote ...) is refused.", { args: { type: "array", items: str, minItems: 1 } }, ["args"]),
|
|
107
111
|
fn("hub_send", "Send a message to the other agents now, before your turn ends. Conclusions only. Your final answer is shared anyway; use this for something that cannot wait.", { text: str, to: { type: "array", items: str } }, ["text"]),
|
|
108
112
|
];
|
|
@@ -179,8 +183,17 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
179
183
|
if (!command.trim()) return "error: empty command";
|
|
180
184
|
// The approver sees the whole command, not a prefix: what is hidden cannot be approved.
|
|
181
185
|
if (command.length > 4000) return "error: command longer than 4000 characters; put it in a script file with write, then run that";
|
|
182
|
-
const
|
|
183
|
-
|
|
186
|
+
const input=Object.freeze({...a,command});
|
|
187
|
+
for(let attempt=0;;attempt++) {
|
|
188
|
+
const refused = approvalError(await ctx.permit(`bash: ${command}`, "bash", ctx.signal, undefined, undefined, undefined, input, attempt?{forceAsk:true}:undefined), "error: the user did not approve this command");
|
|
189
|
+
if (refused) return refused;
|
|
190
|
+
if(ctx.signal?.aborted)return "error: turn cancelled before shell execution";
|
|
191
|
+
let check:ShellExecutionCheck="valid";
|
|
192
|
+
try {check=ctx.shellExecutionCheck?.(input)??"valid";}catch{check="ask";}
|
|
193
|
+
if(check==="aborted" || ctx.signal?.aborted)return "error: turn cancelled before shell execution";
|
|
194
|
+
if(check==="valid")break;
|
|
195
|
+
if(attempt)return "error: read-only shell proof remained unstable; no command was executed";
|
|
196
|
+
}
|
|
184
197
|
const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, readOnlyData: !!ctx.readOnly?.length, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
185
198
|
try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
|
|
186
199
|
return `${res.output}\n(exit ${res.code})`;
|
package/src/ui/index.html
CHANGED
|
@@ -1399,7 +1399,8 @@ function renderBoard(snapshot, peers, ids) {
|
|
|
1399
1399
|
const data=(snapshot.boardLegend || []).find(item=>item.peer===id), row=el('div',undefined,'agent-slot');
|
|
1400
1400
|
row.append(peerMark(id),el('p',String(peers[id].state || 'unknown'),'meta'));
|
|
1401
1401
|
const wip=data && Number.isSafeInteger(data.wip) && data.wip>=0 ? data.wip : 'unknown';
|
|
1402
|
-
|
|
1402
|
+
const split=data?.wipBreakdown,splitText=split && [split.active,split.reviewWait,split.reserved].every(Number.isSafeInteger) ? ' (active '+split.active+', review-wait '+split.reviewWait+', reserved '+split.reserved+')' : '';
|
|
1403
|
+
row.append(el('p','WIP '+wip+(Number.isSafeInteger(data?.wipLimit) ? '/'+data.wipLimit : '')+splitText+' / permission '+(data?.permissionMode || 'unknown'),'meta'));
|
|
1403
1404
|
if (Array.isArray(data?.activeTasks) && data.activeTasks.length) row.append(el('p','Active tasks: '+data.activeTasks.map(id=>'#'+id).join(', '),'meta'));
|
|
1404
1405
|
const filter=el('button','View '+id);filter.type='button';filter.addEventListener('click',()=>{if(!boardViewState)return;peerFilter.value=id;renderBoard(boardViewState.snapshot,boardViewState.peers,boardViewState.ids)});row.append(filter);
|
|
1405
1406
|
if (eligible.includes(id)) dropTarget(row,card=>card.assignable,{action:'assign',peer:id});
|