@afokapu/atdd-flow 0.3.29 → 0.3.31

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.29",
3
+ "version": "0.3.31",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -191,15 +191,23 @@ async function liveWorkspaces(session: string, command: HerdrCommand) {
191
191
  }
192
192
 
193
193
  /** A live workspace without a checkout path never matches: resolve("") would be the process cwd. */
194
- function matchWorkspace(workspaces: Workspace[], worktree: string) {
195
- return workspaces.find((entry) => {
194
+ /**
195
+ * Herdr may list a primary workspace created for a non-code checkout with no checkout path. Such a
196
+ * workspace matches only the primary target, and only by its exact project label (so it is never
197
+ * renamed); linked seat targets always require a checkout path.
198
+ */
199
+ function matchWorkspace(workspaces: Workspace[], target: Target) {
200
+ const checkoutOf = (entry: Workspace) => {
196
201
  const checkout = entry.worktree?.checkout_path;
197
- return typeof checkout === "string" && checkout !== "" && resolve(checkout) === worktree;
198
- });
202
+ return typeof checkout === "string" && checkout !== "" ? checkout : undefined;
203
+ };
204
+ const byCheckout = workspaces.find((entry) => { const checkout = checkoutOf(entry); return checkout !== undefined && resolve(checkout) === target.worktree; });
205
+ if (byCheckout || target.workspaceLabel !== target.project) return byCheckout;
206
+ return workspaces.find((entry) => checkoutOf(entry) === undefined && entry.label === target.project);
199
207
  }
200
208
 
201
209
  async function ensureWorkspace(session: string, target: Target, workspaces: Workspace[], command: HerdrCommand, primary?: WorkspaceState) {
202
- let current = matchWorkspace(workspaces, target.worktree);
210
+ let current = matchWorkspace(workspaces, target);
203
211
  if (!current) {
204
212
  const args = target.workspaceLabel === target.project
205
213
  ? ["workspace", "create", "--cwd", target.worktree, "--label", target.workspaceLabel, "--no-focus"]
@@ -297,9 +305,9 @@ export async function multiplexer(root: string, args: string[]) {
297
305
  const command: HerdrCommand = herdr;
298
306
  const workspaces = await liveWorkspaces(session, command);
299
307
  if (action === "status") {
300
- const present = desired.filter((target) => matchWorkspace(workspaces, target.worktree));
308
+ const present = desired.filter((target) => matchWorkspace(workspaces, target));
301
309
  const topology = await Promise.all(desired.map(async (target) => {
302
- const workspace = matchWorkspace(workspaces, target.worktree);
310
+ const workspace = matchWorkspace(workspaces, target);
303
311
  return workspace ? topologyCompliant(session, target, workspace, command) : false;
304
312
  }));
305
313
  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, unprojected }, null, 2));
package/src/threads.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readdir, rm, stat } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { notify } from "./adapters";
4
+ import { seatTasks } from "./tasks";
4
5
  import {
5
6
  atomicYaml, canonicalAddress, desk, has, id, now, paths, project, readYaml,
6
7
  required, runtimeAddress, seat, words,
@@ -82,6 +83,13 @@ async function assertRoute(root: string, from: string, recipients: string[]) {
82
83
  throw new Error(`${from} may not directly address operator@desk; route through the responsible coordinator and main seat.`);
83
84
  }
84
85
  if (sender.role === "driver" && targets.some((target) => target.role !== "coordinator")) {
86
+ // A driver may also reach the actual coordinator of its own non-done task in the same project (e.g. main).
87
+ const owners = new Set((await seatTasks(root, sender.project, from))
88
+ .filter(({ task }) => task.assignee === from && task.status !== "done")
89
+ .map(({ task }) => task.coordinator));
90
+ const permitted = (target: { address: string; role: string; project: string }) =>
91
+ target.role === "coordinator" || (target.project === sender.project && owners.has(target.address));
92
+ if (targets.every(permitted)) return;
85
93
  throw new Error(`${from} may message only a coordinator; coordinators and main seats handle further escalation.`);
86
94
  }
87
95
  }