@staix/agent-hub 0.12.23 → 0.12.25
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 +109 -1
- 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 +10 -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 +8 -0
- package/docs/agent-notes/tests.md +5 -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 +666 -19
- 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 +2 -2
- 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 +288 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +633 -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/verification/2026-10-14-0.12.24-audit.md +123 -0
- package/docs/verification/2026-10-14-0.12.25.md +27 -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 +171 -23
- 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 +914 -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 +115 -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 +905 -89
- package/src/hub/text-projection.ts +151 -0
- package/src/hub/tooling.ts +170 -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 +757 -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/pi/extension.ts +40 -5
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +843 -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,375 @@ 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
|
+
private hasPersonReservation(task: Task): boolean {
|
|
568
|
+
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);
|
|
569
|
+
return !!task.reserved && reservation?.by === USER;
|
|
570
|
+
}
|
|
571
|
+
wip(peer: PeerId, except?: number): number {
|
|
572
|
+
return this.d.board.plannerWip(peer, except);
|
|
573
|
+
}
|
|
574
|
+
private prerequisiteOf(task: Task, prerequisite: number): boolean {
|
|
575
|
+
const pending = [...(task.deps ?? [])], seen = new Set<number>();
|
|
576
|
+
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 ?? [])); }
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
private plannerFull(task: Task): PeerId[] {
|
|
580
|
+
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; });
|
|
581
|
+
}
|
|
582
|
+
private plannerAssignment(task: Task, reservedOnly = false): Assignment {
|
|
583
|
+
const states = this.states();
|
|
584
|
+
const full = this.plannerFull(task);
|
|
585
|
+
// Preview the To Do transition without routing or offering the persisted backlog row.
|
|
586
|
+
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) });
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
|
|
590
|
+
this.planningAuthority(by);
|
|
591
|
+
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");
|
|
592
|
+
const validate = () => {
|
|
593
|
+
const task = this.d.board.get(input.id);
|
|
594
|
+
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");
|
|
595
|
+
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
596
|
+
if (task.release != null && (!release || ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull requires unfrozen release scope");
|
|
597
|
+
if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
|
|
598
|
+
const priority = task.priority ?? (task.signals.includes("urgent") ? 0 : 2);
|
|
599
|
+
if (priority === 0) {
|
|
600
|
+
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")));
|
|
601
|
+
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];
|
|
602
|
+
const order = effectiveOrder(task) ?? { rank: task.rank ?? Number.MAX_SAFE_INTEGER, id: task.id };
|
|
603
|
+
if (this.d.board.list("backlog").some(other => {
|
|
604
|
+
if (other.id === task.id || this.prerequisiteOf(other, task.id) || (other.priority ?? (other.signals.includes("urgent") ? 0 : 2)) !== 0) return false;
|
|
605
|
+
const competing = effectiveOrder(other);
|
|
606
|
+
return !!competing && (competing.rank < order.rank || competing.rank === order.rank && competing.id < order.id);
|
|
607
|
+
})) throw new PreEffectToolRefusal("a person's P0 backlog order is retained");
|
|
608
|
+
}
|
|
609
|
+
const assignment = this.plannerAssignment(task);
|
|
610
|
+
if (!assignment.owner) throw new PreEffectToolRefusal("no peer has eligible planner WIP capacity");
|
|
611
|
+
if (this.hasPersonReservation(task) && assignment.owner !== task.reserved) throw new PreEffectToolRefusal("a person's reservation is retained");
|
|
612
|
+
return { task, owner: assignment.owner };
|
|
613
|
+
};
|
|
614
|
+
const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
|
|
615
|
+
const current = validate();
|
|
616
|
+
const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld);
|
|
617
|
+
this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
|
|
618
|
+
return pulled;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
backlogList(by: PeerId): Record<string, unknown>[] {
|
|
622
|
+
this.planningAuthority(by);
|
|
623
|
+
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));
|
|
624
|
+
}
|
|
625
|
+
private addPlannerBacklog(peer: PeerId, ids: readonly number[]): void {
|
|
626
|
+
if (!this.d.roles?.[peer]?.includes("planner")) return;
|
|
627
|
+
const pending = this.plannerBacklog.get(peer) ?? new Set<number>();
|
|
628
|
+
for (const id of ids) if (this.d.board.get(id)?.state === "backlog") pending.add(id);
|
|
629
|
+
if (pending.size) this.plannerBacklog.set(peer, pending);
|
|
630
|
+
}
|
|
631
|
+
/** One bounded ids-only batch per attached planner; failed or evicted copies stay pending. */
|
|
632
|
+
flushPlannerNotices(): number {
|
|
633
|
+
const configured = Object.entries(this.d.roles ?? {}).filter(([, roles]) => roles.includes("planner")).map(([peer]) => peer);
|
|
634
|
+
for (const peer of this.knownPlanners) if (!configured.includes(peer)) {
|
|
635
|
+
this.knownPlanners.delete(peer); this.plannerBacklog.delete(peer); this.plannerBlockedQueue.delete(peer);
|
|
636
|
+
}
|
|
637
|
+
for (const peer of configured) if (!this.knownPlanners.has(peer)) {
|
|
638
|
+
this.knownPlanners.add(peer);
|
|
639
|
+
const retained = this.d.bus.queuedBacklogIds(peer);
|
|
640
|
+
this.addPlannerBacklog(peer, this.d.board.list("backlog").filter(task => !retained.has(task.id)).map(task => task.id));
|
|
641
|
+
}
|
|
642
|
+
const planners = configured.filter(peer => this.d.bus.isRostered(peer) && this.d.bus.peers.has(peer) && this.d.bus.stateOf(peer) !== "offline");
|
|
643
|
+
if (!planners.length) return 0;
|
|
644
|
+
for (const id of this.incomingBacklog) if (this.d.board.get(id)?.state !== "backlog") this.incomingBacklog.delete(id);
|
|
645
|
+
const incoming = [...this.incomingBacklog].sort((a, b) => a - b).slice(0, 64);
|
|
646
|
+
if (incoming.length) {
|
|
647
|
+
for (const peer of configured) {
|
|
648
|
+
const queued = this.d.bus.queuedBacklogIds(peer);
|
|
649
|
+
this.addPlannerBacklog(peer, incoming.filter(id => !queued.has(id)));
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
for (const id of incoming) this.incomingBacklog.delete(id);
|
|
653
|
+
const notified = new Set<number>();
|
|
654
|
+
for (const peer of planners) {
|
|
655
|
+
const pending = this.plannerBacklog.get(peer);
|
|
656
|
+
if (!pending) continue;
|
|
657
|
+
const blockedQueue = this.plannerBlockedQueue.get(peer);
|
|
658
|
+
if (blockedQueue !== undefined && blockedQueue === JSON.stringify(this.d.bus.queueIds(peer))) continue;
|
|
659
|
+
this.plannerBlockedQueue.delete(peer);
|
|
660
|
+
for (const id of pending) if (this.d.board.get(id)?.state !== "backlog") pending.delete(id);
|
|
661
|
+
const ids = [...pending].sort((a, b) => a - b).slice(0, 64);
|
|
662
|
+
if (!ids.length) continue;
|
|
663
|
+
// Remove before publish: an immediate overflow callback may put this recipient's ids back.
|
|
664
|
+
for (const id of ids) pending.delete(id);
|
|
665
|
+
try {
|
|
666
|
+
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 } }));
|
|
667
|
+
if (!queued.includes(peer)) this.addPlannerBacklog(peer, ids);
|
|
668
|
+
for (const id of ids) notified.add(id);
|
|
669
|
+
} catch (error) { this.addPlannerBacklog(peer, ids); throw error; }
|
|
670
|
+
}
|
|
671
|
+
return notified.size;
|
|
672
|
+
}
|
|
673
|
+
releaseList(by: PeerId): Record<string, unknown>[] {
|
|
674
|
+
this.planningAuthority(by);
|
|
675
|
+
const scopes = new Map<number, { id: number; state: Task["state"]; deps: number[] }[]>();
|
|
676
|
+
type Approval = { id: number; release: Task["release"]; doneAt: number | null };
|
|
677
|
+
const approved: Approval[] = [], owned = new Map<number, Approval[]>();
|
|
678
|
+
for (const task of this.d.board.list()) {
|
|
679
|
+
if (typeof task.release === "number") {
|
|
680
|
+
const scope = scopes.get(task.release) ?? [];
|
|
681
|
+
scope.push({ id: task.id, state: task.state, deps: task.deps ?? [] }); scopes.set(task.release, scope);
|
|
682
|
+
}
|
|
683
|
+
if (task.state !== "approved") continue;
|
|
684
|
+
const row = { id: task.id, release: task.release, doneAt: actualTaskDates(task).doneAt };
|
|
685
|
+
approved.push(row);
|
|
686
|
+
if (typeof task.release === "number") { const rows = owned.get(task.release) ?? []; rows.push(row); owned.set(task.release, rows); }
|
|
687
|
+
}
|
|
688
|
+
const compare = (a: Approval, b: Approval) => a.doneAt! - b.doneAt! || a.id - b.id;
|
|
689
|
+
const known = approved.filter(task => task.doneAt !== null).sort(compare), unknown = approved.filter(task => task.doneAt === null);
|
|
690
|
+
const after = (rows: Approval[], cut: number) => {
|
|
691
|
+
let low = 0, high = rows.length;
|
|
692
|
+
while (low < high) { const mid = Math.floor((low + high) / 2); if (rows[mid]!.doneAt! <= cut) low = mid + 1; else high = mid; }
|
|
693
|
+
return low;
|
|
694
|
+
};
|
|
695
|
+
// ponytail: audit previews show 32 ids plus exact counts; add a paginated audit endpoint for exhaustive lists.
|
|
696
|
+
const outsideIds = (rows: Approval[], start: number, release: number) => {
|
|
697
|
+
const result: number[] = [];
|
|
698
|
+
for (let i = start; i < rows.length && result.length < 32; i++) if (rows[i]!.release !== release) result.push(rows[i]!.id);
|
|
699
|
+
return result;
|
|
700
|
+
};
|
|
701
|
+
return this.d.board.releases().map(release => {
|
|
702
|
+
const own = owned.get(release.id) ?? [], ownKnown = own.filter(task => task.doneAt !== null).sort(compare);
|
|
703
|
+
const cutStart = release.cut === null ? known.length : after(known, release.cut);
|
|
704
|
+
const unknownPool = release.cut === null ? approved : unknown;
|
|
705
|
+
return { ...release,
|
|
706
|
+
name: this.nameable(release.name) ? release.name : "[withheld]",
|
|
707
|
+
goal: this.nameable(release.goal) ? release.goal : "[withheld]",
|
|
708
|
+
tasks: scopes.get(release.id) ?? [],
|
|
709
|
+
afterCutUnknown: outsideIds(unknownPool, 0, release.id),
|
|
710
|
+
afterCutUnknownCount: release.cut === null ? approved.length - own.length : unknown.length - (own.length - ownKnown.length),
|
|
711
|
+
afterCut: release.cut === null ? null : outsideIds(known, cutStart, release.id),
|
|
712
|
+
afterCutCount: release.cut === null ? null : known.length - cutStart - (ownKnown.length - after(ownKnown, release.cut)),
|
|
713
|
+
};
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async planBacklog(by: PeerId, input: { id: number; revision: number; fields: unknown; reason: string }): Promise<Task> {
|
|
718
|
+
this.planningAuthority(by);
|
|
719
|
+
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0) throw new PreEffectToolRefusal("invalid planning identity or revision");
|
|
720
|
+
if (typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("planning needs a bounded reason");
|
|
721
|
+
let patch: PlanningFields;
|
|
722
|
+
try { patch = planningPatch(input.fields); } catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
723
|
+
const validate = () => {
|
|
724
|
+
const task = this.d.board.get(input.id);
|
|
725
|
+
if (!task || (task.planningRevision ?? 0) !== input.revision) throw new PreEffectToolRefusal("planning revision changed");
|
|
726
|
+
if (task.state === "approved") throw new PreEffectToolRefusal("approved work is final");
|
|
727
|
+
if (by !== USER && (task.state !== "backlog" || taskHasStarted(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
|
|
728
|
+
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");
|
|
729
|
+
if (by !== USER && Object.hasOwn(patch, "reserved") && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained");
|
|
730
|
+
if (Object.hasOwn(patch, "deps") || Object.hasOwn(patch, "reserved")) {
|
|
731
|
+
if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
|
|
732
|
+
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");
|
|
733
|
+
if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
|
|
734
|
+
const rows = new Map(this.d.board.list().map(row => [row.id, row]));
|
|
735
|
+
const pending = [...(patch.deps ?? task.deps ?? [])], seen = new Set<number>();
|
|
736
|
+
while (pending.length) {
|
|
737
|
+
const id = pending.pop()!;
|
|
738
|
+
if (id === task.id) throw new PreEffectToolRefusal("planning dependencies would form a cycle");
|
|
739
|
+
if (seen.has(id)) continue;
|
|
740
|
+
seen.add(id);
|
|
741
|
+
const dependency = rows.get(id);
|
|
742
|
+
if (!dependency) throw new PreEffectToolRefusal(`no dependency task #${id}`);
|
|
743
|
+
pending.push(...(dependency.deps ?? []));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const target = patch.release === undefined ? task.release : patch.release;
|
|
747
|
+
if (target != null && !this.d.board.release(target)) throw new PreEffectToolRefusal("no such release");
|
|
748
|
+
for (const id of new Set([task.release, target])) {
|
|
749
|
+
if (id == null) continue;
|
|
750
|
+
const release = this.d.board.release(id)!;
|
|
751
|
+
if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release scope is final");
|
|
752
|
+
if (by !== USER && release.state === "frozen") throw new PreEffectToolRefusal("frozen release changes require a person");
|
|
753
|
+
}
|
|
754
|
+
return task;
|
|
755
|
+
};
|
|
756
|
+
const task = validate(), withheld = await this.withholds("note", input.reason, task);
|
|
757
|
+
validate(); // a person may have changed planning or frozen a release while the screen ran
|
|
758
|
+
const updated = this.d.board.plan(task.id, by, patch, input.revision, input.reason.trim(), withheld);
|
|
759
|
+
this.planningRecorded({ by, task: task.id, fields: Object.keys(patch), revision: updated.planningRevision! });
|
|
760
|
+
return updated; // persistence/readback failures must never claim no effect
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
async undoPlanning(by: PeerId, id: number, revision: number): Promise<Task> {
|
|
764
|
+
this.planningAuthority(by);
|
|
765
|
+
const task = this.d.board.get(id), entry = task ? planningUndoEntry(task) : undefined;
|
|
766
|
+
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");
|
|
767
|
+
if (entry.planning.split) {
|
|
768
|
+
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
769
|
+
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");
|
|
770
|
+
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");
|
|
771
|
+
const childIds = new Set(entry.planning.split.children.map(child => child.id)), budgets = this.d.executionBudget?.status();
|
|
772
|
+
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");
|
|
773
|
+
if (this.d.store) {
|
|
774
|
+
try { this.d.store.store.refresh(); } catch { throw new PreEffectToolRefusal("split undo requires current memory link evidence"); }
|
|
775
|
+
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)));
|
|
776
|
+
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");
|
|
777
|
+
}
|
|
778
|
+
const restored = this.d.board.undoBacklogSplit(id, by, revision);
|
|
779
|
+
this.planningRecorded({ by, task: id, fields: ["split", "deps"], revision: restored.planningRevision! });
|
|
780
|
+
return restored;
|
|
781
|
+
}
|
|
782
|
+
if (entry.planning.pull) {
|
|
783
|
+
const release = task.release == null ? undefined : this.d.board.release(task.release);
|
|
784
|
+
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");
|
|
785
|
+
const returned = this.d.board.undoPlannedPull(id, by, revision);
|
|
786
|
+
this.offered.delete(id); this.incomingBacklog.add(id);
|
|
787
|
+
this.planningRecorded({ by, task: id, fields: ["pull", "reserved"], revision: returned.planningRevision! });
|
|
788
|
+
return returned;
|
|
789
|
+
}
|
|
790
|
+
return this.planBacklog(by, { id, revision, fields: entry.planning.previous, reason: `undo planning revision ${revision}` });
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async importGitHub(by: PeerId, input: GitHubImportSelection, run?: SdlcGh): Promise<{ imported: number[]; existing: number[]; partial: boolean; complete: boolean; unconfirmed?: true }> {
|
|
794
|
+
if (by !== USER) throw new PreEffectToolRefusal("GitHub import is a person console command");
|
|
795
|
+
const deadline = performance.now() + 20_000;
|
|
796
|
+
const page = await readGitHubImport(input, run ?? ((args, timeout, _cwd, bound) => sdlcGh(args, timeout, this.d.cwd, bound)));
|
|
797
|
+
const imported: number[] = [], existing: number[] = [];
|
|
798
|
+
for (const issue of page.issues) {
|
|
799
|
+
if (performance.now() >= deadline) return { imported, existing, partial: true, complete: false };
|
|
800
|
+
let created = false;
|
|
801
|
+
try {
|
|
802
|
+
const prior = this.d.board.imported(issue.source.repo, issue.source.issue);
|
|
803
|
+
if (prior) { existing.push(prior.id); continue; }
|
|
804
|
+
const classes = profileData(this.d.profileData ?? this.d.profile ?? "dev").classes;
|
|
805
|
+
const cls = Object.keys(classes).find(name => classes[name] === "implement") ?? Object.keys(classes)[0];
|
|
806
|
+
const task = await this.propose(USER, { title: issue.title, detail: issue.detail, class: cls, backlog: true }, issue.source, () => { created = true; });
|
|
807
|
+
(created ? imported : existing).push(task.id);
|
|
808
|
+
if (performance.now() >= deadline) return { imported, existing, partial: true, complete: false };
|
|
809
|
+
} catch {
|
|
810
|
+
if (created) {
|
|
811
|
+
try {
|
|
812
|
+
const saved = this.d.board.imported(issue.source.repo, issue.source.issue);
|
|
813
|
+
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 }; }
|
|
814
|
+
} catch { /* A failed readback leaves the committed identity unconfirmed. */ }
|
|
815
|
+
}
|
|
816
|
+
return { imported, existing, partial: true, complete: false, unconfirmed: true };
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return { imported, existing, partial: page.partial, complete: !page.partial };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async splitBacklog(by: PeerId, input: { id: number; revision: number; parts: unknown; reason: string }): Promise<{ parent: Task; children: Task[] }> {
|
|
823
|
+
this.planningAuthority(by);
|
|
824
|
+
const { id, revision } = input;
|
|
825
|
+
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");
|
|
826
|
+
const reason = input.reason.trim();
|
|
827
|
+
let parts: unknown;
|
|
828
|
+
try { parts = structuredClone(input.parts); } catch { throw new PreEffectToolRefusal("split parts must be cloneable data"); }
|
|
829
|
+
if (!Array.isArray(parts) || parts.length < 2 || parts.length > 8) throw new PreEffectToolRefusal("split needs two to eight parts");
|
|
830
|
+
const rows = parts as Record<string, unknown>[];
|
|
831
|
+
const validate = () => {
|
|
832
|
+
const parent = this.d.board.get(id);
|
|
833
|
+
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");
|
|
834
|
+
const release = parent.release == null ? undefined : this.d.board.release(parent.release);
|
|
835
|
+
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");
|
|
836
|
+
const profile = parent.profile ?? "dev";
|
|
837
|
+
const drafts = rows.map((part, index) => {
|
|
838
|
+
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");
|
|
839
|
+
const given = part.class ?? parent.profileClass ?? parent.class;
|
|
840
|
+
const storedProfile = !parent.profile ? "dev" : profile === (this.d.profile ?? "dev") ? this.d.profileData ?? profile : profile;
|
|
841
|
+
let cls: TaskClass;
|
|
842
|
+
try { cls = part.class === undefined ? parent.class : profileClass(given as string, storedProfile); } catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
843
|
+
const after = part.after ?? [], afterParts = part.afterParts ?? [];
|
|
844
|
+
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");
|
|
845
|
+
const deps = [...new Set([...(parent.deps ?? []), ...after as number[]])], pending = [...deps], seen = new Set<number>();
|
|
846
|
+
const direct = afterParts.length ? deps.filter(dep => !parent.deps?.includes(dep)) : deps;
|
|
847
|
+
if (direct.length + afterParts.length > 64) throw new PreEffectToolRefusal("split child dependencies exceed the 64-edge cap");
|
|
848
|
+
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 ?? [])); }
|
|
849
|
+
const refs = part.refs === undefined ? parent.refs : cleanRefs(part.refs), plan = cleanPlan(part.plan, storedProfile);
|
|
850
|
+
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 } : {}) } : {}) };
|
|
851
|
+
const fields: PlanningFields = {};
|
|
852
|
+
for (const key of ["priority", "release", "plannedStart", "due"] as const) if (parent[key] !== undefined) fields[key] = parent[key];
|
|
853
|
+
return { ...draft, fields };
|
|
854
|
+
});
|
|
855
|
+
return { parent, drafts };
|
|
856
|
+
};
|
|
857
|
+
const initial = validate(), screening = this.screenOn();
|
|
858
|
+
const initialPrivacy = initial.drafts.map(part => this.signalsFor({ ...part, detail: [part.detail, planText(part.plan)].join("\n") }).filter(signal => signal === "pii" || signal === "pii_path"));
|
|
859
|
+
const initialPrivate = initialPrivacy.map(signals => this.isPii({ signals })), initialParentPrivate = this.isPii(initial.parent);
|
|
860
|
+
const initialParentPrivacy = initial.parent.signals.filter(signal => signal === "pii" || signal === "pii_path");
|
|
861
|
+
const initialReasonPrivate = initialParentPrivate || !this.nameable(reason);
|
|
862
|
+
let deadline: ReturnType<typeof setTimeout> | undefined;
|
|
863
|
+
const attempt = Promise.all([
|
|
864
|
+
Promise.all(initial.drafts.map((part, index) => screening && !initialParentPrivate && !initialPrivate[index] ? this.screenCall(screenText({ title: part.title, detail: part.detail, plan: part.plan })) : undefined)),
|
|
865
|
+
this.withholds("note", reason, initial.parent),
|
|
866
|
+
]);
|
|
867
|
+
let screened: Awaited<typeof attempt>;
|
|
868
|
+
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); })]); }
|
|
869
|
+
finally { if (deadline !== undefined) clearTimeout(deadline); }
|
|
870
|
+
const [screenedVerdicts, withheld] = screened, current = validate();
|
|
871
|
+
const finalSignals = current.drafts.map(part => this.signalsFor({ ...part, detail: [part.detail, planText(part.plan)].join("\n") }));
|
|
872
|
+
const deterministicPrivate = finalSignals.map((signals, index) => initialPrivate[index] || this.isPii({ signals }));
|
|
873
|
+
const verdicts = screenedVerdicts.map((verdict, index) => deterministicPrivate[index] ? undefined : verdict);
|
|
874
|
+
const inherited = current.drafts.map((_part, index) => (initialParentPrivate || this.isPii(current.parent)) && !deterministicPrivate[index]);
|
|
875
|
+
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"] : [])])] }));
|
|
876
|
+
const result = this.d.board.splitBacklog(id, by, revision, drafts, reason, withheld || initialReasonPrivate || this.isPii(current.parent) || !this.nameable(reason));
|
|
877
|
+
for (const [index, child] of result.children.entries()) {
|
|
878
|
+
this.incomingBacklog.add(child.id);
|
|
879
|
+
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`); }
|
|
880
|
+
}
|
|
881
|
+
this.planningRecorded({ by, task: id, fields: ["split", "deps"], revision: result.parent.planningRevision! });
|
|
882
|
+
return result;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async newRelease(by: PeerId, input: { name: string; goal: string; order: number }): Promise<Release> {
|
|
886
|
+
this.planningAuthority(by);
|
|
887
|
+
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");
|
|
888
|
+
const text = input.name + "\n" + input.goal;
|
|
889
|
+
if (!this.nameable(text) || this.screenOn() && (await this.screenCall(text)).label !== "clear") throw new PreEffectToolRefusal("release text was not cleared for public planning");
|
|
890
|
+
if (this.d.board.releases().some(release => release.name === input.name)) throw new PreEffectToolRefusal("release name already exists");
|
|
891
|
+
const release = this.d.board.newRelease(input.name, input.goal.trim(), input.order);
|
|
892
|
+
this.planningRecorded({ by, release: release.id, fields: ["created"], revision: release.revision });
|
|
893
|
+
return release;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
changeRelease(by: PeerId, input: { id: number; revision: number; state?: ReleaseState; plannedStart?: number | null; cut?: number | null; ship?: number | null }): Release {
|
|
897
|
+
this.planningAuthority(by);
|
|
898
|
+
const release = this.d.board.release(input.id);
|
|
899
|
+
if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || !release || release.revision !== input.revision) throw new PreEffectToolRefusal("release revision changed");
|
|
900
|
+
if (["shipped", "dropped"].includes(release.state)) throw new PreEffectToolRefusal("settled release is final");
|
|
901
|
+
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");
|
|
902
|
+
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");
|
|
903
|
+
const { id, revision, ...patch } = input;
|
|
904
|
+
if (!Object.keys(patch).length || Object.keys(patch).some(key => !["state", "plannedStart", "cut", "ship"].includes(key))) throw new PreEffectToolRefusal("unknown or empty release fields");
|
|
905
|
+
const updated = this.d.board.updateRelease(id, patch, revision);
|
|
906
|
+
this.planningRecorded({ by, release: id, fields: Object.keys(patch), revision: updated.revision });
|
|
907
|
+
return updated; // a failed readback may follow a durable release write
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
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> {
|
|
911
|
+
external = external ? { ...external } : undefined;
|
|
912
|
+
if (external && (by !== USER || input.backlog !== true)) throw new PreEffectToolRefusal("external intake requires person-owned backlog staging");
|
|
913
|
+
if (input.backlog !== undefined && typeof input.backlog !== "boolean") throw new PreEffectToolRefusal("backlog must be a boolean");
|
|
914
|
+
const backlog = input.backlog === true;
|
|
915
|
+
if (backlog && input.owner && !this.d.bus.isRostered(input.owner)) throw new PreEffectToolRefusal(`${input.owner} is off roster; add it before reserving work`);
|
|
496
916
|
// Callers are models: a title is one line (it is part of console and hub.log lines), not a document.
|
|
497
917
|
const title = String(input.title ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
|
|
498
918
|
if (!title) throw new PreEffectToolRefusal("title is required");
|
|
919
|
+
const goal = input.goal == null || input.goal === "" ? undefined : this.goalFor(by, input.goal);
|
|
499
920
|
// Dependencies can only name tasks that exist, so the new task closes no cycle: nothing can depend on it yet.
|
|
500
921
|
// Callers are models: a missing or null `after` is no dependency, and an id is an integer or a digit string.
|
|
501
922
|
let afterIds: unknown[] = [];
|
|
@@ -509,40 +930,54 @@ export class Tasks {
|
|
|
509
930
|
if (!deps.includes(id)) deps.push(id);
|
|
510
931
|
}
|
|
511
932
|
// 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;
|
|
933
|
+
const reserved = backlog || this.waitsFor({ deps }).length ? input.owner : undefined;
|
|
513
934
|
const given = input.class === undefined || input.class === "" ? undefined : input.class;
|
|
514
|
-
|
|
935
|
+
const profile = this.d.profile ?? "dev";
|
|
936
|
+
let baseGiven: TaskClass | undefined;
|
|
937
|
+
try { if (given !== undefined) baseGiven = profileClass(given, this.d.profileData ?? profile); } catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
515
938
|
const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
|
|
516
|
-
const plan = cleanPlan(input.plan);
|
|
939
|
+
const plan = cleanPlan(input.plan, this.d.profileData ?? profile);
|
|
517
940
|
// 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
|
|
941
|
+
const screenDetail = [text.detail, external?.repo, external?.url].filter(value => value !== undefined && value !== "").join("\n");
|
|
942
|
+
const signals = this.signalsFor({ ...text, detail: [screenDetail, planText(plan)].join("\n"), plan });
|
|
519
943
|
// Urgent work is handed over even when its paused owner's window resets soon (issue #36).
|
|
520
944
|
if (input.urgent === true && !signals.includes("urgent")) signals.push("urgent");
|
|
521
945
|
// The PII screen (issue #198) reads the same text before anything else does: until its verdict the task is on no
|
|
522
946
|
// board, in no envelope and before no other model. A pattern match is PII without a call; no verdict is PII too.
|
|
523
947
|
const screening = this.screenOn();
|
|
524
|
-
const screened = screening && !signals.includes("pii") ? await this.screenCall(screenText({ ...text, plan })) : undefined;
|
|
948
|
+
const screened = screening && !signals.includes("pii") ? await this.screenCall(screenText({ ...text, detail: screenDetail, plan })) : undefined;
|
|
525
949
|
if (screened && screened.label !== "clear") signals.push("pii");
|
|
526
|
-
let cls =
|
|
950
|
+
let cls = baseGiven;
|
|
527
951
|
let triaged = false;
|
|
528
952
|
let fenced = false; // a PII task off campus: triage is not asked
|
|
529
953
|
if (!cls && this.d.triage) {
|
|
530
954
|
// Signals first: a PII task's text may only go to a model that is reached without leaving the campus network.
|
|
531
|
-
const pii =
|
|
955
|
+
const pii = this.isPii({ signals });
|
|
532
956
|
if (!pii || (await this.d.triage.onCampus().catch(() => false))) cls = await this.d.triage.classify(text.title, text.detail).catch(() => undefined);
|
|
533
957
|
else fenced = true;
|
|
534
958
|
triaged = !!cls;
|
|
535
959
|
}
|
|
536
960
|
// 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;
|
|
961
|
+
const defaulted = !backlog && !cls && input.owner === by;
|
|
538
962
|
if (defaulted) cls = "implement";
|
|
539
963
|
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
|
-
|
|
964
|
+
let selected;
|
|
965
|
+
try { selected = taskRole(input.role, cls, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
|
|
966
|
+
catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
967
|
+
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 } : {}) } : {}) };
|
|
968
|
+
if (external) { const prior = this.d.board.imported(external.repo, external.issue); if (prior) return prior; }
|
|
969
|
+
let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
|
|
970
|
+
created?.();
|
|
542
971
|
if (screening) task = this.screenedTask(task, screened);
|
|
972
|
+
if (goal) this.linkNative(task, goal, by);
|
|
543
973
|
if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
|
|
544
974
|
if (defaulted) task = this.d.board.update(task.id, "hub", "class defaulted", {}, "class implement for a claim without one");
|
|
545
|
-
|
|
975
|
+
if (backlog) {
|
|
976
|
+
this.incomingBacklog.add(task.id);
|
|
977
|
+
this.releaseNotice(`task ${this.publicTitle(task)} backlogged by ${by} [${profileTaskLabel(task)}]`);
|
|
978
|
+
return task;
|
|
979
|
+
}
|
|
980
|
+
this.d.notify(`task ${this.publicTitle(task)} proposed by ${by} [${profileTaskLabel(task)}]${task.signals.length ? ` signals: ${task.signals.join(", ")}` : ""}`);
|
|
546
981
|
// Re-read: a dependency approved while triage was awaited looked for its dependents before this row existed.
|
|
547
982
|
const still = this.waitsFor(task);
|
|
548
983
|
if (still.length) {
|
|
@@ -550,14 +985,37 @@ export class Tasks {
|
|
|
550
985
|
return this.d.board.update(task.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
|
|
551
986
|
}
|
|
552
987
|
// 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 } : {});
|
|
988
|
+
return this.assignOwner(task, by, input.owner ? { candidates: [input.owner], namedOwner: input.owner, claim: input.owner === by } : {});
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/** Person-owned staging only. Flow transitions remain with their existing owners and reviewers. */
|
|
992
|
+
async moveBacklog(by: PeerId, id: unknown, pull: boolean): Promise<Task> {
|
|
993
|
+
if (by !== USER) throw new PreEffectToolRefusal("backlog pull/return is a console command");
|
|
994
|
+
if (typeof pull !== "boolean") throw new PreEffectToolRefusal("pull must be a boolean");
|
|
995
|
+
const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
|
|
996
|
+
if (!Number.isSafeInteger(n) || n <= 0) throw new PreEffectToolRefusal("id must be a positive task number");
|
|
997
|
+
const task = this.d.board.get(n);
|
|
998
|
+
if (!task) throw new PreEffectToolRefusal(`no task #${n}`);
|
|
999
|
+
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"}`);
|
|
1000
|
+
if (!pull && taskHasStarted(task)) throw new PreEffectToolRefusal(`task #${n}: started work cannot return to backlog`);
|
|
1001
|
+
const pulled = task.history.findLast(entry => entry.event === "backlog pulled" || entry.event === "backlog returned");
|
|
1002
|
+
const restoreReservation = !pull && pulled?.planning?.pull && !task.history.slice(task.history.lastIndexOf(pulled) + 1).some(entry => entry.event === "reserved" || entry.planning?.fields.includes("reserved"));
|
|
1003
|
+
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 } : {}) });
|
|
1004
|
+
this.offered.delete(n);
|
|
1005
|
+
if (!pull) { this.incomingBacklog.add(n); return next; }
|
|
1006
|
+
const waits = this.waitsFor(next);
|
|
1007
|
+
if (waits.length) return this.d.board.update(n, HUB, "blocked", {}, `waits for ${waits.map(v => `#${v}`).join(", ")}`);
|
|
1008
|
+
// This persisted ready marker also lets a restart offer ownerless work once a peer attaches.
|
|
1009
|
+
const ready = this.d.board.update(n, HUB, "ready", {}, "pulled from backlog");
|
|
1010
|
+
return this.assignOwner(ready, by);
|
|
554
1011
|
}
|
|
555
1012
|
|
|
556
1013
|
/** 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. */
|
|
1014
|
+
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
1015
|
private screenedTask(task: Task, v: PiiVerdict | undefined): Task {
|
|
558
1016
|
this.recordScreen(task.id, "task", v);
|
|
559
1017
|
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 =
|
|
1018
|
+
const note = this.screenNote(v);
|
|
561
1019
|
return this.d.board.update(task.id, HUB, "screened", {}, note);
|
|
562
1020
|
}
|
|
563
1021
|
|
|
@@ -653,6 +1111,38 @@ export class Tasks {
|
|
|
653
1111
|
/** The tasks an overlap text for `task` names: their plans are in it (issue #108). */
|
|
654
1112
|
overlapTasks = (task: Task): number[] => this.overlapHits(task).map((h) => h.task.id);
|
|
655
1113
|
|
|
1114
|
+
/**
|
|
1115
|
+
* Dashboard warning links (#264): the board's overlap pairs and the live cohorts, as task and peer ids only.
|
|
1116
|
+
* Paths, symbols and plans stay out; PII tasks are excluded by the same rules overlapHits applies.
|
|
1117
|
+
* ponytail: O(open²) board reads per call, fine at dashboard board sizes; cache by board revision if it ever shows.
|
|
1118
|
+
*/
|
|
1119
|
+
overlapLinks(): { tasks: [number, number]; peers: string[] }[] {
|
|
1120
|
+
const links: { tasks: [number, number]; peers: string[] }[] = [];
|
|
1121
|
+
const seen = new Set<string>();
|
|
1122
|
+
for (const task of this.d.board.list()) {
|
|
1123
|
+
if (!OPEN.includes(task.state) || !task.owner || this.isPii(task)) continue;
|
|
1124
|
+
for (const hit of this.overlapHits(task)) {
|
|
1125
|
+
const pair = [task.id, hit.task.id].sort((a, b) => a - b) as [number, number];
|
|
1126
|
+
const key = pair.join(":");
|
|
1127
|
+
if (seen.has(key)) continue;
|
|
1128
|
+
seen.add(key);
|
|
1129
|
+
links.push({ tasks: pair, peers: [...new Set([task.owner, hit.task.owner!])].sort() });
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
return links;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** The live cohorts as dashboard links (#264): member task ids and their owners, nothing else. */
|
|
1136
|
+
cohortLinks(): { tasks: number[]; peers: string[] }[] {
|
|
1137
|
+
const links: { tasks: number[]; peers: string[] }[] = [];
|
|
1138
|
+
for (const cohort of this.cohorts.list()) {
|
|
1139
|
+
const members = [...cohort.members.values()].filter(member => { const task = this.d.board.get(member.task); return task && !this.isPii(task); });
|
|
1140
|
+
if (members.length < 2) continue;
|
|
1141
|
+
links.push({ tasks: members.map((m) => m.task).sort((a, b) => a - b), peers: [...new Set(members.map((m) => m.owner))].sort() });
|
|
1142
|
+
}
|
|
1143
|
+
return links;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
656
1146
|
/** While a gone owner's tasks move, its other tasks are about to move too: they are no one to settle with. */
|
|
657
1147
|
private releasing: PeerId | undefined;
|
|
658
1148
|
|
|
@@ -681,6 +1171,16 @@ export class Tasks {
|
|
|
681
1171
|
* for a recorded notice to this peer whose task is gone, has another owner, or left the states it was about.
|
|
682
1172
|
*/
|
|
683
1173
|
relevant = (peer: PeerId, env: Envelope): boolean => {
|
|
1174
|
+
if (env.from === HUB && env.kind === "task" && env.refs?.backlog) {
|
|
1175
|
+
const ids = env.refs.backlog;
|
|
1176
|
+
if (!Array.isArray(ids) || !ids.length || ids.length > 64 || ids.some(id => !Number.isSafeInteger(id) || id <= 0)) return false;
|
|
1177
|
+
const current = ids.filter(id => this.d.board.get(id)?.state === "backlog");
|
|
1178
|
+
if (current.length !== ids.length || !this.d.roles?.[peer]?.includes("planner") || !this.d.bus.isRostered(peer)) {
|
|
1179
|
+
this.addPlannerBacklog(peer, current);
|
|
1180
|
+
return false;
|
|
1181
|
+
}
|
|
1182
|
+
return true;
|
|
1183
|
+
}
|
|
684
1184
|
const c = this.conditional.get(env.id);
|
|
685
1185
|
if (!c || c.peer !== peer) return true;
|
|
686
1186
|
const t = this.d.board.get(c.task);
|
|
@@ -714,7 +1214,7 @@ export class Tasks {
|
|
|
714
1214
|
*/
|
|
715
1215
|
private places(task: Task): { paths: string[]; symbols: string[] } {
|
|
716
1216
|
const paths = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].map((p) => this.projectPath(p));
|
|
717
|
-
return { paths: [...new Set(paths)], symbols: task.plan?.symbols ?? [] };
|
|
1217
|
+
return { paths: [...new Set(paths)], symbols: [...(task.plan?.symbols ?? []), ...(task.plan?.sections ?? []).map(v => `section ${v}`), ...(task.plan?.sources ?? []).map(v => `source ${v}`)] };
|
|
718
1218
|
}
|
|
719
1219
|
|
|
720
1220
|
private roots?: string[];
|
|
@@ -738,7 +1238,7 @@ export class Tasks {
|
|
|
738
1238
|
return this.d.board.list().flatMap((t) => {
|
|
739
1239
|
if (t.id === task.id || !t.owner || t.owner === task.owner || t.owner === this.releasing || !OPEN.includes(t.state) || this.isPii(t)) return [];
|
|
740
1240
|
const theirs = this.places(t);
|
|
741
|
-
const paths = mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
|
|
1241
|
+
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
1242
|
const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
|
|
743
1243
|
return paths.length || symbols.length ? [{ task: t, paths, symbols }] : [];
|
|
744
1244
|
});
|
|
@@ -778,7 +1278,7 @@ export class Tasks {
|
|
|
778
1278
|
// Awaits run between tasks (briefs, memory): re-read, and stop for anything that changed meanwhile.
|
|
779
1279
|
const task = this.d.board.get(id);
|
|
780
1280
|
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;
|
|
1281
|
+
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
1282
|
try {
|
|
783
1283
|
const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
|
|
784
1284
|
const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", reason: "offline", note: why });
|
|
@@ -802,18 +1302,20 @@ export class Tasks {
|
|
|
802
1302
|
|
|
803
1303
|
/** Same code as assignment, without doing it. `routing`: a candidate policy to explain under (the settings preview, #269). */
|
|
804
1304
|
explain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }, routing: Routing = this.d.routing()): string[] {
|
|
1305
|
+
const workflow = this.d.sdlc?.config().workflow === "gsd-lite" ? [`workflow: gsd-lite (${this.d.sdlc.config().mode}); checklist guidance, board dependencies authoritative`] : [];
|
|
805
1306
|
if (typeof target === "number") {
|
|
806
1307
|
const task = this.d.board.get(target);
|
|
807
1308
|
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) });
|
|
1309
|
+
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
1310
|
// 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 ?? [])];
|
|
1311
|
+
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
1312
|
}
|
|
812
1313
|
const draft = { title: target.title, detail: target.detail ?? "", refs: target.refs ?? {} };
|
|
813
|
-
const signals =
|
|
814
|
-
const
|
|
1314
|
+
const signals = this.signalsFor(draft, routing);
|
|
1315
|
+
const base = profileClass(target.class, this.d.profileData ?? this.d.profile);
|
|
1316
|
+
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
1317
|
// 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;
|
|
1318
|
+
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
1319
|
}
|
|
818
1320
|
|
|
819
1321
|
/** Public read path: every generated line is screened, including profile/config-derived trace fields. */
|
|
@@ -855,9 +1357,12 @@ export class Tasks {
|
|
|
855
1357
|
}
|
|
856
1358
|
|
|
857
1359
|
/** `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> {
|
|
1360
|
+
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> {
|
|
1361
|
+
if (task.state === "backlog") throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
|
|
859
1362
|
const waits = this.waitsFor(task);
|
|
860
|
-
const
|
|
1363
|
+
const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
|
|
1364
|
+
const plannerFull = plannedMove ? this.plannerFull(task) : [];
|
|
1365
|
+
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
1366
|
|
|
862
1367
|
if (waits.length) {
|
|
863
1368
|
this.releaseNotice(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
|
|
@@ -865,7 +1370,9 @@ export class Tasks {
|
|
|
865
1370
|
}
|
|
866
1371
|
const passed = this.passedOver(task, a);
|
|
867
1372
|
if (!a.owner) {
|
|
868
|
-
this.releaseNotice(
|
|
1373
|
+
this.releaseNotice(opts.roster
|
|
1374
|
+
? `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>`
|
|
1375
|
+
: `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
1376
|
// Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
|
|
870
1377
|
if (opts.clearOnFail && task.owner) {
|
|
871
1378
|
this.cohorts.leave(task.id);
|
|
@@ -877,7 +1384,7 @@ export class Tasks {
|
|
|
877
1384
|
// A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
|
|
878
1385
|
// out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
|
|
879
1386
|
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! } : {}) });
|
|
1387
|
+
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
1388
|
// What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
|
|
882
1389
|
// escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
|
|
883
1390
|
// yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
|
|
@@ -925,25 +1432,61 @@ export class Tasks {
|
|
|
925
1432
|
return next;
|
|
926
1433
|
}
|
|
927
1434
|
|
|
1435
|
+
/** Person-only advice changes never change ownership, class or authority. */
|
|
1436
|
+
setRole(by: PeerId, id: unknown, raw: unknown): Task {
|
|
1437
|
+
if (by !== USER) throw new PreEffectToolRefusal("only the person may change an existing task catalog role");
|
|
1438
|
+
if (raw === undefined) throw new PreEffectToolRefusal("name a catalog role id or explicit null to clear it");
|
|
1439
|
+
const task = this.need(id, false, true);
|
|
1440
|
+
if (task.state === "approved") throw new PreEffectToolRefusal("an approved task's role is historical; propose new work instead");
|
|
1441
|
+
let selected;
|
|
1442
|
+
try { selected = taskRole(raw === null ? undefined : raw, task.class, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
|
|
1443
|
+
catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
|
|
1444
|
+
if (task.role === selected?.id) return task;
|
|
1445
|
+
const next = this.d.board.update(task.id, by, "catalog role changed", { role: selected?.id });
|
|
1446
|
+
for (const peer of new Set([next.owner, next.state === "in_review" ? next.reviewer : null])) {
|
|
1447
|
+
if (!peer || peer === USER) continue;
|
|
1448
|
+
this.releaseNotice(`task ${this.publicTitle(next)}: catalog role update delivery unconfirmed`, () => this.d.bus.publish(newEnvelope(HUB,
|
|
1449
|
+
`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."}`,
|
|
1450
|
+
{ to: [peer], kind: "task", refs: { task: String(next.id) }, ...(this.isPii(next) ? { private: true } : {}) })));
|
|
1451
|
+
}
|
|
1452
|
+
return next;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/** A self-claim has no assignment envelope; its tool result carries the same bounded guidance. */
|
|
1456
|
+
claimRoleGuidance(task: Task, peer: PeerId): string {
|
|
1457
|
+
return task.owner === peer && task.state === "in_progress" ? this.roleGuidance(task, peer) : "";
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
private roleGuidance(task: Task, peer: PeerId, review = false): string {
|
|
1461
|
+
if (!task.role) return "";
|
|
1462
|
+
if (!review && this.d.sessionRoles?.(peer).includes(task.role)) return `Task role ${task.role} (already in your session guidance).`;
|
|
1463
|
+
const role = (this.d.catalog ?? loadRoleCatalog(this.d.cwd)).roles.get(task.role);
|
|
1464
|
+
if (role) try { return taskRoleText(role, review); } catch { /* a changed definition cannot break a saved assignment */ }
|
|
1465
|
+
return `Task role ${task.role}: definition unavailable or exceeds the task guidance limit; grants no authority.`;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
928
1468
|
/** `contextWithheld`: the PII screen did not clear a budget hand-off (#198); an escalation's reason is the hub's own words. */
|
|
929
1469
|
private async sendTask(task: Task, a: Assignment, context?: string, overlap = "", contextWithheld = false): Promise<void> {
|
|
930
1470
|
const pii = this.isPii(task);
|
|
931
1471
|
const brief = pii ? undefined : await this.d.briefs?.forTask(task.owner!, task).catch(() => undefined);
|
|
932
1472
|
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
1473
|
const handoff = context && !pii ? this.screen(task, context, "handoff", a.owner, contextWithheld).slice(0, 3000) : "";
|
|
934
|
-
const facts = [`class ${task
|
|
1474
|
+
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
1475
|
const plan = planText(task.plan);
|
|
936
1476
|
const body = [
|
|
937
|
-
`Task #${task.id} [${task
|
|
1477
|
+
`Task #${task.id} [${profileTaskLabel(task)}] ${task.title}`,
|
|
938
1478
|
task.detail,
|
|
939
1479
|
`Facts: ${facts}`,
|
|
1480
|
+
this.roleGuidance(task, task.owner!),
|
|
940
1481
|
plan ? `Plan so far: ${plan}` : "",
|
|
941
1482
|
overlap,
|
|
942
1483
|
rejected.length ? `Earlier review notes:\n${rejected.join("\n")}` : "",
|
|
943
1484
|
brief ?? "",
|
|
944
1485
|
// What the previous owner left behind. Peer-written free text: never attached to a PII task.
|
|
945
1486
|
handoff ? `Handoff from the previous owner:\n${handoff}` : "",
|
|
946
|
-
|
|
1487
|
+
task.state === "in_review"
|
|
1488
|
+
? `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.`
|
|
1489
|
+
: `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
1490
|
].filter(Boolean).join("\n\n");
|
|
948
1491
|
// A hand-over the owner takes in a turn of its own (#109, #115): idle before, busy after and not queued, this envelope
|
|
949
1492
|
// was in the delivery that started its turn. Busy before, it is queued, or steered into a turn about something else
|
|
@@ -1010,8 +1553,9 @@ export class Tasks {
|
|
|
1010
1553
|
*/
|
|
1011
1554
|
async releaseReady(): Promise<void> {
|
|
1012
1555
|
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;
|
|
1556
|
+
const last = t.history.findLast(entry => !QUIET_EVENTS.has(entry.event))?.event, planned = this.plannerPullRecord(t)?.pull === true;
|
|
1557
|
+
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;
|
|
1558
|
+
if (planned && !this.plannerAssignment(t, true).owner) continue;
|
|
1015
1559
|
// ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
|
|
1016
1560
|
// an offline one; hold reserved tasks for a grace period if restarts show that up.
|
|
1017
1561
|
if (!assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.health() }).owner) continue; // nobody attached can take it yet
|
|
@@ -1025,11 +1569,14 @@ export class Tasks {
|
|
|
1025
1569
|
try {
|
|
1026
1570
|
const t = this.d.board.get(stale.id);
|
|
1027
1571
|
if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
|
|
1572
|
+
const planned = this.plannerPullRecord(t)?.pull === true;
|
|
1573
|
+
const selected = planned ? this.plannerAssignment(t, true).owner : undefined;
|
|
1574
|
+
if (planned && !selected) return;
|
|
1028
1575
|
this.offered.add(t.id);
|
|
1029
1576
|
offering = true;
|
|
1030
1577
|
const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
|
|
1031
1578
|
this.releaseNotice(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
|
|
1032
|
-
await this.assignOwner(ready, HUB);
|
|
1579
|
+
await this.assignOwner(ready, HUB, planned ? { candidates: [selected!], namedOwner: selected!, planner: true } : {});
|
|
1033
1580
|
} catch (e) {
|
|
1034
1581
|
// A saved move whose delivery failed is not "could not be assigned" (#297); the console notice was printed
|
|
1035
1582
|
// where the error was thrown. The offer stays consumed by the error's own evidence, without the readback.
|
|
@@ -1051,12 +1598,88 @@ export class Tasks {
|
|
|
1051
1598
|
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
1599
|
}
|
|
1053
1600
|
|
|
1601
|
+
/** Artifact links and claimed local gates stay on the board; no outside text is accepted. */
|
|
1602
|
+
recordLifecycle(by: PeerId, id: unknown, input: unknown): Task {
|
|
1603
|
+
const task = this.need(id, false, true);
|
|
1604
|
+
if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
|
|
1605
|
+
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
1606
|
+
const next = sdlcReceipt(input, task.sdlc, config);
|
|
1607
|
+
const definition = stageDefinition(config, next.stage);
|
|
1608
|
+
if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
|
|
1609
|
+
const actor = definition?.actor;
|
|
1610
|
+
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"));
|
|
1611
|
+
if (!acts) throw new PreEffectToolRefusal(`lifecycle stage requires its ${actor ?? "unknown"} actor or the person`);
|
|
1612
|
+
}
|
|
1613
|
+
if (input && typeof input === "object" && "phase" in input) {
|
|
1614
|
+
this.mine(task, by, "reviewer", true);
|
|
1615
|
+
if (next.phase && !this.isPii(task) && !this.nameable(JSON.stringify(next.phase))) throw new PreEffectToolRefusal("phase metadata matches a PII pattern; use private work");
|
|
1616
|
+
if (next.phase) validatePhaseLinks(task, next.phase, id => this.d.board.get(id), t => this.isPii(t));
|
|
1617
|
+
}
|
|
1618
|
+
// Recording a head is the reviewer's; a head dropped because the repo or PR changed is not a recording, and a
|
|
1619
|
+
// head supplied with a new PR identity is one even when its value is unchanged (#382).
|
|
1620
|
+
const suppliedHead = !!input && typeof input === "object" && (input as { reviewedHead?: unknown }).reviewedHead !== undefined;
|
|
1621
|
+
const artifactChanged = next.repo !== task.sdlc?.repo || next.pr !== task.sdlc?.pr;
|
|
1622
|
+
if (next.reviewedHead !== undefined && (next.reviewedHead !== task.sdlc?.reviewedHead || (suppliedHead && artifactChanged))) this.mine(task, by, "reviewer", true);
|
|
1623
|
+
return this.d.board.update(task.id, by, "lifecycle", { sdlc: next });
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
async deliveryQueue(repo: string) {
|
|
1627
|
+
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 })));
|
|
1628
|
+
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) }));
|
|
1629
|
+
const report = await readMergeQueue(repo, this.d.sdlc?.config() ?? sdlcConfig(undefined), links, this.d.sdlc?.gh);
|
|
1630
|
+
// Policy can change across remote awaits without any board write. Never return its stale public projection.
|
|
1631
|
+
const currentPrivacy = this.d.board.list().map(task => ({ id: task.id, private: this.isPii(task) }));
|
|
1632
|
+
if (workflowHash(links.map(({ id, private: hidden }) => ({ id, private: hidden }))) !== workflowHash(currentPrivacy)) {
|
|
1633
|
+
return { ...report, complete: false, rows: [], order: [], unordered: [], conflicts: [] };
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
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 })));
|
|
1637
|
+
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"); } }
|
|
1638
|
+
return report;
|
|
1639
|
+
}
|
|
1640
|
+
async releaseReadiness(repo: string, liveIssues: number[]) {
|
|
1641
|
+
const report = await readReleaseReadiness(this.d.cwd, repo, liveIssues, { gh: this.d.sdlc?.gh });
|
|
1642
|
+
if (report.documents) report.documents.documents = report.documents.documents.map(d => {
|
|
1643
|
+
if (d.path && (!this.nameable(d.path) || matchesPolicy(this.d.cwd, d.path, this.d.piiPaths ?? [], true))) { const { path: _privatePath, ...metadata } = d; return metadata; }
|
|
1644
|
+
return d;
|
|
1645
|
+
});
|
|
1646
|
+
return report;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
private workflowView(task: Task) {
|
|
1650
|
+
return phaseView(this.d.sdlc?.config() ?? sdlcConfig(undefined), task, id => this.d.board.get(id), t => this.isPii(t));
|
|
1651
|
+
}
|
|
1652
|
+
workflowExport(id: unknown): Record<string, unknown> {
|
|
1653
|
+
const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
1654
|
+
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 }) };
|
|
1655
|
+
return { schema: 1, workflow: config.workflow ?? "none", state, stateHash: workflowHash(state), sourceHash: workflowHash(config) };
|
|
1656
|
+
}
|
|
1657
|
+
async lifecycleStatus(id: unknown, refresh = true): Promise<SdlcStatus> {
|
|
1658
|
+
const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
1659
|
+
const evidence = task.sdlc && !this.isPii(task) && refresh && config.mode !== "off" ? await readSdlcPr(task.sdlc, this.d.sdlc?.gh) : undefined;
|
|
1660
|
+
return sdlcStatus(config, task.sdlc, evidence, this.workflowView(task));
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string): Promise<void> {
|
|
1664
|
+
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
1665
|
+
if (config.mode === "off") return;
|
|
1666
|
+
const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
|
|
1667
|
+
const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, this.d.sdlc?.gh) : undefined;
|
|
1668
|
+
const current = this.need(task.id, false, true);
|
|
1669
|
+
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`);
|
|
1670
|
+
const status = sdlcStatus(config, receipt, evidence, this.workflowView(current));
|
|
1671
|
+
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;
|
|
1672
|
+
if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
|
|
1673
|
+
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("; ")}`);
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1054
1676
|
private need(id: unknown, open = false, preEffect = false): Task {
|
|
1055
1677
|
// Callers are models (#70): an id is a whole number or a digit string, never an array that Number() would accept.
|
|
1056
1678
|
const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
|
|
1057
1679
|
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
1680
|
const task = this.d.board.get(n);
|
|
1059
1681
|
if (!task) throw new (preEffect ? PreEffectToolRefusal : Error)(`no task #${n}`);
|
|
1682
|
+
if (task.state === "backlog") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${n} is backlog: pull it into To Do first`);
|
|
1060
1683
|
// Handing a task to someone else only makes sense while there is work left on it.
|
|
1061
1684
|
if (open && !OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: it can no longer change hands`);
|
|
1062
1685
|
return task;
|
|
@@ -1072,10 +1695,10 @@ export class Tasks {
|
|
|
1072
1695
|
this.ready(task, true);
|
|
1073
1696
|
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
1697
|
// Models send null or {} for an optional field they leave empty: neither replaces a plan.
|
|
1075
|
-
const given = plan == null ? undefined : cleanPlan(plan);
|
|
1698
|
+
const given = plan == null ? undefined : cleanPlan(plan, (task.profile ?? "dev") === (this.d.profile ?? "dev") ? this.d.profileData ?? task.profile ?? "dev" : task.profile ?? "dev");
|
|
1076
1699
|
const cleaned = given && Object.keys(given).length ? given : undefined;
|
|
1077
1700
|
// 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:
|
|
1701
|
+
if (cleaned && !this.isPii(task) && this.isPii({ signals: this.signalsFor({ title: "", detail: planText(cleaned), refs: {}, plan: cleaned }) })) {
|
|
1079
1702
|
throw new PreEffectToolRefusal("this plan matches a PII pattern and is not kept: other owners would see it");
|
|
1080
1703
|
}
|
|
1081
1704
|
const before = new Set(this.overlapHits(task).map((h) => h.task.id));
|
|
@@ -1138,9 +1761,11 @@ export class Tasks {
|
|
|
1138
1761
|
const task = this.need(id, true, true);
|
|
1139
1762
|
this.mine(task, by, "owner", true);
|
|
1140
1763
|
// The owner it is declined for goes on the entry, also when the console declines for it: `excluded()` keeps it out (#207).
|
|
1141
|
-
const
|
|
1764
|
+
const planned = this.plannerPullRecord(task)?.pull === true;
|
|
1765
|
+
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 } : {});
|
|
1766
|
+
if (planned) this.offered.delete(task.id);
|
|
1142
1767
|
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 });
|
|
1768
|
+
return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(planned ? { planner: true } : {}) });
|
|
1144
1769
|
}
|
|
1145
1770
|
|
|
1146
1771
|
/** Tasks whose check is queued or running, and the owner it was started for. */
|
|
@@ -1168,6 +1793,7 @@ export class Tasks {
|
|
|
1168
1793
|
// The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
|
|
1169
1794
|
const withheld = await this.withholds("summary", summary, valid());
|
|
1170
1795
|
let task = valid();
|
|
1796
|
+
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "review"); task = valid(); }
|
|
1171
1797
|
// A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
|
|
1172
1798
|
const cohort = this.cohorts.of(task.id);
|
|
1173
1799
|
const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
|
|
@@ -1197,8 +1823,8 @@ export class Tasks {
|
|
|
1197
1823
|
else if (evidence) this.doneNotes.set(task.id, `Overlapping work finished while you worked (no turn-free integration step ran):\n${evidence}`);
|
|
1198
1824
|
}
|
|
1199
1825
|
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;
|
|
1201
|
-
if (!command) return this.complete(task, by, summary, refs, "", withheld);
|
|
1826
|
+
const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
|
|
1827
|
+
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review");
|
|
1202
1828
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
1203
1829
|
this.checking.set(task.id, task.owner);
|
|
1204
1830
|
const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary, { withheld: withheld || undefined });
|
|
@@ -1241,7 +1867,7 @@ export class Tasks {
|
|
|
1241
1867
|
const at = [...u.history].reverse().find((h) => h.event === "done");
|
|
1242
1868
|
if (u.owner === t.owner || !at || at.at < handedAt(t)) return [];
|
|
1243
1869
|
const theirs = this.places(u);
|
|
1244
|
-
const paths = mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
|
|
1870
|
+
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
1871
|
const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
|
|
1246
1872
|
return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note, at.withheld)] : [];
|
|
1247
1873
|
});
|
|
@@ -1324,13 +1950,23 @@ export class Tasks {
|
|
|
1324
1950
|
this.tell(task, `Task #${id}: its check failed.\n$ ${outcome}${result.tail ? `\n${result.tail}` : ""}\nFix it and call hub_task_done again.`, pii);
|
|
1325
1951
|
}
|
|
1326
1952
|
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1953
|
+
/** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
|
|
1954
|
+
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review"): Promise<Task> {
|
|
1955
|
+
let reviewer = task.reviewer;
|
|
1956
|
+
if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
|
|
1957
|
+
await this.lifecycleGate(task, reviewer ? "review" : "approval");
|
|
1958
|
+
task = this.need(task.id, false, true);
|
|
1959
|
+
this.mine(task, by, "owner", true);
|
|
1960
|
+
// In every mode: another done may have landed during the readback.
|
|
1961
|
+
if (task.state === "in_review" || task.state === "approved") throw new Error(`task #${task.id} is already ${task.state}`);
|
|
1962
|
+
reviewer = task.reviewer;
|
|
1963
|
+
}
|
|
1964
|
+
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
1965
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
1331
1966
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
|
|
1332
1967
|
this.tellCompleted(next, summary, withheld);
|
|
1333
1968
|
if (!reviewer) {
|
|
1969
|
+
this.settleMemory(next);
|
|
1334
1970
|
if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true); // approved without a review is a success too
|
|
1335
1971
|
this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
|
|
1336
1972
|
await this.releaseDependents(next);
|
|
@@ -1405,11 +2041,15 @@ export class Tasks {
|
|
|
1405
2041
|
`- List what is unmet in hub_review's unmet, one item each.`,
|
|
1406
2042
|
].join("\n");
|
|
1407
2043
|
const summary = last?.note ? this.screen(task, last.note, "summary", reviewer, last.withheld) : "(no summary)";
|
|
1408
|
-
const
|
|
2044
|
+
const guidance = this.roleGuidance(task, reviewer, true);
|
|
2045
|
+
let projection = "";
|
|
2046
|
+
try { const text = this.d.projectedReview?.(task); if (text) projection = this.screen(task, sanitize(text), "document projection", reviewer); }
|
|
2047
|
+
catch { projection = "(document projection unavailable)"; }
|
|
2048
|
+
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
2049
|
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
2050
|
}
|
|
1411
2051
|
|
|
1412
|
-
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown): Promise<Task> {
|
|
2052
|
+
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown): Promise<Task> {
|
|
1413
2053
|
const valid = (): Task => {
|
|
1414
2054
|
const t = this.need(id, false, true);
|
|
1415
2055
|
this.mine(t, by, "reviewer", true);
|
|
@@ -1421,11 +2061,17 @@ export class Tasks {
|
|
|
1421
2061
|
if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
|
|
1422
2062
|
// The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
|
|
1423
2063
|
const withheld = await this.withholds("review note", note, valid());
|
|
1424
|
-
|
|
2064
|
+
let task = valid();
|
|
2065
|
+
let reviewedHead: string | undefined;
|
|
2066
|
+
if (head !== undefined) {
|
|
2067
|
+
const checked = sdlcReceipt({ reviewedHead: head }, task.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined));
|
|
2068
|
+
reviewedHead = checked.reviewedHead;
|
|
2069
|
+
}
|
|
2070
|
+
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead); task = valid(); }
|
|
1425
2071
|
const pii = this.isPii(task);
|
|
1426
2072
|
const flag = { withheld: withheld || undefined };
|
|
1427
2073
|
if (verdict === "approved") {
|
|
1428
|
-
const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0 }, note, flag);
|
|
2074
|
+
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
2075
|
if (next.owner) {
|
|
1430
2076
|
this.d.board.recordOutcome(next.owner, next.class, true);
|
|
1431
2077
|
this.d.board.recordReview({ implementer: next.owner, reviewer: by, class: next.class, kind: "approved", task: next.id });
|
|
@@ -1435,6 +2081,7 @@ export class Tasks {
|
|
|
1435
2081
|
}
|
|
1436
2082
|
}
|
|
1437
2083
|
this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`, withheld);
|
|
2084
|
+
this.settleMemory(next);
|
|
1438
2085
|
this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
|
|
1439
2086
|
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
|
|
1440
2087
|
await this.releaseDependents(next);
|
|
@@ -1507,10 +2154,36 @@ export class Tasks {
|
|
|
1507
2154
|
}
|
|
1508
2155
|
|
|
1509
2156
|
/** 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
|
-
|
|
2157
|
+
async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner"): Promise<Task> {
|
|
2158
|
+
if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or reviewer");
|
|
2159
|
+
if (role === "reviewer" && by !== USER) {
|
|
2160
|
+
const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
|
|
2161
|
+
if (conductors.length !== 1 || conductors[0] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the configured conductor may name a reviewer");
|
|
2162
|
+
}
|
|
2163
|
+
const task = this.need(id, role === "owner", preEffect);
|
|
2164
|
+
if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster; add it to the roster before assigning work`);
|
|
2165
|
+
if (role === "reviewer") {
|
|
2166
|
+
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`);
|
|
2167
|
+
this.ready(task, preEffect);
|
|
2168
|
+
const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
|
|
2169
|
+
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("; ")}`);
|
|
2170
|
+
const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, `named reviewer ${peer}`, { reason: "manual" });
|
|
2171
|
+
if (next.state === "in_review" && task.reviewer && task.reviewer !== peer) {
|
|
2172
|
+
const previous = task.reviewer;
|
|
2173
|
+
this.releaseNotice(`task ${this.publicTitle(next)}: could not notify previous reviewer ${previous} that its review request is withdrawn`, () => {
|
|
2174
|
+
for (const env of this.d.bus.snapshot(false).queues[previous] ?? []) {
|
|
2175
|
+
if (env.from === HUB && env.kind === "review" && env.refs?.task === String(task.id)) this.d.bus.withdraw(env.id);
|
|
2176
|
+
}
|
|
2177
|
+
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.`;
|
|
2178
|
+
if (previous === USER) this.d.notify(body);
|
|
2179
|
+
else this.d.bus.publish(newEnvelope(HUB, body, { to: [previous], kind: "status", priority: "important", refs: { task: String(task.id) }, ...(this.isPii(next) ? { private: true } : {}) }));
|
|
2180
|
+
});
|
|
2181
|
+
}
|
|
2182
|
+
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));
|
|
2183
|
+
return next;
|
|
2184
|
+
}
|
|
1512
2185
|
// 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 });
|
|
2186
|
+
if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], namedOwner: peer, event: "reassigned", reason: "manual", override: by === USER });
|
|
1514
2187
|
const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" });
|
|
1515
2188
|
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
2189
|
return next;
|
|
@@ -1521,35 +2194,70 @@ export class Tasks {
|
|
|
1521
2194
|
* the usual constraints. Tasks it was reviewing get another reviewer. Moves are reported, never taken back automatically.
|
|
1522
2195
|
*/
|
|
1523
2196
|
async reassignForPause(peer: PeerId, context: string | undefined, urgentOnly = false): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
1524
|
-
|
|
2197
|
+
return this.reassignWork(peer, context, urgentOnly, false);
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
/** Read-only preview using the same assignment constraints as the roster relay. */
|
|
2201
|
+
rosterPreview(peer: PeerId): { id: number; title: string; role: "owner" | "reviewer"; to: PeerId | null }[] {
|
|
1525
2202
|
const routing = this.d.routing();
|
|
1526
|
-
|
|
2203
|
+
return this.d.board.list().filter(task => task.state !== "approved" && (task.owner === peer || task.reviewer === peer)).map(task => {
|
|
2204
|
+
const owner = task.owner === peer;
|
|
2205
|
+
const weights = this.weights(task.class);
|
|
2206
|
+
const pii = this.isPii(task);
|
|
2207
|
+
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) : [];
|
|
2208
|
+
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 } : {}) });
|
|
2209
|
+
return { id: task.id, title: this.publicTitle(task), role: owner ? "owner" : "reviewer", to: (owner ? a.owner : a.reviewer) ?? null };
|
|
2210
|
+
});
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
/** A roster removal offers work through the quota relay's constraints, preserving assignments nobody can take. */
|
|
2214
|
+
async reassignForRoster(peer: PeerId): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
2215
|
+
return this.reassignWork(peer, undefined, false, true);
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
private async reassignWork(peer: PeerId, context: string | undefined, urgentOnly: boolean, roster: boolean): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
2219
|
+
const moved: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[] = [];
|
|
2220
|
+
const routing = this.d.routing(), why = roster ? `roster removal of ${peer}` : `budget pause of ${peer}`;
|
|
2221
|
+
// Budget context is screened exactly as before; a roster removal supplies no agent-written context.
|
|
1527
2222
|
const moves = (t: Task) => (!urgentOnly || t.signals.includes("urgent")) && t.owner === peer && OPEN.includes(t.state);
|
|
1528
2223
|
const contextWithheld = context && this.d.board.list().some((t) => moves(t) && !this.isPii(t)) ? await this.withholds("handoff", context) : false;
|
|
1529
2224
|
for (const task of this.d.board.list()) {
|
|
1530
|
-
// Waiting out a window that resets soon (issue #36): only urgent work moves.
|
|
1531
2225
|
if (urgentOnly && !task.signals.includes("urgent")) continue;
|
|
1532
|
-
if (task.owner === peer && OPEN.includes(task.state)) {
|
|
2226
|
+
if (task.owner === peer && (OPEN.includes(task.state) || roster && task.state === "in_review")) {
|
|
1533
2227
|
const pii = this.isPii(task);
|
|
1534
2228
|
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
|
-
|
|
2229
|
+
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;
|
|
2230
|
+
// Budget keeps its original release/unassign behaviour for ordinary work. Roster changes the state in
|
|
2231
|
+
// assignOwner's same owner write only after routing has found somebody to take it.
|
|
2232
|
+
const back = !roster && task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
|
|
2233
|
+
let next: Task;
|
|
1536
2234
|
try {
|
|
1537
|
-
|
|
1538
|
-
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
2235
|
+
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
2236
|
} catch (e) {
|
|
1540
|
-
//
|
|
1541
|
-
//
|
|
2237
|
+
// The move is saved and its central #297 notice already spoke; relay the
|
|
2238
|
+
// remaining tasks and run the roster tail on the saved row as on success.
|
|
1542
2239
|
if (!(e instanceof AssignmentUndeliveredError)) throw e;
|
|
1543
|
-
|
|
2240
|
+
next = e.task;
|
|
2241
|
+
}
|
|
2242
|
+
if (roster) {
|
|
2243
|
+
if (!next.owner || next.owner === peer) continue;
|
|
2244
|
+
this.releaseNotice(`task ${this.publicTitle(next)} moved from ${peer} to ${next.owner} (off roster)`);
|
|
2245
|
+
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
2246
|
}
|
|
2247
|
+
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
1545
2248
|
} 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
2249
|
const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
|
|
1548
2250
|
const reviewer = a.reviewer && a.reviewer !== peer && a.reviewer !== task.owner ? a.reviewer : null;
|
|
1549
|
-
|
|
2251
|
+
if (roster && !reviewer) {
|
|
2252
|
+
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}`);
|
|
2253
|
+
continue;
|
|
2254
|
+
}
|
|
2255
|
+
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, why);
|
|
1550
2256
|
moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
|
|
1551
|
-
if (next.state === "in_review" && reviewer && reviewer !== USER)
|
|
1552
|
-
|
|
2257
|
+
if (next.state === "in_review" && reviewer && reviewer !== USER) {
|
|
2258
|
+
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.)`));
|
|
2259
|
+
else this.sendReview(next, reviewer, `(${peer} was reviewing this and is paused for quota.)`);
|
|
2260
|
+
} 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
2261
|
}
|
|
1554
2262
|
}
|
|
1555
2263
|
return moved;
|
|
@@ -1566,7 +2274,7 @@ export class Tasks {
|
|
|
1566
2274
|
return { ...(policy?.route ? { route: policy.route } : {}), ...(policy?.fixed_model ? { fixedModel: policy.fixed_model } : {}), pii, task: String(about.id) };
|
|
1567
2275
|
}
|
|
1568
2276
|
|
|
1569
|
-
async remember(by: PeerId, input: { text?: string; title?: string; kind?: string; task?: unknown }): Promise<string> {
|
|
2277
|
+
async remember(by: PeerId, input: { text?: string; title?: string; kind?: string; task?: unknown; horizon?: unknown; scope?: unknown }): Promise<string> {
|
|
1570
2278
|
const text = String(input.text ?? "").trim();
|
|
1571
2279
|
if (!text) throw new Error("text is required");
|
|
1572
2280
|
const task = input.task !== undefined ? this.d.board.get(Number(input.task)) : undefined;
|
|
@@ -1574,24 +2282,132 @@ export class Tasks {
|
|
|
1574
2282
|
// Shared with every other peer as well, so text that matches a PII pattern is refused like a note about a PII task.
|
|
1575
2283
|
const title = String(input.title ?? "").trim();
|
|
1576
2284
|
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 (
|
|
2285
|
+
if (input.horizon !== undefined && input.horizon !== "short" && input.horizon !== "long") throw new Error('horizon is "short" or "long"');
|
|
2286
|
+
if (input.scope !== undefined && input.scope !== "project" && input.scope !== "shared") throw new Error('scope is "project" or "shared"');
|
|
2287
|
+
if (!this.d.memory && !this.d.store) return "memory is disabled; nothing saved";
|
|
2288
|
+
// Long-term notes leave the machine (#258), and claude-mem's observer is a cloud model: a secret goes to neither.
|
|
2289
|
+
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");
|
|
2290
|
+
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
2291
|
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 (
|
|
2292
|
+
const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) as Kind : "finding";
|
|
2293
|
+
const native = this.saveNative(by, kind, text, title, task, input.horizon === "long" || input.horizon === "short" ? input.horizon : undefined, input.scope === "shared" ? "shared" : "project");
|
|
2294
|
+
if (task && this.d.memory) this.markMemoryReference(task, by);
|
|
2295
|
+
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;
|
|
2296
|
+
if (!res && !native) return this.d.memory ? "memory worker unavailable; nothing saved" : "memory store unavailable; nothing saved";
|
|
1582
2297
|
const line = noteLine(by, kind, title ? `${title}: ${text}` : text);
|
|
1583
2298
|
this.d.notify(line);
|
|
1584
2299
|
this.d.share?.(by, line);
|
|
1585
|
-
return
|
|
2300
|
+
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`;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
/** The task's key in the hub's store: unique across `ahub reset --all`, which restarts the ids. */
|
|
2304
|
+
private memoryKey(task: Task): string { return taskKey(this.d.store!.project, task); }
|
|
2305
|
+
|
|
2306
|
+
/**
|
|
2307
|
+
* 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
|
|
2308
|
+
* across projects is a person's). Refused before any board write.
|
|
2309
|
+
*/
|
|
2310
|
+
private goalFor(by: PeerId, ref: unknown): GoalView {
|
|
2311
|
+
if (!this.d.store) throw new PreEffectToolRefusal("goal: hub memory is off for this project");
|
|
2312
|
+
const g = typeof ref === "string" && ref.trim() ? this.d.store.store.goalOf(ref.trim().replace(/^goal:/, "")) : undefined;
|
|
2313
|
+
if (!g) throw new PreEffectToolRefusal(`goal: no long-term task ${JSON.stringify(ref).slice(0, 40)} (ahub goal list shows them)`);
|
|
2314
|
+
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");
|
|
2315
|
+
if (g.state === "done" || g.state === "dropped") throw new PreEffectToolRefusal(`goal: that long-term task is ${g.state}`);
|
|
2316
|
+
return g;
|
|
2317
|
+
}
|
|
2318
|
+
/** Fence deletion before external memory writes; one marker survives store absence. */
|
|
2319
|
+
private markMemoryReference(task: Task, by: PeerId): void {
|
|
2320
|
+
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", {});
|
|
2321
|
+
}
|
|
2322
|
+
/** Record a long-term link, never for PII; failures remain notices. */
|
|
2323
|
+
private linkNative(task: Task, goal: GoalView, by: PeerId): string {
|
|
2324
|
+
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)"; }
|
|
2325
|
+
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)}`; }
|
|
2326
|
+
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"; }
|
|
2327
|
+
}
|
|
2328
|
+
/** A person links a board task of this project to a long-term task (`ahub goal link`). */
|
|
2329
|
+
linkGoal(by: PeerId, id: unknown, ref: unknown): string {
|
|
2330
|
+
const task = this.d.board.get(Number(id));
|
|
2331
|
+
if (!task) throw new Error(`no task #${String(id).slice(0, 12)}`);
|
|
2332
|
+
const goal = this.goalFor(by, ref);
|
|
2333
|
+
return `task #${task.id}: ${this.linkNative(task, goal, by)}`;
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
/**
|
|
2337
|
+
* One note in the hub's own store (#258). Short-term while its task is open; on an approved task, or with horizon
|
|
2338
|
+
* long, decisions, contracts and fails are long-term. Fail-open: a store that cannot be written costs one notice.
|
|
2339
|
+
* The caller has screened the text for PII; never a PII task's.
|
|
2340
|
+
*/
|
|
2341
|
+
private saveNative(by: PeerId, kind: Kind, text: string, title: string, task: Task | undefined, horizon?: Tier, scope: Scope = "project"): { id: string; tier: Tier } | undefined {
|
|
2342
|
+
const st = this.d.store;
|
|
2343
|
+
if (!st || (task && this.isPii(task))) return undefined;
|
|
2344
|
+
try { // never throws: callers run after a board write
|
|
2345
|
+
// Approved, or settled in the store already: the snapshot the caller holds can predate an approval made while it awaited.
|
|
2346
|
+
const approved = !!task && (task.state === "approved" || st.store.settled(this.memoryKey(task)));
|
|
2347
|
+
const tier: Tier = horizon ?? (approved && PROMOTED_KINDS.includes(kind) ? "long" : "short");
|
|
2348
|
+
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 } } : {}) });
|
|
2349
|
+
// A short-term note on a task already approved (before hub memory existed, or late): the task is settled first, so
|
|
2350
|
+
// the note gets its own deadline and is never taken for a source waiting for promotion.
|
|
2351
|
+
if (task && approved && tier === "short" && !st.store.settled(this.memoryKey(task))) st.store.settle(this.memoryKey(task), by);
|
|
2352
|
+
if (task) this.markMemoryReference(task, by);
|
|
2353
|
+
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");
|
|
2354
|
+
st.onNote?.(id);
|
|
2355
|
+
return { id, tier };
|
|
2356
|
+
} catch (e) {
|
|
2357
|
+
this.releaseNotice(`a ${kind} note was not saved to hub memory (${(e as Error).message})`);
|
|
2358
|
+
return undefined;
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
/** An approved task's decision, contract and fail notes move to long-term; its short-term entries start to expire (#258). */
|
|
2363
|
+
/**
|
|
2364
|
+
* At hub start: an approved task whose settle entry a crash or a failed write cut off is settled now, so its
|
|
2365
|
+
* decisions, contracts and fails still reach long-term memory and its findings get their deadline.
|
|
2366
|
+
*/
|
|
2367
|
+
reconcileMemory(): number {
|
|
2368
|
+
if (!this.d.store) return 0;
|
|
2369
|
+
let n = 0;
|
|
2370
|
+
for (const t of this.d.board.list("approved")) {
|
|
2371
|
+
if (this.isPii(t)) continue;
|
|
2372
|
+
try { const key = this.memoryKey(t); if (this.d.store.store.hasTask(key) && !this.d.store.store.settled(key)) { this.settleMemory(t); n++; } }
|
|
2373
|
+
catch { /* fail-open: tried again at the next start */ }
|
|
2374
|
+
}
|
|
2375
|
+
return n;
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
private settleMemory(task: Task): void {
|
|
2379
|
+
if (!this.d.store || this.isPii(task)) return;
|
|
2380
|
+
// The store screens each note as it moves to the synced log (the promotion screen the daemon gives it).
|
|
2381
|
+
try { this.d.store.store.settle(this.memoryKey(task), task.reviewer ?? task.owner ?? HUB); }
|
|
2382
|
+
catch (e) { this.releaseNotice(`task #${task.id}: hub memory could not settle its notes (${(e as Error).message})`); }
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
/**
|
|
2386
|
+
* Whether a note names, or its task touches, a path the current `pii.paths` makes private: checked before either memory
|
|
2387
|
+
* backend sees it, whatever the task's signals said when it was proposed.
|
|
2388
|
+
*/
|
|
2389
|
+
private aboutPrivatePath(title: string, text: string, task: Task | undefined): boolean {
|
|
2390
|
+
const st = this.d.store;
|
|
2391
|
+
// No denylist here: a path the graph leaves out is still checked against the privacy policy.
|
|
2392
|
+
const about = extractNodes(`${title}\n${text}`, { cwd: this.d.cwd, project: st?.project ?? this.d.project, repo: st?.repo ?? this.d.project, deny: [],
|
|
2393
|
+
paths: [...(task?.refs.paths ?? []), ...(task?.plan?.paths ?? [])], symbols: task?.plan?.symbols });
|
|
2394
|
+
return mentionsPrivatePath(`${title}\n${text}`, about, this.d.piiPaths ?? [], this.d.cwd);
|
|
1586
2395
|
}
|
|
1587
2396
|
|
|
1588
2397
|
/** 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;
|
|
2398
|
+
private note(task: Task, by: PeerId, kind: Kind, text: string, withheld = false): void {
|
|
2399
|
+
if (this.isPii(task) || (!this.d.memory && !this.d.store)) return;
|
|
1591
2400
|
// 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).
|
|
2401
|
+
// not clear (#198), is not sent (#69). Long-term hub memory leaves the machine (#258): the same rule, and no secrets.
|
|
1593
2402
|
if (withheld || !this.nameable(text)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (${this.whyWithheld(text)})`);
|
|
1594
|
-
|
|
2403
|
+
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)`);
|
|
2404
|
+
const title = `agent-hub task #${task.id}: ${task.title}`.slice(0, 120);
|
|
2405
|
+
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)`);
|
|
2406
|
+
this.saveNative(by, kind, text, title, task);
|
|
2407
|
+
if (this.d.memory) {
|
|
2408
|
+
try { this.markMemoryReference(task, by); } catch { return this.releaseNotice(`task #${task.id}: memory reference could not be recorded; note was not saved`); }
|
|
2409
|
+
void this.d.memory.save({ text, title, project: this.d.project, metadata: { peer: by, task: task.id, kind } });
|
|
2410
|
+
}
|
|
1595
2411
|
}
|
|
1596
2412
|
|
|
1597
2413
|
private tell(task: Task, body: string, pii: boolean): void {
|