@afokapu/atdd-flow 0.3.77 → 0.3.78

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.77",
3
+ "version": "0.3.78",
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
@@ -67,6 +67,7 @@ Usage:
67
67
  atdd-flow task respond <project> <task-id> --by <coordinator> --outcome <accept|return> --phase <plan|red|green|refactor>
68
68
  atdd-flow task block <project> <task-id> --by <address> --reason <text>
69
69
  atdd-flow task supersede|reject <project> <task-id> --by <coordinator-address> --reason <text>
70
+ atdd-flow task supersede <project> <task-id> --by main@<project> --reason <text> --authorization <operator-message-id>
70
71
  atdd-flow task defer <project> <task-id> --by <coordinator-address> --reason <text> --owner <address> --trigger <text> --review-at <ISO-8601>
71
72
  atdd-flow task unblock <project> <task-id> --by <coordinator-address>
72
73
  atdd-flow task list <project> [--coordinator <address>] [--assignee <address>]
@@ -83,7 +84,7 @@ Usage:
83
84
  atdd-flow thread add <thread-id> <address>
84
85
  atdd-flow thread open <thread-id>
85
86
  atdd-flow message read <message-id>
86
- atdd-flow post <thread-id> --from <address> --to <all|address,...> --body <text> [--label <non-sensitive-text>] [--expects-result] [--task-transfer-authorization <json>]
87
+ atdd-flow post <thread-id> --from <address> --to <all|address,...> --body <text> [--label <non-sensitive-text>] [--expects-result] [--task-transfer-authorization <json>] [--task-supersede-authorization <json>]
87
88
  atdd-flow receipt <thread-id> <message-id> --from <address> [--body <text>] [--label <non-sensitive-text>]
88
89
  atdd-flow result <thread-id> <message-id> --from <address> --body <text> [--label <non-sensitive-text>]
89
90
  atdd-flow status [project <project>|task <project> <task-id>|seat <address>|thread <thread-id>] [--all]
@@ -99,7 +99,7 @@ async function isExactNamedCoordinator(root: string, record: Seat, address: stri
99
99
  return Boolean(worktree && await hasUniqueCoordinatorPlacement(root, record, worktree));
100
100
  }
101
101
 
