@staix/agent-hub 0.13.0 → 0.15.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 +30 -0
- package/README.md +19 -7
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +18 -4
- package/docs/agent-notes/budget.md +5 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +19 -7
- package/docs/agent-notes/tasks.md +15 -7
- package/docs/agent-notes/tests.md +4 -4
- package/docs/dogfood.md +18 -10
- package/docs/events.md +21 -7
- package/docs/operations.md +131 -27
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +13 -3
- package/docs/sdlc.md +40 -2
- package/docs/security.md +45 -14
- package/docs/specs/2026-09-19-agent-hub-design.md +112 -10
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +14 -0
- package/docs/verification/2026-10-23-0.14.0.md +69 -0
- package/docs/verification/2026-10-23-0.15.0.md +80 -0
- package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
- package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
- package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
- package/docs/verified.json +34 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1078 -327
- package/src/adapters/acp.ts +43 -7
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/claude-implementer.ts +243 -0
- package/src/adapters/claude-print-child.ts +90 -0
- package/src/adapters/claude-task-sessions.ts +80 -0
- package/src/adapters/codex-review-isolation.ts +18 -7
- package/src/adapters/compat.ts +4 -0
- package/src/adapters/conformance/enrollment.ts +2 -1
- package/src/adapters/conformance/fakes/acp-server.ts +3 -1
- package/src/adapters/definitions/builtin-metadata.ts +15 -3
- package/src/adapters/definitions/native-channel-state.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/kimi-wire-usage.ts +303 -0
- package/src/adapters/registry-types.ts +7 -3
- package/src/adapters/registry.ts +14 -2
- package/src/adapters/reviewer-print.ts +64 -60
- package/src/cli/console-config.ts +123 -0
- package/src/cli/console-state.ts +28 -1
- package/src/cli/console.ts +25 -1
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +6 -4
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +52 -9
- package/src/cli/output.ts +75 -2
- package/src/cli/reset.ts +1 -1
- package/src/cli/settings-edit.ts +89 -0
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/terminal-recovery.ts +1 -6
- package/src/hub/action-enforce.ts +82 -0
- package/src/hub/action-policy.ts +22 -10
- package/src/hub/board.ts +48 -3
- package/src/hub/budget.ts +5 -0
- package/src/hub/bus.ts +5 -5
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/config-apply.ts +5 -0
- package/src/hub/config-edit-host.ts +173 -0
- package/src/hub/config-edit.ts +282 -0
- package/src/hub/config-tier.ts +89 -0
- package/src/hub/control-client.ts +24 -3
- package/src/hub/daemon.ts +450 -71
- package/src/hub/deliverable-files.ts +131 -0
- package/src/hub/deliverables.ts +125 -0
- package/src/hub/delivery-journal.ts +18 -2
- package/src/hub/events.ts +3 -1
- package/src/hub/hub-tools.ts +21 -4
- package/src/hub/instances.ts +19 -6
- package/src/hub/kimi-usage.ts +144 -0
- package/src/hub/manager.ts +3 -3
- package/src/hub/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/routing.ts +118 -3
- package/src/hub/sdlc-metrics.ts +260 -0
- package/src/hub/sdlc.ts +5 -3
- package/src/hub/settings.ts +1 -1
- package/src/hub/spec-read.ts +31 -0
- package/src/hub/task-code-group.ts +52 -0
- package/src/hub/tasks.ts +374 -37
- package/src/hub/token-window.ts +33 -0
- package/src/hub/ui.ts +3 -0
- package/src/hub/usage-rollup.ts +74 -6
- package/src/hub/workflow.ts +16 -0
- package/src/ui/index.html +388 -3
package/src/hub/tasks.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { publicTestIntegrity, readTestIntegrity, testIntegrityBlockers, type Tes
|
|
|
4
4
|
import { completePlanInventory, planConformance, planConformanceBlockers, planFingerprint, type PlanConformance } from "./plan-conformance.ts";
|
|
5
5
|
import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
|
|
6
6
|
import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
|
|
7
|
-
import { specRefs } from "./spec-read.ts";
|
|
7
|
+
import { qualifiedTitleRepos, siblingTitleRefs, specRefs } from "./spec-read.ts";
|
|
8
|
+
import { sdlcMetrics, type BandResult, type MetricTask, type MetricsReport } from "./sdlc-metrics.ts";
|
|
8
9
|
import type { InstancesConfig } from "./instances.ts";
|
|
9
10
|
import { composeRememberText } from "./memory-fail.ts";
|
|
10
11
|
import { VERSION } from "../version.ts";
|
|
@@ -20,7 +21,9 @@ import { correctionInput, resolveVisibleMemory } from "./memory-candidates.ts";
|
|
|
20
21
|
import type { MemoryScreen } from "./read-tools.ts";
|
|
21
22
|
import { withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
22
23
|
import { profileClass, profileData, profileTaskLabel, projectProfile, type ProfileName, type ProfileSelection, type ProjectProfile } from "./profiles.ts";
|
|
23
|
-
import { criteriaView, workflowHash } from "./workflow.ts";
|
|
24
|
+
import { criteriaView, unmetCriteriaIds, workflowHash } from "./workflow.ts";
|
|
25
|
+
import { approvedDeliverables, latestDeliverables, parseDeliverableClaims, publicDeliverables, type DeliverableRecord, type DeliverableStatus } from "./deliverables.ts";
|
|
26
|
+
import { checkDeliverables, DeliverableRefusal, hashDeliverables, vetDeliverables, type DeliverablePorts } from "./deliverable-files.ts";
|
|
24
27
|
import { isAbsolute, relative } from "node:path";
|
|
25
28
|
import type { Briefs, NativeItem } from "../memory/brief.ts";
|
|
26
29
|
import type { MemoryClient } from "../memory/client.ts";
|
|
@@ -65,6 +68,13 @@ export interface TasksDeps {
|
|
|
65
68
|
profile?: ProfileName;
|
|
66
69
|
profileData?: ProjectProfile;
|
|
67
70
|
piiPaths?: string[];
|
|
71
|
+
/** The project's secrets denylist (`local.deny`), for the deliverable file reader (#509a). */
|
|
72
|
+
deny?: string[];
|
|
73
|
+
/**
|
|
74
|
+
* #509a: the root a peer's files live under: its verified execution root (the daemon's `ownerWriteRoot`), which for a declared worktree
|
|
75
|
+
* instance is not the project checkout. `undefined` means the root is not proven right now. Absent (library use): the project root.
|
|
76
|
+
*/
|
|
77
|
+
executionRoot?: (peer: PeerId) => string | undefined;
|
|
68
78
|
catalog?: RoleCatalog;
|
|
69
79
|
/** Role ids whose contract was prepared for the current native session. */
|
|
70
80
|
sessionRoles?: (peer: PeerId) => string[];
|
|
@@ -91,6 +101,8 @@ export interface TasksDeps {
|
|
|
91
101
|
reviewOnly?: (peer: PeerId) => boolean;
|
|
92
102
|
/** #223: captured when a verdict call starts; a non-undefined result refuses the verdict at every later check. */
|
|
93
103
|
reviewAuthority?: (by: PeerId, task: number) => (() => string | undefined) | undefined;
|
|
104
|
+
/** Host-captured current-child authority, rechecked before an admitted async tool first commits effects. */
|
|
105
|
+
requestAuthority?: (by: PeerId) => (() => string | undefined) | undefined;
|
|
94
106
|
memory?: MemoryClient;
|
|
95
107
|
/** Captured native profile fence at our memory operation boundaries; no board mutation. */
|
|
96
108
|
memoryCheck?: () => void;
|
|
@@ -182,6 +194,11 @@ const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
|
182
194
|
const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
|
|
183
195
|
QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits for its reserved owner's WIP slot.
|
|
184
196
|
QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
|
|
197
|
+
/** The records of the `done (checking)` entry a passed check now completes: what the owner delivered then (#509a). */
|
|
198
|
+
const latestPendingDeliverables = (task: Task): DeliverableRecord[] | undefined => {
|
|
199
|
+
const checking = task.history.findLastIndex(entry => entry.event === "done (checking)");
|
|
200
|
+
return checking >= 0 && !task.history.slice(checking + 1).some(entry => entry.event === "done") ? task.history[checking]!.deliverables : undefined;
|
|
201
|
+
};
|
|
185
202
|
|
|
186
203
|
/**
|
|
187
204
|
* Thrown when an assignment's board write is saved but a publish or notify after it failed (#297): the move is
|
|
@@ -357,6 +374,12 @@ export class Tasks {
|
|
|
357
374
|
};
|
|
358
375
|
|
|
359
376
|
/** One bounded screen call. A screener that fails is no verdict, handled as PII (fail closed). */
|
|
377
|
+
private requestCheck(by: PeerId): () => void {
|
|
378
|
+
const authority = this.d.requestAuthority?.(by);
|
|
379
|
+
const check = () => { const reason = authority?.(); if (reason) throw new PreEffectToolRefusal(reason); };
|
|
380
|
+
check(); return check;
|
|
381
|
+
}
|
|
382
|
+
|
|
360
383
|
private async screenCall(text: string): Promise<PiiVerdict> {
|
|
361
384
|
return (await this.d.piiScreen?.(text).catch(() => undefined)) ?? { label: "unknown", miss: "failed", ms: 0 };
|
|
362
385
|
}
|
|
@@ -558,6 +581,31 @@ export class Tasks {
|
|
|
558
581
|
}
|
|
559
582
|
|
|
560
583
|
/** Host-authored proposals contain only cleared IDs, integer task references and fixed instruction targets. */
|
|
584
|
+
/**
|
|
585
|
+
* #736: how many separate pieces of work the tasks are. Tasks that share a PR, a branch in one repository, or a
|
|
586
|
+
* supersession (a cancelled task names its replacement) are one piece of work, as an approve-as-step follow-up is: a
|
|
587
|
+
* finding carried between them is the same finding, not a recurrence.
|
|
588
|
+
*/
|
|
589
|
+
private workUnits(rows: readonly Task[]): number {
|
|
590
|
+
const parent = new Map<number, number>();
|
|
591
|
+
const find = (id: number): number => { let at = id; while (parent.has(at)) at = parent.get(at)!; return at; };
|
|
592
|
+
const join = (a: number, b: number) => { const x = find(a), y = find(b); if (x !== y) parent.set(x, y); };
|
|
593
|
+
const first = new Map<string, number>(), ids = new Set(rows.map(row => row.id));
|
|
594
|
+
for (const row of rows) {
|
|
595
|
+
// Repository identity comes from the board's own resolver, so aliases match and an outside path never falls back to this project.
|
|
596
|
+
const prRepo = row.sdlc?.pr ? this.repositories.scope(row.sdlc.repo ? { repo: row.sdlc.repo, paths: row.refs.paths } : row.refs).key : "";
|
|
597
|
+
const branchRepo = row.refs.branch ? this.repositories.scope(row.refs).key : "";
|
|
598
|
+
for (const key of [prRepo ? `pr ${prRepo}#${row.sdlc!.pr}` : "", branchRepo ? `branch ${branchRepo} ${row.refs.branch}` : ""]) {
|
|
599
|
+
if (!key) continue;
|
|
600
|
+
const seen = first.get(key);
|
|
601
|
+
if (seen === undefined) first.set(key, row.id); else join(row.id, seen);
|
|
602
|
+
}
|
|
603
|
+
// superseded_by is free text ("task #73", "#73" or "73"); the first task number in it names the replacement.
|
|
604
|
+
const next = Number(/#?(\d+)/.exec(row.cancelled?.supersededBy ?? "")?.[1]);
|
|
605
|
+
if (ids.has(next)) join(row.id, next);
|
|
606
|
+
}
|
|
607
|
+
return new Set(rows.map(row => find(row.id))).size;
|
|
608
|
+
}
|
|
561
609
|
private proposeRecurringFindings(task: Task, by: PeerId, items: readonly ReviewFinding[], withheld: boolean): void {
|
|
562
610
|
if (withheld || this.isPii(task) || this.d.privateTurn?.(by) || !items.length || !this.findingTextAllowed(task, items)) return;
|
|
563
611
|
const rows = this.d.board.list();
|
|
@@ -573,7 +621,7 @@ export class Tasks {
|
|
|
573
621
|
const finding = last?.reviewFindings?.items.find(value => value.id === item.id);
|
|
574
622
|
return !last?.withheld && finding && same(finding) && finding.disposition !== "false_positive" && this.findingTextAllowed(row, [finding]);
|
|
575
623
|
});
|
|
576
|
-
if (
|
|
624
|
+
if (this.workUnits(holders) < 2) continue;
|
|
577
625
|
const key = `Review finding key: ${item.pass}:${item.id}`;
|
|
578
626
|
if (keys.has(key)) continue;
|
|
579
627
|
const title = `Review policy amendment for recurring finding ${item.pass}:${item.id}`;
|
|
@@ -607,6 +655,8 @@ export class Tasks {
|
|
|
607
655
|
...(h.owner === undefined ? {} : { owner: h.owner }),
|
|
608
656
|
...(h.reviewerFamily ? { reviewerFamily: screenHistoryText(h.reviewerFamily, "reviewer family") } : {}),
|
|
609
657
|
...(h.reviewerReason ? { reviewerReason: h.reviewerReason } : {}),
|
|
658
|
+
...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
|
|
659
|
+
...(h.deliverables?.length ? { deliverables: publicDeliverables(h.deliverables, this.nameable) } : {}),
|
|
610
660
|
...(h.reviewBlocked ? { reviewBlocked: true } : {}),
|
|
611
661
|
...(h.reviewerFailure ? { reviewerFailure: { peer: screenHistoryText(h.reviewerFailure.peer, "reviewer"), cause: h.reviewerFailure.cause, ...(h.reviewerFailure.head ? { head: screenHistoryText(h.reviewerFailure.head, "review head") } : {}) } } : {}),
|
|
612
662
|
...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
|
|
@@ -744,10 +794,19 @@ export class Tasks {
|
|
|
744
794
|
}
|
|
745
795
|
|
|
746
796
|
/** What assignment weighs besides states and policy: quota, demotion and the review record. */
|
|
797
|
+
/**
|
|
798
|
+
* #525-3: the settled planner holders, sorted: the one input of the planner walk. It comes from the same settled role map as planner
|
|
799
|
+
* authority (`d.roles`), restricted to rostered peers, so assignment, planner notices and `route explain` agree on who the holders are.
|
|
800
|
+
* Read-only for now: the walk's result is shown and recorded nowhere until 525-2.
|
|
801
|
+
*/
|
|
802
|
+
private plannerHolders(): PeerId[] {
|
|
803
|
+
return Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => Array.isArray(roles) && roles.includes("planner") && this.d.bus.isRostered(peer)).map(([peer]) => peer).sort();
|
|
804
|
+
}
|
|
805
|
+
|
|
747
806
|
private weights(cls: TaskClass, task?: Task) {
|
|
748
807
|
const now = Date.now();
|
|
749
808
|
const quota = this.d.quota?.();
|
|
750
|
-
return { ...this.health(), ...(task ? { reviewPool: this.reviewPoolOptions(task) } : {}), now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
|
|
809
|
+
return { ...this.health(), planners: this.plannerHolders(), ...(task ? { reviewPool: this.reviewPoolOptions(task) } : {}), now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
|
|
751
810
|
}
|
|
752
811
|
|
|
753
812
|
/**
|
|
@@ -892,11 +951,11 @@ export class Tasks {
|
|
|
892
951
|
}
|
|
893
952
|
return parts.length ? `no peer can take the planner pull: ${parts.join("; ")}` : "no peer has eligible planner WIP capacity";
|
|
894
953
|
}
|
|
895
|
-
private plannerAssignment(task: Task, reservedOnly = false): Assignment {
|
|
954
|
+
private plannerAssignment(task: Task, reservedOnly = false, routing: Routing = this.d.routing()): Assignment {
|
|
896
955
|
const states = this.states();
|
|
897
956
|
const full = this.plannerFull(task);
|
|
898
957
|
// Preview the To Do transition without routing or offering the persisted backlog row.
|
|
899
|
-
return assign({ ...task, state: "proposed" }, states,
|
|
958
|
+
return assign({ ...task, state: "proposed" }, states, routing, { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
|
|
900
959
|
}
|
|
901
960
|
|
|
902
961
|
async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
|
|
@@ -921,14 +980,25 @@ export class Tasks {
|
|
|
921
980
|
return !!competing && (competing.rank < order.rank || competing.rank === order.rank && competing.id < order.id);
|
|
922
981
|
})) throw new PreEffectToolRefusal("a person's P0 backlog order is retained");
|
|
923
982
|
}
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
if (
|
|
983
|
+
if (task.reserved && this.excluded(task).includes(task.reserved)) throw new PreEffectToolRefusal(this.plannerRefusal(task));
|
|
984
|
+
const assignment = this.plannerAssignment(task, !!task.reserved);
|
|
985
|
+
if (!assignment.owner) {
|
|
986
|
+
const refusal = task.reserved ? `reserved peer ${task.reserved} cannot take the planner pull: ${assignment.refusals?.find(row => row.role === "owner" && row.peer === task.reserved)?.reason ?? "not eligible"}; ${this.plannerRefusal(task)}` : this.plannerRefusal(task);
|
|
987
|
+
throw new PreEffectToolRefusal(`${this.hasPersonReservation(task) ? "a person's reservation is retained; " : ""}${refusal}`);
|
|
988
|
+
}
|
|
927
989
|
return { task, owner: assignment.owner };
|
|
928
990
|
};
|
|
929
991
|
const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
|
|
930
992
|
authorized();
|
|
931
993
|
const current = validate();
|
|
994
|
+
if (current.task.reserved) {
|
|
995
|
+
return this.assignOwner(current.task, by, { candidates: [current.owner], namedOwner: current.owner, planner: true, refuseNamed: "pre",
|
|
996
|
+
plannerPull: { revision: input.revision, note: input.reason.trim(), withheld, guard: () => {
|
|
997
|
+
authorized();
|
|
998
|
+
const checked = validate();
|
|
999
|
+
if (checked.task.history.length !== current.task.history.length || checked.owner !== current.owner) throw new PreEffectToolRefusal("planner pull changed during admission");
|
|
1000
|
+
} } });
|
|
1001
|
+
}
|
|
932
1002
|
const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld);
|
|
933
1003
|
this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
|
|
934
1004
|
return pulled;
|
|
@@ -1370,6 +1440,7 @@ export class Tasks {
|
|
|
1370
1440
|
}
|
|
1371
1441
|
|
|
1372
1442
|
async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; role?: unknown; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown; backlog?: unknown; goal?: unknown }, external?: GitHubSource, created?: () => void): Promise<Task> {
|
|
1443
|
+
const requestCheck = this.requestCheck(by);
|
|
1373
1444
|
external = external ? { ...external } : undefined;
|
|
1374
1445
|
if (external && (by !== USER || input.backlog !== true)) throw new PreEffectToolRefusal("external intake requires person-owned backlog staging");
|
|
1375
1446
|
if (input.backlog !== undefined && typeof input.backlog !== "boolean") throw new PreEffectToolRefusal("backlog must be a boolean");
|
|
@@ -1414,6 +1485,7 @@ export class Tasks {
|
|
|
1414
1485
|
// board, in no envelope and before no other model. A pattern match is PII without a call; no verdict is PII too.
|
|
1415
1486
|
const screening = this.screenOn();
|
|
1416
1487
|
const screened = screening && !signals.includes("pii") ? await this.screenCall(screenText({ ...text, detail: screenDetail, plan })) : undefined;
|
|
1488
|
+
requestCheck();
|
|
1417
1489
|
if (screened && screened.label !== "clear") signals.push("pii");
|
|
1418
1490
|
let cls = baseGiven;
|
|
1419
1491
|
let triaged = false;
|
|
@@ -1421,8 +1493,11 @@ export class Tasks {
|
|
|
1421
1493
|
if (!cls && this.d.triage) {
|
|
1422
1494
|
// Signals first: a PII task's text may only go to a model that is reached without leaving the campus network.
|
|
1423
1495
|
const pii = this.isPii({ signals });
|
|
1424
|
-
|
|
1496
|
+
const reached = !pii || await this.d.triage.onCampus().catch(() => false);
|
|
1497
|
+
requestCheck();
|
|
1498
|
+
if (reached) cls = await this.d.triage.classify(text.title, text.detail).catch(() => undefined);
|
|
1425
1499
|
else fenced = true;
|
|
1500
|
+
requestCheck();
|
|
1426
1501
|
triaged = !!cls;
|
|
1427
1502
|
}
|
|
1428
1503
|
// A claim is work the caller will do itself: without a class and a model to name one, it is implementation (#6).
|
|
@@ -1438,11 +1513,13 @@ export class Tasks {
|
|
|
1438
1513
|
if (reserved) this.requireCapabilities({ ...draft, signals, ...(external ? { external } : {}) }, reserved);
|
|
1439
1514
|
this.warmRepository({ refs: draft.refs, plan });
|
|
1440
1515
|
const repositoryMismatch = !this.isPii({ signals }) && this.repositories.mismatch(draft.refs, plan);
|
|
1516
|
+
requestCheck();
|
|
1441
1517
|
let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
|
|
1442
1518
|
if (repositoryMismatch) this.releaseNotice(`task #${task.id}: refs and plan name different repositories; check refs.repo before editing`);
|
|
1443
1519
|
if (this.d.coordination) this.coordinationModes.set(task.id, this.d.coordination());
|
|
1444
1520
|
created?.();
|
|
1445
1521
|
if (screening) task = this.screenedTask(task, screened);
|
|
1522
|
+
task = this.linkOnWrite(task); // #723
|
|
1446
1523
|
if (goal) this.linkNative(task, goal, by);
|
|
1447
1524
|
if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
|
|
1448
1525
|
if (defaulted) task = this.d.board.update(task.id, "hub", "class defaulted", {}, "class implement for a claim without one");
|
|
@@ -1855,7 +1932,9 @@ export class Tasks {
|
|
|
1855
1932
|
const task = this.d.board.get(target);
|
|
1856
1933
|
if (!task) throw new Error(`no task #${target}`);
|
|
1857
1934
|
if (isClosedTaskState(task.state)) return [...workflow, `task ${this.publicTitle(task)} (${task.state}): closed; not routed`];
|
|
1858
|
-
const
|
|
1935
|
+
const planned = task.state === "backlog" && !!task.reserved || this.plannerPullRecord(task)?.pull === true;
|
|
1936
|
+
const a = planned ? this.plannerAssignment(task, true, routing)
|
|
1937
|
+
: assign(task, this.states(), routing, { exclude: this.excluded(task), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
|
|
1859
1938
|
// The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
|
|
1860
1939
|
return [...workflow, `task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"}${task.reserved ? `, reserved for ${task.reserved}` : ""})`, ...(task.profileClass ? [`profile class ${profileTaskLabel(task)}`] : []), ...(task.role ? [`task role ${task.role} (guidance only)`] : []), "if it were assigned now:", ...a.trace, ...(this.splitShadow(task, task.owner ?? a.owner)?.trace ?? [])];
|
|
1861
1940
|
}
|
|
@@ -1943,14 +2022,17 @@ export class Tasks {
|
|
|
1943
2022
|
}
|
|
1944
2023
|
|
|
1945
2024
|
/** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
|
|
1946
|
-
private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number; refuseNamed?: "pre" | "plain" } = {}): Promise<Task> {
|
|
1947
|
-
if (task.state === "backlog") throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
|
|
2025
|
+
private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number; refuseNamed?: "pre" | "plain"; plannerPull?: { revision: number; note: string; withheld: boolean; guard: () => void } } = {}): Promise<Task> {
|
|
2026
|
+
if (task.state === "backlog" && !opts.plannerPull) throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
|
|
1948
2027
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
1949
2028
|
let stagedClaimGap: string | undefined;
|
|
1950
2029
|
if (mode !== "off" && this.isBuildTask(task)) {
|
|
1951
2030
|
const gap = task.sdlc?.planReview ? await this.planReviewGap(task) : "plan not reviewed since proposal";
|
|
1952
2031
|
const current = this.d.board.get(task.id);
|
|
1953
|
-
if (!current || !this.ownerAssignmentCurrent(task, current))
|
|
2032
|
+
if (!current || !this.ownerAssignmentCurrent(task, current)) {
|
|
2033
|
+
if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull changed during admission");
|
|
2034
|
+
return current ?? task;
|
|
2035
|
+
}
|
|
1954
2036
|
task = current; // Reviewer-only and quiet bookkeeping updates do not supersede ownership authority.
|
|
1955
2037
|
if (gap) {
|
|
1956
2038
|
this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}; planner: call hub_planner_groom then hub_plan_review`);
|
|
@@ -1967,9 +2049,10 @@ export class Tasks {
|
|
|
1967
2049
|
const waits = this.waitsFor(task);
|
|
1968
2050
|
const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
|
|
1969
2051
|
const plannerFull = plannedMove ? this.plannerFull(task) : [];
|
|
1970
|
-
const a = assign(task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class, task), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}) });
|
|
2052
|
+
const a = assign(opts.plannerPull ? { ...task, state: "proposed" } : task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class, task), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}) });
|
|
1971
2053
|
|
|
1972
2054
|
if (waits.length) {
|
|
2055
|
+
if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
|
|
1973
2056
|
this.releaseNotice(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
|
|
1974
2057
|
return task;
|
|
1975
2058
|
}
|
|
@@ -1995,7 +2078,13 @@ export class Tasks {
|
|
|
1995
2078
|
// A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
|
|
1996
2079
|
// out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
|
|
1997
2080
|
const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
|
|
1998
|
-
|
|
2081
|
+
opts.plannerPull?.guard();
|
|
2082
|
+
const assignmentNote = this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a);
|
|
2083
|
+
const assignmentExtra = { ...this.reviewMetadata(a), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) };
|
|
2084
|
+
const next = opts.plannerPull
|
|
2085
|
+
? this.d.board.pullAssigned(task.id, by, a.owner, opts.plannerPull.revision, opts.plannerPull.note, opts.plannerPull.withheld, a.reviewer ?? null, assignmentNote, assignmentExtra, task.history.length)
|
|
2086
|
+
: this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, assignmentNote, assignmentExtra, opts.expectedFlowRevision);
|
|
2087
|
+
if (opts.plannerPull) this.planningRecorded({ by, task: next.id, fields: ["pull", "reserved"], revision: next.planningRevision! });
|
|
1999
2088
|
// What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
|
|
2000
2089
|
// escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
|
|
2001
2090
|
// yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
|
|
@@ -2009,6 +2098,11 @@ export class Tasks {
|
|
|
2009
2098
|
// The cohort record formed just below reads it, and so does `route explain` while that turn lasts.
|
|
2010
2099
|
if (opts.claim && a.owner === by && this.d.bus.peers.get(by)?.state === "busy") this.sent.set(handOver(next, by), by);
|
|
2011
2100
|
try {
|
|
2101
|
+
if (opts.plannerPull && task.offer) {
|
|
2102
|
+
const prior = { ...task, owner: task.offer.owner };
|
|
2103
|
+
this.withdrawRoleRequest(prior, next, "owner");
|
|
2104
|
+
this.releaseNotice(`task #${next.id}: stale offer withdrawal failed`, () => this.d.bus.withdraw(task.offer!.envelopeId));
|
|
2105
|
+
}
|
|
2012
2106
|
if (by === USER && opts.event === "reassigned") this.withdrawRoleRequest(task, next, "owner");
|
|
2013
2107
|
const hits = this.overlapHits(next);
|
|
2014
2108
|
this.formCohort(next, hits);
|
|
@@ -2379,6 +2473,25 @@ export class Tasks {
|
|
|
2379
2473
|
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`);
|
|
2380
2474
|
}
|
|
2381
2475
|
|
|
2476
|
+
/**
|
|
2477
|
+
* #723: link the issue a task names the moment it is proposed or accepted, in advise and enforce, so the lifecycle never
|
|
2478
|
+
* waits for a groom. It is a local parse with no GitHub read, and it never refuses or fails the write it follows.
|
|
2479
|
+
*/
|
|
2480
|
+
private linkOnWrite(task: Task): Task {
|
|
2481
|
+
try { return (this.d.sdlc?.config().mode ?? "off") === "off" ? task : this.linkIssue(task); }
|
|
2482
|
+
catch { return task; }
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
/**
|
|
2486
|
+
* #723: a title that qualifies another repository (`agent-mem#13`, `owner/repo#N`) while `refs.repo` is absent cannot link
|
|
2487
|
+
* (the identity needs the repository), so the proposer is told to set it. Advice only; a PII task gets none.
|
|
2488
|
+
*/
|
|
2489
|
+
refsRepoHint(task: Task): string | undefined {
|
|
2490
|
+
if (this.isPii(task) || task.refs.repo || task.sdlc?.issue || (this.d.sdlc?.config().mode ?? "off") === "off") return undefined;
|
|
2491
|
+
const names = qualifiedTitleRepos(task.title);
|
|
2492
|
+
return names.length ? `task #${task.id}: the title names ${names.join(", ")}; set refs.repo to that repository (owner/name) so its issue links and the lifecycle shows it` : undefined;
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2382
2495
|
/** #694: write the one issue the task itself names (the plan-review source) into its lifecycle receipt, once; never relinks. */
|
|
2383
2496
|
private linkIssue(task: Task): Task {
|
|
2384
2497
|
if (this.isPii(task) || task.sdlc?.issue) return task;
|
|
@@ -2428,12 +2541,16 @@ export class Tasks {
|
|
|
2428
2541
|
let issues: number[];
|
|
2429
2542
|
try { issues = specRefs(task, repository); }
|
|
2430
2543
|
catch { throw new PreEffectToolRefusal("plan review issue identity unavailable"); }
|
|
2544
|
+
// #723: a title that qualifies the sibling repository `refs.repo` names (`agent-mem#13`) is a local parse, never a read.
|
|
2545
|
+
// A sibling shorthand next to a bare #N is two identities, refused below, never the bare one alone.
|
|
2546
|
+
issues = [...new Set([...issues, ...siblingTitleRefs(task.title, task.refs.repo)])];
|
|
2431
2547
|
if (issues.length > 1) throw new PreEffectToolRefusal("plan review needs one explicit issue identity");
|
|
2432
2548
|
return issues[0] ? { repo: repository, issue: issues[0] } : undefined;
|
|
2433
2549
|
}
|
|
2434
2550
|
|
|
2435
2551
|
async groomingDigest(by: PeerId): Promise<Record<string, unknown>> {
|
|
2436
2552
|
this.planningAuthority(by);
|
|
2553
|
+
this.backfillIssueLinks(by); // #723: first, bounded, before the slow reads
|
|
2437
2554
|
const included = (task: Task) => !isClosedTaskState(task.state) || task.state === "cancelled" && task.history.some(entry => !!entry.supersededBy);
|
|
2438
2555
|
const snapshot = this.d.board.list().filter(included), issues = new Map<string, PlanIssueMetadata>(), attempted = new Set<string>();
|
|
2439
2556
|
const deadline = performance.now() + 4_000;
|
|
@@ -2473,17 +2590,114 @@ export class Tasks {
|
|
|
2473
2590
|
const digest = plannerGrooming(visible, { repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
|
|
2474
2591
|
limits: Object.fromEntries(peers.map(peer => [peer, this.wipLimit(peer)])), wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])),
|
|
2475
2592
|
states, issues, reviewedIssues, acceptedIssues, blockers });
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2593
|
+
return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
/**
|
|
2597
|
+
* #694, #723: backfill lifecycle issue links with their own bounds, run first and apart from the slow metadata reads and
|
|
2598
|
+
* the digest, so a groom that later times out has still linked. Each call links up to 32 open tasks that still lack one
|
|
2599
|
+
* within a one-second budget, so later tasks are reached on later calls. A local parse; a failure skips that task only.
|
|
2600
|
+
*/
|
|
2601
|
+
private backfillCursor = 0;
|
|
2602
|
+
private backfillIssueLinks(by: PeerId): number {
|
|
2603
|
+
if ((this.d.sdlc?.config().mode ?? "off") === "off") return 0;
|
|
2604
|
+
// ponytail: 32 links and 1 s per call; raise both only with a measured groom time on a 400-task board.
|
|
2605
|
+
const deadline = performance.now() + 1_000;
|
|
2606
|
+
let linked = 0, i = 0;
|
|
2607
|
+
const list = this.d.board.list(), n = list.length;
|
|
2608
|
+
// The scan resumes where the last call stopped, so rows that never link cannot hold later ones back.
|
|
2609
|
+
for (; i < n; i++) {
|
|
2610
|
+
if (linked >= 32 || performance.now() >= deadline) break;
|
|
2611
|
+
const task = list[(this.backfillCursor + i) % n]!;
|
|
2612
|
+
if (isClosedTaskState(task.state) || task.sdlc?.issue || this.isPii(task)) continue;
|
|
2613
|
+
this.planningAuthority(by);
|
|
2614
|
+
try { if (this.linkIssue(task) !== task) linked++; } catch { /* one task never stops the rest */ }
|
|
2615
|
+
}
|
|
2616
|
+
this.backfillCursor = n ? (this.backfillCursor + i) % n : 0;
|
|
2617
|
+
return linked;
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
/**
|
|
2621
|
+
* #481 S7: the SDLC metrics from board history, read-only. Private tasks are counted as withheld and read for nothing else; PR
|
|
2622
|
+
* readbacks are not made here (the cost report owns them), so merge time and same-head reruns read `unknown` unless given.
|
|
2623
|
+
* Detection never calls a model and the report writes nothing.
|
|
2624
|
+
*/
|
|
2625
|
+
sdlcMetrics(by: PeerId, since?: unknown, merged?: { mergedAt?: Record<number, number>; ciReruns?: Record<number, number | null> }): MetricsReport {
|
|
2626
|
+
this.planningAuthority(by);
|
|
2627
|
+
if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("metrics unavailable during private work");
|
|
2628
|
+
let cutoff: number | undefined;
|
|
2629
|
+
if (since !== undefined && since !== null && since !== "") {
|
|
2630
|
+
cutoff = typeof since === "number" ? since : /^\d+$/.test(String(since)) ? Number(since) : Date.parse(String(since));
|
|
2631
|
+
if (!Number.isFinite(cutoff) || cutoff < 0) throw new PreEffectToolRefusal("since must be a time (YYYY-MM-DD or an ISO timestamp) or milliseconds");
|
|
2632
|
+
}
|
|
2633
|
+
const rows = this.d.board.list();
|
|
2634
|
+
const tasks: MetricTask[] = rows.map(task => ({ id: task.id, private: this.isPii(task), history: task.history.map(entry => ({ at: entry.at, event: entry.event,
|
|
2635
|
+
...(entry.reviewCost?.head ? { requestHead: entry.reviewCost.head } : {}), ...(entry.reviewFindings?.head ? { verdictHead: entry.reviewFindings.head } : {}) })) }));
|
|
2636
|
+
const visibleIds = new Set(rows.filter(task => !this.isPii(task)).map(task => task.id));
|
|
2637
|
+
const byKind: Record<string, number> = {};
|
|
2638
|
+
for (const stall of this.stalled()) if (visibleIds.has(stall.task)) byKind[stall.cause.kind] = (byKind[stall.cause.kind] ?? 0) + 1;
|
|
2639
|
+
const stalls = { open: Object.values(byKind).reduce((sum, n) => sum + n, 0), byKind, doneWithoutWrites: rows.filter(task => visibleIds.has(task.id) && task.history.findLast(entry => entry.event === "done")?.noWrites).length };
|
|
2640
|
+
return sdlcMetrics(tasks, { ...(cutoff !== undefined ? { since: cutoff } : {}), ...(this.d.sdlc?.config().bands ? { bands: this.d.sdlc.config().bands } : {}), stalls, ...(merged ?? {}) });
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
/**
|
|
2644
|
+
* #481 S7: act on the band tiers, deterministically and once per breach episode. An episode is a run of non-ok values for one metric
|
|
2645
|
+
* (`BandResult.episode`, derived from history), so a sustained shift is one episode however many tasks finish inside it. `log` is one
|
|
2646
|
+
* hub notice, `diagnose` one read-only `triage` backlog task and `propose` one `plan` backlog card naming the metric and its evidence.
|
|
2647
|
+
* A metric with an open band card (a backlog or open task whose detail carries its key) writes nothing more, so a value that grows
|
|
2648
|
+
* from diagnose to propose inside one episode does not add a second card. Held while the hub is stopping, recovering or has a
|
|
2649
|
+
* storage error, like every host writer. The text is fixed words, numbers and task ids, never task text, and no GitHub issue is
|
|
2650
|
+
* created. Returns the task ids created.
|
|
2651
|
+
*/
|
|
2652
|
+
/**
|
|
2653
|
+
* Whether `proposeFromBands` would run now, and a signature of what it would read. The daemon timer records a board revision only for a
|
|
2654
|
+
* pass that ran, and re-runs when the signature changes (a reload that enables bands or edits them on an idle board).
|
|
2655
|
+
*/
|
|
2656
|
+
bandsState(): { ready: boolean; signature: string } {
|
|
2657
|
+
const config = this.d.sdlc?.config();
|
|
2658
|
+
const configured = !!config && config.mode !== "off" && !!config.bands && Object.keys(config.bands).length > 0;
|
|
2659
|
+
return { ready: configured && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError, signature: configured ? JSON.stringify([config!.mode, config!.bands]) : "" };
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
proposeFromBands(by: PeerId = HUB): number[] {
|
|
2663
|
+
const config = this.d.sdlc?.config();
|
|
2664
|
+
if (!config || config.mode === "off" || !config.bands || !Object.keys(config.bands).length) return [];
|
|
2665
|
+
if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return [];
|
|
2666
|
+
if (by !== HUB && by !== USER) this.planningAuthority(by);
|
|
2667
|
+
const report = this.sdlcMetrics(by === HUB ? USER : by);
|
|
2668
|
+
if (report.bands === "off") return [];
|
|
2669
|
+
const rows = this.d.board.list();
|
|
2670
|
+
const bandRows = rows.filter(row => row.history[0]?.by === HUB && row.detail?.startsWith("Band breach key: "));
|
|
2671
|
+
const keyOf = (row: Task) => row.detail!.split("\n")[0]!.slice("Band breach key: ".length);
|
|
2672
|
+
const seen = new Set(bandRows.map(keyOf));
|
|
2673
|
+
// A card counts as open while its task is not closed: the planner has not yet decided on it.
|
|
2674
|
+
const open = new Set(bandRows.filter(row => !isClosedTaskState(row.state)).map(row => keyOf(row).split(":")[0]!));
|
|
2675
|
+
const created: number[] = [];
|
|
2676
|
+
for (const band of report.bands as BandResult[]) {
|
|
2677
|
+
if (band.tier === "ok" || band.lastTask === null || band.episode === null) continue;
|
|
2678
|
+
const key = `${band.metric}:#${band.episode}`;
|
|
2679
|
+
if (band.tier === "log") {
|
|
2680
|
+
const marker = `${key}:log`;
|
|
2681
|
+
if (!open.has(band.metric) && this.d.board.recordBandNotice(marker)) this.releaseNotice(`sdlc band: ${band.metric} is ${band.reason}; last value ${band.last} on task #${band.lastTask}`);
|
|
2682
|
+
continue;
|
|
2484
2683
|
}
|
|
2684
|
+
if (seen.has(`${key}:propose`) || seen.has(`${key}:diagnose`) || open.has(band.metric)) continue;
|
|
2685
|
+
const tier = band.tier === "propose" ? "propose" : "diagnose";
|
|
2686
|
+
const full = `${key}:${tier}`;
|
|
2687
|
+
const evidence = `metric ${band.metric}; newest value ${band.last} on task #${band.lastTask}; episode began at task #${band.episode}; baseline mean ${band.mean}, deviation ${band.sd} over ${band.baseline} tasks; ${band.reason}`;
|
|
2688
|
+
const diagnose = tier === "diagnose";
|
|
2689
|
+
const title = diagnose ? `Diagnose SDLC metric ${band.metric}: read-only triage` : `Review SDLC metric ${band.metric}: control band breach`;
|
|
2690
|
+
const detail = `Band breach key: ${full}\n${evidence}\n${diagnose
|
|
2691
|
+
? "Read-only triage for the planner: inspect the recent task histories behind this metric and report what changed. Do not edit files, branches or the board."
|
|
2692
|
+
: "Backlog card for the planner: decide whether this breach needs a process change. It names the metric and its evidence; it opens no GitHub issue."}`;
|
|
2693
|
+
const draft = { title, detail, refs: {} };
|
|
2694
|
+
const signals = this.signalsFor(draft);
|
|
2695
|
+
if (this.isPii({ signals }) || !this.candidateTextAllowed(`${title}\n${detail}`)) continue;
|
|
2696
|
+
const proposal = this.d.board.propose(HUB, { ...draft, class: diagnose ? "triage" : "plan", backlog: true, signals });
|
|
2697
|
+
this.incomingBacklog.add(proposal.id); seen.add(full); open.add(band.metric); created.push(proposal.id);
|
|
2485
2698
|
}
|
|
2486
|
-
|
|
2699
|
+
for (const id of created) this.releaseNotice(`task #${id}: SDLC control band breach proposed in the backlog`);
|
|
2700
|
+
return created;
|
|
2487
2701
|
}
|
|
2488
2702
|
|
|
2489
2703
|
/** S10 read-only planner report; S7 owns rolling bands and automatic board proposals. */
|
|
@@ -2591,7 +2805,23 @@ export class Tasks {
|
|
|
2591
2805
|
}
|
|
2592
2806
|
|
|
2593
2807
|
/** Artifact links and claimed local gates stay on the board; no outside text is accepted. */
|
|
2594
|
-
|
|
2808
|
+
/**
|
|
2809
|
+
* `hub_sdlc_record` (#509a): the receipt may also name `deliverables`. They are split off here, claims vetted and the files hashed by the hub,
|
|
2810
|
+
* and only then is the rest recorded as before. A receipt with deliverables and nothing else records just the deliverables.
|
|
2811
|
+
*/
|
|
2812
|
+
async recordSdlc(by: PeerId, id: unknown, input: unknown): Promise<Task> {
|
|
2813
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || !("deliverables" in input)) return this.recordLifecycle(by, id, input);
|
|
2814
|
+
const { deliverables, ...receipt } = input as Record<string, unknown>;
|
|
2815
|
+
const task = this.need(id, false, true);
|
|
2816
|
+
if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
|
|
2817
|
+
const claims = this.deliverableClaims(deliverables, task);
|
|
2818
|
+
const records = await this.hashClaims(claims, task, true);
|
|
2819
|
+
const current = this.need(id, false, true);
|
|
2820
|
+
if (current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed while its deliverables were hashed; record them again");
|
|
2821
|
+
return this.recordLifecycle(by, id, receipt, records);
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
recordLifecycle(by: PeerId, id: unknown, input: unknown, deliverables?: DeliverableRecord[]): Task {
|
|
2595
2825
|
const task = this.need(id, false, true);
|
|
2596
2826
|
if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
|
|
2597
2827
|
const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
@@ -2621,7 +2851,7 @@ export class Tasks {
|
|
|
2621
2851
|
if (next.reviewedHead !== undefined && (next.reviewedHead !== task.sdlc?.reviewedHead || (suppliedHead && artifactChanged))) this.mine(task, by, "reviewer", true);
|
|
2622
2852
|
const departure = task.history.findLast(h => h.by === USER && h.planDeparture)?.planDeparture;
|
|
2623
2853
|
const invalidatesDeparture = !!departure && (artifactChanged || next.localHead !== task.sdlc?.localHead && next.localHead !== undefined && next.localHead !== departure.head || next.reviewedHead !== task.sdlc?.reviewedHead && next.reviewedHead !== undefined && next.reviewedHead !== departure.head);
|
|
2624
|
-
return this.d.board.update(task.id, by, "lifecycle", { sdlc: next }, undefined, { ...this.planObservation({ ...task, sdlc: next }), ...(invalidatesDeparture ? { planDepartureInvalidated: true } : {}) });
|
|
2854
|
+
return this.d.board.update(task.id, by, "lifecycle", { sdlc: next }, undefined, { ...this.planObservation({ ...task, sdlc: next }), ...(invalidatesDeparture ? { planDepartureInvalidated: true } : {}), ...(deliverables ? { deliverables } : {}) });
|
|
2625
2855
|
}
|
|
2626
2856
|
|
|
2627
2857
|
async deliveryQueue(repo: string) {
|
|
@@ -2831,7 +3061,9 @@ export class Tasks {
|
|
|
2831
3061
|
const task = this.need(id, false, true), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
|
|
2832
3062
|
if (config.mode === "off" || by === USER || !input || typeof input !== "object" || !("stage" in input)) return;
|
|
2833
3063
|
let next: ReturnType<typeof sdlcReceipt>;
|
|
2834
|
-
|
|
3064
|
+
// #509a: `deliverables` ride in the receipt but are not a lifecycle field: recordSdlc splits them off, so the gate reads the same receipt it will record.
|
|
3065
|
+
const { deliverables: _carried, ...lifecycleInput } = input as Record<string, unknown>;
|
|
3066
|
+
try { next = sdlcReceipt(lifecycleInput, task.sdlc, config, VERSION); } catch { return; } // recordLifecycle reports an invalid receipt
|
|
2835
3067
|
const definition = stageDefinition(config, next.stage);
|
|
2836
3068
|
if (next.stage === task.sdlc?.stage || !definition || canonicalStage(definition.base) !== "build") return;
|
|
2837
3069
|
const accepted = next.acceptance;
|
|
@@ -2909,6 +3141,7 @@ export class Tasks {
|
|
|
2909
3141
|
return base === "build" || base === "implement";
|
|
2910
3142
|
}
|
|
2911
3143
|
async acceptReviewed(by: PeerId, id: unknown, plan?: unknown, refs?: unknown): Promise<Task> {
|
|
3144
|
+
const requestCheck = this.requestCheck(by);
|
|
2912
3145
|
const metadata = acceptRefs(refs);
|
|
2913
3146
|
const task = this.need(id, false, true);
|
|
2914
3147
|
this.mine(task, by, "owner", true);
|
|
@@ -2919,6 +3152,7 @@ export class Tasks {
|
|
|
2919
3152
|
if ((this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode === "off" || !this.isBuildTask(task)) return this.accept(by, id, plan, metadata);
|
|
2920
3153
|
await this.specGate(by, id);
|
|
2921
3154
|
await this.planEntryGate(by, draft);
|
|
3155
|
+
requestCheck();
|
|
2922
3156
|
const current = this.need(id, false, true);
|
|
2923
3157
|
this.mine(current, by, "owner", true);
|
|
2924
3158
|
if (current.history.length !== task.history.length || current.owner !== task.owner) throw new PreEffectToolRefusal("task changed during build admission");
|
|
@@ -2997,7 +3231,7 @@ export class Tasks {
|
|
|
2997
3231
|
this.warmRepository(draft);
|
|
2998
3232
|
const repositoryChanged = this.repositories.scope(task.refs).key !== this.repositories.scope(draft.refs).key;
|
|
2999
3233
|
const before = new Set(this.overlapHits(task).map((h) => h.task.id));
|
|
3000
|
-
const next = this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}), ...(metadata ? { refs: metadata } : {}) });
|
|
3234
|
+
const next = this.linkOnWrite(this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}), ...(metadata ? { refs: metadata } : {}) })); // #723: refs.repo may have just arrived
|
|
3001
3235
|
this.captureOwnerAcceptance(next);
|
|
3002
3236
|
this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
|
|
3003
3237
|
if (repositoryChanged) this.cohorts.leave(next.id);
|
|
@@ -3075,6 +3309,59 @@ export class Tasks {
|
|
|
3075
3309
|
return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
|
|
3076
3310
|
}
|
|
3077
3311
|
|
|
3312
|
+
/**
|
|
3313
|
+
* #509a: the task's deliverables as they are now against what was delivered and what was approved. `changedSinceReview` is true only for a file
|
|
3314
|
+
* measured to differ from the bytes a reviewer approved (or, before approval, from the bytes delivered); a file the hub cannot read is
|
|
3315
|
+
* `unknown`, never `unchanged`. Ids, hashes and sizes only for a private record. Undefined when the task delivered nothing.
|
|
3316
|
+
*/
|
|
3317
|
+
async deliverableStatus(task: Task): Promise<{ state: "none" | "delivered" | "approved"; changedSinceReview: boolean; files: DeliverableStatus[] } | undefined> {
|
|
3318
|
+
const delivered = latestDeliverables(task.history);
|
|
3319
|
+
if (!delivered) return undefined;
|
|
3320
|
+
const approved = task.state === "approved" ? approvedDeliverables(task.history) : undefined;
|
|
3321
|
+
const basis = approved ?? delivered;
|
|
3322
|
+
const checks = await this.checkRecords(basis, task);
|
|
3323
|
+
const shown = publicDeliverables(basis, this.nameable)!;
|
|
3324
|
+
const files = checks.map((check, i): DeliverableStatus => {
|
|
3325
|
+
const { sha256, id, format, version, path, private: hidden } = shown[i]!;
|
|
3326
|
+
return { id, sha256, state: check.state, ...(path ? { path } : {}), format, ...(version ? { version } : {}), ...(hidden ? { private: true as const } : {}) };
|
|
3327
|
+
});
|
|
3328
|
+
return { state: approved ? "approved" : "delivered", changedSinceReview: files.some(file => file.state === "changed"), files };
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
/** Check records against the owner's root; with that root unproven every file is `unknown` (never `unchanged`, never an exception). */
|
|
3332
|
+
private async checkRecords(records: DeliverableRecord[], task: Task) {
|
|
3333
|
+
try { return await checkDeliverables(records, this.deliverablePorts(task)); }
|
|
3334
|
+
catch (error) { return records.map(record => ({ record, state: "unknown" as const, why: error instanceof Error ? error.message : "unreadable" })); }
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
/** What the deliverable reader needs from the host: the project root, the secrets denylist, and which files make a record private (#509a). */
|
|
3338
|
+
private deliverablePorts(task: Task): DeliverablePorts {
|
|
3339
|
+
const taskPrivate = this.isPii(task);
|
|
3340
|
+
// The owner's own root: a declared worktree instance writes under its worktree, so its deliverables are hashed there, never in the canonical
|
|
3341
|
+
// checkout (which holds a different file or none). With the root unproven there is nothing safe to hash: refuse rather than guess.
|
|
3342
|
+
const root = !this.d.executionRoot || !task.owner || task.owner === USER ? this.d.cwd : this.d.executionRoot(task.owner);
|
|
3343
|
+
if (root === undefined) throw new DeliverableRefusal(`deliverables: ${task.owner}'s execution root is not verified right now, so its files cannot be hashed`);
|
|
3344
|
+
return { cwd: root, deny: this.d.deny ?? [], private: path => taskPrivate || matchesPolicy(root, path, this.d.piiPaths ?? [], true) };
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
/**
|
|
3348
|
+
* #509a: parse and vet a call's deliverable claims before any effect. A claimed hash is a refusal (the hub hashes the files itself), and so is a
|
|
3349
|
+
* path outside the project, on the denylist, inside the hub's own state, not a regular file, missing or too large. Nothing is hashed or stored here.
|
|
3350
|
+
*/
|
|
3351
|
+
private deliverableClaims(input: unknown, task: Task, preEffect = true) {
|
|
3352
|
+
try {
|
|
3353
|
+
const claims = parseDeliverableClaims(input);
|
|
3354
|
+
vetDeliverables(claims, this.deliverablePorts(task));
|
|
3355
|
+
return claims;
|
|
3356
|
+
} catch (error) { throw new (preEffect ? PreEffectToolRefusal : Error)(error instanceof Error ? error.message : "invalid deliverables"); }
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
/** Hash vetted claims now. A refusal during the read (the file changed while it was read) is still before any write. */
|
|
3360
|
+
private async hashClaims(claims: ReturnType<Tasks["deliverableClaims"]>, task: Task, preEffect = true): Promise<DeliverableRecord[]> {
|
|
3361
|
+
try { return await hashDeliverables(claims, this.deliverablePorts(task)); }
|
|
3362
|
+
catch (error) { throw new (preEffect ? PreEffectToolRefusal : Error)(error instanceof DeliverableRefusal || error instanceof Error ? error.message : "deliverables could not be hashed"); }
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3078
3365
|
/** Tasks whose check is queued or running, and the owner it was started for. */
|
|
3079
3366
|
private readonly checking = new Map<number, PeerId | null>();
|
|
3080
3367
|
private checkQueue: Promise<void> = Promise.resolve();
|
|
@@ -3102,11 +3389,17 @@ export class Tasks {
|
|
|
3102
3389
|
return task;
|
|
3103
3390
|
}
|
|
3104
3391
|
|
|
3105
|
-
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs): Promise<Task> {
|
|
3106
|
-
const
|
|
3392
|
+
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs, deliverables?: unknown): Promise<Task> {
|
|
3393
|
+
const requestCheck = this.requestCheck(by);
|
|
3394
|
+
const valid = () => { requestCheck(); return this.completionCurrent(id, by, true); };
|
|
3395
|
+
// #509a: a bad claim (a claimed hash, an outside or denied path, a missing file) is refused before any effect, with nothing recorded.
|
|
3396
|
+
const claims = deliverables === undefined ? undefined : this.deliverableClaims(deliverables, valid());
|
|
3107
3397
|
// The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
|
|
3108
3398
|
const withheld = await this.withholds("summary", summary, valid());
|
|
3109
3399
|
let task = valid();
|
|
3400
|
+
// The hub hashes the files itself, once, here: what the owner delivered is what this read, whatever the file becomes afterwards.
|
|
3401
|
+
const records = claims ? await this.hashClaims(claims, task, true) : undefined;
|
|
3402
|
+
task = valid();
|
|
3110
3403
|
// #481 S1: done without a separate accept starts build work too: the same live spec check as accept, before any write.
|
|
3111
3404
|
if (task.state === "proposed" || task.state === "changes_requested") {
|
|
3112
3405
|
const admission = task;
|
|
@@ -3152,12 +3445,12 @@ export class Tasks {
|
|
|
3152
3445
|
}
|
|
3153
3446
|
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
|
|
3154
3447
|
const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
|
|
3155
|
-
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link);
|
|
3448
|
+
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link, records, requestCheck);
|
|
3156
3449
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
3157
3450
|
this.checking.set(task.id, task.owner);
|
|
3158
3451
|
const checkedRefs = cleanRefs(refs);
|
|
3159
3452
|
this.warmRepository({ ...task, refs: { ...task.refs, ...checkedRefs } });
|
|
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);
|
|
3453
|
+
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, ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
3161
3454
|
this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
|
|
3162
3455
|
const seen = { events: pending.history.length, owner: pending.owner };
|
|
3163
3456
|
this.pendingChecks++;
|
|
@@ -3288,11 +3581,15 @@ export class Tasks {
|
|
|
3288
3581
|
}
|
|
3289
3582
|
|
|
3290
3583
|
/** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
|
|
3291
|
-
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink): Promise<Task> {
|
|
3584
|
+
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink, records?: DeliverableRecord[], requestCheck: () => void = () => {}): Promise<Task> {
|
|
3585
|
+
requestCheck();
|
|
3586
|
+
// A check's completion carries the records its `done (checking)` bound: the same bytes the owner delivered, not a later read.
|
|
3587
|
+
records ??= latestPendingDeliverables(task);
|
|
3292
3588
|
let selection = this.poolReview(task, refs);
|
|
3293
3589
|
let reviewer = selection?.reviewer ?? task.reviewer, reviewEvidence: PrEvidence | undefined;
|
|
3294
3590
|
if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
|
|
3295
3591
|
reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
|
|
3592
|
+
requestCheck();
|
|
3296
3593
|
task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
|
|
3297
3594
|
selection = this.poolReview(task, refs);
|
|
3298
3595
|
reviewer = selection?.reviewer ?? task.reviewer;
|
|
@@ -3300,6 +3597,7 @@ export class Tasks {
|
|
|
3300
3597
|
// A delayed local check needs a new readback; a completion-selected reviewer also needs optional facts in off mode.
|
|
3301
3598
|
if (!admission && ((reviewer && (this.d.sdlc?.config().mode ?? "off") !== "off") || this.d.prepareReviewBrief)) {
|
|
3302
3599
|
admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence, undefined, staged => this.withLink(staged, link, refs), !reviewer);
|
|
3600
|
+
requestCheck();
|
|
3303
3601
|
task = this.withLink(this.completionCurrent(task.id, by, false), link, refs);
|
|
3304
3602
|
}
|
|
3305
3603
|
selection = this.poolReview(task, refs);
|
|
@@ -3310,7 +3608,7 @@ export class Tasks {
|
|
|
3310
3608
|
const completedRefs = cleanRefs(refs);
|
|
3311
3609
|
this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
3312
3610
|
const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
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);
|
|
3611
|
+
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 } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
|
|
3314
3612
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
3315
3613
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
|
|
3316
3614
|
this.tellCompleted(next, summary, withheld);
|
|
@@ -3500,7 +3798,19 @@ export class Tasks {
|
|
|
3500
3798
|
const checklistLines = checklistTemplates.map(template =>
|
|
3501
3799
|
template.replaceAll("{target}", target).replaceAll("{check}", checkResult)
|
|
3502
3800
|
);
|
|
3503
|
-
|
|
3801
|
+
// #507b: the task's recorded acceptance criteria (the #481 S8 checklist, ids and all). A PII task shows none: its checklist is private.
|
|
3802
|
+
const recorded = this.isPii(task) ? undefined : checklistOf(task.sdlc);
|
|
3803
|
+
const criteriaBlock = recorded?.criteria.length
|
|
3804
|
+
? ["Acceptance criteria (name an id at the start of an unmet item, such as \"AC1: what is missing\"):",
|
|
3805
|
+
...recorded.criteria.map(c => `- ${c.id}${c.required ? "" : " (optional)"}: ${c.result}`)].join("\n") + "\n"
|
|
3806
|
+
: "";
|
|
3807
|
+
// #509a: what was delivered, by id, hash and size. A private record (a PII task, or a path under pii.paths) shows no path.
|
|
3808
|
+
const delivered = publicDeliverables(latestDeliverables(task.history), this.nameable);
|
|
3809
|
+
const deliverablesBlock = delivered?.length
|
|
3810
|
+
? ["Deliverables (the hub hashed these files when the task was done; approval binds these hashes):",
|
|
3811
|
+
...delivered.map(d => `- ${d.id}: ${d.path ?? "(path withheld)"} [${d.format}${d.version ? ` ${d.version}` : ""}] sha256 ${d.sha256.slice(0, 16)}, ${d.bytes} bytes`)].join("\n") + "\n"
|
|
3812
|
+
: "";
|
|
3813
|
+
const checklist = deliverablesBlock + criteriaBlock + [
|
|
3504
3814
|
"Checklist:",
|
|
3505
3815
|
...this.planGuardItems(task).map(item => `- Check the plan against this live fail before the remaining checks: ${item.line}`),
|
|
3506
3816
|
...checklistLines.map(line => `- ${line}`),
|
|
@@ -3679,6 +3989,28 @@ export class Tasks {
|
|
|
3679
3989
|
const checked = sdlcReceipt({ reviewedHead: head }, task.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined));
|
|
3680
3990
|
reviewedHead = checked.reviewedHead;
|
|
3681
3991
|
}
|
|
3992
|
+
// #509a: an approval binds the bytes that were delivered. The files are read again now: unchanged, their hashes go on the approval; changed,
|
|
3993
|
+
// missing or unreadable, a reviewer's approval is refused before any write, and the person may approve the files as they are, saying so.
|
|
3994
|
+
let boundDeliverables: DeliverableRecord[] | undefined;
|
|
3995
|
+
if (verdict === "approved") {
|
|
3996
|
+
const delivered = latestDeliverables(task.history);
|
|
3997
|
+
if (delivered) {
|
|
3998
|
+
const identity = JSON.stringify(delivered);
|
|
3999
|
+
const checks = await this.checkRecords(delivered, task);
|
|
4000
|
+
task = valid();
|
|
4001
|
+
if (JSON.stringify(latestDeliverables(task.history)) !== identity) throw new PreEffectToolRefusal("the task's deliverables changed during the verdict; review the current work again");
|
|
4002
|
+
const drift = checks.filter(check => check.state !== "unchanged");
|
|
4003
|
+
if (!drift.length) boundDeliverables = delivered;
|
|
4004
|
+
else {
|
|
4005
|
+
const text = drift.map(check => `${check.record.id} (${check.state})`).join(", ");
|
|
4006
|
+
if (by !== USER) throw new PreEffectToolRefusal(`deliverable changed since it was delivered: ${text}; ask its owner to call hub_task_done again so the current files are reviewed, or a person may approve them as they are`);
|
|
4007
|
+
// The person approves what is on disk now: those bytes are measured and bound, and the verdict says the files had moved.
|
|
4008
|
+
boundDeliverables = await this.hashClaims(delivered.map(record => ({ path: record.path!, format: record.format, ...(record.version !== undefined ? { version: record.version } : {}) })), task, true);
|
|
4009
|
+
task = valid();
|
|
4010
|
+
note = `${note ?? ""}\nApproved with the deliverable files changed since delivery: ${text}`.trim();
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
4013
|
+
}
|
|
3682
4014
|
if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid(); }
|
|
3683
4015
|
let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
|
|
3684
4016
|
if (verdict === "changes_requested" && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
|
|
@@ -3697,7 +4029,10 @@ export class Tasks {
|
|
|
3697
4029
|
const family = by === USER ? undefined : (this.d.adapterCatalog?.familyOf ?? familyOf)(by, this.d.instances?.());
|
|
3698
4030
|
const reviewerFamily = poolRequest?.reviewerFamily === family ? family : undefined;
|
|
3699
4031
|
const reviewerReason = reviewerFamily ? poolRequest?.reviewerReason : undefined;
|
|
3700
|
-
|
|
4032
|
+
// #507b: unmet items that start with a recorded criterion id name that criterion. The ids come from the checklist on the board as it is at this
|
|
4033
|
+
// write (the `task` just rechecked by valid()), never from the caller; a PII task carries none, its checklist being private.
|
|
4034
|
+
const unmetCriteria = pii ? [] : unmetCriteriaIds(items, checklistOf(task.sdlc));
|
|
4035
|
+
const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
|
|
3701
4036
|
if (verdict === "approved") {
|
|
3702
4037
|
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);
|
|
3703
4038
|
if (next.owner) {
|
|
@@ -3997,6 +4332,7 @@ export class Tasks {
|
|
|
3997
4332
|
}
|
|
3998
4333
|
|
|
3999
4334
|
async remember(by: PeerId, input: { text?: string; title?: string; kind?: string; task?: unknown; horizon?: unknown; scope?: unknown; approach?: unknown; symptom?: unknown; cause?: unknown; evidence?: unknown; retry_when?: unknown }): Promise<string> {
|
|
4335
|
+
const requestCheck = this.requestCheck(by);
|
|
4000
4336
|
const text = composeRememberText(input);
|
|
4001
4337
|
const task = input.task !== undefined ? this.d.board.get(Number(input.task)) : undefined;
|
|
4002
4338
|
if (task && this.isPii(task)) throw new Error("notes about a PII task are not saved: claude-mem processes what it stores with a cloud model");
|
|
@@ -4010,6 +4346,7 @@ export class Tasks {
|
|
|
4010
4346
|
if (looksSecret(`${title}\n${text}`)) throw new Error("this note looks like it holds a secret (a key, token or password) and is not saved");
|
|
4011
4347
|
if (this.aboutPrivatePath(title, text, task)) throw new Error("this note is about a path the project keeps private (pii.paths) and is not saved");
|
|
4012
4348
|
if (await this.withholds("note", `${title}\n${text}`, task)) throw new Error("the PII screen did not clear this note, so it is not saved: claude-mem processes what it stores with a cloud model");
|
|
4349
|
+
requestCheck();
|
|
4013
4350
|
const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) as Kind : "finding";
|
|
4014
4351
|
const native = this.saveNative(by, kind, text, title, task, input.horizon === "long" || input.horizon === "short" ? input.horizon : undefined, input.scope === "shared" ? "shared" : "project");
|
|
4015
4352
|
if (task && this.d.memory) this.markMemoryReference(task, by);
|