@staix/agent-hub 0.12.44 → 0.12.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +3 -3
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +102 -14
- package/docs/agent-notes/README.md +9 -0
- package/docs/agent-notes/adapters.md +58 -44
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/daemon.md +9 -7
- package/docs/agent-notes/local-worker.md +4 -3
- package/docs/agent-notes/tasks.md +8 -5
- package/docs/agent-notes/tests.md +7 -7
- package/docs/operations.md +99 -7
- package/docs/quickstart.md +5 -1
- package/docs/sdlc.md +26 -8
- package/docs/security.md +6 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +114 -5
- package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
- package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
- package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
- package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
- package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
- package/docs/verification/2026-10-22-0.12.45.md +60 -0
- package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-board-768-light.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-board-390-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
- package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
- package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
- package/docs/verified.json +19 -16
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +9818 -9605
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +3 -1
- package/src/adapters/claude-channel.ts +14 -5
- package/src/adapters/codex-appserver.ts +1 -0
- package/src/adapters/conformance/acp.ts +114 -0
- package/src/adapters/conformance/codex.ts +191 -0
- package/src/adapters/conformance/enrollment.ts +43 -0
- package/src/adapters/conformance/factories.ts +17 -0
- package/src/adapters/conformance/fake-paths.ts +12 -0
- package/src/adapters/conformance/fakes/acp-server.ts +267 -0
- package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
- package/src/adapters/conformance/fakes/app-server.ts +119 -0
- package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
- package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
- package/src/adapters/conformance/fakes/model-server.ts +39 -0
- package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
- package/src/adapters/conformance/index.ts +11 -0
- package/src/adapters/conformance/kit.ts +50 -0
- package/src/adapters/conformance/local-pi.ts +100 -0
- package/src/adapters/conformance/owned-acp.ts +44 -0
- package/src/adapters/definitions/builtin-metadata.ts +23 -6
- package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/qwen.ts +4 -1
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-module-scan.ts +126 -0
- package/src/adapters/external-modules.ts +240 -0
- package/src/adapters/pi.ts +2 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +10 -0
- package/src/adapters/registry.ts +51 -15
- package/src/cli/help.ts +21 -16
- package/src/cli/identity.ts +1 -1
- package/src/cli/main.ts +23 -5
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +4 -1
- package/src/hub/board.ts +30 -4
- package/src/hub/config-apply.ts +18 -10
- package/src/hub/config-reload.ts +8 -5
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +95 -39
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +21 -5
- package/src/hub/instance-config.ts +17 -5
- package/src/hub/instance-execution.ts +20 -8
- package/src/hub/instances.ts +2 -1
- package/src/hub/peers.ts +9 -0
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +31 -5
- package/src/hub/sdlc-queue.ts +19 -4
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +27 -12
- package/src/hub/settings.ts +5 -1
- package/src/hub/snapshots.ts +8 -6
- package/src/hub/tasks.ts +275 -45
- package/src/hub/ui.ts +2 -2
- package/src/ui/index.html +559 -78
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
package/src/hub/tasks.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { completePlanInventory, planConformance, planConformanceBlockers, planFingerprint, type PlanConformance } from "./plan-conformance.ts";
|
|
1
2
|
import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
|
|
2
3
|
import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
|
|
3
4
|
import { specRefs } from "./spec-read.ts";
|
|
@@ -8,6 +9,7 @@ import { NoWriteTurns } from "./no-writes.ts";
|
|
|
8
9
|
import { readGitHubImport, type GitHubSource, type GitHubImportSelection } from "./github-import.ts";
|
|
9
10
|
import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
|
|
10
11
|
import { readMergeQueue } from "./sdlc-queue.ts";
|
|
12
|
+
import { DEFAULT_MAX_NITS, parseReviewFindings, findingsForHead, findingHead as resolveFindingHead, findingTally, type ReviewFinding, type ReviewFindingSet } from "./review-findings.ts";
|
|
11
13
|
import { readReleaseReadiness } from "./sdlc-release.ts";
|
|
12
14
|
import { loadRoleCatalog, taskRole, taskRoleText, type RoleCatalog } from "./roles.ts";
|
|
13
15
|
import { matchesPolicy } from "./path-policy.ts";
|
|
@@ -174,6 +176,7 @@ const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
|
|
|
174
176
|
const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
175
177
|
/** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
|
|
176
178
|
const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
|
|
179
|
+
QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
|
|
177
180
|
|
|
178
181
|
/**
|
|
179
182
|
* Thrown when an assignment's board write is saved but a publish or notify after it failed (#297): the move is
|
|
@@ -250,9 +253,17 @@ function acceptRefs(input: unknown): TaskRefs | undefined {
|
|
|
250
253
|
/** The same for a plan (issue #31): only its four lists, each of short strings. */
|
|
251
254
|
function cleanPlan(input: unknown, profile: ProfileSelection = "dev"): TaskPlan {
|
|
252
255
|
const r = fields(input);
|
|
256
|
+
const evidence: Pick<TaskPlan, "proof" | "risks"> = {};
|
|
257
|
+
for (const key of ["proof", "risks"] as const) {
|
|
258
|
+
const value = r[key];
|
|
259
|
+
if (value === undefined) continue;
|
|
260
|
+
if (!Array.isArray(value) || value.length > 32 || value.some(item => typeof item !== "string" || item.length > 512)) throw new PreEffectToolRefusal(`plan.${key}: expected at most 32 strings of at most 512 characters`);
|
|
261
|
+
const items = value.map(item => item.replace(/\s+/g, " ").trim()).filter(Boolean);
|
|
262
|
+
if (items.length) evidence[key] = items;
|
|
263
|
+
}
|
|
253
264
|
let scope: CostPlan;
|
|
254
265
|
try { scope = cleanCostPlan(r); } catch { throw new PreEffectToolRefusal("invalid review or CI cost scope"); }
|
|
255
|
-
return { ...Object.fromEntries((profileData(profile).plan_keys as (keyof TaskPlan)[]).map((k) => [k, k === "paths" ? [...new Set(textList(r[k]).map(normPath))] : textList(r[k])] as const).filter(([, v]) => v.length)), ...scope };
|
|
266
|
+
return { ...Object.fromEntries((profileData(profile).plan_keys as (keyof TaskPlan)[]).map((k) => [k, k === "paths" ? [...new Set(textList(r[k]).map(normPath))] : textList(r[k])] as const).filter(([, v]) => v.length)), ...evidence, ...scope };
|
|
256
267
|
}
|
|
257
268
|
|
|
258
269
|
/** A plan as one line for other owners; the whole plan is on the board. */
|
|
@@ -280,6 +291,7 @@ export class Tasks {
|
|
|
280
291
|
private sweeping = false;
|
|
281
292
|
|
|
282
293
|
constructor(private readonly d: TasksDeps) {
|
|
294
|
+
d.board.trackPlanRevisions = () => (d.sdlc?.config().mode ?? "off") !== "off";
|
|
283
295
|
this.repositories = new TaskRepositories(d.cwd, d.repository);
|
|
284
296
|
for (const task of d.board.list()) this.warmRepository(task);
|
|
285
297
|
if (d.coordination) {
|
|
@@ -526,14 +538,59 @@ export class Tasks {
|
|
|
526
538
|
/** What peers other than the owner, the console stream and the log may see of a task. */
|
|
527
539
|
publicTitle = (task: Task) => (this.isPii(task) ? `#${task.id} [pii]` : `#${task.id} ${task.title}`);
|
|
528
540
|
|
|
541
|
+
private reviewFindingSets(task: Task): ReviewFindingSet[] { return task.history.flatMap(entry => entry.reviewFindings ? [entry.reviewFindings] : []); }
|
|
542
|
+
|
|
543
|
+
private findingHead(task: Task, reviewedHead?: string): string | undefined {
|
|
544
|
+
return resolveFindingHead(task.sdlc, reviewedHead ? { head: reviewedHead } : undefined, task.refs.commit);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private findingTextAllowed(task: Task, items: readonly ReviewFinding[]): boolean {
|
|
548
|
+
return this.candidateTextAllowed(JSON.stringify(items), task)
|
|
549
|
+
&& items.every(item => !item.path || !matchesPolicy(this.d.cwd, item.path, this.d.piiPaths ?? [], true));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Host-authored proposals contain only cleared IDs, integer task references and fixed instruction targets. */
|
|
553
|
+
private proposeRecurringFindings(task: Task, by: PeerId, items: readonly ReviewFinding[], withheld: boolean): void {
|
|
554
|
+
if (withheld || this.isPii(task) || this.d.privateTurn?.(by) || !items.length || !this.findingTextAllowed(task, items)) return;
|
|
555
|
+
const rows = this.d.board.list();
|
|
556
|
+
const keys = new Set(rows.filter(row => row.class === "plan" && row.history[0]?.by === HUB)
|
|
557
|
+
.flatMap(row => row.detail?.split("\n").filter(line => line.startsWith("Review finding key: ")) ?? []));
|
|
558
|
+
const created: number[] = [];
|
|
559
|
+
// No await separates the durable dedupe read from creation; concurrent verdicts see the first proposal.
|
|
560
|
+
for (const item of items.filter(item => item.disposition !== "false_positive")) {
|
|
561
|
+
const same = (finding: ReviewFinding) => finding.id === item.id && finding.pass === item.pass;
|
|
562
|
+
const holders = rows.filter(row => {
|
|
563
|
+
if (this.isPii(row)) return false;
|
|
564
|
+
const last = row.history.findLast(entry => entry.reviewFindings?.items.some(value => value.id === item.id));
|
|
565
|
+
const finding = last?.reviewFindings?.items.find(value => value.id === item.id);
|
|
566
|
+
return !last?.withheld && finding && same(finding) && finding.disposition !== "false_positive" && this.findingTextAllowed(row, [finding]);
|
|
567
|
+
});
|
|
568
|
+
if (new Set(holders.map(row => row.id)).size < 2) continue;
|
|
569
|
+
const key = `Review finding key: ${item.pass}:${item.id}`;
|
|
570
|
+
if (keys.has(key)) continue;
|
|
571
|
+
const title = `Review policy amendment for recurring finding ${item.pass}:${item.id}`;
|
|
572
|
+
const detail = `${key}\nFinding ${item.pass}:${item.id} recurred on tasks ${holders.slice(0, 8).map(row => `#${row.id}`).join(", ")}. Inspect their review histories and propose an AGENTS.md or relevant docs/agent-notes/ amendment. This backlog card is a proposal, not authorization to edit instruction files.`;
|
|
573
|
+
const draft = { title, detail, refs: { paths: ["AGENTS.md", "docs/agent-notes/"] } };
|
|
574
|
+
const signals = this.signalsFor(draft);
|
|
575
|
+
if (this.isPii({ signals }) || !this.candidateTextAllowed(`${title}\n${detail}`)) continue;
|
|
576
|
+
const proposal = this.d.board.propose(HUB, { ...draft, class: "plan", backlog: true, signals });
|
|
577
|
+
this.incomingBacklog.add(proposal.id); keys.add(key); created.push(proposal.id);
|
|
578
|
+
}
|
|
579
|
+
for (const id of created) this.releaseNotice(`task #${id}: recurring review finding amendment proposed in the backlog`);
|
|
580
|
+
}
|
|
581
|
+
|
|
529
582
|
/** A task as a cloud peer may see it. */
|
|
583
|
+
|
|
530
584
|
publicView(task: Task, fullHistory = false): Record<string, unknown> {
|
|
531
|
-
const { title, detail, history, external, cancelled, ...
|
|
585
|
+
const { title, detail, history, external, cancelled, ...taskRest } = task;
|
|
586
|
+
const rest = { ...taskRest, ...(history.some(h => h.by === USER && h.planDeparture) ? { planDepartureAccepted: true } : {}) };
|
|
532
587
|
if (this.isPii(task)) return { ...rest, actualDates: actualTaskDates(task), refs: {}, plan: {}, ...(task.sdlc ? { sdlc: { stage: stageDefinition(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc.stage)?.base ?? "unknown" } } : {}), title: "[pii]", detail: "[pii]" };
|
|
533
588
|
const screenHistoryText = (text: string, field: string, withheld = false): string => !withheld && this.nameable(text) ? text : `[history ${field} withheld: ${this.whyWithheld(text)}; ahub task show ${task.id}]`;
|
|
534
589
|
const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
|
|
535
590
|
at: h.at, by: h.by, event: h.event,
|
|
536
591
|
...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note", h.withheld) }),
|
|
592
|
+
...(h.reviewFindings ? { reviewFindings: !h.withheld && this.findingTextAllowed(task, h.reviewFindings.items) ? h.reviewFindings
|
|
593
|
+
: { ...(h.reviewFindings.head ? { head: h.reviewFindings.head } : {}), tally: findingTally(h.reviewFindings.items), withheld: true } } : {}),
|
|
537
594
|
...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
|
|
538
595
|
...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
|
|
539
596
|
...(h.owner === undefined ? {} : { owner: h.owner }),
|
|
@@ -545,7 +602,8 @@ export class Tasks {
|
|
|
545
602
|
...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
|
|
546
603
|
...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
|
|
547
604
|
}));
|
|
548
|
-
|
|
605
|
+
const sets = this.reviewFindingSets(task), current = findingsForHead(sets, this.findingHead(task));
|
|
606
|
+
return { ...rest, ...(current.length ? { findings: findingTally(current) } : {}), ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task), undefined, sets, task.refs.commit) } : {}), title, detail, history: publicHistory };
|
|
549
607
|
}
|
|
550
608
|
|
|
551
609
|
/** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
|
|
@@ -2409,13 +2467,16 @@ export class Tasks {
|
|
|
2409
2467
|
const suppliedHead = !!input && typeof input === "object" && (input as { reviewedHead?: unknown }).reviewedHead !== undefined;
|
|
2410
2468
|
const artifactChanged = next.repo !== task.sdlc?.repo || next.pr !== task.sdlc?.pr;
|
|
2411
2469
|
if (next.reviewedHead !== undefined && (next.reviewedHead !== task.sdlc?.reviewedHead || (suppliedHead && artifactChanged))) this.mine(task, by, "reviewer", true);
|
|
2412
|
-
|
|
2470
|
+
const departure = task.history.findLast(h => h.by === USER && h.planDeparture)?.planDeparture;
|
|
2471
|
+
const invalidatesDeparture = !!departure && (artifactChanged || next.localHead !== task.sdlc?.localHead && next.localHead !== undefined && next.localHead !== departure.head || next.reviewedHead !== task.sdlc?.reviewedHead && next.reviewedHead !== undefined && next.reviewedHead !== departure.head);
|
|
2472
|
+
return this.d.board.update(task.id, by, "lifecycle", { sdlc: next }, undefined, { ...this.planObservation({ ...task, sdlc: next }), ...(invalidatesDeparture ? { planDepartureInvalidated: true } : {}) });
|
|
2413
2473
|
}
|
|
2414
2474
|
|
|
2415
2475
|
async deliveryQueue(repo: string) {
|
|
2476
|
+
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2416
2477
|
const before = workflowHash(this.d.board.list().map(t => ({ id: t.id, state: t.state, after: t.deps, sdlc: t.sdlc, history: t.history.length })));
|
|
2417
|
-
const links = this.d.board.list().map(t => ({ id: t.id, after: t.deps ?? [], private: this.isPii(t), receipt: t.sdlc, phase: this.isPii(t) ? undefined : this.workflowView(t) }));
|
|
2418
|
-
const report = await readMergeQueue(repo,
|
|
2478
|
+
const links = this.d.board.list().map(t => ({ id: t.id, after: t.deps ?? [], private: this.isPii(t), receipt: t.sdlc, phase: this.isPii(t) ? undefined : this.workflowView(t), findings: this.isPii(t) ? undefined : this.reviewFindingSets(t), plan: config.mode === "off" || this.isPii(t) ? undefined : { paths: this.planPaths(t), revision: t.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(t.plan ?? {}), accepted: this.validPlanDepartures(t) } }));
|
|
2479
|
+
const report = await readMergeQueue(repo, config, links, this.d.sdlc?.gh);
|
|
2419
2480
|
// Policy can change across remote awaits without any board write. Never return its stale public projection.
|
|
2420
2481
|
const currentPrivacy = this.d.board.list().map(task => ({ id: task.id, private: this.isPii(task) }));
|
|
2421
2482
|
if (workflowHash(links.map(({ id, private: hidden }) => ({ id, private: hidden }))) !== workflowHash(currentPrivacy)) {
|
|
@@ -2424,6 +2485,13 @@ export class Tasks {
|
|
|
2424
2485
|
|
|
2425
2486
|
const after = workflowHash(this.d.board.list().map(t => ({ id: t.id, state: t.state, after: t.deps, sdlc: t.sdlc, history: t.history.length })));
|
|
2426
2487
|
if (before !== after) { report.complete = false; report.order = []; if (report.rows.length) report.unordered = report.rows.map(row => row.pr); for (const row of report.rows) { row.ready = false; row.blockers.push("board changed during delivery readback"); } }
|
|
2488
|
+
else if (config.mode !== "off") for (const row of report.rows) if (row.head && !row.private) for (const id of row.tasks) {
|
|
2489
|
+
const task = this.d.board.get(id);
|
|
2490
|
+
if (task?.sdlc?.repo?.toLowerCase() === report.repo.toLowerCase() && task.sdlc.pr === row.pr) {
|
|
2491
|
+
if (this.validPlanDepartures(task).length && row.planConformance?.some(item => item.task === id && item.state !== "unknown" && !item.accepted)) this.invalidatedPlanDepartures.add(id);
|
|
2492
|
+
this.capturePlanHead(task, row.head);
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2427
2495
|
return report;
|
|
2428
2496
|
}
|
|
2429
2497
|
async releaseReadiness(repo: string, liveIssues: number[]) {
|
|
@@ -2457,12 +2525,112 @@ export class Tasks {
|
|
|
2457
2525
|
const state = { id: task.id, state: task.state, stage, ...(this.isPii(task) ? { private: true } : { criteria: this.workflowView(task) ?? null }) };
|
|
2458
2526
|
return { schema: 2, state, stateHash: workflowHash(state), sourceHash: workflowHash(config) };
|
|
2459
2527
|
}
|
|
2528
|
+
private readonly planReads = new Map<number, { identity: string; evidence: PrEvidence }>();
|
|
2529
|
+
// ponytail: consent expires on restart because a refused readback cannot first mutate the task's history.
|
|
2530
|
+
// A durable host observation journal can retain safe consent across lifetimes without reviving pending invalidations.
|
|
2531
|
+
private readonly departureGeneration = crypto.randomUUID();
|
|
2532
|
+
private readonly invalidatedPlanDepartures = new Set<number>();
|
|
2533
|
+
private readonly observedPlans = new Map<number, NonNullable<HistoryEntry["planObservation"]>>();
|
|
2534
|
+
private planIdentity(task: Task): string {
|
|
2535
|
+
return JSON.stringify([task.refs, task.sdlc, task.plan, task.owner, task.reviewer, task.state, this.isPii(task), this.d.sdlc?.config()]);
|
|
2536
|
+
}
|
|
2537
|
+
private planReadIdentity(task: Task): string { return JSON.stringify([task.sdlc?.repo, task.sdlc?.pr, task.refs.repo, planFingerprint(task.plan ?? {})]); }
|
|
2538
|
+
private planPaths(task: Task): string[] | undefined {
|
|
2539
|
+
if (!task.sdlc?.repo) return;
|
|
2540
|
+
// Resolve the artifact slug first: it may warm the canonical project's previously unnamed identity.
|
|
2541
|
+
const artifact = this.repositories.scope({ repo: task.sdlc.repo, paths: task.refs.paths });
|
|
2542
|
+
const scope = this.repositories.scope(task.refs);
|
|
2543
|
+
if (scope.key !== artifact.key) return;
|
|
2544
|
+
return task.plan?.paths?.map(path => scope === this.repositories.project ? this.projectPath(path) : this.repositories.relativePath(scope, path));
|
|
2545
|
+
}
|
|
2546
|
+
private planScopeIdentity(task: Task): string {
|
|
2547
|
+
const paths = this.planPaths(task); // Warm verified aliases before capturing the scope identity.
|
|
2548
|
+
const scope = this.repositories.scope(task.refs);
|
|
2549
|
+
return planFingerprint({ key: scope.key, root: scope.root ?? null, aliases: [...scope.aliases].sort(),
|
|
2550
|
+
repo: task.refs.repo ?? null, absoluteRefs: (task.refs.paths ?? []).filter(isAbsolute).sort(), paths: paths ?? null });
|
|
2551
|
+
}
|
|
2552
|
+
private validPlanDepartures(task: Task): NonNullable<HistoryEntry["planDeparture"]>[] {
|
|
2553
|
+
const invalidated = task.history.findLastIndex(h => h.planDepartureInvalidated && !h.planDeparture);
|
|
2554
|
+
if (this.invalidatedPlanDepartures.has(task.id)) return [];
|
|
2555
|
+
const latest = task.history.findLastIndex(h => h.by === USER && h.event === "plan departure accepted" && h.planDeparture);
|
|
2556
|
+
return latest > invalidated && task.history[latest]!.planDeparture!.generation === this.departureGeneration
|
|
2557
|
+
&& task.history[latest]!.planDeparture!.scope === this.planScopeIdentity(task) ? [task.history[latest]!.planDeparture!] : [];
|
|
2558
|
+
}
|
|
2559
|
+
private planReport(task: Task, evidence?: PrEvidence) {
|
|
2560
|
+
const rawReport = planConformance(this.isPii(task) ? undefined : this.planPaths(task), this.isPii(task) ? undefined : evidence);
|
|
2561
|
+
const safe = [...rawReport.outside, ...rawReport.untouched].every(path => this.publicMetadataText(path));
|
|
2562
|
+
const report: PlanConformance = safe ? rawReport : { state: "unknown", head: rawReport.head, outside: [], untouched: [] };
|
|
2563
|
+
const fingerprint = planFingerprint(task.plan ?? {});
|
|
2564
|
+
const observation = task.history.find(h => h.planObservation?.repo === task.sdlc?.repo && h.planObservation?.pr === task.sdlc?.pr)?.planObservation ?? this.observedPlans.get(task.id);
|
|
2565
|
+
const revision = task.history.findLast(h => h.planRevision)?.planRevision?.revision;
|
|
2566
|
+
const acceptance = evidence && this.validPlanDepartures(task).findLast(item => item.repo === task.sdlc?.repo && item.pr === task.sdlc?.pr && item.head === evidence.head
|
|
2567
|
+
&& item.revision === (revision ?? 0) && item.fingerprint === fingerprint && JSON.stringify([...item.paths].sort()) === JSON.stringify([...evidence.paths].sort()));
|
|
2568
|
+
return { ...report, accepted: !!acceptance, earlierPushes: "unknown" as const,
|
|
2569
|
+
...(observation && observation.repo === task.sdlc?.repo && observation.pr === task.sdlc?.pr ? { revisionsAfterFirstObservedPush: Math.max(0, (revision ?? 0) - observation.revision) } : {}) };
|
|
2570
|
+
}
|
|
2571
|
+
private capturePlanRead(task: Task, evidence: PrEvidence | undefined): void {
|
|
2572
|
+
if ((this.d.sdlc?.config().mode ?? "off") === "off" || !evidence || this.isPii(task) || !task.sdlc?.repo || !task.sdlc.pr) return;
|
|
2573
|
+
if (this.planReads.size >= 256 && !this.planReads.has(task.id)) this.planReads.delete(this.planReads.keys().next().value!);
|
|
2574
|
+
this.planReads.set(task.id, { identity: this.planReadIdentity(task), evidence });
|
|
2575
|
+
const accepted = task.history.findLast(h => h.by === USER && h.planDeparture)?.planDeparture;
|
|
2576
|
+
if (accepted && evidence.pathsComplete === true && JSON.stringify([...accepted.paths].sort()) !== JSON.stringify([...evidence.paths].sort())) this.invalidatedPlanDepartures.add(task.id);
|
|
2577
|
+
this.capturePlanHead(task, evidence.head);
|
|
2578
|
+
}
|
|
2579
|
+
private capturePlanHead(task: Task, head: string): void {
|
|
2580
|
+
if ((this.d.sdlc?.config().mode ?? "off") === "off" || this.isPii(task) || !task.sdlc?.repo || !task.sdlc.pr) return;
|
|
2581
|
+
if (this.planReads.get(task.id)?.evidence.head !== head) this.planReads.delete(task.id);
|
|
2582
|
+
const accepted = task.history.findLast(h => h.by === USER && h.planDeparture)?.planDeparture;
|
|
2583
|
+
if (accepted && (accepted.head !== head || accepted.repo !== task.sdlc.repo || accepted.pr !== task.sdlc.pr)) this.invalidatedPlanDepartures.add(task.id);
|
|
2584
|
+
if (!task.history.some(h => h.planObservation?.repo === task.sdlc?.repo && h.planObservation?.pr === task.sdlc?.pr)
|
|
2585
|
+
&& (!this.observedPlans.has(task.id) || this.observedPlans.get(task.id)?.repo !== task.sdlc.repo || this.observedPlans.get(task.id)?.pr !== task.sdlc.pr)) {
|
|
2586
|
+
// ponytail: this is first observed push, not a true push event. Earlier pushes stay unknown; upgrade to a webhook/Actions push receipt.
|
|
2587
|
+
if (this.observedPlans.size >= 256) this.observedPlans.delete(this.observedPlans.keys().next().value!);
|
|
2588
|
+
this.observedPlans.set(task.id, { repo: task.sdlc.repo, pr: task.sdlc.pr, head,
|
|
2589
|
+
revision: task.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(task.plan ?? {}) });
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
private planObservation(task: Task): Pick<HistoryEntry, "planObservation" | "planDepartureInvalidated"> {
|
|
2593
|
+
if ((this.d.sdlc?.config().mode ?? "off") === "off") return {};
|
|
2594
|
+
const observation = this.observedPlans.get(task.id);
|
|
2595
|
+
const lastAcceptance = task.history.findLastIndex(h => h.by === USER && h.planDeparture);
|
|
2596
|
+
return {
|
|
2597
|
+
...(observation && observation.repo === task.sdlc?.repo && observation.pr === task.sdlc?.pr
|
|
2598
|
+
&& !task.history.some(h => h.planObservation?.repo === observation.repo && h.planObservation.pr === observation.pr) ? { planObservation: observation } : {}),
|
|
2599
|
+
...(this.invalidatedPlanDepartures.has(task.id) && !task.history.slice(lastAcceptance + 1).some(h => h.planDepartureInvalidated) ? { planDepartureInvalidated: true as const } : {}),
|
|
2600
|
+
};
|
|
2601
|
+
}
|
|
2602
|
+
async acceptPlanDeparture(by: PeerId, id: unknown, head: unknown, reason: unknown): Promise<Task> {
|
|
2603
|
+
if (by !== USER) throw new PreEffectToolRefusal("only the person may accept a plan departure");
|
|
2604
|
+
if (typeof head !== "string" || !/^[a-f0-9]{40}$/.test(head) || typeof reason !== "string" || !reason.trim() || reason.length > 2048) throw new PreEffectToolRefusal("departure acceptance requires a full head and a bounded reason");
|
|
2605
|
+
const initial = this.need(id, false, true), identity = this.planIdentity(initial), scope = this.planScopeIdentity(initial), history = initial.history.length;
|
|
2606
|
+
const valid = () => { const now = this.need(id, false, true); if (isClosedTaskState(now.state) || identity !== this.planIdentity(now) || scope !== this.planScopeIdentity(now) || history !== now.history.length) throw new PreEffectToolRefusal("task changed during departure readback"); return now; };
|
|
2607
|
+
if (this.isPii(initial)) throw new PreEffectToolRefusal("private PR file evidence is unavailable; amend the private plan instead");
|
|
2608
|
+
const read: SdlcGh = (args, timeout, cwd, cap) => { valid(); return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap); };
|
|
2609
|
+
const evidence = initial.sdlc && await readSdlcPr(initial.sdlc, read); valid();
|
|
2610
|
+
if (!evidence || completePlanInventory(evidence) === undefined) throw new PreEffectToolRefusal("complete PR file inventory unknown");
|
|
2611
|
+
if (evidence.head !== head) throw new PreEffectToolRefusal("departure acceptance head is not the current PR head");
|
|
2612
|
+
if (this.planReport(initial, evidence).state !== "departure") throw new PreEffectToolRefusal("current plan conformance is not a known departure; amend unknown scope or coverage first");
|
|
2613
|
+
const note = sanitize(reason.replace(/\s+/g, " ").trim());
|
|
2614
|
+
const withheld = await this.withholds("note", note, initial); const current = valid();
|
|
2615
|
+
this.capturePlanRead(current, evidence);
|
|
2616
|
+
const next = this.d.board.update(current.id, USER, "plan departure accepted", {}, note, { withheld: withheld || undefined, ...this.planObservation(current),
|
|
2617
|
+
planDeparture: { generation: this.departureGeneration, scope, repo: current.sdlc!.repo!, pr: current.sdlc!.pr!, head, revision: current.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(current.plan ?? {}), paths: [...evidence.paths] } });
|
|
2618
|
+
this.invalidatedPlanDepartures.delete(current.id);
|
|
2619
|
+
return next;
|
|
2620
|
+
}
|
|
2460
2621
|
async lifecycleStatus(id: unknown, refresh = true): Promise<SdlcStatus> {
|
|
2461
|
-
const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2462
|
-
const
|
|
2622
|
+
const task = this.need(id), config = this.d.sdlc?.config() ?? sdlcConfig(undefined), identity = this.planIdentity(task);
|
|
2623
|
+
const valid = () => { const now = this.need(id, false, true); if (this.planIdentity(now) !== identity || now.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed during lifecycle readback"); return now; };
|
|
2624
|
+
const read: SdlcGh = (args, timeout, cwd, cap) => { valid(); return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap); };
|
|
2625
|
+
const evidence = task.sdlc && !this.isPii(task) && refresh && config.mode !== "off" ? await readSdlcPr(task.sdlc, read) : undefined;
|
|
2626
|
+
valid();
|
|
2463
2627
|
const accepted = task.sdlc?.acceptance;
|
|
2464
|
-
const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue,
|
|
2465
|
-
|
|
2628
|
+
const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue, read) : undefined;
|
|
2629
|
+
const current = valid();
|
|
2630
|
+
if (this.planIdentity(current) !== this.planIdentity(task) || current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed during lifecycle readback");
|
|
2631
|
+
this.capturePlanRead(current, evidence);
|
|
2632
|
+
return sdlcStatus(config, current.sdlc, evidence, this.workflowView(current), edit, this.isPii(current) ? [] : this.reviewFindingSets(current), current.refs.commit,
|
|
2633
|
+
config.mode !== "off" ? this.planReport(this.d.board.get(current.id)!, evidence) : undefined);
|
|
2466
2634
|
}
|
|
2467
2635
|
|
|
2468
2636
|
/** #481 S1: only build work starts from an accepted spec. */
|
|
@@ -2528,7 +2696,7 @@ export class Tasks {
|
|
|
2528
2696
|
this.d.notify(this.specText(task, state, "advice"));
|
|
2529
2697
|
}
|
|
2530
2698
|
|
|
2531
|
-
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string): Promise<PrEvidence | undefined> {
|
|
2699
|
+
private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[]): Promise<PrEvidence | undefined> {
|
|
2532
2700
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2533
2701
|
if (config.mode === "off") return;
|
|
2534
2702
|
const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
|
|
@@ -2542,10 +2710,13 @@ export class Tasks {
|
|
|
2542
2710
|
if (config.mode === "enforce" && (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || current.state !== task.state)) throw new PreEffectToolRefusal(`task #${task.id}: changed during lifecycle readback; retry on current work`);
|
|
2543
2711
|
// Advise may continue on the current task, but evidence from its previous link cannot admit that task's review.
|
|
2544
2712
|
if (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || current.state !== task.state || this.isPii(current) !== this.isPii(task)) return;
|
|
2545
|
-
|
|
2713
|
+
this.capturePlanRead(current, evidence);
|
|
2714
|
+
const planReport = this.planReport(current, evidence);
|
|
2715
|
+
const status = sdlcStatus(config, receipt, evidence, this.workflowView(current), undefined, this.isPii(current) ? [] : findings ?? this.reviewFindingSets(current), current.refs.commit, planReport);
|
|
2546
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;
|
|
2547
2717
|
if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
|
|
2548
|
-
|
|
2718
|
+
const advice = [...new Set([...blockers, ...planConformanceBlockers(planReport)])];
|
|
2719
|
+
if (config.mode === "advise" && advice.length) this.d.notify(`task ${this.publicTitle(task)}: SDLC ${transition} advice: ${this.isPii(task) ? "private lifecycle evidence unknown" : advice.join("; ")}`);
|
|
2549
2720
|
return evidence;
|
|
2550
2721
|
}
|
|
2551
2722
|
|
|
@@ -2746,19 +2917,25 @@ export class Tasks {
|
|
|
2746
2917
|
/** Whether a completion check is still running for this task. */
|
|
2747
2918
|
isChecking = (id: number) => this.checking.has(id);
|
|
2748
2919
|
|
|
2920
|
+
private completionCurrent(id: unknown, by: PeerId, preEffect: boolean): Task {
|
|
2921
|
+
const task = this.need(id, false, preEffect);
|
|
2922
|
+
this.mine(task, by, "owner", preEffect);
|
|
2923
|
+
if (preEffect) {
|
|
2924
|
+
if (task.state === "in_review" || isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
|
|
2925
|
+
} else {
|
|
2926
|
+
if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
|
|
2927
|
+
}
|
|
2928
|
+
this.ready(task, preEffect);
|
|
2929
|
+
if (task.state === "proposed" || task.state === "changes_requested") this.acceptanceCapacity(task);
|
|
2930
|
+
if (this.checking.has(task.id)) {
|
|
2931
|
+
// The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
|
|
2932
|
+
throw new (preEffect ? PreEffectToolRefusal : Error)(this.checking.get(task.id) === task.owner ? `task #${task.id}: its check is still running; its result comes as a task message` : `task #${task.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
|
|
2933
|
+
}
|
|
2934
|
+
return task;
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2749
2937
|
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs): Promise<Task> {
|
|
2750
|
-
const valid = ()
|
|
2751
|
-
const t = this.need(id, false, true);
|
|
2752
|
-
this.mine(t, by, "owner", true);
|
|
2753
|
-
if (t.state === "in_review" || isClosedTaskState(t.state)) throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
|
|
2754
|
-
this.ready(t, true);
|
|
2755
|
-
if (t.state === "proposed" || t.state === "changes_requested") this.acceptanceCapacity(t);
|
|
2756
|
-
if (this.checking.has(t.id)) {
|
|
2757
|
-
// The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
|
|
2758
|
-
throw new PreEffectToolRefusal(this.checking.get(t.id) === t.owner ? `task #${t.id}: its check is still running; its result comes as a task message` : `task #${t.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
|
|
2759
|
-
}
|
|
2760
|
-
return t;
|
|
2761
|
-
};
|
|
2938
|
+
const valid = () => this.completionCurrent(id, by, true);
|
|
2762
2939
|
// The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
|
|
2763
2940
|
const withheld = await this.withholds("summary", summary, valid());
|
|
2764
2941
|
let task = valid();
|
|
@@ -2814,7 +2991,13 @@ export class Tasks {
|
|
|
2814
2991
|
this.pendingChecks++;
|
|
2815
2992
|
this.checkQueue = this.checkQueue
|
|
2816
2993
|
.then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld))
|
|
2817
|
-
.catch((e: Error) =>
|
|
2994
|
+
.catch((e: Error) => {
|
|
2995
|
+
this.releaseNotice(`task #${pending.id}: its check result could not be recorded: ${e.message}`);
|
|
2996
|
+
this.releaseNotice(`task #${pending.id}: its owner retry notice could not be published`, () => {
|
|
2997
|
+
const current = this.d.board.get(pending.id);
|
|
2998
|
+
if (current?.state === "in_progress" && current.owner === seen.owner) this.tell(current, `Task #${current.id}: its check completion was refused. Call hub_task_done again when it is ready.`, this.isPii(current));
|
|
2999
|
+
});
|
|
3000
|
+
})
|
|
2818
3001
|
.finally(() => this.pendingChecks--);
|
|
2819
3002
|
return pending;
|
|
2820
3003
|
}
|
|
@@ -2938,20 +3121,14 @@ export class Tasks {
|
|
|
2938
3121
|
let reviewer = selection?.reviewer ?? task.reviewer, reviewEvidence: PrEvidence | undefined;
|
|
2939
3122
|
if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
|
|
2940
3123
|
reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
|
|
2941
|
-
task = this.
|
|
2942
|
-
this.mine(task, by, "owner", true);
|
|
2943
|
-
// In every mode: another done may have landed during the readback.
|
|
2944
|
-
if (task.state === "in_review" || isClosedTaskState(task.state)) throw new Error(`task #${task.id} is already ${task.state}`);
|
|
3124
|
+
task = this.completionCurrent(task.id, by, false);
|
|
2945
3125
|
selection = this.poolReview(task, refs);
|
|
2946
3126
|
reviewer = selection?.reviewer ?? task.reviewer;
|
|
2947
3127
|
}
|
|
2948
3128
|
// A delayed local check needs a new readback; the immediate done path already admitted before effects.
|
|
2949
3129
|
if (reviewer && !admission && (this.d.sdlc?.config().mode ?? "off") !== "off") {
|
|
2950
|
-
const observed = task;
|
|
2951
3130
|
admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence);
|
|
2952
|
-
task = this.
|
|
2953
|
-
this.mine(task, by, "owner");
|
|
2954
|
-
if (task.history.length !== observed.history.length || task.state === "in_review" || isClosedTaskState(task.state)) throw new Error("task changed during review admission");
|
|
3131
|
+
task = this.completionCurrent(task.id, by, false);
|
|
2955
3132
|
}
|
|
2956
3133
|
selection = this.poolReview(task, refs);
|
|
2957
3134
|
reviewer = selection?.reviewer ?? task.reviewer;
|
|
@@ -2961,7 +3138,7 @@ export class Tasks {
|
|
|
2961
3138
|
const completedRefs = cleanRefs(refs);
|
|
2962
3139
|
this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
2963
3140
|
const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
2964
|
-
const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}) });
|
|
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 } : {}) });
|
|
2965
3142
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
2966
3143
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
|
|
2967
3144
|
this.tellCompleted(next, summary, withheld);
|
|
@@ -3052,30 +3229,41 @@ export class Tasks {
|
|
|
3052
3229
|
private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean): Promise<ReviewReadiness | undefined> {
|
|
3053
3230
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
3054
3231
|
if (mode === "off") return undefined;
|
|
3232
|
+
let quietDrift = false;
|
|
3055
3233
|
const guard = () => {
|
|
3056
3234
|
if (actor !== USER && actor !== task.owner) {
|
|
3057
3235
|
const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
|
|
3058
3236
|
if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
|
|
3059
3237
|
}
|
|
3060
3238
|
const current = this.need(task.id, false, preEffect);
|
|
3061
|
-
|
|
3239
|
+
const changed = current.history.length !== task.history.length;
|
|
3240
|
+
const quiet = current.history.length > task.history.length && current.history.slice(task.history.length).every(entry => QUIET_EVENTS.has(entry.event));
|
|
3241
|
+
const sameHistory = !changed || (mode === "advise" && quiet) || !!sameRequest?.();
|
|
3242
|
+
if ((this.d.sdlc?.config().mode ?? "off") !== mode || !sameHistory || current.owner !== task.owner || mode !== "advise" && current.reviewer !== task.reviewer
|
|
3062
3243
|
|| this.isPii(current) !== this.isPii(task) || JSON.stringify(current.sdlc ?? null) !== JSON.stringify(task.sdlc ?? null)
|
|
3244
|
+
|| mode === "advise" && (current.state !== task.state || current.refs.commit !== task.refs.commit)
|
|
3063
3245
|
|| actor !== USER && this.d.privateTurn?.(actor) && !this.isPii(current)) throw new (preEffect ? PreEffectToolRefusal : Error)("task or authority changed during review admission");
|
|
3246
|
+
quietDrift ||= mode === "advise" && changed && quiet;
|
|
3064
3247
|
return current;
|
|
3065
3248
|
};
|
|
3066
3249
|
guard();
|
|
3067
3250
|
const expected = cleanRefs(refs).commit ?? task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead;
|
|
3068
|
-
const read: SdlcGh = (args, timeout, cwd, cap) => {
|
|
3251
|
+
const read: SdlcGh = (args, timeout, cwd, cap) => {
|
|
3252
|
+
guard();
|
|
3253
|
+
if (quietDrift) throw new Error("review readiness unknown: task changed during readback");
|
|
3254
|
+
return (this.d.sdlc?.gh ?? sdlcGh)(args, timeout, cwd, cap);
|
|
3255
|
+
};
|
|
3069
3256
|
const evidence: ReviewReadiness = this.isPii(task)
|
|
3070
3257
|
? { ready: false, blockers: ["private review readiness unavailable"] }
|
|
3071
3258
|
: task.sdlc ? reviewReadinessFromEvidence(previous, expected, this.d.sdlc?.config().requiredChecks ?? []) ?? await readReviewReadiness(task.sdlc, expected, read, this.d.sdlc?.config().requiredChecks ?? []) : { ready: false, blockers: ["review PR identity missing"] };
|
|
3072
3259
|
const current = guard();
|
|
3073
|
-
|
|
3074
|
-
|
|
3260
|
+
const admitted: ReviewReadiness = quietDrift ? { ready: false, blockers: ["review readiness unknown: task changed during readback"] } : evidence;
|
|
3261
|
+
if (!admitted.ready) {
|
|
3262
|
+
const message = `task ${this.publicTitle(current)}: premature review: ${admitted.blockers.join("; ")}`;
|
|
3075
3263
|
if (mode === "enforce") throw new (preEffect ? PreEffectToolRefusal : Error)(message);
|
|
3076
3264
|
this.releaseNotice(message);
|
|
3077
3265
|
}
|
|
3078
|
-
return
|
|
3266
|
+
return admitted;
|
|
3079
3267
|
}
|
|
3080
3268
|
|
|
3081
3269
|
private sendReview(task: Task, reviewer: PeerId, why = "", admission?: ReviewReadiness): void {
|
|
@@ -3092,6 +3280,9 @@ export class Tasks {
|
|
|
3092
3280
|
// A checklist that maps the change to its contract (issue #35): reviewers who check against the written plan catch more.
|
|
3093
3281
|
const plan = planText(task.plan);
|
|
3094
3282
|
const check = [...task.history].reverse().find((h) => h.event === "check passed");
|
|
3283
|
+
const cachedPlanRead = this.planReads.get(task.id);
|
|
3284
|
+
const planRead = this.planReport(task, cachedPlanRead?.identity === this.planReadIdentity(task) && cachedPlanRead.evidence.head === this.findingHead(task) ? cachedPlanRead.evidence : undefined);
|
|
3285
|
+
const conformanceText = mode !== "off" ? this.screen(task, `Plan conformance: ${JSON.stringify(planRead)}`, "plan conformance", reviewer) + "\n" : "";
|
|
3095
3286
|
const checklist = [
|
|
3096
3287
|
"Checklist:",
|
|
3097
3288
|
...this.planGuardItems(task).map(item => `- Check the plan against this live fail before the remaining checks: ${item.line}`),
|
|
@@ -3105,6 +3296,9 @@ export class Tasks {
|
|
|
3105
3296
|
const specRepo = task.sdlc?.repo ?? task.external?.repo, specIssue = task.sdlc?.issue ?? task.external?.issue;
|
|
3106
3297
|
const spec = reviewOnly && specRepo && specIssue ? `Spec issue: ${specRepo}#${specIssue}\n` : "";
|
|
3107
3298
|
const findings = reviewOnly ? task.history.filter(h => h.event === "changes_requested" && h.note).map(h => `- ${h.by}: ${this.screen(task, h.note!, "review note", reviewer, h.withheld)}`) : [];
|
|
3299
|
+
const carried = findingsForHead(this.reviewFindingSets(task), this.findingHead(task)).filter(item => item.severity === "important" && item.disposition === "open");
|
|
3300
|
+
const visible = this.isPii(task) ? [] : carried.filter(item => this.findingTextAllowed(task, [item]) && !task.history.some(entry => entry.withheld && entry.reviewFindings?.items.some(value => value.id === item.id)));
|
|
3301
|
+
const carriedText = carried.length ? `Open Important findings carried until explicitly dispositioned: ${carried.length}\n${visible.map(({ id, pass, severity, path }) => JSON.stringify({ id, pass, severity, ...(path ? { path } : {}) })).join("\n")}${visible.length < carried.length ? "\nSome finding details withheld." : ""}\n` : "";
|
|
3108
3302
|
const guidance = this.roleGuidance(task, reviewer, true);
|
|
3109
3303
|
let projection = "";
|
|
3110
3304
|
try { const text = this.d.projectedReview?.(task); if (text) projection = this.screen(task, sanitize(text), "document projection", reviewer); }
|
|
@@ -3112,7 +3306,7 @@ export class Tasks {
|
|
|
3112
3306
|
const brief = this.briefText(task);
|
|
3113
3307
|
// #572 AC3: advisory only, from git state never from task text, so it is safe for a PII task too.
|
|
3114
3308
|
const checkout = mainCheckoutWarning(this.d.cwd, this.publicMetadataText);
|
|
3115
|
-
const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${last?.noWrites ? "no writes recorded since acceptance\n" : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${checkout ? `Note: ${checkout}.\n` : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
|
|
3309
|
+
const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${last?.noWrites ? "no writes recorded since acceptance\n" : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${checkout ? `Note: ${checkout}.\n` : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${carriedText}${conformanceText}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet, findings: [{id, pass: "bugs" | "security" | "compliance", severity: "important" | "nit", path?, disposition: "open" | "fixed" | "false_positive"}]}. Reuse finding ids when updating their disposition; only the person may accept a finding with a rationale.`;
|
|
3116
3310
|
this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) }));
|
|
3117
3311
|
}
|
|
3118
3312
|
|
|
@@ -3161,11 +3355,13 @@ export class Tasks {
|
|
|
3161
3355
|
} catch { return "Review brief unavailable: it could not be built."; }
|
|
3162
3356
|
}
|
|
3163
3357
|
|
|
3164
|
-
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown): Promise<Task> {
|
|
3358
|
+
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown, findings?: unknown): Promise<Task> {
|
|
3165
3359
|
const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
|
|
3360
|
+
let findingGuard: ((task: Task) => void) | undefined;
|
|
3166
3361
|
// #520: what the coverage was checked against, pinned through every await: the commit and the lifecycle receipt (the
|
|
3167
3362
|
// inherited reviewedHead lives there).
|
|
3168
3363
|
let covered: { commit: string | undefined; receipt: string } | undefined;
|
|
3364
|
+
let conformanceIdentity: string | undefined;
|
|
3169
3365
|
const valid = (): Task => {
|
|
3170
3366
|
const refused = authority?.();
|
|
3171
3367
|
if (refused) throw new PreEffectToolRefusal(refused); // rechecked after every await, right before each write
|
|
@@ -3174,13 +3370,42 @@ export class Tasks {
|
|
|
3174
3370
|
this.mine(t, by, "reviewer", true);
|
|
3175
3371
|
if (verdict !== "approved" && verdict !== "changes_requested") throw new PreEffectToolRefusal('verdict must be "approved" or "changes_requested"');
|
|
3176
3372
|
if (t.state !== "in_review") throw new PreEffectToolRefusal(`task #${t.id} is ${t.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
|
|
3373
|
+
if (conformanceIdentity !== undefined && conformanceIdentity !== this.planIdentity(t)) throw new PreEffectToolRefusal("plan conformance identity changed during verdict");
|
|
3374
|
+
findingGuard?.(t);
|
|
3177
3375
|
return t;
|
|
3178
3376
|
};
|
|
3377
|
+
if ((this.d.sdlc?.config().mode ?? "off") !== "off") conformanceIdentity = this.planIdentity(valid());
|
|
3179
3378
|
const items = textList(unmet);
|
|
3180
3379
|
if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
|
|
3181
3380
|
// #520: an approval accounts for every reviewable path of the brief. enforce refuses before any effect, advise records
|
|
3182
3381
|
// the gap in the verdict note, off and a person's own verdict skip it.
|
|
3183
3382
|
const mode = this.d.sdlc?.config().mode ?? "off";
|
|
3383
|
+
const initial = valid(), suppliedFindings = findings !== undefined;
|
|
3384
|
+
let parsed: ReviewFinding[];
|
|
3385
|
+
try { parsed = parseReviewFindings(findings, { person: by === USER, maxNits: this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS }); }
|
|
3386
|
+
catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid structured findings"); }
|
|
3387
|
+
const resolvedHead = this.findingHead(initial);
|
|
3388
|
+
let findingHead = resolvedHead;
|
|
3389
|
+
if (head !== undefined) {
|
|
3390
|
+
try { findingHead = sdlcReceipt({ reviewedHead: head }, initial.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined)).reviewedHead; }
|
|
3391
|
+
catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid finding head"); }
|
|
3392
|
+
}
|
|
3393
|
+
const report: ReviewFindingSet | undefined = suppliedFindings ? { ...(findingHead ? { head: findingHead } : {}), items: parsed } : undefined;
|
|
3394
|
+
const proposedSets = (task: Task) => [...this.reviewFindingSets(task), ...(report ? [report] : [])];
|
|
3395
|
+
if (head !== undefined || suppliedFindings || this.reviewFindingSets(initial).length) {
|
|
3396
|
+
const identity = (task: Task) => JSON.stringify({ owner: task.owner, reviewer: task.reviewer, commit: task.refs.commit, receipt: task.sdlc, resolvedHead: this.findingHead(task), private: this.isPii(task), findings: this.reviewFindingSets(task) });
|
|
3397
|
+
const original = identity(initial);
|
|
3398
|
+
findingGuard = task => {
|
|
3399
|
+
if (identity(task) !== original) throw new PreEffectToolRefusal("task review identity, privacy or findings changed during the verdict; review current work again");
|
|
3400
|
+
if (by !== USER && suppliedFindings && this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("structured review of ordinary work is unavailable during a private turn");
|
|
3401
|
+
if (parsed.filter(item => item.severity === "nit").length > (this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS)) throw new PreEffectToolRefusal("review nit cap exceeded");
|
|
3402
|
+
if (report?.items.length && !report.head && this.d.sdlc?.config().mode === "enforce") throw new PreEffectToolRefusal("a finding report requires a resolvable full reviewed head in enforce");
|
|
3403
|
+
const current = findingsForHead(proposedSets(task), findingHead);
|
|
3404
|
+
if (current.length > 64) throw new PreEffectToolRefusal("one reviewed head supports at most 64 distinct finding IDs");
|
|
3405
|
+
if (verdict === "approved" && this.d.sdlc?.config().mode === "enforce" && findingTally(current).openImportant) throw new PreEffectToolRefusal("approval blocked: open important review findings on this head");
|
|
3406
|
+
};
|
|
3407
|
+
findingGuard(initial);
|
|
3408
|
+
}
|
|
3184
3409
|
// A Tasks without a brief provider (library use; the daemon always has one) has no coverage gate.
|
|
3185
3410
|
if (verdict === "approved" && by !== USER && mode !== "off" && this.d.reviewBrief) {
|
|
3186
3411
|
const t = valid(), enforce = mode === "enforce", list = (paths: string[]) => `${paths.slice(0, 20).join(", ")}${paths.length > 20 ? ", ..." : ""}`;
|
|
@@ -3206,15 +3431,17 @@ export class Tasks {
|
|
|
3206
3431
|
if (gap.length) note = `${note ?? ""}\nCoverage gap (${gap.length}): ${list(gap)}`.trim();
|
|
3207
3432
|
}
|
|
3208
3433
|
}
|
|
3434
|
+
if (head !== undefined && resolvedHead && findingHead !== resolvedHead) throw new PreEffectToolRefusal("supplied review head differs from the task resolved head");
|
|
3209
3435
|
// The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
|
|
3210
|
-
const withheld = await this.withholds("review note", note, valid())
|
|
3436
|
+
const withheld = await this.withholds("review note", parsed.length ? JSON.stringify({ note, findings: parsed }) : note, valid())
|
|
3437
|
+
|| parsed.length > 0 && !this.findingTextAllowed(initial, parsed);
|
|
3211
3438
|
let task = valid();
|
|
3212
3439
|
let reviewedHead: string | undefined;
|
|
3213
3440
|
if (head !== undefined) {
|
|
3214
3441
|
const checked = sdlcReceipt({ reviewedHead: head }, task.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined));
|
|
3215
3442
|
reviewedHead = checked.reviewedHead;
|
|
3216
3443
|
}
|
|
3217
|
-
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead); task = valid(); }
|
|
3444
|
+
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid(); }
|
|
3218
3445
|
let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
|
|
3219
3446
|
if (verdict === "changes_requested" && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|
|
3220
3447
|
const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
|
|
@@ -3232,7 +3459,7 @@ export class Tasks {
|
|
|
3232
3459
|
const family = by === USER ? undefined : (this.d.adapterCatalog?.familyOf ?? familyOf)(by, this.d.instances?.());
|
|
3233
3460
|
const reviewerFamily = poolRequest?.reviewerFamily === family ? family : undefined;
|
|
3234
3461
|
const reviewerReason = reviewerFamily ? poolRequest?.reviewerReason : undefined;
|
|
3235
|
-
const flag = { withheld: withheld || undefined, ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
|
|
3462
|
+
const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
|
|
3236
3463
|
if (verdict === "approved") {
|
|
3237
3464
|
const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0, ...(reviewedHead && task.sdlc ? { sdlc: { ...task.sdlc, reviewedHead, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), "merge") } } : {}) }, note, flag);
|
|
3238
3465
|
if (next.owner) {
|
|
@@ -3248,9 +3475,12 @@ export class Tasks {
|
|
|
3248
3475
|
this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
|
|
3249
3476
|
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
|
|
3250
3477
|
await this.releaseDependents(next);
|
|
3478
|
+
// A proposal storage failure cannot roll back the saved verdict; another matching verdict retries it.
|
|
3479
|
+
this.releaseNotice(`task #${next.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(next, by, parsed, withheld));
|
|
3251
3480
|
return next;
|
|
3252
3481
|
}
|
|
3253
3482
|
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note, flag);
|
|
3483
|
+
this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
|
|
3254
3484
|
if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
|
|
3255
3485
|
try { this.d.memoryCheck?.(); this.d.candidates?.put({ ...candidate, evidenceRefs: [...candidate.evidenceRefs, `review:${rejected.id}:${rejected.history.length}`] }, "public"); } catch { /* a saved review remains authoritative if the optional sidecar fails */ }
|
|
3256
3486
|
}
|
package/src/hub/ui.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS } from "./planning.ts";
|
|
2
2
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
3
3
|
import { boardColumns } from "../ui/task-board.ts";
|
|
4
|
-
import { PEER_PALETTE,
|
|
4
|
+
import { PEER_PALETTE, peerPaletteThemeCss } from "../ui/peer-palette.ts";
|
|
5
5
|
import { PEER_START_RETRY_MS } from "./start-mode.ts";
|
|
6
6
|
import { taskProgress } from "../ui/task-progress.ts";
|
|
7
7
|
import { officeModel } from "../ui/office-model.ts";
|
|
@@ -37,7 +37,7 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
37
37
|
const html = readFileSync(new URL("../ui/index.html", import.meta.url), "utf8")
|
|
38
38
|
.replace("/* BOARD_MODEL */", () => `const boardColumns = ${boardColumns.toString()}; const peerPalette = ${JSON.stringify(PEER_PALETTE)};`)
|
|
39
39
|
.replace("/* ADAPTER_DISPLAY */", () => `const adapterDisplay = ${JSON.stringify(ADAPTER_DISPLAY)};`)
|
|
40
|
-
.replace("/* PEER_PALETTE_CSS */", () =>
|
|
40
|
+
.replace("/* PEER_PALETTE_CSS */", () => peerPaletteThemeCss())
|
|
41
41
|
.replace("/* TASK_PROGRESS_MODEL */", () => `const taskProgress = ${taskProgress.toString()};`)
|
|
42
42
|
.replace("/* PEER_START_RETRY_MS */", () => `const PEER_START_RETRY_MS = ${PEER_START_RETRY_MS};`)
|
|
43
43
|
.replace("/* OFFICE_MODEL */", () => `const officeModel = ((OFFICE_PEER_ORDER) => ${officeModel.toString()})(${JSON.stringify(OFFICE_PEER_ORDER)});`)
|