@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.19",
3
+ "version": "0.3.20",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
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) {