@afokapu/atdd-flow 0.3.9 → 0.3.10

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.9",
3
+ "version": "0.3.10",
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
@@ -10,6 +10,7 @@ import * as judgment from "./judgment";
10
10
  import * as reviews from "./reviews";
11
11
  import { status } from "./overview";
12
12
  import { multiplexer } from "./multiplexer";
13
+ import * as cleanup from "./ephemeral-resources";
13
14
 
14
15
  const usage = `atdd-flow — filesystem-first agent seats and tasks
15
16
 
@@ -39,6 +40,8 @@ Usage:
39
40
  atdd-flow task unblock <project> <task-id> --by <coordinator-address>
40
41
  atdd-flow task list <project> [--coordinator <address>] [--assignee <address>]
41
42
  atdd-flow task open <project> <task-id>
43
+ atdd-flow cleanup status <project>
44
+ atdd-flow cleanup checklist <project> <task-id>
42
45
  atdd-flow thread start --with <address,...> --subject <text> [--task <project/task-id>]
43
46
  atdd-flow thread add <thread-id> <address>
44
47
  atdd-flow thread open <thread-id>
@@ -108,6 +111,12 @@ async function main() {
108
111
  if (subcommand === "open") return tasks.open(root, required(projectName, "project"), required(taskId, "task id"));
109
112
  throw new Error("Use `atdd-flow task add|assign|amend|import|start|prove|review|return|done|block|unblock|list|open`.");
110
113
  },
114
+ cleanup: async () => {
115
+ const [subcommand, projectName, taskId] = rest;
116
+ if (subcommand === "status") return console.log(Bun.YAML.stringify(await cleanup.coordinatorStatus(root, required(projectName, "project"))));
117
+ if (subcommand === "checklist") return console.log(await cleanup.checklist(root, { project: required(projectName, "project"), task: required(taskId, "task id") }));
118
+ throw new Error("Use `atdd-flow cleanup status <project>` or `atdd-flow cleanup checklist <project> <task-id>`.");
119
+ },
111
120
  thread: async () => {
112
121
  const [subcommand, ...tail] = rest;
113
122
  if (subcommand === "start") return startThread(root, tail);
@@ -0,0 +1,236 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readdir } from "node:fs/promises";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+ import { atomicYaml, exists, now, readYaml, taskId } from "./core";
5
+
6
+ export type InventoryKind = "fixture-directory" | "git-worktree" | "git-branch" | "process" | "port" | "herdr-workspace" | "github-preview";
7
+ export type InventoryEntry = { kind: InventoryKind; locator: string };
8
+ export type Parent = { project: string; task: string; pr?: string };
9
+ export type Declaration = {
10
+ id: string;
11
+ owner: string;
12
+ purpose: string;
13
+ parent: Parent;
14
+ trigger: { merge?: boolean; close?: boolean; expiresAt?: string };
15
+ inventory: InventoryEntry[];
16
+ cleanupAssignee: string;
17
+ authorizedScope: "local" | "git" | "herdr" | "github";
18
+ };
19
+ type Status = "pending" | "ready" | "cancelled" | "received" | "failed" | "timeout" | "retained";
20
+ type Record = {
21
+ schema: "atdd-workflow/ephemeral-resource/v1";
22
+ id: string;
23
+ declaration: Declaration;
24
+ cleanupTask: string;
25
+ status: Status;
26
+ updated_at: string;
27
+ };
28
+ type CleanupRecord = {
29
+ schema: "atdd-workflow/cleanup-record/v1";
30
+ id: string;
31
+ parent: Parent;
32
+ assignee: string;
33
+ status: Status;
34
+ updated_at: string;
35
+ };
36
+ type Audit = { schema: "atdd-workflow/ephemeral-resource-audit/v1"; event: string; immutable: true; at: string; detail?: string };
37
+
38
+ const resourceRoot = (root: string) => join(root, ".atdd-flow", "ephemeral-resources");
39
+ const resourceFile = (root: string, id: string) => join(resourceRoot(root), `${taskId(id)}.yaml`);
40
+ const cleanupFile = (root: string, id: string) => join(resourceRoot(root), "cleanup", `${taskId(id)}.yaml`);
41
+ const auditRoot = (root: string, id: string) => join(resourceRoot(root), "audit", taskId(id));
42
+ const tombstoneFile = (root: string, id: string) => join(resourceRoot(root), "tombstones", `${taskId(id)}.yaml`);
43
+
44
+ function required(value: unknown, name: string): asserts value {
45
+ if (value === undefined || value === null || value === "" || Array.isArray(value) && !value.length) throw new Error(`Declaration requires ${name}.`);
46
+ }
47
+
48
+ const scopeForKind: Record<InventoryKind, Declaration["authorizedScope"]> = {
49
+ "fixture-directory": "local",
50
+ "git-worktree": "git",
51
+ "git-branch": "git",
52
+ process: "local",
53
+ port: "local",
54
+ "herdr-workspace": "herdr",
55
+ "github-preview": "github",
56
+ };
57
+
58
+ function validate(value: Declaration) {
59
+ required(value?.id, "id");
60
+ taskId(value.id);
61
+ required(value.owner, "owner");
62
+ required(value.purpose, "purpose");
63
+ required(value.parent, "authoritative parent");
64
+ required(value.parent.project, "parent project");
65
+ required(value.parent.task, "parent task");
66
+ taskId(value.parent.task);
67
+ required(value.trigger, "expiry or trigger");
68
+ if (!value.trigger.merge && !value.trigger.close && !value.trigger.expiresAt) throw new Error("Declaration requires an expiry or trigger.");
69
+ required(value.inventory, "exact inventory");
70
+ required(value.cleanupAssignee, "cleanup assignee");
71
+ required(value.authorizedScope, "authorized scope");
72
+ if (!(["local", "git", "herdr", "github"] as string[]).includes(value.authorizedScope)) throw new Error("Declaration has an unsupported authorized scope.");
73
+ for (const entry of value.inventory) {
74
+ required(entry?.kind, "inventory kind");
75
+ required(entry.locator, "inventory locator");
76
+ if (!(["fixture-directory", "git-worktree", "git-branch", "process", "port", "herdr-workspace", "github-preview"] as string[]).includes(entry.kind)) {
77
+ throw new Error(`Declaration has unsupported inventory kind: ${entry.kind}.`);
78
+ }
79
+ if (scopeForKind[entry.kind] !== value.authorizedScope) {
80
+ throw new Error(`Declaration scope ${value.authorizedScope} is incompatible with ${entry.kind}; it requires ${scopeForKind[entry.kind]} scope.`);
81
+ }
82
+ }
83
+ }
84
+
85
+ async function read(root: string, id: string) {
86
+ return readYaml<Record>(resourceFile(root, id));
87
+ }
88
+
89
+ async function write(root: string, value: Record) {
90
+ value.updated_at = now();
91
+ await atomicYaml(resourceFile(root, value.id), value);
92
+ await atomicYaml(cleanupFile(root, value.id), {
93
+ schema: "atdd-workflow/cleanup-record/v1", id: value.cleanupTask, parent: value.declaration.parent,
94
+ assignee: value.declaration.cleanupAssignee, status: value.status, updated_at: value.updated_at,
95
+ } satisfies CleanupRecord);
96
+ }
97
+
98
+ /** Audit entries are write-once files; state records only point at that history. */
99
+ async function appendAudit(root: string, id: string, event: string, detail?: string) {
100
+ const folder = auditRoot(root, id);
101
+ await mkdir(folder, { recursive: true });
102
+ // A collision-proof name keeps concurrent signal records write-once; do not
103
+ // derive a filename from a racy directory listing.
104
+ await atomicYaml(join(folder, `${now().replace(/[^0-9]/g, "")}-${randomUUID()}-${event}.yaml`), {
105
+ schema: "atdd-workflow/ephemeral-resource-audit/v1", event, immutable: true, at: now(), ...(detail ? { detail } : {}),
106
+ } satisfies Audit);
107
+ }
108
+
109
+ export async function declare(root: string, declaration: Declaration) {
110
+ validate(declaration);
111
+ if (await exists(resourceFile(root, declaration.id))) throw new Error(`Ephemeral resource ${declaration.id} is already declared.`);
112
+ const value: Record = {
113
+ schema: "atdd-workflow/ephemeral-resource/v1", id: declaration.id, declaration,
114
+ cleanupTask: `cleanup-${taskId(declaration.id)}`, status: "pending", updated_at: now(),
115
+ };
116
+ // Persist the resource and cleanup record before publishing the declaration audit.
117
+ await write(root, value);
118
+ await appendAudit(root, declaration.id, "declared");
119
+ return { id: value.id, cleanupTask: value.cleanupTask, status: value.status as "pending" };
120
+ }
121
+
122
+ export async function signal(root: string, id: string, event: "merged" | "closed" | "expired" | "cancelled") {
123
+ const value = await read(root, id);
124
+ if (value.status === "received" || value.status === "retained") return { status: value.status };
125
+ value.status = event === "cancelled" ? "cancelled" : "ready";
126
+ await write(root, value);
127
+ await appendAudit(root, id, event);
128
+ return { status: value.status as "ready" | "cancelled" };
129
+ }
130
+
131
+ function fixtureRefusal(root: string, declaration: Declaration, entry: InventoryEntry) {
132
+ if (entry.kind !== "fixture-directory" || declaration.authorizedScope !== "local") {
133
+ return `${entry.kind} cleanup requires a separately authorized typed adapter with verified scope, ownership, liveness, and Git safety attestations.`;
134
+ }
135
+ const fixtureRoot = resolve(root, ".atdd-flow", "fixtures");
136
+ const target = resolve(entry.locator);
137
+ const relation = relative(fixtureRoot, target);
138
+ if (!relation || relation.startsWith("..") || isAbsolute(relation)) {
139
+ return `Fixture target ${entry.locator} is outside the declared bounded fixture scope ${fixtureRoot}.`;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * This delivery has no typed destructive adapter. It can execute only a
145
+ * declared local fixture below the Desk's bounded fixture root; all OS, git,
146
+ * process, Herdr, and GitHub targets fail closed before an executor is called.
147
+ * A future adapter must verify and immutably attest scope, ownership, liveness,
148
+ * and Git safety before it is allowed to extend this boundary. No daemon or GC.
149
+ */
150
+ export async function execute(root: string, id: string, executor: (entry: InventoryEntry) => Promise<"removed" | "failed" | "timeout" | "cancelled">) {
151
+ const value = await read(root, id);
152
+ if (value.status === "received") return { status: "received" as const };
153
+ if (value.status === "retained") throw new Error(`Cleanup ${id} is retained and cannot be executed.`);
154
+ if (!(["ready", "cancelled"] as Status[]).includes(value.status)) throw new Error(`Cleanup ${id} is not ready for execution.`);
155
+ for (const entry of value.declaration.inventory) {
156
+ const reason = fixtureRefusal(root, value.declaration, entry);
157
+ if (reason) {
158
+ await appendAudit(root, id, "cleanup-refused", reason);
159
+ throw new Error(reason);
160
+ }
161
+ }
162
+ for (const entry of value.declaration.inventory) {
163
+ const outcome = await executor(entry);
164
+ if (outcome !== "removed") {
165
+ value.status = outcome;
166
+ await write(root, value);
167
+ await appendAudit(root, id, `cleanup-${outcome}`, entry.locator);
168
+ return { status: outcome };
169
+ }
170
+ }
171
+ value.status = "received";
172
+ await write(root, value);
173
+ await appendAudit(root, id, "cleanup-received");
174
+ return { status: "received" as const };
175
+ }
176
+
177
+ export async function receipt(root: string, id: string) {
178
+ const value = await read(root, id);
179
+ if (value.status !== "received") throw new Error(`Cleanup ${id} has no receipt; it is ${value.status}.`);
180
+ return { status: "received" as const };
181
+ }
182
+
183
+ export async function retain(root: string, id: string, decision: { by: string; reason: string }) {
184
+ required(decision?.by, "retained decision author");
185
+ required(decision?.reason, "retained decision reason");
186
+ const value = await read(root, id);
187
+ if (value.status === "received") throw new Error(`Cleanup ${id} already has a receipt.`);
188
+ value.status = "retained";
189
+ await write(root, value);
190
+ const tombstone = tombstoneFile(root, id);
191
+ if (!await exists(tombstone)) await atomicYaml(tombstone, {
192
+ schema: "atdd-workflow/ephemeral-resource-tombstone/v1", resource: id, immutable: true, retained_by: decision.by, reason: decision.reason, at: now(),
193
+ });
194
+ await appendAudit(root, id, "retained", `${decision.by}: ${decision.reason}`);
195
+ return { status: "retained" as const, tombstone };
196
+ }
197
+
198
+ export async function audit(root: string, id: string) {
199
+ const folder = auditRoot(root, id);
200
+ try {
201
+ return await Promise.all((await readdir(folder)).filter((file) => file.endsWith(".yaml")).sort().map((file) => readYaml<Audit>(join(folder, file))));
202
+ } catch { return []; }
203
+ }
204
+
205
+ async function matching(root: string, parent: Parent) {
206
+ try {
207
+ const files = (await readdir(resourceRoot(root))).filter((file) => file.endsWith(".yaml"));
208
+ const records = await Promise.all(files.map((file) => readYaml<Record>(join(resourceRoot(root), file))));
209
+ return records.filter((value) => value.declaration.parent.project === parent.project && value.declaration.parent.task === parent.task);
210
+ } catch { return []; }
211
+ }
212
+
213
+ async function assertSettled(root: string, parent: Parent) {
214
+ const pending = (await matching(root, parent)).filter((value) => !(["received", "retained"] as Status[]).includes(value.status));
215
+ if (pending.length) throw new Error(`Parent ${parent.project}/${parent.task} is held by cleanup receipt or retained decision: ${pending.map((value) => value.id).join(", ")}.`);
216
+ }
217
+
218
+ export const assertParentMayComplete = assertSettled;
219
+ export const assertDependentsMayUnblock = assertSettled;
220
+
221
+ export async function coordinatorStatus(root: string, project: string) {
222
+ // Coordinator status is project-wide, while completion gates match one parent task.
223
+ let all: Record[] = [];
224
+ try {
225
+ all = await Promise.all((await readdir(resourceRoot(root))).filter((file) => file.endsWith(".yaml")).map((file) => readYaml<Record>(join(resourceRoot(root), file))));
226
+ } catch { /* no declarations is a clean status */ }
227
+ return { pending: all.filter((value) => value.declaration.parent.project === project && !(["received", "retained"] as Status[]).includes(value.status)).map((value) => value.id).sort() };
228
+ }
229
+
230
+ export async function checklist(root: string, parent: Parent) {
231
+ const records = await matching(root, parent);
232
+ const pending = records.filter((value) => !(["received", "retained"] as Status[]).includes(value.status));
233
+ return pending.length
234
+ ? `Cleanup pending for ${parent.project}/${parent.task}: obtain cleanup receipt or retained-resource decision for ${pending.map((value) => value.id).join(", ")}.`
235
+ : `Cleanup checklist for ${parent.project}/${parent.task}: every declared resource has a cleanup receipt or retained-resource decision.`;
236
+ }
package/src/tasks.ts CHANGED
@@ -192,6 +192,8 @@ async function transition(root: string, projectName: string, id: string, next: T
192
192
  if (next === "done") {
193
193
  if (actor !== task.coordinator) throw new Error(`Only ${task.coordinator} may complete task ${id}.`);
194
194
  if (!allProofs(task)) throw new Error(`Task ${id} is missing completion proof.`);
195
+ const { assertParentMayComplete } = await import("./ephemeral-resources");
196
+ await assertParentMayComplete(root, { project: projectName, task: id });
195
197
  const { assertAcceptedBehavioralReview } = await import("./reviews");
196
198
  await assertAcceptedBehavioralReview(root, projectName, id, task);
197
199
  if (retire) await retireAssignee(root, projectName, id, task);
@@ -246,6 +248,8 @@ export async function unblock(root: string, projectName: string, id: string, arg
246
248
  if (actor !== task.coordinator) throw new Error(`Only ${task.coordinator} may unblock task ${id}.`);
247
249
  if (task.status !== "in_progress") throw new Error(`Task ${id} can only be unblocked while in_progress.`);
248
250
  if (!task.blocker) throw new Error(`Task ${id} has no blocker to clear.`);
251
+ const { assertDependentsMayUnblock } = await import("./ephemeral-resources");
252
+ await assertDependentsMayUnblock(root, { project: projectName, task: id });
249
253
  delete task.blocker;
250
254
  await writeTask(root, projectName, id, task);
251
255
  console.log(`${id} unblocked`);