@afokapu/atdd-flow 0.3.70 → 0.3.72

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
@@ -130,7 +130,7 @@ atdd-flow task review example-app runtime-rollout --by driver.runtime@example-ap
130
130
  atdd-flow task done example-app runtime-rollout --by coordinator@example-app
131
131
  ```
132
132
 
133
- Proof is a compact PR, CI run, report, commit range, deployment, or thread reference. Dependencies gate prerequisites; independent tasks are parallel-ready. A coordinator can staff an unassigned ready task with `atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>`; assignment is allowed only once while the task is `todo`. Use `task block` only for a real external blocker, then checkpoint exact state and next action. Once the blocker is resolved, only that coordinator can clear it with `atdd-flow task unblock <project> <task-id> --by <coordinator-address>` while the task remains `in_progress`. For deliveries explicitly governed by the `workflow` ATDD Bun profile, `review → done` additionally requires a durable final behavioral-review result with decision `APPROVE` for the current clean delivery commit. For an idle driver’s final task, `task done ... --retire-assignee` delegates clean-and-merged worktree retirement to ATDD Bun. A driver whose assigned tasks are all already `done` is retired with `atdd-flow seat retire <address> --by <coordinator-or-main>`, using the same checks. When every assigned task is `done` and the recorded worktree is absent from disk and from `git worktree list`, `seat retire` records the retirement without running worktree finish, deleting the branch, or recreating the worktree; a missing worktree that Git still registers fails closed. Both paths fail closed on unfinished tasks or a dirty or unmerged worktree; on success they record `retired` and a checkpoint, then close the driver's Herdr workspace when `close_inactive` is enabled. `main@<project>` may likewise retire a coordinator seat (never main or the operator) once every task it coordinates or is assigned is `done`. A non-main coordinator sharing the primary checkout is retired as a Desk record without worktree finish; other coordinators use the same worktree-finish, absent-and-unregistered, and fail-closed paths. Retirement runs in the seat worktree with ATDD Bun from the primary checkout's `node_modules/.bin`, else the primary checkout's own source when it is the ATDD Bun package, else `PATH`; never the seat worktree's copy, which may pin an older toolkit.
133
+ Proof is a compact PR, CI run, report, commit range, deployment, or thread reference. Dependencies gate prerequisites; independent tasks are parallel-ready. A coordinator can staff an unassigned ready task with `atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>`; assignment is allowed only once while the task is `todo`. Add `--immediate --priority <priority> --mutation-boundary <text>` to persist and natively deliver a task-specific dispatch (task, priority, boundary, exact assignee-only `task start` command); it fails closed, writing nothing, when the assignee has no native queue. The assignee records a typed, dispatch-bound `atdd-flow task acknowledge <project> <task-id> --dispatch <message-id> --by <assignee>` (idempotent, distinct from `task start`; messages, receipts, and heartbeats never count), and the coordinator's on-demand `atdd-flow task observe-assignment` posts at most one `missing_acknowledgement` and one `missing_start` alert to the assignee. Use `task block` only for a real external blocker, then checkpoint exact state and next action. Once the blocker is resolved, only that coordinator can clear it with `atdd-flow task unblock <project> <task-id> --by <coordinator-address>` while the task remains `in_progress`. For deliveries explicitly governed by the `workflow` ATDD Bun profile, `review → done` additionally requires a durable final behavioral-review result with decision `APPROVE` for the current clean delivery commit. For an idle driver’s final task, `task done ... --retire-assignee` delegates clean-and-merged worktree retirement to ATDD Bun. A driver whose assigned tasks are all already `done` is retired with `atdd-flow seat retire <address> --by <coordinator-or-main>`, using the same checks. When every assigned task is `done` and the recorded worktree is absent from disk and from `git worktree list`, `seat retire` records the retirement without running worktree finish, deleting the branch, or recreating the worktree; a missing worktree that Git still registers fails closed. Both paths fail closed on unfinished tasks or a dirty or unmerged worktree; on success they record `retired` and a checkpoint, then close the driver's Herdr workspace when `close_inactive` is enabled. `main@<project>` may likewise retire a coordinator seat (never main or the operator) once every task it coordinates or is assigned is `done`. A non-main coordinator sharing the primary checkout is retired as a Desk record without worktree finish; other coordinators use the same worktree-finish, absent-and-unregistered, and fail-closed paths. Retirement runs in the seat worktree with ATDD Bun from the primary checkout's `node_modules/.bin`, else the primary checkout's own source when it is the ATDD Bun package, else `PATH`; never the seat worktree's copy, which may pin an older toolkit.
134
134
 
135
135
  At 80% context usage a seat writes a structured handover (template and process rules in the lifecycle convention), checks it with `atdd-flow handover <address> --check --file <path>`, and records it with `atdd-flow handover <address> --file <path>`. The seat keeps one durable `handover.yaml` beside its unchanged checkpoint. Recording rejects a missing header, Runtime line, or section, sections out of order, more than 150 lines, vague references such as `latest` or `that PR` without an exact ID, and a `supersedes` value that does not match the recorded handover id. A fresh session bound to the same seat reads it with `atdd-flow handover show <address>`.
136
136
 
@@ -192,7 +192,7 @@ atdd-flow multiplexer apply herdr --session forge
192
192
  atdd-flow multiplexer apply herdr
193
193
  ```
194
194
 
195
- 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.
195
+ For each Desk project, apply reconciles the declared repository checkout as workspace `{project}` and gives every main, operator, and non-generic coordinator seat using it a tab and pane named `{seat.address}`. `main@{project}` is the single primary coordinator projection: when it uses the primary checkout, the preserved legacy generic `coordinator@{project}` sharing that checkout is not bulk-projected as a second tab (an explicit per-seat projection still projects it, and a project without a primary main keeps it). 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.
196
196
 
197
197
  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:
198
198
 
@@ -250,6 +250,29 @@ atdd-flow --root /path/to/desk attach driver.runtime@example-app --application h
250
250
 
251
251
  Pi receives its identity and startup task from the extension itself. Its normal TUI remains visible and manually usable; incoming Desk mail wakes it through Pi's native message API. The runtime lifecycle has one internal bounded inbox transport; it does not add a second watcher, registry, mailbox, reservation system, or routing path.
252
252
 
