@staix/agent-hub 0.12.22 → 0.12.24
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 +117 -0
- package/README.md +22 -9
- package/docs/addons/claude-mem.md +24 -0
- package/docs/addons/codex-plugin.md +8 -0
- package/docs/addons/dot-admit.md +8 -0
- package/docs/addons/open-code-review.md +8 -0
- package/docs/addons/ponytail.md +23 -0
- package/docs/addons/ripwire.md +22 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +18 -6
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +14 -1
- package/docs/agent-notes/local-worker.md +6 -3
- package/docs/agent-notes/tasks.md +6 -1
- package/docs/agent-notes/tests.md +4 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +10 -3
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +873 -30
- package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
- package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
- package/docs/profiles.md +57 -0
- package/docs/quickstart.md +9 -4
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +124 -8
- package/docs/smoke.md +354 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
- package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
- package/docs/specs/2026-10-12-office-view-design.md +212 -0
- package/docs/text-projection.md +19 -0
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
- package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
- package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
- package/docs/verification/2026-10-13-0.12.24.md +94 -0
- package/docs/verification/2026-10-13-board-phone.md +31 -0
- package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
- package/docs/verified.json +34 -16
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
- package/plugins/agent-hub/server.js +1108 -261
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +201 -20
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +54 -13
- package/src/adapters/jcode.ts +110 -0
- package/src/adapters/local-worker.ts +10 -7
- package/src/adapters/pi.ts +94 -20
- package/src/cli/attention.ts +60 -0
- package/src/cli/bench.ts +26 -6
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +131 -39
- package/src/cli/console.ts +73 -17
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +52 -21
- package/src/cli/identity.ts +19 -8
- package/src/cli/init.ts +48 -10
- package/src/cli/jcode-mcp.ts +58 -0
- package/src/cli/kimi-mcp.ts +53 -0
- package/src/cli/launch.ts +16 -1
- package/src/cli/main.ts +641 -193
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +404 -0
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +40 -6
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +1 -29
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-runtime.ts +56 -10
- package/src/cli/upgrade.ts +23 -5
- package/src/cli/watch.ts +31 -0
- package/src/hub/activity.ts +53 -0
- package/src/hub/ask.ts +10 -5
- package/src/hub/bench.ts +10 -3
- package/src/hub/board.ts +224 -16
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +58 -20
- package/src/hub/child-process.ts +11 -0
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +34 -17
- package/src/hub/config-trust.ts +20 -7
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +11 -1
- package/src/hub/daemon.ts +1218 -113
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +7 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +14 -2
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +28 -8
- package/src/hub/git.ts +38 -0
- package/src/hub/github-import.ts +28 -0
- package/src/hub/hub-tools.ts +49 -10
- package/src/hub/inference.ts +1 -1
- package/src/hub/lifecycle-schema.ts +17 -0
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +26 -6
- package/src/hub/path-policy.ts +96 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/peers.ts +5 -3
- package/src/hub/permission-mode.ts +33 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +6 -5
- package/src/hub/registry.ts +2 -1
- package/src/hub/research.ts +144 -21
- package/src/hub/roles.ts +123 -0
- package/src/hub/roster.ts +23 -0
- package/src/hub/routing.ts +132 -18
- package/src/hub/sdlc-queue.ts +112 -0
- package/src/hub/sdlc-release.ts +125 -0
- package/src/hub/sdlc.ts +250 -0
- package/src/hub/settings.ts +411 -0
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +92 -16
- package/src/hub/start-mode.ts +50 -0
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +1014 -111
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/text-projection.ts +126 -0
- package/src/hub/tooling.ts +159 -0
- package/src/hub/ui.ts +40 -14
- package/src/hub/workflow.ts +71 -0
- package/src/local/deny.ts +29 -7
- package/src/local/sandbox.ts +50 -10
- package/src/local/tools.ts +11 -6
- package/src/memory/brief.ts +28 -14
- package/src/memory/import.ts +55 -0
- package/src/memory/recall.ts +86 -2
- package/src/memory/store.ts +750 -0
- package/src/memory/sync.ts +145 -0
- package/src/models/mlx.ts +2 -1
- package/src/pet/NOTICE.md +10 -0
- package/src/pet/characters.ts +1438 -0
- package/src/pet/config.ts +31 -0
- package/src/pet/frame.ts +17 -0
- package/src/pet/pixels.ts +28 -0
- package/src/pi/tool-receipts.ts +2 -1
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +983 -40
- package/src/ui/needs.ts +58 -0
- package/src/ui/office-model.ts +440 -0
- package/src/ui/peer-palette.ts +16 -0
- package/src/ui/task-board.ts +49 -0
- package/src/ui/task-progress.ts +5 -1
- package/templates/AGENTS.block.md +3 -2
- package/templates/config.json +5 -1
- package/templates/profiles/dev.json +22 -0
- package/templates/profiles/general.json +26 -0
- package/templates/profiles/research.json +57 -0
- package/templates/profiles/writing.json +28 -0
- package/templates/roles/NOTICE.md +26 -0
- package/templates/roles/dev/backend-engineer.md +26 -0
- package/templates/roles/dev/code-reviewer.md +26 -0
- package/templates/roles/dev/frontend-engineer.md +26 -0
- package/templates/roles/dev/security-reviewer.md +26 -0
- package/templates/roles/dev/software-architect.md +26 -0
- package/templates/roles/dev/test-engineer.md +26 -0
- package/templates/roles/research/analyst.md +26 -0
- package/templates/roles/research/fact-checker.md +26 -0
- package/templates/roles/research/research-lead.md +26 -0
- package/templates/roles/research/researcher.md +26 -0
- package/templates/roles/research/statistician.md +26 -0
package/src/hub/tasks.ts
CHANGED
|
@@ -1,29 +1,57 @@
|
|
|
1
|
+
import { readGitHubImport, type GitHubSource, type GitHubImportSelection } from "./github-import.ts";
|
|
2
|
+
import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
|
|
3
|
+
import { readMergeQueue } from "./sdlc-queue.ts";
|
|
4
|
+
import { readReleaseReadiness } from "./sdlc-release.ts";
|
|
5
|
+
import { loadRoleCatalog, taskRole, taskRoleText, type RoleCatalog } from "./roles.ts";
|
|
6
|
+
import { matchesPolicy } from "./path-policy.ts";
|
|
7
|
+
import { mentionsPrivatePath } from "../memory/recall.ts";
|
|
8
|
+
import { profileClass, profileData, profileTaskLabel, projectProfile, type ProfileName, type ProfileSelection, type ProjectProfile } from "./profiles.ts";
|
|
9
|
+
import { phaseView, validatePhaseLinks, workflowHash } from "./workflow.ts";
|
|
1
10
|
import { isAbsolute, relative } from "node:path";
|
|
2
11
|
import type { Briefs } from "../memory/brief.ts";
|
|
3
12
|
import type { MemoryClient } from "../memory/client.ts";
|
|
4
|
-
import {
|
|
13
|
+
import { extractNodes, looksSecret, PROMOTED_KINDS, taskKey, type GoalView, type Kind, type MemoryStore, type Scope, type Tier } from "../memory/store.ts";
|
|
14
|
+
import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskHasStarted, planningUndoEntry, type HistoryEntry, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
|
|
5
15
|
import type { Bus } from "./bus.ts";
|
|
6
|
-
import { HUB, newEnvelope, NOTE_KINDS, noteLine, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
16
|
+
import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
7
17
|
import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
|
|
8
18
|
import { assign, detectSignals, LOCAL, peerEntry, PI, predictSplit, type Assignment, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
9
19
|
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
10
20
|
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
11
21
|
import { realPath } from "./project.ts";
|
|
12
22
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
23
|
+
import { SUPPORTED_PEERS } from "./peer-catalog.ts";
|
|
13
24
|
import { nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
25
|
+
import { readSdlcPr, sdlcConfig, sdlcReceipt, sdlcStatus, stageDefinition, lifecycleStage, type SdlcConfig, sdlcGh, type SdlcGh, type SdlcStatus } from "./sdlc.ts";
|
|
26
|
+
|
|
27
|
+
export type TaskAssignmentRole = "owner" | "reviewer";
|
|
14
28
|
|
|
15
29
|
export interface TasksDeps {
|
|
30
|
+
/** Effective per-peer planner capacity, taken from the existing settings loader. */
|
|
31
|
+
wipLimit?: (peer: PeerId) => number;
|
|
32
|
+
recordPlanning?: (change: { by: string; task?: number; release?: number; fields: string[]; revision: number }) => void;
|
|
16
33
|
board: Board;
|
|
17
34
|
bus: Bus;
|
|
18
35
|
routing: () => Routing;
|
|
19
36
|
cwd: string;
|
|
20
37
|
project: string;
|
|
38
|
+
profile?: ProfileName;
|
|
39
|
+
profileData?: ProjectProfile;
|
|
40
|
+
piiPaths?: string[];
|
|
41
|
+
catalog?: RoleCatalog;
|
|
42
|
+
/** Role ids whose contract was prepared for the current native session. */
|
|
43
|
+
sessionRoles?: (peer: PeerId) => string[];
|
|
44
|
+
/** Optional bounded snapshot presentation; never stored in task history or memory. */
|
|
45
|
+
projectedReview?: (task: Task) => string | undefined;
|
|
46
|
+
sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
|
|
21
47
|
briefs?: Briefs;
|
|
22
48
|
memory?: MemoryClient;
|
|
49
|
+
/** #258: the hub's own store, the project name it files entries under (the git chain) and the repo `#N` issues belong to. */
|
|
50
|
+
store?: { store: MemoryStore; project: string; repo: string; deny?: string[]; onNote?: (id: string) => void };
|
|
23
51
|
/** a line for the human: console tail and hub.log */
|
|
24
52
|
notify: (line: string) => void;
|
|
25
53
|
/** The completion check configured for a class, if any (local config only; issue #7). */
|
|
26
|
-
check?: (cls: TaskClass) => string | undefined;
|
|
54
|
+
check?: (cls: TaskClass, task?: Task) => string | undefined;
|
|
27
55
|
/** Runs one check; the hub runs them one at a time. */
|
|
28
56
|
runCheck?: (command: string) => Promise<{ code: number | null; timedOut: boolean; interrupted?: boolean; tail: string }>;
|
|
29
57
|
/** Hands a saved note to every other peer; it rides on their next delivery. */
|
|
@@ -90,7 +118,25 @@ const DEMOTE_AT = 1.5;
|
|
|
90
118
|
const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
|
|
91
119
|
const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
92
120
|
/** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
|
|
93
|
-
const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep"]);
|
|
121
|
+
const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Thrown when an assignment's board write is saved but a publish or notify after it failed (#297): the move is
|
|
125
|
+
* fact, the delivery is unconfirmed. Callers decide by this type, never by comparing owners or reading history.
|
|
126
|
+
* It stays a plain Error subclass, so the preEffect rule and the #231 ready-offer contract are unchanged.
|
|
127
|
+
* `task` and `cause` are non-enumerable: a PII task's title and detail must not reach a JSON.stringify of the error.
|
|
128
|
+
*/
|
|
129
|
+
export class AssignmentUndeliveredError extends Error {
|
|
130
|
+
/** The task as the board has it after the write. */
|
|
131
|
+
declare readonly task: Task;
|
|
132
|
+
declare readonly cause: unknown;
|
|
133
|
+
constructor(task: Task, cause: unknown) {
|
|
134
|
+
super(`task #${task.id}: the assignment to ${task.owner} is saved, but its delivery is not confirmed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
135
|
+
this.name = "AssignmentUndeliveredError";
|
|
136
|
+
Object.defineProperty(this, "task", { value: task, enumerable: false });
|
|
137
|
+
Object.defineProperty(this, "cause", { value: cause, enumerable: false });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
94
140
|
|
|
95
141
|
/** A project path as one spelling (#67): no leading `./`, no repeated or trailing `/`; the root is `.`. */
|
|
96
142
|
export const normPath = (p: string) => p.replace(/^(\.\/)+/, "").replace(/\/{2,}/g, "/").replace(/\/+$/, "") || ".";
|
|
@@ -129,15 +175,15 @@ function cleanRefs(input: unknown): TaskRefs {
|
|
|
129
175
|
}
|
|
130
176
|
|
|
131
177
|
/** The same for a plan (issue #31): only its four lists, each of short strings. */
|
|
132
|
-
function cleanPlan(input: unknown): TaskPlan {
|
|
178
|
+
function cleanPlan(input: unknown, profile: ProfileSelection = "dev"): TaskPlan {
|
|
133
179
|
const r = fields(input);
|
|
134
|
-
return Object.fromEntries(
|
|
180
|
+
return Object.fromEntries((profileData(profile).plan_keys as (keyof TaskPlan)[]).map((k) => [k, k === "paths" ? [...new Set(textList(r[k]).map(normPath))] : textList(r[k])] as const).filter(([, v]) => v.length));
|
|
135
181
|
}
|
|
136
182
|
|
|
137
183
|
/** A plan as one line for other owners; the whole plan is on the board. */
|
|
138
|
-
const planText = (plan: TaskPlan = {}) =>
|
|
184
|
+
const planText = (plan: TaskPlan = {}) => ALL_PLAN_KEYS.filter((k) => plan[k]?.length).map((k) => `${k.replace("_", " ")}: ${plan[k]!.join("; ")}`).join(" | ");
|
|
139
185
|
/** What the PII screen reads of a task (#198): the text the patterns are checked against. */
|
|
140
|
-
const screenText = (t: { title: string; detail: string; plan?: TaskPlan }) => [t.title, t.detail, planText(t.plan)].join("\n");
|
|
186
|
+
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");
|
|
141
187
|
/** Re-screens of one task whose verdict was unknown, per hub run (#198). */
|
|
142
188
|
const RESCREEN_MAX = 10;
|
|
143
189
|
|
|
@@ -146,12 +192,23 @@ const RESCREEN_MAX = 10;
|
|
|
146
192
|
* PII handling, envelopes, briefs and memory notes are decided in one place.
|
|
147
193
|
*/
|
|
148
194
|
export class Tasks {
|
|
195
|
+
private readonly incomingBacklog = new Set<number>();
|
|
196
|
+
private readonly plannerBacklog = new Map<PeerId, Set<number>>();
|
|
197
|
+
private readonly plannerBlockedQueue = new Map<PeerId, string>();
|
|
198
|
+
private readonly knownPlanners = new Set<PeerId>();
|
|
149
199
|
/** Turn-free cohorts (issue #107): who works without messages, and who integrates. */
|
|
150
200
|
readonly cohorts: Cohorts;
|
|
151
201
|
private readonly sweepConfig: TaskSweepConfig;
|
|
152
202
|
private sweeping = false;
|
|
153
203
|
|
|
154
204
|
constructor(private readonly d: TasksDeps) {
|
|
205
|
+
const backlog = d.board.list("backlog");
|
|
206
|
+
for (const [peer, roles] of Object.entries(d.roles ?? {})) {
|
|
207
|
+
if (!roles.includes("planner")) continue;
|
|
208
|
+
this.knownPlanners.add(peer);
|
|
209
|
+
const queued = d.bus.queuedBacklogIds(peer);
|
|
210
|
+
this.addPlannerBacklog(peer, backlog.filter(task => !queued.has(task.id)).map(task => task.id));
|
|
211
|
+
}
|
|
155
212
|
this.sweepConfig = taskSweepConfig(d.sweep);
|
|
156
213
|
this.cohorts = new Cohorts({
|
|
157
214
|
silence: (owners) => this.turnFree() && owners.every((p) => p !== USER && p !== HUB && (this.d.capable?.(p) ?? false)),
|
|
@@ -160,6 +217,11 @@ export class Tasks {
|
|
|
160
217
|
// What the on-prem worker says about a PII task is private on the bus (console tail and log show a stub), so the
|
|
161
218
|
// board keeps the text: `ahub task show <id>` is where the console user reads it, a refusal included.
|
|
162
219
|
d.bus.tap((e) => {
|
|
220
|
+
if (e.t === "overflow" && e.env.from === HUB && e.env.kind === "task" && Array.isArray(e.env.refs?.backlog)) {
|
|
221
|
+
const ids = e.env.refs.backlog;
|
|
222
|
+
if (ids.length <= 64 && ids.every(id => Number.isSafeInteger(id) && id > 0)) this.addPlannerBacklog(e.peer, ids);
|
|
223
|
+
this.plannerBlockedQueue.set(e.peer, JSON.stringify(d.bus.queueIds(e.peer)));
|
|
224
|
+
}
|
|
163
225
|
// A turn that ends takes its hand-overs with it: busy later is another turn (#115). The adapter's own state, since a
|
|
164
226
|
// pause shows as `paused` while the turn goes on.
|
|
165
227
|
if (e.t === "state" && d.bus.peers.get(e.peer)?.state !== "busy") for (const [k, p] of this.sent) if (p === e.peer) this.sent.delete(k);
|
|
@@ -172,7 +234,17 @@ export class Tasks {
|
|
|
172
234
|
});
|
|
173
235
|
}
|
|
174
236
|
|
|
175
|
-
isPii = (task: Pick<Task, "signals">) => task.signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
|
|
237
|
+
isPii = (task: Pick<Task, "signals">) => task.signals.includes("pii_path") || (task.signals.includes("pii") && this.d.routing().constraints.pii === "local_only");
|
|
238
|
+
|
|
239
|
+
private signalsFor(task: Pick<Task, "title" | "detail" | "refs"> & { plan?: TaskPlan }, routing = this.d.routing()) {
|
|
240
|
+
const signals = detectSignals(task, routing, this.d.cwd);
|
|
241
|
+
const paths = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? []), ...(task.plan?.sections ?? []).map(anchor => anchor.split("#")[0]!).filter(Boolean)];
|
|
242
|
+
if (paths.some(path => matchesPolicy(this.d.cwd, path, this.d.piiPaths ?? [], true))) {
|
|
243
|
+
if (!signals.includes("pii")) signals.push("pii");
|
|
244
|
+
signals.push("pii_path");
|
|
245
|
+
}
|
|
246
|
+
return signals;
|
|
247
|
+
}
|
|
176
248
|
|
|
177
249
|
/** `signals.pii_screen = "local"` where the PII constraint applies (issue #198); on without a screener, every verdict is unknown. */
|
|
178
250
|
private screenOn = (): boolean => {
|
|
@@ -185,8 +257,8 @@ export class Tasks {
|
|
|
185
257
|
return (await this.d.piiScreen?.(text).catch(() => undefined)) ?? { label: "unknown", miss: "failed", ms: 0 };
|
|
186
258
|
}
|
|
187
259
|
|
|
188
|
-
private recordScreen(task: number | undefined, item: ScreenItem, v: PiiVerdict | undefined): void {
|
|
189
|
-
const record: ScreenRecord = !v
|
|
260
|
+
private recordScreen(task: number | undefined, item: ScreenItem, v: PiiVerdict | undefined, inherited = false): void {
|
|
261
|
+
const record: ScreenRecord = inherited ? { item, label: "pii", source: "inherited" } : !v
|
|
190
262
|
? { item, label: "pii", source: "regex" }
|
|
191
263
|
: { item, label: v.label === "clear" ? "clear" : "pii", source: v.label === "unknown" ? "unknown" : "screen", ...(v.category ? { category: v.category } : {}), ...(v.miss ? { miss: v.miss } : {}), ms: v.ms };
|
|
192
264
|
try {
|
|
@@ -222,6 +294,7 @@ export class Tasks {
|
|
|
222
294
|
if (!finding || this.waitsFor(task).length || this.isChecking(task.id) || this.silentFor(task.id)) continue;
|
|
223
295
|
const responsible = finding.kind === "review-pending" ? task.reviewer! : task.owner!;
|
|
224
296
|
if (responsible !== USER && !this.sweepAvailable(responsible)) continue;
|
|
297
|
+
if (finding.step === 3 && finding.kind !== "review-pending" && this.sweepConfig.auto_reassign && this.plannerPullRecord(task)?.pull && !assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), responsible], excludeOwners: this.plannerFull(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) }).owner) continue;
|
|
225
298
|
const title = this.publicTitle(task);
|
|
226
299
|
const message = `Task ${title}: ${finding.kind}, idle escalation ${finding.step}/3.`;
|
|
227
300
|
// Only identifiers, kind and step are kept in the ladder event. A crash after this write and before publish
|
|
@@ -237,12 +310,18 @@ export class Tasks {
|
|
|
237
310
|
}
|
|
238
311
|
} else {
|
|
239
312
|
const reviewer = finding.kind === "review-pending";
|
|
240
|
-
const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), responsible], waitsFor: this.waitsFor(task), ...(reviewer ? { candidates: [], notReviewer: task.owner ?? undefined } : {}), ...this.weights(task.class) });
|
|
313
|
+
const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), responsible], excludeOwners: finding.kind !== "review-pending" && this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], waitsFor: this.waitsFor(task), ...(reviewer ? { candidates: [], notReviewer: task.owner ?? undefined } : {}), ...this.weights(task.class) });
|
|
241
314
|
const candidate = reviewer ? a.reviewer : a.owner;
|
|
242
315
|
this.passedOver(task, a);
|
|
243
316
|
this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
|
|
244
317
|
if (!reviewer && candidate && this.sweepConfig.auto_reassign && this.sweepAvailable(candidate)) {
|
|
245
|
-
|
|
318
|
+
try {
|
|
319
|
+
await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [candidate], exclude: [responsible], event: "reassigned", reason: "idle", note: `idle sweep: ${finding.kind}; from ${responsible}` });
|
|
320
|
+
} catch (e) {
|
|
321
|
+
// A saved move whose delivery failed: the console notice was printed where the error was thrown, and
|
|
322
|
+
// the sweep goes on (#297).
|
|
323
|
+
if (!(e instanceof AssignmentUndeliveredError)) throw e;
|
|
324
|
+
}
|
|
246
325
|
}
|
|
247
326
|
}
|
|
248
327
|
}
|
|
@@ -289,18 +368,19 @@ export class Tasks {
|
|
|
289
368
|
|
|
290
369
|
/** A task as a cloud peer may see it. */
|
|
291
370
|
publicView(task: Task, fullHistory = false): Record<string, unknown> {
|
|
292
|
-
const { title, detail, history, ...rest } = task;
|
|
293
|
-
if (this.isPii(task)) return { ...rest, refs: {}, plan: {}, title: "[pii]", detail: "[pii]" };
|
|
371
|
+
const { title, detail, history, external, ...rest } = task;
|
|
372
|
+
if (this.isPii(task)) return { ...rest, actualDates: actualTaskDates(task), refs: {}, plan: {}, ...(task.sdlc ? { sdlc: { stage: stageDefinition(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc.stage)?.base ?? "unknown" } } : {}), title: "[pii]", detail: "[pii]" };
|
|
294
373
|
const screenHistoryText = (text: string, field: string, withheld = false): string => !withheld && this.nameable(text) ? text : `[history ${field} withheld: ${this.whyWithheld(text)}; ahub task show ${task.id}]`;
|
|
295
374
|
const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
|
|
296
375
|
at: h.at, by: h.by, event: h.event,
|
|
297
376
|
...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note", h.withheld) }),
|
|
298
377
|
...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
|
|
299
378
|
...(h.owner === undefined ? {} : { owner: h.owner }),
|
|
379
|
+
...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
|
|
300
380
|
...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
|
|
301
381
|
...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
|
|
302
382
|
}));
|
|
303
|
-
return { ...rest, title, detail, history: publicHistory };
|
|
383
|
+
return { ...rest, ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task)) } : {}), title, detail, history: publicHistory };
|
|
304
384
|
}
|
|
305
385
|
|
|
306
386
|
/** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
|
|
@@ -371,7 +451,7 @@ export class Tasks {
|
|
|
371
451
|
private health() {
|
|
372
452
|
const failing = this.d.failing?.();
|
|
373
453
|
const held = this.d.held?.();
|
|
374
|
-
return { ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
|
|
454
|
+
return { pullOnly: [...this.d.bus.peers.values()].filter(peer => peer.id === "kimi" && peer.pullOnly).map(peer => peer.id), offRoster: SUPPORTED_PEERS.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
|
|
375
455
|
}
|
|
376
456
|
|
|
377
457
|
/** What assignment weighs besides states and policy: quota, demotion and the review record. */
|
|
@@ -468,10 +548,371 @@ export class Tasks {
|
|
|
468
548
|
}
|
|
469
549
|
}
|
|
470
550
|
|
|
471
|
-
|
|
551
|
+
private planningRecorded(change: { by: string; task?: number; release?: number; fields: string[]; revision: number }): void {
|
|
552
|
+
try { this.d.recordPlanning?.(change); } catch { /* telemetry never changes a durable result */ }
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private planningAuthority(by: PeerId): void {
|
|
556
|
+
if (by !== USER && !this.d.roles?.[by]?.includes("planner")) throw new PreEffectToolRefusal("planning requires a person or configured planner role");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
wipLimit(peer: PeerId): number {
|
|
560
|
+
const limit = this.d.wipLimit?.(peer) ?? 1;
|
|
561
|
+
if (!Number.isSafeInteger(limit) || limit < 0 || limit > 32) throw new PreEffectToolRefusal("invalid planner WIP capacity");
|
|
562
|
+
return limit;
|
|
563
|
+
}
|
|
564
|
+
private plannerPullRecord(task: Task): HistoryEntry["planning"] {
|
|
565
|
+
return task.history.findLast(entry => entry.event === "backlog pulled" || entry.event === "backlog returned")?.planning;
|
|
566
|
+
}
|
|
567
|
+
wip(peer: PeerId, except?: number): number {
|
|
568
|
+
return this.d.board.plannerWip(peer, except);
|
|
569
|
+
}
|
|
570
|
+
private prerequisiteOf(task: Task, prerequisite: number): boolean {
|
|
571
|
+
const pending = [...(task.deps ?? [])], seen = new Set<number>();
|
|
572
|
+
while (pending.length) { const id = pending.pop()!; if (id === prerequisite) return true; if (seen.has(id)) continue; seen.add(id); pending.push(...(this.d.board.get(id)?.deps ?? [])); }
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
private plannerFull(task: Task): PeerId[] {
|
|
576
|
+
return Object.keys(this.states()).filter(peer => { const queue = this.d.bus.queueSummary(peer); return this.wip(peer, task.id) >= this.wipLimit(peer) || !!queue.heldBy || queue.needsReview > 0; });
|
|
577
|
+
}
|
|
578
|
+
private plannerAssignment(task: Task, reservedOnly = false): Assignment {
|
|
579
|
+
const states = this.states();
|
|
580
|
+
const full = this.plannerFull(task);
|
|
581
|
+
// Preview the To Do transition without routing or offering the persisted backlog row.
|
|
582
|
+
return assign({ ...task, state: "proposed" }, states, this.d.routing(), { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved && !this.excluded(task).includes(task.reserved) ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class) });
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
|
|
586
|
+
this.planningAuthority(by);
|
|
587
|
+
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0 || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("planner pull needs an id, revision and bounded reason");
|
|
588
|
+
const validate = () => {
|
|
589
|
+
const task = this.d.board.get(input.id);
|
|
590
|
+
if (!task || (task.planningRevision ?? 0) !== input.revision || task.state !== "backlog" || task.owner || taskHasStarted(task) || task.pinned) throw new PreEffectToolRefusal("planner pull changed, pinned or already started");
|
|
591
|
+
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
592
|
+
if (task.release != null && (!release || ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull requires unfrozen release scope");
|
|
593
|
+
if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
|
|
594
|
+
const priority = task.priority ?? (task.signals.includes("urgent") ? 0 : 2);
|
|
595
|
+
if (priority === 0) {
|
|
596
|
+
const goals = this.d.board.list().filter(goal => goal.state !== "approved" && (goal.priority ?? (goal.signals.includes("urgent") ? 0 : 2)) === 0 && goal.rank != null && goal.history.some(entry => entry.by === USER && entry.planning?.fields.includes("rank")));
|
|
597
|
+
const effectiveOrder = (candidate: Task) => goals.filter(goal => goal.id === candidate.id || this.prerequisiteOf(goal, candidate.id)).map(goal => ({ rank: goal.rank!, id: goal.id })).sort((a,b) => a.rank-b.rank || a.id-b.id)[0];
|
|
598
|
+
const order = effectiveOrder(task) ?? { rank: task.rank ?? Number.MAX_SAFE_INTEGER, id: task.id };
|
|
599
|
+
if (this.d.board.list("backlog").some(other => {
|
|
600
|
+
if (other.id === task.id || this.prerequisiteOf(other, task.id) || (other.priority ?? (other.signals.includes("urgent") ? 0 : 2)) !== 0) return false;
|
|
601
|
+
const competing = effectiveOrder(other);
|
|
602
|
+
return !!competing && (competing.rank < order.rank || competing.rank === order.rank && competing.id < order.id);
|
|
603
|
+
})) throw new PreEffectToolRefusal("a person's P0 backlog order is retained");
|
|
604
|
+
}
|
|
605
|
+
const assignment = this.plannerAssignment(task);
|
|
606
|
+
if (!assignment.owner) throw new PreEffectToolRefusal("no peer has eligible planner WIP capacity");
|
|
607
|
+
const reservation = task.history.findLast(entry => entry.event === "reserved" || entry.planning?.fields.includes("reserved") && !["backlog pulled", "backlog returned"].includes(entry.event)) ?? (task.history[0]?.event === "backlogged" ? task.history[0] : undefined);
|
|
608
|
+
if (task.reserved && reservation?.by === USER && assignment.owner !== task.reserved) throw new PreEffectToolRefusal("a person's reservation is retained");
|
|
609
|
+
return { task, owner: assignment.owner };
|
|
610
|
+
};
|
|
611
|
+
const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
|
|
612
|
+
const current = validate();
|
|
613
|
+
const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld);
|
|
614
|
+
this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
|
|
615
|
+
return pulled;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
backlogList(by: PeerId): Record<string, unknown>[] {
|
|
619
|
+
this.planningAuthority(by);
|
|
620
|
+
return this.d.board.list("backlog").sort((a, b) => (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER) || a.id - b.id).map(task => this.publicView(task));
|
|
621
|
+
}
|
|
622
|
+
private addPlannerBacklog(peer: PeerId, ids: readonly number[]): void {
|
|
623
|
+
if (!this.d.roles?.[peer]?.includes("planner")) return;
|
|
624
|
+
const pending = this.plannerBacklog.get(peer) ?? new Set<number>();
|
|
625
|
+
for (const id of ids) if (this.d.board.get(id)?.state === "backlog") pending.add(id);
|
|
626
|
+
if (pending.size) this.plannerBacklog.set(peer, pending);
|
|
627
|
+
}
|
|
628
|
+
/** One bounded ids-only batch per attached planner; failed or evicted copies stay pending. */
|
|
629
|
+
flushPlannerNotices(): number {
|
|
630
|
+
const configured = Object.entries(this.d.roles ?? {}).filter(([, roles]) => roles.includes("planner")).map(([peer]) => peer);
|
|
631
|
+
for (const peer of this.knownPlanners) if (!configured.includes(peer)) {
|
|
632
|
+
this.knownPlanners.delete(peer); this.plannerBacklog.delete(peer); this.plannerBlockedQueue.delete(peer);
|
|
633
|
+
}
|
|
634
|
+
for (const peer of configured) if (!this.knownPlanners.has(peer)) {
|
|
635
|
+
this.knownPlanners.add(peer);
|
|
636
|
+
const retained = this.d.bus.queuedBacklogIds(peer);
|
|
637
|
+
this.addPlannerBacklog(peer, this.d.board.list("backlog").filter(task => !retained.has(task.id)).map(task => task.id));
|
|
638
|
+
}
|
|
639
|
+
const planners = configured.filter(peer => this.d.bus.isRostered(peer) && this.d.bus.peers.has(peer) && this.d.bus.stateOf(peer) !== "offline");
|
|
640
|
+
if (!planners.length) return 0;
|
|
641
|
+
for (const id of this.incomingBacklog) if (this.d.board.get(id)?.state !== "backlog") this.incomingBacklog.delete(id);
|
|
642
|
+
const incoming = [...this.incomingBacklog].sort((a, b) => a - b).slice(0, 64);
|
|
643
|
+
if (incoming.length) {
|
|
644
|
+
for (const peer of configured) {
|
|
645
|
+
const queued = this.d.bus.queuedBacklogIds(peer);
|
|
646
|
+
this.addPlannerBacklog(peer, incoming.filter(id => !queued.has(id)));
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
for (const id of incoming) this.incomingBacklog.delete(id);
|
|
650
|
+
const notified = new Set<number>();
|
|
651
|
+
for (const peer of planners) {
|
|
652
|
+
const pending = this.plannerBacklog.get(peer);
|
|
653
|
+
if (!pending) continue;
|
|
654
|
+
const blockedQueue = this.plannerBlockedQueue.get(peer);
|
|
655
|
+
if (blockedQueue !== undefined && blockedQueue === JSON.stringify(this.d.bus.queueIds(peer))) continue;
|
|
656
|
+
this.plannerBlockedQueue.delete(peer);
|
|
657
|
+
for (const id of pending) if (this.d.board.get(id)?.state !== "backlog") pending.delete(id);
|
|
658
|
+
const ids = [...pending].sort((a, b) => a - b).slice(0, 64);
|
|
659
|
+
if (!ids.length) continue;
|
|
660
|
+
// Remove before publish: an immediate overflow callback may put this recipient's ids back.
|
|
661
|
+
for (const id of ids) pending.delete(id);
|
|
662
|
+
try {
|
|
663
|
+
const queued = this.d.bus.publish(newEnvelope(HUB, `Backlog items ${ids.map(id => `#${id}`).join(", ")}. Read hub_backlog_list and plan the next work with a reason for each change.`, { to: [peer], kind: "task", refs: { backlog: ids } }));
|
|
664
|
+
if (!queued.includes(peer)) this.addPlannerBacklog(peer, ids);
|
|
665
|
+
for (const id of ids) notified.add(id);
|
|
666
|
+
} catch (error) { this.addPlannerBacklog(peer, ids); throw error; }
|
|
667
|
+
}
|
|
668
|
+
return notified.size;
|
|
669
|
+
}
|
|
670
|
+
releaseList(by: PeerId): Record<string, unknown>[] {
|
|
671
|
+
this.planningAuthority(by);
|
|
672
|
+
const scopes = new Map<number, { id: number; state: Task["state"]; deps: number[] }[]>();
|
|
673
|
+
type Approval = { id: number; release: Task["release"]; doneAt: number | null };
|
|
674
|
+
const approved: Approval[] = [], owned = new Map<number, Approval[]>();
|
|
675
|
+
for (const task of this.d.board.list()) {
|
|
676
|
+
if (typeof task.release === "number") {
|
|
677
|
+
const scope = scopes.get(task.release) ?? [];
|
|
678
|
+
scope.push({ id: task.id, state: task.state, deps: task.deps ?? [] }); scopes.set(task.release, scope);
|
|
679
|
+
}
|
|
680
|
+
if (task.state !== "approved") continue;
|
|
681
|
+
const row = { id: task.id, release: task.release, doneAt: actualTaskDates(task).doneAt };
|
|
682
|
+
approved.push(row);
|
|
683
|
+
if (typeof task.release === "number") { const rows = owned.get(task.release) ?? []; rows.push(row); owned.set(task.release, rows); }
|
|
684
|
+
}
|
|
685
|
+
const compare = (a: Approval, b: Approval) => a.doneAt! - b.doneAt! || a.id - b.id;
|
|
686
|
+
const known = approved.filter(task => task.doneAt !== null).sort(compare), unknown = approved.filter(task => task.doneAt === null);
|
|
687
|
+
const after = (rows: Approval[], cut: number) => {
|
|
688
|
+
let low = 0, high = rows.length;
|
|
689
|
+
while (low < high) { const mid = Math.floor((low + high) / 2); if (rows[mid]!.doneAt! <= cut) low = mid + 1; else high = mid; }
|
|
690
|
+
return low;
|
|
691
|
+
};
|
|
692
|
+
// ponytail: audit previews show 32 ids plus exact counts; add a paginated audit endpoint for exhaustive lists.
|
|
693
|
+
const outsideIds = (rows: Approval[], start: number, release: number) => {
|
|
694
|
+
const result: number[] = [];
|
|
695
|
+
for (let i = start; i < rows.length && result.length < 32; i++) if (rows[i]!.release !== release) result.push(rows[i]!.id);
|
|
696
|
+
return result;
|
|
697
|
+
};
|
|
698
|
+
return this.d.board.releases().map(release => {
|
|
699
|
+
const own = owned.get(release.id) ?? [], ownKnown = own.filter(task => task.doneAt !== null).sort(compare);
|
|
700
|
+
const cutStart = release.cut === null ? known.length : after(known, release.cut);
|
|
701
|
+
const unknownPool = release.cut === null ? approved : unknown;
|
|
702
|
+
return { ...release,
|
|
703
|
+
name: this.nameable(release.name) ? release.name : "[withheld]",
|
|
704
|
+
goal: this.nameable(release.goal) ? release.goal : "[withheld]",
|
|
705
|
+
tasks: scopes.get(release.id) ?? [],
|
|
706
|
+
afterCutUnknown: outsideIds(unknownPool, 0, release.id),
|
|
707
|
+
afterCutUnknownCount: release.cut === null ? approved.length - own.length : unknown.length - (own.length - ownKnown.length),
|
|
708
|
+
afterCut: release.cut === null ? null : outsideIds(known, cutStart, release.id),
|
|
709
|
+
afterCutCount: release.cut === null ? null : known.length - cutStart - (ownKnown.length - after(ownKnown, release.cut)),
|
|
710
|
+
};
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async planBacklog(by: PeerId, input: { id: number; revision: number; fields: unknown; reason: string }): Promise<Task> {
|
|
715
|
+
this.planningAuthority(by);
|
|
716
|
+
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid planning identity or revision");
|
|
717
|
+
if (typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("planning needs a bounded reason");
|
|
718
|
+
let patch: PlanningFields;
|
|
719
|
+
try { patch = planningPatch(input.fields); } catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
720
|
+
const validate = () => {
|
|
721
|
+
const task = this.d.board.get(input.id);
|
|
722
|
+
if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("planning revision changed");
|
|
723
|
+
if (task.state === "approved") throw new PreEffectToolRefusal("approved work is final");
|
|
724
|
+
if (by !== USER && (task.state !== "backlog" || taskHasStarted(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
|
|
725
|
+
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");
|
|
726
|
+
if (Object.hasOwn(patch, "deps") || Object.hasOwn(patch, "reserved")) {
|
|
727
|
+
if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
|
|
728
|
+
if (patch.reserved && !(SUPPORTED_PEERS as readonly string[]).includes(patch.reserved) && !this.d.bus.knownPeers().includes(patch.reserved) && !this.d.routing().classes[task.class]?.peers.includes(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is not a supported or configured peer");
|
|
729
|
+
if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
|
|
730
|
+
const rows = new Map(this.d.board.list().map(row => [row.id, row]));
|
|
731
|
+
const pending = [...(patch.deps ?? task.deps ?? [])], seen = new Set<number>();
|
|
732
|
+
while (pending.length) {
|
|
733
|
+
const id = pending.pop()!;
|
|
734
|
+
if (id === task.id) throw new PreEffectToolRefusal("planning dependencies would form a cycle");
|
|
735
|
+
if (seen.has(id)) continue;
|
|
736
|
+
seen.add(id);
|
|
737
|
+
const dependency = rows.get(id);
|
|
738
|
+
if (!dependency) throw new PreEffectToolRefusal(`no dependency task #${id}`);
|
|
739
|
+
pending.push(...(dependency.deps ?? []));
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
const target = patch.release === undefined ? task.release : patch.release;
|
|
743
|
+
if (target != null && !this.d.board.release(target)) throw new PreEffectToolRefusal("no such release");
|
|
744
|
+
for (const id of new Set([task.release, target])) {
|
|
745
|
+
if (id == null) continue;
|
|
746
|
+
const release = this.d.board.release(id)!;
|
|
747
|
+
if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release scope is final");
|
|
748
|
+
if (by !== USER && release.state === "frozen") throw new PreEffectToolRefusal("frozen release changes require a person");
|
|
749
|
+
}
|
|
750
|
+
return task;
|
|
751
|
+
};
|
|
752
|
+
const task = validate(), withheld = await this.withholds("note", input.reason, task);
|
|
753
|
+
validate(); // a person may have changed planning or frozen a release while the screen ran
|
|
754
|
+
const updated = this.d.board.plan(task.id, by, patch, input.revision, input.reason.trim(), withheld);
|
|
755
|
+
this.planningRecorded({ by, task: task.id, fields: Object.keys(patch), revision: updated.planningRevision! });
|
|
756
|
+
return updated; // persistence/readback failures must never claim no effect
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async undoPlanning(by: PeerId, id: number, revision: number): Promise<Task> {
|
|
760
|
+
this.planningAuthority(by);
|
|
761
|
+
const task = this.d.board.get(id), entry = task ? planningUndoEntry(task) : undefined;
|
|
762
|
+
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");
|
|
763
|
+
if (entry.planning.split) {
|
|
764
|
+
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
765
|
+
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");
|
|
766
|
+
if (entry.planning.split.children.some(saved => this.d.board.get(saved.id)?.history.some(item => item.event === "memory reference started"))) throw new PreEffectToolRefusal("split children are referenced by persistent long-term memory");
|
|
767
|
+
const childIds = new Set(entry.planning.split.children.map(child => child.id)), budgets = this.d.executionBudget?.status();
|
|
768
|
+
if (Array.isArray(budgets) && budgets.some(budget => budget.kind === "task" && budget.taskId !== undefined && childIds.has(budget.taskId))) throw new PreEffectToolRefusal("split children are referenced by execution budgets");
|
|
769
|
+
if (this.d.store) {
|
|
770
|
+
try { this.d.store.store.refresh(); } catch { throw new PreEffectToolRefusal("split undo requires current memory link evidence"); }
|
|
771
|
+
const children = new Set(entry.planning.split.children.map(child => this.d.board.get(child.id)).filter((child): child is Task => !!child).map(child => this.memoryKey(child)));
|
|
772
|
+
if ([...children].some(key => this.d.store!.store.hasTask(key)) || this.d.store.store.goals().some(goal => goal.links.some(link => children.has(link.task)))) throw new PreEffectToolRefusal("split children are referenced by long-term tasks");
|
|
773
|
+
}
|
|
774
|
+
const restored = this.d.board.undoBacklogSplit(id, by, revision);
|
|
775
|
+
this.planningRecorded({ by, task: id, fields: ["split", "deps"], revision: restored.planningRevision! });
|
|
776
|
+
return restored;
|
|
777
|
+
}
|
|
778
|
+
if (entry.planning.pull) {
|
|
779
|
+
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
780
|
+
if (task.state !== "proposed" || task.owner || taskHasStarted(task) || by !== USER && (task.pinned || release && ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull undo is no longer eligible");
|
|
781
|
+
const returned = this.d.board.undoPlannedPull(id, by, revision);
|
|
782
|
+
this.offered.delete(id); this.incomingBacklog.add(id);
|
|
783
|
+
this.planningRecorded({ by, task: id, fields: ["pull", "reserved"], revision: returned.planningRevision! });
|
|
784
|
+
return returned;
|
|
785
|
+
}
|
|
786
|
+
return this.planBacklog(by, { id, revision, fields: entry.planning.previous, reason: `undo planning revision ${revision}` });
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async importGitHub(by: PeerId, input: GitHubImportSelection, run?: SdlcGh): Promise<{ imported: number[]; existing: number[]; partial: boolean; complete: boolean; unconfirmed?: true }> {
|
|
790
|
+
if (by !== USER) throw new PreEffectToolRefusal("GitHub import is a person console command");
|
|
791
|
+
const deadline = performance.now() + 20_000;
|
|
792
|
+
const page = await readGitHubImport(input, run ?? ((args, timeout, _cwd, bound) => sdlcGh(args, timeout, this.d.cwd, bound)));
|
|
793
|
+
const imported: number[] = [], existing: number[] = [];
|
|
794
|
+
for (const issue of page.issues) {
|
|
795
|
+
if (performance.now() >= deadline) return { imported, existing, partial: true, complete: false };
|
|
796
|
+
let created = false;
|
|
797
|
+
try {
|
|
798
|
+
const prior = this.d.board.imported(issue.source.repo, issue.source.issue);
|
|
799
|
+
if (prior) { existing.push(prior.id); continue; }
|
|
800
|
+
const classes = profileData(this.d.profileData ?? this.d.profile ?? "dev").classes;
|
|
801
|
+
const cls = Object.keys(classes).find(name => classes[name] === "implement") ?? Object.keys(classes)[0];
|
|
802
|
+
const task = await this.propose(USER, { title: issue.title, detail: issue.detail, class: cls, backlog: true }, issue.source, () => { created = true; });
|
|
803
|
+
(created ? imported : existing).push(task.id);
|
|
804
|
+
if (performance.now() >= deadline) return { imported, existing, partial: true, complete: false };
|
|
805
|
+
} catch {
|
|
806
|
+
if (created) {
|
|
807
|
+
try {
|
|
808
|
+
const saved = this.d.board.imported(issue.source.repo, issue.source.issue);
|
|
809
|
+
if (saved) { if (!imported.includes(saved.id)) imported.push(saved.id); if (saved.state === "backlog") this.incomingBacklog.add(saved.id); return { imported, existing, partial: true, complete: false }; }
|
|
810
|
+
} catch { /* A failed readback leaves the committed identity unconfirmed. */ }
|
|
811
|
+
}
|
|
812
|
+
return { imported, existing, partial: true, complete: false, unconfirmed: true };
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return { imported, existing, partial: page.partial, complete: !page.partial };
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
async splitBacklog(by: PeerId, input: { id: number; revision: number; parts: unknown; reason: string }): Promise<{ parent: Task; children: Task[] }> {
|
|
819
|
+
this.planningAuthority(by);
|
|
820
|
+
const { id, revision } = input;
|
|
821
|
+
if (!Number.isSafeInteger(id) || id <= 0 || !Number.isSafeInteger(revision) || revision < 0 || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("split needs an id, revision and bounded reason");
|
|
822
|
+
const reason = input.reason.trim();
|
|
823
|
+
let parts: unknown;
|
|
824
|
+
try { parts = structuredClone(input.parts); } catch { throw new PreEffectToolRefusal("split parts must be cloneable data"); }
|
|
825
|
+
if (!Array.isArray(parts) || parts.length < 2 || parts.length > 8) throw new PreEffectToolRefusal("split needs two to eight parts");
|
|
826
|
+
const rows = parts as Record<string, unknown>[];
|
|
827
|
+
const validate = () => {
|
|
828
|
+
const parent = this.d.board.get(id);
|
|
829
|
+
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");
|
|
830
|
+
const release = parent.release == null ? undefined : this.d.board.release(parent.release);
|
|
831
|
+
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");
|
|
832
|
+
const profile = parent.profile ?? "dev";
|
|
833
|
+
const drafts = rows.map((part, index) => {
|
|
834
|
+
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");
|
|
835
|
+
const given = part.class ?? parent.profileClass ?? parent.class;
|
|
836
|
+
const storedProfile = !parent.profile ? "dev" : profile === (this.d.profile ?? "dev") ? this.d.profileData ?? profile : profile;
|
|
837
|
+
let cls: TaskClass;
|
|
838
|
+
try { cls = part.class === undefined ? parent.class : profileClass(given as string, storedProfile); } catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
839
|
+
const after = part.after ?? [], afterParts = part.afterParts ?? [];
|
|
840
|
+
if (!Array.isArray(after) || after.length > 64 || after.some(dep => !Number.isSafeInteger(dep) || dep <= 0) || new Set(after).size !== after.length || !Array.isArray(afterParts) || afterParts.length > 8 || afterParts.some(dep => !Number.isSafeInteger(dep) || dep < 0 || dep >= index) || new Set(afterParts).size !== afterParts.length) throw new PreEffectToolRefusal("split dependencies must name existing tasks or earlier parts");
|
|
841
|
+
const deps = [...new Set([...(parent.deps ?? []), ...after as number[]])], pending = [...deps], seen = new Set<number>();
|
|
842
|
+
const direct = afterParts.length ? deps.filter(dep => !parent.deps?.includes(dep)) : deps;
|
|
843
|
+
if (direct.length + afterParts.length > 64) throw new PreEffectToolRefusal("split child dependencies exceed the 64-edge cap");
|
|
844
|
+
while (pending.length) { const dependency = pending.pop()!; if (dependency === id) throw new PreEffectToolRefusal("split dependency reaches its parent"); if (seen.has(dependency)) continue; seen.add(dependency); const task = this.d.board.get(dependency); if (!task) throw new PreEffectToolRefusal("split dependency is missing"); pending.push(...(task.deps ?? [])); }
|
|
845
|
+
const refs = part.refs === undefined ? parent.refs : cleanRefs(part.refs), plan = cleanPlan(part.plan, storedProfile);
|
|
846
|
+
const draft = { title: part.title.replace(/\s+/g, " ").trim(), detail: part.detail as string | undefined ?? "", class: cls, refs, plan, deps, afterParts: afterParts as number[], ...(parent.role && cls === parent.class ? { role: parent.role } : {}), ...(parent.profile ? { profile: parent.profile, profileClass: given as string, ...(parent.profile === "dev" ? { customProfile: true as const } : {}) } : {}) };
|
|
847
|
+
const fields: PlanningFields = {};
|
|
848
|
+
for (const key of ["priority", "release", "plannedStart", "due"] as const) if (parent[key] !== undefined) fields[key] = parent[key];
|
|
849
|
+
return { ...draft, fields };
|
|
850
|
+
});
|
|
851
|
+
return { parent, drafts };
|
|
852
|
+
};
|
|
853
|
+
const initial = validate(), screening = this.screenOn();
|
|
854
|
+
const initialPrivacy = initial.drafts.map(part => this.signalsFor({ ...part, detail: [part.detail, planText(part.plan)].join("\n") }).filter(signal => signal === "pii" || signal === "pii_path"));
|
|
855
|
+
const initialPrivate = initialPrivacy.map(signals => this.isPii({ signals })), initialParentPrivate = this.isPii(initial.parent);
|
|
856
|
+
const initialParentPrivacy = initial.parent.signals.filter(signal => signal === "pii" || signal === "pii_path");
|
|
857
|
+
const initialReasonPrivate = initialParentPrivate || !this.nameable(reason);
|
|
858
|
+
let deadline: ReturnType<typeof setTimeout> | undefined;
|
|
859
|
+
const attempt = Promise.all([
|
|
860
|
+
Promise.all(initial.drafts.map((part, index) => screening && !initialParentPrivate && !initialPrivate[index] ? this.screenCall(screenText({ title: part.title, detail: part.detail, plan: part.plan })) : undefined)),
|
|
861
|
+
this.withholds("note", reason, initial.parent),
|
|
862
|
+
]);
|
|
863
|
+
let screened: Awaited<typeof attempt>;
|
|
864
|
+
try { screened = await Promise.race([attempt, new Promise<never>((_, reject) => { deadline = setTimeout(() => reject(new PreEffectToolRefusal("split screening request deadline exceeded")), BACKLOG_SPLIT_SCREEN_TIMEOUT_MS); })]); }
|
|
865
|
+
finally { if (deadline !== undefined) clearTimeout(deadline); }
|
|
866
|
+
const [screenedVerdicts, withheld] = screened, current = validate();
|
|
867
|
+
const finalSignals = current.drafts.map(part => this.signalsFor({ ...part, detail: [part.detail, planText(part.plan)].join("\n") }));
|
|
868
|
+
const deterministicPrivate = finalSignals.map((signals, index) => initialPrivate[index] || this.isPii({ signals }));
|
|
869
|
+
const verdicts = screenedVerdicts.map((verdict, index) => deterministicPrivate[index] ? undefined : verdict);
|
|
870
|
+
const inherited = current.drafts.map((_part, index) => (initialParentPrivate || this.isPii(current.parent)) && !deterministicPrivate[index]);
|
|
871
|
+
const drafts = current.drafts.map((part, index) => ({ ...part, ...(screening ? { screeningNote: inherited[index] ? "pii: inherited" : this.screenNote(verdicts[index]) } : {}), signals: [...new Set([...initialPrivacy[index]!, ...initialParentPrivacy, ...finalSignals[index]!, ...current.parent.signals.filter(signal => ["pii", "pii_path", "urgent"].includes(signal)), ...(verdicts[index] && verdicts[index]!.label !== "clear" ? ["pii"] : [])])] }));
|
|
872
|
+
const result = this.d.board.splitBacklog(id, by, revision, drafts, reason, withheld || initialReasonPrivate || this.isPii(current.parent) || !this.nameable(reason));
|
|
873
|
+
for (const [index, child] of result.children.entries()) {
|
|
874
|
+
this.incomingBacklog.add(child.id);
|
|
875
|
+
if (screening) { this.recordScreen(child.id, "task", verdicts[index], inherited[index]); if (verdicts[index]?.label === "unknown") this.releaseNotice(`task #${child.id}: the PII screen gave no verdict (${verdicts[index]!.miss}); it is handled as a PII task`); }
|
|
876
|
+
}
|
|
877
|
+
this.planningRecorded({ by, task: id, fields: ["split", "deps"], revision: result.parent.planningRevision! });
|
|
878
|
+
return result;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async newRelease(by: PeerId, input: { name: string; goal: string; order: number }): Promise<Release> {
|
|
882
|
+
this.planningAuthority(by);
|
|
883
|
+
if (typeof input.name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(input.name) || typeof input.goal !== "string" || !input.goal.trim() || input.goal.length > 1000 || !Number.isSafeInteger(input.order) || input.order < 0) throw new PreEffectToolRefusal("invalid release name, goal or order");
|
|
884
|
+
const text = input.name + "\n" + input.goal;
|
|
885
|
+
if (!this.nameable(text) || this.screenOn() && (await this.screenCall(text)).label !== "clear") throw new PreEffectToolRefusal("release text was not cleared for public planning");
|
|
886
|
+
if (this.d.board.releases().some(release => release.name === input.name)) throw new PreEffectToolRefusal("release name already exists");
|
|
887
|
+
const release = this.d.board.newRelease(input.name, input.goal.trim(), input.order);
|
|
888
|
+
this.planningRecorded({ by, release: release.id, fields: ["created"], revision: release.revision });
|
|
889
|
+
return release;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
changeRelease(by: PeerId, input: { id: number; revision: number; state?: ReleaseState; plannedStart?: number | null; cut?: number | null; ship?: number | null }): Release {
|
|
893
|
+
this.planningAuthority(by);
|
|
894
|
+
const release = this.d.board.release(input.id);
|
|
895
|
+
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || !release || release.revision !== input.revision) throw new PreEffectToolRefusal("release revision changed");
|
|
896
|
+
if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release is final");
|
|
897
|
+
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");
|
|
898
|
+
if (input.state === "shipped" && this.d.board.list().some(task => task.release === release.id && task.state !== "approved")) throw new PreEffectToolRefusal("every release task must be approved or moved out before ship");
|
|
899
|
+
const { id, revision, ...patch } = input;
|
|
900
|
+
if (!Object.keys(patch).length || Object.keys(patch).some(key => !["state", "plannedStart", "cut", "ship"].includes(key))) throw new PreEffectToolRefusal("unknown or empty release fields");
|
|
901
|
+
const updated = this.d.board.updateRelease(id, patch, revision);
|
|
902
|
+
this.planningRecorded({ by, release: id, fields: Object.keys(patch), revision: updated.revision });
|
|
903
|
+
return updated; // a failed readback may follow a durable release write
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; role?: unknown; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown; backlog?: unknown; goal?: unknown }, external?: GitHubSource, created?: () => void): Promise<Task> {
|
|
907
|
+
external = external ? { ...external } : undefined;
|
|
908
|
+
if (external && (by !== USER || input.backlog !== true)) throw new PreEffectToolRefusal("external intake requires person-owned backlog staging");
|
|
909
|
+
if (input.backlog !== undefined && typeof input.backlog !== "boolean") throw new PreEffectToolRefusal("backlog must be a boolean");
|
|
910
|
+
const backlog = input.backlog === true;
|
|
911
|
+
if (backlog && input.owner && !this.d.bus.isRostered(input.owner)) throw new PreEffectToolRefusal(`${input.owner} is off roster; add it before reserving work`);
|
|
472
912
|
// Callers are models: a title is one line (it is part of console and hub.log lines), not a document.
|
|
473
913
|
const title = String(input.title ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
|
|
474
914
|
if (!title) throw new PreEffectToolRefusal("title is required");
|
|
915
|
+
const goal = input.goal == null || input.goal === "" ? undefined : this.goalFor(by, input.goal);
|
|
475
916
|
// Dependencies can only name tasks that exist, so the new task closes no cycle: nothing can depend on it yet.
|
|
476
917
|
// Callers are models: a missing or null `after` is no dependency, and an id is an integer or a digit string.
|
|
477
918
|
let afterIds: unknown[] = [];
|
|
@@ -485,40 +926,54 @@ export class Tasks {
|
|
|
485
926
|
if (!deps.includes(id)) deps.push(id);
|
|
486
927
|
}
|
|
487
928
|
// An owner for work that waits is reserved (issue #207): routing offers it to that peer first once it is ready.
|
|
488
|
-
const reserved = this.waitsFor({ deps }).length ? input.owner : undefined;
|
|
929
|
+
const reserved = backlog || this.waitsFor({ deps }).length ? input.owner : undefined;
|
|
489
930
|
const given = input.class === undefined || input.class === "" ? undefined : input.class;
|
|
490
|
-
|
|
931
|
+
const profile = this.d.profile ?? "dev";
|
|
932
|
+
let baseGiven: TaskClass | undefined;
|
|
933
|
+
try { if (given !== undefined) baseGiven = profileClass(given, this.d.profileData ?? profile); } catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
491
934
|
const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
|
|
492
|
-
const plan = cleanPlan(input.plan);
|
|
935
|
+
const plan = cleanPlan(input.plan, this.d.profileData ?? profile);
|
|
493
936
|
// The plan reaches other owners, so a PII pattern in it makes the task a PII task like one in the detail would.
|
|
494
|
-
const
|
|
937
|
+
const screenDetail = [text.detail, external?.repo, external?.url].filter(value => value !== undefined && value !== "").join("\n");
|
|
938
|
+
const signals = this.signalsFor({ ...text, detail: [screenDetail, planText(plan)].join("\n"), plan });
|
|
495
939
|
// Urgent work is handed over even when its paused owner's window resets soon (issue #36).
|
|
496
940
|
if (input.urgent === true && !signals.includes("urgent")) signals.push("urgent");
|
|
497
941
|
// The PII screen (issue #198) reads the same text before anything else does: until its verdict the task is on no
|
|
498
942
|
// board, in no envelope and before no other model. A pattern match is PII without a call; no verdict is PII too.
|
|
499
943
|
const screening = this.screenOn();
|
|
500
|
-
const screened = screening && !signals.includes("pii") ? await this.screenCall(screenText({ ...text, plan })) : undefined;
|
|
944
|
+
const screened = screening && !signals.includes("pii") ? await this.screenCall(screenText({ ...text, detail: screenDetail, plan })) : undefined;
|
|
501
945
|
if (screened && screened.label !== "clear") signals.push("pii");
|
|
502
|
-
let cls =
|
|
946
|
+
let cls = baseGiven;
|
|
503
947
|
let triaged = false;
|
|
504
948
|
let fenced = false; // a PII task off campus: triage is not asked
|
|
505
949
|
if (!cls && this.d.triage) {
|
|
506
950
|
// Signals first: a PII task's text may only go to a model that is reached without leaving the campus network.
|
|
507
|
-
const pii =
|
|
951
|
+
const pii = this.isPii({ signals });
|
|
508
952
|
if (!pii || (await this.d.triage.onCampus().catch(() => false))) cls = await this.d.triage.classify(text.title, text.detail).catch(() => undefined);
|
|
509
953
|
else fenced = true;
|
|
510
954
|
triaged = !!cls;
|
|
511
955
|
}
|
|
512
956
|
// A claim is work the caller will do itself: without a class and a model to name one, it is implementation (#6).
|
|
513
|
-
const defaulted = !cls && input.owner === by;
|
|
957
|
+
const defaulted = !backlog && !cls && input.owner === by;
|
|
514
958
|
if (defaulted) cls = "implement";
|
|
515
959
|
if (!cls) throw new PreEffectToolRefusal(`class is required (one of ${CLASSES.join(", ")}); ${fenced ? "the task is handled as PII and the hub's model is not reached on campus, so it was not asked to name one" : "the hub could not name one for you"}`);
|
|
516
|
-
|
|
517
|
-
|
|
960
|
+
let selected;
|
|
961
|
+
try { selected = taskRole(input.role, cls, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
|
|
962
|
+
catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
963
|
+
const draft = { ...text, class: cls, ...(selected ? { role: selected.id } : {}), ...(profile !== "dev" || given !== undefined && given !== cls || this.d.profileData && this.d.profileData !== projectProfile("dev") ? { profile, profileClass: given ?? cls, ...(profile === "dev" ? { customProfile: true as const } : {}) } : {}) };
|
|
964
|
+
if (external) { const prior = this.d.board.imported(external.repo, external.issue); if (prior) return prior; }
|
|
965
|
+
let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
|
|
966
|
+
created?.();
|
|
518
967
|
if (screening) task = this.screenedTask(task, screened);
|
|
968
|
+
if (goal) this.linkNative(task, goal, by);
|
|
519
969
|
if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
|
|
520
970
|
if (defaulted) task = this.d.board.update(task.id, "hub", "class defaulted", {}, "class implement for a claim without one");
|
|
521
|
-
|
|
971
|
+
if (backlog) {
|
|
972
|
+
this.incomingBacklog.add(task.id);
|
|
973
|
+
this.releaseNotice(`task ${this.publicTitle(task)} backlogged by ${by} [${profileTaskLabel(task)}]`);
|
|
974
|
+
return task;
|
|
975
|
+
}
|
|
976
|
+
this.d.notify(`task ${this.publicTitle(task)} proposed by ${by} [${profileTaskLabel(task)}]${task.signals.length ? ` signals: ${task.signals.join(", ")}` : ""}`);
|
|
522
977
|
// Re-read: a dependency approved while triage was awaited looked for its dependents before this row existed.
|
|
523
978
|
const still = this.waitsFor(task);
|
|
524
979
|
if (still.length) {
|
|
@@ -526,14 +981,37 @@ export class Tasks {
|
|
|
526
981
|
return this.d.board.update(task.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
|
|
527
982
|
}
|
|
528
983
|
// Naming yourself is a claim: the work is already yours, so no offer comes back to you (paper: Agensh CLAIM, #68).
|
|
529
|
-
return this.assignOwner(task, by, input.owner ? { candidates: [input.owner], claim: input.owner === by } : {});
|
|
984
|
+
return this.assignOwner(task, by, input.owner ? { candidates: [input.owner], namedOwner: input.owner, claim: input.owner === by } : {});
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** Person-owned staging only. Flow transitions remain with their existing owners and reviewers. */
|
|
988
|
+
async moveBacklog(by: PeerId, id: unknown, pull: boolean): Promise<Task> {
|
|
989
|
+
if (by !== USER) throw new PreEffectToolRefusal("backlog pull/return is a console command");
|
|
990
|
+
if (typeof pull !== "boolean") throw new PreEffectToolRefusal("pull must be a boolean");
|
|
991
|
+
const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
|
|
992
|
+
if (!Number.isSafeInteger(n) || n <= 0) throw new PreEffectToolRefusal("id must be a positive task number");
|
|
993
|
+
const task = this.d.board.get(n);
|
|
994
|
+
if (!task) throw new PreEffectToolRefusal(`no task #${n}`);
|
|
995
|
+
if (task.state !== (pull ? "backlog" : "proposed") || task.owner) throw new PreEffectToolRefusal(`task #${n}: ${pull ? "only backlog may be pulled" : "only ownerless proposed work may return to backlog"}`);
|
|
996
|
+
if (!pull && taskHasStarted(task)) throw new PreEffectToolRefusal(`task #${n}: started work cannot return to backlog`);
|
|
997
|
+
const pulled = task.history.findLast(entry => entry.event === "backlog pulled" || entry.event === "backlog returned");
|
|
998
|
+
const restoreReservation = !pull && pulled?.planning?.pull && !task.history.slice(task.history.lastIndexOf(pulled) + 1).some(entry => entry.event === "reserved" || entry.planning?.fields.includes("reserved"));
|
|
999
|
+
const next = this.d.board.update(n, by, pull ? "backlog pulled" : "backlog returned", { state: pull ? "proposed" : "backlog", owner: null, reviewer: null, ...(restoreReservation ? { reserved: pulled.planning!.previous.reserved ?? null } : {}) });
|
|
1000
|
+
this.offered.delete(n);
|
|
1001
|
+
if (!pull) { this.incomingBacklog.add(n); return next; }
|
|
1002
|
+
const waits = this.waitsFor(next);
|
|
1003
|
+
if (waits.length) return this.d.board.update(n, HUB, "blocked", {}, `waits for ${waits.map(v => `#${v}`).join(", ")}`);
|
|
1004
|
+
// This persisted ready marker also lets a restart offer ownerless work once a peer attaches.
|
|
1005
|
+
const ready = this.d.board.update(n, HUB, "ready", {}, "pulled from backlog");
|
|
1006
|
+
return this.assignOwner(ready, by);
|
|
530
1007
|
}
|
|
531
1008
|
|
|
532
1009
|
/** Where a screened task's `pii` signal came from, on its history and in events (issue #198); no verdict is a console line by id. */
|
|
1010
|
+
private screenNote(v: PiiVerdict | undefined): string { return !v ? "pii: regex" : v.label === "clear" ? "clear" : v.label === "pii" ? `pii: screen, ${v.category}` : `pii: unknown, ${v.miss}`; }
|
|
533
1011
|
private screenedTask(task: Task, v: PiiVerdict | undefined): Task {
|
|
534
1012
|
this.recordScreen(task.id, "task", v);
|
|
535
1013
|
if (v?.label === "unknown") this.d.notify(`task #${task.id}: the PII screen gave no verdict (${v.miss}); it is handled as a PII task`);
|
|
536
|
-
const note =
|
|
1014
|
+
const note = this.screenNote(v);
|
|
537
1015
|
return this.d.board.update(task.id, HUB, "screened", {}, note);
|
|
538
1016
|
}
|
|
539
1017
|
|
|
@@ -569,7 +1047,12 @@ export class Tasks {
|
|
|
569
1047
|
this.d.notify(`task ${this.publicTitle(lifted)}: the PII screen cleared it on a second look; it is routed as usual`);
|
|
570
1048
|
const still = this.waitsFor(lifted);
|
|
571
1049
|
if (still.length) return void this.d.board.update(lifted.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
|
|
572
|
-
|
|
1050
|
+
try {
|
|
1051
|
+
await this.assignOwner(lifted, HUB);
|
|
1052
|
+
} catch (e) {
|
|
1053
|
+
// A saved move whose delivery failed: the console notice was printed where the error was thrown (#297).
|
|
1054
|
+
if (!(e instanceof AssignmentUndeliveredError)) throw e;
|
|
1055
|
+
}
|
|
573
1056
|
}
|
|
574
1057
|
|
|
575
1058
|
/**
|
|
@@ -624,6 +1107,38 @@ export class Tasks {
|
|
|
624
1107
|
/** The tasks an overlap text for `task` names: their plans are in it (issue #108). */
|
|
625
1108
|
overlapTasks = (task: Task): number[] => this.overlapHits(task).map((h) => h.task.id);
|
|
626
1109
|
|
|
1110
|
+
/**
|
|
1111
|
+
* Dashboard warning links (#264): the board's overlap pairs and the live cohorts, as task and peer ids only.
|
|
1112
|
+
* Paths, symbols and plans stay out; PII tasks are excluded by the same rules overlapHits applies.
|
|
1113
|
+
* ponytail: O(open²) board reads per call, fine at dashboard board sizes; cache by board revision if it ever shows.
|
|
1114
|
+
*/
|
|
1115
|
+
overlapLinks(): { tasks: [number, number]; peers: string[] }[] {
|
|
1116
|
+
const links: { tasks: [number, number]; peers: string[] }[] = [];
|
|
1117
|
+
const seen = new Set<string>();
|
|
1118
|
+
for (const task of this.d.board.list()) {
|
|
1119
|
+
if (!OPEN.includes(task.state) || !task.owner || this.isPii(task)) continue;
|
|
1120
|
+
for (const hit of this.overlapHits(task)) {
|
|
1121
|
+
const pair = [task.id, hit.task.id].sort((a, b) => a - b) as [number, number];
|
|
1122
|
+
const key = pair.join(":");
|
|
1123
|
+
if (seen.has(key)) continue;
|
|
1124
|
+
seen.add(key);
|
|
1125
|
+
links.push({ tasks: pair, peers: [...new Set([task.owner, hit.task.owner!])].sort() });
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
return links;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** The live cohorts as dashboard links (#264): member task ids and their owners, nothing else. */
|
|
1132
|
+
cohortLinks(): { tasks: number[]; peers: string[] }[] {
|
|
1133
|
+
const links: { tasks: number[]; peers: string[] }[] = [];
|
|
1134
|
+
for (const cohort of this.cohorts.list()) {
|
|
1135
|
+
const members = [...cohort.members.values()].filter(member => { const task = this.d.board.get(member.task); return task && !this.isPii(task); });
|
|
1136
|
+
if (members.length < 2) continue;
|
|
1137
|
+
links.push({ tasks: members.map((m) => m.task).sort((a, b) => a - b), peers: [...new Set(members.map((m) => m.owner))].sort() });
|
|
1138
|
+
}
|
|
1139
|
+
return links;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
627
1142
|
/** While a gone owner's tasks move, its other tasks are about to move too: they are no one to settle with. */
|
|
628
1143
|
private releasing: PeerId | undefined;
|
|
629
1144
|
|
|
@@ -652,6 +1167,16 @@ export class Tasks {
|
|
|
652
1167
|
* for a recorded notice to this peer whose task is gone, has another owner, or left the states it was about.
|
|
653
1168
|
*/
|
|
654
1169
|
relevant = (peer: PeerId, env: Envelope): boolean => {
|
|
1170
|
+
if (env.from === HUB && env.kind === "task" && env.refs?.backlog) {
|
|
1171
|
+
const ids = env.refs.backlog;
|
|
1172
|
+
if (!Array.isArray(ids) || !ids.length || ids.length > 64 || ids.some(id => !Number.isSafeInteger(id) || id <= 0)) return false;
|
|
1173
|
+
const current = ids.filter(id => this.d.board.get(id)?.state === "backlog");
|
|
1174
|
+
if (current.length !== ids.length || !this.d.roles?.[peer]?.includes("planner") || !this.d.bus.isRostered(peer)) {
|
|
1175
|
+
this.addPlannerBacklog(peer, current);
|
|
1176
|
+
return false;
|
|
1177
|
+
}
|
|
1178
|
+
return true;
|
|
1179
|
+
}
|
|
655
1180
|
const c = this.conditional.get(env.id);
|
|
656
1181
|
if (!c || c.peer !== peer) return true;
|
|
657
1182
|
const t = this.d.board.get(c.task);
|
|
@@ -685,7 +1210,7 @@ export class Tasks {
|
|
|
685
1210
|
*/
|
|
686
1211
|
private places(task: Task): { paths: string[]; symbols: string[] } {
|
|
687
1212
|
const paths = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].map((p) => this.projectPath(p));
|
|
688
|
-
return { paths: [...new Set(paths)], symbols: task.plan?.symbols ?? [] };
|
|
1213
|
+
return { paths: [...new Set(paths)], symbols: [...(task.plan?.symbols ?? []), ...(task.plan?.sections ?? []).map(v => `section ${v}`), ...(task.plan?.sources ?? []).map(v => `source ${v}`)] };
|
|
689
1214
|
}
|
|
690
1215
|
|
|
691
1216
|
private roots?: string[];
|
|
@@ -709,7 +1234,7 @@ export class Tasks {
|
|
|
709
1234
|
return this.d.board.list().flatMap((t) => {
|
|
710
1235
|
if (t.id === task.id || !t.owner || t.owner === task.owner || t.owner === this.releasing || !OPEN.includes(t.state) || this.isPii(t)) return [];
|
|
711
1236
|
const theirs = this.places(t);
|
|
712
|
-
const paths = mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
|
|
1237
|
+
const paths = task.profile && t.profile && (task.plan?.sections?.length && t.plan?.sections?.length) ? [] : mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
|
|
713
1238
|
const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
|
|
714
1239
|
return paths.length || symbols.length ? [{ task: t, paths, symbols }] : [];
|
|
715
1240
|
});
|
|
@@ -749,7 +1274,7 @@ export class Tasks {
|
|
|
749
1274
|
// Awaits run between tasks (briefs, memory): re-read, and stop for anything that changed meanwhile.
|
|
750
1275
|
const task = this.d.board.get(id);
|
|
751
1276
|
if (!task || task.owner !== peer || !OPEN.includes(task.state) || this.d.bus.stateOf(peer) !== "offline") continue;
|
|
752
|
-
if (!assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), peer], ...this.health() }).owner) continue;
|
|
1277
|
+
if (!assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), peer], excludeOwners: this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], ...this.health() }).owner) continue;
|
|
753
1278
|
try {
|
|
754
1279
|
const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
|
|
755
1280
|
const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", reason: "offline", note: why });
|
|
@@ -757,7 +1282,12 @@ export class Tasks {
|
|
|
757
1282
|
// The gone owner hears it on its next delivery, if it comes back mid-work.
|
|
758
1283
|
if (next.owner && next.owner !== peer) this.d.tell?.(peer, noteLine(HUB, "decision", `task #${task.id} moved to ${next.owner} while you were offline; stop working on it`));
|
|
759
1284
|
} catch (e) {
|
|
760
|
-
|
|
1285
|
+
if (e instanceof AssignmentUndeliveredError) {
|
|
1286
|
+
// The move is fact for the board, so it is fact here too: record it, and tell the gone owner to stop
|
|
1287
|
+
// (the console notice was printed where the error was thrown) (#297).
|
|
1288
|
+
moved.push({ id: task.id, title: this.publicTitle(task), to: e.task.owner });
|
|
1289
|
+
if (e.task.owner && e.task.owner !== peer) this.d.tell?.(peer, noteLine(HUB, "decision", `task #${task.id} moved to ${e.task.owner} while you were offline; stop working on it`));
|
|
1290
|
+
} else this.d.notify(`task ${this.publicTitle(task)}: could not be released from ${peer}: ${(e as Error).message}`);
|
|
761
1291
|
}
|
|
762
1292
|
}
|
|
763
1293
|
} finally {
|
|
@@ -766,26 +1296,27 @@ export class Tasks {
|
|
|
766
1296
|
return moved;
|
|
767
1297
|
}
|
|
768
1298
|
|
|
769
|
-
/** Same code as assignment, without doing it. */
|
|
770
|
-
explain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }): string[] {
|
|
771
|
-
const
|
|
1299
|
+
/** Same code as assignment, without doing it. `routing`: a candidate policy to explain under (the settings preview, #269). */
|
|
1300
|
+
explain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }, routing: Routing = this.d.routing()): string[] {
|
|
1301
|
+
const workflow = this.d.sdlc?.config().workflow === "gsd-lite" ? [`workflow: gsd-lite (${this.d.sdlc.config().mode}); checklist guidance, board dependencies authoritative`] : [];
|
|
772
1302
|
if (typeof target === "number") {
|
|
773
1303
|
const task = this.d.board.get(target);
|
|
774
1304
|
if (!task) throw new Error(`no task #${target}`);
|
|
775
|
-
const a = assign(task, this.states(), routing, { exclude: this.excluded(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) });
|
|
1305
|
+
const a = assign(task, this.states(), routing, { exclude: this.excluded(task), excludeOwners: this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], waitsFor: this.waitsFor(task), ...this.weights(task.class) });
|
|
776
1306
|
// The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
|
|
777
|
-
return [`task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"}${task.reserved ? `, reserved for ${task.reserved}` : ""})`, "if it were assigned now:", ...a.trace, ...(this.splitShadow(task, task.owner ?? a.owner)?.trace ?? [])];
|
|
1307
|
+
return [...workflow, `task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"}${task.reserved ? `, reserved for ${task.reserved}` : ""})`, ...(task.profileClass ? [`profile class ${profileTaskLabel(task)}`] : []), ...(task.role ? [`task role ${task.role} (guidance only)`] : []), "if it were assigned now:", ...a.trace, ...(this.splitShadow(task, task.owner ?? a.owner)?.trace ?? [])];
|
|
778
1308
|
}
|
|
779
1309
|
const draft = { title: target.title, detail: target.detail ?? "", refs: target.refs ?? {} };
|
|
780
|
-
const signals =
|
|
781
|
-
const
|
|
1310
|
+
const signals = this.signalsFor(draft, routing);
|
|
1311
|
+
const base = profileClass(target.class, this.d.profileData ?? this.d.profile);
|
|
1312
|
+
const trace = [...(this.d.profile && this.d.profile !== "dev" ? [`profile class ${target.class} (base ${base}, ${this.d.profile})`] : []), ...assign({ class: base, signals }, this.states(), routing, this.weights(base)).trace];
|
|
782
1313
|
// A draft is not sent to the PII screen (issue #198): a proposal is, and a pii or unknown verdict adds the pii signal.
|
|
783
|
-
return this.screenOn() && !signals.includes("pii") ? [...trace, "pii_screen is on: at proposal a pii or unknown verdict adds signal pii (local only)"] : trace;
|
|
1314
|
+
return [...workflow, ...(this.screenOn() && !signals.includes("pii") ? [...trace, "pii_screen is on: at proposal a pii or unknown verdict adds signal pii (local only)"] : trace)];
|
|
784
1315
|
}
|
|
785
1316
|
|
|
786
1317
|
/** Public read path: every generated line is screened, including profile/config-derived trace fields. */
|
|
787
|
-
publicExplain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }): string[] {
|
|
788
|
-
return this.explain(target).map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
|
|
1318
|
+
publicExplain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }, routing?: Routing): string[] {
|
|
1319
|
+
return this.explain(target, routing).map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
|
|
789
1320
|
}
|
|
790
1321
|
|
|
791
1322
|
/**
|
|
@@ -801,7 +1332,8 @@ export class Tasks {
|
|
|
801
1332
|
*/
|
|
802
1333
|
private passedOver(task: Task, a: Assignment): string {
|
|
803
1334
|
if (!a.unreserved || task.reserved === task.owner || this.excluded(task).includes(task.reserved!)) return "";
|
|
804
|
-
|
|
1335
|
+
// Before the write, but after the caller's own (a decline's, say): a throwing console fails nothing (#297).
|
|
1336
|
+
this.releaseNotice(`task ${this.publicTitle(task)}: its reserved owner ${task.reserved} is passed over (${a.unreserved}); routing proceeds`);
|
|
805
1337
|
return `; reserved owner ${task.reserved} passed over: ${a.unreserved}`;
|
|
806
1338
|
}
|
|
807
1339
|
|
|
@@ -821,17 +1353,22 @@ export class Tasks {
|
|
|
821
1353
|
}
|
|
822
1354
|
|
|
823
1355
|
/** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
|
|
824
|
-
private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean } = {}): Promise<Task> {
|
|
1356
|
+
private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean } = {}): Promise<Task> {
|
|
1357
|
+
if (task.state === "backlog") throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
|
|
825
1358
|
const waits = this.waitsFor(task);
|
|
826
|
-
const
|
|
1359
|
+
const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
|
|
1360
|
+
const plannerFull = plannedMove ? this.plannerFull(task) : [];
|
|
1361
|
+
const a = assign(task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}) });
|
|
827
1362
|
|
|
828
1363
|
if (waits.length) {
|
|
829
|
-
this.
|
|
1364
|
+
this.releaseNotice(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
|
|
830
1365
|
return task;
|
|
831
1366
|
}
|
|
832
1367
|
const passed = this.passedOver(task, a);
|
|
833
1368
|
if (!a.owner) {
|
|
834
|
-
this.
|
|
1369
|
+
this.releaseNotice(opts.roster
|
|
1370
|
+
? `task ${this.publicTitle(task)}: ${task.owner} is off roster and no peer can take it; it stays with ${task.owner} in ${task.state}; assign with: ahub task assign ${task.id} <peer>`
|
|
1371
|
+
: `task ${this.publicTitle(task)}: no peer can take it (${a.trace.filter((l) => l.includes("skipped")).length} skipped); assign with: ahub task assign ${task.id} <peer>`);
|
|
835
1372
|
// Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
|
|
836
1373
|
if (opts.clearOnFail && task.owner) {
|
|
837
1374
|
this.cohorts.leave(task.id);
|
|
@@ -843,7 +1380,7 @@ export class Tasks {
|
|
|
843
1380
|
// A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
|
|
844
1381
|
// out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
|
|
845
1382
|
const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
|
|
846
|
-
const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, `${opts.note ?? `to ${a.owner}`}${passed}`, { ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) });
|
|
1383
|
+
const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, `${opts.note ?? `to ${a.owner}`}${passed}`, { ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) });
|
|
847
1384
|
// What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
|
|
848
1385
|
// escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
|
|
849
1386
|
// yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
|
|
@@ -856,41 +1393,96 @@ export class Tasks {
|
|
|
856
1393
|
// A claim is its own hand-over: the claimant took the task in the turn it is in, so it is taking it, not busy elsewhere.
|
|
857
1394
|
// The cohort record formed just below reads it, and so does `route explain` while that turn lasts.
|
|
858
1395
|
if (opts.claim && a.owner === by && this.d.bus.peers.get(by)?.state === "busy") this.sent.set(handOver(next, by), by);
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
1396
|
+
try {
|
|
1397
|
+
const hits = this.overlapHits(next);
|
|
1398
|
+
this.formCohort(next, hits);
|
|
1399
|
+
if (hits.length) {
|
|
1400
|
+
this.announceOverlap(next, hits);
|
|
1401
|
+
this.tellEarlierOwners(next, hits);
|
|
1402
|
+
}
|
|
1403
|
+
this.announceRouting(next, a);
|
|
1404
|
+
if (!opts.claim || a.owner !== by) await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits), opts.contextWithheld);
|
|
1405
|
+
} catch (e) {
|
|
1406
|
+
// Everything between the write and the end of the delivery is in here: the move is fact, and a failure is a
|
|
1407
|
+
// delivery failure (#297). The one console notice is printed here, once, for every caller; callers decide
|
|
1408
|
+
// control flow by the error type.
|
|
1409
|
+
let saved = next;
|
|
1410
|
+
try {
|
|
1411
|
+
saved = this.d.board.get(task.id) ?? next;
|
|
1412
|
+
} catch { /* the write's row is still true */ }
|
|
1413
|
+
// Superseded means a later entry that moves or settles the task: another assignment, an unassign, the
|
|
1414
|
+
// owner's accept, done, approval, close. Entries that do neither (the hub's quiet bookkeeping, a check
|
|
1415
|
+
// result landing during the brief) leave the assignment exactly as undelivered as before: the notice and
|
|
1416
|
+
// the typed error stand (#297 Decisions).
|
|
1417
|
+
const moves = (entries: Task["history"]) => entries.filter((h) => !QUIET_EVENTS.has(h.event) && !h.event.startsWith("check "));
|
|
1418
|
+
if (moves(saved.history).length !== moves(next.history).length) throw e;
|
|
1419
|
+
this.releaseNotice(this.undeliveredNotice(saved, e));
|
|
1420
|
+
throw new AssignmentUndeliveredError(saved, e);
|
|
864
1421
|
}
|
|
865
|
-
|
|
1422
|
+
// A claim is its own hand-over, with no delivery: its accepted write and its line stay as they were (#297).
|
|
866
1423
|
if (opts.claim && a.owner === by) {
|
|
867
1424
|
const claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" });
|
|
868
|
-
this.
|
|
1425
|
+
this.releaseNotice(`task ${this.publicTitle(claimed)} claimed by ${by}`);
|
|
869
1426
|
return claimed;
|
|
870
1427
|
}
|
|
871
|
-
await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits), opts.contextWithheld);
|
|
872
1428
|
return next;
|
|
873
1429
|
}
|
|
874
1430
|
|
|
1431
|
+
/** Person-only advice changes never change ownership, class or authority. */
|
|
1432
|
+
setRole(by: PeerId, id: unknown, raw: unknown): Task {
|
|
1433
|
+
if (by !== USER) throw new PreEffectToolRefusal("only the person may change an existing task catalog role");
|
|
1434
|
+
if (raw === undefined) throw new PreEffectToolRefusal("name a catalog role id or explicit null to clear it");
|
|
1435
|
+
const task = this.need(id, false, true);
|
|
1436
|
+
if (task.state === "approved") throw new PreEffectToolRefusal("an approved task's role is historical; propose new work instead");
|
|
1437
|
+
let selected;
|
|
1438
|
+
try { selected = taskRole(raw === null ? undefined : raw, task.class, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
|
|
1439
|
+
catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
1440
|
+
if (task.role === selected?.id) return task;
|
|
1441
|
+
const next = this.d.board.update(task.id, by, "catalog role changed", { role: selected?.id });
|
|
1442
|
+
for (const peer of new Set([next.owner, next.state === "in_review" ? next.reviewer : null])) {
|
|
1443
|
+
if (!peer || peer === USER) continue;
|
|
1444
|
+
this.releaseNotice(`task ${this.publicTitle(next)}: catalog role update delivery unconfirmed`, () => this.d.bus.publish(newEnvelope(HUB,
|
|
1445
|
+
`Task #${next.id}: catalog role changed by the person. ${next.role ? this.roleGuidance(next, peer, peer === next.reviewer) : "Catalog guidance cleared; follow the task's existing plan and requirements."}`,
|
|
1446
|
+
{ to: [peer], kind: "task", refs: { task: String(next.id) }, ...(this.isPii(next) ? { private: true } : {}) })));
|
|
1447
|
+
}
|
|
1448
|
+
return next;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/** A self-claim has no assignment envelope; its tool result carries the same bounded guidance. */
|
|
1452
|
+
claimRoleGuidance(task: Task, peer: PeerId): string {
|
|
1453
|
+
return task.owner === peer && task.state === "in_progress" ? this.roleGuidance(task, peer) : "";
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
private roleGuidance(task: Task, peer: PeerId, review = false): string {
|
|
1457
|
+
if (!task.role) return "";
|
|
1458
|
+
if (!review && this.d.sessionRoles?.(peer).includes(task.role)) return `Task role ${task.role} (already in your session guidance).`;
|
|
1459
|
+
const role = (this.d.catalog ?? loadRoleCatalog(this.d.cwd)).roles.get(task.role);
|
|
1460
|
+
if (role) try { return taskRoleText(role, review); } catch { /* a changed definition cannot break a saved assignment */ }
|
|
1461
|
+
return `Task role ${task.role}: definition unavailable or exceeds the task guidance limit; grants no authority.`;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
875
1464
|
/** `contextWithheld`: the PII screen did not clear a budget hand-off (#198); an escalation's reason is the hub's own words. */
|
|
876
1465
|
private async sendTask(task: Task, a: Assignment, context?: string, overlap = "", contextWithheld = false): Promise<void> {
|
|
877
1466
|
const pii = this.isPii(task);
|
|
878
1467
|
const brief = pii ? undefined : await this.d.briefs?.forTask(task.owner!, task).catch(() => undefined);
|
|
879
1468
|
const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", a.owner, h.withheld)}`);
|
|
880
1469
|
const handoff = context && !pii ? this.screen(task, context, "handoff", a.owner, contextWithheld).slice(0, 3000) : "";
|
|
881
|
-
const facts = [`class ${task
|
|
1470
|
+
const facts = [`class ${profileTaskLabel(task)}`, a.owner === PI ? `backend pi/${a.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", a.reviewer ? `reviewer ${a.reviewer}` : task.class === "review" ? "no reviewer" : this.noReviewer(a)].filter(Boolean).join("; ");
|
|
882
1471
|
const plan = planText(task.plan);
|
|
883
1472
|
const body = [
|
|
884
|
-
`Task #${task.id} [${task
|
|
1473
|
+
`Task #${task.id} [${profileTaskLabel(task)}] ${task.title}`,
|
|
885
1474
|
task.detail,
|
|
886
1475
|
`Facts: ${facts}`,
|
|
1476
|
+
this.roleGuidance(task, task.owner!),
|
|
887
1477
|
plan ? `Plan so far: ${plan}` : "",
|
|
888
1478
|
overlap,
|
|
889
1479
|
rejected.length ? `Earlier review notes:\n${rejected.join("\n")}` : "",
|
|
890
1480
|
brief ?? "",
|
|
891
1481
|
// What the previous owner left behind. Peer-written free text: never attached to a PII task.
|
|
892
1482
|
handoff ? `Handoff from the previous owner:\n${handoff}` : "",
|
|
893
|
-
|
|
1483
|
+
task.state === "in_review"
|
|
1484
|
+
? `Ownership moved while this task is already under review. Read it with hub_task_show {id: ${task.id}} and wait for the review verdict; do not accept it or repeat completed work.`
|
|
1485
|
+
: `Take it with hub_task_accept {id: ${task.id}, plan: {paths, symbols, signatures, insertion_points}} (what you will change, before you start${pii ? "" : "; owners of overlapping tasks see it"}) or pass with hub_task_decline. When finished: hub_task_done {id: ${task.id}, summary: what changed, why, and the check you ran with its result, refs}.`,
|
|
894
1486
|
].filter(Boolean).join("\n\n");
|
|
895
1487
|
// A hand-over the owner takes in a turn of its own (#109, #115): idle before, busy after and not queued, this envelope
|
|
896
1488
|
// was in the delivery that started its turn. Busy before, it is queued, or steered into a turn about something else
|
|
@@ -936,6 +1528,16 @@ export class Tasks {
|
|
|
936
1528
|
}
|
|
937
1529
|
}
|
|
938
1530
|
|
|
1531
|
+
/**
|
|
1532
|
+
* The one text for a saved assignment whose delivery is unconfirmed (#297), built through publicTitle (a PII task
|
|
1533
|
+
* shows nothing more than it does today): what to check, and how to send it again. No automatic resend: a blind
|
|
1534
|
+
* resend can duplicate a publish that did leave (the #276 decision).
|
|
1535
|
+
*/
|
|
1536
|
+
undeliveredNotice(task: Task, cause: unknown): string {
|
|
1537
|
+
const owner = task.owner ?? "its owner";
|
|
1538
|
+
return `task ${this.publicTitle(task)}: the move to ${owner} is saved, but its assignment's delivery is not confirmed (${cause instanceof Error ? cause.message : String(cause)}); look for a queued or needs_review delivery with ahub queue list --peer ${owner} (then ahub queue show <id>), or the accepted entry in ahub task show ${task.id} if the journal is down; send it again with: ahub task assign ${task.id} ${owner}`;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
939
1541
|
private readonly offered = new Set<number>(); // assignments in this hub run, including offers still in flight
|
|
940
1542
|
private readonly sent = new Map<string, PeerId>(); // hand-over (`handOver`) -> owner, for the owner's current turn: its task envelope started it, or it claimed the task in it
|
|
941
1543
|
|
|
@@ -947,8 +1549,9 @@ export class Tasks {
|
|
|
947
1549
|
*/
|
|
948
1550
|
async releaseReady(): Promise<void> {
|
|
949
1551
|
for (const t of this.d.board.list("proposed")) {
|
|
950
|
-
const last = t.history.
|
|
951
|
-
if (!t.deps?.length || t.owner || this.offered.has(t.id) || (last !== "blocked" && last !== "ready" && last !== "reserved") || this.waitsFor(t).length) continue;
|
|
1552
|
+
const last = t.history.findLast(entry => !QUIET_EVENTS.has(entry.event))?.event, planned = this.plannerPullRecord(t)?.pull === true;
|
|
1553
|
+
if ((!t.deps?.length && !t.history.some(h => h.event === "backlog pulled")) || t.owner || this.offered.has(t.id) || (!planned && last !== "blocked" && last !== "ready" && last !== "reserved") || this.waitsFor(t).length) continue;
|
|
1554
|
+
if (planned && !this.plannerAssignment(t, true).owner) continue;
|
|
952
1555
|
// ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
|
|
953
1556
|
// an offline one; hold reserved tasks for a grace period if restarts show that up.
|
|
954
1557
|
if (!assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.health() }).owner) continue; // nobody attached can take it yet
|
|
@@ -958,19 +1561,25 @@ export class Tasks {
|
|
|
958
1561
|
|
|
959
1562
|
/** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
|
|
960
1563
|
private async offerReady(stale: Task, why: string): Promise<void> {
|
|
961
|
-
let offering = false;
|
|
1564
|
+
let offering = false, undelivered: AssignmentUndeliveredError | undefined;
|
|
962
1565
|
try {
|
|
963
1566
|
const t = this.d.board.get(stale.id);
|
|
964
1567
|
if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
|
|
1568
|
+
const planned = this.plannerPullRecord(t)?.pull === true;
|
|
1569
|
+
const selected = planned ? this.plannerAssignment(t, true).owner : undefined;
|
|
1570
|
+
if (planned && !selected) return;
|
|
965
1571
|
this.offered.add(t.id);
|
|
966
1572
|
offering = true;
|
|
967
1573
|
const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
|
|
968
1574
|
this.releaseNotice(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
|
|
969
|
-
await this.assignOwner(ready, HUB);
|
|
1575
|
+
await this.assignOwner(ready, HUB, planned ? { candidates: [selected!], namedOwner: selected!, planner: true } : {});
|
|
970
1576
|
} catch (e) {
|
|
971
|
-
|
|
1577
|
+
// A saved move whose delivery failed is not "could not be assigned" (#297); the console notice was printed
|
|
1578
|
+
// where the error was thrown. The offer stays consumed by the error's own evidence, without the readback.
|
|
1579
|
+
if (e instanceof AssignmentUndeliveredError) undelivered = e;
|
|
1580
|
+
else this.releaseNotice(`task ${this.publicTitle(stale)}: could not be assigned: ${e instanceof Error ? e.message : String(e)}`);
|
|
972
1581
|
} finally {
|
|
973
|
-
if (offering) {
|
|
1582
|
+
if (offering && !undelivered) {
|
|
974
1583
|
try {
|
|
975
1584
|
if (!this.d.board.get(stale.id)?.owner) this.offered.delete(stale.id);
|
|
976
1585
|
} catch (e) {
|
|
@@ -985,12 +1594,84 @@ export class Tasks {
|
|
|
985
1594
|
if (by !== USER && task[role] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
|
|
986
1595
|
}
|
|
987
1596
|
|
|
1597
|
+
/** Artifact links and claimed local gates stay on the board; no outside text is accepted. */
|
|
1598
|
+
recordLifecycle(by: PeerId, id: unknown, input: unknown): Task {
|
|
1599
|
+
const task = this.need(id, false, true);
|
|
1600
|
+
if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
|
|
1601
|
+
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
1602
|
+
const next = sdlcReceipt(input, task.sdlc, config);
|
|
1603
|
+
const definition = stageDefinition(config, next.stage);
|
|
1604
|
+
if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
|
|
1605
|
+
const actor = definition?.actor;
|
|
1606
|
+
const acts = actor === "owner" ? by === task.owner : actor === "reviewer" ? by === task.reviewer : actor === "planner" ? this.d.roles?.[by]?.includes("planner") : actor === "owner/planner" && (by === task.owner || this.d.roles?.[by]?.includes("planner"));
|
|
1607
|
+
if (!acts) throw new PreEffectToolRefusal(`lifecycle stage requires its ${actor ?? "unknown"} actor or the person`);
|
|
1608
|
+
}
|
|
1609
|
+
if (input && typeof input === "object" && "phase" in input) {
|
|
1610
|
+
this.mine(task, by, "reviewer", true);
|
|
1611
|
+
if (next.phase && !this.isPii(task) && !this.nameable(JSON.stringify(next.phase))) throw new PreEffectToolRefusal("phase metadata matches a PII pattern; use private work");
|
|
1612
|
+
if (next.phase) validatePhaseLinks(task, next.phase, id => this.d.board.get(id), t => this.isPii(t));
|
|
1613
|
+
}
|
|
1614
|
+
if (next.reviewedHead !== task.sdlc?.reviewedHead) this.mine(task, by, "reviewer", true);
|
|
1615
|
+
return this.d.board.update(task.id, by, "lifecycle", { sdlc: next });
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
async deliveryQueue(repo: string) {
|
|
1619
|
+
const before = workflowHash(this.d.board.list().map(t => ({ id: t.id, state: t.state, after: t.deps, sdlc: t.sdlc, history: t.history.length })));
|
|
1620
|
+
const links = this.d.board.list().map(t => ({ id: t.id, after: t.deps ?? [], private: this.isPii(t), receipt: t.sdlc, phase: this.isPii(t) ? undefined : this.workflowView(t) }));
|
|
1621
|
+
const report = await readMergeQueue(repo, this.d.sdlc?.config() ?? sdlcConfig(undefined), links, this.d.sdlc?.gh);
|
|
1622
|
+
// Policy can change across remote awaits without any board write. Never return its stale public projection.
|
|
1623
|
+
const currentPrivacy = this.d.board.list().map(task => ({ id: task.id, private: this.isPii(task) }));
|
|
1624
|
+
if (workflowHash(links.map(({ id, private: hidden }) => ({ id, private: hidden }))) !== workflowHash(currentPrivacy)) {
|
|
1625
|
+
return { ...report, complete: false, rows: [], order: [], unordered: [], conflicts: [] };
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
const after = workflowHash(this.d.board.list().map(t => ({ id: t.id, state: t.state, after: t.deps, sdlc: t.sdlc, history: t.history.length })));
|
|
1629
|
+
if (before !== after) { report.complete = false; report.order = []; if (report.rows.length) report.unordered = report.rows.map(row => row.pr); for (const row of report.rows) { row.ready = false; row.blockers.push("board changed during delivery readback"); } }
|
|
1630
|
+
return report;
|
|
1631
|
+
}
|
|
1632
|
+
async releaseReadiness(repo: string, liveIssues: number[]) {
|
|
1633
|
+
const report = await readReleaseReadiness(this.d.cwd, repo, liveIssues, { gh: this.d.sdlc?.gh });
|
|
1634
|
+
if (report.documents) report.documents.documents = report.documents.documents.map(d => {
|
|
1635
|
+
if (d.path && (!this.nameable(d.path) || matchesPolicy(this.d.cwd, d.path, this.d.piiPaths ?? [], true))) { const { path: _privatePath, ...metadata } = d; return metadata; }
|
|
1636
|
+
return d;
|
|
1637
|
+
});
|
|
1638
|
+
return report;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
private workflowView(task: Task) {
|
|
1642
|
+
return phaseView(this.d.sdlc?.config() ?? sdlcConfig(undefined), task, id => this.d.board.get(id), t => this.isPii(t));
|
|
1643
|
+
}
|
|
1644
|
+
workflowExport(id: unknown): Record<string, unknown> {
|
|
1645
|
+
const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
1646
|
+
const state = { id: task.id, state: task.state, stage: task.sdlc?.stage ?? config.stages[0] ?? "spec", ...(this.isPii(task) ? { private: true } : { phase: this.workflowView(task) ?? null }) };
|
|
1647
|
+
return { schema: 1, workflow: config.workflow ?? "none", state, stateHash: workflowHash(state), sourceHash: workflowHash(config) };
|
|
1648
|
+
}
|
|
1649
|
+
async lifecycleStatus(id: unknown, refresh = true): Promise<SdlcStatus> {
|
|
1650
|
+
const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
1651
|
+
const evidence = task.sdlc && !this.isPii(task) && refresh && config.mode !== "off" ? await readSdlcPr(task.sdlc, this.d.sdlc?.gh) : undefined;
|
|
1652
|
+
return sdlcStatus(config, task.sdlc, evidence, this.workflowView(task));
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string): Promise<void> {
|
|
1656
|
+
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
1657
|
+
if (config.mode === "off") return;
|
|
1658
|
+
const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
|
|
1659
|
+
const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, this.d.sdlc?.gh) : undefined;
|
|
1660
|
+
const current = this.need(task.id, false, true);
|
|
1661
|
+
if (config.mode === "enforce" && (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || current.state !== task.state)) throw new PreEffectToolRefusal(`task #${task.id}: changed during lifecycle readback; retry on current work`);
|
|
1662
|
+
const status = sdlcStatus(config, receipt, evidence, this.workflowView(current));
|
|
1663
|
+
const blockers = transition === "review" ? [...new Set([...status.blockers.filter(blocker => ["lifecycle stage definition unknown", "issue identity missing", "PR identity missing", "current PR evidence unknown", "PR is not open", "local gate on current head missing or failed"].includes(blocker)), ...(status.entry ?? []), ...(status.exit ?? [])])] : status.blockers;
|
|
1664
|
+
if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
|
|
1665
|
+
if (config.mode === "advise" && blockers.length) this.d.notify(`task ${this.publicTitle(task)}: SDLC ${transition} advice: ${this.isPii(task) ? "private lifecycle evidence unknown" : blockers.join("; ")}`);
|
|
1666
|
+
}
|
|
1667
|
+
|
|
988
1668
|
private need(id: unknown, open = false, preEffect = false): Task {
|
|
989
1669
|
// Callers are models (#70): an id is a whole number or a digit string, never an array that Number() would accept.
|
|
990
1670
|
const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
|
|
991
1671
|
if (!Number.isInteger(n)) throw new (preEffect ? PreEffectToolRefusal : Error)(`id must be a task number, not ${(typeof id === "number" ? String(id) : JSON.stringify(id ?? null)).slice(0, 60)}`);
|
|
992
1672
|
const task = this.d.board.get(n);
|
|
993
1673
|
if (!task) throw new (preEffect ? PreEffectToolRefusal : Error)(`no task #${n}`);
|
|
1674
|
+
if (task.state === "backlog") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${n} is backlog: pull it into To Do first`);
|
|
994
1675
|
// Handing a task to someone else only makes sense while there is work left on it.
|
|
995
1676
|
if (open && !OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: it can no longer change hands`);
|
|
996
1677
|
return task;
|
|
@@ -1006,10 +1687,10 @@ export class Tasks {
|
|
|
1006
1687
|
this.ready(task, true);
|
|
1007
1688
|
if (task.state !== "proposed" && task.state !== "changes_requested" && task.state !== "in_progress") throw new PreEffectToolRefusal(`task #${task.id} is ${task.state}: cannot move to in_progress`);
|
|
1008
1689
|
// Models send null or {} for an optional field they leave empty: neither replaces a plan.
|
|
1009
|
-
const given = plan == null ? undefined : cleanPlan(plan);
|
|
1690
|
+
const given = plan == null ? undefined : cleanPlan(plan, (task.profile ?? "dev") === (this.d.profile ?? "dev") ? this.d.profileData ?? task.profile ?? "dev" : task.profile ?? "dev");
|
|
1010
1691
|
const cleaned = given && Object.keys(given).length ? given : undefined;
|
|
1011
1692
|
// A task's signals are fixed when it is proposed: text that matches a PII pattern cannot be let in afterwards.
|
|
1012
|
-
if (cleaned && !this.isPii(task) && this.isPii({ signals:
|
|
1693
|
+
if (cleaned && !this.isPii(task) && this.isPii({ signals: this.signalsFor({ title: "", detail: planText(cleaned), refs: {}, plan: cleaned }) })) {
|
|
1013
1694
|
throw new PreEffectToolRefusal("this plan matches a PII pattern and is not kept: other owners would see it");
|
|
1014
1695
|
}
|
|
1015
1696
|
const before = new Set(this.overlapHits(task).map((h) => h.task.id));
|
|
@@ -1072,9 +1753,11 @@ export class Tasks {
|
|
|
1072
1753
|
const task = this.need(id, true, true);
|
|
1073
1754
|
this.mine(task, by, "owner", true);
|
|
1074
1755
|
// The owner it is declined for goes on the entry, also when the console declines for it: `excluded()` keeps it out (#207).
|
|
1075
|
-
const
|
|
1756
|
+
const planned = this.plannerPullRecord(task)?.pull === true;
|
|
1757
|
+
const back = this.d.board.update(task.id, by, "declined", { ...(["in_progress", "changes_requested"].includes(task.state) ? { state: "proposed" as const } : {}), ...(planned ? { reserved: null } : {}) }, reason, task.owner ? { from: task.owner } : {});
|
|
1758
|
+
if (planned) this.offered.delete(task.id);
|
|
1076
1759
|
this.d.notify(`task ${this.publicTitle(back)} declined by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
|
|
1077
|
-
return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true });
|
|
1760
|
+
return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(planned ? { planner: true } : {}) });
|
|
1078
1761
|
}
|
|
1079
1762
|
|
|
1080
1763
|
/** Tasks whose check is queued or running, and the owner it was started for. */
|
|
@@ -1102,6 +1785,7 @@ export class Tasks {
|
|
|
1102
1785
|
// The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
|
|
1103
1786
|
const withheld = await this.withholds("summary", summary, valid());
|
|
1104
1787
|
let task = valid();
|
|
1788
|
+
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "review"); task = valid(); }
|
|
1105
1789
|
// A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
|
|
1106
1790
|
const cohort = this.cohorts.of(task.id);
|
|
1107
1791
|
const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
|
|
@@ -1131,7 +1815,7 @@ export class Tasks {
|
|
|
1131
1815
|
else if (evidence) this.doneNotes.set(task.id, `Overlapping work finished while you worked (no turn-free integration step ran):\n${evidence}`);
|
|
1132
1816
|
}
|
|
1133
1817
|
if (task.state === "proposed" || task.state === "changes_requested") task = this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE); // done without a separate accept
|
|
1134
|
-
const command = this.d.runCheck ? this.d.check?.(task.class) : undefined;
|
|
1818
|
+
const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
|
|
1135
1819
|
if (!command) return this.complete(task, by, summary, refs, "", withheld);
|
|
1136
1820
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
1137
1821
|
this.checking.set(task.id, task.owner);
|
|
@@ -1175,7 +1859,7 @@ export class Tasks {
|
|
|
1175
1859
|
const at = [...u.history].reverse().find((h) => h.event === "done");
|
|
1176
1860
|
if (u.owner === t.owner || !at || at.at < handedAt(t)) return [];
|
|
1177
1861
|
const theirs = this.places(u);
|
|
1178
|
-
const paths = mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
|
|
1862
|
+
const paths = t.profile && u.profile && (t.plan?.sections?.length && u.plan?.sections?.length) ? [] : mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
|
|
1179
1863
|
const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
|
|
1180
1864
|
return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note, at.withheld)] : [];
|
|
1181
1865
|
});
|
|
@@ -1259,12 +1943,19 @@ export class Tasks {
|
|
|
1259
1943
|
}
|
|
1260
1944
|
|
|
1261
1945
|
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false): Promise<Task> {
|
|
1262
|
-
|
|
1263
|
-
|
|
1946
|
+
let reviewer = task.reviewer;
|
|
1947
|
+
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") {
|
|
1948
|
+
await this.lifecycleGate(task, reviewer ? "review" : "approval");
|
|
1949
|
+
task = this.need(task.id, false, true);
|
|
1950
|
+
this.mine(task, by, "owner", true);
|
|
1951
|
+
reviewer = task.reviewer;
|
|
1952
|
+
}
|
|
1953
|
+
const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary, { withheld: withheld || undefined });
|
|
1264
1954
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
1265
1955
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
|
|
1266
1956
|
this.tellCompleted(next, summary, withheld);
|
|
1267
1957
|
if (!reviewer) {
|
|
1958
|
+
this.settleMemory(next);
|
|
1268
1959
|
if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true); // approved without a review is a success too
|
|
1269
1960
|
this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
|
|
1270
1961
|
await this.releaseDependents(next);
|
|
@@ -1339,11 +2030,15 @@ export class Tasks {
|
|
|
1339
2030
|
`- List what is unmet in hub_review's unmet, one item each.`,
|
|
1340
2031
|
].join("\n");
|
|
1341
2032
|
const summary = last?.note ? this.screen(task, last.note, "summary", reviewer, last.withheld) : "(no summary)";
|
|
1342
|
-
const
|
|
2033
|
+
const guidance = this.roleGuidance(task, reviewer, true);
|
|
2034
|
+
let projection = "";
|
|
2035
|
+
try { const text = this.d.projectedReview?.(task); if (text) projection = this.screen(task, sanitize(text), "document projection", reviewer); }
|
|
2036
|
+
catch { projection = "(document projection unavailable)"; }
|
|
2037
|
+
const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
|
|
1343
2038
|
this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, task: String(task.id) }, ...(this.isPii(task) ? { private: true } : {}) }));
|
|
1344
2039
|
}
|
|
1345
2040
|
|
|
1346
|
-
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown): Promise<Task> {
|
|
2041
|
+
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown): Promise<Task> {
|
|
1347
2042
|
const valid = (): Task => {
|
|
1348
2043
|
const t = this.need(id, false, true);
|
|
1349
2044
|
this.mine(t, by, "reviewer", true);
|
|
@@ -1355,11 +2050,17 @@ export class Tasks {
|
|
|
1355
2050
|
if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
|
|
1356
2051
|
// The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
|
|
1357
2052
|
const withheld = await this.withholds("review note", note, valid());
|
|
1358
|
-
|
|
2053
|
+
let task = valid();
|
|
2054
|
+
let reviewedHead: string | undefined;
|
|
2055
|
+
if (head !== undefined) {
|
|
2056
|
+
const checked = sdlcReceipt({ reviewedHead: head }, task.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined));
|
|
2057
|
+
reviewedHead = checked.reviewedHead;
|
|
2058
|
+
}
|
|
2059
|
+
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead); task = valid(); }
|
|
1359
2060
|
const pii = this.isPii(task);
|
|
1360
2061
|
const flag = { withheld: withheld || undefined };
|
|
1361
2062
|
if (verdict === "approved") {
|
|
1362
|
-
const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0 }, note, flag);
|
|
2063
|
+
const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0, ...(reviewedHead && task.sdlc ? { sdlc: { ...task.sdlc, reviewedHead, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), "merge") } } : {}) }, note, flag);
|
|
1363
2064
|
if (next.owner) {
|
|
1364
2065
|
this.d.board.recordOutcome(next.owner, next.class, true);
|
|
1365
2066
|
this.d.board.recordReview({ implementer: next.owner, reviewer: by, class: next.class, kind: "approved", task: next.id });
|
|
@@ -1369,6 +2070,7 @@ export class Tasks {
|
|
|
1369
2070
|
}
|
|
1370
2071
|
}
|
|
1371
2072
|
this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`, withheld);
|
|
2073
|
+
this.settleMemory(next);
|
|
1372
2074
|
this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
|
|
1373
2075
|
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
|
|
1374
2076
|
await this.releaseDependents(next);
|
|
@@ -1380,7 +2082,18 @@ export class Tasks {
|
|
|
1380
2082
|
this.contradict(rejected);
|
|
1381
2083
|
this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
|
|
1382
2084
|
if (rejected.rejections >= ESCALATE_AFTER) {
|
|
1383
|
-
|
|
2085
|
+
let moved: Task;
|
|
2086
|
+
try {
|
|
2087
|
+
moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
|
|
2088
|
+
} catch (e) {
|
|
2089
|
+
// The verdict is saved. If the escalation's move was saved too, only its delivery is unconfirmed, and the
|
|
2090
|
+
// error carries the saved task (#297); the console notice was printed where the error was thrown. The
|
|
2091
|
+
// reviewer gets the saved task back, never a failed tool call (#276). Anything else (the task closed
|
|
2092
|
+
// meanwhile (#254), a failed board write, a superseded move) still throws. assignOwner keeps throwing for
|
|
2093
|
+
// the ready offer's retry (#231).
|
|
2094
|
+
if (!(e instanceof AssignmentUndeliveredError)) throw e;
|
|
2095
|
+
return e.task;
|
|
2096
|
+
}
|
|
1384
2097
|
if (moved.owner !== rejected.owner) return moved;
|
|
1385
2098
|
// Nobody to escalate to: the owner still has to hear the verdict and the note.
|
|
1386
2099
|
this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${moved.owner ?? "its owner"} changes were requested again`, () =>
|
|
@@ -1404,24 +2117,64 @@ export class Tasks {
|
|
|
1404
2117
|
if (from && by !== HUB) this.d.board.recordOutcome(from, task.class, false);
|
|
1405
2118
|
// Only a reviewer that asked for changes on this work saw it fail: not an escalation of unreviewed work (a Pi failure).
|
|
1406
2119
|
if (from && task.reviewer && task.reviewer !== USER && this.requestedChanges(task).has(task.reviewer)) this.d.board.recordReview({ implementer: from, reviewer: task.reviewer, class: task.class, kind: "escalated", task: task.id });
|
|
1407
|
-
|
|
2120
|
+
// The guarded tail of a landed move: the console notice, the decision note and the old owner's stop message,
|
|
2121
|
+
// each behind its own releaseNotice. It runs on the success path and on a saved move whose delivery failed.
|
|
2122
|
+
const tail = (moved: Task) => {
|
|
2123
|
+
this.releaseNotice(`task ${this.publicTitle(moved)} escalated from ${from} to ${moved.owner} (${why})`);
|
|
2124
|
+
this.note(moved, by, "decision", `Task #${moved.id} escalated from ${from} to ${moved.owner}: ${why}`);
|
|
2125
|
+
if (from) this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${from} it moved to ${moved.owner}`, () =>
|
|
2126
|
+
this.tell({ ...moved, owner: from }, `Task #${moved.id} moved to ${moved.owner} (${why}). Stop working on it.`, this.isPii(moved)));
|
|
2127
|
+
};
|
|
2128
|
+
let next: Task;
|
|
2129
|
+
try {
|
|
2130
|
+
next = await this.assignOwner(task, by, { candidates: list, event: "escalated", reason, note: `${why}; from ${from ?? "none"}`, context: why });
|
|
2131
|
+
} catch (e) {
|
|
2132
|
+
// A saved move whose delivery failed arrives as AssignmentUndeliveredError carrying the saved task (#297);
|
|
2133
|
+
// the tail still runs on it (its parts are guarded), and the caller still gets the throw. Anything before
|
|
2134
|
+
// the write gets neither.
|
|
2135
|
+
if (e instanceof AssignmentUndeliveredError) tail(e.task);
|
|
2136
|
+
throw e;
|
|
2137
|
+
}
|
|
1408
2138
|
if (next.owner && next.owner !== from) {
|
|
1409
2139
|
// The move is on the board (#243): review() reaches here after its changes_requested write.
|
|
1410
|
-
|
|
1411
|
-
this.note(next, by, "decision", `Task #${next.id} escalated from ${from} to ${next.owner}: ${why}`);
|
|
1412
|
-
if (from) this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${from} it moved to ${next.owner}`, () =>
|
|
1413
|
-
this.tell({ ...next, owner: from }, `Task #${next.id} moved to ${next.owner} (${why}). Stop working on it.`, this.isPii(next)));
|
|
2140
|
+
tail(next);
|
|
1414
2141
|
} else this.releaseNotice(`task ${this.publicTitle(task)}: escalation found nobody in [${list.join(", ")}]; it stays with ${from ?? "nobody"}`);
|
|
1415
2142
|
return this.d.board.get(next.id)!;
|
|
1416
2143
|
}
|
|
1417
2144
|
|
|
1418
2145
|
/** Caller authority is checked by the daemon; keep the real actor in task history. */
|
|
1419
|
-
async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false): Promise<Task> {
|
|
1420
|
-
|
|
2146
|
+
async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner"): Promise<Task> {
|
|
2147
|
+
if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or reviewer");
|
|
2148
|
+
if (role === "reviewer" && by !== USER) {
|
|
2149
|
+
const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
2150
|
+
if (conductors.length !== 1 || conductors[0] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the configured conductor may name a reviewer");
|
|
2151
|
+
}
|
|
2152
|
+
const task = this.need(id, role === "owner", preEffect);
|
|
2153
|
+
if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster; add it to the roster before assigning work`);
|
|
2154
|
+
if (role === "reviewer") {
|
|
2155
|
+
if (!OPEN.includes(task.state) && task.state !== "in_review") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its reviewer can no longer change`);
|
|
2156
|
+
this.ready(task, preEffect);
|
|
2157
|
+
const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
|
|
2158
|
+
if (a.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot name ${peer} as reviewer: ${a.trace.filter(line => line.includes("reviewer") || line.includes("pii")).join("; ")}`);
|
|
2159
|
+
const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, `named reviewer ${peer}`, { reason: "manual" });
|
|
2160
|
+
if (next.state === "in_review" && task.reviewer && task.reviewer !== peer) {
|
|
2161
|
+
const previous = task.reviewer;
|
|
2162
|
+
this.releaseNotice(`task ${this.publicTitle(next)}: could not notify previous reviewer ${previous} that its review request is withdrawn`, () => {
|
|
2163
|
+
for (const env of this.d.bus.snapshot(false).queues[previous] ?? []) {
|
|
2164
|
+
if (env.from === HUB && env.kind === "review" && env.refs?.task === String(task.id)) this.d.bus.withdraw(env.id);
|
|
2165
|
+
}
|
|
2166
|
+
const body = `Review request for task #${task.id} ${this.publicTitle(next)} is withdrawn: its reviewer is now ${peer}. Do not review this task; a late verdict from the previous reviewer is refused.`;
|
|
2167
|
+
if (previous === USER) this.d.notify(body);
|
|
2168
|
+
else this.d.bus.publish(newEnvelope(HUB, body, { to: [previous], kind: "status", priority: "important", refs: { task: String(task.id) }, ...(this.isPii(next) ? { private: true } : {}) }));
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
if (next.state === "in_review" && peer !== USER) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${peer}`, () => this.sendReview(next, peer));
|
|
2172
|
+
return next;
|
|
2173
|
+
}
|
|
1421
2174
|
// Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
|
|
1422
|
-
if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], event: "reassigned", reason: "manual", override: by === USER });
|
|
2175
|
+
if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], namedOwner: peer, event: "reassigned", reason: "manual", override: by === USER });
|
|
1423
2176
|
const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" });
|
|
1424
|
-
this.
|
|
2177
|
+
this.releaseNotice(`task ${this.publicTitle(next)} reserved for ${peer} by ${by}; it is offered to ${peer} first once what it waits for is approved`);
|
|
1425
2178
|
return next;
|
|
1426
2179
|
}
|
|
1427
2180
|
|
|
@@ -1430,28 +2183,70 @@ export class Tasks {
|
|
|
1430
2183
|
* the usual constraints. Tasks it was reviewing get another reviewer. Moves are reported, never taken back automatically.
|
|
1431
2184
|
*/
|
|
1432
2185
|
async reassignForPause(peer: PeerId, context: string | undefined, urgentOnly = false): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
1433
|
-
|
|
2186
|
+
return this.reassignWork(peer, context, urgentOnly, false);
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
/** Read-only preview using the same assignment constraints as the roster relay. */
|
|
2190
|
+
rosterPreview(peer: PeerId): { id: number; title: string; role: "owner" | "reviewer"; to: PeerId | null }[] {
|
|
1434
2191
|
const routing = this.d.routing();
|
|
1435
|
-
|
|
2192
|
+
return this.d.board.list().filter(task => task.state !== "approved" && (task.owner === peer || task.reviewer === peer)).map(task => {
|
|
2193
|
+
const owner = task.owner === peer;
|
|
2194
|
+
const weights = this.weights(task.class);
|
|
2195
|
+
const pii = this.isPii(task);
|
|
2196
|
+
const candidates = owner ? [pii ? LOCAL : PI, pii ? undefined : LOCAL, ...(routing.classes[task.class]?.peers ?? []).filter(p => p !== LOCAL && p !== PI)].filter((p): p is PeerId => !!p) : [];
|
|
2197
|
+
const a = assign(task, this.states(), routing, { ...weights, offRoster: [...(weights.offRoster ?? []), peer], exclude: [peer], excludeOwners: owner && this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], candidates, ...(owner ? { waitsFor: this.waitsFor(task) } : task.owner ? { notReviewer: task.owner } : {}) });
|
|
2198
|
+
return { id: task.id, title: this.publicTitle(task), role: owner ? "owner" : "reviewer", to: (owner ? a.owner : a.reviewer) ?? null };
|
|
2199
|
+
});
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
/** A roster removal offers work through the quota relay's constraints, preserving assignments nobody can take. */
|
|
2203
|
+
async reassignForRoster(peer: PeerId): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
2204
|
+
return this.reassignWork(peer, undefined, false, true);
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
private async reassignWork(peer: PeerId, context: string | undefined, urgentOnly: boolean, roster: boolean): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
2208
|
+
const moved: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[] = [];
|
|
2209
|
+
const routing = this.d.routing(), why = roster ? `roster removal of ${peer}` : `budget pause of ${peer}`;
|
|
2210
|
+
// Budget context is screened exactly as before; a roster removal supplies no agent-written context.
|
|
1436
2211
|
const moves = (t: Task) => (!urgentOnly || t.signals.includes("urgent")) && t.owner === peer && OPEN.includes(t.state);
|
|
1437
2212
|
const contextWithheld = context && this.d.board.list().some((t) => moves(t) && !this.isPii(t)) ? await this.withholds("handoff", context) : false;
|
|
1438
2213
|
for (const task of this.d.board.list()) {
|
|
1439
|
-
// Waiting out a window that resets soon (issue #36): only urgent work moves.
|
|
1440
2214
|
if (urgentOnly && !task.signals.includes("urgent")) continue;
|
|
1441
|
-
if (task.owner === peer && OPEN.includes(task.state)) {
|
|
2215
|
+
if (task.owner === peer && (OPEN.includes(task.state) || roster && task.state === "in_review")) {
|
|
1442
2216
|
const pii = this.isPii(task);
|
|
1443
2217
|
const candidates = [pii ? LOCAL : PI, pii ? undefined : LOCAL, ...(routing.classes[task.class]?.peers ?? []).filter((p) => p !== LOCAL && p !== PI)].filter((p): p is PeerId => !!p);
|
|
1444
|
-
|
|
1445
|
-
|
|
2218
|
+
if (this.plannerPullRecord(task)?.pull && !assign(task, this.states(), routing, { candidates, exclude: [...this.excluded(task), peer], excludeOwners: this.plannerFull(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) }).owner) continue;
|
|
2219
|
+
// Budget keeps its original release/unassign behaviour for ordinary work. Roster changes the state in
|
|
2220
|
+
// assignOwner's same owner write only after routing has found somebody to take it.
|
|
2221
|
+
const back = !roster && task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
|
|
2222
|
+
let next: Task;
|
|
2223
|
+
try {
|
|
2224
|
+
next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", reason: roster ? "manual" : "budget", note: why, ...(roster ? { roster: true } : { clearOnFail: true }), ...(context ? { context, contextWithheld } : {}) });
|
|
2225
|
+
} catch (e) {
|
|
2226
|
+
// The move is saved and its central #297 notice already spoke; relay the
|
|
2227
|
+
// remaining tasks and run the roster tail on the saved row as on success.
|
|
2228
|
+
if (!(e instanceof AssignmentUndeliveredError)) throw e;
|
|
2229
|
+
next = e.task;
|
|
2230
|
+
}
|
|
2231
|
+
if (roster) {
|
|
2232
|
+
if (!next.owner || next.owner === peer) continue;
|
|
2233
|
+
this.releaseNotice(`task ${this.publicTitle(next)} moved from ${peer} to ${next.owner} (off roster)`);
|
|
2234
|
+
if (next.state === "in_review" && next.reviewer && next.reviewer !== task.reviewer && next.reviewer !== USER) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${next.reviewer}`, () => this.sendReview(next, next.reviewer!, `(${peer} owned this and is off roster.)`));
|
|
2235
|
+
}
|
|
1446
2236
|
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
1447
2237
|
} else if (task.reviewer === peer && task.state !== "approved") {
|
|
1448
|
-
// No owner candidates: only the reviewer is wanted, and it must be neither the paused peer nor the task's owner.
|
|
1449
2238
|
const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
|
|
1450
2239
|
const reviewer = a.reviewer && a.reviewer !== peer && a.reviewer !== task.owner ? a.reviewer : null;
|
|
1451
|
-
|
|
2240
|
+
if (roster && !reviewer) {
|
|
2241
|
+
this.releaseNotice(`task ${this.publicTitle(task)}: its reviewer ${peer} is off roster and nobody else can review; the reviewer stays ${peer}; use ahub review ${task.id}`);
|
|
2242
|
+
continue;
|
|
2243
|
+
}
|
|
2244
|
+
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, why);
|
|
1452
2245
|
moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
|
|
1453
|
-
if (next.state === "in_review" && reviewer && reviewer !== USER)
|
|
1454
|
-
|
|
2246
|
+
if (next.state === "in_review" && reviewer && reviewer !== USER) {
|
|
2247
|
+
if (roster) this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, `(${peer} was reviewing this and is off roster.)`));
|
|
2248
|
+
else this.sendReview(next, reviewer, `(${peer} was reviewing this and is paused for quota.)`);
|
|
2249
|
+
} else if (next.state === "in_review" && !reviewer) this.d.notify(`task ${this.publicTitle(next)}: its reviewer ${peer} is paused and nobody else can review; use ahub review ${next.id}`);
|
|
1455
2250
|
}
|
|
1456
2251
|
}
|
|
1457
2252
|
return moved;
|
|
@@ -1468,7 +2263,7 @@ export class Tasks {
|
|
|
1468
2263
|
return { ...(policy?.route ? { route: policy.route } : {}), ...(policy?.fixed_model ? { fixedModel: policy.fixed_model } : {}), pii, task: String(about.id) };
|
|
1469
2264
|
}
|
|
1470
2265
|
|
|
1471
|
-
async remember(by: PeerId, input: { text?: string; title?: string; kind?: string; task?: unknown }): Promise<string> {
|
|
2266
|
+
async remember(by: PeerId, input: { text?: string; title?: string; kind?: string; task?: unknown; horizon?: unknown; scope?: unknown }): Promise<string> {
|
|
1472
2267
|
const text = String(input.text ?? "").trim();
|
|
1473
2268
|
if (!text) throw new Error("text is required");
|
|
1474
2269
|
const task = input.task !== undefined ? this.d.board.get(Number(input.task)) : undefined;
|
|
@@ -1476,24 +2271,132 @@ export class Tasks {
|
|
|
1476
2271
|
// Shared with every other peer as well, so text that matches a PII pattern is refused like a note about a PII task.
|
|
1477
2272
|
const title = String(input.title ?? "").trim();
|
|
1478
2273
|
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: claude-mem processes what it stores with a cloud model");
|
|
1479
|
-
if (
|
|
2274
|
+
if (input.horizon !== undefined && input.horizon !== "short" && input.horizon !== "long") throw new Error('horizon is "short" or "long"');
|
|
2275
|
+
if (input.scope !== undefined && input.scope !== "project" && input.scope !== "shared") throw new Error('scope is "project" or "shared"');
|
|
2276
|
+
if (!this.d.memory && !this.d.store) return "memory is disabled; nothing saved";
|
|
2277
|
+
// Long-term notes leave the machine (#258), and claude-mem's observer is a cloud model: a secret goes to neither.
|
|
2278
|
+
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");
|
|
2279
|
+
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");
|
|
1480
2280
|
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: claude-mem processes what it stores with a cloud model");
|
|
1481
|
-
const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) : "finding";
|
|
1482
|
-
const
|
|
1483
|
-
if (
|
|
2281
|
+
const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) as Kind : "finding";
|
|
2282
|
+
const native = this.saveNative(by, kind, text, title, task, input.horizon === "long" || input.horizon === "short" ? input.horizon : undefined, input.scope === "shared" ? "shared" : "project");
|
|
2283
|
+
if (task && this.d.memory) this.markMemoryReference(task, by);
|
|
2284
|
+
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;
|
|
2285
|
+
if (!res && !native) return this.d.memory ? "memory worker unavailable; nothing saved" : "memory store unavailable; nothing saved";
|
|
1484
2286
|
const line = noteLine(by, kind, title ? `${title}: ${text}` : text);
|
|
1485
2287
|
this.d.notify(line);
|
|
1486
2288
|
this.d.share?.(by, line);
|
|
1487
|
-
return
|
|
2289
|
+
return `saved to shared memory${native ? ` (hub memory ${native.id.slice(0, 8)}, ${native.tier}-term${this.d.memory && !res ? "; claude-mem unavailable" : ""})` : ""}; the other agents get it with their next message`;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
/** The task's key in the hub's store: unique across `ahub reset --all`, which restarts the ids. */
|
|
2293
|
+
private memoryKey(task: Task): string { return taskKey(this.d.store!.project, task); }
|
|
2294
|
+
|
|
2295
|
+
/**
|
|
2296
|
+
* The long-term task a board task may join (#258). A peer names one of this project's; a person may name any (a link
|
|
2297
|
+
* across projects is a person's). Refused before any board write.
|
|
2298
|
+
*/
|
|
2299
|
+
private goalFor(by: PeerId, ref: unknown): GoalView {
|
|
2300
|
+
if (!this.d.store) throw new PreEffectToolRefusal("goal: hub memory is off for this project");
|
|
2301
|
+
const g = typeof ref === "string" && ref.trim() ? this.d.store.store.goalOf(ref.trim().replace(/^goal:/, "")) : undefined;
|
|
2302
|
+
if (!g) throw new PreEffectToolRefusal(`goal: no long-term task ${JSON.stringify(ref).slice(0, 40)} (ahub goal list shows them)`);
|
|
2303
|
+
if (by !== USER && g.project !== this.d.store.project) throw new PreEffectToolRefusal("goal: that long-term task belongs to another project; linking across projects is a person's");
|
|
2304
|
+
if (g.state === "done" || g.state === "dropped") throw new PreEffectToolRefusal(`goal: that long-term task is ${g.state}`);
|
|
2305
|
+
return g;
|
|
2306
|
+
}
|
|
2307
|
+
/** Fence deletion before external memory writes; one marker survives store absence. */
|
|
2308
|
+
private markMemoryReference(task: Task, by: PeerId): void {
|
|
2309
|
+
if (!this.d.board.get(task.id)?.history.some(entry => entry.event === "memory reference started")) this.d.board.update(task.id, by, "memory reference started", {});
|
|
2310
|
+
}
|
|
2311
|
+
/** Record a long-term link, never for PII; failures remain notices. */
|
|
2312
|
+
private linkNative(task: Task, goal: GoalView, by: PeerId): string {
|
|
2313
|
+
if (this.isPii(task)) { this.releaseNotice(`task #${task.id} is not linked to a long-term task: nothing about a PII task enters hub memory`); return "not linked (PII task)"; }
|
|
2314
|
+
try { this.markMemoryReference(task, by); this.d.store!.store.goalLink(goal.id, this.memoryKey(task), by); return `part of long-term task ${goal.id.slice(0, 8)}`; }
|
|
2315
|
+
catch (e) { this.releaseNotice(`task #${task.id}: the link to long-term task ${goal.id.slice(0, 8)} was not saved (${(e as Error).message})`); return "not linked"; }
|
|
2316
|
+
}
|
|
2317
|
+
/** A person links a board task of this project to a long-term task (`ahub goal link`). */
|
|
2318
|
+
linkGoal(by: PeerId, id: unknown, ref: unknown): string {
|
|
2319
|
+
const task = this.d.board.get(Number(id));
|
|
2320
|
+
if (!task) throw new Error(`no task #${String(id).slice(0, 12)}`);
|
|
2321
|
+
const goal = this.goalFor(by, ref);
|
|
2322
|
+
return `task #${task.id}: ${this.linkNative(task, goal, by)}`;
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
/**
|
|
2326
|
+
* One note in the hub's own store (#258). Short-term while its task is open; on an approved task, or with horizon
|
|
2327
|
+
* long, decisions, contracts and fails are long-term. Fail-open: a store that cannot be written costs one notice.
|
|
2328
|
+
* The caller has screened the text for PII; never a PII task's.
|
|
2329
|
+
*/
|
|
2330
|
+
private saveNative(by: PeerId, kind: Kind, text: string, title: string, task: Task | undefined, horizon?: Tier, scope: Scope = "project"): { id: string; tier: Tier } | undefined {
|
|
2331
|
+
const st = this.d.store;
|
|
2332
|
+
if (!st || (task && this.isPii(task))) return undefined;
|
|
2333
|
+
try { // never throws: callers run after a board write
|
|
2334
|
+
// Approved, or settled in the store already: the snapshot the caller holds can predate an approval made while it awaited.
|
|
2335
|
+
const approved = !!task && (task.state === "approved" || st.store.settled(this.memoryKey(task)));
|
|
2336
|
+
const tier: Tier = horizon ?? (approved && PROMOTED_KINDS.includes(kind) ? "long" : "short");
|
|
2337
|
+
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 } } : {}) });
|
|
2338
|
+
// A short-term note on a task already approved (before hub memory existed, or late): the task is settled first, so
|
|
2339
|
+
// the note gets its own deadline and is never taken for a source waiting for promotion.
|
|
2340
|
+
if (task && approved && tier === "short" && !st.store.settled(this.memoryKey(task))) st.store.settle(this.memoryKey(task), by);
|
|
2341
|
+
if (task) this.markMemoryReference(task, by);
|
|
2342
|
+
const id = st.store.note({ op: "note", project: st.project, scope, tier, kind, peer: by, ...(title ? { title } : {}), text, ...(task ? { task: this.memoryKey(task) } : {}), about, source: by === USER ? "person" : "hub" }, horizon === "short");
|
|
2343
|
+
st.onNote?.(id);
|
|
2344
|
+
return { id, tier };
|
|
2345
|
+
} catch (e) {
|
|
2346
|
+
this.releaseNotice(`a ${kind} note was not saved to hub memory (${(e as Error).message})`);
|
|
2347
|
+
return undefined;
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
/** An approved task's decision, contract and fail notes move to long-term; its short-term entries start to expire (#258). */
|
|
2352
|
+
/**
|
|
2353
|
+
* At hub start: an approved task whose settle entry a crash or a failed write cut off is settled now, so its
|
|
2354
|
+
* decisions, contracts and fails still reach long-term memory and its findings get their deadline.
|
|
2355
|
+
*/
|
|
2356
|
+
reconcileMemory(): number {
|
|
2357
|
+
if (!this.d.store) return 0;
|
|
2358
|
+
let n = 0;
|
|
2359
|
+
for (const t of this.d.board.list("approved")) {
|
|
2360
|
+
if (this.isPii(t)) continue;
|
|
2361
|
+
try { const key = this.memoryKey(t); if (this.d.store.store.hasTask(key) && !this.d.store.store.settled(key)) { this.settleMemory(t); n++; } }
|
|
2362
|
+
catch { /* fail-open: tried again at the next start */ }
|
|
2363
|
+
}
|
|
2364
|
+
return n;
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
private settleMemory(task: Task): void {
|
|
2368
|
+
if (!this.d.store || this.isPii(task)) return;
|
|
2369
|
+
// The store screens each note as it moves to the synced log (the promotion screen the daemon gives it).
|
|
2370
|
+
try { this.d.store.store.settle(this.memoryKey(task), task.reviewer ?? task.owner ?? HUB); }
|
|
2371
|
+
catch (e) { this.releaseNotice(`task #${task.id}: hub memory could not settle its notes (${(e as Error).message})`); }
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
/**
|
|
2375
|
+
* Whether a note names, or its task touches, a path the current `pii.paths` makes private: checked before either memory
|
|
2376
|
+
* backend sees it, whatever the task's signals said when it was proposed.
|
|
2377
|
+
*/
|
|
2378
|
+
private aboutPrivatePath(title: string, text: string, task: Task | undefined): boolean {
|
|
2379
|
+
const st = this.d.store;
|
|
2380
|
+
// No denylist here: a path the graph leaves out is still checked against the privacy policy.
|
|
2381
|
+
const about = extractNodes(`${title}\n${text}`, { cwd: this.d.cwd, project: st?.project ?? this.d.project, repo: st?.repo ?? this.d.project, deny: [],
|
|
2382
|
+
paths: [...(task?.refs.paths ?? []), ...(task?.plan?.paths ?? [])], symbols: task?.plan?.symbols });
|
|
2383
|
+
return mentionsPrivatePath(`${title}\n${text}`, about, this.d.piiPaths ?? [], this.d.cwd);
|
|
1488
2384
|
}
|
|
1489
2385
|
|
|
1490
2386
|
/** Auto notes: only transitions that carry content, never for PII. */
|
|
1491
|
-
private note(task: Task, by: PeerId, kind:
|
|
1492
|
-
if (this.isPii(task) || !this.d.memory) return;
|
|
2387
|
+
private note(task: Task, by: PeerId, kind: Kind, text: string, withheld = false): void {
|
|
2388
|
+
if (this.isPii(task) || (!this.d.memory && !this.d.store)) return;
|
|
1493
2389
|
// claude-mem's observer is a cloud model: model-written text that matches a PII pattern, or that the PII screen did
|
|
1494
|
-
// not clear (#198), is not sent (#69).
|
|
2390
|
+
// not clear (#198), is not sent (#69). Long-term hub memory leaves the machine (#258): the same rule, and no secrets.
|
|
1495
2391
|
if (withheld || !this.nameable(text)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (${this.whyWithheld(text)})`);
|
|
1496
|
-
|
|
2392
|
+
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)`);
|
|
2393
|
+
const title = `agent-hub task #${task.id}: ${task.title}`.slice(0, 120);
|
|
2394
|
+
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)`);
|
|
2395
|
+
this.saveNative(by, kind, text, title, task);
|
|
2396
|
+
if (this.d.memory) {
|
|
2397
|
+
try { this.markMemoryReference(task, by); } catch { return this.releaseNotice(`task #${task.id}: memory reference could not be recorded; note was not saved`); }
|
|
2398
|
+
void this.d.memory.save({ text, title, project: this.d.project, metadata: { peer: by, task: task.id, kind } });
|
|
2399
|
+
}
|
|
1497
2400
|
}
|
|
1498
2401
|
|
|
1499
2402
|
private tell(task: Task, body: string, pii: boolean): void {
|