@staix/agent-hub 0.30.0 → 0.31.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 +39 -0
- package/README.md +13 -7
- package/docs/agent-notes/README.md +1 -0
- package/docs/agent-notes/adapters.md +5 -2
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +9 -4
- package/docs/agent-notes/daemon.md +15 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +7 -3
- package/docs/agent-notes/tests.md +6 -4
- package/docs/design/adapters.md +52 -0
- package/docs/design/agent-hub-architecture.md +46 -0
- package/docs/design/bus.md +139 -1
- package/docs/design/task-board.md +82 -0
- package/docs/dogfood.md +22 -4
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +307 -78
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +18 -5
- package/docs/security.md +154 -60
- package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
- package/docs/specs/2026-09-19-multi-project-design.md +7 -2
- package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
- package/docs/verification/2026-11-05-0.31.0.md +182 -0
- package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
- package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
- package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +14 -14
- package/package.json +3 -4
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4393 -3609
- package/src/adapters/acp.ts +5 -4
- package/src/adapters/channel-peer.ts +72 -3
- package/src/adapters/claude-channel-evidence.ts +52 -0
- package/src/adapters/claude-channel.ts +61 -18
- package/src/adapters/claude-implementer.ts +29 -7
- package/src/adapters/claude-print-child.ts +12 -2
- package/src/adapters/codex-appserver.ts +13 -12
- package/src/adapters/compat.ts +3 -3
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +61 -18
- package/src/adapters/pi.ts +48 -6
- package/src/adapters/registry-types.ts +12 -0
- package/src/adapters/registry.ts +2 -0
- package/src/adapters/reviewer-print.ts +50 -11
- package/src/broker/server.ts +44 -13
- package/src/broker/store.ts +38 -8
- package/src/cli/answer-args.ts +50 -0
- package/src/cli/attention.ts +17 -2
- package/src/cli/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +77 -16
- package/src/cli/console.ts +54 -1
- package/src/cli/handoff-args.ts +15 -5
- package/src/cli/handoff-output.ts +15 -2
- package/src/cli/help.ts +8 -5
- package/src/cli/identity.ts +13 -3
- package/src/cli/main.ts +128 -16
- package/src/cli/memory.ts +107 -3
- package/src/cli/output.ts +29 -6
- package/src/cli/pet.ts +4 -4
- package/src/cli/results-args.ts +30 -0
- package/src/cli/runtime-version-warning.ts +50 -0
- package/src/cli/status-lines.ts +41 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-interactive.ts +14 -2
- package/src/hub/action-class.ts +9 -1
- package/src/hub/board.ts +369 -18
- package/src/hub/bus.ts +493 -53
- package/src/hub/completion-detail.ts +50 -0
- package/src/hub/completions-store.ts +168 -0
- package/src/hub/completions.ts +120 -0
- package/src/hub/config-apply.ts +7 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1569 -134
- package/src/hub/delivery-failure.ts +14 -0
- package/src/hub/delivery-hints.ts +14 -4
- package/src/hub/delivery-journal.ts +751 -42
- package/src/hub/delivery-superseded.ts +4 -1
- package/src/hub/envelope.ts +3 -3
- package/src/hub/events.ts +6 -0
- package/src/hub/handoff.ts +145 -6
- package/src/hub/hub-tools.ts +56 -7
- package/src/hub/manager.ts +59 -3
- package/src/hub/memory-policy.ts +44 -1
- package/src/hub/memory-sync-policy.ts +109 -0
- package/src/hub/memory-sync-state.ts +145 -0
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/operational-settings.ts +11 -2
- package/src/hub/operational-view.ts +12 -1
- package/src/hub/peers.ts +25 -2
- package/src/hub/planning.ts +6 -1
- package/src/hub/question-desk.ts +201 -0
- package/src/hub/question-detach.ts +6 -0
- package/src/hub/questions.ts +260 -0
- package/src/hub/research-evidence.ts +106 -0
- package/src/hub/restart.ts +14 -0
- package/src/hub/review-anchor.ts +181 -0
- package/src/hub/review-brief.ts +13 -0
- package/src/hub/review-comments.ts +115 -0
- package/src/hub/review-findings.ts +7 -2
- package/src/hub/review-material.ts +76 -0
- package/src/hub/routing.ts +3 -1
- package/src/hub/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +577 -63
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/rooms/link.ts +209 -21
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +338 -22
- package/src/rooms/store.ts +437 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +138 -7
- package/src/ui/needs.ts +10 -4
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +207 -27
- package/src/pet/window.html +0 -114
- package/src/pet/window.jxa.js +0 -122
- package/src/pet/window.ts +0 -154
package/src/hub/tasks.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import { parseEvidenceRecord, parseEvidenceLink, evidenceLinkId, evidenceLinkCanonical, type EvidenceRecordInput, type EvidenceReceipt } from "./research-evidence.ts";
|
|
2
|
+
import type { ActionMeta, ReviewAnchor } from "@staix/agent-console/contract";
|
|
3
|
+
import { parseReviewAnchor, reviewAnchorErrorMessage, anchorStatus, type ReviewAnchorStatus } from "./review-anchor.ts";
|
|
4
|
+
import type { ReviewMaterialBatch } from "./review-material.ts";
|
|
5
|
+
import { isReviewObserverExcluded } from "./memory-policy.ts";
|
|
6
|
+
import { renderFindingAnchor } from "./review-brief.ts";
|
|
7
|
+
import { reviewCommentFields, reviewCommentNumber, reviewCommentId, reviewCommentIds, reviewCommentHead, reviewCommentBody, reviewCommentAuthor, privateReviewAnchor, reviewThreadProjection, renderReviewComment, REVIEW_PACKAGE_BYTES, REVIEW_COMMENT_LIMIT,
|
|
8
|
+
type ReviewCommentRecord, type ReviewCommentFence, type ReviewCommentMutation, type ReviewCommentCounts, type ReviewCommentView } from "./review-comments.ts";
|
|
1
9
|
import { parseAttemptReceipt, parseResearchRunners, verifyAttemptReceipt } from "./research-runner.ts";
|
|
2
10
|
import { approvalBinding, approvalText } from "./task-approval.ts";
|
|
3
11
|
import type { InvocationRow } from "./invocations.ts";
|
|
@@ -12,7 +20,7 @@ import { parseExperimentProtocol, parseExperimentAttempt, parseExperimentEvent,
|
|
|
12
20
|
import { parseComparisonPlan, comparisonPlanHash, comparisonProjection, evaluatorIdentityHash, protocolApprovalBindingMatches, type ComparisonPlanV1 } from "./research-experiments.ts";
|
|
13
21
|
import { parseResearchHandoff, studyProjection, campaignHash, parseResearchCampaign, type ResearchCampaignContract, type ResearchCampaignStatus } from "./research-campaign.ts";
|
|
14
22
|
import type { ResearchCampaignRecord, ResearchUnitBinding, PreparedResearchUnit, HandoffOutboundRow } from "./board.ts";
|
|
15
|
-
import { HANDOFF_EVENTS, HandoffRefusal, followBackLine, handoffEventFor, parseHandoff, type HandoffEvent } from "./handoff.ts";
|
|
23
|
+
import { HANDOFF_EVENTS, HANDOFF_KEY, HandoffRefusal, handoffRequestDigest, duplicateIssueTasks, duplicateRefusalText, followBackLine, handoffEventFor, parseHandoff, type HandoffEvent } from "./handoff.ts";
|
|
16
24
|
import { taskShellBindingValid, type TaskShellBinding } from "./task-shell-command.ts";
|
|
17
25
|
import { buildTaskCodeSymbols, publicTaskCodeSymbols, taskCodeSymbolsDisjoint, HOST_CODE_SYMBOLS } from "./task-code-symbols.ts";
|
|
18
26
|
import { normalizePlanSymbol, symbolsOverlap } from "./symbol-normalizer.ts";
|
|
@@ -46,6 +54,7 @@ import { foldSegment, isHubWritePath } from "./permission-mode.ts";
|
|
|
46
54
|
import { correctionInput, resolveVisibleMemory } from "./memory-candidates.ts";
|
|
47
55
|
import type { MemoryScreen } from "./read-tools.ts";
|
|
48
56
|
import { withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
57
|
+
import { createMemorySyncPolicy } from "./memory-sync-policy.ts";
|
|
49
58
|
import { profileClass, profileData, profileTaskLabel, projectProfile, type ProfileName, type ProfileSelection, type ProjectProfile } from "./profiles.ts";
|
|
50
59
|
import { criteriaView, unmetCriteriaIds, workflowHash } from "./workflow.ts";
|
|
51
60
|
import { candidateCard, criterionKey, groupUnmetCriteria, LEARNING_DEFAULTS, overridesDiff, parseCandidateCard, selectGroups, type CriterionGroup } from "./learning.ts";
|
|
@@ -63,8 +72,8 @@ import { checkDeliverables, DeliverableRefusal, hashDeliverables, vetDeliverable
|
|
|
63
72
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
64
73
|
import type { Briefs, NativeItem } from "../memory/brief.ts";
|
|
65
74
|
import type { MemoryClient } from "../memory/client.ts";
|
|
66
|
-
import { candidateTargetRevision, type CandidateInput, type CandidateStore, extractNodes, looksSecret, PROMOTED_KINDS, taskKey, type GoalView, type Kind, type MemoryStore, type Scope, type Tier } from "@staix/agent-mem";
|
|
67
|
-
import { parseClaimVerdict,
|
|
75
|
+
import { candidateTargetRevision, type CandidateInput, type CandidateStore, extractNodes, looksSecret, PROMOTED_KINDS, taskKey, type GoalView, type Kind, type MemoryStore, type NoteInput, type Scope, type Tier } from "@staix/agent-mem";
|
|
76
|
+
import { parseClaimVerdict, taskClaimIdentity, CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskCanPlan, taskHasWorkEvidence, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type ReviewerHistory, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs, type PlannerBinding, type SpecAck, type SpecTaskReport } from "./board.ts";
|
|
68
77
|
import type { Bus } from "./bus.ts";
|
|
69
78
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
70
79
|
import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
|
|
@@ -85,6 +94,10 @@ import { plannerGrooming, nextSweep, plannedStartPending, taskSweepConfig, type
|
|
|
85
94
|
import { CiWatchers, pollCi, type CiOutcome, type CiWatch } from "./ci-watch.ts";
|
|
86
95
|
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";
|
|
87
96
|
|
|
97
|
+
/** #1176: refusals for an agent that reaches a person-run card by a path other than the pull gate. */
|
|
98
|
+
const PERSON_RUN_SPLIT = "a person-run card is the person's to split: its children would be unmarked cards an agent could take (the person's own split marks every child person-run)";
|
|
99
|
+
const PERSON_RUN_ACCEPT = (id: number) => `task #${id} is a person-run leg: no agent accepts it (it waits on the person: live receipts, their accounts or approvals). The person runs it or clears the mark with ahub backlog plan`;
|
|
100
|
+
|
|
88
101
|
function requireLocalTaskPeer(peer: unknown): void {
|
|
89
102
|
if (isRemotePeerId(peer)) throw new PreEffectToolRefusal("remote peers have no local task authority");
|
|
90
103
|
}
|
|
@@ -170,6 +183,10 @@ export interface TasksDeps {
|
|
|
170
183
|
privateTurn?: (peer: PeerId) => boolean;
|
|
171
184
|
/** #223 R4: the hub's reading of a verdict's context (`fresh` only for a reviewer session started for this review). */
|
|
172
185
|
reviewContext?: (by: PeerId, task: number) => "fresh" | "continuing" | undefined;
|
|
186
|
+
/** Current-turn input carries review material or anchored findings; it expires on the next ordinary input. */
|
|
187
|
+
reviewObserverInput?: (peer: PeerId) => boolean;
|
|
188
|
+
/** Latch an anchor-bearing hub_review input before its first await or effect, even if the verdict is refused. */
|
|
189
|
+
onReviewObserverInput?: (peer: PeerId) => void;
|
|
173
190
|
/** #852: what a reviewer reads. `code` (the fixed sandboxed reviewers by default, or a local `review_scope`) reads the code only; unset or `full` also runs tests and reads CI. */
|
|
174
191
|
reviewScope?: (peer: PeerId) => ReviewScope;
|
|
175
192
|
/** #223 R2: a reviewer-only instance gets review facts, not the builder's done summary. */
|
|
@@ -201,12 +218,16 @@ export interface TasksDeps {
|
|
|
201
218
|
/** Hands one peer a line that rides on its next delivery, without a turn of its own (issue #6). */
|
|
202
219
|
tell?: (peer: PeerId, line: string) => void;
|
|
203
220
|
/** Structured overlap records for telemetry (issue #40); the notice line stays for the human. */
|
|
221
|
+
/** #1054: a completion check this peer owned just ended (its ownership is already cleared), so a run that waited on it may settle. Advisory: a throw is ignored. */
|
|
222
|
+
checkEnded?: (owner: PeerId) => void;
|
|
204
223
|
recordOverlap?: (task: number, owner: PeerId, others: { task: number; owner: PeerId; paths: string[]; symbols?: string[] }[]) => void;
|
|
205
224
|
/** Quota per peer from fresh readings (issue #36): routing drains the windows that reset soonest first. */
|
|
206
225
|
quota?: () => Record<PeerId, { headroom: number; resetsAt?: number }>;
|
|
207
226
|
/** Optional persisted task/run execution limits (issue #102). */
|
|
208
227
|
executionBudget?: ExecutionBudget;
|
|
209
228
|
/** Project-scoped immutable-source metadata, read only before a study transaction. */
|
|
229
|
+
/** Host-scoped immutable-head/material batch; supports host-staged completion refs/sdlc, never client-staged Task objects. The shared reader owns file authorization and budgets. */
|
|
230
|
+
reviewMaterials?: (task: Task, requests: readonly { path: string; side?: "base" | "head" }[]) => Promise<ReviewMaterialBatch>;
|
|
210
231
|
studySource?: (id: string) => { id: string; sha256: string; private: boolean } | undefined;
|
|
211
232
|
/** Host-only fixture clock; never read from model input or project configuration. */
|
|
212
233
|
studyNow?: () => number;
|
|
@@ -450,7 +471,251 @@ export interface DeliverableRow {
|
|
|
450
471
|
files: { id: string; path?: string; format: string; version?: string; sha256: string; bytes: number; private?: true; state?: DeliverableStatus["state"] }[];
|
|
451
472
|
}
|
|
452
473
|
|
|
474
|
+
/** Detect an own anchor field without invoking model-provided accessors or proxy traps. */
|
|
475
|
+
function hasReviewAnchorInput(raw: unknown): boolean {
|
|
476
|
+
if (raw === undefined || !Array.isArray(raw)) return false;
|
|
477
|
+
if (types.isProxy(raw)) return true;
|
|
478
|
+
try {
|
|
479
|
+
if (Object.getPrototypeOf(raw) !== Array.prototype || Object.getOwnPropertySymbols(raw).length || raw.length > 64) return true;
|
|
480
|
+
const descriptors = Object.getOwnPropertyDescriptors(raw);
|
|
481
|
+
for (let index = 0; index < raw.length; index++) {
|
|
482
|
+
const entry = descriptors[String(index)];
|
|
483
|
+
if (!entry || !Object.hasOwn(entry, "value") || !entry.enumerable) return true;
|
|
484
|
+
const item: unknown = entry.value;
|
|
485
|
+
if (!item || typeof item !== "object") continue;
|
|
486
|
+
if (types.isProxy(item)) return true;
|
|
487
|
+
const prototype = Object.getPrototypeOf(item);
|
|
488
|
+
if (prototype !== Object.prototype && prototype !== null) return true;
|
|
489
|
+
if (Object.getOwnPropertyDescriptor(item, "anchor")) return true;
|
|
490
|
+
}
|
|
491
|
+
return false;
|
|
492
|
+
} catch { return true; }
|
|
493
|
+
}
|
|
494
|
+
|
|
453
495
|
export class Tasks {
|
|
496
|
+
private readonly findingPlacements = new WeakMap<ReviewReadiness, { binding: string; placements: Map<string, ReviewAnchorStatus>; placementOnly: boolean }>();
|
|
497
|
+
private findingPlacementBinding(task: Task): string {
|
|
498
|
+
return JSON.stringify([task.id, task.created, this.reviewHead(task), task.refs.paths, task.plan, this.reviewFindingSets(task)]);
|
|
499
|
+
}
|
|
500
|
+
private async prepareFindingPlacements(task: Task, refs: TaskRefs | undefined, guard: () => unknown): Promise<Map<string, ReviewAnchorStatus>> {
|
|
501
|
+
const staged = { ...task, refs: { ...task.refs, ...cleanRefs(refs) } }, placements = new Map<string, ReviewAnchorStatus>();
|
|
502
|
+
const findings = findingsForHead(this.reviewFindingSets(staged), this.findingHead(staged)).filter(item => item.anchor && !this.reviewPathPrivate(item.anchor.path) && this.findingTextAllowed(staged, [item]));
|
|
503
|
+
if (this.isPii(staged) || !findings.length || !this.d.reviewMaterials) return placements;
|
|
504
|
+
try {
|
|
505
|
+
const batch = await this.reviewBatch(staged, findings.map(item => item.anchor!));
|
|
506
|
+
guard();
|
|
507
|
+
if (batch.head === this.reviewHead(staged)) for (const item of findings) placements.set(item.id, this.reviewPlacement(item.anchor!, batch));
|
|
508
|
+
} catch { guard(); } // Optional placement failure is unavailable, never invented coordinates.
|
|
509
|
+
return placements;
|
|
510
|
+
}
|
|
511
|
+
private reviewObserverTurn(by: PeerId, task?: number): boolean {
|
|
512
|
+
try {
|
|
513
|
+
if (this.d.reviewObserverInput?.(by) === true || this.d.bus.lastDeliveredTo(by).some(isReviewObserverExcluded)) return true;
|
|
514
|
+
if (task === undefined) return false;
|
|
515
|
+
return this.d.board.hasReviewObserverScope(by, task) || this.d.bus.hasUnsettledReviewScope(by, task);
|
|
516
|
+
} catch { return true; }
|
|
517
|
+
}
|
|
518
|
+
async reviewCommentPut(by: PeerId, input: unknown, meta?: ActionMeta): Promise<ReviewCommentMutation> {
|
|
519
|
+
const author = this.reviewPerson(by, meta), check = this.requestCheck(by, () => this.reviewCommentHold());
|
|
520
|
+
const fields = this.reviewInput(input, ["task", "id", "revision", "commit", "anchor", "body"]);
|
|
521
|
+
let taskId: number, id: string, expected: number | undefined, commit: string, anchor: ReviewAnchor, body: string;
|
|
522
|
+
try {
|
|
523
|
+
taskId = reviewCommentNumber(fields.task, "task"); id = fields.id === undefined ? `comment-${crypto.randomUUID()}` : reviewCommentId(fields.id);
|
|
524
|
+
expected = fields.id === undefined ? undefined : reviewCommentNumber(fields.revision, "revision");
|
|
525
|
+
if (fields.id === undefined && fields.revision !== undefined) throw new Error("unexpected revision");
|
|
526
|
+
} catch { throw new PreEffectToolRefusal("review task, comment id or revision is invalid; refresh the task review and use its current id and revision, or omit both for a new draft"); }
|
|
527
|
+
try { commit = reviewCommentHead(fields.commit); }
|
|
528
|
+
catch { throw new PreEffectToolRefusal("review commit must be a current full lowercase SHA; refresh the task review before saving"); }
|
|
529
|
+
try { anchor = parseReviewAnchor(fields.anchor); }
|
|
530
|
+
catch (error) { throw new PreEffectToolRefusal(`${reviewAnchorErrorMessage(error)}; refresh the review and recreate the draft`); }
|
|
531
|
+
try { body = reviewCommentBody(fields.body); }
|
|
532
|
+
catch { throw new PreEffectToolRefusal("review comment body must be nonempty and at most 2048 UTF-8 bytes; edit the body before saving"); }
|
|
533
|
+
return this.commentWrite(async () => {
|
|
534
|
+
check(); const before = this.reviewContext(taskId), rows = this.d.board.reviewComments(taskId, before.task.created), old = rows.find(row => row.thread.id === id);
|
|
535
|
+
if (expected !== undefined && (!old || old.lifecycle !== "draft" || old.revision !== expected)) throw new PreEffectToolRefusal("review draft changed or was sent; refresh the draft list before editing");
|
|
536
|
+
if (old && old.commit !== commit) throw new PreEffectToolRefusal("draft is anchored to an earlier head; delete it and create a new draft against the current task head");
|
|
537
|
+
if (expected === undefined && rows.length >= REVIEW_COMMENT_LIMIT) throw new PreEffectToolRefusal("task review already has 64 comments; remove an unused draft before adding another");
|
|
538
|
+
const strip = () => { if (this.reviewPathPrivate(anchor.path)) { try { anchor = privateReviewAnchor(anchor); } catch { throw new PreEffectToolRefusal("private review path needs an existing lineRange or structural selector; select a safe anchor before retrying"); } } };
|
|
539
|
+
strip(); const privateRecord = this.reviewTaskPrivate(before.task) || this.reviewPathPrivate(anchor.path), batch = privateRecord ? undefined : await this.reviewBatch(before.task, [anchor]);
|
|
540
|
+
if (batch && batch.head !== commit) throw new PreEffectToolRefusal("review task head moved; delete this draft and recreate it against the current full commit");
|
|
541
|
+
if (!privateRecord) {
|
|
542
|
+
const material = batch!.materials.find(row => row.path === anchor.path && row.side === (anchor.side ?? "head"));
|
|
543
|
+
if (!material || material.state !== "available") throw new PreEffectToolRefusal("review anchor material is unavailable; refresh the task review and choose a currently available file before saving");
|
|
544
|
+
if (anchor.contentHash !== material.contentHash || JSON.stringify(anchor.projection ?? null) !== JSON.stringify(material.projection ?? null)) throw new PreEffectToolRefusal("review anchor content hash or projection differs from the immutable file; refresh the review and recreate the draft on the current material");
|
|
545
|
+
}
|
|
546
|
+
check(); const current = this.reviewContext(taskId);
|
|
547
|
+
if (current.key !== before.key || this.reviewHead(current.task) !== commit || batch && batch.head !== commit) throw new PreEffectToolRefusal("review task head changed; delete this draft and recreate it against the current head before saving");
|
|
548
|
+
strip(); const record: ReviewCommentRecord = { task: taskId, taskCreated: current.task.created, commit, lifecycle: "draft", revision: (old?.revision ?? 0) + 1, private: false,
|
|
549
|
+
thread: { id, anchor, body, author, at: new Date(this.d.now?.() ?? Date.now()).toISOString() } };
|
|
550
|
+
record.private = this.reviewRecordPrivate(current.task, record);
|
|
551
|
+
check(); const saved = this.d.board.putReviewComment(record, current.fence, expected);
|
|
552
|
+
return { task: taskId, id, revision: saved.revision, lifecycle: saved.lifecycle };
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
async reviewCommentDelete(by: PeerId, input: unknown, meta?: ActionMeta): Promise<ReviewCommentMutation> {
|
|
556
|
+
this.reviewPerson(by, meta); const check = this.requestCheck(by, () => this.reviewCommentHold()), fields = this.reviewInput(input, ["task", "id", "revision"]);
|
|
557
|
+
let task: number, id: string, revision: number;
|
|
558
|
+
try { task = reviewCommentNumber(fields.task, "task"); id = reviewCommentId(fields.id); revision = reviewCommentNumber(fields.revision, "revision"); }
|
|
559
|
+
catch { throw new PreEffectToolRefusal("review delete identity is invalid; refresh the list and use its current id and revision"); }
|
|
560
|
+
return this.commentWrite(async () => {
|
|
561
|
+
check(); const context = this.reviewContext(task), record = this.d.board.reviewComments(task, context.task.created).find(row => row.thread.id === id);
|
|
562
|
+
if (!record || record.lifecycle !== "dismissed" && record.revision !== revision) throw new PreEffectToolRefusal("review comment changed; refresh its current revision before deleting or dismissing");
|
|
563
|
+
const result = this.d.board.deleteReviewComment(id, revision, context.fence);
|
|
564
|
+
return { task, id, revision: result.record.revision, ...(result.deleted ? { deleted: true as const } : { lifecycle: result.record.lifecycle }) };
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
async reviewCommentList(by: PeerId, input: unknown): Promise<{ task: number; counts: ReviewCommentCounts; comments: ReviewCommentView[]; packages: import("./review-comments.ts").ReviewCommentPackageView[] }> {
|
|
568
|
+
if (this.commentsStopped) throw new PreEffectToolRefusal("review comments are stopping; reopen the task review after the hub restarts");
|
|
569
|
+
this.reviewPerson(by); const fields = this.reviewInput(input, ["task"]), taskId = reviewCommentNumber(fields.task, "task"), before = this.reviewContext(taskId);
|
|
570
|
+
const counts = this.d.board.reviewCommentCounts(taskId, before.task.created), records = this.d.board.reviewComments(taskId, before.task.created);
|
|
571
|
+
const packages = this.d.board.reviewCommentPackages(taskId, before.task.created);
|
|
572
|
+
if (this.reviewTaskPrivate(before.task)) return { task: taskId, counts, comments: [], packages };
|
|
573
|
+
const candidates = records.filter(row => !this.reviewRecordPrivate(before.task, row));
|
|
574
|
+
if (!candidates.length) return { task: taskId, counts, comments: [], packages };
|
|
575
|
+
let batch: ReviewMaterialBatch;
|
|
576
|
+
try { batch = await this.reviewBatch(before.task, candidates.map(row => row.thread.anchor)); } catch { batch = { head: "", materials: [] }; }
|
|
577
|
+
if (this.commentsStopped) return { task: taskId, counts, comments: [], packages };
|
|
578
|
+
const current = this.reviewContext(taskId);
|
|
579
|
+
if (current.key !== before.key) throw new PreEffectToolRefusal("review task changed while reading; refresh the task review");
|
|
580
|
+
const latest = this.d.board.reviewComments(taskId, current.task.created);
|
|
581
|
+
if (latest.length !== records.length || records.some(row => !latest.some(fresh => fresh.thread.id === row.thread.id && fresh.revision === row.revision))) throw new PreEffectToolRefusal("review comments changed while reading; refresh the task review");
|
|
582
|
+
const comments = candidates.flatMap<ReviewCommentView>(record => {
|
|
583
|
+
const placement = this.reviewPlacement(record.thread.anchor, batch);
|
|
584
|
+
if (this.reviewRecordPrivate(current.task, record)) return [];
|
|
585
|
+
if (placement.status === "unavailable") return [{ thread: reviewThreadProjection(record), task: taskId, commit: record.commit, lifecycle: record.lifecycle, revision: record.revision, ...(record.sentAt ? { sentAt: record.sentAt } : {}), placement: { status: "unavailable" } }];
|
|
586
|
+
return [{ thread: reviewThreadProjection(record), task: taskId, commit: record.commit, lifecycle: record.lifecycle, revision: record.revision, ...(record.sentAt ? { sentAt: record.sentAt } : {}), placement }];
|
|
587
|
+
});
|
|
588
|
+
return { task: taskId, counts, comments, packages };
|
|
589
|
+
}
|
|
590
|
+
async reviewCommentSend(by: PeerId, input: unknown, meta?: ActionMeta): Promise<{ task: number; packageId: string; count: number; delivery: "queued" | "unconfirmed" }> {
|
|
591
|
+
this.reviewPerson(by, meta); const check = this.requestCheck(by, () => this.reviewCommentHold()), fields = this.reviewInput(input, ["task", "ids", "packageId"]), taskId = reviewCommentNumber(fields.task, "task");
|
|
592
|
+
if (fields.packageId !== undefined) {
|
|
593
|
+
if (fields.ids !== undefined) throw new PreEffectToolRefusal("choose either saved packageId or draft ids; refresh the person review and retry one operation");
|
|
594
|
+
let packageId: string;
|
|
595
|
+
try { if (typeof fields.packageId !== "string" || !/^review-[a-f0-9-]{36}$/.test(fields.packageId)) throw new Error(); packageId = fields.packageId; }
|
|
596
|
+
catch { throw new PreEffectToolRefusal("review package id is invalid; select a saved package from the current person review list"); }
|
|
597
|
+
return this.commentWrite(async () => {
|
|
598
|
+
check(); const context = this.reviewContext(taskId), packet = this.d.board.reviewCommentPackage(packageId);
|
|
599
|
+
if (!packet || packet.task !== taskId || packet.taskCreated !== context.task.created || packet.owner !== context.task.owner) throw new PreEffectToolRefusal("saved review package is unavailable for this task owner; refresh the task review and inspect the existing package history");
|
|
600
|
+
if (!packet.body || !packet.envelopeId || !packet.trace || packet.ts === undefined) throw new PreEffectToolRefusal(`saved review package ${packageId} predates recoverable delivery; inspect the owner's queue and sent comments before drafting a new package`);
|
|
601
|
+
if (!context.task.owner || context.task.owner === USER || context.task.owner === HUB) throw new PreEffectToolRefusal("saved review package has no current agent owner; assign the open task before retrying");
|
|
602
|
+
if (isClosedTaskState(context.task.state)) throw new PreEffectToolRefusal("review package cannot be sent because the task is closed; reopen the task review before sending");
|
|
603
|
+
const env = { id: packet.envelopeId, trace: packet.trace, hop: 0, from: HUB, to: [packet.owner], kind: "review" as const, priority: "important" as const, body: packet.body,
|
|
604
|
+
refs: { task: String(taskId), taskRecipient: "owner" as const, reviewPackage: packageId }, ts: packet.ts };
|
|
605
|
+
check();
|
|
606
|
+
const status = this.d.bus.reviewEnvelopeStatus(env.id);
|
|
607
|
+
if (status === "unknown") throw new PreEffectToolRefusal("saved review package delivery history is unavailable; inspect ahub queue list --peer " + packet.owner + " and sent comments before retrying");
|
|
608
|
+
if (status === "failed" || status === "discarded") throw new PreEffectToolRefusal(`saved review package delivery is ${status}; inspect ahub queue list --peer ${packet.owner} and ahub queue show <delivery-id> before retrying`);
|
|
609
|
+
if (status === "absent") {
|
|
610
|
+
if (packet.publicationEvidenceVersion !== 1) throw new PreEffectToolRefusal(`saved review package ${packageId} has no retained delivery proof; inspect the owner's queue and sent comments before retrying`);
|
|
611
|
+
if (packet.visibleIds === undefined) throw new PreEffectToolRefusal("saved review package has no privacy binding; inspect the owner's queue and sent comments, then dismiss or recreate the package");
|
|
612
|
+
const records = this.d.board.reviewComments(taskId, context.task.created);
|
|
613
|
+
if (packet.ids.some(id => records.find(row => row.thread.id === id)?.lifecycle !== "sent")) throw new PreEffectToolRefusal("saved review package comments are no longer all sent; inspect the owner's queue and dismiss or recreate the package");
|
|
614
|
+
if (packet.visibleIds.length && (this.reviewTaskPrivate(context.task)
|
|
615
|
+
|| packet.visibleIds.some(id => { const record = records.find(row => row.thread.id === id); return !record || this.reviewRecordPrivate(context.task, record); }))) {
|
|
616
|
+
throw new PreEffectToolRefusal("saved review package is private under current policy; inspect the owner's queue, dismiss the old comments, and create a new package only after review privacy is clear");
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
try {
|
|
620
|
+
const targets = this.d.bus.publishReviewEnvelope(env);
|
|
621
|
+
if (!targets.includes(packet.owner)) throw new Error("owner not queued");
|
|
622
|
+
} catch {
|
|
623
|
+
const current = this.d.bus.reviewEnvelopeStatus(env.id);
|
|
624
|
+
if (current === "failed" || current === "discarded") throw new PreEffectToolRefusal(`saved review package delivery is ${current}; inspect ahub queue list --peer ${packet.owner} and ahub queue show <delivery-id> before retrying`);
|
|
625
|
+
throw new PreEffectToolRefusal("saved review package delivery history is unavailable or publication failed; inspect ahub queue list --peer " + packet.owner + " and sent comments before retrying");
|
|
626
|
+
}
|
|
627
|
+
this.d.board.markReviewCommentPackageQueued(packageId);
|
|
628
|
+
return { task: taskId, packageId, count: packet.ids.length, delivery: "queued" };
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
let ids: string[] | undefined;
|
|
632
|
+
try { ids = fields.ids === undefined ? undefined : reviewCommentIds(fields.ids); } catch { throw new PreEffectToolRefusal("review send ids are invalid; select current distinct draft ids"); }
|
|
633
|
+
return this.commentWrite(async () => {
|
|
634
|
+
check(); const before = this.reviewContext(taskId), owner = before.task.owner;
|
|
635
|
+
if (!owner || owner === USER || owner === HUB) throw new PreEffectToolRefusal("review package needs an agent owner; assign the task before sending comments");
|
|
636
|
+
if (isClosedTaskState(before.task.state)) throw new PreEffectToolRefusal("review comments can only be sent while the task is open; reopen the task review before sending");
|
|
637
|
+
const records = this.d.board.reviewComments(taskId, before.task.created), selected = ids === undefined ? records.filter(row => row.lifecycle === "draft") : ids.map(id => records.find(row => row.thread.id === id));
|
|
638
|
+
if (!selected.length || selected.some(row => !row || row.lifecycle !== "draft")) throw new PreEffectToolRefusal("review send needs current draft comments; refresh the list and select draft ids");
|
|
639
|
+
const drafts = selected as ReviewCommentRecord[], privateTask = this.reviewTaskPrivate(before.task), publicDrafts = drafts.filter(row => !this.reviewRecordPrivate(before.task, row));
|
|
640
|
+
const batch = privateTask || !publicDrafts.length ? undefined : await this.reviewBatch(before.task, publicDrafts.map(row => row.thread.anchor));
|
|
641
|
+
check(); const current = this.reviewContext(taskId);
|
|
642
|
+
if (current.key !== before.key) throw new PreEffectToolRefusal("review task or owner changed; refresh the review before sending");
|
|
643
|
+
const latest = this.d.board.reviewComments(taskId, current.task.created);
|
|
644
|
+
if (drafts.some(row => { const fresh = latest.find(value => value.thread.id === row.thread.id); return !fresh || fresh.lifecycle !== "draft" || fresh.revision !== row.revision; })) throw new PreEffectToolRefusal("review drafts changed; refresh the list before sending");
|
|
645
|
+
const unavailable = privateTask ? [] : publicDrafts.filter(row => this.reviewPlacement(row.thread.anchor, batch!).status === "unavailable");
|
|
646
|
+
if (unavailable.length) throw new PreEffectToolRefusal(`review material is unavailable for ${unavailable.map(row => row.thread.id).join(", ")}; refresh the task review and retry after the anchored file is available`);
|
|
647
|
+
const packageId = `review-${crypto.randomUUID()}`, envelopeId = crypto.randomUUID(), trace = crypto.randomUUID(), ts = Date.now();
|
|
648
|
+
const body = privateTask ? `Task #${taskId}: ${drafts.length} person review comments are kept on the board; private text is withheld.`
|
|
649
|
+
: [`Person review comments for task #${taskId} (${drafts.length}). Address these comments; report resolved ids in hub_task_done.`, ...drafts.map(row => {
|
|
650
|
+
if (this.reviewRecordPrivate(current.task, row)) return renderReviewComment(row, { status: "unavailable" }, false);
|
|
651
|
+
return renderReviewComment(row, this.reviewPlacement(row.thread.anchor, batch!), true);
|
|
652
|
+
})].join("\n\n");
|
|
653
|
+
if (Buffer.byteLength(body, "utf8") > REVIEW_PACKAGE_BYTES) throw new PreEffectToolRefusal("review package exceeds 12000 presentation bytes; select fewer ids before sending");
|
|
654
|
+
let packageHead: string;
|
|
655
|
+
try { packageHead = reviewCommentHead(batch?.head ?? this.reviewHead(current.task)); }
|
|
656
|
+
catch { throw new PreEffectToolRefusal("review package has no current immutable head; refresh the task review before sending"); }
|
|
657
|
+
check(); this.d.board.sendReviewComments(drafts, { id: packageId, task: taskId, taskCreated: current.task.created, ids: drafts.map(row => row.thread.id),
|
|
658
|
+
visibleIds: privateTask ? [] : publicDrafts.map(row => row.thread.id), owner, head: packageHead, at: new Date(this.d.now?.() ?? Date.now()).toISOString(), body,
|
|
659
|
+
envelopeId, trace, ts, delivery: "unconfirmed", publicationEvidenceVersion: 1 }, current.fence);
|
|
660
|
+
let delivery: "queued" | "unconfirmed" = "unconfirmed";
|
|
661
|
+
this.releaseNotice(`task #${taskId}: review package ${packageId} publication unconfirmed; inspect ahub queue list --peer ${owner} before any retry`, () => {
|
|
662
|
+
try { const targets = this.d.bus.publishResult({ id: envelopeId, trace, hop: 0, from: HUB, to: [owner], kind: "review", priority: "important", body,
|
|
663
|
+
refs: { task: String(taskId), taskRecipient: "owner", reviewPackage: packageId }, ts }); if (!targets.targets.includes(owner)) throw new Error("not queued"); this.d.board.markReviewCommentPackageQueued(packageId); delivery = "queued"; }
|
|
664
|
+
catch { throw new Error("publication may be unconfirmed; inspect the saved queue before any explicit retry"); }
|
|
665
|
+
});
|
|
666
|
+
return { task: taskId, packageId, count: drafts.length, delivery };
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
private commentWrites = 0;
|
|
670
|
+
private commentsStopped = false;
|
|
671
|
+
private readonly commentDrain = new Set<() => void>();
|
|
672
|
+
reviewCommentsPending = (): number => this.commentWrites;
|
|
673
|
+
async stopReviewComments(): Promise<void> { this.commentsStopped = true; if (this.commentWrites) await new Promise<void>(resolve => this.commentDrain.add(resolve)); }
|
|
674
|
+
private reviewCommentHold(): string | undefined {
|
|
675
|
+
try { if (this.commentsStopped || this.d.cancellationHeld?.() || this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return "review comment writes are held; wait for recovery or shutdown to settle before retrying"; }
|
|
676
|
+
catch { return "review comment admission is unavailable; inspect ahub status before retrying"; }
|
|
677
|
+
return undefined;
|
|
678
|
+
}
|
|
679
|
+
private async commentWrite<T>(write: () => Promise<T>): Promise<T> {
|
|
680
|
+
this.commentWrites++;
|
|
681
|
+
try { return await write(); } finally { if (--this.commentWrites === 0) { for (const resolve of this.commentDrain) resolve(); this.commentDrain.clear(); } }
|
|
682
|
+
}
|
|
683
|
+
private reviewPerson(by: PeerId, meta?: ActionMeta): string {
|
|
684
|
+
if (by !== USER) throw new PreEffectToolRefusal("only the console person may change review comments; use the person's task review surface");
|
|
685
|
+
try { return reviewCommentAuthor(meta); } catch { throw new PreEffectToolRefusal("verified person identity is unavailable; reopen the authenticated console review before retrying"); }
|
|
686
|
+
}
|
|
687
|
+
private reviewTaskPrivate(task: Task): boolean {
|
|
688
|
+
try { return task.signals.includes("pii") || task.signals.includes("pii_path") || this.isPii(task); } catch { return true; }
|
|
689
|
+
}
|
|
690
|
+
private reviewPathPrivate(path: string): boolean {
|
|
691
|
+
try { return isDenied(path, this.d.deny) || matchesPolicy(this.d.cwd, path, this.d.piiPaths ?? [], true) || this.publicMetadataText(path) !== true; } catch { return true; }
|
|
692
|
+
}
|
|
693
|
+
private reviewRecordPrivate(task: Task, record: ReviewCommentRecord): boolean {
|
|
694
|
+
try { return record.private || this.reviewTaskPrivate(task) || this.reviewPathPrivate(record.thread.anchor.path) || this.publicMetadataText(JSON.stringify(record.thread)) !== true; } catch { return true; }
|
|
695
|
+
}
|
|
696
|
+
private reviewContext(id: unknown): { task: Task; fence: ReviewCommentFence; key: string } {
|
|
697
|
+
const task = this.need(id, false, true, true), fence: ReviewCommentFence = { task: task.id, taskCreated: task.created, flow: task.history.length, owner: task.owner, ...(this.reviewHead(task) ? { head: this.reviewHead(task) } : {}) };
|
|
698
|
+
return { task, fence, key: JSON.stringify([fence, this.reviewTaskPrivate(task)]) };
|
|
699
|
+
}
|
|
700
|
+
private reviewInput(input: unknown, keys: readonly string[]): Record<string, unknown> {
|
|
701
|
+
try { return reviewCommentFields(input, keys); } catch { throw new PreEffectToolRefusal("review comment input is invalid; use only the operation's current task, ids, revision and bounded comment fields"); }
|
|
702
|
+
}
|
|
703
|
+
private async reviewBatch(task: Task, anchors: readonly ReviewAnchor[]): Promise<ReviewMaterialBatch> {
|
|
704
|
+
if (!this.d.reviewMaterials) throw new PreEffectToolRefusal("review material reader is unavailable; reopen the task review with the current hub before retrying");
|
|
705
|
+
const requests = [...new Map(anchors.map(anchor => [JSON.stringify([anchor.path, anchor.side ?? "head"]), { path: anchor.path, ...(anchor.side ? { side: anchor.side } : {}) }])).values()];
|
|
706
|
+
try { const batch = await this.d.reviewMaterials(task, requests); reviewCommentHead(batch.head); return batch; }
|
|
707
|
+
catch { throw new PreEffectToolRefusal("review material or immutable head is unavailable; refresh the task review before retrying"); }
|
|
708
|
+
}
|
|
709
|
+
private reviewPlacement(anchor: ReviewAnchor, batch: ReviewMaterialBatch): ReviewAnchorStatus {
|
|
710
|
+
const material = batch.materials.find(row => row.path === anchor.path && row.side === (anchor.side ?? "head"));
|
|
711
|
+
if (!material) return { status: "unavailable" };
|
|
712
|
+
try { return anchorStatus(anchor, material); } catch { return { status: "unavailable" }; }
|
|
713
|
+
}
|
|
714
|
+
private reviewCommentResolve(by: PeerId, task: Task, input: unknown): string[] | undefined {
|
|
715
|
+
if (input === undefined) return undefined;
|
|
716
|
+
try { const ids = reviewCommentIds(input); if (ids.length && by !== task.owner) throw new Error("not owner"); this.d.board.reviewCommentResolution(task, ids); return ids; }
|
|
717
|
+
catch { throw new PreEffectToolRefusal("comment resolution requires the task owner and sent comment ids; refresh the task review before hub_task_done"); }
|
|
718
|
+
}
|
|
454
719
|
private readonly planReviewRequests = new Map<number, string>();
|
|
455
720
|
private readonly repositories: TaskRepositories;
|
|
456
721
|
private readonly incomingBacklog = new Set<number>();
|
|
@@ -757,7 +1022,8 @@ export class Tasks {
|
|
|
757
1022
|
|
|
758
1023
|
private findingTextAllowed(task: Task, items: readonly ReviewFinding[]): boolean {
|
|
759
1024
|
return this.candidateTextAllowed(JSON.stringify(items), task)
|
|
760
|
-
&& items.every(item => !item.path || !matchesPolicy(this.d.cwd, item.path, this.d.piiPaths ?? [], true))
|
|
1025
|
+
&& items.every(item => (!item.path || !matchesPolicy(this.d.cwd, item.path, this.d.piiPaths ?? [], true))
|
|
1026
|
+
&& (!item.anchor || !this.reviewPathPrivate(item.anchor.path)));
|
|
761
1027
|
}
|
|
762
1028
|
|
|
763
1029
|
/** Host-authored proposals contain only cleared IDs, integer task references and fixed instruction targets. */
|
|
@@ -913,6 +1179,14 @@ export class Tasks {
|
|
|
913
1179
|
for (const id of created) this.releaseNotice(`task #${id}: recurring review finding amendment proposed in the backlog`);
|
|
914
1180
|
}
|
|
915
1181
|
|
|
1182
|
+
private publicClaimBindings(task: Task): ReturnType<typeof taskClaimIdentity>[] | undefined {
|
|
1183
|
+
const claims = task.plan?.claims;
|
|
1184
|
+
if (task.profile !== "research" || task.signals.includes("pii") || task.signals.includes("pii_path")
|
|
1185
|
+
|| !Array.isArray(claims) || !claims.length || claims.length > 50 || claims.some(value => typeof value !== "string")) return undefined;
|
|
1186
|
+
try { return claims.map((_claim, index) => taskClaimIdentity(task, index + 1)); }
|
|
1187
|
+
catch { return undefined; } // Optional public metadata never exposes a partial or malformed binding.
|
|
1188
|
+
}
|
|
1189
|
+
|
|
916
1190
|
/** A task as a cloud peer may see it. */
|
|
917
1191
|
|
|
918
1192
|
publicView(task: Task, fullHistory = false): Record<string, unknown> {
|
|
@@ -920,7 +1194,8 @@ export class Tasks {
|
|
|
920
1194
|
const plannerBinding = history.findLast(h => h.planner === task.planner && h.plannerReason !== undefined);
|
|
921
1195
|
const ownerBinding = history.findLast(h => h.owner === task.owner && ["assigned", "reassigned", "escalated", "backlog pulled"].includes(h.event));
|
|
922
1196
|
const reviewerBinding = history.findLast(h => ["assigned", "reassigned", "escalated", "done", "reviewer changed", "reviewer unavailable"].includes(h.event));
|
|
923
|
-
const
|
|
1197
|
+
const reviewComments = this.d.board.reviewCommentCounts(task.id, task.created);
|
|
1198
|
+
const rest = { ...taskRest, ...(Object.values(reviewComments).some(Boolean) ? { reviewComments } : {}),
|
|
924
1199
|
...(plannerBinding?.plannerReason && ["rotation", "named", "kept", "none"].includes(plannerBinding.plannerReason) ? { plannerReason: plannerBinding.plannerReason } : {}),
|
|
925
1200
|
...(ownerBinding?.reason && TASK_MOVE_REASONS.includes(ownerBinding.reason) ? { ownerReason: ownerBinding.reason === "manual" && ownerBinding.event === "reassigned" && ownerBinding.by !== HUB ? "named" : ownerBinding.reason } : {}),
|
|
926
1201
|
...(reviewerBinding?.reviewerReason && ["rotation", "implementer skip", "unavailable skip", "same-family fallback"].includes(reviewerBinding.reviewerReason) ? { reviewerReason: reviewerBinding.reviewerReason } : reviewerBinding?.event === "reviewer changed" && reviewerBinding.reason === "manual" ? { reviewerReason: "named" } : {}),
|
|
@@ -929,6 +1204,7 @@ export class Tasks {
|
|
|
929
1204
|
if (rest.sdlc?.redFirst && !this.publicMetadataText(JSON.stringify(rest.sdlc.redFirst))) {
|
|
930
1205
|
const { redFirst: _withheld, ...receipt } = rest.sdlc; rest.sdlc = receipt;
|
|
931
1206
|
}
|
|
1207
|
+
const claimBindings = this.publicClaimBindings(task);
|
|
932
1208
|
const currentSpecReport = this.currentSpecReport(task);
|
|
933
1209
|
const currentSpecEntry = currentSpecReport ? history.findLast(entry => entry.event === "done" || entry.event === "done (checking)") : undefined;
|
|
934
1210
|
const screenHistoryText = (text: string, field: string, withheld = false): string => !withheld && this.nameable(text) ? text : `[history ${field} withheld: ${this.whyWithheld(text)}; ahub task show ${task.id}]`;
|
|
@@ -964,7 +1240,7 @@ export class Tasks {
|
|
|
964
1240
|
const symbolRepository = codeSymbols && this.codeSymbolsRepository(task);
|
|
965
1241
|
const publicSymbols = codeSymbols && symbolRepository ? publicTaskCodeSymbols(codeSymbols, this.reviewHead(task), symbolRepository.key, this.publicMetadataText) : undefined;
|
|
966
1242
|
const handedOff = this.d.board.handoffOrigin(task.id); // #604: where another project's handoff came from; two ids, never text
|
|
967
|
-
return { ...rest, ...(currentSpecReport ? { specReport: currentSpecReport } : {}), ...(approvalBinding(task) ? { approval: approvalBinding(task) } : {}), ...(handedOff ? { handoffOrigin: { projectId: handedOff.projectId, peer: handedOff.peer, ...(handedOff.originTask === null ? {} : { task: handedOff.originTask }) } } : {}), ...(publicSymbols ? { codeSymbols: publicSymbols } : {}), ...(current.length ? { findings: findingTally(current) } : {}), ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task), undefined, sets, task.refs.commit) } : {}), title, detail, history: publicHistory };
|
|
1243
|
+
return { ...rest, ...(claimBindings ? { claims: claimBindings } : {}), ...(currentSpecReport ? { specReport: currentSpecReport } : {}), ...(approvalBinding(task) ? { approval: approvalBinding(task) } : {}), ...(handedOff ? { handoffOrigin: { projectId: handedOff.projectId, peer: handedOff.peer, ...(handedOff.originTask === null ? {} : { task: handedOff.originTask }) } } : {}), ...(publicSymbols ? { codeSymbols: publicSymbols } : {}), ...(current.length ? { findings: findingTally(current) } : {}), ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task), undefined, sets, task.refs.commit) } : {}), title, detail, history: publicHistory };
|
|
968
1244
|
}
|
|
969
1245
|
|
|
970
1246
|
/** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
|
|
@@ -1868,8 +2144,19 @@ export class Tasks {
|
|
|
1868
2144
|
if (by !== USER && !this.d.roles?.[by]?.includes("planner") && this.d.operational?.(by) !== true) throw new HandoffRefusal("no_authority", "a handoff needs the person, a configured planner or the conductor");
|
|
1869
2145
|
}
|
|
1870
2146
|
|
|
2147
|
+
/**
|
|
2148
|
+
* #734 (AC6): who may ask the origin to catch up now (`ahub task handoff --sync`). The person, or the conductor acting on the person's instruction: the same bar as `handoffOutAuthority`
|
|
2149
|
+
* without the planner role, because a sync starts no work and files nothing, but it does ask other projects' hubs, which a planner agent has no need to trigger.
|
|
2150
|
+
*/
|
|
2151
|
+
handoffSyncAuthority(by: PeerId): void {
|
|
2152
|
+
requireLocalTaskPeer(by);
|
|
2153
|
+
if (by !== USER && this.d.operational?.(by) !== true) throw new HandoffRefusal("no_authority", "a handoff sync is the person's or the conductor's: ask them to run `ahub task handoff --sync`");
|
|
2154
|
+
}
|
|
2155
|
+
|
|
1871
2156
|
/** ORIGIN side: remember what was handed over, once the target answered with its task id. */
|
|
1872
2157
|
handoffSent(by: PeerId, target: { projectId: string }, remoteTask: number, title: string, localTask?: number): void {
|
|
2158
|
+
// #731: a row that already exists is never reset to "proposed": a replayed answer can arrive after the target's own event for the same task was recorded.
|
|
2159
|
+
if (this.d.board.handoffOutboundFor(target.projectId, remoteTask)) return;
|
|
1873
2160
|
this.d.board.recordHandoffOutbound({ remoteProject: target.projectId, remoteTask, originPeer: by, ...(localTask === undefined ? {} : { localTask }), title: title.slice(0, 300), state: "proposed" });
|
|
1874
2161
|
}
|
|
1875
2162
|
|
|
@@ -1878,7 +2165,7 @@ export class Tasks {
|
|
|
1878
2165
|
* The request was parsed closed by the origin and is parsed again here, because a control message is data from another process: the body is bounded, control characters are refused and the
|
|
1879
2166
|
* text is sanitized before it is stored. A `[pii]` result is refused whole, and nothing is left behind.
|
|
1880
2167
|
*/
|
|
1881
|
-
async handoffIn(origin: { projectId: unknown; peer: unknown; taskId?: unknown }, input: unknown, fence: () => string | undefined = () => undefined): Promise<{ task: number; state: string; note?: string }> {
|
|
2168
|
+
async handoffIn(origin: { projectId: unknown; peer: unknown; taskId?: unknown }, input: unknown, fence: () => string | undefined = () => undefined): Promise<{ task: number; state: string; note?: string; replayed?: true }> {
|
|
1882
2169
|
const request = parseHandoff({ ...(input && typeof input === "object" && !Array.isArray(input) ? input as Record<string, unknown> : { invalid: true }), project: "local" });
|
|
1883
2170
|
if (typeof origin.projectId !== "string" || !origin.projectId || origin.projectId.length > 200 || typeof origin.peer !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(origin.peer)
|
|
1884
2171
|
|| origin.taskId !== undefined && (!Number.isSafeInteger(origin.taskId) || (origin.taskId as number) < 1)) throw new HandoffRefusal("invalid", "handoff origin is malformed");
|
|
@@ -1886,9 +2173,21 @@ export class Tasks {
|
|
|
1886
2173
|
// The fence is asked right before the board write and after every await: a recovery that began while the PII screen was pending must find nothing written (agent-hub#1030, review).
|
|
1887
2174
|
const held = (): void => { const reason = fence(); if (reason) throw new HandoffRefusal("invalid", reason); };
|
|
1888
2175
|
held();
|
|
2176
|
+
// #731 (agent-hub#1203): a request key this origin already used answers with the task it made, before the duplicate check (which would refuse it as a duplicate of itself) and before any PII screen
|
|
2177
|
+
// or write. The same key for a different request is refused, never answered with a task the caller did not ask for.
|
|
2178
|
+
const key = request.handoffKey, digest = handoffRequestDigest(request, origin.taskId as number | undefined);
|
|
2179
|
+
const replay = (): { task: number; state: string; replayed: true } | undefined => {
|
|
2180
|
+
const existing = key === undefined ? undefined : this.d.board.handoffOriginByKey(origin.projectId as string, key);
|
|
2181
|
+
const task = existing && this.d.board.get(existing.taskId);
|
|
2182
|
+
if (!existing || !task) return undefined;
|
|
2183
|
+
if (existing.digest !== null && existing.digest !== digest) throw new HandoffRefusal("invalid", "that handoff key was already used for a different request: hand the task off again with a new key");
|
|
2184
|
+
return { task: task.id, state: task.state, replayed: true };
|
|
2185
|
+
};
|
|
2186
|
+
const early = replay();
|
|
2187
|
+
if (early) return early;
|
|
1889
2188
|
// The spec issue the caller named is kept in the task itself, as its own line: the target's planner reads it from the detail, and `ahub task show` shows it there.
|
|
1890
2189
|
const issueLine = request.issue === undefined ? "" : `spec issue: #${request.issue} in this repository`;
|
|
1891
|
-
const detail = sanitize([request.detail, issueLine, `[handed off from project ${origin.projectId} by ${origin.peer}${origin.taskId === undefined ? "" : ` (their task #${origin.taskId})`}]`].filter(Boolean).join("\n\n"));
|
|
2190
|
+
const detail = sanitize([request.detail, issueLine, request.duplicate_ok === undefined ? "" : `duplicate acknowledged by the sender: ${request.duplicate_ok}`, `[handed off from project ${origin.projectId} by ${origin.peer}${origin.taskId === undefined ? "" : ` (their task #${origin.taskId})`}]`].filter(Boolean).join("\n\n"));
|
|
1892
2191
|
const refs: TaskRefs = { ...(request.paths.length ? { paths: request.paths } : {}) };
|
|
1893
2192
|
const signals = this.signalsFor({ title: request.title, detail, refs, plan: {} });
|
|
1894
2193
|
if (signals.includes("pii")) throw new HandoffRefusal("pii_task", "the handed-off text matches a PII pattern; it is not stored here");
|
|
@@ -1896,8 +2195,20 @@ export class Tasks {
|
|
|
1896
2195
|
const screened = screening ? await this.screenCall(screenText({ title: request.title, detail: [detail, ...request.paths].join("\n"), plan: {} })) : undefined;
|
|
1897
2196
|
held();
|
|
1898
2197
|
if (screening && (!screened || screened.label !== "clear")) throw new HandoffRefusal("pii_task", "the handed-off text could not be cleared by the PII screen; it is not stored here");
|
|
1899
|
-
|
|
1900
|
-
|
|
2198
|
+
// The first attempt may have committed while this one waited for the screen (the origin's timeout does not stop it): ask again, with nothing awaited between here and the write below.
|
|
2199
|
+
const late = replay();
|
|
2200
|
+
if (late) return late;
|
|
2201
|
+
// #1175: a spec issue that already has open work here is refused before any row exists, so a duplicate is never offered to an owner or escalated by the sweep. This runs after the last
|
|
2202
|
+
// await and writes nothing before it, so the check and the proposal below see one board; a `[pii]` task is left out, so no private task id is named to another project.
|
|
2203
|
+
if (request.issue !== undefined && request.duplicate_ok === undefined) {
|
|
2204
|
+
const open = duplicateIssueTasks(this.d.board.list().filter(row => !this.isPii(row)).map(row => ({ id: row.id, state: row.state, title: row.title, detail: row.detail, refs: row.refs, external: row.external, sdlc: row.sdlc })), request.issue, this.d.repository);
|
|
2205
|
+
if (open.length) throw new HandoffRefusal("duplicate", duplicateRefusalText(request.issue, open));
|
|
2206
|
+
}
|
|
2207
|
+
// #731 (H9): the proposal and its origin row are one board transaction. A request with no key (an older origin) is written the same way, without a key, so it is never left without its origin either.
|
|
2208
|
+
const made = this.d.board.proposeHandoff(HUB, { title: request.title, detail, class: request.class, refs, plan: {}, signals },
|
|
2209
|
+
{ projectId: origin.projectId, peer: origin.peer, ...(origin.taskId === undefined ? {} : { originTask: origin.taskId as number }), ...(key === undefined ? {} : { key, digest }) });
|
|
2210
|
+
if (made.replayed) return { task: made.task.id, state: made.task.state, replayed: true };
|
|
2211
|
+
let task = made.task;
|
|
1901
2212
|
// From here the task and its origin are saved, so nothing below may fail the handoff: the origin must learn the task id, or the target keeps work nobody can track.
|
|
1902
2213
|
try { task = this.linkOnWrite(task); } catch { /* the lifecycle link is advice */ }
|
|
1903
2214
|
try { this.d.notify(`task ${this.publicTitle(task)} handed off from project ${origin.projectId} by ${origin.peer}`); } catch { /* never throw after a board write */ }
|
|
@@ -1916,20 +2227,34 @@ export class Tasks {
|
|
|
1916
2227
|
return origin ? { projectId: origin.projectId, peer: origin.peer, originTask: origin.originTask } : undefined;
|
|
1917
2228
|
}
|
|
1918
2229
|
|
|
1919
|
-
/**
|
|
2230
|
+
/**
|
|
2231
|
+
* ORIGIN side: the target reported a state change. Records it and tells the origin peer with one `kind: task` envelope. Idempotent: a state already recorded sends nothing.
|
|
2232
|
+
* #733: an event may carry `seq`, the target task's flow revision. One that is not newer than the stored sequence changes nothing and sends nothing (an `in_review` that arrives after
|
|
2233
|
+
* `approved` cannot reopen the row), decided inside the board write; an event without one (an older target) applies as before.
|
|
2234
|
+
*/
|
|
1920
2235
|
handoffEvent(input: unknown): { recorded: boolean } {
|
|
1921
2236
|
if (!input || typeof input !== "object" || Array.isArray(input)) throw new HandoffRefusal("invalid", "handoff event must be a data object");
|
|
1922
2237
|
const e = input as Record<string, unknown>;
|
|
1923
2238
|
if (typeof e.projectId !== "string" || !e.projectId || e.projectId.length > 200 || !Number.isSafeInteger(e.remoteTask) || (e.remoteTask as number) < 1
|
|
1924
|
-
|| typeof e.state !== "string" || !(HANDOFF_EVENTS as readonly string[]).includes(e.state)
|
|
2239
|
+
|| typeof e.state !== "string" || !(HANDOFF_EVENTS as readonly string[]).includes(e.state)
|
|
2240
|
+
|| e.seq !== undefined && (!Number.isSafeInteger(e.seq) || (e.seq as number) < 1)) throw new HandoffRefusal("invalid", "handoff event is malformed");
|
|
1925
2241
|
const row = this.d.board.handoffOutboundFor(e.projectId, e.remoteTask as number);
|
|
1926
2242
|
if (!row) throw new HandoffRefusal("invalid", "no handoff of ours is recorded for that task");
|
|
2243
|
+
const seq = e.seq as number | undefined;
|
|
1927
2244
|
const pr = Number.isSafeInteger(e.pr) && (e.pr as number) > 0 ? e.pr as number : undefined, head = typeof e.head === "string" && /^[0-9a-f]{40}$/.test(e.head) ? e.head : undefined;
|
|
1928
|
-
if (
|
|
1929
|
-
|
|
2245
|
+
if (seq !== undefined && row.seq !== null && seq <= row.seq) return { recorded: false };
|
|
2246
|
+
if (row.state === e.state && (pr === undefined || pr === row.pr) && (head === undefined || head === row.head)) {
|
|
2247
|
+
// Nothing to tell the peer, but a newer sequence is still kept, so a later stale event is judged against it.
|
|
2248
|
+
if (seq !== undefined && (row.seq === null || seq > row.seq)) this.d.board.recordHandoffOutbound({ remoteProject: row.remoteProject, remoteTask: row.remoteTask, originPeer: row.originPeer, ...(row.localTask === null ? {} : { localTask: row.localTask }), title: row.title, state: row.state, seq });
|
|
2249
|
+
return { recorded: false };
|
|
2250
|
+
}
|
|
2251
|
+
const written = this.d.board.recordHandoffOutbound({ remoteProject: row.remoteProject, remoteTask: row.remoteTask, originPeer: row.originPeer, ...(row.localTask === null ? {} : { localTask: row.localTask }), title: row.title, state: e.state, ...(pr === undefined ? {} : { pr }), ...(head === undefined ? {} : { head }), ...(seq === undefined ? {} : { seq }) });
|
|
2252
|
+
if (!written.applied) return { recorded: false }; // a newer event won the race between the read above and this write
|
|
1930
2253
|
const body = followBackLine(row.remoteTask, e.state as HandoffEvent, { pr, head });
|
|
1931
2254
|
if (row.originPeer !== USER && row.originPeer !== HUB) {
|
|
1932
|
-
|
|
2255
|
+
// #731 (H3): the envelope names the REMOTE task and no local one. `refs.task` made the bus judge it against whatever local task has that number (or the origin's own task it came from), so a
|
|
2256
|
+
// cancelled or approved local task of the same number dropped a state change that was about another project's task.
|
|
2257
|
+
try { this.d.bus.publish(newEnvelope(HUB, body, { to: [row.originPeer], kind: "task", priority: "important", refs: { handoff: { project: row.remoteProject, task: row.remoteTask } } })); }
|
|
1933
2258
|
catch { /* never throw after a board write */ }
|
|
1934
2259
|
}
|
|
1935
2260
|
return { recorded: true };
|
|
@@ -1939,9 +2264,9 @@ export class Tasks {
|
|
|
1939
2264
|
* TARGET side, catch-up (#1030 design 4): the origin asks for the current state of the tasks it handed us, by id, after it was down. Answers only for tasks whose recorded origin IS the asking
|
|
1940
2265
|
* project, and only with the same fields a live event carries (the event name, a PR number, a full head), so an origin learns nothing it could not have been told live.
|
|
1941
2266
|
*/
|
|
1942
|
-
handoffStatesFor(projectId: unknown, taskIds: unknown): { task: number; state: HandoffEvent | "proposed"; pr?: number; head?: string }[] {
|
|
2267
|
+
handoffStatesFor(projectId: unknown, taskIds: unknown): { task: number; state: HandoffEvent | "proposed"; pr?: number; head?: string; seq: number }[] {
|
|
1943
2268
|
if (typeof projectId !== "string" || !projectId || projectId.length > 200 || !Array.isArray(taskIds) || taskIds.length > 200 || !taskIds.every(id => Number.isSafeInteger(id) && id > 0)) throw new HandoffRefusal("invalid", "handoff state request is malformed");
|
|
1944
|
-
const out: { task: number; state: HandoffEvent | "proposed"; pr?: number; head?: string }[] = [];
|
|
2269
|
+
const out: { task: number; state: HandoffEvent | "proposed"; pr?: number; head?: string; seq: number }[] = [];
|
|
1945
2270
|
for (const id of taskIds as number[]) {
|
|
1946
2271
|
const origin = this.d.board.handoffOrigin(id), task = origin?.projectId === projectId ? this.d.board.get(id) : undefined;
|
|
1947
2272
|
if (!task) continue;
|
|
@@ -1949,17 +2274,28 @@ export class Tasks {
|
|
|
1949
2274
|
const state = entry ? handoffEventFor(entry.event, task.state) ?? "proposed" : "proposed";
|
|
1950
2275
|
const pr = typeof task.sdlc?.pr === "number" && Number.isSafeInteger(task.sdlc.pr) && task.sdlc.pr > 0 ? task.sdlc.pr : undefined;
|
|
1951
2276
|
const rawHead = task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead, head = typeof rawHead === "string" && /^[0-9a-f]{40}$/.test(rawHead) ? rawHead : undefined; // the owner's commit, else the receipt's head; only a full SHA crosses
|
|
1952
|
-
|
|
2277
|
+
// #733: the task's current flow revision (its history length), the same number a live event carries, so a catch-up answer and a late live event are ordered against each other.
|
|
2278
|
+
out.push({ task: id, state, ...(pr === undefined ? {} : { pr }), ...(head === undefined ? {} : { head }), seq: Math.max(1, task.history.length) });
|
|
1953
2279
|
}
|
|
1954
2280
|
return out;
|
|
1955
2281
|
}
|
|
1956
2282
|
|
|
2283
|
+
/**
|
|
2284
|
+
* TARGET side (#731): where the task a handoff key made stands, for an origin whose request got no answer. Answers only the asking project, and only the fields a live event carries; nothing is
|
|
2285
|
+
* written. `undefined` means this target holds no task for that key.
|
|
2286
|
+
*/
|
|
2287
|
+
handoffStateByKey(projectId: unknown, key: unknown): { task: number; state: HandoffEvent | "proposed"; pr?: number; head?: string; seq: number } | undefined {
|
|
2288
|
+
if (typeof projectId !== "string" || !projectId || projectId.length > 200 || typeof key !== "string" || !HANDOFF_KEY.test(key)) throw new HandoffRefusal("invalid", "handoff key lookup is malformed");
|
|
2289
|
+
const found = this.d.board.handoffOriginByKey(projectId, key);
|
|
2290
|
+
return found ? this.handoffStatesFor(projectId, [found.taskId])[0] : undefined;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
1957
2293
|
/** ORIGIN side: record what a target answered to the catch-up, and tell the origin peer about a change this project missed while it was down. */
|
|
1958
|
-
handoffCaughtUp(projectId: string, rows: { task: number; state: string; pr?: number; head?: string }[]): number {
|
|
2294
|
+
handoffCaughtUp(projectId: string, rows: { task: number; state: string; pr?: number; head?: string; seq?: number }[]): number {
|
|
1959
2295
|
let changed = 0;
|
|
1960
2296
|
for (const row of rows) {
|
|
1961
2297
|
if (!(HANDOFF_EVENTS as readonly string[]).includes(row.state)) continue;
|
|
1962
|
-
try { if (this.handoffEvent({ projectId, remoteTask: row.task, state: row.state, ...(row.pr === undefined ? {} : { pr: row.pr }), ...(row.head === undefined ? {} : { head: row.head }) }).recorded) changed++; }
|
|
2298
|
+
try { if (this.handoffEvent({ projectId, remoteTask: row.task, state: row.state, ...(row.pr === undefined ? {} : { pr: row.pr }), ...(row.head === undefined ? {} : { head: row.head }), ...(row.seq === undefined ? {} : { seq: row.seq }) }).recorded) changed++; }
|
|
1963
2299
|
catch { /* a row we hold no handoff for is not ours */ }
|
|
1964
2300
|
}
|
|
1965
2301
|
return changed;
|
|
@@ -2562,7 +2898,7 @@ export class Tasks {
|
|
|
2562
2898
|
private topReservedReady(peer: PeerId, planner?: PeerId, pullable = false): Task | undefined {
|
|
2563
2899
|
const now = this.d.now?.() ?? Date.now();
|
|
2564
2900
|
return this.d.board.list().filter(card => (pullable ? card.state === "backlog" : ["backlog", "proposed"].includes(card.state)) && !card.owner && card.reserved === peer
|
|
2565
|
-
&& (planner === undefined || this.taskPlanner(card).includes(planner)) && !plannedStartPending(card, now) && !(pullable && this.isPii(card)) && !card.pinned && !card.draft && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(peer)
|
|
2901
|
+
&& (planner === undefined || this.taskPlanner(card).includes(planner)) && !plannedStartPending(card, now) && !(pullable && this.isPii(card)) && !card.pinned && !card.needsPerson && !card.draft && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(peer)
|
|
2566
2902
|
&& (card.release == null || ["planning", "active"].includes(this.d.board.release(card.release)?.state ?? ""))).sort(this.readyOrder)[0];
|
|
2567
2903
|
}
|
|
2568
2904
|
|
|
@@ -2669,6 +3005,8 @@ export class Tasks {
|
|
|
2669
3005
|
const task = this.d.board.get(input.id);
|
|
2670
3006
|
if (task?.draft) throw new PreEffectToolRefusal(`task #${task.id} is an intake draft; the person approves it first`);
|
|
2671
3007
|
if (!task || (task.planningRevision ?? 0) !== input.revision || task.state !== "backlog" || task.owner || !taskCanPlan(task) || task.pinned) throw new PreEffectToolRefusal("planner pull changed, pinned or already started");
|
|
3008
|
+
// #1176: the one gate every agent pull passes (a planner's hub_backlog_pull and the opt-in auto pull alike): a person-run leg is never pulled into an agent's To Do.
|
|
3009
|
+
if (task.needsPerson) throw new PreEffectToolRefusal(`task #${task.id} is a person-run leg: it waits on the person (live receipts, their accounts or approvals), so no agent is offered it; the person runs it or clears the mark with ahub backlog plan`);
|
|
2672
3010
|
this.planningAuthority(planner, task);
|
|
2673
3011
|
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
2674
3012
|
if (task.release != null && (!release || ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull requires unfrozen release scope");
|
|
@@ -2896,7 +3234,11 @@ export class Tasks {
|
|
|
2896
3234
|
if (!this.isPii(task) && !this.publicMetadataText(JSON.stringify(patch.sdlc))) throw new PreEffectToolRefusal("planning identity metadata is private or sensitive");
|
|
2897
3235
|
}
|
|
2898
3236
|
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
|
|
3237
|
+
// #1176: marking a card an agent already owns (an offer in its queue, or work it accepted) would leave the agent on it: the offer is not withdrawn by a mark. Refused, with the way out named.
|
|
3238
|
+
if (patch.needsPerson === true && !task.needsPerson && (task.owner || !["backlog", "proposed"].includes(task.state))) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.owner ? `${task.state} with ${task.owner}` : task.state}: take it off the agent first (its owner declines the offer, or ahub task cancel ${task.id} --reason <text>), then mark it person-run`);
|
|
2899
3239
|
if (by !== USER && (task.state !== "backlog" || !taskCanPlan(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
|
|
3240
|
+
// #1176: whether a leg is the person's to run is the person's call, set or cleared: an agent that could clear the mark would put the card back in front of an agent.
|
|
3241
|
+
if (by !== USER && (Object.hasOwn(patch, "needsPerson") || task.needsPerson)) throw new PreEffectToolRefusal("a person-run card is the person's to mark and plan: ask the person (ahub backlog plan <id> <revision> '{\"needsPerson\":true|false}' <reason>)");
|
|
2900
3242
|
if (by !== USER && patch.rank !== undefined && task.history.some(entry => entry.by === USER && entry.planning?.fields.includes("rank"))) throw new PreEffectToolRefusal("a person's backlog order is retained");
|
|
2901
3243
|
if (by !== USER && Object.hasOwn(patch, "reserved") && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained");
|
|
2902
3244
|
if (Object.hasOwn(patch, "deps") || Object.hasOwn(patch, "reserved")) {
|
|
@@ -3020,6 +3362,8 @@ export class Tasks {
|
|
|
3020
3362
|
const validate = () => {
|
|
3021
3363
|
const parent = this.d.board.get(id);
|
|
3022
3364
|
if (!parent || parent.state !== "backlog" || parent.owner || taskHasStarted(parent) || (parent.planningRevision ?? 0) !== revision || by !== USER && parent.pinned) throw new PreEffectToolRefusal("split changed, pinned or already started");
|
|
3365
|
+
// #1176: children are unmarked cards an agent could take, so an agent's split would turn person-only work into agent work. Checked here, which runs before the screen and again after it, and in the board's transaction.
|
|
3366
|
+
if (by !== USER && parent.needsPerson) throw new PreEffectToolRefusal(PERSON_RUN_SPLIT);
|
|
3023
3367
|
const release = parent.release == null ? undefined : this.d.board.release(parent.release);
|
|
3024
3368
|
if (parent.release != null && (!release || ["shipped", "dropped"].includes(release.state) || by !== USER && release.state === "frozen")) throw new PreEffectToolRefusal("split release scope is frozen or final");
|
|
3025
3369
|
authority(parent);
|
|
@@ -3039,9 +3383,12 @@ export class Tasks {
|
|
|
3039
3383
|
if (direct.length + afterParts.length > 64) throw new PreEffectToolRefusal("split child dependencies exceed the 64-edge cap");
|
|
3040
3384
|
while (pending.length) { const dependency = pending.pop()!; if (dependency === id) throw new PreEffectToolRefusal("split dependency reaches its parent"); if (seen.has(dependency)) continue; seen.add(dependency); const task = this.d.board.get(dependency); if (!task) throw new PreEffectToolRefusal("split dependency is missing"); pending.push(...(task.deps ?? [])); }
|
|
3041
3385
|
const refs = part.refs === undefined ? parent.refs : cleanRefs(part.refs), plan = cleanPlan(part.plan, storedProfile);
|
|
3386
|
+
// #1161: a part's plan sources are registered ids, as at propose and accept (#1144); refused before any screen or board write.
|
|
3387
|
+
this.validatePlanSources(plan);
|
|
3042
3388
|
const draft = { title: part.title.replace(/\s+/g, " ").trim(), detail: part.detail as string | undefined ?? "", class: cls, refs, plan, deps, afterParts: afterParts as number[], ...(parent.role && cls === parent.class ? { role: parent.role } : {}), ...(parent.profile ? { profile: parent.profile, profileClass: given as string, ...(parent.profile === "dev" ? { customProfile: true as const } : {}) } : {}) };
|
|
3043
3389
|
const fields: PlanningFields = {};
|
|
3044
3390
|
for (const key of ["priority", "release", "plannedStart", "due"] as const) if (parent[key] !== undefined) fields[key] = parent[key];
|
|
3391
|
+
if (parent.needsPerson) fields.needsPerson = true; // #1176: only the person gets here (above), and each child of a person-run leg is a person-run leg until the person says otherwise
|
|
3045
3392
|
return { ...draft, fields };
|
|
3046
3393
|
});
|
|
3047
3394
|
return { parent, drafts };
|
|
@@ -3715,6 +4062,8 @@ export class Tasks {
|
|
|
3715
4062
|
catch (e) { this.releaseNotice(`task ${this.publicTitle(task)}: could not record the dropped study offer: ${e instanceof Error ? e.message : String(e)}`); }
|
|
3716
4063
|
return false;
|
|
3717
4064
|
}
|
|
4065
|
+
// #1176: an offer queued before the card was marked is dropped at delivery, not handed to the agent.
|
|
4066
|
+
if (env.kind === "task" && env.refs.taskOffer !== undefined && task?.needsPerson && task.owner === peer) return false;
|
|
3718
4067
|
if (task?.state === "cancelled") return env.kind === "task" && typeof env.refs.taskCancelled === "number" && env.refs.taskCancelled === task.cancelled?.flowRevision;
|
|
3719
4068
|
if (env.refs.taskRecipient === "planner") return !!task && this.taskPlanner(task).includes(peer);
|
|
3720
4069
|
if (env.refs.planReview !== undefined) return !!task && !isClosedTaskState(task.state) && (task.planningRevision ?? 0) === env.refs.planReview
|
|
@@ -4513,6 +4862,65 @@ export class Tasks {
|
|
|
4513
4862
|
// Advice joins the planner's next delivery; it must not manufacture a turn beside a withheld backlog batch.
|
|
4514
4863
|
for (const peer of this.taskPlanner(task).filter(peer => this.d.bus.isRostered(peer))) this.releaseNotice(`task #${task.id}: stall notice unavailable`, () => this.d.tell?.(peer, noteLine(HUB, "finding", text)));
|
|
4515
4864
|
}
|
|
4865
|
+
/** T1 records declared provenance only; no locator resolution or #901 judgment is manufactured. */
|
|
4866
|
+
async evidenceRecord(by: PeerId, input: unknown): Promise<EvidenceReceipt> {
|
|
4867
|
+
try { requireLocalTaskPeer(by); }
|
|
4868
|
+
catch { throw new PreEffectToolRefusal("remote peers cannot record local evidence; ask the current local research task owner to record it"); }
|
|
4869
|
+
const requestCheck = this.requestCheck(by, () => this.studyHold());
|
|
4870
|
+
let proposed: EvidenceRecordInput;
|
|
4871
|
+
try { proposed = parseEvidenceRecord(input); }
|
|
4872
|
+
catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid evidence record; check the bounded evidence fields before retrying"); }
|
|
4873
|
+
return this.studyWrite(async () => {
|
|
4874
|
+
const context = () => {
|
|
4875
|
+
requestCheck();
|
|
4876
|
+
let task: Task;
|
|
4877
|
+
try { task = this.studyParent(proposed.taskId); }
|
|
4878
|
+
catch { throw new PreEffectToolRefusal("evidence task is unavailable or outside this project; refresh ahub task show and select an active research task in this project"); }
|
|
4879
|
+
if (task.profile !== "research" || task.state !== "in_progress" || task.owner !== by) throw new PreEffectToolRefusal("evidence recording requires the current in-progress research task owner; accept an assigned research task before recording evidence");
|
|
4880
|
+
if (this.d.privateTurn?.(by) || this.isPii(task) || task.signals.includes("pii") || task.signals.includes("pii_path")) throw new PreEffectToolRefusal("private evidence recording is unavailable in T1; use a public research task outside a private turn");
|
|
4881
|
+
let identity;
|
|
4882
|
+
try { identity = taskClaimIdentity(task, Number(proposed.claimId.split(":").at(-1))); }
|
|
4883
|
+
catch { throw new PreEffectToolRefusal("evidence claim is unavailable; refresh hub_task_show or ahub task show --json and use a current claim binding"); }
|
|
4884
|
+
if (identity.claimId !== proposed.claimId || identity.claimRevision !== proposed.claimRevision) throw new PreEffectToolRefusal("evidence claim revision changed; refresh hub_task_show or ahub task show --json and record the current claim binding");
|
|
4885
|
+
const source = this.studySource(proposed.sourceId);
|
|
4886
|
+
if (!source || source.sha256 !== proposed.rawSha256) throw new PreEffectToolRefusal("evidence source is unknown or its raw hash changed; use ahub source list and read the registered snapshot before retrying");
|
|
4887
|
+
if (source.private) throw new PreEffectToolRefusal("private evidence sources are unavailable in T1; select a registered public source");
|
|
4888
|
+
return { task, key: JSON.stringify([task.created, task.history.length, task.owner, identity, source]) };
|
|
4889
|
+
};
|
|
4890
|
+
const before = context();
|
|
4891
|
+
const link = parseEvidenceLink({ ...proposed, id: proposed.id ?? evidenceLinkId(proposed, by), recordedBy: by });
|
|
4892
|
+
const canonical = evidenceLinkCanonical(link);
|
|
4893
|
+
const requirePublicMetadata = () => {
|
|
4894
|
+
let nameable = false;
|
|
4895
|
+
try { nameable = this.publicMetadataText(canonical) === true; } catch { /* unknown privacy is refused */ }
|
|
4896
|
+
if (!nameable) throw new PreEffectToolRefusal("evidence metadata is private or sensitive; remove sensitive metadata before recording a public link");
|
|
4897
|
+
};
|
|
4898
|
+
requirePublicMetadata();
|
|
4899
|
+
const requireRevision = () => {
|
|
4900
|
+
const latest = this.d.board.evidenceLink(this.d.project, link.id);
|
|
4901
|
+
const next = (latest?.revision ?? 0) + 1;
|
|
4902
|
+
if (latest && (latest.taskId !== link.taskId || latest.claimId !== link.claimId)) throw new PreEffectToolRefusal("evidence id belongs to another claim; use a new evidence id");
|
|
4903
|
+
const known = this.d.board.evidenceLink(this.d.project, link.id, link.revision);
|
|
4904
|
+
if (known && evidenceLinkCanonical(known) !== canonical) throw new PreEffectToolRefusal(`evidence revision already records different content; use next revision ${next} or a new evidence id`);
|
|
4905
|
+
if (!known && link.revision !== next) throw new PreEffectToolRefusal(`evidence revision is out of sequence; expected next revision ${next}; use that revision or replay an unchanged recorded revision`);
|
|
4906
|
+
return !!known;
|
|
4907
|
+
};
|
|
4908
|
+
const replay = requireRevision();
|
|
4909
|
+
if (!replay && this.screenOn()) {
|
|
4910
|
+
const screen = await this.screenCall(canonical);
|
|
4911
|
+
requestCheck();
|
|
4912
|
+
if (screen.label !== "clear") throw new PreEffectToolRefusal("evidence privacy screening did not clear the metadata; use public evidence metadata and retry when the screen is available");
|
|
4913
|
+
}
|
|
4914
|
+
requestCheck();
|
|
4915
|
+
const current = context();
|
|
4916
|
+
if (current.key !== before.key) throw new PreEffectToolRefusal("evidence task, claim or source changed during admission; refresh hub_task_show or ahub task show --json and registered source metadata before retrying");
|
|
4917
|
+
requirePublicMetadata();
|
|
4918
|
+
requireRevision();
|
|
4919
|
+
const saved = this.d.board.recordEvidenceLink(this.d.project, link, { taskFlowRevision: current.task.history.length, taskCreated: current.task.created, owner: by });
|
|
4920
|
+
return { id: saved.id, revision: saved.revision, taskId: saved.taskId };
|
|
4921
|
+
});
|
|
4922
|
+
}
|
|
4923
|
+
|
|
4516
4924
|
/**
|
|
4517
4925
|
* #901: the person's verdict on one declared claim of a document plan, with the model label they say produced it. Person only (an agent's word on its own claim is no verdict, and task
|
|
4518
4926
|
* approval never supplies one). The claim is named by its 1-based slot in the task's current plan; its text is not stored, sent or echoed, so a PII task records the same counters and
|
|
@@ -4526,7 +4934,8 @@ export class Tasks {
|
|
|
4526
4934
|
if (!claims) throw new Error(`task #${task.id} declares no plan claims: add them to its plan first`);
|
|
4527
4935
|
const claim = parseClaimVerdict(input, claims);
|
|
4528
4936
|
// Bound to the claim list it evaluated: a hash of the plan's claims (never their text), so a later replacement or reorder of the list leaves this verdict naming no claim of the new list.
|
|
4529
|
-
|
|
4937
|
+
const identity = taskClaimIdentity(task, claim.slot);
|
|
4938
|
+
return this.d.board.update(task.id, USER, "claim verdict", {}, undefined, { claim: { ...claim, list: identity.claimRevision } }); // no note: nothing a person typed beyond the closed fields is kept
|
|
4530
4939
|
}
|
|
4531
4940
|
|
|
4532
4941
|
async cancel(id: unknown, by: PeerId, input: { reason: string; supersededBy?: string; expectedFlowRevision?: number }): Promise<Task> {
|
|
@@ -4538,7 +4947,10 @@ export class Tasks {
|
|
|
4538
4947
|
this.checkFlowRevision(task, input.expectedFlowRevision, true);
|
|
4539
4948
|
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
|
|
4540
4949
|
if (by !== USER && this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("cancellation on ordinary work is unavailable during a private turn");
|
|
4541
|
-
if (by !== USER) {
|
|
4950
|
+
if (by !== USER && this.conductorHandoffCancel(by, task)) { /* #1175: the receiving conductor's own authority over a handed-off task nobody accepted; the board write below records it as the actor */ }
|
|
4951
|
+
else if (by !== USER) {
|
|
4952
|
+
// The conductor without the planner role is refused with its own rule (#1175): the planner sentence below would point it at a role it does not hold.
|
|
4953
|
+
if (this.configuredManualConductor(by) && !this.d.roles?.[by]?.includes("planner")) throw new PreEffectToolRefusal("the conductor may cancel only a task another project handed off that nobody has accepted (no owner, branch, commit, PR, check, done or verdict) while the conductor is on the roster; for any other task the person runs `ahub task cancel <id> --reason <text>`");
|
|
4542
4954
|
this.planningAuthority(by, task);
|
|
4543
4955
|
const proposer = task.history.find(h => h.event === "proposed" || h.event === "backlogged")?.by;
|
|
4544
4956
|
// #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.
|
|
@@ -4568,6 +4980,16 @@ export class Tasks {
|
|
|
4568
4980
|
return next;
|
|
4569
4981
|
}
|
|
4570
4982
|
|
|
4983
|
+
/**
|
|
4984
|
+
* #1175: the receiving project's configured conductor may cancel a task another project handed it, while nobody has accepted it and it has no execution evidence (the same bar as the
|
|
4985
|
+
* planner's own-work rule, `taskHasStarted` and `taskHasWorkEvidence`). It is re-asked inside both validations of `cancel`, so authority that ended during the privacy screen writes
|
|
4986
|
+
* nothing. The origin hears the cancel through the existing follow-back, which reads the history entry and carries no text.
|
|
4987
|
+
*/
|
|
4988
|
+
private conductorHandoffCancel(by: PeerId, task: Task): boolean {
|
|
4989
|
+
return this.configuredManualConductor(by) && this.d.bus.isRostered(by) && this.d.board.handoffOrigin(task.id) !== undefined && ["proposed", "backlog"].includes(task.state)
|
|
4990
|
+
&& !taskHasStarted(task) && !taskHasWorkEvidence(task) && !this.isPii(task);
|
|
4991
|
+
}
|
|
4992
|
+
|
|
4571
4993
|
/** A slot freed for `peer` (cancel, untouched return): its held ready tasks are offered in priority order, and the cap stops the offers (#655). */
|
|
4572
4994
|
private async releaseHeld(peer: PeerId | null | undefined): Promise<void> {
|
|
4573
4995
|
if (!peer) return;
|
|
@@ -4583,6 +5005,7 @@ export class Tasks {
|
|
|
4583
5005
|
const haveQueueOrigins = this.queuePressureKnown !== false && this.d.board.queuePressureStallPage(0, 1).afterTask > 0;
|
|
4584
5006
|
for (const t of [...this.d.board.list("proposed")].sort(this.readyOrder)) {
|
|
4585
5007
|
if (t.owner) { this.d.board.clearStall(t.id); continue; }
|
|
5008
|
+
if (t.needsPerson) { this.d.board.clearStall(t.id); continue; } // #1176: waiting on the person is the intent, not a stall: no owner is wanted, so none is reported missing
|
|
4586
5009
|
const cancelledDependency = t.deps?.find(id => this.d.board.get(id)?.state === "cancelled");
|
|
4587
5010
|
if (cancelledDependency) { this.reportStall(t, { kind: "cancelled-dependency", dependency: cancelledDependency, code: "cancelled" }); continue; }
|
|
4588
5011
|
const last = t.history.findLast(entry => !QUIET_EVENTS.has(entry.event))?.event, planned = this.plannerPullRecord(t)?.pull === true;
|
|
@@ -4613,6 +5036,7 @@ export class Tasks {
|
|
|
4613
5036
|
try {
|
|
4614
5037
|
const t = this.d.board.get(stale.id);
|
|
4615
5038
|
if (!t || t.state !== "proposed" || t.owner || this.personUnassigned(t) || this.offered.has(t.id)) return;
|
|
5039
|
+
if (t.needsPerson) return; // #1176: a person-run leg is never offered to an agent, whatever made it ready
|
|
4616
5040
|
if (plannedStartPending(t, this.d.now?.() ?? Date.now())) return;
|
|
4617
5041
|
const planned = this.plannerPullRecord(t)?.pull === true;
|
|
4618
5042
|
const selected = planned ? this.plannerAssignment(t, true).owner : undefined;
|
|
@@ -5789,6 +6213,7 @@ export class Tasks {
|
|
|
5789
6213
|
const metadata = acceptRefs(refs);
|
|
5790
6214
|
const task = this.need(id, false, true);
|
|
5791
6215
|
this.mine(task, by, "owner", true);
|
|
6216
|
+
if (by !== USER && task.needsPerson) throw new PreEffectToolRefusal(PERSON_RUN_ACCEPT(task.id)); // #1176: before any spec or plan-review work is spent on it
|
|
5792
6217
|
const proposedPlan = plan == null ? undefined : cleanPlan(plan, (task.profile ?? "dev") === (this.d.profile ?? "dev") ? this.d.profileData ?? task.profile ?? "dev" : task.profile ?? "dev");
|
|
5793
6218
|
this.validatePlanSources(proposedPlan && Object.keys(proposedPlan).length ? proposedPlan : task.plan);
|
|
5794
6219
|
this.acceptanceCapacity(task, 0, "check"); // #1080: an urgent task may still take unaccepted offers; `accept` does that, after its gates
|
|
@@ -5921,6 +6346,8 @@ export class Tasks {
|
|
|
5921
6346
|
const task = this.need(id, false, true);
|
|
5922
6347
|
this.mine(task, by, "owner", true);
|
|
5923
6348
|
this.ready(task, true);
|
|
6349
|
+
// #1176: a person-run leg is never an agent's to accept, whatever offer or owner it carries from before it was marked.
|
|
6350
|
+
if (by !== USER && task.needsPerson) throw new PreEffectToolRefusal(PERSON_RUN_ACCEPT(task.id));
|
|
5924
6351
|
if (task.state !== "proposed" && task.state !== "changes_requested" && task.state !== "in_progress") throw new PreEffectToolRefusal(`task #${task.id} is ${task.state}: cannot move to in_progress`);
|
|
5925
6352
|
// Models send null or {} for an optional field they leave empty: neither replaces a plan.
|
|
5926
6353
|
const given = plan == null ? undefined : cleanPlan(plan, (task.profile ?? "dev") === (this.d.profile ?? "dev") ? this.d.profileData ?? task.profile ?? "dev" : task.profile ?? "dev");
|
|
@@ -6201,6 +6628,9 @@ export class Tasks {
|
|
|
6201
6628
|
/** Checks queued or running, until their result is on the board: a recovery commit waits for them. */
|
|
6202
6629
|
checksPending = () => this.pendingChecks;
|
|
6203
6630
|
|
|
6631
|
+
/** #1054: completion checks queued or running for work this peer owns, so a run is not settled while its own check is still going. */
|
|
6632
|
+
checksOwnedBy = (peer: PeerId): number => { let n = 0; for (const owner of this.checking.values()) if (owner === peer) n++; return n; };
|
|
6633
|
+
|
|
6204
6634
|
/** Whether a completion check is still running for this task. */
|
|
6205
6635
|
isChecking = (id: number) => this.checking.has(id);
|
|
6206
6636
|
|
|
@@ -6256,10 +6686,12 @@ export class Tasks {
|
|
|
6256
6686
|
if (state !== "OPEN" && state !== "CLOSED") throw new PreEffectToolRefusal(`task #${task.id}: PR merge state unavailable; cannot reopen approval`);
|
|
6257
6687
|
}
|
|
6258
6688
|
|
|
6259
|
-
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs, deliverables?: unknown, specAck?: unknown): Promise<Task> {
|
|
6689
|
+
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs, deliverables?: unknown, specAck?: unknown, resolved?: unknown): Promise<Task> {
|
|
6260
6690
|
const requestCheck = this.requestCheck(by);
|
|
6261
6691
|
const initial = this.need(id, false, true);
|
|
6262
6692
|
this.mine(initial, by, "owner", true);
|
|
6693
|
+
const reviewObserverTurn = this.reviewObserverTurn(by, initial.id);
|
|
6694
|
+
const resolvedReviewComments = this.reviewCommentResolve(by, initial, resolved);
|
|
6263
6695
|
let refresh: CompletionRefresh | undefined;
|
|
6264
6696
|
if (initial.state === "in_review" || initial.state === "approved") {
|
|
6265
6697
|
const commit = cleanRefs(refs).commit;
|
|
@@ -6278,7 +6710,7 @@ export class Tasks {
|
|
|
6278
6710
|
const refileHead: RefileHeadGuard | undefined = !submittedCommit && initial.sdlc?.repo && initial.sdlc.pr && this.hasReviewHistory(initial) && !this.isPii(initial)
|
|
6279
6711
|
? { recorded: this.ownerReviewHead(initial) } : undefined;
|
|
6280
6712
|
const draft = refresh ?? (submittedCommit && /^[a-f0-9]{40}$/.test(submittedCommit) ? { commit: submittedCommit } : undefined);
|
|
6281
|
-
const valid = () => { requestCheck();
|
|
6713
|
+
const valid = () => { requestCheck(); const current = this.completionDraft(this.completionCurrent(id, by, true, refresh), draft); if (resolvedReviewComments !== undefined) this.reviewCommentResolve(by, current, resolvedReviewComments); return current; };
|
|
6282
6714
|
if (initial.state === "approved") { await this.unmergedForReopen(initial, valid); valid(); }
|
|
6283
6715
|
// #509a: a bad claim (a claimed hash, an outside or denied path, a missing file) is refused before any effect, with nothing recorded.
|
|
6284
6716
|
const claims = deliverables === undefined ? undefined : this.deliverableClaims(deliverables, valid());
|
|
@@ -6343,7 +6775,7 @@ export class Tasks {
|
|
|
6343
6775
|
}
|
|
6344
6776
|
if (task.state === "proposed" || task.state === "changes_requested") task = this.withLink(this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE), link, refs); // done without a separate accept
|
|
6345
6777
|
const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
|
|
6346
|
-
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link, records, requestCheck, refresh, specInspection);
|
|
6778
|
+
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link, records, requestCheck, refresh, specInspection, false, resolvedReviewComments, reviewObserverTurn);
|
|
6347
6779
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
6348
6780
|
if (refresh?.state === "approved") { await this.unmergedForReopen(task, valid); task = current(); }
|
|
6349
6781
|
this.checking.set(task.id, task.owner);
|
|
@@ -6359,7 +6791,7 @@ export class Tasks {
|
|
|
6359
6791
|
const seen = { events: pending.history.length, owner: pending.owner };
|
|
6360
6792
|
this.pendingChecks++;
|
|
6361
6793
|
this.checkQueue = this.checkQueue
|
|
6362
|
-
.then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld, specInspection))
|
|
6794
|
+
.then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld, specInspection, resolvedReviewComments, reviewObserverTurn))
|
|
6363
6795
|
.catch((e: Error) => {
|
|
6364
6796
|
this.releaseNotice(`task #${pending.id}: its check result could not be recorded: ${e.message}`);
|
|
6365
6797
|
this.releaseNotice(`task #${pending.id}: its owner retry notice could not be published`, () => {
|
|
@@ -6462,9 +6894,11 @@ export class Tasks {
|
|
|
6462
6894
|
return { ...(key ? { checkKey: key } : {}), ...(match ? { missingKeys: Number(match[2]) } : {}) };
|
|
6463
6895
|
}
|
|
6464
6896
|
|
|
6465
|
-
private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }, withheld: boolean, specInspection: PreparedSpecInspection): Promise<void> {
|
|
6897
|
+
private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }, withheld: boolean, specInspection: PreparedSpecInspection, resolvedReviewComments?: string[], reviewObserverExcluded = false): Promise<void> {
|
|
6466
6898
|
const result = await this.d.runCheck!(command).catch((e: Error) => ({ code: null, timedOut: false, interrupted: false, tail: e.message }));
|
|
6899
|
+
const checkOwner = this.checking.get(id);
|
|
6467
6900
|
this.checking.delete(id);
|
|
6901
|
+
if (checkOwner) { try { queueMicrotask(() => { try { this.d.checkEnded?.(checkOwner); } catch { /* advisory */ } }); } catch { /* advisory */ } }
|
|
6468
6902
|
const task = this.d.board.get(id);
|
|
6469
6903
|
if (!task) return;
|
|
6470
6904
|
const outcome = `${command} -> ${result.interrupted ? "interrupted by a hub stop" : result.timedOut ? "timed out" : `exit ${result.code ?? "?"}`}`;
|
|
@@ -6486,22 +6920,22 @@ export class Tasks {
|
|
|
6486
6920
|
this.tell(task, `Task #${id}: its check passed, but the overlapping work or the files changed while it ran (turn-free integration). Call hub_task_done again.`, pii);
|
|
6487
6921
|
return;
|
|
6488
6922
|
}
|
|
6489
|
-
this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim(), this.checkProvenance(task, result.tail));
|
|
6923
|
+
this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim(), { ...this.checkProvenance(task, result.tail), ...(withheld ? { withheld: true } : {}) });
|
|
6490
6924
|
if (refileForSpecs) { this.requestSpecRefile(task); return; }
|
|
6491
6925
|
// The output goes to the reviewer with the done note, not into shared memory: nobody screened it.
|
|
6492
|
-
await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail, withheld, undefined, undefined, undefined, undefined, undefined, undefined, specInspection, true);
|
|
6926
|
+
await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail, withheld, undefined, undefined, undefined, undefined, undefined, undefined, specInspection, true, resolvedReviewComments, reviewObserverExcluded);
|
|
6493
6927
|
return;
|
|
6494
6928
|
}
|
|
6495
|
-
this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim(), this.checkProvenance(task, result.tail));
|
|
6929
|
+
this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim(), { ...this.checkProvenance(task, result.tail), ...(withheld ? { withheld: true } : {}) });
|
|
6496
6930
|
this.cohorts.withdraw(id); // open again: its done intent no longer counts (issue #107)
|
|
6497
6931
|
if (task.owner) this.d.board.recordOutcome(task.owner, task.class, false);
|
|
6498
6932
|
this.contradict(task);
|
|
6499
6933
|
this.d.notify(`task ${this.publicTitle(task)}: its check failed (${outcome}); it stays with ${task.owner ?? by}`);
|
|
6500
|
-
this.tell(task, `Task #${id}: its check failed.\n$ ${outcome}${result.tail ? `\n${result.tail}` : ""}\nFix it and call hub_task_done again.`, pii);
|
|
6934
|
+
this.tell(task, `Task #${id}: its check failed.\n$ ${outcome}${result.tail ? `\n${result.tail}` : ""}\nFix it and call hub_task_done again.`, pii, "owner", this.reviewObserverTurn(by, id));
|
|
6501
6935
|
}
|
|
6502
6936
|
|
|
6503
6937
|
/** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
|
|
6504
|
-
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink, records?: DeliverableRecord[], requestCheck: () => void = () => {}, refresh?: CompletionRefresh, specInspection?: PreparedSpecInspection, asyncCheckCompletion = false): Promise<Task> {
|
|
6938
|
+
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink, records?: DeliverableRecord[], requestCheck: () => void = () => {}, refresh?: CompletionRefresh, specInspection?: PreparedSpecInspection, asyncCheckCompletion = false, resolvedReviewComments?: string[], reviewObserverExcluded = false): Promise<Task> {
|
|
6505
6939
|
requestCheck();
|
|
6506
6940
|
if (specInspection && asyncCheckCompletion && this.refreshAsyncSpecInspection(specInspection, task, by)) { this.requestSpecRefile(task); return task; }
|
|
6507
6941
|
// A check's completion carries the records its `done (checking)` bound: the same bytes the owner delivered, not a later read.
|
|
@@ -6548,14 +6982,15 @@ export class Tasks {
|
|
|
6548
6982
|
this.warmRepository({ ...task, refs: storedRefs });
|
|
6549
6983
|
const noWrites = this.noWritesRecorded({ ...task, refs: storedRefs });
|
|
6550
6984
|
const capacity = this.capacityBefore(task);
|
|
6551
|
-
|
|
6985
|
+
reviewObserverExcluded ||= this.reviewObserverTurn(by, task.id);
|
|
6986
|
+
const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: storedRefs }), state: reviewer ? "in_review" : "approved", ...(selection || refresh?.state === "approved" ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...(resolvedReviewComments?.length ? { resolvedReviewComments: this.reviewCommentResolve(by, task, resolvedReviewComments) } : {}), ...this.planObservation(task), withheld: withheld || undefined, ...(specInspection?.report ? { specReport: specInspection.report, specRefBinding: this.specRefBinding({ refs: storedRefs }) } : {}), ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection, { ...task, refs: storedRefs }), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
6552
6987
|
this.queueReviewWritten(next);
|
|
6553
6988
|
this.writePlannerNotices(next, capacity, next.state === "approved");
|
|
6554
6989
|
this.ciWatchers.cancel(next.id); this.heldReviews.delete(next.id); // a newer head or a move ends the old head's watcher (#785 P1b)
|
|
6555
6990
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
6556
6991
|
const memorySymbols = this.completionMemorySymbols(next);
|
|
6557
|
-
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\nChanged-symbol memory anchors: ${memorySymbols.state === "known" ? memorySymbols.nodes.length : "unknown (no complete code facts)"}\n${summary ?? ""}`, withheld, memorySymbols);
|
|
6558
|
-
this.tellCompleted(next, summary, withheld);
|
|
6992
|
+
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\nChanged-symbol memory anchors: ${memorySymbols.state === "known" ? memorySymbols.nodes.length : "unknown (no complete code facts)"}\n${summary ?? ""}`, withheld || reviewObserverExcluded, memorySymbols);
|
|
6993
|
+
this.tellCompleted(next, summary, withheld || reviewObserverExcluded);
|
|
6559
6994
|
if (!reviewer) {
|
|
6560
6995
|
this.settleMemory(next);
|
|
6561
6996
|
if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true); // approved without a review is a success too
|
|
@@ -6665,7 +7100,8 @@ export class Tasks {
|
|
|
6665
7100
|
/** Review admission is separate from brief construction (#228). All refusals precede the requesting done's writes. */
|
|
6666
7101
|
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> {
|
|
6667
7102
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
6668
|
-
|
|
7103
|
+
const anchored = this.reviewFindingSets(task).some(set => set.items.some(item => item.anchor));
|
|
7104
|
+
if (mode === "off" && !this.d.prepareReviewBrief && !anchored) return undefined;
|
|
6669
7105
|
let quietDrift = false;
|
|
6670
7106
|
const guard = () => {
|
|
6671
7107
|
if (actor !== USER && actor !== task.owner) {
|
|
@@ -6690,7 +7126,14 @@ export class Tasks {
|
|
|
6690
7126
|
catch { /* Optional code facts must not refuse a review. */ }
|
|
6691
7127
|
guard();
|
|
6692
7128
|
}
|
|
6693
|
-
if (
|
|
7129
|
+
if (prepareOnly) return undefined;
|
|
7130
|
+
if (mode === "off") {
|
|
7131
|
+
if (!anchored) return undefined;
|
|
7132
|
+
const placements = await this.prepareFindingPlacements(task, refs, guard);
|
|
7133
|
+
const admission: ReviewReadiness = { ready: false, blockers: [] };
|
|
7134
|
+
this.findingPlacements.set(admission, { binding: this.findingPlacementBinding({ ...task, refs: { ...task.refs, ...cleanRefs(refs) } }), placements, placementOnly: true });
|
|
7135
|
+
return admission;
|
|
7136
|
+
}
|
|
6694
7137
|
const expected = cleanRefs(refs).commit ?? task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead;
|
|
6695
7138
|
const read: SdlcGh = (args, timeout, cwd, cap) => {
|
|
6696
7139
|
guard();
|
|
@@ -6712,6 +7155,10 @@ export class Tasks {
|
|
|
6712
7155
|
if (mode === "enforce") throw new (preEffect ? PreEffectToolRefusal : Error)(message);
|
|
6713
7156
|
this.releaseNotice(message);
|
|
6714
7157
|
}
|
|
7158
|
+
if (anchored) {
|
|
7159
|
+
const placements = await this.prepareFindingPlacements(task, refs, guard);
|
|
7160
|
+
this.findingPlacements.set(admitted, { binding: this.findingPlacementBinding({ ...task, refs: { ...task.refs, ...cleanRefs(refs) } }), placements, placementOnly: false });
|
|
7161
|
+
}
|
|
6715
7162
|
return admitted;
|
|
6716
7163
|
}
|
|
6717
7164
|
|
|
@@ -6721,7 +7168,7 @@ export class Tasks {
|
|
|
6721
7168
|
* 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.
|
|
6722
7169
|
*/
|
|
6723
7170
|
private hubVerifiedBlock(task: Task, admission?: ReviewReadiness): string {
|
|
6724
|
-
if (!admission || this.isPii(task)) return "";
|
|
7171
|
+
if (!admission || this.isPii(task) || this.findingPlacements.get(admission)?.placementOnly) return "";
|
|
6725
7172
|
const head = admission.head ?? this.reviewHead(task) ?? "unknown head";
|
|
6726
7173
|
if (admission.ready) {
|
|
6727
7174
|
const names = admission.checks?.length ? admission.checks.join(", ") : "the required checks";
|
|
@@ -6803,7 +7250,13 @@ export class Tasks {
|
|
|
6803
7250
|
const findings = reviewOnly ? task.history.filter(h => h.event === "changes_requested" && h.note).map(h => `- ${h.by}: ${this.screen(task, h.note!, "review note", reviewer, h.withheld)}`) : [];
|
|
6804
7251
|
const carried = findingsForHead(this.reviewFindingSets(task), this.findingHead(task)).filter(item => item.severity === "important" && item.disposition === "open");
|
|
6805
7252
|
const visible = this.isPii(task) ? [] : carried.filter(item => this.findingTextAllowed(task, [item]) && !task.history.some(entry => entry.withheld && entry.reviewFindings?.items.some(value => value.id === item.id)));
|
|
6806
|
-
const
|
|
7253
|
+
const prepared = admission && this.findingPlacements.get(admission);
|
|
7254
|
+
const placements = prepared?.binding === this.findingPlacementBinding(task) ? prepared.placements : undefined;
|
|
7255
|
+
// ponytail: rare synchronous role re-offers without an async admission show unavailable until refreshed; no old location is guessed.
|
|
7256
|
+
const carriedText = carried.length ? `Open Important findings carried until explicitly dispositioned: ${carried.length}\n${visible.map(item => {
|
|
7257
|
+
const { id, pass, severity, path } = item, legacy = JSON.stringify({ id, pass, severity, ...(path ? { path } : {}) });
|
|
7258
|
+
return item.anchor ? legacy + "\n" + renderFindingAnchor(item, this.reviewPathPrivate(item.anchor.path) ? { status: "unavailable" } : placements?.get(id) ?? { status: "unavailable" }) : legacy;
|
|
7259
|
+
}).join("\n")}${visible.length < carried.length ? "\nSome finding details withheld." : ""}\n` : "";
|
|
6807
7260
|
const guidance = this.roleGuidance(task, reviewer, true);
|
|
6808
7261
|
let projection = "";
|
|
6809
7262
|
try { const text = this.d.projectedReview?.(task); if (text) projection = this.screen(task, sanitize(text), "document projection", reviewer); }
|
|
@@ -6812,7 +7265,10 @@ export class Tasks {
|
|
|
6812
7265
|
// #572 AC3: advisory only, from git state never from task text, so it is safe for a PII task too.
|
|
6813
7266
|
const checkout = mainCheckoutWarning(this.d.cwd, this.publicMetadataText);
|
|
6814
7267
|
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.`;
|
|
6815
|
-
|
|
7268
|
+
const comments = this.d.board.reviewCommentCounts(task.id, task.created);
|
|
7269
|
+
const quoteMaterial = task.history.some(entry => entry.reviewFindings?.items.some(item => item.anchor !== undefined))
|
|
7270
|
+
|| comments.sent + comments.resolved + comments.dismissed > 0;
|
|
7271
|
+
return this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(quoteMaterial ? { reviewPackage: `reviewer-request-${crypto.randomUUID()}` } : {}), ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) })).includes(reviewer);
|
|
6816
7272
|
}
|
|
6817
7273
|
|
|
6818
7274
|
/** #785 P1b: remember a review the hub held as premature, so a green CI at this exact head can re-offer it once. Also watches that head. */
|
|
@@ -6995,6 +7451,10 @@ export class Tasks {
|
|
|
6995
7451
|
}
|
|
6996
7452
|
|
|
6997
7453
|
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown, findings?: unknown): Promise<Task> {
|
|
7454
|
+
if (by !== USER && hasReviewAnchorInput(findings)) {
|
|
7455
|
+
try { this.d.onReviewObserverInput?.(by); }
|
|
7456
|
+
catch { throw new PreEffectToolRefusal("the anchored review input could not be privately scoped; retry after ordinary new input"); }
|
|
7457
|
+
}
|
|
6998
7458
|
const requestCheck = this.requestCheck(by);
|
|
6999
7459
|
if (typeof id !== "number" && !(typeof id === "string" && /^\s*\d+\s*$/.test(id))) this.need(id, false, true);
|
|
7000
7460
|
const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
|
|
@@ -7005,7 +7465,9 @@ export class Tasks {
|
|
|
7005
7465
|
let conformanceIdentity: string | undefined;
|
|
7006
7466
|
let reviewRound: number | undefined;
|
|
7007
7467
|
let withdrawalRevision: number | undefined;
|
|
7008
|
-
const
|
|
7468
|
+
const before = this.need(id, false, true);
|
|
7469
|
+
const reviewObserverTurn = this.reviewObserverTurn(by, before.id);
|
|
7470
|
+
const withdrawing = before.state === "approved" && verdict === "changes_requested";
|
|
7009
7471
|
const valid = (): Task => {
|
|
7010
7472
|
requestCheck();
|
|
7011
7473
|
const refused = authority?.();
|
|
@@ -7035,6 +7497,10 @@ export class Tasks {
|
|
|
7035
7497
|
let parsed: ReviewFinding[];
|
|
7036
7498
|
try { parsed = parseReviewFindings(findings, { person: by === USER, maxNits: this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS }); }
|
|
7037
7499
|
catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid structured findings"); }
|
|
7500
|
+
const anchorPrivacy = new Map(parsed.flatMap(item => item.anchor ? [[item.anchor.path, this.reviewPathPrivate(item.anchor.path)] as const] : []));
|
|
7501
|
+
try { parsed = parsed.map(item => item.anchor && anchorPrivacy.get(item.anchor.path) ? { ...item, anchor: privateReviewAnchor(item.anchor) } : item); }
|
|
7502
|
+
catch { throw new PreEffectToolRefusal("private finding path needs an existing lineRange or structural selector; remove quoted selectors and retry"); }
|
|
7503
|
+
const observerExcluded = reviewObserverTurn || parsed.some(item => item.anchor !== undefined);
|
|
7038
7504
|
// #852 AC3 (amended): a code-scope reviewer's changes_requested is always recorded, never refused. Without an open structured finding it is
|
|
7039
7505
|
// evidence-only: it does not count toward rejections or escalation, the task returns to its owner as usual, and the conductor gets one ids-only
|
|
7040
7506
|
// notice. The rule is structural (a finding with disposition open), never a reading of the unmet text.
|
|
@@ -7052,6 +7518,7 @@ export class Tasks {
|
|
|
7052
7518
|
const identity = (task: Task) => JSON.stringify({ owner: task.owner, reviewer: task.reviewer, commit: task.refs.commit, receipt: task.sdlc, resolvedHead: this.findingHead(task), private: this.isPii(task), findings: this.reviewFindingSets(task) });
|
|
7053
7519
|
const original = identity(initial);
|
|
7054
7520
|
findingGuard = task => {
|
|
7521
|
+
if ([...anchorPrivacy].some(([path, privatePath]) => this.reviewPathPrivate(path) !== privatePath)) throw new PreEffectToolRefusal("review path privacy changed; refresh the task review and retry");
|
|
7055
7522
|
if (identity(task) !== original) throw new PreEffectToolRefusal("task review identity, privacy or findings changed during the verdict; review current work again");
|
|
7056
7523
|
if (by !== USER && suppliedFindings && this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("structured review of ordinary work is unavailable during a private turn");
|
|
7057
7524
|
if (parsed.filter(item => item.severity === "nit").length > (this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS)) throw new PreEffectToolRefusal("review nit cap exceeded");
|
|
@@ -7089,7 +7556,12 @@ export class Tasks {
|
|
|
7089
7556
|
}
|
|
7090
7557
|
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`);
|
|
7091
7558
|
// The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
|
|
7092
|
-
|
|
7559
|
+
// The configured PII screen sees reviewer prose and anchor metadata, but never immutable source selectors or projection content.
|
|
7560
|
+
const screenFindings = parsed.map(({ anchor, ...finding }) => ({ ...finding, ...(anchor ? { anchor: {
|
|
7561
|
+
path: anchor.path, ...(anchor.side ? { side: anchor.side } : {}), ...(anchor.contentHash ? { contentHash: anchor.contentHash } : {}),
|
|
7562
|
+
...(anchor.projection ? { projection: anchor.projection } : {}),
|
|
7563
|
+
} } : {}) }));
|
|
7564
|
+
const withheld = await this.withholds("review note", parsed.length ? JSON.stringify({ note, findings: screenFindings }) : note, valid())
|
|
7093
7565
|
|| parsed.length > 0 && !this.findingTextAllowed(initial, parsed);
|
|
7094
7566
|
let task = valid();
|
|
7095
7567
|
let reviewedHead: string | undefined;
|
|
@@ -7132,7 +7604,7 @@ export class Tasks {
|
|
|
7132
7604
|
if (expected && currentHead && currentHead !== expected) throw this.reviewHeadMoveRefusal(task.id, expected, currentHead);
|
|
7133
7605
|
}
|
|
7134
7606
|
let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
|
|
7135
|
-
if (verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|
|
7607
|
+
if (!observerExcluded && verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|
|
7136
7608
|
const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
|
|
7137
7609
|
cause: "unknown: review unmet items do not establish a cause", retryWhen: "unknown: a person must review the unmet items before accepting",
|
|
7138
7610
|
evidenceRefs: [`task:${task.id}`, `reviewed-head:${reviewedHead ?? task.refs.commit ?? "unknown"}`] };
|
|
@@ -7166,12 +7638,12 @@ export class Tasks {
|
|
|
7166
7638
|
this.d.board.recordReview({ implementer: next.owner, reviewer: r, class: next.class, kind: "caught", task: next.id });
|
|
7167
7639
|
}
|
|
7168
7640
|
}
|
|
7169
|
-
this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}
|
|
7641
|
+
this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}${observerExcluded ? "" : `\n${note ?? ""}`}`, withheld);
|
|
7170
7642
|
this.settleMemory(next);
|
|
7171
7643
|
this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
|
|
7172
|
-
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
|
|
7644
|
+
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii, "owner", observerExcluded));
|
|
7173
7645
|
await this.releaseDependents(next);
|
|
7174
|
-
this.autoMerge(next, this.mirrorVerdict(next, by, "approved", reviewedHead ?? next.sdlc?.reviewedHead, note, withheld));
|
|
7646
|
+
this.autoMerge(next, this.mirrorVerdict(next, by, "approved", reviewedHead ?? next.sdlc?.reviewedHead, note, withheld || observerExcluded));
|
|
7175
7647
|
// A proposal storage failure cannot roll back the saved verdict; another matching verdict retries it.
|
|
7176
7648
|
this.releaseNotice(`task #${next.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(next, by, parsed, withheld));
|
|
7177
7649
|
this.releaseNotice(`task #${next.id}: review saved but recurring-criterion proposal unavailable`, () => this.proposeCriterionCandidates(next, by, withheld));
|
|
@@ -7179,17 +7651,17 @@ export class Tasks {
|
|
|
7179
7651
|
}
|
|
7180
7652
|
this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
|
|
7181
7653
|
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: evidenceOnly ? task.rejections : task.rejections + 1 }, note, flag, task.history.length);
|
|
7182
|
-
void this.mirrorVerdict(rejected, by, "changes_requested", reviewedHead ?? rejected.sdlc?.reviewedHead, note, withheld);
|
|
7654
|
+
void this.mirrorVerdict(rejected, by, "changes_requested", reviewedHead ?? rejected.sdlc?.reviewedHead, note, withheld || observerExcluded);
|
|
7183
7655
|
if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
|
|
7184
7656
|
if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-criterion proposal unavailable`, () => this.proposeCriterionCandidates(rejected, by, withheld));
|
|
7185
|
-
if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
|
|
7657
|
+
if (!observerExcluded && candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
|
|
7186
7658
|
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 */ }
|
|
7187
7659
|
}
|
|
7188
7660
|
this.cohorts.withdraw(rejected.id); // open again: its done intent no longer counts (issue #107)
|
|
7189
7661
|
// Evidence-only is not a verdict on the owner's work: no failed outcome, no contradiction of an earlier approval, and no escalation count.
|
|
7190
7662
|
if (rejected.owner && !evidenceOnly) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
|
|
7191
7663
|
if (!evidenceOnly) this.contradict(rejected);
|
|
7192
|
-
this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}
|
|
7664
|
+
this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}${observerExcluded ? "" : `\n${note ?? ""}`}`, withheld);
|
|
7193
7665
|
if (evidenceOnly) this.tellConductorEvidenceOnly(rejected, by);
|
|
7194
7666
|
if (withdrawing) {
|
|
7195
7667
|
this.releaseNotice(`task ${this.publicTitle(rejected)}: could not tell its owner the approval was withdrawn`, () =>
|
|
@@ -7212,12 +7684,12 @@ export class Tasks {
|
|
|
7212
7684
|
if (moved.owner !== rejected.owner) return moved;
|
|
7213
7685
|
// Nobody to escalate to: the owner still has to hear the verdict and the note.
|
|
7214
7686
|
this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${moved.owner ?? "its owner"} changes were requested again`, () =>
|
|
7215
|
-
this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${this.screen(moved, note, "review note", moved.owner, withheld)}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii));
|
|
7687
|
+
this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${this.screen(moved, note, "review note", moved.owner, withheld)}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii, "owner", observerExcluded));
|
|
7216
7688
|
return moved;
|
|
7217
7689
|
}
|
|
7218
7690
|
const reopened = this.d.board.update(rejected.id, HUB, "reopened", { state: "in_progress" });
|
|
7219
7691
|
this.releaseNotice(`task ${this.publicTitle(reopened)}: could not tell ${reopened.owner ?? "its owner"} changes were requested`, () =>
|
|
7220
|
-
this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${this.screen(reopened, note, "review note", reopened.owner, withheld)}` : ""} Fix it and call hub_task_done again.`, pii));
|
|
7692
|
+
this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${this.screen(reopened, note, "review note", reopened.owner, withheld)}` : ""} Fix it and call hub_task_done again.`, pii, "owner", observerExcluded));
|
|
7221
7693
|
return reopened;
|
|
7222
7694
|
}
|
|
7223
7695
|
|
|
@@ -7345,6 +7817,8 @@ export class Tasks {
|
|
|
7345
7817
|
queueAssignment?.current();
|
|
7346
7818
|
const task = this.need(id, role === "owner", preEffect);
|
|
7347
7819
|
this.checkFlowRevision(task, expectedFlowRevision, preEffect);
|
|
7820
|
+
// #1176: routing hands a person-run leg to nobody, so naming an owner would only be a silent no-op plus a stall report. Refused for the conductor and planner (they are agents) and for the person alike: the person clears the mark first.
|
|
7821
|
+
if (role === "owner" && task.needsPerson) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is a person-run leg: no agent is named its owner. Clear the mark first (ahub backlog plan ${task.id} <revision> '{"needsPerson":false}' <reason>), then assign it`);
|
|
7348
7822
|
if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster; add it to the roster before assigning work`);
|
|
7349
7823
|
this.requireCapabilities(task, peer, role, preEffect);
|
|
7350
7824
|
if (role === "reviewer") {
|
|
@@ -7552,6 +8026,11 @@ export class Tasks {
|
|
|
7552
8026
|
if (task && this.isPii(task)) throw new Error("notes about a PII task are not saved: shared memory excludes private data");
|
|
7553
8027
|
// Shared with every other peer as well, so text that matches a PII pattern is refused like a note about a PII task.
|
|
7554
8028
|
const title = String(input.title ?? "").trim();
|
|
8029
|
+
if (by !== USER) {
|
|
8030
|
+
let scoped = false;
|
|
8031
|
+
try { scoped = this.reviewObserverTurn(by, task?.id); } catch { throw new PreEffectToolRefusal("shared memory is held because the open review scope (sent comments, anchored findings, unsettled delivery or current review input) could not be checked; inspect ahub task list after recovery"); }
|
|
8032
|
+
if (scoped) throw new PreEffectToolRefusal(`shared memory is held while an open review scope (sent comments, anchored findings, unsettled delivery or current review input) exists; complete or review the task, ask the person to dismiss comments if appropriate, inspect ahub queue list --peer ${by} and explicitly settle any delivery, then wait for ordinary new input after completion`);
|
|
8033
|
+
}
|
|
7555
8034
|
if (this.isPii({ signals: detectSignals({ title, detail: text, refs: {} }, this.d.routing(), this.d.cwd) })) throw new Error("this note matches a PII pattern and is not saved: shared memory excludes private data");
|
|
7556
8035
|
if (input.horizon !== undefined && input.horizon !== "short" && input.horizon !== "long") throw new Error('horizon is "short" or "long"');
|
|
7557
8036
|
if (input.scope !== undefined && input.scope !== "project" && input.scope !== "shared") throw new Error('scope is "project" or "shared"');
|
|
@@ -7563,6 +8042,7 @@ export class Tasks {
|
|
|
7563
8042
|
requestCheck();
|
|
7564
8043
|
const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) as Kind : "finding";
|
|
7565
8044
|
const native = this.saveNative(by, kind, text, title, task, input.horizon === "long" || input.horizon === "short" ? input.horizon : undefined, input.scope === "shared" ? "shared" : "project");
|
|
8045
|
+
if (native && "refused" in native) throw new PreEffectToolRefusal(`${native.refused}; save it with horizon "short" or correct the machine/repository privacy policy, then retry`);
|
|
7566
8046
|
if (task && this.d.memory) this.markMemoryReference(task, by);
|
|
7567
8047
|
const res = !native && this.d.memory ? await this.d.memory.save({ text, ...(title ? { title } : {}), project: this.d.project, metadata: { peer: by, kind, ...(task ? { task: task.id } : {}) } }) : undefined;
|
|
7568
8048
|
if (!res && !native) return "memory store unavailable; nothing saved";
|
|
@@ -7617,14 +8097,16 @@ export class Tasks {
|
|
|
7617
8097
|
* long, decisions, contracts and fails are long-term. Fail-open: a store that cannot be written costs one notice.
|
|
7618
8098
|
* The caller has screened the text for PII; never a PII task's.
|
|
7619
8099
|
*/
|
|
7620
|
-
private saveNative(by: PeerId, kind: Kind, text: string, title: string, task: Task | undefined, horizon?: Tier, scope: Scope = "project", symbols?: MemorySymbolNodes): { id: string; tier: Tier } | undefined {
|
|
8100
|
+
private saveNative(by: PeerId, kind: Kind, text: string, title: string, task: Task | undefined, horizon?: Tier, scope: Scope = "project", symbols?: MemorySymbolNodes): { id: string; tier: Tier } | { refused: string } | undefined {
|
|
7621
8101
|
const st = this.d.store;
|
|
7622
8102
|
if (!st || (task && this.isPii(task))) return undefined;
|
|
8103
|
+
let longTerm = horizon === "long" || (horizon !== "short" && !!task && task.state === "approved" && PROMOTED_KINDS.includes(kind));
|
|
7623
8104
|
try { // never throws: callers run after a board write
|
|
7624
8105
|
this.d.memoryCheck?.();
|
|
7625
8106
|
// Approved, or settled in the store already: the snapshot the caller holds can predate an approval made while it awaited.
|
|
7626
8107
|
const approved = !!task && (task.state === "approved" || st.store.settled(this.memoryKey(task)));
|
|
7627
8108
|
const tier: Tier = horizon ?? (approved && PROMOTED_KINDS.includes(kind) ? "long" : "short");
|
|
8109
|
+
longTerm ||= tier === "long";
|
|
7628
8110
|
const about = extractNodes(`${title}\n${text}`, { cwd: this.d.cwd, project: st.project, repo: st.repo, deny: st.deny, paths: [...(task?.refs.paths ?? []), ...(task?.plan?.paths ?? [])], symbols: symbols ? [] : task?.plan?.symbols?.map(normalizeMemorySymbolInput), ...(task ? { task: { id: task.id, created: task.created } } : {}) });
|
|
7629
8111
|
// Existing notes remain immutable; the completion note connects actual symbols through the shared task node.
|
|
7630
8112
|
if (symbols && task) {
|
|
@@ -7633,12 +8115,20 @@ export class Tasks {
|
|
|
7633
8115
|
}
|
|
7634
8116
|
// A short-term note on a task already approved (before hub memory existed, or late): the task is settled first, so
|
|
7635
8117
|
// the note gets its own deadline and is never taken for a source waiting for promotion.
|
|
7636
|
-
if (task && approved && tier === "short" && !st.store.settled(this.memoryKey(task)))
|
|
8118
|
+
if (task && approved && tier === "short" && !st.store.settled(this.memoryKey(task))) this.settleMemory(task);
|
|
8119
|
+
const row: NoteInput = { op: "note", project: st.project, scope, tier, kind, peer: by, ...(title ? { title } : {}), text, ...(task ? { task: this.memoryKey(task) } : {}), about, source: by === USER ? "person" : "hub" };
|
|
8120
|
+
if (tier === "long") {
|
|
8121
|
+
try {
|
|
8122
|
+
const result = this.memorySyncPolicy().screen(row);
|
|
8123
|
+
if (!result.ok) return { refused: `long-term note declined by ${result.rule}` };
|
|
8124
|
+
} catch { return { refused: "machine privacy policy unavailable" }; }
|
|
8125
|
+
}
|
|
7637
8126
|
if (task) this.markMemoryReference(task, by);
|
|
7638
|
-
const id = st.store.note(
|
|
8127
|
+
const id = st.store.note(row, horizon === "short");
|
|
7639
8128
|
st.onNote?.(id);
|
|
7640
8129
|
return { id, tier };
|
|
7641
8130
|
} catch (e) {
|
|
8131
|
+
if (longTerm) return { refused: "machine privacy policy unavailable" };
|
|
7642
8132
|
this.releaseNotice(`a ${kind} note was not saved to hub memory (${(e as Error).message})`);
|
|
7643
8133
|
return undefined;
|
|
7644
8134
|
}
|
|
@@ -7654,17 +8144,40 @@ export class Tasks {
|
|
|
7654
8144
|
let n = 0;
|
|
7655
8145
|
for (const t of this.d.board.list("approved")) {
|
|
7656
8146
|
if (this.isPii(t)) continue;
|
|
7657
|
-
try { this.d.memoryCheck?.(); const key = this.memoryKey(t); if (this.d.store.store.hasTask(key) && !this.d.store.store.settled(key, { includeLegacy: true })
|
|
8147
|
+
try { this.d.memoryCheck?.(); const key = this.memoryKey(t); if (this.d.store.store.hasTask(key) && !this.d.store.store.settled(key, { includeLegacy: true }) && this.settleMemory(t)) n++; }
|
|
7658
8148
|
catch { /* fail-open: tried again at the next start */ }
|
|
7659
8149
|
}
|
|
7660
8150
|
return n;
|
|
7661
8151
|
}
|
|
7662
8152
|
|
|
7663
|
-
private settleMemory(task: Task):
|
|
7664
|
-
|
|
7665
|
-
|
|
7666
|
-
try {
|
|
7667
|
-
|
|
8153
|
+
private settleMemory(task: Task): boolean {
|
|
8154
|
+
const st = this.d.store;
|
|
8155
|
+
if (!st || this.isPii(task)) return false;
|
|
8156
|
+
try {
|
|
8157
|
+
this.d.memoryCheck?.();
|
|
8158
|
+
st.store.settle(this.memoryKey(task), task.reviewer ?? task.owner ?? HUB);
|
|
8159
|
+
return true;
|
|
8160
|
+
} catch (e) { this.releaseNotice(`task #${task.id}: hub memory could not settle its notes (${(e as Error).message})`); return false; }
|
|
8161
|
+
}
|
|
8162
|
+
|
|
8163
|
+
/** Standalone outbound rules plus the hub's current routing and project-private-path policy. */
|
|
8164
|
+
private memorySyncPolicy() {
|
|
8165
|
+
const st = this.d.store!;
|
|
8166
|
+
const hostScreen = (row: Record<string, unknown>) => {
|
|
8167
|
+
const taskKeyValue = typeof row.task === "string" ? row.task : undefined;
|
|
8168
|
+
const match = taskKeyValue ? /#(\d+)@(\d+)$/.exec(taskKeyValue) : null;
|
|
8169
|
+
const task = match ? this.d.board.get(Number(match[1])) : undefined;
|
|
8170
|
+
const taskPaths = task && String(task.created) === match?.[2] ? [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])] : [];
|
|
8171
|
+
const title = typeof row.title === "string" ? row.title : "";
|
|
8172
|
+
const text = typeof row.text === "string" ? row.text : "";
|
|
8173
|
+
const about = Array.isArray(row.about) ? row.about.filter((value): value is string => typeof value === "string") : [];
|
|
8174
|
+
const extraText = JSON.stringify(Object.fromEntries(Object.entries(row).filter(([key]) => !["title", "text", "about", "task", "project"].includes(key))));
|
|
8175
|
+
const note = { title, text: `${text}\n${extraText}`, about: [...about, ...taskPaths.map(path => `path:${path}`)], ...(typeof row.task === "string" ? { task: row.task } : {}), ...(typeof row.project === "string" ? { project: row.project } : {}) };
|
|
8176
|
+
const safe = withoutPii([note], this.d.routing(), this.d.cwd, this.d.piiPaths ?? []);
|
|
8177
|
+
const privatePath = mentionsPrivatePath(`${title}\n${text}\n${extraText}`, note.about, this.d.piiPaths ?? [], this.d.cwd);
|
|
8178
|
+
return safe.length && !privatePath ? { ok: true as const } : { ok: false as const, rule: "hub privacy policy: PII pattern or private path" };
|
|
8179
|
+
};
|
|
8180
|
+
return createMemorySyncPolicy({ home: st.store.profile.home, cwd: this.d.cwd, denyPaths: st.deny, piiPatterns: this.d.routing().signals.pii_patterns, hostScreen });
|
|
7668
8181
|
}
|
|
7669
8182
|
|
|
7670
8183
|
/**
|
|
@@ -7688,15 +8201,16 @@ export class Tasks {
|
|
|
7688
8201
|
const title = `agent-hub task #${task.id}: ${task.title}`.slice(0, 120);
|
|
7689
8202
|
if (this.aboutPrivatePath(title, text, task)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (it is about a private path)`);
|
|
7690
8203
|
const native = this.saveNative(by, kind, text, title, task, undefined, "project", symbols);
|
|
8204
|
+
if (native && "refused" in native) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (${native.refused})`);
|
|
7691
8205
|
if (!native && this.d.memory) {
|
|
7692
8206
|
try { this.markMemoryReference(task, by); } catch { return this.releaseNotice(`task #${task.id}: memory reference could not be recorded; note was not saved`); }
|
|
7693
8207
|
void this.d.memory.save({ text, title, project: this.d.project, metadata: { peer: by, task: task.id, kind } });
|
|
7694
8208
|
}
|
|
7695
8209
|
}
|
|
7696
8210
|
|
|
7697
|
-
private tell(task: Task, body: string, pii: boolean, audience: "owner" | "withdrawal" = "owner"): void {
|
|
8211
|
+
private tell(task: Task, body: string, pii: boolean, audience: "owner" | "withdrawal" = "owner", reviewObserverExcluded = false): void {
|
|
7698
8212
|
if (!task.owner || task.owner === USER) return;
|
|
7699
|
-
this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner], kind: "task", priority: "important", refs: { task: String(task.id), ...(audience === "owner" ? { taskRecipient: "owner" as const } : {}) }, ...(pii ? { private: true } : {}) }));
|
|
8213
|
+
this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner], kind: reviewObserverExcluded ? "review" : "task", priority: "important", refs: { task: String(task.id), ...(reviewObserverExcluded ? { reviewPackage: `finding-${crypto.randomUUID()}` } : {}), ...(audience === "owner" ? { taskRecipient: "owner" as const } : {}) }, ...(pii ? { private: true } : {}) }));
|
|
7700
8214
|
}
|
|
7701
8215
|
}
|
|
7702
8216
|
|