@staix/agent-hub 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +23 -9
- package/docs/agent-notes/README.md +8 -0
- package/docs/agent-notes/adapters.md +21 -4
- package/docs/agent-notes/budget.md +6 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +20 -9
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +21 -4
- package/docs/agent-notes/tests.md +6 -3
- package/docs/dogfood.md +19 -5
- package/docs/events.md +21 -7
- package/docs/memory.md +78 -10
- package/docs/operations.md +197 -39
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +16 -6
- package/docs/sdlc.md +17 -0
- package/docs/security.md +110 -21
- package/docs/smoke.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +156 -16
- package/docs/verification/2026-10-23-0.15.0.md +80 -0
- package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
- package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
- package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
- package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
- package/docs/verification/2026-10-24-amem-runtime.md +69 -0
- package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
- package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
- package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
- package/docs/verification/2026-10-25-0.16.0.md +82 -0
- package/docs/verified.json +40 -19
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3109 -2046
- package/src/adapters/acp.ts +46 -7
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/claude-implementer.ts +243 -0
- package/src/adapters/claude-print-child.ts +90 -0
- package/src/adapters/claude-task-sessions.ts +80 -0
- package/src/adapters/codex-appserver.ts +69 -1
- package/src/adapters/compat.ts +6 -2
- package/src/adapters/conformance/enrollment.ts +2 -1
- package/src/adapters/conformance/fakes/acp-server.ts +3 -1
- package/src/adapters/definitions/builtin-metadata.ts +17 -5
- package/src/adapters/definitions/native-channel-state.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/kimi-wire-usage.ts +303 -0
- package/src/adapters/local-worker.ts +5 -4
- package/src/adapters/registry-types.ts +8 -4
- package/src/adapters/registry.ts +14 -2
- package/src/adapters/reviewer-print.ts +30 -53
- package/src/cli/console-config.ts +123 -0
- package/src/cli/console-state.ts +48 -3
- package/src/cli/console-symbols.ts +3 -2
- package/src/cli/console-usage.ts +5 -2
- package/src/cli/console.ts +30 -2
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +10 -7
- package/src/cli/identity.ts +3 -1
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +91 -19
- package/src/cli/memory.ts +119 -7
- package/src/cli/output.ts +40 -4
- package/src/cli/reset.ts +1 -1
- package/src/cli/settings-edit.ts +89 -0
- package/src/cli/terminal-recovery.ts +1 -6
- package/src/hub/action-class.ts +211 -17
- package/src/hub/action-enforce.ts +90 -0
- package/src/hub/action-policy.ts +40 -17
- package/src/hub/ask.ts +16 -4
- package/src/hub/board.ts +110 -31
- package/src/hub/budget-notes.ts +65 -0
- package/src/hub/budget.ts +44 -7
- package/src/hub/bus.ts +6 -6
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/code-facts.ts +56 -16
- package/src/hub/conductor.ts +5 -2
- package/src/hub/config-apply.ts +12 -0
- package/src/hub/config-edit-host.ts +173 -0
- package/src/hub/config-edit.ts +282 -0
- package/src/hub/config-tier.ts +89 -0
- package/src/hub/control-client.ts +24 -3
- package/src/hub/daemon.ts +614 -104
- package/src/hub/deliverable-files.ts +131 -0
- package/src/hub/deliverables.ts +125 -0
- package/src/hub/delivery-journal.ts +18 -2
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +5 -3
- package/src/hub/hub-tools.ts +22 -6
- package/src/hub/instances.ts +7 -4
- package/src/hub/kimi-usage.ts +156 -0
- package/src/hub/lifecycle.ts +10 -5
- package/src/hub/manager.ts +1 -1
- package/src/hub/memory-code-head.ts +44 -0
- package/src/hub/memory-code-overlay.ts +119 -0
- package/src/hub/memory-plan-guard.ts +10 -1
- package/src/hub/memory-stale-anchors.ts +159 -0
- package/src/hub/memory-symbols.ts +74 -0
- package/src/hub/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/planning.ts +10 -4
- package/src/hub/read-tools.ts +21 -3
- package/src/hub/routing.ts +118 -3
- package/src/hub/sdlc.ts +34 -0
- package/src/hub/settings.ts +18 -3
- package/src/hub/settled-roles.ts +15 -3
- package/src/hub/task-sweep.ts +25 -3
- package/src/hub/tasks.ts +551 -75
- package/src/hub/token-window.ts +46 -0
- package/src/hub/usage-rollup.ts +16 -6
- package/src/hub/window-kind.ts +13 -0
- package/src/hub/workflow.ts +16 -0
- package/src/local/tools.ts +5 -5
- package/src/memory/brief.ts +22 -12
- package/src/memory/capture.ts +26 -40
- package/src/memory/client.ts +137 -65
- package/src/memory/recall.ts +13 -22
- package/src/ui/index.html +215 -19
package/src/hub/tasks.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { canonicalMemorySymbolNode, normalizeMemorySymbolInput, MEMORY_SYMBOL_LIMIT, type MemorySymbolNodes } from "./memory-symbols.ts";
|
|
1
2
|
import { buildTaskCodeSymbols, publicTaskCodeSymbols, taskCodeSymbolsDisjoint, HOST_CODE_SYMBOLS } from "./task-code-symbols.ts";
|
|
2
3
|
import { normalizePlanSymbol, symbolsOverlap } from "./symbol-normalizer.ts";
|
|
3
4
|
import { publicTestIntegrity, readTestIntegrity, testIntegrityBlockers, type TestIntegrity } from "./test-integrity.ts";
|
|
@@ -21,12 +22,14 @@ import { correctionInput, resolveVisibleMemory } from "./memory-candidates.ts";
|
|
|
21
22
|
import type { MemoryScreen } from "./read-tools.ts";
|
|
22
23
|
import { withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
23
24
|
import { profileClass, profileData, profileTaskLabel, projectProfile, type ProfileName, type ProfileSelection, type ProjectProfile } from "./profiles.ts";
|
|
24
|
-
import { criteriaView, workflowHash } from "./workflow.ts";
|
|
25
|
+
import { criteriaView, unmetCriteriaIds, workflowHash } from "./workflow.ts";
|
|
26
|
+
import { approvedDeliverables, latestDeliverables, parseDeliverableClaims, publicDeliverables, type DeliverableRecord, type DeliverableStatus } from "./deliverables.ts";
|
|
27
|
+
import { checkDeliverables, DeliverableRefusal, hashDeliverables, vetDeliverables, type DeliverablePorts } from "./deliverable-files.ts";
|
|
25
28
|
import { isAbsolute, relative } from "node:path";
|
|
26
29
|
import type { Briefs, NativeItem } from "../memory/brief.ts";
|
|
27
30
|
import type { MemoryClient } from "../memory/client.ts";
|
|
28
31
|
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";
|
|
29
|
-
import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskCanPlan, taskHasWorkEvidence, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type ReviewerHistory, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
|
|
32
|
+
import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskCanPlan, taskHasWorkEvidence, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type ReviewerHistory, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs, type PlannerBinding } from "./board.ts";
|
|
30
33
|
import type { Bus } from "./bus.ts";
|
|
31
34
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
32
35
|
import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
|
|
@@ -41,9 +44,9 @@ import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief
|
|
|
41
44
|
import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
|
|
42
45
|
import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
43
46
|
import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
44
|
-
import { linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
|
|
47
|
+
import { planningIssueReceipt, linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
|
|
45
48
|
|
|
46
|
-
export type TaskAssignmentRole = "owner" | "reviewer";
|
|
49
|
+
export type TaskAssignmentRole = "owner" | "reviewer" | "planner";
|
|
47
50
|
|
|
48
51
|
export interface ReleaseWindow { at: number; host: boolean; quota: boolean; idle: boolean; reasons: string[]; current?: () => boolean; close?: () => Promise<void>; }
|
|
49
52
|
|
|
@@ -54,6 +57,8 @@ export interface TasksDeps {
|
|
|
54
57
|
/** Effective per-peer planner capacity, taken from the existing settings loader. */
|
|
55
58
|
wipLimit?: (peer: PeerId) => number;
|
|
56
59
|
recordPlanning?: (change: { by: string; task?: number; release?: number; fields: string[]; revision: number }) => void;
|
|
60
|
+
/** The clock of the planner board-changed notice window (#758); tests inject one. Absent: `Date.now`. */
|
|
61
|
+
now?: () => number;
|
|
57
62
|
board: Board;
|
|
58
63
|
bus: Bus;
|
|
59
64
|
routing: () => Routing;
|
|
@@ -66,6 +71,13 @@ export interface TasksDeps {
|
|
|
66
71
|
profile?: ProfileName;
|
|
67
72
|
profileData?: ProjectProfile;
|
|
68
73
|
piiPaths?: string[];
|
|
74
|
+
/** The project's secrets denylist (`local.deny`), for the deliverable file reader (#509a). */
|
|
75
|
+
deny?: string[];
|
|
76
|
+
/**
|
|
77
|
+
* #509a: the root a peer's files live under: its verified execution root (the daemon's `ownerWriteRoot`), which for a declared worktree
|
|
78
|
+
* instance is not the project checkout. `undefined` means the root is not proven right now. Absent (library use): the project root.
|
|
79
|
+
*/
|
|
80
|
+
executionRoot?: (peer: PeerId) => string | undefined;
|
|
69
81
|
catalog?: RoleCatalog;
|
|
70
82
|
/** Role ids whose contract was prepared for the current native session. */
|
|
71
83
|
sessionRoles?: (peer: PeerId) => string[];
|
|
@@ -78,6 +90,8 @@ export interface TasksDeps {
|
|
|
78
90
|
/** #520: the review brief for a task's commit (git only, bounded); undefined when the task names no commit. */
|
|
79
91
|
prepareReviewBrief?: (task: Task) => Promise<void>;
|
|
80
92
|
reviewBrief?: (task: Task) => ReviewBrief | { unavailable: string } | undefined;
|
|
93
|
+
/** Host-verified existing symbols at this task's admitted completion head; never plan guesses. */
|
|
94
|
+
memorySymbolNodes?: (task: Task) => MemorySymbolNodes | undefined;
|
|
81
95
|
sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
|
|
82
96
|
briefs?: Briefs;
|
|
83
97
|
/** Screened live fail notes directly intersecting the task plan or refs; read-only advice. */
|
|
@@ -92,6 +106,8 @@ export interface TasksDeps {
|
|
|
92
106
|
reviewOnly?: (peer: PeerId) => boolean;
|
|
93
107
|
/** #223: captured when a verdict call starts; a non-undefined result refuses the verdict at every later check. */
|
|
94
108
|
reviewAuthority?: (by: PeerId, task: number) => (() => string | undefined) | undefined;
|
|
109
|
+
/** Host-captured current-child authority, rechecked before an admitted async tool first commits effects. */
|
|
110
|
+
requestAuthority?: (by: PeerId) => (() => string | undefined) | undefined;
|
|
95
111
|
memory?: MemoryClient;
|
|
96
112
|
/** Captured native profile fence at our memory operation boundaries; no board mutation. */
|
|
97
113
|
memoryCheck?: () => void;
|
|
@@ -183,6 +199,12 @@ const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
|
183
199
|
const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
|
|
184
200
|
QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits for its reserved owner's WIP slot.
|
|
185
201
|
QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
|
|
202
|
+
QUIET_EVENTS.add("planner changed"); // Planning authority does not move the execution owner or revoke its handoff.
|
|
203
|
+
/** The records of the `done (checking)` entry a passed check now completes: what the owner delivered then (#509a). */
|
|
204
|
+
const latestPendingDeliverables = (task: Task): DeliverableRecord[] | undefined => {
|
|
205
|
+
const checking = task.history.findLastIndex(entry => entry.event === "done (checking)");
|
|
206
|
+
return checking >= 0 && !task.history.slice(checking + 1).some(entry => entry.event === "done") ? task.history[checking]!.deliverables : undefined;
|
|
207
|
+
};
|
|
186
208
|
|
|
187
209
|
/**
|
|
188
210
|
* Thrown when an assignment's board write is saved but a publish or notify after it failed (#297): the move is
|
|
@@ -278,6 +300,10 @@ const planText = (plan: TaskPlan = {}) => ALL_PLAN_KEYS.filter((k) => plan[k]?.l
|
|
|
278
300
|
const screenText = (t: { title: string; detail: string; plan?: TaskPlan; external?: Task["external"] }) => [t.title, t.detail, planText(t.plan), t.external?.repo, t.external?.url].filter(value => value !== undefined).join("\n");
|
|
279
301
|
/** Re-screens of one task whose verdict was unknown, per hub run (#198). */
|
|
280
302
|
const RESCREEN_MAX = 10;
|
|
303
|
+
/** #758: the planner "board changed" notice. Its body starts with this, so an unsettled copy is found (and replaced) by the prefix. */
|
|
304
|
+
const GROOM_NOTICE_PREFIX = "The board changed";
|
|
305
|
+
/** #758: a board change that needs no plan review is recorded at most once per planner in this window. */
|
|
306
|
+
const GROOM_FYI_WINDOW_MS = 10 * 60_000;
|
|
281
307
|
|
|
282
308
|
/**
|
|
283
309
|
* The task flow. Adapters and tools never touch the board: every change comes through here, where assignment,
|
|
@@ -286,12 +312,35 @@ const RESCREEN_MAX = 10;
|
|
|
286
312
|
/** #694: a PR staged at done with the commit or branch it was read for. */
|
|
287
313
|
interface IdentityLink { repo: string; pr: number; basis: { commit?: string; branch?: string } }
|
|
288
314
|
|
|
315
|
+
/** What one live check of a task's files says as a whole (#317): `unchanged` only when EVERY file measured unchanged. A change outranks a missing file, which outranks one the hub could not read. */
|
|
316
|
+
export type DeliverableResult = "changed" | "missing" | "unknown" | "unchanged";
|
|
317
|
+
export const deliverableResult = (states: readonly DeliverableStatus["state"][]): DeliverableResult =>
|
|
318
|
+
states.includes("changed") ? "changed" : states.includes("missing") ? "missing" : states.includes("unknown") || !states.length ? "unknown" : "unchanged";
|
|
319
|
+
|
|
320
|
+
/** One row of `ahub deliverables` and one card's badge (#317): ids, hashes and sizes always; a path only where `publicDeliverables` allows it. */
|
|
321
|
+
export interface DeliverableRow {
|
|
322
|
+
task: number; title: string; taskState: string; state: "delivered" | "approved";
|
|
323
|
+
/** True only when the files were measured for this row. Without it `changedSinceReview` is absent: the board alone cannot know. */
|
|
324
|
+
checked: boolean; changedSinceReview?: boolean;
|
|
325
|
+
/** Present only when `checked`: the whole-row reading of the measurement (never `unchanged` while any file is missing or unreadable). */
|
|
326
|
+
result?: DeliverableResult;
|
|
327
|
+
files: { id: string; path?: string; format: string; version?: string; sha256: string; bytes: number; private?: true; state?: DeliverableStatus["state"] }[];
|
|
328
|
+
}
|
|
329
|
+
|
|
289
330
|
export class Tasks {
|
|
331
|
+
private readonly planReviewRequests = new Map<number, string>();
|
|
290
332
|
private readonly repositories: TaskRepositories;
|
|
291
333
|
private readonly incomingBacklog = new Set<number>();
|
|
292
334
|
private readonly plannerBacklog = new Map<PeerId, Set<number>>();
|
|
293
335
|
private readonly plannerBlockedQueue = new Map<PeerId, string>();
|
|
294
336
|
private readonly knownPlanners = new Set<PeerId>();
|
|
337
|
+
/**
|
|
338
|
+
* #758: what the planner board-changed notice has read of the board. `revision` is the board status revision last scanned, `lengths` each task's
|
|
339
|
+
* history length at that scan (so only entries added since are read), `pending` what each planner has not yet been told (`review`: a planning
|
|
340
|
+
* revision or dependency change of a card it plans; `other`: a state move, review or assignment) and `sent` when it was last told.
|
|
341
|
+
* ponytail: a scan lists the whole board once per status revision (at most one per flush); the upgrade is an indexed history read.
|
|
342
|
+
*/
|
|
343
|
+
private readonly groomScan = { revision: -1, lengths: new Map<number, number>(), deps: new Map<number, string>(), pending: new Map<PeerId, { review: boolean; other: boolean }>(), sent: new Map<PeerId, { fyiAt: number }>() };
|
|
295
344
|
/** Turn-free cohorts (issue #107): who works without messages, and who integrates. */
|
|
296
345
|
readonly cohorts: Cohorts;
|
|
297
346
|
private readonly coordinationModes = new Map<number, boolean>();
|
|
@@ -302,6 +351,7 @@ export class Tasks {
|
|
|
302
351
|
constructor(private readonly d: TasksDeps) {
|
|
303
352
|
d.board.trackPlanRevisions = () => (d.sdlc?.config().mode ?? "off") !== "off";
|
|
304
353
|
this.repositories = new TaskRepositories(d.cwd, d.repository);
|
|
354
|
+
d.board.proposalPlanner = task => this.proposalPlanner(task);
|
|
305
355
|
for (const task of d.board.list()) this.warmRepository(task);
|
|
306
356
|
if (d.coordination) {
|
|
307
357
|
const mode = d.coordination();
|
|
@@ -358,6 +408,12 @@ export class Tasks {
|
|
|
358
408
|
};
|
|
359
409
|
|
|
360
410
|
/** One bounded screen call. A screener that fails is no verdict, handled as PII (fail closed). */
|
|
411
|
+
private requestCheck(by: PeerId): () => void {
|
|
412
|
+
const authority = this.d.requestAuthority?.(by);
|
|
413
|
+
const check = () => { const reason = authority?.(); if (reason) throw new PreEffectToolRefusal(reason); };
|
|
414
|
+
check(); return check;
|
|
415
|
+
}
|
|
416
|
+
|
|
361
417
|
private async screenCall(text: string): Promise<PiiVerdict> {
|
|
362
418
|
return (await this.d.piiScreen?.(text).catch(() => undefined)) ?? { label: "unknown", miss: "failed", ms: 0 };
|
|
363
419
|
}
|
|
@@ -402,7 +458,7 @@ export class Tasks {
|
|
|
402
458
|
if (finding.kind === "ready-unassigned") {
|
|
403
459
|
this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
|
|
404
460
|
recorded.push({ task: task.id, finding });
|
|
405
|
-
const planners =
|
|
461
|
+
const planners = this.taskPlanner(task).filter(peer => this.d.bus.isRostered(peer));
|
|
406
462
|
const text = `Task #${task.id}: ready-unassigned, idle escalation ${finding.step}/3.`;
|
|
407
463
|
if (finding.step === 1 || finding.step === 2) {
|
|
408
464
|
if (finding.step === 2 || !planners.length) this.releaseNotice(text);
|
|
@@ -430,8 +486,8 @@ export class Tasks {
|
|
|
430
486
|
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");
|
|
431
487
|
} else if (finding.step === 2) {
|
|
432
488
|
this.d.notify(message);
|
|
433
|
-
for (const
|
|
434
|
-
if (peer !== responsible &&
|
|
489
|
+
for (const peer of this.taskPlanner(task)) {
|
|
490
|
+
if (peer !== responsible && 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");
|
|
435
491
|
}
|
|
436
492
|
} else {
|
|
437
493
|
const reviewer = finding.kind === "review-pending";
|
|
@@ -559,6 +615,31 @@ export class Tasks {
|
|
|
559
615
|
}
|
|
560
616
|
|
|
561
617
|
/** Host-authored proposals contain only cleared IDs, integer task references and fixed instruction targets. */
|
|
618
|
+
/**
|
|
619
|
+
* #736: how many separate pieces of work the tasks are. Tasks that share a PR, a branch in one repository, or a
|
|
620
|
+
* supersession (a cancelled task names its replacement) are one piece of work, as an approve-as-step follow-up is: a
|
|
621
|
+
* finding carried between them is the same finding, not a recurrence.
|
|
622
|
+
*/
|
|
623
|
+
private workUnits(rows: readonly Task[]): number {
|
|
624
|
+
const parent = new Map<number, number>();
|
|
625
|
+
const find = (id: number): number => { let at = id; while (parent.has(at)) at = parent.get(at)!; return at; };
|
|
626
|
+
const join = (a: number, b: number) => { const x = find(a), y = find(b); if (x !== y) parent.set(x, y); };
|
|
627
|
+
const first = new Map<string, number>(), ids = new Set(rows.map(row => row.id));
|
|
628
|
+
for (const row of rows) {
|
|
629
|
+
// Repository identity comes from the board's own resolver, so aliases match and an outside path never falls back to this project.
|
|
630
|
+
const prRepo = row.sdlc?.pr ? this.repositories.scope(row.sdlc.repo ? { repo: row.sdlc.repo, paths: row.refs.paths } : row.refs).key : "";
|
|
631
|
+
const branchRepo = row.refs.branch ? this.repositories.scope(row.refs).key : "";
|
|
632
|
+
for (const key of [prRepo ? `pr ${prRepo}#${row.sdlc!.pr}` : "", branchRepo ? `branch ${branchRepo} ${row.refs.branch}` : ""]) {
|
|
633
|
+
if (!key) continue;
|
|
634
|
+
const seen = first.get(key);
|
|
635
|
+
if (seen === undefined) first.set(key, row.id); else join(row.id, seen);
|
|
636
|
+
}
|
|
637
|
+
// superseded_by is free text ("task #73", "#73" or "73"); the first task number in it names the replacement.
|
|
638
|
+
const next = Number(/#?(\d+)/.exec(row.cancelled?.supersededBy ?? "")?.[1]);
|
|
639
|
+
if (ids.has(next)) join(row.id, next);
|
|
640
|
+
}
|
|
641
|
+
return new Set(rows.map(row => find(row.id))).size;
|
|
642
|
+
}
|
|
562
643
|
private proposeRecurringFindings(task: Task, by: PeerId, items: readonly ReviewFinding[], withheld: boolean): void {
|
|
563
644
|
if (withheld || this.isPii(task) || this.d.privateTurn?.(by) || !items.length || !this.findingTextAllowed(task, items)) return;
|
|
564
645
|
const rows = this.d.board.list();
|
|
@@ -574,7 +655,7 @@ export class Tasks {
|
|
|
574
655
|
const finding = last?.reviewFindings?.items.find(value => value.id === item.id);
|
|
575
656
|
return !last?.withheld && finding && same(finding) && finding.disposition !== "false_positive" && this.findingTextAllowed(row, [finding]);
|
|
576
657
|
});
|
|
577
|
-
if (
|
|
658
|
+
if (this.workUnits(holders) < 2) continue;
|
|
578
659
|
const key = `Review finding key: ${item.pass}:${item.id}`;
|
|
579
660
|
if (keys.has(key)) continue;
|
|
580
661
|
const title = `Review policy amendment for recurring finding ${item.pass}:${item.id}`;
|
|
@@ -606,8 +687,14 @@ export class Tasks {
|
|
|
606
687
|
...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
|
|
607
688
|
...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
|
|
608
689
|
...(h.owner === undefined ? {} : { owner: h.owner }),
|
|
690
|
+
...(h.planner === undefined ? {} : { planner: h.planner }),
|
|
691
|
+
...(h.plannerReason ? { plannerReason: h.plannerReason } : {}),
|
|
692
|
+
...(h.planAuthor === undefined ? {} : { planAuthor: h.planAuthor }),
|
|
693
|
+
...(h.selfReview ? { selfReview: true } : {}),
|
|
609
694
|
...(h.reviewerFamily ? { reviewerFamily: screenHistoryText(h.reviewerFamily, "reviewer family") } : {}),
|
|
610
695
|
...(h.reviewerReason ? { reviewerReason: h.reviewerReason } : {}),
|
|
696
|
+
...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
|
|
697
|
+
...(h.deliverables?.length ? { deliverables: publicDeliverables(h.deliverables, this.nameable) } : {}),
|
|
611
698
|
...(h.reviewBlocked ? { reviewBlocked: true } : {}),
|
|
612
699
|
...(h.reviewerFailure ? { reviewerFailure: { peer: screenHistoryText(h.reviewerFailure.peer, "reviewer"), cause: h.reviewerFailure.cause, ...(h.reviewerFailure.head ? { head: screenHistoryText(h.reviewerFailure.head, "review head") } : {}) } } : {}),
|
|
613
700
|
...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
|
|
@@ -734,7 +821,7 @@ export class Tasks {
|
|
|
734
821
|
}
|
|
735
822
|
|
|
736
823
|
/** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
|
|
737
|
-
private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: TaskAssignmentRole = "owner", preEffect = true): void {
|
|
824
|
+
private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: Exclude<TaskAssignmentRole, "planner"> = "owner", preEffect = true): void {
|
|
738
825
|
const instances = this.d.instances?.(), adapter = (this.d.adapterCatalog?.definitionOf ?? definitionOf)(peer, instances);
|
|
739
826
|
const declaredExtra = !!adapter && adapter.kind !== peer;
|
|
740
827
|
if ((!this.d.capabilities || this.isPii(task)) && !declaredExtra) return;
|
|
@@ -745,10 +832,19 @@ export class Tasks {
|
|
|
745
832
|
}
|
|
746
833
|
|
|
747
834
|
/** What assignment weighs besides states and policy: quota, demotion and the review record. */
|
|
835
|
+
/**
|
|
836
|
+
* #525-3: the settled planner holders, sorted: the one input of the planner walk. It comes from the same settled role map as planner
|
|
837
|
+
* authority (`d.roles`), restricted to rostered peers, so assignment, planner notices and `route explain` agree on who the holders are.
|
|
838
|
+
* Selection stays in assign(); task bindings only keep that selection while the holder remains eligible.
|
|
839
|
+
*/
|
|
840
|
+
private plannerHolders(): PeerId[] {
|
|
841
|
+
return Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => Array.isArray(roles) && roles.includes("planner") && this.d.bus.isRostered(peer)).map(([peer]) => peer).sort();
|
|
842
|
+
}
|
|
843
|
+
|
|
748
844
|
private weights(cls: TaskClass, task?: Task) {
|
|
749
845
|
const now = Date.now();
|
|
750
846
|
const quota = this.d.quota?.();
|
|
751
|
-
return { ...this.health(), ...(task ? { reviewPool: this.reviewPoolOptions(task) } : {}), now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
|
|
847
|
+
return { ...this.health(), planners: this.plannerHolders(), ...(task ? { reviewPool: this.reviewPoolOptions(task), ...(task.planner ? { keptPlanner: task.planner } : {}) } : {}), now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
|
|
752
848
|
}
|
|
753
849
|
|
|
754
850
|
/**
|
|
@@ -843,13 +939,62 @@ export class Tasks {
|
|
|
843
939
|
try { this.d.recordPlanning?.(change); } catch { /* telemetry never changes a durable result */ }
|
|
844
940
|
}
|
|
845
941
|
|
|
846
|
-
|
|
847
|
-
|
|
942
|
+
/** A removed/off-roster binding falls back to legacy holders; a busy, paused or offline holder keeps authority. */
|
|
943
|
+
taskPlanner(task: Pick<Task, "planner">): PeerId[] {
|
|
944
|
+
const holders = Object.keys(this.d.roles ?? {}).filter(peer => this.d.roles?.[peer]?.includes("planner")).sort();
|
|
945
|
+
return task.planner && holders.includes(task.planner) && this.d.bus.isRostered(task.planner) ? [task.planner] : holders;
|
|
848
946
|
}
|
|
849
947
|
|
|
850
|
-
private
|
|
948
|
+
private planningAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
|
|
949
|
+
if (by !== USER && (!this.d.roles?.[by]?.includes("planner") || task !== undefined && !this.taskPlanner(task).includes(by))) throw new PreEffectToolRefusal(`planning requires a person or configured planner role${task ? "; only the task's effective planner may act" : ""}`);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
private plannerWriteAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
|
|
851
953
|
if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("planner writes are unavailable during a private turn");
|
|
852
|
-
this.planningAuthority(by);
|
|
954
|
+
this.planningAuthority(by, task);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** Every new host proposal, including split children, consumes the same pure walk before its first notification. */
|
|
958
|
+
private proposalPlanner(task: Task): PlannerBinding {
|
|
959
|
+
const choice = assign({ ...task, state: "proposed" }, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [] });
|
|
960
|
+
return { planner: this.isPii(task) ? null : choice.planner ?? null, plannerReason: this.isPii(task) ? "none" : choice.plannerReason ?? "none" };
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
private plannerBinding(task: Task, assignment: Assignment, bindNull = false): PlannerBinding | undefined {
|
|
964
|
+
if (bindNull && task.planner == null) return { planner: this.isPii(task) ? null : assignment.planner ?? null, plannerReason: assignment.plannerReason ?? "none" };
|
|
965
|
+
return task.planner && this.plannerHolders().includes(task.planner) ? { planner: task.planner, plannerReason: "kept" } : undefined;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
private planAuthor(task: Task): PeerId | null {
|
|
969
|
+
// Review scope bookkeeping preserves the existing plan-content author.
|
|
970
|
+
const authored = task.history.findLast(entry => entry.event !== "plan reviewed" && entry.planRevision !== undefined)
|
|
971
|
+
?? task.history.findLast(entry => entry.event !== "plan reviewed" && (entry.planning?.fields as readonly string[] | undefined)?.includes("plan"));
|
|
972
|
+
if (authored && authored !== task.history[0]) return authored.by;
|
|
973
|
+
return ALL_PLAN_KEYS.some(key => !!task.plan?.[key]?.length) ? authored?.by ?? task.history[0]?.by ?? null : null;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
private planReviewAuthority(by: PeerId, task: Task): { planAuthor: PeerId | null; selfReview?: true } {
|
|
977
|
+
this.plannerWriteAuthority(by); // Independent settled holders have review authority only, not the task's other planner powers.
|
|
978
|
+
const author = this.planAuthor(task);
|
|
979
|
+
const independent = this.plannerHolders().filter(peer => peer !== author && this.d.bus.stateOf(peer) !== "offline");
|
|
980
|
+
if (by !== USER && by === author && independent.length) throw new PreEffectToolRefusal("an independent planner reviews this plan");
|
|
981
|
+
return { planAuthor: author, ...(by === author ? { selfReview: true as const } : {}) };
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
private requestIndependentPlanReview(task: Task): void {
|
|
985
|
+
const author = this.planAuthor(task);
|
|
986
|
+
if (!author || !this.taskPlanner(task).includes(author)) return;
|
|
987
|
+
const planners = this.plannerHolders().filter(peer => peer !== author && this.d.bus.stateOf(peer) !== "offline");
|
|
988
|
+
if (!planners.length) return;
|
|
989
|
+
const selected = assign({ ...task, state: "proposed" }, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], planners, keptPlanner: undefined }).planner;
|
|
990
|
+
if (!selected) return;
|
|
991
|
+
const revision = task.planningRevision ?? 0, signature = `${revision}:${author}:${selected}`;
|
|
992
|
+
if (this.planReviewRequests.get(task.id) === signature) return;
|
|
993
|
+
const view = this.publicView(task);
|
|
994
|
+
const queued = this.d.bus.publish(newEnvelope(HUB, `Independent plan review requested for task #${view.id}, revision ${revision}. Record hub_plan_review for that revision.`, {
|
|
995
|
+
to: [selected], kind: "task", refs: { task: String(task.id), planReview: revision, planReviewFlow: task.history.length, planAuthor: author }, ...(this.isPii(task) ? { private: true } : {}),
|
|
996
|
+
}));
|
|
997
|
+
if (queued.includes(selected)) this.planReviewRequests.set(task.id, signature);
|
|
853
998
|
}
|
|
854
999
|
|
|
855
1000
|
wipLimit(peer: PeerId): number {
|
|
@@ -908,6 +1053,7 @@ export class Tasks {
|
|
|
908
1053
|
const validate = () => {
|
|
909
1054
|
const task = this.d.board.get(input.id);
|
|
910
1055
|
if (!task || (task.planningRevision ?? 0) !== input.revision || task.state !== "backlog" || task.owner || !taskCanPlan(task) || task.pinned) throw new PreEffectToolRefusal("planner pull changed, pinned or already started");
|
|
1056
|
+
this.planningAuthority(by, task);
|
|
911
1057
|
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
912
1058
|
if (task.release != null && (!release || ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull requires unfrozen release scope");
|
|
913
1059
|
if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
|
|
@@ -928,7 +1074,7 @@ export class Tasks {
|
|
|
928
1074
|
const refusal = task.reserved ? `reserved peer ${task.reserved} cannot take the planner pull: ${assignment.refusals?.find(row => row.role === "owner" && row.peer === task.reserved)?.reason ?? "not eligible"}; ${this.plannerRefusal(task)}` : this.plannerRefusal(task);
|
|
929
1075
|
throw new PreEffectToolRefusal(`${this.hasPersonReservation(task) ? "a person's reservation is retained; " : ""}${refusal}`);
|
|
930
1076
|
}
|
|
931
|
-
return { task, owner: assignment.owner };
|
|
1077
|
+
return { task, owner: assignment.owner, assignment };
|
|
932
1078
|
};
|
|
933
1079
|
const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
|
|
934
1080
|
authorized();
|
|
@@ -941,7 +1087,7 @@ export class Tasks {
|
|
|
941
1087
|
if (checked.task.history.length !== current.task.history.length || checked.owner !== current.owner) throw new PreEffectToolRefusal("planner pull changed during admission");
|
|
942
1088
|
} } });
|
|
943
1089
|
}
|
|
944
|
-
const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld);
|
|
1090
|
+
const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld, this.plannerBinding(current.task, current.assignment, true));
|
|
945
1091
|
this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
|
|
946
1092
|
return pulled;
|
|
947
1093
|
}
|
|
@@ -953,15 +1099,77 @@ export class Tasks {
|
|
|
953
1099
|
private addPlannerBacklog(peer: PeerId, ids: readonly number[]): void {
|
|
954
1100
|
if (!this.d.roles?.[peer]?.includes("planner")) return;
|
|
955
1101
|
const pending = this.plannerBacklog.get(peer) ?? new Set<number>();
|
|
956
|
-
for (const id of ids)
|
|
1102
|
+
for (const id of ids) {
|
|
1103
|
+
const task = this.d.board.get(id);
|
|
1104
|
+
if (task?.state === "backlog" && this.taskPlanner(task).includes(peer)) pending.add(id);
|
|
1105
|
+
}
|
|
957
1106
|
if (pending.size) this.plannerBacklog.set(peer, pending);
|
|
958
1107
|
}
|
|
959
|
-
|
|
1108
|
+
/**
|
|
1109
|
+
* The planner "board changed" notice (#758). Read only what was added to a card's history since the last scan, and tell each attached planner of
|
|
1110
|
+
* what it did not write itself and plans (`taskPlanner`, the #525-2 scoping): a planning revision or dependency change (in or outside a
|
|
1111
|
+
* `planning` entry) or the creation of a card is a task notice, replacing a copy that
|
|
1112
|
+
* is still unsettled so the planner meets the latest revision once; a state move, review or assignment is recorded at `fyi` weight (no turn),
|
|
1113
|
+
* at most once per planner per ten minutes. The hub's own bookkeeping entries tell nobody. A planner with no earlier notice gets one task notice
|
|
1114
|
+
* (a restart, or the first one after it was attached), as before.
|
|
1115
|
+
*/
|
|
1116
|
+
private groomNotices(configured: readonly PeerId[], planners: readonly PeerId[]): void {
|
|
1117
|
+
const scan = this.groomScan, revision = this.d.board.statusRevision(), first = scan.revision < 0;
|
|
1118
|
+
if (scan.revision !== revision) {
|
|
1119
|
+
for (const task of this.d.board.list()) {
|
|
1120
|
+
const seen = scan.lengths.get(task.id), depsKey = JSON.stringify(task.deps ?? []), depsBefore = scan.deps.get(task.id);
|
|
1121
|
+
scan.lengths.set(task.id, task.history.length); scan.deps.set(task.id, depsKey);
|
|
1122
|
+
if (first || seen === task.history.length && depsBefore === depsKey) continue;
|
|
1123
|
+
const holders = this.taskPlanner(task);
|
|
1124
|
+
const tell = (author: string, review: boolean) => {
|
|
1125
|
+
for (const holder of configured) { // every configured planner, so a change made while one was offline is not lost
|
|
1126
|
+
if (holder === author || !holders.includes(holder)) continue;
|
|
1127
|
+
const pending = scan.pending.get(holder) ?? { review: false, other: false };
|
|
1128
|
+
if (review) pending.review = true; else pending.other = true;
|
|
1129
|
+
scan.pending.set(holder, pending);
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
// A card's creation (a propose, with or without `after`, a split child, a generated card) is new work to consolidate, so it needs a plan review.
|
|
1133
|
+
if (seen === undefined) tell(task.history[0]?.by ?? HUB, true);
|
|
1134
|
+
for (const entry of seen === undefined ? task.history.slice(1) : task.history.slice(seen)) {
|
|
1135
|
+
if (entry.by === HUB && entry.planning === undefined) continue; // the hub's own bookkeeping (lifecycle links, sweeps, holds) is not a board change to tell anyone about
|
|
1136
|
+
tell(entry.by, entry.planning !== undefined);
|
|
1137
|
+
}
|
|
1138
|
+
// Dependencies changed outside a `planning` entry (a propose with `after`, a split, any other write): the same as a planning change.
|
|
1139
|
+
// Its author is the last entry's only when this scan saw a new entry; a write with no entry has no known author, so no planner is skipped.
|
|
1140
|
+
if (depsBefore !== undefined && depsBefore !== depsKey) tell(seen !== undefined && seen < task.history.length ? task.history.at(-1)!.by : "", true);
|
|
1141
|
+
}
|
|
1142
|
+
scan.revision = revision;
|
|
1143
|
+
}
|
|
1144
|
+
const now = this.d.now?.() ?? Date.now();
|
|
1145
|
+
for (const peer of planners) {
|
|
1146
|
+
const told = scan.sent.get(peer), pending = scan.pending.get(peer);
|
|
1147
|
+
const review = told === undefined || pending?.review === true;
|
|
1148
|
+
if (!review && !pending?.other) continue;
|
|
1149
|
+
if (!review && now - told!.fyiAt < GROOM_FYI_WINDOW_MS) { scan.pending.delete(peer); continue; }
|
|
1150
|
+
this.releaseNotice(`could not notify planner ${peer} about grooming`, () => {
|
|
1151
|
+
if (review) this.sendGroomNotice(peer, revision);
|
|
1152
|
+
else this.d.bus.publish(newEnvelope(HUB, `${GROOM_NOTICE_PREFIX} (revision ${revision}): state moves, reviews or assignments only. No plan review is needed.`, { to: [peer], kind: "task", priority: "fyi" }));
|
|
1153
|
+
scan.pending.delete(peer);
|
|
1154
|
+
scan.sent.set(peer, { fyiAt: review ? told?.fyiAt ?? 0 : now });
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** The task-weight notice: an unsettled earlier copy for this planner is withdrawn and replaced, never a second one queued. */
|
|
1160
|
+
private sendGroomNotice(peer: PeerId, revision: number): void {
|
|
1161
|
+
const body = `${GROOM_NOTICE_PREFIX} (revision ${revision}). Before offering or accepting build work, read hub_planner_groom, consolidate dependencies and priorities, and record hub_plan_review against the current planning revision.`;
|
|
1162
|
+
for (const old of this.d.bus.pendingEnvelopes(peer)) if (old.from === HUB && old.kind === "task" && old.body.startsWith(GROOM_NOTICE_PREFIX)) this.d.bus.withdraw(old.id);
|
|
1163
|
+
const recipients = this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task" }));
|
|
1164
|
+
if (!recipients.includes(peer)) throw new Error("the notice was not queued");
|
|
1165
|
+
}
|
|
1166
|
+
|
|
960
1167
|
/** One bounded ids-only batch per attached planner; failed or evicted copies stay pending. */
|
|
961
1168
|
flushPlannerNotices(): number {
|
|
962
1169
|
const configured = Object.entries(this.d.roles ?? {}).filter(([, roles]) => roles.includes("planner")).map(([peer]) => peer);
|
|
963
1170
|
for (const peer of this.knownPlanners) if (!configured.includes(peer)) {
|
|
964
1171
|
this.knownPlanners.delete(peer); this.plannerBacklog.delete(peer); this.plannerBlockedQueue.delete(peer);
|
|
1172
|
+
this.groomScan.pending.delete(peer); this.groomScan.sent.delete(peer);
|
|
965
1173
|
}
|
|
966
1174
|
for (const peer of configured) if (!this.knownPlanners.has(peer)) {
|
|
967
1175
|
this.knownPlanners.add(peer);
|
|
@@ -970,18 +1178,7 @@ export class Tasks {
|
|
|
970
1178
|
}
|
|
971
1179
|
const planners = configured.filter(peer => this.d.bus.isRostered(peer) && this.d.bus.peers.has(peer) && this.d.bus.stateOf(peer) !== "offline");
|
|
972
1180
|
if (!planners.length) return 0;
|
|
973
|
-
if ((this.d.sdlc?.config().mode ?? "off") !== "off")
|
|
974
|
-
const revision = this.d.board.statusRevision();
|
|
975
|
-
const body = "The board changed. Before offering or accepting build work, read hub_planner_groom, consolidate dependencies and priorities, and record hub_plan_review against the current planning revision.";
|
|
976
|
-
for (const peer of planners) if (this.groomingNotified.get(peer) !== revision) {
|
|
977
|
-
const queued = this.d.bus.pendingEnvelopes(peer).some(env => env.from === HUB && env.kind === "task" && env.body === body);
|
|
978
|
-
if (queued) continue;
|
|
979
|
-
this.releaseNotice(`could not notify planner ${peer} about grooming`, () => {
|
|
980
|
-
const recipients = this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task" }));
|
|
981
|
-
if (recipients.includes(peer)) this.groomingNotified.set(peer, revision);
|
|
982
|
-
});
|
|
983
|
-
}
|
|
984
|
-
}
|
|
1181
|
+
if ((this.d.sdlc?.config().mode ?? "off") !== "off") this.groomNotices(configured, planners);
|
|
985
1182
|
for (const id of this.incomingBacklog) if (this.d.board.get(id)?.state !== "backlog") this.incomingBacklog.delete(id);
|
|
986
1183
|
const incoming = [...this.incomingBacklog].sort((a, b) => a - b).slice(0, 64);
|
|
987
1184
|
if (incoming.length) {
|
|
@@ -995,10 +1192,17 @@ export class Tasks {
|
|
|
995
1192
|
for (const peer of planners) {
|
|
996
1193
|
const pending = this.plannerBacklog.get(peer);
|
|
997
1194
|
if (!pending) continue;
|
|
1195
|
+
for (const id of pending) {
|
|
1196
|
+
const task = this.d.board.get(id);
|
|
1197
|
+
if (task?.state !== "backlog") pending.delete(id);
|
|
1198
|
+
else if (!this.taskPlanner(task).includes(peer)) {
|
|
1199
|
+
pending.delete(id);
|
|
1200
|
+
for (const recipient of this.taskPlanner(task)) this.addPlannerBacklog(recipient, [id]);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
998
1203
|
const blockedQueue = this.plannerBlockedQueue.get(peer);
|
|
999
1204
|
if (blockedQueue !== undefined && blockedQueue === JSON.stringify(this.d.bus.queueIds(peer))) continue;
|
|
1000
1205
|
this.plannerBlockedQueue.delete(peer);
|
|
1001
|
-
for (const id of pending) if (this.d.board.get(id)?.state !== "backlog") pending.delete(id);
|
|
1002
1206
|
const ids = [...pending].sort((a, b) => a - b).slice(0, 64);
|
|
1003
1207
|
if (!ids.length) continue;
|
|
1004
1208
|
// Remove before publish: an immediate overflow callback may put this recipient's ids back.
|
|
@@ -1055,7 +1259,7 @@ export class Tasks {
|
|
|
1055
1259
|
});
|
|
1056
1260
|
}
|
|
1057
1261
|
|
|
1058
|
-
async planBacklog(by: PeerId, input: { id: number; revision: number; fields
|
|
1262
|
+
async planBacklog(by: PeerId, input: { id: number; revision: number; fields?: unknown; reason: string }): Promise<Task> {
|
|
1059
1263
|
this.planningAuthority(by);
|
|
1060
1264
|
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid planning identity or revision");
|
|
1061
1265
|
if (typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("planning needs a bounded reason");
|
|
@@ -1064,6 +1268,12 @@ export class Tasks {
|
|
|
1064
1268
|
const validate = () => {
|
|
1065
1269
|
const task = this.d.board.get(input.id);
|
|
1066
1270
|
if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("planning revision changed");
|
|
1271
|
+
this.planningAuthority(by, task);
|
|
1272
|
+
if (patch.sdlc !== undefined) {
|
|
1273
|
+
if (task.state !== "backlog" || task.owner || taskHasStarted(task)) throw new PreEffectToolRefusal("issue identity changes require unstarted backlog work");
|
|
1274
|
+
if (by !== USER && this.isPii(task)) throw new PreEffectToolRefusal("private issue identity changes require the person");
|
|
1275
|
+
if (!this.isPii(task) && !this.publicMetadataText(JSON.stringify(patch.sdlc))) throw new PreEffectToolRefusal("planning identity metadata is private or sensitive");
|
|
1276
|
+
}
|
|
1067
1277
|
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
|
|
1068
1278
|
if (by !== USER && (task.state !== "backlog" || !taskCanPlan(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
|
|
1069
1279
|
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");
|
|
@@ -1096,6 +1306,7 @@ export class Tasks {
|
|
|
1096
1306
|
for (const id of new Set([task.release, target])) {
|
|
1097
1307
|
if (id == null) continue;
|
|
1098
1308
|
const release = this.d.board.release(id)!;
|
|
1309
|
+
if (Object.hasOwn(patch, "release")) this.planningAuthority(by, release);
|
|
1099
1310
|
if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release scope is final");
|
|
1100
1311
|
if (by !== USER && release.state === "frozen") throw new PreEffectToolRefusal("frozen release changes require a person");
|
|
1101
1312
|
}
|
|
@@ -1103,8 +1314,11 @@ export class Tasks {
|
|
|
1103
1314
|
};
|
|
1104
1315
|
const task = validate(), withheld = await this.withholds("note", input.reason, task);
|
|
1105
1316
|
this.planningAuthority(by); // #455: or moved the planner
|
|
1106
|
-
validate(); // a person may have changed planning or frozen a release while the screen ran
|
|
1107
|
-
|
|
1317
|
+
const latest = validate(); // a person may have changed planning or frozen a release while the screen ran
|
|
1318
|
+
let receipt: Task["sdlc"];
|
|
1319
|
+
try { receipt = patch.sdlc === undefined ? undefined : planningIssueReceipt(patch.sdlc, latest.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined)); }
|
|
1320
|
+
catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
1321
|
+
const updated = this.d.board.plan(task.id, by, patch, input.revision, input.reason.trim(), withheld, receipt);
|
|
1108
1322
|
this.planningRecorded({ by, task: task.id, fields: Object.keys(patch), revision: updated.planningRevision! });
|
|
1109
1323
|
return updated; // persistence/readback failures must never claim no effect
|
|
1110
1324
|
}
|
|
@@ -1113,6 +1327,7 @@ export class Tasks {
|
|
|
1113
1327
|
this.planningAuthority(by);
|
|
1114
1328
|
const task = this.d.board.get(id), entry = task ? planningUndoEntry(task) : undefined;
|
|
1115
1329
|
if (!task || !entry?.planning || entry.planning.revision !== revision || (task.planningRevision ?? 0) !== revision || by !== USER && entry.by !== by) throw new PreEffectToolRefusal("planning undo is stale or not owned");
|
|
1330
|
+
this.planningAuthority(by, task);
|
|
1116
1331
|
if (entry.planning.split) {
|
|
1117
1332
|
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
1118
1333
|
if (task.state !== "backlog" || task.owner || taskHasStarted(task) || release && ["shipped", "dropped"].includes(release.state) || by !== USER && (task.pinned || release?.state === "frozen")) throw new PreEffectToolRefusal("split undo is no longer eligible");
|
|
@@ -1182,6 +1397,7 @@ export class Tasks {
|
|
|
1182
1397
|
if (!parent || parent.state !== "backlog" || parent.owner || taskHasStarted(parent) || (parent.planningRevision ?? 0) !== revision || by !== USER && parent.pinned) throw new PreEffectToolRefusal("split changed, pinned or already started");
|
|
1183
1398
|
const release = parent.release == null ? undefined : this.d.board.release(parent.release);
|
|
1184
1399
|
if (parent.release != null && (!release || ["shipped", "dropped"].includes(release.state) || by !== USER && release.state === "frozen")) throw new PreEffectToolRefusal("split release scope is frozen or final");
|
|
1400
|
+
this.planningAuthority(by, parent);
|
|
1185
1401
|
const profile = parent.profile ?? "dev";
|
|
1186
1402
|
const drafts = rows.map((part, index) => {
|
|
1187
1403
|
if (!part || typeof part !== "object" || Array.isArray(part) || Object.keys(part).some(key => !["title", "detail", "class", "refs", "plan", "after", "afterParts"].includes(key)) || typeof part.title !== "string" || !part.title.trim() || part.title.length > 300 || part.detail !== undefined && (typeof part.detail !== "string" || part.detail.length > 8000) || part.class !== undefined && typeof part.class !== "string") throw new PreEffectToolRefusal("invalid split part");
|
|
@@ -1246,10 +1462,12 @@ export class Tasks {
|
|
|
1246
1462
|
this.plannerWriteAuthority(by);
|
|
1247
1463
|
const task = this.d.board.get(input.id);
|
|
1248
1464
|
if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("release assignment changed");
|
|
1465
|
+
this.plannerWriteAuthority(by, task);
|
|
1249
1466
|
if (by !== USER && (this.isPii(task) || task.pinned)) throw new PreEffectToolRefusal("private or pinned release scope requires the person");
|
|
1250
1467
|
for (const id of new Set([task.release, input.release])) if (id != null) {
|
|
1251
1468
|
const release = this.d.board.release(id);
|
|
1252
1469
|
if (!release || ["frozen", "shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("release scope is frozen or final");
|
|
1470
|
+
this.plannerWriteAuthority(by, release);
|
|
1253
1471
|
}
|
|
1254
1472
|
return task;
|
|
1255
1473
|
};
|
|
@@ -1268,6 +1486,7 @@ export class Tasks {
|
|
|
1268
1486
|
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid release identity or revision");
|
|
1269
1487
|
const release = this.d.board.release(input.id);
|
|
1270
1488
|
if (!release || release.revision !== input.revision || !["planning", "active"].includes(release.state)) throw new PreEffectToolRefusal("release revision changed or scope frozen");
|
|
1489
|
+
this.plannerWriteAuthority(by, release);
|
|
1271
1490
|
if (input.state !== undefined && input.state !== "active") throw new PreEffectToolRefusal("invalid train state");
|
|
1272
1491
|
if (input.cut !== undefined && (!Number.isSafeInteger(input.cut) || input.cut <= 0 || input.cut > 8_640_000_000_000_000)) throw new PreEffectToolRefusal("release cut must be a positive safe UTC timestamp");
|
|
1273
1492
|
const dates = [release.plannedStart, input.cut ?? release.cut, release.ship].filter((date): date is number => date !== null);
|
|
@@ -1311,6 +1530,7 @@ export class Tasks {
|
|
|
1311
1530
|
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid release identity or revision");
|
|
1312
1531
|
const release = this.d.board.release(input.id);
|
|
1313
1532
|
if (!release || release.revision !== input.revision || release.state !== "active") throw new PreEffectToolRefusal("release revision changed or train is not active");
|
|
1533
|
+
this.plannerWriteAuthority(by, release);
|
|
1314
1534
|
if (this.d.board.releases().some(row => row.id !== input.id && ["active", "frozen"].includes(row.state))) throw new PreEffectToolRefusal("one open release train at a time");
|
|
1315
1535
|
if ((release.train?.history.length ?? 0) > RELEASE_TRAIN_HISTORY_LIMIT - 2) throw new PreEffectToolRefusal("release train history full; cut needs slots for cut and ship, drop this train and create a replacement");
|
|
1316
1536
|
const report = this.releaseReport(by, input.id);
|
|
@@ -1354,7 +1574,7 @@ export class Tasks {
|
|
|
1354
1574
|
if (!this.nameable(text) || this.screenOn() && (await this.screenCall(text)).label !== "clear") throw new PreEffectToolRefusal("release text was not cleared for public planning");
|
|
1355
1575
|
this.plannerWriteAuthority(by); // Privacy and planner identity may change while screening.
|
|
1356
1576
|
if (this.d.board.releases().some(release => release.name === input.name)) throw new PreEffectToolRefusal("release name already exists");
|
|
1357
|
-
const release = this.d.board.newRelease(input.name, input.goal.trim(), input.order);
|
|
1577
|
+
const release = this.d.board.newRelease(input.name, input.goal.trim(), input.order, by === USER ? null : by);
|
|
1358
1578
|
this.planningRecorded({ by, release: release.id, fields: ["created"], revision: release.revision });
|
|
1359
1579
|
return release;
|
|
1360
1580
|
}
|
|
@@ -1363,6 +1583,7 @@ export class Tasks {
|
|
|
1363
1583
|
this.planningAuthority(by);
|
|
1364
1584
|
const release = this.d.board.release(input.id);
|
|
1365
1585
|
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || !release || release.revision !== input.revision) throw new PreEffectToolRefusal("release revision changed");
|
|
1586
|
+
this.planningAuthority(by, release);
|
|
1366
1587
|
if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release is final");
|
|
1367
1588
|
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");
|
|
1368
1589
|
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");
|
|
@@ -1382,6 +1603,7 @@ export class Tasks {
|
|
|
1382
1603
|
}
|
|
1383
1604
|
|
|
1384
1605
|
async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; role?: unknown; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown; backlog?: unknown; goal?: unknown }, external?: GitHubSource, created?: () => void): Promise<Task> {
|
|
1606
|
+
const requestCheck = this.requestCheck(by);
|
|
1385
1607
|
external = external ? { ...external } : undefined;
|
|
1386
1608
|
if (external && (by !== USER || input.backlog !== true)) throw new PreEffectToolRefusal("external intake requires person-owned backlog staging");
|
|
1387
1609
|
if (input.backlog !== undefined && typeof input.backlog !== "boolean") throw new PreEffectToolRefusal("backlog must be a boolean");
|
|
@@ -1426,6 +1648,7 @@ export class Tasks {
|
|
|
1426
1648
|
// board, in no envelope and before no other model. A pattern match is PII without a call; no verdict is PII too.
|
|
1427
1649
|
const screening = this.screenOn();
|
|
1428
1650
|
const screened = screening && !signals.includes("pii") ? await this.screenCall(screenText({ ...text, detail: screenDetail, plan })) : undefined;
|
|
1651
|
+
requestCheck();
|
|
1429
1652
|
if (screened && screened.label !== "clear") signals.push("pii");
|
|
1430
1653
|
let cls = baseGiven;
|
|
1431
1654
|
let triaged = false;
|
|
@@ -1433,8 +1656,11 @@ export class Tasks {
|
|
|
1433
1656
|
if (!cls && this.d.triage) {
|
|
1434
1657
|
// Signals first: a PII task's text may only go to a model that is reached without leaving the campus network.
|
|
1435
1658
|
const pii = this.isPii({ signals });
|
|
1436
|
-
|
|
1659
|
+
const reached = !pii || await this.d.triage.onCampus().catch(() => false);
|
|
1660
|
+
requestCheck();
|
|
1661
|
+
if (reached) cls = await this.d.triage.classify(text.title, text.detail).catch(() => undefined);
|
|
1437
1662
|
else fenced = true;
|
|
1663
|
+
requestCheck();
|
|
1438
1664
|
triaged = !!cls;
|
|
1439
1665
|
}
|
|
1440
1666
|
// A claim is work the caller will do itself: without a class and a model to name one, it is implementation (#6).
|
|
@@ -1450,6 +1676,7 @@ export class Tasks {
|
|
|
1450
1676
|
if (reserved) this.requireCapabilities({ ...draft, signals, ...(external ? { external } : {}) }, reserved);
|
|
1451
1677
|
this.warmRepository({ refs: draft.refs, plan });
|
|
1452
1678
|
const repositoryMismatch = !this.isPii({ signals }) && this.repositories.mismatch(draft.refs, plan);
|
|
1679
|
+
requestCheck();
|
|
1453
1680
|
let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
|
|
1454
1681
|
if (repositoryMismatch) this.releaseNotice(`task #${task.id}: refs and plan name different repositories; check refs.repo before editing`);
|
|
1455
1682
|
if (this.d.coordination) this.coordinationModes.set(task.id, this.d.coordination());
|
|
@@ -1487,6 +1714,7 @@ export class Tasks {
|
|
|
1487
1714
|
|| typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("untouched return needs an id, revision and bounded reason");
|
|
1488
1715
|
const validate = () => {
|
|
1489
1716
|
const task = this.need(input.id, false, true);
|
|
1717
|
+
this.plannerWriteAuthority(by, task);
|
|
1490
1718
|
if (!["proposed", "in_progress"].includes(task.state) || (task.planningRevision ?? 0) !== input.revision || taskHasWorkEvidence(task)) throw new PreEffectToolRefusal("work has execution evidence or its planning revision changed");
|
|
1491
1719
|
if (by !== USER && task.pinned) throw new PreEffectToolRefusal("pinned work requires the person");
|
|
1492
1720
|
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
@@ -1719,9 +1947,18 @@ export class Tasks {
|
|
|
1719
1947
|
if (env.from === HUB && env.kind === "task" && env.refs?.backlog) {
|
|
1720
1948
|
const ids = env.refs.backlog;
|
|
1721
1949
|
if (!Array.isArray(ids) || !ids.length || ids.length > 64 || ids.some(id => !Number.isSafeInteger(id) || id <= 0)) return false;
|
|
1722
|
-
const current = ids.filter(id =>
|
|
1950
|
+
const current = ids.filter(id => {
|
|
1951
|
+
const task = this.d.board.get(id);
|
|
1952
|
+
return task?.state === "backlog" && this.taskPlanner(task).includes(peer);
|
|
1953
|
+
});
|
|
1723
1954
|
if (current.length !== ids.length || !this.d.roles?.[peer]?.includes("planner") || !this.d.bus.isRostered(peer)) {
|
|
1724
1955
|
this.addPlannerBacklog(peer, current);
|
|
1956
|
+
for (const id of ids) {
|
|
1957
|
+
const task = this.d.board.get(id);
|
|
1958
|
+
if (task?.state === "backlog" && !this.taskPlanner(task).includes(peer)) {
|
|
1959
|
+
for (const recipient of this.taskPlanner(task)) this.addPlannerBacklog(recipient, [id]);
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1725
1962
|
return false;
|
|
1726
1963
|
}
|
|
1727
1964
|
return true;
|
|
@@ -1729,6 +1966,10 @@ export class Tasks {
|
|
|
1729
1966
|
if (env.from === HUB && env.refs?.task) {
|
|
1730
1967
|
const task = this.d.board.get(Number(env.refs.task));
|
|
1731
1968
|
if (task?.state === "cancelled") return env.kind === "task" && typeof env.refs.taskCancelled === "number" && env.refs.taskCancelled === task.cancelled?.flowRevision;
|
|
1969
|
+
if (env.refs.taskRecipient === "planner") return !!task && this.taskPlanner(task).includes(peer);
|
|
1970
|
+
if (env.refs.planReview !== undefined) return !!task && !isClosedTaskState(task.state) && (task.planningRevision ?? 0) === env.refs.planReview
|
|
1971
|
+
&& this.plannerHolders().includes(peer) && this.planAuthor(task) !== peer && Number.isSafeInteger(env.refs.planReviewFlow)
|
|
1972
|
+
&& !task.history.slice(env.refs.planReviewFlow).some(entry => entry.event === "plan reviewed");
|
|
1732
1973
|
}
|
|
1733
1974
|
const c = this.conditional.get(env.id);
|
|
1734
1975
|
if (!c || c.peer !== peer) return true;
|
|
@@ -1971,6 +2212,7 @@ export class Tasks {
|
|
|
1971
2212
|
}
|
|
1972
2213
|
task = current; // Reviewer-only and quiet bookkeeping updates do not supersede ownership authority.
|
|
1973
2214
|
if (gap) {
|
|
2215
|
+
this.requestIndependentPlanReview(current);
|
|
1974
2216
|
this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}; planner: call hub_planner_groom then hub_plan_review`);
|
|
1975
2217
|
if (mode === "enforce") {
|
|
1976
2218
|
if (opts.claim && opts.namedOwner === by) stagedClaimGap = gap;
|
|
@@ -2016,10 +2258,11 @@ export class Tasks {
|
|
|
2016
2258
|
const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
|
|
2017
2259
|
opts.plannerPull?.guard();
|
|
2018
2260
|
const assignmentNote = this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a);
|
|
2019
|
-
const
|
|
2261
|
+
const binding = this.plannerBinding(task, a, !!opts.plannerPull);
|
|
2262
|
+
const assignmentExtra = { ...this.reviewMetadata(a), ...(binding ? { plannerReason: binding.plannerReason } : {}), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) };
|
|
2020
2263
|
const next = opts.plannerPull
|
|
2021
|
-
? this.d.board.pullAssigned(task.id, by, a.owner, opts.plannerPull.revision, opts.plannerPull.note, opts.plannerPull.withheld, a.reviewer ?? null, assignmentNote, assignmentExtra, task.history.length)
|
|
2022
|
-
: 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 } : {}) }, assignmentNote, assignmentExtra, opts.expectedFlowRevision);
|
|
2264
|
+
? this.d.board.pullAssigned(task.id, by, a.owner, opts.plannerPull.revision, opts.plannerPull.note, opts.plannerPull.withheld, a.reviewer ?? null, assignmentNote, assignmentExtra, task.history.length, binding)
|
|
2265
|
+
: this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(binding ? { planner: binding.planner } : {}), ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, assignmentNote, assignmentExtra, opts.expectedFlowRevision);
|
|
2023
2266
|
if (opts.plannerPull) this.planningRecorded({ by, task: next.id, fields: ["pull", "reserved"], revision: next.planningRevision! });
|
|
2024
2267
|
// What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
|
|
2025
2268
|
// escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
|
|
@@ -2303,7 +2546,7 @@ export class Tasks {
|
|
|
2303
2546
|
this.releaseNotice(text);
|
|
2304
2547
|
try { this.d.stallNotice?.(stall); } catch { /* advisory never changes task authority */ }
|
|
2305
2548
|
// Advice joins the planner's next delivery; it must not manufacture a turn beside a withheld backlog batch.
|
|
2306
|
-
for (const
|
|
2549
|
+
for (const peer of this.taskPlanner(task).filter(peer => this.d.bus.isRostered(peer))) this.releaseNotice(`task #${task.id}: stall notice unavailable`, () => this.d.tell?.(peer, noteLine(HUB, "finding", text)));
|
|
2307
2550
|
}
|
|
2308
2551
|
async cancel(id: unknown, by: PeerId, input: { reason: string; supersededBy?: string; expectedFlowRevision?: number }): Promise<Task> {
|
|
2309
2552
|
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");
|
|
@@ -2314,7 +2557,7 @@ export class Tasks {
|
|
|
2314
2557
|
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
|
|
2315
2558
|
if (by !== USER && this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("cancellation on ordinary work is unavailable during a private turn");
|
|
2316
2559
|
if (by !== USER) {
|
|
2317
|
-
this.planningAuthority(by);
|
|
2560
|
+
this.planningAuthority(by, task);
|
|
2318
2561
|
const proposer = task.history.find(h => h.event === "proposed" || h.event === "backlogged")?.by;
|
|
2319
2562
|
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");
|
|
2320
2563
|
}
|
|
@@ -2509,7 +2752,16 @@ export class Tasks {
|
|
|
2509
2752
|
if (issue) issues.set(key, issue);
|
|
2510
2753
|
}
|
|
2511
2754
|
this.planningAuthority(by);
|
|
2512
|
-
const
|
|
2755
|
+
const all = this.d.board.list(), current = all.filter(included), visible = current.filter(task => !this.isPii(task));
|
|
2756
|
+
// Include only bounded public dependency context, so approved intermediate phases do not disappear from reachability.
|
|
2757
|
+
const indexed = new Map(all.map(task => [task.id, task])), dependencies = new Map<number, readonly number[]>();
|
|
2758
|
+
const pending = visible.slice(0, 500).map(task => task.id).reverse(), visited = new Set<number>();
|
|
2759
|
+
while (pending.length && visited.size < 500) {
|
|
2760
|
+
const id = pending.pop()!; if (visited.has(id)) continue; visited.add(id);
|
|
2761
|
+
const task = indexed.get(id); if (!task || this.isPii(task)) continue;
|
|
2762
|
+
dependencies.set(id, task.deps ?? []); pending.push(...task.deps ?? []);
|
|
2763
|
+
}
|
|
2764
|
+
const dependencyTruncated = pending.some(id => !visited.has(id));
|
|
2513
2765
|
const peers = [...new Set([...Object.keys(this.states()), ...current.flatMap(task => task.owner ? [task.owner] : [])])];
|
|
2514
2766
|
const reviewedIssues = new Map(visible.flatMap(task => task.sdlc?.planReview?.issue ? [[task.id, task.sdlc.planReview.issue] as const] : []));
|
|
2515
2767
|
const acceptedIssues = new Map(visible.flatMap(task => task.sdlc?.acceptance ? [[task.id, task.sdlc.acceptance] as const] : []));
|
|
@@ -2523,7 +2775,7 @@ export class Tasks {
|
|
|
2523
2775
|
if (!result.owner) for (const refusal of result.refusals ?? []) if (refusal.role === "owner") blockers.push({ task: task.id, code: refusal.code === "instance" || refusal.code === "family" ? "policy" : refusal.code });
|
|
2524
2776
|
if (this.waitsFor(task).length) blockers.push({ task: task.id, code: "dependency" });
|
|
2525
2777
|
}
|
|
2526
|
-
const digest = plannerGrooming(visible, { repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
|
|
2778
|
+
const digest = plannerGrooming(visible, { dependencies, dependencyTruncated, repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
|
|
2527
2779
|
limits: Object.fromEntries(peers.map(peer => [peer, this.wipLimit(peer)])), wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])),
|
|
2528
2780
|
states, issues, reviewedIssues, acceptedIssues, blockers });
|
|
2529
2781
|
return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
|
|
@@ -2706,6 +2958,7 @@ export class Tasks {
|
|
|
2706
2958
|
if (Object.keys(raw).some(key => !["revision", "reason", "review", "ci"].includes(key)) || !Number.isSafeInteger(raw.revision) || (raw.revision as number) < 0
|
|
2707
2959
|
|| typeof raw.reason !== "string" || !raw.reason.trim() || raw.reason.length > 1000) throw new PreEffectToolRefusal("plan review needs a current revision and bounded reason");
|
|
2708
2960
|
const task = this.need(id, false, true, true), revision = raw.revision as number;
|
|
2961
|
+
this.planReviewAuthority(by, task);
|
|
2709
2962
|
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
|
|
2710
2963
|
if ((task.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("planning revision changed");
|
|
2711
2964
|
let scope: CostPlan;
|
|
@@ -2719,6 +2972,7 @@ export class Tasks {
|
|
|
2719
2972
|
const flow = task.history.length, withheld = await this.withholds("note", raw.reason, task);
|
|
2720
2973
|
this.plannerWriteAuthority(by);
|
|
2721
2974
|
const observed = this.need(task.id, false, true, true);
|
|
2975
|
+
this.planReviewAuthority(by, observed);
|
|
2722
2976
|
if (observed.history.length !== flow || (observed.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
|
|
2723
2977
|
const identity = this.planIssueIdentity(observed);
|
|
2724
2978
|
if (identity?.issue && this.isPii(observed)) throw new PreEffectToolRefusal("private issue plan review readback unavailable");
|
|
@@ -2727,10 +2981,12 @@ export class Tasks {
|
|
|
2727
2981
|
if (issue && !this.isPii(task) && !this.nameable(JSON.stringify(issue))) throw new PreEffectToolRefusal("plan review metadata matches a PII pattern");
|
|
2728
2982
|
this.plannerWriteAuthority(by);
|
|
2729
2983
|
const current = this.need(task.id, false, true, true);
|
|
2984
|
+
const authorship = this.planReviewAuthority(by, current);
|
|
2730
2985
|
if (current.history.length !== flow || (current.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
|
|
2731
2986
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2732
2987
|
let updated = this.d.board.update(current.id, by, "plan reviewed", { ...(Object.keys(scope).length ? { plan: { ...current.plan, ...scope } } : {}), sdlc: { ...current.sdlc, stage: current.sdlc?.stage ?? lifecycleStage(config, "plan"),
|
|
2733
|
-
planReview: { planner: by, planningRevision: revision, recordedAt: Date.now(), ...(Object.keys(scope).length ? { scope } : {}), ...(issue ? { issue } : {}) } } }, raw.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
|
|
2988
|
+
planReview: { planner: by, ...authorship, planningRevision: revision, recordedAt: Date.now(), ...(Object.keys(scope).length ? { scope } : {}), ...(issue ? { issue } : {}) } } }, raw.reason.trim(), { ...authorship, ...(withheld ? { withheld: true } : {}) }, flow);
|
|
2989
|
+
this.planReviewRequests.delete(updated.id);
|
|
2734
2990
|
if (config.mode !== "off") updated = this.linkIssue(updated); // #694
|
|
2735
2991
|
if (updated.state === "proposed" && !updated.owner) {
|
|
2736
2992
|
this.offered.delete(updated.id);
|
|
@@ -2741,21 +2997,40 @@ export class Tasks {
|
|
|
2741
2997
|
}
|
|
2742
2998
|
|
|
2743
2999
|
/** Artifact links and claimed local gates stay on the board; no outside text is accepted. */
|
|
2744
|
-
|
|
3000
|
+
/**
|
|
3001
|
+
* `hub_sdlc_record` (#509a): the receipt may also name `deliverables`. They are split off here, claims vetted and the files hashed by the hub,
|
|
3002
|
+
* and only then is the rest recorded as before. A receipt with deliverables and nothing else records just the deliverables.
|
|
3003
|
+
*/
|
|
3004
|
+
async recordSdlc(by: PeerId, id: unknown, input: unknown): Promise<Task> {
|
|
3005
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || !("deliverables" in input)) return this.recordLifecycle(by, id, input);
|
|
3006
|
+
const { deliverables, ...receipt } = input as Record<string, unknown>;
|
|
2745
3007
|
const task = this.need(id, false, true);
|
|
2746
3008
|
if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
|
|
3009
|
+
const claims = this.deliverableClaims(deliverables, task);
|
|
3010
|
+
const records = await this.hashClaims(claims, task, true);
|
|
3011
|
+
const current = this.need(id, false, true);
|
|
3012
|
+
if (current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed while its deliverables were hashed; record them again");
|
|
3013
|
+
return this.recordLifecycle(by, id, receipt, records);
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
recordLifecycle(by: PeerId, id: unknown, input: unknown, deliverables?: DeliverableRecord[]): Task {
|
|
3017
|
+
const task = this.need(id, false, true);
|
|
2747
3018
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2748
3019
|
const next = sdlcReceipt(input, task.sdlc, config, VERSION);
|
|
3020
|
+
const definition = stageDefinition(config, next.stage);
|
|
3021
|
+
const plannerStage = !!input && typeof input === "object" && !Array.isArray(input) && Object.hasOwn(input, "stage")
|
|
3022
|
+
&& Object.keys(input).every(key => ["stage", "repo", "issue"].includes(key))
|
|
3023
|
+
&& (definition?.actor === "planner" || definition?.actor === "owner/planner") && this.taskPlanner(task).includes(by);
|
|
3024
|
+
if (by !== USER && by !== task.owner && by !== task.reviewer && !plannerStage) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence; a stage planner may record stage identity");
|
|
2749
3025
|
// The spec identity is person-owned in enforce. No observation cache or restart can reopen a relink bypass.
|
|
2750
3026
|
const specRelink = (task.sdlc?.repo !== undefined && next.repo !== task.sdlc.repo) || (task.sdlc?.issue !== undefined && next.issue !== task.sdlc.issue);
|
|
2751
3027
|
if (config.mode === "enforce" && by !== USER && specRelink) throw new PreEffectToolRefusal("only the person may relink an established repository or issue in enforce");
|
|
2752
|
-
const definition = stageDefinition(config, next.stage);
|
|
2753
3028
|
if (next.redFirst && input && typeof input === "object" && "redFirst" in input && !this.isPii(task) && !this.publicMetadataText(JSON.stringify(next.redFirst))) throw new PreEffectToolRefusal("red-first metadata matches a PII pattern; use private work");
|
|
2754
3029
|
// #481 S1: entering a build stage in enforce needs the person's spec acceptance; the person may move any stage.
|
|
2755
3030
|
if (config.mode === "enforce" && by !== USER && next.stage !== task.sdlc?.stage && definition && canonicalStage(definition.base) === "build" && specState(next, undefined) === "missing") throw new PreEffectToolRefusal(this.specText(task, "missing", "blocked"));
|
|
2756
3031
|
if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
|
|
2757
3032
|
const actor = definition?.actor;
|
|
2758
|
-
const acts = actor === "owner" ? by === task.owner : actor === "reviewer" ? by === task.reviewer : actor === "planner" ? this.
|
|
3033
|
+
const acts = actor === "owner" ? by === task.owner : actor === "reviewer" ? by === task.reviewer : actor === "planner" ? this.taskPlanner(task).includes(by) : actor === "owner/planner" && (by === task.owner || this.taskPlanner(task).includes(by));
|
|
2759
3034
|
if (!acts) throw new PreEffectToolRefusal(`lifecycle stage requires its ${actor ?? "unknown"} actor or the person`);
|
|
2760
3035
|
}
|
|
2761
3036
|
if (input && typeof input === "object" && ("criteria" in input || "phase" in input)) {
|
|
@@ -2771,7 +3046,7 @@ export class Tasks {
|
|
|
2771
3046
|
if (next.reviewedHead !== undefined && (next.reviewedHead !== task.sdlc?.reviewedHead || (suppliedHead && artifactChanged))) this.mine(task, by, "reviewer", true);
|
|
2772
3047
|
const departure = task.history.findLast(h => h.by === USER && h.planDeparture)?.planDeparture;
|
|
2773
3048
|
const invalidatesDeparture = !!departure && (artifactChanged || next.localHead !== task.sdlc?.localHead && next.localHead !== undefined && next.localHead !== departure.head || next.reviewedHead !== task.sdlc?.reviewedHead && next.reviewedHead !== undefined && next.reviewedHead !== departure.head);
|
|
2774
|
-
return this.d.board.update(task.id, by, "lifecycle", { sdlc: next }, undefined, { ...this.planObservation({ ...task, sdlc: next }), ...(invalidatesDeparture ? { planDepartureInvalidated: true } : {}) });
|
|
3049
|
+
return this.d.board.update(task.id, by, "lifecycle", { sdlc: next }, undefined, { ...this.planObservation({ ...task, sdlc: next }), ...(invalidatesDeparture ? { planDepartureInvalidated: true } : {}), ...(deliverables ? { deliverables } : {}) });
|
|
2775
3050
|
}
|
|
2776
3051
|
|
|
2777
3052
|
async deliveryQueue(repo: string) {
|
|
@@ -2981,7 +3256,9 @@ export class Tasks {
|
|
|
2981
3256
|
const task = this.need(id, false, true), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2982
3257
|
if (config.mode === "off" || by === USER || !input || typeof input !== "object" || !("stage" in input)) return;
|
|
2983
3258
|
let next: ReturnType<typeof sdlcReceipt>;
|
|
2984
|
-
|
|
3259
|
+
// #509a: `deliverables` ride in the receipt but are not a lifecycle field: recordSdlc splits them off, so the gate reads the same receipt it will record.
|
|
3260
|
+
const { deliverables: _carried, ...lifecycleInput } = input as Record<string, unknown>;
|
|
3261
|
+
try { next = sdlcReceipt(lifecycleInput, task.sdlc, config, VERSION); } catch { return; } // recordLifecycle reports an invalid receipt
|
|
2985
3262
|
const definition = stageDefinition(config, next.stage);
|
|
2986
3263
|
if (next.stage === task.sdlc?.stage || !definition || canonicalStage(definition.base) !== "build") return;
|
|
2987
3264
|
const accepted = next.acceptance;
|
|
@@ -3059,6 +3336,7 @@ export class Tasks {
|
|
|
3059
3336
|
return base === "build" || base === "implement";
|
|
3060
3337
|
}
|
|
3061
3338
|
async acceptReviewed(by: PeerId, id: unknown, plan?: unknown, refs?: unknown): Promise<Task> {
|
|
3339
|
+
const requestCheck = this.requestCheck(by);
|
|
3062
3340
|
const metadata = acceptRefs(refs);
|
|
3063
3341
|
const task = this.need(id, false, true);
|
|
3064
3342
|
this.mine(task, by, "owner", true);
|
|
@@ -3069,6 +3347,7 @@ export class Tasks {
|
|
|
3069
3347
|
if ((this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode === "off" || !this.isBuildTask(task)) return this.accept(by, id, plan, metadata);
|
|
3070
3348
|
await this.specGate(by, id);
|
|
3071
3349
|
await this.planEntryGate(by, draft);
|
|
3350
|
+
requestCheck();
|
|
3072
3351
|
const current = this.need(id, false, true);
|
|
3073
3352
|
this.mine(current, by, "owner", true);
|
|
3074
3353
|
if (current.history.length !== task.history.length || current.owner !== task.owner) throw new PreEffectToolRefusal("task changed during build admission");
|
|
@@ -3225,6 +3504,101 @@ export class Tasks {
|
|
|
3225
3504
|
return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
|
|
3226
3505
|
}
|
|
3227
3506
|
|
|
3507
|
+
/**
|
|
3508
|
+
* #509a: the task's deliverables as they are now against what was delivered and what was approved. `changedSinceReview` is true only for a file
|
|
3509
|
+
* measured to differ from the bytes a reviewer approved (or, before approval, from the bytes delivered); a file the hub cannot read is
|
|
3510
|
+
* `unknown`, never `unchanged`. Ids, hashes and sizes only for a private record. Undefined when the task delivered nothing.
|
|
3511
|
+
*/
|
|
3512
|
+
async deliverableStatus(task: Task): Promise<{ state: "none" | "delivered" | "approved"; changedSinceReview: boolean; files: DeliverableStatus[] } | undefined> {
|
|
3513
|
+
const delivered = latestDeliverables(task.history);
|
|
3514
|
+
if (!delivered) return undefined;
|
|
3515
|
+
const approved = task.state === "approved" ? approvedDeliverables(task.history) : undefined;
|
|
3516
|
+
const basis = approved ?? delivered;
|
|
3517
|
+
const checks = await this.checkRecords(basis, task);
|
|
3518
|
+
const shown = publicDeliverables(basis, this.nameable)!;
|
|
3519
|
+
const files = checks.map((check, i): DeliverableStatus => {
|
|
3520
|
+
const { sha256, id, format, version, path, private: hidden } = shown[i]!;
|
|
3521
|
+
return { id, sha256, state: check.state, ...(path ? { path } : {}), format, ...(version ? { version } : {}), ...(hidden ? { private: true as const } : {}) };
|
|
3522
|
+
});
|
|
3523
|
+
const changedSinceReview = files.some(file => file.state === "changed");
|
|
3524
|
+
this.deliverableSeen.set(task.id, { key: Tasks.deliverableKey(basis), at: Date.now(), result: deliverableResult(files.map(file => file.state)) });
|
|
3525
|
+
return { state: approved ? "approved" : "delivered", changedSinceReview, files };
|
|
3526
|
+
}
|
|
3527
|
+
|
|
3528
|
+
/** The last live check per task (#317): what the dashboard may say about "changed since review" without hashing on its 1 s poll. Keyed by the records it measured. */
|
|
3529
|
+
private deliverableSeen = new Map<number, { key: string; at: number; result: DeliverableResult }>();
|
|
3530
|
+
private static deliverableKey = (records: readonly DeliverableRecord[]) => records.map(record => `${record.id}:${record.sha256}`).join(",");
|
|
3531
|
+
|
|
3532
|
+
/**
|
|
3533
|
+
* #317: the deliverables of the board's tasks, newest task first, for `ahub deliverables`. `approved` is what a reviewer's approval bound (else what was delivered).
|
|
3534
|
+
* Without `check` this reads the board only: no file is opened, so no row claims `changedSinceReview`. With `check` every file is measured now (one task at a time).
|
|
3535
|
+
* Every path goes through `publicDeliverables`, so a PII task or a path under `pii.paths` shows id, hash and size only.
|
|
3536
|
+
*/
|
|
3537
|
+
async deliverableList(opts: { task?: number; approvedOnly?: boolean; check?: boolean } = {}): Promise<DeliverableRow[]> {
|
|
3538
|
+
const rows: DeliverableRow[] = [];
|
|
3539
|
+
const tasks = (opts.task !== undefined ? [this.d.board.get(opts.task)].filter((task): task is Task => !!task) : this.d.board.list()).sort((a, b) => b.id - a.id);
|
|
3540
|
+
for (const task of tasks) {
|
|
3541
|
+
const delivered = latestDeliverables(task.history);
|
|
3542
|
+
if (!delivered) continue;
|
|
3543
|
+
const approved = task.state === "approved" ? approvedDeliverables(task.history) : undefined;
|
|
3544
|
+
if (opts.approvedOnly && !approved) continue;
|
|
3545
|
+
const basis = approved ?? delivered, shown = publicDeliverables(basis, this.nameable)!;
|
|
3546
|
+
const row: DeliverableRow = { task: task.id, title: this.publicTitle(task), taskState: task.state, state: approved ? "approved" : "delivered", checked: false,
|
|
3547
|
+
files: shown.map(({ id, path, format, version, sha256, bytes, private: hidden }) => ({ id, ...(path ? { path } : {}), format, ...(version ? { version } : {}), sha256, bytes, ...(hidden ? { private: true as const } : {}) })) };
|
|
3548
|
+
if (opts.check) {
|
|
3549
|
+
const status = await this.deliverableStatus(task);
|
|
3550
|
+
if (status) { row.checked = true; row.changedSinceReview = status.changedSinceReview; row.result = deliverableResult(status.files.map(file => file.state)); row.files = row.files.map((file, i) => ({ ...file, state: status.files[i]!.state })); }
|
|
3551
|
+
}
|
|
3552
|
+
rows.push(row);
|
|
3553
|
+
}
|
|
3554
|
+
return rows;
|
|
3555
|
+
}
|
|
3556
|
+
|
|
3557
|
+
/** What the dashboard card shows: the board's records, plus the last live check when it measured exactly these records. Opens no file. */
|
|
3558
|
+
deliverableBadge(task: Task): { state: "delivered" | "approved"; files: DeliverableRow["files"]; checked?: { at: number; result: DeliverableResult } } | undefined {
|
|
3559
|
+
const delivered = latestDeliverables(task.history);
|
|
3560
|
+
if (!delivered) return undefined;
|
|
3561
|
+
const approved = task.state === "approved" ? approvedDeliverables(task.history) : undefined;
|
|
3562
|
+
const basis = approved ?? delivered, seen = this.deliverableSeen.get(task.id);
|
|
3563
|
+
return { state: approved ? "approved" : "delivered",
|
|
3564
|
+
files: publicDeliverables(basis, this.nameable)!.map(({ id, path, format, version, sha256, bytes, private: hidden }) => ({ id, ...(path ? { path } : {}), format, ...(version ? { version } : {}), sha256, bytes, ...(hidden ? { private: true as const } : {}) })),
|
|
3565
|
+
...(seen && seen.key === Tasks.deliverableKey(basis) ? { checked: { at: seen.at, result: seen.result } } : {}) };
|
|
3566
|
+
}
|
|
3567
|
+
|
|
3568
|
+
/** Check records against the owner's root; with that root unproven every file is `unknown` (never `unchanged`, never an exception). */
|
|
3569
|
+
private async checkRecords(records: DeliverableRecord[], task: Task) {
|
|
3570
|
+
try { return await checkDeliverables(records, this.deliverablePorts(task)); }
|
|
3571
|
+
catch (error) { return records.map(record => ({ record, state: "unknown" as const, why: error instanceof Error ? error.message : "unreadable" })); }
|
|
3572
|
+
}
|
|
3573
|
+
|
|
3574
|
+
/** What the deliverable reader needs from the host: the project root, the secrets denylist, and which files make a record private (#509a). */
|
|
3575
|
+
private deliverablePorts(task: Task): DeliverablePorts {
|
|
3576
|
+
const taskPrivate = this.isPii(task);
|
|
3577
|
+
// The owner's own root: a declared worktree instance writes under its worktree, so its deliverables are hashed there, never in the canonical
|
|
3578
|
+
// checkout (which holds a different file or none). With the root unproven there is nothing safe to hash: refuse rather than guess.
|
|
3579
|
+
const root = !this.d.executionRoot || !task.owner || task.owner === USER ? this.d.cwd : this.d.executionRoot(task.owner);
|
|
3580
|
+
if (root === undefined) throw new DeliverableRefusal(`deliverables: ${task.owner}'s execution root is not verified right now, so its files cannot be hashed`);
|
|
3581
|
+
return { cwd: root, deny: this.d.deny ?? [], private: path => taskPrivate || matchesPolicy(root, path, this.d.piiPaths ?? [], true) };
|
|
3582
|
+
}
|
|
3583
|
+
|
|
3584
|
+
/**
|
|
3585
|
+
* #509a: parse and vet a call's deliverable claims before any effect. A claimed hash is a refusal (the hub hashes the files itself), and so is a
|
|
3586
|
+
* path outside the project, on the denylist, inside the hub's own state, not a regular file, missing or too large. Nothing is hashed or stored here.
|
|
3587
|
+
*/
|
|
3588
|
+
private deliverableClaims(input: unknown, task: Task, preEffect = true) {
|
|
3589
|
+
try {
|
|
3590
|
+
const claims = parseDeliverableClaims(input);
|
|
3591
|
+
vetDeliverables(claims, this.deliverablePorts(task));
|
|
3592
|
+
return claims;
|
|
3593
|
+
} catch (error) { throw new (preEffect ? PreEffectToolRefusal : Error)(error instanceof Error ? error.message : "invalid deliverables"); }
|
|
3594
|
+
}
|
|
3595
|
+
|
|
3596
|
+
/** Hash vetted claims now. A refusal during the read (the file changed while it was read) is still before any write. */
|
|
3597
|
+
private async hashClaims(claims: ReturnType<Tasks["deliverableClaims"]>, task: Task, preEffect = true): Promise<DeliverableRecord[]> {
|
|
3598
|
+
try { return await hashDeliverables(claims, this.deliverablePorts(task)); }
|
|
3599
|
+
catch (error) { throw new (preEffect ? PreEffectToolRefusal : Error)(error instanceof DeliverableRefusal || error instanceof Error ? error.message : "deliverables could not be hashed"); }
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3228
3602
|
/** Tasks whose check is queued or running, and the owner it was started for. */
|
|
3229
3603
|
private readonly checking = new Map<number, PeerId | null>();
|
|
3230
3604
|
private checkQueue: Promise<void> = Promise.resolve();
|
|
@@ -3252,11 +3626,17 @@ export class Tasks {
|
|
|
3252
3626
|
return task;
|
|
3253
3627
|
}
|
|
3254
3628
|
|
|
3255
|
-
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs): Promise<Task> {
|
|
3256
|
-
const
|
|
3629
|
+
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs, deliverables?: unknown): Promise<Task> {
|
|
3630
|
+
const requestCheck = this.requestCheck(by);
|
|
3631
|
+
const valid = () => { requestCheck(); return this.completionCurrent(id, by, true); };
|
|
3632
|
+
// #509a: a bad claim (a claimed hash, an outside or denied path, a missing file) is refused before any effect, with nothing recorded.
|
|
3633
|
+
const claims = deliverables === undefined ? undefined : this.deliverableClaims(deliverables, valid());
|
|
3257
3634
|
// The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
|
|
3258
3635
|
const withheld = await this.withholds("summary", summary, valid());
|
|
3259
3636
|
let task = valid();
|
|
3637
|
+
// The hub hashes the files itself, once, here: what the owner delivered is what this read, whatever the file becomes afterwards.
|
|
3638
|
+
const records = claims ? await this.hashClaims(claims, task, true) : undefined;
|
|
3639
|
+
task = valid();
|
|
3260
3640
|
// #481 S1: done without a separate accept starts build work too: the same live spec check as accept, before any write.
|
|
3261
3641
|
if (task.state === "proposed" || task.state === "changes_requested") {
|
|
3262
3642
|
const admission = task;
|
|
@@ -3302,12 +3682,12 @@ export class Tasks {
|
|
|
3302
3682
|
}
|
|
3303
3683
|
if (task.state === "proposed" || task.state === "changes_requested") task = this.withLink(this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE), link, refs); // done without a separate accept
|
|
3304
3684
|
const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
|
|
3305
|
-
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link);
|
|
3685
|
+
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link, records, requestCheck);
|
|
3306
3686
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
3307
3687
|
this.checking.set(task.id, task.owner);
|
|
3308
3688
|
const checkedRefs = cleanRefs(refs);
|
|
3309
3689
|
this.warmRepository({ ...task, refs: { ...task.refs, ...checkedRefs } });
|
|
3310
|
-
const pending = this.d.board.update(task.id, by, "done (checking)", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...checkedRefs } }), refs: checkedRefs, ...(task.sdlc ? { sdlc: task.sdlc } : {}) }, summary, { withheld: withheld || undefined }, task.history.length, HOST_CODE_SYMBOLS);
|
|
3690
|
+
const pending = this.d.board.update(task.id, by, "done (checking)", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...checkedRefs } }), refs: checkedRefs, ...(task.sdlc ? { sdlc: task.sdlc } : {}) }, summary, { withheld: withheld || undefined, ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
3311
3691
|
this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
|
|
3312
3692
|
const seen = { events: pending.history.length, owner: pending.owner };
|
|
3313
3693
|
this.pendingChecks++;
|
|
@@ -3438,11 +3818,15 @@ export class Tasks {
|
|
|
3438
3818
|
}
|
|
3439
3819
|
|
|
3440
3820
|
/** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
|
|
3441
|
-
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink): Promise<Task> {
|
|
3821
|
+
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink, records?: DeliverableRecord[], requestCheck: () => void = () => {}): Promise<Task> {
|
|
3822
|
+
requestCheck();
|
|
3823
|
+
// A check's completion carries the records its `done (checking)` bound: the same bytes the owner delivered, not a later read.
|
|
3824
|
+
records ??= latestPendingDeliverables(task);
|
|
3442
3825
|
let selection = this.poolReview(task, refs);
|
|
3443
3826
|
let reviewer = selection?.reviewer ?? task.reviewer, reviewEvidence: PrEvidence | undefined;
|
|
3444
3827
|
if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
|
|
3445
3828
|
reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
|
|
3829
|
+
requestCheck();
|
|
3446
3830
|
task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
|
|
3447
3831
|
selection = this.poolReview(task, refs);
|
|
3448
3832
|
reviewer = selection?.reviewer ?? task.reviewer;
|
|
@@ -3450,6 +3834,7 @@ export class Tasks {
|
|
|
3450
3834
|
// A delayed local check needs a new readback; a completion-selected reviewer also needs optional facts in off mode.
|
|
3451
3835
|
if (!admission && ((reviewer && (this.d.sdlc?.config().mode ?? "off") !== "off") || this.d.prepareReviewBrief)) {
|
|
3452
3836
|
admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence, undefined, staged => this.withLink(staged, link, refs), !reviewer);
|
|
3837
|
+
requestCheck();
|
|
3453
3838
|
task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
|
|
3454
3839
|
}
|
|
3455
3840
|
selection = this.poolReview(task, refs);
|
|
@@ -3460,9 +3845,10 @@ export class Tasks {
|
|
|
3460
3845
|
const completedRefs = cleanRefs(refs);
|
|
3461
3846
|
this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
3462
3847
|
const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
3463
|
-
const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
3848
|
+
const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
3464
3849
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
3465
|
-
|
|
3850
|
+
const memorySymbols = this.completionMemorySymbols(next);
|
|
3851
|
+
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\nChanged-symbol memory anchors: ${memorySymbols.state === "known" ? memorySymbols.nodes.length : "unknown (no complete code facts)"}\n${summary ?? ""}`, withheld, memorySymbols);
|
|
3466
3852
|
this.tellCompleted(next, summary, withheld);
|
|
3467
3853
|
if (!reviewer) {
|
|
3468
3854
|
this.settleMemory(next);
|
|
@@ -3650,7 +4036,19 @@ export class Tasks {
|
|
|
3650
4036
|
const checklistLines = checklistTemplates.map(template =>
|
|
3651
4037
|
template.replaceAll("{target}", target).replaceAll("{check}", checkResult)
|
|
3652
4038
|
);
|
|
3653
|
-
|
|
4039
|
+
// #507b: the task's recorded acceptance criteria (the #481 S8 checklist, ids and all). A PII task shows none: its checklist is private.
|
|
4040
|
+
const recorded = this.isPii(task) ? undefined : checklistOf(task.sdlc);
|
|
4041
|
+
const criteriaBlock = recorded?.criteria.length
|
|
4042
|
+
? ["Acceptance criteria (name an id at the start of an unmet item, such as \"AC1: what is missing\"):",
|
|
4043
|
+
...recorded.criteria.map(c => `- ${c.id}${c.required ? "" : " (optional)"}: ${c.result}`)].join("\n") + "\n"
|
|
4044
|
+
: "";
|
|
4045
|
+
// #509a: what was delivered, by id, hash and size. A private record (a PII task, or a path under pii.paths) shows no path.
|
|
4046
|
+
const delivered = publicDeliverables(latestDeliverables(task.history), this.nameable);
|
|
4047
|
+
const deliverablesBlock = delivered?.length
|
|
4048
|
+
? ["Deliverables (the hub hashed these files when the task was done; approval binds these hashes):",
|
|
4049
|
+
...delivered.map(d => `- ${d.id}: ${d.path ?? "(path withheld)"} [${d.format}${d.version ? ` ${d.version}` : ""}] sha256 ${d.sha256.slice(0, 16)}, ${d.bytes} bytes`)].join("\n") + "\n"
|
|
4050
|
+
: "";
|
|
4051
|
+
const checklist = deliverablesBlock + criteriaBlock + [
|
|
3654
4052
|
"Checklist:",
|
|
3655
4053
|
...this.planGuardItems(task).map(item => `- Check the plan against this live fail before the remaining checks: ${item.line}`),
|
|
3656
4054
|
...checklistLines.map(line => `- ${line}`),
|
|
@@ -3718,6 +4116,39 @@ export class Tasks {
|
|
|
3718
4116
|
} catch { return undefined; }
|
|
3719
4117
|
}
|
|
3720
4118
|
|
|
4119
|
+
/** Read-time code advice uses this project's tree only; declared foreign or inconsistent scope is unknown. */
|
|
4120
|
+
memoryCodeEligible(task: Task): boolean {
|
|
4121
|
+
if (this.isPii(task)) return false;
|
|
4122
|
+
const repository = this.codeSymbolsRepository(task);
|
|
4123
|
+
return !!repository && repository.key === this.repositories.project.key && !this.repositories.mismatch(task.refs, task.plan);
|
|
4124
|
+
}
|
|
4125
|
+
|
|
4126
|
+
/** Read-only host input for exists verification; private/foreign/stale observations supply nothing. */
|
|
4127
|
+
memorySymbolCandidates(task: Task): string[] | undefined {
|
|
4128
|
+
if (this.isPii(task) || !this.d.store) return undefined;
|
|
4129
|
+
const observed = this.observedCodeSymbols(task);
|
|
4130
|
+
return observed?.state === "known" ? observed.symbols.map(symbol => symbol.id) : undefined;
|
|
4131
|
+
}
|
|
4132
|
+
|
|
4133
|
+
/** Only current host observations and positive exists checks may anchor the completion note. */
|
|
4134
|
+
private completionMemorySymbols(task: Task): MemorySymbolNodes {
|
|
4135
|
+
const unknown: MemorySymbolNodes = { state: "unknown", nodes: [], unknown: 0, truncated: false };
|
|
4136
|
+
if (this.isPii(task) || !this.d.store) return unknown;
|
|
4137
|
+
const repository = this.codeSymbolsRepository(task);
|
|
4138
|
+
if (!repository || repository.key !== this.repositories.project.key) return unknown;
|
|
4139
|
+
const observed = publicTaskCodeSymbols(task.codeSymbols, this.reviewHead(task), repository.key, this.publicMetadataText);
|
|
4140
|
+
if (!observed || observed.state !== "known") return unknown;
|
|
4141
|
+
try {
|
|
4142
|
+
const verified = this.d.memorySymbolNodes?.(task);
|
|
4143
|
+
if (!verified) return unknown;
|
|
4144
|
+
const context = { cwd: this.d.cwd, project: this.d.store.project, repo: this.d.store.repo, deny: this.d.store.deny, allowed: this.publicMetadataText };
|
|
4145
|
+
const actual = new Set(observed.symbols.map(symbol => canonicalMemorySymbolNode(symbol.id, context)).filter(Boolean));
|
|
4146
|
+
const nodes = [...new Set(verified.nodes)].filter(node => actual.has(node) && canonicalMemorySymbolNode(node, context) === node).slice(0, MEMORY_SYMBOL_LIMIT);
|
|
4147
|
+
const complete = observed.complete && verified.state === "known" && nodes.length === verified.nodes.length;
|
|
4148
|
+
return { state: complete ? "known" : "unknown", nodes, unknown: verified.unknown, truncated: verified.truncated || nodes.length !== verified.nodes.length };
|
|
4149
|
+
} catch { return unknown; }
|
|
4150
|
+
}
|
|
4151
|
+
|
|
3721
4152
|
/** Host observations are saved only with an admitted transition, never by an optional read. */
|
|
3722
4153
|
private observedCodeSymbols(task: Task) {
|
|
3723
4154
|
if (this.isPii(task) || !task.refs.commit) return undefined;
|
|
@@ -3829,6 +4260,28 @@ export class Tasks {
|
|
|
3829
4260
|
const checked = sdlcReceipt({ reviewedHead: head }, task.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined));
|
|
3830
4261
|
reviewedHead = checked.reviewedHead;
|
|
3831
4262
|
}
|
|
4263
|
+
// #509a: an approval binds the bytes that were delivered. The files are read again now: unchanged, their hashes go on the approval; changed,
|
|
4264
|
+
// missing or unreadable, a reviewer's approval is refused before any write, and the person may approve the files as they are, saying so.
|
|
4265
|
+
let boundDeliverables: DeliverableRecord[] | undefined;
|
|
4266
|
+
if (verdict === "approved") {
|
|
4267
|
+
const delivered = latestDeliverables(task.history);
|
|
4268
|
+
if (delivered) {
|
|
4269
|
+
const identity = JSON.stringify(delivered);
|
|
4270
|
+
const checks = await this.checkRecords(delivered, task);
|
|
4271
|
+
task = valid();
|
|
4272
|
+
if (JSON.stringify(latestDeliverables(task.history)) !== identity) throw new PreEffectToolRefusal("the task's deliverables changed during the verdict; review the current work again");
|
|
4273
|
+
const drift = checks.filter(check => check.state !== "unchanged");
|
|
4274
|
+
if (!drift.length) boundDeliverables = delivered;
|
|
4275
|
+
else {
|
|
4276
|
+
const text = drift.map(check => `${check.record.id} (${check.state})`).join(", ");
|
|
4277
|
+
if (by !== USER) throw new PreEffectToolRefusal(`deliverable changed since it was delivered: ${text}; ask its owner to call hub_task_done again so the current files are reviewed, or a person may approve them as they are`);
|
|
4278
|
+
// The person approves what is on disk now: those bytes are measured and bound, and the verdict says the files had moved.
|
|
4279
|
+
boundDeliverables = await this.hashClaims(delivered.map(record => ({ path: record.path!, format: record.format, ...(record.version !== undefined ? { version: record.version } : {}) })), task, true);
|
|
4280
|
+
task = valid();
|
|
4281
|
+
note = `${note ?? ""}\nApproved with the deliverable files changed since delivery: ${text}`.trim();
|
|
4282
|
+
}
|
|
4283
|
+
}
|
|
4284
|
+
}
|
|
3832
4285
|
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid(); }
|
|
3833
4286
|
let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
|
|
3834
4287
|
if (verdict === "changes_requested" && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|
|
@@ -3847,7 +4300,10 @@ export class Tasks {
|
|
|
3847
4300
|
const family = by === USER ? undefined : (this.d.adapterCatalog?.familyOf ?? familyOf)(by, this.d.instances?.());
|
|
3848
4301
|
const reviewerFamily = poolRequest?.reviewerFamily === family ? family : undefined;
|
|
3849
4302
|
const reviewerReason = reviewerFamily ? poolRequest?.reviewerReason : undefined;
|
|
3850
|
-
|
|
4303
|
+
// #507b: unmet items that start with a recorded criterion id name that criterion. The ids come from the checklist on the board as it is at this
|
|
4304
|
+
// write (the `task` just rechecked by valid()), never from the caller; a PII task carries none, its checklist being private.
|
|
4305
|
+
const unmetCriteria = pii ? [] : unmetCriteriaIds(items, checklistOf(task.sdlc));
|
|
4306
|
+
const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
|
|
3851
4307
|
if (verdict === "approved") {
|
|
3852
4308
|
const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0, ...(reviewedHead && task.sdlc ? { sdlc: { ...task.sdlc, reviewedHead, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), "merge") } } : {}) }, note, flag);
|
|
3853
4309
|
if (next.owner) {
|
|
@@ -3942,7 +4398,7 @@ export class Tasks {
|
|
|
3942
4398
|
}
|
|
3943
4399
|
|
|
3944
4400
|
/** Withdraw only a former role's queued request, then tell it that its authority changed. */
|
|
3945
|
-
private withdrawRoleRequest(previous: Task, next: Task, role: TaskAssignmentRole): void {
|
|
4401
|
+
private withdrawRoleRequest(previous: Task, next: Task, role: Exclude<TaskAssignmentRole, "planner">): void {
|
|
3946
4402
|
const peer = previous[role];
|
|
3947
4403
|
if (!peer || peer === next[role]) return;
|
|
3948
4404
|
const kind = role === "owner" ? "task" : "review";
|
|
@@ -3966,7 +4422,18 @@ export class Tasks {
|
|
|
3966
4422
|
|
|
3967
4423
|
/** Caller authority is checked by the daemon; keep the real actor in task history. */
|
|
3968
4424
|
async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner", expectedFlowRevision?: number): Promise<Task> {
|
|
3969
|
-
if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or
|
|
4425
|
+
if (role !== "owner" && role !== "reviewer" && role !== "planner") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner, reviewer or planner");
|
|
4426
|
+
if (role === "planner") {
|
|
4427
|
+
if (by !== USER) throw new PreEffectToolRefusal("only the person may name a task planner");
|
|
4428
|
+
const task = this.need(id, false, preEffect);
|
|
4429
|
+
this.checkFlowRevision(task, expectedFlowRevision, preEffect);
|
|
4430
|
+
if (isClosedTaskState(task.state) || this.isPii(task)) throw new PreEffectToolRefusal("closed or private work cannot name a planner");
|
|
4431
|
+
const choice = assign({ ...task, state: "proposed" }, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], namedPlanner: peer });
|
|
4432
|
+
if (choice.planner !== peer) throw new PreEffectToolRefusal("named planner must be a rostered settled planner holder");
|
|
4433
|
+
const next = this.d.board.update(task.id, by, "planner changed", { planner: peer }, "named", { plannerReason: "named" }, expectedFlowRevision);
|
|
4434
|
+
if (next.state === "backlog") { this.incomingBacklog.add(next.id); this.addPlannerBacklog(peer, [next.id]); }
|
|
4435
|
+
return next;
|
|
4436
|
+
}
|
|
3970
4437
|
if (role === "reviewer" && by !== USER) {
|
|
3971
4438
|
const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
3972
4439
|
if (conductors.length !== 1 || conductors[0] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the configured conductor may name a reviewer");
|
|
@@ -4147,29 +4614,34 @@ export class Tasks {
|
|
|
4147
4614
|
}
|
|
4148
4615
|
|
|
4149
4616
|
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> {
|
|
4617
|
+
const requestCheck = this.requestCheck(by);
|
|
4150
4618
|
const text = composeRememberText(input);
|
|
4151
4619
|
const task = input.task !== undefined ? this.d.board.get(Number(input.task)) : undefined;
|
|
4152
|
-
if (task && this.isPii(task)) throw new Error("notes about a PII task are not saved:
|
|
4620
|
+
if (task && this.isPii(task)) throw new Error("notes about a PII task are not saved: shared memory excludes private data");
|
|
4153
4621
|
// Shared with every other peer as well, so text that matches a PII pattern is refused like a note about a PII task.
|
|
4154
4622
|
const title = String(input.title ?? "").trim();
|
|
4155
|
-
if (this.isPii({ signals: detectSignals({ title, detail: text, refs: {} }, this.d.routing(), this.d.cwd) })) throw new Error("this note matches a PII pattern and is not saved:
|
|
4623
|
+
if (this.isPii({ signals: detectSignals({ title, detail: text, refs: {} }, this.d.routing(), this.d.cwd) })) throw new Error("this note matches a PII pattern and is not saved: shared memory excludes private data");
|
|
4156
4624
|
if (input.horizon !== undefined && input.horizon !== "short" && input.horizon !== "long") throw new Error('horizon is "short" or "long"');
|
|
4157
4625
|
if (input.scope !== undefined && input.scope !== "project" && input.scope !== "shared") throw new Error('scope is "project" or "shared"');
|
|
4158
4626
|
if (!this.d.memory && !this.d.store) return "memory is disabled; nothing saved";
|
|
4159
|
-
// Long-term notes leave the machine (#258)
|
|
4627
|
+
// Long-term notes can leave the machine (#258); local storage does not relax secret exclusion.
|
|
4160
4628
|
if (looksSecret(`${title}\n${text}`)) throw new Error("this note looks like it holds a secret (a key, token or password) and is not saved");
|
|
4161
4629
|
if (this.aboutPrivatePath(title, text, task)) throw new Error("this note is about a path the project keeps private (pii.paths) and is not saved");
|
|
4162
|
-
if (await this.withholds("note", `${title}\n${text}`, task)) throw new Error("the PII screen did not clear this note, so it is not saved:
|
|
4630
|
+
if (await this.withholds("note", `${title}\n${text}`, task)) throw new Error("the PII screen did not clear this note, so it is not saved: shared memory excludes private data");
|
|
4631
|
+
requestCheck();
|
|
4163
4632
|
const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) as Kind : "finding";
|
|
4164
4633
|
const native = this.saveNative(by, kind, text, title, task, input.horizon === "long" || input.horizon === "short" ? input.horizon : undefined, input.scope === "shared" ? "shared" : "project");
|
|
4165
4634
|
if (task && this.d.memory) this.markMemoryReference(task, by);
|
|
4166
|
-
const res = this.d.memory ? await this.d.memory.save({ text, ...(title ? { title } : {}), project: this.d.project, metadata: { peer: by, kind, ...(task ? { task: task.id } : {}) } }) : undefined;
|
|
4167
|
-
if (!res && !native) return
|
|
4635
|
+
const res = !native && this.d.memory ? await this.d.memory.save({ text, ...(title ? { title } : {}), project: this.d.project, metadata: { peer: by, kind, ...(task ? { task: task.id } : {}) } }) : undefined;
|
|
4636
|
+
if (!res && !native) return "memory store unavailable; nothing saved";
|
|
4637
|
+
if (!native && res && typeof res === "object" && "status" in res && res.status === "candidate") {
|
|
4638
|
+
return "saved as a pending memory candidate; a person must review it before it becomes shared evidence";
|
|
4639
|
+
}
|
|
4168
4640
|
const line = noteLine(by, kind, title ? `${title}: ${text}` : text);
|
|
4169
4641
|
this.d.notify(line);
|
|
4170
4642
|
// Display identity only: a queued recall may already contain this saved entry. Stored/captured text stays unchanged.
|
|
4171
4643
|
this.d.share?.(by, native ? noteLine(by, kind, `mem:${native.id} ${title ? `${title}: ` : ""}${text}`) : line);
|
|
4172
|
-
return `saved to shared memory${native ? ` (hub memory ${native.id.slice(0, 8)}, ${native.tier}-term
|
|
4644
|
+
return `saved to shared memory${native ? ` (hub memory ${native.id.slice(0, 8)}, ${native.tier}-term)` : ""}; the other agents get it with their next message`;
|
|
4173
4645
|
}
|
|
4174
4646
|
|
|
4175
4647
|
/** The task's key in the hub's store: unique across `ahub reset --all`, which restarts the ids. */
|
|
@@ -4212,7 +4684,7 @@ export class Tasks {
|
|
|
4212
4684
|
* long, decisions, contracts and fails are long-term. Fail-open: a store that cannot be written costs one notice.
|
|
4213
4685
|
* The caller has screened the text for PII; never a PII task's.
|
|
4214
4686
|
*/
|
|
4215
|
-
private saveNative(by: PeerId, kind: Kind, text: string, title: string, task: Task | undefined, horizon?: Tier, scope: Scope = "project"): { id: string; tier: Tier } | undefined {
|
|
4687
|
+
private saveNative(by: PeerId, kind: Kind, text: string, title: string, task: Task | undefined, horizon?: Tier, scope: Scope = "project", symbols?: MemorySymbolNodes): { id: string; tier: Tier } | undefined {
|
|
4216
4688
|
const st = this.d.store;
|
|
4217
4689
|
if (!st || (task && this.isPii(task))) return undefined;
|
|
4218
4690
|
try { // never throws: callers run after a board write
|
|
@@ -4220,7 +4692,12 @@ export class Tasks {
|
|
|
4220
4692
|
// Approved, or settled in the store already: the snapshot the caller holds can predate an approval made while it awaited.
|
|
4221
4693
|
const approved = !!task && (task.state === "approved" || st.store.settled(this.memoryKey(task)));
|
|
4222
4694
|
const tier: Tier = horizon ?? (approved && PROMOTED_KINDS.includes(kind) ? "long" : "short");
|
|
4223
|
-
const about = extractNodes(`${title}\n${text}`, { cwd: this.d.cwd, project: st.project, repo: st.repo, deny: st.deny, paths: [...(task?.refs.paths ?? []), ...(task?.plan?.paths ?? [])], symbols: task?.plan?.symbols, ...(task ? { task: { id: task.id, created: task.created } } : {}) });
|
|
4695
|
+
const about = extractNodes(`${title}\n${text}`, { cwd: this.d.cwd, project: st.project, repo: st.repo, deny: st.deny, paths: [...(task?.refs.paths ?? []), ...(task?.plan?.paths ?? [])], symbols: symbols ? [] : task?.plan?.symbols?.map(normalizeMemorySymbolInput), ...(task ? { task: { id: task.id, created: task.created } } : {}) });
|
|
4696
|
+
// Existing notes remain immutable; the completion note connects actual symbols through the shared task node.
|
|
4697
|
+
if (symbols && task) {
|
|
4698
|
+
const taskNode = `task:${this.memoryKey(task)}`;
|
|
4699
|
+
about.splice(0, about.length, ...[...new Set([taskNode, ...symbols.nodes, ...about])].slice(0, 64).sort());
|
|
4700
|
+
}
|
|
4224
4701
|
// A short-term note on a task already approved (before hub memory existed, or late): the task is settled first, so
|
|
4225
4702
|
// the note gets its own deadline and is never taken for a source waiting for promotion.
|
|
4226
4703
|
if (task && approved && tier === "short" && !st.store.settled(this.memoryKey(task))) st.store.settle(this.memoryKey(task), by);
|
|
@@ -4270,16 +4747,15 @@ export class Tasks {
|
|
|
4270
4747
|
}
|
|
4271
4748
|
|
|
4272
4749
|
/** Auto notes: only transitions that carry content, never for PII. */
|
|
4273
|
-
private note(task: Task, by: PeerId, kind: Kind, text: string, withheld = false): void {
|
|
4750
|
+
private note(task: Task, by: PeerId, kind: Kind, text: string, withheld = false, symbols?: MemorySymbolNodes): void {
|
|
4274
4751
|
if (this.isPii(task) || (!this.d.memory && !this.d.store)) return;
|
|
4275
|
-
//
|
|
4276
|
-
// not clear (#198), is not sent (#69). Long-term hub memory leaves the machine (#258): the same rule, and no secrets.
|
|
4752
|
+
// Model-written text must clear the host PII screen (#198/#69); long-term memory may sync (#258). No secrets.
|
|
4277
4753
|
if (withheld || !this.nameable(text)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (${this.whyWithheld(text)})`);
|
|
4278
4754
|
if (looksSecret(text)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (it looks like it holds a secret)`);
|
|
4279
4755
|
const title = `agent-hub task #${task.id}: ${task.title}`.slice(0, 120);
|
|
4280
4756
|
if (this.aboutPrivatePath(title, text, task)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (it is about a private path)`);
|
|
4281
|
-
this.saveNative(by, kind, text, title, task);
|
|
4282
|
-
if (this.d.memory) {
|
|
4757
|
+
const native = this.saveNative(by, kind, text, title, task, undefined, "project", symbols);
|
|
4758
|
+
if (!native && this.d.memory) {
|
|
4283
4759
|
try { this.markMemoryReference(task, by); } catch { return this.releaseNotice(`task #${task.id}: memory reference could not be recorded; note was not saved`); }
|
|
4284
4760
|
void this.d.memory.save({ text, title, project: this.d.project, metadata: { peer: by, task: task.id, kind } });
|
|
4285
4761
|
}
|