@staix/agent-hub 0.12.23 → 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 +98 -0
- package/README.md +8 -3
- 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 +8 -1
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +7 -1
- package/docs/agent-notes/local-worker.md +4 -2
- package/docs/agent-notes/tasks.md +4 -0
- package/docs/agent-notes/tests.md +2 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +3 -0
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +651 -14
- 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 +1 -1
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +111 -4
- package/docs/smoke.md +282 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +601 -7
- 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-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 +22 -13
- 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 +1175 -339
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp.ts +112 -13
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +11 -0
- 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 +24 -5
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +103 -17
- package/src/cli/console.ts +54 -8
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +35 -9
- package/src/cli/identity.ts +18 -7
- 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 +392 -57
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +45 -4
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +38 -5
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-runtime.ts +32 -3
- 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 +222 -16
- package/src/hub/bus.ts +48 -9
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +12 -7
- package/src/hub/config-trust.ts +4 -2
- package/src/hub/crash.ts +9 -0
- package/src/hub/daemon.ts +910 -120
- package/src/hub/delivery-journal.ts +5 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +12 -2
- package/src/hub/facts.ts +26 -7
- package/src/hub/git.ts +8 -2
- 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/listen.ts +15 -0
- package/src/hub/manager.ts +12 -5
- package/src/hub/path-policy.ts +96 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/permission-mode.ts +19 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- 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 +18 -5
- 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 +34 -15
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +89 -15
- package/src/hub/start-mode.ts +7 -5
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +892 -87
- package/src/hub/text-projection.ts +126 -0
- package/src/hub/tooling.ts +159 -0
- package/src/hub/ui.ts +16 -2
- package/src/hub/workflow.ts +71 -0
- package/src/local/sandbox.ts +6 -4
- 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 +84 -0
- package/src/memory/store.ts +750 -0
- package/src/memory/sync.ts +145 -0
- 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/role-text.d.ts +1 -0
- package/src/ui/index.html +842 -46
- 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,7 @@ 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"]);
|
|
94
122
|
|
|
95
123
|
/**
|
|
96
124
|
* Thrown when an assignment's board write is saved but a publish or notify after it failed (#297): the move is
|
|
@@ -147,15 +175,15 @@ function cleanRefs(input: unknown): TaskRefs {
|
|
|
147
175
|
}
|
|
148
176
|
|
|
149
177
|
/** The same for a plan (issue #31): only its four lists, each of short strings. */
|
|
150
|
-
function cleanPlan(input: unknown): TaskPlan {
|
|
178
|
+
function cleanPlan(input: unknown, profile: ProfileSelection = "dev"): TaskPlan {
|
|
151
179
|
const r = fields(input);
|
|
152
|
-
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));
|
|
153
181
|
}
|
|
154
182
|
|
|
155
183
|
/** A plan as one line for other owners; the whole plan is on the board. */
|
|
156
|
-
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(" | ");
|
|
157
185
|
/** What the PII screen reads of a task (#198): the text the patterns are checked against. */
|
|
158
|
-
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");
|
|
159
187
|
/** Re-screens of one task whose verdict was unknown, per hub run (#198). */
|
|
160
188
|
const RESCREEN_MAX = 10;
|
|
161
189
|
|
|
@@ -164,12 +192,23 @@ const RESCREEN_MAX = 10;
|
|
|
164
192
|
* PII handling, envelopes, briefs and memory notes are decided in one place.
|
|
165
193
|
*/
|
|
166
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>();
|
|
167
199
|
/** Turn-free cohorts (issue #107): who works without messages, and who integrates. */
|
|
168
200
|
readonly cohorts: Cohorts;
|
|
169
201
|
private readonly sweepConfig: TaskSweepConfig;
|
|
170
202
|
private sweeping = false;
|
|
171
203
|
|
|
172
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
|
+
}
|
|
173
212
|
this.sweepConfig = taskSweepConfig(d.sweep);
|
|
174
213
|
this.cohorts = new Cohorts({
|
|
175
214
|
silence: (owners) => this.turnFree() && owners.every((p) => p !== USER && p !== HUB && (this.d.capable?.(p) ?? false)),
|
|
@@ -178,6 +217,11 @@ export class Tasks {
|
|
|
178
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
|
|
179
218
|
// board keeps the text: `ahub task show <id>` is where the console user reads it, a refusal included.
|
|
180
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
|
+
}
|
|
181
225
|
// A turn that ends takes its hand-overs with it: busy later is another turn (#115). The adapter's own state, since a
|
|
182
226
|
// pause shows as `paused` while the turn goes on.
|
|
183
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);
|
|
@@ -190,7 +234,17 @@ export class Tasks {
|
|
|
190
234
|
});
|
|
191
235
|
}
|
|
192
236
|
|
|
193
|
-
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
|
+
}
|
|
194
248
|
|
|
195
249
|
/** `signals.pii_screen = "local"` where the PII constraint applies (issue #198); on without a screener, every verdict is unknown. */
|
|
196
250
|
private screenOn = (): boolean => {
|
|
@@ -203,8 +257,8 @@ export class Tasks {
|
|
|
203
257
|
return (await this.d.piiScreen?.(text).catch(() => undefined)) ?? { label: "unknown", miss: "failed", ms: 0 };
|
|
204
258
|
}
|
|
205
259
|
|
|
206
|
-
private recordScreen(task: number | undefined, item: ScreenItem, v: PiiVerdict | undefined): void {
|
|
207
|
-
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
|
|
208
262
|
? { item, label: "pii", source: "regex" }
|
|
209
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 };
|
|
210
264
|
try {
|
|
@@ -240,6 +294,7 @@ export class Tasks {
|
|
|
240
294
|
if (!finding || this.waitsFor(task).length || this.isChecking(task.id) || this.silentFor(task.id)) continue;
|
|
241
295
|
const responsible = finding.kind === "review-pending" ? task.reviewer! : task.owner!;
|
|
242
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;
|
|
243
298
|
const title = this.publicTitle(task);
|
|
244
299
|
const message = `Task ${title}: ${finding.kind}, idle escalation ${finding.step}/3.`;
|
|
245
300
|
// Only identifiers, kind and step are kept in the ladder event. A crash after this write and before publish
|
|
@@ -255,7 +310,7 @@ export class Tasks {
|
|
|
255
310
|
}
|
|
256
311
|
} else {
|
|
257
312
|
const reviewer = finding.kind === "review-pending";
|
|
258
|
-
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) });
|
|
259
314
|
const candidate = reviewer ? a.reviewer : a.owner;
|
|
260
315
|
this.passedOver(task, a);
|
|
261
316
|
this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
|
|
@@ -313,18 +368,19 @@ export class Tasks {
|
|
|
313
368
|
|
|
314
369
|
/** A task as a cloud peer may see it. */
|
|
315
370
|
publicView(task: Task, fullHistory = false): Record<string, unknown> {
|
|
316
|
-
const { title, detail, history, ...rest } = task;
|
|
317
|
-
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]" };
|
|
318
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}]`;
|
|
319
374
|
const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
|
|
320
375
|
at: h.at, by: h.by, event: h.event,
|
|
321
376
|
...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note", h.withheld) }),
|
|
322
377
|
...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
|
|
323
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) } : {}) } } : {}),
|
|
324
380
|
...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
|
|
325
381
|
...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
|
|
326
382
|
}));
|
|
327
|
-
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 };
|
|
328
384
|
}
|
|
329
385
|
|
|
330
386
|
/** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
|
|
@@ -395,7 +451,7 @@ export class Tasks {
|
|
|
395
451
|
private health() {
|
|
396
452
|
const failing = this.d.failing?.();
|
|
397
453
|
const held = this.d.held?.();
|
|
398
|
-
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 } : {}) };
|
|
399
455
|
}
|
|
400
456
|
|
|
401
457
|
/** What assignment weighs besides states and policy: quota, demotion and the review record. */
|
|
@@ -492,10 +548,371 @@ export class Tasks {
|
|
|
492
548
|
}
|
|
493
549
|
}
|
|
494
550
|
|
|
495
|
-
|
|
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`);
|
|
496
912
|
// Callers are models: a title is one line (it is part of console and hub.log lines), not a document.
|
|
497
913
|
const title = String(input.title ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
|
|
498
914
|
if (!title) throw new PreEffectToolRefusal("title is required");
|
|
915
|
+
const goal = input.goal == null || input.goal === "" ? undefined : this.goalFor(by, input.goal);
|
|
499
916
|
// Dependencies can only name tasks that exist, so the new task closes no cycle: nothing can depend on it yet.
|
|
500
917
|
// Callers are models: a missing or null `after` is no dependency, and an id is an integer or a digit string.
|
|
501
918
|
let afterIds: unknown[] = [];
|
|
@@ -509,40 +926,54 @@ export class Tasks {
|
|
|
509
926
|
if (!deps.includes(id)) deps.push(id);
|
|
510
927
|
}
|
|
511
928
|
// An owner for work that waits is reserved (issue #207): routing offers it to that peer first once it is ready.
|
|
512
|
-
const reserved = this.waitsFor({ deps }).length ? input.owner : undefined;
|
|
929
|
+
const reserved = backlog || this.waitsFor({ deps }).length ? input.owner : undefined;
|
|
513
930
|
const given = input.class === undefined || input.class === "" ? undefined : input.class;
|
|
514
|
-
|
|
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); }
|
|
515
934
|
const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
|
|
516
|
-
const plan = cleanPlan(input.plan);
|
|
935
|
+
const plan = cleanPlan(input.plan, this.d.profileData ?? profile);
|
|
517
936
|
// The plan reaches other owners, so a PII pattern in it makes the task a PII task like one in the detail would.
|
|
518
|
-
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 });
|
|
519
939
|
// Urgent work is handed over even when its paused owner's window resets soon (issue #36).
|
|
520
940
|
if (input.urgent === true && !signals.includes("urgent")) signals.push("urgent");
|
|
521
941
|
// The PII screen (issue #198) reads the same text before anything else does: until its verdict the task is on no
|
|
522
942
|
// board, in no envelope and before no other model. A pattern match is PII without a call; no verdict is PII too.
|
|
523
943
|
const screening = this.screenOn();
|
|
524
|
-
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;
|
|
525
945
|
if (screened && screened.label !== "clear") signals.push("pii");
|
|
526
|
-
let cls =
|
|
946
|
+
let cls = baseGiven;
|
|
527
947
|
let triaged = false;
|
|
528
948
|
let fenced = false; // a PII task off campus: triage is not asked
|
|
529
949
|
if (!cls && this.d.triage) {
|
|
530
950
|
// Signals first: a PII task's text may only go to a model that is reached without leaving the campus network.
|
|
531
|
-
const pii =
|
|
951
|
+
const pii = this.isPii({ signals });
|
|
532
952
|
if (!pii || (await this.d.triage.onCampus().catch(() => false))) cls = await this.d.triage.classify(text.title, text.detail).catch(() => undefined);
|
|
533
953
|
else fenced = true;
|
|
534
954
|
triaged = !!cls;
|
|
535
955
|
}
|
|
536
956
|
// A claim is work the caller will do itself: without a class and a model to name one, it is implementation (#6).
|
|
537
|
-
const defaulted = !cls && input.owner === by;
|
|
957
|
+
const defaulted = !backlog && !cls && input.owner === by;
|
|
538
958
|
if (defaulted) cls = "implement";
|
|
539
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"}`);
|
|
540
|
-
|
|
541
|
-
|
|
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?.();
|
|
542
967
|
if (screening) task = this.screenedTask(task, screened);
|
|
968
|
+
if (goal) this.linkNative(task, goal, by);
|
|
543
969
|
if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
|
|
544
970
|
if (defaulted) task = this.d.board.update(task.id, "hub", "class defaulted", {}, "class implement for a claim without one");
|
|
545
|
-
|
|
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(", ")}` : ""}`);
|
|
546
977
|
// Re-read: a dependency approved while triage was awaited looked for its dependents before this row existed.
|
|
547
978
|
const still = this.waitsFor(task);
|
|
548
979
|
if (still.length) {
|
|
@@ -550,14 +981,37 @@ export class Tasks {
|
|
|
550
981
|
return this.d.board.update(task.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
|
|
551
982
|
}
|
|
552
983
|
// Naming yourself is a claim: the work is already yours, so no offer comes back to you (paper: Agensh CLAIM, #68).
|
|
553
|
-
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);
|
|
554
1007
|
}
|
|
555
1008
|
|
|
556
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}`; }
|
|
557
1011
|
private screenedTask(task: Task, v: PiiVerdict | undefined): Task {
|
|
558
1012
|
this.recordScreen(task.id, "task", v);
|
|
559
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`);
|
|
560
|
-
const note =
|
|
1014
|
+
const note = this.screenNote(v);
|
|
561
1015
|
return this.d.board.update(task.id, HUB, "screened", {}, note);
|
|
562
1016
|
}
|
|
563
1017
|
|
|
@@ -653,6 +1107,38 @@ export class Tasks {
|
|
|
653
1107
|
/** The tasks an overlap text for `task` names: their plans are in it (issue #108). */
|
|
654
1108
|
overlapTasks = (task: Task): number[] => this.overlapHits(task).map((h) => h.task.id);
|
|
655
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
|
+
|
|
656
1142
|
/** While a gone owner's tasks move, its other tasks are about to move too: they are no one to settle with. */
|
|
657
1143
|
private releasing: PeerId | undefined;
|
|
658
1144
|
|
|
@@ -681,6 +1167,16 @@ export class Tasks {
|
|
|
681
1167
|
* for a recorded notice to this peer whose task is gone, has another owner, or left the states it was about.
|
|
682
1168
|
*/
|
|
683
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
|
+
}
|
|
684
1180
|
const c = this.conditional.get(env.id);
|
|
685
1181
|
if (!c || c.peer !== peer) return true;
|
|
686
1182
|
const t = this.d.board.get(c.task);
|
|
@@ -714,7 +1210,7 @@ export class Tasks {
|
|
|
714
1210
|
*/
|
|
715
1211
|
private places(task: Task): { paths: string[]; symbols: string[] } {
|
|
716
1212
|
const paths = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].map((p) => this.projectPath(p));
|
|
717
|
-
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}`)] };
|
|
718
1214
|
}
|
|
719
1215
|
|
|
720
1216
|
private roots?: string[];
|
|
@@ -738,7 +1234,7 @@ export class Tasks {
|
|
|
738
1234
|
return this.d.board.list().flatMap((t) => {
|
|
739
1235
|
if (t.id === task.id || !t.owner || t.owner === task.owner || t.owner === this.releasing || !OPEN.includes(t.state) || this.isPii(t)) return [];
|
|
740
1236
|
const theirs = this.places(t);
|
|
741
|
-
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)));
|
|
742
1238
|
const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
|
|
743
1239
|
return paths.length || symbols.length ? [{ task: t, paths, symbols }] : [];
|
|
744
1240
|
});
|
|
@@ -778,7 +1274,7 @@ export class Tasks {
|
|
|
778
1274
|
// Awaits run between tasks (briefs, memory): re-read, and stop for anything that changed meanwhile.
|
|
779
1275
|
const task = this.d.board.get(id);
|
|
780
1276
|
if (!task || task.owner !== peer || !OPEN.includes(task.state) || this.d.bus.stateOf(peer) !== "offline") continue;
|
|
781
|
-
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;
|
|
782
1278
|
try {
|
|
783
1279
|
const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
|
|
784
1280
|
const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", reason: "offline", note: why });
|
|
@@ -802,18 +1298,20 @@ export class Tasks {
|
|
|
802
1298
|
|
|
803
1299
|
/** Same code as assignment, without doing it. `routing`: a candidate policy to explain under (the settings preview, #269). */
|
|
804
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`] : [];
|
|
805
1302
|
if (typeof target === "number") {
|
|
806
1303
|
const task = this.d.board.get(target);
|
|
807
1304
|
if (!task) throw new Error(`no task #${target}`);
|
|
808
|
-
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) });
|
|
809
1306
|
// The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
|
|
810
|
-
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 ?? [])];
|
|
811
1308
|
}
|
|
812
1309
|
const draft = { title: target.title, detail: target.detail ?? "", refs: target.refs ?? {} };
|
|
813
|
-
const signals =
|
|
814
|
-
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];
|
|
815
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.
|
|
816
|
-
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)];
|
|
817
1315
|
}
|
|
818
1316
|
|
|
819
1317
|
/** Public read path: every generated line is screened, including profile/config-derived trace fields. */
|
|
@@ -855,9 +1353,12 @@ export class Tasks {
|
|
|
855
1353
|
}
|
|
856
1354
|
|
|
857
1355
|
/** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
|
|
858
|
-
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`);
|
|
859
1358
|
const waits = this.waitsFor(task);
|
|
860
|
-
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 } : {}) });
|
|
861
1362
|
|
|
862
1363
|
if (waits.length) {
|
|
863
1364
|
this.releaseNotice(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
|
|
@@ -865,7 +1366,9 @@ export class Tasks {
|
|
|
865
1366
|
}
|
|
866
1367
|
const passed = this.passedOver(task, a);
|
|
867
1368
|
if (!a.owner) {
|
|
868
|
-
this.releaseNotice(
|
|
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>`);
|
|
869
1372
|
// Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
|
|
870
1373
|
if (opts.clearOnFail && task.owner) {
|
|
871
1374
|
this.cohorts.leave(task.id);
|
|
@@ -877,7 +1380,7 @@ export class Tasks {
|
|
|
877
1380
|
// A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
|
|
878
1381
|
// out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
|
|
879
1382
|
const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
|
|
880
|
-
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! } : {}) });
|
|
881
1384
|
// What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
|
|
882
1385
|
// escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
|
|
883
1386
|
// yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
|
|
@@ -925,25 +1428,61 @@ export class Tasks {
|
|
|
925
1428
|
return next;
|
|
926
1429
|
}
|
|
927
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
|
+
|
|
928
1464
|
/** `contextWithheld`: the PII screen did not clear a budget hand-off (#198); an escalation's reason is the hub's own words. */
|
|
929
1465
|
private async sendTask(task: Task, a: Assignment, context?: string, overlap = "", contextWithheld = false): Promise<void> {
|
|
930
1466
|
const pii = this.isPii(task);
|
|
931
1467
|
const brief = pii ? undefined : await this.d.briefs?.forTask(task.owner!, task).catch(() => undefined);
|
|
932
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)}`);
|
|
933
1469
|
const handoff = context && !pii ? this.screen(task, context, "handoff", a.owner, contextWithheld).slice(0, 3000) : "";
|
|
934
|
-
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("; ");
|
|
935
1471
|
const plan = planText(task.plan);
|
|
936
1472
|
const body = [
|
|
937
|
-
`Task #${task.id} [${task
|
|
1473
|
+
`Task #${task.id} [${profileTaskLabel(task)}] ${task.title}`,
|
|
938
1474
|
task.detail,
|
|
939
1475
|
`Facts: ${facts}`,
|
|
1476
|
+
this.roleGuidance(task, task.owner!),
|
|
940
1477
|
plan ? `Plan so far: ${plan}` : "",
|
|
941
1478
|
overlap,
|
|
942
1479
|
rejected.length ? `Earlier review notes:\n${rejected.join("\n")}` : "",
|
|
943
1480
|
brief ?? "",
|
|
944
1481
|
// What the previous owner left behind. Peer-written free text: never attached to a PII task.
|
|
945
1482
|
handoff ? `Handoff from the previous owner:\n${handoff}` : "",
|
|
946
|
-
|
|
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}.`,
|
|
947
1486
|
].filter(Boolean).join("\n\n");
|
|
948
1487
|
// A hand-over the owner takes in a turn of its own (#109, #115): idle before, busy after and not queued, this envelope
|
|
949
1488
|
// was in the delivery that started its turn. Busy before, it is queued, or steered into a turn about something else
|
|
@@ -1010,8 +1549,9 @@ export class Tasks {
|
|
|
1010
1549
|
*/
|
|
1011
1550
|
async releaseReady(): Promise<void> {
|
|
1012
1551
|
for (const t of this.d.board.list("proposed")) {
|
|
1013
|
-
const last = t.history.
|
|
1014
|
-
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;
|
|
1015
1555
|
// ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
|
|
1016
1556
|
// an offline one; hold reserved tasks for a grace period if restarts show that up.
|
|
1017
1557
|
if (!assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.health() }).owner) continue; // nobody attached can take it yet
|
|
@@ -1025,11 +1565,14 @@ export class Tasks {
|
|
|
1025
1565
|
try {
|
|
1026
1566
|
const t = this.d.board.get(stale.id);
|
|
1027
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;
|
|
1028
1571
|
this.offered.add(t.id);
|
|
1029
1572
|
offering = true;
|
|
1030
1573
|
const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
|
|
1031
1574
|
this.releaseNotice(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
|
|
1032
|
-
await this.assignOwner(ready, HUB);
|
|
1575
|
+
await this.assignOwner(ready, HUB, planned ? { candidates: [selected!], namedOwner: selected!, planner: true } : {});
|
|
1033
1576
|
} catch (e) {
|
|
1034
1577
|
// A saved move whose delivery failed is not "could not be assigned" (#297); the console notice was printed
|
|
1035
1578
|
// where the error was thrown. The offer stays consumed by the error's own evidence, without the readback.
|
|
@@ -1051,12 +1594,84 @@ export class Tasks {
|
|
|
1051
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`);
|
|
1052
1595
|
}
|
|
1053
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
|
+
|
|
1054
1668
|
private need(id: unknown, open = false, preEffect = false): Task {
|
|
1055
1669
|
// Callers are models (#70): an id is a whole number or a digit string, never an array that Number() would accept.
|
|
1056
1670
|
const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
|
|
1057
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)}`);
|
|
1058
1672
|
const task = this.d.board.get(n);
|
|
1059
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`);
|
|
1060
1675
|
// Handing a task to someone else only makes sense while there is work left on it.
|
|
1061
1676
|
if (open && !OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: it can no longer change hands`);
|
|
1062
1677
|
return task;
|
|
@@ -1072,10 +1687,10 @@ export class Tasks {
|
|
|
1072
1687
|
this.ready(task, true);
|
|
1073
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`);
|
|
1074
1689
|
// Models send null or {} for an optional field they leave empty: neither replaces a plan.
|
|
1075
|
-
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");
|
|
1076
1691
|
const cleaned = given && Object.keys(given).length ? given : undefined;
|
|
1077
1692
|
// A task's signals are fixed when it is proposed: text that matches a PII pattern cannot be let in afterwards.
|
|
1078
|
-
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 }) })) {
|
|
1079
1694
|
throw new PreEffectToolRefusal("this plan matches a PII pattern and is not kept: other owners would see it");
|
|
1080
1695
|
}
|
|
1081
1696
|
const before = new Set(this.overlapHits(task).map((h) => h.task.id));
|
|
@@ -1138,9 +1753,11 @@ export class Tasks {
|
|
|
1138
1753
|
const task = this.need(id, true, true);
|
|
1139
1754
|
this.mine(task, by, "owner", true);
|
|
1140
1755
|
// The owner it is declined for goes on the entry, also when the console declines for it: `excluded()` keeps it out (#207).
|
|
1141
|
-
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);
|
|
1142
1759
|
this.d.notify(`task ${this.publicTitle(back)} declined by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
|
|
1143
|
-
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 } : {}) });
|
|
1144
1761
|
}
|
|
1145
1762
|
|
|
1146
1763
|
/** Tasks whose check is queued or running, and the owner it was started for. */
|
|
@@ -1168,6 +1785,7 @@ export class Tasks {
|
|
|
1168
1785
|
// The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
|
|
1169
1786
|
const withheld = await this.withholds("summary", summary, valid());
|
|
1170
1787
|
let task = valid();
|
|
1788
|
+
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "review"); task = valid(); }
|
|
1171
1789
|
// A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
|
|
1172
1790
|
const cohort = this.cohorts.of(task.id);
|
|
1173
1791
|
const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
|
|
@@ -1197,7 +1815,7 @@ export class Tasks {
|
|
|
1197
1815
|
else if (evidence) this.doneNotes.set(task.id, `Overlapping work finished while you worked (no turn-free integration step ran):\n${evidence}`);
|
|
1198
1816
|
}
|
|
1199
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
|
|
1200
|
-
const command = this.d.runCheck ? this.d.check?.(task.class) : undefined;
|
|
1818
|
+
const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
|
|
1201
1819
|
if (!command) return this.complete(task, by, summary, refs, "", withheld);
|
|
1202
1820
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
1203
1821
|
this.checking.set(task.id, task.owner);
|
|
@@ -1241,7 +1859,7 @@ export class Tasks {
|
|
|
1241
1859
|
const at = [...u.history].reverse().find((h) => h.event === "done");
|
|
1242
1860
|
if (u.owner === t.owner || !at || at.at < handedAt(t)) return [];
|
|
1243
1861
|
const theirs = this.places(u);
|
|
1244
|
-
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)));
|
|
1245
1863
|
const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
|
|
1246
1864
|
return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note, at.withheld)] : [];
|
|
1247
1865
|
});
|
|
@@ -1325,12 +1943,19 @@ export class Tasks {
|
|
|
1325
1943
|
}
|
|
1326
1944
|
|
|
1327
1945
|
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false): Promise<Task> {
|
|
1328
|
-
|
|
1329
|
-
|
|
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 });
|
|
1330
1954
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
1331
1955
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
|
|
1332
1956
|
this.tellCompleted(next, summary, withheld);
|
|
1333
1957
|
if (!reviewer) {
|
|
1958
|
+
this.settleMemory(next);
|
|
1334
1959
|
if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true); // approved without a review is a success too
|
|
1335
1960
|
this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
|
|
1336
1961
|
await this.releaseDependents(next);
|
|
@@ -1405,11 +2030,15 @@ export class Tasks {
|
|
|
1405
2030
|
`- List what is unmet in hub_review's unmet, one item each.`,
|
|
1406
2031
|
].join("\n");
|
|
1407
2032
|
const summary = last?.note ? this.screen(task, last.note, "summary", reviewer, last.withheld) : "(no summary)";
|
|
1408
|
-
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}.`;
|
|
1409
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 } : {}) }));
|
|
1410
2039
|
}
|
|
1411
2040
|
|
|
1412
|
-
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> {
|
|
1413
2042
|
const valid = (): Task => {
|
|
1414
2043
|
const t = this.need(id, false, true);
|
|
1415
2044
|
this.mine(t, by, "reviewer", true);
|
|
@@ -1421,11 +2050,17 @@ export class Tasks {
|
|
|
1421
2050
|
if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
|
|
1422
2051
|
// The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
|
|
1423
2052
|
const withheld = await this.withholds("review note", note, valid());
|
|
1424
|
-
|
|
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(); }
|
|
1425
2060
|
const pii = this.isPii(task);
|
|
1426
2061
|
const flag = { withheld: withheld || undefined };
|
|
1427
2062
|
if (verdict === "approved") {
|
|
1428
|
-
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);
|
|
1429
2064
|
if (next.owner) {
|
|
1430
2065
|
this.d.board.recordOutcome(next.owner, next.class, true);
|
|
1431
2066
|
this.d.board.recordReview({ implementer: next.owner, reviewer: by, class: next.class, kind: "approved", task: next.id });
|
|
@@ -1435,6 +2070,7 @@ export class Tasks {
|
|
|
1435
2070
|
}
|
|
1436
2071
|
}
|
|
1437
2072
|
this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`, withheld);
|
|
2073
|
+
this.settleMemory(next);
|
|
1438
2074
|
this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
|
|
1439
2075
|
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
|
|
1440
2076
|
await this.releaseDependents(next);
|
|
@@ -1507,10 +2143,36 @@ export class Tasks {
|
|
|
1507
2143
|
}
|
|
1508
2144
|
|
|
1509
2145
|
/** Caller authority is checked by the daemon; keep the real actor in task history. */
|
|
1510
|
-
async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false): Promise<Task> {
|
|
1511
|
-
|
|
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
|
+
}
|
|
1512
2174
|
// Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
|
|
1513
|
-
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 });
|
|
1514
2176
|
const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" });
|
|
1515
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`);
|
|
1516
2178
|
return next;
|
|
@@ -1521,35 +2183,70 @@ export class Tasks {
|
|
|
1521
2183
|
* the usual constraints. Tasks it was reviewing get another reviewer. Moves are reported, never taken back automatically.
|
|
1522
2184
|
*/
|
|
1523
2185
|
async reassignForPause(peer: PeerId, context: string | undefined, urgentOnly = false): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
1524
|
-
|
|
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 }[] {
|
|
1525
2191
|
const routing = this.d.routing();
|
|
1526
|
-
|
|
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.
|
|
1527
2211
|
const moves = (t: Task) => (!urgentOnly || t.signals.includes("urgent")) && t.owner === peer && OPEN.includes(t.state);
|
|
1528
2212
|
const contextWithheld = context && this.d.board.list().some((t) => moves(t) && !this.isPii(t)) ? await this.withholds("handoff", context) : false;
|
|
1529
2213
|
for (const task of this.d.board.list()) {
|
|
1530
|
-
// Waiting out a window that resets soon (issue #36): only urgent work moves.
|
|
1531
2214
|
if (urgentOnly && !task.signals.includes("urgent")) continue;
|
|
1532
|
-
if (task.owner === peer && OPEN.includes(task.state)) {
|
|
2215
|
+
if (task.owner === peer && (OPEN.includes(task.state) || roster && task.state === "in_review")) {
|
|
1533
2216
|
const pii = this.isPii(task);
|
|
1534
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);
|
|
1535
|
-
|
|
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;
|
|
1536
2223
|
try {
|
|
1537
|
-
|
|
1538
|
-
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
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 } : {}) });
|
|
1539
2225
|
} catch (e) {
|
|
1540
|
-
//
|
|
1541
|
-
//
|
|
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.
|
|
1542
2228
|
if (!(e instanceof AssignmentUndeliveredError)) throw e;
|
|
1543
|
-
|
|
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.)`));
|
|
1544
2235
|
}
|
|
2236
|
+
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
1545
2237
|
} else if (task.reviewer === peer && task.state !== "approved") {
|
|
1546
|
-
// No owner candidates: only the reviewer is wanted, and it must be neither the paused peer nor the task's owner.
|
|
1547
2238
|
const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
|
|
1548
2239
|
const reviewer = a.reviewer && a.reviewer !== peer && a.reviewer !== task.owner ? a.reviewer : null;
|
|
1549
|
-
|
|
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);
|
|
1550
2245
|
moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
|
|
1551
|
-
if (next.state === "in_review" && reviewer && reviewer !== USER)
|
|
1552
|
-
|
|
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}`);
|
|
1553
2250
|
}
|
|
1554
2251
|
}
|
|
1555
2252
|
return moved;
|
|
@@ -1566,7 +2263,7 @@ export class Tasks {
|
|
|
1566
2263
|
return { ...(policy?.route ? { route: policy.route } : {}), ...(policy?.fixed_model ? { fixedModel: policy.fixed_model } : {}), pii, task: String(about.id) };
|
|
1567
2264
|
}
|
|
1568
2265
|
|
|
1569
|
-
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> {
|
|
1570
2267
|
const text = String(input.text ?? "").trim();
|
|
1571
2268
|
if (!text) throw new Error("text is required");
|
|
1572
2269
|
const task = input.task !== undefined ? this.d.board.get(Number(input.task)) : undefined;
|
|
@@ -1574,24 +2271,132 @@ export class Tasks {
|
|
|
1574
2271
|
// Shared with every other peer as well, so text that matches a PII pattern is refused like a note about a PII task.
|
|
1575
2272
|
const title = String(input.title ?? "").trim();
|
|
1576
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");
|
|
1577
|
-
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");
|
|
1578
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");
|
|
1579
|
-
const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) : "finding";
|
|
1580
|
-
const
|
|
1581
|
-
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";
|
|
1582
2286
|
const line = noteLine(by, kind, title ? `${title}: ${text}` : text);
|
|
1583
2287
|
this.d.notify(line);
|
|
1584
2288
|
this.d.share?.(by, line);
|
|
1585
|
-
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);
|
|
1586
2384
|
}
|
|
1587
2385
|
|
|
1588
2386
|
/** Auto notes: only transitions that carry content, never for PII. */
|
|
1589
|
-
private note(task: Task, by: PeerId, kind:
|
|
1590
|
-
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;
|
|
1591
2389
|
// claude-mem's observer is a cloud model: model-written text that matches a PII pattern, or that the PII screen did
|
|
1592
|
-
// 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.
|
|
1593
2391
|
if (withheld || !this.nameable(text)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (${this.whyWithheld(text)})`);
|
|
1594
|
-
|
|
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
|
+
}
|
|
1595
2400
|
}
|
|
1596
2401
|
|
|
1597
2402
|
private tell(task: Task, body: string, pii: boolean): void {
|