253
+ ### Wake or migrate live Pi seats
254
+
255
+ `atdd-flow pi wake` replaces the manual `/model` → `/reload` → inbox/task prompt chain with one command per seat or fleet. It sends only the local extension command `/flow-wake` through Herdr (no model prompt) and verifies Pi's own session transcript.
256
+
257
+ ```sh
258
+ atdd-flow pi wake driver.runtime@example-app --by operator@desk # keep model, reconcile, wake once
259
+ atdd-flow pi wake driver.runtime@example-app --model-profile restore --by main@example-app # swap to the mapped model first
260
+ atdd-flow pi wake --scope project --by main@example-app # every live seat of the project
261
+ atdd-flow pi wake --scope desk --by operator@desk # every live seat of the Desk
262
+ ```
263
+
264
+ `--by` must be `operator@desk` or the project's `main@<project>`; `--scope desk` requires the operator. A profile is declared in `models.yaml` and must map a source `provider/model` to an existing, enabled portfolio candidate that names one exact Pi `--provider`/`--model`; anything else is rejected when `models.yaml` is read:
265
+
266
+ ```yaml
267
+ model_migrations:
268
+ restore:
269
+ anthropic/claude-sonnet-4-5: strong
270
+ ```
271
+
272
+ The extension re-checks the target against Pi's authenticated model registry, calls `setModel`, preserves the thinking level, and reloads. On reload the unmatched request is reconstructed from the active transcript branch; Flow reactivates mail, writes the result, and injects the recovery bundle plus the authoritative task directive as exactly one resumed-work turn. A seat whose Pi predates `/flow-wake` is first sent one `/reload` at a safe boundary, and only a fresh `flow-wake-capability/v1` transcript entry lets the wake proceed.
273
+
274
+ Seats are handled in lexical order, one at a time, and every seat gets exactly one row: `woke`, `swapped+woke`, `deferred` (agent working), `refused` (blocked/question UI, stale pane/session/process, unmapped model), `skipped` (retired, terminal task, no agent or binding, the invoking seat), or `failed` (unavailable target, partial model/reload evidence; never retried). A named seat exits non-zero unless it woke; a fleet exits non-zero only on `refused` or `failed`. No fallback model, relaunch, compaction, seat reassignment, or session replacement exists, and Desk, seat, runtime and repository records are never written; only Pi's native model-change and flow-wake entries are appended to the transcript. `ATDD_FLOW_WAKE_TIMEOUT_MS` bounds each wait (default 30 s; 5 min for a seat to return to idle after a bootstrap reload).
275
+
253
276
  The lifecycle and recovery design was informed by [nicobailon/pi-messenger](https://github.com/nicobailon/pi-messenger) at `09937ed647a1b07a3b595bf75943feacb80ff123` (MIT). No pi-messenger source is copied here. Flow keeps Desk identity, recipient selection, immutable mail, and governance authoritative; any future copied source must carry its MIT notice.
254
277
 
255
278
  With ATDD Bun, enable the Workflow profile:
@@ -4,9 +4,10 @@ import { basename, dirname, join } from "node:path";
4
4
  import { parse, stringify } from "yaml";
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsRuntimeState, recordMailActivation, registerRuntimeState, runtimeOwnerToken, withOwnedRuntimeState } from "../../src/runtime-state";
7
- import { paths, readYaml, type Seat } from "../../src/core";
7
+ import { modelPortfolio, paths, piModel, readYaml, type Seat } from "../../src/core";
8
+ import { type TranscriptEntry, customEntries, wakeCapabilityType, wakeRequestIdPattern, wakeRequestType, wakeResultType, wakeStartType } from "../../src/pi-wake-protocol";
8
9
  import { activationNotice, diagnoseMailActivation, expectsNativeMail } from "../../src/mail-activation-notice";
9
- import { type NoticeState, createNoticeState, refreshDelivered, sendSettledDirective, sendTaskDirective, shouldNotifyMail } from "../../src/next-action-notices";
10
+ import { type NoticeState, createNoticeState, deliveredDirectiveText, refreshDelivered, sendSettledDirective, sendTaskDirective, shouldNotifyMail } from "../../src/next-action-notices";
10
11
  import { handoverThresholdInstruction, runtimeStartupInstruction, successorInstruction } from "../../src/collaboration-policy";
11
12
  import { type RecoveryMode, recoveryFor } from "../../src/recovery-bundle";
12
13
  import { contextHandoverThreshold, readHandoverReplacement } from "../../src/handover";
@@ -362,6 +363,49 @@ const unsetOrEqual = (recorded: string | undefined, live: string | undefined) =>
362
363
  const sessionUnsetOrEqual = (runtime: Seat["runtime"], piSession?: string, piSessionPath?: string) =>
363
364
  unsetOrEqual(runtime?.pi_session, piSession) && unsetOrEqual(runtime?.pi_session_path, piSessionPath);
364
365
 
