@afokapu/atdd-flow 0.3.83 → 0.3.84

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
@@ -83,8 +83,13 @@ atdd-flow project init example-app
83
83
  atdd-flow project configure example-app --repository /Users/you/Github/example-app --worktree-root /Users/you/Github/worktrees/example-app
84
84
  ```
85
85
 
86
- `project configure` sets only `repository` and `worktree_root`. It fails closed, leaving the project
87
- unchanged, when `--repository` is not the top level of a Git repository or a flag is unknown, repeated,
86
+ New projects default to Jujutsu: `project init` records `vcs: jj`, so `--repository` must be a JJ checkout
87
+ (`jj git init --colocate` on an existing Git clone) and every spawned main, coordinator and driver worktree is a JJ
88
+ workspace. Git is an explicit opt-out (`atdd-flow project init example-app --vcs git`); a legacy project record
89
+ without a `vcs` field keeps reading as Git.
90
+
91
+ `project configure` sets only `repository` and `worktree_root` (and `vcs` when asked). It fails closed, leaving the project
92
+ unchanged, when `--repository` is not the top level of a repository of the project's VCS or a flag is unknown, repeated,
88
93
  or missing its value. It never changes seats, runtimes, tasks, branches, worktrees, or repository
89
94
  content; it lists each seat whose recorded placement violates the topology as `unprojected`.
90
95
 
@@ -629,5 +629,5 @@ export default function (pi: ExtensionAPI, environment: NodeJS.ProcessEnv = proc
629
629
  ? noticeActivation("inactive")
630
630
  : noticeActivation(current.active ? "active" : "inactive", error instanceof Error ? error.message : String(error)));
631
631
  });
632
- pi.on("session_shutdown", () => { void runtime?.stop(); });
632
+ pi.on("session_shutdown", async () => { await runtime?.stop(); });
633
633
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.83",
3
+ "version": "0.3.84",
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
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { basename, resolve } from "node:path";
9
9
  import { init, initProject, configureProject, migrateNamedCoordinatorTopology, spawn, bind, useApplication, attach, describe, checkpoint, launchPiRuntime, migrate, openSeat, piExtensionPath } from "./seats";
10
+ import { formatMonitorRow, monitorContext } from "./context-monitor";
10
11
  import { formatWakeRow, wakePi } from "./pi-wake";
11
12
  import { createExactSessionAdoptionAuthorization } from "./adoption-authorizations";
12
13
  import { addParticipant, openThread, post, readMessage, receipt, result, startThread } from "./threads";
@@ -33,7 +34,7 @@ Legacy coordination.yaml registries are read safely and can be migrated.
33
34
  Usage:
34
35
  atdd-flow init <desk-directory> [--git]
35
36
  atdd-flow desk migrate
36
- atdd-flow project init <project>
37
+ atdd-flow project init <project> [--vcs <git|jj>] (default: jj)
37
38
  atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]
38
39
  atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary [--retain-large-primary-file <path>:<sha256>:<bytes>:<mode> ...]] [--dry-run]
39
40
  atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>
@@ -116,11 +117,11 @@ async function main() {
116
117
  throw new Error("Use `atdd-flow desk migrate`.");
117
118
  },
118
119
  project: async () => {
119
- if (rest[0] === "init") return initProject(root, required(rest[1], "project"));
120
+ if (rest[0] === "init") return initProject(root, required(rest[1], "project"), rest.slice(2));
120
121
  if (rest[0] === "configure") return configureProject(root, required(rest[1], "project"), rest.slice(2));
121
122
  if (rest[0] === "migrate-vcs") return migrateProjectVcs(root, required(rest[1], "project"), rest.slice(2));
122
123
  if (rest[0] === "migrate-named-coordinator-topology") return migrateNamedCoordinatorTopology(root, required(rest[1], "project"), rest.slice(2));
123
- throw new Error("Use `atdd-flow project init <project>`, `atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]`, `atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary [--retain-large-primary-file <path>:<sha256>:<bytes>:<mode> ...]] [--dry-run]`, or `atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>`.");
124
+ throw new Error("Use `atdd-flow project init <project> [--vcs <git|jj>]`, `atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]`, `atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary [--retain-large-primary-file <path>:<sha256>:<bytes>:<mode> ...]] [--dry-run]`, or `atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>`.");
124
125
  },
125
126
  spawn: () => spawn(root, required(rest[0], "project"), required(rest[1], "role"), required(rest[2], "name"), rest.slice(3)),
126
127
  bind: () => bind(root, required(rest[0], "address"), rest.slice(1)),
@@ -133,6 +134,12 @@ async function main() {
133
134
  if (rows.length !== 1) console.log(`pi wake: ${rows.length} seat(s) — ${["woke", "swapped+woke", "deferred", "refused", "skipped", "failed"].map((outcome) => [outcome, rows.filter((entry) => entry.outcome === outcome).length] as const).filter(([, count]) => count).map(([outcome, count]) => `${count} ${outcome}`).join(", ") || "none"}`);
134
135
  return;
135
136
  }
137
+ if (rest[0] === "context-monitor") {
138
+ const { rows, success } = await monitorContext(root, rest.slice(1), (entry) => console.log(formatMonitorRow(entry)));
139
+ if (!success) process.exitCode = 1;
140
+ if (rows.length !== 1) console.log(`pi context-monitor: ${rows.length} seat(s) — ${["prompted", "would-prompt", "below", "already", "deferred", "refused", "skipped", "failed"].map((outcome) => [outcome, rows.filter((entry) => entry.outcome === outcome).length] as const).filter(([, count]) => count).map(([outcome, count]) => `${count} ${outcome}`).join(", ") || "none"}`);
141
+ return;
142
+ }
136
143
  if (rest[0] === "runtime" && rest[1] === "authorize-adoption") return createExactSessionAdoptionAuthorization(root, required(rest[2], "authorization id"), rest.slice(3));
137
144
  if (rest[0] === "runtime" && rest[1] === "launch") {
138
145
  const plan = await launchPiRuntime(root, required(rest[2], "address"), rest.slice(3));
@@ -140,7 +147,7 @@ async function main() {
140
147
  else console.log(`Launched Pi runtime for ${required(rest[2], "address")} with ${plan.candidate}.`);
141
148
  return;
142
149
  }
143
- throw new Error("Use `pi extension-path`, `pi wake <seat>|--scope project|desk [--model-profile <name>] --by <authorized-seat>`, `pi runtime authorize-adoption <id> ...`, or `pi runtime launch <address> [--pane <asserted-pane>] --herdr-session <session> [--resume] [--dry-run] [--adopt-session <exact-jsonl> --source-herdr-session <session> --source-pane <pane> --authorization <record-id>]`.");
150
+ throw new Error("Use `pi extension-path`, `pi wake <seat>|--scope project|desk [--model-profile <name>] --by <authorized-seat>`, `pi context-monitor <seat>|--scope project|desk --by <authorized-seat> [--dry-run]`, `pi runtime authorize-adoption <id> ...`, or `pi runtime launch <address> [--pane <asserted-pane>] --herdr-session <session> [--resume] [--dry-run] [--adopt-session <exact-jsonl> --source-herdr-session <session> --source-pane <pane> --authorization <record-id>]`.");
144
151
  },
145
152
  application: async () => {
146
153
  if (rest[0] === "use") return useApplication(root, required(rest[1], "address"), required(rest[2], "application"));
@@ -0,0 +1,103 @@
1
+ // URN: component:flow:e025:context-monitor:backend:application
2
+ // Tested-By:
3
+ // - test:atdd-flow:server-side-context-monitor
4
+ // Runtime: bun
5
+ // Purpose: Prompt the context-threshold handover from Flow, using the exact Pi transcript.
6
+
7
+ import { mkdir, rm, writeFile } from "node:fs/promises";
8
+ import { dirname, join } from "node:path";
9
+ import { canonicalAddress, modelPortfolio, now, piModel, type ModelPortfolio, yaml } from "./core";
10
+ import { handoverThresholdInstruction } from "./collaboration-policy";
11
+ import { contextHandoverThreshold } from "./handover";
12
+ import { type Outcome, type Prepared, herdr, hold, message, parseWakeArguments, prepare, readBranch, targetAddresses } from "./pi-wake";
13
+ import { recoveryFor } from "./recovery-bundle";
14
+ import type { TranscriptEntry } from "./pi-wake-protocol";
15
+ import { customEntries } from "./pi-wake-protocol";
16
+
17
+ export const monitorUsage = "Use `atdd-flow pi context-monitor <seat>|--scope project|desk --by <authorized-seat> [--dry-run]`.";
18
+ export type MonitorOutcome = Outcome | "prompted" | "would-prompt" | "below" | "already";
19
+ export type MonitorRow = { seat: string; outcome: MonitorOutcome; detail?: string };
20
+
21
+ /** The extension's own threshold prompt; the same custom type also carries successor prompts, so only the threshold detail counts. */
22
+ const thresholdPromptType = "atdd-flow-context-handover-replacement";
23
+ const object = (value: unknown): Record<string, unknown> | undefined => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
24
+ const monitorRow = (seat: string, outcome: MonitorOutcome, detail?: string): MonitorRow => ({ seat, outcome, ...(detail ? { detail } : {}) });
25
+ export const markerPath = (root: string, seat: string, piSession: string) => join(root, ".atdd-flow", "context-monitor", `${encodeURIComponent(seat)}__${piSession}.yaml`);
26
+
27
+ /** The last assistant turn on the active branch: its exact model and the context it occupied. */
28
+ export function lastAssistantUsage(branch: TranscriptEntry[]) {
29
+ for (const entry of [...branch].reverse()) {
30
+ const body = object((entry as Record<string, unknown>).message);
31
+ if (entry.type !== "message" || body?.role !== "assistant") continue;
32
+ const tokens = object(body.usage)?.totalTokens;
33
+ if (typeof tokens === "number" && tokens > 0 && typeof body.provider === "string" && typeof body.model === "string") return { tokens, provider: body.provider, model: body.model };
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ /** The window the Desk declares for exactly this provider/model; nothing is inferred. */
39
+ function declaredWindow(portfolio: ModelPortfolio | undefined, provider: string, model: string) {
40
+ const windows = (portfolio?.models ?? []).flatMap((candidate) => {
41
+ const exact = piModel(candidate);
42
+ return exact?.provider === provider && exact.model === model && candidate.context_window ? [candidate.context_window] : [];
43
+ });
44
+ return windows.length === 1 ? windows[0] : undefined;
45
+ }
46
+
47
+ const format = (value: number) => `${Math.round(value * 10) / 10}%`;
48
+
49
+ /** The reason a session needs no prompt yet (already prompted, no usage, no declared window), else its measured context. */
50
+ async function measure(root: string, address: string, ready: { sessionPath: string; sessionId: string }, portfolio: ModelPortfolio | undefined) {
51
+ const branch = await readBranch(ready.sessionPath).catch((error) => error as Error);
52
+ if (branch instanceof Error) return monitorRow(address, "refused", `cannot read the Pi transcript: ${branch.message}`);
53
+ if (customEntries(branch, thresholdPromptType).some((entry) => entry.details?.threshold !== undefined)) return monitorRow(address, "already", "threshold prompt already in the transcript");
54
+ if (await Bun.file(markerPath(root, address, ready.sessionId)).exists()) return monitorRow(address, "already", "this Pi session was already prompted");
55
+ const usage = lastAssistantUsage(branch);
56
+ if (!usage) return monitorRow(address, "skipped", "no assistant usage recorded in the transcript");
57
+ const window = declaredWindow(portfolio, usage.provider, usage.model);
58
+ if (!window) return monitorRow(address, "skipped", `no context_window declared for ${usage.provider}/${usage.model}`);
59
+ return { percent: usage.tokens / window * 100, window };
60
+ }
61
+
62
+ /** Create-only marker first so a concurrent monitor cannot double-prompt; a failed prompt releases it. */
63
+ async function prompt(root: string, address: string, ready: { session: string; pane: string; sessionId: string }, percent: number, threshold: number, summary: string) {
64
+ const marker = markerPath(root, address, ready.sessionId);
65
+ const bundle = await recoveryFor(root, address, "threshold", ready.sessionId).catch(() => undefined);
66
+ await mkdir(dirname(marker), { recursive: true });
67
+ try { await writeFile(marker, yaml.print({ schema: "atdd-flow/context-monitor-marker/v1", seat: address, pi_session: ready.sessionId, percent: Math.floor(percent), threshold, prompted_at: now() }), { encoding: "utf8", flag: "wx" }); }
68
+ catch { return monitorRow(address, "already", "this Pi session was already prompted"); }
69
+ try { await herdr(ready.session, "agent", "prompt", ready.pane, handoverThresholdInstruction(Math.floor(percent), threshold, bundle)); }
70
+ catch (error) { await rm(marker, { force: true }); return monitorRow(address, "failed", message(error)); }
71
+ return monitorRow(address, "prompted", summary);
72
+ }
73
+
74
+ async function monitorOne(root: string, address: string, prepared: Extract<Prepared, { record: unknown }>, threshold: number, portfolio: ModelPortfolio | undefined, dryRun: boolean): Promise<MonitorRow> {
75
+ const { ready } = prepared;
76
+ const measured = await measure(root, address, ready, portfolio);
77
+ if ("outcome" in measured) return measured;
78
+ const summary = `${format(measured.percent)} of ${measured.window} tokens`;
79
+ if (measured.percent < threshold) return monitorRow(address, "below", `${summary}; threshold ${threshold}%`);
80
+ if (dryRun) return monitorRow(address, "would-prompt", `${summary}; threshold ${threshold}%`);
81
+ return prompt(root, address, ready, measured.percent, threshold, summary);
82
+ }
83
+
84
+ /** Runs the closed `pi context-monitor` grammar; each seat's row is reported in lexical order as it completes. */
85
+ export async function monitorContext(root: string, args: string[], report: (line: MonitorRow) => void = () => undefined) {
86
+ const dryRun = args.includes("--dry-run");
87
+ const options = (() => { try { return parseWakeArguments(args.filter((argument) => argument !== "--dry-run")); } catch { throw new Error(monitorUsage); } })();
88
+ if (options.profile) throw new Error(monitorUsage);
89
+ const actor = await canonicalAddress(root, options.by);
90
+ const addresses = (await targetAddresses(root, options, actor)).sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
91
+ const threshold = await contextHandoverThreshold(root), portfolio = await modelPortfolio(root);
92
+ const rows: MonitorRow[] = [];
93
+ for (const address of addresses) {
94
+ const prepared: Prepared = await prepare(root, address, actor).catch((error) => hold(address, "failed", message(error)));
95
+ const outcome = "row" in prepared ? monitorRow(prepared.row.seat, prepared.row.outcome, prepared.row.detail)
96
+ : await monitorOne(root, address, prepared, threshold, portfolio, dryRun).catch((error) => monitorRow(address, "failed", message(error)));
97
+ rows.push(outcome);
98
+ report(outcome);
99
+ }
100
+ return { rows, success: rows.every((entry) => entry.outcome !== "refused" && entry.outcome !== "failed") };
101
+ }
102
+
103
+ export const formatMonitorRow = (entry: MonitorRow) => `${entry.seat} ${entry.outcome}${entry.detail ? ` ${entry.detail}` : ""}`;
package/src/core.ts CHANGED
@@ -34,6 +34,8 @@ export type ModelCandidate = {
34
34
  args?: string[];
35
35
  description?: string;
36
36
  enabled?: boolean;
37
+ /** Context window in tokens; the server-side context monitor never guesses one. */
38
+ context_window?: number;
37
39
  };
38
40
  export type ModelPortfolio = {
39
41
  schema: "atdd-workflow/models/v1";
@@ -300,6 +302,10 @@ function assertModelMigrations(value: unknown, models: ModelCandidate[]) {
300
302
  for (const [profile, migration] of Object.entries(value)) assertMigration(profile, migration, models);
301
303
  }
302
304
 
305
+ function assertContextWindow(model: ModelCandidate) {
306
+ if (model.context_window !== undefined && !(Number.isInteger(model.context_window) && model.context_window > 0)) throw new Error(`context_window for ${model.id} must be a positive integer of tokens.`);
307
+ }
308
+
303
309
  export async function modelPortfolio(root: string): Promise<ModelPortfolio | undefined> {
304
310
  const file = paths(root).models;
305
311
  if (!await exists(file)) return undefined;
@@ -311,6 +317,7 @@ export async function modelPortfolio(root: string): Promise<ModelPortfolio | und
311
317
  if (!model.id?.trim() || !model.executable?.trim()) throw new Error("Every model requires id and executable.");
312
318
  if (ids.has(model.id)) throw new Error(`Duplicate model id in models.yaml: ${model.id}`);
313
319
  ids.add(model.id);
320
+ assertContextWindow(model);
314
321
  }
315
322
  assertModelMigrations(value.model_migrations, value.models);
316
323
  return value;
@@ -322,21 +329,40 @@ export async function project(root: string, name: string) {
322
329
  return value;
323
330
  }
324
331
 
325
- export async function canonicalAddress(root: string, address: string) {
332
+ /**
333
+ * Follows the Desk's alias chain without validating the result, so stored history written under a renamed seat's
334
+ * old address reads as its current address. One Desk read serves a whole batch; a cycle fails closed.
335
+ */
336
+ export async function addressAliasResolver(root: string) {
326
337
  const aliases = (await desk(root)).aliases ?? {};
327
- const visited = new Set<string>();
328
- let resolved = address;
329
- while (aliases[resolved]) {
330
- if (visited.has(resolved)) throw new Error(`Address alias cycle: ${address}`);
331
- visited.add(resolved);
332
- resolved = aliases[resolved];
333
- }
338
+ return (address: string) => {
339
+ const visited = new Set<string>();
340
+ let resolved = address;
341
+ while (aliases[resolved]) {
342
+ if (visited.has(resolved)) throw new Error(`Address alias cycle: ${address}`);
343
+ visited.add(resolved);
344
+ resolved = aliases[resolved];
345
+ }
346
+ return resolved;
347
+ };
348
+ }
349
+
350
+ export async function canonicalAddress(root: string, address: string) {
351
+ const resolved = (await addressAliasResolver(root))(address);
334
352
  addressParts(resolved);
335
353
  return resolved;
336
354
  }
337
355
 
356
+ /** A Herdr pane id never carries its session; a binding typed as `<session>/<pane>` names that pane. */
357
+ export function herdrPaneId(session: string, pane: string) {
358
+ return pane.startsWith(`${session}/`) ? pane.slice(session.length + 1) : pane;
359
+ }
360
+
338
361
  export async function seat(root: string, address: string) {
339
- return readYaml<Seat>(paths(root).seatFile(await canonicalAddress(root, address)));
362
+ const record = await readYaml<Seat>(paths(root).seatFile(await canonicalAddress(root, address)));
363
+ const locator = record.runtime?.addresses.herdr;
364
+ if (locator && typeof locator === "object") locator.pane = herdrPaneId(locator.session, locator.pane);
365
+ return record;
340
366
  }
341
367
 
342
368
  export function fill(template: string, entries: Record<string, string>) {
package/src/handover.ts CHANGED
@@ -229,11 +229,35 @@ async function failedAdoptionTaskEligible(root: string, record: Seat, address: s
229
229
  && await isExactMainSeat(root, record, address);
230
230
  }
231
231
 
232
- export async function replacementForMainLaunch(root: string, record: Seat, address: string) {
232
+ export type MainLaunchReplacement = { request?: HandoverReplacementRequest; taskId: string; task: Task; bootstrap?: { handover: string } };
233
+
234
+ /**
235
+ * A brand-new exact main seat has no predecessor to name. It may launch once when it records no Pi identity, has no
236
+ * Path-A request file, has a recorded handover and exactly one ready self-assigned todo task; nothing is fabricated.
237
+ */
238
+ /** A staged shell-pane binding is a Herdr locator only; any Pi session, path or receipt proves a Pi already ran. */
239
+ function hasPiIdentity(runtime: Runtime | undefined) {
240
+ return Boolean(runtime?.pi_session || runtime?.pi_session_path || runtime?.launch_receipt);
241
+ }
242
+
243
+ async function bootstrapForMainLaunch(root: string, record: Seat, address: string): Promise<MainLaunchReplacement | undefined> {
244
+ if (hasPiIdentity(record.runtime) || await exists(handoverReplacementPath(root, address))) return undefined;
245
+ const handover = await recorded(root, address);
246
+ if (!handover) return undefined;
247
+ const ready = await readyMainTasks(root, record, address);
248
+ if (ready.length !== 1) return undefined;
249
+ const taskId = ready[0]!;
250
+ return { taskId, task: await readYaml<Task>(paths(root).taskFile(record.project, taskId)), bootstrap: { handover: handover.handover_id } };
251
+ }
252
+
253
+ export async function replacementForMainLaunch(root: string, record: Seat, address: string): Promise<MainLaunchReplacement | undefined> {
233
254
  if (!await isExactMainSeat(root, record, address)) return undefined;
234
- const request = await readHandoverReplacement(root, address).catch(() => {
255
+ const request = await readHandoverReplacement(root, address).catch(async () => {
256
+ const bootstrap = await bootstrapForMainLaunch(root, record, address);
257
+ if (bootstrap) return bootstrap;
235
258
  throw new Error(`${address} requires one validated Path-A replacement request; refusing runtime launch.`);
236
259
  });
260
+ if ("taskId" in request) return request;
237
261
  const task = await readYaml<Task>(paths(root).taskFile(record.project, request.task));
238
262
  const failedAdoption = await exactFailedAdoptionProof(root, record, address, request,
239
263
  (task) => failedAdoptionTaskEligible(root, record, address, task));
@@ -245,7 +269,7 @@ export async function replacementForMainLaunch(root: string, record: Seat, addre
245
269
  if (!canonicalLaunch && !failedAdoptionRecovery) {
246
270
  throw new Error("Path-A replacement source tuple no longer matches the recorded runtime or ready main takeover task.");
247
271
  }
248
- return { request, task };
272
+ return { request, taskId: request.task, task };
249
273
  }
250
274
 
251
275
  export function assertReplacementTarget(request: HandoverReplacementRequest, herdrSession: string, pane: string, worktree: string) {
@@ -254,15 +278,12 @@ export function assertReplacementTarget(request: HandoverReplacementRequest, her
254
278
  }
255
279
  }
256
280
 
257
- async function exactMainTakeoverTask(root: string, address: string, id: string) {
258
- const record = await seat(root, address);
259
- if (!await isExactMainSeat(root, record, address)) {
260
- throw new Error(`Path-A replacement requires exact main@${record.project} primary-main topology.`);
261
- }
281
+ /** Ids of the todo tasks that main@project owns and could start now: unblocked, self-assigned, dependencies terminal. */
282
+ async function readyMainTasks(root: string, record: Seat, address: string) {
262
283
  let files: string[];
263
284
  try { files = await readdir(paths(root).tasks(record.project)); }
264
- catch { throw new Error(`Path-A replacement requires one ready self-assigned main takeover task for ${address}.`); }
265
- const ready = (await Promise.all(files.filter((file) => file.endsWith(".yaml") && !file.endsWith(".reviews.yaml")).map(async (file) => {
285
+ catch { return []; }
286
+ return (await Promise.all(files.filter((file) => file.endsWith(".yaml") && !file.endsWith(".reviews.yaml")).map(async (file) => {
266
287
  const taskId = file.slice(0, -5);
267
288
  const task = await readYaml<Task>(paths(root).taskFile(record.project, taskId));
268
289
  if (task.schema !== "atdd-workflow/task/v1" || task.status !== "todo" || task.blocker || task.coordinator !== address || task.assignee !== address) return undefined;
@@ -272,6 +293,15 @@ async function exactMainTakeoverTask(root: string, address: string, id: string)
272
293
  }));
273
294
  return dependencies.every(Boolean) ? taskId : undefined;
274
295
  }))).filter((entry): entry is string => Boolean(entry));
296
+ }
297
+
298
+ async function exactMainTakeoverTask(root: string, address: string, id: string) {
299
+ const record = await seat(root, address);
300
+ if (!await isExactMainSeat(root, record, address)) {
301
+ throw new Error(`Path-A replacement requires exact main@${record.project} primary-main topology.`);
302
+ }
303
+ const ready = await readyMainTasks(root, record, address);
304
+ if (!ready.length && !await exists(paths(root).tasks(record.project))) throw new Error(`Path-A replacement requires one ready self-assigned main takeover task for ${address}.`);
275
305
  if (ready.length !== 1 || ready[0] !== id) throw new Error(`Path-A replacement requires exactly one ready self-assigned main takeover task; found: ${ready.join(", ") || "none"}.`);
276
306
  return { record };
277
307
  }
@@ -765,8 +795,30 @@ export async function resumedHandover(root: string, address: string) {
765
795
  return await isExactOperatorSeat(root, record, resolved) ? operatorResumed(root, record) : resumedHandoverReplacement(root, resolved);
766
796
  }
767
797
 
798
+ /** A bootstrap successor has no Path-A request: reconcile it against its own launch receipt, Herdr binding and heartbeat. */
799
+ async function resumedBootstrapLaunch(root: string, address: string) {
800
+ const record = await seat(root, address);
801
+ const runtime = record.runtime;
802
+ const locator = runtime?.addresses.herdr;
803
+ const receipt = runtime?.launch_receipt && await exists(runtime.launch_receipt) ? await readYaml<Record<string, any>>(runtime.launch_receipt) : undefined;
804
+ const bootstrap = receipt?.bootstrap_launch as { handover?: { id?: string }; task?: string } | undefined;
805
+ const handover = await recorded(root, address);
806
+ const runtimeState = await readRuntimeState(root, address);
807
+ const verified = [
808
+ Boolean(handover && bootstrap?.handover?.id === handover.handover_id && bootstrap.task && receipt?.seat === address && !receipt.handover_replacement),
809
+ runtime?.application === "herdr" && runtime.wake === "native",
810
+ Boolean(runtime?.pi_session && runtime.pi_session_path && receipt?.pi_session === runtime.pi_session && receipt.pi_session_path === runtime.pi_session_path),
811
+ typeof locator !== "string" && Boolean(locator) && locator?.session === receipt?.herdr_session && locator?.pane === receipt?.pane,
812
+ Boolean(runtimeState && runtimeState.seat === address),
813
+ ];
814
+ const discrepancies = ["bootstrap receipt", "native delivery", "fresh Pi session/JSONL path", "target tuple", "fresh heartbeat"].filter((_, index) => !verified[index]);
815
+ console.log(`RESUMED; verified ${verified.filter(Boolean).length}/${verified.length}; discrepancies: ${discrepancies.join(", ") || "none"}.`);
816
+ if (discrepancies.length) throw new Error("Bootstrap successor is not reconciled against the live Desk.");
817
+ }
818
+
768
819
  export async function resumedHandoverReplacement(root: string, address: string) {
769
820
  const resolved = await canonicalAddress(root, address);
821
+ if (!await exists(handoverReplacementPath(root, resolved)) && await isExactMainSeat(root, await seat(root, resolved), resolved)) return resumedBootstrapLaunch(root, resolved);
770
822
  const [request, record] = await Promise.all([readHandoverReplacement(root, resolved), seat(root, resolved)]);
771
823
  const runtime = record.runtime;
772
824
  const locator = runtime?.addresses.herdr;
@@ -240,11 +240,17 @@ function matchWorkspace(workspaces: Workspace[], target: Target) {
240
240
  return workspaces.find((entry) => checkoutOf(entry) === undefined && entry.label === target.project);
241
241
  }
242
242
 
243
- async function ensureWorkspace(session: string, target: Target, workspaces: Workspace[], command: HerdrCommand, primary?: WorkspaceState) {
243
+ /**
244
+ * Workspaces and tabs Flow creates inherit the Desk root. Herdr 0.9.3 `worktree open` has no `--env`, so a linked
245
+ * worktree's reused root tab gets the root from `pi runtime launch`, which exports it into the pane before Pi starts.
246
+ */
247
+ const deskEnv = (root: string) => ["--env", `ATDD_WORKFLOW_ROOT=${root}`];
248
+
249
+ async function ensureWorkspace(root: string, session: string, target: Target, workspaces: Workspace[], command: HerdrCommand, primary?: WorkspaceState) {
244
250
  let current = matchWorkspace(workspaces, target);
245
251
  if (!current) {
246
252
  const args = target.workspaceLabel === target.project
247
- ? ["workspace", "create", "--cwd", target.worktree, "--label", target.workspaceLabel, "--no-focus"]
253
+ ? ["workspace", "create", "--cwd", target.worktree, "--label", target.workspaceLabel, "--no-focus", ...deskEnv(root)]
248
254
  : ["worktree", "open", "--workspace", primary?.id ?? "", "--path", target.worktree, "--label", target.workspaceLabel, "--no-focus"];
249
255
  if (args.includes("")) throw new Error(`Project primary workspace is required to open linked worktree ${target.worktree}.`);
250
256
  const created = json(await command(["herdr", "--session", session, ...args]));
@@ -270,7 +276,7 @@ async function panes(session: string, workspace: string, command: HerdrCommand)
270
276
  return result.panes as Pane[];
271
277
  }
272
278
 
273
- async function ensureSeatTab(session: string, workspace: WorkspaceState, target: Target & { seat: Seat }, command: HerdrCommand): Promise<HerdrSeatProjection> {
279
+ async function ensureSeatTab(root: string, session: string, workspace: WorkspaceState, target: Target & { seat: Seat }, command: HerdrCommand): Promise<HerdrSeatProjection> {
274
280
  let entries = await tabs(session, workspace.id, command);
275
281
  let tab = entries.find((entry) => entry.label === target.seat.address);
276
282
  let pane: string | undefined;
@@ -280,7 +286,7 @@ async function ensureSeatTab(session: string, workspace: WorkspaceState, target:
280
286
  tab.label = target.seat.address;
281
287
  }
282
288
  if (!tab) {
283
- const created = json(await command(["herdr", "--session", session, "tab", "create", "--workspace", workspace.id, "--cwd", target.worktree, "--label", target.seat.address, "--no-focus"]));
289
+ const created = json(await command(["herdr", "--session", session, "tab", "create", "--workspace", workspace.id, "--cwd", target.worktree, "--label", target.seat.address, "--no-focus", ...deskEnv(root)]));
284
290
  const id = tabId(created);
285
291
  if (!id) throw new Error("Herdr did not return a tab id.");
286
292
  tab = { tab_id: id, workspace_id: workspace.id, label: target.seat.address };
@@ -295,9 +301,9 @@ async function ensureSeatTab(session: string, workspace: WorkspaceState, target:
295
301
  return { session, workspace: workspace.id, tab: tab.tab_id, pane: first, worktree: target.worktree, label: target.seat.address };
296
302
  }
297
303
 
298
- async function ensureTarget(session: string, target: Target, workspaces: Workspace[], command: HerdrCommand, primary: WorkspaceState) {
299
- const workspace = target.workspaceLabel === target.project ? primary : await ensureWorkspace(session, target, workspaces, command, primary);
300
- return target.seat ? ensureSeatTab(session, workspace, target as Target & { seat: Seat }, command) : undefined;
304
+ async function ensureTarget(root: string, session: string, target: Target, workspaces: Workspace[], command: HerdrCommand, primary: WorkspaceState) {
305
+ const workspace = target.workspaceLabel === target.project ? primary : await ensureWorkspace(root, session, target, workspaces, command, primary);
306
+ return target.seat ? ensureSeatTab(root, session, workspace, target as Target & { seat: Seat }, command) : undefined;
301
307
  }
302
308
 
303
309
  /** Reconciles exactly one Desk seat into a labeled Herdr workspace/tab/pane. */
@@ -309,8 +315,8 @@ export async function projectHerdrSeat(root: string, address: string, session: s
309
315
  const primaryTarget = desired.find((entry) => entry.project === target.project && entry.workspaceLabel === target.project && !entry.seat);
310
316
  if (!primaryTarget) throw new Error(`Project ${target.project} has no declared primary workspace.`);
311
317
  const workspaces = await liveWorkspaces(session, command);
312
- const primary = await ensureWorkspace(session, primaryTarget, workspaces, command);
313
- return (await ensureTarget(session, target, workspaces, command, primary))!;
318
+ const primary = await ensureWorkspace(root, session, primaryTarget, workspaces, command);
319
+ return (await ensureTarget(root, session, target, workspaces, command, primary))!;
314
320
  }
315
321
 
316
322
  type StaleWorkspace = { workspace: Workspace; address: string; reason: "retired" | "no active task" };
@@ -453,10 +459,10 @@ export async function multiplexer(root: string, args: string[]) {
453
459
  if (!projectPrimary) {
454
460
  const primaryTarget = desired.find((entry) => entry.project === target.project && entry.workspaceLabel === target.project && !entry.seat);
455
461
  if (!primaryTarget) throw new Error(`Project ${target.project} has no declared primary workspace.`);
456
- projectPrimary = await ensureWorkspace(session, primaryTarget, workspaces, command);
462
+ projectPrimary = await ensureWorkspace(root, session, primaryTarget, workspaces, command);
457
463
  primary.set(target.project, projectPrimary);
458
464
  }
459
- await ensureTarget(session, target, workspaces, command, projectPrimary);
465
+ await ensureTarget(root, session, target, workspaces, command, projectPrimary);
460
466
  }
461
467
  for (const entry of unprojected) console.log(`unprojected ${entry.address}: ${entry.reason}`);
462
468
  // Decided at close time with the same read-only rule `status` reports.
package/src/overview.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { readdir } from "node:fs/promises";
2
- import { canonicalAddress, desk, exists, paths, readYaml, type Checkpoint, type Seat } from "./core";
2
+ import { addressAliasResolver, canonicalAddress, desk, exists, paths, readYaml, type Checkpoint, type Seat } from "./core";
3
3
  import { type Task, type TaskHandoff, type TaskStatus, terminal } from "./tasks";
4
4
  import { isRuntimeStateStale, readRuntimeState } from "./runtime-state";
5
5
  import { describeActivation, nativeMailInactive } from "./mail-activation-notice";
6
- import { type Message, type Thread } from "./threads";
6
+ import { type Message, type Thread, canonicalMessage, canonicalThread } from "./threads";
7
7
 
8
8
  type ListedTask = { project: string; id: string; task: Task; waiting: string[] };
9
9
  type ListedSeat = { project: string; record: Seat; checkpoint?: Checkpoint };
@@ -62,13 +62,14 @@ async function tasks(root: string, project: string): Promise<ListedTask[]> {
62
62
 
63
63
  async function threads(root: string): Promise<ListedThread[]> {
64
64
  const ids = await names(paths(root).threads);
65
+ const resolve = await addressAliasResolver(root);
65
66
  return (await Promise.all(ids.map(async (id) => {
66
67
  const threadFile = paths(root).threadFile(id);
67
68
  if (!await exists(threadFile)) return undefined;
68
- const record = await readYaml<Thread>(threadFile);
69
+ const record = canonicalThread(await readYaml<Thread>(threadFile), resolve);
69
70
  const messages = await Promise.all((await names(paths(root).thread(id)))
70
71
  .filter((file) => file.startsWith("M-") && file.endsWith(".yaml"))
71
- .map((file) => readYaml<Message>(`${paths(root).thread(id)}/${file}`)));
72
+ .map(async (file) => canonicalMessage(await readYaml<Message>(`${paths(root).thread(id)}/${file}`), resolve)));
72
73
  const pending = messages.flatMap((message) => {
73
74
  if (!message.expects_result) return [];
74
75
  const recipients = message.to === "all" ? record.participants.filter((address) => address !== message.from) : message.to;
@@ -287,7 +287,7 @@ async function prepareLaunch(root: string, address: string, args: string[], depe
287
287
  const resolved = await canonicalAddress(root, address), record = await seat(root, resolved), config = await desk(root);
288
288
  const options = parseLaunchOptions(args), existingPane = existingPaneForResume(record, options);
289
289
  const replacement = await replacementForMainLaunch(root, record, resolved);
290
- const active = replacement ? [{ id: replacement.request.task, task: replacement.task }] : await launchEligibleTasks(root, record, resolved);
290
+ const active = replacement ? [{ id: replacement.taskId, task: replacement.task }] : await launchEligibleTasks(root, record, resolved);
291
291
  const operator = await isExactOperatorSeat(root, record, resolved);
292
292
  if (!active.length && !operator) throw new Error(`${resolved} has no launch-eligible task; refusing runtime launch.`);
293
293
  const selection = await chooseLaunchModel(root, config, record, required(await modelPortfolio(root), "models.yaml for Pi runtime launch"), dependencies.select);
@@ -306,7 +306,7 @@ function assertProjectedPane(launch: PreparedLaunch, pane: string) {
306
306
  const { options, existingPane, replacement, record } = launch;
307
307
  if (options.resume && pane !== existingPane) throw new Error("Resume cannot relocate a Pi session across a Herdr session or pane; use explicit exact-session adoption.");
308
308
  if (options.requestedPane && options.requestedPane !== pane) throw new Error("The asserted --pane does not match the seat-scoped Herdr projection.");
309
- if (replacement) assertReplacementTarget(replacement.request, options.herdrSession, pane, record.worktree);
309
+ if (replacement?.request) assertReplacementTarget(replacement.request, options.herdrSession, pane, record.worktree);
310
310
  }
311
311
 
312
312
  const sameTerminalRefusal = "Run this from a different pane; the target pane must be an idle shell.";
@@ -446,7 +446,8 @@ async function persistLaunch(launch: PreparedLaunch, plan: LaunchPlan["plan"], s
446
446
  const receiptRecord = {
447
447
  schema: "atdd-flow/pi-runtime-launch-receipt/v1", seat: resolved, tasks: active.map((entry) => entry.id), candidate: selection.candidate.id,
448
448
  selection: receiptSelection(launch), pi_session: piSession, pi_session_path: sessionPath, herdr_session: options.herdrSession, pane: plan.pane,
449
- ...(replacement ? { handover_replacement: { request: replacement.request, successor_session: piSession } } : {}),
449
+ ...(replacement?.request ? { handover_replacement: { request: replacement.request, successor_session: piSession } } : {}),
450
+ ...(replacement?.bootstrap ? { bootstrap_launch: { handover: { id: replacement.bootstrap.handover }, task: replacement.taskId } } : {}),
450
451
  ...(relocation ? { relocation: { source_herdr_session: options.sourceHerdrSession, source_pane: options.sourcePane, authorization: authorizationRecord, backup: relocation.backup, backup_sha256: relocation.sha256 } } : {}),
451
452
  created_at: dependencies.at?.() ?? now(),
452
453
  };
package/src/pi-wake.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
 
17
17
  export const wakeUsage = "Use `atdd-flow pi wake <seat>|--scope project|desk [--model-profile <name>] --by <authorized-seat>`.";
18
18
 
19
- type Outcome = "woke" | "swapped+woke" | "deferred" | "refused" | "skipped" | "failed";
19
+ export type Outcome = "woke" | "swapped+woke" | "deferred" | "refused" | "skipped" | "failed";
20
20
  export type WakeRow = { seat: string; outcome: Outcome; detail?: string };
21
21
  type Options = { target?: string; scope?: "project" | "desk"; profile?: string; by: string };
22
22
  type Target = { provider: string; model: string };
@@ -24,7 +24,7 @@ type Target = { provider: string; model: string };
24
24
  type Pane = { agent?: string; status?: string; sessionPath?: string; pids: number[] };
25
25
  type Ready = { session: string; pane: string; sessionId: string; sessionPath: string };
26
26
  type Inspection = { row: WakeRow } | { ready: Ready };
27
- type Prepared = { row: WakeRow } | { record: Seat; ready: Ready; inspectedAt: number };
27
+ export type Prepared = { row: WakeRow } | { record: Seat; ready: Ready; inspectedAt: number };
28
28
 
29
29
  const pollMs = 50;
30
30
  /** A runtime heartbeat older than this is not a live Pi (the extension beats every 15 s). */
@@ -40,11 +40,11 @@ const timeouts = () => {
40
40
  return { step: explicit ?? 30_000, idle: explicit ?? 300_000 };
41
41
  };
42
42
  const pause = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
43
- const row = (seat: string, outcome: Outcome, detail?: string): WakeRow => ({ seat, outcome, ...(detail ? { detail } : {}) });
44
- const hold = (seat: string, outcome: Outcome, detail: string): { row: WakeRow } => ({ row: row(seat, outcome, detail) });
43
+ export const row = (seat: string, outcome: Outcome, detail?: string): WakeRow => ({ seat, outcome, ...(detail ? { detail } : {}) });
44
+ export const hold = (seat: string, outcome: Outcome, detail: string): { row: WakeRow } => ({ row: row(seat, outcome, detail) });
45
45
  const object = (value: unknown): Record<string, unknown> | undefined => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
46
46
  const text = (value: unknown) => typeof value === "string" ? value : undefined;
47
- const message = (error: unknown) => error instanceof Error ? error.message : String(error);
47
+ export const message = (error: unknown) => error instanceof Error ? error.message : String(error);
48
48
 
49
49
  /** Polls until `read` yields a value or the bound elapses; one read always happens. */
50
50
  async function until<T>(read: () => Promise<T | undefined>, timeoutMs: number) {
@@ -77,7 +77,7 @@ export function parseWakeArguments(args: string[]): Options {
77
77
  return { ...(targets[0] ? { target: targets[0] } : {}), ...(scope ? { scope } : {}), ...(profile ? { profile } : {}), by };
78
78
  }
79
79
 
80
- async function herdr(session: string, ...args: string[]) {
80
+ export async function herdr(session: string, ...args: string[]) {
81
81
  const child = Bun.spawn(["herdr", "--session", session, ...args], { stdout: "pipe", stderr: "pipe" });
82
82
  const [stdout, stderr, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
83
83
  if (code !== 0) throw new Error(`herdr ${args.slice(0, 2).join(" ")} failed: ${stderr.trim() || stdout.trim()}`);
@@ -100,7 +100,7 @@ async function observe(session: string, pane: string): Promise<Pane> {
100
100
  }
101
101
 
102
102
  /** The transcript's active branch: the chain from the newest entry, excluding abandoned siblings. */
103
- const readBranch = async (path: string) => activeBranch(parseTranscript(await readFile(path, "utf8")));
103
+ export const readBranch = async (path: string) => activeBranch(parseTranscript(await readFile(path, "utf8")));
104
104
 
105
105
  /** Resolve a profile mapping for the seat's current model. `{}` means wake without a swap. */
106
106
  function resolveSwap(portfolio: ModelPortfolio, profile: string, branch: TranscriptEntry[]): { candidate?: string; target?: Target } | { refused: string } {
@@ -161,7 +161,7 @@ const capabilityOf = (branch: TranscriptEntry[], ready: Ready, address: string)
161
161
  customEntries(branch, wakeCapabilityType).filter((entry) => entry.data?.seat === address && entry.data.pi_session === ready.sessionId && entry.data.pane === ready.pane);
162
162
 
163
163
  /** Classify one seat without sending anything; every non-eligible seat resolves to its final row. */
164
- async function prepare(root: string, address: string, actor: string): Promise<Prepared> {
164
+ export async function prepare(root: string, address: string, actor: string): Promise<Prepared> {
165
165
  if (address === actor) return hold(address, "skipped", "invoking seat");
166
166
  const record = await readYaml<Seat>(paths(root).seatFile(address)).catch(() => undefined);
167
167
  if (!record) return hold(address, "skipped", "no seat record");
@@ -248,7 +248,7 @@ async function seatAddresses(root: string, project: string) {
248
248
  }
249
249
 
250
250
  /** Authorization is by address: the operator may wake anything; a project main only its own project. */
251
- async function targetAddresses(root: string, options: Options, actor: string) {
251
+ export async function targetAddresses(root: string, options: Options, actor: string) {
252
252
  const operator = actor === "operator@desk";
253
253
  if (options.target) {
254
254
  const target = await canonicalAddress(root, options.target), project = addressParts(target).project;
package/src/seats.ts CHANGED
@@ -5,7 +5,7 @@ import { basename, dirname, join, resolve } from "node:path";
5
5
  import { discoverAddress, discoverHerdrLocator, notify } from "./adapters";
6
6
  import {
7
7
  type Checkpoint, type Desk, type ModelCandidate, type ModelPortfolio, type Project, type Role, type Runtime, type Seat,
8
- atomicYaml, canonicalAddress, desk, exists, fill, has, migrateDesk, modelPortfolio, now, paths, project, readYaml,
8
+ addressAliasResolver, atomicYaml, canonicalAddress, desk, exists, fill, has, herdrPaneId, migrateDesk, modelPortfolio, now, paths, project, readYaml,
9
9
  required, run, runOutput, runtimeAddress, seat, taskId, words, yaml,
10
10
  } from "./core";
11
11
  import { type ModelSelectionInput, type ModelSelectionResponse, selectModel } from "./judgment";
@@ -65,12 +65,16 @@ export async function migrate(root: string) {
65
65
  await projectCollaborationPolicy(root);
66
66
  }
67
67
 
68
- export async function initProject(root: string, name: string) {
68
+ const initUsage = "Use `atdd-flow project init <project> [--vcs <git|jj>]`; new projects default to Jujutsu (jj).";
69
+
70
+ /** New projects default to Jujutsu; Git is an explicit per-project opt-out. Legacy records without `vcs` keep reading as Git. */
71
+ export async function initProject(root: string, name: string, args: string[] = []) {
72
+ if (args.length && (args.length !== 2 || args[0] !== "--vcs" || (args[1] !== "git" && args[1] !== "jj"))) throw new Error(initUsage);
69
73
  await desk(root);
70
- const config: Project = { schema: "atdd-workflow/project/v1", project: name, roles: defaultRoles(Boolean(await modelPortfolio(root))) };
74
+ const config: Project = { schema: "atdd-workflow/project/v1", project: name, roles: defaultRoles(Boolean(await modelPortfolio(root))), vcs: (args[1] ?? "jj") as "git" | "jj" };
71
75
  await mkdir(paths(root).seats(name), { recursive: true });
72
76
  await atomicYaml(paths(root).projectFile(name), config);
73
- console.log(`Initialized project ${name}`);
77
+ console.log(`Initialized project ${name} (vcs: ${config.vcs})`);
74
78
  }
75
79
 
76
80
  const configureUsage = "Use `atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]`.";
@@ -197,7 +201,7 @@ export async function configureProject(root: string, name: string, args: string[
197
201
  const repositoryPath = configuredRepository ? resolve(configuredRepository) : undefined;
198
202
  if (repositoryPath && (repository || selectedVcs)) {
199
203
  try { await assertRepository(mode, repositoryPath); }
200
- catch (error) { throw new Error((error as Error).message); }
204
+ catch (error) { throw new Error(mode === "jj" && !selectedVcs ? `${(error as Error).message} Project ${name} uses Jujutsu by default; colocate the checkout with \`jj git init --colocate\` or opt out with \`--vcs git\`.` : (error as Error).message); }
201
205
  config.repository = repositoryPath;
