@staix/agent-hub 0.16.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 +34 -0
- package/README.md +2 -2
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +20 -5
- package/docs/agent-notes/benchmarks.md +1 -0
- package/docs/agent-notes/daemon.md +5 -3
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +9 -3
- package/docs/agent-notes/tests.md +6 -3
- package/docs/dogfood.md +21 -4
- package/docs/memory.md +1 -1
- package/docs/operations.md +150 -34
- package/docs/profiles.md +36 -0
- package/docs/quickstart.md +14 -4
- package/docs/roles.md +1 -0
- package/docs/sdlc.md +39 -13
- package/docs/security.md +41 -9
- package/docs/smoke.md +51 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +56 -10
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
- package/docs/verification/2026-10-25-0.17.0.md +74 -0
- package/docs/verification/2026-10-25-0.18.0.md +76 -0
- package/docs/verification/2026-10-25-codex-0164-reviewer-tool-call.md +52 -0
- package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
- package/docs/verification/2026-10-25-kimi-231-reviewer-compat.md +48 -0
- package/docs/verification/2026-10-25-kimi-usage-ac11.md +48 -0
- package/docs/verification/2026-10-25-qwen-dual-output-hub-e2e.md +23 -0
- package/docs/verification/2026-10-25-qwen-dual-output-t0.md +20 -0
- package/docs/verification/2026-10-25-remote-docker-transport.md +46 -0
- package/docs/verification/2026-10-25-reviewer-no-verdict-diagnostics.md +35 -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 +28 -19
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1115 -192
- package/src/adapters/codex-review-isolation.ts +10 -1
- package/src/adapters/compat.ts +7 -2
- package/src/adapters/conformance/enrollment.ts +18 -4
- package/src/adapters/conformance/factories.ts +4 -1
- package/src/adapters/conformance/kit.ts +11 -0
- package/src/adapters/definitions/builtin-metadata.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-qwen.ts +32 -4
- package/src/adapters/definitions/native-cli-preview-qwen.ts +9 -1
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/definitions/native-recovery.ts +8 -0
- package/src/adapters/definitions/qwen.ts +7 -4
- package/src/adapters/local-worker.ts +5 -3
- package/src/adapters/qwen-dual.ts +578 -0
- package/src/adapters/registry-types.ts +5 -1
- package/src/adapters/registry.ts +9 -1
- package/src/adapters/reviewer-mcp.ts +12 -6
- package/src/adapters/reviewer-print.ts +100 -17
- 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/cli/upgrade.ts +4 -3
- 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 +487 -94
- 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/memory-plan-guard.ts +8 -33
- 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 +266 -28
- package/src/local/tools.ts +17 -4
- package/src/memory/brief.ts +45 -5
- 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;
|
|
@@ -653,7 +655,10 @@ export class Tasks {
|
|
|
653
655
|
if (this.isPii(row)) return false;
|
|
654
656
|
const last = row.history.findLast(entry => entry.reviewFindings?.items.some(value => value.id === item.id));
|
|
655
657
|
const finding = last?.reviewFindings?.items.find(value => value.id === item.id);
|
|
656
|
-
|
|
658
|
+
// #328: a follow-up that only records an earlier task's finding as fixed never had it; it counts once seen open.
|
|
659
|
+
// By id alone: a reclassified finding keeps its id (task note); a withheld review is never evidence.
|
|
660
|
+
const occurred = row.history.some(entry => !entry.withheld && entry.reviewFindings?.items.some(value => value.id === item.id && (value.disposition === "open" || value.disposition === "accepted")));
|
|
661
|
+
return !last?.withheld && finding && same(finding) && occurred && finding.disposition !== "false_positive" && this.findingTextAllowed(row, [finding]);
|
|
657
662
|
});
|
|
658
663
|
if (this.workUnits(holders) < 2) continue;
|
|
659
664
|
const key = `Review finding key: ${item.pass}:${item.id}`;
|
|
@@ -787,7 +792,7 @@ export class Tasks {
|
|
|
787
792
|
const held = this.d.held?.();
|
|
788
793
|
const workload = this.instanceRouting();
|
|
789
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);
|
|
790
|
-
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 } : {}) };
|
|
791
796
|
}
|
|
792
797
|
|
|
793
798
|
private reviewHead(task: Task): string | undefined { return task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; }
|
|
@@ -796,7 +801,7 @@ export class Tasks {
|
|
|
796
801
|
const head = this.reviewHead(task);
|
|
797
802
|
const round = task.state === "in_review" ? task.history.slice(task.history.findLastIndex(entry => entry.event === "done") + 1) : [];
|
|
798
803
|
const failed = [...new Set(round.flatMap(entry => entry.reviewerFailure?.head === head && entry.reviewerFailure ? [entry.reviewerFailure.peer] : []))];
|
|
799
|
-
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 } : {}) };
|
|
800
805
|
}
|
|
801
806
|
|
|
802
807
|
private manuallyNamedReviewer(task: Task): boolean {
|
|
@@ -945,10 +950,31 @@ export class Tasks {
|
|
|
945
950
|
return task.planner && holders.includes(task.planner) && this.d.bus.isRostered(task.planner) ? [task.planner] : holders;
|
|
946
951
|
}
|
|
947
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
|
+
|
|
948
971
|
private planningAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
|
|
949
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" : ""}`);
|
|
950
973
|
}
|
|
951
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
|
+
|
|
952
978
|
private plannerWriteAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
|
|
953
979
|
if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("planner writes are unavailable during a private turn");
|
|
954
980
|
this.planningAuthority(by, task);
|
|
@@ -1012,6 +1038,14 @@ export class Tasks {
|
|
|
1012
1038
|
wip(peer: PeerId, except?: number): number {
|
|
1013
1039
|
return this.d.board.plannerWip(peer, except);
|
|
1014
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
|
+
}
|
|
1015
1049
|
private prerequisiteOf(task: Task, prerequisite: number): boolean {
|
|
1016
1050
|
const pending = [...(task.deps ?? [])], seen = new Set<number>();
|
|
1017
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 ?? [])); }
|
|
@@ -2199,12 +2233,14 @@ export class Tasks {
|
|
|
2199
2233
|
}
|
|
2200
2234
|
|
|
2201
2235
|
/** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
|
|
2202
|
-
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?.();
|
|
2203
2238
|
if (task.state === "backlog" && !opts.plannerPull) throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
|
|
2204
2239
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
2205
2240
|
let stagedClaimGap: string | undefined;
|
|
2206
2241
|
if (mode !== "off" && this.isBuildTask(task)) {
|
|
2207
2242
|
const gap = task.sdlc?.planReview ? await this.planReviewGap(task) : "plan not reviewed since proposal";
|
|
2243
|
+
opts.current?.();
|
|
2208
2244
|
const current = this.d.board.get(task.id);
|
|
2209
2245
|
if (!current || !this.ownerAssignmentCurrent(task, current)) {
|
|
2210
2246
|
if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull changed during admission");
|
|
@@ -2260,6 +2296,7 @@ export class Tasks {
|
|
|
2260
2296
|
const assignmentNote = this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a);
|
|
2261
2297
|
const binding = this.plannerBinding(task, a, !!opts.plannerPull);
|
|
2262
2298
|
const assignmentExtra = { ...this.reviewMetadata(a), ...(binding ? { plannerReason: binding.plannerReason } : {}), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) };
|
|
2299
|
+
opts.current?.();
|
|
2263
2300
|
const next = opts.plannerPull
|
|
2264
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)
|
|
2265
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);
|
|
@@ -2277,6 +2314,7 @@ export class Tasks {
|
|
|
2277
2314
|
// The cohort record formed just below reads it, and so does `route explain` while that turn lasts.
|
|
2278
2315
|
if (opts.claim && a.owner === by && this.d.bus.peers.get(by)?.state === "busy") this.sent.set(handOver(next, by), by);
|
|
2279
2316
|
try {
|
|
2317
|
+
opts.current?.();
|
|
2280
2318
|
if (opts.plannerPull && task.offer) {
|
|
2281
2319
|
const prior = { ...task, owner: task.offer.owner };
|
|
2282
2320
|
this.withdrawRoleRequest(prior, next, "owner");
|
|
@@ -2290,7 +2328,7 @@ export class Tasks {
|
|
|
2290
2328
|
this.tellEarlierOwners(next, hits);
|
|
2291
2329
|
}
|
|
2292
2330
|
this.announceRouting(next, a);
|
|
2293
|
-
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);
|
|
2294
2332
|
} catch (e) {
|
|
2295
2333
|
// Everything between the write and the end of the delivery is in here: the move is fact, and a failure is a
|
|
2296
2334
|
// delivery failure (#297). The one console notice is printed here, once, for every caller; callers decide
|
|
@@ -2368,8 +2406,10 @@ export class Tasks {
|
|
|
2368
2406
|
}
|
|
2369
2407
|
|
|
2370
2408
|
/** `contextWithheld`: the PII screen did not clear a budget hand-off (#198); an escalation's reason is the hub's own words. */
|
|
2371
|
-
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?.();
|
|
2372
2411
|
const brief = this.isPii(task) ? undefined : await this.d.briefs?.forTask(task.owner!, task, this.planGuardItems(task)).catch(() => undefined);
|
|
2412
|
+
currentAuthority?.();
|
|
2373
2413
|
const current = this.d.board.get(task.id);
|
|
2374
2414
|
if (!current || !this.ownerAssignmentCurrent(task, current)) throw new AssignmentSupersededError(current ?? task);
|
|
2375
2415
|
task = current;
|
|
@@ -2399,7 +2439,9 @@ export class Tasks {
|
|
|
2399
2439
|
const owner = task.owner!;
|
|
2400
2440
|
const idle = this.d.bus.stateOf(owner) === "idle";
|
|
2401
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?.();
|
|
2402
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?.();
|
|
2403
2445
|
this.d.bus.publish(env);
|
|
2404
2446
|
if (idle && this.d.bus.stateOf(owner) === "busy" && !this.d.bus.queueIds(owner).includes(env.id)) this.sent.set(handOver(task, owner), owner);
|
|
2405
2447
|
}
|
|
@@ -2559,7 +2601,8 @@ export class Tasks {
|
|
|
2559
2601
|
if (by !== USER) {
|
|
2560
2602
|
this.planningAuthority(by, task);
|
|
2561
2603
|
const proposer = task.history.find(h => h.event === "proposed" || h.event === "backlogged")?.by;
|
|
2562
|
-
|
|
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");
|
|
2563
2606
|
}
|
|
2564
2607
|
return task;
|
|
2565
2608
|
};
|
|
@@ -3049,10 +3092,15 @@ export class Tasks {
|
|
|
3049
3092
|
return this.d.board.update(task.id, by, "lifecycle", { sdlc: next }, undefined, { ...this.planObservation({ ...task, sdlc: next }), ...(invalidatesDeparture ? { planDepartureInvalidated: true } : {}), ...(deliverables ? { deliverables } : {}) });
|
|
3050
3093
|
}
|
|
3051
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
|
+
|
|
3052
3100
|
async deliveryQueue(repo: string) {
|
|
3053
3101
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
3054
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 })));
|
|
3055
|
-
const links = this.
|
|
3103
|
+
const links = this.deliveryLinks(config);
|
|
3056
3104
|
const report = await readMergeQueue(repo, config, links, this.d.sdlc?.gh, this.publicMetadataText);
|
|
3057
3105
|
// Policy can change across remote awaits without any board write. Never return its stale public projection.
|
|
3058
3106
|
const currentPrivacy = this.d.board.list().map(task => ({ id: task.id, private: this.isPii(task) }));
|
|
@@ -3078,6 +3126,150 @@ export class Tasks {
|
|
|
3078
3126
|
}
|
|
3079
3127
|
return report;
|
|
3080
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
|
+
}
|
|
3081
3273
|
async releaseReadiness(repo: string, liveIssues: number[]) {
|
|
3082
3274
|
const report = await readReleaseReadiness(this.d.cwd, repo, liveIssues, { gh: this.d.sdlc?.gh });
|
|
3083
3275
|
if (report.documents) report.documents.documents = report.documents.documents.map(d => {
|
|
@@ -4003,12 +4195,27 @@ export class Tasks {
|
|
|
4003
4195
|
return admitted;
|
|
4004
4196
|
}
|
|
4005
4197
|
|
|
4006
|
-
|
|
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
|
+
|
|
4213
|
+
private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness): boolean {
|
|
4007
4214
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
4008
4215
|
if (mode === "enforce" && !admission?.ready) {
|
|
4009
4216
|
// Background roster/quota role changes are retained, but never invent a remotely admitted review request.
|
|
4010
4217
|
this.releaseNotice(`task ${this.publicTitle(task)}: premature review held; retry the reviewer assignment after current CI and bot replies are verified`);
|
|
4011
|
-
return;
|
|
4218
|
+
return false;
|
|
4012
4219
|
}
|
|
4013
4220
|
|
|
4014
4221
|
const r = task.refs;
|
|
@@ -4027,7 +4234,12 @@ export class Tasks {
|
|
|
4027
4234
|
: profile.name === "general"
|
|
4028
4235
|
? "No check ran for this class: verify the outcome yourself."
|
|
4029
4236
|
: "No check ran for this class: run the affected tests yourself.";
|
|
4030
|
-
|
|
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;
|
|
4031
4243
|
const checklistTemplates = profile.review_checklist ?? [
|
|
4032
4244
|
"Map the changed signatures and call sites to {target}, and name each one that does not match.",
|
|
4033
4245
|
"{check}",
|
|
@@ -4048,10 +4260,16 @@ export class Tasks {
|
|
|
4048
4260
|
? ["Deliverables (the hub hashed these files when the task was done; approval binds these hashes):",
|
|
4049
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"
|
|
4050
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" : "";
|
|
4051
4268
|
const checklist = deliverablesBlock + criteriaBlock + [
|
|
4052
4269
|
"Checklist:",
|
|
4053
4270
|
...this.planGuardItems(task).map(item => `- Check the plan against this live fail before the remaining checks: ${item.line}`),
|
|
4054
4271
|
...checklistLines.map(line => `- ${line}`),
|
|
4272
|
+
...(rulings.length ? ["- a departure a listed ruling names is not an unmet item by itself; judge its code."] : []),
|
|
4055
4273
|
].join("\n");
|
|
4056
4274
|
// #223 R2: a reviewer-only instance judges from facts; the builder's narrative stays out, earlier findings go in.
|
|
4057
4275
|
const reviewOnly = this.d.reviewOnly?.(reviewer) === true;
|
|
@@ -4069,8 +4287,8 @@ export class Tasks {
|
|
|
4069
4287
|
const brief = this.briefText(task);
|
|
4070
4288
|
// #572 AC3: advisory only, from git state never from task text, so it is safe for a PII task too.
|
|
4071
4289
|
const checkout = mainCheckoutWarning(this.d.cwd, this.publicMetadataText);
|
|
4072
|
-
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.`;
|
|
4073
|
-
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 } : {}) }));
|
|
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.`;
|
|
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);
|
|
4074
4292
|
}
|
|
4075
4293
|
|
|
4076
4294
|
private reviewRequestRevision(task: Task): number {
|
|
@@ -4086,25 +4304,35 @@ export class Tasks {
|
|
|
4086
4304
|
}
|
|
4087
4305
|
|
|
4088
4306
|
/** Host-only failure report, bound to the exact review request and its still-current child generation. */
|
|
4089
|
-
async retryReviewer(id: number, failedPeer: PeerId, failure: { head?: string; flowRevision?: number; cause: "start_failed" | "quota" | "no_verdict" }, current: () => boolean): Promise<Task> {
|
|
4307
|
+
async retryReviewer(id: number, failedPeer: PeerId, failure: { head?: string; flowRevision?: number; cause: "start_failed" | "quota" | "no_verdict" }, current: () => boolean, onReoffered?: () => void): Promise<Task> {
|
|
4090
4308
|
if (!failure || !["start_failed", "quota", "no_verdict"].includes(failure.cause) || !/^[a-z][a-z0-9-]{0,63}$/.test(failedPeer) || !Number.isSafeInteger(failure.flowRevision) || failure.flowRevision! < 1 || failure.head !== undefined && (typeof failure.head !== "string" || text(failure.head) !== failure.head || Buffer.byteLength(failure.head) > 1200 || /\p{Cc}/u.test(failure.head))) throw new PreEffectToolRefusal("invalid reviewer failure binding");
|
|
4091
4309
|
const valid = () => {
|
|
4092
4310
|
const task = this.need(id, false, true);
|
|
4093
|
-
if (!current() || task.state !== "in_review" || task.reviewer !== failedPeer || this.reviewRequestRevision(task) !== failure.flowRevision || this.reviewHead(task) !== failure.head || this.isPii(task) || this.manuallyNamedReviewer(task)) throw new PreEffectToolRefusal("reviewer failure is no longer current");
|
|
4311
|
+
if (!current() || task.state !== "in_review" || task.reviewer !== failedPeer || this.reviewRequestRevision(task) !== failure.flowRevision || this.reviewHead(task) !== failure.head || this.isPii(task) || this.manuallyNamedReviewer(task) && failure.cause !== "no_verdict") throw new PreEffectToolRefusal("reviewer failure is no longer current");
|
|
4094
4312
|
return task;
|
|
4095
4313
|
};
|
|
4096
4314
|
const before = valid();
|
|
4097
4315
|
const admission = await this.reviewRequestAdmission(before, undefined, true, before.owner ?? USER, undefined, () => { valid(); return true; });
|
|
4098
4316
|
const task = valid();
|
|
4099
4317
|
const options = this.reviewPoolOptions(task);
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
const
|
|
4318
|
+
// A person-selected reviewer never falls through to an agent the person did not select.
|
|
4319
|
+
const manual = this.manuallyNamedReviewer(task);
|
|
4320
|
+
const selection = manual ? undefined : assign(task, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), reviewPool: { ...options, failed: [...options.failed, failedPeer] } });
|
|
4321
|
+
if (!selection?.reviewSelection && failure.cause !== "no_verdict") throw new PreEffectToolRefusal("no declared reviewer pool can receive the fallback");
|
|
4322
|
+
const reviewer = selection?.reviewSelection ? selection.reviewer ?? USER : USER;
|
|
4103
4323
|
valid();
|
|
4104
|
-
const
|
|
4324
|
+
const note = manual ? "named reviewer recorded no verdict; the person must review"
|
|
4325
|
+
: selection?.reviewSelection ? this.assignmentNote(`reviewer unavailable: ${failure.cause}`, selection) : "no declared reviewer pool can receive the fallback; the person must review";
|
|
4326
|
+
const next = this.d.board.update(task.id, HUB, "reviewer unavailable", { reviewer }, note, { ...this.reviewMetadata(selection), ...(reviewer === USER ? { reviewBlocked: true } : {}), reviewerFailure: { peer: failedPeer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } }, task.history.length);
|
|
4105
4327
|
this.releaseNotice(`task ${this.publicTitle(next)}: former review request withdrawal incomplete`, () => this.withdrawRoleRequest(task, next, "reviewer"));
|
|
4106
|
-
|
|
4107
|
-
|
|
4328
|
+
let offered = false;
|
|
4329
|
+
if (reviewer === USER) {
|
|
4330
|
+
const notice = `task ${this.publicTitle(next)}: blocked: the person must review`;
|
|
4331
|
+
this.releaseNotice(notice, () => { this.d.notify(notice); offered = true; });
|
|
4332
|
+
} else this.releaseNotice(`task ${this.publicTitle(next)}: fallback review request unavailable`, () => { offered = this.sendReview(next, reviewer, "", admission); });
|
|
4333
|
+
if (offered && current() && this.reviewBindingCurrent(reviewer, next.id, { head: this.reviewHead(next), flowRevision: this.reviewRequestRevision(next) })) {
|
|
4334
|
+
try { onReoffered?.(); } catch { /* Unconfirmed host settlement retains the delivery hold. */ }
|
|
4335
|
+
}
|
|
4108
4336
|
return next;
|
|
4109
4337
|
}
|
|
4110
4338
|
|
|
@@ -4319,11 +4547,13 @@ export class Tasks {
|
|
|
4319
4547
|
this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
|
|
4320
4548
|
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
|
|
4321
4549
|
await this.releaseDependents(next);
|
|
4550
|
+
this.autoMerge(next, this.mirrorVerdict(next, by, "approved", reviewedHead ?? next.sdlc?.reviewedHead, note, withheld));
|
|
4322
4551
|
// A proposal storage failure cannot roll back the saved verdict; another matching verdict retries it.
|
|
4323
4552
|
this.releaseNotice(`task #${next.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(next, by, parsed, withheld));
|
|
4324
4553
|
return next;
|
|
4325
4554
|
}
|
|
4326
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);
|
|
4327
4557
|
this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
|
|
4328
4558
|
if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
|
|
4329
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 */ }
|
|
@@ -4518,17 +4748,20 @@ export class Tasks {
|
|
|
4518
4748
|
}
|
|
4519
4749
|
|
|
4520
4750
|
/** A roster removal offers work through the quota relay's constraints, preserving assignments nobody can take. */
|
|
4521
|
-
async reassignForRoster(peer: PeerId): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
4522
|
-
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);
|
|
4523
4753
|
}
|
|
4524
4754
|
|
|
4525
|
-
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?.();
|
|
4526
4757
|
const moved: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[] = [];
|
|
4527
4758
|
const routing = this.d.routing(), why = roster ? `roster removal of ${peer}` : `budget pause of ${peer}`;
|
|
4528
4759
|
// Budget context is screened exactly as before; a roster removal supplies no agent-written context.
|
|
4529
4760
|
const moves = (t: Task) => (!urgentOnly || t.signals.includes("urgent")) && t.owner === peer && OPEN.includes(t.state);
|
|
4530
4761
|
const contextWithheld = context && this.d.board.list().some((t) => moves(t) && !this.isPii(t)) ? await this.withholds("handoff", context) : false;
|
|
4762
|
+
current?.();
|
|
4531
4763
|
for (const task of this.d.board.list()) {
|
|
4764
|
+
current?.();
|
|
4532
4765
|
if (urgentOnly && !task.signals.includes("urgent")) continue;
|
|
4533
4766
|
if (task.owner === peer && (OPEN.includes(task.state) || roster && task.state === "in_review")) {
|
|
4534
4767
|
const pii = this.isPii(task);
|
|
@@ -4539,19 +4772,22 @@ export class Tasks {
|
|
|
4539
4772
|
const back = !roster && task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
|
|
4540
4773
|
let next: Task;
|
|
4541
4774
|
try {
|
|
4542
|
-
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 } : {}) });
|
|
4543
4776
|
} catch (e) {
|
|
4544
4777
|
// The move is saved and its central #297 notice already spoke; relay the
|
|
4545
4778
|
// remaining tasks and run the roster tail on the saved row as on success.
|
|
4546
4779
|
if (!(e instanceof AssignmentUndeliveredError)) throw e;
|
|
4547
4780
|
next = e.task;
|
|
4548
4781
|
}
|
|
4782
|
+
// A saved assignment failure is retained, but never permits a revoked initiating operation to continue.
|
|
4783
|
+
current?.();
|
|
4549
4784
|
if (!this.ownOwnerMove(back, next, HUB, "reassigned") && !this.ownOwnerMove(back, next, HUB, "unassigned")) continue;
|
|
4550
4785
|
if (roster) {
|
|
4551
4786
|
if (!next.owner || next.owner === peer) continue;
|
|
4552
4787
|
this.releaseNotice(`task ${this.publicTitle(next)} moved from ${peer} to ${next.owner} (off roster)`);
|
|
4553
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.)`));
|
|
4554
4789
|
}
|
|
4790
|
+
current?.();
|
|
4555
4791
|
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
4556
4792
|
} else if (task.reviewer === peer && !isClosedTaskState(task.state)) {
|
|
4557
4793
|
const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class, task) });
|
|
@@ -4560,8 +4796,10 @@ export class Tasks {
|
|
|
4560
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}`);
|
|
4561
4797
|
continue;
|
|
4562
4798
|
}
|
|
4799
|
+
current?.();
|
|
4563
4800
|
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a), this.reviewMetadata(a));
|
|
4564
4801
|
moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
|
|
4802
|
+
current?.();
|
|
4565
4803
|
if (next.state === "in_review" && reviewer && reviewer !== USER) {
|
|
4566
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.)`));
|
|
4567
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})`;
|