366
+
367
+ type WakeIdentity = { seat: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string };
368
+ type WakeContext = {
369
+ model?: { provider: string; id: string };
370
+ thinkingLevel?: string;
371
+ modelRegistry?: { find(provider: string, id: string): unknown; hasConfiguredAuth(model: never): boolean };
372
+ sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string | undefined; getBranch?: () => unknown[] };
373
+ reload?: () => Promise<void>;
374
+ };
375
+
376
+ /** The live Pi/Herdr identity: launcher variables first, then Herdr's host-injected pane identity. */
377
+ function wakeIdentity(seat: string, ctx: WakeContext): WakeIdentity {
378
+ const manager = ctx.sessionManager;
379
+ return {
380
+ seat,
381
+ pi_session: process.env.ATDD_FLOW_PI_SESSION ?? manager?.getSessionId?.(),
382
+ pi_session_path: manager?.getSessionFile?.(),
383
+ herdr_session: process.env.ATDD_FLOW_HERDR_SESSION ?? process.env.HERDR_SESSION,
384
+ pane: process.env.ATDD_FLOW_HERDR_PANE ?? process.env.HERDR_PANE_ID,
385
+ };
386
+ }
387
+
388
+ /** A wake may act only for the exact seat, Pi session and Herdr pane that the Desk binds together. */
389
+ async function boundToLiveIdentity(root: string, identity: WakeIdentity) {
390
+ if (!identity.pi_session || !identity.herdr_session || !identity.pane) return false;
391
+ try {
392
+ const runtime = (await readYaml<Seat>(paths(root).seatFile(identity.seat))).runtime;
393
+ const locator = runtime?.addresses.herdr;
394
+ return runtime?.application === "herdr" && typeof locator === "object" && locator.session === identity.herdr_session && locator.pane === identity.pane
395
+ && unsetOrEqual(runtime.pi_session, identity.pi_session);
396
+ } catch { return false; }
397
+ }
398
+
399
+ const branchOf = (ctx: WakeContext) => (ctx.sessionManager?.getBranch?.() ?? []) as TranscriptEntry[];
400
+
401
+ /** The single unmatched request on the active branch that was issued for this exact live identity. */
402
+ function unmatchedWakeRequest(branch: TranscriptEntry[], identity: WakeIdentity) {
403
+ const matched = new Set(customEntries(branch, wakeResultType).map((entry) => entry.data?.request_id));
404
+ const request = customEntries(branch, wakeRequestType).map((entry) => entry.data).filter((data) => data && !matched.has(data.request_id)).at(-1);
405
+ if (!request || typeof request.request_id !== "string") return undefined;
406
+ return request.seat === identity.seat && request.pi_session === identity.pi_session && request.pane === identity.pane ? request : undefined;
407
+ }
408
+
365
409
  /** The recovery bundle is advisory; any failure falls back to the plain prompt. */
366
410
  const recovery = (root: string, seat: string, mode: RecoveryMode, session?: string) => recoveryFor(root, seat, mode, session).catch(() => undefined);
367
411
 
@@ -417,7 +461,75 @@ export default function (pi: ExtensionAPI, environment: NodeJS.ProcessEnv = proc
417
461
  ctx.shutdown();
418
462
  } catch { /* No validated replacement request exists for this source session. */ }
419
463
  });
464
+ const safeThinking = (ctx?: WakeContext) => ctx?.thinkingLevel ?? (typeof pi.getThinkingLevel === "function" ? pi.getThinkingLevel() : undefined);
465
+ const wakeResult = (requestId: string, outcome: string, extra: Record<string, unknown> = {}) => pi.appendEntry(wakeResultType, { request_id: requestId, outcome, ...extra });
466
+ // Request, optional authenticated model swap, thinking restore, then reload; the post-reload session_start writes the result and the one resumed-work turn.
467
+ // The command is registered only on hosts that expose it; `/reload` keeps the old instance terminal, so nothing runs after it here.
468
+ (pi as { registerCommand?: ExtensionAPI["registerCommand"] }).registerCommand?.("flow-wake", {
469
+ description: "Flow: optionally swap to a mapped model, reload, reconcile, and wake this seat once",
470
+ handler: async (args: string, rawContext: unknown) => {
471
+ const ctx = rawContext as WakeContext & { hasUI?: boolean; ui?: { notify(message: string, level: string): void } };
472
+ const words = args.trim().split(/\s+/).filter(Boolean);
473
+ const requestId = words.at(-1);
474
+ if (words.length < 1 || words.length > 2 || !requestId || !wakeRequestIdPattern.test(requestId)) {
475
+ if (ctx.hasUI) ctx.ui?.notify("Usage: /flow-wake [portfolio-candidate] <request-id>", "error");
476
+ return;
477
+ }
478
+ const candidate = words.length === 2 ? words[0]! : undefined;
479
+ const identity = wakeIdentity(seat, ctx);
480
+ if (customEntries(branchOf(ctx), wakeRequestType).some((entry) => entry.data?.request_id === requestId)) {
481
+ wakeResult(requestId, "failed", { reason: "duplicate request id" });
482
+ return;
483
+ }
484
+ const source = { provider: ctx.model?.provider, model: ctx.model?.id, thinking: safeThinking(ctx) };
485
+ pi.appendEntry(wakeRequestType, { request_id: requestId, ...identity, ...(candidate ? { candidate } : {}), source });
486
+ try {
487
+ if (!await boundToLiveIdentity(root, identity)) return wakeResult(requestId, "failed", { reason: "Desk binding does not match the live seat, session and pane" });
488
+ if (candidate) {
489
+ const entry = (await modelPortfolio(root))?.models.find((model) => model.id === candidate);
490
+ const exact = entry && entry.enabled !== false ? piModel(entry) : undefined;
491
+ if (!exact) return wakeResult(requestId, "failed", { reason: `candidate ${candidate} is not an enabled exact Pi model` });
492
+ const model = ctx.modelRegistry?.find(exact.provider, exact.model);
493
+ if (!model || !ctx.modelRegistry!.hasConfiguredAuth(model as never)) return wakeResult(requestId, "failed", { reason: `${exact.provider}/${exact.model} is not available and authenticated in Pi` });
494
+ const thinking = pi.getThinkingLevel();
495
+ if (!await pi.setModel(model as never)) return wakeResult(requestId, "failed", { reason: `Pi did not accept ${exact.provider}/${exact.model}` });
496
+ pi.setThinkingLevel(thinking);
497
+ }
498
+ await ctx.reload?.();
499
+ } catch (error) {
500
+ wakeResult(requestId, "failed", { reason: error instanceof Error ? error.message : String(error) });
501
+ }
502
+ },
503
+ });
420
504
  pi.on("session_start", async (_event, ctx) => {
505
+ const wakeContext = ctx as unknown as WakeContext;
506
+ const identity = wakeIdentity(seat, wakeContext);
507
+ const wake = unmatchedWakeRequest(branchOf(wakeContext), identity);
508
+ // Capability is evidence of a verified activation under this extension version; it never lives in runtime state.
509
+ const announceCapability = () => { try { pi.appendEntry(wakeCapabilityType, { seat, pi_session: identity.pi_session, pane: identity.pane }); } catch { /* The host re-checks capability; absence only forces a reload. */ } };
510
+ // One reload-reconstructed result and one combined recovery + task-directive turn replace the plain startup message.
511
+ let wakeSettled = false;
512
+ const completeWake = async () => {
513
+ if (!wake) return false;
514
+ wakeSettled = true;
515
+ const requestId = String(wake.request_id), candidate = typeof wake.candidate === "string" ? wake.candidate : undefined;
516
+ const target = { provider: wakeContext.model?.provider, model: wakeContext.model?.id, thinking: safeThinking(wakeContext) };
517
+ if (candidate) {
518
+ const entry = (await modelPortfolio(root).catch(() => undefined))?.models.find((model) => model.id === candidate);
519
+ const exact = entry && entry.enabled !== false ? piModel(entry) : undefined;
520
+ if (!exact || exact.provider !== target.provider || exact.model !== target.model) { wakeResult(requestId, "failed", { reason: "model after reload is not the requested candidate", target }); return true; }
521
+ }
522
+ const bundle = await recovery(root, seat, "start", identity.pi_session);
523
+ const directive = await deliveredDirectiveText(root, seat, notices);
524
+ wakeResult(requestId, candidate ? "swapped+woke" : "woke", { target });
525
+ pi.sendMessage({
526
+ customType: wakeStartType,
527
+ content: `${runtimeStartupInstruction(seat, bundle)}${directive ? `\n${directive}` : ""}`,
528
+ display: true, details: { seat, root, request_id: requestId, wake: true },
529
+ }, { triggerTurn: true, deliverAs: "followUp" });
530
+ return true;
531
+ };
532
+ const failWake = (reason: string) => { if (wake && !wakeSettled) { wakeSettled = true; wakeResult(String(wake.request_id), "failed", { reason }); } };
421
533
  const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string | undefined } }).sessionManager;
