@afokapu/atdd-flow 0.3.19 → 0.3.20
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/package.json +1 -1
- package/src/cli.ts +17 -0
- package/src/owner-escalations.ts +123 -0
- package/src/threads.ts +2 -0
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -11,6 +11,7 @@ import * as reviews from "./reviews";
|
|
|
11
11
|
import { status } from "./overview";
|
|
12
12
|
import { multiplexer } from "./multiplexer";
|
|
13
13
|
import * as cleanup from "./ephemeral-resources";
|
|
14
|
+
import * as ownerAlerts from "./owner-escalations";
|
|
14
15
|
|
|
15
16
|
const usage = `atdd-flow — filesystem-first agent seats and tasks
|
|
16
17
|
|
|
@@ -45,6 +46,8 @@ Usage:
|
|
|
45
46
|
atdd-flow task open <project> <task-id>
|
|
46
47
|
atdd-flow cleanup status <project>
|
|
47
48
|
atdd-flow cleanup checklist <project> <task-id>
|
|
49
|
+
atdd-flow owner-alert scan --by <coordinator-or-main> [--re-notify-after-ms <milliseconds>]
|
|
50
|
+
atdd-flow owner-alert deliver|acknowledge|execute|resolve <alert-id> --by <address> [--note <text>|--option <id>|--reason <text>]
|
|
48
51
|
atdd-flow thread start --with <address,...> --subject <text> [--task <project/task-id>]
|
|
49
52
|
atdd-flow thread add <thread-id> <address>
|
|
50
53
|
atdd-flow thread open <thread-id>
|
|
@@ -128,6 +131,20 @@ async function main() {
|
|
|
128
131
|
if (subcommand === "checklist") return console.log(await cleanup.checklist(root, { project: required(projectName, "project"), task: required(taskId, "task id") }));
|
|
129
132
|
throw new Error("Use `atdd-flow cleanup status <project>` or `atdd-flow cleanup checklist <project> <task-id>`.");
|
|
130
133
|
},
|
|
134
|
+
"owner-alert": async () => {
|
|
135
|
+
const [subcommand, alertId, ...tail] = rest;
|
|
136
|
+
if (subcommand === "scan") {
|
|
137
|
+
const delay = words(rest.slice(1), "--re-notify-after-ms");
|
|
138
|
+
if (delay !== undefined && (!Number.isSafeInteger(Number(delay)) || Number(delay) < 0)) throw new Error("--re-notify-after-ms must be a non-negative integer.");
|
|
139
|
+
return console.log(Bun.YAML.stringify(await ownerAlerts.scanOwnerAlerts(root, { by: required(words(rest.slice(1), "--by"), "--by"), ...(delay === undefined ? {} : { re_notify_after_ms: Number(delay) }) })));
|
|
140
|
+
}
|
|
141
|
+
const by = required(words(tail, "--by"), "--by");
|
|
142
|
+
if (subcommand === "deliver") return ownerAlerts.deliverOwnerAlert(root, required(alertId, "alert id"), { by });
|
|
143
|
+
if (subcommand === "acknowledge") return ownerAlerts.acknowledgeOwnerAlert(root, required(alertId, "alert id"), { by, note: required(words(tail, "--note"), "--note") });
|
|
144
|
+
if (subcommand === "execute") return ownerAlerts.chooseOwnerAlertOption(root, required(alertId, "alert id"), { by, option: required(words(tail, "--option"), "--option") as "authorize-safe-fallback" | "provide-cross-project-decision" | "hold-or-resolve" });
|
|
145
|
+
if (subcommand === "resolve") return ownerAlerts.resolveOwnerAlert(root, required(alertId, "alert id"), { by, reason: required(words(tail, "--reason"), "--reason") });
|
|
146
|
+
throw new Error("Use `atdd-flow owner-alert scan|deliver|acknowledge|execute|resolve ...`.");
|
|
147
|
+
},
|
|
131
148
|
thread: async () => {
|
|
132
149
|
const [subcommand, ...tail] = rest;
|
|
133
150
|
if (subcommand === "start") return startThread(root, tail);
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { atomicYaml, exists, now as workflowNow, paths, readYaml, required, seat } from "./core";
|
|
5
|
+
import { type Task } from "./tasks";
|
|
6
|
+
import { post, startThread } from "./threads";
|
|
7
|
+
|
|
8
|
+
type Option = "authorize-safe-fallback" | "provide-cross-project-decision" | "hold-or-resolve";
|
|
9
|
+
type Alert = {
|
|
10
|
+
schema: "atdd-workflow/owner-alert/v1"; id: string; incident: string; fingerprint: string;
|
|
11
|
+
state: "blocked" | "waiting" | "resolved" | "superseded";
|
|
12
|
+
material_class: "cross-project" | "toolkit" | "authorization"; affected_tasks: string[]; evidence: string;
|
|
13
|
+
fallback: { state: "absent"; evidence: string; safe_automatic_action: "none" };
|
|
14
|
+
owner_options: { id: Option; text: string }[]; prohibited_actions: string[];
|
|
15
|
+
delivery: { state: "persisted" | "sent" | "delivered" | "acknowledged" | "executing" | "owner_unavailable"; persisted_at: string; sent_at?: string; delivered_at?: string; acknowledged_at?: string; executing_at?: string; unavailable_reason?: string };
|
|
16
|
+
notifications: { at: string; thread?: string; message?: string; kind: "initial" | "reminder" }[]; thread?: string;
|
|
17
|
+
owner_decision?: { option: Option; by: "operator@desk"; at: string }; resolution?: { by: string; reason: string; at: string }; superseded_by?: string;
|
|
18
|
+
};
|
|
19
|
+
type Candidate = Omit<Alert, "id" | "incident" | "fingerprint" | "delivery" | "notifications" | "thread">;
|
|
20
|
+
export type OwnerAlertScan = { created: string[]; sent: string[]; renotified: string[]; unavailable: string[]; ignored: string[] };
|
|
21
|
+
|
|
22
|
+
const directory = (root: string) => join(root, ".atdd-flow", "owner-alerts");
|
|
23
|
+
export const ownerAlertFile = (root: string, id: string) => join(directory(root), `${id}.yaml`);
|
|
24
|
+
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
|
|
25
|
+
const time = (value?: string) => value ?? workflowNow();
|
|
26
|
+
const options: { id: Option; text: string }[] = [
|
|
27
|
+
{ id: "authorize-safe-fallback", text: "Authorize one named safe fallback; Flow will not infer that authorization." },
|
|
28
|
+
{ id: "provide-cross-project-decision", text: "Provide the named cross-project/toolkit/authorization decision or owner." },
|
|
29
|
+
{ id: "hold-or-resolve", text: "Hold the work explicitly or confirm the blocker is resolved." },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/** Redaction occurs before blocker text enters an alert key, record, or durable mail. */
|
|
33
|
+
export function redactOwnerEvidence(value: string) {
|
|
34
|
+
return value.replace(/\b(token|password|secret|api[-_ ]?key|authorization)\s*([:=])\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi, "$1$2[REDACTED]")
|
|
35
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]");
|
|
36
|
+
}
|
|
37
|
+
function classification(value: string) {
|
|
38
|
+
if (/\bcross[- ]project\b/i.test(value)) return "cross-project" as const;
|
|
39
|
+
if (/\btoolkit\b/i.test(value)) return "toolkit" as const;
|
|
40
|
+
if (/\bauthorization\b|\bauthori[sz](?:e|ation)\b/i.test(value)) return "authorization" as const;
|
|
41
|
+
}
|
|
42
|
+
async function projects(root: string) {
|
|
43
|
+
let names: string[]; try { names = await readdir(paths(root).work); } catch { return []; }
|
|
44
|
+
const found = await Promise.all(names.map(async (name) => ({ name, valid: await exists(paths(root).projectFile(name)) })));
|
|
45
|
+
return found.filter((entry) => entry.valid).map((entry) => entry.name);
|
|
46
|
+
}
|
|
47
|
+
async function tasks(root: string) {
|
|
48
|
+
const result: { project: string; id: string; task: Task }[] = [];
|
|
49
|
+
for (const project of await projects(root)) {
|
|
50
|
+
let files: string[]; try { files = await readdir(paths(root).tasks(project)); } catch { continue; }
|
|
51
|
+
for (const file of files.sort()) if (file.endsWith(".yaml") && !file.endsWith(".reviews.yaml")) result.push({ project, id: file.slice(0, -5), task: await readYaml<Task>(join(paths(root).tasks(project), file)) });
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
async function alerts(root: string) {
|
|
56
|
+
let files: string[]; try { files = await readdir(directory(root)); } catch { return [] as Alert[]; }
|
|
57
|
+
return Promise.all(files.filter((file) => file.endsWith(".yaml")).sort().map((file) => readYaml<Alert>(join(directory(root), file))));
|
|
58
|
+
}
|
|
59
|
+
async function escalator(root: string, address: string) {
|
|
60
|
+
const actor = await seat(root, address);
|
|
61
|
+
if (actor.role !== "coordinator" && actor.role !== "main") throw new Error("Only a coordinator or main seat may scan or resolve owner alerts.");
|
|
62
|
+
return actor.address;
|
|
63
|
+
}
|
|
64
|
+
async function ownerAvailable(root: string) { try { return (await seat(root, "operator@desk")).role === "operator"; } catch { return false; } }
|
|
65
|
+
async function independent(root: string, entry: { project: string; id: string }, all: Awaited<ReturnType<typeof tasks>>) {
|
|
66
|
+
for (const other of all) {
|
|
67
|
+
if (other.project !== entry.project || other.id === entry.id || other.task.status !== "in_progress" || other.task.blocker || !other.task.assignee) continue;
|
|
68
|
+
try { if ((await readYaml<{ status?: string }>(paths(root).checkpointFile(other.task.assignee))).status === "active") return true; } catch { /* absent checkpoint is not visible execution */ }
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
function candidate(entry: { project: string; id: string; task: Task }): Candidate | undefined {
|
|
73
|
+
if (entry.task.status === "done" || !entry.task.blocker) return;
|
|
74
|
+
const material = classification(entry.task.blocker); if (!material) return;
|
|
75
|
+
return { schema: "atdd-workflow/owner-alert/v1", state: "blocked", material_class: material, affected_tasks: [`${entry.project}/${entry.id}`], evidence: redactOwnerEvidence(entry.task.blocker),
|
|
76
|
+
fallback: { state: "absent", evidence: "No independent checkpoint-active task is visibly executing in the affected project.", safe_automatic_action: "none" }, owner_options: options,
|
|
77
|
+
prohibited_actions: ["Do not infer authorization.", "Do not execute an external action.", "Do not expose secrets."] };
|
|
78
|
+
}
|
|
79
|
+
function incident(value: Candidate) { return hash(`${value.material_class}\0${value.affected_tasks.join("\0")}`).slice(0, 16); }
|
|
80
|
+
function fingerprint(value: Candidate) { return hash(JSON.stringify({ state: value.state, evidence: value.evidence, fallback: value.fallback })); }
|
|
81
|
+
async function coordinatorFor(root: string, record: Alert) {
|
|
82
|
+
return required((await tasks(root)).find((entry) => record.affected_tasks.includes(`${entry.project}/${entry.id}`))?.task.coordinator, "alert coordinator");
|
|
83
|
+
}
|
|
84
|
+
async function send(root: string, record: Alert, kind: "initial" | "reminder", timestamp: string) {
|
|
85
|
+
const coordinator = await coordinatorFor(root, record);
|
|
86
|
+
const thread = record.thread ?? await startThread(root, ["--with", `operator@desk,${coordinator}`, "--subject", `Owner alert ${record.id}`]);
|
|
87
|
+
if (!record.thread) { record.thread = thread; await atomicYaml(ownerAlertFile(root, record.id), record); }
|
|
88
|
+
const message = await post(root, thread, ["--from", coordinator, "--to", "operator@desk", "--label", "material stall owner alert", "--expects-result", "--body",
|
|
89
|
+
`OWNER ALERT ${record.id}\nstate: ${record.state}\nclass: ${record.material_class}\naffected: ${record.affected_tasks.join(", ")}\nevidence: ${record.evidence}\nfallback: ${record.fallback.evidence}\nsafe automatic action: ${record.fallback.safe_automatic_action}\noptions: ${record.owner_options.map((option) => `${option.id} (${option.text})`).join("; ")}\nprohibited: ${record.prohibited_actions.join(" ")}`]);
|
|
90
|
+
record.thread = thread; record.delivery.state = "sent"; record.delivery.sent_at = timestamp; record.notifications.push({ at: timestamp, thread, message, kind });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** An explicit bounded observation, never a daemon, executor, or authorization proxy. */
|
|
94
|
+
export async function scanOwnerAlerts(root: string, input: { by: string; re_notify_after_ms?: number; now?: string }): Promise<OwnerAlertScan> {
|
|
95
|
+
await escalator(root, input.by); const timestamp = time(input.now); const result: OwnerAlertScan = { created: [], sent: [], renotified: [], unavailable: [], ignored: [] };
|
|
96
|
+
const all = await tasks(root); const stored = await alerts(root);
|
|
97
|
+
for (const entry of all) {
|
|
98
|
+
const next = candidate(entry);
|
|
99
|
+
if (!next || await independent(root, entry, all)) { result.ignored.push(`${entry.project}/${entry.id}`); continue; }
|
|
100
|
+
const key = incident(next); const digest = fingerprint(next); const active = stored.find((record) => record.incident === key && record.state !== "resolved" && record.state !== "superseded");
|
|
101
|
+
if (active && active.fingerprint !== digest) { active.state = "superseded"; active.superseded_by = `O-${key}-${digest.slice(0, 8)}`; await atomicYaml(ownerAlertFile(root, active.id), active); }
|
|
102
|
+
const exact = stored.find((record) => record.incident === key && record.fingerprint === digest);
|
|
103
|
+
if (exact && exact.state !== "superseded") {
|
|
104
|
+
if (exact.state === "resolved") { result.ignored.push(`${entry.project}/${entry.id}`); continue; }
|
|
105
|
+
const last = exact.notifications.at(-1)?.at; const delay = input.re_notify_after_ms;
|
|
106
|
+
if (exact.delivery.state === "sent" && delay !== undefined && delay >= 0 && last && Date.parse(timestamp) - Date.parse(last) >= delay) { await send(root, exact, "reminder", timestamp); await atomicYaml(ownerAlertFile(root, exact.id), exact); result.renotified.push(exact.id); }
|
|
107
|
+
else result.ignored.push(`${entry.project}/${entry.id}`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const id = `O-${key}-${digest.slice(0, 8)}`; const record: Alert = { ...next, id, incident: key, fingerprint: digest, delivery: { state: "persisted", persisted_at: timestamp }, notifications: [] };
|
|
111
|
+
await atomicYaml(ownerAlertFile(root, id), record); result.created.push(id);
|
|
112
|
+
if (!await ownerAvailable(root)) { record.delivery.state = "owner_unavailable"; record.delivery.unavailable_reason = "operator@desk is absent or is not an operator seat."; await atomicYaml(ownerAlertFile(root, id), record); result.unavailable.push(id); continue; }
|
|
113
|
+
try { await send(root, record, "initial", timestamp); await atomicYaml(ownerAlertFile(root, id), record); result.sent.push(id); }
|
|
114
|
+
catch (error) { record.delivery.state = "persisted"; record.delivery.unavailable_reason = `Owner delivery path failed closed: ${(error as Error).message}`; await atomicYaml(ownerAlertFile(root, id), record); result.unavailable.push(id); }
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
async function read(root: string, id: string) { return readYaml<Alert>(ownerAlertFile(root, id)); }
|
|
119
|
+
async function owner(root: string, address: string) { if (address !== "operator@desk" || !await ownerAvailable(root)) throw new Error("Only the available operator@desk seat may update owner delivery state."); }
|
|
120
|
+
export async function deliverOwnerAlert(root: string, id: string, input: { by: string; now?: string }) { await owner(root, input.by); const record = await read(root, id); if (record.delivery.state !== "sent") throw new Error(`Owner alert ${id} is not awaiting delivery.`); record.delivery.state = "delivered"; record.delivery.delivered_at = time(input.now); await atomicYaml(ownerAlertFile(root, id), record); }
|
|
121
|
+
export async function acknowledgeOwnerAlert(root: string, id: string, input: { by: string; note: string; now?: string }) { await owner(root, input.by); required(input.note, "owner acknowledgement note"); const record = await read(root, id); if (record.delivery.state !== "delivered") throw new Error(`Owner alert ${id} must be delivered before acknowledgement.`); record.delivery.state = "acknowledged"; record.delivery.acknowledged_at = time(input.now); await atomicYaml(ownerAlertFile(root, id), record); }
|
|
122
|
+
export async function chooseOwnerAlertOption(root: string, id: string, input: { by: string; option: Option; now?: string }) { await owner(root, input.by); if (!options.some((entry) => entry.id === input.option)) throw new Error("Unknown owner alert option."); const record = await read(root, id); if (record.delivery.state !== "acknowledged") throw new Error(`Owner alert ${id} must be acknowledged before selecting an option.`); record.delivery.state = "executing"; record.delivery.executing_at = time(input.now); record.owner_decision = { option: input.option, by: "operator@desk", at: record.delivery.executing_at }; await atomicYaml(ownerAlertFile(root, id), record); }
|
|
123
|
+
export async function resolveOwnerAlert(root: string, id: string, input: { by: string; reason: string; now?: string }) { const by = await escalator(root, input.by); const record = await read(root, id); if (record.state === "resolved" || record.state === "superseded") throw new Error(`Owner alert ${id} is already terminal.`); record.state = "resolved"; record.resolution = { by, reason: required(input.reason, "resolution reason"), at: time(input.now) }; await atomicYaml(ownerAlertFile(root, id), record); }
|
package/src/threads.ts
CHANGED
|
@@ -236,6 +236,7 @@ async function post(root: string, threadId: string, args: string[], overrides: P
|
|
|
236
236
|
await Promise.all(prepared.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry)).map((entry) => publishNativeMail(root, entry.address, entry.segment, message.id)));
|
|
237
237
|
await Promise.all(recipients.filter((address) => address !== from).map((recipient) => inject(root, recipient, message, threadId, prepared.some((entry) => entry?.address === recipient))));
|
|
238
238
|
console.log(message.id);
|
|
239
|
+
return message.id;
|
|
239
240
|
}
|
|
240
241
|
|
|
241
242
|
export async function startThread(root: string, args: string[]) {
|
|
@@ -249,6 +250,7 @@ export async function startThread(root: string, args: string[]) {
|
|
|
249
250
|
};
|
|
250
251
|
await atomicYaml(paths(root).threadFile(record.id), record);
|
|
251
252
|
console.log(record.id);
|
|
253
|
+
return record.id;
|
|
252
254
|
}
|
|
253
255
|
|
|
254
256
|
export async function addParticipant(root: string, threadId: string, address: string) {
|