@staix/agent-hub 0.12.45 → 0.14.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 +31 -0
- package/README.md +85 -116
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +10 -7
- package/docs/agent-notes/budget.md +7 -1
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +14 -2
- package/docs/agent-notes/local-worker.md +2 -1
- package/docs/agent-notes/tasks.md +16 -3
- package/docs/agent-notes/tests.md +5 -3
- package/docs/dogfood.md +95 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +290 -27
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +13 -3
- package/docs/sdlc.md +59 -8
- package/docs/security.md +26 -13
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +797 -8
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
- package/docs/verification/2026-10-22-code-facts.md +78 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +61 -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/2026-10-23-0.14.0.md +69 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +22 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +702 -347
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +20 -9
- package/src/adapters/codex-appserver.ts +18 -2
- package/src/adapters/codex-review-isolation.ts +29 -11
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/definitions/builtin-metadata.ts +3 -2
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
- package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
- 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/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +2 -1
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/registry-types.ts +3 -1
- package/src/adapters/registry.ts +1 -0
- package/src/adapters/reviewer-print.ts +34 -7
- 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 +17 -6
- package/src/cli/identity.ts +29 -6
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +127 -11
- package/src/cli/output.ts +91 -3
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/cli/status-lines.ts +1 -1
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +65 -9
- 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 +10 -1
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/daemon.ts +319 -34
- 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/hub-tools.ts +26 -3
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instances.ts +87 -5
- package/src/hub/manager.ts +2 -2
- 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/sdlc-metrics.ts +260 -0
- package/src/hub/sdlc-queue.ts +31 -9
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc.ts +54 -14
- package/src/hub/settings.ts +5 -2
- package/src/hub/snapshots.ts +93 -8
- package/src/hub/spec-read.ts +31 -0
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-group.ts +52 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +455 -67
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/ui.ts +3 -0
- package/src/hub/usage-rollup.ts +419 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +274 -2
- 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,7 +1,11 @@
|
|
|
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";
|
|
1
4
|
import { completePlanInventory, planConformance, planConformanceBlockers, planFingerprint, type PlanConformance } from "./plan-conformance.ts";
|
|
2
5
|
import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
|
|
3
6
|
import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
|
|
4
|
-
import { specRefs } from "./spec-read.ts";
|
|
7
|
+
import { qualifiedTitleRepos, siblingTitleRefs, specRefs } from "./spec-read.ts";
|
|
8
|
+
import { sdlcMetrics, type BandResult, type MetricTask, type MetricsReport } from "./sdlc-metrics.ts";
|
|
5
9
|
import type { InstancesConfig } from "./instances.ts";
|
|
6
10
|
import { composeRememberText } from "./memory-fail.ts";
|
|
7
11
|
import { VERSION } from "../version.ts";
|
|
@@ -37,7 +41,7 @@ import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief
|
|
|
37
41
|
import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
|
|
38
42
|
import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
39
43
|
import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
40
|
-
import { readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
|
|
44
|
+
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";
|
|
41
45
|
|
|
42
46
|
export type TaskAssignmentRole = "owner" | "reviewer";
|
|
43
47
|
|
|
@@ -72,6 +76,7 @@ export interface TasksDeps {
|
|
|
72
76
|
/** Explicit acceptance/self-claim only; implicit done has no pre-work baseline. */
|
|
73
77
|
ownerAccepted?: (task: Task) => void;
|
|
74
78
|
/** #520: the review brief for a task's commit (git only, bounded); undefined when the task names no commit. */
|
|
79
|
+
prepareReviewBrief?: (task: Task) => Promise<void>;
|
|
75
80
|
reviewBrief?: (task: Task) => ReviewBrief | { unavailable: string } | undefined;
|
|
76
81
|
sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
|
|
77
82
|
briefs?: Briefs;
|
|
@@ -176,6 +181,7 @@ const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
|
|
|
176
181
|
const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
177
182
|
/** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
|
|
178
183
|
const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
|
|
184
|
+
QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits for its reserved owner's WIP slot.
|
|
179
185
|
QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
|
|
180
186
|
|
|
181
187
|
/**
|
|
@@ -277,6 +283,9 @@ const RESCREEN_MAX = 10;
|
|
|
277
283
|
* The task flow. Adapters and tools never touch the board: every change comes through here, where assignment,
|
|
278
284
|
* PII handling, envelopes, briefs and memory notes are decided in one place.
|
|
279
285
|
*/
|
|
286
|
+
/** #694: a PR staged at done with the commit or branch it was read for. */
|
|
287
|
+
interface IdentityLink { repo: string; pr: number; basis: { commit?: string; branch?: string } }
|
|
288
|
+
|
|
280
289
|
export class Tasks {
|
|
281
290
|
private readonly repositories: TaskRepositories;
|
|
282
291
|
private readonly incomingBacklog = new Set<number>();
|
|
@@ -401,7 +410,7 @@ export class Tasks {
|
|
|
401
410
|
} else {
|
|
402
411
|
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) });
|
|
403
412
|
this.releaseNotice(`${text} Owner reassignment suggestion: ${admission.owner ?? "no eligible peer"}.`);
|
|
404
|
-
if (admission.owner && config.auto_reassign && this.sweepAvailable(admission.owner)) {
|
|
413
|
+
if (admission.owner && config.auto_reassign && this.sweepAvailable(admission.owner) && !this.reservedHold(task)) {
|
|
405
414
|
try { await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [admission.owner], event: "reassigned", reason: "idle", note: "ready-unassigned idle sweep" }); }
|
|
406
415
|
catch (e) { if (!(e instanceof AssignmentUndeliveredError)) throw e; }
|
|
407
416
|
}
|
|
@@ -582,9 +591,12 @@ export class Tasks {
|
|
|
582
591
|
/** A task as a cloud peer may see it. */
|
|
583
592
|
|
|
584
593
|
publicView(task: Task, fullHistory = false): Record<string, unknown> {
|
|
585
|
-
const { title, detail, history, external, cancelled, ...taskRest } = task;
|
|
594
|
+
const { title, detail, history, external, cancelled, codeSymbols, ...taskRest } = task;
|
|
586
595
|
const rest = { ...taskRest, ...(history.some(h => h.by === USER && h.planDeparture) ? { planDepartureAccepted: true } : {}) };
|
|
587
596
|
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]" };
|
|
597
|
+
if (rest.sdlc?.redFirst && !this.publicMetadataText(JSON.stringify(rest.sdlc.redFirst))) {
|
|
598
|
+
const { redFirst: _withheld, ...receipt } = rest.sdlc; rest.sdlc = receipt;
|
|
599
|
+
}
|
|
588
600
|
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}]`;
|
|
589
601
|
const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
|
|
590
602
|
at: h.at, by: h.by, event: h.event,
|
|
@@ -603,7 +615,9 @@ export class Tasks {
|
|
|
603
615
|
...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
|
|
604
616
|
}));
|
|
605
617
|
const sets = this.reviewFindingSets(task), current = findingsForHead(sets, this.findingHead(task));
|
|
606
|
-
|
|
618
|
+
const symbolRepository = codeSymbols && this.codeSymbolsRepository(task);
|
|
619
|
+
const publicSymbols = codeSymbols && symbolRepository ? publicTaskCodeSymbols(codeSymbols, this.reviewHead(task), symbolRepository.key, this.publicMetadataText) : undefined;
|
|
620
|
+
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 };
|
|
607
621
|
}
|
|
608
622
|
|
|
609
623
|
/** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
|
|
@@ -819,7 +833,7 @@ export class Tasks {
|
|
|
819
833
|
// were stored as written, so both sides are compared in one spelling.
|
|
820
834
|
if (mine.repositoryKey !== theirs.repositoryKey) continue;
|
|
821
835
|
const file = (p: string) => normPath(p) !== "." && theirs.paths.some((q) => normPath(q) === normPath(p));
|
|
822
|
-
const same = mine.paths.some(file) || mine.symbols.some((x) => theirs.symbols.
|
|
836
|
+
const same = mine.paths.some(file) || mine.symbols.some((x) => theirs.symbols.some((y) => symbolsOverlap(x, y)));
|
|
823
837
|
if (!same || this.d.board.reviews({ task: t.id }).some((r) => r.kind === "contradicted")) continue;
|
|
824
838
|
this.d.board.recordReview({ implementer: t.owner, reviewer: approval.by, class: t.class, kind: "contradicted", task: t.id });
|
|
825
839
|
}
|
|
@@ -879,11 +893,11 @@ export class Tasks {
|
|
|
879
893
|
}
|
|
880
894
|
return parts.length ? `no peer can take the planner pull: ${parts.join("; ")}` : "no peer has eligible planner WIP capacity";
|
|
881
895
|
}
|
|
882
|
-
private plannerAssignment(task: Task, reservedOnly = false): Assignment {
|
|
896
|
+
private plannerAssignment(task: Task, reservedOnly = false, routing: Routing = this.d.routing()): Assignment {
|
|
883
897
|
const states = this.states();
|
|
884
898
|
const full = this.plannerFull(task);
|
|
885
899
|
// Preview the To Do transition without routing or offering the persisted backlog row.
|
|
886
|
-
return assign({ ...task, state: "proposed" }, states,
|
|
900
|
+
return assign({ ...task, state: "proposed" }, states, routing, { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
|
|
887
901
|
}
|
|
888
902
|
|
|
889
903
|
async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
|
|
@@ -908,14 +922,25 @@ export class Tasks {
|
|
|
908
922
|
return !!competing && (competing.rank < order.rank || competing.rank === order.rank && competing.id < order.id);
|
|
909
923
|
})) throw new PreEffectToolRefusal("a person's P0 backlog order is retained");
|
|
910
924
|
}
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
if (
|
|
925
|
+
if (task.reserved && this.excluded(task).includes(task.reserved)) throw new PreEffectToolRefusal(this.plannerRefusal(task));
|
|
926
|
+
const assignment = this.plannerAssignment(task, !!task.reserved);
|
|
927
|
+
if (!assignment.owner) {
|
|
928
|
+
const refusal = task.reserved ? `reserved peer ${task.reserved} cannot take the planner pull: ${assignment.refusals?.find(row => row.role === "owner" && row.peer === task.reserved)?.reason ?? "not eligible"}; ${this.plannerRefusal(task)}` : this.plannerRefusal(task);
|
|
929
|
+
throw new PreEffectToolRefusal(`${this.hasPersonReservation(task) ? "a person's reservation is retained; " : ""}${refusal}`);
|
|
930
|
+
}
|
|
914
931
|
return { task, owner: assignment.owner };
|
|
915
932
|
};
|
|
916
933
|
const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
|
|
917
934
|
authorized();
|
|
918
935
|
const current = validate();
|
|
936
|
+
if (current.task.reserved) {
|
|
937
|
+
return this.assignOwner(current.task, by, { candidates: [current.owner], namedOwner: current.owner, planner: true, refuseNamed: "pre",
|
|
938
|
+
plannerPull: { revision: input.revision, note: input.reason.trim(), withheld, guard: () => {
|
|
939
|
+
authorized();
|
|
940
|
+
const checked = validate();
|
|
941
|
+
if (checked.task.history.length !== current.task.history.length || checked.owner !== current.owner) throw new PreEffectToolRefusal("planner pull changed during admission");
|
|
942
|
+
} } });
|
|
943
|
+
}
|
|
919
944
|
const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld);
|
|
920
945
|
this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
|
|
921
946
|
return pulled;
|
|
@@ -1430,6 +1455,7 @@ export class Tasks {
|
|
|
1430
1455
|
if (this.d.coordination) this.coordinationModes.set(task.id, this.d.coordination());
|
|
1431
1456
|
created?.();
|
|
1432
1457
|
if (screening) task = this.screenedTask(task, screened);
|
|
1458
|
+
task = this.linkOnWrite(task); // #723
|
|
1433
1459
|
if (goal) this.linkNative(task, goal, by);
|
|
1434
1460
|
if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
|
|
1435
1461
|
if (defaulted) task = this.d.board.update(task.id, "hub", "class defaulted", {}, "class implement for a claim without one");
|
|
@@ -1479,6 +1505,7 @@ export class Tasks {
|
|
|
1479
1505
|
this.withdrawRoleRequest(confirmed, returned, "reviewer");
|
|
1480
1506
|
this.incomingBacklog.add(confirmed.id);
|
|
1481
1507
|
this.planningRecorded({ by, task: confirmed.id, fields: ["return"], revision: confirmed.planningRevision ?? 0 });
|
|
1508
|
+
await this.releaseHeld(confirmed.owner); // its owner's slot is free (#655)
|
|
1482
1509
|
return returned;
|
|
1483
1510
|
}
|
|
1484
1511
|
|
|
@@ -1606,6 +1633,18 @@ export class Tasks {
|
|
|
1606
1633
|
/** The tasks an overlap text for `task` names: their plans are in it (issue #108). */
|
|
1607
1634
|
overlapTasks = (task: Task): number[] => this.overlapHits(task).map((h) => h.task.id);
|
|
1608
1635
|
|
|
1636
|
+
/** Console detail advice only: no pair persistence, and no private task on either side. */
|
|
1637
|
+
symbolOverlaps(task: Task): { task: number; files: string[]; sameFileDifferentSymbols: boolean }[] {
|
|
1638
|
+
// A routing-locality opt-out does not make symbol metadata for a marked task public.
|
|
1639
|
+
const markedPrivate = (value: Task) => value.signals.includes("pii") || value.signals.includes("pii_path");
|
|
1640
|
+
if (markedPrivate(task)) return [];
|
|
1641
|
+
return this.overlapHits(task).filter(hit => !markedPrivate(hit.task)).map(hit => ({
|
|
1642
|
+
task: hit.task.id,
|
|
1643
|
+
files: hit.paths.filter(this.publicMetadataText),
|
|
1644
|
+
sameFileDifferentSymbols: this.sameFileDifferentSymbols(task, hit),
|
|
1645
|
+
}));
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1609
1648
|
/**
|
|
1610
1649
|
* Dashboard warning links (#264): the board's overlap pairs and the live cohorts, as task and peer ids only.
|
|
1611
1650
|
* Paths, symbols and plans stay out; PII tasks are excluded by the same rules overlapHits applies.
|
|
@@ -1648,12 +1687,23 @@ export class Tasks {
|
|
|
1648
1687
|
* ponytail: kept in memory, so after a restart such a notice is delivered whatever its task's state; persist the
|
|
1649
1688
|
* condition if stale notices after restarts show up.
|
|
1650
1689
|
*/
|
|
1651
|
-
private readonly conditional = new Map<string, { peer: PeerId; task: number; states: Task["state"][] }>();
|
|
1690
|
+
private readonly conditional = new Map<string, { peer: PeerId; task: number; tasks?: number[]; states: Task["state"][] }>();
|
|
1652
1691
|
|
|
1653
1692
|
/**
|
|
1654
1693
|
* Publish a notice about `task`, which only matters to `to` while that task is in one of `states` for it (issue
|
|
1655
1694
|
* #106): open work by default; the conflict notices of #91 count a task in review too.
|
|
1656
1695
|
*/
|
|
1696
|
+
/**
|
|
1697
|
+
* #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
|
|
1698
|
+
* recipient, and carries `completedFor` so the bus replaces an earlier queued notice for the same `done` task and recipient.
|
|
1699
|
+
*/
|
|
1700
|
+
whileOpenFor(to: PeerId, done: number, about: number[], body: string): void {
|
|
1701
|
+
const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(about[0]), taskRecipient: "owner", completedFor: done } });
|
|
1702
|
+
this.conditional.set(env.id, { peer: to, task: about[0]!, tasks: about, states: OPEN });
|
|
1703
|
+
if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
|
|
1704
|
+
this.d.bus.publish(env);
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1657
1707
|
whileOpen(to: PeerId, task: number, body: string, states: Task["state"][] = OPEN): void {
|
|
1658
1708
|
const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task), taskRecipient: "owner" } });
|
|
1659
1709
|
this.conditional.set(env.id, { peer: to, task, states });
|
|
@@ -1682,8 +1732,8 @@ export class Tasks {
|
|
|
1682
1732
|
}
|
|
1683
1733
|
const c = this.conditional.get(env.id);
|
|
1684
1734
|
if (!c || c.peer !== peer) return true;
|
|
1685
|
-
|
|
1686
|
-
return !!t && t.owner === peer && c.states.includes(t.state);
|
|
1735
|
+
// A notice about several of the recipient's tasks matters while any one of them is still open for it (#713).
|
|
1736
|
+
return (c.tasks ?? [c.task]).some((id) => { const t = this.d.board.get(id); return !!t && t.owner === peer && c.states.includes(t.state); });
|
|
1687
1737
|
};
|
|
1688
1738
|
|
|
1689
1739
|
/** Whether a model-written name may be shown to other peers and in the log: one matching a PII pattern may be PII. */
|
|
@@ -1719,7 +1769,12 @@ export class Tasks {
|
|
|
1719
1769
|
private places(task: Task): { repositoryKey: string; paths: string[]; symbols: string[] } {
|
|
1720
1770
|
const scope = this.repositories.scope(task.refs);
|
|
1721
1771
|
const paths = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].map((p) => scope === this.repositories.project ? this.projectPath(p) : normPath(this.repositories.relativePath(scope, p)));
|
|
1722
|
-
|
|
1772
|
+
const symbols = [
|
|
1773
|
+
...(task.plan?.symbols ?? []).map(normalizePlanSymbol),
|
|
1774
|
+
...(task.plan?.sections ?? []).map(v => `section ${v}`),
|
|
1775
|
+
...(task.plan?.sources ?? []).map(v => `source ${v}`),
|
|
1776
|
+
];
|
|
1777
|
+
return { repositoryKey: scope.key, paths: [...new Set(paths)], symbols: [...new Set(symbols)] };
|
|
1723
1778
|
}
|
|
1724
1779
|
|
|
1725
1780
|
private roots?: string[];
|
|
@@ -1745,7 +1800,7 @@ export class Tasks {
|
|
|
1745
1800
|
const theirs = this.places(t);
|
|
1746
1801
|
if (mine.repositoryKey !== theirs.repositoryKey) return [];
|
|
1747
1802
|
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)));
|
|
1748
|
-
const symbols = mine.symbols.filter((x) => theirs.symbols.
|
|
1803
|
+
const symbols = mine.symbols.filter((x) => theirs.symbols.some((y) => symbolsOverlap(x, y)));
|
|
1749
1804
|
return paths.length || symbols.length ? [{ task: t, paths, symbols }] : [];
|
|
1750
1805
|
});
|
|
1751
1806
|
}
|
|
@@ -1813,7 +1868,9 @@ export class Tasks {
|
|
|
1813
1868
|
const task = this.d.board.get(target);
|
|
1814
1869
|
if (!task) throw new Error(`no task #${target}`);
|
|
1815
1870
|
if (isClosedTaskState(task.state)) return [...workflow, `task ${this.publicTitle(task)} (${task.state}): closed; not routed`];
|
|
1816
|
-
const
|
|
1871
|
+
const planned = task.state === "backlog" && !!task.reserved || this.plannerPullRecord(task)?.pull === true;
|
|
1872
|
+
const a = planned ? this.plannerAssignment(task, true, routing)
|
|
1873
|
+
: assign(task, this.states(), routing, { exclude: this.excluded(task), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
|
|
1817
1874
|
// The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
|
|
1818
1875
|
return [...workflow, `task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"}${task.reserved ? `, reserved for ${task.reserved}` : ""})`, ...(task.profileClass ? [`profile class ${profileTaskLabel(task)}`] : []), ...(task.role ? [`task role ${task.role} (guidance only)`] : []), "if it were assigned now:", ...a.trace, ...(this.splitShadow(task, task.owner ?? a.owner)?.trace ?? [])];
|
|
1819
1876
|
}
|
|
@@ -1901,14 +1958,17 @@ export class Tasks {
|
|
|
1901
1958
|
}
|
|
1902
1959
|
|
|
1903
1960
|
/** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
|
|
1904
|
-
private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number; refuseNamed?: "pre" | "plain" } = {}): Promise<Task> {
|
|
1905
|
-
if (task.state === "backlog") throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
|
|
1961
|
+
private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number; refuseNamed?: "pre" | "plain"; plannerPull?: { revision: number; note: string; withheld: boolean; guard: () => void } } = {}): Promise<Task> {
|
|
1962
|
+
if (task.state === "backlog" && !opts.plannerPull) throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
|
|
1906
1963
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
1907
1964
|
let stagedClaimGap: string | undefined;
|
|
1908
1965
|
if (mode !== "off" && this.isBuildTask(task)) {
|
|
1909
1966
|
const gap = task.sdlc?.planReview ? await this.planReviewGap(task) : "plan not reviewed since proposal";
|
|
1910
1967
|
const current = this.d.board.get(task.id);
|
|
1911
|
-
if (!current || !this.ownerAssignmentCurrent(task, current))
|
|
1968
|
+
if (!current || !this.ownerAssignmentCurrent(task, current)) {
|
|
1969
|
+
if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull changed during admission");
|
|
1970
|
+
return current ?? task;
|
|
1971
|
+
}
|
|
1912
1972
|
task = current; // Reviewer-only and quiet bookkeeping updates do not supersede ownership authority.
|
|
1913
1973
|
if (gap) {
|
|
1914
1974
|
this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}; planner: call hub_planner_groom then hub_plan_review`);
|
|
@@ -1925,9 +1985,10 @@ export class Tasks {
|
|
|
1925
1985
|
const waits = this.waitsFor(task);
|
|
1926
1986
|
const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
|
|
1927
1987
|
const plannerFull = plannedMove ? this.plannerFull(task) : [];
|
|
1928
|
-
const a = assign(task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class, task), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}) });
|
|
1988
|
+
const a = assign(opts.plannerPull ? { ...task, state: "proposed" } : task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class, task), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}) });
|
|
1929
1989
|
|
|
1930
1990
|
if (waits.length) {
|
|
1991
|
+
if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
|
|
1931
1992
|
this.releaseNotice(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
|
|
1932
1993
|
return task;
|
|
1933
1994
|
}
|
|
@@ -1953,7 +2014,13 @@ export class Tasks {
|
|
|
1953
2014
|
// A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
|
|
1954
2015
|
// out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
|
|
1955
2016
|
const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
|
|
1956
|
-
|
|
2017
|
+
opts.plannerPull?.guard();
|
|
2018
|
+
const assignmentNote = this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a);
|
|
2019
|
+
const assignmentExtra = { ...this.reviewMetadata(a), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) };
|
|
2020
|
+
const next = opts.plannerPull
|
|
2021
|
+
? this.d.board.pullAssigned(task.id, by, a.owner, opts.plannerPull.revision, opts.plannerPull.note, opts.plannerPull.withheld, a.reviewer ?? null, assignmentNote, assignmentExtra, task.history.length)
|
|
2022
|
+
: this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, assignmentNote, assignmentExtra, opts.expectedFlowRevision);
|
|
2023
|
+
if (opts.plannerPull) this.planningRecorded({ by, task: next.id, fields: ["pull", "reserved"], revision: next.planningRevision! });
|
|
1957
2024
|
// What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
|
|
1958
2025
|
// escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
|
|
1959
2026
|
// yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
|
|
@@ -1967,6 +2034,11 @@ export class Tasks {
|
|
|
1967
2034
|
// The cohort record formed just below reads it, and so does `route explain` while that turn lasts.
|
|
1968
2035
|
if (opts.claim && a.owner === by && this.d.bus.peers.get(by)?.state === "busy") this.sent.set(handOver(next, by), by);
|
|
1969
2036
|
try {
|
|
2037
|
+
if (opts.plannerPull && task.offer) {
|
|
2038
|
+
const prior = { ...task, owner: task.offer.owner };
|
|
2039
|
+
this.withdrawRoleRequest(prior, next, "owner");
|
|
2040
|
+
this.releaseNotice(`task #${next.id}: stale offer withdrawal failed`, () => this.d.bus.withdraw(task.offer!.envelopeId));
|
|
2041
|
+
}
|
|
1970
2042
|
if (by === USER && opts.event === "reassigned") this.withdrawRoleRequest(task, next, "owner");
|
|
1971
2043
|
const hits = this.overlapHits(next);
|
|
1972
2044
|
this.formCohort(next, hits);
|
|
@@ -2095,13 +2167,49 @@ export class Tasks {
|
|
|
2095
2167
|
if (waits.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} waits for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet`);
|
|
2096
2168
|
}
|
|
2097
2169
|
|
|
2170
|
+
/** Ready offers between their cap check and the owner write (#655): task id -> reserved owner whose slot it takes. */
|
|
2171
|
+
private readonly readyInFlight = new Map<number, PeerId>();
|
|
2172
|
+
|
|
2173
|
+
/** Offer order for ready work (#655): priority (urgent is P0, none is P2), then rank, then id. */
|
|
2174
|
+
private readonly readyOrder = (a: Task, b: Task): number =>
|
|
2175
|
+
(a.priority ?? (a.signals.includes("urgent") ? 0 : 2)) - (b.priority ?? (b.signals.includes("urgent") ? 0 : 2))
|
|
2176
|
+
|| (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER) || a.id - b.id;
|
|
2177
|
+
|
|
2178
|
+
/** Ready work the hub parked on its reserved owner's WIP cap (#655): its latest move-or-hold entry is `ready held`. */
|
|
2179
|
+
private isHeld(task: Task): boolean {
|
|
2180
|
+
// A planner's priority or rank edit (`planned`) is no move: it must not make held work look released.
|
|
2181
|
+
return task.history.findLast(entry => entry.event === "ready held" || !QUIET_EVENTS.has(entry.event) && entry.event !== "planned")?.event === "ready held";
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
/**
|
|
2185
|
+
* Why readiness must not hand `task` to its reserved owner yet (#655): that owner is at its planner WIP cap, the count
|
|
2186
|
+
* `acceptanceCapacity` refuses at. Peer id and counts only, so it is safe for a PII task. A reserved owner the task
|
|
2187
|
+
* excludes is not honoured by routing, and planner-pulled work was admitted by its pull: neither is held.
|
|
2188
|
+
*/
|
|
2189
|
+
private reservedHold(task: Task): string | undefined {
|
|
2190
|
+
const owner = task.reserved;
|
|
2191
|
+
if (!owner || task.owner || this.plannerPullRecord(task)?.pull === true || this.excluded(task).includes(owner)) return undefined;
|
|
2192
|
+
// Offers in flight take a slot before their owner is written; once it is written wip() counts it, so never twice.
|
|
2193
|
+
const flying = [...this.readyInFlight].filter(([id, peer]) => peer === owner && id !== task.id && !this.d.board.get(id)?.owner).length;
|
|
2194
|
+
const limit = this.wipLimit(owner), used = this.wip(owner, task.id) + (this.pendingClaims.get(owner) ?? 0) + flying;
|
|
2195
|
+
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;
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
/** The task is ready but its reserved owner has no room: say so once on the task history, never assign. */
|
|
2199
|
+
private holdReady(task: Task, why: string, reason: string): void {
|
|
2200
|
+
if (this.isHeld(task)) return;
|
|
2201
|
+
const ready = task.history.at(-1)?.event === "ready" ? task : this.d.board.update(task.id, HUB, "ready", {}, why);
|
|
2202
|
+
this.d.board.update(ready.id, HUB, "ready held", {}, reason);
|
|
2203
|
+
this.releaseNotice(`task ${this.publicTitle(ready)} is ready but held: ${reason}`);
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2098
2206
|
/** An approved task may be the last thing others waited for: those go through assignment now (issue #34). */
|
|
2099
2207
|
private async releaseDependents(approved: Task): Promise<void> {
|
|
2100
2208
|
try {
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
}
|
|
2209
|
+
// Its dependents and, as its owner's slot just freed (#655), that owner's held ready tasks: one list in priority order.
|
|
2210
|
+
const ready = this.d.board.list("proposed").filter(t => !t.owner && !this.waitsFor(t).length
|
|
2211
|
+
&& (t.deps?.includes(approved.id) || !!approved.owner && t.reserved === approved.owner && this.isHeld(t))).sort(this.readyOrder);
|
|
2212
|
+
for (const t of ready) await this.offerReady(t, t.deps?.includes(approved.id) ? `#${approved.id} approved` : "owner slot freed");
|
|
2105
2213
|
} catch (e) {
|
|
2106
2214
|
this.releaseNotice(`task #${approved.id}: could not release dependents: ${e instanceof Error ? e.message : String(e)}`);
|
|
2107
2215
|
}
|
|
@@ -2223,14 +2331,25 @@ export class Tasks {
|
|
|
2223
2331
|
this.offered.delete(task.id);
|
|
2224
2332
|
this.releaseNotice(`task #${task.id}: cancellation stall cleanup unavailable`, () => this.d.board.clearStall(task.id));
|
|
2225
2333
|
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 } : {}) })));
|
|
2334
|
+
await this.releaseHeld(task.owner); // its owner's slot is free (#655)
|
|
2226
2335
|
this.releaseNotice(`task #${task.id}: cancellation dependent advice unavailable`, () => {
|
|
2227
2336
|
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" }));
|
|
2228
2337
|
});
|
|
2229
2338
|
return next;
|
|
2230
2339
|
}
|
|
2231
2340
|
|
|
2341
|
+
/** A slot freed for `peer` (cancel, untouched return): its held ready tasks are offered in priority order, and the cap stops the offers (#655). */
|
|
2342
|
+
private async releaseHeld(peer: PeerId | null | undefined): Promise<void> {
|
|
2343
|
+
if (!peer) return;
|
|
2344
|
+
try {
|
|
2345
|
+
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");
|
|
2346
|
+
} catch (e) {
|
|
2347
|
+
this.releaseNotice(`task ${peer}: could not offer held ready work: ${e instanceof Error ? e.message : String(e)}`);
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2232
2351
|
async releaseReady(): Promise<void> {
|
|
2233
|
-
for (const t of this.d.board.list("proposed")) {
|
|
2352
|
+
for (const t of [...this.d.board.list("proposed")].sort(this.readyOrder)) {
|
|
2234
2353
|
if (t.owner) { this.d.board.clearStall(t.id); continue; }
|
|
2235
2354
|
const cancelledDependency = t.deps?.find(id => this.d.board.get(id)?.state === "cancelled");
|
|
2236
2355
|
if (cancelledDependency) { this.reportStall(t, { kind: "cancelled-dependency", dependency: cancelledDependency, code: "cancelled" }); continue; }
|
|
@@ -2242,7 +2361,7 @@ export class Tasks {
|
|
|
2242
2361
|
continue;
|
|
2243
2362
|
}
|
|
2244
2363
|
this.d.board.clearStall(t.id);
|
|
2245
|
-
if ((!t.deps?.length && !t.history.some(h => h.event === "backlog pulled")) || (!planned && last !== "blocked" && last !== "ready" && last !== "reserved")) continue;
|
|
2364
|
+
if ((!t.deps?.length && !t.history.some(h => h.event === "backlog pulled")) || (!planned && last !== "blocked" && last !== "ready" && last !== "reserved" && !this.isHeld(t))) continue;
|
|
2246
2365
|
// ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
|
|
2247
2366
|
// an offline one; hold reserved tasks for a grace period if restarts show that up.
|
|
2248
2367
|
await this.offerReady(t, "nothing left to wait for");
|
|
@@ -2251,13 +2370,18 @@ export class Tasks {
|
|
|
2251
2370
|
|
|
2252
2371
|
/** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
|
|
2253
2372
|
private async offerReady(stale: Task, why: string): Promise<void> {
|
|
2254
|
-
let offering = false, undelivered: AssignmentUndeliveredError | undefined;
|
|
2373
|
+
let offering = false, undelivered: AssignmentUndeliveredError | undefined, slot: PeerId | undefined;
|
|
2255
2374
|
try {
|
|
2256
2375
|
const t = this.d.board.get(stale.id);
|
|
2257
2376
|
if (!t || t.state !== "proposed" || t.owner || this.personUnassigned(t) || this.offered.has(t.id)) return;
|
|
2258
2377
|
const planned = this.plannerPullRecord(t)?.pull === true;
|
|
2259
2378
|
const selected = planned ? this.plannerAssignment(t, true).owner : undefined;
|
|
2260
2379
|
if (planned && !selected) return;
|
|
2380
|
+
const held = planned ? undefined : this.reservedHold(t); // #655: the cap holds before the ready move is offered
|
|
2381
|
+
if (held) { this.holdReady(t, why, held); return; }
|
|
2382
|
+
// The check and the slot are one synchronous step: assignOwner can yield before it writes the owner, and a second offer
|
|
2383
|
+
// for the same reserved owner must see this one as taking the last slot.
|
|
2384
|
+
if (!planned && t.reserved) { slot = t.reserved; this.readyInFlight.set(t.id, slot); }
|
|
2261
2385
|
this.offered.add(t.id);
|
|
2262
2386
|
offering = true;
|
|
2263
2387
|
const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
|
|
@@ -2269,6 +2393,7 @@ export class Tasks {
|
|
|
2269
2393
|
if (e instanceof AssignmentUndeliveredError) undelivered = e;
|
|
2270
2394
|
else this.releaseNotice(`task ${this.publicTitle(stale)}: could not be assigned: ${e instanceof Error ? e.message : String(e)}`);
|
|
2271
2395
|
} finally {
|
|
2396
|
+
if (slot) this.readyInFlight.delete(stale.id);
|
|
2272
2397
|
if (offering && !undelivered) {
|
|
2273
2398
|
try {
|
|
2274
2399
|
if (!this.d.board.get(stale.id)?.owner) this.offered.delete(stale.id);
|
|
@@ -2284,6 +2409,67 @@ export class Tasks {
|
|
|
2284
2409
|
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`);
|
|
2285
2410
|
}
|
|
2286
2411
|
|
|
2412
|
+
/**
|
|
2413
|
+
* #723: link the issue a task names the moment it is proposed or accepted, in advise and enforce, so the lifecycle never
|
|
2414
|
+
* waits for a groom. It is a local parse with no GitHub read, and it never refuses or fails the write it follows.
|
|
2415
|
+
*/
|
|
2416
|
+
private linkOnWrite(task: Task): Task {
|
|
2417
|
+
try { return (this.d.sdlc?.config().mode ?? "off") === "off" ? task : this.linkIssue(task); }
|
|
2418
|
+
catch { return task; }
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
/**
|
|
2422
|
+
* #723: a title that qualifies another repository (`agent-mem#13`, `owner/repo#N`) while `refs.repo` is absent cannot link
|
|
2423
|
+
* (the identity needs the repository), so the proposer is told to set it. Advice only; a PII task gets none.
|
|
2424
|
+
*/
|
|
2425
|
+
refsRepoHint(task: Task): string | undefined {
|
|
2426
|
+
if (this.isPii(task) || task.refs.repo || task.sdlc?.issue || (this.d.sdlc?.config().mode ?? "off") === "off") return undefined;
|
|
2427
|
+
const names = qualifiedTitleRepos(task.title);
|
|
2428
|
+
return names.length ? `task #${task.id}: the title names ${names.join(", ")}; set refs.repo to that repository (owner/name) so its issue links and the lifecycle shows it` : undefined;
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
/** #694: write the one issue the task itself names (the plan-review source) into its lifecycle receipt, once; never relinks. */
|
|
2432
|
+
private linkIssue(task: Task): Task {
|
|
2433
|
+
if (this.isPii(task) || task.sdlc?.issue) return task;
|
|
2434
|
+
let identity: { repo: string; issue: number } | undefined;
|
|
2435
|
+
try { identity = this.planIssueIdentity(task); } catch { return task; }
|
|
2436
|
+
const next = identity && linkedReceipt(task.sdlc, identity, this.d.sdlc?.config() ?? sdlcConfig(undefined));
|
|
2437
|
+
return next ? this.d.board.update(task.id, HUB, "lifecycle linked", { sdlc: next }, `issue #${identity!.issue}`) : task;
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
/**
|
|
2441
|
+
* #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
|
|
2442
|
+
* (or, with no commit, its branch). The read's basis travels with it, so a task whose commit or branch changed while the
|
|
2443
|
+
* read was awaited never receives a PR found for another head. The done persists it only after its gates admit it, so a
|
|
2444
|
+
* refused done stays effect-free (#254).
|
|
2445
|
+
*/
|
|
2446
|
+
private async identityLink(task: Task, refs?: TaskRefs): Promise<IdentityLink | undefined> {
|
|
2447
|
+
if (this.isPii(task) || task.sdlc?.pr) return undefined;
|
|
2448
|
+
const repository = task.sdlc?.repo ?? task.refs.repo ?? this.d.repository;
|
|
2449
|
+
const commit = refs?.commit ?? task.refs.commit, branch = refs?.branch ?? task.refs.branch;
|
|
2450
|
+
if (!repository || !commit && !branch) return undefined;
|
|
2451
|
+
const pr = await openPrFor(repository, commit ? { commit } : { branch }, this.d.sdlc?.gh ?? sdlcGh);
|
|
2452
|
+
return pr ? { repo: repository, pr, basis: { commit, branch } } : undefined;
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
/**
|
|
2456
|
+
* The task as the done's gates see it: the issue the current task names (re-resolved now, so a changed title or refs
|
|
2457
|
+
* counts) and the staged PR only while the done's effective commit and branch are still the ones it was read for.
|
|
2458
|
+
*/
|
|
2459
|
+
private withLink(task: Task, link: IdentityLink | undefined, refs?: TaskRefs): Task {
|
|
2460
|
+
if ((this.d.sdlc?.config().mode ?? "off") === "off" || this.isPii(task)) return task;
|
|
2461
|
+
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2462
|
+
let issue: { repo: string; issue: number } | undefined;
|
|
2463
|
+
if (!task.sdlc?.issue) { try { issue = this.planIssueIdentity(task); } catch { issue = undefined; } }
|
|
2464
|
+
const commit = refs?.commit ?? task.refs.commit, branch = refs?.branch ?? task.refs.branch;
|
|
2465
|
+
const repository = task.sdlc?.repo ?? task.refs.repo ?? this.d.repository;
|
|
2466
|
+
const pr = link && !task.sdlc?.pr && repository?.toLowerCase() === link.repo.toLowerCase() && link.basis.commit === commit && (commit !== undefined || link.basis.branch === branch) ? link : undefined;
|
|
2467
|
+
let next = task.sdlc;
|
|
2468
|
+
if (issue) next = linkedReceipt(next, issue, config) ?? next;
|
|
2469
|
+
if (pr) next = linkedReceipt(next, { repo: pr.repo, pr: pr.pr }, config) ?? next;
|
|
2470
|
+
return next === task.sdlc ? task : { ...task, sdlc: next };
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2287
2473
|
private planIssueIdentity(task: Task): { repo: string; issue: number } | undefined {
|
|
2288
2474
|
const source = task.external ?? task.sdlc;
|
|
2289
2475
|
if (source?.repo && source.issue) return { repo: source.repo, issue: source.issue };
|
|
@@ -2291,12 +2477,16 @@ export class Tasks {
|
|
|
2291
2477
|
let issues: number[];
|
|
2292
2478
|
try { issues = specRefs(task, repository); }
|
|
2293
2479
|
catch { throw new PreEffectToolRefusal("plan review issue identity unavailable"); }
|
|
2480
|
+
// #723: a title that qualifies the sibling repository `refs.repo` names (`agent-mem#13`) is a local parse, never a read.
|
|
2481
|
+
// A sibling shorthand next to a bare #N is two identities, refused below, never the bare one alone.
|
|
2482
|
+
issues = [...new Set([...issues, ...siblingTitleRefs(task.title, task.refs.repo)])];
|
|
2294
2483
|
if (issues.length > 1) throw new PreEffectToolRefusal("plan review needs one explicit issue identity");
|
|
2295
2484
|
return issues[0] ? { repo: repository, issue: issues[0] } : undefined;
|
|
2296
2485
|
}
|
|
2297
2486
|
|
|
2298
2487
|
async groomingDigest(by: PeerId): Promise<Record<string, unknown>> {
|
|
2299
2488
|
this.planningAuthority(by);
|
|
2489
|
+
this.backfillIssueLinks(by); // #723: first, bounded, before the slow reads
|
|
2300
2490
|
const included = (task: Task) => !isClosedTaskState(task.state) || task.state === "cancelled" && task.history.some(entry => !!entry.supersededBy);
|
|
2301
2491
|
const snapshot = this.d.board.list().filter(included), issues = new Map<string, PlanIssueMetadata>(), attempted = new Set<string>();
|
|
2302
2492
|
const deadline = performance.now() + 4_000;
|
|
@@ -2339,6 +2529,113 @@ export class Tasks {
|
|
|
2339
2529
|
return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
|
|
2340
2530
|
}
|
|
2341
2531
|
|
|
2532
|
+
/**
|
|
2533
|
+
* #694, #723: backfill lifecycle issue links with their own bounds, run first and apart from the slow metadata reads and
|
|
2534
|
+
* the digest, so a groom that later times out has still linked. Each call links up to 32 open tasks that still lack one
|
|
2535
|
+
* within a one-second budget, so later tasks are reached on later calls. A local parse; a failure skips that task only.
|
|
2536
|
+
*/
|
|
2537
|
+
private backfillCursor = 0;
|
|
2538
|
+
private backfillIssueLinks(by: PeerId): number {
|
|
2539
|
+
if ((this.d.sdlc?.config().mode ?? "off") === "off") return 0;
|
|
2540
|
+
// ponytail: 32 links and 1 s per call; raise both only with a measured groom time on a 400-task board.
|
|
2541
|
+
const deadline = performance.now() + 1_000;
|
|
2542
|
+
let linked = 0, i = 0;
|
|
2543
|
+
const list = this.d.board.list(), n = list.length;
|
|
2544
|
+
// The scan resumes where the last call stopped, so rows that never link cannot hold later ones back.
|
|
2545
|
+
for (; i < n; i++) {
|
|
2546
|
+
if (linked >= 32 || performance.now() >= deadline) break;
|
|
2547
|
+
const task = list[(this.backfillCursor + i) % n]!;
|
|
2548
|
+
if (isClosedTaskState(task.state) || task.sdlc?.issue || this.isPii(task)) continue;
|
|
2549
|
+
this.planningAuthority(by);
|
|
2550
|
+
try { if (this.linkIssue(task) !== task) linked++; } catch { /* one task never stops the rest */ }
|
|
2551
|
+
}
|
|
2552
|
+
this.backfillCursor = n ? (this.backfillCursor + i) % n : 0;
|
|
2553
|
+
return linked;
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
/**
|
|
2557
|
+
* #481 S7: the SDLC metrics from board history, read-only. Private tasks are counted as withheld and read for nothing else; PR
|
|
2558
|
+
* readbacks are not made here (the cost report owns them), so merge time and same-head reruns read `unknown` unless given.
|
|
2559
|
+
* Detection never calls a model and the report writes nothing.
|
|
2560
|
+
*/
|
|
2561
|
+
sdlcMetrics(by: PeerId, since?: unknown, merged?: { mergedAt?: Record<number, number>; ciReruns?: Record<number, number | null> }): MetricsReport {
|
|
2562
|
+
this.planningAuthority(by);
|
|
2563
|
+
if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("metrics unavailable during private work");
|
|
2564
|
+
let cutoff: number | undefined;
|
|
2565
|
+
if (since !== undefined && since !== null && since !== "") {
|
|
2566
|
+
cutoff = typeof since === "number" ? since : /^\d+$/.test(String(since)) ? Number(since) : Date.parse(String(since));
|
|
2567
|
+
if (!Number.isFinite(cutoff) || cutoff < 0) throw new PreEffectToolRefusal("since must be a time (YYYY-MM-DD or an ISO timestamp) or milliseconds");
|
|
2568
|
+
}
|
|
2569
|
+
const rows = this.d.board.list();
|
|
2570
|
+
const tasks: MetricTask[] = rows.map(task => ({ id: task.id, private: this.isPii(task), history: task.history.map(entry => ({ at: entry.at, event: entry.event,
|
|
2571
|
+
...(entry.reviewCost?.head ? { requestHead: entry.reviewCost.head } : {}), ...(entry.reviewFindings?.head ? { verdictHead: entry.reviewFindings.head } : {}) })) }));
|
|
2572
|
+
const visibleIds = new Set(rows.filter(task => !this.isPii(task)).map(task => task.id));
|
|
2573
|
+
const byKind: Record<string, number> = {};
|
|
2574
|
+
for (const stall of this.stalled()) if (visibleIds.has(stall.task)) byKind[stall.cause.kind] = (byKind[stall.cause.kind] ?? 0) + 1;
|
|
2575
|
+
const stalls = { open: Object.values(byKind).reduce((sum, n) => sum + n, 0), byKind, doneWithoutWrites: rows.filter(task => visibleIds.has(task.id) && task.history.findLast(entry => entry.event === "done")?.noWrites).length };
|
|
2576
|
+
return sdlcMetrics(tasks, { ...(cutoff !== undefined ? { since: cutoff } : {}), ...(this.d.sdlc?.config().bands ? { bands: this.d.sdlc.config().bands } : {}), stalls, ...(merged ?? {}) });
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
/**
|
|
2580
|
+
* #481 S7: act on the band tiers, deterministically and once per breach episode. An episode is a run of non-ok values for one metric
|
|
2581
|
+
* (`BandResult.episode`, derived from history), so a sustained shift is one episode however many tasks finish inside it. `log` is one
|
|
2582
|
+
* hub notice, `diagnose` one read-only `triage` backlog task and `propose` one `plan` backlog card naming the metric and its evidence.
|
|
2583
|
+
* A metric with an open band card (a backlog or open task whose detail carries its key) writes nothing more, so a value that grows
|
|
2584
|
+
* from diagnose to propose inside one episode does not add a second card. Held while the hub is stopping, recovering or has a
|
|
2585
|
+
* storage error, like every host writer. The text is fixed words, numbers and task ids, never task text, and no GitHub issue is
|
|
2586
|
+
* created. Returns the task ids created.
|
|
2587
|
+
*/
|
|
2588
|
+
/**
|
|
2589
|
+
* Whether `proposeFromBands` would run now, and a signature of what it would read. The daemon timer records a board revision only for a
|
|
2590
|
+
* pass that ran, and re-runs when the signature changes (a reload that enables bands or edits them on an idle board).
|
|
2591
|
+
*/
|
|
2592
|
+
bandsState(): { ready: boolean; signature: string } {
|
|
2593
|
+
const config = this.d.sdlc?.config();
|
|
2594
|
+
const configured = !!config && config.mode !== "off" && !!config.bands && Object.keys(config.bands).length > 0;
|
|
2595
|
+
return { ready: configured && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError, signature: configured ? JSON.stringify([config!.mode, config!.bands]) : "" };
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
proposeFromBands(by: PeerId = HUB): number[] {
|
|
2599
|
+
const config = this.d.sdlc?.config();
|
|
2600
|
+
if (!config || config.mode === "off" || !config.bands || !Object.keys(config.bands).length) return [];
|
|
2601
|
+
if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return [];
|
|
2602
|
+
if (by !== HUB && by !== USER) this.planningAuthority(by);
|
|
2603
|
+
const report = this.sdlcMetrics(by === HUB ? USER : by);
|
|
2604
|
+
if (report.bands === "off") return [];
|
|
2605
|
+
const rows = this.d.board.list();
|
|
2606
|
+
const bandRows = rows.filter(row => row.history[0]?.by === HUB && row.detail?.startsWith("Band breach key: "));
|
|
2607
|
+
const keyOf = (row: Task) => row.detail!.split("\n")[0]!.slice("Band breach key: ".length);
|
|
2608
|
+
const seen = new Set(bandRows.map(keyOf));
|
|
2609
|
+
// A card counts as open while its task is not closed: the planner has not yet decided on it.
|
|
2610
|
+
const open = new Set(bandRows.filter(row => !isClosedTaskState(row.state)).map(row => keyOf(row).split(":")[0]!));
|
|
2611
|
+
const created: number[] = [];
|
|
2612
|
+
for (const band of report.bands as BandResult[]) {
|
|
2613
|
+
if (band.tier === "ok" || band.lastTask === null || band.episode === null) continue;
|
|
2614
|
+
const key = `${band.metric}:#${band.episode}`;
|
|
2615
|
+
if (band.tier === "log") {
|
|
2616
|
+
const marker = `${key}:log`;
|
|
2617
|
+
if (!open.has(band.metric) && this.d.board.recordBandNotice(marker)) this.releaseNotice(`sdlc band: ${band.metric} is ${band.reason}; last value ${band.last} on task #${band.lastTask}`);
|
|
2618
|
+
continue;
|
|
2619
|
+
}
|
|
2620
|
+
if (seen.has(`${key}:propose`) || seen.has(`${key}:diagnose`) || open.has(band.metric)) continue;
|
|
2621
|
+
const tier = band.tier === "propose" ? "propose" : "diagnose";
|
|
2622
|
+
const full = `${key}:${tier}`;
|
|
2623
|
+
const evidence = `metric ${band.metric}; newest value ${band.last} on task #${band.lastTask}; episode began at task #${band.episode}; baseline mean ${band.mean}, deviation ${band.sd} over ${band.baseline} tasks; ${band.reason}`;
|
|
2624
|
+
const diagnose = tier === "diagnose";
|
|
2625
|
+
const title = diagnose ? `Diagnose SDLC metric ${band.metric}: read-only triage` : `Review SDLC metric ${band.metric}: control band breach`;
|
|
2626
|
+
const detail = `Band breach key: ${full}\n${evidence}\n${diagnose
|
|
2627
|
+
? "Read-only triage for the planner: inspect the recent task histories behind this metric and report what changed. Do not edit files, branches or the board."
|
|
2628
|
+
: "Backlog card for the planner: decide whether this breach needs a process change. It names the metric and its evidence; it opens no GitHub issue."}`;
|
|
2629
|
+
const draft = { title, detail, refs: {} };
|
|
2630
|
+
const signals = this.signalsFor(draft);
|
|
2631
|
+
if (this.isPii({ signals }) || !this.candidateTextAllowed(`${title}\n${detail}`)) continue;
|
|
2632
|
+
const proposal = this.d.board.propose(HUB, { ...draft, class: diagnose ? "triage" : "plan", backlog: true, signals });
|
|
2633
|
+
this.incomingBacklog.add(proposal.id); seen.add(full); open.add(band.metric); created.push(proposal.id);
|
|
2634
|
+
}
|
|
2635
|
+
for (const id of created) this.releaseNotice(`task #${id}: SDLC control band breach proposed in the backlog`);
|
|
2636
|
+
return created;
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2342
2639
|
/** S10 read-only planner report; S7 owns rolling bands and automatic board proposals. */
|
|
2343
2640
|
async costReport(by: PeerId, repository?: unknown, scopePrs?: readonly number[]): Promise<Record<string, unknown>> {
|
|
2344
2641
|
const authority = () => {
|
|
@@ -2432,8 +2729,9 @@ export class Tasks {
|
|
|
2432
2729
|
const current = this.need(task.id, false, true, true);
|
|
2433
2730
|
if (current.history.length !== flow || (current.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
|
|
2434
2731
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2435
|
-
|
|
2732
|
+
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"),
|
|
2436
2733
|
planReview: { planner: by, planningRevision: revision, recordedAt: Date.now(), ...(Object.keys(scope).length ? { scope } : {}), ...(issue ? { issue } : {}) } } }, raw.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
|
|
2734
|
+
if (config.mode !== "off") updated = this.linkIssue(updated); // #694
|
|
2437
2735
|
if (updated.state === "proposed" && !updated.owner) {
|
|
2438
2736
|
this.offered.delete(updated.id);
|
|
2439
2737
|
try { await this.offerReady(updated, "planner review recorded"); }
|
|
@@ -2448,7 +2746,11 @@ export class Tasks {
|
|
|
2448
2746
|
if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
|
|
2449
2747
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2450
2748
|
const next = sdlcReceipt(input, task.sdlc, config, VERSION);
|
|
2749
|
+
// The spec identity is person-owned in enforce. No observation cache or restart can reopen a relink bypass.
|
|
2750
|
+
const specRelink = (task.sdlc?.repo !== undefined && next.repo !== task.sdlc.repo) || (task.sdlc?.issue !== undefined && next.issue !== task.sdlc.issue);
|
|
2751
|
+
if (config.mode === "enforce" && by !== USER && specRelink) throw new PreEffectToolRefusal("only the person may relink an established repository or issue in enforce");
|
|
2451
2752
|
const definition = stageDefinition(config, next.stage);
|
|
2753
|
+
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");
|
|
2452
2754
|
// #481 S1: entering a build stage in enforce needs the person's spec acceptance; the person may move any stage.
|
|
2453
2755
|
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"));
|
|
2454
2756
|
if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
|
|
@@ -2476,7 +2778,7 @@ export class Tasks {
|
|
|
2476
2778
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2477
2779
|
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 })));
|
|
2478
2780
|
const links = this.d.board.list().map(t => ({ id: t.id, after: t.deps ?? [], private: this.isPii(t), receipt: t.sdlc, phase: this.isPii(t) ? undefined : this.workflowView(t), findings: this.isPii(t) ? undefined : this.reviewFindingSets(t), plan: config.mode === "off" || this.isPii(t) ? undefined : { paths: this.planPaths(t), revision: t.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(t.plan ?? {}), accepted: this.validPlanDepartures(t) } }));
|
|
2479
|
-
const report = await readMergeQueue(repo, config, links, this.d.sdlc?.gh);
|
|
2781
|
+
const report = await readMergeQueue(repo, config, links, this.d.sdlc?.gh, this.publicMetadataText);
|
|
2480
2782
|
// Policy can change across remote awaits without any board write. Never return its stale public projection.
|
|
2481
2783
|
const currentPrivacy = this.d.board.list().map(task => ({ id: task.id, private: this.isPii(task) }));
|
|
2482
2784
|
if (workflowHash(links.map(({ id, private: hidden }) => ({ id, private: hidden }))) !== workflowHash(currentPrivacy)) {
|
|
@@ -2492,6 +2794,13 @@ export class Tasks {
|
|
|
2492
2794
|
this.capturePlanHead(task, row.head);
|
|
2493
2795
|
}
|
|
2494
2796
|
}
|
|
2797
|
+
for (const row of report.rows) if (row.testIntegrity) {
|
|
2798
|
+
row.testIntegrity = row.testIntegrity.map(item => ({ ...publicTestIntegrity(item, this.publicMetadataText), ...(item.task === undefined ? {} : { task: item.task }) }));
|
|
2799
|
+
if (config.mode === "enforce") {
|
|
2800
|
+
row.blockers = [...new Set([...row.blockers, ...row.testIntegrity.flatMap(testIntegrityBlockers)])];
|
|
2801
|
+
row.ready = row.ready && row.blockers.length === 0;
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2495
2804
|
return report;
|
|
2496
2805
|
}
|
|
2497
2806
|
async releaseReadiness(repo: string, liveIssues: number[]) {
|
|
@@ -2621,16 +2930,18 @@ export class Tasks {
|
|
|
2621
2930
|
async lifecycleStatus(id: unknown, refresh = true): Promise<SdlcStatus> {
|
|
2622
2931
|
const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined), identity = this.planIdentity(task);
|
|
2623
2932
|
const valid = () => { const now = this.need(id, false, true); if (this.planIdentity(now) !== identity || now.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed during lifecycle readback"); return now; };
|
|
2624
|
-
const
|
|
2625
|
-
const
|
|
2933
|
+
const deadline = performance.now() + 20_000;
|
|
2934
|
+
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: "" }); };
|
|
2935
|
+
const evidence = task.sdlc && !this.isPii(task) && refresh && config.mode !== "off" ? await readSdlcPr(task.sdlc, read, true) : undefined;
|
|
2626
2936
|
valid();
|
|
2627
2937
|
const accepted = task.sdlc?.acceptance;
|
|
2628
2938
|
const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue, read) : undefined;
|
|
2939
|
+
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;
|
|
2629
2940
|
const current = valid();
|
|
2630
2941
|
if (this.planIdentity(current) !== this.planIdentity(task) || current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed during lifecycle readback");
|
|
2631
2942
|
this.capturePlanRead(current, evidence);
|
|
2632
2943
|
return sdlcStatus(config, current.sdlc, evidence, this.workflowView(current), edit, this.isPii(current) ? [] : this.reviewFindingSets(current), current.refs.commit,
|
|
2633
|
-
config.mode !== "off" ? this.planReport(this.d.board.get(current.id)!, evidence) : undefined);
|
|
2944
|
+
config.mode !== "off" ? this.planReport(this.d.board.get(current.id)!, evidence) : undefined, integrity);
|
|
2634
2945
|
}
|
|
2635
2946
|
|
|
2636
2947
|
/** #481 S1: only build work starts from an accepted spec. */
|
|
@@ -2696,26 +3007,33 @@ export class Tasks {
|
|
|
2696
3007
|
this.d.notify(this.specText(task, state, "advice"));
|
|
2697
3008
|
}
|
|
2698
3009
|
|
|
3010
|
+
private unknownTestIntegrity(privateTask = false): TestIntegrity {
|
|
3011
|
+
return { applicability: "unknown", redFirst: "unknown", fileState: "unknown", modifiedTests: [], blockers: [privateTask ? "test integrity: private evidence unavailable" : "test integrity: current evidence unknown"] };
|
|
3012
|
+
}
|
|
3013
|
+
|
|
2699
3014
|
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[]): Promise<PrEvidence | undefined> {
|
|
2700
3015
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2701
3016
|
if (config.mode === "off") return;
|
|
2702
3017
|
const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
|
|
3018
|
+
const deadline = performance.now() + 20_000;
|
|
2703
3019
|
const read: SdlcGh = (args, timeout, cwd, cap) => {
|
|
2704
3020
|
const current = this.need(task.id, false, true);
|
|
2705
3021
|
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: "" });
|
|
2706
|
-
|
|
3022
|
+
const remaining = deadline - performance.now();
|
|
3023
|
+
return remaining > 0 ? (this.d.sdlc?.gh ?? sdlcGh)(args, Math.min(timeout ?? 10_000, remaining), cwd, cap) : Promise.resolve({ code: 1, stdout: "" });
|
|
2707
3024
|
};
|
|
2708
|
-
const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read) : undefined;
|
|
3025
|
+
const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read, true) : undefined;
|
|
3026
|
+
const integrity = publicTestIntegrity(receipt && evidence && !this.isPii(task) ? await readTestIntegrity(receipt, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText);
|
|
2709
3027
|
const current = this.need(task.id, false, true);
|
|
2710
3028
|
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`);
|
|
2711
3029
|
// Advise may continue on the current task, but evidence from its previous link cannot admit that task's review.
|
|
2712
3030
|
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;
|
|
2713
3031
|
this.capturePlanRead(current, evidence);
|
|
2714
3032
|
const planReport = this.planReport(current, evidence);
|
|
2715
|
-
const status = sdlcStatus(config, receipt, evidence, this.workflowView(current), undefined, this.isPii(current) ? [] : findings ?? this.reviewFindingSets(current), current.refs.commit, planReport);
|
|
2716
|
-
const blockers = transition === "review" ? [...new Set([...status.blockers.filter(blocker => ["lifecycle stage definition unknown", "issue identity missing", "PR identity missing", "current PR evidence unknown", "PR is not open", "local gate on current head missing or failed"].includes(blocker)), ...(status.entry ?? []), ...(status.exit ?? [])])] : status.blockers;
|
|
3033
|
+
const status = sdlcStatus(config, receipt, evidence, this.workflowView(current), undefined, this.isPii(current) ? [] : findings ?? this.reviewFindingSets(current), current.refs.commit, planReport, integrity);
|
|
3034
|
+
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;
|
|
2717
3035
|
if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
|
|
2718
|
-
const advice = [...new Set([...blockers, ...planConformanceBlockers(planReport)])];
|
|
3036
|
+
const advice = [...new Set([...blockers, ...planConformanceBlockers(planReport), ...testIntegrityBlockers(integrity)])];
|
|
2719
3037
|
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("; ")}`);
|
|
2720
3038
|
return evidence;
|
|
2721
3039
|
}
|
|
@@ -2829,7 +3147,7 @@ export class Tasks {
|
|
|
2829
3147
|
this.warmRepository(draft);
|
|
2830
3148
|
const repositoryChanged = this.repositories.scope(task.refs).key !== this.repositories.scope(draft.refs).key;
|
|
2831
3149
|
const before = new Set(this.overlapHits(task).map((h) => h.task.id));
|
|
2832
|
-
const next = this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}), ...(metadata ? { refs: metadata } : {}) });
|
|
3150
|
+
const next = this.linkOnWrite(this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}), ...(metadata ? { refs: metadata } : {}) })); // #723: refs.repo may have just arrived
|
|
2833
3151
|
this.captureOwnerAcceptance(next);
|
|
2834
3152
|
this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
|
|
2835
3153
|
if (repositoryChanged) this.cohorts.leave(next.id);
|
|
@@ -2946,10 +3264,14 @@ export class Tasks {
|
|
|
2946
3264
|
await this.planEntryGate(by, admission);
|
|
2947
3265
|
task = valid();
|
|
2948
3266
|
}
|
|
3267
|
+
// #694: stage the identities the hub can verify; the gates read them, and only the admitted done persists them.
|
|
3268
|
+
const link = this.d.sdlc && this.d.sdlc.config().mode !== "off" ? await this.identityLink(task, refs) : undefined;
|
|
3269
|
+
const current = () => this.withLink(valid(), link, refs);
|
|
3270
|
+
task = current();
|
|
2949
3271
|
let reviewEvidence: PrEvidence | undefined;
|
|
2950
|
-
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task =
|
|
2951
|
-
const reviewAdmission = task.reviewer ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence) : undefined;
|
|
2952
|
-
task =
|
|
3272
|
+
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task = current(); }
|
|
3273
|
+
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;
|
|
3274
|
+
task = current();
|
|
2953
3275
|
// A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
|
|
2954
3276
|
const cohort = this.cohorts.of(task.id);
|
|
2955
3277
|
const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
|
|
@@ -2969,8 +3291,8 @@ export class Tasks {
|
|
|
2969
3291
|
if (r.action === "request") return this.d.board.update(task.id, HUB, "integration requested", {}, this.integrationRequest(task, r));
|
|
2970
3292
|
if (r.action === "unresolved") {
|
|
2971
3293
|
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`);
|
|
2972
|
-
task = this.d.board.update(task.id, HUB, "integration unresolved", {}, r.why);
|
|
2973
|
-
} else if (r.integrated) task = this.d.board.update(task.id, HUB, "integrated", {}, `cohort #${cohort!.id}, revision ${cohort!.revision}`);
|
|
3294
|
+
task = this.withLink(this.d.board.update(task.id, HUB, "integration unresolved", {}, r.why), link, refs);
|
|
3295
|
+
} else if (r.integrated) task = this.withLink(this.d.board.update(task.id, HUB, "integrated", {}, `cohort #${cohort!.id}, revision ${cohort!.revision}`), link, refs);
|
|
2974
3296
|
} else if (cohort?.held.size && !this.isPii(task)) {
|
|
2975
3297
|
// No integration step for this member (silence lifted, a PII task open): the notices the silence held stand in
|
|
2976
3298
|
// for it (issue #107, AC4).
|
|
@@ -2978,14 +3300,14 @@ export class Tasks {
|
|
|
2978
3300
|
if (evidence && by === USER) this.d.notify(`task ${this.publicTitle(task)} done by the console; overlapping work finished meanwhile:\n${evidence}`);
|
|
2979
3301
|
else if (evidence) this.doneNotes.set(task.id, `Overlapping work finished while you worked (no turn-free integration step ran):\n${evidence}`);
|
|
2980
3302
|
}
|
|
2981
|
-
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
|
|
3303
|
+
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
|
|
2982
3304
|
const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
|
|
2983
|
-
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission);
|
|
3305
|
+
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link);
|
|
2984
3306
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
2985
3307
|
this.checking.set(task.id, task.owner);
|
|
2986
3308
|
const checkedRefs = cleanRefs(refs);
|
|
2987
3309
|
this.warmRepository({ ...task, refs: { ...task.refs, ...checkedRefs } });
|
|
2988
|
-
const pending = this.d.board.update(task.id, by, "done (checking)", { refs: checkedRefs }, summary, { withheld: withheld || undefined });
|
|
3310
|
+
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);
|
|
2989
3311
|
this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
|
|
2990
3312
|
const seen = { events: pending.history.length, owner: pending.owner };
|
|
2991
3313
|
this.pendingChecks++;
|
|
@@ -3033,7 +3355,7 @@ export class Tasks {
|
|
|
3033
3355
|
const theirs = this.places(u);
|
|
3034
3356
|
if (mine.repositoryKey !== theirs.repositoryKey) return [];
|
|
3035
3357
|
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)));
|
|
3036
|
-
const symbols = mine.symbols.filter((x) => theirs.symbols.
|
|
3358
|
+
const symbols = mine.symbols.filter((x) => theirs.symbols.some((y) => symbolsOverlap(x, y)));
|
|
3037
3359
|
return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note, at.withheld)] : [];
|
|
3038
3360
|
});
|
|
3039
3361
|
if (!open.length && !done.length) continue;
|
|
@@ -3116,19 +3438,19 @@ export class Tasks {
|
|
|
3116
3438
|
}
|
|
3117
3439
|
|
|
3118
3440
|
/** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
|
|
3119
|
-
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness): Promise<Task> {
|
|
3441
|
+
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink): Promise<Task> {
|
|
3120
3442
|
let selection = this.poolReview(task, refs);
|
|
3121
3443
|
let reviewer = selection?.reviewer ?? task.reviewer, reviewEvidence: PrEvidence | undefined;
|
|
3122
3444
|
if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
|
|
3123
3445
|
reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
|
|
3124
|
-
task = this.completionCurrent(task.id, by, false);
|
|
3446
|
+
task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
|
|
3125
3447
|
selection = this.poolReview(task, refs);
|
|
3126
3448
|
reviewer = selection?.reviewer ?? task.reviewer;
|
|
3127
3449
|
}
|
|
3128
|
-
// A delayed local check needs a new readback;
|
|
3129
|
-
if (
|
|
3130
|
-
admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence);
|
|
3131
|
-
task = this.completionCurrent(task.id, by, false);
|
|
3450
|
+
// A delayed local check needs a new readback; a completion-selected reviewer also needs optional facts in off mode.
|
|
3451
|
+
if (!admission && ((reviewer && (this.d.sdlc?.config().mode ?? "off") !== "off") || this.d.prepareReviewBrief)) {
|
|
3452
|
+
admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence, undefined, staged => this.withLink(staged, link, refs), !reviewer);
|
|
3453
|
+
task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
|
|
3132
3454
|
}
|
|
3133
3455
|
selection = this.poolReview(task, refs);
|
|
3134
3456
|
reviewer = selection?.reviewer ?? task.reviewer;
|
|
@@ -3138,7 +3460,7 @@ export class Tasks {
|
|
|
3138
3460
|
const completedRefs = cleanRefs(refs);
|
|
3139
3461
|
this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
3140
3462
|
const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
3141
|
-
const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}) });
|
|
3463
|
+
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);
|
|
3142
3464
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
3143
3465
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
|
|
3144
3466
|
this.tellCompleted(next, summary, withheld);
|
|
@@ -3148,7 +3470,7 @@ export class Tasks {
|
|
|
3148
3470
|
this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
|
|
3149
3471
|
await this.releaseDependents(next);
|
|
3150
3472
|
}
|
|
3151
|
-
else if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}: review it
|
|
3473
|
+
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]`);
|
|
3152
3474
|
else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, "", admission));
|
|
3153
3475
|
return next;
|
|
3154
3476
|
}
|
|
@@ -3162,6 +3484,9 @@ export class Tasks {
|
|
|
3162
3484
|
const hits = this.overlapHits(task).filter((h) => h.task.owner !== USER && h.task.owner !== HUB);
|
|
3163
3485
|
const cohort = this.cohorts.of(task.id);
|
|
3164
3486
|
const silent = this.silentFor(task.id);
|
|
3487
|
+
// #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
|
|
3488
|
+
// 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).
|
|
3489
|
+
const byOwner = new Map<PeerId, typeof hits>();
|
|
3165
3490
|
for (const hit of hits) {
|
|
3166
3491
|
// A member of the same silent cohort (issue #107): the member that finishes last integrates instead
|
|
3167
3492
|
// (Cohorts.completion). The notice is held, so something replaces it if no integration runs. Others hear it.
|
|
@@ -3169,9 +3494,23 @@ export class Tasks {
|
|
|
3169
3494
|
cohort.held.add(task.id);
|
|
3170
3495
|
continue;
|
|
3171
3496
|
}
|
|
3172
|
-
|
|
3173
|
-
|
|
3497
|
+
// 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.
|
|
3498
|
+
if (!["in_progress", "changes_requested", "in_review"].includes(hit.task.state) || !this.ownerAcceptance(hit.task)) continue;
|
|
3499
|
+
byOwner.set(hit.task.owner!, [...(byOwner.get(hit.task.owner!) ?? []), hit]);
|
|
3174
3500
|
}
|
|
3501
|
+
for (const [owner, mine] of byOwner) {
|
|
3502
|
+
this.releaseNotice(`task ${this.publicTitle(task)}: could not publish the completed-change notice to ${owner}`, () =>
|
|
3503
|
+
this.whileOpenFor(owner, task.id, mine.map((h) => h.task.id), this.completedNotice(task, mine, summary, withheld)));
|
|
3504
|
+
}
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3507
|
+
/** Share the same screened observation rule between completion notices and read-only console detail. */
|
|
3508
|
+
private sameFileDifferentSymbols(task: Task, hit: { task: Task; paths: string[] }): boolean {
|
|
3509
|
+
const symbolRepository = task.codeSymbols && !this.isPii(task) ? this.codeSymbolsRepository(task) : undefined;
|
|
3510
|
+
const otherRepository = hit.task.codeSymbols && !this.isPii(hit.task) ? this.codeSymbolsRepository(hit.task) : undefined;
|
|
3511
|
+
return !!symbolRepository && symbolRepository.key === otherRepository?.key && !this.isPii(task) && !this.isPii(hit.task) && taskCodeSymbolsDisjoint(
|
|
3512
|
+
task.codeSymbols, hit.task.codeSymbols, this.reviewHead(task), this.reviewHead(hit.task), symbolRepository.key,
|
|
3513
|
+
hit.paths, false, this.publicMetadataText);
|
|
3175
3514
|
}
|
|
3176
3515
|
|
|
3177
3516
|
/**
|
|
@@ -3179,13 +3518,18 @@ export class Tasks {
|
|
|
3179
3518
|
* included), and any item that matches a PII pattern is left out of what other owners get, the summary also when the
|
|
3180
3519
|
* PII screen did not clear it (`withheld`, #198).
|
|
3181
3520
|
*/
|
|
3182
|
-
private completedNotice(task: Task,
|
|
3521
|
+
private completedNotice(task: Task, hitOrHits: { task: Task; paths: string[]; symbols: string[] } | { task: Task; paths: string[]; symbols: string[] }[], summary?: string, withheld = false): string {
|
|
3522
|
+
const hits = Array.isArray(hitOrHits) ? hitOrHits : [hitOrHits];
|
|
3183
3523
|
const paths = this.places(task).paths.filter(this.nameable);
|
|
3184
3524
|
const signatures = (task.plan?.signatures ?? []).filter(this.nameable);
|
|
3185
3525
|
const first = (summary ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
|
|
3186
3526
|
const line = first && !withheld && this.nameable(first) ? first : undefined;
|
|
3527
|
+
// One line per overlapping task of the recipient, so a single notice names all of them (#713).
|
|
3528
|
+
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)" : ""}`);
|
|
3529
|
+
const open = hits.length > 1 ? `your open tasks ${touches.join("; ")}` : `your open ${touches[0]}`;
|
|
3530
|
+
|
|
3187
3531
|
return [
|
|
3188
|
-
`Task #${task.id} (owner ${task.owner}) is done and touches
|
|
3532
|
+
`Task #${task.id} (owner ${task.owner}) is done and touches ${open}. Check your work against it before you go on.`,
|
|
3189
3533
|
paths.length ? `Changed files: ${paths.join(", ")}` : "",
|
|
3190
3534
|
signatures.length ? `New or changed signatures: ${signatures.join("; ")}` : "",
|
|
3191
3535
|
line ? `Summary: ${line}` : "",
|
|
@@ -3226,16 +3570,17 @@ export class Tasks {
|
|
|
3226
3570
|
|
|
3227
3571
|
/** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
|
|
3228
3572
|
/** Review admission is separate from brief construction (#228). All refusals precede the requesting done's writes. */
|
|
3229
|
-
private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean): Promise<ReviewReadiness | undefined> {
|
|
3573
|
+
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> {
|
|
3230
3574
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
3231
|
-
if (mode === "off") return undefined;
|
|
3575
|
+
if (mode === "off" && !this.d.prepareReviewBrief) return undefined;
|
|
3232
3576
|
let quietDrift = false;
|
|
3233
3577
|
const guard = () => {
|
|
3234
3578
|
if (actor !== USER && actor !== task.owner) {
|
|
3235
3579
|
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
|
|
3236
3580
|
if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
|
|
3237
3581
|
}
|
|
3238
|
-
|
|
3582
|
+
// #694: the board copy seen through the same staged identity link the caller's task carries.
|
|
3583
|
+
const current = stage(this.need(task.id, false, preEffect));
|
|
3239
3584
|
const changed = current.history.length !== task.history.length;
|
|
3240
3585
|
const quiet = current.history.length > task.history.length && current.history.slice(task.history.length).every(entry => QUIET_EVENTS.has(entry.event));
|
|
3241
3586
|
const sameHistory = !changed || (mode === "advise" && quiet) || !!sameRequest?.();
|
|
@@ -3247,6 +3592,12 @@ export class Tasks {
|
|
|
3247
3592
|
return current;
|
|
3248
3593
|
};
|
|
3249
3594
|
guard();
|
|
3595
|
+
if (!this.isPii(task) && this.d.prepareReviewBrief) {
|
|
3596
|
+
try { await this.d.prepareReviewBrief({ ...task, refs: { ...task.refs, ...cleanRefs(refs) } }); }
|
|
3597
|
+
catch { /* Optional code facts must not refuse a review. */ }
|
|
3598
|
+
guard();
|
|
3599
|
+
}
|
|
3600
|
+
if (mode === "off" || prepareOnly) return undefined;
|
|
3250
3601
|
const expected = cleanRefs(refs).commit ?? task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead;
|
|
3251
3602
|
const read: SdlcGh = (args, timeout, cwd, cap) => {
|
|
3252
3603
|
guard();
|
|
@@ -3283,12 +3634,26 @@ export class Tasks {
|
|
|
3283
3634
|
const cachedPlanRead = this.planReads.get(task.id);
|
|
3284
3635
|
const planRead = this.planReport(task, cachedPlanRead?.identity === this.planReadIdentity(task) && cachedPlanRead.evidence.head === this.findingHead(task) ? cachedPlanRead.evidence : undefined);
|
|
3285
3636
|
const conformanceText = mode !== "off" ? this.screen(task, `Plan conformance: ${JSON.stringify(planRead)}`, "plan conformance", reviewer) + "\n" : "";
|
|
3637
|
+
const profile = (task.profile ?? "dev") === (this.d.profile ?? "dev") ? profileData(this.d.profileData ?? task.profile ?? "dev") : profileData(task.profile);
|
|
3638
|
+
const target = plan ? `the plan (${plan})` : task.detail ? "the task detail above" : "the task title";
|
|
3639
|
+
const noCheckWording = profile.name === "research" || profile.name === "writing"
|
|
3640
|
+
? "No check ran for this class: verify the claims and sources yourself."
|
|
3641
|
+
: profile.name === "general"
|
|
3642
|
+
? "No check ran for this class: verify the outcome yourself."
|
|
3643
|
+
: "No check ran for this class: run the affected tests yourself.";
|
|
3644
|
+
const checkResult = check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : noCheckWording;
|
|
3645
|
+
const checklistTemplates = profile.review_checklist ?? [
|
|
3646
|
+
"Map the changed signatures and call sites to {target}, and name each one that does not match.",
|
|
3647
|
+
"{check}",
|
|
3648
|
+
"List what is unmet in hub_review's unmet, one item each.",
|
|
3649
|
+
];
|
|
3650
|
+
const checklistLines = checklistTemplates.map(template =>
|
|
3651
|
+
template.replaceAll("{target}", target).replaceAll("{check}", checkResult)
|
|
3652
|
+
);
|
|
3286
3653
|
const checklist = [
|
|
3287
3654
|
"Checklist:",
|
|
3288
3655
|
...this.planGuardItems(task).map(item => `- Check the plan against this live fail before the remaining checks: ${item.line}`),
|
|
3289
|
-
|
|
3290
|
-
`- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
|
|
3291
|
-
`- List what is unmet in hub_review's unmet, one item each.`,
|
|
3656
|
+
...checklistLines.map(line => `- ${line}`),
|
|
3292
3657
|
].join("\n");
|
|
3293
3658
|
// #223 R2: a reviewer-only instance judges from facts; the builder's narrative stays out, earlier findings go in.
|
|
3294
3659
|
const reviewOnly = this.d.reviewOnly?.(reviewer) === true;
|
|
@@ -3345,6 +3710,29 @@ export class Tasks {
|
|
|
3345
3710
|
return next;
|
|
3346
3711
|
}
|
|
3347
3712
|
|
|
3713
|
+
private codeSymbolsRepository(task: Task) {
|
|
3714
|
+
try {
|
|
3715
|
+
const repository = this.repositories.scope(task.refs);
|
|
3716
|
+
if (task.sdlc?.repo && this.repositories.scope({ repo: task.sdlc.repo }).key !== repository.key) return undefined;
|
|
3717
|
+
return repository;
|
|
3718
|
+
} catch { return undefined; }
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
/** Host observations are saved only with an admitted transition, never by an optional read. */
|
|
3722
|
+
private observedCodeSymbols(task: Task) {
|
|
3723
|
+
if (this.isPii(task) || !task.refs.commit) return undefined;
|
|
3724
|
+
const repository = this.codeSymbolsRepository(task);
|
|
3725
|
+
// ponytail: the brief analyzes the validated project execution root. Foreign repositories stay unknown
|
|
3726
|
+
// until the brief carries independent repository provenance; a model's refs.repo cannot supply that proof.
|
|
3727
|
+
if (!repository || repository.key !== this.repositories.project.key) return undefined;
|
|
3728
|
+
try {
|
|
3729
|
+
const brief = this.d.reviewBrief?.(task);
|
|
3730
|
+
const observation = brief && !("unavailable" in brief) ? buildTaskCodeSymbols(brief, repository.key, this.publicMetadataText) : undefined;
|
|
3731
|
+
// A resolved short ref or a stale provider result is unknown advice, never a reason to refuse done.
|
|
3732
|
+
return observation?.head === task.refs.commit ? observation : undefined;
|
|
3733
|
+
} catch { return undefined; } // Optional metadata never costs the admitted result.
|
|
3734
|
+
}
|
|
3735
|
+
|
|
3348
3736
|
/** #520: the rendered review brief of a non-PII task's commit, or why there is none; empty without a commit. */
|
|
3349
3737
|
briefText(task: Task): string {
|
|
3350
3738
|
if (this.isPii(task) || !task.refs.commit) return "";
|