422
534
  const liveSession = environment.ATDD_FLOW_PI_SESSION ?? sessionManager?.getSessionId?.();
423
535
  // One display-only notice per session: a refused, timed-out or failed activation must never be silent, and never wakes the agent.
@@ -456,10 +568,13 @@ export default function (pi: ExtensionAPI, environment: NodeJS.ProcessEnv = proc
456
568
  if (await hostAttachedActivation({ root, seat, herdrSession: environment.HERDR_SESSION, pane: environment.HERDR_PANE_ID, piSession: sessionManager?.getSessionId?.(), piSessionPath: sessionManager?.getSessionFile?.() })) {
457
569
  hostActivatedAt = Date.now();
458
570
  if (await runtime.activate()) {
571
+ announceCapability();
572
+ if (await completeWake()) return;
459
573
  const bundle = await recovery(root, seat, "start", sessionManager?.getSessionId?.());
460
574
  const notice = "Notices created before this session were not replayed; run atdd-flow status seat " + seat + " for unanswered mail.";
461
575
  pi.sendMessage({ customType: "atdd-flow-start", content: `${runtimeStartupInstruction(seat, bundle)} ${notice}`, display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
462
576
  }
577
+ else failWake("native mail activation was refused");
463
578
  } else if (await expectsNativeMail(root, seat)) await noticeActivation("inactive");
464
579
  return;
465
580
  }
@@ -468,7 +583,12 @@ export default function (pi: ExtensionAPI, environment: NodeJS.ProcessEnv = proc
468
583
  void awaitLaunchActivation({
469
584
  root, seat, piSession, herdrSession, pane,
470
585
  activate: async () => {
471
- if (!await activateWithRetry(() => current.activate())) return;
586
+ if (!await activateWithRetry(() => current.activate())) return failWake("native mail activation was refused");
587
+ announceCapability();
588
+ if (await completeWake()) {
589
+ if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
590
+ return;
591
+ }
472
592
  const request = await readHandoverReplacement(root, seat).catch(() => undefined);
473
593
  const successor = request && request.source.pi_session !== piSession ? request : undefined;
474
594
  const bundle = await recovery(root, seat, successor ? "successor" : "start", piSession);
@@ -483,7 +603,7 @@ export default function (pi: ExtensionAPI, environment: NodeJS.ProcessEnv = proc
483
603
  await sendTaskDirective(host, root, seat, notices);
484
604
  if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
485
605
  },
486
- }).catch((error) => error instanceof ActivationTimeout
606
+ }).catch((error) => (failWake(error instanceof Error ? error.message : String(error)), error instanceof ActivationTimeout)
487
607
  ? noticeActivation("inactive")
488
608
  : noticeActivation(current.active ? "active" : "inactive", error instanceof Error ? error.message : String(error)));
489
609
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.70",
3
+ "version": "0.3.72",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,11 +26,11 @@ export async function addressBook(root: string) {
26
26
  catch { /* A partially removed legacy seat is not an address-book entry. */ }
27
27
  }
28
28
  }
29
- console.log("# Desk address book\n");
30
- console.log("`operator@desk` is the human authority. Drivers route normal work through coordinators; main seats are the normal technical gateway to the operator.\n");
31
- console.log("| Address | Identity | Purpose / authority |\n| --- | --- | --- |");
29
+ process.stdout.write("# Desk address book\n\n");
30
+ process.stdout.write("`operator@desk` is the human authority. Drivers route normal work through coordinators; main seats are the normal technical gateway to the operator.\n\n");
31
+ process.stdout.write("| Address | Identity | Purpose / authority |\n| --- | --- | --- |\n");
32
32
  for (const record of records.sort((left, right) => left.address.localeCompare(right.address))) {
33
33
  const purpose = (record.purpose ?? authority(record)).replaceAll("|", "\\|");
34
- console.log(`| ${record.address} | ${identity(record.address)} | ${purpose} |`);
34
+ process.stdout.write(`| ${record.address} | ${identity(record.address)} | ${purpose} |\n`);
35
35
  }
36
36
  }
@@ -1,3 +1,9 @@
1
+ // URN: component:flow:e006:adoption-authorizations:backend:integration
2
+ // Tested-By:
3
+ // - test:flow:exact-adoption-resumed-proof-and-relaunch
4
+ // Runtime: bun
5
+ // Purpose: Create and resolve exact-session adoption authorizations.
6
+
1
7
  import { createHash } from "node:crypto";
2
8
  import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
9
  import { dirname, join, resolve } from "node:path";
@@ -44,7 +50,7 @@ export async function createExactSessionAdoptionAuthorization(root: string, id:
44
50
  await mkdir(dirname(file), { recursive: true });
45
51
  await writeFile(file, content, { encoding: "utf8", flag: "wx" });
46
52
  await writeFile(hashPath(root, id), `${digest(content)}\n`, { encoding: "utf8", flag: "wx" });
47
- console.log(id);
53
+ process.stdout.write(`${id}\n`);
48
54
  }
49
55
 
50
56
  function same(record: ExactSessionAdoptionAuthorization, expected: Omit<ExactSessionAdoptionAuthorization, "schema" | "id" | "issued_by" | "created_at">) {
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 { formatWakeRow, wakePi } from "./pi-wake";
10
11
  import { createExactSessionAdoptionAuthorization } from "./adoption-authorizations";
11
12
  import { addParticipant, openThread, post, readMessage, receipt, result, startThread } from "./threads";
12
13
  import * as tasks from "./tasks";
@@ -34,12 +35,13 @@ Usage:
34
35
  atdd-flow desk migrate
35
36
  atdd-flow project init <project>
36
37
  atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]
37
- atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary] [--dry-run]
38
+ atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary [--retain-large-primary-file <path>:<sha256>:<bytes>:<mode> ...]] [--dry-run]
38
39
  atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>
