@staix/agent-hub 0.12.43 → 0.12.45
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 +38 -3
- package/README.md +3 -3
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +228 -0
- package/docs/agent-notes/README.md +14 -0
- package/docs/agent-notes/adapters.md +61 -42
- package/docs/agent-notes/budget.md +2 -2
- package/docs/agent-notes/daemon.md +16 -10
- package/docs/agent-notes/local-worker.md +4 -3
- package/docs/agent-notes/tasks.md +13 -6
- package/docs/agent-notes/tests.md +9 -7
- package/docs/operations.md +163 -12
- package/docs/quickstart.md +10 -2
- package/docs/sdlc.md +26 -8
- package/docs/security.md +11 -6
- package/docs/specs/2026-09-19-agent-hub-design.md +273 -8
- package/docs/verification/2026-10-21-0.12.44.md +54 -0
- package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
- package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
- package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
- package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
- package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
- package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
- package/docs/verification/2026-10-22-0.12.45.md +60 -0
- package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-board-768-light.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-board-390-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
- package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
- package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
- package/docs/verified.json +38 -20
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10287 -9560
- package/src/adapters/acp-owned-group.ts +124 -0
- package/src/adapters/acp.ts +92 -25
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +4 -4
- package/src/adapters/agy/entry.ts +4 -1
- package/src/adapters/channel-peer.ts +143 -0
- package/src/adapters/claude-channel.ts +21 -12
- package/src/adapters/codex-appserver.ts +17 -2
- package/src/adapters/configured-acp.ts +124 -0
- package/src/adapters/conformance/acp.ts +114 -0
- package/src/adapters/conformance/codex.ts +191 -0
- package/src/adapters/conformance/enrollment.ts +43 -0
- package/src/adapters/conformance/factories.ts +17 -0
- package/src/adapters/conformance/fake-paths.ts +12 -0
- package/src/adapters/conformance/fakes/acp-server.ts +267 -0
- package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
- package/src/adapters/conformance/fakes/app-server.ts +119 -0
- package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
- package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
- package/src/adapters/conformance/fakes/model-server.ts +39 -0
- package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
- package/src/adapters/conformance/index.ts +11 -0
- package/src/adapters/conformance/kit.ts +50 -0
- package/src/adapters/conformance/local-pi.ts +100 -0
- package/src/adapters/conformance/owned-acp.ts +44 -0
- package/src/adapters/definitions/builtin-metadata.ts +60 -39
- package/src/adapters/definitions/native-channel-state.ts +41 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +9 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/native-identity-defaults.ts +14 -0
- package/src/adapters/definitions/qwen.ts +7 -9
- package/src/adapters/definitions/reviewer-variants.ts +3 -7
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-module-scan.ts +126 -0
- package/src/adapters/external-modules.ts +240 -0
- package/src/adapters/jcode.ts +4 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/adapters/pi.ts +6 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +28 -16
- package/src/adapters/registry.ts +115 -17
- package/src/adapters/reviewer-print.ts +4 -0
- package/src/cli/agy-mcp.ts +3 -2
- package/src/cli/configured-acp-entry.ts +58 -0
- package/src/cli/console-state.ts +9 -4
- package/src/cli/facts-hook.ts +2 -1
- package/src/cli/help.ts +27 -16
- package/src/cli/identity.ts +8 -7
- package/src/cli/launch.ts +5 -4
- package/src/cli/main.ts +101 -32
- package/src/cli/memory.ts +13 -10
- package/src/cli/output.ts +1 -0
- package/src/cli/preview.ts +9 -0
- package/src/cli/recovery-guidance.ts +1 -1
- package/src/cli/terminal-recovery.ts +16 -13
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +6 -4
- package/src/cli/upgrade-runtime.ts +25 -7
- package/src/cli/upgrade.ts +3 -2
- package/src/hub/board.ts +37 -8
- package/src/hub/budget.ts +9 -2
- package/src/hub/bus.ts +6 -2
- package/src/hub/claude-state.ts +2 -37
- package/src/hub/conductor.ts +8 -6
- package/src/hub/config-apply.ts +37 -17
- package/src/hub/config-reload.ts +42 -11
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +6 -0
- package/src/hub/control-client.ts +4 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +412 -328
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +57 -5
- package/src/hub/instance-config.ts +33 -12
- package/src/hub/instance-execution.ts +23 -11
- package/src/hub/instances.ts +47 -22
- package/src/hub/lifecycle-schema.ts +5 -3
- package/src/hub/memory-profile.ts +16 -3
- package/src/hub/no-writes.ts +50 -0
- package/src/hub/peers.ts +9 -0
- package/src/hub/permission-mode.ts +9 -7
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +35 -6
- package/src/hub/routing.ts +21 -20
- package/src/hub/sdlc-queue.ts +19 -4
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +27 -12
- package/src/hub/settings.ts +54 -16
- package/src/hub/snapshots.ts +32 -6
- package/src/hub/start-mode.ts +10 -8
- package/src/hub/task-sweep.ts +8 -2
- package/src/hub/tasks.ts +358 -60
- package/src/hub/ui.ts +2 -2
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +563 -77
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
package/src/hub/tasks.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { completePlanInventory, planConformance, planConformanceBlockers, planFingerprint, type PlanConformance } from "./plan-conformance.ts";
|
|
1
2
|
import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
|
|
2
3
|
import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
|
|
3
4
|
import { specRefs } from "./spec-read.ts";
|
|
4
5
|
import type { InstancesConfig } from "./instances.ts";
|
|
5
6
|
import { composeRememberText } from "./memory-fail.ts";
|
|
6
7
|
import { VERSION } from "../version.ts";
|
|
8
|
+
import { NoWriteTurns } from "./no-writes.ts";
|
|
7
9
|
import { readGitHubImport, type GitHubSource, type GitHubImportSelection } from "./github-import.ts";
|
|
8
10
|
import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
|
|
9
11
|
import { readMergeQueue } from "./sdlc-queue.ts";
|
|
12
|
+
import { DEFAULT_MAX_NITS, parseReviewFindings, findingsForHead, findingHead as resolveFindingHead, findingTally, type ReviewFinding, type ReviewFindingSet } from "./review-findings.ts";
|
|
10
13
|
import { readReleaseReadiness } from "./sdlc-release.ts";
|
|
11
14
|
import { loadRoleCatalog, taskRole, taskRoleText, type RoleCatalog } from "./roles.ts";
|
|
12
15
|
import { matchesPolicy } from "./path-policy.ts";
|
|
@@ -31,7 +34,8 @@ import { TaskRepositories } from "./task-repository.ts";
|
|
|
31
34
|
import { mainCheckoutWarning } from "./main-checkout.ts";
|
|
32
35
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
33
36
|
import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief.ts";
|
|
34
|
-
import {
|
|
37
|
+
import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
|
|
38
|
+
import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
35
39
|
import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
36
40
|
import { readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
|
|
37
41
|
|
|
@@ -40,6 +44,7 @@ export type TaskAssignmentRole = "owner" | "reviewer";
|
|
|
40
44
|
export interface ReleaseWindow { at: number; host: boolean; quota: boolean; idle: boolean; reasons: string[]; current?: () => boolean; close?: () => Promise<void>; }
|
|
41
45
|
|
|
42
46
|
export interface TasksDeps {
|
|
47
|
+
adapterCatalog?: ProjectAdapterCatalog;
|
|
43
48
|
/** Host-bound evidence only; absent/expired observations never admit a release cut. */
|
|
44
49
|
releaseWindow?: () => Promise<ReleaseWindow>;
|
|
45
50
|
/** Effective per-peer planner capacity, taken from the existing settings loader. */
|
|
@@ -62,6 +67,10 @@ export interface TasksDeps {
|
|
|
62
67
|
sessionRoles?: (peer: PeerId) => string[];
|
|
63
68
|
/** Optional bounded snapshot presentation; never stored in task history or memory. */
|
|
64
69
|
projectedReview?: (task: Task) => string | undefined;
|
|
70
|
+
/** Closed host evidence since the current acceptance: false means proven no writes; undefined is unknown. */
|
|
71
|
+
ownerWrites?: (task: Task, since: number) => boolean | undefined;
|
|
72
|
+
/** Explicit acceptance/self-claim only; implicit done has no pre-work baseline. */
|
|
73
|
+
ownerAccepted?: (task: Task) => void;
|
|
65
74
|
/** #520: the review brief for a task's commit (git only, bounded); undefined when the task names no commit. */
|
|
66
75
|
reviewBrief?: (task: Task) => ReviewBrief | { unavailable: string } | undefined;
|
|
67
76
|
sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
|
|
@@ -167,6 +176,7 @@ const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
|
|
|
167
176
|
const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
168
177
|
/** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
|
|
169
178
|
const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
|
|
179
|
+
QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
|
|
170
180
|
|
|
171
181
|
/**
|
|
172
182
|
* Thrown when an assignment's board write is saved but a publish or notify after it failed (#297): the move is
|
|
@@ -243,9 +253,17 @@ function acceptRefs(input: unknown): TaskRefs | undefined {
|
|
|
243
253
|
/** The same for a plan (issue #31): only its four lists, each of short strings. */
|
|
244
254
|
function cleanPlan(input: unknown, profile: ProfileSelection = "dev"): TaskPlan {
|
|
245
255
|
const r = fields(input);
|
|
256
|
+
const evidence: Pick<TaskPlan, "proof" | "risks"> = {};
|
|
257
|
+
for (const key of ["proof", "risks"] as const) {
|
|
258
|
+
const value = r[key];
|
|
259
|
+
if (value === undefined) continue;
|
|
260
|
+
if (!Array.isArray(value) || value.length > 32 || value.some(item => typeof item !== "string" || item.length > 512)) throw new PreEffectToolRefusal(`plan.${key}: expected at most 32 strings of at most 512 characters`);
|
|
261
|
+
const items = value.map(item => item.replace(/\s+/g, " ").trim()).filter(Boolean);
|
|
262
|
+
if (items.length) evidence[key] = items;
|
|
263
|
+
}
|
|
246
264
|
let scope: CostPlan;
|
|
247
265
|
try { scope = cleanCostPlan(r); } catch { throw new PreEffectToolRefusal("invalid review or CI cost scope"); }
|
|
248
|
-
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)), ...scope };
|
|
266
|
+
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)), ...evidence, ...scope };
|
|
249
267
|
}
|
|
250
268
|
|
|
251
269
|
/** A plan as one line for other owners; the whole plan is on the board. */
|
|
@@ -269,9 +287,11 @@ export class Tasks {
|
|
|
269
287
|
readonly cohorts: Cohorts;
|
|
270
288
|
private readonly coordinationModes = new Map<number, boolean>();
|
|
271
289
|
private sweepConfig: TaskSweepConfig;
|
|
290
|
+
private readonly noWriteTurns = new NoWriteTurns();
|
|
272
291
|
private sweeping = false;
|
|
273
292
|
|
|
274
293
|
constructor(private readonly d: TasksDeps) {
|
|
294
|
+
d.board.trackPlanRevisions = () => (d.sdlc?.config().mode ?? "off") !== "off";
|
|
275
295
|
this.repositories = new TaskRepositories(d.cwd, d.repository);
|
|
276
296
|
for (const task of d.board.list()) this.warmRepository(task);
|
|
277
297
|
if (d.coordination) {
|
|
@@ -428,6 +448,57 @@ export class Tasks {
|
|
|
428
448
|
/** A validated snapshot takes effect on the next sweep, never halfway through one. */
|
|
429
449
|
configureSweep(config: TaskSweepConfig): void {
|
|
430
450
|
this.sweepConfig = taskSweepConfig(config);
|
|
451
|
+
if (this.sweepConfig.no_writes_turns === 0) {
|
|
452
|
+
this.noWriteTurns.retain([]);
|
|
453
|
+
for (const stall of this.d.board.stalls()) if (stall.cause.kind === "no-writes") this.d.board.clearStall(stall.task);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private ownerAcceptance(task: Task, first = false): { index: number; at: number } | undefined {
|
|
458
|
+
if (!task.owner) return undefined;
|
|
459
|
+
const boundary = task.history.findLastIndex(entry => OWNERSHIP_EVENTS.has(entry.event) || entry.owner !== undefined && entry.owner !== task.owner);
|
|
460
|
+
const accepted = (entry: Task["history"][number], index: number) => index > boundary && entry.event === "accepted" && (entry.by === task.owner || entry.by === USER);
|
|
461
|
+
const index = first ? task.history.findIndex(accepted) : task.history.findLastIndex(accepted);
|
|
462
|
+
return index > boundary && index >= 0 ? { index, at: task.history[index]!.at } : undefined;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Captured at native turn start, not supplied by a model. */
|
|
466
|
+
ownerTurnTasks(peer: PeerId): { id: number; acceptance: number }[] {
|
|
467
|
+
return this.d.board.list("in_progress").flatMap(task => {
|
|
468
|
+
const accepted = task.owner === peer && ["implement", "bulk_edit"].includes(task.class) ? this.ownerAcceptance(task) : undefined;
|
|
469
|
+
return accepted ? [{ id: task.id, acceptance: accepted.index }] : [];
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
recordOwnerTurn(peer: PeerId, bindings: readonly { id: number; acceptance: number }[], changed: boolean | undefined, effect: boolean): void {
|
|
474
|
+
this.noWriteTurns.retain(this.d.board.list("in_progress").filter(task => ["implement", "bulk_edit"].includes(task.class)).map(task => task.id));
|
|
475
|
+
const current = new Map(this.ownerTurnTasks(peer).map(binding => [binding.id, binding.acceptance]));
|
|
476
|
+
for (const binding of bindings) {
|
|
477
|
+
if (current.get(binding.id) !== binding.acceptance) continue;
|
|
478
|
+
const task = this.d.board.get(binding.id)!;
|
|
479
|
+
const limit = this.sweepConfig.no_writes_turns ?? 2;
|
|
480
|
+
if (changed !== false || effect || limit === 0) {
|
|
481
|
+
if (this.d.board.stalls().some(stall => stall.task === task.id && stall.cause.kind === "no-writes")) this.d.board.clearStall(task.id);
|
|
482
|
+
}
|
|
483
|
+
const count = this.noWriteTurns.observe({ task: task.id, generation: `${task.created}:${peer}:${binding.acceptance}`, limit, changed, effect });
|
|
484
|
+
if (count === undefined) continue;
|
|
485
|
+
this.reportStall(task, { kind: "no-writes", code: "no-writes", peer, turns: count, acceptance: binding.acceptance }, Date.now(),
|
|
486
|
+
`Task ${this.publicTitle(task)} stalled: ${count} consecutive owner turns with no writes or task tool effects recorded.`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private captureOwnerAcceptance(task: Task): void {
|
|
491
|
+
try { this.d.ownerAccepted?.(task); } catch { /* advisory capture cannot fail a saved acceptance */ }
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Host evidence spans the first acceptance after the latest ownership change, including plan revisions. */
|
|
495
|
+
ownerWriteAcceptance(task: Task): { index: number; at: number } | undefined { return this.ownerAcceptance(task, true); }
|
|
496
|
+
|
|
497
|
+
private noWritesRecorded(task: Task): boolean {
|
|
498
|
+
if (!["implement", "bulk_edit"].includes(task.class) || task.refs.branch || this.reviewHead(task) || task.sdlc?.pr) return false;
|
|
499
|
+
const accepted = this.ownerWriteAcceptance(task);
|
|
500
|
+
if (!accepted) return false;
|
|
501
|
+
try { return this.d.ownerWrites?.(task, accepted.at) === false; } catch { return false; }
|
|
431
502
|
}
|
|
432
503
|
|
|
433
504
|
private sweepAvailable(peer: PeerId): boolean {
|
|
@@ -467,14 +538,59 @@ export class Tasks {
|
|
|
467
538
|
/** What peers other than the owner, the console stream and the log may see of a task. */
|
|
468
539
|
publicTitle = (task: Task) => (this.isPii(task) ? `#${task.id} [pii]` : `#${task.id} ${task.title}`);
|
|
469
540
|
|
|
541
|
+
private reviewFindingSets(task: Task): ReviewFindingSet[] { return task.history.flatMap(entry => entry.reviewFindings ? [entry.reviewFindings] : []); }
|
|
542
|
+
|
|
543
|
+
private findingHead(task: Task, reviewedHead?: string): string | undefined {
|
|
544
|
+
return resolveFindingHead(task.sdlc, reviewedHead ? { head: reviewedHead } : undefined, task.refs.commit);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private findingTextAllowed(task: Task, items: readonly ReviewFinding[]): boolean {
|
|
548
|
+
return this.candidateTextAllowed(JSON.stringify(items), task)
|
|
549
|
+
&& items.every(item => !item.path || !matchesPolicy(this.d.cwd, item.path, this.d.piiPaths ?? [], true));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Host-authored proposals contain only cleared IDs, integer task references and fixed instruction targets. */
|
|
553
|
+
private proposeRecurringFindings(task: Task, by: PeerId, items: readonly ReviewFinding[], withheld: boolean): void {
|
|
554
|
+
if (withheld || this.isPii(task) || this.d.privateTurn?.(by) || !items.length || !this.findingTextAllowed(task, items)) return;
|
|
555
|
+
const rows = this.d.board.list();
|
|
556
|
+
const keys = new Set(rows.filter(row => row.class === "plan" && row.history[0]?.by === HUB)
|
|
557
|
+
.flatMap(row => row.detail?.split("\n").filter(line => line.startsWith("Review finding key: ")) ?? []));
|
|
558
|
+
const created: number[] = [];
|
|
559
|
+
// No await separates the durable dedupe read from creation; concurrent verdicts see the first proposal.
|
|
560
|
+
for (const item of items.filter(item => item.disposition !== "false_positive")) {
|
|
561
|
+
const same = (finding: ReviewFinding) => finding.id === item.id && finding.pass === item.pass;
|
|
562
|
+
const holders = rows.filter(row => {
|
|
563
|
+
if (this.isPii(row)) return false;
|
|
564
|
+
const last = row.history.findLast(entry => entry.reviewFindings?.items.some(value => value.id === item.id));
|
|
565
|
+
const finding = last?.reviewFindings?.items.find(value => value.id === item.id);
|
|
566
|
+
return !last?.withheld && finding && same(finding) && finding.disposition !== "false_positive" && this.findingTextAllowed(row, [finding]);
|
|
567
|
+
});
|
|
568
|
+
if (new Set(holders.map(row => row.id)).size < 2) continue;
|
|
569
|
+
const key = `Review finding key: ${item.pass}:${item.id}`;
|
|
570
|
+
if (keys.has(key)) continue;
|
|
571
|
+
const title = `Review policy amendment for recurring finding ${item.pass}:${item.id}`;
|
|
572
|
+
const detail = `${key}\nFinding ${item.pass}:${item.id} recurred on tasks ${holders.slice(0, 8).map(row => `#${row.id}`).join(", ")}. Inspect their review histories and propose an AGENTS.md or relevant docs/agent-notes/ amendment. This backlog card is a proposal, not authorization to edit instruction files.`;
|
|
573
|
+
const draft = { title, detail, refs: { paths: ["AGENTS.md", "docs/agent-notes/"] } };
|
|
574
|
+
const signals = this.signalsFor(draft);
|
|
575
|
+
if (this.isPii({ signals }) || !this.candidateTextAllowed(`${title}\n${detail}`)) continue;
|
|
576
|
+
const proposal = this.d.board.propose(HUB, { ...draft, class: "plan", backlog: true, signals });
|
|
577
|
+
this.incomingBacklog.add(proposal.id); keys.add(key); created.push(proposal.id);
|
|
578
|
+
}
|
|
579
|
+
for (const id of created) this.releaseNotice(`task #${id}: recurring review finding amendment proposed in the backlog`);
|
|
580
|
+
}
|
|
581
|
+
|
|
470
582
|
/** A task as a cloud peer may see it. */
|
|
583
|
+
|
|
471
584
|
publicView(task: Task, fullHistory = false): Record<string, unknown> {
|
|
472
|
-
const { title, detail, history, external, cancelled, ...
|
|
585
|
+
const { title, detail, history, external, cancelled, ...taskRest } = task;
|
|
586
|
+
const rest = { ...taskRest, ...(history.some(h => h.by === USER && h.planDeparture) ? { planDepartureAccepted: true } : {}) };
|
|
473
587
|
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]" };
|
|
474
588
|
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}]`;
|
|
475
589
|
const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
|
|
476
590
|
at: h.at, by: h.by, event: h.event,
|
|
477
591
|
...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note", h.withheld) }),
|
|
592
|
+
...(h.reviewFindings ? { reviewFindings: !h.withheld && this.findingTextAllowed(task, h.reviewFindings.items) ? h.reviewFindings
|
|
593
|
+
: { ...(h.reviewFindings.head ? { head: h.reviewFindings.head } : {}), tally: findingTally(h.reviewFindings.items), withheld: true } } : {}),
|
|
478
594
|
...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
|
|
479
595
|
...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
|
|
480
596
|
...(h.owner === undefined ? {} : { owner: h.owner }),
|
|
@@ -486,7 +602,8 @@ export class Tasks {
|
|
|
486
602
|
...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
|
|
487
603
|
...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
|
|
488
604
|
}));
|
|
489
|
-
|
|
605
|
+
const sets = this.reviewFindingSets(task), current = findingsForHead(sets, this.findingHead(task));
|
|
606
|
+
return { ...rest, ...(current.length ? { findings: findingTally(current) } : {}), ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(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), undefined, sets, task.refs.commit) } : {}), title, detail, history: publicHistory };
|
|
490
607
|
}
|
|
491
608
|
|
|
492
609
|
/** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
|
|
@@ -558,9 +675,9 @@ export class Tasks {
|
|
|
558
675
|
/** Workload is board/queue data only: wip() never calls health() or weights(). */
|
|
559
676
|
private instanceRouting(): InstanceRoutingOptions {
|
|
560
677
|
const instances = this.d.instances?.();
|
|
561
|
-
if (!instances || !Object.keys(instances).length) return {};
|
|
562
|
-
const peers = [...new Set([...ADAPTERS.map(adapter => adapter.kind), ...Object.keys(instances), ...this.d.bus.peers.keys()])];
|
|
563
|
-
return { instances, ownerIsolation: this.d.ownerIsolation?.() ?? {}, wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])), load: Object.fromEntries(peers.map(peer => [peer, this.d.bus.queued(peer)])) };
|
|
678
|
+
if (!instances || !Object.keys(instances).length) return { adapterCatalog: this.d.adapterCatalog };
|
|
679
|
+
const peers = [...new Set([...(this.d.adapterCatalog?.definitions ?? ADAPTERS).map(adapter => adapter.kind), ...Object.keys(instances), ...this.d.bus.peers.keys()])];
|
|
680
|
+
return { adapterCatalog: this.d.adapterCatalog, instances, ownerIsolation: this.d.ownerIsolation?.() ?? {}, wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])), load: Object.fromEntries(peers.map(peer => [peer, this.d.bus.queued(peer)])) };
|
|
564
681
|
}
|
|
565
682
|
|
|
566
683
|
/** Peer health and roles the daemon feeds routing (issues #89, #90, #92): failing peers are skipped, held queues explained. */
|
|
@@ -568,8 +685,8 @@ export class Tasks {
|
|
|
568
685
|
const failing = this.d.failing?.();
|
|
569
686
|
const held = this.d.held?.();
|
|
570
687
|
const workload = this.instanceRouting();
|
|
571
|
-
const peers = workload.instances ? [...new Set([...ADAPTERS.map(adapter => adapter.kind), ...Object.keys(workload.instances), ...this.d.bus.peers.keys()])] : ADAPTERS.map(adapter => adapter.kind);
|
|
572
|
-
return { ...workload, reviewPool: { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, required: this.d.review?.pool !== undefined }, ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => definitionOf(peer.id, workload.instances)?.attach?.pullOnlyForms?.length && peer.pullOnly).map(peer => peer.id), offRoster: peers.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
|
|
688
|
+
const peers = workload.instances ? [...new Set([...(this.d.adapterCatalog?.definitions ?? ADAPTERS).map(adapter => adapter.kind), ...Object.keys(workload.instances), ...this.d.bus.peers.keys()])] : (this.d.adapterCatalog?.definitions ?? ADAPTERS).map(adapter => adapter.kind);
|
|
689
|
+
return { ...workload, reviewPool: { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, required: this.d.review?.pool !== undefined }, ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => (this.d.adapterCatalog?.definitionOf ?? definitionOf)(peer.id, workload.instances)?.attach?.pullOnlyForms?.length && peer.pullOnly).map(peer => peer.id), offRoster: peers.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
|
|
573
690
|
}
|
|
574
691
|
|
|
575
692
|
private reviewHead(task: Task): string | undefined { return task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; }
|
|
@@ -595,7 +712,7 @@ export class Tasks {
|
|
|
595
712
|
|
|
596
713
|
private reviewMetadata(assignment?: Assignment): ReviewerHistory {
|
|
597
714
|
const choice = assignment?.reviewSelection;
|
|
598
|
-
const family = choice && assignment?.plannerFallback && assignment.reviewer ? familyOf(assignment.reviewer, this.d.instances?.()) : choice?.family;
|
|
715
|
+
const family = choice && assignment?.plannerFallback && assignment.reviewer ? (this.d.adapterCatalog?.familyOf ?? familyOf)(assignment.reviewer, this.d.instances?.()) : choice?.family;
|
|
599
716
|
const reason = choice && assignment?.plannerFallback
|
|
600
717
|
? assignment.sameFamilyFallback ? "same-family fallback" as const : "unavailable skip" as const
|
|
601
718
|
: choice?.reason;
|
|
@@ -604,7 +721,7 @@ export class Tasks {
|
|
|
604
721
|
|
|
605
722
|
/** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
|
|
606
723
|
private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: TaskAssignmentRole = "owner", preEffect = true): void {
|
|
607
|
-
const instances = this.d.instances?.(), adapter = definitionOf(peer, instances);
|
|
724
|
+
const instances = this.d.instances?.(), adapter = (this.d.adapterCatalog?.definitionOf ?? definitionOf)(peer, instances);
|
|
608
725
|
const declaredExtra = !!adapter && adapter.kind !== peer;
|
|
609
726
|
if ((!this.d.capabilities || this.isPii(task)) && !declaredExtra) return;
|
|
610
727
|
const { state: _state, ...draft } = task; // backlog/readiness must not bypass an explicit reservation's admission
|
|
@@ -931,6 +1048,7 @@ export class Tasks {
|
|
|
931
1048
|
if (!returnedUntouched) {
|
|
932
1049
|
if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
|
|
933
1050
|
}
|
|
1051
|
+
const definitionOf = this.d.adapterCatalog?.definitionOf ?? builtinDefinitionOf;
|
|
934
1052
|
if (patch.reserved && !definitionOf(patch.reserved, this.d.instances?.()) && !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");
|
|
935
1053
|
if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
|
|
936
1054
|
// #531 D3: a reservation for a peer routing leaves out for good could never be honoured. Say so, and who can lift it.
|
|
@@ -1888,6 +2006,7 @@ export class Tasks {
|
|
|
1888
2006
|
}
|
|
1889
2007
|
if (stagedClaimGap) return next; // An explicit claim may reserve its owner, but no offer or acceptance bypasses review.
|
|
1890
2008
|
const claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" });
|
|
2009
|
+
this.captureOwnerAcceptance(claimed);
|
|
1891
2010
|
this.releaseNotice(`task ${this.publicTitle(claimed)} claimed by ${by}`);
|
|
1892
2011
|
return claimed;
|
|
1893
2012
|
}
|
|
@@ -1942,7 +2061,7 @@ export class Tasks {
|
|
|
1942
2061
|
const pii = this.isPii(task), delivery = { ...a, reviewer: task.reviewer ?? undefined };
|
|
1943
2062
|
const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", delivery.owner, h.withheld)}`);
|
|
1944
2063
|
const handoff = context && !pii ? this.screen(task, context, "handoff", delivery.owner, contextWithheld).slice(0, 3000) : "";
|
|
1945
|
-
const facts = [`class ${profileTaskLabel(task)}`, delivery.owner && definitionOf(delivery.owner, this.d.instances?.())?.routing?.backendLabel ? `backend ${definitionOf(delivery.owner, this.d.instances?.())!.routing!.backendLabel}/${delivery.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", delivery.reviewer ? `reviewer ${delivery.reviewer}` : task.class === "review" ? "no reviewer" : this.noReviewer(delivery)].filter(Boolean).join("; ");
|
|
2064
|
+
const facts = [`class ${profileTaskLabel(task)}`, delivery.owner && (this.d.adapterCatalog?.definitionOf ?? definitionOf)(delivery.owner, this.d.instances?.())?.routing?.backendLabel ? `backend ${(this.d.adapterCatalog?.definitionOf ?? definitionOf)(delivery.owner, this.d.instances?.())!.routing!.backendLabel}/${delivery.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", delivery.reviewer ? `reviewer ${delivery.reviewer}` : task.class === "review" ? "no reviewer" : this.noReviewer(delivery)].filter(Boolean).join("; ");
|
|
1946
2065
|
const plan = planText(task.plan);
|
|
1947
2066
|
const body = [
|
|
1948
2067
|
`Task #${task.id} [${profileTaskLabel(task)}] ${task.title}`,
|
|
@@ -2042,6 +2161,7 @@ export class Tasks {
|
|
|
2042
2161
|
const revision = this.d.board.statusRevision();
|
|
2043
2162
|
if (this.stallCandidates?.revision !== revision) this.stallCandidates = { revision, rows: this.d.board.stalls().flatMap(stall => {
|
|
2044
2163
|
const task = this.d.board.get(stall.task);
|
|
2164
|
+
if (stall.cause.kind === "no-writes") return (this.sweepConfig.no_writes_turns ?? 2) > 0 && task?.state === "in_progress" && ["implement", "bulk_edit"].includes(task.class) && task.owner === stall.cause.peer && this.ownerAcceptance(task)?.index === stall.cause.acceptance ? [{ stall, task, planned: false }] : [];
|
|
2045
2165
|
if (task?.state !== "proposed" || task.owner || (task.planningRevision ?? 0) !== stall.replanRevision) return [];
|
|
2046
2166
|
if (stall.cause.kind === "cancelled-dependency") return task.deps?.includes(stall.cause.dependency!) === true && this.d.board.get(stall.cause.dependency!)?.state === "cancelled" ? [{ stall, task, planned: false }] : [];
|
|
2047
2167
|
return this.waitsFor(task).length ? [] : [{ stall, task, planned: this.plannerPullRecord(task)?.pull === true }];
|
|
@@ -2051,7 +2171,7 @@ export class Tasks {
|
|
|
2051
2171
|
const queue = this.d.bus.queueSummary(peer);
|
|
2052
2172
|
return [peer, this.wipLimit(peer), queue.heldBy, queue.needsReview];
|
|
2053
2173
|
})]);
|
|
2054
|
-
if (this.stallView?.key !== key) this.stallView = { key, stalls: this.stallCandidates.rows.filter(({ stall, task, planned }) => stall.cause.kind === "cancelled-dependency" || !(planned ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.health() })).owner).map(({ stall }) => stall) };
|
|
2174
|
+
if (this.stallView?.key !== key) this.stallView = { key, stalls: this.stallCandidates.rows.filter(({ stall, task, planned }) => stall.cause.kind === "no-writes" || stall.cause.kind === "cancelled-dependency" || !(planned ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.health() })).owner).map(({ stall }) => stall) };
|
|
2055
2175
|
return structuredClone(this.stallView.stalls);
|
|
2056
2176
|
}
|
|
2057
2177
|
/** Why a proposal's named owner (a self-claim included) did not get it, from the stall its proposal recorded (#601); undefined when it did. */
|
|
@@ -2068,10 +2188,10 @@ export class Tasks {
|
|
|
2068
2188
|
const persisted = (code: NonNullable<Assignment["refusals"]>[number]["code"]): TaskStall["cause"]["code"] => code === "instance" || code === "family" ? "policy" : code;
|
|
2069
2189
|
return refusal ? { kind: "reservation", peer: refusal.peer, code: persisted(refusal.code) } : { kind: "no-eligible-peer", code: "unavailable", refusals: admission.refusals?.filter(row => row.role === "owner").map(({ peer, code }) => ({ peer, code: persisted(code) })).sort((a, b) => a.peer.localeCompare(b.peer)) ?? [] };
|
|
2070
2190
|
}
|
|
2071
|
-
private reportStall(task: Task, cause: TaskStall["cause"], now = Date.now()): void {
|
|
2191
|
+
private reportStall(task: Task, cause: TaskStall["cause"], now = Date.now(), description?: string): void {
|
|
2072
2192
|
const stall: TaskStall = { task: task.id, cause, signature: JSON.stringify(cause), replanRevision: task.planningRevision ?? 0, at: now };
|
|
2073
2193
|
if (!this.d.board.recordStall(stall)) return;
|
|
2074
|
-
const text = `Task #${task.id} stalled: ${cause.kind}${cause.peer ? ` ${cause.peer}` : ""}${cause.dependency ? ` #${cause.dependency}` : ""} (${cause.code}); configure the runner or peer, ahub task assign ${task.id} <peer>, re-plan dependencies, or cancel it.`;
|
|
2194
|
+
const text = description ?? `Task #${task.id} stalled: ${cause.kind}${cause.peer ? ` ${cause.peer}` : ""}${cause.dependency ? ` #${cause.dependency}` : ""} (${cause.code}); configure the runner or peer, ahub task assign ${task.id} <peer>, re-plan dependencies, or cancel it.`;
|
|
2075
2195
|
this.releaseNotice(text);
|
|
2076
2196
|
try { this.d.stallNotice?.(stall); } catch { /* advisory never changes task authority */ }
|
|
2077
2197
|
// Advice joins the planner's next delivery; it must not manufacture a turn beside a withheld backlog batch.
|
|
@@ -2203,7 +2323,8 @@ export class Tasks {
|
|
|
2203
2323
|
const peers = [...new Set([...Object.keys(this.states()), ...current.flatMap(task => task.owner ? [task.owner] : [])])];
|
|
2204
2324
|
const reviewedIssues = new Map(visible.flatMap(task => task.sdlc?.planReview?.issue ? [[task.id, task.sdlc.planReview.issue] as const] : []));
|
|
2205
2325
|
const acceptedIssues = new Map(visible.flatMap(task => task.sdlc?.acceptance ? [[task.id, task.sdlc.acceptance] as const] : []));
|
|
2206
|
-
const
|
|
2326
|
+
const currentNoWrites = new Set(this.stalled().filter(stall => stall.cause.kind === "no-writes").map(stall => stall.task));
|
|
2327
|
+
const blockers: { task: number; code: string }[] = this.d.board.stalls().filter(stall => stall.cause.kind !== "no-writes" || currentNoWrites.has(stall.task)).map(stall => ({ task: stall.task, code: stall.cause.code }));
|
|
2207
2328
|
const health = this.health(), states = this.states(), routing = this.d.routing(), hostBlocks = this.d.plannerBlocks?.() ?? {};
|
|
2208
2329
|
for (const task of visible.filter(task => !isClosedTaskState(task.state)).slice(0, 500)) {
|
|
2209
2330
|
const owner = task.owner ?? task.reserved;
|
|
@@ -2254,7 +2375,10 @@ export class Tasks {
|
|
|
2254
2375
|
for (const task of current) if (this.isPii(task)) visiblePrs.delete(task.sdlc!.pr!);
|
|
2255
2376
|
if (JSON.stringify([...visiblePrs].sort((a, b) => a - b)) !== JSON.stringify(ids)) incomplete = true;
|
|
2256
2377
|
const report = costMetrics(current.map(task => ({ repo, pr: task.sdlc!.pr!, private: this.isPii(task), history: task.history })), observations.filter(pr => visiblePrs.has(pr.pr)));
|
|
2257
|
-
|
|
2378
|
+
const scoped = current.filter(task => visiblePrs.has(task.sdlc!.pr!) && !this.isPii(task));
|
|
2379
|
+
const scopedIds = new Set(scoped.map(task => task.id));
|
|
2380
|
+
const stalls = { noWrites: this.stalled().filter(stall => stall.cause.kind === "no-writes" && scopedIds.has(stall.task)).length, doneWithoutWrites: scoped.filter(task => task.history.findLast(entry => entry.event === "done")?.noWrites).length };
|
|
2381
|
+
return { ...report, stalls, truncated: report.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}), bands: "S7 pending" };
|
|
2258
2382
|
}
|
|
2259
2383
|
|
|
2260
2384
|
async planReviewGap(task: Task): Promise<string | undefined> {
|
|
@@ -2343,13 +2467,16 @@ export class Tasks {
|
|
|
2343
2467
|
const suppliedHead = !!input && typeof input === "object" && (input as { reviewedHead?: unknown }).reviewedHead !== undefined;
|
|
2344
2468
|
const artifactChanged = next.repo !== task.sdlc?.repo || next.pr !== task.sdlc?.pr;
|
|
2345
2469
|
if (next.reviewedHead !== undefined && (next.reviewedHead !== task.sdlc?.reviewedHead || (suppliedHead && artifactChanged))) this.mine(task, by, "reviewer", true);
|
|
2346
|
-
|
|
2470
|
+
const departure = task.history.findLast(h => h.by === USER && h.planDeparture)?.planDeparture;
|
|
2471
|
+
const invalidatesDeparture = !!departure && (artifactChanged || next.localHead !== task.sdlc?.localHead && next.localHead !== undefined && next.localHead !== departure.head || next.reviewedHead !== task.sdlc?.reviewedHead && next.reviewedHead !== undefined && next.reviewedHead !== departure.head);
|
|
2472
|
+
return this.d.board.update(task.id, by, "lifecycle", { sdlc: next }, undefined, { ...this.planObservation({ ...task, sdlc: next }), ...(invalidatesDeparture ? { planDepartureInvalidated: true } : {}) });
|
|
2347
2473
|
}
|
|
2348
2474
|
|
|
2349
2475
|
async deliveryQueue(repo: string) {
|
|
2476
|
+
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2350
2477
|
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 })));
|
|
2351
|
-
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) }));
|
|
2352
|
-
const report = await readMergeQueue(repo,
|
|
2478
|
+
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), findings: this.isPii(t) ? undefined : this.reviewFindingSets(t), plan: config.mode === "off" || this.isPii(t) ? undefined : { paths: this.planPaths(t), revision: t.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(t.plan ?? {}), accepted: this.validPlanDepartures(t) } }));
|
|
2479
|
+
const report = await readMergeQueue(repo, config, links, this.d.sdlc?.gh);
|
|
2353
2480
|
// Policy can change across remote awaits without any board write. Never return its stale public projection.
|
|
2354
2481
|
const currentPrivacy = this.d.board.list().map(task => ({ id: task.id, private: this.isPii(task) }));
|
|
2355
2482
|
if (workflowHash(links.map(({ id, private: hidden }) => ({ id, private: hidden }))) !== workflowHash(currentPrivacy)) {
|
|
@@ -2358,6 +2485,13 @@ export class Tasks {
|
|
|
2358
2485
|
|
|
2359
2486
|
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 })));
|
|
2360
2487
|
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"); } }
|
|
2488
|
+
else if (config.mode !== "off") for (const row of report.rows) if (row.head && !row.private) for (const id of row.tasks) {
|
|
2489
|
+
const task = this.d.board.get(id);
|
|
2490
|
+
if (task?.sdlc?.repo?.toLowerCase() === report.repo.toLowerCase() && task.sdlc.pr === row.pr) {
|
|
2491
|
+
if (this.validPlanDepartures(task).length && row.planConformance?.some(item => item.task === id && item.state !== "unknown" && !item.accepted)) this.invalidatedPlanDepartures.add(id);
|
|
2492
|
+
this.capturePlanHead(task, row.head);
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2361
2495
|
return report;
|
|
2362
2496
|
}
|
|
2363
2497
|
async releaseReadiness(repo: string, liveIssues: number[]) {
|
|
@@ -2391,12 +2525,112 @@ export class Tasks {
|
|
|
2391
2525
|
const state = { id: task.id, state: task.state, stage, ...(this.isPii(task) ? { private: true } : { criteria: this.workflowView(task) ?? null }) };
|
|
2392
2526
|
return { schema: 2, state, stateHash: workflowHash(state), sourceHash: workflowHash(config) };
|
|
2393
2527
|
}
|
|
2528
|
+
private readonly planReads = new Map<number, { identity: string; evidence: PrEvidence }>();
|
|
2529
|
+
// ponytail: consent expires on restart because a refused readback cannot first mutate the task's history.
|
|
2530
|
+
// A durable host observation journal can retain safe consent across lifetimes without reviving pending invalidations.
|
|
2531
|
+
private readonly departureGeneration = crypto.randomUUID();
|
|
2532
|
+
private readonly invalidatedPlanDepartures = new Set<number>();
|
|
2533
|
+
private readonly observedPlans = new Map<number, NonNullable<HistoryEntry["planObservation"]>>();
|
|
2534
|
+
private planIdentity(task: Task): string {
|
|
2535
|
+
return JSON.stringify([task.refs, task.sdlc, task.plan, task.owner, task.reviewer, task.state, this.isPii(task), this.d.sdlc?.config()]);
|
|
2536
|
+
}
|
|
2537
|
+
private planReadIdentity(task: Task): string { return JSON.stringify([task.sdlc?.repo, task.sdlc?.pr, task.refs.repo, planFingerprint(task.plan ?? {})]); }
|
|
2538
|
+
private planPaths(task: Task): string[] | undefined {
|
|
2539
|
+
if (!task.sdlc?.repo) return;
|
|
2540
|
+
// Resolve the artifact slug first: it may warm the canonical project's previously unnamed identity.
|
|
2541
|
+
const artifact = this.repositories.scope({ repo: task.sdlc.repo, paths: task.refs.paths });
|
|
2542
|
+
const scope = this.repositories.scope(task.refs);
|
|
2543
|
+
if (scope.key !== artifact.key) return;
|
|
2544
|
+
return task.plan?.paths?.map(path => scope === this.repositories.project ? this.projectPath(path) : this.repositories.relativePath(scope, path));
|
|
2545
|
+
}
|
|
2546
|
+
private planScopeIdentity(task: Task): string {
|
|
2547
|
+
const paths = this.planPaths(task); // Warm verified aliases before capturing the scope identity.
|
|
2548
|
+
const scope = this.repositories.scope(task.refs);
|
|
2549
|
+
return planFingerprint({ key: scope.key, root: scope.root ?? null, aliases: [...scope.aliases].sort(),
|
|
2550
|
+
repo: task.refs.repo ?? null, absoluteRefs: (task.refs.paths ?? []).filter(isAbsolute).sort(), paths: paths ?? null });
|
|
2551
|
+
}
|
|
2552
|
+
private validPlanDepartures(task: Task): NonNullable<HistoryEntry["planDeparture"]>[] {
|
|
2553
|
+
const invalidated = task.history.findLastIndex(h => h.planDepartureInvalidated && !h.planDeparture);
|
|
2554
|
+
if (this.invalidatedPlanDepartures.has(task.id)) return [];
|
|
2555
|
+
const latest = task.history.findLastIndex(h => h.by === USER && h.event === "plan departure accepted" && h.planDeparture);
|
|
2556
|
+
return latest > invalidated && task.history[latest]!.planDeparture!.generation === this.departureGeneration
|
|
2557
|
+
&& task.history[latest]!.planDeparture!.scope === this.planScopeIdentity(task) ? [task.history[latest]!.planDeparture!] : [];
|
|
2558
|
+
}
|
|
2559
|
+
private planReport(task: Task, evidence?: PrEvidence) {
|
|
2560
|
+
const rawReport = planConformance(this.isPii(task) ? undefined : this.planPaths(task), this.isPii(task) ? undefined : evidence);
|
|
2561
|
+
const safe = [...rawReport.outside, ...rawReport.untouched].every(path => this.publicMetadataText(path));
|
|
2562
|
+
const report: PlanConformance = safe ? rawReport : { state: "unknown", head: rawReport.head, outside: [], untouched: [] };
|
|
2563
|
+
const fingerprint = planFingerprint(task.plan ?? {});
|
|
2564
|
+
const observation = task.history.find(h => h.planObservation?.repo === task.sdlc?.repo && h.planObservation?.pr === task.sdlc?.pr)?.planObservation ?? this.observedPlans.get(task.id);
|
|
2565
|
+
const revision = task.history.findLast(h => h.planRevision)?.planRevision?.revision;
|
|
2566
|
+
const acceptance = evidence && this.validPlanDepartures(task).findLast(item => item.repo === task.sdlc?.repo && item.pr === task.sdlc?.pr && item.head === evidence.head
|
|
2567
|
+
&& item.revision === (revision ?? 0) && item.fingerprint === fingerprint && JSON.stringify([...item.paths].sort()) === JSON.stringify([...evidence.paths].sort()));
|
|
2568
|
+
return { ...report, accepted: !!acceptance, earlierPushes: "unknown" as const,
|
|
2569
|
+
...(observation && observation.repo === task.sdlc?.repo && observation.pr === task.sdlc?.pr ? { revisionsAfterFirstObservedPush: Math.max(0, (revision ?? 0) - observation.revision) } : {}) };
|
|
2570
|
+
}
|
|
2571
|
+
private capturePlanRead(task: Task, evidence: PrEvidence | undefined): void {
|
|
2572
|
+
if ((this.d.sdlc?.config().mode ?? "off") === "off" || !evidence || this.isPii(task) || !task.sdlc?.repo || !task.sdlc.pr) return;
|
|
2573
|
+
if (this.planReads.size >= 256 && !this.planReads.has(task.id)) this.planReads.delete(this.planReads.keys().next().value!);
|
|
2574
|
+
this.planReads.set(task.id, { identity: this.planReadIdentity(task), evidence });
|
|
2575
|
+
const accepted = task.history.findLast(h => h.by === USER && h.planDeparture)?.planDeparture;
|
|
2576
|
+
if (accepted && evidence.pathsComplete === true && JSON.stringify([...accepted.paths].sort()) !== JSON.stringify([...evidence.paths].sort())) this.invalidatedPlanDepartures.add(task.id);
|
|
2577
|
+
this.capturePlanHead(task, evidence.head);
|
|
2578
|
+
}
|
|
2579
|
+
private capturePlanHead(task: Task, head: string): void {
|
|
2580
|
+
if ((this.d.sdlc?.config().mode ?? "off") === "off" || this.isPii(task) || !task.sdlc?.repo || !task.sdlc.pr) return;
|
|
2581
|
+
if (this.planReads.get(task.id)?.evidence.head !== head) this.planReads.delete(task.id);
|
|
2582
|
+
const accepted = task.history.findLast(h => h.by === USER && h.planDeparture)?.planDeparture;
|
|
2583
|
+
if (accepted && (accepted.head !== head || accepted.repo !== task.sdlc.repo || accepted.pr !== task.sdlc.pr)) this.invalidatedPlanDepartures.add(task.id);
|
|
2584
|
+
if (!task.history.some(h => h.planObservation?.repo === task.sdlc?.repo && h.planObservation?.pr === task.sdlc?.pr)
|
|
2585
|
+
&& (!this.observedPlans.has(task.id) || this.observedPlans.get(task.id)?.repo !== task.sdlc.repo || this.observedPlans.get(task.id)?.pr !== task.sdlc.pr)) {
|
|
2586
|
+
// ponytail: this is first observed push, not a true push event. Earlier pushes stay unknown; upgrade to a webhook/Actions push receipt.
|
|
2587
|
+
if (this.observedPlans.size >= 256) this.observedPlans.delete(this.observedPlans.keys().next().value!);
|
|
2588
|
+
this.observedPlans.set(task.id, { repo: task.sdlc.repo, pr: task.sdlc.pr, head,
|
|
2589
|
+
revision: task.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(task.plan ?? {}) });
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
private planObservation(task: Task): Pick<HistoryEntry, "planObservation" | "planDepartureInvalidated"> {
|
|
2593
|
+
if ((this.d.sdlc?.config().mode ?? "off") === "off") return {};
|
|
2594
|
+
const observation = this.observedPlans.get(task.id);
|
|
2595
|
+
const lastAcceptance = task.history.findLastIndex(h => h.by === USER && h.planDeparture);
|
|
2596
|
+
return {
|
|
2597
|
+
...(observation && observation.repo === task.sdlc?.repo && observation.pr === task.sdlc?.pr
|
|
2598
|
+
&& !task.history.some(h => h.planObservation?.repo === observation.repo && h.planObservation.pr === observation.pr) ? { planObservation: observation } : {}),
|
|
2599
|
+
...(this.invalidatedPlanDepartures.has(task.id) && !task.history.slice(lastAcceptance + 1).some(h => h.planDepartureInvalidated) ? { planDepartureInvalidated: true as const } : {}),
|
|
2600
|
+
};
|
|
2601
|
+
}
|
|
2602
|
+
async acceptPlanDeparture(by: PeerId, id: unknown, head: unknown, reason: unknown): Promise<Task> {
|
|
2603
|
+
if (by !== USER) throw new PreEffectToolRefusal("only the person may accept a plan departure");
|
|
2604
|
+
if (typeof head !== "string" || !/^[a-f0-9]{40}$/.test(head) || typeof reason !== "string" || !reason.trim() || reason.length > 2048) throw new PreEffectToolRefusal("departure acceptance requires a full head and a bounded reason");
|
|
2605
|
+
const initial = this.need(id, false, true), identity = this.planIdentity(initial), scope = this.planScopeIdentity(initial), history = initial.history.length;
|
|
2606
|
+
const valid = () => { const now = this.need(id, false, true); if (isClosedTaskState(now.state) || identity !== this.planIdentity(now) || scope !== this.planScopeIdentity(now) || history !== now.history.length) throw new PreEffectToolRefusal("task changed during departure readback"); return now; };
|
|
2607
|
+
if (this.isPii(initial)) throw new PreEffectToolRefusal("private PR file evidence is unavailable; amend the private plan instead");
|
|
2608
|
+
const read: SdlcGh = (args, timeout, cwd, cap) => { valid(); return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap); };
|
|
2609
|
+
const evidence = initial.sdlc && await readSdlcPr(initial.sdlc, read); valid();
|
|
2610
|
+
if (!evidence || completePlanInventory(evidence) === undefined) throw new PreEffectToolRefusal("complete PR file inventory unknown");
|
|
2611
|
+
if (evidence.head !== head) throw new PreEffectToolRefusal("departure acceptance head is not the current PR head");
|
|
2612
|
+
if (this.planReport(initial, evidence).state !== "departure") throw new PreEffectToolRefusal("current plan conformance is not a known departure; amend unknown scope or coverage first");
|
|
2613
|
+
const note = sanitize(reason.replace(/\s+/g, " ").trim());
|
|
2614
|
+
const withheld = await this.withholds("note", note, initial); const current = valid();
|
|
2615
|
+
this.capturePlanRead(current, evidence);
|
|
2616
|
+
const next = this.d.board.update(current.id, USER, "plan departure accepted", {}, note, { withheld: withheld || undefined, ...this.planObservation(current),
|
|
2617
|
+
planDeparture: { generation: this.departureGeneration, scope, repo: current.sdlc!.repo!, pr: current.sdlc!.pr!, head, revision: current.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(current.plan ?? {}), paths: [...evidence.paths] } });
|
|
2618
|
+
this.invalidatedPlanDepartures.delete(current.id);
|
|
2619
|
+
return next;
|
|
2620
|
+
}
|
|
2394
2621
|
async lifecycleStatus(id: unknown, refresh = true): Promise<SdlcStatus> {
|
|
2395
|
-
const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2396
|
-
const
|
|
2622
|
+
const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined), identity = this.planIdentity(task);
|
|
2623
|
+
const valid = () => { const now = this.need(id, false, true); if (this.planIdentity(now) !== identity || now.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed during lifecycle readback"); return now; };
|
|
2624
|
+
const read: SdlcGh = (args, timeout, cwd, cap) => { valid(); return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap); };
|
|
2625
|
+
const evidence = task.sdlc && !this.isPii(task) && refresh && config.mode !== "off" ? await readSdlcPr(task.sdlc, read) : undefined;
|
|
2626
|
+
valid();
|
|
2397
2627
|
const accepted = task.sdlc?.acceptance;
|
|
2398
|
-
const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue,
|
|
2399
|
-
|
|
2628
|
+
const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue, read) : undefined;
|
|
2629
|
+
const current = valid();
|
|
2630
|
+
if (this.planIdentity(current) !== this.planIdentity(task) || current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed during lifecycle readback");
|
|
2631
|
+
this.capturePlanRead(current, evidence);
|
|
2632
|
+
return sdlcStatus(config, current.sdlc, evidence, this.workflowView(current), edit, this.isPii(current) ? [] : this.reviewFindingSets(current), current.refs.commit,
|
|
2633
|
+
config.mode !== "off" ? this.planReport(this.d.board.get(current.id)!, evidence) : undefined);
|
|
2400
2634
|
}
|
|
2401
2635
|
|
|
2402
2636
|
/** #481 S1: only build work starts from an accepted spec. */
|
|
@@ -2462,7 +2696,7 @@ export class Tasks {
|
|
|
2462
2696
|
this.d.notify(this.specText(task, state, "advice"));
|
|
2463
2697
|
}
|
|
2464
2698
|
|
|
2465
|
-
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string): Promise<PrEvidence | undefined> {
|
|
2699
|
+
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[]): Promise<PrEvidence | undefined> {
|
|
2466
2700
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2467
2701
|
if (config.mode === "off") return;
|
|
2468
2702
|
const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
|
|
@@ -2476,10 +2710,13 @@ export class Tasks {
|
|
|
2476
2710
|
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`);
|
|
2477
2711
|
// Advise may continue on the current task, but evidence from its previous link cannot admit that task's review.
|
|
2478
2712
|
if (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || current.state !== task.state || this.isPii(current) !== this.isPii(task)) return;
|
|
2479
|
-
|
|
2713
|
+
this.capturePlanRead(current, evidence);
|
|
2714
|
+
const planReport = this.planReport(current, evidence);
|
|
2715
|
+
const status = sdlcStatus(config, receipt, evidence, this.workflowView(current), undefined, this.isPii(current) ? [] : findings ?? this.reviewFindingSets(current), current.refs.commit, planReport);
|
|
2480
2716
|
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;
|
|
2481
2717
|
if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
|
|
2482
|
-
|
|
2718
|
+
const advice = [...new Set([...blockers, ...planConformanceBlockers(planReport)])];
|
|
2719
|
+
if (config.mode === "advise" && advice.length) this.d.notify(`task ${this.publicTitle(task)}: SDLC ${transition} advice: ${this.isPii(task) ? "private lifecycle evidence unknown" : advice.join("; ")}`);
|
|
2483
2720
|
return evidence;
|
|
2484
2721
|
}
|
|
2485
2722
|
|
|
@@ -2593,6 +2830,7 @@ export class Tasks {
|
|
|
2593
2830
|
const repositoryChanged = this.repositories.scope(task.refs).key !== this.repositories.scope(draft.refs).key;
|
|
2594
2831
|
const before = new Set(this.overlapHits(task).map((h) => h.task.id));
|
|
2595
2832
|
const next = this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}), ...(metadata ? { refs: metadata } : {}) });
|
|
2833
|
+
this.captureOwnerAcceptance(next);
|
|
2596
2834
|
this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
|
|
2597
2835
|
if (repositoryChanged) this.cohorts.leave(next.id);
|
|
2598
2836
|
if (cleaned || metadata) {
|
|
@@ -2679,19 +2917,25 @@ export class Tasks {
|
|
|
2679
2917
|
/** Whether a completion check is still running for this task. */
|
|
2680
2918
|
isChecking = (id: number) => this.checking.has(id);
|
|
2681
2919
|
|
|
2920
|
+
private completionCurrent(id: unknown, by: PeerId, preEffect: boolean): Task {
|
|
2921
|
+
const task = this.need(id, false, preEffect);
|
|
2922
|
+
this.mine(task, by, "owner", preEffect);
|
|
2923
|
+
if (preEffect) {
|
|
2924
|
+
if (task.state === "in_review" || isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
|
|
2925
|
+
} else {
|
|
2926
|
+
if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
|
|
2927
|
+
}
|
|
2928
|
+
this.ready(task, preEffect);
|
|
2929
|
+
if (task.state === "proposed" || task.state === "changes_requested") this.acceptanceCapacity(task);
|
|
2930
|
+
if (this.checking.has(task.id)) {
|
|
2931
|
+
// The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
|
|
2932
|
+
throw new (preEffect ? PreEffectToolRefusal : Error)(this.checking.get(task.id) === task.owner ? `task #${task.id}: its check is still running; its result comes as a task message` : `task #${task.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
|
|
2933
|
+
}
|
|
2934
|
+
return task;
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2682
2937
|
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs): Promise<Task> {
|
|
2683
|
-
const valid = ()
|
|
2684
|
-
const t = this.need(id, false, true);
|
|
2685
|
-
this.mine(t, by, "owner", true);
|
|
2686
|
-
if (t.state === "in_review" || isClosedTaskState(t.state)) throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
|
|
2687
|
-
this.ready(t, true);
|
|
2688
|
-
if (t.state === "proposed" || t.state === "changes_requested") this.acceptanceCapacity(t);
|
|
2689
|
-
if (this.checking.has(t.id)) {
|
|
2690
|
-
// The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
|
|
2691
|
-
throw new PreEffectToolRefusal(this.checking.get(t.id) === t.owner ? `task #${t.id}: its check is still running; its result comes as a task message` : `task #${t.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
|
|
2692
|
-
}
|
|
2693
|
-
return t;
|
|
2694
|
-
};
|
|
2938
|
+
const valid = () => this.completionCurrent(id, by, true);
|
|
2695
2939
|
// The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
|
|
2696
2940
|
const withheld = await this.withholds("summary", summary, valid());
|
|
2697
2941
|
let task = valid();
|
|
@@ -2747,7 +2991,13 @@ export class Tasks {
|
|
|
2747
2991
|
this.pendingChecks++;
|
|
2748
2992
|
this.checkQueue = this.checkQueue
|
|
2749
2993
|
.then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld))
|
|
2750
|
-
.catch((e: Error) =>
|
|
2994
|
+
.catch((e: Error) => {
|
|
2995
|
+
this.releaseNotice(`task #${pending.id}: its check result could not be recorded: ${e.message}`);
|
|
2996
|
+
this.releaseNotice(`task #${pending.id}: its owner retry notice could not be published`, () => {
|
|
2997
|
+
const current = this.d.board.get(pending.id);
|
|
2998
|
+
if (current?.state === "in_progress" && current.owner === seen.owner) this.tell(current, `Task #${current.id}: its check completion was refused. Call hub_task_done again when it is ready.`, this.isPii(current));
|
|
2999
|
+
});
|
|
3000
|
+
})
|
|
2751
3001
|
.finally(() => this.pendingChecks--);
|
|
2752
3002
|
return pending;
|
|
2753
3003
|
}
|
|
@@ -2871,20 +3121,14 @@ export class Tasks {
|
|
|
2871
3121
|
let reviewer = selection?.reviewer ?? task.reviewer, reviewEvidence: PrEvidence | undefined;
|
|
2872
3122
|
if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
|
|
2873
3123
|
reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
|
|
2874
|
-
task = this.
|
|
2875
|
-
this.mine(task, by, "owner", true);
|
|
2876
|
-
// In every mode: another done may have landed during the readback.
|
|
2877
|
-
if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
|
|
3124
|
+
task = this.completionCurrent(task.id, by, false);
|
|
2878
3125
|
selection = this.poolReview(task, refs);
|
|
2879
3126
|
reviewer = selection?.reviewer ?? task.reviewer;
|
|
2880
3127
|
}
|
|
2881
3128
|
// A delayed local check needs a new readback; the immediate done path already admitted before effects.
|
|
2882
3129
|
if (reviewer && !admission && (this.d.sdlc?.config().mode ?? "off") !== "off") {
|
|
2883
|
-
const observed = task;
|
|
2884
3130
|
admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence);
|
|
2885
|
-
task = this.
|
|
2886
|
-
this.mine(task, by, "owner");
|
|
2887
|
-
if (task.history.length !== observed.history.length || task.state === "in_review" || isClosedTaskState(task.state)) throw new Error("task changed during review admission");
|
|
3131
|
+
task = this.completionCurrent(task.id, by, false);
|
|
2888
3132
|
}
|
|
2889
3133
|
selection = this.poolReview(task, refs);
|
|
2890
3134
|
reviewer = selection?.reviewer ?? task.reviewer;
|
|
@@ -2893,7 +3137,8 @@ export class Tasks {
|
|
|
2893
3137
|
const routedSummary = selection?.reviewSelection?.blocked ? `${summary ?? ""}\nReview routing: ${this.assignmentNote("pool unavailable", selection)}`.trim() : summary;
|
|
2894
3138
|
const completedRefs = cleanRefs(refs);
|
|
2895
3139
|
this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
2896
|
-
const
|
|
3140
|
+
const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
3141
|
+
const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}) });
|
|
2897
3142
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
2898
3143
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
|
|
2899
3144
|
this.tellCompleted(next, summary, withheld);
|
|
@@ -2984,30 +3229,41 @@ export class Tasks {
|
|
|
2984
3229
|
private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean): Promise<ReviewReadiness | undefined> {
|
|
2985
3230
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
2986
3231
|
if (mode === "off") return undefined;
|
|
3232
|
+
let quietDrift = false;
|
|
2987
3233
|
const guard = () => {
|
|
2988
3234
|
if (actor !== USER && actor !== task.owner) {
|
|
2989
3235
|
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
|
|
2990
3236
|
if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
|
|
2991
3237
|
}
|
|
2992
3238
|
const current = this.need(task.id, false, preEffect);
|
|
2993
|
-
|
|
3239
|
+
const changed = current.history.length !== task.history.length;
|
|
3240
|
+
const quiet = current.history.length > task.history.length && current.history.slice(task.history.length).every(entry => QUIET_EVENTS.has(entry.event));
|
|
3241
|
+
const sameHistory = !changed || (mode === "advise" && quiet) || !!sameRequest?.();
|
|
3242
|
+
if ((this.d.sdlc?.config().mode ?? "off") !== mode || !sameHistory || current.owner !== task.owner || mode !== "advise" && current.reviewer !== task.reviewer
|
|
2994
3243
|
|| this.isPii(current) !== this.isPii(task) || JSON.stringify(current.sdlc ?? null) !== JSON.stringify(task.sdlc ?? null)
|
|
3244
|
+
|| mode === "advise" && (current.state !== task.state || current.refs.commit !== task.refs.commit)
|
|
2995
3245
|
|| actor !== USER && this.d.privateTurn?.(actor) && !this.isPii(current)) throw new (preEffect ? PreEffectToolRefusal : Error)("task or authority changed during review admission");
|
|
3246
|
+
quietDrift ||= mode === "advise" && changed && quiet;
|
|
2996
3247
|
return current;
|
|
2997
3248
|
};
|
|
2998
3249
|
guard();
|
|
2999
3250
|
const expected = cleanRefs(refs).commit ?? task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead;
|
|
3000
|
-
const read: SdlcGh = (args, timeout, cwd, cap) => {
|
|
3251
|
+
const read: SdlcGh = (args, timeout, cwd, cap) => {
|
|
3252
|
+
guard();
|
|
3253
|
+
if (quietDrift) throw new Error("review readiness unknown: task changed during readback");
|
|
3254
|
+
return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap);
|
|
3255
|
+
};
|
|
3001
3256
|
const evidence: ReviewReadiness = this.isPii(task)
|
|
3002
3257
|
? { ready: false, blockers: ["private review readiness unavailable"] }
|
|
3003
3258
|
: task.sdlc ? reviewReadinessFromEvidence(previous, expected, this.d.sdlc?.config().requiredChecks ?? []) ?? await readReviewReadiness(task.sdlc, expected, read, this.d.sdlc?.config().requiredChecks ?? []) : { ready: false, blockers: ["review PR identity missing"] };
|
|
3004
3259
|
const current = guard();
|
|
3005
|
-
|
|
3006
|
-
|
|
3260
|
+
const admitted: ReviewReadiness = quietDrift ? { ready: false, blockers: ["review readiness unknown: task changed during readback"] } : evidence;
|
|
3261
|
+
if (!admitted.ready) {
|
|
3262
|
+
const message = `task ${this.publicTitle(current)}: premature review: ${admitted.blockers.join("; ")}`;
|
|
3007
3263
|
if (mode === "enforce") throw new (preEffect ? PreEffectToolRefusal : Error)(message);
|
|
3008
3264
|
this.releaseNotice(message);
|
|
3009
3265
|
}
|
|
3010
|
-
return
|
|
3266
|
+
return admitted;
|
|
3011
3267
|
}
|
|
3012
3268
|
|
|
3013
3269
|
private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness): void {
|
|
@@ -3024,6 +3280,9 @@ export class Tasks {
|
|
|
3024
3280
|
// A checklist that maps the change to its contract (issue #35): reviewers who check against the written plan catch more.
|
|
3025
3281
|
const plan = planText(task.plan);
|
|
3026
3282
|
const check = [...task.history].reverse().find((h) => h.event === "check passed");
|
|
3283
|
+
const cachedPlanRead = this.planReads.get(task.id);
|
|
3284
|
+
const planRead = this.planReport(task, cachedPlanRead?.identity === this.planReadIdentity(task) && cachedPlanRead.evidence.head === this.findingHead(task) ? cachedPlanRead.evidence : undefined);
|
|
3285
|
+
const conformanceText = mode !== "off" ? this.screen(task, `Plan conformance: ${JSON.stringify(planRead)}`, "plan conformance", reviewer) + "\n" : "";
|
|
3027
3286
|
const checklist = [
|
|
3028
3287
|
"Checklist:",
|
|
3029
3288
|
...this.planGuardItems(task).map(item => `- Check the plan against this live fail before the remaining checks: ${item.line}`),
|
|
@@ -3037,6 +3296,9 @@ export class Tasks {
|
|
|
3037
3296
|
const specRepo = task.sdlc?.repo ?? task.external?.repo, specIssue = task.sdlc?.issue ?? task.external?.issue;
|
|
3038
3297
|
const spec = reviewOnly && specRepo && specIssue ? `Spec issue: ${specRepo}#${specIssue}\n` : "";
|
|
3039
3298
|
const findings = reviewOnly ? task.history.filter(h => h.event === "changes_requested" && h.note).map(h => `- ${h.by}: ${this.screen(task, h.note!, "review note", reviewer, h.withheld)}`) : [];
|
|
3299
|
+
const carried = findingsForHead(this.reviewFindingSets(task), this.findingHead(task)).filter(item => item.severity === "important" && item.disposition === "open");
|
|
3300
|
+
const visible = this.isPii(task) ? [] : carried.filter(item => this.findingTextAllowed(task, [item]) && !task.history.some(entry => entry.withheld && entry.reviewFindings?.items.some(value => value.id === item.id)));
|
|
3301
|
+
const carriedText = carried.length ? `Open Important findings carried until explicitly dispositioned: ${carried.length}\n${visible.map(({ id, pass, severity, path }) => JSON.stringify({ id, pass, severity, ...(path ? { path } : {}) })).join("\n")}${visible.length < carried.length ? "\nSome finding details withheld." : ""}\n` : "";
|
|
3040
3302
|
const guidance = this.roleGuidance(task, reviewer, true);
|
|
3041
3303
|
let projection = "";
|
|
3042
3304
|
try { const text = this.d.projectedReview?.(task); if (text) projection = this.screen(task, sanitize(text), "document projection", reviewer); }
|
|
@@ -3044,7 +3306,7 @@ export class Tasks {
|
|
|
3044
3306
|
const brief = this.briefText(task);
|
|
3045
3307
|
// #572 AC3: advisory only, from git state never from task text, so it is safe for a PII task too.
|
|
3046
3308
|
const checkout = mainCheckoutWarning(this.d.cwd, this.publicMetadataText);
|
|
3047
|
-
const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${checkout ? `Note: ${checkout}.\n` : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
|
|
3309
|
+
const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${last?.noWrites ? "no writes recorded since acceptance\n" : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${checkout ? `Note: ${checkout}.\n` : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${carriedText}${conformanceText}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet, findings: [{id, pass: "bugs" | "security" | "compliance", severity: "important" | "nit", path?, disposition: "open" | "fixed" | "false_positive"}]}. Reuse finding ids when updating their disposition; only the person may accept a finding with a rationale.`;
|
|
3048
3310
|
this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) }));
|
|
3049
3311
|
}
|
|
3050
3312
|
|
|
@@ -3093,11 +3355,13 @@ export class Tasks {
|
|
|
3093
3355
|
} catch { return "Review brief unavailable: it could not be built."; }
|
|
3094
3356
|
}
|
|
3095
3357
|
|
|
3096
|
-
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown): Promise<Task> {
|
|
3358
|
+
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown, findings?: unknown): Promise<Task> {
|
|
3097
3359
|
const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
|
|
3360
|
+
let findingGuard: ((task: Task) => void) | undefined;
|
|
3098
3361
|
// #520: what the coverage was checked against, pinned through every await: the commit and the lifecycle receipt (the
|
|
3099
3362
|
// inherited reviewedHead lives there).
|
|
3100
3363
|
let covered: { commit: string | undefined; receipt: string } | undefined;
|
|
3364
|
+
let conformanceIdentity: string | undefined;
|
|
3101
3365
|
const valid = (): Task => {
|
|
3102
3366
|
const refused = authority?.();
|
|
3103
3367
|
if (refused) throw new PreEffectToolRefusal(refused); // rechecked after every await, right before each write
|
|
@@ -3106,13 +3370,42 @@ export class Tasks {
|
|
|
3106
3370
|
this.mine(t, by, "reviewer", true);
|
|
3107
3371
|
if (verdict !== "approved" && verdict !== "changes_requested") throw new PreEffectToolRefusal('verdict must be "approved" or "changes_requested"');
|
|
3108
3372
|
if (t.state !== "in_review") throw new PreEffectToolRefusal(`task #${t.id} is ${t.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
|
|
3373
|
+
if (conformanceIdentity !== undefined && conformanceIdentity !== this.planIdentity(t)) throw new PreEffectToolRefusal("plan conformance identity changed during verdict");
|
|
3374
|
+
findingGuard?.(t);
|
|
3109
3375
|
return t;
|
|
3110
3376
|
};
|
|
3377
|
+
if ((this.d.sdlc?.config().mode ?? "off") !== "off") conformanceIdentity = this.planIdentity(valid());
|
|
3111
3378
|
const items = textList(unmet);
|
|
3112
3379
|
if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
|
|
3113
3380
|
// #520: an approval accounts for every reviewable path of the brief. enforce refuses before any effect, advise records
|
|
3114
3381
|
// the gap in the verdict note, off and a person's own verdict skip it.
|
|
3115
3382
|
const mode = this.d.sdlc?.config().mode ?? "off";
|
|
3383
|
+
const initial = valid(), suppliedFindings = findings !== undefined;
|
|
3384
|
+
let parsed: ReviewFinding[];
|
|
3385
|
+
try { parsed = parseReviewFindings(findings, { person: by === USER, maxNits: this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS }); }
|
|
3386
|
+
catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid structured findings"); }
|
|
3387
|
+
const resolvedHead = this.findingHead(initial);
|
|
3388
|
+
let findingHead = resolvedHead;
|
|
3389
|
+
if (head !== undefined) {
|
|
3390
|
+
try { findingHead = sdlcReceipt({ reviewedHead: head }, initial.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined)).reviewedHead; }
|
|
3391
|
+
catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid finding head"); }
|
|
3392
|
+
}
|
|
3393
|
+
const report: ReviewFindingSet | undefined = suppliedFindings ? { ...(findingHead ? { head: findingHead } : {}), items: parsed } : undefined;
|
|
3394
|
+
const proposedSets = (task: Task) => [...this.reviewFindingSets(task), ...(report ? [report] : [])];
|
|
3395
|
+
if (head !== undefined || suppliedFindings || this.reviewFindingSets(initial).length) {
|
|
3396
|
+
const identity = (task: Task) => JSON.stringify({ owner: task.owner, reviewer: task.reviewer, commit: task.refs.commit, receipt: task.sdlc, resolvedHead: this.findingHead(task), private: this.isPii(task), findings: this.reviewFindingSets(task) });
|
|
3397
|
+
const original = identity(initial);
|
|
3398
|
+
findingGuard = task => {
|
|
3399
|
+
if (identity(task) !== original) throw new PreEffectToolRefusal("task review identity, privacy or findings changed during the verdict; review current work again");
|
|
3400
|
+
if (by !== USER && suppliedFindings && this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("structured review of ordinary work is unavailable during a private turn");
|
|
3401
|
+
if (parsed.filter(item => item.severity === "nit").length > (this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS)) throw new PreEffectToolRefusal("review nit cap exceeded");
|
|
3402
|
+
if (report?.items.length && !report.head && this.d.sdlc?.config().mode === "enforce") throw new PreEffectToolRefusal("a finding report requires a resolvable full reviewed head in enforce");
|
|
3403
|
+
const current = findingsForHead(proposedSets(task), findingHead);
|
|
3404
|
+
if (current.length > 64) throw new PreEffectToolRefusal("one reviewed head supports at most 64 distinct finding IDs");
|
|
3405
|
+
if (verdict === "approved" && this.d.sdlc?.config().mode === "enforce" && findingTally(current).openImportant) throw new PreEffectToolRefusal("approval blocked: open important review findings on this head");
|
|
3406
|
+
};
|
|
3407
|
+
findingGuard(initial);
|
|
3408
|
+
}
|
|
3116
3409
|
// A Tasks without a brief provider (library use; the daemon always has one) has no coverage gate.
|
|
3117
3410
|
if (verdict === "approved" && by !== USER && mode !== "off" && this.d.reviewBrief) {
|
|
3118
3411
|
const t = valid(), enforce = mode === "enforce", list = (paths: string[]) => `${paths.slice(0, 20).join(", ")}${paths.length > 20 ? ", ..." : ""}`;
|
|
@@ -3138,15 +3431,17 @@ export class Tasks {
|
|
|
3138
3431
|
if (gap.length) note = `${note ?? ""}\nCoverage gap (${gap.length}): ${list(gap)}`.trim();
|
|
3139
3432
|
}
|
|
3140
3433
|
}
|
|
3434
|
+
if (head !== undefined && resolvedHead && findingHead !== resolvedHead) throw new PreEffectToolRefusal("supplied review head differs from the task resolved head");
|
|
3141
3435
|
// The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
|
|
3142
|
-
const withheld = await this.withholds("review note", note, valid())
|
|
3436
|
+
const withheld = await this.withholds("review note", parsed.length ? JSON.stringify({ note, findings: parsed }) : note, valid())
|
|
3437
|
+
|| parsed.length > 0 && !this.findingTextAllowed(initial, parsed);
|
|
3143
3438
|
let task = valid();
|
|
3144
3439
|
let reviewedHead: string | undefined;
|
|
3145
3440
|
if (head !== undefined) {
|
|
3146
3441
|
const checked = sdlcReceipt({ reviewedHead: head }, task.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined));
|
|
3147
3442
|
reviewedHead = checked.reviewedHead;
|
|
3148
3443
|
}
|
|
3149
|
-
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead); task = valid(); }
|
|
3444
|
+
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid(); }
|
|
3150
3445
|
let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
|
|
3151
3446
|
if (verdict === "changes_requested" && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|
|
3152
3447
|
const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
|
|
@@ -3161,10 +3456,10 @@ export class Tasks {
|
|
|
3161
3456
|
const pii = this.isPii(task);
|
|
3162
3457
|
const reviewContext = by === USER ? undefined : this.d.reviewContext?.(by, task.id);
|
|
3163
3458
|
const poolRequest = task.history.findLast(entry => ["done", "reviewer changed", "reviewer unavailable"].includes(entry.event));
|
|
3164
|
-
const family = by === USER ? undefined : familyOf(by, this.d.instances?.());
|
|
3459
|
+
const family = by === USER ? undefined : (this.d.adapterCatalog?.familyOf ?? familyOf)(by, this.d.instances?.());
|
|
3165
3460
|
const reviewerFamily = poolRequest?.reviewerFamily === family ? family : undefined;
|
|
3166
3461
|
const reviewerReason = reviewerFamily ? poolRequest?.reviewerReason : undefined;
|
|
3167
|
-
const flag = { withheld: withheld || undefined, ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
|
|
3462
|
+
const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
|
|
3168
3463
|
if (verdict === "approved") {
|
|
3169
3464
|
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);
|
|
3170
3465
|
if (next.owner) {
|
|
@@ -3180,9 +3475,12 @@ export class Tasks {
|
|
|
3180
3475
|
this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
|
|
3181
3476
|
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
|
|
3182
3477
|
await this.releaseDependents(next);
|
|
3478
|
+
// A proposal storage failure cannot roll back the saved verdict; another matching verdict retries it.
|
|
3479
|
+
this.releaseNotice(`task #${next.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(next, by, parsed, withheld));
|
|
3183
3480
|
return next;
|
|
3184
3481
|
}
|
|
3185
3482
|
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note, flag);
|
|
3483
|
+
this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
|
|
3186
3484
|
if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
|
|
3187
3485
|
try { this.d.memoryCheck?.(); this.d.candidates?.put({ ...candidate, evidenceRefs: [...candidate.evidenceRefs, `review:${rejected.id}:${rejected.history.length}`] }, "public"); } catch { /* a saved review remains authoritative if the optional sidecar fails */ }
|
|
3188
3486
|
}
|