@afokapu/atdd-flow 0.3.37 → 0.3.39

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/README.md CHANGED
@@ -188,7 +188,7 @@ atdd-flow multiplexer apply herdr
188
188
 
189
189
  For each Desk project, apply reconciles the declared repository checkout as workspace `{project}` and gives every main/coordinator seat using it a tab and pane named `{seat.address}`. Linked coordinator worktrees and active drivers become linked-worktree workspaces named `{seat.address}`, with an equally named tab and pane. A driver is active only while it has an unblocked `in_progress` assigned task or a bound Herdr pane that still runs an agent; never-started, blocked, and finished drivers get no workspace. An explicit per-seat projection such as `pi runtime launch` still projects its own seat. A missing linked worktree is skipped and reported under `unprojected` instead of aborting the Desk. Unbound worktrees are ignored, and apply never focuses or guesses about other sessions.
190
190
 
191
- A workspace is stale when it is labelled with a Desk driver seat address, that driver is retired or inactive, and its checkout is a linked worktree of the seat's Desk project or no longer exists. `multiplexer status` reports the `stale` count. `multiplexer apply` closes stale workspaces only when `desk.yaml` enables it:
191
+ A workspace is stale when it is labelled with a Desk driver seat address, that driver is retired or inactive, and its checkout is a linked worktree of the seat's Desk project or no longer exists. `multiplexer status` reports the `stale` count and, read-only, lists each one under `stale_workspaces` with its `address`, `workspace_id`, `reason` (`retired` or `no active task`), current pane `agents` and `agent_status`, and the `outcome` apply would take (`close` or `keep`, with `because` for a kept workspace). Review that list before running apply. `multiplexer apply` closes stale workspaces only when `desk.yaml` enables it:
192
192
 