39
40
  atdd-flow spawn <project> <role> <name> [--worktree <path>] [--branch <branch>] [--agent <legacy-executable>]
40
41
  atdd-flow bind <address> [--application <application>] --address <native-address> [--session <name>] [--agent <executable>] [--worktree <path>] [--wake host|native]
41
42
  atdd-flow attach <address> [--application <application>] [--wake host|native]
42
43
  atdd-flow pi extension-path
44
+ atdd-flow pi wake <seat>|--scope project|desk [--model-profile <name>] --by <authorized-seat>
43
45
  atdd-flow pi runtime authorize-adoption <id> --by operator@desk --seat <seat> --pi-session <id> --pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --target-herdr-session <session> --target-pane <pane> --target-cwd <path>
44
46
  atdd-flow pi runtime launch <address> [--pane <asserted-herdr-pane>] --herdr-session <session> [--resume] [--dry-run] [--adopt-session <exact-jsonl> --source-herdr-session <session> --source-pane <pane> --authorization <record-id>]
45
47
  atdd-flow application use <address> <application>
@@ -48,10 +50,12 @@ Usage:
48
50
  atdd-flow checkpoint <address> --summary <text> --next <text> [--status active|standby|blocked|complete|unverified]
49
51
  atdd-flow handover <address> --file <path> [--check]
50
52
  atdd-flow handover show <address>
