@staix/agent-hub 0.12.29 → 0.12.31
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 +92 -218
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +3 -2
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +7 -1
- package/docs/agent-notes/daemon.md +14 -1
- package/docs/agent-notes/local-worker.md +4 -2
- package/docs/agent-notes/tasks.md +14 -0
- package/docs/agent-notes/tests.md +6 -3
- package/docs/memory.md +70 -152
- package/docs/operations.md +317 -25
- package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
- package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
- package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
- package/docs/plans/amem-cases.json +482 -0
- package/docs/quickstart.md +24 -2
- package/docs/security.md +42 -2
- package/docs/smoke.md +11 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +142 -7
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
- package/docs/verification/2026-10-17-0.12.30.md +70 -0
- package/docs/verification/2026-10-17-0.12.31.md +71 -0
- package/docs/verification/2026-10-17-418-probes.md +21 -0
- package/docs/verified.json +13 -13
- package/package.json +5 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +129 -62
- package/src/adapters/claude-channel.ts +29 -11
- package/src/adapters/local-worker.ts +2 -2
- package/src/adapters/pi.ts +5 -0
- package/src/cli/attention.ts +5 -1
- package/src/cli/console-state.ts +145 -29
- package/src/cli/console.ts +40 -10
- package/src/cli/help.ts +3 -2
- package/src/cli/identity.ts +4 -3
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/main.js +2 -2
- package/src/cli/main.ts +43 -14
- package/src/cli/memory.ts +87 -187
- package/src/cli/output.ts +19 -2
- package/src/cli/recovery-package.ts +59 -10
- package/src/cli/upgrade-interactive.ts +2 -1
- package/src/cli/upgrade-restore-state.ts +133 -0
- package/src/cli/upgrade-runtime.ts +204 -63
- package/src/cli/upgrade.ts +57 -24
- package/src/hub/board.ts +83 -24
- package/src/hub/bus.ts +108 -38
- package/src/hub/check-runner.ts +514 -0
- package/src/hub/child-process.ts +1 -0
- package/src/hub/control-client.ts +5 -1
- package/src/hub/daemon.ts +529 -89
- package/src/hub/delivery-hints.ts +30 -0
- package/src/hub/delivery-journal.ts +185 -12
- package/src/hub/delivery-superseded.ts +52 -0
- package/src/hub/envelope.ts +31 -1
- package/src/hub/git.ts +2 -38
- package/src/hub/host-executable.ts +11 -0
- package/src/hub/hub-tools.ts +14 -2
- package/src/hub/manager.ts +1 -1
- package/src/hub/memory-candidates.ts +103 -0
- package/src/hub/memory-fail.ts +25 -0
- package/src/hub/memory-observations.ts +47 -0
- package/src/hub/memory-plan-guard.ts +33 -0
- package/src/hub/memory-policy.ts +42 -0
- package/src/hub/memory-profile-check.ts +170 -0
- package/src/hub/memory-profile.ts +192 -0
- package/src/hub/memory-staleness.ts +55 -0
- package/src/hub/path-policy.ts +8 -0
- package/src/hub/peer-catalog.ts +3 -0
- package/src/hub/project.ts +4 -37
- package/src/hub/read-tools.ts +127 -0
- package/src/hub/routing.ts +35 -16
- package/src/hub/settings.ts +2 -1
- package/src/hub/spec-read.ts +120 -0
- package/src/hub/supervision.ts +21 -6
- package/src/hub/task-sweep.ts +7 -6
- package/src/hub/tasks.ts +370 -71
- package/src/local/deny.ts +4 -17
- package/src/memory/brief.ts +8 -5
- package/src/memory/capture.ts +3 -1
- package/src/memory/recall.ts +50 -110
- package/src/pi/process-signature.ts +2 -42
- package/src/ui/index.html +12 -9
- package/src/ui/needs.ts +7 -5
- package/src/ui/office-model.ts +4 -1
- package/src/ui/task-board.ts +1 -1
- package/src/ui/task-progress.ts +6 -2
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
- package/src/memory/import.ts +0 -90
- package/src/memory/store.ts +0 -805
- package/src/memory/sync.ts +0 -145
package/src/hub/tasks.ts
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
|
+
import { composeRememberText } from "./memory-fail.ts";
|
|
1
2
|
import { readGitHubImport, type GitHubSource, type GitHubImportSelection } from "./github-import.ts";
|
|
2
3
|
import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
|
|
3
4
|
import { readMergeQueue } from "./sdlc-queue.ts";
|
|
4
5
|
import { readReleaseReadiness } from "./sdlc-release.ts";
|
|
5
6
|
import { loadRoleCatalog, taskRole, taskRoleText, type RoleCatalog } from "./roles.ts";
|
|
6
7
|
import { matchesPolicy } from "./path-policy.ts";
|
|
7
|
-
import {
|
|
8
|
+
import { correctionInput, resolveVisibleMemory } from "./memory-candidates.ts";
|
|
9
|
+
import type { MemoryScreen } from "./read-tools.ts";
|
|
10
|
+
import { withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
8
11
|
import { profileClass, profileData, profileTaskLabel, projectProfile, type ProfileName, type ProfileSelection, type ProjectProfile } from "./profiles.ts";
|
|
9
12
|
import { phaseView, validatePhaseLinks, workflowHash } from "./workflow.ts";
|
|
10
13
|
import { isAbsolute, relative } from "node:path";
|
|
11
|
-
import type { Briefs } from "../memory/brief.ts";
|
|
14
|
+
import type { Briefs, NativeItem } from "../memory/brief.ts";
|
|
12
15
|
import type { MemoryClient } from "../memory/client.ts";
|
|
13
|
-
import { extractNodes, looksSecret, PROMOTED_KINDS, taskKey, type GoalView, type Kind, type MemoryStore, type Scope, type Tier } from "
|
|
14
|
-
import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskHasStarted, planningUndoEntry, type HistoryEntry, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
|
|
16
|
+
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";
|
|
17
|
+
import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
|
|
15
18
|
import type { Bus } from "./bus.ts";
|
|
16
19
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
17
20
|
import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
|
|
18
|
-
import { assign, detectSignals, LOCAL, peerEntry, PI, predictSplit, type Assignment, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
21
|
+
import { assign, detectSignals, LOCAL, peerEntry, PI, predictSplit, type Assignment, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
19
22
|
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
20
23
|
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
21
24
|
import { realPath } from "./project.ts";
|
|
@@ -45,7 +48,15 @@ export interface TasksDeps {
|
|
|
45
48
|
projectedReview?: (task: Task) => string | undefined;
|
|
46
49
|
sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
|
|
47
50
|
briefs?: Briefs;
|
|
51
|
+
/** Screened live fail notes directly intersecting the task plan or refs; read-only advice. */
|
|
52
|
+
planFails?: (task: Task) => NativeItem[];
|
|
53
|
+
candidates?: CandidateStore;
|
|
54
|
+
candidateScreen?: MemoryScreen;
|
|
55
|
+
/** Authenticated live turn context, not a model-supplied flag. */
|
|
56
|
+
privateTurn?: (peer: PeerId) => boolean;
|
|
48
57
|
memory?: MemoryClient;
|
|
58
|
+
/** Captured native profile fence at our memory operation boundaries; no board mutation. */
|
|
59
|
+
memoryCheck?: () => void;
|
|
49
60
|
/** #258: the hub's own store, the project name it files entries under (the git chain) and the repo `#N` issues belong to. */
|
|
50
61
|
store?: { store: MemoryStore; project: string; repo: string; deny?: string[]; onNote?: (id: string) => void };
|
|
51
62
|
/** a line for the human: console tail and hub.log */
|
|
@@ -67,6 +78,12 @@ export interface TasksDeps {
|
|
|
67
78
|
/** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
|
|
68
79
|
review?: { adaptive: boolean; min_reviews: number };
|
|
69
80
|
/** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
|
|
81
|
+
capabilities?: () => Record<PeerId, PeerCapabilities>;
|
|
82
|
+
repository?: string;
|
|
83
|
+
offerRead?: (peer: PeerId, envelopeId: string) => boolean;
|
|
84
|
+
offerPullOnly?: (peer: PeerId) => boolean;
|
|
85
|
+
cancellationHeld?: () => boolean;
|
|
86
|
+
stallNotice?: (stall: TaskStall) => void;
|
|
70
87
|
failing?: () => Record<PeerId, string>;
|
|
71
88
|
/** Peers whose queue a needs_review delivery holds, with the hold detail (issue #90); routing explains the hold, never rejects. */
|
|
72
89
|
held?: () => Record<PeerId, string>;
|
|
@@ -138,6 +155,14 @@ export class AssignmentUndeliveredError extends Error {
|
|
|
138
155
|
}
|
|
139
156
|
}
|
|
140
157
|
|
|
158
|
+
/** A later task-flow event won before the assignment could be published, so the stale envelope is suppressed. */
|
|
159
|
+
export class AssignmentSupersededError extends Error {
|
|
160
|
+
constructor(task: Task) {
|
|
161
|
+
super(`task #${task.id}: assignment was superseded before publication`);
|
|
162
|
+
this.name = "AssignmentSupersededError";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
141
166
|
/** A project path as one spelling (#67): no leading `./`, no repeated or trailing `/`; the root is `.`. */
|
|
142
167
|
export const normPath = (p: string) => p.replace(/^(\.\/)+/, "").replace(/\/{2,}/g, "/").replace(/\/+$/, "") || ".";
|
|
143
168
|
|
|
@@ -290,8 +315,27 @@ export class Tasks {
|
|
|
290
315
|
try {
|
|
291
316
|
for (const task of this.d.board.list()) {
|
|
292
317
|
if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) break;
|
|
293
|
-
|
|
318
|
+
if (!task.owner && this.personUnassigned(task)) continue;
|
|
319
|
+
const finding = nextSweep(task, this.sweepConfig, now, this.waitsFor(task).length === 0);
|
|
294
320
|
if (!finding || this.waitsFor(task).length || this.isChecking(task.id) || this.silentFor(task.id)) continue;
|
|
321
|
+
if (finding.kind === "ready-unassigned") {
|
|
322
|
+
this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
|
|
323
|
+
recorded.push({ task: task.id, finding });
|
|
324
|
+
const planners = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => roles.includes("planner") && this.d.bus.isRostered(peer)).map(([peer]) => peer);
|
|
325
|
+
const text = `Task #${task.id}: ready-unassigned, idle escalation ${finding.step}/3.`;
|
|
326
|
+
if (finding.step === 1 || finding.step === 2) {
|
|
327
|
+
if (finding.step === 2 || !planners.length) this.releaseNotice(text);
|
|
328
|
+
for (const peer of planners) this.releaseNotice(`task #${task.id}: sweep notice unavailable`, () => this.sweepNotice(task, peer, `${text} Check the board and assign, re-plan or cancel it.`, "planner"));
|
|
329
|
+
} else {
|
|
330
|
+
const admission = this.plannerPullRecord(task)?.pull ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.weights(task.class) });
|
|
331
|
+
this.releaseNotice(`${text} Owner reassignment suggestion: ${admission.owner ?? "no eligible peer"}.`);
|
|
332
|
+
if (admission.owner && this.sweepConfig.auto_reassign && this.sweepAvailable(admission.owner)) {
|
|
333
|
+
try { await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [admission.owner], event: "reassigned", reason: "idle", note: "ready-unassigned idle sweep" }); }
|
|
334
|
+
catch (e) { if (!(e instanceof AssignmentUndeliveredError)) throw e; }
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
295
339
|
const responsible = finding.kind === "review-pending" ? task.reviewer! : task.owner!;
|
|
296
340
|
if (responsible !== USER && !this.sweepAvailable(responsible)) continue;
|
|
297
341
|
if (finding.step === 3 && finding.kind !== "review-pending" && this.sweepConfig.auto_reassign && this.plannerPullRecord(task)?.pull && !assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), responsible], excludeOwners: this.plannerFull(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) }).owner) continue;
|
|
@@ -302,11 +346,11 @@ export class Tasks {
|
|
|
302
346
|
this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
|
|
303
347
|
recorded.push({ task: task.id, finding });
|
|
304
348
|
if (finding.step === 1) {
|
|
305
|
-
this.sweepNotice(task, responsible, `${message} Check its current state with hub_task_list and accept, complete or review your assigned work
|
|
349
|
+
this.sweepNotice(task, responsible, `${message} Check its current state with hub_task_list and accept, complete or review your assigned work.`, finding.kind === "review-pending" ? "reviewer" : "owner");
|
|
306
350
|
} else if (finding.step === 2) {
|
|
307
351
|
this.d.notify(message);
|
|
308
352
|
for (const [peer, roles] of Object.entries(this.d.roles ?? {})) {
|
|
309
|
-
if (peer !== responsible && roles.includes("planner") && this.sweepAvailable(peer)) this.sweepNotice(task, peer, `${message} The assigned ${finding.kind === "review-pending" ? "reviewer" : "owner"} has not recorded fresh task activity; check the board before coordinating
|
|
353
|
+
if (peer !== responsible && roles.includes("planner") && this.sweepAvailable(peer)) this.sweepNotice(task, peer, `${message} The assigned ${finding.kind === "review-pending" ? "reviewer" : "owner"} has not recorded fresh task activity; check the board before coordinating.`, "planner");
|
|
310
354
|
}
|
|
311
355
|
} else {
|
|
312
356
|
const reviewer = finding.kind === "review-pending";
|
|
@@ -334,9 +378,9 @@ export class Tasks {
|
|
|
334
378
|
return bus.stateOf(peer) === "idle" && (this.d.idle?.(peer) ?? true) && !peerEntry(this.d.held?.(), peer) && !bus.queueSummary(peer).heldBy && !bus.queued(peer) && !bus.hasInFlight(peer);
|
|
335
379
|
}
|
|
336
380
|
|
|
337
|
-
private sweepNotice(task: Task, peer: PeerId, body: string): void {
|
|
381
|
+
private sweepNotice(task: Task, peer: PeerId, body: string, taskRecipient: "owner" | "reviewer" | "planner"): void {
|
|
338
382
|
if (peer === USER) this.d.notify(body);
|
|
339
|
-
else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task", refs: { task: String(task.id) } }));
|
|
383
|
+
else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task", refs: { task: String(task.id), taskRecipient } }));
|
|
340
384
|
}
|
|
341
385
|
|
|
342
386
|
configureExecutionBudget = (config: ExecutionBudgetConfig): ExecutionBudgetStatus | undefined => {
|
|
@@ -368,19 +412,20 @@ export class Tasks {
|
|
|
368
412
|
|
|
369
413
|
/** A task as a cloud peer may see it. */
|
|
370
414
|
publicView(task: Task, fullHistory = false): Record<string, unknown> {
|
|
371
|
-
const { title, detail, history, external, ...rest } = task;
|
|
415
|
+
const { title, detail, history, external, cancelled, ...rest } = task;
|
|
372
416
|
if (this.isPii(task)) return { ...rest, actualDates: actualTaskDates(task), refs: {}, plan: {}, ...(task.sdlc ? { sdlc: { stage: stageDefinition(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc.stage)?.base ?? "unknown" } } : {}), title: "[pii]", detail: "[pii]" };
|
|
373
417
|
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}]`;
|
|
374
418
|
const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
|
|
375
419
|
at: h.at, by: h.by, event: h.event,
|
|
376
420
|
...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note", h.withheld) }),
|
|
421
|
+
...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
|
|
377
422
|
...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
|
|
378
423
|
...(h.owner === undefined ? {} : { owner: h.owner }),
|
|
379
424
|
...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
|
|
380
425
|
...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
|
|
381
426
|
...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
|
|
382
427
|
}));
|
|
383
|
-
return { ...rest, ...(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)) } : {}), title, detail, history: publicHistory };
|
|
428
|
+
return { ...rest, ...(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)) } : {}), title, detail, history: publicHistory };
|
|
384
429
|
}
|
|
385
430
|
|
|
386
431
|
/** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
|
|
@@ -451,7 +496,16 @@ export class Tasks {
|
|
|
451
496
|
private health() {
|
|
452
497
|
const failing = this.d.failing?.();
|
|
453
498
|
const held = this.d.held?.();
|
|
454
|
-
return { pullOnly: [...this.d.bus.peers.values()].filter(peer => peer.id === "kimi" && peer.pullOnly).map(peer => peer.id), offRoster: SUPPORTED_PEERS.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
|
|
499
|
+
return { ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => peer.id === "kimi" && peer.pullOnly).map(peer => peer.id), offRoster: SUPPORTED_PEERS.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
|
|
503
|
+
private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: TaskAssignmentRole = "owner", preEffect = true): void {
|
|
504
|
+
if (!this.d.capabilities || this.isPii(task)) return;
|
|
505
|
+
const { state: _state, ...draft } = task; // backlog/readiness must not bypass an explicit reservation's admission
|
|
506
|
+
const admission = assign(draft, this.states(), this.d.routing(), { candidates: role === "owner" ? [peer] : [], ...(role === "owner" ? { namedOwner: peer } : { namedReviewer: peer }), ...this.weights(task.class) });
|
|
507
|
+
const missing = (admission.refusals ?? []).filter(row => row.peer === peer && row.role === role && (row.code === "tests" || row.code === "spec")).map(row => ` ${role} candidate ${peer}: skipped, ${row.reason}`);
|
|
508
|
+
if (missing.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot assign ${peer}: ${missing.join("; ")}`);
|
|
455
509
|
}
|
|
456
510
|
|
|
457
511
|
/** What assignment weighs besides states and policy: quota, demotion and the review record. */
|
|
@@ -587,7 +641,9 @@ export class Tasks {
|
|
|
587
641
|
}
|
|
588
642
|
|
|
589
643
|
async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
|
|
590
|
-
|
|
644
|
+
// #455: one authority check, made before the screen and again after it (a person may move the planner meanwhile).
|
|
645
|
+
const authorized = () => this.planningAuthority(by);
|
|
646
|
+
authorized();
|
|
591
647
|
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0 || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("planner pull needs an id, revision and bounded reason");
|
|
592
648
|
const validate = () => {
|
|
593
649
|
const task = this.d.board.get(input.id);
|
|
@@ -597,7 +653,7 @@ export class Tasks {
|
|
|
597
653
|
if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
|
|
598
654
|
const priority = task.priority ?? (task.signals.includes("urgent") ? 0 : 2);
|
|
599
655
|
if (priority === 0) {
|
|
600
|
-
const goals = this.d.board.list().filter(goal => goal.state
|
|
656
|
+
const goals = this.d.board.list().filter(goal => !isClosedTaskState(goal.state) && (goal.priority ?? (goal.signals.includes("urgent") ? 0 : 2)) === 0 && goal.rank != null && goal.history.some(entry => entry.by === USER && entry.planning?.fields.includes("rank")));
|
|
601
657
|
const effectiveOrder = (candidate: Task) => goals.filter(goal => goal.id === candidate.id || this.prerequisiteOf(goal, candidate.id)).map(goal => ({ rank: goal.rank!, id: goal.id })).sort((a,b) => a.rank-b.rank || a.id-b.id)[0];
|
|
602
658
|
const order = effectiveOrder(task) ?? { rank: task.rank ?? Number.MAX_SAFE_INTEGER, id: task.id };
|
|
603
659
|
if (this.d.board.list("backlog").some(other => {
|
|
@@ -612,6 +668,7 @@ export class Tasks {
|
|
|
612
668
|
return { task, owner: assignment.owner };
|
|
613
669
|
};
|
|
614
670
|
const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
|
|
671
|
+
authorized();
|
|
615
672
|
const current = validate();
|
|
616
673
|
const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld);
|
|
617
674
|
this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
|
|
@@ -723,7 +780,7 @@ export class Tasks {
|
|
|
723
780
|
const validate = () => {
|
|
724
781
|
const task = this.d.board.get(input.id);
|
|
725
782
|
if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("planning revision changed");
|
|
726
|
-
if (task.state
|
|
783
|
+
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
|
|
727
784
|
if (by !== USER && (task.state !== "backlog" || taskHasStarted(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
|
|
728
785
|
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");
|
|
729
786
|
if (by !== USER && Object.hasOwn(patch, "reserved") && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained");
|
|
@@ -731,6 +788,7 @@ export class Tasks {
|
|
|
731
788
|
if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
|
|
732
789
|
if (patch.reserved && !(SUPPORTED_PEERS as readonly string[]).includes(patch.reserved) && !this.d.bus.knownPeers().includes(patch.reserved) && !this.d.routing().classes[task.class]?.peers.includes(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is not a supported or configured peer");
|
|
733
790
|
if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
|
|
791
|
+
if (patch.reserved) this.requireCapabilities(task, patch.reserved);
|
|
734
792
|
const rows = new Map(this.d.board.list().map(row => [row.id, row]));
|
|
735
793
|
const pending = [...(patch.deps ?? task.deps ?? [])], seen = new Set<number>();
|
|
736
794
|
while (pending.length) {
|
|
@@ -754,6 +812,7 @@ export class Tasks {
|
|
|
754
812
|
return task;
|
|
755
813
|
};
|
|
756
814
|
const task = validate(), withheld = await this.withholds("note", input.reason, task);
|
|
815
|
+
this.planningAuthority(by); // #455: or moved the planner
|
|
757
816
|
validate(); // a person may have changed planning or frozen a release while the screen ran
|
|
758
817
|
const updated = this.d.board.plan(task.id, by, patch, input.revision, input.reason.trim(), withheld);
|
|
759
818
|
this.planningRecorded({ by, task: task.id, fields: Object.keys(patch), revision: updated.planningRevision! });
|
|
@@ -771,7 +830,7 @@ export class Tasks {
|
|
|
771
830
|
const childIds = new Set(entry.planning.split.children.map(child => child.id)), budgets = this.d.executionBudget?.status();
|
|
772
831
|
if (Array.isArray(budgets) && budgets.some(budget => budget.kind === "task" && budget.taskId !== undefined && childIds.has(budget.taskId))) throw new PreEffectToolRefusal("split children are referenced by execution budgets");
|
|
773
832
|
if (this.d.store) {
|
|
774
|
-
try { this.d.store.store.refresh(); } catch { throw new PreEffectToolRefusal("split undo requires current memory link evidence"); }
|
|
833
|
+
try { this.d.memoryCheck?.(); this.d.store.store.refresh(); } catch { throw new PreEffectToolRefusal("split undo requires current memory link evidence"); }
|
|
775
834
|
const children = new Set(entry.planning.split.children.map(child => this.d.board.get(child.id)).filter((child): child is Task => !!child).map(child => this.memoryKey(child)));
|
|
776
835
|
if ([...children].some(key => this.d.store!.store.hasTask(key)) || this.d.store.store.goals().some(goal => goal.links.some(link => children.has(link.task)))) throw new PreEffectToolRefusal("split children are referenced by long-term tasks");
|
|
777
836
|
}
|
|
@@ -867,6 +926,7 @@ export class Tasks {
|
|
|
867
926
|
let screened: Awaited<typeof attempt>;
|
|
868
927
|
try { screened = await Promise.race([attempt, new Promise<never>((_, reject) => { deadline = setTimeout(() => reject(new PreEffectToolRefusal("split screening request deadline exceeded")), BACKLOG_SPLIT_SCREEN_TIMEOUT_MS); })]); }
|
|
869
928
|
finally { if (deadline !== undefined) clearTimeout(deadline); }
|
|
929
|
+
this.planningAuthority(by); // #455: a person may have moved the planner while the screen ran
|
|
870
930
|
const [screenedVerdicts, withheld] = screened, current = validate();
|
|
871
931
|
const finalSignals = current.drafts.map(part => this.signalsFor({ ...part, detail: [part.detail, planText(part.plan)].join("\n") }));
|
|
872
932
|
const deterministicPrivate = finalSignals.map((signals, index) => initialPrivate[index] || this.isPii({ signals }));
|
|
@@ -887,6 +947,7 @@ export class Tasks {
|
|
|
887
947
|
if (typeof input.name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(input.name) || typeof input.goal !== "string" || !input.goal.trim() || input.goal.length > 1000 || !Number.isSafeInteger(input.order) || input.order < 0) throw new PreEffectToolRefusal("invalid release name, goal or order");
|
|
888
948
|
const text = input.name + "\n" + input.goal;
|
|
889
949
|
if (!this.nameable(text) || this.screenOn() && (await this.screenCall(text)).label !== "clear") throw new PreEffectToolRefusal("release text was not cleared for public planning");
|
|
950
|
+
this.planningAuthority(by); // #455: a person may have moved the planner while the screen ran
|
|
890
951
|
if (this.d.board.releases().some(release => release.name === input.name)) throw new PreEffectToolRefusal("release name already exists");
|
|
891
952
|
const release = this.d.board.newRelease(input.name, input.goal.trim(), input.order);
|
|
892
953
|
this.planningRecorded({ by, release: release.id, fields: ["created"], revision: release.revision });
|
|
@@ -899,7 +960,7 @@ export class Tasks {
|
|
|
899
960
|
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || !release || release.revision !== input.revision) throw new PreEffectToolRefusal("release revision changed");
|
|
900
961
|
if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release is final");
|
|
901
962
|
if (by !== USER && (release.state === "frozen" || input.state === "frozen" || input.state === "shipped" || input.state === "dropped")) throw new PreEffectToolRefusal("freeze, ship, drop and frozen changes require a person");
|
|
902
|
-
if (input.state === "shipped" && this.d.board.list().some(task => task.release === release.id && task.state
|
|
963
|
+
if (input.state === "shipped" && this.d.board.list().some(task => task.release === release.id && !isClosedTaskState(task.state))) throw new PreEffectToolRefusal("every release task must be approved, cancelled, or moved out before ship");
|
|
903
964
|
const { id, revision, ...patch } = input;
|
|
904
965
|
if (!Object.keys(patch).length || Object.keys(patch).some(key => !["state", "plannedStart", "cut", "ship"].includes(key))) throw new PreEffectToolRefusal("unknown or empty release fields");
|
|
905
966
|
const updated = this.d.board.updateRelease(id, patch, revision);
|
|
@@ -966,6 +1027,7 @@ export class Tasks {
|
|
|
966
1027
|
catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
967
1028
|
const draft = { ...text, class: cls, ...(selected ? { role: selected.id } : {}), ...(profile !== "dev" || given !== undefined && given !== cls || this.d.profileData && this.d.profileData !== projectProfile("dev") ? { profile, profileClass: given ?? cls, ...(profile === "dev" ? { customProfile: true as const } : {}) } : {}) };
|
|
968
1029
|
if (external) { const prior = this.d.board.imported(external.repo, external.issue); if (prior) return prior; }
|
|
1030
|
+
if (reserved) this.requireCapabilities({ ...draft, signals, ...(external ? { external } : {}) }, reserved);
|
|
969
1031
|
let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
|
|
970
1032
|
created?.();
|
|
971
1033
|
if (screening) task = this.screenedTask(task, screened);
|
|
@@ -1048,6 +1110,7 @@ export class Tasks {
|
|
|
1048
1110
|
if (v?.label === "unknown") return;
|
|
1049
1111
|
if (v?.label !== "clear") return void this.d.board.update(task.id, HUB, "screened", {}, v ? `pii: screen, ${v.category}` : "pii: regex");
|
|
1050
1112
|
const lifted = this.d.board.update(task.id, HUB, "screened", { signals: now.signals.filter((s) => s !== "pii") }, "clear, screened again");
|
|
1113
|
+
if (this.personUnassigned(lifted)) return;
|
|
1051
1114
|
this.d.notify(`task ${this.publicTitle(lifted)}: the PII screen cleared it on a second look; it is routed as usual`);
|
|
1052
1115
|
const still = this.waitsFor(lifted);
|
|
1053
1116
|
if (still.length) return void this.d.board.update(lifted.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
|
|
@@ -1160,7 +1223,7 @@ export class Tasks {
|
|
|
1160
1223
|
* #106): open work by default; the conflict notices of #91 count a task in review too.
|
|
1161
1224
|
*/
|
|
1162
1225
|
whileOpen(to: PeerId, task: number, body: string, states: Task["state"][] = OPEN): void {
|
|
1163
|
-
const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task) } });
|
|
1226
|
+
const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task), taskRecipient: "owner" } });
|
|
1164
1227
|
this.conditional.set(env.id, { peer: to, task, states });
|
|
1165
1228
|
if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
|
|
1166
1229
|
this.d.bus.publish(env);
|
|
@@ -1181,6 +1244,10 @@ export class Tasks {
|
|
|
1181
1244
|
}
|
|
1182
1245
|
return true;
|
|
1183
1246
|
}
|
|
1247
|
+
if (env.from === HUB && env.refs?.task) {
|
|
1248
|
+
const task = this.d.board.get(Number(env.refs.task));
|
|
1249
|
+
if (task?.state === "cancelled") return env.kind === "task" && typeof env.refs.taskCancelled === "number" && env.refs.taskCancelled === task.cancelled?.flowRevision;
|
|
1250
|
+
}
|
|
1184
1251
|
const c = this.conditional.get(env.id);
|
|
1185
1252
|
if (!c || c.peer !== peer) return true;
|
|
1186
1253
|
const t = this.d.board.get(c.task);
|
|
@@ -1282,7 +1349,7 @@ export class Tasks {
|
|
|
1282
1349
|
try {
|
|
1283
1350
|
const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
|
|
1284
1351
|
const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", reason: "offline", note: why });
|
|
1285
|
-
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner });
|
|
1352
|
+
if (this.ownOwnerMove(back, next, HUB, "reassigned")) moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner });
|
|
1286
1353
|
// The gone owner hears it on its next delivery, if it comes back mid-work.
|
|
1287
1354
|
if (next.owner && next.owner !== peer) this.d.tell?.(peer, noteLine(HUB, "decision", `task #${task.id} moved to ${next.owner} while you were offline; stop working on it`));
|
|
1288
1355
|
} catch (e) {
|
|
@@ -1306,6 +1373,7 @@ export class Tasks {
|
|
|
1306
1373
|
if (typeof target === "number") {
|
|
1307
1374
|
const task = this.d.board.get(target);
|
|
1308
1375
|
if (!task) throw new Error(`no task #${target}`);
|
|
1376
|
+
if (isClosedTaskState(task.state)) return [...workflow, `task ${this.publicTitle(task)} (${task.state}): closed; not routed`];
|
|
1309
1377
|
const a = assign(task, this.states(), routing, { exclude: this.excluded(task), excludeOwners: this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], waitsFor: this.waitsFor(task), ...this.weights(task.class) });
|
|
1310
1378
|
// The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
|
|
1311
1379
|
return [...workflow, `task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"}${task.reserved ? `, reserved for ${task.reserved}` : ""})`, ...(task.profileClass ? [`profile class ${profileTaskLabel(task)}`] : []), ...(task.role ? [`task role ${task.role} (guidance only)`] : []), "if it were assigned now:", ...a.trace, ...(this.splitShadow(task, task.owner ?? a.owner)?.trace ?? [])];
|
|
@@ -1313,14 +1381,17 @@ export class Tasks {
|
|
|
1313
1381
|
const draft = { title: target.title, detail: target.detail ?? "", refs: target.refs ?? {} };
|
|
1314
1382
|
const signals = this.signalsFor(draft, routing);
|
|
1315
1383
|
const base = profileClass(target.class, this.d.profileData ?? this.d.profile);
|
|
1316
|
-
const trace = [...(this.d.profile && this.d.profile !== "dev" ? [`profile class ${target.class} (base ${base}, ${this.d.profile})`] : []), ...assign({ class: base, signals }, this.states(), routing, this.weights(base)).trace];
|
|
1384
|
+
const trace = [...(this.d.profile && this.d.profile !== "dev" ? [`profile class ${target.class} (base ${base}, ${this.d.profile})`] : []), ...assign({ class: base, signals, ...draft }, this.states(), routing, this.weights(base)).trace];
|
|
1317
1385
|
// A draft is not sent to the PII screen (issue #198): a proposal is, and a pii or unknown verdict adds the pii signal.
|
|
1318
1386
|
return [...workflow, ...(this.screenOn() && !signals.includes("pii") ? [...trace, "pii_screen is on: at proposal a pii or unknown verdict adds signal pii (local only)"] : trace)];
|
|
1319
1387
|
}
|
|
1320
1388
|
|
|
1321
1389
|
/** Public read path: every generated line is screened, including profile/config-derived trace fields. */
|
|
1322
1390
|
publicExplain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }, routing?: Routing): string[] {
|
|
1323
|
-
|
|
1391
|
+
const trace = this.explain(target, routing);
|
|
1392
|
+
const holds = Object.entries(this.d.held?.() ?? {}).flatMap(([peer, hold]) =>
|
|
1393
|
+
trace.some(line => line.startsWith(` hold: ${peer}'s queue is held:`)) ? [] : [`${peer} queue: ${hold}`]);
|
|
1394
|
+
return [...trace, ...holds].map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
|
|
1324
1395
|
}
|
|
1325
1396
|
|
|
1326
1397
|
/**
|
|
@@ -1329,6 +1400,19 @@ export class Tasks {
|
|
|
1329
1400
|
*/
|
|
1330
1401
|
private excluded = (task: Task) => task.history.flatMap((h) => (h.event === "declined" ? [h.from ?? h.by] : h.event === "escalated" && h.from ? [h.from] : []));
|
|
1331
1402
|
|
|
1403
|
+
private checkFlowRevision(task: Task, expected: number | undefined, preEffect = false): void {
|
|
1404
|
+
if (expected !== undefined && (!Number.isSafeInteger(expected) || expected < 0 || task.history.length !== expected)) {
|
|
1405
|
+
throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: flow revision changed; reload and retry`);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/** A person's owner removal sticks until a later explicit assignment or reservation, across hub runs. */
|
|
1410
|
+
private personUnassigned(task: Task): boolean {
|
|
1411
|
+
const removed = task.history.findLastIndex((h) => h.event === "unassigned" && h.by === USER);
|
|
1412
|
+
if (removed < 0) return false;
|
|
1413
|
+
return !task.history.slice(removed + 1).some((h) => h.by === USER && ["assigned", "reassigned", "escalated", "reserved", "backlog pulled"].includes(h.event));
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1332
1416
|
/**
|
|
1333
1417
|
* Says on the console and hub.log that routing passed over the reserved owner (#207); returns the history note's suffix.
|
|
1334
1418
|
* Only news is said: not when the reserved owner is the one the task moves away from, nor once it refused the task for
|
|
@@ -1356,8 +1440,17 @@ export class Tasks {
|
|
|
1356
1440
|
if (hold) this.d.notify(`task ${this.publicTitle(task)}: ${a.owner}'s queue is held (${hold}); the task arrives once the hold is resolved`);
|
|
1357
1441
|
}
|
|
1358
1442
|
|
|
1443
|
+
/** A later move to another owner means the caller's assignment was superseded, so its success tail is not authoritative. */
|
|
1444
|
+
private ownOwnerMove(before: Task, after: Task, by: PeerId, event: string): boolean {
|
|
1445
|
+
const moves = (task: Task) => task.history.filter(entry => ["assigned", "reassigned", "escalated", "unassigned"].includes(entry.event));
|
|
1446
|
+
const prior = moves(before).length, entries = moves(after).slice(prior), own = entries[0];
|
|
1447
|
+
if (!own || own.by !== by || own.event !== event) return false;
|
|
1448
|
+
if (own.event === "unassigned") return entries.length === 1;
|
|
1449
|
+
return entries.every(entry => entry.owner === own.owner);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1359
1452
|
/** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
|
|
1360
|
-
private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean } = {}): Promise<Task> {
|
|
1453
|
+
private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number } = {}): Promise<Task> {
|
|
1361
1454
|
if (task.state === "backlog") throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
|
|
1362
1455
|
const waits = this.waitsFor(task);
|
|
1363
1456
|
const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
|
|
@@ -1370,6 +1463,7 @@ export class Tasks {
|
|
|
1370
1463
|
}
|
|
1371
1464
|
const passed = this.passedOver(task, a);
|
|
1372
1465
|
if (!a.owner) {
|
|
1466
|
+
if (!task.owner && task.state === "proposed") this.reportStall(task, this.ownerStallCause(task, a));
|
|
1373
1467
|
this.releaseNotice(opts.roster
|
|
1374
1468
|
? `task ${this.publicTitle(task)}: ${task.owner} is off roster and no peer can take it; it stays with ${task.owner} in ${task.state}; assign with: ahub task assign ${task.id} <peer>`
|
|
1375
1469
|
: `task ${this.publicTitle(task)}: no peer can take it (${a.trace.filter((l) => l.includes("skipped")).length} skipped); assign with: ahub task assign ${task.id} <peer>`);
|
|
@@ -1384,7 +1478,7 @@ export class Tasks {
|
|
|
1384
1478
|
// A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
|
|
1385
1479
|
// out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
|
|
1386
1480
|
const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
|
|
1387
|
-
const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, `${opts.note ?? `to ${a.owner}`}${passed}`, { ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) });
|
|
1481
|
+
const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, `${opts.note ?? `to ${a.owner}`}${passed}`, { ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) }, opts.expectedFlowRevision);
|
|
1388
1482
|
// What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
|
|
1389
1483
|
// escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
|
|
1390
1484
|
// yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
|
|
@@ -1398,6 +1492,7 @@ export class Tasks {
|
|
|
1398
1492
|
// The cohort record formed just below reads it, and so does `route explain` while that turn lasts.
|
|
1399
1493
|
if (opts.claim && a.owner === by && this.d.bus.peers.get(by)?.state === "busy") this.sent.set(handOver(next, by), by);
|
|
1400
1494
|
try {
|
|
1495
|
+
if (by === USER && opts.event === "reassigned") this.withdrawRoleRequest(task, next, "owner");
|
|
1401
1496
|
const hits = this.overlapHits(next);
|
|
1402
1497
|
this.formCohort(next, hits);
|
|
1403
1498
|
if (hits.length) {
|
|
@@ -1419,7 +1514,10 @@ export class Tasks {
|
|
|
1419
1514
|
// result landing during the brief) leave the assignment exactly as undelivered as before: the notice and
|
|
1420
1515
|
// the typed error stand (#297 Decisions).
|
|
1421
1516
|
const moves = (entries: Task["history"]) => entries.filter((h) => !QUIET_EVENTS.has(h.event) && !h.event.startsWith("check "));
|
|
1422
|
-
if (moves(saved.history).length !== moves(next.history).length)
|
|
1517
|
+
if (moves(saved.history).length !== moves(next.history).length) {
|
|
1518
|
+
if (e instanceof AssignmentSupersededError) return saved;
|
|
1519
|
+
throw e;
|
|
1520
|
+
}
|
|
1423
1521
|
this.releaseNotice(this.undeliveredNotice(saved, e));
|
|
1424
1522
|
throw new AssignmentUndeliveredError(saved, e);
|
|
1425
1523
|
}
|
|
@@ -1437,7 +1535,7 @@ export class Tasks {
|
|
|
1437
1535
|
if (by !== USER) throw new PreEffectToolRefusal("only the person may change an existing task catalog role");
|
|
1438
1536
|
if (raw === undefined) throw new PreEffectToolRefusal("name a catalog role id or explicit null to clear it");
|
|
1439
1537
|
const task = this.need(id, false, true);
|
|
1440
|
-
if (task.state
|
|
1538
|
+
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("a closed task's role is historical; propose new work instead");
|
|
1441
1539
|
let selected;
|
|
1442
1540
|
try { selected = taskRole(raw === null ? undefined : raw, task.class, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
|
|
1443
1541
|
catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
@@ -1447,7 +1545,7 @@ export class Tasks {
|
|
|
1447
1545
|
if (!peer || peer === USER) continue;
|
|
1448
1546
|
this.releaseNotice(`task ${this.publicTitle(next)}: catalog role update delivery unconfirmed`, () => this.d.bus.publish(newEnvelope(HUB,
|
|
1449
1547
|
`Task #${next.id}: catalog role changed by the person. ${next.role ? this.roleGuidance(next, peer, peer === next.reviewer) : "Catalog guidance cleared; follow the task's existing plan and requirements."}`,
|
|
1450
|
-
{ to: [peer], kind: "task", refs: { task: String(next.id) }, ...(this.isPii(next) ? { private: true } : {}) })));
|
|
1548
|
+
{ to: [peer], kind: "task", refs: { task: String(next.id), taskRecipient: peer === next.reviewer ? "reviewer" : "owner" }, ...(this.isPii(next) ? { private: true } : {}) })));
|
|
1451
1549
|
}
|
|
1452
1550
|
return next;
|
|
1453
1551
|
}
|
|
@@ -1465,13 +1563,22 @@ export class Tasks {
|
|
|
1465
1563
|
return `Task role ${task.role}: definition unavailable or exceeds the task guidance limit; grants no authority.`;
|
|
1466
1564
|
}
|
|
1467
1565
|
|
|
1566
|
+
private ownerAssignmentCurrent(assigned: Task, current: Task): boolean {
|
|
1567
|
+
if (current.owner !== assigned.owner) return false;
|
|
1568
|
+
const moves = (entries: Task["history"]) => entries.filter((h) => !QUIET_EVENTS.has(h.event) && !h.event.startsWith("check "));
|
|
1569
|
+
return moves(current.history).length === moves(assigned.history).length;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1468
1572
|
/** `contextWithheld`: the PII screen did not clear a budget hand-off (#198); an escalation's reason is the hub's own words. */
|
|
1469
1573
|
private async sendTask(task: Task, a: Assignment, context?: string, overlap = "", contextWithheld = false): Promise<void> {
|
|
1470
|
-
const
|
|
1471
|
-
const
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
const
|
|
1574
|
+
const brief = this.isPii(task) ? undefined : await this.d.briefs?.forTask(task.owner!, task, this.planGuardItems(task)).catch(() => undefined);
|
|
1575
|
+
const current = this.d.board.get(task.id);
|
|
1576
|
+
if (!current || !this.ownerAssignmentCurrent(task, current)) throw new AssignmentSupersededError(current ?? task);
|
|
1577
|
+
task = current;
|
|
1578
|
+
const pii = this.isPii(task), delivery = { ...a, reviewer: task.reviewer ?? undefined };
|
|
1579
|
+
const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", delivery.owner, h.withheld)}`);
|
|
1580
|
+
const handoff = context && !pii ? this.screen(task, context, "handoff", delivery.owner, contextWithheld).slice(0, 3000) : "";
|
|
1581
|
+
const facts = [`class ${profileTaskLabel(task)}`, delivery.owner === PI ? `backend pi/${delivery.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", delivery.reviewer ? `reviewer ${delivery.reviewer}` : task.class === "review" ? "no reviewer" : this.noReviewer(delivery)].filter(Boolean).join("; ");
|
|
1475
1582
|
const plan = planText(task.plan);
|
|
1476
1583
|
const body = [
|
|
1477
1584
|
`Task #${task.id} [${profileTaskLabel(task)}] ${task.title}`,
|
|
@@ -1493,7 +1600,8 @@ export class Tasks {
|
|
|
1493
1600
|
// (Codex, Pi); held, it starts no turn yet: neither is taking it.
|
|
1494
1601
|
const owner = task.owner!;
|
|
1495
1602
|
const idle = this.d.bus.stateOf(owner) === "idle";
|
|
1496
|
-
const env = newEnvelope(HUB, body, { to: [owner], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id) }, ...(pii ? { private: true } : {}) });
|
|
1603
|
+
const env = newEnvelope(HUB, body, { to: [owner], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id), taskRecipient: "owner", taskOffer: task.history.length }, ...(pii ? { private: true } : {}) });
|
|
1604
|
+
this.d.board.recordTaskOffer(task.id, { envelopeId: env.id, owner, assignmentFlowRevision: task.history.length, ...(this.d.offerPullOnly ? { pullOnly: this.d.offerPullOnly(owner) } : {}) }, task.history.length);
|
|
1497
1605
|
this.d.bus.publish(env);
|
|
1498
1606
|
if (idle && this.d.bus.stateOf(owner) === "busy" && !this.d.bus.queueIds(owner).includes(env.id)) this.sent.set(handOver(task, owner), owner);
|
|
1499
1607
|
}
|
|
@@ -1551,14 +1659,98 @@ export class Tasks {
|
|
|
1551
1659
|
* each such task is offered once an attached peer can take it (peers attach one by one). Only an assignment uses
|
|
1552
1660
|
* up its offer for this hub run; a failed or ownerless offer stays eligible, after approval and on the timer alike.
|
|
1553
1661
|
*/
|
|
1662
|
+
private offerCandidates?: { revision: number; offers: { task: number; peer: PeerId; envelopeId: string; assignmentFlowRevision: number; pullOnly?: boolean }[] };
|
|
1663
|
+
private stallCandidates?: { revision: number; rows: { stall: TaskStall; task: Task; planned: boolean }[] };
|
|
1664
|
+
private stallView?: { key: string; stalls: TaskStall[] };
|
|
1665
|
+
|
|
1666
|
+
pendingOffers(): { task: number; peer: PeerId; envelopeId: string; assignmentFlowRevision: number; pullOnly?: boolean }[] {
|
|
1667
|
+
const revision = this.d.board.statusRevision();
|
|
1668
|
+
if (this.offerCandidates?.revision !== revision) this.offerCandidates = { revision, offers: this.d.board.list("proposed").flatMap(task => {
|
|
1669
|
+
const offer = task.offer;
|
|
1670
|
+
if (!offer || task.owner !== offer.owner || task.history.slice(offer.assignmentFlowRevision).some(h => ["accepted", "declined", "assigned", "reassigned", "escalated", "unassigned", "reserved", "backlog returned"].includes(h.event) || h.owner !== undefined && h.owner !== offer.owner)) return [];
|
|
1671
|
+
return [{ task: task.id, peer: offer.owner, envelopeId: offer.envelopeId, assignmentFlowRevision: offer.assignmentFlowRevision, ...(offer.pullOnly === undefined ? {} : { pullOnly: offer.pullOnly }) }];
|
|
1672
|
+
}) };
|
|
1673
|
+
return this.offerCandidates.offers.filter(offer => {
|
|
1674
|
+
try { return this.d.offerRead?.(offer.peer, offer.envelopeId) !== true; } catch { return true; }
|
|
1675
|
+
}).map(offer => ({ ...offer }));
|
|
1676
|
+
}
|
|
1677
|
+
stalled(): TaskStall[] {
|
|
1678
|
+
const revision = this.d.board.statusRevision();
|
|
1679
|
+
if (this.stallCandidates?.revision !== revision) this.stallCandidates = { revision, rows: this.d.board.stalls().flatMap(stall => {
|
|
1680
|
+
const task = this.d.board.get(stall.task);
|
|
1681
|
+
if (task?.state !== "proposed" || task.owner || (task.planningRevision ?? 0) !== stall.replanRevision) return [];
|
|
1682
|
+
if (stall.cause.kind === "cancelled-dependency") return task.deps?.includes(stall.cause.dependency!) === true && this.d.board.get(stall.cause.dependency!)?.state === "cancelled" ? [{ stall, task, planned: false }] : [];
|
|
1683
|
+
return this.waitsFor(task).length ? [] : [{ stall, task, planned: this.plannerPullRecord(task)?.pull === true }];
|
|
1684
|
+
}) };
|
|
1685
|
+
// Eligibility inputs are live; queue journal writes alone do not re-decode board histories.
|
|
1686
|
+
const key = JSON.stringify([revision, this.states(), this.d.routing(), this.health(), [...this.d.bus.peers.keys()].map(peer => {
|
|
1687
|
+
const queue = this.d.bus.queueSummary(peer);
|
|
1688
|
+
return [peer, this.wipLimit(peer), queue.heldBy, queue.needsReview];
|
|
1689
|
+
})]);
|
|
1690
|
+
if (this.stallView?.key !== key) this.stallView = { key, stalls: this.stallCandidates.rows.filter(({ stall, task, planned }) => stall.cause.kind === "cancelled-dependency" || !(planned ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.health() })).owner).map(({ stall }) => stall) };
|
|
1691
|
+
return structuredClone(this.stallView.stalls);
|
|
1692
|
+
}
|
|
1693
|
+
private ownerStallCause(task: Task, admission: Assignment): TaskStall["cause"] {
|
|
1694
|
+
const refusal = admission.refusals?.find(row => row.role === "owner" && row.peer === task.reserved);
|
|
1695
|
+
return refusal ? { kind: "reservation", peer: refusal.peer, code: refusal.code } : { kind: "no-eligible-peer", code: "unavailable", refusals: admission.refusals?.filter(row => row.role === "owner").map(({ peer, code }) => ({ peer, code })).sort((a, b) => a.peer.localeCompare(b.peer)) ?? [] };
|
|
1696
|
+
}
|
|
1697
|
+
private reportStall(task: Task, cause: TaskStall["cause"], now = Date.now()): void {
|
|
1698
|
+
const stall: TaskStall = { task: task.id, cause, signature: JSON.stringify(cause), replanRevision: task.planningRevision ?? 0, at: now };
|
|
1699
|
+
if (!this.d.board.recordStall(stall)) return;
|
|
1700
|
+
const text = `Task #${task.id} stalled: ${cause.kind}${cause.peer ? ` ${cause.peer}` : ""}${cause.dependency ? ` #${cause.dependency}` : ""} (${cause.code}); configure the runner or peer, ahub task assign ${task.id} <peer>, re-plan dependencies, or cancel it.`;
|
|
1701
|
+
this.releaseNotice(text);
|
|
1702
|
+
try { this.d.stallNotice?.(stall); } catch { /* advisory never changes task authority */ }
|
|
1703
|
+
// Advice joins the planner's next delivery; it must not manufacture a turn beside a withheld backlog batch.
|
|
1704
|
+
for (const [peer, roles] of Object.entries(this.d.roles ?? {})) if (roles.includes("planner") && this.d.bus.isRostered(peer)) this.releaseNotice(`task #${task.id}: stall notice unavailable`, () => this.d.tell?.(peer, noteLine(HUB, "finding", text)));
|
|
1705
|
+
}
|
|
1706
|
+
async cancel(id: unknown, by: PeerId, input: { reason: string; supersededBy?: string; expectedFlowRevision?: number }): Promise<Task> {
|
|
1707
|
+
if (typeof input.reason !== "string" || !input.reason.trim() || Buffer.byteLength(input.reason) > 4000 || input.supersededBy !== undefined && (typeof input.supersededBy !== "string" || !input.supersededBy.trim() || Buffer.byteLength(input.supersededBy) > 1000)) throw new PreEffectToolRefusal("cancellation needs a bounded reason and supersession reference");
|
|
1708
|
+
const validate = () => {
|
|
1709
|
+
if (this.d.cancellationHeld?.() || this.d.bus.isRecoveryHeld) throw new PreEffectToolRefusal("recovery or shutdown is holding cancellation");
|
|
1710
|
+
const task = this.need(id, false, true, true);
|
|
1711
|
+
this.checkFlowRevision(task, input.expectedFlowRevision, true);
|
|
1712
|
+
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
|
|
1713
|
+
if (by !== USER && this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("cancellation on ordinary work is unavailable during a private turn");
|
|
1714
|
+
if (by !== USER) {
|
|
1715
|
+
this.planningAuthority(by);
|
|
1716
|
+
const proposer = task.history.find(h => h.event === "proposed" || h.event === "backlogged")?.by;
|
|
1717
|
+
if (proposer !== by || !["backlog", "proposed"].includes(task.state) || taskHasStarted(task)) throw new PreEffectToolRefusal("planner may cancel only its own historically unaccepted backlog or proposed work");
|
|
1718
|
+
}
|
|
1719
|
+
return task;
|
|
1720
|
+
};
|
|
1721
|
+
const before = validate(), revision = before.history.length;
|
|
1722
|
+
const reasonText = [input.reason, input.supersededBy].filter(Boolean).join("\n");
|
|
1723
|
+
const withheld = looksSecret(reasonText) || this.aboutPrivatePath("", reasonText, before) || await this.withholds("note", reasonText, before);
|
|
1724
|
+
const task = validate();
|
|
1725
|
+
if (task.history.length !== revision) throw new PreEffectToolRefusal("task flow revision changed");
|
|
1726
|
+
const cancelled = { reason: input.reason.trim(), ...(input.supersededBy ? { supersededBy: input.supersededBy.trim() } : {}), flowRevision: revision + 1, ...(withheld ? { withheld: true as const } : {}) };
|
|
1727
|
+
const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
|
|
1728
|
+
this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
|
|
1729
|
+
this.offered.delete(task.id);
|
|
1730
|
+
this.releaseNotice(`task #${task.id}: cancellation stall cleanup unavailable`, () => this.d.board.clearStall(task.id));
|
|
1731
|
+
for (const peer of new Set([task.owner, task.reviewer])) if (peer && peer !== USER) this.releaseNotice(`task #${task.id}: cancellation notice unavailable`, () => this.d.bus.publish(newEnvelope(HUB, sanitize(`Task #${task.id} cancelled. Stop working on it. No native turn or working tree was changed.`), { to: [peer], kind: "task", priority: "important", refs: { task: String(task.id), taskCancelled: cancelled.flowRevision }, ...(this.isPii(next) ? { private: true } : {}) })));
|
|
1732
|
+
this.releaseNotice(`task #${task.id}: cancellation dependent advice unavailable`, () => {
|
|
1733
|
+
for (const dependent of this.d.board.list()) if (!isClosedTaskState(dependent.state) && !dependent.owner && dependent.deps?.includes(task.id)) this.releaseNotice(`task #${dependent.id}: cancellation stall advice unavailable`, () => this.reportStall(dependent, { kind: "cancelled-dependency", dependency: task.id, code: "cancelled" }));
|
|
1734
|
+
});
|
|
1735
|
+
return next;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1554
1738
|
async releaseReady(): Promise<void> {
|
|
1555
1739
|
for (const t of this.d.board.list("proposed")) {
|
|
1740
|
+
if (t.owner) { this.d.board.clearStall(t.id); continue; }
|
|
1741
|
+
const cancelledDependency = t.deps?.find(id => this.d.board.get(id)?.state === "cancelled");
|
|
1742
|
+
if (cancelledDependency) { this.reportStall(t, { kind: "cancelled-dependency", dependency: cancelledDependency, code: "cancelled" }); continue; }
|
|
1556
1743
|
const last = t.history.findLast(entry => !QUIET_EVENTS.has(entry.event))?.event, planned = this.plannerPullRecord(t)?.pull === true;
|
|
1557
|
-
if ((
|
|
1558
|
-
|
|
1744
|
+
if (this.personUnassigned(t) || this.offered.has(t.id) || this.waitsFor(t).length) continue;
|
|
1745
|
+
const admission = planned ? this.plannerAssignment(t, true) : assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.health() });
|
|
1746
|
+
if (!admission.owner) {
|
|
1747
|
+
this.reportStall(t, this.ownerStallCause(t, admission));
|
|
1748
|
+
continue;
|
|
1749
|
+
}
|
|
1750
|
+
this.d.board.clearStall(t.id);
|
|
1751
|
+
if ((!t.deps?.length && !t.history.some(h => h.event === "backlog pulled")) || (!planned && last !== "blocked" && last !== "ready" && last !== "reserved")) continue;
|
|
1559
1752
|
// ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
|
|
1560
1753
|
// an offline one; hold reserved tasks for a grace period if restarts show that up.
|
|
1561
|
-
if (!assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.health() }).owner) continue; // nobody attached can take it yet
|
|
1562
1754
|
await this.offerReady(t, "nothing left to wait for");
|
|
1563
1755
|
}
|
|
1564
1756
|
}
|
|
@@ -1568,7 +1760,7 @@ export class Tasks {
|
|
|
1568
1760
|
let offering = false, undelivered: AssignmentUndeliveredError | undefined;
|
|
1569
1761
|
try {
|
|
1570
1762
|
const t = this.d.board.get(stale.id);
|
|
1571
|
-
if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
|
|
1763
|
+
if (!t || t.state !== "proposed" || t.owner || this.personUnassigned(t) || this.offered.has(t.id)) return;
|
|
1572
1764
|
const planned = this.plannerPullRecord(t)?.pull === true;
|
|
1573
1765
|
const selected = planned ? this.plannerAssignment(t, true).owner : undefined;
|
|
1574
1766
|
if (planned && !selected) return;
|
|
@@ -1673,13 +1865,13 @@ export class Tasks {
|
|
|
1673
1865
|
if (config.mode === "advise" && blockers.length) this.d.notify(`task ${this.publicTitle(task)}: SDLC ${transition} advice: ${this.isPii(task) ? "private lifecycle evidence unknown" : blockers.join("; ")}`);
|
|
1674
1866
|
}
|
|
1675
1867
|
|
|
1676
|
-
private need(id: unknown, open = false, preEffect = false): Task {
|
|
1868
|
+
private need(id: unknown, open = false, preEffect = false, allowBacklog = false): Task {
|
|
1677
1869
|
// Callers are models (#70): an id is a whole number or a digit string, never an array that Number() would accept.
|
|
1678
1870
|
const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
|
|
1679
1871
|
if (!Number.isInteger(n)) throw new (preEffect ? PreEffectToolRefusal : Error)(`id must be a task number, not ${(typeof id === "number" ? String(id) : JSON.stringify(id ?? null)).slice(0, 60)}`);
|
|
1680
1872
|
const task = this.d.board.get(n);
|
|
1681
1873
|
if (!task) throw new (preEffect ? PreEffectToolRefusal : Error)(`no task #${n}`);
|
|
1682
|
-
if (task.state === "backlog") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${n} is backlog: pull it into To Do first`);
|
|
1874
|
+
if (task.state === "backlog" && !allowBacklog) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${n} is backlog: pull it into To Do first`);
|
|
1683
1875
|
// Handing a task to someone else only makes sense while there is work left on it.
|
|
1684
1876
|
if (open && !OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: it can no longer change hands`);
|
|
1685
1877
|
return task;
|
|
@@ -1782,7 +1974,7 @@ export class Tasks {
|
|
|
1782
1974
|
const valid = (): Task => {
|
|
1783
1975
|
const t = this.need(id, false, true);
|
|
1784
1976
|
this.mine(t, by, "owner", true);
|
|
1785
|
-
if (t.state === "in_review" || t.state
|
|
1977
|
+
if (t.state === "in_review" || isClosedTaskState(t.state)) throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
|
|
1786
1978
|
this.ready(t, true);
|
|
1787
1979
|
if (this.checking.has(t.id)) {
|
|
1788
1980
|
// The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
|
|
@@ -1958,7 +2150,7 @@ export class Tasks {
|
|
|
1958
2150
|
task = this.need(task.id, false, true);
|
|
1959
2151
|
this.mine(task, by, "owner", true);
|
|
1960
2152
|
// In every mode: another done may have landed during the readback.
|
|
1961
|
-
if (task.state === "in_review" || task.state
|
|
2153
|
+
if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
|
|
1962
2154
|
reviewer = task.reviewer;
|
|
1963
2155
|
}
|
|
1964
2156
|
const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary, { withheld: withheld || undefined });
|
|
@@ -2026,6 +2218,25 @@ export class Tasks {
|
|
|
2026
2218
|
return lines.length ? lines.join("\n") : undefined;
|
|
2027
2219
|
}
|
|
2028
2220
|
|
|
2221
|
+
private planGuardItems(task: Task): NativeItem[] {
|
|
2222
|
+
if (this.isPii(task)) return [];
|
|
2223
|
+
try {
|
|
2224
|
+
const items: NativeItem[] = [];
|
|
2225
|
+
for (const item of this.d.planFails?.(task) ?? []) {
|
|
2226
|
+
const line = sanitize(item.line.replace(/\s+/g, " ").trim());
|
|
2227
|
+
if (item.kind === "fail" && this.nameable(line) && !items.some(existing => existing.id === item.id)) items.push({ ...item, line });
|
|
2228
|
+
if (items.length === 8) break;
|
|
2229
|
+
}
|
|
2230
|
+
return items;
|
|
2231
|
+
} catch { return []; } // Optional memory cannot refuse an assignment or review.
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
/** Current plan advice for an accepted plan's tool result, which has no new assignment envelope. */
|
|
2235
|
+
planGuardText(task: Task): string {
|
|
2236
|
+
const items = this.planGuardItems(task);
|
|
2237
|
+
return items.length ? ["Live fail notes for this plan (advice, not a block):", ...items.map(item => item.line)].join("\n") : "";
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2029
2240
|
/** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
|
|
2030
2241
|
private sendReview(task: Task, reviewer: PeerId, why = ""): void {
|
|
2031
2242
|
const r = task.refs;
|
|
@@ -2036,6 +2247,7 @@ export class Tasks {
|
|
|
2036
2247
|
const check = [...task.history].reverse().find((h) => h.event === "check passed");
|
|
2037
2248
|
const checklist = [
|
|
2038
2249
|
"Checklist:",
|
|
2250
|
+
...this.planGuardItems(task).map(item => `- Check the plan against this live fail before the remaining checks: ${item.line}`),
|
|
2039
2251
|
`- Map the changed signatures and call sites to ${plan ? `the plan (${plan})` : task.detail ? "the task detail above" : "the task title"}, and name each one that does not match.`,
|
|
2040
2252
|
`- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
|
|
2041
2253
|
`- List what is unmet in hub_review's unmet, one item each.`,
|
|
@@ -2068,6 +2280,17 @@ export class Tasks {
|
|
|
2068
2280
|
reviewedHead = checked.reviewedHead;
|
|
2069
2281
|
}
|
|
2070
2282
|
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead); task = valid(); }
|
|
2283
|
+
let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
|
|
2284
|
+
if (verdict === "changes_requested" && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|
|
2285
|
+
const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
|
|
2286
|
+
cause: "unknown: review unmet items do not establish a cause", retryWhen: "unknown: a person must review the unmet items before accepting",
|
|
2287
|
+
evidenceRefs: [`task:${task.id}`, `reviewed-head:${reviewedHead ?? task.refs.commit ?? "unknown"}`] };
|
|
2288
|
+
const context = JSON.stringify({ title: task.title, detail: task.detail, refs: task.refs, plan: task.plan, signals: task.signals });
|
|
2289
|
+
const payload = JSON.stringify({ input, note, context });
|
|
2290
|
+
const cleared = this.candidateTextAllowed(payload, task) && !await this.withholds("review note", payload, task);
|
|
2291
|
+
task = valid();
|
|
2292
|
+
if (cleared && context === JSON.stringify({ title: task.title, detail: task.detail, refs: task.refs, plan: task.plan, signals: task.signals })) candidate = input; // Async screening cannot authorize a verdict under an obsolete role/state.
|
|
2293
|
+
}
|
|
2071
2294
|
const pii = this.isPii(task);
|
|
2072
2295
|
const flag = { withheld: withheld || undefined };
|
|
2073
2296
|
if (verdict === "approved") {
|
|
@@ -2088,6 +2311,9 @@ export class Tasks {
|
|
|
2088
2311
|
return next;
|
|
2089
2312
|
}
|
|
2090
2313
|
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note, flag);
|
|
2314
|
+
if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
|
|
2315
|
+
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 */ }
|
|
2316
|
+
}
|
|
2091
2317
|
this.cohorts.withdraw(rejected.id); // open again: its done intent no longer counts (issue #107)
|
|
2092
2318
|
if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
|
|
2093
2319
|
this.contradict(rejected);
|
|
@@ -2128,13 +2354,16 @@ export class Tasks {
|
|
|
2128
2354
|
if (from && by !== HUB) this.d.board.recordOutcome(from, task.class, false);
|
|
2129
2355
|
// Only a reviewer that asked for changes on this work saw it fail: not an escalation of unreviewed work (a Pi failure).
|
|
2130
2356
|
if (from && task.reviewer && task.reviewer !== USER && this.requestedChanges(task).has(task.reviewer)) this.d.board.recordReview({ implementer: from, reviewer: task.reviewer, class: task.class, kind: "escalated", task: task.id });
|
|
2131
|
-
|
|
2132
|
-
|
|
2357
|
+
const stopFormerOwner = (moved: Task) => {
|
|
2358
|
+
if (from && moved.owner && moved.owner !== from) this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${from} it moved to ${moved.owner}`, () =>
|
|
2359
|
+
this.tell({ ...moved, owner: from }, `Task #${moved.id} moved to ${moved.owner} (${why}). Stop working on it.`, this.isPii(moved), "withdrawal"));
|
|
2360
|
+
};
|
|
2361
|
+
// Only our landed move authorizes a success notice and decision note. The former owner's stop
|
|
2362
|
+
// message still names the current owner when a later assignment superseded this move.
|
|
2133
2363
|
const tail = (moved: Task) => {
|
|
2134
2364
|
this.releaseNotice(`task ${this.publicTitle(moved)} escalated from ${from} to ${moved.owner} (${why})`);
|
|
2135
2365
|
this.note(moved, by, "decision", `Task #${moved.id} escalated from ${from} to ${moved.owner}: ${why}`);
|
|
2136
|
-
|
|
2137
|
-
this.tell({ ...moved, owner: from }, `Task #${moved.id} moved to ${moved.owner} (${why}). Stop working on it.`, this.isPii(moved)));
|
|
2366
|
+
stopFormerOwner(moved);
|
|
2138
2367
|
};
|
|
2139
2368
|
let next: Task;
|
|
2140
2369
|
try {
|
|
@@ -2143,52 +2372,93 @@ export class Tasks {
|
|
|
2143
2372
|
// A saved move whose delivery failed arrives as AssignmentUndeliveredError carrying the saved task (#297);
|
|
2144
2373
|
// the tail still runs on it (its parts are guarded), and the caller still gets the throw. Anything before
|
|
2145
2374
|
// the write gets neither.
|
|
2146
|
-
if (e instanceof AssignmentUndeliveredError) tail(e.task);
|
|
2375
|
+
if (e instanceof AssignmentUndeliveredError && this.ownOwnerMove(task, e.task, by, "escalated")) tail(e.task);
|
|
2147
2376
|
throw e;
|
|
2148
2377
|
}
|
|
2149
2378
|
if (next.owner && next.owner !== from) {
|
|
2150
2379
|
// The move is on the board (#243): review() reaches here after its changes_requested write.
|
|
2151
|
-
tail(next);
|
|
2380
|
+
if (this.ownOwnerMove(task, next, by, "escalated")) tail(next);
|
|
2381
|
+
else stopFormerOwner(next);
|
|
2152
2382
|
} else this.releaseNotice(`task ${this.publicTitle(task)}: escalation found nobody in [${list.join(", ")}]; it stays with ${from ?? "nobody"}`);
|
|
2153
2383
|
return this.d.board.get(next.id)!;
|
|
2154
2384
|
}
|
|
2155
2385
|
|
|
2386
|
+
/** Withdraw only a former role's queued request, then tell it that its authority changed. */
|
|
2387
|
+
private withdrawRoleRequest(previous: Task, next: Task, role: TaskAssignmentRole): void {
|
|
2388
|
+
const peer = previous[role];
|
|
2389
|
+
if (!peer || peer === next[role]) return;
|
|
2390
|
+
const kind = role === "owner" ? "task" : "review";
|
|
2391
|
+
this.releaseNotice(`task ${this.publicTitle(next)}: could not notify previous ${role} ${peer} that its request is withdrawn`, () => {
|
|
2392
|
+
const matches = (env: Envelope) => (role === "reviewer" || env.body.startsWith(`Task #${previous.id} [`)) && env.from === HUB && env.kind === kind && env.refs?.task === String(previous.id);
|
|
2393
|
+
const assignment = previous.history.map(entry => OWNERSHIP_EVENTS.has(entry.event)).lastIndexOf(true);
|
|
2394
|
+
// A self-claim also records accepted, after assigned. State can belong to an earlier owner.
|
|
2395
|
+
const acceptance = previous.history.slice(assignment + 1).some(entry => entry.event === "accepted" && entry.by === peer);
|
|
2396
|
+
const boardEvidence = role === "owner" && acceptance;
|
|
2397
|
+
const mayHaveReceived = boardEvidence || this.d.bus.lastDeliveredTo(peer).some(matches) || this.d.bus.queueList(peer).some(row => ["dispatching", "accepted", "needs_review", "completed"].includes(row.state) && row.originals.some(matches));
|
|
2398
|
+
for (const env of this.d.bus.snapshot(false).queues[peer] ?? []) if (matches(env)) this.d.bus.withdraw(env.id);
|
|
2399
|
+
if (!mayHaveReceived) return;
|
|
2400
|
+
const body = role === "owner"
|
|
2401
|
+
? `Task ${this.publicTitle(next)} is no longer assigned to you${next.owner ? `; its owner is now ${next.owner}` : "; it is unassigned"}. Do not accept or complete it; late calls under your former assignment are refused.`
|
|
2402
|
+
: `Review request for task #${previous.id} ${this.publicTitle(next)} is withdrawn: ${next.reviewer ? `its reviewer is now ${next.reviewer}` : "the task has no reviewer"}. Do not review this task; a late verdict from the previous reviewer is refused.`;
|
|
2403
|
+
if (peer === USER) this.d.notify(body);
|
|
2404
|
+
// An untagged task notice remains needed by a former role until approval; kind review would look moved already.
|
|
2405
|
+
else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task", priority: "important", refs: { task: String(previous.id) }, ...(this.isPii(next) ? { private: true } : {}) }));
|
|
2406
|
+
});
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2156
2409
|
/** Caller authority is checked by the daemon; keep the real actor in task history. */
|
|
2157
|
-
async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner"): Promise<Task> {
|
|
2410
|
+
async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner", expectedFlowRevision?: number): Promise<Task> {
|
|
2158
2411
|
if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or reviewer");
|
|
2159
2412
|
if (role === "reviewer" && by !== USER) {
|
|
2160
2413
|
const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
2161
2414
|
if (conductors.length !== 1 || conductors[0] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the configured conductor may name a reviewer");
|
|
2162
2415
|
}
|
|
2163
2416
|
const task = this.need(id, role === "owner", preEffect);
|
|
2417
|
+
this.checkFlowRevision(task, expectedFlowRevision, preEffect);
|
|
2164
2418
|
if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster; add it to the roster before assigning work`);
|
|
2419
|
+
this.requireCapabilities(task, peer, role, preEffect);
|
|
2165
2420
|
if (role === "reviewer") {
|
|
2166
2421
|
if (!OPEN.includes(task.state) && task.state !== "in_review") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its reviewer can no longer change`);
|
|
2167
2422
|
this.ready(task, preEffect);
|
|
2168
2423
|
const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
|
|
2169
2424
|
if (a.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot name ${peer} as reviewer: ${a.trace.filter(line => line.includes("reviewer") || line.includes("pii")).join("; ")}`);
|
|
2170
|
-
const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, `named reviewer ${peer}`, { reason: "manual" });
|
|
2425
|
+
const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, `named reviewer ${peer}`, { reason: "manual" }, expectedFlowRevision);
|
|
2171
2426
|
if (next.state === "in_review" && task.reviewer && task.reviewer !== peer) {
|
|
2172
|
-
|
|
2173
|
-
this.releaseNotice(`task ${this.publicTitle(next)}: could not notify previous reviewer ${previous} that its review request is withdrawn`, () => {
|
|
2174
|
-
for (const env of this.d.bus.snapshot(false).queues[previous] ?? []) {
|
|
2175
|
-
if (env.from === HUB && env.kind === "review" && env.refs?.task === String(task.id)) this.d.bus.withdraw(env.id);
|
|
2176
|
-
}
|
|
2177
|
-
const body = `Review request for task #${task.id} ${this.publicTitle(next)} is withdrawn: its reviewer is now ${peer}. Do not review this task; a late verdict from the previous reviewer is refused.`;
|
|
2178
|
-
if (previous === USER) this.d.notify(body);
|
|
2179
|
-
else this.d.bus.publish(newEnvelope(HUB, body, { to: [previous], kind: "status", priority: "important", refs: { task: String(task.id) }, ...(this.isPii(next) ? { private: true } : {}) }));
|
|
2180
|
-
});
|
|
2427
|
+
this.withdrawRoleRequest(task, next, "reviewer");
|
|
2181
2428
|
}
|
|
2182
2429
|
if (next.state === "in_review" && peer !== USER) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${peer}`, () => this.sendReview(next, peer));
|
|
2183
2430
|
return next;
|
|
2184
2431
|
}
|
|
2185
2432
|
// Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
|
|
2186
|
-
if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], namedOwner: peer, event: "reassigned", reason: "manual", override: by === USER });
|
|
2187
|
-
const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" });
|
|
2433
|
+
if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], namedOwner: peer, event: "reassigned", reason: "manual", override: by === USER, expectedFlowRevision });
|
|
2434
|
+
const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" }, expectedFlowRevision);
|
|
2188
2435
|
this.releaseNotice(`task ${this.publicTitle(next)} reserved for ${peer} by ${by}; it is offered to ${peer} first once what it waits for is approved`);
|
|
2189
2436
|
return next;
|
|
2190
2437
|
}
|
|
2191
2438
|
|
|
2439
|
+
/** Person-only removal without routing a replacement. The history marker suppresses later automatic owner offers. */
|
|
2440
|
+
async unassign(id: unknown, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner", expectedFlowRevision?: number): Promise<Task> {
|
|
2441
|
+
if (by !== USER) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user may unassign a task role");
|
|
2442
|
+
if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or reviewer");
|
|
2443
|
+
const task = this.need(id, false, preEffect);
|
|
2444
|
+
this.checkFlowRevision(task, expectedFlowRevision, preEffect);
|
|
2445
|
+
if (isClosedTaskState(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its ${role} is historical`);
|
|
2446
|
+
if (role === "owner") {
|
|
2447
|
+
if (!OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its owner cannot be unassigned`);
|
|
2448
|
+
if (!task.owner && !task.reserved) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: no owner or owner reservation to unassign`);
|
|
2449
|
+
const next = this.d.board.update(task.id, by, "unassigned", { owner: null, reserved: null, ...(task.state === "proposed" ? {} : { state: "proposed" as const }) }, `person unassigned ${[task.owner, task.reserved].filter(Boolean).join(" and ")}`, { reason: "manual" }, expectedFlowRevision);
|
|
2450
|
+
this.cohorts.leave(task.id);
|
|
2451
|
+
this.withdrawRoleRequest(task, next, "owner");
|
|
2452
|
+
return next;
|
|
2453
|
+
}
|
|
2454
|
+
if (!OPEN.includes(task.state) && task.state !== "in_review") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its reviewer cannot be unassigned`);
|
|
2455
|
+
if (!task.reviewer) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: no reviewer to unassign`);
|
|
2456
|
+
if (task.reviewer === USER && this.isPii(task)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: the human reviewer is required for PII work`);
|
|
2457
|
+
const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: null }, `person unassigned reviewer ${task.reviewer}`, { reason: "manual" }, expectedFlowRevision);
|
|
2458
|
+
this.withdrawRoleRequest(task, next, "reviewer");
|
|
2459
|
+
return next;
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2192
2462
|
/**
|
|
2193
2463
|
* Budget relay: a paused peer's open work moves on. `local` first (it has no quota), then the class list, all through
|
|
2194
2464
|
* the usual constraints. Tasks it was reviewing get another reviewer. Moves are reported, never taken back automatically.
|
|
@@ -2200,7 +2470,7 @@ export class Tasks {
|
|
|
2200
2470
|
/** Read-only preview using the same assignment constraints as the roster relay. */
|
|
2201
2471
|
rosterPreview(peer: PeerId): { id: number; title: string; role: "owner" | "reviewer"; to: PeerId | null }[] {
|
|
2202
2472
|
const routing = this.d.routing();
|
|
2203
|
-
return this.d.board.list().filter(task => task.state
|
|
2473
|
+
return this.d.board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)).map(task => {
|
|
2204
2474
|
const owner = task.owner === peer;
|
|
2205
2475
|
const weights = this.weights(task.class);
|
|
2206
2476
|
const pii = this.isPii(task);
|
|
@@ -2239,13 +2509,14 @@ export class Tasks {
|
|
|
2239
2509
|
if (!(e instanceof AssignmentUndeliveredError)) throw e;
|
|
2240
2510
|
next = e.task;
|
|
2241
2511
|
}
|
|
2512
|
+
if (!this.ownOwnerMove(back, next, HUB, "reassigned") && !this.ownOwnerMove(back, next, HUB, "unassigned")) continue;
|
|
2242
2513
|
if (roster) {
|
|
2243
2514
|
if (!next.owner || next.owner === peer) continue;
|
|
2244
2515
|
this.releaseNotice(`task ${this.publicTitle(next)} moved from ${peer} to ${next.owner} (off roster)`);
|
|
2245
2516
|
if (next.state === "in_review" && next.reviewer && next.reviewer !== task.reviewer && next.reviewer !== USER) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${next.reviewer}`, () => this.sendReview(next, next.reviewer!, `(${peer} owned this and is off roster.)`));
|
|
2246
2517
|
}
|
|
2247
2518
|
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
2248
|
-
} else if (task.reviewer === peer && task.state
|
|
2519
|
+
} else if (task.reviewer === peer && !isClosedTaskState(task.state)) {
|
|
2249
2520
|
const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
|
|
2250
2521
|
const reviewer = a.reviewer && a.reviewer !== peer && a.reviewer !== task.owner ? a.reviewer : null;
|
|
2251
2522
|
if (roster && !reviewer) {
|
|
@@ -2274,9 +2545,33 @@ export class Tasks {
|
|
|
2274
2545
|
return { ...(policy?.route ? { route: policy.route } : {}), ...(policy?.fixed_model ? { fixedModel: policy.fixed_model } : {}), pii, task: String(about.id) };
|
|
2275
2546
|
}
|
|
2276
2547
|
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2548
|
+
private candidateTextAllowed(text: string, task?: Task): boolean {
|
|
2549
|
+
return (!task || !this.isPii(task)) && this.nameable(text) && !looksSecret(text) && !this.aboutPrivatePath("", text, task);
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
/** Candidate only: the person remains the sole acceptance authority. */
|
|
2553
|
+
async correct(by: PeerId, input: unknown): Promise<string> {
|
|
2554
|
+
const args = correctionInput(input);
|
|
2555
|
+
const st = this.d.store;
|
|
2556
|
+
if (!st || !this.d.candidates) throw new PreEffectToolRefusal("memory correction unavailable");
|
|
2557
|
+
const screen: MemoryScreen = rows => this.d.candidateScreen?.(rows) ?? withoutPii(rows, this.d.routing(), this.d.cwd, this.d.piiPaths ?? []);
|
|
2558
|
+
const valid = () => {
|
|
2559
|
+
this.d.memoryCheck?.();
|
|
2560
|
+
if (this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("memory correction unavailable during a private turn");
|
|
2561
|
+
if (!this.candidateTextAllowed(JSON.stringify(args))) throw new PreEffectToolRefusal("memory correction withheld by privacy policy");
|
|
2562
|
+
return resolveVisibleMemory(st.store, st.project, screen, args.id);
|
|
2563
|
+
};
|
|
2564
|
+
const target = valid(), revision = candidateTargetRevision(target);
|
|
2565
|
+
if (await this.withholds("note", `${args.text}\n${args.reason}`)) throw new PreEffectToolRefusal("memory correction withheld by privacy policy");
|
|
2566
|
+
const current = valid();
|
|
2567
|
+
if (current.id !== target.id || candidateTargetRevision(current) !== revision) throw new PreEffectToolRefusal("memory correction target changed; read it again");
|
|
2568
|
+
const id = this.d.candidates.put({ project: st.project, kind: "correction", target: { id: current.id, revision }, proposedText: args.text, reason: args.reason,
|
|
2569
|
+
...(current.kind === "decision" || current.kind === "contract" || current.kind === "fail" ? { noteKind: current.kind } : {}) }, "public");
|
|
2570
|
+
return `candidate:${id}; a person reviews it with amem review; memory was not edited`;
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
async remember(by: PeerId, input: { text?: string; title?: string; kind?: string; task?: unknown; horizon?: unknown; scope?: unknown; approach?: unknown; symptom?: unknown; cause?: unknown; evidence?: unknown; retry_when?: unknown }): Promise<string> {
|
|
2574
|
+
const text = composeRememberText(input);
|
|
2280
2575
|
const task = input.task !== undefined ? this.d.board.get(Number(input.task)) : undefined;
|
|
2281
2576
|
if (task && this.isPii(task)) throw new Error("notes about a PII task are not saved: claude-mem processes what it stores with a cloud model");
|
|
2282
2577
|
// Shared with every other peer as well, so text that matches a PII pattern is refused like a note about a PII task.
|
|
@@ -2296,7 +2591,8 @@ export class Tasks {
|
|
|
2296
2591
|
if (!res && !native) return this.d.memory ? "memory worker unavailable; nothing saved" : "memory store unavailable; nothing saved";
|
|
2297
2592
|
const line = noteLine(by, kind, title ? `${title}: ${text}` : text);
|
|
2298
2593
|
this.d.notify(line);
|
|
2299
|
-
this.
|
|
2594
|
+
// Display identity only: a queued recall may already contain this saved entry. Stored/captured text stays unchanged.
|
|
2595
|
+
this.d.share?.(by, native ? noteLine(by, kind, `mem:${native.id} ${title ? `${title}: ` : ""}${text}`) : line);
|
|
2300
2596
|
return `saved to shared memory${native ? ` (hub memory ${native.id.slice(0, 8)}, ${native.tier}-term${this.d.memory && !res ? "; claude-mem unavailable" : ""})` : ""}; the other agents get it with their next message`;
|
|
2301
2597
|
}
|
|
2302
2598
|
|
|
@@ -2309,6 +2605,8 @@ export class Tasks {
|
|
|
2309
2605
|
*/
|
|
2310
2606
|
private goalFor(by: PeerId, ref: unknown): GoalView {
|
|
2311
2607
|
if (!this.d.store) throw new PreEffectToolRefusal("goal: hub memory is off for this project");
|
|
2608
|
+
try { this.d.memoryCheck?.(); }
|
|
2609
|
+
catch { throw new PreEffectToolRefusal("goal: memory profile unavailable; inspect the selector and generation state before linking a long-term task"); }
|
|
2312
2610
|
const g = typeof ref === "string" && ref.trim() ? this.d.store.store.goalOf(ref.trim().replace(/^goal:/, "")) : undefined;
|
|
2313
2611
|
if (!g) throw new PreEffectToolRefusal(`goal: no long-term task ${JSON.stringify(ref).slice(0, 40)} (ahub goal list shows them)`);
|
|
2314
2612
|
if (by !== USER && g.project !== this.d.store.project) throw new PreEffectToolRefusal("goal: that long-term task belongs to another project; linking across projects is a person's");
|
|
@@ -2322,7 +2620,7 @@ export class Tasks {
|
|
|
2322
2620
|
/** Record a long-term link, never for PII; failures remain notices. */
|
|
2323
2621
|
private linkNative(task: Task, goal: GoalView, by: PeerId): string {
|
|
2324
2622
|
if (this.isPii(task)) { this.releaseNotice(`task #${task.id} is not linked to a long-term task: nothing about a PII task enters hub memory`); return "not linked (PII task)"; }
|
|
2325
|
-
try { this.markMemoryReference(task, by); this.d.store!.store.goalLink(goal.id, this.memoryKey(task), by); return `part of long-term task ${goal.id.slice(0, 8)}`; }
|
|
2623
|
+
try { this.d.memoryCheck?.(); this.markMemoryReference(task, by); this.d.store!.store.goalLink(goal.id, this.memoryKey(task), by); return `part of long-term task ${goal.id.slice(0, 8)}`; }
|
|
2326
2624
|
catch (e) { this.releaseNotice(`task #${task.id}: the link to long-term task ${goal.id.slice(0, 8)} was not saved (${(e as Error).message})`); return "not linked"; }
|
|
2327
2625
|
}
|
|
2328
2626
|
/** A person links a board task of this project to a long-term task (`ahub goal link`). */
|
|
@@ -2342,6 +2640,7 @@ export class Tasks {
|
|
|
2342
2640
|
const st = this.d.store;
|
|
2343
2641
|
if (!st || (task && this.isPii(task))) return undefined;
|
|
2344
2642
|
try { // never throws: callers run after a board write
|
|
2643
|
+
this.d.memoryCheck?.();
|
|
2345
2644
|
// Approved, or settled in the store already: the snapshot the caller holds can predate an approval made while it awaited.
|
|
2346
2645
|
const approved = !!task && (task.state === "approved" || st.store.settled(this.memoryKey(task)));
|
|
2347
2646
|
const tier: Tier = horizon ?? (approved && PROMOTED_KINDS.includes(kind) ? "long" : "short");
|
|
@@ -2369,7 +2668,7 @@ export class Tasks {
|
|
|
2369
2668
|
let n = 0;
|
|
2370
2669
|
for (const t of this.d.board.list("approved")) {
|
|
2371
2670
|
if (this.isPii(t)) continue;
|
|
2372
|
-
try { const key = this.memoryKey(t); if (this.d.store.store.hasTask(key) && !this.d.store.store.settled(key)) { this.settleMemory(t); n++; } }
|
|
2671
|
+
try { this.d.memoryCheck?.(); const key = this.memoryKey(t); if (this.d.store.store.hasTask(key) && !this.d.store.store.settled(key)) { this.settleMemory(t); n++; } }
|
|
2373
2672
|
catch { /* fail-open: tried again at the next start */ }
|
|
2374
2673
|
}
|
|
2375
2674
|
return n;
|
|
@@ -2378,7 +2677,7 @@ export class Tasks {
|
|
|
2378
2677
|
private settleMemory(task: Task): void {
|
|
2379
2678
|
if (!this.d.store || this.isPii(task)) return;
|
|
2380
2679
|
// The store screens each note as it moves to the synced log (the promotion screen the daemon gives it).
|
|
2381
|
-
try { this.d.store.store.settle(this.memoryKey(task), task.reviewer ?? task.owner ?? HUB); }
|
|
2680
|
+
try { this.d.memoryCheck?.(); this.d.store.store.settle(this.memoryKey(task), task.reviewer ?? task.owner ?? HUB); }
|
|
2382
2681
|
catch (e) { this.releaseNotice(`task #${task.id}: hub memory could not settle its notes (${(e as Error).message})`); }
|
|
2383
2682
|
}
|
|
2384
2683
|
|
|
@@ -2410,9 +2709,9 @@ export class Tasks {
|
|
|
2410
2709
|
}
|
|
2411
2710
|
}
|
|
2412
2711
|
|
|
2413
|
-
private tell(task: Task, body: string, pii: boolean): void {
|
|
2712
|
+
private tell(task: Task, body: string, pii: boolean, audience: "owner" | "withdrawal" = "owner"): void {
|
|
2414
2713
|
if (!task.owner || task.owner === USER) return;
|
|
2415
|
-
this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner], kind: "task", priority: "important", refs: { task: String(task.id) }, ...(pii ? { private: true } : {}) }));
|
|
2714
|
+
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 } : {}) }));
|
|
2416
2715
|
}
|
|
2417
2716
|
}
|
|
2418
2717
|
|