@afokapu/atdd-flow 0.3.56 → 0.3.57
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/extensions/pi/index.ts +14 -16
- package/package.json +1 -1
- package/src/next-action-notices.ts +102 -0
- package/src/tasks.ts +57 -36
package/extensions/pi/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { parse, stringify } from "yaml";
|
|
|
5
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsRuntimeState, registerRuntimeState, runtimeOwnerToken, withOwnedRuntimeState } from "../../src/runtime-state";
|
|
7
7
|
import { paths, readYaml, type Seat } from "../../src/core";
|
|
8
|
-
import {
|
|
8
|
+
import { type NoticeState, createNoticeState, refreshDelivered, sendTaskDirective, shouldNotifyMail } from "../../src/next-action-notices";
|
|
9
9
|
import { handoverThresholdInstruction, runtimeStartupInstruction, successorInstruction } from "../../src/collaboration-policy";
|
|
10
10
|
import { type RecoveryMode, recoveryFor } from "../../src/recovery-bundle";
|
|
11
11
|
import { contextHandoverThreshold, readHandoverReplacement } from "../../src/handover";
|
|
@@ -341,18 +341,6 @@ const unsetOrEqual = (recorded: string | undefined, live: string | undefined) =>
|
|
|
341
341
|
const sessionUnsetOrEqual = (runtime: Seat["runtime"], piSession?: string, piSessionPath?: string) =>
|
|
342
342
|
unsetOrEqual(runtime?.pi_session, piSession) && unsetOrEqual(runtime?.pi_session_path, piSessionPath);
|
|
343
343
|
|
|
344
|
-
/** Injects the current task-derived action; failure to derive never blocks mail or startup. */
|
|
345
|
-
async function sendTaskDirective(pi: ExtensionAPI, root: string, seat: string) {
|
|
346
|
-
try {
|
|
347
|
-
const directive = await nextDirective(root, seat);
|
|
348
|
-
if (!directive) return;
|
|
349
|
-
pi.sendMessage({
|
|
350
|
-
customType: "atdd-flow-next-action", content: directiveNotice(seat, directive), display: true,
|
|
351
|
-
details: { project: directive.project, task: directive.task, lifecycle: directive.lifecycle, role: directive.role, authority: "task" },
|
|
352
|
-
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
353
|
-
} catch { /* The durable task remains authoritative and is re-derived at the next startup or wake. */ }
|
|
354
|
-
}
|
|
355
|
-
|
|
356
344
|
/** The recovery bundle is advisory; any failure falls back to the plain prompt. */
|
|
357
345
|
const recovery = (root: string, seat: string, mode: RecoveryMode, session?: string) => recoveryFor(root, seat, mode, session).catch(() => undefined);
|
|
358
346
|
|
|
@@ -365,6 +353,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
365
353
|
let handoverPrompted = false;
|
|
366
354
|
// Host-attach only: mail older than this activation is consumed without a prompt, so a long backlog cannot trigger one turn per message.
|
|
367
355
|
let hostActivatedAt: number | undefined;
|
|
356
|
+
const notices: NoticeState = createNoticeState();
|
|
357
|
+
const host = pi as unknown as Parameters<typeof sendTaskDirective>[0];
|
|
358
|
+
// Held follow-ups are re-derived when Pi delivers them, so a notice queued before a state change cannot reach the model stale.
|
|
359
|
+
pi.on("message_end", async (event) => {
|
|
360
|
+
const message = await refreshDelivered(root, seat, notices, event.message as Parameters<typeof refreshDelivered>[3]);
|
|
361
|
+
return message ? { message: message as typeof event.message } : undefined;
|
|
362
|
+
});
|
|
363
|
+
// Nothing queued survives a settled run, so a pending flag left by a cleared queue cannot suppress future notices.
|
|
364
|
+
pi.on("agent_settled", () => { notices.pending = false; });
|
|
368
365
|
pi.on("turn_end", async (_event, ctx) => {
|
|
369
366
|
if (handoverPrompted) return;
|
|
370
367
|
const percent = ctx.getContextUsage()?.percent;
|
|
@@ -399,9 +396,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
399
396
|
runtime = createPiRuntime({ root, seat, deferActivation: true, deliver: async (mail, thread, path, sendIfOwned) => {
|
|
400
397
|
if (!sendIfOwned) return false;
|
|
401
398
|
if (hostActivatedAt !== undefined && Date.parse(typeof mail.created_at === "string" ? mail.created_at : "") < hostActivatedAt) return sendIfOwned(async () => undefined);
|
|
399
|
+
const notify = typeof mail.id === "string" && typeof thread.id === "string" && await shouldNotifyMail(root, seat, notices, thread.id, mail.id);
|
|
402
400
|
return sendIfOwned(async () => {
|
|
403
|
-
|
|
404
|
-
await sendTaskDirective(
|
|
401
|
+
if (!notify) return;
|
|
402
|
+
await sendTaskDirective(host, root, seat, notices);
|
|
405
403
|
pi.sendMessage({
|
|
406
404
|
customType: "atdd-flow-mail",
|
|
407
405
|
content: mailNotice(typeof thread.id === "string" ? thread.id : "unknown", { ...mail, subject: typeof thread.subject === "string" ? thread.subject : undefined }),
|
|
@@ -442,7 +440,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
442
440
|
display: true, details: { seat, handover: successor.handover.id, authority: "handover-replacement" },
|
|
443
441
|
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
444
442
|
}
|
|
445
|
-
await sendTaskDirective(
|
|
443
|
+
await sendTaskDirective(host, root, seat, notices);
|
|
446
444
|
if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
|
|
447
445
|
},
|
|
448
446
|
}).catch(() => undefined);
|
package/package.json
CHANGED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// URN: component:flow:e007:next-action-notices:backend:application
|
|
2
|
+
// Tested-By:
|
|
3
|
+
// - test:flow:next-action-notice-correctness
|
|
4
|
+
// Runtime: bun
|
|
5
|
+
// Purpose: Keep Pi next-action and mail notices current and non-repeating.
|
|
6
|
+
|
|
7
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { parse } from "yaml";
|
|
10
|
+
import { type TaskDirective, directiveNotice, nextDirective } from "./tasks";
|
|
11
|
+
|
|
12
|
+
/** Per-session notice memory. Nothing here is durable; task and thread records stay authoritative. */
|
|
13
|
+
export type NoticeState = {
|
|
14
|
+
/** A next-action follow-up has been queued and not yet delivered. */
|
|
15
|
+
pending: boolean;
|
|
16
|
+
lastDelivered?: string;
|
|
17
|
+
/** Blocker notices already delivered this session, keyed by task and blocker text. */
|
|
18
|
+
blockers: Set<string>;
|
|
19
|
+
/** Mail ids already notified this session. */
|
|
20
|
+
mail: Set<string>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type NoticeHost = {
|
|
24
|
+
sendMessage: (message: { customType: string; content: string; display: boolean; details: Record<string, unknown> }, options: { triggerTurn: boolean; deliverAs: "followUp" }) => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** A delivered message as Pi hands it to `message_end`; only the fields used here. */
|
|
28
|
+
export type NoticeMessage = { role?: string; customType?: string; content?: unknown; details?: Record<string, unknown> };
|
|
29
|
+
|
|
30
|
+
export const createNoticeState = (): NoticeState => ({ pending: false, blockers: new Set(), mail: new Set() });
|
|
31
|
+
|
|
32
|
+
const directiveKey = (directive: TaskDirective) =>
|
|
33
|
+
[directive.project, directive.task, directive.lifecycle, directive.role, directive.action, directive.command ?? ""].join("|");
|
|
34
|
+
|
|
35
|
+
const directiveDetails = (directive: TaskDirective) =>
|
|
36
|
+
({ project: directive.project, task: directive.task, lifecycle: directive.lifecycle, role: directive.role, authority: "task" });
|
|
37
|
+
|
|
38
|
+
/** Queues the current task-derived action unless one is pending or the last delivered one is unchanged. */
|
|
39
|
+
export async function sendTaskDirective(host: NoticeHost, root: string, seat: string, state: NoticeState) {
|
|
40
|
+
if (state.pending) return;
|
|
41
|
+
try {
|
|
42
|
+
const directive = await nextDirective(root, seat);
|
|
43
|
+
if (!directive || state.pending) return;
|
|
44
|
+
const key = directiveKey(directive);
|
|
45
|
+
if (key === state.lastDelivered || state.blockers.has(key)) return;
|
|
46
|
+
state.pending = true;
|
|
47
|
+
host.sendMessage({ customType: "atdd-flow-next-action", content: directiveNotice(seat, directive), display: true, details: directiveDetails(directive) }, { triggerTurn: true, deliverAs: "followUp" });
|
|
48
|
+
} catch {
|
|
49
|
+
state.pending = false; // The durable task remains authoritative and is re-derived at the next startup or wake.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Re-derives a held next-action follow-up when Pi delivers it; a queued message cannot be dropped, so it is replaced by the shortest truthful text. */
|
|
54
|
+
async function currentDirective(root: string, seat: string, state: NoticeState, message: NoticeMessage): Promise<NoticeMessage> {
|
|
55
|
+
state.pending = false;
|
|
56
|
+
const directive = await nextDirective(root, seat).catch(() => undefined);
|
|
57
|
+
if (!directive) {
|
|
58
|
+
state.lastDelivered = undefined;
|
|
59
|
+
return { ...message, content: `SYSTEM: next-action notice for ${seat} withdrawn: task state has no actionable step. Task state outranks mail.`, details: { authority: "task", lifecycle: "NONE" } };
|
|
60
|
+
}
|
|
61
|
+
const key = directiveKey(directive);
|
|
62
|
+
if (key === state.lastDelivered) {
|
|
63
|
+
return { ...message, content: `SYSTEM: next action for ${seat} unchanged on ${directive.project}/${directive.task} [${directive.lifecycle}]; see the previous notice.`, details: directiveDetails(directive) };
|
|
64
|
+
}
|
|
65
|
+
state.lastDelivered = key;
|
|
66
|
+
if (directive.lifecycle === "BLOCKED") state.blockers.add(key);
|
|
67
|
+
return { ...message, content: directiveNotice(seat, directive), details: directiveDetails(directive) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const isMailFile = (file: string, mailId: string) => file.startsWith("M-") && file.endsWith(".yaml") && file !== `${mailId}.yaml`;
|
|
71
|
+
|
|
72
|
+
const cites = (record: Record<string, unknown> | undefined, seat: string, mailId: string) =>
|
|
73
|
+
record?.from === seat && (record.in_reply_to === mailId || String(record.body ?? "").includes(mailId));
|
|
74
|
+
|
|
75
|
+
const readRecord = (path: string) => readFile(path, "utf8").then((raw) => parse(raw) as Record<string, unknown>).catch(() => undefined);
|
|
76
|
+
|
|
77
|
+
/** True when this seat already replied to, or cited, the mail in the same thread. */
|
|
78
|
+
export async function answeredBy(root: string, seat: string, threadId: string, mailId: string) {
|
|
79
|
+
const folder = join(root, "threads", threadId);
|
|
80
|
+
for (const file of (await readdir(folder).catch(() => [] as string[])).filter((entry) => isMailFile(entry, mailId))) {
|
|
81
|
+
if (cites(await readRecord(join(folder, file)), seat, mailId)) return true;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** At most one notice per mail id per session, and none for mail this seat already answered. */
|
|
87
|
+
export async function shouldNotifyMail(root: string, seat: string, state: NoticeState, threadId: string, mailId: string) {
|
|
88
|
+
if (state.mail.has(mailId)) return false;
|
|
89
|
+
state.mail.add(mailId);
|
|
90
|
+
return !await answeredBy(root, seat, threadId, mailId).catch(() => false);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `message_end` hook: keeps held notices truthful at the moment they reach the model. */
|
|
94
|
+
export async function refreshDelivered(root: string, seat: string, state: NoticeState, message: NoticeMessage): Promise<NoticeMessage | undefined> {
|
|
95
|
+
if (message.role !== "custom") return undefined;
|
|
96
|
+
if (message.customType === "atdd-flow-next-action") return currentDirective(root, seat, state, message);
|
|
97
|
+
if (message.customType !== "atdd-flow-mail") return undefined;
|
|
98
|
+
const { thread, message: mailId } = message.details ?? {};
|
|
99
|
+
if (typeof thread !== "string" || typeof mailId !== "string") return undefined;
|
|
100
|
+
if (!await answeredBy(root, seat, thread, mailId).catch(() => false)) return undefined;
|
|
101
|
+
return { ...message, content: `SYSTEM: Flow mail ${mailId} on thread ${thread} was already answered by ${seat}; no action.` };
|
|
102
|
+
}
|
package/src/tasks.ts
CHANGED
|
@@ -9,7 +9,8 @@ export type TaskStatus = "todo" | "in_progress" | "review" | "done" | "supersede
|
|
|
9
9
|
export type TaskPhase = "plan" | "red" | "green" | "refactor";
|
|
10
10
|
export type HandoffState = "executing" | "awaiting_coordinator" | "awaiting_assignee";
|
|
11
11
|
/** A mutable projection of an immutable evidence message/reference, not a second task lifecycle. */
|
|
12
|
-
|
|
12
|
+
/** `submitted_phase` is absent on legacy records; it lets a same-phase accept be told apart from a never-submitted phase. */
|
|
13
|
+
export type TaskHandoff = { state: HandoffState; evidence: string; updated_at: string; submitted_phase?: TaskPhase };
|
|
13
14
|
export type DoneWhen = { text: string; proof?: string };
|
|
14
15
|
export type GovernedBase = { coordinator: string; branch: string; commit: string };
|
|
15
16
|
/** An append-only accountability decision; prior coordinator evidence remains untouched. */
|
|
@@ -539,7 +540,7 @@ export async function submitHandoff(root: string, projectName: string, id: strin
|
|
|
539
540
|
if (task.handoff?.state === "awaiting_coordinator") throw new Error(`Task ${id} is already awaiting coordinator handoff response.`);
|
|
540
541
|
const evidence = required(words(args, "--evidence"), "--evidence");
|
|
541
542
|
task.phase = phase(words(args, "--phase"));
|
|
542
|
-
task.handoff = { state: "awaiting_coordinator", evidence, updated_at: now() };
|
|
543
|
+
task.handoff = { state: "awaiting_coordinator", evidence, updated_at: now(), submitted_phase: task.phase };
|
|
543
544
|
await writeTask(root, projectName, taskId(id), task);
|
|
544
545
|
console.log(`${id} handoff awaiting_coordinator`);
|
|
545
546
|
}
|
|
@@ -662,7 +663,7 @@ export async function open(root: string, projectName: string, id: string) {
|
|
|
662
663
|
export type TaskDirective = {
|
|
663
664
|
project: string;
|
|
664
665
|
task: string;
|
|
665
|
-
lifecycle: "TODO" | "PLAN" | "RED" | "GREEN" | "REFACTOR" | "REVIEW" | "
|
|
666
|
+
lifecycle: "TODO" | "PLAN" | "RED" | "GREEN" | "REFACTOR" | "REVIEW" | "BLOCKED";
|
|
666
667
|
role: "assignee" | "coordinator";
|
|
667
668
|
action: string;
|
|
668
669
|
command?: string;
|
|
@@ -675,43 +676,65 @@ const phaseActions: Record<TaskPhase, string> = {
|
|
|
675
676
|
refactor: "Refactor only after GREEN acceptance, then submit the REFACTOR handoff",
|
|
676
677
|
};
|
|
677
678
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
if (task.status === "todo") {
|
|
688
|
-
const waiting = await ready(root, projectName, task);
|
|
689
|
-
return waiting.length
|
|
690
|
-
? { ...as, lifecycle: "TODO", action: `Wait; dependencies are unfinished: ${waiting.join(", ")}.` }
|
|
691
|
-
: { ...as, lifecycle: "TODO", action: "Start assigned ready task.", command: `atdd-flow task start ${projectName} ${id} --by ${address}` };
|
|
692
|
-
}
|
|
693
|
-
const current = task.phase ?? "plan";
|
|
694
|
-
const lifecycle = current.toUpperCase() as TaskDirective["lifecycle"];
|
|
695
|
-
if (task.handoff?.state === "awaiting_coordinator") return { ...as, lifecycle, action: `Await coordinator response to the ${lifecycle} handoff; do not continue to the next phase.` };
|
|
679
|
+
const phaseOrder: TaskPhase[] = ["plan", "red", "green", "refactor"];
|
|
680
|
+
|
|
681
|
+
const phaseCommand = (projectName: string, id: string, address: string, current: TaskPhase) =>
|
|
682
|
+
`atdd-flow task handoff ${projectName} ${id} --by ${address} --phase ${current} --evidence <message-or-reference>`;
|
|
683
|
+
|
|
684
|
+
/** An assignee whose submitted phase was accepted without naming a later phase is directed forward, never to resubmit. */
|
|
685
|
+
function acceptedDirective(base: Pick<TaskDirective, "project" | "task" | "role">, id: string, address: string, current: TaskPhase): TaskDirective {
|
|
686
|
+
const next = phaseOrder[phaseOrder.indexOf(current) + 1];
|
|
687
|
+
if (!next) {
|
|
696
688
|
return {
|
|
697
|
-
...
|
|
698
|
-
command: `atdd-flow task
|
|
689
|
+
...base, lifecycle: "REVIEW", action: "REFACTOR accepted; record a proof for every done_when item, then submit the task for review.",
|
|
690
|
+
command: `atdd-flow task review ${base.project} ${id} --by ${address}`,
|
|
699
691
|
};
|
|
700
692
|
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
693
|
+
return {
|
|
694
|
+
...base, lifecycle: next.toUpperCase() as TaskDirective["lifecycle"], action: `${current.toUpperCase()} accepted; ${phaseActions[next]}.`,
|
|
695
|
+
command: phaseCommand(base.project, id, address, next),
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function assigneeDirective(root: string, projectName: string, id: string, task: Task, address: string): Promise<TaskDirective> {
|
|
700
|
+
const as = { project: projectName, task: id, role: "assignee" as const };
|
|
701
|
+
if (task.status === "review") return { ...as, lifecycle: "REVIEW", action: "Await coordinator review; do not continue implementation." };
|
|
702
|
+
if (task.status === "todo") {
|
|
703
|
+
const waiting = await ready(root, projectName, task);
|
|
704
|
+
return waiting.length
|
|
705
|
+
? { ...as, lifecycle: "TODO", action: `Wait; dependencies are unfinished: ${waiting.join(", ")}.` }
|
|
706
|
+
: { ...as, lifecycle: "TODO", action: "Start assigned ready task.", command: `atdd-flow task start ${projectName} ${id} --by ${address}` };
|
|
707
|
+
}
|
|
708
|
+
const current = task.phase ?? "plan";
|
|
709
|
+
const lifecycle = current.toUpperCase() as TaskDirective["lifecycle"];
|
|
710
|
+
if (task.handoff?.state === "awaiting_coordinator") return { ...as, lifecycle, action: `Await coordinator response to the ${lifecycle} handoff; do not continue to the next phase.` };
|
|
711
|
+
if (task.handoff?.state === "awaiting_assignee" && task.handoff.submitted_phase === current) return acceptedDirective(as, id, address, current);
|
|
712
|
+
return { ...as, lifecycle, action: `${phaseActions[current]}.`, command: phaseCommand(projectName, id, address, current) };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function coordinatorDirective(projectName: string, id: string, task: Task, address: string): TaskDirective | undefined {
|
|
716
|
+
const as = { project: projectName, task: id, role: "coordinator" as const };
|
|
717
|
+
if (task.blocker) return { ...as, lifecycle: "BLOCKED", action: `Resolve the blocker (${task.blocker}) and then unblock the task.`, command: `atdd-flow task unblock ${projectName} ${id} --by ${address}` };
|
|
718
|
+
if (task.status === "review") return { ...as, lifecycle: "REVIEW", action: "Review the submitted task; complete it or return it to work.", command: `atdd-flow task open ${projectName} ${id}` };
|
|
719
|
+
if (task.status === "in_progress" && task.handoff?.state === "awaiting_coordinator") {
|
|
720
|
+
const current = task.phase ?? "plan";
|
|
721
|
+
return { ...as, lifecycle: current.toUpperCase() as TaskDirective["lifecycle"], action: `Accept or return the ${current.toUpperCase()} handoff.`, command: `atdd-flow task respond ${projectName} ${id} --by ${address} --outcome <accept|return> --phase <plan|red|green|refactor>` };
|
|
709
722
|
}
|
|
710
723
|
return undefined;
|
|
711
724
|
}
|
|
712
725
|
|
|
726
|
+
/**
|
|
727
|
+
* Pure projection of an existing task record; it creates no durable state. Terminal and deferred tasks
|
|
728
|
+
* yield nothing for any role, a blocked assignee is told nothing, and only the coordinator hears of a blocker.
|
|
729
|
+
*/
|
|
730
|
+
async function directiveFor(root: string, projectName: string, id: string, task: Task, address: string): Promise<TaskDirective | undefined> {
|
|
731
|
+
if (terminal(task) || task.deferred) return undefined;
|
|
732
|
+
if (task.assignee === address && !task.blocker) return assigneeDirective(root, projectName, id, task, address);
|
|
733
|
+
return task.coordinator === address ? coordinatorDirective(projectName, id, task, address) : undefined;
|
|
734
|
+
}
|
|
735
|
+
|
|
713
736
|
const directiveRank = (directive: TaskDirective, task: Task) =>
|
|
714
|
-
directive.lifecycle === "
|
|
737
|
+
directive.lifecycle === "BLOCKED" ? 3 : task.status === "todo" ? 2 : task.status === "review" ? 1 : 0;
|
|
715
738
|
|
|
716
739
|
/**
|
|
717
740
|
* Derives the one next required action for an address from authoritative task
|
|
@@ -727,10 +750,8 @@ export async function nextDirective(root: string, address: string): Promise<Task
|
|
|
727
750
|
if (directive) candidates.push({ directive, task: entry.task });
|
|
728
751
|
}
|
|
729
752
|
const best = (role: "assignee" | "coordinator") => {
|
|
730
|
-
|
|
731
|
-
.sort((left, right) => directiveRank(left.directive, left.task) - directiveRank(right.directive, right.task));
|
|
732
|
-
// Completed housekeeping names the most recent completed task, not the oldest.
|
|
733
|
-
return pool[0] && directiveRank(pool[0].directive, pool[0].task) === 4 ? pool.at(-1) : pool[0];
|
|
753
|
+
return candidates.filter((entry) => entry.directive.role === role)
|
|
754
|
+
.sort((left, right) => directiveRank(left.directive, left.task) - directiveRank(right.directive, right.task))[0];
|
|
734
755
|
};
|
|
735
756
|
return (best("assignee") ?? best("coordinator"))?.directive;
|
|
736
757
|
}
|