102
- async function launchEligibleTasks(root: string, record: Seat, address: string) {
102
+ export async function launchEligibleTasks(root: string, record: Seat, address: string) {
103
103
  const coordinator = await isExactNamedCoordinator(root, record, address);
104
104
  const operator = await isExactOperatorSeat(root, record, address);
105
105
  const tasks = await seatTasks(root, record.project, record.address);
@@ -0,0 +1,113 @@
1
+ // URN: component:flow:e022:task-supersede-authorization:backend:integration
2
+ // Tested-By:
3
+ // - test:atdd-flow:coordinator-stale-task-supersede-authorization
4
+ // Runtime: bun
5
+ // Purpose: Owner-authorized supersession of a stale coordinator task.
6
+
7
+ import { createHash } from "node:crypto";
8
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { atomicYaml, paths, readYaml, seat, yaml } from "./core";
11
+
12
+ /** The one exact tuple an operator@desk message may carry to authorize `task supersede` by main. */
13
+ export type SupersedeAuthorization = {
14
+ schema: "atdd-workflow/task-supersede-authorization/v1";
15
+ project: string;
16
+ task: string;
17
+ coordinator: string;
18
+ action: "supersede";
19
+ reason: string;
20
+ task_sha256: string;
21
+ };
22
+
23
+ const sha256 = (content: string) => createHash("sha256").update(content).digest("hex");
24
+
25
+ /** Locate the one operator@desk message `reference`, addressed to main in a thread main joined, and return its typed payload. */
26
+ export async function operatorAuthorizationPayload(root: string, reference: string, main: string): Promise<unknown> {
27
+ type Found = { schema?: string; id?: string; from?: string; to?: "all" | string[]; authorization?: unknown };
28
+ const folders = await readdir(paths(root).threads, { withFileTypes: true });
29
+ const matches = (await Promise.all(folders.filter((entry) => entry.isDirectory() && entry.name.startsWith("T-"))
30
+ .map(async (entry) => {
31
+ try {
32
+ const [thread, message] = await Promise.all([
33
+ readYaml<{ schema?: string; participants?: string[] }>(paths(root).threadFile(entry.name)),
34
+ readYaml<Found>(paths(root).message(entry.name, reference)),
35
+ ]);
36
+ return { thread, message };
37
+ } catch { return undefined; }
38
+ }))).filter((entry): entry is NonNullable<typeof entry> =>
39
+ entry?.thread.schema === "atdd-workflow/thread/v1" && entry.thread.participants?.includes(main) === true
40
+ && entry.message.schema === "atdd-workflow/message/v1" && entry.message.id === reference);
41
+ if (matches.length !== 1 || matches[0]?.message.from !== "operator@desk") {
42
+ throw new Error(`Authorization ${reference} is not an owner-authorized topology plan.`);
43
+ }
44
+ const message = matches[0].message;
45
+ if (message.to !== "all" && !message.to?.includes(main)) throw new Error(`Authorization ${reference} is not applicable to ${main}.`);
46
+ return message.authorization;
47
+ }
48
+
49
+ /** Create-only evidence: an existing file must hold exactly the same bytes. */
50
+ async function publishOrVerify(file: string, bytes: string) {
51
+ try { await writeFile(file, bytes, { encoding: "utf8", flag: "wx" }); }
52
+ catch (error) { if ((error as { code?: string }).code !== "EEXIST") throw error; }
53
+ if (await readFile(file, "utf8") !== bytes) throw new Error("Task supersede archive collides with different immutable evidence.");
54
+ }
55
+
56
+ /** Archive the pre-mutation task bytes and the authorization by task hash, create-only. */
57
+ async function archiveEvidence(root: string, projectName: string, id: string, hash: string, taskBytes: string, evidence: Record<string, unknown>) {
58
+ const folder = join(root, ".atdd-flow", "task-supersede-archive", projectName, id, hash);
59
+ await mkdir(folder, { recursive: true });
60
+ await publishOrVerify(join(folder, "task.yaml"), taskBytes);
61
+ await publishOrVerify(join(folder, "authorization.yaml"), yaml.print(evidence));
62
+ }
63
+
64
+ /** Exact tuple equality: the same keys with the same values, and nothing more. */
65
+ function sameTuple(actual: unknown, expected: SupersedeAuthorization) {
66
+ const record = actual && typeof actual === "object" ? actual as Record<string, unknown> : undefined;
67
+ const keys = Object.keys(expected) as (keyof SupersedeAuthorization)[];
68
+ return Boolean(record) && Object.keys(record!).length === keys.length && keys.every((key) => record![key] === expected[key]);
69
+ }
70
+
71
+ type Subject = { project: string; id: string; coordinator: string; reason: string; main: string; reference: string };
72
+
73
+ async function assertStaleCoordinatorTask(root: string, subject: Subject) {
74
+ const owner = await seat(root, subject.coordinator).catch(() => undefined);
75
+ if (owner?.role !== "coordinator" || owner.project !== subject.project) {
76
+ throw new Error(`Task ${subject.id} is not owned by a named coordinator of ${subject.project}; the owner authorization applies only to coordinator tasks.`);
77
+ }
78
+ const { launchEligibleTasks } = await import("./pi-runtime-launch");
79
+ if ((await launchEligibleTasks(root, owner, subject.coordinator)).length) {
80
+ throw new Error(`${subject.coordinator} has a launch-eligible task and can supersede task ${subject.id} itself.`);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Validate every exact owner-authorization and staleness condition, then archive the evidence. Nothing is written until
86
+ * all checks pass; the caller records the terminal disposition afterwards. Returns the authorization reference.
87
+ */
88
+ export async function authorizeSupersede(root: string, subject: Subject): Promise<string> {
89
+ const taskBytes = await readFile(paths(root).taskFile(subject.project, subject.id), "utf8");
90
+ const expected: SupersedeAuthorization = {
91
+ schema: "atdd-workflow/task-supersede-authorization/v1", project: subject.project, task: subject.id,
92
+ coordinator: subject.coordinator, action: "supersede", reason: subject.reason, task_sha256: sha256(taskBytes),
93
+ };
94
+ const authorization = await operatorAuthorizationPayload(root, subject.reference, subject.main);
95
+ if (!sameTuple(authorization, expected)) throw new Error(`Authorization ${subject.reference} is not an exact supersede authorization.`);
96
+ await assertStaleCoordinatorTask(root, subject);
97
+ await archiveEvidence(root, subject.project, subject.id, expected.task_sha256, taskBytes, { id: subject.reference, from: "operator@desk", authorization: expected });
98
+ return subject.reference;
99
+ }
100
+
101
+ /** Parse `post --task-supersede-authorization`: operator@desk only, one exact task-supersede-authorization/v1 tuple. */
102
+ export function parseSupersedeAuthorization(encoded: string | undefined, from: string): SupersedeAuthorization | undefined {
103
+ if (!encoded) return undefined;
104
+ if (from !== "operator@desk") throw new Error("Only operator@desk may create a task supersede authorization.");
105
+ let value: Partial<SupersedeAuthorization> | undefined;
106
+ try { value = JSON.parse(encoded); } catch { throw new Error("--task-supersede-authorization must be valid JSON."); }
107
+ const fields = [value?.project, value?.task, value?.coordinator, value?.reason, value?.task_sha256];
108
+ if (value?.schema !== "atdd-workflow/task-supersede-authorization/v1" || value.action !== "supersede"
109
+ || !fields.every((entry) => typeof entry === "string" && entry.length) || !/^[a-f0-9]{64}$/i.test(value.task_sha256!)) {
110
+ throw new Error("--task-supersede-authorization must contain one exact task-supersede-authorization/v1 tuple.");
111
+ }
112
+ return value as SupersedeAuthorization;
113
+ }
package/src/tasks.ts CHANGED
@@ -3,6 +3,7 @@ import { basename, dirname, join, resolve } from "node:path";
3
3
  import {
4
4
  type Checkpoint, type Seat, atomicYaml, canonicalAddress, exists, now, paths, project, readYaml, required, runOutput, seat, taskId, values, words,
5
5
  } from "./core";
6
+ import { operatorAuthorizationPayload } from "./task-supersede-authorization";
6
7
  import { branchCommit, currentCommit, isAncestor, vcsMode, type VcsMode } from "./vcs";
7
8
 
8
9
  export type TaskStatus = "todo" | "in_progress" | "review" | "done" | "superseded" | "rejected";
@@ -27,7 +28,7 @@ export type CoordinatorTransfer = {
27
28
  lineage_ref?: LineageRef;
28
29
  effective_at: string;
29
30
  };
30
- export type TerminalDisposition = { kind: "superseded" | "rejected"; by: string; at: string; reason: string };
31
+ export type TerminalDisposition = { kind: "superseded" | "rejected"; by: string; at: string; reason: string; authorization?: string };
31
32
  export type DeferredDisposition = { by: string; at: string; reason: string; owner: string; trigger: string; review_at: string };
32
33
  export type DispatchAlert = { key: string; message: string; observed_at: string };
33
34
  /** Persisted evidence for an immediate assignment; each field is written only by its own typed command. */
@@ -463,28 +464,7 @@ type TransferAuthorization = {
463
464
  };
464
465
 
465
466
  async function operatorAuthorization(root: string, reference: string, main: string, expected: TransferAuthorization) {
466
- type AuthorizationMessage = { schema?: string; id?: string; from?: string; to?: "all" | string[]; authorization?: TransferAuthorization };
467
- type AuthorizationThread = { schema?: string; participants?: string[] };
468
- const folders = await readdir(paths(root).threads, { withFileTypes: true });
469
- const matches = (await Promise.all(folders.filter((entry) => entry.isDirectory() && entry.name.startsWith("T-"))
470
- .map(async (entry) => {
471
- try {
472
- const [thread, message] = await Promise.all([
473
- readYaml<AuthorizationThread>(paths(root).threadFile(entry.name)),
474
- readYaml<AuthorizationMessage>(paths(root).message(entry.name, reference)),
475
- ]);
476
- return { thread, message };
477
- } catch { return undefined; }
478
- }))).filter((entry): entry is NonNullable<typeof entry> =>
479
- entry?.thread.schema === "atdd-workflow/thread/v1" && entry.thread.participants?.includes(main) === true
480
- && entry.message.schema === "atdd-workflow/message/v1" && entry.message.id === reference);
481
- if (matches.length !== 1 || matches[0]?.message.from !== "operator@desk") {
482
- throw new Error(`Authorization ${reference} is not an owner-authorized topology plan.`);
483
- }
484
- if (matches[0].message.to !== "all" && !matches[0].message.to?.includes(main)) {
485
- throw new Error(`Authorization ${reference} is not applicable to ${main}.`);
486
- }
487
- const authorization = matches[0].message.authorization;
467
+ const authorization = await operatorAuthorizationPayload(root, reference, main) as TransferAuthorization | undefined;
488
468
  if (!authorization || authorization.schema !== expected.schema
489
469
  || authorization.project !== expected.project || authorization.task !== expected.task
490
470
  || authorization.from !== expected.from || authorization.to !== expected.to
@@ -721,10 +701,14 @@ export async function block(root: string, projectName: string, id: string, args:
721
701
  export async function disposition(root: string, projectName: string, id: string, kind: TerminalDisposition["kind"], args: string[]) {
722
702
  const task = await readTask(root, projectName, taskId(id));
723
703
  const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
724
- if (actor !== task.coordinator) throw new Error(`Only ${task.coordinator} may ${kind} task ${id}.`);
704
+ const owner = kind === "superseded" && actor === `main@${projectName}` && actor !== task.coordinator && Boolean(words(args, "--authorization"));
705
+ if (actor !== task.coordinator && !owner) throw new Error(`Only ${task.coordinator} may ${kind} task ${id}.`);
725
706
  if (task.disposition || task.status === "done") throw new Error(`Task ${id} already has terminal history and cannot be ${kind}.`);
707
+ const reason = required(words(args, "--reason"), "--reason");
708
+ const reference = owner ? words(args, "--authorization") : undefined;
709
+ const authorization = reference ? await (await import("./task-supersede-authorization")).authorizeSupersede(root, { project: projectName, id, coordinator: task.coordinator, reason, main: actor, reference }) : undefined;
726
710
  task.status = kind;
727
- task.disposition = { kind, by: actor, at: now(), reason: required(words(args, "--reason"), "--reason") };
711
+ task.disposition = { kind, by: actor, at: now(), reason, ...(authorization ? { authorization } : {}) };
728
712
  delete task.blocker;
729
713
  await writeTask(root, projectName, taskId(id), task);
730
714
  console.log(`${id} ${kind}`);
package/src/threads.ts CHANGED
@@ -2,6 +2,7 @@ import { mkdir, readdir, rm, stat } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { notify } from "./adapters";
4
4
  import { seatTasks, terminal } from "./tasks";
5
+ import { type SupersedeAuthorization, parseSupersedeAuthorization } from "./task-supersede-authorization";
5
6
  import {
6
7
  atomicYaml, canonicalAddress, desk, has, id, now, paths, project, readYaml,
7
8
  required, runtimeAddress, seat, words,
@@ -29,7 +30,7 @@ export type Message = {
29
30
  created_at: string;
30
31
  body: string;
31
32
  /** Immutable owner authorization for one exact coordinator-transfer tuple. */
32
- authorization?: TransferAuthorization;
33
+ authorization?: TransferAuthorization | SupersedeAuthorization;
33
34
  };
34
35
 
35
36
  async function thread(root: string, threadId: string) {
@@ -280,6 +281,11 @@ function transferAuthorization(args: string[], from: string) {
280
281
  return value as TransferAuthorization;
281
282
  }
282
283
 
284
+ /** The one typed owner authorization a post may carry: a transfer tuple or a supersede tuple. */
285
+ function typedAuthorization(args: string[], from: string) {
286
+ return transferAuthorization(args, from) ?? parseSupersedeAuthorization(words(args, "--task-supersede-authorization"), from);
287
+ }
288
+
283
289
  async function post(root: string, threadId: string, args: string[], overrides: Partial<Message> = {}) {
284
290
  const record = await thread(root, threadId);
285
291
  const from = await canonicalAddress(root, required(overrides.from ?? words(args, "--from"), "--from"));
@@ -289,7 +295,7 @@ async function post(root: string, threadId: string, args: string[], overrides: P
289
295
  if (recipients.some((address) => !record.participants.includes(address))) throw new Error("Recipients must be thread participants.");
290
296
  await assertRoute(root, from, recipients.filter((address) => address !== from));
291
297
  const kind = overrides.kind ?? "message";
292
- const authorization = overrides.authorization ?? transferAuthorization(args, from);
298
+ const authorization = overrides.authorization ?? typedAuthorization(args, from);
293
299
  // Labels are an explicit, non-sensitive operator hint; message bodies never enter durable IDs.
294
300
  const label = words(args, "--label") ?? kind;
295
301
  const message: Message = {