202
206
  }
203
207
  if (selectedVcs) config.vcs = mode;
@@ -397,7 +401,7 @@ export async function bind(root: string, address: string, args: string[], select
397
401
  const nativeAddress = required(words(args, "--address"), "--address");
398
402
  const requestedSession = words(args, "--session");
399
403
  const addresses = record.runtime?.addresses ?? {};
400
- const runtimeBinding = application === "herdr" && requestedSession ? { session: requestedSession, pane: nativeAddress } : nativeAddress;
404
+ const runtimeBinding = application === "herdr" && requestedSession ? { session: requestedSession, pane: herdrPaneId(requestedSession, nativeAddress) } : nativeAddress;
401
405
  record.runtime = {
402
406
  ...record.runtime,
403
407
  application,
@@ -493,11 +497,12 @@ export async function openSeat(root: string, address: string) {
493
497
  const checkpointFile = paths(root).checkpointFile(resolved);
494
498
  if (await exists(checkpointFile)) console.log(yaml.print(await readYaml<Checkpoint>(checkpointFile)));
495
499
  const threadIds = await readdir(paths(root).threads);
500
+ const resolve = await addressAliasResolver(root);
496
501
  for (const threadId of threadIds) {
497
502
  const file = paths(root).threadFile(threadId);
498
503
  if (!await exists(file)) continue;
499
504
  const entry = await readYaml<{ participants: string[]; state: string; subject: string }>(file);
500
- if (entry.participants.includes(resolved)) console.log(`${threadId} ${entry.state} ${entry.subject}`);
505
+ if (entry.participants.map(resolve).includes(resolved)) console.log(`${threadId} ${entry.state} ${entry.subject}`);
501
506
  }
502
507
  console.log(`Convention: ${lifecycleConventionPath}`);
503
508
  }
@@ -7,7 +7,7 @@
7
7
  import { createHash } from "node:crypto";
8
8
  import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
9
9
  import { join } from "node:path";
10
- import { atomicYaml, paths, readYaml, seat, yaml } from "./core";
10
+ import { addressAliasResolver, atomicYaml, paths, readYaml, seat, yaml } from "./core";
11
11
 
12
12
  /** The one exact tuple an operator@desk message may carry to authorize `task supersede` by main. */
13
13
  export type SupersedeAuthorization = {
@@ -26,6 +26,7 @@ const sha256 = (content: string) => createHash("sha256").update(content).digest(
26
26
  export async function operatorAuthorizationPayload(root: string, reference: string, main: string): Promise<unknown> {
27
27
  type Found = { schema?: string; id?: string; from?: string; to?: "all" | string[]; authorization?: unknown };
28
28
  const folders = await readdir(paths(root).threads, { withFileTypes: true });
29
+ const resolve = await addressAliasResolver(root);
29
30
  const matches = (await Promise.all(folders.filter((entry) => entry.isDirectory() && entry.name.startsWith("T-"))
30
31
  .map(async (entry) => {
31
32
  try {
@@ -36,7 +37,7 @@ export async function operatorAuthorizationPayload(root: string, reference: stri
36
37
  return { thread, message };
37
38
  } catch { return undefined; }
38
39
  }))).filter((entry): entry is NonNullable<typeof entry> =>
39
- entry?.thread.schema === "atdd-workflow/thread/v1" && entry.thread.participants?.includes(main) === true
40
+ entry?.thread.schema === "atdd-workflow/thread/v1" && entry.thread.participants?.map(resolve).includes(main) === true
40
41
  && entry.message.schema === "atdd-workflow/message/v1" && entry.message.id === reference);
41
42
  if (matches.length !== 1 || matches[0]?.message.from !== "operator@desk") {
42
43
  throw new Error(`Authorization ${reference} is not an owner-authorized topology plan.`);
package/src/threads.ts CHANGED
@@ -4,7 +4,7 @@ import { notify } from "./adapters";
4
4
  import { seatTasks, terminal } from "./tasks";
5
5
  import { type SupersedeAuthorization, parseSupersedeAuthorization } from "./task-supersede-authorization";
6
6
  import {
7
- atomicYaml, canonicalAddress, desk, has, id, now, paths, project, readYaml,
7
+ addressAliasResolver, atomicYaml, canonicalAddress, desk, has, id, now, paths, project, readYaml,
8
8
  required, runtimeAddress, seat, words,
9
9
  } from "./core";
10
10
 
@@ -33,14 +33,27 @@ export type Message = {
33
33
  authorization?: TransferAuthorization | SupersedeAuthorization;
34
34
  };
35
35
 
36
- async function thread(root: string, threadId: string) {
37
- return readYaml<Thread>(paths(root).threadFile(threadId));
36
+ type Resolve = (address: string) => string;
37
+
38
+ /** A thread as its current seats see it: addresses written before a seat rename read as the new address. The file is untouched. */
39
+ export function canonicalThread(record: Thread, resolve: Resolve): Thread {
40
+ return { ...record, participants: [...new Set(record.participants.map(resolve))] };
41
+ }
42
+
43
+ export function canonicalMessage(message: Message, resolve: Resolve): Message {
44
+ return { ...message, from: resolve(message.from), to: message.to === "all" ? "all" : [...new Set(message.to.map(resolve))] };
45
+ }
46
+
47
+ async function thread(root: string, threadId: string, resolve?: Resolve) {
48
+ const record = await readYaml<Thread>(paths(root).threadFile(threadId));
49
+ return canonicalThread(record, resolve ?? await addressAliasResolver(root));
38
50
  }
39
51
 
40
- export async function messages(root: string, threadId: string) {
52
+ export async function messages(root: string, threadId: string, resolve?: Resolve) {
41
53
  const folder = paths(root).thread(threadId);
42
54
  const files = (await readdir(folder)).filter((file) => file.startsWith("M-") && file.endsWith(".yaml")).sort();
43
- return Promise.all(files.map((file) => readYaml<Message>(`${folder}/${file}`)));
55
+ const resolver = resolve ?? await addressAliasResolver(root);
56
+ return Promise.all(files.map(async (file) => canonicalMessage(await readYaml<Message>(`${folder}/${file}`), resolver)));
44
57
  }
45
58
 
46
59
  async function replyTarget(root: string, threadId: string, messageId: string, from: string, needsResult: boolean) {
@@ -384,9 +397,10 @@ export function pendingResponders(participants: string[], all: Message[], messag
384
397
 
385
398
  export async function status(root: string) {
386
399
  const threadIds = (await readdir(paths(root).threads)).sort();
400
+ const resolve = await addressAliasResolver(root);
387
401
  for (const threadId of threadIds) {
388
- const record = await thread(root, threadId);
389
- const all = await messages(root, threadId);
402
+ const record = await thread(root, threadId, resolve);
403
+ const all = await messages(root, threadId, resolve);
390
404
  const pending = all.flatMap((message) => message.expects_result
391
405
  ? pendingResponders(record.participants, all, message).map((address) => `${message.id}@${address}`) : []);
392
406
  console.log(`${threadId} ${record.state} ${record.subject}${pending.length ? ` waiting:${pending.join(",")}` : ""}`);
@@ -396,7 +410,8 @@ export async function status(root: string) {
396
410
  export async function threadsForSeat(root: string, address: string) {
397
411
  const resolved = await canonicalAddress(root, address);
398
412
  const threadIds = await readdir(paths(root).threads);
399
- const records = await Promise.all(threadIds.map(async (threadId) => ({ threadId, record: await thread(root, threadId) })));
413
+ const resolve = await addressAliasResolver(root);
414
+ const records = await Promise.all(threadIds.map(async (threadId) => ({ threadId, record: await thread(root, threadId, resolve) })));
400
415
  return records.filter(({ record }) => record.participants.includes(resolved));
401
416
  }
402
417