@staix/agent-hub 0.28.0 → 0.29.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 +10 -0
- package/README.md +2 -2
- package/docs/agent-notes/daemon.md +2 -0
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +3 -1
- package/docs/agent-notes/tests.md +1 -0
- package/docs/design/task-board.md +1 -1
- package/docs/dogfood.md +6 -3
- package/docs/operations.md +73 -11
- package/docs/quickstart.md +11 -3
- package/docs/sdlc.md +1 -1
- package/docs/security.md +17 -2
- package/docs/verification/2026-11-03-0.29.0.md +59 -0
- package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
- package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
- package/docs/verification/2026-11-03-live-board-latency.json +556 -0
- package/docs/verification/2026-11-03-live-board-latency.md +64 -0
- package/docs/verified.json +13 -13
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +524 -18
- package/src/cli/help.ts +1 -1
- package/src/cli/output.ts +5 -0
- package/src/cli/recovery-package.ts +9 -0
- package/src/hub/board.ts +3 -2
- package/src/hub/control-client.ts +16 -1
- package/src/hub/control-write-trace.ts +409 -0
- package/src/hub/daemon.ts +171 -47
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/routing.ts +9 -2
- package/src/hub/runtime-capture-prune.ts +4 -1
- package/src/hub/sdlc-release-authorization.ts +13 -4
- package/src/hub/sdlc-release.ts +23 -10
- package/src/hub/sdlc.ts +15 -2
- package/src/hub/task-worktree.ts +331 -0
- package/src/hub/tasks.ts +162 -17
- package/src/local/deny.ts +49 -0
- package/src/local/tools.ts +4 -2
- package/src/memory/brief.ts +5 -0
- package/src/memory/client.ts +5 -2
package/src/hub/tasks.ts
CHANGED
|
@@ -68,13 +68,14 @@ import { parseClaimVerdict, claimListId, CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEY
|
|
|
68
68
|
import type { Bus } from "./bus.ts";
|
|
69
69
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
70
70
|
import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
|
|
71
|
-
import { assign, isRemotePeerId, detectSignals, peerEntry, predictSplit, relayCandidates, type Assignment, type InstanceRoutingOptions, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
71
|
+
import { assign, isRemotePeerId, detectSignals, peerEntry, predictSplit, relayCandidates, writesRepository, type Assignment, type InstanceRoutingOptions, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
72
72
|
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
73
73
|
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
74
74
|
import { realPath } from "./project.ts";
|
|
75
75
|
import { TaskRepositories } from "./task-repository.ts";
|
|
76
76
|
import { mainCheckoutWarning } from "./main-checkout.ts";
|
|
77
77
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
78
|
+
import type { TaskWorktreePort } from "./task-worktree.ts";
|
|
78
79
|
import { coverageGap, renderReviewBrief, renderSpecTaskReport, type BriefFile, type ReviewBrief } from "./review-brief.ts";
|
|
79
80
|
import { DEFAULT_SPECS_CONFIG, type SpecsConfig } from "./specs-config.ts";
|
|
80
81
|
import type { SpecsCheckReport, SpecsCoverReport } from "./specs.ts";
|
|
@@ -82,7 +83,7 @@ import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
|
|
|
82
83
|
import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
83
84
|
import { plannerGrooming, nextSweep, plannedStartPending, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
84
85
|
import { CiWatchers, pollCi, type CiOutcome, type CiWatch } from "./ci-watch.ts";
|
|
85
|
-
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";
|
|
86
|
+
import { planningIssueReceipt, linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSdlcPrHead, 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";
|
|
86
87
|
|
|
87
88
|
function requireLocalTaskPeer(peer: unknown): void {
|
|
88
89
|
if (isRemotePeerId(peer)) throw new PreEffectToolRefusal("remote peers have no local task authority");
|
|
@@ -217,6 +218,8 @@ export interface TasksDeps {
|
|
|
217
218
|
review?: { adaptive: boolean; min_reviews: number; pool?: readonly string[]; rerotate?: boolean; same_family?: "fallback" | "never" };
|
|
218
219
|
/** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
|
|
219
220
|
capabilities?: () => Record<PeerId, PeerCapabilities>;
|
|
221
|
+
/** #1115: the hub makes, commits and clears the task worktree of a hub-sandboxed peer. Absent: such a peer keeps the #1081 refusal. */
|
|
222
|
+
taskWorktree?: TaskWorktreePort;
|
|
220
223
|
repository?: string;
|
|
221
224
|
offerRead?: (peer: PeerId, envelopeId: string) => boolean;
|
|
222
225
|
offerPullOnly?: (peer: PeerId) => boolean;
|
|
@@ -429,6 +432,7 @@ export interface ExperimentView { attempts: ExperimentAttemptView[]; counts: { a
|
|
|
429
432
|
comparison?: { planHash: string; baselineAttemptId: string; variantAttemptId: string; approved: boolean; private: boolean; projection: ReturnType<typeof comparisonProjection> | "[pii]" } }
|
|
430
433
|
interface ExperimentContext { task: Task; campaign: ResearchCampaignRecord; private: boolean; key: string; fence: ExperimentWriteFence }
|
|
431
434
|
interface CompletionRefresh { revision: number; commit: string; state: "in_review" | "approved" }
|
|
435
|
+
interface RefileHeadGuard { recorded?: string }
|
|
432
436
|
interface IdentityLink { repo: string; pr: number; basis: { commit?: string; branch?: string } }
|
|
433
437
|
|
|
434
438
|
/** What one live check of a task's files says as a whole (#317): `unchanged` only when EVERY file measured unchanged. A change outranks a missing file, which outranks one the hub could not read. */
|
|
@@ -1335,6 +1339,38 @@ export class Tasks {
|
|
|
1335
1339
|
|
|
1336
1340
|
private reviewHead(task: Task): string | undefined { return task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; }
|
|
1337
1341
|
|
|
1342
|
+
private recordedReviewHead(task: Task): string | undefined {
|
|
1343
|
+
const isFullHead = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{40}$/.test(value);
|
|
1344
|
+
const historyHead = task.history.findLast(entry => (entry.event === "approved" || entry.event === "changes_requested") && isFullHead(entry.reviewedHead))?.reviewedHead;
|
|
1345
|
+
return [task.sdlc?.reviewedHead, historyHead].find(isFullHead);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/** An owner ref, when present, outranks host-derived receipt.head values for review identity. */
|
|
1349
|
+
private ownerReviewHead(task: Task): string | undefined {
|
|
1350
|
+
return /^[a-f0-9]{40}$/.test(task.refs.commit ?? "") ? task.refs.commit : this.recordedReviewHead(task);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
private hasReviewHistory(task: Task): boolean {
|
|
1354
|
+
return task.state === "changes_requested" || !!task.sdlc?.reviewedHead || task.history.some(entry => entry.event === "approved" || entry.event === "changes_requested");
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
private reviewHeadMoveRefusal(taskId: number, recorded: string | undefined, current?: string): PreEffectToolRefusal {
|
|
1358
|
+
const known = recorded ? `recorded review head ${recorded}` : "a recorded full review head";
|
|
1359
|
+
const cause = current ? `${known} differs from current head of PR ${current}`
|
|
1360
|
+
: `current PR head could not be verified against ${known}`;
|
|
1361
|
+
const next = current ? `refs.commit set to ${current}` : "refs.commit set to the current full SHA";
|
|
1362
|
+
return new PreEffectToolRefusal(`task #${taskId}: ${cause}; owner must re-file hub_task_done with ${next} before review`);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
private async currentPrHeadForReview(task: Task, validate: () => Task): Promise<string | undefined> {
|
|
1366
|
+
const receipt = task.sdlc, gh = this.d.sdlc?.gh;
|
|
1367
|
+
if (this.isPii(task) || !receipt?.repo || !receipt.pr || !gh) return undefined;
|
|
1368
|
+
const identity = (value: Task) => JSON.stringify([value.id, value.state, value.owner, value.reviewer, value.history.length, this.isPii(value), value.refs, value.sdlc]);
|
|
1369
|
+
const before = identity(task), head = await readSdlcPrHead(receipt, gh), current = validate();
|
|
1370
|
+
if (identity(current) !== before) throw new PreEffectToolRefusal(`task #${task.id}: task or review head changed during PR readback; reload and retry`);
|
|
1371
|
+
return head;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1338
1374
|
private reviewPoolOptions(task: Task) {
|
|
1339
1375
|
const head = this.reviewHead(task);
|
|
1340
1376
|
const round = task.state === "in_review" ? task.history.slice(task.history.findLastIndex(entry => entry.event === "done") + 1) : [];
|
|
@@ -1377,16 +1413,18 @@ export class Tasks {
|
|
|
1377
1413
|
* sandboxed peer from accepting work, failing at `git worktree add` (#270) and leaving an empty branch ref behind (#621, #565). Unknown capability is
|
|
1378
1414
|
* not a refusal, and a PII task skips the check as `requireCapabilities` does.
|
|
1379
1415
|
*/
|
|
1380
|
-
private declaredNeedCheck(task: Pick<Task, "signals" | "id"> & Partial<Pick<Task, "signals">>, peer: PeerId): void {
|
|
1416
|
+
private declaredNeedCheck(task: Pick<Task, "signals" | "id"> & Partial<Pick<Task, "signals" | "class">>, peer: PeerId): void {
|
|
1381
1417
|
if (!this.d.capabilities || this.isPii(task as Task)) return;
|
|
1382
|
-
|
|
1418
|
+
// #1129: write-class work needs the hub's worktree whether or not it says so, because a hub-sandboxed owner cannot commit.
|
|
1419
|
+
const declaredWorktree = task.signals?.includes("needs_worktree") === true;
|
|
1420
|
+
const wantsNetwork = task.signals?.includes("needs_network") === true, wantsWorktree = declaredWorktree || (task.class !== undefined && writesRepository(task.class));
|
|
1383
1421
|
if (!wantsNetwork && !wantsWorktree) return;
|
|
1384
1422
|
const caps = peerEntry(this.d.capabilities(), peer);
|
|
1385
1423
|
if (wantsNetwork && caps?.network === false) {
|
|
1386
1424
|
throw new PreEffectToolRefusal(`task #${task.id} needs the network and ${peer} has none: ${caps.networkReason ?? "its shell cannot reach the network"}; decline it so the hub offers it to a peer with network, or ask the person to enable it`);
|
|
1387
1425
|
}
|
|
1388
1426
|
if (wantsWorktree && caps?.worktree === false) {
|
|
1389
|
-
throw new PreEffectToolRefusal(`task #${task.id} needs its own git worktree and ${peer} cannot create one: ${caps.worktreeReason ?? "it cannot create the task worktree"}; nothing was created, so no branch ref is left behind; decline it so the hub offers it to a peer that can, this will not change by waiting`);
|
|
1427
|
+
throw new PreEffectToolRefusal(`task #${task.id} ${declaredWorktree ? "needs its own git worktree" : "changes the repository, so the hub must make its git worktree and commit for it"} and ${peer} cannot create one: ${caps.worktreeReason ?? "it cannot create the task worktree"}; nothing was created, so no branch ref is left behind; decline it so the hub offers it to a peer that can, this will not change by waiting`);
|
|
1390
1428
|
}
|
|
1391
1429
|
}
|
|
1392
1430
|
|
|
@@ -4044,7 +4082,17 @@ export class Tasks {
|
|
|
4044
4082
|
}
|
|
4045
4083
|
if (stagedClaimGap) return next; // An explicit claim may reserve its owner, but no offer or acceptance bypasses review.
|
|
4046
4084
|
this.studyDispatchCheck(next, by);
|
|
4047
|
-
|
|
4085
|
+
// #1129: a self-claim of write-class work is an accept too, so a hub-sandboxed claimant gets the hub's worktree here, before the write. When the hub cannot make one the claim stays an assignment, like the missing-spec case above: `hub_task_accept` starts it and refuses with the same cause.
|
|
4086
|
+
let undoWorktree: (() => void) | undefined;
|
|
4087
|
+
try { undoWorktree = this.hubWorktreeFor(next, by); }
|
|
4088
|
+
catch (error) {
|
|
4089
|
+
if (!(error instanceof PreEffectToolRefusal)) throw error;
|
|
4090
|
+
this.releaseNotice(`${error.message}; the claim stays assigned to ${by} and is not started`);
|
|
4091
|
+
return next;
|
|
4092
|
+
}
|
|
4093
|
+
let claimed: Task;
|
|
4094
|
+
try { claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" }); }
|
|
4095
|
+
catch (error) { undoWorktree?.(); throw error; }
|
|
4048
4096
|
this.captureOwnerAcceptance(claimed);
|
|
4049
4097
|
this.releaseNotice(`task ${this.publicTitle(claimed)} claimed by ${by}`);
|
|
4050
4098
|
return claimed;
|
|
@@ -4080,7 +4128,8 @@ export class Tasks {
|
|
|
4080
4128
|
/** A self-claim has no assignment envelope; its tool result carries the same bounded guidance. */
|
|
4081
4129
|
claimRoleGuidance(task: Task, peer: PeerId): string {
|
|
4082
4130
|
requireLocalTaskPeer(peer);
|
|
4083
|
-
|
|
4131
|
+
// #1129: a claim by a hub-sandboxed peer carries the worktree note as an accept does, since the claim result is all it reads.
|
|
4132
|
+
return task.owner === peer && task.state === "in_progress" ? [this.roleGuidance(task, peer), this.worktreeNote(task, peer)].filter(Boolean).join("\n") : "";
|
|
4084
4133
|
}
|
|
4085
4134
|
|
|
4086
4135
|
private roleGuidance(task: Task, peer: PeerId, review = false): string {
|
|
@@ -4493,6 +4542,7 @@ export class Tasks {
|
|
|
4493
4542
|
const capacity = this.capacityBefore(task);
|
|
4494
4543
|
this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
|
|
4495
4544
|
const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
|
|
4545
|
+
this.releaseHubWorktree(task, task.owner);
|
|
4496
4546
|
this.writePlannerNotices(next, capacity);
|
|
4497
4547
|
this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
|
|
4498
4548
|
this.offered.delete(task.id);
|
|
@@ -5648,10 +5698,18 @@ export class Tasks {
|
|
|
5648
5698
|
|
|
5649
5699
|
/** The eval record the last approval-time lifecycle read produced for a task; consumed by the verdict that follows it. */
|
|
5650
5700
|
private readonly evalRecords = new Map<number, string>();
|
|
5651
|
-
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[]): Promise<PrEvidence | undefined> {
|
|
5701
|
+
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[], refileHead?: RefileHeadGuard): Promise<PrEvidence | undefined> {
|
|
5652
5702
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
5653
5703
|
if (config.mode === "off") return;
|
|
5654
5704
|
const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
|
|
5705
|
+
const approvalHead = transition === "approval" ? reviewedHead ?? this.ownerReviewHead(task) ?? this.findingHead(task) : undefined;
|
|
5706
|
+
const guardedHead = !!refileHead || !!approvalHead && !!receipt?.repo && !!receipt.pr && !this.isPii(task);
|
|
5707
|
+
// Compare raw head identity on both sides; `task` may carry an uncommitted identityLink.
|
|
5708
|
+
// Completion state/history races stay with completionCurrent's existing post-readback fence.
|
|
5709
|
+
const identity = (value: Task) => JSON.stringify([value.id, value.owner, value.reviewer, this.isPii(value),
|
|
5710
|
+
value.refs.repo, value.refs.commit, value.refs.branch, value.sdlc?.repo, value.sdlc?.pr,
|
|
5711
|
+
value.sdlc?.head, value.sdlc?.localHead, value.sdlc?.reviewedHead]);
|
|
5712
|
+
const beforeHeadRead = guardedHead ? identity(this.need(task.id, false, true)) : undefined;
|
|
5655
5713
|
const deadline = performance.now() + 20_000;
|
|
5656
5714
|
const read: SdlcGh = (args, timeout, cwd, cap) => {
|
|
5657
5715
|
const current = this.need(task.id, false, true);
|
|
@@ -5660,6 +5718,16 @@ export class Tasks {
|
|
|
5660
5718
|
return remaining > 0 ? (this.d.sdlc?.gh ?? sdlcGh)(args, Math.min(timeout ?? 10_000, remaining), cwd, cap) : Promise.resolve({ code: 1, stdout: "" });
|
|
5661
5719
|
};
|
|
5662
5720
|
const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read, true) : undefined;
|
|
5721
|
+
const currentHead = receipt && !this.isPii(task) && (refileHead || approvalHead) && !evidence?.head
|
|
5722
|
+
? await readSdlcPrHead(receipt, read) : evidence?.head;
|
|
5723
|
+
if (beforeHeadRead !== undefined) {
|
|
5724
|
+
const current = this.need(task.id, false, true);
|
|
5725
|
+
if (identity(current) !== beforeHeadRead) throw new PreEffectToolRefusal(`task #${task.id}: task or review head changed during PR readback; reload and retry`);
|
|
5726
|
+
}
|
|
5727
|
+
if (refileHead) {
|
|
5728
|
+
if (!refileHead.recorded || !currentHead) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded);
|
|
5729
|
+
if (currentHead !== refileHead.recorded) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded, currentHead);
|
|
5730
|
+
} else if (approvalHead && currentHead && currentHead !== approvalHead) throw this.reviewHeadMoveRefusal(task.id, approvalHead, currentHead);
|
|
5663
5731
|
const integrity = publicTestIntegrity(receipt && evidence && !this.isPii(task) ? await readTestIntegrity(receipt, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText);
|
|
5664
5732
|
const evalResult = receipt && !this.isPii(task) ? await readEvalFor(config, evidence, receipt.repo, read) : undefined;
|
|
5665
5733
|
const current = this.need(task.id, false, true);
|
|
@@ -5863,8 +5931,13 @@ export class Tasks {
|
|
|
5863
5931
|
const repositoryChanged = this.repositories.scope(task.refs).key !== this.repositories.scope(draft.refs).key;
|
|
5864
5932
|
const before = new Set(this.overlapHits(task).map((h) => h.task.id));
|
|
5865
5933
|
this.studyDispatchCheck(task, task.owner ?? undefined);
|
|
5866
|
-
|
|
5867
|
-
const
|
|
5934
|
+
// #1115: a hub-sandboxed peer cannot run `git worktree add`, so for work that declares a worktree the hub makes it here, before any write. Anything that throws below clears it again.
|
|
5935
|
+
const undoWorktree = this.hubWorktreeFor(task, task.owner ?? by);
|
|
5936
|
+
let next: Task;
|
|
5937
|
+
try {
|
|
5938
|
+
this.acceptanceCapacity(task, 0, "displace"); // #1080: every gate has passed; synchronous from here to the accept write
|
|
5939
|
+
next = this.linkOnWrite(this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}), ...(metadata ? { refs: metadata } : {}) })); // #723: refs.repo may have just arrived
|
|
5940
|
+
} catch (error) { undoWorktree?.(); throw error; }
|
|
5868
5941
|
this.captureOwnerAcceptance(next);
|
|
5869
5942
|
this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
|
|
5870
5943
|
if (repositoryChanged) this.cohorts.leave(next.id);
|
|
@@ -5925,6 +5998,51 @@ export class Tasks {
|
|
|
5925
5998
|
/** #572 AC3: what the owner's `hub_task_done` result adds while the project root checkout is on a task branch. */
|
|
5926
5999
|
checkoutNote = (): string | undefined => { const warning = mainCheckoutWarning(this.d.cwd, this.publicMetadataText); return warning ? `Note: ${warning}.` : undefined; };
|
|
5927
6000
|
|
|
6001
|
+
/**
|
|
6002
|
+
* #1115, #1129: the hub makes the worktree of a hub-sandboxed owner for write-class work (`implement`, `bulk_edit`) and for any work that declares `needs_worktree`. Returns a function that clears it again, or
|
|
6003
|
+
* undefined when there is nothing to clear (another peer, a read-only class that did not declare it, a PII task, or a worktree an earlier accept already made). A failure refuses the
|
|
6004
|
+
* accept before any board write and names the cause and the next step; nothing is left behind.
|
|
6005
|
+
*/
|
|
6006
|
+
private hubWorktreeFor(task: Task, peer: PeerId): (() => void) | undefined {
|
|
6007
|
+
const port = this.d.taskWorktree;
|
|
6008
|
+
if (!port || peer === USER || peer === HUB || !port.applies(peer) || this.isPii(task) || !(task.signals.includes("needs_worktree") || writesRepository(task.class))) return undefined;
|
|
6009
|
+
const made = port.create({ peer, task: task.id });
|
|
6010
|
+
if (!made.ok) throw new PreEffectToolRefusal(`task #${task.id} needs its own git worktree and the hub could not make one for ${peer}: ${made.reason}; nothing was created, so no branch ref is left behind. Decline the task so the hub offers it to a peer that makes its own worktree`);
|
|
6011
|
+
return made.created ? () => { try { port.remove({ peer, task: task.id }); } catch { /* the accept error that got us here is the one reported */ } } : undefined;
|
|
6012
|
+
}
|
|
6013
|
+
|
|
6014
|
+
/** The hub-made worktree of the task's owner, for the accept answer: where to edit, and what the hub does at `hub_task_done`. */
|
|
6015
|
+
worktreeNote(task: Task, peer: PeerId): string | undefined {
|
|
6016
|
+
const found = this.d.taskWorktree?.describe(peer, task.id);
|
|
6017
|
+
return found ? `Worktree: the hub made ${found.path} on branch ${found.branch} for this task. Edit files there only; do not run git. At hub_task_done the hub stages, commits and pushes it for you.` : undefined;
|
|
6018
|
+
}
|
|
6019
|
+
|
|
6020
|
+
/**
|
|
6021
|
+
* #1115: at `hub_task_done` the hub commits and pushes a hub-sandboxed peer's worktree and answers the refs the done carries. Undefined when the peer made no
|
|
6022
|
+
* hub worktree for this task (it commits itself). A refused commit or push is a pre-effect refusal that names the cause and the next step: the board is untouched.
|
|
6023
|
+
*/
|
|
6024
|
+
async worktreeHandoff(by: PeerId, id: unknown, refs: unknown): Promise<TaskRefs | undefined> {
|
|
6025
|
+
const port = this.d.taskWorktree;
|
|
6026
|
+
if (!port || by === USER || by === HUB || !port.applies(by)) return undefined;
|
|
6027
|
+
const task = this.need(id, false, true);
|
|
6028
|
+
this.mine(task, by, "owner", true);
|
|
6029
|
+
if (this.isPii(task) || !port.describe(by, task.id)) return undefined;
|
|
6030
|
+
const result = await port.commitAndPush({ peer: by, task: task.id, title: task.title });
|
|
6031
|
+
if (!result.ok) throw new PreEffectToolRefusal(`hub_task_done for task #${task.id}: ${result.reason}`);
|
|
6032
|
+
if (result.note) this.doneNotes.set(task.id, result.note);
|
|
6033
|
+
return { ...cleanRefs(refs), commit: result.commit, branch: result.branch };
|
|
6034
|
+
}
|
|
6035
|
+
|
|
6036
|
+
/** Decline or cancel: clear the hub-made worktree and its empty branch. Uncommitted work is kept and reported, never force-removed. */
|
|
6037
|
+
private releaseHubWorktree(task: Task, peer: PeerId | null | undefined): void {
|
|
6038
|
+
const port = this.d.taskWorktree;
|
|
6039
|
+
if (!port || !peer || peer === USER || peer === HUB || !port.applies(peer)) return;
|
|
6040
|
+
this.releaseNotice(`task #${task.id}: the hub-made worktree could not be cleared`, () => {
|
|
6041
|
+
const removal = port.remove({ peer, task: task.id });
|
|
6042
|
+
if (removal.kept) this.d.notify(`task #${task.id}: ${removal.kept}`);
|
|
6043
|
+
});
|
|
6044
|
+
}
|
|
6045
|
+
|
|
5928
6046
|
/**
|
|
5929
6047
|
* `capacity` (#531): the owner has no room right now, so the decline excludes it only from this move's own reroute
|
|
5930
6048
|
* (below), never from a later reroute or planner pull. Without it, a decline is a lasting refusal (`excluded()`), and so
|
|
@@ -5939,6 +6057,7 @@ export class Tasks {
|
|
|
5939
6057
|
const back = this.d.board.update(task.id, by, "declined", { ...(["in_progress", "changes_requested"].includes(task.state) ? { state: "proposed" as const } : {}), ...(planned ? { reserved: null } : {}) }, reason, task.owner ? { from: task.owner, ...(capacity === true ? { capacity: true as const } : {}) } : {});
|
|
5940
6058
|
if (planned) this.offered.delete(task.id);
|
|
5941
6059
|
this.d.notify(`task ${this.publicTitle(back)} declined${capacity === true ? " (capacity only)" : ""} by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
|
|
6060
|
+
this.releaseHubWorktree(task, task.owner ?? by);
|
|
5942
6061
|
// A capacity decline is not in excluded(), so this move's own reroute leaves the decliner out explicitly.
|
|
5943
6062
|
try {
|
|
5944
6063
|
return await this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
|
|
@@ -6122,11 +6241,20 @@ export class Tasks {
|
|
|
6122
6241
|
let refresh: CompletionRefresh | undefined;
|
|
6123
6242
|
if (initial.state === "in_review" || initial.state === "approved") {
|
|
6124
6243
|
const commit = cleanRefs(refs).commit;
|
|
6125
|
-
|
|
6126
|
-
|
|
6244
|
+
const recorded = this.ownerReviewHead(initial) ?? this.findingHead(initial);
|
|
6245
|
+
const currentReviewHead = this.ownerReviewHead(initial) ?? this.reviewHead(initial);
|
|
6246
|
+
if (!commit || !/^[a-f0-9]{40}$/.test(commit) || commit === currentReviewHead) {
|
|
6247
|
+
throw new PreEffectToolRefusal(`task #${initial.id} is already ${initial.state}${recorded ? ` with recorded review head ${recorded}` : ""}; re-file requires a different full head SHA. Owner must re-file hub_task_done with refs.commit set to the new full SHA`);
|
|
6248
|
+
}
|
|
6127
6249
|
refresh = { revision: initial.history.length, commit, state: initial.state };
|
|
6128
6250
|
}
|
|
6129
6251
|
const submittedCommit = cleanRefs(refs).commit;
|
|
6252
|
+
if (submittedCommit && this.hasReviewHistory(initial) && !/^[a-f0-9]{40}$/.test(submittedCommit)) {
|
|
6253
|
+
const recorded = this.ownerReviewHead(initial);
|
|
6254
|
+
throw new PreEffectToolRefusal(`task #${initial.id}: re-file after review needs refs.commit to be a full lowercase SHA${recorded ? ` (recorded review head ${recorded})` : ""}; owner must re-file hub_task_done with the new full SHA in refs.commit`);
|
|
6255
|
+
}
|
|
6256
|
+
const refileHead: RefileHeadGuard | undefined = !submittedCommit && initial.sdlc?.repo && initial.sdlc.pr && this.hasReviewHistory(initial) && !this.isPii(initial)
|
|
6257
|
+
? { recorded: this.ownerReviewHead(initial) } : undefined;
|
|
6130
6258
|
const draft = refresh ?? (submittedCommit && /^[a-f0-9]{40}$/.test(submittedCommit) ? { commit: submittedCommit } : undefined);
|
|
6131
6259
|
const valid = () => { requestCheck(); return this.completionDraft(this.completionCurrent(id, by, true, refresh), draft); };
|
|
6132
6260
|
if (initial.state === "approved") { await this.unmergedForReopen(initial, valid); valid(); }
|
|
@@ -6150,9 +6278,15 @@ export class Tasks {
|
|
|
6150
6278
|
const current = () => this.withLink(valid(), link, refs);
|
|
6151
6279
|
task = current();
|
|
6152
6280
|
let reviewEvidence: PrEvidence | undefined;
|
|
6153
|
-
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task = current(); }
|
|
6154
|
-
const reviewAdmission = task.reviewer || this.d.prepareReviewBrief ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence, undefined, staged => this.withLink(this.completionDraft(staged, draft), link, refs), !task.reviewer) : undefined;
|
|
6281
|
+
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review", undefined, undefined, refileHead); task = current(); }
|
|
6282
|
+
const reviewAdmission = task.reviewer || this.d.prepareReviewBrief ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence, undefined, staged => this.withLink(this.completionDraft(staged, draft), link, refs), !task.reviewer, refileHead) : undefined;
|
|
6155
6283
|
task = current();
|
|
6284
|
+
if (refileHead && (this.d.sdlc?.config().mode ?? "off") === "off") {
|
|
6285
|
+
const currentHead = await this.currentPrHeadForReview(task, current);
|
|
6286
|
+
task = current();
|
|
6287
|
+
if (!refileHead.recorded || !currentHead) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded);
|
|
6288
|
+
if (currentHead !== refileHead.recorded) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded, currentHead);
|
|
6289
|
+
}
|
|
6156
6290
|
const currentSpecTask = () => { const fresh = current(); return { ...fresh, refs: { ...fresh.refs, ...cleanRefs(refs) } }; };
|
|
6157
6291
|
const specInspection = await this.inspectTaskSpecs(currentSpecTask(), by, specAck, currentSpecTask);
|
|
6158
6292
|
task = current(); // the host head and task authority are revalidated after spec inspection
|
|
@@ -6507,7 +6641,7 @@ export class Tasks {
|
|
|
6507
6641
|
|
|
6508
6642
|
/** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
|
|
6509
6643
|
/** Review admission is separate from brief construction (#228). All refusals precede the requesting done's writes. */
|
|
6510
|
-
private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean, stage: (task: Task) => Task = task => task, prepareOnly = false): Promise<ReviewReadiness | undefined> {
|
|
6644
|
+
private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean, stage: (task: Task) => Task = task => task, prepareOnly = false, refileHead?: RefileHeadGuard): Promise<ReviewReadiness | undefined> {
|
|
6511
6645
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
6512
6646
|
if (mode === "off" && !this.d.prepareReviewBrief) return undefined;
|
|
6513
6647
|
let quietDrift = false;
|
|
@@ -6544,8 +6678,13 @@ export class Tasks {
|
|
|
6544
6678
|
const evidence: ReviewReadiness = this.isPii(task)
|
|
6545
6679
|
? { ready: false, blockers: ["private review readiness unavailable"] }
|
|
6546
6680
|
: task.sdlc ? reviewReadinessFromEvidence(previous, expected, this.d.sdlc?.config().requiredChecks ?? []) ?? await readReviewReadiness(task.sdlc, expected, read, this.d.sdlc?.config().requiredChecks ?? []) : { ready: false, blockers: ["review PR identity missing"] };
|
|
6681
|
+
const currentHead = refileHead && !evidence.head && task.sdlc ? await readSdlcPrHead(task.sdlc, read) : evidence.head;
|
|
6547
6682
|
const current = guard();
|
|
6548
6683
|
const admitted: ReviewReadiness = quietDrift ? { ready: false, blockers: ["review readiness unknown: task changed during readback"] } : evidence;
|
|
6684
|
+
if (refileHead) {
|
|
6685
|
+
if (!refileHead.recorded || !currentHead) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded);
|
|
6686
|
+
if (currentHead !== refileHead.recorded) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded, currentHead);
|
|
6687
|
+
}
|
|
6549
6688
|
if (!admitted.ready) {
|
|
6550
6689
|
const message = `task ${this.publicTitle(current)}: premature review: ${admitted.blockers.join("; ")}`;
|
|
6551
6690
|
if (mode === "enforce") throw new (preEffect ? PreEffectToolRefusal : Error)(message);
|
|
@@ -6879,7 +7018,7 @@ export class Tasks {
|
|
|
6879
7018
|
// notice. The rule is structural (a finding with disposition open), never a reading of the unmet text.
|
|
6880
7019
|
const codeScope = by !== USER && this.d.reviewScope?.(by) === "code";
|
|
6881
7020
|
const evidenceOnly = codeScope && verdict === "changes_requested" && !parsed.some(finding => finding.disposition === "open");
|
|
6882
|
-
const resolvedHead = this.findingHead(initial);
|
|
7021
|
+
const resolvedHead = this.ownerReviewHead(initial) ?? this.findingHead(initial);
|
|
6883
7022
|
let findingHead = resolvedHead;
|
|
6884
7023
|
if (head !== undefined) {
|
|
6885
7024
|
try { findingHead = sdlcReceipt({ reviewedHead: head }, initial.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined)).reviewedHead; }
|
|
@@ -6926,7 +7065,7 @@ export class Tasks {
|
|
|
6926
7065
|
if (gap.length) note = `${note ?? ""}\nCoverage gap (${gap.length}): ${list(gap)}`.trim();
|
|
6927
7066
|
}
|
|
6928
7067
|
}
|
|
6929
|
-
if (head !== undefined && resolvedHead && findingHead !== resolvedHead) throw new PreEffectToolRefusal(
|
|
7068
|
+
if (head !== undefined && resolvedHead && findingHead !== resolvedHead) throw new PreEffectToolRefusal(`task #${initial.id}: supplied review head ${findingHead} differs from recorded task head ${resolvedHead}; owner must re-file hub_task_done with refs.commit set to the supplied full SHA before review`);
|
|
6930
7069
|
// The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
|
|
6931
7070
|
const withheld = await this.withholds("review note", parsed.length ? JSON.stringify({ note, findings: parsed }) : note, valid())
|
|
6932
7071
|
|| parsed.length > 0 && !this.findingTextAllowed(initial, parsed);
|
|
@@ -6963,6 +7102,12 @@ export class Tasks {
|
|
|
6963
7102
|
await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid();
|
|
6964
7103
|
const record = this.evalRecords.get(task.id); this.evalRecords.delete(task.id);
|
|
6965
7104
|
if (record) note = `${note ?? ""}\n${record}`.trim();
|
|
7105
|
+
} else if (verdict === "approved" && this.d.sdlc?.config().mode === "off" && this.d.sdlc?.gh && task.sdlc?.repo && task.sdlc.pr
|
|
7106
|
+
&& this.ownerReviewHead(task) && !this.isPii(task)) {
|
|
7107
|
+
const expected = reviewedHead ?? this.ownerReviewHead(task) ?? this.findingHead(task);
|
|
7108
|
+
const currentHead = await this.currentPrHeadForReview(task, valid);
|
|
7109
|
+
task = valid();
|
|
7110
|
+
if (expected && currentHead && currentHead !== expected) throw this.reviewHeadMoveRefusal(task.id, expected, currentHead);
|
|
6966
7111
|
}
|
|
6967
7112
|
let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
|
|
6968
7113
|
if (verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|
package/src/local/deny.ts
CHANGED
|
@@ -77,6 +77,54 @@ const hooksUnder = (parent: string): string => {
|
|
|
77
77
|
return depth > MAX_ANCHOR_DEPTH ? `^.*/${hfsFoldedName("hooks")}(/|$)` : `^${"/[^/]+".repeat(depth)}/${hfsFoldedName("hooks")}(/|$)`;
|
|
78
78
|
};
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* #1123: a sandboxed command may not move what a branch, HEAD or a linked worktree points at. `refs/**` (branches, tags, `refs/stash`), `packed-refs*`, `HEAD*` (its lock too) and
|
|
82
|
+
* `worktrees/**` (a linked worktree's admin dir: its HEAD, index, logs) are refused directly under every plain `.git` and under each external git dir (the common dir and a linked
|
|
83
|
+
* worktree's own, whatever they are named). The hub's own git runs outside the sandbox and is unaffected, so #1115's hub commit still lands.
|
|
84
|
+
*
|
|
85
|
+
* Every name must sit DIRECTLY under its git dir. An unanchored `refs` or `worktrees` filter would also match an ANCESTOR of the project with that name, and a project under
|
|
86
|
+
* `~/worktrees/` would lose every `.git` write (the #520 `hooks` lesson). So each rule is adjacent by construction: one literal for `.git` + a short name, or, for the long names
|
|
87
|
+
* (`worktrees`, `packed-refs`, where `.git` + name would pass the 1024-byte literal limit), a front half and a back half that must both match, or a depth anchor for an external dir.
|
|
88
|
+
* A submodule's git dir (`.git/modules/<name>`, nested ones, and `.git/worktrees/<wt>/modules/<name>` for a linked worktree) is covered the same way (`subDirs`, the discovered
|
|
89
|
+
* ones: a git dir that is not discovered is refused whole by the #518/#520 rules), per the person's "deny .git writes" decision: nothing is listed as a residual.
|
|
90
|
+
*/
|
|
91
|
+
const refDepth = (d: string): number => d.split("/").filter(Boolean).length;
|
|
92
|
+
/**
|
|
93
|
+
* `^(/[^/]+){depth}/<name>(/|$)`: `name` as the entry directly under `d`, the depth written out because seatbelt ignores `{n}`. The folded names cost (measured, bytes of regex): `refs` 334,
|
|
94
|
+
* `worktrees` 669, so past the depth where the path plus the name would near 1024 it falls back to the unanchored name, which over-refuses only under an ancestor with that name and
|
|
95
|
+
* never under-protects.
|
|
96
|
+
*/
|
|
97
|
+
const refUnder = (d: string, name: string): string => {
|
|
98
|
+
const depth = refDepth(d), cost = Buffer.byteLength(hfsFoldedName(name)) + depth * 6 + 8;
|
|
99
|
+
return cost > 1000 ? `^.*/${hfsFoldedName(name)}(/|$)` : `^${"/[^/]+".repeat(depth)}/${hfsFoldedName(name)}(/|$)`;
|
|
100
|
+
};
|
|
101
|
+
function gitRefRules(gitDirs: string[], subDirs: string[] = []): string[] {
|
|
102
|
+
const git = hfsFoldedName(".git");
|
|
103
|
+
const re = (s: string) => `(regex ${sbplString(s)})`;
|
|
104
|
+
const name = (n: string) => hfsFoldedName(n);
|
|
105
|
+
// A linked worktree's own submodule git dirs (`worktrees/<wt>/modules/**`) are governed by the #518/#520 rules, not these.
|
|
106
|
+
const plainSub = `(require-not ${re(`^.*/${git}/worktrees/[^/]+/modules(/|$)`)})`;
|
|
107
|
+
const plain = [
|
|
108
|
+
// `.git` (335 bytes) + `HEAD` or `refs` (334) fit one literal: adjacent by construction. `HEAD.lock` goes with `HEAD`.
|
|
109
|
+
re(`^.*/${git}/${name("HEAD")}(\\.lock)?$`),
|
|
110
|
+
re(`^.*/${git}/${name("refs")}(/|$)`),
|
|
111
|
+
// `.git` + `packed-refs` (803) and `.git` + `worktrees` (669) do not fit one literal. The front literal pins the start of the name right under a `.git`, and for `worktrees` the
|
|
112
|
+
// back literal pins its end; `packed` alone covers `packed-refs` and its lock/new files, which is the intent.
|
|
113
|
+
re(`^.*/${git}/${name("packed")}`),
|
|
114
|
+
`(require-all ${re(`^.*/${git}/${name("work")}`)} ${re(`^.*${name("trees")}(/|$)`)} ${plainSub})`,
|
|
115
|
+
];
|
|
116
|
+
const external = [...gitDirs, ...subDirs].flatMap((d) => {
|
|
117
|
+
const own = sbplString(d), direct = re(`^${sbplEscape(d)}/[^/]+$`);
|
|
118
|
+
return [
|
|
119
|
+
`(require-all ${direct} ${re(`^.*/${name("HEAD")}(\\.lock)?$`)})`,
|
|
120
|
+
`(require-all ${direct} ${re(`^.*/${name("packed")}`)})`,
|
|
121
|
+
`(require-all (subpath ${own}) ${re(refUnder(d, "refs"))})`,
|
|
122
|
+
`(require-all (subpath ${own}) ${re(refUnder(d, "worktrees"))} (require-not ${re(`^${sbplEscape(d)}/worktrees/[^/]+/modules(/|$)`)}))`,
|
|
123
|
+
];
|
|
124
|
+
});
|
|
125
|
+
return [...plain, ...external];
|
|
126
|
+
}
|
|
127
|
+
|
|
80
128
|
export function hubWriteRegexes(root: string, gitDirs: string[] = [], moduleGit: readonly { root: string; entries: string[]; dirs?: string[] }[] = [], worktreesRoot?: string): string[] {
|
|
81
129
|
const git = hfsFoldedName(".git");
|
|
82
130
|
const re = (s: string) => `(regex ${sbplString(s)})`;
|
|
@@ -157,6 +205,7 @@ export function hubWriteRegexes(root: string, gitDirs: string[] = [], moduleGit:
|
|
|
157
205
|
`(require-all ${re(`^.*/${git}/[^/]+$`)} ${CONFIG_WORKTREE_NAME.map(re).join(" ")})`,
|
|
158
206
|
re(`^.*/${git}/${hfsFoldedName("hooks")}(/|$)`),
|
|
159
207
|
`(require-all ${re(`^.*/${git}/.*$`)} ${re(`^.*/${hfsFoldedName("commondir")}$`)})`,
|
|
208
|
+
...gitRefRules(gitDirs, allDirs),
|
|
160
209
|
...modules,
|
|
161
210
|
...siblings,
|
|
162
211
|
...external,
|
package/src/local/tools.ts
CHANGED
|
@@ -117,7 +117,7 @@ export const TOOL_SCHEMAS = [
|
|
|
117
117
|
fn("write", "Create or overwrite a file inside the project. Needs the user's approval.", { path: str, content: str }, ["path", "content"]),
|
|
118
118
|
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"]),
|
|
119
119
|
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"]),
|
|
120
|
-
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"]),
|
|
120
|
+
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. The sandbox does not let you move a branch, tag or HEAD anywhere (commit, branch, tag, stash, switch, merge, rebase and reset fail, submodules included), nor stage with add inside a linked worktree: edit files, and the hub commits and pushes them when you call hub_task_done.", { args: { type: "array", items: str, minItems: 1 } }, ["args"]),
|
|
121
121
|
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"]),
|
|
122
122
|
];
|
|
123
123
|
|
|
@@ -235,7 +235,9 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
235
235
|
// Sandboxed like bash: flags such as --output or an editor cannot write outside the project or reach the network.
|
|
236
236
|
const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, readOnlyData: !!ctx.readOnly?.length, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
237
237
|
try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
|
|
238
|
-
|
|
238
|
+
// #1123: the profile refuses writes to refs, HEAD, packed-refs and a linked worktree's admin dir, so a commit, branch, tag, stash or switch cannot succeed here. Say so, with the next step.
|
|
239
|
+
const refused = res.code !== 0 && /(?:\.git\/(?:refs|HEAD|packed-refs|worktrees)\b|cannot lock ref|index\.lock)[^\n]*(?:Operation not permitted|not permitted)/i.test(res.output);
|
|
240
|
+
return `${res.output}${refused ? "\nnote: the sandbox does not let you move a branch, tag or HEAD, or stage inside a linked worktree (#1123). Leave your edits in the working tree and finish with hub_task_done. For implementation work the hub stages, commits and pushes them for you; for work that changes no files, or a private task, there is nothing for it to commit, so do not try to commit it yourself." : ""}\n(exit ${res.code})`;
|
|
239
241
|
}
|
|
240
242
|
case "doc_read": {
|
|
241
243
|
if (!ctx.docRead) return "error: doc_read is not enabled for this project (local.doc_read in .agenthub/config.local.json)";
|
package/src/memory/brief.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildIntentBrief, type IntentBriefOptions, type IntentBriefSource } from "@staix/agent-mem";
|
|
2
|
+
import { controlWriteSpan } from "../hub/control-write-trace.ts";
|
|
2
3
|
import type { PeerId } from "../hub/envelope.ts";
|
|
3
4
|
import type { MemoryClient } from "./client.ts";
|
|
4
5
|
|
|
@@ -70,6 +71,10 @@ export class Briefs {
|
|
|
70
71
|
) {}
|
|
71
72
|
|
|
72
73
|
async forTask(peer: PeerId, task: BriefTask, priority: readonly NativeItem[] = []): Promise<string | undefined> {
|
|
74
|
+
return controlWriteSpan("memory", () => this.forTaskBody(peer, task, priority));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async forTaskBody(peer: PeerId, task: BriefTask, priority: readonly NativeItem[]): Promise<string | undefined> {
|
|
73
78
|
// A brief snapshots its configured cap once, before either optional source is selected.
|
|
74
79
|
const maximum = typeof this.maxItems === "function" ? this.maxItems() : this.maxItems;
|
|
75
80
|
let corpusRevision: string | undefined;
|
package/src/memory/client.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import packageJson from "../../package.json";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
import { controlWriteSpan } from "../hub/control-write-trace.ts";
|
|
3
4
|
import { CaptureStore, SearchIndex, looksSecret, memoryBase, readCaptureConfig, type CaptureOptions as StoreCaptureOptions, type MemoryStore, type CandidateStore, type View } from "@staix/agent-mem";
|
|
4
5
|
|
|
5
6
|
export interface CaptureEvent {
|
|
@@ -54,8 +55,10 @@ export class MemoryClient {
|
|
|
54
55
|
}
|
|
55
56
|
close(): void { this.closed = true; this.releaseCapture(); }
|
|
56
57
|
private run<T>(work: (o: MemoryClientOptions) => T): T | undefined {
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
return controlWriteSpan("memory", () => {
|
|
59
|
+
try { if (!this.options || this.closed) return undefined; this.options.check(); return work(this.options); }
|
|
60
|
+
catch { this.releaseCapture(); this.options?.log?.("amem operation unavailable"); return undefined; }
|
|
61
|
+
});
|
|
59
62
|
}
|
|
60
63
|
private rows(o: MemoryClientOptions): View[] {
|
|
61
64
|
const store = o.store;
|