@staix/agent-hub 0.15.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 +25 -0
- package/README.md +12 -8
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +6 -3
- package/docs/agent-notes/budget.md +4 -2
- package/docs/agent-notes/daemon.md +9 -3
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +13 -1
- package/docs/agent-notes/tests.md +3 -0
- package/docs/dogfood.md +13 -4
- package/docs/events.md +1 -1
- package/docs/memory.md +78 -10
- package/docs/operations.md +116 -30
- package/docs/quickstart.md +8 -4
- package/docs/security.md +80 -18
- package/docs/smoke.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +84 -8
- 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 +26 -17
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +2085 -1702
- package/src/adapters/acp.ts +5 -2
- package/src/adapters/codex-appserver.ts +69 -1
- package/src/adapters/compat.ts +2 -2
- package/src/adapters/definitions/builtin-metadata.ts +3 -3
- package/src/adapters/local-worker.ts +5 -4
- package/src/adapters/registry-types.ts +1 -1
- package/src/cli/console-state.ts +20 -2
- package/src/cli/console-symbols.ts +3 -2
- package/src/cli/console-usage.ts +5 -2
- package/src/cli/console.ts +5 -1
- package/src/cli/help.ts +7 -5
- package/src/cli/identity.ts +3 -1
- package/src/cli/main.ts +44 -10
- package/src/cli/memory.ts +119 -7
- package/src/cli/output.ts +12 -4
- package/src/hub/action-class.ts +211 -17
- package/src/hub/action-enforce.ts +13 -5
- package/src/hub/action-policy.ts +19 -8
- package/src/hub/ask.ts +16 -4
- package/src/hub/board.ts +86 -31
- package/src/hub/budget-notes.ts +65 -0
- package/src/hub/budget.ts +42 -10
- package/src/hub/bus.ts +1 -1
- package/src/hub/code-facts.ts +56 -16
- package/src/hub/conductor.ts +5 -2
- package/src/hub/config-apply.ts +7 -0
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +250 -49
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +2 -2
- package/src/hub/hub-tools.ts +3 -3
- package/src/hub/kimi-usage.ts +12 -0
- package/src/hub/lifecycle.ts +10 -5
- 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/planning.ts +10 -4
- package/src/hub/read-tools.ts +21 -3
- package/src/hub/sdlc.ts +34 -0
- package/src/hub/settings.ts +17 -2
- package/src/hub/settled-roles.ts +15 -3
- package/src/hub/task-sweep.ts +25 -3
- package/src/hub/tasks.ts +353 -64
- package/src/hub/token-window.ts +15 -2
- package/src/hub/window-kind.ts +13 -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 +100 -18
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";
|
|
@@ -28,7 +29,7 @@ import { isAbsolute, relative } from "node:path";
|
|
|
28
29
|
import type { Briefs, NativeItem } from "../memory/brief.ts";
|
|
29
30
|
import type { MemoryClient } from "../memory/client.ts";
|
|
30
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";
|
|
31
|
-
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";
|
|
32
33
|
import type { Bus } from "./bus.ts";
|
|
33
34
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
34
35
|
import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
|
|
@@ -43,9 +44,9 @@ import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief
|
|
|
43
44
|
import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
|
|
44
45
|
import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
45
46
|
import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
46
|
-
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";
|
|
47
48
|
|
|
48
|
-
export type TaskAssignmentRole = "owner" | "reviewer";
|
|
49
|
+
export type TaskAssignmentRole = "owner" | "reviewer" | "planner";
|
|
49
50
|
|
|
50
51
|
export interface ReleaseWindow { at: number; host: boolean; quota: boolean; idle: boolean; reasons: string[]; current?: () => boolean; close?: () => Promise<void>; }
|
|
51
52
|
|
|
@@ -56,6 +57,8 @@ export interface TasksDeps {
|
|
|
56
57
|
/** Effective per-peer planner capacity, taken from the existing settings loader. */
|
|
57
58
|
wipLimit?: (peer: PeerId) => number;
|
|
58
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;
|
|
59
62
|
board: Board;
|
|
60
63
|
bus: Bus;
|
|
61
64
|
routing: () => Routing;
|
|
@@ -87,6 +90,8 @@ export interface TasksDeps {
|
|
|
87
90
|
/** #520: the review brief for a task's commit (git only, bounded); undefined when the task names no commit. */
|
|
88
91
|
prepareReviewBrief?: (task: Task) => Promise<void>;
|
|
89
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;
|
|
90
95
|
sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
|
|
91
96
|
briefs?: Briefs;
|
|
92
97
|
/** Screened live fail notes directly intersecting the task plan or refs; read-only advice. */
|
|
@@ -194,6 +199,7 @@ const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
|
194
199
|
const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
|
|
195
200
|
QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits for its reserved owner's WIP slot.
|
|
196
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.
|
|
197
203
|
/** The records of the `done (checking)` entry a passed check now completes: what the owner delivered then (#509a). */
|
|
198
204
|
const latestPendingDeliverables = (task: Task): DeliverableRecord[] | undefined => {
|
|
199
205
|
const checking = task.history.findLastIndex(entry => entry.event === "done (checking)");
|
|
@@ -294,6 +300,10 @@ const planText = (plan: TaskPlan = {}) => ALL_PLAN_KEYS.filter((k) => plan[k]?.l
|
|
|
294
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");
|
|
295
301
|
/** Re-screens of one task whose verdict was unknown, per hub run (#198). */
|
|
296
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;
|
|
297
307
|
|
|
298
308
|
/**
|
|
299
309
|
* The task flow. Adapters and tools never touch the board: every change comes through here, where assignment,
|
|
@@ -302,12 +312,35 @@ const RESCREEN_MAX = 10;
|
|
|
302
312
|
/** #694: a PR staged at done with the commit or branch it was read for. */
|
|
303
313
|
interface IdentityLink { repo: string; pr: number; basis: { commit?: string; branch?: string } }
|
|
304
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
|
+
|
|
305
330
|
export class Tasks {
|
|
331
|
+
private readonly planReviewRequests = new Map<number, string>();
|
|
306
332
|
private readonly repositories: TaskRepositories;
|
|
307
333
|
private readonly incomingBacklog = new Set<number>();
|
|
308
334
|
private readonly plannerBacklog = new Map<PeerId, Set<number>>();
|
|
309
335
|
private readonly plannerBlockedQueue = new Map<PeerId, string>();
|
|
310
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 }>() };
|
|
311
344
|
/** Turn-free cohorts (issue #107): who works without messages, and who integrates. */
|
|
312
345
|
readonly cohorts: Cohorts;
|
|
313
346
|
private readonly coordinationModes = new Map<number, boolean>();
|
|
@@ -318,6 +351,7 @@ export class Tasks {
|
|
|
318
351
|
constructor(private readonly d: TasksDeps) {
|
|
319
352
|
d.board.trackPlanRevisions = () => (d.sdlc?.config().mode ?? "off") !== "off";
|
|
320
353
|
this.repositories = new TaskRepositories(d.cwd, d.repository);
|
|
354
|
+
d.board.proposalPlanner = task => this.proposalPlanner(task);
|
|
321
355
|
for (const task of d.board.list()) this.warmRepository(task);
|
|
322
356
|
if (d.coordination) {
|
|
323
357
|
const mode = d.coordination();
|
|
@@ -424,7 +458,7 @@ export class Tasks {
|
|
|
424
458
|
if (finding.kind === "ready-unassigned") {
|
|
425
459
|
this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
|
|
426
460
|
recorded.push({ task: task.id, finding });
|
|
427
|
-
const planners =
|
|
461
|
+
const planners = this.taskPlanner(task).filter(peer => this.d.bus.isRostered(peer));
|
|
428
462
|
const text = `Task #${task.id}: ready-unassigned, idle escalation ${finding.step}/3.`;
|
|
429
463
|
if (finding.step === 1 || finding.step === 2) {
|
|
430
464
|
if (finding.step === 2 || !planners.length) this.releaseNotice(text);
|
|
@@ -452,8 +486,8 @@ export class Tasks {
|
|
|
452
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");
|
|
453
487
|
} else if (finding.step === 2) {
|
|
454
488
|
this.d.notify(message);
|
|
455
|
-
for (const
|
|
456
|
-
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");
|
|
457
491
|
}
|
|
458
492
|
} else {
|
|
459
493
|
const reviewer = finding.kind === "review-pending";
|
|
@@ -653,6 +687,10 @@ export class Tasks {
|
|
|
653
687
|
...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
|
|
654
688
|
...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
|
|
655
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 } : {}),
|
|
656
694
|
...(h.reviewerFamily ? { reviewerFamily: screenHistoryText(h.reviewerFamily, "reviewer family") } : {}),
|
|
657
695
|
...(h.reviewerReason ? { reviewerReason: h.reviewerReason } : {}),
|
|
658
696
|
...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
|
|
@@ -783,7 +821,7 @@ export class Tasks {
|
|
|
783
821
|
}
|
|
784
822
|
|
|
785
823
|
/** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
|
|
786
|
-
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 {
|
|
787
825
|
const instances = this.d.instances?.(), adapter = (this.d.adapterCatalog?.definitionOf ?? definitionOf)(peer, instances);
|
|
788
826
|
const declaredExtra = !!adapter && adapter.kind !== peer;
|
|
789
827
|
if ((!this.d.capabilities || this.isPii(task)) && !declaredExtra) return;
|
|
@@ -797,7 +835,7 @@ export class Tasks {
|
|
|
797
835
|
/**
|
|
798
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
|
|
799
837
|
* authority (`d.roles`), restricted to rostered peers, so assignment, planner notices and `route explain` agree on who the holders are.
|
|
800
|
-
*
|
|
838
|
+
* Selection stays in assign(); task bindings only keep that selection while the holder remains eligible.
|
|
801
839
|
*/
|
|
802
840
|
private plannerHolders(): PeerId[] {
|
|
803
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();
|
|
@@ -806,7 +844,7 @@ export class Tasks {
|
|
|
806
844
|
private weights(cls: TaskClass, task?: Task) {
|
|
807
845
|
const now = Date.now();
|
|
808
846
|
const quota = this.d.quota?.();
|
|
809
|
-
return { ...this.health(), planners: this.plannerHolders(), ...(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 } } : {}) };
|
|
810
848
|
}
|
|
811
849
|
|
|
812
850
|
/**
|
|
@@ -901,13 +939,62 @@ export class Tasks {
|
|
|
901
939
|
try { this.d.recordPlanning?.(change); } catch { /* telemetry never changes a durable result */ }
|
|
902
940
|
}
|
|
903
941
|
|
|
904
|
-
|
|
905
|
-
|
|
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;
|
|
946
|
+
}
|
|
947
|
+
|
|
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" : ""}`);
|
|
906
950
|
}
|
|
907
951
|
|
|
908
|
-
private plannerWriteAuthority(by: PeerId): void {
|
|
952
|
+
private plannerWriteAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
|
|
909
953
|
if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("planner writes are unavailable during a private turn");
|
|
910
|
-
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);
|
|
911
998
|
}
|
|
912
999
|
|
|
913
1000
|
wipLimit(peer: PeerId): number {
|
|
@@ -966,6 +1053,7 @@ export class Tasks {
|
|
|
966
1053
|
const validate = () => {
|
|
967
1054
|
const task = this.d.board.get(input.id);
|
|
968
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);
|
|
969
1057
|
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
970
1058
|
if (task.release != null && (!release || ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull requires unfrozen release scope");
|
|
971
1059
|
if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
|
|
@@ -986,7 +1074,7 @@ export class Tasks {
|
|
|
986
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);
|
|
987
1075
|
throw new PreEffectToolRefusal(`${this.hasPersonReservation(task) ? "a person's reservation is retained; " : ""}${refusal}`);
|
|
988
1076
|
}
|
|
989
|
-
return { task, owner: assignment.owner };
|
|
1077
|
+
return { task, owner: assignment.owner, assignment };
|
|
990
1078
|
};
|
|
991
1079
|
const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
|
|
992
1080
|
authorized();
|
|
@@ -999,7 +1087,7 @@ export class Tasks {
|
|
|
999
1087
|
if (checked.task.history.length !== current.task.history.length || checked.owner !== current.owner) throw new PreEffectToolRefusal("planner pull changed during admission");
|
|
1000
1088
|
} } });
|
|
1001
1089
|
}
|
|
1002
|
-
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));
|
|
1003
1091
|
this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
|
|
1004
1092
|
return pulled;
|
|
1005
1093
|
}
|
|
@@ -1011,15 +1099,77 @@ export class Tasks {
|
|
|
1011
1099
|
private addPlannerBacklog(peer: PeerId, ids: readonly number[]): void {
|
|
1012
1100
|
if (!this.d.roles?.[peer]?.includes("planner")) return;
|
|
1013
1101
|
const pending = this.plannerBacklog.get(peer) ?? new Set<number>();
|
|
1014
|
-
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
|
+
}
|
|
1015
1106
|
if (pending.size) this.plannerBacklog.set(peer, pending);
|
|
1016
1107
|
}
|
|
1017
|
-
|
|
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
|
+
|
|
1018
1167
|
/** One bounded ids-only batch per attached planner; failed or evicted copies stay pending. */
|
|
1019
1168
|
flushPlannerNotices(): number {
|
|
1020
1169
|
const configured = Object.entries(this.d.roles ?? {}).filter(([, roles]) => roles.includes("planner")).map(([peer]) => peer);
|
|
1021
1170
|
for (const peer of this.knownPlanners) if (!configured.includes(peer)) {
|
|
1022
1171
|
this.knownPlanners.delete(peer); this.plannerBacklog.delete(peer); this.plannerBlockedQueue.delete(peer);
|
|
1172
|
+
this.groomScan.pending.delete(peer); this.groomScan.sent.delete(peer);
|
|
1023
1173
|
}
|
|
1024
1174
|
for (const peer of configured) if (!this.knownPlanners.has(peer)) {
|
|
1025
1175
|
this.knownPlanners.add(peer);
|
|
@@ -1028,18 +1178,7 @@ export class Tasks {
|
|
|
1028
1178
|
}
|
|
1029
1179
|
const planners = configured.filter(peer => this.d.bus.isRostered(peer) && this.d.bus.peers.has(peer) && this.d.bus.stateOf(peer) !== "offline");
|
|
1030
1180
|
if (!planners.length) return 0;
|
|
1031
|
-
if ((this.d.sdlc?.config().mode ?? "off") !== "off")
|
|
1032
|
-
const revision = this.d.board.statusRevision();
|
|
1033
|
-
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.";
|
|
1034
|
-
for (const peer of planners) if (this.groomingNotified.get(peer) !== revision) {
|
|
1035
|
-
const queued = this.d.bus.pendingEnvelopes(peer).some(env => env.from === HUB && env.kind === "task" && env.body === body);
|
|
1036
|
-
if (queued) continue;
|
|
1037
|
-
this.releaseNotice(`could not notify planner ${peer} about grooming`, () => {
|
|
1038
|
-
const recipients = this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task" }));
|
|
1039
|
-
if (recipients.includes(peer)) this.groomingNotified.set(peer, revision);
|
|
1040
|
-
});
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1181
|
+
if ((this.d.sdlc?.config().mode ?? "off") !== "off") this.groomNotices(configured, planners);
|
|
1043
1182
|
for (const id of this.incomingBacklog) if (this.d.board.get(id)?.state !== "backlog") this.incomingBacklog.delete(id);
|
|
1044
1183
|
const incoming = [...this.incomingBacklog].sort((a, b) => a - b).slice(0, 64);
|
|
1045
1184
|
if (incoming.length) {
|
|
@@ -1053,10 +1192,17 @@ export class Tasks {
|
|
|
1053
1192
|
for (const peer of planners) {
|
|
1054
1193
|
const pending = this.plannerBacklog.get(peer);
|
|
1055
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
|
+
}
|
|
1056
1203
|
const blockedQueue = this.plannerBlockedQueue.get(peer);
|
|
1057
1204
|
if (blockedQueue !== undefined && blockedQueue === JSON.stringify(this.d.bus.queueIds(peer))) continue;
|
|
1058
1205
|
this.plannerBlockedQueue.delete(peer);
|
|
1059
|
-
for (const id of pending) if (this.d.board.get(id)?.state !== "backlog") pending.delete(id);
|
|
1060
1206
|
const ids = [...pending].sort((a, b) => a - b).slice(0, 64);
|
|
1061
1207
|
if (!ids.length) continue;
|
|
1062
1208
|
// Remove before publish: an immediate overflow callback may put this recipient's ids back.
|
|
@@ -1113,7 +1259,7 @@ export class Tasks {
|
|
|
1113
1259
|
});
|
|
1114
1260
|
}
|
|
1115
1261
|
|
|
1116
|
-
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> {
|
|
1117
1263
|
this.planningAuthority(by);
|
|
1118
1264
|
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid planning identity or revision");
|
|
1119
1265
|
if (typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("planning needs a bounded reason");
|
|
@@ -1122,6 +1268,12 @@ export class Tasks {
|
|
|
1122
1268
|
const validate = () => {
|
|
1123
1269
|
const task = this.d.board.get(input.id);
|
|
1124
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
|
+
}
|
|
1125
1277
|
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
|
|
1126
1278
|
if (by !== USER && (task.state !== "backlog" || !taskCanPlan(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
|
|
1127
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");
|
|
@@ -1154,6 +1306,7 @@ export class Tasks {
|
|
|
1154
1306
|
for (const id of new Set([task.release, target])) {
|
|
1155
1307
|
if (id == null) continue;
|
|
1156
1308
|
const release = this.d.board.release(id)!;
|
|
1309
|
+
if (Object.hasOwn(patch, "release")) this.planningAuthority(by, release);
|
|
1157
1310
|
if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release scope is final");
|
|
1158
1311
|
if (by !== USER && release.state === "frozen") throw new PreEffectToolRefusal("frozen release changes require a person");
|
|
1159
1312
|
}
|
|
@@ -1161,8 +1314,11 @@ export class Tasks {
|
|
|
1161
1314
|
};
|
|
1162
1315
|
const task = validate(), withheld = await this.withholds("note", input.reason, task);
|
|
1163
1316
|
this.planningAuthority(by); // #455: or moved the planner
|
|
1164
|
-
validate(); // a person may have changed planning or frozen a release while the screen ran
|
|
1165
|
-
|
|
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);
|
|
1166
1322
|
this.planningRecorded({ by, task: task.id, fields: Object.keys(patch), revision: updated.planningRevision! });
|
|
1167
1323
|
return updated; // persistence/readback failures must never claim no effect
|
|
1168
1324
|
}
|
|
@@ -1171,6 +1327,7 @@ export class Tasks {
|
|
|
1171
1327
|
this.planningAuthority(by);
|
|
1172
1328
|
const task = this.d.board.get(id), entry = task ? planningUndoEntry(task) : undefined;
|
|
1173
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);
|
|
1174
1331
|
if (entry.planning.split) {
|
|
1175
1332
|
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
1176
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");
|
|
@@ -1240,6 +1397,7 @@ export class Tasks {
|
|
|
1240
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");
|
|
1241
1398
|
const release = parent.release == null ? undefined : this.d.board.release(parent.release);
|
|
1242
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);
|
|
1243
1401
|
const profile = parent.profile ?? "dev";
|
|
1244
1402
|
const drafts = rows.map((part, index) => {
|
|
1245
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");
|
|
@@ -1304,10 +1462,12 @@ export class Tasks {
|
|
|
1304
1462
|
this.plannerWriteAuthority(by);
|
|
1305
1463
|
const task = this.d.board.get(input.id);
|
|
1306
1464
|
if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("release assignment changed");
|
|
1465
|
+
this.plannerWriteAuthority(by, task);
|
|
1307
1466
|
if (by !== USER && (this.isPii(task) || task.pinned)) throw new PreEffectToolRefusal("private or pinned release scope requires the person");
|
|
1308
1467
|
for (const id of new Set([task.release, input.release])) if (id != null) {
|
|
1309
1468
|
const release = this.d.board.release(id);
|
|
1310
1469
|
if (!release || ["frozen", "shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("release scope is frozen or final");
|
|
1470
|
+
this.plannerWriteAuthority(by, release);
|
|
1311
1471
|
}
|
|
1312
1472
|
return task;
|
|
1313
1473
|
};
|
|
@@ -1326,6 +1486,7 @@ export class Tasks {
|
|
|
1326
1486
|
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid release identity or revision");
|
|
1327
1487
|
const release = this.d.board.release(input.id);
|
|
1328
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);
|
|
1329
1490
|
if (input.state !== undefined && input.state !== "active") throw new PreEffectToolRefusal("invalid train state");
|
|
1330
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");
|
|
1331
1492
|
const dates = [release.plannedStart, input.cut ?? release.cut, release.ship].filter((date): date is number => date !== null);
|
|
@@ -1369,6 +1530,7 @@ export class Tasks {
|
|
|
1369
1530
|
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid release identity or revision");
|
|
1370
1531
|
const release = this.d.board.release(input.id);
|
|
1371
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);
|
|
1372
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");
|
|
1373
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");
|
|
1374
1536
|
const report = this.releaseReport(by, input.id);
|
|
@@ -1412,7 +1574,7 @@ export class Tasks {
|
|
|
1412
1574
|
if (!this.nameable(text) || this.screenOn() && (await this.screenCall(text)).label !== "clear") throw new PreEffectToolRefusal("release text was not cleared for public planning");
|
|
1413
1575
|
this.plannerWriteAuthority(by); // Privacy and planner identity may change while screening.
|
|
1414
1576
|
if (this.d.board.releases().some(release => release.name === input.name)) throw new PreEffectToolRefusal("release name already exists");
|
|
1415
|
-
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);
|
|
1416
1578
|
this.planningRecorded({ by, release: release.id, fields: ["created"], revision: release.revision });
|
|
1417
1579
|
return release;
|
|
1418
1580
|
}
|
|
@@ -1421,6 +1583,7 @@ export class Tasks {
|
|
|
1421
1583
|
this.planningAuthority(by);
|
|
1422
1584
|
const release = this.d.board.release(input.id);
|
|
1423
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);
|
|
1424
1587
|
if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release is final");
|
|
1425
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");
|
|
1426
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");
|
|
@@ -1551,6 +1714,7 @@ export class Tasks {
|
|
|
1551
1714
|
|| typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("untouched return needs an id, revision and bounded reason");
|
|
1552
1715
|
const validate = () => {
|
|
1553
1716
|
const task = this.need(input.id, false, true);
|
|
1717
|
+
this.plannerWriteAuthority(by, task);
|
|
1554
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");
|
|
1555
1719
|
if (by !== USER && task.pinned) throw new PreEffectToolRefusal("pinned work requires the person");
|
|
1556
1720
|
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
@@ -1783,9 +1947,18 @@ export class Tasks {
|
|
|
1783
1947
|
if (env.from === HUB && env.kind === "task" && env.refs?.backlog) {
|
|
1784
1948
|
const ids = env.refs.backlog;
|
|
1785
1949
|
if (!Array.isArray(ids) || !ids.length || ids.length > 64 || ids.some(id => !Number.isSafeInteger(id) || id <= 0)) return false;
|
|
1786
|
-
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
|
+
});
|
|
1787
1954
|
if (current.length !== ids.length || !this.d.roles?.[peer]?.includes("planner") || !this.d.bus.isRostered(peer)) {
|
|
1788
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
|
+
}
|
|
1789
1962
|
return false;
|
|
1790
1963
|
}
|
|
1791
1964
|
return true;
|
|
@@ -1793,6 +1966,10 @@ export class Tasks {
|
|
|
1793
1966
|
if (env.from === HUB && env.refs?.task) {
|
|
1794
1967
|
const task = this.d.board.get(Number(env.refs.task));
|
|
1795
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");
|
|
1796
1973
|
}
|
|
1797
1974
|
const c = this.conditional.get(env.id);
|
|
1798
1975
|
if (!c || c.peer !== peer) return true;
|
|
@@ -2035,6 +2212,7 @@ export class Tasks {
|
|
|
2035
2212
|
}
|
|
2036
2213
|
task = current; // Reviewer-only and quiet bookkeeping updates do not supersede ownership authority.
|
|
2037
2214
|
if (gap) {
|
|
2215
|
+
this.requestIndependentPlanReview(current);
|
|
2038
2216
|
this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}; planner: call hub_planner_groom then hub_plan_review`);
|
|
2039
2217
|
if (mode === "enforce") {
|
|
2040
2218
|
if (opts.claim && opts.namedOwner === by) stagedClaimGap = gap;
|
|
@@ -2080,10 +2258,11 @@ export class Tasks {
|
|
|
2080
2258
|
const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
|
|
2081
2259
|
opts.plannerPull?.guard();
|
|
2082
2260
|
const assignmentNote = this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a);
|
|
2083
|
-
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! } : {}) };
|
|
2084
2263
|
const next = opts.plannerPull
|
|
2085
|
-
? 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)
|
|
2086
|
-
: 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);
|
|
2087
2266
|
if (opts.plannerPull) this.planningRecorded({ by, task: next.id, fields: ["pull", "reserved"], revision: next.planningRevision! });
|
|
2088
2267
|
// What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
|
|
2089
2268
|
// escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
|
|
@@ -2367,7 +2546,7 @@ export class Tasks {
|
|
|
2367
2546
|
this.releaseNotice(text);
|
|
2368
2547
|
try { this.d.stallNotice?.(stall); } catch { /* advisory never changes task authority */ }
|
|
2369
2548
|
// Advice joins the planner's next delivery; it must not manufacture a turn beside a withheld backlog batch.
|
|
2370
|
-
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)));
|
|
2371
2550
|
}
|
|
2372
2551
|
async cancel(id: unknown, by: PeerId, input: { reason: string; supersededBy?: string; expectedFlowRevision?: number }): Promise<Task> {
|
|
2373
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");
|
|
@@ -2378,7 +2557,7 @@ export class Tasks {
|
|
|
2378
2557
|
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
|
|
2379
2558
|
if (by !== USER && this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("cancellation on ordinary work is unavailable during a private turn");
|
|
2380
2559
|
if (by !== USER) {
|
|
2381
|
-
this.planningAuthority(by);
|
|
2560
|
+
this.planningAuthority(by, task);
|
|
2382
2561
|
const proposer = task.history.find(h => h.event === "proposed" || h.event === "backlogged")?.by;
|
|
2383
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");
|
|
2384
2563
|
}
|
|
@@ -2573,7 +2752,16 @@ export class Tasks {
|
|
|
2573
2752
|
if (issue) issues.set(key, issue);
|
|
2574
2753
|
}
|
|
2575
2754
|
this.planningAuthority(by);
|
|
2576
|
-
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));
|
|
2577
2765
|
const peers = [...new Set([...Object.keys(this.states()), ...current.flatMap(task => task.owner ? [task.owner] : [])])];
|
|
2578
2766
|
const reviewedIssues = new Map(visible.flatMap(task => task.sdlc?.planReview?.issue ? [[task.id, task.sdlc.planReview.issue] as const] : []));
|
|
2579
2767
|
const acceptedIssues = new Map(visible.flatMap(task => task.sdlc?.acceptance ? [[task.id, task.sdlc.acceptance] as const] : []));
|
|
@@ -2587,7 +2775,7 @@ export class Tasks {
|
|
|
2587
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 });
|
|
2588
2776
|
if (this.waitsFor(task).length) blockers.push({ task: task.id, code: "dependency" });
|
|
2589
2777
|
}
|
|
2590
|
-
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)}`,
|
|
2591
2779
|
limits: Object.fromEntries(peers.map(peer => [peer, this.wipLimit(peer)])), wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])),
|
|
2592
2780
|
states, issues, reviewedIssues, acceptedIssues, blockers });
|
|
2593
2781
|
return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
|
|
@@ -2770,6 +2958,7 @@ export class Tasks {
|
|
|
2770
2958
|
if (Object.keys(raw).some(key => !["revision", "reason", "review", "ci"].includes(key)) || !Number.isSafeInteger(raw.revision) || (raw.revision as number) < 0
|
|
2771
2959
|
|| typeof raw.reason !== "string" || !raw.reason.trim() || raw.reason.length > 1000) throw new PreEffectToolRefusal("plan review needs a current revision and bounded reason");
|
|
2772
2960
|
const task = this.need(id, false, true, true), revision = raw.revision as number;
|
|
2961
|
+
this.planReviewAuthority(by, task);
|
|
2773
2962
|
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
|
|
2774
2963
|
if ((task.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("planning revision changed");
|
|
2775
2964
|
let scope: CostPlan;
|
|
@@ -2783,6 +2972,7 @@ export class Tasks {
|
|
|
2783
2972
|
const flow = task.history.length, withheld = await this.withholds("note", raw.reason, task);
|
|
2784
2973
|
this.plannerWriteAuthority(by);
|
|
2785
2974
|
const observed = this.need(task.id, false, true, true);
|
|
2975
|
+
this.planReviewAuthority(by, observed);
|
|
2786
2976
|
if (observed.history.length !== flow || (observed.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
|
|
2787
2977
|
const identity = this.planIssueIdentity(observed);
|
|
2788
2978
|
if (identity?.issue && this.isPii(observed)) throw new PreEffectToolRefusal("private issue plan review readback unavailable");
|
|
@@ -2791,10 +2981,12 @@ export class Tasks {
|
|
|
2791
2981
|
if (issue && !this.isPii(task) && !this.nameable(JSON.stringify(issue))) throw new PreEffectToolRefusal("plan review metadata matches a PII pattern");
|
|
2792
2982
|
this.plannerWriteAuthority(by);
|
|
2793
2983
|
const current = this.need(task.id, false, true, true);
|
|
2984
|
+
const authorship = this.planReviewAuthority(by, current);
|
|
2794
2985
|
if (current.history.length !== flow || (current.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
|
|
2795
2986
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2796
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"),
|
|
2797
|
-
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);
|
|
2798
2990
|
if (config.mode !== "off") updated = this.linkIssue(updated); // #694
|
|
2799
2991
|
if (updated.state === "proposed" && !updated.owner) {
|
|
2800
2992
|
this.offered.delete(updated.id);
|
|
@@ -2823,19 +3015,22 @@ export class Tasks {
|
|
|
2823
3015
|
|
|
2824
3016
|
recordLifecycle(by: PeerId, id: unknown, input: unknown, deliverables?: DeliverableRecord[]): Task {
|
|
2825
3017
|
const task = this.need(id, false, true);
|
|
2826
|
-
if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
|
|
2827
3018
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2828
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");
|
|
2829
3025
|
// The spec identity is person-owned in enforce. No observation cache or restart can reopen a relink bypass.
|
|
2830
3026
|
const specRelink = (task.sdlc?.repo !== undefined && next.repo !== task.sdlc.repo) || (task.sdlc?.issue !== undefined && next.issue !== task.sdlc.issue);
|
|
2831
3027
|
if (config.mode === "enforce" && by !== USER && specRelink) throw new PreEffectToolRefusal("only the person may relink an established repository or issue in enforce");
|
|
2832
|
-
const definition = stageDefinition(config, next.stage);
|
|
2833
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");
|
|
2834
3029
|
// #481 S1: entering a build stage in enforce needs the person's spec acceptance; the person may move any stage.
|
|
2835
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"));
|
|
2836
3031
|
if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
|
|
2837
3032
|
const actor = definition?.actor;
|
|
2838
|
-
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));
|
|
2839
3034
|
if (!acts) throw new PreEffectToolRefusal(`lifecycle stage requires its ${actor ?? "unknown"} actor or the person`);
|
|
2840
3035
|
}
|
|
2841
3036
|
if (input && typeof input === "object" && ("criteria" in input || "phase" in input)) {
|
|
@@ -3325,7 +3520,49 @@ export class Tasks {
|
|
|
3325
3520
|
const { sha256, id, format, version, path, private: hidden } = shown[i]!;
|
|
3326
3521
|
return { id, sha256, state: check.state, ...(path ? { path } : {}), format, ...(version ? { version } : {}), ...(hidden ? { private: true as const } : {}) };
|
|
3327
3522
|
});
|
|
3328
|
-
|
|
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 } } : {}) };
|
|
3329
3566
|
}
|
|
3330
3567
|
|
|
3331
3568
|
/** Check records against the owner's root; with that root unproven every file is `unknown` (never `unchanged`, never an exception). */
|
|
@@ -3610,7 +3847,8 @@ export class Tasks {
|
|
|
3610
3847
|
const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
3611
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);
|
|
3612
3849
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
3613
|
-
|
|
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);
|
|
3614
3852
|
this.tellCompleted(next, summary, withheld);
|
|
3615
3853
|
if (!reviewer) {
|
|
3616
3854
|
this.settleMemory(next);
|
|
@@ -3878,6 +4116,39 @@ export class Tasks {
|
|
|
3878
4116
|
} catch { return undefined; }
|
|
3879
4117
|
}
|
|
3880
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
|
+
|
|
3881
4152
|
/** Host observations are saved only with an admitted transition, never by an optional read. */
|
|
3882
4153
|
private observedCodeSymbols(task: Task) {
|
|
3883
4154
|
if (this.isPii(task) || !task.refs.commit) return undefined;
|
|
@@ -4127,7 +4398,7 @@ export class Tasks {
|
|
|
4127
4398
|
}
|
|
4128
4399
|
|
|
4129
4400
|
/** Withdraw only a former role's queued request, then tell it that its authority changed. */
|
|
4130
|
-
private withdrawRoleRequest(previous: Task, next: Task, role: TaskAssignmentRole): void {
|
|
4401
|
+
private withdrawRoleRequest(previous: Task, next: Task, role: Exclude<TaskAssignmentRole, "planner">): void {
|
|
4131
4402
|
const peer = previous[role];
|
|
4132
4403
|
if (!peer || peer === next[role]) return;
|
|
4133
4404
|
const kind = role === "owner" ? "task" : "review";
|
|
@@ -4151,7 +4422,18 @@ export class Tasks {
|
|
|
4151
4422
|
|
|
4152
4423
|
/** Caller authority is checked by the daemon; keep the real actor in task history. */
|
|
4153
4424
|
async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner", expectedFlowRevision?: number): Promise<Task> {
|
|
4154
|
-
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
|
+
}
|
|
4155
4437
|
if (role === "reviewer" && by !== USER) {
|
|
4156
4438
|
const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
4157
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");
|
|
@@ -4335,28 +4617,31 @@ export class Tasks {
|
|
|
4335
4617
|
const requestCheck = this.requestCheck(by);
|
|
4336
4618
|
const text = composeRememberText(input);
|
|
4337
4619
|
const task = input.task !== undefined ? this.d.board.get(Number(input.task)) : undefined;
|
|
4338
|
-
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");
|
|
4339
4621
|
// Shared with every other peer as well, so text that matches a PII pattern is refused like a note about a PII task.
|
|
4340
4622
|
const title = String(input.title ?? "").trim();
|
|
4341
|
-
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");
|
|
4342
4624
|
if (input.horizon !== undefined && input.horizon !== "short" && input.horizon !== "long") throw new Error('horizon is "short" or "long"');
|
|
4343
4625
|
if (input.scope !== undefined && input.scope !== "project" && input.scope !== "shared") throw new Error('scope is "project" or "shared"');
|
|
4344
4626
|
if (!this.d.memory && !this.d.store) return "memory is disabled; nothing saved";
|
|
4345
|
-
// Long-term notes leave the machine (#258)
|
|
4627
|
+
// Long-term notes can leave the machine (#258); local storage does not relax secret exclusion.
|
|
4346
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");
|
|
4347
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");
|
|
4348
|
-
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");
|
|
4349
4631
|
requestCheck();
|
|
4350
4632
|
const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) as Kind : "finding";
|
|
4351
4633
|
const native = this.saveNative(by, kind, text, title, task, input.horizon === "long" || input.horizon === "short" ? input.horizon : undefined, input.scope === "shared" ? "shared" : "project");
|
|
4352
4634
|
if (task && this.d.memory) this.markMemoryReference(task, by);
|
|
4353
|
-
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;
|
|
4354
|
-
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
|
+
}
|
|
4355
4640
|
const line = noteLine(by, kind, title ? `${title}: ${text}` : text);
|
|
4356
4641
|
this.d.notify(line);
|
|
4357
4642
|
// Display identity only: a queued recall may already contain this saved entry. Stored/captured text stays unchanged.
|
|
4358
4643
|
this.d.share?.(by, native ? noteLine(by, kind, `mem:${native.id} ${title ? `${title}: ` : ""}${text}`) : line);
|
|
4359
|
-
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`;
|
|
4360
4645
|
}
|
|
4361
4646
|
|
|
4362
4647
|
/** The task's key in the hub's store: unique across `ahub reset --all`, which restarts the ids. */
|
|
@@ -4399,7 +4684,7 @@ export class Tasks {
|
|
|
4399
4684
|
* long, decisions, contracts and fails are long-term. Fail-open: a store that cannot be written costs one notice.
|
|
4400
4685
|
* The caller has screened the text for PII; never a PII task's.
|
|
4401
4686
|
*/
|
|
4402
|
-
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 {
|
|
4403
4688
|
const st = this.d.store;
|
|
4404
4689
|
if (!st || (task && this.isPii(task))) return undefined;
|
|
4405
4690
|
try { // never throws: callers run after a board write
|
|
@@ -4407,7 +4692,12 @@ export class Tasks {
|
|
|
4407
4692
|
// Approved, or settled in the store already: the snapshot the caller holds can predate an approval made while it awaited.
|
|
4408
4693
|
const approved = !!task && (task.state === "approved" || st.store.settled(this.memoryKey(task)));
|
|
4409
4694
|
const tier: Tier = horizon ?? (approved && PROMOTED_KINDS.includes(kind) ? "long" : "short");
|
|
4410
|
-
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
|
+
}
|
|
4411
4701
|
// A short-term note on a task already approved (before hub memory existed, or late): the task is settled first, so
|
|
4412
4702
|
// the note gets its own deadline and is never taken for a source waiting for promotion.
|
|
4413
4703
|
if (task && approved && tier === "short" && !st.store.settled(this.memoryKey(task))) st.store.settle(this.memoryKey(task), by);
|
|
@@ -4457,16 +4747,15 @@ export class Tasks {
|
|
|
4457
4747
|
}
|
|
4458
4748
|
|
|
4459
4749
|
/** Auto notes: only transitions that carry content, never for PII. */
|
|
4460
|
-
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 {
|
|
4461
4751
|
if (this.isPii(task) || (!this.d.memory && !this.d.store)) return;
|
|
4462
|
-
//
|
|
4463
|
-
// 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.
|
|
4464
4753
|
if (withheld || !this.nameable(text)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (${this.whyWithheld(text)})`);
|
|
4465
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)`);
|
|
4466
4755
|
const title = `agent-hub task #${task.id}: ${task.title}`.slice(0, 120);
|
|
4467
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)`);
|
|
4468
|
-
this.saveNative(by, kind, text, title, task);
|
|
4469
|
-
if (this.d.memory) {
|
|
4757
|
+
const native = this.saveNative(by, kind, text, title, task, undefined, "project", symbols);
|
|
4758
|
+
if (!native && this.d.memory) {
|
|
4470
4759
|
try { this.markMemoryReference(task, by); } catch { return this.releaseNotice(`task #${task.id}: memory reference could not be recorded; note was not saved`); }
|
|
4471
4760
|
void this.d.memory.save({ text, title, project: this.d.project, metadata: { peer: by, task: task.id, kind } });
|
|
4472
4761
|
}
|