@staix/agent-hub 0.12.44 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +83 -116
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +102 -14
- package/docs/agent-notes/README.md +15 -0
- package/docs/agent-notes/adapters.md +63 -46
- package/docs/agent-notes/budget.md +7 -2
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +15 -8
- package/docs/agent-notes/local-worker.md +6 -4
- package/docs/agent-notes/tasks.md +18 -5
- package/docs/agent-notes/tests.md +11 -9
- package/docs/dogfood.md +92 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +349 -26
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +12 -2
- package/docs/sdlc.md +61 -13
- package/docs/security.md +24 -12
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -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-22-0.12.45.md +60 -0
- package/docs/verification/2026-10-22-code-facts.md +78 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
- package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
- package/docs/verification/2026-10-23-0.13.0.md +89 -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/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +19 -16
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10120 -9623
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +3 -1
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +32 -12
- package/src/adapters/codex-appserver.ts +19 -2
- package/src/adapters/codex-review-isolation.ts +12 -5
- package/src/adapters/compat.ts +140 -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 +25 -7
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
- package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
- package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +5 -1
- 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 +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/pi.ts +2 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +13 -1
- package/src/adapters/registry.ts +52 -15
- package/src/cli/agents.ts +40 -0
- package/src/cli/console-review.ts +54 -0
- package/src/cli/console-state.ts +124 -29
- package/src/cli/console-symbols.ts +74 -0
- package/src/cli/console-usage.ts +89 -0
- package/src/cli/console.ts +26 -1
- package/src/cli/help.ts +36 -21
- package/src/cli/identity.ts +30 -7
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +145 -16
- package/src/cli/output.ts +44 -1
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +4 -1
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +72 -11
- package/src/hub/budget.ts +71 -2
- package/src/hub/bus.ts +43 -3
- package/src/hub/checks.ts +80 -31
- package/src/hub/code-facts.ts +223 -0
- package/src/hub/config-apply.ts +28 -11
- package/src/hub/config-reload.ts +8 -5
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +332 -61
- package/src/hub/dogfood-read.ts +199 -0
- package/src/hub/dogfood.ts +163 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +11 -2
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +45 -7
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instance-config.ts +17 -5
- package/src/hub/instance-execution.ts +20 -8
- package/src/hub/instances.ts +75 -2
- package/src/hub/peers.ts +9 -0
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/profiles.ts +7 -2
- package/src/hub/release-worktree.ts +56 -0
- package/src/hub/review-brief.ts +36 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +31 -5
- package/src/hub/sdlc-queue.ts +48 -11
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +71 -18
- package/src/hub/settings.ts +10 -3
- package/src/hub/snapshots.ts +101 -14
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +556 -88
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/ui.ts +2 -2
- package/src/hub/usage-rollup.ts +361 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +560 -78
- 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/dev.json +6 -1
- package/templates/profiles/general.json +6 -1
- package/templates/profiles/research.json +6 -1
- package/templates/profiles/writing.json +6 -1
- package/templates/release-guard.block.md +6 -0
package/src/hub/tasks.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { buildTaskCodeSymbols, publicTaskCodeSymbols, taskCodeSymbolsDisjoint, HOST_CODE_SYMBOLS } from "./task-code-symbols.ts";
|
|
2
|
+
import { normalizePlanSymbol, symbolsOverlap } from "./symbol-normalizer.ts";
|
|
3
|
+
import { publicTestIntegrity, readTestIntegrity, testIntegrityBlockers, type TestIntegrity } from "./test-integrity.ts";
|
|
4
|
+
import { completePlanInventory, planConformance, planConformanceBlockers, planFingerprint, type PlanConformance } from "./plan-conformance.ts";
|
|
1
5
|
import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
|
|
2
6
|
import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
|
|
3
7
|
import { specRefs } from "./spec-read.ts";
|
|
@@ -8,6 +12,7 @@ import { NoWriteTurns } from "./no-writes.ts";
|
|
|
8
12
|
import { readGitHubImport, type GitHubSource, type GitHubImportSelection } from "./github-import.ts";
|
|
9
13
|
import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
|
|
10
14
|
import { readMergeQueue } from "./sdlc-queue.ts";
|
|
15
|
+
import { DEFAULT_MAX_NITS, parseReviewFindings, findingsForHead, findingHead as resolveFindingHead, findingTally, type ReviewFinding, type ReviewFindingSet } from "./review-findings.ts";
|
|
11
16
|
import { readReleaseReadiness } from "./sdlc-release.ts";
|
|
12
17
|
import { loadRoleCatalog, taskRole, taskRoleText, type RoleCatalog } from "./roles.ts";
|
|
13
18
|
import { matchesPolicy } from "./path-policy.ts";
|
|
@@ -35,7 +40,7 @@ import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief
|
|
|
35
40
|
import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
|
|
36
41
|
import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
37
42
|
import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
38
|
-
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";
|
|
43
|
+
import { linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
|
|
39
44
|
|
|
40
45
|
export type TaskAssignmentRole = "owner" | "reviewer";
|
|
41
46
|
|
|
@@ -70,6 +75,7 @@ export interface TasksDeps {
|
|
|
70
75
|
/** Explicit acceptance/self-claim only; implicit done has no pre-work baseline. */
|
|
71
76
|
ownerAccepted?: (task: Task) => void;
|
|
72
77
|
/** #520: the review brief for a task's commit (git only, bounded); undefined when the task names no commit. */
|
|
78
|
+
prepareReviewBrief?: (task: Task) => Promise<void>;
|
|
73
79
|
reviewBrief?: (task: Task) => ReviewBrief | { unavailable: string } | undefined;
|
|
74
80
|
sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
|
|
75
81
|
briefs?: Briefs;
|
|
@@ -174,6 +180,8 @@ const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
|
|
|
174
180
|
const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
175
181
|
/** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
|
|
176
182
|
const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
|
|
183
|
+
QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits for its reserved owner's WIP slot.
|
|
184
|
+
QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
|
|
177
185
|
|
|
178
186
|
/**
|
|
179
187
|
* Thrown when an assignment's board write is saved but a publish or notify after it failed (#297): the move is
|
|
@@ -250,9 +258,17 @@ function acceptRefs(input: unknown): TaskRefs | undefined {
|
|
|
250
258
|
/** The same for a plan (issue #31): only its four lists, each of short strings. */
|
|
251
259
|
function cleanPlan(input: unknown, profile: ProfileSelection = "dev"): TaskPlan {
|
|
252
260
|
const r = fields(input);
|
|
261
|
+
const evidence: Pick<TaskPlan, "proof" | "risks"> = {};
|
|
262
|
+
for (const key of ["proof", "risks"] as const) {
|
|
263
|
+
const value = r[key];
|
|
264
|
+
if (value === undefined) continue;
|
|
265
|
+
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`);
|
|
266
|
+
const items = value.map(item => item.replace(/\s+/g, " ").trim()).filter(Boolean);
|
|
267
|
+
if (items.length) evidence[key] = items;
|
|
268
|
+
}
|
|
253
269
|
let scope: CostPlan;
|
|
254
270
|
try { scope = cleanCostPlan(r); } catch { throw new PreEffectToolRefusal("invalid review or CI cost scope"); }
|
|
255
|
-
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 };
|
|
271
|
+
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 };
|
|
256
272
|
}
|
|
257
273
|
|
|
258
274
|
/** A plan as one line for other owners; the whole plan is on the board. */
|
|
@@ -266,6 +282,9 @@ const RESCREEN_MAX = 10;
|
|
|
266
282
|
* The task flow. Adapters and tools never touch the board: every change comes through here, where assignment,
|
|
267
283
|
* PII handling, envelopes, briefs and memory notes are decided in one place.
|
|
268
284
|
*/
|
|
285
|
+
/** #694: a PR staged at done with the commit or branch it was read for. */
|
|
286
|
+
interface IdentityLink { repo: string; pr: number; basis: { commit?: string; branch?: string } }
|
|
287
|
+
|
|
269
288
|
export class Tasks {
|
|
270
289
|
private readonly repositories: TaskRepositories;
|
|
271
290
|
private readonly incomingBacklog = new Set<number>();
|
|
@@ -280,6 +299,7 @@ export class Tasks {
|
|
|
280
299
|
private sweeping = false;
|
|
281
300
|
|
|
282
301
|
constructor(private readonly d: TasksDeps) {
|
|
302
|
+
d.board.trackPlanRevisions = () => (d.sdlc?.config().mode ?? "off") !== "off";
|
|
283
303
|
this.repositories = new TaskRepositories(d.cwd, d.repository);
|
|
284
304
|
for (const task of d.board.list()) this.warmRepository(task);
|
|
285
305
|
if (d.coordination) {
|
|
@@ -389,7 +409,7 @@ export class Tasks {
|
|
|
389
409
|
} else {
|
|
390
410
|
const admission = this.plannerPullRecord(task)?.pull ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.weights(task.class, task) });
|
|
391
411
|
this.releaseNotice(`${text} Owner reassignment suggestion: ${admission.owner ?? "no eligible peer"}.`);
|
|
392
|
-
if (admission.owner && config.auto_reassign && this.sweepAvailable(admission.owner)) {
|
|
412
|
+
if (admission.owner && config.auto_reassign && this.sweepAvailable(admission.owner) && !this.reservedHold(task)) {
|
|
393
413
|
try { await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [admission.owner], event: "reassigned", reason: "idle", note: "ready-unassigned idle sweep" }); }
|
|
394
414
|
catch (e) { if (!(e instanceof AssignmentUndeliveredError)) throw e; }
|
|
395
415
|
}
|
|
@@ -526,14 +546,62 @@ export class Tasks {
|
|
|
526
546
|
/** What peers other than the owner, the console stream and the log may see of a task. */
|
|
527
547
|
publicTitle = (task: Task) => (this.isPii(task) ? `#${task.id} [pii]` : `#${task.id} ${task.title}`);
|
|
528
548
|
|
|
549
|
+
private reviewFindingSets(task: Task): ReviewFindingSet[] { return task.history.flatMap(entry => entry.reviewFindings ? [entry.reviewFindings] : []); }
|
|
550
|
+
|
|
551
|
+
private findingHead(task: Task, reviewedHead?: string): string | undefined {
|
|
552
|
+
return resolveFindingHead(task.sdlc, reviewedHead ? { head: reviewedHead } : undefined, task.refs.commit);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private findingTextAllowed(task: Task, items: readonly ReviewFinding[]): boolean {
|
|
556
|
+
return this.candidateTextAllowed(JSON.stringify(items), task)
|
|
557
|
+
&& items.every(item => !item.path || !matchesPolicy(this.d.cwd, item.path, this.d.piiPaths ?? [], true));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Host-authored proposals contain only cleared IDs, integer task references and fixed instruction targets. */
|
|
561
|
+
private proposeRecurringFindings(task: Task, by: PeerId, items: readonly ReviewFinding[], withheld: boolean): void {
|
|
562
|
+
if (withheld || this.isPii(task) || this.d.privateTurn?.(by) || !items.length || !this.findingTextAllowed(task, items)) return;
|
|
563
|
+
const rows = this.d.board.list();
|
|
564
|
+
const keys = new Set(rows.filter(row => row.class === "plan" && row.history[0]?.by === HUB)
|
|
565
|
+
.flatMap(row => row.detail?.split("\n").filter(line => line.startsWith("Review finding key: ")) ?? []));
|
|
566
|
+
const created: number[] = [];
|
|
567
|
+
// No await separates the durable dedupe read from creation; concurrent verdicts see the first proposal.
|
|
568
|
+
for (const item of items.filter(item => item.disposition !== "false_positive")) {
|
|
569
|
+
const same = (finding: ReviewFinding) => finding.id === item.id && finding.pass === item.pass;
|
|
570
|
+
const holders = rows.filter(row => {
|
|
571
|
+
if (this.isPii(row)) return false;
|
|
572
|
+
const last = row.history.findLast(entry => entry.reviewFindings?.items.some(value => value.id === item.id));
|
|
573
|
+
const finding = last?.reviewFindings?.items.find(value => value.id === item.id);
|
|
574
|
+
return !last?.withheld && finding && same(finding) && finding.disposition !== "false_positive" && this.findingTextAllowed(row, [finding]);
|
|
575
|
+
});
|
|
576
|
+
if (new Set(holders.map(row => row.id)).size < 2) continue;
|
|
577
|
+
const key = `Review finding key: ${item.pass}:${item.id}`;
|
|
578
|
+
if (keys.has(key)) continue;
|
|
579
|
+
const title = `Review policy amendment for recurring finding ${item.pass}:${item.id}`;
|
|
580
|
+
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.`;
|
|
581
|
+
const draft = { title, detail, refs: { paths: ["AGENTS.md", "docs/agent-notes/"] } };
|
|
582
|
+
const signals = this.signalsFor(draft);
|
|
583
|
+
if (this.isPii({ signals }) || !this.candidateTextAllowed(`${title}\n${detail}`)) continue;
|
|
584
|
+
const proposal = this.d.board.propose(HUB, { ...draft, class: "plan", backlog: true, signals });
|
|
585
|
+
this.incomingBacklog.add(proposal.id); keys.add(key); created.push(proposal.id);
|
|
586
|
+
}
|
|
587
|
+
for (const id of created) this.releaseNotice(`task #${id}: recurring review finding amendment proposed in the backlog`);
|
|
588
|
+
}
|
|
589
|
+
|
|
529
590
|
/** A task as a cloud peer may see it. */
|
|
591
|
+
|
|
530
592
|
publicView(task: Task, fullHistory = false): Record<string, unknown> {
|
|
531
|
-
const { title, detail, history, external, cancelled, ...
|
|
593
|
+
const { title, detail, history, external, cancelled, codeSymbols, ...taskRest } = task;
|
|
594
|
+
const rest = { ...taskRest, ...(history.some(h => h.by === USER && h.planDeparture) ? { planDepartureAccepted: true } : {}) };
|
|
532
595
|
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]" };
|
|
596
|
+
if (rest.sdlc?.redFirst && !this.publicMetadataText(JSON.stringify(rest.sdlc.redFirst))) {
|
|
597
|
+
const { redFirst: _withheld, ...receipt } = rest.sdlc; rest.sdlc = receipt;
|
|
598
|
+
}
|
|
533
599
|
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}]`;
|
|
534
600
|
const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
|
|
535
601
|
at: h.at, by: h.by, event: h.event,
|
|
536
602
|
...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note", h.withheld) }),
|
|
603
|
+
...(h.reviewFindings ? { reviewFindings: !h.withheld && this.findingTextAllowed(task, h.reviewFindings.items) ? h.reviewFindings
|
|
604
|
+
: { ...(h.reviewFindings.head ? { head: h.reviewFindings.head } : {}), tally: findingTally(h.reviewFindings.items), withheld: true } } : {}),
|
|
537
605
|
...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
|
|
538
606
|
...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
|
|
539
607
|
...(h.owner === undefined ? {} : { owner: h.owner }),
|
|
@@ -545,7 +613,10 @@ export class Tasks {
|
|
|
545
613
|
...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
|
|
546
614
|
...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
|
|
547
615
|
}));
|
|
548
|
-
|
|
616
|
+
const sets = this.reviewFindingSets(task), current = findingsForHead(sets, this.findingHead(task));
|
|
617
|
+
const symbolRepository = codeSymbols && this.codeSymbolsRepository(task);
|
|
618
|
+
const publicSymbols = codeSymbols && symbolRepository ? publicTaskCodeSymbols(codeSymbols, this.reviewHead(task), symbolRepository.key, this.publicMetadataText) : undefined;
|
|
619
|
+
return { ...rest, ...(publicSymbols ? { codeSymbols: publicSymbols } : {}), ...(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 };
|
|
549
620
|
}
|
|
550
621
|
|
|
551
622
|
/** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
|
|
@@ -761,7 +832,7 @@ export class Tasks {
|
|
|
761
832
|
// were stored as written, so both sides are compared in one spelling.
|
|
762
833
|
if (mine.repositoryKey !== theirs.repositoryKey) continue;
|
|
763
834
|
const file = (p: string) => normPath(p) !== "." && theirs.paths.some((q) => normPath(q) === normPath(p));
|
|
764
|
-
const same = mine.paths.some(file) || mine.symbols.some((x) => theirs.symbols.
|
|
835
|
+
const same = mine.paths.some(file) || mine.symbols.some((x) => theirs.symbols.some((y) => symbolsOverlap(x, y)));
|
|
765
836
|
if (!same || this.d.board.reviews({ task: t.id }).some((r) => r.kind === "contradicted")) continue;
|
|
766
837
|
this.d.board.recordReview({ implementer: t.owner, reviewer: approval.by, class: t.class, kind: "contradicted", task: t.id });
|
|
767
838
|
}
|
|
@@ -1421,6 +1492,7 @@ export class Tasks {
|
|
|
1421
1492
|
this.withdrawRoleRequest(confirmed, returned, "reviewer");
|
|
1422
1493
|
this.incomingBacklog.add(confirmed.id);
|
|
1423
1494
|
this.planningRecorded({ by, task: confirmed.id, fields: ["return"], revision: confirmed.planningRevision ?? 0 });
|
|
1495
|
+
await this.releaseHeld(confirmed.owner); // its owner's slot is free (#655)
|
|
1424
1496
|
return returned;
|
|
1425
1497
|
}
|
|
1426
1498
|
|
|
@@ -1548,6 +1620,18 @@ export class Tasks {
|
|
|
1548
1620
|
/** The tasks an overlap text for `task` names: their plans are in it (issue #108). */
|
|
1549
1621
|
overlapTasks = (task: Task): number[] => this.overlapHits(task).map((h) => h.task.id);
|
|
1550
1622
|
|
|
1623
|
+
/** Console detail advice only: no pair persistence, and no private task on either side. */
|
|
1624
|
+
symbolOverlaps(task: Task): { task: number; files: string[]; sameFileDifferentSymbols: boolean }[] {
|
|
1625
|
+
// A routing-locality opt-out does not make symbol metadata for a marked task public.
|
|
1626
|
+
const markedPrivate = (value: Task) => value.signals.includes("pii") || value.signals.includes("pii_path");
|
|
1627
|
+
if (markedPrivate(task)) return [];
|
|
1628
|
+
return this.overlapHits(task).filter(hit => !markedPrivate(hit.task)).map(hit => ({
|
|
1629
|
+
task: hit.task.id,
|
|
1630
|
+
files: hit.paths.filter(this.publicMetadataText),
|
|
1631
|
+
sameFileDifferentSymbols: this.sameFileDifferentSymbols(task, hit),
|
|
1632
|
+
}));
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1551
1635
|
/**
|
|
1552
1636
|
* Dashboard warning links (#264): the board's overlap pairs and the live cohorts, as task and peer ids only.
|
|
1553
1637
|
* Paths, symbols and plans stay out; PII tasks are excluded by the same rules overlapHits applies.
|
|
@@ -1590,12 +1674,23 @@ export class Tasks {
|
|
|
1590
1674
|
* ponytail: kept in memory, so after a restart such a notice is delivered whatever its task's state; persist the
|
|
1591
1675
|
* condition if stale notices after restarts show up.
|
|
1592
1676
|
*/
|
|
1593
|
-
private readonly conditional = new Map<string, { peer: PeerId; task: number; states: Task["state"][] }>();
|
|
1677
|
+
private readonly conditional = new Map<string, { peer: PeerId; task: number; tasks?: number[]; states: Task["state"][] }>();
|
|
1594
1678
|
|
|
1595
1679
|
/**
|
|
1596
1680
|
* Publish a notice about `task`, which only matters to `to` while that task is in one of `states` for it (issue
|
|
1597
1681
|
* #106): open work by default; the conflict notices of #91 count a task in review too.
|
|
1598
1682
|
*/
|
|
1683
|
+
/**
|
|
1684
|
+
* #713: the completed-change notice of task `done` for one recipient, about all of its tasks in `about`. It stays relevant while any of them is still open for the
|
|
1685
|
+
* recipient, and carries `completedFor` so the bus replaces an earlier queued notice for the same `done` task and recipient.
|
|
1686
|
+
*/
|
|
1687
|
+
whileOpenFor(to: PeerId, done: number, about: number[], body: string): void {
|
|
1688
|
+
const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(about[0]), taskRecipient: "owner", completedFor: done } });
|
|
1689
|
+
this.conditional.set(env.id, { peer: to, task: about[0]!, tasks: about, states: OPEN });
|
|
1690
|
+
if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
|
|
1691
|
+
this.d.bus.publish(env);
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1599
1694
|
whileOpen(to: PeerId, task: number, body: string, states: Task["state"][] = OPEN): void {
|
|
1600
1695
|
const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task), taskRecipient: "owner" } });
|
|
1601
1696
|
this.conditional.set(env.id, { peer: to, task, states });
|
|
@@ -1624,8 +1719,8 @@ export class Tasks {
|
|
|
1624
1719
|
}
|
|
1625
1720
|
const c = this.conditional.get(env.id);
|
|
1626
1721
|
if (!c || c.peer !== peer) return true;
|
|
1627
|
-
|
|
1628
|
-
return !!t && t.owner === peer && c.states.includes(t.state);
|
|
1722
|
+
// A notice about several of the recipient's tasks matters while any one of them is still open for it (#713).
|
|
1723
|
+
return (c.tasks ?? [c.task]).some((id) => { const t = this.d.board.get(id); return !!t && t.owner === peer && c.states.includes(t.state); });
|
|
1629
1724
|
};
|
|
1630
1725
|
|
|
1631
1726
|
/** Whether a model-written name may be shown to other peers and in the log: one matching a PII pattern may be PII. */
|
|
@@ -1661,7 +1756,12 @@ export class Tasks {
|
|
|
1661
1756
|
private places(task: Task): { repositoryKey: string; paths: string[]; symbols: string[] } {
|
|
1662
1757
|
const scope = this.repositories.scope(task.refs);
|
|
1663
1758
|
const paths = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].map((p) => scope === this.repositories.project ? this.projectPath(p) : normPath(this.repositories.relativePath(scope, p)));
|
|
1664
|
-
|
|
1759
|
+
const symbols = [
|
|
1760
|
+
...(task.plan?.symbols ?? []).map(normalizePlanSymbol),
|
|
1761
|
+
...(task.plan?.sections ?? []).map(v => `section ${v}`),
|
|
1762
|
+
...(task.plan?.sources ?? []).map(v => `source ${v}`),
|
|
1763
|
+
];
|
|
1764
|
+
return { repositoryKey: scope.key, paths: [...new Set(paths)], symbols: [...new Set(symbols)] };
|
|
1665
1765
|
}
|
|
1666
1766
|
|
|
1667
1767
|
private roots?: string[];
|
|
@@ -1687,7 +1787,7 @@ export class Tasks {
|
|
|
1687
1787
|
const theirs = this.places(t);
|
|
1688
1788
|
if (mine.repositoryKey !== theirs.repositoryKey) return [];
|
|
1689
1789
|
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)));
|
|
1690
|
-
const symbols = mine.symbols.filter((x) => theirs.symbols.
|
|
1790
|
+
const symbols = mine.symbols.filter((x) => theirs.symbols.some((y) => symbolsOverlap(x, y)));
|
|
1691
1791
|
return paths.length || symbols.length ? [{ task: t, paths, symbols }] : [];
|
|
1692
1792
|
});
|
|
1693
1793
|
}
|
|
@@ -2037,13 +2137,49 @@ export class Tasks {
|
|
|
2037
2137
|
if (waits.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} waits for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet`);
|
|
2038
2138
|
}
|
|
2039
2139
|
|
|
2140
|
+
/** Ready offers between their cap check and the owner write (#655): task id -> reserved owner whose slot it takes. */
|
|
2141
|
+
private readonly readyInFlight = new Map<number, PeerId>();
|
|
2142
|
+
|
|
2143
|
+
/** Offer order for ready work (#655): priority (urgent is P0, none is P2), then rank, then id. */
|
|
2144
|
+
private readonly readyOrder = (a: Task, b: Task): number =>
|
|
2145
|
+
(a.priority ?? (a.signals.includes("urgent") ? 0 : 2)) - (b.priority ?? (b.signals.includes("urgent") ? 0 : 2))
|
|
2146
|
+
|| (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER) || a.id - b.id;
|
|
2147
|
+
|
|
2148
|
+
/** Ready work the hub parked on its reserved owner's WIP cap (#655): its latest move-or-hold entry is `ready held`. */
|
|
2149
|
+
private isHeld(task: Task): boolean {
|
|
2150
|
+
// A planner's priority or rank edit (`planned`) is no move: it must not make held work look released.
|
|
2151
|
+
return task.history.findLast(entry => entry.event === "ready held" || !QUIET_EVENTS.has(entry.event) && entry.event !== "planned")?.event === "ready held";
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
/**
|
|
2155
|
+
* Why readiness must not hand `task` to its reserved owner yet (#655): that owner is at its planner WIP cap, the count
|
|
2156
|
+
* `acceptanceCapacity` refuses at. Peer id and counts only, so it is safe for a PII task. A reserved owner the task
|
|
2157
|
+
* excludes is not honoured by routing, and planner-pulled work was admitted by its pull: neither is held.
|
|
2158
|
+
*/
|
|
2159
|
+
private reservedHold(task: Task): string | undefined {
|
|
2160
|
+
const owner = task.reserved;
|
|
2161
|
+
if (!owner || task.owner || this.plannerPullRecord(task)?.pull === true || this.excluded(task).includes(owner)) return undefined;
|
|
2162
|
+
// Offers in flight take a slot before their owner is written; once it is written wip() counts it, so never twice.
|
|
2163
|
+
const flying = [...this.readyInFlight].filter(([id, peer]) => peer === owner && id !== task.id && !this.d.board.get(id)?.owner).length;
|
|
2164
|
+
const limit = this.wipLimit(owner), used = this.wip(owner, task.id) + (this.pendingClaims.get(owner) ?? 0) + flying;
|
|
2165
|
+
return used >= limit ? `reserved owner ${owner} is at its planner WIP cap (${used}/${limit}); stays ready in To Do with its reservation until a slot frees` : undefined;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
/** The task is ready but its reserved owner has no room: say so once on the task history, never assign. */
|
|
2169
|
+
private holdReady(task: Task, why: string, reason: string): void {
|
|
2170
|
+
if (this.isHeld(task)) return;
|
|
2171
|
+
const ready = task.history.at(-1)?.event === "ready" ? task : this.d.board.update(task.id, HUB, "ready", {}, why);
|
|
2172
|
+
this.d.board.update(ready.id, HUB, "ready held", {}, reason);
|
|
2173
|
+
this.releaseNotice(`task ${this.publicTitle(ready)} is ready but held: ${reason}`);
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2040
2176
|
/** An approved task may be the last thing others waited for: those go through assignment now (issue #34). */
|
|
2041
2177
|
private async releaseDependents(approved: Task): Promise<void> {
|
|
2042
2178
|
try {
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
}
|
|
2179
|
+
// Its dependents and, as its owner's slot just freed (#655), that owner's held ready tasks: one list in priority order.
|
|
2180
|
+
const ready = this.d.board.list("proposed").filter(t => !t.owner && !this.waitsFor(t).length
|
|
2181
|
+
&& (t.deps?.includes(approved.id) || !!approved.owner && t.reserved === approved.owner && this.isHeld(t))).sort(this.readyOrder);
|
|
2182
|
+
for (const t of ready) await this.offerReady(t, t.deps?.includes(approved.id) ? `#${approved.id} approved` : "owner slot freed");
|
|
2047
2183
|
} catch (e) {
|
|
2048
2184
|
this.releaseNotice(`task #${approved.id}: could not release dependents: ${e instanceof Error ? e.message : String(e)}`);
|
|
2049
2185
|
}
|
|
@@ -2165,14 +2301,25 @@ export class Tasks {
|
|
|
2165
2301
|
this.offered.delete(task.id);
|
|
2166
2302
|
this.releaseNotice(`task #${task.id}: cancellation stall cleanup unavailable`, () => this.d.board.clearStall(task.id));
|
|
2167
2303
|
for (const peer of new Set([task.owner, task.reviewer])) if (peer && peer !== USER) this.releaseNotice(`task #${task.id}: cancellation notice unavailable`, () => this.d.bus.publish(newEnvelope(HUB, sanitize(`Task #${task.id} cancelled. Stop working on it. No native turn or working tree was changed.`), { to: [peer], kind: "task", priority: "important", refs: { task: String(task.id), taskCancelled: cancelled.flowRevision }, ...(this.isPii(next) ? { private: true } : {}) })));
|
|
2304
|
+
await this.releaseHeld(task.owner); // its owner's slot is free (#655)
|
|
2168
2305
|
this.releaseNotice(`task #${task.id}: cancellation dependent advice unavailable`, () => {
|
|
2169
2306
|
for (const dependent of this.d.board.list()) if (!isClosedTaskState(dependent.state) && !dependent.owner && dependent.deps?.includes(task.id)) this.releaseNotice(`task #${dependent.id}: cancellation stall advice unavailable`, () => this.reportStall(dependent, { kind: "cancelled-dependency", dependency: task.id, code: "cancelled" }));
|
|
2170
2307
|
});
|
|
2171
2308
|
return next;
|
|
2172
2309
|
}
|
|
2173
2310
|
|
|
2311
|
+
/** A slot freed for `peer` (cancel, untouched return): its held ready tasks are offered in priority order, and the cap stops the offers (#655). */
|
|
2312
|
+
private async releaseHeld(peer: PeerId | null | undefined): Promise<void> {
|
|
2313
|
+
if (!peer) return;
|
|
2314
|
+
try {
|
|
2315
|
+
for (const t of this.d.board.list("proposed").filter(t => !t.owner && t.reserved === peer && this.isHeld(t) && !this.waitsFor(t).length).sort(this.readyOrder)) await this.offerReady(t, "owner slot freed");
|
|
2316
|
+
} catch (e) {
|
|
2317
|
+
this.releaseNotice(`task ${peer}: could not offer held ready work: ${e instanceof Error ? e.message : String(e)}`);
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2174
2321
|
async releaseReady(): Promise<void> {
|
|
2175
|
-
for (const t of this.d.board.list("proposed")) {
|
|
2322
|
+
for (const t of [...this.d.board.list("proposed")].sort(this.readyOrder)) {
|
|
2176
2323
|
if (t.owner) { this.d.board.clearStall(t.id); continue; }
|
|
2177
2324
|
const cancelledDependency = t.deps?.find(id => this.d.board.get(id)?.state === "cancelled");
|
|
2178
2325
|
if (cancelledDependency) { this.reportStall(t, { kind: "cancelled-dependency", dependency: cancelledDependency, code: "cancelled" }); continue; }
|
|
@@ -2184,7 +2331,7 @@ export class Tasks {
|
|
|
2184
2331
|
continue;
|
|
2185
2332
|
}
|
|
2186
2333
|
this.d.board.clearStall(t.id);
|
|
2187
|
-
if ((!t.deps?.length && !t.history.some(h => h.event === "backlog pulled")) || (!planned && last !== "blocked" && last !== "ready" && last !== "reserved")) continue;
|
|
2334
|
+
if ((!t.deps?.length && !t.history.some(h => h.event === "backlog pulled")) || (!planned && last !== "blocked" && last !== "ready" && last !== "reserved" && !this.isHeld(t))) continue;
|
|
2188
2335
|
// ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
|
|
2189
2336
|
// an offline one; hold reserved tasks for a grace period if restarts show that up.
|
|
2190
2337
|
await this.offerReady(t, "nothing left to wait for");
|
|
@@ -2193,13 +2340,18 @@ export class Tasks {
|
|
|
2193
2340
|
|
|
2194
2341
|
/** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
|
|
2195
2342
|
private async offerReady(stale: Task, why: string): Promise<void> {
|
|
2196
|
-
let offering = false, undelivered: AssignmentUndeliveredError | undefined;
|
|
2343
|
+
let offering = false, undelivered: AssignmentUndeliveredError | undefined, slot: PeerId | undefined;
|
|
2197
2344
|
try {
|
|
2198
2345
|
const t = this.d.board.get(stale.id);
|
|
2199
2346
|
if (!t || t.state !== "proposed" || t.owner || this.personUnassigned(t) || this.offered.has(t.id)) return;
|
|
2200
2347
|
const planned = this.plannerPullRecord(t)?.pull === true;
|
|
2201
2348
|
const selected = planned ? this.plannerAssignment(t, true).owner : undefined;
|
|
2202
2349
|
if (planned && !selected) return;
|
|
2350
|
+
const held = planned ? undefined : this.reservedHold(t); // #655: the cap holds before the ready move is offered
|
|
2351
|
+
if (held) { this.holdReady(t, why, held); return; }
|
|
2352
|
+
// The check and the slot are one synchronous step: assignOwner can yield before it writes the owner, and a second offer
|
|
2353
|
+
// for the same reserved owner must see this one as taking the last slot.
|
|
2354
|
+
if (!planned && t.reserved) { slot = t.reserved; this.readyInFlight.set(t.id, slot); }
|
|
2203
2355
|
this.offered.add(t.id);
|
|
2204
2356
|
offering = true;
|
|
2205
2357
|
const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
|
|
@@ -2211,6 +2363,7 @@ export class Tasks {
|
|
|
2211
2363
|
if (e instanceof AssignmentUndeliveredError) undelivered = e;
|
|
2212
2364
|
else this.releaseNotice(`task ${this.publicTitle(stale)}: could not be assigned: ${e instanceof Error ? e.message : String(e)}`);
|
|
2213
2365
|
} finally {
|
|
2366
|
+
if (slot) this.readyInFlight.delete(stale.id);
|
|
2214
2367
|
if (offering && !undelivered) {
|
|
2215
2368
|
try {
|
|
2216
2369
|
if (!this.d.board.get(stale.id)?.owner) this.offered.delete(stale.id);
|
|
@@ -2226,6 +2379,48 @@ export class Tasks {
|
|
|
2226
2379
|
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`);
|
|
2227
2380
|
}
|
|
2228
2381
|
|
|
2382
|
+
/** #694: write the one issue the task itself names (the plan-review source) into its lifecycle receipt, once; never relinks. */
|
|
2383
|
+
private linkIssue(task: Task): Task {
|
|
2384
|
+
if (this.isPii(task) || task.sdlc?.issue) return task;
|
|
2385
|
+
let identity: { repo: string; issue: number } | undefined;
|
|
2386
|
+
try { identity = this.planIssueIdentity(task); } catch { return task; }
|
|
2387
|
+
const next = identity && linkedReceipt(task.sdlc, identity, this.d.sdlc?.config() ?? sdlcConfig(undefined));
|
|
2388
|
+
return next ? this.d.board.update(task.id, HUB, "lifecycle linked", { sdlc: next }, `issue #${identity!.issue}`) : task;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
/**
|
|
2392
|
+
* #694: the PR the hub can verify for a done, without writing it: the one open PR whose head is the done's full commit
|
|
2393
|
+
* (or, with no commit, its branch). The read's basis travels with it, so a task whose commit or branch changed while the
|
|
2394
|
+
* read was awaited never receives a PR found for another head. The done persists it only after its gates admit it, so a
|
|
2395
|
+
* refused done stays effect-free (#254).
|
|
2396
|
+
*/
|
|
2397
|
+
private async identityLink(task: Task, refs?: TaskRefs): Promise<IdentityLink | undefined> {
|
|
2398
|
+
if (this.isPii(task) || task.sdlc?.pr) return undefined;
|
|
2399
|
+
const repository = task.sdlc?.repo ?? task.refs.repo ?? this.d.repository;
|
|
2400
|
+
const commit = refs?.commit ?? task.refs.commit, branch = refs?.branch ?? task.refs.branch;
|
|
2401
|
+
if (!repository || !commit && !branch) return undefined;
|
|
2402
|
+
const pr = await openPrFor(repository, commit ? { commit } : { branch }, this.d.sdlc?.gh ?? sdlcGh);
|
|
2403
|
+
return pr ? { repo: repository, pr, basis: { commit, branch } } : undefined;
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
/**
|
|
2407
|
+
* The task as the done's gates see it: the issue the current task names (re-resolved now, so a changed title or refs
|
|
2408
|
+
* counts) and the staged PR only while the done's effective commit and branch are still the ones it was read for.
|
|
2409
|
+
*/
|
|
2410
|
+
private withLink(task: Task, link: IdentityLink | undefined, refs?: TaskRefs): Task {
|
|
2411
|
+
if ((this.d.sdlc?.config().mode ?? "off") === "off" || this.isPii(task)) return task;
|
|
2412
|
+
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2413
|
+
let issue: { repo: string; issue: number } | undefined;
|
|
2414
|
+
if (!task.sdlc?.issue) { try { issue = this.planIssueIdentity(task); } catch { issue = undefined; } }
|
|
2415
|
+
const commit = refs?.commit ?? task.refs.commit, branch = refs?.branch ?? task.refs.branch;
|
|
2416
|
+
const repository = task.sdlc?.repo ?? task.refs.repo ?? this.d.repository;
|
|
2417
|
+
const pr = link && !task.sdlc?.pr && repository?.toLowerCase() === link.repo.toLowerCase() && link.basis.commit === commit && (commit !== undefined || link.basis.branch === branch) ? link : undefined;
|
|
2418
|
+
let next = task.sdlc;
|
|
2419
|
+
if (issue) next = linkedReceipt(next, issue, config) ?? next;
|
|
2420
|
+
if (pr) next = linkedReceipt(next, { repo: pr.repo, pr: pr.pr }, config) ?? next;
|
|
2421
|
+
return next === task.sdlc ? task : { ...task, sdlc: next };
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2229
2424
|
private planIssueIdentity(task: Task): { repo: string; issue: number } | undefined {
|
|
2230
2425
|
const source = task.external ?? task.sdlc;
|
|
2231
2426
|
if (source?.repo && source.issue) return { repo: source.repo, issue: source.issue };
|
|
@@ -2278,6 +2473,16 @@ export class Tasks {
|
|
|
2278
2473
|
const digest = plannerGrooming(visible, { repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
|
|
2279
2474
|
limits: Object.fromEntries(peers.map(peer => [peer, this.wipLimit(peer)])), wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])),
|
|
2280
2475
|
states, issues, reviewedIssues, acceptedIssues, blockers });
|
|
2476
|
+
// #694: backfill lifecycle issue links after every authority check, with their own bound (not the metadata-read
|
|
2477
|
+
// budget): each call links up to 32 open tasks that still lack one, so later tasks are reached on later calls.
|
|
2478
|
+
if ((this.d.sdlc?.config().mode ?? "off") !== "off") {
|
|
2479
|
+
let linked = 0;
|
|
2480
|
+
for (const task of this.d.board.list()) {
|
|
2481
|
+
if (linked >= 32) break;
|
|
2482
|
+
if (isClosedTaskState(task.state) || task.sdlc?.issue || this.isPii(task)) continue;
|
|
2483
|
+
if (this.linkIssue(task) !== task) linked++;
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2281
2486
|
return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
|
|
2282
2487
|
}
|
|
2283
2488
|
|
|
@@ -2374,8 +2579,9 @@ export class Tasks {
|
|
|
2374
2579
|
const current = this.need(task.id, false, true, true);
|
|
2375
2580
|
if (current.history.length !== flow || (current.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
|
|
2376
2581
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2377
|
-
|
|
2582
|
+
let updated = this.d.board.update(current.id, by, "plan reviewed", { ...(Object.keys(scope).length ? { plan: { ...current.plan, ...scope } } : {}), sdlc: { ...current.sdlc, stage: current.sdlc?.stage ?? lifecycleStage(config, "plan"),
|
|
2378
2583
|
planReview: { planner: by, planningRevision: revision, recordedAt: Date.now(), ...(Object.keys(scope).length ? { scope } : {}), ...(issue ? { issue } : {}) } } }, raw.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
|
|
2584
|
+
if (config.mode !== "off") updated = this.linkIssue(updated); // #694
|
|
2379
2585
|
if (updated.state === "proposed" && !updated.owner) {
|
|
2380
2586
|
this.offered.delete(updated.id);
|
|
2381
2587
|
try { await this.offerReady(updated, "planner review recorded"); }
|
|
@@ -2390,7 +2596,11 @@ export class Tasks {
|
|
|
2390
2596
|
if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
|
|
2391
2597
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2392
2598
|
const next = sdlcReceipt(input, task.sdlc, config, VERSION);
|
|
2599
|
+
// The spec identity is person-owned in enforce. No observation cache or restart can reopen a relink bypass.
|
|
2600
|
+
const specRelink = (task.sdlc?.repo !== undefined && next.repo !== task.sdlc.repo) || (task.sdlc?.issue !== undefined && next.issue !== task.sdlc.issue);
|
|
2601
|
+
if (config.mode === "enforce" && by !== USER && specRelink) throw new PreEffectToolRefusal("only the person may relink an established repository or issue in enforce");
|
|
2393
2602
|
const definition = stageDefinition(config, next.stage);
|
|
2603
|
+
if (next.redFirst && input && typeof input === "object" && "redFirst" in input && !this.isPii(task) && !this.publicMetadataText(JSON.stringify(next.redFirst))) throw new PreEffectToolRefusal("red-first metadata matches a PII pattern; use private work");
|
|
2394
2604
|
// #481 S1: entering a build stage in enforce needs the person's spec acceptance; the person may move any stage.
|
|
2395
2605
|
if (config.mode === "enforce" && by !== USER && next.stage !== task.sdlc?.stage && definition && canonicalStage(definition.base) === "build" && specState(next, undefined) === "missing") throw new PreEffectToolRefusal(this.specText(task, "missing", "blocked"));
|
|
2396
2606
|
if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
|
|
@@ -2409,13 +2619,16 @@ export class Tasks {
|
|
|
2409
2619
|
const suppliedHead = !!input && typeof input === "object" && (input as { reviewedHead?: unknown }).reviewedHead !== undefined;
|
|
2410
2620
|
const artifactChanged = next.repo !== task.sdlc?.repo || next.pr !== task.sdlc?.pr;
|
|
2411
2621
|
if (next.reviewedHead !== undefined && (next.reviewedHead !== task.sdlc?.reviewedHead || (suppliedHead && artifactChanged))) this.mine(task, by, "reviewer", true);
|
|
2412
|
-
|
|
2622
|
+
const departure = task.history.findLast(h => h.by === USER && h.planDeparture)?.planDeparture;
|
|
2623
|
+
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);
|
|
2624
|
+
return this.d.board.update(task.id, by, "lifecycle", { sdlc: next }, undefined, { ...this.planObservation({ ...task, sdlc: next }), ...(invalidatesDeparture ? { planDepartureInvalidated: true } : {}) });
|
|
2413
2625
|
}
|
|
2414
2626
|
|
|
2415
2627
|
async deliveryQueue(repo: string) {
|
|
2628
|
+
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2416
2629
|
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 })));
|
|
2417
|
-
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) }));
|
|
2418
|
-
const report = await readMergeQueue(repo,
|
|
2630
|
+
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) } }));
|
|
2631
|
+
const report = await readMergeQueue(repo, config, links, this.d.sdlc?.gh, this.publicMetadataText);
|
|
2419
2632
|
// Policy can change across remote awaits without any board write. Never return its stale public projection.
|
|
2420
2633
|
const currentPrivacy = this.d.board.list().map(task => ({ id: task.id, private: this.isPii(task) }));
|
|
2421
2634
|
if (workflowHash(links.map(({ id, private: hidden }) => ({ id, private: hidden }))) !== workflowHash(currentPrivacy)) {
|
|
@@ -2424,6 +2637,20 @@ export class Tasks {
|
|
|
2424
2637
|
|
|
2425
2638
|
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 })));
|
|
2426
2639
|
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"); } }
|
|
2640
|
+
else if (config.mode !== "off") for (const row of report.rows) if (row.head && !row.private) for (const id of row.tasks) {
|
|
2641
|
+
const task = this.d.board.get(id);
|
|
2642
|
+
if (task?.sdlc?.repo?.toLowerCase() === report.repo.toLowerCase() && task.sdlc.pr === row.pr) {
|
|
2643
|
+
if (this.validPlanDepartures(task).length && row.planConformance?.some(item => item.task === id && item.state !== "unknown" && !item.accepted)) this.invalidatedPlanDepartures.add(id);
|
|
2644
|
+
this.capturePlanHead(task, row.head);
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
for (const row of report.rows) if (row.testIntegrity) {
|
|
2648
|
+
row.testIntegrity = row.testIntegrity.map(item => ({ ...publicTestIntegrity(item, this.publicMetadataText), ...(item.task === undefined ? {} : { task: item.task }) }));
|
|
2649
|
+
if (config.mode === "enforce") {
|
|
2650
|
+
row.blockers = [...new Set([...row.blockers, ...row.testIntegrity.flatMap(testIntegrityBlockers)])];
|
|
2651
|
+
row.ready = row.ready && row.blockers.length === 0;
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2427
2654
|
return report;
|
|
2428
2655
|
}
|
|
2429
2656
|
async releaseReadiness(repo: string, liveIssues: number[]) {
|
|
@@ -2457,12 +2684,114 @@ export class Tasks {
|
|
|
2457
2684
|
const state = { id: task.id, state: task.state, stage, ...(this.isPii(task) ? { private: true } : { criteria: this.workflowView(task) ?? null }) };
|
|
2458
2685
|
return { schema: 2, state, stateHash: workflowHash(state), sourceHash: workflowHash(config) };
|
|
2459
2686
|
}
|
|
2687
|
+
private readonly planReads = new Map<number, { identity: string; evidence: PrEvidence }>();
|
|
2688
|
+
// ponytail: consent expires on restart because a refused readback cannot first mutate the task's history.
|
|
2689
|
+
// A durable host observation journal can retain safe consent across lifetimes without reviving pending invalidations.
|
|
2690
|
+
private readonly departureGeneration = crypto.randomUUID();
|
|
2691
|
+
private readonly invalidatedPlanDepartures = new Set<number>();
|
|
2692
|
+
private readonly observedPlans = new Map<number, NonNullable<HistoryEntry["planObservation"]>>();
|
|
2693
|
+
private planIdentity(task: Task): string {
|
|
2694
|
+
return JSON.stringify([task.refs, task.sdlc, task.plan, task.owner, task.reviewer, task.state, this.isPii(task), this.d.sdlc?.config()]);
|
|
2695
|
+
}
|
|
2696
|
+
private planReadIdentity(task: Task): string { return JSON.stringify([task.sdlc?.repo, task.sdlc?.pr, task.refs.repo, planFingerprint(task.plan ?? {})]); }
|
|
2697
|
+
private planPaths(task: Task): string[] | undefined {
|
|
2698
|
+
if (!task.sdlc?.repo) return;
|
|
2699
|
+
// Resolve the artifact slug first: it may warm the canonical project's previously unnamed identity.
|
|
2700
|
+
const artifact = this.repositories.scope({ repo: task.sdlc.repo, paths: task.refs.paths });
|
|
2701
|
+
const scope = this.repositories.scope(task.refs);
|
|
2702
|
+
if (scope.key !== artifact.key) return;
|
|
2703
|
+
return task.plan?.paths?.map(path => scope === this.repositories.project ? this.projectPath(path) : this.repositories.relativePath(scope, path));
|
|
2704
|
+
}
|
|
2705
|
+
private planScopeIdentity(task: Task): string {
|
|
2706
|
+
const paths = this.planPaths(task); // Warm verified aliases before capturing the scope identity.
|
|
2707
|
+
const scope = this.repositories.scope(task.refs);
|
|
2708
|
+
return planFingerprint({ key: scope.key, root: scope.root ?? null, aliases: [...scope.aliases].sort(),
|
|
2709
|
+
repo: task.refs.repo ?? null, absoluteRefs: (task.refs.paths ?? []).filter(isAbsolute).sort(), paths: paths ?? null });
|
|
2710
|
+
}
|
|
2711
|
+
private validPlanDepartures(task: Task): NonNullable<HistoryEntry["planDeparture"]>[] {
|
|
2712
|
+
const invalidated = task.history.findLastIndex(h => h.planDepartureInvalidated && !h.planDeparture);
|
|
2713
|
+
if (this.invalidatedPlanDepartures.has(task.id)) return [];
|
|
2714
|
+
const latest = task.history.findLastIndex(h => h.by === USER && h.event === "plan departure accepted" && h.planDeparture);
|
|
2715
|
+
return latest > invalidated && task.history[latest]!.planDeparture!.generation === this.departureGeneration
|
|
2716
|
+
&& task.history[latest]!.planDeparture!.scope === this.planScopeIdentity(task) ? [task.history[latest]!.planDeparture!] : [];
|
|
2717
|
+
}
|
|
2718
|
+
private planReport(task: Task, evidence?: PrEvidence) {
|
|
2719
|
+
const rawReport = planConformance(this.isPii(task) ? undefined : this.planPaths(task), this.isPii(task) ? undefined : evidence);
|
|
2720
|
+
const safe = [...rawReport.outside, ...rawReport.untouched].every(path => this.publicMetadataText(path));
|
|
2721
|
+
const report: PlanConformance = safe ? rawReport : { state: "unknown", head: rawReport.head, outside: [], untouched: [] };
|
|
2722
|
+
const fingerprint = planFingerprint(task.plan ?? {});
|
|
2723
|
+
const observation = task.history.find(h => h.planObservation?.repo === task.sdlc?.repo && h.planObservation?.pr === task.sdlc?.pr)?.planObservation ?? this.observedPlans.get(task.id);
|
|
2724
|
+
const revision = task.history.findLast(h => h.planRevision)?.planRevision?.revision;
|
|
2725
|
+
const acceptance = evidence && this.validPlanDepartures(task).findLast(item => item.repo === task.sdlc?.repo && item.pr === task.sdlc?.pr && item.head === evidence.head
|
|
2726
|
+
&& item.revision === (revision ?? 0) && item.fingerprint === fingerprint && JSON.stringify([...item.paths].sort()) === JSON.stringify([...evidence.paths].sort()));
|
|
2727
|
+
return { ...report, accepted: !!acceptance, earlierPushes: "unknown" as const,
|
|
2728
|
+
...(observation && observation.repo === task.sdlc?.repo && observation.pr === task.sdlc?.pr ? { revisionsAfterFirstObservedPush: Math.max(0, (revision ?? 0) - observation.revision) } : {}) };
|
|
2729
|
+
}
|
|
2730
|
+
private capturePlanRead(task: Task, evidence: PrEvidence | undefined): void {
|
|
2731
|
+
if ((this.d.sdlc?.config().mode ?? "off") === "off" || !evidence || this.isPii(task) || !task.sdlc?.repo || !task.sdlc.pr) return;
|
|
2732
|
+
if (this.planReads.size >= 256 && !this.planReads.has(task.id)) this.planReads.delete(this.planReads.keys().next().value!);
|
|
2733
|
+
this.planReads.set(task.id, { identity: this.planReadIdentity(task), evidence });
|
|
2734
|
+
const accepted = task.history.findLast(h => h.by === USER && h.planDeparture)?.planDeparture;
|
|
2735
|
+
if (accepted && evidence.pathsComplete === true && JSON.stringify([...accepted.paths].sort()) !== JSON.stringify([...evidence.paths].sort())) this.invalidatedPlanDepartures.add(task.id);
|
|
2736
|
+
this.capturePlanHead(task, evidence.head);
|
|
2737
|
+
}
|
|
2738
|
+
private capturePlanHead(task: Task, head: string): void {
|
|
2739
|
+
if ((this.d.sdlc?.config().mode ?? "off") === "off" || this.isPii(task) || !task.sdlc?.repo || !task.sdlc.pr) return;
|
|
2740
|
+
if (this.planReads.get(task.id)?.evidence.head !== head) this.planReads.delete(task.id);
|
|
2741
|
+
const accepted = task.history.findLast(h => h.by === USER && h.planDeparture)?.planDeparture;
|
|
2742
|
+
if (accepted && (accepted.head !== head || accepted.repo !== task.sdlc.repo || accepted.pr !== task.sdlc.pr)) this.invalidatedPlanDepartures.add(task.id);
|
|
2743
|
+
if (!task.history.some(h => h.planObservation?.repo === task.sdlc?.repo && h.planObservation?.pr === task.sdlc?.pr)
|
|
2744
|
+
&& (!this.observedPlans.has(task.id) || this.observedPlans.get(task.id)?.repo !== task.sdlc.repo || this.observedPlans.get(task.id)?.pr !== task.sdlc.pr)) {
|
|
2745
|
+
// ponytail: this is first observed push, not a true push event. Earlier pushes stay unknown; upgrade to a webhook/Actions push receipt.
|
|
2746
|
+
if (this.observedPlans.size >= 256) this.observedPlans.delete(this.observedPlans.keys().next().value!);
|
|
2747
|
+
this.observedPlans.set(task.id, { repo: task.sdlc.repo, pr: task.sdlc.pr, head,
|
|
2748
|
+
revision: task.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(task.plan ?? {}) });
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
private planObservation(task: Task): Pick<HistoryEntry, "planObservation" | "planDepartureInvalidated"> {
|
|
2752
|
+
if ((this.d.sdlc?.config().mode ?? "off") === "off") return {};
|
|
2753
|
+
const observation = this.observedPlans.get(task.id);
|
|
2754
|
+
const lastAcceptance = task.history.findLastIndex(h => h.by === USER && h.planDeparture);
|
|
2755
|
+
return {
|
|
2756
|
+
...(observation && observation.repo === task.sdlc?.repo && observation.pr === task.sdlc?.pr
|
|
2757
|
+
&& !task.history.some(h => h.planObservation?.repo === observation.repo && h.planObservation.pr === observation.pr) ? { planObservation: observation } : {}),
|
|
2758
|
+
...(this.invalidatedPlanDepartures.has(task.id) && !task.history.slice(lastAcceptance + 1).some(h => h.planDepartureInvalidated) ? { planDepartureInvalidated: true as const } : {}),
|
|
2759
|
+
};
|
|
2760
|
+
}
|
|
2761
|
+
async acceptPlanDeparture(by: PeerId, id: unknown, head: unknown, reason: unknown): Promise<Task> {
|
|
2762
|
+
if (by !== USER) throw new PreEffectToolRefusal("only the person may accept a plan departure");
|
|
2763
|
+
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");
|
|
2764
|
+
const initial = this.need(id, false, true), identity = this.planIdentity(initial), scope = this.planScopeIdentity(initial), history = initial.history.length;
|
|
2765
|
+
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; };
|
|
2766
|
+
if (this.isPii(initial)) throw new PreEffectToolRefusal("private PR file evidence is unavailable; amend the private plan instead");
|
|
2767
|
+
const read: SdlcGh = (args, timeout, cwd, cap) => { valid(); return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap); };
|
|
2768
|
+
const evidence = initial.sdlc && await readSdlcPr(initial.sdlc, read); valid();
|
|
2769
|
+
if (!evidence || completePlanInventory(evidence) === undefined) throw new PreEffectToolRefusal("complete PR file inventory unknown");
|
|
2770
|
+
if (evidence.head !== head) throw new PreEffectToolRefusal("departure acceptance head is not the current PR head");
|
|
2771
|
+
if (this.planReport(initial, evidence).state !== "departure") throw new PreEffectToolRefusal("current plan conformance is not a known departure; amend unknown scope or coverage first");
|
|
2772
|
+
const note = sanitize(reason.replace(/\s+/g, " ").trim());
|
|
2773
|
+
const withheld = await this.withholds("note", note, initial); const current = valid();
|
|
2774
|
+
this.capturePlanRead(current, evidence);
|
|
2775
|
+
const next = this.d.board.update(current.id, USER, "plan departure accepted", {}, note, { withheld: withheld || undefined, ...this.planObservation(current),
|
|
2776
|
+
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] } });
|
|
2777
|
+
this.invalidatedPlanDepartures.delete(current.id);
|
|
2778
|
+
return next;
|
|
2779
|
+
}
|
|
2460
2780
|
async lifecycleStatus(id: unknown, refresh = true): Promise<SdlcStatus> {
|
|
2461
|
-
const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2462
|
-
const
|
|
2781
|
+
const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined), identity = this.planIdentity(task);
|
|
2782
|
+
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; };
|
|
2783
|
+
const deadline = performance.now() + 20_000;
|
|
2784
|
+
const read: SdlcGh = (args, timeout, cwd, cap) => { valid(); const remaining = deadline - performance.now(); return remaining > 0 ? (this.d.sdlc?.gh ?? sdlcGh)(args, Math.min(timeout ?? 10_000, remaining), cwd, cap) : Promise.resolve({ code: 1, stdout: "" }); };
|
|
2785
|
+
const evidence = task.sdlc && !this.isPii(task) && refresh && config.mode !== "off" ? await readSdlcPr(task.sdlc, read, true) : undefined;
|
|
2786
|
+
valid();
|
|
2463
2787
|
const accepted = task.sdlc?.acceptance;
|
|
2464
|
-
const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue,
|
|
2465
|
-
|
|
2788
|
+
const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue, read) : undefined;
|
|
2789
|
+
const integrity = config.mode !== "off" ? publicTestIntegrity(task.sdlc && evidence && !this.isPii(task) ? await readTestIntegrity(task.sdlc, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText) : undefined;
|
|
2790
|
+
const current = valid();
|
|
2791
|
+
if (this.planIdentity(current) !== this.planIdentity(task) || current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed during lifecycle readback");
|
|
2792
|
+
this.capturePlanRead(current, evidence);
|
|
2793
|
+
return sdlcStatus(config, current.sdlc, evidence, this.workflowView(current), edit, this.isPii(current) ? [] : this.reviewFindingSets(current), current.refs.commit,
|
|
2794
|
+
config.mode !== "off" ? this.planReport(this.d.board.get(current.id)!, evidence) : undefined, integrity);
|
|
2466
2795
|
}
|
|
2467
2796
|
|
|
2468
2797
|
/** #481 S1: only build work starts from an accepted spec. */
|
|
@@ -2528,24 +2857,34 @@ export class Tasks {
|
|
|
2528
2857
|
this.d.notify(this.specText(task, state, "advice"));
|
|
2529
2858
|
}
|
|
2530
2859
|
|
|
2531
|
-
private
|
|
2860
|
+
private unknownTestIntegrity(privateTask = false): TestIntegrity {
|
|
2861
|
+
return { applicability: "unknown", redFirst: "unknown", fileState: "unknown", modifiedTests: [], blockers: [privateTask ? "test integrity: private evidence unavailable" : "test integrity: current evidence unknown"] };
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[]): Promise<PrEvidence | undefined> {
|
|
2532
2865
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2533
2866
|
if (config.mode === "off") return;
|
|
2534
2867
|
const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
|
|
2868
|
+
const deadline = performance.now() + 20_000;
|
|
2535
2869
|
const read: SdlcGh = (args, timeout, cwd, cap) => {
|
|
2536
2870
|
const current = this.need(task.id, false, true);
|
|
2537
2871
|
if (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || this.isPii(current) !== this.isPii(task)) return Promise.resolve({ code: 1, stdout: "" });
|
|
2538
|
-
|
|
2872
|
+
const remaining = deadline - performance.now();
|
|
2873
|
+
return remaining > 0 ? (this.d.sdlc?.gh ?? sdlcGh)(args, Math.min(timeout ?? 10_000, remaining), cwd, cap) : Promise.resolve({ code: 1, stdout: "" });
|
|
2539
2874
|
};
|
|
2540
|
-
const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read) : undefined;
|
|
2875
|
+
const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read, true) : undefined;
|
|
2876
|
+
const integrity = publicTestIntegrity(receipt && evidence && !this.isPii(task) ? await readTestIntegrity(receipt, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText);
|
|
2541
2877
|
const current = this.need(task.id, false, true);
|
|
2542
2878
|
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`);
|
|
2543
2879
|
// Advise may continue on the current task, but evidence from its previous link cannot admit that task's review.
|
|
2544
2880
|
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;
|
|
2545
|
-
|
|
2546
|
-
const
|
|
2881
|
+
this.capturePlanRead(current, evidence);
|
|
2882
|
+
const planReport = this.planReport(current, evidence);
|
|
2883
|
+
const status = sdlcStatus(config, receipt, evidence, this.workflowView(current), undefined, this.isPii(current) ? [] : findings ?? this.reviewFindingSets(current), current.refs.commit, planReport, integrity);
|
|
2884
|
+
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) || blocker.startsWith("test integrity:")), ...(status.entry ?? []), ...(status.exit ?? [])])] : status.blockers;
|
|
2547
2885
|
if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
|
|
2548
|
-
|
|
2886
|
+
const advice = [...new Set([...blockers, ...planConformanceBlockers(planReport), ...testIntegrityBlockers(integrity)])];
|
|
2887
|
+
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("; ")}`);
|
|
2549
2888
|
return evidence;
|
|
2550
2889
|
}
|
|
2551
2890
|
|
|
@@ -2746,19 +3085,25 @@ export class Tasks {
|
|
|
2746
3085
|
/** Whether a completion check is still running for this task. */
|
|
2747
3086
|
isChecking = (id: number) => this.checking.has(id);
|
|
2748
3087
|
|
|
3088
|
+
private completionCurrent(id: unknown, by: PeerId, preEffect: boolean): Task {
|
|
3089
|
+
const task = this.need(id, false, preEffect);
|
|
3090
|
+
this.mine(task, by, "owner", preEffect);
|
|
3091
|
+
if (preEffect) {
|
|
3092
|
+
if (task.state === "in_review" || isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
|
|
3093
|
+
} else {
|
|
3094
|
+
if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
|
|
3095
|
+
}
|
|
3096
|
+
this.ready(task, preEffect);
|
|
3097
|
+
if (task.state === "proposed" || task.state === "changes_requested") this.acceptanceCapacity(task);
|
|
3098
|
+
if (this.checking.has(task.id)) {
|
|
3099
|
+
// The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
|
|
3100
|
+
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`);
|
|
3101
|
+
}
|
|
3102
|
+
return task;
|
|
3103
|
+
}
|
|
3104
|
+
|
|
2749
3105
|
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs): Promise<Task> {
|
|
2750
|
-
const valid = ()
|
|
2751
|
-
const t = this.need(id, false, true);
|
|
2752
|
-
this.mine(t, by, "owner", true);
|
|
2753
|
-
if (t.state === "in_review" || isClosedTaskState(t.state)) throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
|
|
2754
|
-
this.ready(t, true);
|
|
2755
|
-
if (t.state === "proposed" || t.state === "changes_requested") this.acceptanceCapacity(t);
|
|
2756
|
-
if (this.checking.has(t.id)) {
|
|
2757
|
-
// The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
|
|
2758
|
-
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`);
|
|
2759
|
-
}
|
|
2760
|
-
return t;
|
|
2761
|
-
};
|
|
3106
|
+
const valid = () => this.completionCurrent(id, by, true);
|
|
2762
3107
|
// The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
|
|
2763
3108
|
const withheld = await this.withholds("summary", summary, valid());
|
|
2764
3109
|
let task = valid();
|
|
@@ -2769,10 +3114,14 @@ export class Tasks {
|
|
|
2769
3114
|
await this.planEntryGate(by, admission);
|
|
2770
3115
|
task = valid();
|
|
2771
3116
|
}
|
|
3117
|
+
// #694: stage the identities the hub can verify; the gates read them, and only the admitted done persists them.
|
|
3118
|
+
const link = this.d.sdlc && this.d.sdlc.config().mode !== "off" ? await this.identityLink(task, refs) : undefined;
|
|
3119
|
+
const current = () => this.withLink(valid(), link, refs);
|
|
3120
|
+
task = current();
|
|
2772
3121
|
let reviewEvidence: PrEvidence | undefined;
|
|
2773
|
-
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task =
|
|
2774
|
-
const reviewAdmission = task.reviewer ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence) : undefined;
|
|
2775
|
-
task =
|
|
3122
|
+
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task = current(); }
|
|
3123
|
+
const reviewAdmission = task.reviewer || this.d.prepareReviewBrief ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence, undefined, staged => this.withLink(staged, link, refs), !task.reviewer) : undefined;
|
|
3124
|
+
task = current();
|
|
2776
3125
|
// A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
|
|
2777
3126
|
const cohort = this.cohorts.of(task.id);
|
|
2778
3127
|
const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
|
|
@@ -2792,8 +3141,8 @@ export class Tasks {
|
|
|
2792
3141
|
if (r.action === "request") return this.d.board.update(task.id, HUB, "integration requested", {}, this.integrationRequest(task, r));
|
|
2793
3142
|
if (r.action === "unresolved") {
|
|
2794
3143
|
this.d.notify(`task ${this.publicTitle(task)}: turn-free integration unresolved after ${MAX_REQUESTS} requests (${r.why}); its done is recorded, check the overlapping work by hand`);
|
|
2795
|
-
task = this.d.board.update(task.id, HUB, "integration unresolved", {}, r.why);
|
|
2796
|
-
} else if (r.integrated) task = this.d.board.update(task.id, HUB, "integrated", {}, `cohort #${cohort!.id}, revision ${cohort!.revision}`);
|
|
3144
|
+
task = this.withLink(this.d.board.update(task.id, HUB, "integration unresolved", {}, r.why), link, refs);
|
|
3145
|
+
} else if (r.integrated) task = this.withLink(this.d.board.update(task.id, HUB, "integrated", {}, `cohort #${cohort!.id}, revision ${cohort!.revision}`), link, refs);
|
|
2797
3146
|
} else if (cohort?.held.size && !this.isPii(task)) {
|
|
2798
3147
|
// No integration step for this member (silence lifted, a PII task open): the notices the silence held stand in
|
|
2799
3148
|
// for it (issue #107, AC4).
|
|
@@ -2801,20 +3150,26 @@ export class Tasks {
|
|
|
2801
3150
|
if (evidence && by === USER) this.d.notify(`task ${this.publicTitle(task)} done by the console; overlapping work finished meanwhile:\n${evidence}`);
|
|
2802
3151
|
else if (evidence) this.doneNotes.set(task.id, `Overlapping work finished while you worked (no turn-free integration step ran):\n${evidence}`);
|
|
2803
3152
|
}
|
|
2804
|
-
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
|
|
3153
|
+
if (task.state === "proposed" || task.state === "changes_requested") task = this.withLink(this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE), link, refs); // done without a separate accept
|
|
2805
3154
|
const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
|
|
2806
|
-
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission);
|
|
3155
|
+
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link);
|
|
2807
3156
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
2808
3157
|
this.checking.set(task.id, task.owner);
|
|
2809
3158
|
const checkedRefs = cleanRefs(refs);
|
|
2810
3159
|
this.warmRepository({ ...task, refs: { ...task.refs, ...checkedRefs } });
|
|
2811
|
-
const pending = this.d.board.update(task.id, by, "done (checking)", { refs: checkedRefs }, summary, { withheld: withheld || undefined });
|
|
3160
|
+
const pending = this.d.board.update(task.id, by, "done (checking)", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...checkedRefs } }), refs: checkedRefs, ...(task.sdlc ? { sdlc: task.sdlc } : {}) }, summary, { withheld: withheld || undefined }, task.history.length, HOST_CODE_SYMBOLS);
|
|
2812
3161
|
this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
|
|
2813
3162
|
const seen = { events: pending.history.length, owner: pending.owner };
|
|
2814
3163
|
this.pendingChecks++;
|
|
2815
3164
|
this.checkQueue = this.checkQueue
|
|
2816
3165
|
.then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld))
|
|
2817
|
-
.catch((e: Error) =>
|
|
3166
|
+
.catch((e: Error) => {
|
|
3167
|
+
this.releaseNotice(`task #${pending.id}: its check result could not be recorded: ${e.message}`);
|
|
3168
|
+
this.releaseNotice(`task #${pending.id}: its owner retry notice could not be published`, () => {
|
|
3169
|
+
const current = this.d.board.get(pending.id);
|
|
3170
|
+
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));
|
|
3171
|
+
});
|
|
3172
|
+
})
|
|
2818
3173
|
.finally(() => this.pendingChecks--);
|
|
2819
3174
|
return pending;
|
|
2820
3175
|
}
|
|
@@ -2850,7 +3205,7 @@ export class Tasks {
|
|
|
2850
3205
|
const theirs = this.places(u);
|
|
2851
3206
|
if (mine.repositoryKey !== theirs.repositoryKey) return [];
|
|
2852
3207
|
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)));
|
|
2853
|
-
const symbols = mine.symbols.filter((x) => theirs.symbols.
|
|
3208
|
+
const symbols = mine.symbols.filter((x) => theirs.symbols.some((y) => symbolsOverlap(x, y)));
|
|
2854
3209
|
return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note, at.withheld)] : [];
|
|
2855
3210
|
});
|
|
2856
3211
|
if (!open.length && !done.length) continue;
|
|
@@ -2933,25 +3288,19 @@ export class Tasks {
|
|
|
2933
3288
|
}
|
|
2934
3289
|
|
|
2935
3290
|
/** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
|
|
2936
|
-
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness): Promise<Task> {
|
|
3291
|
+
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink): Promise<Task> {
|
|
2937
3292
|
let selection = this.poolReview(task, refs);
|
|
2938
3293
|
let reviewer = selection?.reviewer ?? task.reviewer, reviewEvidence: PrEvidence | undefined;
|
|
2939
3294
|
if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
|
|
2940
3295
|
reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
|
|
2941
|
-
task = this.
|
|
2942
|
-
this.mine(task, by, "owner", true);
|
|
2943
|
-
// In every mode: another done may have landed during the readback.
|
|
2944
|
-
if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
|
|
3296
|
+
task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
|
|
2945
3297
|
selection = this.poolReview(task, refs);
|
|
2946
3298
|
reviewer = selection?.reviewer ?? task.reviewer;
|
|
2947
3299
|
}
|
|
2948
|
-
// A delayed local check needs a new readback;
|
|
2949
|
-
if (
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
task = this.need(task.id);
|
|
2953
|
-
this.mine(task, by, "owner");
|
|
2954
|
-
if (task.history.length !== observed.history.length || task.state === "in_review" || isClosedTaskState(task.state)) throw new Error("task changed during review admission");
|
|
3300
|
+
// A delayed local check needs a new readback; a completion-selected reviewer also needs optional facts in off mode.
|
|
3301
|
+
if (!admission && ((reviewer && (this.d.sdlc?.config().mode ?? "off") !== "off") || this.d.prepareReviewBrief)) {
|
|
3302
|
+
admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence, undefined, staged => this.withLink(staged, link, refs), !reviewer);
|
|
3303
|
+
task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
|
|
2955
3304
|
}
|
|
2956
3305
|
selection = this.poolReview(task, refs);
|
|
2957
3306
|
reviewer = selection?.reviewer ?? task.reviewer;
|
|
@@ -2961,7 +3310,7 @@ export class Tasks {
|
|
|
2961
3310
|
const completedRefs = cleanRefs(refs);
|
|
2962
3311
|
this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
2963
3312
|
const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
2964
|
-
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, { withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}) });
|
|
3313
|
+
const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
2965
3314
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
2966
3315
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
|
|
2967
3316
|
this.tellCompleted(next, summary, withheld);
|
|
@@ -2971,7 +3320,7 @@ export class Tasks {
|
|
|
2971
3320
|
this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
|
|
2972
3321
|
await this.releaseDependents(next);
|
|
2973
3322
|
}
|
|
2974
|
-
else if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}: review it
|
|
3323
|
+
else if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}: review it in the ahub console (Tasks, select #${next.id}, press r) or with ahub review ${next.id} approved|changes_requested [note]`);
|
|
2975
3324
|
else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, "", admission));
|
|
2976
3325
|
return next;
|
|
2977
3326
|
}
|
|
@@ -2985,6 +3334,9 @@ export class Tasks {
|
|
|
2985
3334
|
const hits = this.overlapHits(task).filter((h) => h.task.owner !== USER && h.task.owner !== HUB);
|
|
2986
3335
|
const cohort = this.cohorts.of(task.id);
|
|
2987
3336
|
const silent = this.silentFor(task.id);
|
|
3337
|
+
// #713: one notice per recipient per done, naming all of that recipient's overlapping tasks, and only for work the recipient has accepted. An offered or proposed
|
|
3338
|
+
// task has nothing to check yet (its offer, or the accept that follows, already shows the current board), and a repeat done replaces the queued notice (see Bus.enqueue).
|
|
3339
|
+
const byOwner = new Map<PeerId, typeof hits>();
|
|
2988
3340
|
for (const hit of hits) {
|
|
2989
3341
|
// A member of the same silent cohort (issue #107): the member that finishes last integrates instead
|
|
2990
3342
|
// (Cohorts.completion). The notice is held, so something replaces it if no integration runs. Others hear it.
|
|
@@ -2992,9 +3344,23 @@ export class Tasks {
|
|
|
2992
3344
|
cohort.held.add(task.id);
|
|
2993
3345
|
continue;
|
|
2994
3346
|
}
|
|
2995
|
-
|
|
2996
|
-
|
|
3347
|
+
// Accepted by its own owner since it last changed hands (`ownerAcceptance`, the rule the rest of this class uses), and still in work or review: an offered or proposed task is not.
|
|
3348
|
+
if (!["in_progress", "changes_requested", "in_review"].includes(hit.task.state) || !this.ownerAcceptance(hit.task)) continue;
|
|
3349
|
+
byOwner.set(hit.task.owner!, [...(byOwner.get(hit.task.owner!) ?? []), hit]);
|
|
2997
3350
|
}
|
|
3351
|
+
for (const [owner, mine] of byOwner) {
|
|
3352
|
+
this.releaseNotice(`task ${this.publicTitle(task)}: could not publish the completed-change notice to ${owner}`, () =>
|
|
3353
|
+
this.whileOpenFor(owner, task.id, mine.map((h) => h.task.id), this.completedNotice(task, mine, summary, withheld)));
|
|
3354
|
+
}
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3357
|
+
/** Share the same screened observation rule between completion notices and read-only console detail. */
|
|
3358
|
+
private sameFileDifferentSymbols(task: Task, hit: { task: Task; paths: string[] }): boolean {
|
|
3359
|
+
const symbolRepository = task.codeSymbols && !this.isPii(task) ? this.codeSymbolsRepository(task) : undefined;
|
|
3360
|
+
const otherRepository = hit.task.codeSymbols && !this.isPii(hit.task) ? this.codeSymbolsRepository(hit.task) : undefined;
|
|
3361
|
+
return !!symbolRepository && symbolRepository.key === otherRepository?.key && !this.isPii(task) && !this.isPii(hit.task) && taskCodeSymbolsDisjoint(
|
|
3362
|
+
task.codeSymbols, hit.task.codeSymbols, this.reviewHead(task), this.reviewHead(hit.task), symbolRepository.key,
|
|
3363
|
+
hit.paths, false, this.publicMetadataText);
|
|
2998
3364
|
}
|
|
2999
3365
|
|
|
3000
3366
|
/**
|
|
@@ -3002,13 +3368,18 @@ export class Tasks {
|
|
|
3002
3368
|
* included), and any item that matches a PII pattern is left out of what other owners get, the summary also when the
|
|
3003
3369
|
* PII screen did not clear it (`withheld`, #198).
|
|
3004
3370
|
*/
|
|
3005
|
-
private completedNotice(task: Task,
|
|
3371
|
+
private completedNotice(task: Task, hitOrHits: { task: Task; paths: string[]; symbols: string[] } | { task: Task; paths: string[]; symbols: string[] }[], summary?: string, withheld = false): string {
|
|
3372
|
+
const hits = Array.isArray(hitOrHits) ? hitOrHits : [hitOrHits];
|
|
3006
3373
|
const paths = this.places(task).paths.filter(this.nameable);
|
|
3007
3374
|
const signatures = (task.plan?.signatures ?? []).filter(this.nameable);
|
|
3008
3375
|
const first = (summary ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
|
|
3009
3376
|
const line = first && !withheld && this.nameable(first) ? first : undefined;
|
|
3377
|
+
// One line per overlapping task of the recipient, so a single notice names all of them (#713).
|
|
3378
|
+
const touches = hits.map((hit) => `#${hit.task.id} on ${this.where(hit)}${this.sameFileDifferentSymbols(task, hit) ? " (same file, different symbols; observed names only, deleted definitions may be missing)" : ""}`);
|
|
3379
|
+
const open = hits.length > 1 ? `your open tasks ${touches.join("; ")}` : `your open ${touches[0]}`;
|
|
3380
|
+
|
|
3010
3381
|
return [
|
|
3011
|
-
`Task #${task.id} (owner ${task.owner}) is done and touches
|
|
3382
|
+
`Task #${task.id} (owner ${task.owner}) is done and touches ${open}. Check your work against it before you go on.`,
|
|
3012
3383
|
paths.length ? `Changed files: ${paths.join(", ")}` : "",
|
|
3013
3384
|
signatures.length ? `New or changed signatures: ${signatures.join("; ")}` : "",
|
|
3014
3385
|
line ? `Summary: ${line}` : "",
|
|
@@ -3049,33 +3420,51 @@ export class Tasks {
|
|
|
3049
3420
|
|
|
3050
3421
|
/** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
|
|
3051
3422
|
/** Review admission is separate from brief construction (#228). All refusals precede the requesting done's writes. */
|
|
3052
|
-
private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean): Promise<ReviewReadiness | undefined> {
|
|
3423
|
+
private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean, stage: (task: Task) => Task = task => task, prepareOnly = false): Promise<ReviewReadiness | undefined> {
|
|
3053
3424
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
3054
|
-
if (mode === "off") return undefined;
|
|
3425
|
+
if (mode === "off" && !this.d.prepareReviewBrief) return undefined;
|
|
3426
|
+
let quietDrift = false;
|
|
3055
3427
|
const guard = () => {
|
|
3056
3428
|
if (actor !== USER && actor !== task.owner) {
|
|
3057
3429
|
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
|
|
3058
3430
|
if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
|
|
3059
3431
|
}
|
|
3060
|
-
|
|
3061
|
-
|
|
3432
|
+
// #694: the board copy seen through the same staged identity link the caller's task carries.
|
|
3433
|
+
const current = stage(this.need(task.id, false, preEffect));
|
|
3434
|
+
const changed = current.history.length !== task.history.length;
|
|
3435
|
+
const quiet = current.history.length > task.history.length && current.history.slice(task.history.length).every(entry => QUIET_EVENTS.has(entry.event));
|
|
3436
|
+
const sameHistory = !changed || (mode === "advise" && quiet) || !!sameRequest?.();
|
|
3437
|
+
if ((this.d.sdlc?.config().mode ?? "off") !== mode || !sameHistory || current.owner !== task.owner || mode !== "advise" && current.reviewer !== task.reviewer
|
|
3062
3438
|
|| this.isPii(current) !== this.isPii(task) || JSON.stringify(current.sdlc ?? null) !== JSON.stringify(task.sdlc ?? null)
|
|
3439
|
+
|| mode === "advise" && (current.state !== task.state || current.refs.commit !== task.refs.commit)
|
|
3063
3440
|
|| actor !== USER && this.d.privateTurn?.(actor) && !this.isPii(current)) throw new (preEffect ? PreEffectToolRefusal : Error)("task or authority changed during review admission");
|
|
3441
|
+
quietDrift ||= mode === "advise" && changed && quiet;
|
|
3064
3442
|
return current;
|
|
3065
3443
|
};
|
|
3066
3444
|
guard();
|
|
3445
|
+
if (!this.isPii(task) && this.d.prepareReviewBrief) {
|
|
3446
|
+
try { await this.d.prepareReviewBrief({ ...task, refs: { ...task.refs, ...cleanRefs(refs) } }); }
|
|
3447
|
+
catch { /* Optional code facts must not refuse a review. */ }
|
|
3448
|
+
guard();
|
|
3449
|
+
}
|
|
3450
|
+
if (mode === "off" || prepareOnly) return undefined;
|
|
3067
3451
|
const expected = cleanRefs(refs).commit ?? task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead;
|
|
3068
|
-
const read: SdlcGh = (args, timeout, cwd, cap) => {
|
|
3452
|
+
const read: SdlcGh = (args, timeout, cwd, cap) => {
|
|
3453
|
+
guard();
|
|
3454
|
+
if (quietDrift) throw new Error("review readiness unknown: task changed during readback");
|
|
3455
|
+
return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap);
|
|
3456
|
+
};
|
|
3069
3457
|
const evidence: ReviewReadiness = this.isPii(task)
|
|
3070
3458
|
? { ready: false, blockers: ["private review readiness unavailable"] }
|
|
3071
3459
|
: 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"] };
|
|
3072
3460
|
const current = guard();
|
|
3073
|
-
|
|
3074
|
-
|
|
3461
|
+
const admitted: ReviewReadiness = quietDrift ? { ready: false, blockers: ["review readiness unknown: task changed during readback"] } : evidence;
|
|
3462
|
+
if (!admitted.ready) {
|
|
3463
|
+
const message = `task ${this.publicTitle(current)}: premature review: ${admitted.blockers.join("; ")}`;
|
|
3075
3464
|
if (mode === "enforce") throw new (preEffect ? PreEffectToolRefusal : Error)(message);
|
|
3076
3465
|
this.releaseNotice(message);
|
|
3077
3466
|
}
|
|
3078
|
-
return
|
|
3467
|
+
return admitted;
|
|
3079
3468
|
}
|
|
3080
3469
|
|
|
3081
3470
|
private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness): void {
|
|
@@ -3092,12 +3481,29 @@ export class Tasks {
|
|
|
3092
3481
|
// A checklist that maps the change to its contract (issue #35): reviewers who check against the written plan catch more.
|
|
3093
3482
|
const plan = planText(task.plan);
|
|
3094
3483
|
const check = [...task.history].reverse().find((h) => h.event === "check passed");
|
|
3484
|
+
const cachedPlanRead = this.planReads.get(task.id);
|
|
3485
|
+
const planRead = this.planReport(task, cachedPlanRead?.identity === this.planReadIdentity(task) && cachedPlanRead.evidence.head === this.findingHead(task) ? cachedPlanRead.evidence : undefined);
|
|
3486
|
+
const conformanceText = mode !== "off" ? this.screen(task, `Plan conformance: ${JSON.stringify(planRead)}`, "plan conformance", reviewer) + "\n" : "";
|
|
3487
|
+
const profile = (task.profile ?? "dev") === (this.d.profile ?? "dev") ? profileData(this.d.profileData ?? task.profile ?? "dev") : profileData(task.profile);
|
|
3488
|
+
const target = plan ? `the plan (${plan})` : task.detail ? "the task detail above" : "the task title";
|
|
3489
|
+
const noCheckWording = profile.name === "research" || profile.name === "writing"
|
|
3490
|
+
? "No check ran for this class: verify the claims and sources yourself."
|
|
3491
|
+
: profile.name === "general"
|
|
3492
|
+
? "No check ran for this class: verify the outcome yourself."
|
|
3493
|
+
: "No check ran for this class: run the affected tests yourself.";
|
|
3494
|
+
const checkResult = check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : noCheckWording;
|
|
3495
|
+
const checklistTemplates = profile.review_checklist ?? [
|
|
3496
|
+
"Map the changed signatures and call sites to {target}, and name each one that does not match.",
|
|
3497
|
+
"{check}",
|
|
3498
|
+
"List what is unmet in hub_review's unmet, one item each.",
|
|
3499
|
+
];
|
|
3500
|
+
const checklistLines = checklistTemplates.map(template =>
|
|
3501
|
+
template.replaceAll("{target}", target).replaceAll("{check}", checkResult)
|
|
3502
|
+
);
|
|
3095
3503
|
const checklist = [
|
|
3096
3504
|
"Checklist:",
|
|
3097
3505
|
...this.planGuardItems(task).map(item => `- Check the plan against this live fail before the remaining checks: ${item.line}`),
|
|
3098
|
-
|
|
3099
|
-
`- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
|
|
3100
|
-
`- List what is unmet in hub_review's unmet, one item each.`,
|
|
3506
|
+
...checklistLines.map(line => `- ${line}`),
|
|
3101
3507
|
].join("\n");
|
|
3102
3508
|
// #223 R2: a reviewer-only instance judges from facts; the builder's narrative stays out, earlier findings go in.
|
|
3103
3509
|
const reviewOnly = this.d.reviewOnly?.(reviewer) === true;
|
|
@@ -3105,6 +3511,9 @@ export class Tasks {
|
|
|
3105
3511
|
const specRepo = task.sdlc?.repo ?? task.external?.repo, specIssue = task.sdlc?.issue ?? task.external?.issue;
|
|
3106
3512
|
const spec = reviewOnly && specRepo && specIssue ? `Spec issue: ${specRepo}#${specIssue}\n` : "";
|
|
3107
3513
|
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)}`) : [];
|
|
3514
|
+
const carried = findingsForHead(this.reviewFindingSets(task), this.findingHead(task)).filter(item => item.severity === "important" && item.disposition === "open");
|
|
3515
|
+
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)));
|
|
3516
|
+
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` : "";
|
|
3108
3517
|
const guidance = this.roleGuidance(task, reviewer, true);
|
|
3109
3518
|
let projection = "";
|
|
3110
3519
|
try { const text = this.d.projectedReview?.(task); if (text) projection = this.screen(task, sanitize(text), "document projection", reviewer); }
|
|
@@ -3112,7 +3521,7 @@ export class Tasks {
|
|
|
3112
3521
|
const brief = this.briefText(task);
|
|
3113
3522
|
// #572 AC3: advisory only, from git state never from task text, so it is safe for a PII task too.
|
|
3114
3523
|
const checkout = mainCheckoutWarning(this.d.cwd, this.publicMetadataText);
|
|
3115
|
-
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` : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
|
|
3524
|
+
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.`;
|
|
3116
3525
|
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 } : {}) }));
|
|
3117
3526
|
}
|
|
3118
3527
|
|
|
@@ -3151,6 +3560,29 @@ export class Tasks {
|
|
|
3151
3560
|
return next;
|
|
3152
3561
|
}
|
|
3153
3562
|
|
|
3563
|
+
private codeSymbolsRepository(task: Task) {
|
|
3564
|
+
try {
|
|
3565
|
+
const repository = this.repositories.scope(task.refs);
|
|
3566
|
+
if (task.sdlc?.repo && this.repositories.scope({ repo: task.sdlc.repo }).key !== repository.key) return undefined;
|
|
3567
|
+
return repository;
|
|
3568
|
+
} catch { return undefined; }
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3571
|
+
/** Host observations are saved only with an admitted transition, never by an optional read. */
|
|
3572
|
+
private observedCodeSymbols(task: Task) {
|
|
3573
|
+
if (this.isPii(task) || !task.refs.commit) return undefined;
|
|
3574
|
+
const repository = this.codeSymbolsRepository(task);
|
|
3575
|
+
// ponytail: the brief analyzes the validated project execution root. Foreign repositories stay unknown
|
|
3576
|
+
// until the brief carries independent repository provenance; a model's refs.repo cannot supply that proof.
|
|
3577
|
+
if (!repository || repository.key !== this.repositories.project.key) return undefined;
|
|
3578
|
+
try {
|
|
3579
|
+
const brief = this.d.reviewBrief?.(task);
|
|
3580
|
+
const observation = brief && !("unavailable" in brief) ? buildTaskCodeSymbols(brief, repository.key, this.publicMetadataText) : undefined;
|
|
3581
|
+
// A resolved short ref or a stale provider result is unknown advice, never a reason to refuse done.
|
|
3582
|
+
return observation?.head === task.refs.commit ? observation : undefined;
|
|
3583
|
+
} catch { return undefined; } // Optional metadata never costs the admitted result.
|
|
3584
|
+
}
|
|
3585
|
+
|
|
3154
3586
|
/** #520: the rendered review brief of a non-PII task's commit, or why there is none; empty without a commit. */
|
|
3155
3587
|
briefText(task: Task): string {
|
|
3156
3588
|
if (this.isPii(task) || !task.refs.commit) return "";
|
|
@@ -3161,11 +3593,13 @@ export class Tasks {
|
|
|
3161
3593
|
} catch { return "Review brief unavailable: it could not be built."; }
|
|
3162
3594
|
}
|
|
3163
3595
|
|
|
3164
|
-
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown): Promise<Task> {
|
|
3596
|
+
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown, findings?: unknown): Promise<Task> {
|
|
3165
3597
|
const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
|
|
3598
|
+
let findingGuard: ((task: Task) => void) | undefined;
|
|
3166
3599
|
// #520: what the coverage was checked against, pinned through every await: the commit and the lifecycle receipt (the
|
|
3167
3600
|
// inherited reviewedHead lives there).
|
|
3168
3601
|
let covered: { commit: string | undefined; receipt: string } | undefined;
|
|
3602
|
+
let conformanceIdentity: string | undefined;
|
|
3169
3603
|
const valid = (): Task => {
|
|
3170
3604
|
const refused = authority?.();
|
|
3171
3605
|
if (refused) throw new PreEffectToolRefusal(refused); // rechecked after every await, right before each write
|
|
@@ -3174,13 +3608,42 @@ export class Tasks {
|
|
|
3174
3608
|
this.mine(t, by, "reviewer", true);
|
|
3175
3609
|
if (verdict !== "approved" && verdict !== "changes_requested") throw new PreEffectToolRefusal('verdict must be "approved" or "changes_requested"');
|
|
3176
3610
|
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`);
|
|
3611
|
+
if (conformanceIdentity !== undefined && conformanceIdentity !== this.planIdentity(t)) throw new PreEffectToolRefusal("plan conformance identity changed during verdict");
|
|
3612
|
+
findingGuard?.(t);
|
|
3177
3613
|
return t;
|
|
3178
3614
|
};
|
|
3615
|
+
if ((this.d.sdlc?.config().mode ?? "off") !== "off") conformanceIdentity = this.planIdentity(valid());
|
|
3179
3616
|
const items = textList(unmet);
|
|
3180
3617
|
if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
|
|
3181
3618
|
// #520: an approval accounts for every reviewable path of the brief. enforce refuses before any effect, advise records
|
|
3182
3619
|
// the gap in the verdict note, off and a person's own verdict skip it.
|
|
3183
3620
|
const mode = this.d.sdlc?.config().mode ?? "off";
|
|
3621
|
+
const initial = valid(), suppliedFindings = findings !== undefined;
|
|
3622
|
+
let parsed: ReviewFinding[];
|
|
3623
|
+
try { parsed = parseReviewFindings(findings, { person: by === USER, maxNits: this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS }); }
|
|
3624
|
+
catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid structured findings"); }
|
|
3625
|
+
const resolvedHead = this.findingHead(initial);
|
|
3626
|
+
let findingHead = resolvedHead;
|
|
3627
|
+
if (head !== undefined) {
|
|
3628
|
+
try { findingHead = sdlcReceipt({ reviewedHead: head }, initial.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined)).reviewedHead; }
|
|
3629
|
+
catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid finding head"); }
|
|
3630
|
+
}
|
|
3631
|
+
const report: ReviewFindingSet | undefined = suppliedFindings ? { ...(findingHead ? { head: findingHead } : {}), items: parsed } : undefined;
|
|
3632
|
+
const proposedSets = (task: Task) => [...this.reviewFindingSets(task), ...(report ? [report] : [])];
|
|
3633
|
+
if (head !== undefined || suppliedFindings || this.reviewFindingSets(initial).length) {
|
|
3634
|
+
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) });
|
|
3635
|
+
const original = identity(initial);
|
|
3636
|
+
findingGuard = task => {
|
|
3637
|
+
if (identity(task) !== original) throw new PreEffectToolRefusal("task review identity, privacy or findings changed during the verdict; review current work again");
|
|
3638
|
+
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");
|
|
3639
|
+
if (parsed.filter(item => item.severity === "nit").length > (this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS)) throw new PreEffectToolRefusal("review nit cap exceeded");
|
|
3640
|
+
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");
|
|
3641
|
+
const current = findingsForHead(proposedSets(task), findingHead);
|
|
3642
|
+
if (current.length > 64) throw new PreEffectToolRefusal("one reviewed head supports at most 64 distinct finding IDs");
|
|
3643
|
+
if (verdict === "approved" && this.d.sdlc?.config().mode === "enforce" && findingTally(current).openImportant) throw new PreEffectToolRefusal("approval blocked: open important review findings on this head");
|
|
3644
|
+
};
|
|
3645
|
+
findingGuard(initial);
|
|
3646
|
+
}
|
|
3184
3647
|
// A Tasks without a brief provider (library use; the daemon always has one) has no coverage gate.
|
|
3185
3648
|
if (verdict === "approved" && by !== USER && mode !== "off" && this.d.reviewBrief) {
|
|
3186
3649
|
const t = valid(), enforce = mode === "enforce", list = (paths: string[]) => `${paths.slice(0, 20).join(", ")}${paths.length > 20 ? ", ..." : ""}`;
|
|
@@ -3206,15 +3669,17 @@ export class Tasks {
|
|
|
3206
3669
|
if (gap.length) note = `${note ?? ""}\nCoverage gap (${gap.length}): ${list(gap)}`.trim();
|
|
3207
3670
|
}
|
|
3208
3671
|
}
|
|
3672
|
+
if (head !== undefined && resolvedHead && findingHead !== resolvedHead) throw new PreEffectToolRefusal("supplied review head differs from the task resolved head");
|
|
3209
3673
|
// The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
|
|
3210
|
-
const withheld = await this.withholds("review note", note, valid())
|
|
3674
|
+
const withheld = await this.withholds("review note", parsed.length ? JSON.stringify({ note, findings: parsed }) : note, valid())
|
|
3675
|
+
|| parsed.length > 0 && !this.findingTextAllowed(initial, parsed);
|
|
3211
3676
|
let task = valid();
|
|
3212
3677
|
let reviewedHead: string | undefined;
|
|
3213
3678
|
if (head !== undefined) {
|
|
3214
3679
|
const checked = sdlcReceipt({ reviewedHead: head }, task.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined));
|
|
3215
3680
|
reviewedHead = checked.reviewedHead;
|
|
3216
3681
|
}
|
|
3217
|
-
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead); task = valid(); }
|
|
3682
|
+
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid(); }
|
|
3218
3683
|
let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
|
|
3219
3684
|
if (verdict === "changes_requested" && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|
|
3220
3685
|
const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
|
|
@@ -3232,7 +3697,7 @@ export class Tasks {
|
|
|
3232
3697
|
const family = by === USER ? undefined : (this.d.adapterCatalog?.familyOf ?? familyOf)(by, this.d.instances?.());
|
|
3233
3698
|
const reviewerFamily = poolRequest?.reviewerFamily === family ? family : undefined;
|
|
3234
3699
|
const reviewerReason = reviewerFamily ? poolRequest?.reviewerReason : undefined;
|
|
3235
|
-
const flag = { withheld: withheld || undefined, ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
|
|
3700
|
+
const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
|
|
3236
3701
|
if (verdict === "approved") {
|
|
3237
3702
|
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);
|
|
3238
3703
|
if (next.owner) {
|
|
@@ -3248,9 +3713,12 @@ export class Tasks {
|
|
|
3248
3713
|
this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
|
|
3249
3714
|
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
|
|
3250
3715
|
await this.releaseDependents(next);
|
|
3716
|
+
// A proposal storage failure cannot roll back the saved verdict; another matching verdict retries it.
|
|
3717
|
+
this.releaseNotice(`task #${next.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(next, by, parsed, withheld));
|
|
3251
3718
|
return next;
|
|
3252
3719
|
}
|
|
3253
3720
|
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note, flag);
|
|
3721
|
+
this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
|
|
3254
3722
|
if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
|
|
3255
3723
|
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 */ }
|
|
3256
3724
|
}
|