@staix/agent-hub 0.12.45 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +83 -116
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +10 -7
- package/docs/agent-notes/budget.md +6 -1
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +6 -1
- package/docs/agent-notes/local-worker.md +2 -1
- package/docs/agent-notes/tasks.md +10 -0
- package/docs/agent-notes/tests.md +4 -2
- package/docs/dogfood.md +92 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +250 -19
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +8 -2
- package/docs/sdlc.md +36 -6
- package/docs/security.md +20 -11
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +759 -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 +47 -0
- package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
- package/docs/verification/2026-10-23-0.13.0.md +89 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +14 -14
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +613 -329
- 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 +12 -5
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/definitions/builtin-metadata.ts +2 -1
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
- package/src/adapters/definitions/native-cli-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/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 +16 -6
- package/src/cli/identity.ts +29 -6
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +122 -11
- package/src/cli/output.ts +44 -1
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +43 -8
- 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 +237 -22
- 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 +24 -2
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instances.ts +73 -1
- 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-queue.ts +31 -9
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc.ts +49 -11
- package/src/hub/settings.ts +5 -2
- package/src/hub/snapshots.ts +93 -8
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +292 -54
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/usage-rollup.ts +361 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +1 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/dev.json +6 -1
- package/templates/profiles/general.json +6 -1
- package/templates/profiles/research.json +6 -1
- package/templates/profiles/writing.json +6 -1
- package/templates/release-guard.block.md +6 -0
package/src/hub/tasks.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
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";
|
|
@@ -37,7 +40,7 @@ import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief
|
|
|
37
40
|
import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
|
|
38
41
|
import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
39
42
|
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";
|
|
43
|
+
import { linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
|
|
41
44
|
|
|
42
45
|
export type TaskAssignmentRole = "owner" | "reviewer";
|
|
43
46
|
|
|
@@ -72,6 +75,7 @@ export interface TasksDeps {
|
|
|
72
75
|
/** Explicit acceptance/self-claim only; implicit done has no pre-work baseline. */
|
|
73
76
|
ownerAccepted?: (task: Task) => void;
|
|
74
77
|
/** #520: the review brief for a task's commit (git only, bounded); undefined when the task names no commit. */
|
|
78
|
+
prepareReviewBrief?: (task: Task) => Promise<void>;
|
|
75
79
|
reviewBrief?: (task: Task) => ReviewBrief | { unavailable: string } | undefined;
|
|
76
80
|
sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
|
|
77
81
|
briefs?: Briefs;
|
|
@@ -176,6 +180,7 @@ const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
|
|
|
176
180
|
const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
177
181
|
/** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
|
|
178
182
|
const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
|
|
183
|
+
QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits for its reserved owner's WIP slot.
|
|
179
184
|
QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
|
|
180
185
|
|
|
181
186
|
/**
|
|
@@ -277,6 +282,9 @@ const RESCREEN_MAX = 10;
|
|
|
277
282
|
* The task flow. Adapters and tools never touch the board: every change comes through here, where assignment,
|
|
278
283
|
* PII handling, envelopes, briefs and memory notes are decided in one place.
|
|
279
284
|
*/
|
|
285
|
+
/** #694: a PR staged at done with the commit or branch it was read for. */
|
|
286
|
+
interface IdentityLink { repo: string; pr: number; basis: { commit?: string; branch?: string } }
|
|
287
|
+
|
|
280
288
|
export class Tasks {
|
|
281
289
|
private readonly repositories: TaskRepositories;
|
|
282
290
|
private readonly incomingBacklog = new Set<number>();
|
|
@@ -401,7 +409,7 @@ export class Tasks {
|
|
|
401
409
|
} else {
|
|
402
410
|
const admission = this.plannerPullRecord(task)?.pull ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.weights(task.class, task) });
|
|
403
411
|
this.releaseNotice(`${text} Owner reassignment suggestion: ${admission.owner ?? "no eligible peer"}.`);
|
|
404
|
-
if (admission.owner && config.auto_reassign && this.sweepAvailable(admission.owner)) {
|
|
412
|
+
if (admission.owner && config.auto_reassign && this.sweepAvailable(admission.owner) && !this.reservedHold(task)) {
|
|
405
413
|
try { await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [admission.owner], event: "reassigned", reason: "idle", note: "ready-unassigned idle sweep" }); }
|
|
406
414
|
catch (e) { if (!(e instanceof AssignmentUndeliveredError)) throw e; }
|
|
407
415
|
}
|
|
@@ -582,9 +590,12 @@ export class Tasks {
|
|
|
582
590
|
/** A task as a cloud peer may see it. */
|
|
583
591
|
|
|
584
592
|
publicView(task: Task, fullHistory = false): Record<string, unknown> {
|
|
585
|
-
const { title, detail, history, external, cancelled, ...taskRest } = task;
|
|
593
|
+
const { title, detail, history, external, cancelled, codeSymbols, ...taskRest } = task;
|
|
586
594
|
const rest = { ...taskRest, ...(history.some(h => h.by === USER && h.planDeparture) ? { planDepartureAccepted: true } : {}) };
|
|
587
595
|
if (this.isPii(task)) return { ...rest, actualDates: actualTaskDates(task), refs: {}, plan: {}, ...(task.sdlc ? { sdlc: { stage: stageDefinition(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc.stage)?.base ?? "unknown" } } : {}), title: "[pii]", detail: "[pii]" };
|
|
596
|
+
if (rest.sdlc?.redFirst && !this.publicMetadataText(JSON.stringify(rest.sdlc.redFirst))) {
|
|
597
|
+
const { redFirst: _withheld, ...receipt } = rest.sdlc; rest.sdlc = receipt;
|
|
598
|
+
}
|
|
588
599
|
const screenHistoryText = (text: string, field: string, withheld = false): string => !withheld && this.nameable(text) ? text : `[history ${field} withheld: ${this.whyWithheld(text)}; ahub task show ${task.id}]`;
|
|
589
600
|
const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
|
|
590
601
|
at: h.at, by: h.by, event: h.event,
|
|
@@ -603,7 +614,9 @@ export class Tasks {
|
|
|
603
614
|
...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
|
|
604
615
|
}));
|
|
605
616
|
const sets = this.reviewFindingSets(task), current = findingsForHead(sets, this.findingHead(task));
|
|
606
|
-
|
|
617
|
+
const symbolRepository = codeSymbols && this.codeSymbolsRepository(task);
|
|
618
|
+
const publicSymbols = codeSymbols && symbolRepository ? publicTaskCodeSymbols(codeSymbols, this.reviewHead(task), symbolRepository.key, this.publicMetadataText) : undefined;
|
|
619
|
+
return { ...rest, ...(publicSymbols ? { codeSymbols: publicSymbols } : {}), ...(current.length ? { findings: findingTally(current) } : {}), ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task), undefined, sets, task.refs.commit) } : {}), title, detail, history: publicHistory };
|
|
607
620
|
}
|
|
608
621
|
|
|
609
622
|
/** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
|
|
@@ -819,7 +832,7 @@ export class Tasks {
|
|
|
819
832
|
// were stored as written, so both sides are compared in one spelling.
|
|
820
833
|
if (mine.repositoryKey !== theirs.repositoryKey) continue;
|
|
821
834
|
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.
|
|
835
|
+
const same = mine.paths.some(file) || mine.symbols.some((x) => theirs.symbols.some((y) => symbolsOverlap(x, y)));
|
|
823
836
|
if (!same || this.d.board.reviews({ task: t.id }).some((r) => r.kind === "contradicted")) continue;
|
|
824
837
|
this.d.board.recordReview({ implementer: t.owner, reviewer: approval.by, class: t.class, kind: "contradicted", task: t.id });
|
|
825
838
|
}
|
|
@@ -1479,6 +1492,7 @@ export class Tasks {
|
|
|
1479
1492
|
this.withdrawRoleRequest(confirmed, returned, "reviewer");
|
|
1480
1493
|
this.incomingBacklog.add(confirmed.id);
|
|
1481
1494
|
this.planningRecorded({ by, task: confirmed.id, fields: ["return"], revision: confirmed.planningRevision ?? 0 });
|
|
1495
|
+
await this.releaseHeld(confirmed.owner); // its owner's slot is free (#655)
|
|
1482
1496
|
return returned;
|
|
1483
1497
|
}
|
|
1484
1498
|
|
|
@@ -1606,6 +1620,18 @@ export class Tasks {
|
|
|
1606
1620
|
/** The tasks an overlap text for `task` names: their plans are in it (issue #108). */
|
|
1607
1621
|
overlapTasks = (task: Task): number[] => this.overlapHits(task).map((h) => h.task.id);
|
|
1608
1622
|
|
|
1623
|
+
/** Console detail advice only: no pair persistence, and no private task on either side. */
|
|
1624
|
+
symbolOverlaps(task: Task): { task: number; files: string[]; sameFileDifferentSymbols: boolean }[] {
|
|
1625
|
+
// A routing-locality opt-out does not make symbol metadata for a marked task public.
|
|
1626
|
+
const markedPrivate = (value: Task) => value.signals.includes("pii") || value.signals.includes("pii_path");
|
|
1627
|
+
if (markedPrivate(task)) return [];
|
|
1628
|
+
return this.overlapHits(task).filter(hit => !markedPrivate(hit.task)).map(hit => ({
|
|
1629
|
+
task: hit.task.id,
|
|
1630
|
+
files: hit.paths.filter(this.publicMetadataText),
|
|
1631
|
+
sameFileDifferentSymbols: this.sameFileDifferentSymbols(task, hit),
|
|
1632
|
+
}));
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1609
1635
|
/**
|
|
1610
1636
|
* Dashboard warning links (#264): the board's overlap pairs and the live cohorts, as task and peer ids only.
|
|
1611
1637
|
* Paths, symbols and plans stay out; PII tasks are excluded by the same rules overlapHits applies.
|
|
@@ -1648,12 +1674,23 @@ export class Tasks {
|
|
|
1648
1674
|
* ponytail: kept in memory, so after a restart such a notice is delivered whatever its task's state; persist the
|
|
1649
1675
|
* condition if stale notices after restarts show up.
|
|
1650
1676
|
*/
|
|
1651
|
-
private readonly conditional = new Map<string, { peer: PeerId; task: number; states: Task["state"][] }>();
|
|
1677
|
+
private readonly conditional = new Map<string, { peer: PeerId; task: number; tasks?: number[]; states: Task["state"][] }>();
|
|
1652
1678
|
|
|
1653
1679
|
/**
|
|
1654
1680
|
* Publish a notice about `task`, which only matters to `to` while that task is in one of `states` for it (issue
|
|
1655
1681
|
* #106): open work by default; the conflict notices of #91 count a task in review too.
|
|
1656
1682
|
*/
|
|
1683
|
+
/**
|
|
1684
|
+
* #713: the completed-change notice of task `done` for one recipient, about all of its tasks in `about`. It stays relevant while any of them is still open for the
|
|
1685
|
+
* recipient, and carries `completedFor` so the bus replaces an earlier queued notice for the same `done` task and recipient.
|
|
1686
|
+
*/
|
|
1687
|
+
whileOpenFor(to: PeerId, done: number, about: number[], body: string): void {
|
|
1688
|
+
const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(about[0]), taskRecipient: "owner", completedFor: done } });
|
|
1689
|
+
this.conditional.set(env.id, { peer: to, task: about[0]!, tasks: about, states: OPEN });
|
|
1690
|
+
if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
|
|
1691
|
+
this.d.bus.publish(env);
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1657
1694
|
whileOpen(to: PeerId, task: number, body: string, states: Task["state"][] = OPEN): void {
|
|
1658
1695
|
const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task), taskRecipient: "owner" } });
|
|
1659
1696
|
this.conditional.set(env.id, { peer: to, task, states });
|
|
@@ -1682,8 +1719,8 @@ export class Tasks {
|
|
|
1682
1719
|
}
|
|
1683
1720
|
const c = this.conditional.get(env.id);
|
|
1684
1721
|
if (!c || c.peer !== peer) return true;
|
|
1685
|
-
|
|
1686
|
-
return !!t && t.owner === peer && c.states.includes(t.state);
|
|
1722
|
+
// A notice about several of the recipient's tasks matters while any one of them is still open for it (#713).
|
|
1723
|
+
return (c.tasks ?? [c.task]).some((id) => { const t = this.d.board.get(id); return !!t && t.owner === peer && c.states.includes(t.state); });
|
|
1687
1724
|
};
|
|
1688
1725
|
|
|
1689
1726
|
/** 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 +1756,12 @@ export class Tasks {
|
|
|
1719
1756
|
private places(task: Task): { repositoryKey: string; paths: string[]; symbols: string[] } {
|
|
1720
1757
|
const scope = this.repositories.scope(task.refs);
|
|
1721
1758
|
const paths = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].map((p) => scope === this.repositories.project ? this.projectPath(p) : normPath(this.repositories.relativePath(scope, p)));
|
|
1722
|
-
|
|
1759
|
+
const symbols = [
|
|
1760
|
+
...(task.plan?.symbols ?? []).map(normalizePlanSymbol),
|
|
1761
|
+
...(task.plan?.sections ?? []).map(v => `section ${v}`),
|
|
1762
|
+
...(task.plan?.sources ?? []).map(v => `source ${v}`),
|
|
1763
|
+
];
|
|
1764
|
+
return { repositoryKey: scope.key, paths: [...new Set(paths)], symbols: [...new Set(symbols)] };
|
|
1723
1765
|
}
|
|
1724
1766
|
|
|
1725
1767
|
private roots?: string[];
|
|
@@ -1745,7 +1787,7 @@ export class Tasks {
|
|
|
1745
1787
|
const theirs = this.places(t);
|
|
1746
1788
|
if (mine.repositoryKey !== theirs.repositoryKey) return [];
|
|
1747
1789
|
const paths = task.profile && t.profile && (task.plan?.sections?.length && t.plan?.sections?.length) ? [] : mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
|
|
1748
|
-
const symbols = mine.symbols.filter((x) => theirs.symbols.
|
|
1790
|
+
const symbols = mine.symbols.filter((x) => theirs.symbols.some((y) => symbolsOverlap(x, y)));
|
|
1749
1791
|
return paths.length || symbols.length ? [{ task: t, paths, symbols }] : [];
|
|
1750
1792
|
});
|
|
1751
1793
|
}
|
|
@@ -2095,13 +2137,49 @@ export class Tasks {
|
|
|
2095
2137
|
if (waits.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} waits for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet`);
|
|
2096
2138
|
}
|
|
2097
2139
|
|
|
2140
|
+
/** Ready offers between their cap check and the owner write (#655): task id -> reserved owner whose slot it takes. */
|
|
2141
|
+
private readonly readyInFlight = new Map<number, PeerId>();
|
|
2142
|
+
|
|
2143
|
+
/** Offer order for ready work (#655): priority (urgent is P0, none is P2), then rank, then id. */
|
|
2144
|
+
private readonly readyOrder = (a: Task, b: Task): number =>
|
|
2145
|
+
(a.priority ?? (a.signals.includes("urgent") ? 0 : 2)) - (b.priority ?? (b.signals.includes("urgent") ? 0 : 2))
|
|
2146
|
+
|| (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER) || a.id - b.id;
|
|
2147
|
+
|
|
2148
|
+
/** Ready work the hub parked on its reserved owner's WIP cap (#655): its latest move-or-hold entry is `ready held`. */
|
|
2149
|
+
private isHeld(task: Task): boolean {
|
|
2150
|
+
// A planner's priority or rank edit (`planned`) is no move: it must not make held work look released.
|
|
2151
|
+
return task.history.findLast(entry => entry.event === "ready held" || !QUIET_EVENTS.has(entry.event) && entry.event !== "planned")?.event === "ready held";
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
/**
|
|
2155
|
+
* Why readiness must not hand `task` to its reserved owner yet (#655): that owner is at its planner WIP cap, the count
|
|
2156
|
+
* `acceptanceCapacity` refuses at. Peer id and counts only, so it is safe for a PII task. A reserved owner the task
|
|
2157
|
+
* excludes is not honoured by routing, and planner-pulled work was admitted by its pull: neither is held.
|
|
2158
|
+
*/
|
|
2159
|
+
private reservedHold(task: Task): string | undefined {
|
|
2160
|
+
const owner = task.reserved;
|
|
2161
|
+
if (!owner || task.owner || this.plannerPullRecord(task)?.pull === true || this.excluded(task).includes(owner)) return undefined;
|
|
2162
|
+
// Offers in flight take a slot before their owner is written; once it is written wip() counts it, so never twice.
|
|
2163
|
+
const flying = [...this.readyInFlight].filter(([id, peer]) => peer === owner && id !== task.id && !this.d.board.get(id)?.owner).length;
|
|
2164
|
+
const limit = this.wipLimit(owner), used = this.wip(owner, task.id) + (this.pendingClaims.get(owner) ?? 0) + flying;
|
|
2165
|
+
return used >= limit ? `reserved owner ${owner} is at its planner WIP cap (${used}/${limit}); stays ready in To Do with its reservation until a slot frees` : undefined;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
/** The task is ready but its reserved owner has no room: say so once on the task history, never assign. */
|
|
2169
|
+
private holdReady(task: Task, why: string, reason: string): void {
|
|
2170
|
+
if (this.isHeld(task)) return;
|
|
2171
|
+
const ready = task.history.at(-1)?.event === "ready" ? task : this.d.board.update(task.id, HUB, "ready", {}, why);
|
|
2172
|
+
this.d.board.update(ready.id, HUB, "ready held", {}, reason);
|
|
2173
|
+
this.releaseNotice(`task ${this.publicTitle(ready)} is ready but held: ${reason}`);
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2098
2176
|
/** An approved task may be the last thing others waited for: those go through assignment now (issue #34). */
|
|
2099
2177
|
private async releaseDependents(approved: Task): Promise<void> {
|
|
2100
2178
|
try {
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
}
|
|
2179
|
+
// Its dependents and, as its owner's slot just freed (#655), that owner's held ready tasks: one list in priority order.
|
|
2180
|
+
const ready = this.d.board.list("proposed").filter(t => !t.owner && !this.waitsFor(t).length
|
|
2181
|
+
&& (t.deps?.includes(approved.id) || !!approved.owner && t.reserved === approved.owner && this.isHeld(t))).sort(this.readyOrder);
|
|
2182
|
+
for (const t of ready) await this.offerReady(t, t.deps?.includes(approved.id) ? `#${approved.id} approved` : "owner slot freed");
|
|
2105
2183
|
} catch (e) {
|
|
2106
2184
|
this.releaseNotice(`task #${approved.id}: could not release dependents: ${e instanceof Error ? e.message : String(e)}`);
|
|
2107
2185
|
}
|
|
@@ -2223,14 +2301,25 @@ export class Tasks {
|
|
|
2223
2301
|
this.offered.delete(task.id);
|
|
2224
2302
|
this.releaseNotice(`task #${task.id}: cancellation stall cleanup unavailable`, () => this.d.board.clearStall(task.id));
|
|
2225
2303
|
for (const peer of new Set([task.owner, task.reviewer])) if (peer && peer !== USER) this.releaseNotice(`task #${task.id}: cancellation notice unavailable`, () => this.d.bus.publish(newEnvelope(HUB, sanitize(`Task #${task.id} cancelled. Stop working on it. No native turn or working tree was changed.`), { to: [peer], kind: "task", priority: "important", refs: { task: String(task.id), taskCancelled: cancelled.flowRevision }, ...(this.isPii(next) ? { private: true } : {}) })));
|
|
2304
|
+
await this.releaseHeld(task.owner); // its owner's slot is free (#655)
|
|
2226
2305
|
this.releaseNotice(`task #${task.id}: cancellation dependent advice unavailable`, () => {
|
|
2227
2306
|
for (const dependent of this.d.board.list()) if (!isClosedTaskState(dependent.state) && !dependent.owner && dependent.deps?.includes(task.id)) this.releaseNotice(`task #${dependent.id}: cancellation stall advice unavailable`, () => this.reportStall(dependent, { kind: "cancelled-dependency", dependency: task.id, code: "cancelled" }));
|
|
2228
2307
|
});
|
|
2229
2308
|
return next;
|
|
2230
2309
|
}
|
|
2231
2310
|
|
|
2311
|
+
/** A slot freed for `peer` (cancel, untouched return): its held ready tasks are offered in priority order, and the cap stops the offers (#655). */
|
|
2312
|
+
private async releaseHeld(peer: PeerId | null | undefined): Promise<void> {
|
|
2313
|
+
if (!peer) return;
|
|
2314
|
+
try {
|
|
2315
|
+
for (const t of this.d.board.list("proposed").filter(t => !t.owner && t.reserved === peer && this.isHeld(t) && !this.waitsFor(t).length).sort(this.readyOrder)) await this.offerReady(t, "owner slot freed");
|
|
2316
|
+
} catch (e) {
|
|
2317
|
+
this.releaseNotice(`task ${peer}: could not offer held ready work: ${e instanceof Error ? e.message : String(e)}`);
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2232
2321
|
async releaseReady(): Promise<void> {
|
|
2233
|
-
for (const t of this.d.board.list("proposed")) {
|
|
2322
|
+
for (const t of [...this.d.board.list("proposed")].sort(this.readyOrder)) {
|
|
2234
2323
|
if (t.owner) { this.d.board.clearStall(t.id); continue; }
|
|
2235
2324
|
const cancelledDependency = t.deps?.find(id => this.d.board.get(id)?.state === "cancelled");
|
|
2236
2325
|
if (cancelledDependency) { this.reportStall(t, { kind: "cancelled-dependency", dependency: cancelledDependency, code: "cancelled" }); continue; }
|
|
@@ -2242,7 +2331,7 @@ export class Tasks {
|
|
|
2242
2331
|
continue;
|
|
2243
2332
|
}
|
|
2244
2333
|
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;
|
|
2334
|
+
if ((!t.deps?.length && !t.history.some(h => h.event === "backlog pulled")) || (!planned && last !== "blocked" && last !== "ready" && last !== "reserved" && !this.isHeld(t))) continue;
|
|
2246
2335
|
// ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
|
|
2247
2336
|
// an offline one; hold reserved tasks for a grace period if restarts show that up.
|
|
2248
2337
|
await this.offerReady(t, "nothing left to wait for");
|
|
@@ -2251,13 +2340,18 @@ export class Tasks {
|
|
|
2251
2340
|
|
|
2252
2341
|
/** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
|
|
2253
2342
|
private async offerReady(stale: Task, why: string): Promise<void> {
|
|
2254
|
-
let offering = false, undelivered: AssignmentUndeliveredError | undefined;
|
|
2343
|
+
let offering = false, undelivered: AssignmentUndeliveredError | undefined, slot: PeerId | undefined;
|
|
2255
2344
|
try {
|
|
2256
2345
|
const t = this.d.board.get(stale.id);
|
|
2257
2346
|
if (!t || t.state !== "proposed" || t.owner || this.personUnassigned(t) || this.offered.has(t.id)) return;
|
|
2258
2347
|
const planned = this.plannerPullRecord(t)?.pull === true;
|
|
2259
2348
|
const selected = planned ? this.plannerAssignment(t, true).owner : undefined;
|
|
2260
2349
|
if (planned && !selected) return;
|
|
2350
|
+
const held = planned ? undefined : this.reservedHold(t); // #655: the cap holds before the ready move is offered
|
|
2351
|
+
if (held) { this.holdReady(t, why, held); return; }
|
|
2352
|
+
// The check and the slot are one synchronous step: assignOwner can yield before it writes the owner, and a second offer
|
|
2353
|
+
// for the same reserved owner must see this one as taking the last slot.
|
|
2354
|
+
if (!planned && t.reserved) { slot = t.reserved; this.readyInFlight.set(t.id, slot); }
|
|
2261
2355
|
this.offered.add(t.id);
|
|
2262
2356
|
offering = true;
|
|
2263
2357
|
const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
|
|
@@ -2269,6 +2363,7 @@ export class Tasks {
|
|
|
2269
2363
|
if (e instanceof AssignmentUndeliveredError) undelivered = e;
|
|
2270
2364
|
else this.releaseNotice(`task ${this.publicTitle(stale)}: could not be assigned: ${e instanceof Error ? e.message : String(e)}`);
|
|
2271
2365
|
} finally {
|
|
2366
|
+
if (slot) this.readyInFlight.delete(stale.id);
|
|
2272
2367
|
if (offering && !undelivered) {
|
|
2273
2368
|
try {
|
|
2274
2369
|
if (!this.d.board.get(stale.id)?.owner) this.offered.delete(stale.id);
|
|
@@ -2284,6 +2379,48 @@ export class Tasks {
|
|
|
2284
2379
|
if (by !== USER && task[role] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
|
|
2285
2380
|
}
|
|
2286
2381
|
|
|
2382
|
+
/** #694: write the one issue the task itself names (the plan-review source) into its lifecycle receipt, once; never relinks. */
|
|
2383
|
+
private linkIssue(task: Task): Task {
|
|
2384
|
+
if (this.isPii(task) || task.sdlc?.issue) return task;
|
|
2385
|
+
let identity: { repo: string; issue: number } | undefined;
|
|
2386
|
+
try { identity = this.planIssueIdentity(task); } catch { return task; }
|
|
2387
|
+
const next = identity && linkedReceipt(task.sdlc, identity, this.d.sdlc?.config() ?? sdlcConfig(undefined));
|
|
2388
|
+
return next ? this.d.board.update(task.id, HUB, "lifecycle linked", { sdlc: next }, `issue #${identity!.issue}`) : task;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
/**
|
|
2392
|
+
* #694: the PR the hub can verify for a done, without writing it: the one open PR whose head is the done's full commit
|
|
2393
|
+
* (or, with no commit, its branch). The read's basis travels with it, so a task whose commit or branch changed while the
|
|
2394
|
+
* read was awaited never receives a PR found for another head. The done persists it only after its gates admit it, so a
|
|
2395
|
+
* refused done stays effect-free (#254).
|
|
2396
|
+
*/
|
|
2397
|
+
private async identityLink(task: Task, refs?: TaskRefs): Promise<IdentityLink | undefined> {
|
|
2398
|
+
if (this.isPii(task) || task.sdlc?.pr) return undefined;
|
|
2399
|
+
const repository = task.sdlc?.repo ?? task.refs.repo ?? this.d.repository;
|
|
2400
|
+
const commit = refs?.commit ?? task.refs.commit, branch = refs?.branch ?? task.refs.branch;
|
|
2401
|
+
if (!repository || !commit && !branch) return undefined;
|
|
2402
|
+
const pr = await openPrFor(repository, commit ? { commit } : { branch }, this.d.sdlc?.gh ?? sdlcGh);
|
|
2403
|
+
return pr ? { repo: repository, pr, basis: { commit, branch } } : undefined;
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
/**
|
|
2407
|
+
* The task as the done's gates see it: the issue the current task names (re-resolved now, so a changed title or refs
|
|
2408
|
+
* counts) and the staged PR only while the done's effective commit and branch are still the ones it was read for.
|
|
2409
|
+
*/
|
|
2410
|
+
private withLink(task: Task, link: IdentityLink | undefined, refs?: TaskRefs): Task {
|
|
2411
|
+
if ((this.d.sdlc?.config().mode ?? "off") === "off" || this.isPii(task)) return task;
|
|
2412
|
+
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2413
|
+
let issue: { repo: string; issue: number } | undefined;
|
|
2414
|
+
if (!task.sdlc?.issue) { try { issue = this.planIssueIdentity(task); } catch { issue = undefined; } }
|
|
2415
|
+
const commit = refs?.commit ?? task.refs.commit, branch = refs?.branch ?? task.refs.branch;
|
|
2416
|
+
const repository = task.sdlc?.repo ?? task.refs.repo ?? this.d.repository;
|
|
2417
|
+
const pr = link && !task.sdlc?.pr && repository?.toLowerCase() === link.repo.toLowerCase() && link.basis.commit === commit && (commit !== undefined || link.basis.branch === branch) ? link : undefined;
|
|
2418
|
+
let next = task.sdlc;
|
|
2419
|
+
if (issue) next = linkedReceipt(next, issue, config) ?? next;
|
|
2420
|
+
if (pr) next = linkedReceipt(next, { repo: pr.repo, pr: pr.pr }, config) ?? next;
|
|
2421
|
+
return next === task.sdlc ? task : { ...task, sdlc: next };
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2287
2424
|
private planIssueIdentity(task: Task): { repo: string; issue: number } | undefined {
|
|
2288
2425
|
const source = task.external ?? task.sdlc;
|
|
2289
2426
|
if (source?.repo && source.issue) return { repo: source.repo, issue: source.issue };
|
|
@@ -2336,6 +2473,16 @@ export class Tasks {
|
|
|
2336
2473
|
const digest = plannerGrooming(visible, { repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
|
|
2337
2474
|
limits: Object.fromEntries(peers.map(peer => [peer, this.wipLimit(peer)])), wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])),
|
|
2338
2475
|
states, issues, reviewedIssues, acceptedIssues, blockers });
|
|
2476
|
+
// #694: backfill lifecycle issue links after every authority check, with their own bound (not the metadata-read
|
|
2477
|
+
// budget): each call links up to 32 open tasks that still lack one, so later tasks are reached on later calls.
|
|
2478
|
+
if ((this.d.sdlc?.config().mode ?? "off") !== "off") {
|
|
2479
|
+
let linked = 0;
|
|
2480
|
+
for (const task of this.d.board.list()) {
|
|
2481
|
+
if (linked >= 32) break;
|
|
2482
|
+
if (isClosedTaskState(task.state) || task.sdlc?.issue || this.isPii(task)) continue;
|
|
2483
|
+
if (this.linkIssue(task) !== task) linked++;
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2339
2486
|
return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
|
|
2340
2487
|
}
|
|
2341
2488
|
|
|
@@ -2432,8 +2579,9 @@ export class Tasks {
|
|
|
2432
2579
|
const current = this.need(task.id, false, true, true);
|
|
2433
2580
|
if (current.history.length !== flow || (current.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("plan review changed during readback");
|
|
2434
2581
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2435
|
-
|
|
2582
|
+
let updated = this.d.board.update(current.id, by, "plan reviewed", { ...(Object.keys(scope).length ? { plan: { ...current.plan, ...scope } } : {}), sdlc: { ...current.sdlc, stage: current.sdlc?.stage ?? lifecycleStage(config, "plan"),
|
|
2436
2583
|
planReview: { planner: by, planningRevision: revision, recordedAt: Date.now(), ...(Object.keys(scope).length ? { scope } : {}), ...(issue ? { issue } : {}) } } }, raw.reason.trim(), { ...(withheld ? { withheld: true } : {}) }, flow);
|
|
2584
|
+
if (config.mode !== "off") updated = this.linkIssue(updated); // #694
|
|
2437
2585
|
if (updated.state === "proposed" && !updated.owner) {
|
|
2438
2586
|
this.offered.delete(updated.id);
|
|
2439
2587
|
try { await this.offerReady(updated, "planner review recorded"); }
|
|
@@ -2448,7 +2596,11 @@ export class Tasks {
|
|
|
2448
2596
|
if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
|
|
2449
2597
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2450
2598
|
const next = sdlcReceipt(input, task.sdlc, config, VERSION);
|
|
2599
|
+
// The spec identity is person-owned in enforce. No observation cache or restart can reopen a relink bypass.
|
|
2600
|
+
const specRelink = (task.sdlc?.repo !== undefined && next.repo !== task.sdlc.repo) || (task.sdlc?.issue !== undefined && next.issue !== task.sdlc.issue);
|
|
2601
|
+
if (config.mode === "enforce" && by !== USER && specRelink) throw new PreEffectToolRefusal("only the person may relink an established repository or issue in enforce");
|
|
2451
2602
|
const definition = stageDefinition(config, next.stage);
|
|
2603
|
+
if (next.redFirst && input && typeof input === "object" && "redFirst" in input && !this.isPii(task) && !this.publicMetadataText(JSON.stringify(next.redFirst))) throw new PreEffectToolRefusal("red-first metadata matches a PII pattern; use private work");
|
|
2452
2604
|
// #481 S1: entering a build stage in enforce needs the person's spec acceptance; the person may move any stage.
|
|
2453
2605
|
if (config.mode === "enforce" && by !== USER && next.stage !== task.sdlc?.stage && definition && canonicalStage(definition.base) === "build" && specState(next, undefined) === "missing") throw new PreEffectToolRefusal(this.specText(task, "missing", "blocked"));
|
|
2454
2606
|
if (config.mode === "enforce" && config.definitions && input && typeof input === "object" && "stage" in input && next.stage !== task.sdlc?.stage && by !== USER) {
|
|
@@ -2476,7 +2628,7 @@ export class Tasks {
|
|
|
2476
2628
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2477
2629
|
const before = workflowHash(this.d.board.list().map(t => ({ id: t.id, state: t.state, after: t.deps, sdlc: t.sdlc, history: t.history.length })));
|
|
2478
2630
|
const links = this.d.board.list().map(t => ({ id: t.id, after: t.deps ?? [], private: this.isPii(t), receipt: t.sdlc, phase: this.isPii(t) ? undefined : this.workflowView(t), findings: this.isPii(t) ? undefined : this.reviewFindingSets(t), plan: config.mode === "off" || this.isPii(t) ? undefined : { paths: this.planPaths(t), revision: t.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(t.plan ?? {}), accepted: this.validPlanDepartures(t) } }));
|
|
2479
|
-
const report = await readMergeQueue(repo, config, links, this.d.sdlc?.gh);
|
|
2631
|
+
const report = await readMergeQueue(repo, config, links, this.d.sdlc?.gh, this.publicMetadataText);
|
|
2480
2632
|
// Policy can change across remote awaits without any board write. Never return its stale public projection.
|
|
2481
2633
|
const currentPrivacy = this.d.board.list().map(task => ({ id: task.id, private: this.isPii(task) }));
|
|
2482
2634
|
if (workflowHash(links.map(({ id, private: hidden }) => ({ id, private: hidden }))) !== workflowHash(currentPrivacy)) {
|
|
@@ -2492,6 +2644,13 @@ export class Tasks {
|
|
|
2492
2644
|
this.capturePlanHead(task, row.head);
|
|
2493
2645
|
}
|
|
2494
2646
|
}
|
|
2647
|
+
for (const row of report.rows) if (row.testIntegrity) {
|
|
2648
|
+
row.testIntegrity = row.testIntegrity.map(item => ({ ...publicTestIntegrity(item, this.publicMetadataText), ...(item.task === undefined ? {} : { task: item.task }) }));
|
|
2649
|
+
if (config.mode === "enforce") {
|
|
2650
|
+
row.blockers = [...new Set([...row.blockers, ...row.testIntegrity.flatMap(testIntegrityBlockers)])];
|
|
2651
|
+
row.ready = row.ready && row.blockers.length === 0;
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2495
2654
|
return report;
|
|
2496
2655
|
}
|
|
2497
2656
|
async releaseReadiness(repo: string, liveIssues: number[]) {
|
|
@@ -2621,16 +2780,18 @@ export class Tasks {
|
|
|
2621
2780
|
async lifecycleStatus(id: unknown, refresh = true): Promise<SdlcStatus> {
|
|
2622
2781
|
const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined), identity = this.planIdentity(task);
|
|
2623
2782
|
const valid = () => { const now = this.need(id, false, true); if (this.planIdentity(now) !== identity || now.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed during lifecycle readback"); return now; };
|
|
2624
|
-
const
|
|
2625
|
-
const
|
|
2783
|
+
const deadline = performance.now() + 20_000;
|
|
2784
|
+
const read: SdlcGh = (args, timeout, cwd, cap) => { valid(); const remaining = deadline - performance.now(); return remaining > 0 ? (this.d.sdlc?.gh ?? sdlcGh)(args, Math.min(timeout ?? 10_000, remaining), cwd, cap) : Promise.resolve({ code: 1, stdout: "" }); };
|
|
2785
|
+
const evidence = task.sdlc && !this.isPii(task) && refresh && config.mode !== "off" ? await readSdlcPr(task.sdlc, read, true) : undefined;
|
|
2626
2786
|
valid();
|
|
2627
2787
|
const accepted = task.sdlc?.acceptance;
|
|
2628
2788
|
const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue, read) : undefined;
|
|
2789
|
+
const integrity = config.mode !== "off" ? publicTestIntegrity(task.sdlc && evidence && !this.isPii(task) ? await readTestIntegrity(task.sdlc, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText) : undefined;
|
|
2629
2790
|
const current = valid();
|
|
2630
2791
|
if (this.planIdentity(current) !== this.planIdentity(task) || current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed during lifecycle readback");
|
|
2631
2792
|
this.capturePlanRead(current, evidence);
|
|
2632
2793
|
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);
|
|
2794
|
+
config.mode !== "off" ? this.planReport(this.d.board.get(current.id)!, evidence) : undefined, integrity);
|
|
2634
2795
|
}
|
|
2635
2796
|
|
|
2636
2797
|
/** #481 S1: only build work starts from an accepted spec. */
|
|
@@ -2696,26 +2857,33 @@ export class Tasks {
|
|
|
2696
2857
|
this.d.notify(this.specText(task, state, "advice"));
|
|
2697
2858
|
}
|
|
2698
2859
|
|
|
2860
|
+
private unknownTestIntegrity(privateTask = false): TestIntegrity {
|
|
2861
|
+
return { applicability: "unknown", redFirst: "unknown", fileState: "unknown", modifiedTests: [], blockers: [privateTask ? "test integrity: private evidence unavailable" : "test integrity: current evidence unknown"] };
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2699
2864
|
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[]): Promise<PrEvidence | undefined> {
|
|
2700
2865
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2701
2866
|
if (config.mode === "off") return;
|
|
2702
2867
|
const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
|
|
2868
|
+
const deadline = performance.now() + 20_000;
|
|
2703
2869
|
const read: SdlcGh = (args, timeout, cwd, cap) => {
|
|
2704
2870
|
const current = this.need(task.id, false, true);
|
|
2705
2871
|
if (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || this.isPii(current) !== this.isPii(task)) return Promise.resolve({ code: 1, stdout: "" });
|
|
2706
|
-
|
|
2872
|
+
const remaining = deadline - performance.now();
|
|
2873
|
+
return remaining > 0 ? (this.d.sdlc?.gh ?? sdlcGh)(args, Math.min(timeout ?? 10_000, remaining), cwd, cap) : Promise.resolve({ code: 1, stdout: "" });
|
|
2707
2874
|
};
|
|
2708
|
-
const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read) : undefined;
|
|
2875
|
+
const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read, true) : undefined;
|
|
2876
|
+
const integrity = publicTestIntegrity(receipt && evidence && !this.isPii(task) ? await readTestIntegrity(receipt, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText);
|
|
2709
2877
|
const current = this.need(task.id, false, true);
|
|
2710
2878
|
if (config.mode === "enforce" && (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || current.state !== task.state)) throw new PreEffectToolRefusal(`task #${task.id}: changed during lifecycle readback; retry on current work`);
|
|
2711
2879
|
// Advise may continue on the current task, but evidence from its previous link cannot admit that task's review.
|
|
2712
2880
|
if (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || current.state !== task.state || this.isPii(current) !== this.isPii(task)) return;
|
|
2713
2881
|
this.capturePlanRead(current, evidence);
|
|
2714
2882
|
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;
|
|
2883
|
+
const status = sdlcStatus(config, receipt, evidence, this.workflowView(current), undefined, this.isPii(current) ? [] : findings ?? this.reviewFindingSets(current), current.refs.commit, planReport, integrity);
|
|
2884
|
+
const blockers = transition === "review" ? [...new Set([...status.blockers.filter(blocker => ["lifecycle stage definition unknown", "issue identity missing", "PR identity missing", "current PR evidence unknown", "PR is not open", "local gate on current head missing or failed"].includes(blocker) || blocker.startsWith("test integrity:")), ...(status.entry ?? []), ...(status.exit ?? [])])] : status.blockers;
|
|
2717
2885
|
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)])];
|
|
2886
|
+
const advice = [...new Set([...blockers, ...planConformanceBlockers(planReport), ...testIntegrityBlockers(integrity)])];
|
|
2719
2887
|
if (config.mode === "advise" && advice.length) this.d.notify(`task ${this.publicTitle(task)}: SDLC ${transition} advice: ${this.isPii(task) ? "private lifecycle evidence unknown" : advice.join("; ")}`);
|
|
2720
2888
|
return evidence;
|
|
2721
2889
|
}
|
|
@@ -2946,10 +3114,14 @@ export class Tasks {
|
|
|
2946
3114
|
await this.planEntryGate(by, admission);
|
|
2947
3115
|
task = valid();
|
|
2948
3116
|
}
|
|
3117
|
+
// #694: stage the identities the hub can verify; the gates read them, and only the admitted done persists them.
|
|
3118
|
+
const link = this.d.sdlc && this.d.sdlc.config().mode !== "off" ? await this.identityLink(task, refs) : undefined;
|
|
3119
|
+
const current = () => this.withLink(valid(), link, refs);
|
|
3120
|
+
task = current();
|
|
2949
3121
|
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 =
|
|
3122
|
+
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task = current(); }
|
|
3123
|
+
const reviewAdmission = task.reviewer || this.d.prepareReviewBrief ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence, undefined, staged => this.withLink(staged, link, refs), !task.reviewer) : undefined;
|
|
3124
|
+
task = current();
|
|
2953
3125
|
// A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
|
|
2954
3126
|
const cohort = this.cohorts.of(task.id);
|
|
2955
3127
|
const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
|
|
@@ -2969,8 +3141,8 @@ export class Tasks {
|
|
|
2969
3141
|
if (r.action === "request") return this.d.board.update(task.id, HUB, "integration requested", {}, this.integrationRequest(task, r));
|
|
2970
3142
|
if (r.action === "unresolved") {
|
|
2971
3143
|
this.d.notify(`task ${this.publicTitle(task)}: turn-free integration unresolved after ${MAX_REQUESTS} requests (${r.why}); its done is recorded, check the overlapping work by hand`);
|
|
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}`);
|
|
3144
|
+
task = this.withLink(this.d.board.update(task.id, HUB, "integration unresolved", {}, r.why), link, refs);
|
|
3145
|
+
} else if (r.integrated) task = this.withLink(this.d.board.update(task.id, HUB, "integrated", {}, `cohort #${cohort!.id}, revision ${cohort!.revision}`), link, refs);
|
|
2974
3146
|
} else if (cohort?.held.size && !this.isPii(task)) {
|
|
2975
3147
|
// No integration step for this member (silence lifted, a PII task open): the notices the silence held stand in
|
|
2976
3148
|
// for it (issue #107, AC4).
|
|
@@ -2978,14 +3150,14 @@ export class Tasks {
|
|
|
2978
3150
|
if (evidence && by === USER) this.d.notify(`task ${this.publicTitle(task)} done by the console; overlapping work finished meanwhile:\n${evidence}`);
|
|
2979
3151
|
else if (evidence) this.doneNotes.set(task.id, `Overlapping work finished while you worked (no turn-free integration step ran):\n${evidence}`);
|
|
2980
3152
|
}
|
|
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
|
|
3153
|
+
if (task.state === "proposed" || task.state === "changes_requested") task = this.withLink(this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE), link, refs); // done without a separate accept
|
|
2982
3154
|
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);
|
|
3155
|
+
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link);
|
|
2984
3156
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
2985
3157
|
this.checking.set(task.id, task.owner);
|
|
2986
3158
|
const checkedRefs = cleanRefs(refs);
|
|
2987
3159
|
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 });
|
|
3160
|
+
const pending = this.d.board.update(task.id, by, "done (checking)", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...checkedRefs } }), refs: checkedRefs, ...(task.sdlc ? { sdlc: task.sdlc } : {}) }, summary, { withheld: withheld || undefined }, task.history.length, HOST_CODE_SYMBOLS);
|
|
2989
3161
|
this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
|
|
2990
3162
|
const seen = { events: pending.history.length, owner: pending.owner };
|
|
2991
3163
|
this.pendingChecks++;
|
|
@@ -3033,7 +3205,7 @@ export class Tasks {
|
|
|
3033
3205
|
const theirs = this.places(u);
|
|
3034
3206
|
if (mine.repositoryKey !== theirs.repositoryKey) return [];
|
|
3035
3207
|
const paths = t.profile && u.profile && (t.plan?.sections?.length && u.plan?.sections?.length) ? [] : mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
|
|
3036
|
-
const symbols = mine.symbols.filter((x) => theirs.symbols.
|
|
3208
|
+
const symbols = mine.symbols.filter((x) => theirs.symbols.some((y) => symbolsOverlap(x, y)));
|
|
3037
3209
|
return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note, at.withheld)] : [];
|
|
3038
3210
|
});
|
|
3039
3211
|
if (!open.length && !done.length) continue;
|
|
@@ -3116,19 +3288,19 @@ export class Tasks {
|
|
|
3116
3288
|
}
|
|
3117
3289
|
|
|
3118
3290
|
/** `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> {
|
|
3291
|
+
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink): Promise<Task> {
|
|
3120
3292
|
let selection = this.poolReview(task, refs);
|
|
3121
3293
|
let reviewer = selection?.reviewer ?? task.reviewer, reviewEvidence: PrEvidence | undefined;
|
|
3122
3294
|
if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
|
|
3123
3295
|
reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
|
|
3124
|
-
task = this.completionCurrent(task.id, by, false);
|
|
3296
|
+
task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
|
|
3125
3297
|
selection = this.poolReview(task, refs);
|
|
3126
3298
|
reviewer = selection?.reviewer ?? task.reviewer;
|
|
3127
3299
|
}
|
|
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);
|
|
3300
|
+
// A delayed local check needs a new readback; a completion-selected reviewer also needs optional facts in off mode.
|
|
3301
|
+
if (!admission && ((reviewer && (this.d.sdlc?.config().mode ?? "off") !== "off") || this.d.prepareReviewBrief)) {
|
|
3302
|
+
admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence, undefined, staged => this.withLink(staged, link, refs), !reviewer);
|
|
3303
|
+
task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
|
|
3132
3304
|
}
|
|
3133
3305
|
selection = this.poolReview(task, refs);
|
|
3134
3306
|
reviewer = selection?.reviewer ?? task.reviewer;
|
|
@@ -3138,7 +3310,7 @@ export class Tasks {
|
|
|
3138
3310
|
const completedRefs = cleanRefs(refs);
|
|
3139
3311
|
this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
3140
3312
|
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 } : {}) });
|
|
3313
|
+
const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
3142
3314
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
3143
3315
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
|
|
3144
3316
|
this.tellCompleted(next, summary, withheld);
|
|
@@ -3148,7 +3320,7 @@ export class Tasks {
|
|
|
3148
3320
|
this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
|
|
3149
3321
|
await this.releaseDependents(next);
|
|
3150
3322
|
}
|
|
3151
|
-
else if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}: review it
|
|
3323
|
+
else if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}: review it in the ahub console (Tasks, select #${next.id}, press r) or with ahub review ${next.id} approved|changes_requested [note]`);
|
|
3152
3324
|
else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, "", admission));
|
|
3153
3325
|
return next;
|
|
3154
3326
|
}
|
|
@@ -3162,6 +3334,9 @@ export class Tasks {
|
|
|
3162
3334
|
const hits = this.overlapHits(task).filter((h) => h.task.owner !== USER && h.task.owner !== HUB);
|
|
3163
3335
|
const cohort = this.cohorts.of(task.id);
|
|
3164
3336
|
const silent = this.silentFor(task.id);
|
|
3337
|
+
// #713: one notice per recipient per done, naming all of that recipient's overlapping tasks, and only for work the recipient has accepted. An offered or proposed
|
|
3338
|
+
// task has nothing to check yet (its offer, or the accept that follows, already shows the current board), and a repeat done replaces the queued notice (see Bus.enqueue).
|
|
3339
|
+
const byOwner = new Map<PeerId, typeof hits>();
|
|
3165
3340
|
for (const hit of hits) {
|
|
3166
3341
|
// A member of the same silent cohort (issue #107): the member that finishes last integrates instead
|
|
3167
3342
|
// (Cohorts.completion). The notice is held, so something replaces it if no integration runs. Others hear it.
|
|
@@ -3169,23 +3344,42 @@ export class Tasks {
|
|
|
3169
3344
|
cohort.held.add(task.id);
|
|
3170
3345
|
continue;
|
|
3171
3346
|
}
|
|
3172
|
-
|
|
3173
|
-
|
|
3347
|
+
// Accepted by its own owner since it last changed hands (`ownerAcceptance`, the rule the rest of this class uses), and still in work or review: an offered or proposed task is not.
|
|
3348
|
+
if (!["in_progress", "changes_requested", "in_review"].includes(hit.task.state) || !this.ownerAcceptance(hit.task)) continue;
|
|
3349
|
+
byOwner.set(hit.task.owner!, [...(byOwner.get(hit.task.owner!) ?? []), hit]);
|
|
3350
|
+
}
|
|
3351
|
+
for (const [owner, mine] of byOwner) {
|
|
3352
|
+
this.releaseNotice(`task ${this.publicTitle(task)}: could not publish the completed-change notice to ${owner}`, () =>
|
|
3353
|
+
this.whileOpenFor(owner, task.id, mine.map((h) => h.task.id), this.completedNotice(task, mine, summary, withheld)));
|
|
3174
3354
|
}
|
|
3175
3355
|
}
|
|
3176
3356
|
|
|
3357
|
+
/** Share the same screened observation rule between completion notices and read-only console detail. */
|
|
3358
|
+
private sameFileDifferentSymbols(task: Task, hit: { task: Task; paths: string[] }): boolean {
|
|
3359
|
+
const symbolRepository = task.codeSymbols && !this.isPii(task) ? this.codeSymbolsRepository(task) : undefined;
|
|
3360
|
+
const otherRepository = hit.task.codeSymbols && !this.isPii(hit.task) ? this.codeSymbolsRepository(hit.task) : undefined;
|
|
3361
|
+
return !!symbolRepository && symbolRepository.key === otherRepository?.key && !this.isPii(task) && !this.isPii(hit.task) && taskCodeSymbolsDisjoint(
|
|
3362
|
+
task.codeSymbols, hit.task.codeSymbols, this.reviewHead(task), this.reviewHead(hit.task), symbolRepository.key,
|
|
3363
|
+
hit.paths, false, this.publicMetadataText);
|
|
3364
|
+
}
|
|
3365
|
+
|
|
3177
3366
|
/**
|
|
3178
3367
|
* The completed-change notice: files, signatures and the summary are the owner's own words (paths given at done
|
|
3179
3368
|
* included), and any item that matches a PII pattern is left out of what other owners get, the summary also when the
|
|
3180
3369
|
* PII screen did not clear it (`withheld`, #198).
|
|
3181
3370
|
*/
|
|
3182
|
-
private completedNotice(task: Task,
|
|
3371
|
+
private completedNotice(task: Task, hitOrHits: { task: Task; paths: string[]; symbols: string[] } | { task: Task; paths: string[]; symbols: string[] }[], summary?: string, withheld = false): string {
|
|
3372
|
+
const hits = Array.isArray(hitOrHits) ? hitOrHits : [hitOrHits];
|
|
3183
3373
|
const paths = this.places(task).paths.filter(this.nameable);
|
|
3184
3374
|
const signatures = (task.plan?.signatures ?? []).filter(this.nameable);
|
|
3185
3375
|
const first = (summary ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
|
|
3186
3376
|
const line = first && !withheld && this.nameable(first) ? first : undefined;
|
|
3377
|
+
// One line per overlapping task of the recipient, so a single notice names all of them (#713).
|
|
3378
|
+
const touches = hits.map((hit) => `#${hit.task.id} on ${this.where(hit)}${this.sameFileDifferentSymbols(task, hit) ? " (same file, different symbols; observed names only, deleted definitions may be missing)" : ""}`);
|
|
3379
|
+
const open = hits.length > 1 ? `your open tasks ${touches.join("; ")}` : `your open ${touches[0]}`;
|
|
3380
|
+
|
|
3187
3381
|
return [
|
|
3188
|
-
`Task #${task.id} (owner ${task.owner}) is done and touches
|
|
3382
|
+
`Task #${task.id} (owner ${task.owner}) is done and touches ${open}. Check your work against it before you go on.`,
|
|
3189
3383
|
paths.length ? `Changed files: ${paths.join(", ")}` : "",
|
|
3190
3384
|
signatures.length ? `New or changed signatures: ${signatures.join("; ")}` : "",
|
|
3191
3385
|
line ? `Summary: ${line}` : "",
|
|
@@ -3226,16 +3420,17 @@ export class Tasks {
|
|
|
3226
3420
|
|
|
3227
3421
|
/** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
|
|
3228
3422
|
/** 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> {
|
|
3423
|
+
private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean, stage: (task: Task) => Task = task => task, prepareOnly = false): Promise<ReviewReadiness | undefined> {
|
|
3230
3424
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
3231
|
-
if (mode === "off") return undefined;
|
|
3425
|
+
if (mode === "off" && !this.d.prepareReviewBrief) return undefined;
|
|
3232
3426
|
let quietDrift = false;
|
|
3233
3427
|
const guard = () => {
|
|
3234
3428
|
if (actor !== USER && actor !== task.owner) {
|
|
3235
3429
|
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
|
|
3236
3430
|
if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
|
|
3237
3431
|
}
|
|
3238
|
-
|
|
3432
|
+
// #694: the board copy seen through the same staged identity link the caller's task carries.
|
|
3433
|
+
const current = stage(this.need(task.id, false, preEffect));
|
|
3239
3434
|
const changed = current.history.length !== task.history.length;
|
|
3240
3435
|
const quiet = current.history.length > task.history.length && current.history.slice(task.history.length).every(entry => QUIET_EVENTS.has(entry.event));
|
|
3241
3436
|
const sameHistory = !changed || (mode === "advise" && quiet) || !!sameRequest?.();
|
|
@@ -3247,6 +3442,12 @@ export class Tasks {
|
|
|
3247
3442
|
return current;
|
|
3248
3443
|
};
|
|
3249
3444
|
guard();
|
|
3445
|
+
if (!this.isPii(task) && this.d.prepareReviewBrief) {
|
|
3446
|
+
try { await this.d.prepareReviewBrief({ ...task, refs: { ...task.refs, ...cleanRefs(refs) } }); }
|
|
3447
|
+
catch { /* Optional code facts must not refuse a review. */ }
|
|
3448
|
+
guard();
|
|
3449
|
+
}
|
|
3450
|
+
if (mode === "off" || prepareOnly) return undefined;
|
|
3250
3451
|
const expected = cleanRefs(refs).commit ?? task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead;
|
|
3251
3452
|
const read: SdlcGh = (args, timeout, cwd, cap) => {
|
|
3252
3453
|
guard();
|
|
@@ -3283,12 +3484,26 @@ export class Tasks {
|
|
|
3283
3484
|
const cachedPlanRead = this.planReads.get(task.id);
|
|
3284
3485
|
const planRead = this.planReport(task, cachedPlanRead?.identity === this.planReadIdentity(task) && cachedPlanRead.evidence.head === this.findingHead(task) ? cachedPlanRead.evidence : undefined);
|
|
3285
3486
|
const conformanceText = mode !== "off" ? this.screen(task, `Plan conformance: ${JSON.stringify(planRead)}`, "plan conformance", reviewer) + "\n" : "";
|
|
3487
|
+
const profile = (task.profile ?? "dev") === (this.d.profile ?? "dev") ? profileData(this.d.profileData ?? task.profile ?? "dev") : profileData(task.profile);
|
|
3488
|
+
const target = plan ? `the plan (${plan})` : task.detail ? "the task detail above" : "the task title";
|
|
3489
|
+
const noCheckWording = profile.name === "research" || profile.name === "writing"
|
|
3490
|
+
? "No check ran for this class: verify the claims and sources yourself."
|
|
3491
|
+
: profile.name === "general"
|
|
3492
|
+
? "No check ran for this class: verify the outcome yourself."
|
|
3493
|
+
: "No check ran for this class: run the affected tests yourself.";
|
|
3494
|
+
const checkResult = check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : noCheckWording;
|
|
3495
|
+
const checklistTemplates = profile.review_checklist ?? [
|
|
3496
|
+
"Map the changed signatures and call sites to {target}, and name each one that does not match.",
|
|
3497
|
+
"{check}",
|
|
3498
|
+
"List what is unmet in hub_review's unmet, one item each.",
|
|
3499
|
+
];
|
|
3500
|
+
const checklistLines = checklistTemplates.map(template =>
|
|
3501
|
+
template.replaceAll("{target}", target).replaceAll("{check}", checkResult)
|
|
3502
|
+
);
|
|
3286
3503
|
const checklist = [
|
|
3287
3504
|
"Checklist:",
|
|
3288
3505
|
...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.`,
|
|
3506
|
+
...checklistLines.map(line => `- ${line}`),
|
|
3292
3507
|
].join("\n");
|
|
3293
3508
|
// #223 R2: a reviewer-only instance judges from facts; the builder's narrative stays out, earlier findings go in.
|
|
3294
3509
|
const reviewOnly = this.d.reviewOnly?.(reviewer) === true;
|
|
@@ -3345,6 +3560,29 @@ export class Tasks {
|
|
|
3345
3560
|
return next;
|
|
3346
3561
|
}
|
|
3347
3562
|
|
|
3563
|
+
private codeSymbolsRepository(task: Task) {
|
|
3564
|
+
try {
|
|
3565
|
+
const repository = this.repositories.scope(task.refs);
|
|
3566
|
+
if (task.sdlc?.repo && this.repositories.scope({ repo: task.sdlc.repo }).key !== repository.key) return undefined;
|
|
3567
|
+
return repository;
|
|
3568
|
+
} catch { return undefined; }
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3571
|
+
/** Host observations are saved only with an admitted transition, never by an optional read. */
|
|
3572
|
+
private observedCodeSymbols(task: Task) {
|
|
3573
|
+
if (this.isPii(task) || !task.refs.commit) return undefined;
|
|
3574
|
+
const repository = this.codeSymbolsRepository(task);
|
|
3575
|
+
// ponytail: the brief analyzes the validated project execution root. Foreign repositories stay unknown
|
|
3576
|
+
// until the brief carries independent repository provenance; a model's refs.repo cannot supply that proof.
|
|
3577
|
+
if (!repository || repository.key !== this.repositories.project.key) return undefined;
|
|
3578
|
+
try {
|
|
3579
|
+
const brief = this.d.reviewBrief?.(task);
|
|
3580
|
+
const observation = brief && !("unavailable" in brief) ? buildTaskCodeSymbols(brief, repository.key, this.publicMetadataText) : undefined;
|
|
3581
|
+
// A resolved short ref or a stale provider result is unknown advice, never a reason to refuse done.
|
|
3582
|
+
return observation?.head === task.refs.commit ? observation : undefined;
|
|
3583
|
+
} catch { return undefined; } // Optional metadata never costs the admitted result.
|
|
3584
|
+
}
|
|
3585
|
+
|
|
3348
3586
|
/** #520: the rendered review brief of a non-PII task's commit, or why there is none; empty without a commit. */
|
|
3349
3587
|
briefText(task: Task): string {
|
|
3350
3588
|
if (this.isPii(task) || !task.refs.commit) return "";
|