193
193
  ```yaml
194
194
  multiplexer:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.37",
3
+ "version": "0.3.39",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,7 +41,7 @@
41
41
  "bun": ">=1.3.0"
42
42
  },
43
43
  "devDependencies": {
44
- "@afokapu/atdd-bun": "0.10.71"
44
+ "@afokapu/atdd-bun": "0.10.79"
45
45
  },
46
46
  "dependencies": {
47
47
  "@typesafe-ai/sdk": "^0.6.0",
@@ -26,7 +26,7 @@ export type HerdrPolicy = {
26
26
 
27
27
  type Workspace = { workspace_id: string; label?: string; worktree?: { checkout_path?: string } };
28
28
  type Tab = { tab_id: string; workspace_id: string; label?: string };
29
- type Pane = { pane_id: string; tab_id: string; label?: string; agent_status?: string };
29
+ type Pane = { pane_id: string; tab_id: string; label?: string; agent?: string; agent_status?: string };
30
30
  type Target = { project: string; worktree: string; workspaceLabel: string; seat?: Seat };
31
31
  type WorkspaceState = { id: string; label: string; worktree: string; rootTab?: string; rootPane?: string };
32
32
  type HerdrCommand = (command: string[]) => Promise<string>;
@@ -307,7 +307,7 @@ export async function projectHerdrSeat(root: string, address: string, session: s
307
307
  return (await ensureTarget(session, target, workspaces, command, primary))!;
308
308
  }
309
309
 
310
- type StaleWorkspace = { workspace: Workspace; address: string };
310
+ type StaleWorkspace = { workspace: Workspace; address: string; reason: "retired" | "no active task" };
311
311
 
312
312
  async function commonDirectory(path: string) {
313
313
  return runOutput(["git", "-C", path, "rev-parse", "--path-format=absolute", "--git-common-dir"]);
@@ -344,19 +344,42 @@ async function staleWorkspaces(root: string, workspaces: Workspace[], activeDriv
344
344
  for (const workspace of workspaces.filter((candidate) => candidate.label === entry.address)) {
345
345
  const checkout = workspace.worktree?.checkout_path;
346
346
  if (typeof checkout !== "string" || checkout === "" || resolve(checkout) === primary) continue;
347
- if (await linkedOrMissing(checkout, primary)) result.push({ workspace, address: entry.address });
347
+ if (await linkedOrMissing(checkout, primary)) result.push({ workspace, address: entry.address, reason: entry.retired ? "retired" : "no active task" });
348
348
  }
349
349
  }
350
350
  }
351
351
  return result;
352
352
  }
353
353
 
354
- /** Closes one stale workspace unless any of its agents is working or blocked. */
355
- async function closeStale(session: string, stale: StaleWorkspace, command: HerdrCommand) {
356
- const busy = (await panes(session, stale.workspace.workspace_id, command)).find((entry) => entry.agent_status === "working" || entry.agent_status === "blocked");
357
- if (busy) return `kept ${stale.address} ${stale.workspace.workspace_id}: agent in ${busy.pane_id} is ${busy.agent_status}`;
358
- await command(["herdr", "--session", session, "workspace", "close", stale.workspace.workspace_id]);
359
- return `closed ${stale.address} ${stale.workspace.workspace_id}`;
354
+ type StaleDecision = {
355
+ address: string;
356
+ workspace_id: string;
357
+ reason: StaleWorkspace["reason"];
358
+ agents: Array<Pick<Pane, "pane_id" | "agent" | "agent_status">>;
359
+ outcome: "close" | "keep";
360
+ because?: string;
361
+ };
362
+
363
+ const closeInactiveOff = "close_inactive is off";
364
+
365
+ /**
366
+ * Read-only decision for one stale workspace, shared by `status` and `apply`: it is closed only when
367
+ * close_inactive is on and none of its agents is working or blocked.
368
+ */
369
+ async function decideStale(session: string, stale: StaleWorkspace, close: boolean, command: HerdrCommand): Promise<StaleDecision> {
370
+ const agents = (await panes(session, stale.workspace.workspace_id, command))
371
+ .map(({ pane_id, agent, agent_status }) => ({ pane_id, ...(agent ? { agent } : {}), ...(agent_status ? { agent_status } : {}) }));
372
+ const busy = agents.find((entry) => entry.agent_status === "working" || entry.agent_status === "blocked");
373
+ const because = !close ? closeInactiveOff : busy ? `agent in ${busy.pane_id} is ${busy.agent_status}` : undefined;
374
+ return { address: stale.address, workspace_id: stale.workspace.workspace_id, reason: stale.reason, agents, outcome: because ? "keep" : "close", ...(because ? { because } : {}) };
375
+ }
376
+
377
+ /** Acts on one stale-workspace decision: closes it, or reports why it is kept. */
378
+ async function applyStale(session: string, decision: StaleDecision, command: HerdrCommand) {
379
+ const label = `${decision.address} ${decision.workspace_id}`;
380
+ if (decision.outcome === "keep") return `${decision.because === closeInactiveOff ? "stale" : "kept"} ${label}: ${decision.because}`;
381
+ await command(["herdr", "--session", session, "workspace", "close", decision.workspace_id]);
382
+ return `closed ${label}`;
360
383
  }
361
384
 
362
385
  const closeInactive = async (root: string) => (await desk(root)).multiplexer?.close_inactive === true;
@@ -374,7 +397,7 @@ export async function closeRetiredDriverWorkspace(root: string, entry: Seat, com
374
397
  if (!session) return;
375
398
  const workspaces = (await liveWorkspaces(session, command)).filter((workspace) => workspace.label === entry.address);
376
399
  if (!workspaces.length) return;
377
- for (const stale of await staleWorkspaces(root, workspaces, new Set())) console.log(await closeStale(session, stale, command));
400
+ for (const stale of await staleWorkspaces(root, workspaces, new Set())) console.log(await applyStale(session, await decideStale(session, stale, true, command), command));
378
401
  } catch (error) {
379
402
  console.log(`Workspace close for ${entry.address} was not completed: ${(error as Error).message}`);
380
403
  }
@@ -406,15 +429,16 @@ export async function multiplexer(root: string, args: string[]) {
406
429
  const activeDrivers = new Set<string>();
407
430
  const desired = await targets(root, { unprojected, live: livePane(session, command), activeDrivers });
408
431
  const workspaces = await liveWorkspaces(session, command);
409
- const stale = await staleWorkspaces(root, workspaces, activeDrivers);
410
432
  const close = await closeInactive(root);
433
+ const stale = await staleWorkspaces(root, workspaces, activeDrivers);
434
+ const decide = (entry: StaleWorkspace) => decideStale(session, entry, close, command);
411
435
  if (action === "status") {
412
436
  const present = desired.filter((target) => matchWorkspace(workspaces, target));
413
437
  const topology = await Promise.all(desired.map(async (target) => {
414
438
  const workspace = matchWorkspace(workspaces, target);
415
439
  return workspace ? topologyCompliant(session, target, workspace, command) : false;
416
440
  }));
417
- console.log(JSON.stringify({ schema: "atdd-workflow/multiplexer-status/v1", application: "herdr", session, desired: desired.length, present: present.length, topology_compliant: topology.filter(Boolean).length, stale: stale.length, close_inactive: close, unprojected }, null, 2));
441
+ console.log(JSON.stringify({ schema: "atdd-workflow/multiplexer-status/v1", application: "herdr", session, desired: desired.length, present: present.length, topology_compliant: topology.filter(Boolean).length, stale: stale.length, stale_workspaces: await Promise.all(stale.map(decide)), close_inactive: close, unprojected }, null, 2));
418
442
  return;
419
443
  }
420
444
  const primary = new Map<string, WorkspaceState>();
@@ -429,8 +453,7 @@ export async function multiplexer(root: string, args: string[]) {
429
453
  await ensureTarget(session, target, workspaces, command, projectPrimary);
430
454
  }
431
455
  for (const entry of unprojected) console.log(`unprojected ${entry.address}: ${entry.reason}`);
432
- for (const entry of stale) {
433
- console.log(close ? await closeStale(session, entry, command) : `stale ${entry.address} ${entry.workspace.workspace_id}: close_inactive is off`);
434
- }
456
+ // Decided at close time with the same read-only rule `status` reports.
457
+ for (const entry of stale) console.log(await applyStale(session, await decide(entry), command));
435
458
  console.log(`Projected ${desired.length} Desk seat worktree(s) into Herdr session ${session}.`);
436
459
  }