@staix/agent-hub 0.15.0 → 0.17.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 +43 -0
- package/README.md +12 -8
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +24 -8
- package/docs/agent-notes/budget.md +4 -2
- package/docs/agent-notes/daemon.md +11 -4
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +15 -3
- package/docs/agent-notes/tests.md +8 -3
- package/docs/dogfood.md +18 -6
- package/docs/events.md +1 -1
- package/docs/memory.md +78 -10
- package/docs/operations.md +160 -47
- package/docs/quickstart.md +13 -5
- package/docs/roles.md +1 -0
- package/docs/sdlc.md +11 -11
- package/docs/security.md +94 -22
- package/docs/smoke.md +58 -6
- package/docs/specs/2026-09-19-agent-hub-design.md +124 -12
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
- 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/verification/2026-10-25-0.17.0.md +74 -0
- package/docs/verification/2026-10-25-codex-0164-reviewer-tool-call.md +52 -0
- package/docs/verification/2026-10-25-kimi-231-reviewer-compat.md +48 -0
- package/docs/verification/2026-10-25-kimi-usage-ac11.md +48 -0
- package/docs/verification/2026-10-25-qwen-dual-output-hub-e2e.md +23 -0
- package/docs/verification/2026-10-25-qwen-dual-output-t0.md +20 -0
- package/docs/verification/2026-10-25-reviewer-no-verdict-diagnostics.md +35 -0
- package/docs/verified.json +30 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3421 -2176
- package/src/adapters/acp.ts +5 -2
- package/src/adapters/codex-appserver.ts +69 -1
- package/src/adapters/codex-review-isolation.ts +10 -1
- package/src/adapters/compat.ts +9 -4
- package/src/adapters/conformance/enrollment.ts +18 -4
- package/src/adapters/conformance/factories.ts +4 -1
- package/src/adapters/conformance/kit.ts +11 -0
- package/src/adapters/definitions/builtin-metadata.ts +3 -3
- package/src/adapters/definitions/native-cli-entry-qwen.ts +32 -4
- package/src/adapters/definitions/native-cli-preview-qwen.ts +9 -1
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/definitions/native-recovery.ts +8 -0
- package/src/adapters/definitions/qwen.ts +7 -4
- package/src/adapters/local-worker.ts +5 -4
- package/src/adapters/qwen-dual.ts +578 -0
- package/src/adapters/registry-types.ts +6 -2
- package/src/adapters/registry.ts +9 -1
- package/src/adapters/reviewer-mcp.ts +12 -6
- package/src/adapters/reviewer-print.ts +100 -17
- 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/cli/upgrade.ts +4 -3
- 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 +384 -71
- 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 +11 -27
- 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 +378 -76
- 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 +66 -16
- 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";
|
|
@@ -619,7 +653,10 @@ export class Tasks {
|
|
|
619
653
|
if (this.isPii(row)) return false;
|
|
620
654
|
const last = row.history.findLast(entry => entry.reviewFindings?.items.some(value => value.id === item.id));
|
|
621
655
|
const finding = last?.reviewFindings?.items.find(value => value.id === item.id);
|
|
622
|
-
|
|
656
|
+
// #328: a follow-up that only records an earlier task's finding as fixed never had it; it counts once seen open.
|
|
657
|
+
// By id alone: a reclassified finding keeps its id (task note); a withheld review is never evidence.
|
|
658
|
+
const occurred = row.history.some(entry => !entry.withheld && entry.reviewFindings?.items.some(value => value.id === item.id && (value.disposition === "open" || value.disposition === "accepted")));
|
|
659
|
+
return !last?.withheld && finding && same(finding) && occurred && finding.disposition !== "false_positive" && this.findingTextAllowed(row, [finding]);
|
|
623
660
|
});
|
|
624
661
|
if (this.workUnits(holders) < 2) continue;
|
|
625
662
|
const key = `Review finding key: ${item.pass}:${item.id}`;
|
|
@@ -653,6 +690,10 @@ export class Tasks {
|
|
|
653
690
|
...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
|
|
654
691
|
...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
|
|
655
692
|
...(h.owner === undefined ? {} : { owner: h.owner }),
|
|
693
|
+
...(h.planner === undefined ? {} : { planner: h.planner }),
|
|
694
|
+
...(h.plannerReason ? { plannerReason: h.plannerReason } : {}),
|
|
695
|
+
...(h.planAuthor === undefined ? {} : { planAuthor: h.planAuthor }),
|
|
696
|
+
...(h.selfReview ? { selfReview: true } : {}),
|
|
656
697
|
...(h.reviewerFamily ? { reviewerFamily: screenHistoryText(h.reviewerFamily, "reviewer family") } : {}),
|
|
657
698
|
...(h.reviewerReason ? { reviewerReason: h.reviewerReason } : {}),
|
|
658
699
|
...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
|
|
@@ -783,7 +824,7 @@ export class Tasks {
|
|
|
783
824
|
}
|
|
784
825
|
|
|
785
826
|
/** 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 {
|
|
827
|
+
private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: Exclude<TaskAssignmentRole, "planner"> = "owner", preEffect = true): void {
|
|
787
828
|
const instances = this.d.instances?.(), adapter = (this.d.adapterCatalog?.definitionOf ?? definitionOf)(peer, instances);
|
|
788
829
|
const declaredExtra = !!adapter && adapter.kind !== peer;
|
|
789
830
|
if ((!this.d.capabilities || this.isPii(task)) && !declaredExtra) return;
|
|
@@ -797,7 +838,7 @@ export class Tasks {
|
|
|
797
838
|
/**
|
|
798
839
|
* #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
840
|
* authority (`d.roles`), restricted to rostered peers, so assignment, planner notices and `route explain` agree on who the holders are.
|
|
800
|
-
*
|
|
841
|
+
* Selection stays in assign(); task bindings only keep that selection while the holder remains eligible.
|
|
801
842
|
*/
|
|
802
843
|
private plannerHolders(): PeerId[] {
|
|
803
844
|
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 +847,7 @@ export class Tasks {
|
|
|
806
847
|
private weights(cls: TaskClass, task?: Task) {
|
|
807
848
|
const now = Date.now();
|
|
808
849
|
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 } } : {}) };
|
|
850
|
+
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
851
|
}
|
|
811
852
|
|
|
812
853
|
/**
|
|
@@ -901,13 +942,62 @@ export class Tasks {
|
|
|
901
942
|
try { this.d.recordPlanning?.(change); } catch { /* telemetry never changes a durable result */ }
|
|
902
943
|
}
|
|
903
944
|
|
|
904
|
-
|
|
905
|
-
|
|
945
|
+
/** A removed/off-roster binding falls back to legacy holders; a busy, paused or offline holder keeps authority. */
|
|
946
|
+
taskPlanner(task: Pick<Task, "planner">): PeerId[] {
|
|
947
|
+
const holders = Object.keys(this.d.roles ?? {}).filter(peer => this.d.roles?.[peer]?.includes("planner")).sort();
|
|
948
|
+
return task.planner && holders.includes(task.planner) && this.d.bus.isRostered(task.planner) ? [task.planner] : holders;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
private planningAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
|
|
952
|
+
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
953
|
}
|
|
907
954
|
|
|
908
|
-
private plannerWriteAuthority(by: PeerId): void {
|
|
955
|
+
private plannerWriteAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
|
|
909
956
|
if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("planner writes are unavailable during a private turn");
|
|
910
|
-
this.planningAuthority(by);
|
|
957
|
+
this.planningAuthority(by, task);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** Every new host proposal, including split children, consumes the same pure walk before its first notification. */
|
|
961
|
+
private proposalPlanner(task: Task): PlannerBinding {
|
|
962
|
+
const choice = assign({ ...task, state: "proposed" }, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [] });
|
|
963
|
+
return { planner: this.isPii(task) ? null : choice.planner ?? null, plannerReason: this.isPii(task) ? "none" : choice.plannerReason ?? "none" };
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
private plannerBinding(task: Task, assignment: Assignment, bindNull = false): PlannerBinding | undefined {
|
|
967
|
+
if (bindNull && task.planner == null) return { planner: this.isPii(task) ? null : assignment.planner ?? null, plannerReason: assignment.plannerReason ?? "none" };
|
|
968
|
+
return task.planner && this.plannerHolders().includes(task.planner) ? { planner: task.planner, plannerReason: "kept" } : undefined;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
private planAuthor(task: Task): PeerId | null {
|
|
972
|
+
// Review scope bookkeeping preserves the existing plan-content author.
|
|
973
|
+
const authored = task.history.findLast(entry => entry.event !== "plan reviewed" && entry.planRevision !== undefined)
|
|
974
|
+
?? task.history.findLast(entry => entry.event !== "plan reviewed" && (entry.planning?.fields as readonly string[] | undefined)?.includes("plan"));
|
|
975
|
+
if (authored && authored !== task.history[0]) return authored.by;
|
|
976
|
+
return ALL_PLAN_KEYS.some(key => !!task.plan?.[key]?.length) ? authored?.by ?? task.history[0]?.by ?? null : null;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
private planReviewAuthority(by: PeerId, task: Task): { planAuthor: PeerId | null; selfReview?: true } {
|
|
980
|
+
this.plannerWriteAuthority(by); // Independent settled holders have review authority only, not the task's other planner powers.
|
|
981
|
+
const author = this.planAuthor(task);
|
|
982
|
+
const independent = this.plannerHolders().filter(peer => peer !== author && this.d.bus.stateOf(peer) !== "offline");
|
|
983
|
+
if (by !== USER && by === author && independent.length) throw new PreEffectToolRefusal("an independent planner reviews this plan");
|
|
984
|
+
return { planAuthor: author, ...(by === author ? { selfReview: true as const } : {}) };
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
private requestIndependentPlanReview(task: Task): void {
|
|
988
|
+
const author = this.planAuthor(task);
|
|
989
|
+
if (!author || !this.taskPlanner(task).includes(author)) return;
|
|
990
|
+
const planners = this.plannerHolders().filter(peer => peer !== author && this.d.bus.stateOf(peer) !== "offline");
|
|
991
|
+
if (!planners.length) return;
|
|
992
|
+
const selected = assign({ ...task, state: "proposed" }, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], planners, keptPlanner: undefined }).planner;
|
|
993
|
+
if (!selected) return;
|
|
994
|
+
const revision = task.planningRevision ?? 0, signature = `${revision}:${author}:${selected}`;
|
|
995
|
+
if (this.planReviewRequests.get(task.id) === signature) return;
|
|
996
|
+
const view = this.publicView(task);
|
|
997
|
+
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.`, {
|
|
998
|
+
to: [selected], kind: "task", refs: { task: String(task.id), planReview: revision, planReviewFlow: task.history.length, planAuthor: author }, ...(this.isPii(task) ? { private: true } : {}),
|
|
999
|
+
}));
|
|
1000
|
+
if (queued.includes(selected)) this.planReviewRequests.set(task.id, signature);
|
|
911
1001
|
}
|
|
912
1002
|
|
|
913
1003
|
wipLimit(peer: PeerId): number {
|
|
@@ -966,6 +1056,7 @@ export class Tasks {
|
|
|
966
1056
|
const validate = () => {
|
|
967
1057
|
const task = this.d.board.get(input.id);
|
|
968
1058
|
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");
|
|
1059
|
+
this.planningAuthority(by, task);
|
|
969
1060
|
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
970
1061
|
if (task.release != null && (!release || ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull requires unfrozen release scope");
|
|
971
1062
|
if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
|
|
@@ -986,7 +1077,7 @@ export class Tasks {
|
|
|
986
1077
|
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
1078
|
throw new PreEffectToolRefusal(`${this.hasPersonReservation(task) ? "a person's reservation is retained; " : ""}${refusal}`);
|
|
988
1079
|
}
|
|
989
|
-
return { task, owner: assignment.owner };
|
|
1080
|
+
return { task, owner: assignment.owner, assignment };
|
|
990
1081
|
};
|
|
991
1082
|
const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
|
|
992
1083
|
authorized();
|
|
@@ -999,7 +1090,7 @@ export class Tasks {
|
|
|
999
1090
|
if (checked.task.history.length !== current.task.history.length || checked.owner !== current.owner) throw new PreEffectToolRefusal("planner pull changed during admission");
|
|
1000
1091
|
} } });
|
|
1001
1092
|
}
|
|
1002
|
-
const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld);
|
|
1093
|
+
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
1094
|
this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
|
|
1004
1095
|
return pulled;
|
|
1005
1096
|
}
|
|
@@ -1011,15 +1102,77 @@ export class Tasks {
|
|
|
1011
1102
|
private addPlannerBacklog(peer: PeerId, ids: readonly number[]): void {
|
|
1012
1103
|
if (!this.d.roles?.[peer]?.includes("planner")) return;
|
|
1013
1104
|
const pending = this.plannerBacklog.get(peer) ?? new Set<number>();
|
|
1014
|
-
for (const id of ids)
|
|
1105
|
+
for (const id of ids) {
|
|
1106
|
+
const task = this.d.board.get(id);
|
|
1107
|
+
if (task?.state === "backlog" && this.taskPlanner(task).includes(peer)) pending.add(id);
|
|
1108
|
+
}
|
|
1015
1109
|
if (pending.size) this.plannerBacklog.set(peer, pending);
|
|
1016
1110
|
}
|
|
1017
|
-
|
|
1111
|
+
/**
|
|
1112
|
+
* 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
|
|
1113
|
+
* what it did not write itself and plans (`taskPlanner`, the #525-2 scoping): a planning revision or dependency change (in or outside a
|
|
1114
|
+
* `planning` entry) or the creation of a card is a task notice, replacing a copy that
|
|
1115
|
+
* is still unsettled so the planner meets the latest revision once; a state move, review or assignment is recorded at `fyi` weight (no turn),
|
|
1116
|
+
* 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
|
|
1117
|
+
* (a restart, or the first one after it was attached), as before.
|
|
1118
|
+
*/
|
|
1119
|
+
private groomNotices(configured: readonly PeerId[], planners: readonly PeerId[]): void {
|
|
1120
|
+
const scan = this.groomScan, revision = this.d.board.statusRevision(), first = scan.revision < 0;
|
|
1121
|
+
if (scan.revision !== revision) {
|
|
1122
|
+
for (const task of this.d.board.list()) {
|
|
1123
|
+
const seen = scan.lengths.get(task.id), depsKey = JSON.stringify(task.deps ?? []), depsBefore = scan.deps.get(task.id);
|
|
1124
|
+
scan.lengths.set(task.id, task.history.length); scan.deps.set(task.id, depsKey);
|
|
1125
|
+
if (first || seen === task.history.length && depsBefore === depsKey) continue;
|
|
1126
|
+
const holders = this.taskPlanner(task);
|
|
1127
|
+
const tell = (author: string, review: boolean) => {
|
|
1128
|
+
for (const holder of configured) { // every configured planner, so a change made while one was offline is not lost
|
|
1129
|
+
if (holder === author || !holders.includes(holder)) continue;
|
|
1130
|
+
const pending = scan.pending.get(holder) ?? { review: false, other: false };
|
|
1131
|
+
if (review) pending.review = true; else pending.other = true;
|
|
1132
|
+
scan.pending.set(holder, pending);
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1135
|
+
// 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.
|
|
1136
|
+
if (seen === undefined) tell(task.history[0]?.by ?? HUB, true);
|
|
1137
|
+
for (const entry of seen === undefined ? task.history.slice(1) : task.history.slice(seen)) {
|
|
1138
|
+
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
|
|
1139
|
+
tell(entry.by, entry.planning !== undefined);
|
|
1140
|
+
}
|
|
1141
|
+
// Dependencies changed outside a `planning` entry (a propose with `after`, a split, any other write): the same as a planning change.
|
|
1142
|
+
// 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.
|
|
1143
|
+
if (depsBefore !== undefined && depsBefore !== depsKey) tell(seen !== undefined && seen < task.history.length ? task.history.at(-1)!.by : "", true);
|
|
1144
|
+
}
|
|
1145
|
+
scan.revision = revision;
|
|
1146
|
+
}
|
|
1147
|
+
const now = this.d.now?.() ?? Date.now();
|
|
1148
|
+
for (const peer of planners) {
|
|
1149
|
+
const told = scan.sent.get(peer), pending = scan.pending.get(peer);
|
|
1150
|
+
const review = told === undefined || pending?.review === true;
|
|
1151
|
+
if (!review && !pending?.other) continue;
|
|
1152
|
+
if (!review && now - told!.fyiAt < GROOM_FYI_WINDOW_MS) { scan.pending.delete(peer); continue; }
|
|
1153
|
+
this.releaseNotice(`could not notify planner ${peer} about grooming`, () => {
|
|
1154
|
+
if (review) this.sendGroomNotice(peer, revision);
|
|
1155
|
+
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" }));
|
|
1156
|
+
scan.pending.delete(peer);
|
|
1157
|
+
scan.sent.set(peer, { fyiAt: review ? told?.fyiAt ?? 0 : now });
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/** The task-weight notice: an unsettled earlier copy for this planner is withdrawn and replaced, never a second one queued. */
|
|
1163
|
+
private sendGroomNotice(peer: PeerId, revision: number): void {
|
|
1164
|
+
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.`;
|
|
1165
|
+
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);
|
|
1166
|
+
const recipients = this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task" }));
|
|
1167
|
+
if (!recipients.includes(peer)) throw new Error("the notice was not queued");
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1018
1170
|
/** One bounded ids-only batch per attached planner; failed or evicted copies stay pending. */
|
|
1019
1171
|
flushPlannerNotices(): number {
|
|
1020
1172
|
const configured = Object.entries(this.d.roles ?? {}).filter(([, roles]) => roles.includes("planner")).map(([peer]) => peer);
|
|
1021
1173
|
for (const peer of this.knownPlanners) if (!configured.includes(peer)) {
|
|
1022
1174
|
this.knownPlanners.delete(peer); this.plannerBacklog.delete(peer); this.plannerBlockedQueue.delete(peer);
|
|
1175
|
+
this.groomScan.pending.delete(peer); this.groomScan.sent.delete(peer);
|
|
1023
1176
|
}
|
|
1024
1177
|
for (const peer of configured) if (!this.knownPlanners.has(peer)) {
|
|
1025
1178
|
this.knownPlanners.add(peer);
|
|
@@ -1028,18 +1181,7 @@ export class Tasks {
|
|
|
1028
1181
|
}
|
|
1029
1182
|
const planners = configured.filter(peer => this.d.bus.isRostered(peer) && this.d.bus.peers.has(peer) && this.d.bus.stateOf(peer) !== "offline");
|
|
1030
1183
|
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
|
-
}
|
|
1184
|
+
if ((this.d.sdlc?.config().mode ?? "off") !== "off") this.groomNotices(configured, planners);
|
|
1043
1185
|
for (const id of this.incomingBacklog) if (this.d.board.get(id)?.state !== "backlog") this.incomingBacklog.delete(id);
|
|
1044
1186
|
const incoming = [...this.incomingBacklog].sort((a, b) => a - b).slice(0, 64);
|
|
1045
1187
|
if (incoming.length) {
|
|
@@ -1053,10 +1195,17 @@ export class Tasks {
|
|
|
1053
1195
|
for (const peer of planners) {
|
|
1054
1196
|
const pending = this.plannerBacklog.get(peer);
|
|
1055
1197
|
if (!pending) continue;
|
|
1198
|
+
for (const id of pending) {
|
|
1199
|
+
const task = this.d.board.get(id);
|
|
1200
|
+
if (task?.state !== "backlog") pending.delete(id);
|
|
1201
|
+
else if (!this.taskPlanner(task).includes(peer)) {
|
|
1202
|
+
pending.delete(id);
|
|
1203
|
+
for (const recipient of this.taskPlanner(task)) this.addPlannerBacklog(recipient, [id]);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1056
1206
|
const blockedQueue = this.plannerBlockedQueue.get(peer);
|
|
1057
1207
|
if (blockedQueue !== undefined && blockedQueue === JSON.stringify(this.d.bus.queueIds(peer))) continue;
|
|
1058
1208
|
this.plannerBlockedQueue.delete(peer);
|
|
1059
|
-
for (const id of pending) if (this.d.board.get(id)?.state !== "backlog") pending.delete(id);
|
|
1060
1209
|
const ids = [...pending].sort((a, b) => a - b).slice(0, 64);
|
|
1061
1210
|
if (!ids.length) continue;
|
|
1062
1211
|
// Remove before publish: an immediate overflow callback may put this recipient's ids back.
|
|
@@ -1113,7 +1262,7 @@ export class Tasks {
|
|
|
1113
1262
|
});
|
|
1114
1263
|
}
|
|
1115
1264
|
|
|
1116
|
-
async planBacklog(by: PeerId, input: { id: number; revision: number; fields
|
|
1265
|
+
async planBacklog(by: PeerId, input: { id: number; revision: number; fields?: unknown; reason: string }): Promise<Task> {
|
|
1117
1266
|
this.planningAuthority(by);
|
|
1118
1267
|
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid planning identity or revision");
|
|
1119
1268
|
if (typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("planning needs a bounded reason");
|
|
@@ -1122,6 +1271,12 @@ export class Tasks {
|
|
|
1122
1271
|
const validate = () => {
|
|
1123
1272
|
const task = this.d.board.get(input.id);
|
|
1124
1273
|
if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("planning revision changed");
|
|
1274
|
+
this.planningAuthority(by, task);
|
|
1275
|
+
if (patch.sdlc !== undefined) {
|
|
1276
|
+
if (task.state !== "backlog" || task.owner || taskHasStarted(task)) throw new PreEffectToolRefusal("issue identity changes require unstarted backlog work");
|
|
1277
|
+
if (by !== USER && this.isPii(task)) throw new PreEffectToolRefusal("private issue identity changes require the person");
|
|
1278
|
+
if (!this.isPii(task) && !this.publicMetadataText(JSON.stringify(patch.sdlc))) throw new PreEffectToolRefusal("planning identity metadata is private or sensitive");
|
|
1279
|
+
}
|
|
1125
1280
|
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
|
|
1126
1281
|
if (by !== USER && (task.state !== "backlog" || !taskCanPlan(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
|
|
1127
1282
|
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 +1309,7 @@ export class Tasks {
|
|
|
1154
1309
|
for (const id of new Set([task.release, target])) {
|
|
1155
1310
|
if (id == null) continue;
|
|
1156
1311
|
const release = this.d.board.release(id)!;
|
|
1312
|
+
if (Object.hasOwn(patch, "release")) this.planningAuthority(by, release);
|
|
1157
1313
|
if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release scope is final");
|
|
1158
1314
|
if (by !== USER && release.state === "frozen") throw new PreEffectToolRefusal("frozen release changes require a person");
|
|
1159
1315
|
}
|
|
@@ -1161,8 +1317,11 @@ export class Tasks {
|
|
|
1161
1317
|
};
|
|
1162
1318
|
const task = validate(), withheld = await this.withholds("note", input.reason, task);
|
|
1163
1319
|
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
|
-
|
|
1320
|
+
const latest = validate(); // a person may have changed planning or frozen a release while the screen ran
|
|
1321
|
+
let receipt: Task["sdlc"];
|
|
1322
|
+
try { receipt = patch.sdlc === undefined ? undefined : planningIssueReceipt(patch.sdlc, latest.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined)); }
|
|
1323
|
+
catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
1324
|
+
const updated = this.d.board.plan(task.id, by, patch, input.revision, input.reason.trim(), withheld, receipt);
|
|
1166
1325
|
this.planningRecorded({ by, task: task.id, fields: Object.keys(patch), revision: updated.planningRevision! });
|
|
1167
1326
|
return updated; // persistence/readback failures must never claim no effect
|
|
1168
1327
|
}
|
|
@@ -1171,6 +1330,7 @@ export class Tasks {
|
|
|
1171
1330
|
this.planningAuthority(by);
|
|
1172
1331
|
const task = this.d.board.get(id), entry = task ? planningUndoEntry(task) : undefined;
|
|
1173
1332
|
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");
|
|
1333
|
+
this.planningAuthority(by, task);
|
|
1174
1334
|
if (entry.planning.split) {
|
|
1175
1335
|
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
1176
1336
|
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 +1400,7 @@ export class Tasks {
|
|
|
1240
1400
|
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
1401
|
const release = parent.release == null ? undefined : this.d.board.release(parent.release);
|
|
1242
1402
|
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");
|
|
1403
|
+
this.planningAuthority(by, parent);
|
|
1243
1404
|
const profile = parent.profile ?? "dev";
|
|
1244
1405
|
const drafts = rows.map((part, index) => {
|
|
1245
1406
|
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 +1465,12 @@ export class Tasks {
|
|
|
1304
1465
|
this.plannerWriteAuthority(by);
|
|
1305
1466
|
const task = this.d.board.get(input.id);
|
|
1306
1467
|
if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("release assignment changed");
|
|
1468
|
+
this.plannerWriteAuthority(by, task);
|
|
1307
1469
|
if (by !== USER && (this.isPii(task) || task.pinned)) throw new PreEffectToolRefusal("private or pinned release scope requires the person");
|
|
1308
1470
|
for (const id of new Set([task.release, input.release])) if (id != null) {
|
|
1309
1471
|
const release = this.d.board.release(id);
|
|
1310
1472
|
if (!release || ["frozen", "shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("release scope is frozen or final");
|
|
1473
|
+
this.plannerWriteAuthority(by, release);
|
|
1311
1474
|
}
|
|
1312
1475
|
return task;
|
|
1313
1476
|
};
|
|
@@ -1326,6 +1489,7 @@ export class Tasks {
|
|
|
1326
1489
|
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid release identity or revision");
|
|
1327
1490
|
const release = this.d.board.release(input.id);
|
|
1328
1491
|
if (!release || release.revision !== input.revision || !["planning", "active"].includes(release.state)) throw new PreEffectToolRefusal("release revision changed or scope frozen");
|
|
1492
|
+
this.plannerWriteAuthority(by, release);
|
|
1329
1493
|
if (input.state !== undefined && input.state !== "active") throw new PreEffectToolRefusal("invalid train state");
|
|
1330
1494
|
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
1495
|
const dates = [release.plannedStart, input.cut ?? release.cut, release.ship].filter((date): date is number => date !== null);
|
|
@@ -1369,6 +1533,7 @@ export class Tasks {
|
|
|
1369
1533
|
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid release identity or revision");
|
|
1370
1534
|
const release = this.d.board.release(input.id);
|
|
1371
1535
|
if (!release || release.revision !== input.revision || release.state !== "active") throw new PreEffectToolRefusal("release revision changed or train is not active");
|
|
1536
|
+
this.plannerWriteAuthority(by, release);
|
|
1372
1537
|
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
1538
|
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
1539
|
const report = this.releaseReport(by, input.id);
|
|
@@ -1412,7 +1577,7 @@ export class Tasks {
|
|
|
1412
1577
|
if (!this.nameable(text) || this.screenOn() && (await this.screenCall(text)).label !== "clear") throw new PreEffectToolRefusal("release text was not cleared for public planning");
|
|
1413
1578
|
this.plannerWriteAuthority(by); // Privacy and planner identity may change while screening.
|
|
1414
1579
|
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);
|
|
1580
|
+
const release = this.d.board.newRelease(input.name, input.goal.trim(), input.order, by === USER ? null : by);
|
|
1416
1581
|
this.planningRecorded({ by, release: release.id, fields: ["created"], revision: release.revision });
|
|
1417
1582
|
return release;
|
|
1418
1583
|
}
|
|
@@ -1421,6 +1586,7 @@ export class Tasks {
|
|
|
1421
1586
|
this.planningAuthority(by);
|
|
1422
1587
|
const release = this.d.board.release(input.id);
|
|
1423
1588
|
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || !release || release.revision !== input.revision) throw new PreEffectToolRefusal("release revision changed");
|
|
1589
|
+
this.planningAuthority(by, release);
|
|
1424
1590
|
if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release is final");
|
|
1425
1591
|
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
1592
|
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 +1717,7 @@ export class Tasks {
|
|
|
1551
1717
|
|| typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("untouched return needs an id, revision and bounded reason");
|
|
1552
1718
|
const validate = () => {
|
|
1553
1719
|
const task = this.need(input.id, false, true);
|
|
1720
|
+
this.plannerWriteAuthority(by, task);
|
|
1554
1721
|
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
1722
|
if (by !== USER && task.pinned) throw new PreEffectToolRefusal("pinned work requires the person");
|
|
1556
1723
|
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
@@ -1783,9 +1950,18 @@ export class Tasks {
|
|
|
1783
1950
|
if (env.from === HUB && env.kind === "task" && env.refs?.backlog) {
|
|
1784
1951
|
const ids = env.refs.backlog;
|
|
1785
1952
|
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 =>
|
|
1953
|
+
const current = ids.filter(id => {
|
|
1954
|
+
const task = this.d.board.get(id);
|
|
1955
|
+
return task?.state === "backlog" && this.taskPlanner(task).includes(peer);
|
|
1956
|
+
});
|
|
1787
1957
|
if (current.length !== ids.length || !this.d.roles?.[peer]?.includes("planner") || !this.d.bus.isRostered(peer)) {
|
|
1788
1958
|
this.addPlannerBacklog(peer, current);
|
|
1959
|
+
for (const id of ids) {
|
|
1960
|
+
const task = this.d.board.get(id);
|
|
1961
|
+
if (task?.state === "backlog" && !this.taskPlanner(task).includes(peer)) {
|
|
1962
|
+
for (const recipient of this.taskPlanner(task)) this.addPlannerBacklog(recipient, [id]);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1789
1965
|
return false;
|
|
1790
1966
|
}
|
|
1791
1967
|
return true;
|
|
@@ -1793,6 +1969,10 @@ export class Tasks {
|
|
|
1793
1969
|
if (env.from === HUB && env.refs?.task) {
|
|
1794
1970
|
const task = this.d.board.get(Number(env.refs.task));
|
|
1795
1971
|
if (task?.state === "cancelled") return env.kind === "task" && typeof env.refs.taskCancelled === "number" && env.refs.taskCancelled === task.cancelled?.flowRevision;
|
|
1972
|
+
if (env.refs.taskRecipient === "planner") return !!task && this.taskPlanner(task).includes(peer);
|
|
1973
|
+
if (env.refs.planReview !== undefined) return !!task && !isClosedTaskState(task.state) && (task.planningRevision ?? 0) === env.refs.planReview
|
|
1974
|
+
&& this.plannerHolders().includes(peer) && this.planAuthor(task) !== peer && Number.isSafeInteger(env.refs.planReviewFlow)
|
|
1975
|
+
&& !task.history.slice(env.refs.planReviewFlow).some(entry => entry.event === "plan reviewed");
|
|
1796
1976
|
}
|
|
1797
1977
|
const c = this.conditional.get(env.id);
|
|
1798
1978
|
if (!c || c.peer !== peer) return true;
|
|
@@ -2035,6 +2215,7 @@ export class Tasks {
|
|
|
2035
2215
|
}
|
|
2036
2216
|
task = current; // Reviewer-only and quiet bookkeeping updates do not supersede ownership authority.
|
|
2037
2217
|
if (gap) {
|
|
2218
|
+
this.requestIndependentPlanReview(current);
|
|
2038
2219
|
this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}; planner: call hub_planner_groom then hub_plan_review`);
|
|
2039
2220
|
if (mode === "enforce") {
|
|
2040
2221
|
if (opts.claim && opts.namedOwner === by) stagedClaimGap = gap;
|
|
@@ -2080,10 +2261,11 @@ export class Tasks {
|
|
|
2080
2261
|
const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
|
|
2081
2262
|
opts.plannerPull?.guard();
|
|
2082
2263
|
const assignmentNote = this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a);
|
|
2083
|
-
const
|
|
2264
|
+
const binding = this.plannerBinding(task, a, !!opts.plannerPull);
|
|
2265
|
+
const assignmentExtra = { ...this.reviewMetadata(a), ...(binding ? { plannerReason: binding.plannerReason } : {}), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) };
|
|
2084
2266
|
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);
|
|
2267
|
+
? 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)
|
|
2268
|
+
: 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
2269
|
if (opts.plannerPull) this.planningRecorded({ by, task: next.id, fields: ["pull", "reserved"], revision: next.planningRevision! });
|
|
2088
2270
|
// What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
|
|
2089
2271
|
// escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
|
|
@@ -2367,7 +2549,7 @@ export class Tasks {
|
|
|
2367
2549
|
this.releaseNotice(text);
|
|
2368
2550
|
try { this.d.stallNotice?.(stall); } catch { /* advisory never changes task authority */ }
|
|
2369
2551
|
// Advice joins the planner's next delivery; it must not manufacture a turn beside a withheld backlog batch.
|
|
2370
|
-
for (const
|
|
2552
|
+
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
2553
|
}
|
|
2372
2554
|
async cancel(id: unknown, by: PeerId, input: { reason: string; supersededBy?: string; expectedFlowRevision?: number }): Promise<Task> {
|
|
2373
2555
|
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 +2560,7 @@ export class Tasks {
|
|
|
2378
2560
|
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
|
|
2379
2561
|
if (by !== USER && this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("cancellation on ordinary work is unavailable during a private turn");
|
|
2380
2562
|
if (by !== USER) {
|
|
2381
|
-
this.planningAuthority(by);
|
|
2563
|
+
this.planningAuthority(by, task);
|
|
2382
2564
|
const proposer = task.history.find(h => h.event === "proposed" || h.event === "backlogged")?.by;
|
|
2383
2565
|
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
2566
|
}
|
|
@@ -2573,7 +2755,16 @@ export class Tasks {
|
|
|
2573
2755
|
if (issue) issues.set(key, issue);
|
|
2574
2756
|
}
|
|
2575
2757
|
this.planningAuthority(by);
|
|
2576
|
-
const
|
|
2758
|
+
const all = this.d.board.list(), current = all.filter(included), visible = current.filter(task => !this.isPii(task));
|
|
2759
|
+
// Include only bounded public dependency context, so approved intermediate phases do not disappear from reachability.
|
|
2760
|
+
const indexed = new Map(all.map(task => [task.id, task])), dependencies = new Map<number, readonly number[]>();
|
|
2761
|
+
const pending = visible.slice(0, 500).map(task => task.id).reverse(), visited = new Set<number>();
|
|
2762
|
+
while (pending.length && visited.size < 500) {
|
|
2763
|
+
const id = pending.pop()!; if (visited.has(id)) continue; visited.add(id);
|
|
2764
|
+
const task = indexed.get(id); if (!task || this.isPii(task)) continue;
|
|
2765
|
+
dependencies.set(id, task.deps ?? []); pending.push(...task.deps ?? []);
|
|
2766
|
+
}
|
|
2767
|
+
const dependencyTruncated = pending.some(id => !visited.has(id));
|
|
2577
2768
|
const peers = [...new Set([...Object.keys(this.states()), ...current.flatMap(task => task.owner ? [task.owner] : [])])];
|
|
2578
2769
|
const reviewedIssues = new Map(visible.flatMap(task => task.sdlc?.planReview?.issue ? [[task.id, task.sdlc.planReview.issue] as const] : []));
|
|
2579
2770
|
const acceptedIssues = new Map(visible.flatMap(task => task.sdlc?.acceptance ? [[task.id, task.sdlc.acceptance] as const] : []));
|
|
@@ -2587,7 +2778,7 @@ export class Tasks {
|
|
|
2587
2778
|
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
2779
|
if (this.waitsFor(task).length) blockers.push({ task: task.id, code: "dependency" });
|
|
2589
2780
|
}
|
|
2590
|
-
const digest = plannerGrooming(visible, { repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
|
|
2781
|
+
const digest = plannerGrooming(visible, { dependencies, dependencyTruncated, repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
|
|
2591
2782
|
limits: Object.fromEntries(peers.map(peer => [peer, this.wipLimit(peer)])), wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])),
|
|
2592
2783
|
states, issues, reviewedIssues, acceptedIssues, blockers });
|
|
2593
2784
|
return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
|
|
@@ -2770,6 +2961,7 @@ export class Tasks {
|
|
|
2770
2961
|
if (Object.keys(raw).some(key => !["revision", "reason", "review", "ci"].includes(key)) || !Number.isSafeInteger(raw.revision) || (raw.revision as number) < 0
|
|
2771
2962
|
|| typeof raw.reason !== "string" || !raw.reason.trim() || raw.reason.length > 1000) throw new PreEffectToolRefusal("plan review needs a current revision and bounded reason");
|
|
2772
2963
|
const task = this.need(id, false, true, true), revision = raw.revision as number;
|
|
2964
|
+
this.planReviewAuthority(by, task);
|
|
2773
2965
|
if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
|
|
2774
2966
|
if ((task.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("planning revision changed");
|
|
2775
2967
|
let scope: CostPlan;
|
|
@@ -2783,6 +2975,7 @@ export class Tasks {
|
|
|
2783
2975
|
const flow = task.history.length, withheld = await this.withholds("note", raw.reason, task);
|
|
2784
2976
|
this.plannerWriteAuthority(by);
|
|
2785
2977
|
const observed = this.need(task.id, false, true, true);
|
|
2978
|
+
this.planReviewAuthority(by, observed);
|
|
2786
2979
|
if (observed.history.length !== flow || (observed.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
|
|
2787
2980
|
const identity = this.planIssueIdentity(observed);
|
|
2788
2981
|
if (identity?.issue && this.isPii(observed)) throw new PreEffectToolRefusal("private issue plan review readback unavailable");
|
|
@@ -2791,10 +2984,12 @@ export class Tasks {
|
|
|
2791
2984
|
if (issue && !this.isPii(task) && !this.nameable(JSON.stringify(issue))) throw new PreEffectToolRefusal("plan review metadata matches a PII pattern");
|
|
2792
2985
|
this.plannerWriteAuthority(by);
|
|
2793
2986
|
const current = this.need(task.id, false, true, true);
|
|
2987
|
+
const authorship = this.planReviewAuthority(by, current);
|
|
2794
2988
|
if (current.history.length !== flow || (current.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
|
|
2795
2989
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2796
2990
|
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);
|
|
2991
|
+
planReview: { planner: by, ...authorship, planningRevision: revision, recordedAt: Date.now(), ...(Object.keys(scope).length ? { scope } : {}), ...(issue ? { issue } : {}) } } }, raw.reason.trim(), { ...authorship, ...(withheld ? { withheld: true } : {}) }, flow);
|
|
2992
|
+
this.planReviewRequests.delete(updated.id);
|
|
2798
2993
|
if (config.mode !== "off") updated = this.linkIssue(updated); // #694
|
|
2799
2994
|
if (updated.state === "proposed" && !updated.owner) {
|
|
2800
2995
|
this.offered.delete(updated.id);
|
|
@@ -2823,19 +3018,22 @@ export class Tasks {
|
|
|
2823
3018
|
|
|
2824
3019
|
recordLifecycle(by: PeerId, id: unknown, input: unknown, deliverables?: DeliverableRecord[]): Task {
|
|
2825
3020
|
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
3021
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2828
3022
|
const next = sdlcReceipt(input, task.sdlc, config, VERSION);
|
|
3023
|
+
const definition = stageDefinition(config, next.stage);
|
|
3024
|
+
const plannerStage = !!input && typeof input === "object" && !Array.isArray(input) && Object.hasOwn(input, "stage")
|
|
3025
|
+
&& Object.keys(input).every(key => ["stage", "repo", "issue"].includes(key))
|
|
3026
|
+
&& (definition?.actor === "planner" || definition?.actor === "owner/planner") && this.taskPlanner(task).includes(by);
|
|
3027
|
+
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
3028
|
// The spec identity is person-owned in enforce. No observation cache or restart can reopen a relink bypass.
|
|
2830
3029
|
const specRelink = (task.sdlc?.repo !== undefined && next.repo !== task.sdlc.repo) || (task.sdlc?.issue !== undefined && next.issue !== task.sdlc.issue);
|
|
2831
3030
|
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
3031
|
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
3032
|
// #481 S1: entering a build stage in enforce needs the person's spec acceptance; the person may move any stage.
|
|
2835
3033
|
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
3034
|
if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
|
|
2837
3035
|
const actor = definition?.actor;
|
|
2838
|
-
const acts = actor === "owner" ? by === task.owner : actor === "reviewer" ? by === task.reviewer : actor === "planner" ? this.
|
|
3036
|
+
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
3037
|
if (!acts) throw new PreEffectToolRefusal(`lifecycle stage requires its ${actor ?? "unknown"} actor or the person`);
|
|
2840
3038
|
}
|
|
2841
3039
|
if (input && typeof input === "object" && ("criteria" in input || "phase" in input)) {
|
|
@@ -3325,7 +3523,49 @@ export class Tasks {
|
|
|
3325
3523
|
const { sha256, id, format, version, path, private: hidden } = shown[i]!;
|
|
3326
3524
|
return { id, sha256, state: check.state, ...(path ? { path } : {}), format, ...(version ? { version } : {}), ...(hidden ? { private: true as const } : {}) };
|
|
3327
3525
|
});
|
|
3328
|
-
|
|
3526
|
+
const changedSinceReview = files.some(file => file.state === "changed");
|
|
3527
|
+
this.deliverableSeen.set(task.id, { key: Tasks.deliverableKey(basis), at: Date.now(), result: deliverableResult(files.map(file => file.state)) });
|
|
3528
|
+
return { state: approved ? "approved" : "delivered", changedSinceReview, files };
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
/** 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. */
|
|
3532
|
+
private deliverableSeen = new Map<number, { key: string; at: number; result: DeliverableResult }>();
|
|
3533
|
+
private static deliverableKey = (records: readonly DeliverableRecord[]) => records.map(record => `${record.id}:${record.sha256}`).join(",");
|
|
3534
|
+
|
|
3535
|
+
/**
|
|
3536
|
+
* #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).
|
|
3537
|
+
* 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).
|
|
3538
|
+
* Every path goes through `publicDeliverables`, so a PII task or a path under `pii.paths` shows id, hash and size only.
|
|
3539
|
+
*/
|
|
3540
|
+
async deliverableList(opts: { task?: number; approvedOnly?: boolean; check?: boolean } = {}): Promise<DeliverableRow[]> {
|
|
3541
|
+
const rows: DeliverableRow[] = [];
|
|
3542
|
+
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);
|
|
3543
|
+
for (const task of tasks) {
|
|
3544
|
+
const delivered = latestDeliverables(task.history);
|
|
3545
|
+
if (!delivered) continue;
|
|
3546
|
+
const approved = task.state === "approved" ? approvedDeliverables(task.history) : undefined;
|
|
3547
|
+
if (opts.approvedOnly && !approved) continue;
|
|
3548
|
+
const basis = approved ?? delivered, shown = publicDeliverables(basis, this.nameable)!;
|
|
3549
|
+
const row: DeliverableRow = { task: task.id, title: this.publicTitle(task), taskState: task.state, state: approved ? "approved" : "delivered", checked: false,
|
|
3550
|
+
files: shown.map(({ id, path, format, version, sha256, bytes, private: hidden }) => ({ id, ...(path ? { path } : {}), format, ...(version ? { version } : {}), sha256, bytes, ...(hidden ? { private: true as const } : {}) })) };
|
|
3551
|
+
if (opts.check) {
|
|
3552
|
+
const status = await this.deliverableStatus(task);
|
|
3553
|
+
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 })); }
|
|
3554
|
+
}
|
|
3555
|
+
rows.push(row);
|
|
3556
|
+
}
|
|
3557
|
+
return rows;
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
/** What the dashboard card shows: the board's records, plus the last live check when it measured exactly these records. Opens no file. */
|
|
3561
|
+
deliverableBadge(task: Task): { state: "delivered" | "approved"; files: DeliverableRow["files"]; checked?: { at: number; result: DeliverableResult } } | undefined {
|
|
3562
|
+
const delivered = latestDeliverables(task.history);
|
|
3563
|
+
if (!delivered) return undefined;
|
|
3564
|
+
const approved = task.state === "approved" ? approvedDeliverables(task.history) : undefined;
|
|
3565
|
+
const basis = approved ?? delivered, seen = this.deliverableSeen.get(task.id);
|
|
3566
|
+
return { state: approved ? "approved" : "delivered",
|
|
3567
|
+
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 } : {}) })),
|
|
3568
|
+
...(seen && seen.key === Tasks.deliverableKey(basis) ? { checked: { at: seen.at, result: seen.result } } : {}) };
|
|
3329
3569
|
}
|
|
3330
3570
|
|
|
3331
3571
|
/** Check records against the owner's root; with that root unproven every file is `unknown` (never `unchanged`, never an exception). */
|
|
@@ -3610,7 +3850,8 @@ export class Tasks {
|
|
|
3610
3850
|
const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
3611
3851
|
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
3852
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
3613
|
-
|
|
3853
|
+
const memorySymbols = this.completionMemorySymbols(next);
|
|
3854
|
+
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
3855
|
this.tellCompleted(next, summary, withheld);
|
|
3615
3856
|
if (!reviewer) {
|
|
3616
3857
|
this.settleMemory(next);
|
|
@@ -3765,12 +4006,12 @@ export class Tasks {
|
|
|
3765
4006
|
return admitted;
|
|
3766
4007
|
}
|
|
3767
4008
|
|
|
3768
|
-
private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness):
|
|
4009
|
+
private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness): boolean {
|
|
3769
4010
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
3770
4011
|
if (mode === "enforce" && !admission?.ready) {
|
|
3771
4012
|
// Background roster/quota role changes are retained, but never invent a remotely admitted review request.
|
|
3772
4013
|
this.releaseNotice(`task ${this.publicTitle(task)}: premature review held; retry the reviewer assignment after current CI and bot replies are verified`);
|
|
3773
|
-
return;
|
|
4014
|
+
return false;
|
|
3774
4015
|
}
|
|
3775
4016
|
|
|
3776
4017
|
const r = task.refs;
|
|
@@ -3832,7 +4073,7 @@ export class Tasks {
|
|
|
3832
4073
|
// #572 AC3: advisory only, from git state never from task text, so it is safe for a PII task too.
|
|
3833
4074
|
const checkout = mainCheckoutWarning(this.d.cwd, this.publicMetadataText);
|
|
3834
4075
|
const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${last?.noWrites ? "no writes recorded since acceptance\n" : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${checkout ? `Note: ${checkout}.\n` : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${carriedText}${conformanceText}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet, findings: [{id, pass: "bugs" | "security" | "compliance", severity: "important" | "nit", path?, disposition: "open" | "fixed" | "false_positive"}]}. Reuse finding ids when updating their disposition; only the person may accept a finding with a rationale.`;
|
|
3835
|
-
this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) }));
|
|
4076
|
+
return this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) })).includes(reviewer);
|
|
3836
4077
|
}
|
|
3837
4078
|
|
|
3838
4079
|
private reviewRequestRevision(task: Task): number {
|
|
@@ -3848,25 +4089,35 @@ export class Tasks {
|
|
|
3848
4089
|
}
|
|
3849
4090
|
|
|
3850
4091
|
/** Host-only failure report, bound to the exact review request and its still-current child generation. */
|
|
3851
|
-
async retryReviewer(id: number, failedPeer: PeerId, failure: { head?: string; flowRevision?: number; cause: "start_failed" | "quota" | "no_verdict" }, current: () => boolean): Promise<Task> {
|
|
4092
|
+
async retryReviewer(id: number, failedPeer: PeerId, failure: { head?: string; flowRevision?: number; cause: "start_failed" | "quota" | "no_verdict" }, current: () => boolean, onReoffered?: () => void): Promise<Task> {
|
|
3852
4093
|
if (!failure || !["start_failed", "quota", "no_verdict"].includes(failure.cause) || !/^[a-z][a-z0-9-]{0,63}$/.test(failedPeer) || !Number.isSafeInteger(failure.flowRevision) || failure.flowRevision! < 1 || failure.head !== undefined && (typeof failure.head !== "string" || text(failure.head) !== failure.head || Buffer.byteLength(failure.head) > 1200 || /\p{Cc}/u.test(failure.head))) throw new PreEffectToolRefusal("invalid reviewer failure binding");
|
|
3853
4094
|
const valid = () => {
|
|
3854
4095
|
const task = this.need(id, false, true);
|
|
3855
|
-
if (!current() || task.state !== "in_review" || task.reviewer !== failedPeer || this.reviewRequestRevision(task) !== failure.flowRevision || this.reviewHead(task) !== failure.head || this.isPii(task) || this.manuallyNamedReviewer(task)) throw new PreEffectToolRefusal("reviewer failure is no longer current");
|
|
4096
|
+
if (!current() || task.state !== "in_review" || task.reviewer !== failedPeer || this.reviewRequestRevision(task) !== failure.flowRevision || this.reviewHead(task) !== failure.head || this.isPii(task) || this.manuallyNamedReviewer(task) && failure.cause !== "no_verdict") throw new PreEffectToolRefusal("reviewer failure is no longer current");
|
|
3856
4097
|
return task;
|
|
3857
4098
|
};
|
|
3858
4099
|
const before = valid();
|
|
3859
4100
|
const admission = await this.reviewRequestAdmission(before, undefined, true, before.owner ?? USER, undefined, () => { valid(); return true; });
|
|
3860
4101
|
const task = valid();
|
|
3861
4102
|
const options = this.reviewPoolOptions(task);
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
const
|
|
4103
|
+
// A person-selected reviewer never falls through to an agent the person did not select.
|
|
4104
|
+
const manual = this.manuallyNamedReviewer(task);
|
|
4105
|
+
const selection = manual ? undefined : assign(task, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), reviewPool: { ...options, failed: [...options.failed, failedPeer] } });
|
|
4106
|
+
if (!selection?.reviewSelection && failure.cause !== "no_verdict") throw new PreEffectToolRefusal("no declared reviewer pool can receive the fallback");
|
|
4107
|
+
const reviewer = selection?.reviewSelection ? selection.reviewer ?? USER : USER;
|
|
3865
4108
|
valid();
|
|
3866
|
-
const
|
|
4109
|
+
const note = manual ? "named reviewer recorded no verdict; the person must review"
|
|
4110
|
+
: selection?.reviewSelection ? this.assignmentNote(`reviewer unavailable: ${failure.cause}`, selection) : "no declared reviewer pool can receive the fallback; the person must review";
|
|
4111
|
+
const next = this.d.board.update(task.id, HUB, "reviewer unavailable", { reviewer }, note, { ...this.reviewMetadata(selection), ...(reviewer === USER ? { reviewBlocked: true } : {}), reviewerFailure: { peer: failedPeer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } }, task.history.length);
|
|
3867
4112
|
this.releaseNotice(`task ${this.publicTitle(next)}: former review request withdrawal incomplete`, () => this.withdrawRoleRequest(task, next, "reviewer"));
|
|
3868
|
-
|
|
3869
|
-
|
|
4113
|
+
let offered = false;
|
|
4114
|
+
if (reviewer === USER) {
|
|
4115
|
+
const notice = `task ${this.publicTitle(next)}: blocked: the person must review`;
|
|
4116
|
+
this.releaseNotice(notice, () => { this.d.notify(notice); offered = true; });
|
|
4117
|
+
} else this.releaseNotice(`task ${this.publicTitle(next)}: fallback review request unavailable`, () => { offered = this.sendReview(next, reviewer, "", admission); });
|
|
4118
|
+
if (offered && current() && this.reviewBindingCurrent(reviewer, next.id, { head: this.reviewHead(next), flowRevision: this.reviewRequestRevision(next) })) {
|
|
4119
|
+
try { onReoffered?.(); } catch { /* Unconfirmed host settlement retains the delivery hold. */ }
|
|
4120
|
+
}
|
|
3870
4121
|
return next;
|
|
3871
4122
|
}
|
|
3872
4123
|
|
|
@@ -3878,6 +4129,39 @@ export class Tasks {
|
|
|
3878
4129
|
} catch { return undefined; }
|
|
3879
4130
|
}
|
|
3880
4131
|
|
|
4132
|
+
/** Read-time code advice uses this project's tree only; declared foreign or inconsistent scope is unknown. */
|
|
4133
|
+
memoryCodeEligible(task: Task): boolean {
|
|
4134
|
+
if (this.isPii(task)) return false;
|
|
4135
|
+
const repository = this.codeSymbolsRepository(task);
|
|
4136
|
+
return !!repository && repository.key === this.repositories.project.key && !this.repositories.mismatch(task.refs, task.plan);
|
|
4137
|
+
}
|
|
4138
|
+
|
|
4139
|
+
/** Read-only host input for exists verification; private/foreign/stale observations supply nothing. */
|
|
4140
|
+
memorySymbolCandidates(task: Task): string[] | undefined {
|
|
4141
|
+
if (this.isPii(task) || !this.d.store) return undefined;
|
|
4142
|
+
const observed = this.observedCodeSymbols(task);
|
|
4143
|
+
return observed?.state === "known" ? observed.symbols.map(symbol => symbol.id) : undefined;
|
|
4144
|
+
}
|
|
4145
|
+
|
|
4146
|
+
/** Only current host observations and positive exists checks may anchor the completion note. */
|
|
4147
|
+
private completionMemorySymbols(task: Task): MemorySymbolNodes {
|
|
4148
|
+
const unknown: MemorySymbolNodes = { state: "unknown", nodes: [], unknown: 0, truncated: false };
|
|
4149
|
+
if (this.isPii(task) || !this.d.store) return unknown;
|
|
4150
|
+
const repository = this.codeSymbolsRepository(task);
|
|
4151
|
+
if (!repository || repository.key !== this.repositories.project.key) return unknown;
|
|
4152
|
+
const observed = publicTaskCodeSymbols(task.codeSymbols, this.reviewHead(task), repository.key, this.publicMetadataText);
|
|
4153
|
+
if (!observed || observed.state !== "known") return unknown;
|
|
4154
|
+
try {
|
|
4155
|
+
const verified = this.d.memorySymbolNodes?.(task);
|
|
4156
|
+
if (!verified) return unknown;
|
|
4157
|
+
const context = { cwd: this.d.cwd, project: this.d.store.project, repo: this.d.store.repo, deny: this.d.store.deny, allowed: this.publicMetadataText };
|
|
4158
|
+
const actual = new Set(observed.symbols.map(symbol => canonicalMemorySymbolNode(symbol.id, context)).filter(Boolean));
|
|
4159
|
+
const nodes = [...new Set(verified.nodes)].filter(node => actual.has(node) && canonicalMemorySymbolNode(node, context) === node).slice(0, MEMORY_SYMBOL_LIMIT);
|
|
4160
|
+
const complete = observed.complete && verified.state === "known" && nodes.length === verified.nodes.length;
|
|
4161
|
+
return { state: complete ? "known" : "unknown", nodes, unknown: verified.unknown, truncated: verified.truncated || nodes.length !== verified.nodes.length };
|
|
4162
|
+
} catch { return unknown; }
|
|
4163
|
+
}
|
|
4164
|
+
|
|
3881
4165
|
/** Host observations are saved only with an admitted transition, never by an optional read. */
|
|
3882
4166
|
private observedCodeSymbols(task: Task) {
|
|
3883
4167
|
if (this.isPii(task) || !task.refs.commit) return undefined;
|
|
@@ -4127,7 +4411,7 @@ export class Tasks {
|
|
|
4127
4411
|
}
|
|
4128
4412
|
|
|
4129
4413
|
/** 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 {
|
|
4414
|
+
private withdrawRoleRequest(previous: Task, next: Task, role: Exclude<TaskAssignmentRole, "planner">): void {
|
|
4131
4415
|
const peer = previous[role];
|
|
4132
4416
|
if (!peer || peer === next[role]) return;
|
|
4133
4417
|
const kind = role === "owner" ? "task" : "review";
|
|
@@ -4151,7 +4435,18 @@ export class Tasks {
|
|
|
4151
4435
|
|
|
4152
4436
|
/** Caller authority is checked by the daemon; keep the real actor in task history. */
|
|
4153
4437
|
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
|
|
4438
|
+
if (role !== "owner" && role !== "reviewer" && role !== "planner") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner, reviewer or planner");
|
|
4439
|
+
if (role === "planner") {
|
|
4440
|
+
if (by !== USER) throw new PreEffectToolRefusal("only the person may name a task planner");
|
|
4441
|
+
const task = this.need(id, false, preEffect);
|
|
4442
|
+
this.checkFlowRevision(task, expectedFlowRevision, preEffect);
|
|
4443
|
+
if (isClosedTaskState(task.state) || this.isPii(task)) throw new PreEffectToolRefusal("closed or private work cannot name a planner");
|
|
4444
|
+
const choice = assign({ ...task, state: "proposed" }, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], namedPlanner: peer });
|
|
4445
|
+
if (choice.planner !== peer) throw new PreEffectToolRefusal("named planner must be a rostered settled planner holder");
|
|
4446
|
+
const next = this.d.board.update(task.id, by, "planner changed", { planner: peer }, "named", { plannerReason: "named" }, expectedFlowRevision);
|
|
4447
|
+
if (next.state === "backlog") { this.incomingBacklog.add(next.id); this.addPlannerBacklog(peer, [next.id]); }
|
|
4448
|
+
return next;
|
|
4449
|
+
}
|
|
4155
4450
|
if (role === "reviewer" && by !== USER) {
|
|
4156
4451
|
const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
4157
4452
|
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 +4630,31 @@ export class Tasks {
|
|
|
4335
4630
|
const requestCheck = this.requestCheck(by);
|
|
4336
4631
|
const text = composeRememberText(input);
|
|
4337
4632
|
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:
|
|
4633
|
+
if (task && this.isPii(task)) throw new Error("notes about a PII task are not saved: shared memory excludes private data");
|
|
4339
4634
|
// Shared with every other peer as well, so text that matches a PII pattern is refused like a note about a PII task.
|
|
4340
4635
|
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:
|
|
4636
|
+
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
4637
|
if (input.horizon !== undefined && input.horizon !== "short" && input.horizon !== "long") throw new Error('horizon is "short" or "long"');
|
|
4343
4638
|
if (input.scope !== undefined && input.scope !== "project" && input.scope !== "shared") throw new Error('scope is "project" or "shared"');
|
|
4344
4639
|
if (!this.d.memory && !this.d.store) return "memory is disabled; nothing saved";
|
|
4345
|
-
// Long-term notes leave the machine (#258)
|
|
4640
|
+
// Long-term notes can leave the machine (#258); local storage does not relax secret exclusion.
|
|
4346
4641
|
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
4642
|
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:
|
|
4643
|
+
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
4644
|
requestCheck();
|
|
4350
4645
|
const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) as Kind : "finding";
|
|
4351
4646
|
const native = this.saveNative(by, kind, text, title, task, input.horizon === "long" || input.horizon === "short" ? input.horizon : undefined, input.scope === "shared" ? "shared" : "project");
|
|
4352
4647
|
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
|
|
4648
|
+
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;
|
|
4649
|
+
if (!res && !native) return "memory store unavailable; nothing saved";
|
|
4650
|
+
if (!native && res && typeof res === "object" && "status" in res && res.status === "candidate") {
|
|
4651
|
+
return "saved as a pending memory candidate; a person must review it before it becomes shared evidence";
|
|
4652
|
+
}
|
|
4355
4653
|
const line = noteLine(by, kind, title ? `${title}: ${text}` : text);
|
|
4356
4654
|
this.d.notify(line);
|
|
4357
4655
|
// Display identity only: a queued recall may already contain this saved entry. Stored/captured text stays unchanged.
|
|
4358
4656
|
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
|
|
4657
|
+
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
4658
|
}
|
|
4361
4659
|
|
|
4362
4660
|
/** The task's key in the hub's store: unique across `ahub reset --all`, which restarts the ids. */
|
|
@@ -4399,7 +4697,7 @@ export class Tasks {
|
|
|
4399
4697
|
* long, decisions, contracts and fails are long-term. Fail-open: a store that cannot be written costs one notice.
|
|
4400
4698
|
* The caller has screened the text for PII; never a PII task's.
|
|
4401
4699
|
*/
|
|
4402
|
-
private saveNative(by: PeerId, kind: Kind, text: string, title: string, task: Task | undefined, horizon?: Tier, scope: Scope = "project"): { id: string; tier: Tier } | undefined {
|
|
4700
|
+
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
4701
|
const st = this.d.store;
|
|
4404
4702
|
if (!st || (task && this.isPii(task))) return undefined;
|
|
4405
4703
|
try { // never throws: callers run after a board write
|
|
@@ -4407,7 +4705,12 @@ export class Tasks {
|
|
|
4407
4705
|
// Approved, or settled in the store already: the snapshot the caller holds can predate an approval made while it awaited.
|
|
4408
4706
|
const approved = !!task && (task.state === "approved" || st.store.settled(this.memoryKey(task)));
|
|
4409
4707
|
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 } } : {}) });
|
|
4708
|
+
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 } } : {}) });
|
|
4709
|
+
// Existing notes remain immutable; the completion note connects actual symbols through the shared task node.
|
|
4710
|
+
if (symbols && task) {
|
|
4711
|
+
const taskNode = `task:${this.memoryKey(task)}`;
|
|
4712
|
+
about.splice(0, about.length, ...[...new Set([taskNode, ...symbols.nodes, ...about])].slice(0, 64).sort());
|
|
4713
|
+
}
|
|
4411
4714
|
// A short-term note on a task already approved (before hub memory existed, or late): the task is settled first, so
|
|
4412
4715
|
// the note gets its own deadline and is never taken for a source waiting for promotion.
|
|
4413
4716
|
if (task && approved && tier === "short" && !st.store.settled(this.memoryKey(task))) st.store.settle(this.memoryKey(task), by);
|
|
@@ -4457,16 +4760,15 @@ export class Tasks {
|
|
|
4457
4760
|
}
|
|
4458
4761
|
|
|
4459
4762
|
/** Auto notes: only transitions that carry content, never for PII. */
|
|
4460
|
-
private note(task: Task, by: PeerId, kind: Kind, text: string, withheld = false): void {
|
|
4763
|
+
private note(task: Task, by: PeerId, kind: Kind, text: string, withheld = false, symbols?: MemorySymbolNodes): void {
|
|
4461
4764
|
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.
|
|
4765
|
+
// Model-written text must clear the host PII screen (#198/#69); long-term memory may sync (#258). No secrets.
|
|
4464
4766
|
if (withheld || !this.nameable(text)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (${this.whyWithheld(text)})`);
|
|
4465
4767
|
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
4768
|
const title = `agent-hub task #${task.id}: ${task.title}`.slice(0, 120);
|
|
4467
4769
|
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) {
|
|
4770
|
+
const native = this.saveNative(by, kind, text, title, task, undefined, "project", symbols);
|
|
4771
|
+
if (!native && this.d.memory) {
|
|
4470
4772
|
try { this.markMemoryReference(task, by); } catch { return this.releaseNotice(`task #${task.id}: memory reference could not be recorded; note was not saved`); }
|
|
4471
4773
|
void this.d.memory.save({ text, title, project: this.d.project, metadata: { peer: by, task: task.id, kind } });
|
|
4472
4774
|
}
|