51
- atdd-flow handover replace <address> [--supersede-consumed] [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>
53
+ atdd-flow handover replace <address> [--supersede-consumed] [--recover-stale-main] [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>
52
54
  atdd-flow handover resumed <address>
53
55
  atdd-flow task add <project> <task-id> --title <text> --coordinator <address> [--assignee <address>] [--body <text>] [--source <reference>] [--depends-on <task-id,...>] --done-when <text> [--done-when <text> ...]
54
- atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>
56
+ atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address> [--immediate --priority <priority> --mutation-boundary <text>]
57
+ atdd-flow task acknowledge <project> <task-id> --dispatch <message-id> --by <assignee>
58
+ atdd-flow task observe-assignment <project> <task-id> --by <coordinator-address>
55
59
  atdd-flow task transfer <project> <task-id> --to <main-or-named-coordinator> --reason <text> --by <actor> [--authorization <operator-message-id>]
56
60
  atdd-flow task amend <project> <task-id> [--title <text>] [--body <text>] [--source <reference>] [--depends-on <task-id,...>]
57
61
  atdd-flow task import <project> <task-id> --proof <reference> [--proof <reference> ...] [--done-when <text> ...] [--source <reference>]
@@ -115,13 +119,19 @@ async function main() {
115
119
  if (rest[0] === "configure") return configureProject(root, required(rest[1], "project"), rest.slice(2));
116
120
  if (rest[0] === "migrate-vcs") return migrateProjectVcs(root, required(rest[1], "project"), rest.slice(2));
117
121
  if (rest[0] === "migrate-named-coordinator-topology") return migrateNamedCoordinatorTopology(root, required(rest[1], "project"), rest.slice(2));
118
- 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] [--dry-run]`, or `atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>`.");
122
+ 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>`.");
119
123
  },
120
124
  spawn: () => spawn(root, required(rest[0], "project"), required(rest[1], "role"), required(rest[2], "name"), rest.slice(3)),
121
125
  bind: () => bind(root, required(rest[0], "address"), rest.slice(1)),
122
126
  attach: () => attach(root, required(rest[0], "address"), rest.slice(1)),
123
127
  pi: async () => {
124
128
  if (rest[0] === "extension-path") return console.log(piExtensionPath());
129
+ if (rest[0] === "wake") {
130
+ const { rows, success } = await wakePi(root, rest.slice(1), (entry) => console.log(formatWakeRow(entry)));
131
+ if (!success) process.exitCode = 1;
132
+ 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"}`);
133
+ return;
134
+ }
125
135
  if (rest[0] === "runtime" && rest[1] === "authorize-adoption") return createExactSessionAdoptionAuthorization(root, required(rest[2], "authorization id"), rest.slice(3));
126
136
  if (rest[0] === "runtime" && rest[1] === "launch") {
127
137
  const plan = await launchPiRuntime(root, required(rest[2], "address"), rest.slice(3));
@@ -129,7 +139,7 @@ async function main() {
129
139
  else console.log(`Launched Pi runtime for ${required(rest[2], "address")} with ${plan.candidate}.`);
130
140
  return;
131
141
  }
132
- throw new Error("Use `pi extension-path`, `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>]`.");
142
+ 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>]`.");
133
143
  },
134
144
  application: async () => {
135
145
  if (rest[0] === "use") return useApplication(root, required(rest[1], "address"), required(rest[2], "application"));
@@ -146,6 +156,8 @@ async function main() {
146
156
  const [subcommand, projectName, taskId, ...tail] = rest;
147
157
  if (subcommand === "add") return tasks.add(root, required(projectName, "project"), required(taskId, "task id"), tail);
148
158
  if (subcommand === "assign") return tasks.assign(root, required(projectName, "project"), required(taskId, "task id"), tail);
159
+ if (subcommand === "acknowledge") return tasks.acknowledge(root, required(projectName, "project"), required(taskId, "task id"), tail);
160
+ if (subcommand === "observe-assignment") return tasks.observeAssignment(root, required(projectName, "project"), required(taskId, "task id"), tail);
149
161
  if (subcommand === "transfer") return tasks.transfer(root, required(projectName, "project"), required(taskId, "task id"), tail);
150
162
  if (subcommand === "amend") return tasks.amend(root, required(projectName, "project"), required(taskId, "task id"), tail);
151
163
  if (subcommand === "import") return tasks.importCompleted(root, required(projectName, "project"), required(taskId, "task id"), tail);
@@ -163,7 +175,7 @@ async function main() {
163
175
  if (subcommand === "unblock") return tasks.unblock(root, required(projectName, "project"), required(taskId, "task id"), tail);
164
176
  if (subcommand === "list") return tasks.list(root, required(projectName, "project"), rest.slice(2));
165
177
  if (subcommand === "open") return tasks.open(root, required(projectName, "project"), required(taskId, "task id"));
166
- throw new Error("Use `atdd-flow task add|assign|transfer|amend|import|start|prove|handoff|respond|review|return|done|block|supersede|reject|defer|unblock|list|open`.");
178
+ throw new Error("Use `atdd-flow task add|assign|acknowledge|observe-assignment|transfer|amend|import|start|prove|handoff|respond|review|return|done|block|supersede|reject|defer|unblock|list|open`.");
167
179
  },
168
180
  seat: async () => {
169
181
  if (rest[0] === "retire") return tasks.retireSeat(root, required(rest[1], "address"), rest.slice(2));
@@ -1,3 +1,9 @@
1
+ // URN: component:flow:e004:collaboration-policy:backend:application
2
+ // Tested-By:
3
+ // - test:atdd-flow:seat-recovery-bundle
4
+ // Runtime: bun
5
+ // Purpose: Define the project collaboration policy and its startup instruction.
6
+
1
7
  import { readFileSync } from "node:fs";
2
8
  import { readFile, writeFile } from "node:fs/promises";
3
9
  import { join } from "node:path";
package/src/core.ts CHANGED
@@ -39,6 +39,8 @@ export type ModelPortfolio = {
39
39
  schema: "atdd-workflow/models/v1";
40
40
  /** Ordered strongest to weakest. Workflow prefers the weakest sufficient enabled candidate. */
41
41
  models: ModelCandidate[];
42
+ /** Named, machine-readable migrations: profile -> source `provider/model` -> existing enabled candidate id. */
43
+ model_migrations?: Record<string, Record<string, string>>;
42
44
  };
43
45
  type LegacyCoordination = { schema: "atdd-workflow/coordination/v2"; site: string; application: string; aliases?: Record<string, string> };
44
46
  export type Project = {
@@ -262,6 +264,40 @@ export async function migrateDesk(root: string) {
262
264
  return true;
263
265
  }
264
266
 
267
+ /** The one exact Pi provider/model a candidate selects; undefined when it is not Pi or is ambiguous. */
268
+ export function piModel(candidate: ModelCandidate): { provider: string; model: string } | undefined {
269
+ if (basename(candidate.executable) !== "pi") return undefined;
270
+ const args = candidate.args ?? [];
271
+ const flagValues = (flag: string) => args.flatMap((argument, index) => argument === flag ? [args[index + 1]] : []);
272
+ const [provider, ...otherProviders] = flagValues("--provider"), [model, ...otherModels] = flagValues("--model");
273
+ if (otherProviders.length || otherModels.length) return undefined;
274
+ const exact = (value: string | undefined): value is string => Boolean(value?.trim()) && !value!.startsWith("--");
275
+ return exact(provider) && exact(model) ? { provider, model } : undefined;
276
+ }
277
+
278
+ function assertMigrationTarget(where: string, target: unknown, models: ModelCandidate[]) {
279
+ const candidate = models.find((entry) => entry.id === target);
280
+ if (!candidate) throw new Error(`${where} targets an unknown candidate: ${String(target)}`);
281
+ if (candidate.enabled === false) throw new Error(`${where} targets a disabled candidate: ${candidate.id}`);
282
+ if (!piModel(candidate)) throw new Error(`${where} targets a candidate without one exact Pi provider and model: ${candidate.id}`);
283
+ }
284
+
285
+ const isRecord = (entry: unknown): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry);
286
+
287
+ function assertMigration(profile: string, migration: unknown, models: ModelCandidate[]) {
288
+ if (!profile.trim() || !isRecord(migration)) throw new Error(`model_migrations.${profile || "<empty>"} must be a named map of source provider/model to a candidate id.`);
289
+ for (const [source, target] of Object.entries(migration)) {
290
+ if (!/^[^/\s]+\/\S+$/.test(source)) throw new Error(`model_migrations.${profile}: source must be provider/model: ${source}`);
291
+ assertMigrationTarget(`model_migrations.${profile}.${source}`, target, models);
292
+ }
293
+ }
294
+
295
+ function assertModelMigrations(value: unknown, models: ModelCandidate[]) {
296
+ if (value === undefined) return;
297
+ if (!isRecord(value)) throw new Error("model_migrations must map profile names to migrations.");
298
+ for (const [profile, migration] of Object.entries(value)) assertMigration(profile, migration, models);
299
+ }
300
+
265
301
  export async function modelPortfolio(root: string): Promise<ModelPortfolio | undefined> {
266
302
  const file = paths(root).models;
267
303
  if (!await exists(file)) return undefined;
@@ -274,6 +310,7 @@ export async function modelPortfolio(root: string): Promise<ModelPortfolio | und
274
310
  if (ids.has(model.id)) throw new Error(`Duplicate model id in models.yaml: ${model.id}`);
275
311
  ids.add(model.id);
276
312
  }
313
+ assertModelMigrations(value.model_migrations, value.models);
277
314
  return value;
278
315
  }
279
316
 
@@ -10,6 +10,7 @@ import { basename, join, resolve } from "node:path";
10
10
  import { type Seat, paths, readYaml, yaml } from "./core";
11
11
  import type { HandoverReplacementRequest } from "./handover";
12
12
  import { readRuntimeState } from "./runtime-state";
13
+ import { archivedReceiptMatches, recoveryEvidenceMatches } from "./stale-main-recovery";
13
14
  import type { Task } from "./tasks";
14
15
 
15
16
  type ReplacementLaunchReceipt = {
@@ -90,6 +91,9 @@ function validConsumedRequest(request: HandoverReplacementRequest, seat: string)
90
91
 
91
92
  /** Rebuild and compare every archived supersession byte and identity. */
92
93
  async function exactArchiveMatches(request: HandoverReplacementRequest) {
94
+ // Stale-main recovery proves its evidence by the hash-archived stale receipt, not a consumed request.
95
+ const recovery = request.stale_main_recovery;
96
+ if (recovery) return recoveryEvidenceMatches(request, recovery) && await archivedReceiptMatches(request, recovery);
93
97
  const proof = request.supersession;
94
98
  if (!proof) return false;
95
99
  try {
@@ -147,7 +151,7 @@ async function uniqueAdoptionReceipt(root: string, authorizationId: string, expe
147
151
  function exactAdoptionRuntimeMatches(record: Seat, request: HandoverReplacementRequest) {
148
152
  const runtime = record.runtime, locator = runtime?.addresses.herdr;
149
153
  const observed = {
150
- supersession: Boolean(request.supersession), application: runtime?.application, wake: runtime?.wake,
154
+ supersession: Boolean(request.supersession || request.stale_main_recovery), application: runtime?.application, wake: runtime?.wake,
151
155
  session: runtime?.pi_session, sessionPath: runtime?.pi_session_path,
152
156
  herdrSession: typeof locator === "string" ? undefined : locator?.session,
153
157
  pane: typeof locator === "string" ? undefined : locator?.pane,
package/src/handover.ts CHANGED
@@ -14,6 +14,7 @@ import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizati
14
14
  import { exactFailedAdoptionProof } from "./exact-adoption-recovery";
15
15
  import { isExactOperatorSeat, operatorActivation, operatorRelaunchSection } from "./operator-launch";
16
16
  import { readRuntimeState } from "./runtime-state";
17
+ import { type StaleMainRecovery, buildStaleMainRecovery, replaceActiveRequest, staleRecoveryMatches } from "./stale-main-recovery";
17
18
 
18
19
  export type Handover = {
19
20
  schema: "atdd-workflow/handover/v1";
@@ -119,10 +120,11 @@ export type HandoverReplacementRequest = {
119
120
  canonical_source: { pi_session: string; pi_session_path: string; herdr_session: string; pane: string; cwd: string; launch_receipt: string };
120
121
  live_source: LiveSourceProof;
121
122
  };
123
+ stale_main_recovery?: StaleMainRecovery;
122
124
  created_at: string;
123
125
  };
124
126
 
125
- type LiveSourceProof = {
127
+ export type LiveSourceProof = {
126
128
  authorization_hash: string;
127
129
  pi_session: string;
128
130
  pi_session_path: string;
@@ -204,6 +206,7 @@ function supersessionEvidenceMatches(request: HandoverReplacementRequest, proof:
204
206
  }
205
207
 
206
208
  export function replacementMatchesRuntime(runtime: Runtime | undefined, request: HandoverReplacementRequest) {
209
+ if (request.stale_main_recovery) return staleRecoveryMatches(runtime, request, request.stale_main_recovery);
207
210
  const proof = request.supersession;
208
211
  if (proof) return supersessionPlacementMatches(runtime, proof) && supersessionSessionMatches(runtime, proof) && supersessionEvidenceMatches(request, proof);
209
212
  if (!request.legacy_source) return canonicalSourceMatchesRuntime(runtime, request.source);
@@ -360,10 +363,11 @@ async function publishCreateOnly(file: string, content: string) {
360
363
  } finally { await rm(temporary, { force: true }); }
361
364
  }
362
365
 
363
- const replacementFlags = ["--supersede-consumed", "--legacy-source-bootstrap", "--handover", "--authorization", "--takeover-task", "--source-pi-session", "--source-pi-session-path", "--source-herdr-session", "--source-pane", "--herdr-session", "--target-pane", "--target-cwd"];
366
+ const replacementFlags = ["--supersede-consumed", "--recover-stale-main", "--legacy-source-bootstrap", "--handover", "--authorization", "--takeover-task", "--source-pi-session", "--source-pi-session-path", "--source-herdr-session", "--source-pane", "--herdr-session", "--target-pane", "--target-cwd"];
364
367
  function validateReplacementArgs(args: string[]) {
365
- if (args.some((arg) => arg.startsWith("--") && !replacementFlags.includes(arg))) throw new Error("Use `handover replace <main-seat> [--supersede-consumed] [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>`.");
368
+ if (args.some((arg) => arg.startsWith("--") && !replacementFlags.includes(arg))) throw new Error("Use `handover replace <main-seat> [--supersede-consumed] [--recover-stale-main] [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>`.");
366
369
  if (has(args, "--supersede-consumed") && has(args, "--legacy-source-bootstrap")) throw new Error("Consumed replacement supersession cannot use legacy source bootstrap.");
370
+ if (has(args, "--recover-stale-main") && (has(args, "--supersede-consumed") || has(args, "--legacy-source-bootstrap"))) throw new Error("Stale main recovery cannot be combined with consumed supersession or legacy source bootstrap.");
367
371
  }
368
372
 
369
373
  async function currentReplacementHandover(root: string, resolved: string, args: string[]) {
@@ -394,13 +398,13 @@ function assertReplacementSource(record: Seat, source: HandoverReplacementReques
394
398
 
395
399
  async function prepareReplacement(root: string, resolved: string, args: string[]) {
396
400
  validateReplacementArgs(args);
397
- const legacy = has(args, "--legacy-source-bootstrap"), supersede = has(args, "--supersede-consumed");
401
+ const legacy = has(args, "--legacy-source-bootstrap"), supersede = has(args, "--supersede-consumed"), recover = has(args, "--recover-stale-main");
398
402
  const handover = await currentReplacementHandover(root, resolved, args);
399
403
  const task = nonEmpty(words(args, "--takeover-task"), "--takeover-task");
400
404
  const { record } = await exactMainTakeoverTask(root, resolved, task);
401
405
  const source = replacementSource(args), target = replacementTarget(args, record, source);
402
- if (!supersede) assertReplacementSource(record, source, legacy);
403
- return { legacy, supersede, handover, task, record, source, target };
406
+ if (!supersede && !recover) assertReplacementSource(record, source, legacy);
407
+ return { legacy, supersede, recover, handover, task, record, source, target };
404
408
  }
405
409
 
406
410
  function replacementRequest(resolved: string, intent: Awaited<ReturnType<typeof prepareReplacement>>, authorization: { id: string; hash: string }, legacySource: HandoverReplacementRequest["legacy_source"], dependencies: HandoverReplacementDependencies): HandoverReplacementRequest {
@@ -735,14 +739,15 @@ export async function requestHandoverReplacement(root: string, address: string,
735
739
  const resolved = await canonicalAddress(root, address), intent = await prepareReplacement(root, resolved, args);
736
740
  const file = handoverReplacementPath(root, resolved);
737
741
  if (intent.supersede) return supersedeConsumedReplacement(root, resolved, file, intent, args, dependencies);
738
- if (await exists(file)) throw new Error(`Path-A replacement request already exists for ${resolved}.`);
742
+ if (!intent.recover && await exists(file)) throw new Error(`Path-A replacement request already exists for ${resolved}.`);
739
743
  const authorization = await resolveExactSessionAdoptionAuthorization(root, nonEmpty(words(args, "--authorization"), "--authorization"), {
740
744
  seat: resolved, pi_session: intent.source.pi_session, pi_session_path: intent.source.pi_session_path,
741
745
  source: { herdr_session: intent.source.herdr_session, pane: intent.source.pane }, target: intent.target,
742
746
  });
743
747
  const legacySource = intent.legacy ? await verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies) : undefined;
744
- const request = replacementRequest(resolved, intent, authorization, legacySource, dependencies);
745
- await (dependencies.publish ?? publishCreateOnly)(file, yaml.print(request));
748
+ const recovery = intent.recover ? await buildStaleMainRecovery(root, resolved, intent, { file, isLive: (prior) => replacementMatchesRuntime(intent.record.runtime, prior) }, (canonical) => verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies, canonical)) : undefined;
749
+ const request = { ...replacementRequest(resolved, intent, authorization, legacySource, dependencies), ...(recovery ? { stale_main_recovery: recovery } : {}) };
750
+ await (recovery?.prior_request ? replaceActiveRequest : dependencies.publish ?? publishCreateOnly)(file, yaml.print(request));
746
751
  console.log(`Recorded Path-A replacement request for ${resolved}.`);
747
752
  }
748
753
 
@@ -164,13 +164,19 @@ async function targets(root: string, options: { strict?: string; unprojected?: U
164
164
  if (!config.repository) continue;
165
165
  const primary = resolve(config.repository);
166
166
  result.push({ project: name, worktree: primary, workspaceLabel: name });
167
- for (const entry of await projectSeats(root, name)) {
167
+ const seats = await projectSeats(root, name);
168
+ const mainOnPrimary = seats.some((entry) => entry.role === "main" && resolve(entry.worktree) === primary);
169
+ for (const entry of seats) {
168
170
  try { assertNewTopologyPlacement(config, entry, primary); }
169
171
  catch (error) {
170
172
  if (entry.address === options.strict) throw error;
171
173
  options.unprojected?.push({ address: entry.address, reason: (error as Error).message });
172
174
  continue;
173
175
  }
176
+ // main@project is the single primary coordinator projection: the preserved legacy generic
177
+ // coordinator@project shares its checkout and is not bulk-projected as a second tab. An explicit
178
+ // per-seat projection still projects it, and a project without a primary main keeps it.
179
+ if (mainOnPrimary && entry.role === "coordinator" && entry.address === `coordinator@${name}` && entry.address !== options.strict && resolve(entry.worktree) === primary) continue;
174
180
  if ((entry.role === "main" || entry.role === "coordinator" || entry.role === "operator") && resolve(entry.worktree) === primary) {
175
181
  result.push({ project: name, worktree: primary, workspaceLabel: name, seat: entry });
176
182
  continue;
@@ -52,6 +52,22 @@ export async function sendTaskDirective(host: NoticeHost, root: string, seat: st
52
52
  }
53
53
  }
54
54
 
55
+ /**
56
+ * The current task-derived action as text for a message the caller sends itself (a wake's single combined turn).
57
+ * Recording it as delivered keeps the settled-turn nudge from repeating the same directive.
58
+ */
59
+ export async function deliveredDirectiveText(root: string, seat: string, state: NoticeState) {
60
+ try {
61
+ const directive = await nextDirective(root, seat);
62
+ if (!directive) return undefined;
63
+ const key = directiveKey(directive);
64
+ state.lastDelivered = key;
65
+ state.settledNudged = undefined;
66
+ if (directive.lifecycle === "BLOCKED") state.blockers.add(key);
67
+ return directiveNotice(seat, directive);
68
+ } catch { return undefined; }
69
+ }
70
+
55
71
  /** A settled model turn is not task completion. Give one additional nudge for a
56
72
  * still-actionable directive, but never loop on unchanged task state. */
57
73
  export async function sendSettledDirective(host: NoticeHost, root: string, seat: string, state: NoticeState) {
@@ -26,7 +26,7 @@ export async function operatorRelaunchSection(root: string, record: Seat) {
26
26
  const locator = record.runtime?.addresses.herdr;
27
27
  const session = (typeof locator === "object" ? locator.session : undefined) ?? (await desk(root)).herdr_session;
28
28
  const command = session ? canonicalLaunchCommand(record.address, session) : "UNAVAILABLE: no Herdr session is recorded for this seat or Desk.";
29
- return `\nCANONICAL RELAUNCH COMMAND (generated by Flow; run it in a vacant shell pane after this Pi exits, then run \`atdd-flow handover resumed ${record.address}\`):\n${command}\n`;
29
+ return `\nCANONICAL RELAUNCH COMMAND (generated by Flow. Exit Pi in the operator pane so it is an idle shell, then run this from a DIFFERENT pane (e.g. another pane in this workspace), never inside the target pane; afterwards run \`atdd-flow handover resumed ${record.address}\`):\n${command}\n`;
30
30
  }
31
31
 
32
32
  const notBefore = (value: string | undefined, floor: string | undefined) => Boolean(value && floor && Date.parse(value) >= Date.parse(floor));