@afokapu/atdd-flow 0.3.60 → 0.3.62

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.
@@ -3,9 +3,9 @@ import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/pr
3
3
  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
- import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsRuntimeState, registerRuntimeState, runtimeOwnerToken, withOwnedRuntimeState } from "../../src/runtime-state";
6
+ import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsRuntimeState, recordMailActivation, registerRuntimeState, runtimeOwnerToken, withOwnedRuntimeState } from "../../src/runtime-state";
7
7
  import { paths, readYaml, type Seat } from "../../src/core";
8
- import { expectsNativeMail, inactiveMailNotice } from "../../src/mail-activation-notice";
8
+ import { activationNotice, diagnoseMailActivation, expectsNativeMail } from "../../src/mail-activation-notice";
9
9
  import { type NoticeState, createNoticeState, refreshDelivered, sendTaskDirective, shouldNotifyMail } from "../../src/next-action-notices";
10
10
  import { handoverThresholdInstruction, runtimeStartupInstruction, successorInstruction } from "../../src/collaboration-policy";
11
11
  import { type RecoveryMode, recoveryFor } from "../../src/recovery-bundle";
@@ -261,7 +261,7 @@ export function createPiRuntime({
261
261
  const activate = async () => {
262
262
  if (!await owned()) return false;
263
263
  if (activated) return true;
264
- transport = createTransport({
264
+ const candidate = createTransport({
265
265
  root, seat, batchSize: defaultBatchSize, intervalMs: defaultIntervalMs,
266
266
  deliver: async (mail, thread, path) => {
267
267
  // The application supplies the wake action, but this shared filesystem
@@ -278,7 +278,9 @@ export function createPiRuntime({
278
278
  return ownership === true && accepted !== false;
279
279
  },
280
280
  });
281
- await transport.start();
281
+ // The transport counts as started only after start() succeeds; a failed start is stopped and leaves no transport behind.
282
+ try { await candidate.start(); } catch (error) { candidate.stop(); throw error; }
283
+ transport = candidate;
282
284
  activated = true;
283
285
  return true;
284
286
  };
@@ -294,7 +296,7 @@ export function createPiRuntime({
294
296
  }, heartbeatMs);
295
297
  if (!deferActivation) await activate();
296
298
  };
297
- return { ownerToken, start, activate, stop, reconcile, get transport() { return transport; } };
299
+ return { ownerToken, start, activate, stop, reconcile, get transport() { return transport; }, get active() { return activated; } };
298
300
  }
299
301
 
300
302
  type LaunchReceipt = { seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string };
@@ -307,7 +309,8 @@ export class ActivationTimeout extends Error {
307
309
  /** Wait only for the launcher's already-verified receipt and exact Desk binding; this never creates either record. */
308
310
  export async function awaitLaunchActivation({ root, seat, piSession, herdrSession, pane, activate, intervalMs = defaultActivationIntervalMs, timeoutMs = defaultActivationTimeoutMs }: LaunchActivationOptions) {
309
311
  const deadline = Date.now() + timeoutMs;
310
- while (Date.now() <= deadline) {
312
+ let matched = false;
313
+ while (!matched && Date.now() <= deadline) {
311
314
  try {
312
315
  const record = await readYaml<Seat>(paths(root).seatFile(seat));
313
316
  const runtime = record.runtime;
@@ -315,18 +318,28 @@ export async function awaitLaunchActivation({ root, seat, piSession, herdrSessio
315
318
  if (runtime?.application === "herdr" && typeof locator !== "string" && locator?.session === herdrSession && locator.pane === pane
316
319
  && runtime.pi_session === piSession && runtime.pi_session_path && runtime.launch_receipt) {
317
320
  const receipt = await readYaml<LaunchReceipt>(runtime.launch_receipt);
318
- if (receipt.seat === seat && receipt.pi_session === piSession && receipt.pi_session_path === runtime.pi_session_path
319
- && receipt.herdr_session === herdrSession && receipt.pane === pane) {
320
- await activate();
321
- return;
322
- }
321
+ matched = receipt.seat === seat && receipt.pi_session === piSession && receipt.pi_session_path === runtime.pi_session_path
322
+ && receipt.herdr_session === herdrSession && receipt.pane === pane;
323
323
  }
324
324
  } catch { /* Binding and receipt are written after Herdr verification; wait within this finite window. */ }
325
- await pause(intervalMs);
325
+ if (!matched) await pause(intervalMs);
326
326
  }
327
+ // The callback runs at most once, outside the polling try: a failing startup step is reported, never retried every poll.
328
+ if (matched) return activate();
327
329
  throw new ActivationTimeout();
328
330
  }
329
331
 
332
+ /** A transient start failure (for example a busy inbox lock under concurrent reloads) is retried a bounded number of times before it is reported. */
333
+ export async function activateWithRetry(activate: () => Promise<boolean>, attempts = 3, delayMs = 1_000) {
334
+ for (let attempt = 1; ; attempt += 1) {
335
+ try { return await activate(); }
336
+ catch (error) {
337
+ if (attempt >= attempts) throw error;
338
+ await pause(delayMs);
339
+ }
340
+ }
341
+ }
342
+
330
343
  /**
331
344
  * Host-attached Pi has no launcher variables; it may activate only when Herdr's injected pane identity exactly
332
345
  * matches the Desk seat's native scoped binding. This reads the seat and never writes or creates a binding.
@@ -403,13 +416,19 @@ export default function (pi: ExtensionAPI) {
403
416
  pi.on("session_start", async (_event, ctx) => {
404
417
  const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string | undefined } }).sessionManager;
405
418
  const liveSession = process.env.ATDD_FLOW_PI_SESSION ?? sessionManager?.getSessionId?.();
406
- // One visible notice per session: a refused or timed-out activation must never be silent.
407
- let inactiveNoticed = false;
408
- const noticeInactive = async () => {
409
- if (inactiveNoticed) return;
410
- inactiveNoticed = true;
411
- const content = await inactiveMailNotice(root, seat, liveSession).catch(() => undefined);
412
- if (content) pi.sendMessage({ customType: "atdd-flow-mail-inactive", content, display: true, details: { seat, live: liveSession, authority: "binding" } }, { triggerTurn: true, deliverAs: "followUp" });
419
+ // One display-only notice per session: a refused, timed-out or failed activation must never be silent, and never wakes the agent.
420
+ let noticed = false;
421
+ const noticeActivation = async (transport: "active" | "inactive", error?: string) => {
422
+ if (noticed) return;
423
+ noticed = true;
424
+ const live = { pi_session: liveSession, herdr_session: process.env.ATDD_FLOW_HERDR_SESSION ?? process.env.HERDR_SESSION, pane: process.env.ATDD_FLOW_HERDR_PANE ?? process.env.HERDR_PANE_ID };
425
+ const activation = await diagnoseMailActivation(root, seat, live, transport, error).catch(() => undefined);
426
+ if (!activation) return;
427
+ if (runtime) await recordMailActivation(root, seat, runtime.ownerToken, activation).catch(() => undefined);
428
+ if (transport === "active" && !error) return;
429
+ const content = await activationNotice(root, seat, activation).catch(() => undefined);
430
+ const customType = transport === "active" ? "atdd-flow-mail-startup-failed" : "atdd-flow-mail-inactive";
431
+ if (content) pi.sendMessage({ customType, content, display: true, details: { seat, live, authority: "binding" } }, { triggerTurn: false });
413
432
  };
414
433
  runtime = createPiRuntime({ root, seat, piSession: liveSession, deferActivation: true, deliver: async (mail, thread, path, sendIfOwned) => {
415
434
  if (!sendIfOwned) return false;
@@ -437,7 +456,7 @@ export default function (pi: ExtensionAPI) {
437
456
  const notice = "Notices created before this session were not replayed; run atdd-flow status seat " + seat + " for unanswered mail.";
438
457
  pi.sendMessage({ customType: "atdd-flow-start", content: `${runtimeStartupInstruction(seat, bundle)} ${notice}`, display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
439
458
  }
440
- } else if (await expectsNativeMail(root, seat)) await noticeInactive();
459
+ } else if (await expectsNativeMail(root, seat)) await noticeActivation("inactive");
441
460
  return;
442
461
  }
443
462
  if (!piSession || !herdrSession || !pane) return;
@@ -445,7 +464,7 @@ export default function (pi: ExtensionAPI) {
445
464
  void awaitLaunchActivation({
446
465
  root, seat, piSession, herdrSession, pane,
447
466
  activate: async () => {
448
- if (!await current.activate()) return;
467
+ if (!await activateWithRetry(() => current.activate())) return;
449
468
  const request = await readHandoverReplacement(root, seat).catch(() => undefined);
450
469
  const successor = request && request.source.pi_session !== piSession ? request : undefined;
451
470
  const bundle = await recovery(root, seat, successor ? "successor" : "start", piSession);
@@ -460,7 +479,9 @@ export default function (pi: ExtensionAPI) {
460
479
  await sendTaskDirective(host, root, seat, notices);
461
480
  if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
462
481
  },
463
- }).catch((error) => error instanceof ActivationTimeout ? noticeInactive() : undefined);
482
+ }).catch((error) => error instanceof ActivationTimeout
483
+ ? noticeActivation("inactive")
484
+ : noticeActivation(current.active ? "active" : "inactive", error instanceof Error ? error.message : String(error)));
464
485
  });
465
486
  pi.on("session_shutdown", () => { void runtime?.stop(); });
466
487
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.60",
3
+ "version": "0.3.62",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,23 +1,28 @@
1
1
  // URN: component:flow:e009:mail-activation-notice:backend:application
2
2
  // Tested-By:
3
3
  // - test:flow:relaunched-seat-mail-activation
4
+ // - test:flow:inactive-mail-notice-diagnosable
4
5
  // Runtime: bun
5
6
  // Purpose: Name a Pi whose native mail is inactive; it never rewrites a binding.
6
7
 
8
+ import { existsSync } from "node:fs";
7
9
  import { paths, readYaml, type Seat } from "./core";
10
+ import type { MailActivation } from "./runtime-state";
8
11
  import { seatTasks, terminal } from "./tasks";
9
12
 
10
13
  /** The bound and live Pi sessions of a seat whose native mail cannot activate. */
11
14
  export type InactiveMail = { bound?: string; live?: string };
12
15
 
13
- /** Reads the seat binding at call time; a missing or unreadable seat has no bound session. */
14
- async function boundSession(root: string, seat: string) {
15
- try { return (await readYaml<Seat>(paths(root).seatFile(seat))).runtime?.pi_session; } catch { return undefined; }
16
+ /** The live identifiers a Pi compared with its seat binding; an unknown value is left undefined. */
17
+ export type LiveIdentity = { pi_session?: string; herdr_session?: string; pane?: string };
18
+
19
+ async function readSeat(root: string, seat: string) {
20
+ try { return await readYaml<Seat>(paths(root).seatFile(seat)); } catch { return undefined; }
16
21
  }
17
22
 
18
23
  /** Only a seat bound for native wake expects Pi to deliver its mail; host-wake seats are not warned. */
19
24
  export async function expectsNativeMail(root: string, seat: string) {
20
- try { return (await readYaml<Seat>(paths(root).seatFile(seat))).runtime?.wake === "native"; } catch { return false; }
25
+ return (await readSeat(root, seat))?.runtime?.wake === "native";
21
26
  }
22
27
 
23
28
  /** The task coordinator to ask for a relaunch, or main of the seat's project when no open task names one. */
@@ -29,11 +34,39 @@ async function relaunchOwner(root: string, seat: string) {
29
34
  } catch { return `main@${project}`; }
30
35
  }
31
36
 
32
- /** One line for the agent: native mail will not arrive, with the bound and live sessions and the supported recovery. */
33
- export async function inactiveMailNotice(root: string, seat: string, live?: string) {
34
- const bound = await boundSession(root, seat);
37
+ function boundIdentity(record: Seat | undefined): MailActivation["bound"] {
38
+ const runtime = record?.runtime;
39
+ const locator = runtime?.addresses.herdr;
40
+ const scoped = typeof locator === "object" ? locator : undefined;
41
+ const receipt = runtime?.launch_receipt ? (existsSync(runtime.launch_receipt) ? "present" : "missing") : "none";
42
+ return { pi_session: runtime?.pi_session, herdr_session: scoped?.session, pane: scoped?.pane, receipt };
43
+ }
44
+
45
+ /** Compares the live identity with the seat binding at call time; an unknown live value counts as differing from a bound one. */
46
+ export async function diagnoseMailActivation(root: string, seat: string, live: LiveIdentity, transport: MailActivation["transport"], error?: string): Promise<MailActivation> {
47
+ const bound = boundIdentity(await readSeat(root, seat));
48
+ const fields = [["session", live.pi_session, bound.pi_session], ["herdr", live.herdr_session, bound.herdr_session], ["pane", live.pane, bound.pane]] as const;
49
+ const differs = fields.filter(([, liveValue, boundValue]) => boundValue !== undefined && liveValue !== boundValue).map(([name]) => name);
50
+ return { at: new Date().toISOString(), transport, live, bound, differs, ...(error ? { error } : {}) };
51
+ }
52
+
53
+ /** The exact compared values, shared by the notice and `status seat`. */
54
+ export function describeActivation(activation: MailActivation) {
55
+ const value = (entry?: string) => entry ?? "unknown";
56
+ const { live, bound } = activation;
57
+ return `live session=${value(live.pi_session)} herdr=${value(live.herdr_session)} pane=${value(live.pane)}; bound session=${value(bound.pi_session)} herdr=${value(bound.herdr_session)} pane=${value(bound.pane)} receipt=${bound.receipt}`;
58
+ }
59
+
60
+ /** Display-only text: INACTIVE only when the transport never started; a failed later startup step is reported as ACTIVE. */
61
+ export async function activationNotice(root: string, seat: string, activation: MailActivation) {
62
+ const values = describeActivation(activation);
63
+ if (activation.transport === "active") {
64
+ return `SYSTEM: native Flow mail is ACTIVE for ${seat}; a startup step failed: ${activation.error ?? "unknown"}. Display only; no action is required for mail delivery. ${values}.`;
65
+ }
66
+ const differs = activation.differs.length ? ` Differs: ${activation.differs.join(", ")} (live does not match the binding).` : " All compared values match.";
67
+ const error = activation.error ? ` Last activation error: ${activation.error}.` : "";
35
68
  const owner = await relaunchOwner(root, seat);
36
- return `SYSTEM: native Flow mail is INACTIVE for ${seat}: live Pi session ${live ?? "unknown"} does not match bound session ${bound ?? "none"}, or its launch receipt is missing. Queued mail will not reach this pane. Run atdd-flow status seat ${seat}; ask ${owner} to relaunch via atdd-flow pi runtime launch ${seat}.`;
69
+ return `SYSTEM: native Flow mail is INACTIVE for ${seat} (display only). ${values}.${differs}${error} Queued mail will not reach this pane. Run atdd-flow status seat ${seat}; ask ${owner} to relaunch via atdd-flow pi runtime launch ${seat}.`;
37
70
  }
38
71
 
39
72
  /** A fresh advisory runtime whose live session differs from the seat binding; legacy or matching records report nothing. */
package/src/overview.ts CHANGED
@@ -2,7 +2,7 @@ import { readdir } from "node:fs/promises";
2
2
  import { 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
- import { nativeMailInactive } from "./mail-activation-notice";
5
+ import { describeActivation, nativeMailInactive } from "./mail-activation-notice";
6
6
  import { type Message, type Thread } from "./threads";
7
7
 
8
8
  type ListedTask = { project: string; id: string; task: Task; waiting: string[] };
@@ -357,6 +357,8 @@ async function seatDashboard(root: string, address: string) {
357
357
  if (advisory) console.log(` pi advisory ${isRuntimeStateStale(advisory, 60_000) ? "stale" : "active"} · ${advisory.model} · pid ${advisory.pid} · heartbeat ${advisory.heartbeat_at}`);
358
358
  const inactive = advisory && !isRuntimeStateStale(advisory, 60_000) ? nativeMailInactive(runtime?.pi_session, advisory.pi_session) : undefined;
359
359
  if (inactive) console.log(` ${tone("native mail inactive", "WAITING")} (bound ${inactive.bound}, live ${inactive.live})`);
360
+ const activation = advisory && !isRuntimeStateStale(advisory, 60_000) ? advisory.mail_activation : undefined;
361
+ if (activation) console.log(` native mail ${activation.transport}: ${describeActivation(activation)}${activation.differs.length ? `; differs ${activation.differs.join(", ")}` : ""}${activation.error ? `; error: ${activation.error}` : ""}`);
360
362
  rule("RESPONSIBILITIES", String(owned.length));
361
363
  if (!owned.length) console.log(` ${muted("No task currently names this seat.")}`);
362
364
  for (const task of owned.slice(0, 6)) console.log(` ${tone("●", taskPhase(task))} ${task.id} ${taskPhase(task)}${task.task.assignee === resolved ? " owner" : " coordinator"}`);
@@ -3,6 +3,16 @@ import { randomUUID } from "node:crypto";
3
3
  import { dirname, join } from "node:path";
4
4
  import { addressParts, atomicYaml, yaml } from "./core";
5
5
 
6
+ /** The values one activation attempt compared, as recorded for `status seat`; advisory and absent on legacy records. */
7
+ export type MailActivation = {
8
+ at: string;
9
+ transport: "active" | "inactive";
10
+ live: { pi_session?: string; herdr_session?: string; pane?: string };
11
+ bound: { pi_session?: string; herdr_session?: string; pane?: string; receipt: "present" | "missing" | "none" };
12
+ differs: string[];
13
+ error?: string;
14
+ };
15
+
6
16
  /**
7
17
  * A local, advisory observation of one runtime for one already-authoritative
8
18
  * Desk seat. This record is deliberately not a registry: it cannot select a
@@ -19,6 +29,7 @@ export type RuntimeState = {
19
29
  heartbeat_at: string;
20
30
  /** Live Pi session id when known; absent on legacy records. Advisory only: it never selects a recipient. */
21
31
  pi_session?: string;
32
+ mail_activation?: MailActivation;
22
33
  };
23
34
 
24
35
  export type RuntimeStateRegistration = Pick<RuntimeState, "seat" | "owner_token" | "pid" | "model" | "cwd"> & Partial<Pick<RuntimeState, "pi_session">>;
@@ -47,7 +58,8 @@ function valid(value: unknown): value is RuntimeState {
47
58
  && nonEmpty(record.cwd)
48
59
  && timestamp(record.started_at)
49
60
  && timestamp(record.heartbeat_at)
50
- && (record.pi_session === undefined || nonEmpty(record.pi_session));
61
+ && (record.pi_session === undefined || nonEmpty(record.pi_session))
62
+ && (record.mail_activation === undefined || (typeof record.mail_activation === "object" && record.mail_activation !== null));
51
63
  }
52
64
 
53
65
  function assertRegistration(value: RuntimeStateRegistration) {
@@ -162,6 +174,18 @@ export async function heartbeatRuntimeState(root: string, seat: string, ownerTok
162
174
  });
163
175
  }
164
176
 
177
+ /** Record the compared activation values only while this owner holds the record; the heartbeat and every other field are kept. */
178
+ export async function recordMailActivation(root: string, seat: string, ownerToken: string, activation: MailActivation) {
179
+ if (!nonEmpty(ownerToken)) throw new Error("Runtime state requires a non-empty owner token.");
180
+ const file = runtimeStatePath(root, seat);
181
+ return exclusive(file, async () => {
182
+ const current = await readRuntimeState(root, seat);
183
+ if (!current || current.owner_token !== ownerToken) return false;
184
+ await atomicYaml(file, { ...current, mail_activation: activation });
185
+ return true;
186
+ });
187
+ }
188
+
165
189
  /** Remove local advisory state only if this owner still owns it. */
166
190
  export async function clearRuntimeState(root: string, seat: string, ownerToken: string) {
167
191
  if (!nonEmpty(ownerToken)) throw new Error("Runtime state requires a non-empty owner token.");
package/src/tasks.ts CHANGED
@@ -13,6 +13,7 @@ export type HandoffState = "executing" | "awaiting_coordinator" | "awaiting_assi
13
13
  export type TaskHandoff = { state: HandoffState; evidence: string; updated_at: string; submitted_phase?: TaskPhase };
14
14
  export type DoneWhen = { text: string; proof?: string };
15
15
  export type GovernedBase = { coordinator: string; branch: string; commit: string };
16
+ export type LineageRef = { ref: string; commit: string; reason?: string };
16
17
  /** An append-only accountability decision; prior coordinator evidence remains untouched. */
17
18
  export type CoordinatorTransfer = {
18
19
  from: string;
@@ -21,6 +22,8 @@ export type CoordinatorTransfer = {
21
22
  /** Required when main executes the transfer; references an operator@desk message. */
22
23
  authorization?: string;
23
24
  exact_head: { branch: string; commit: string };
25
+ /** The driver ref used for the lineage check; HEAD means the recorded branch no longer resolved. */
26
+ lineage_ref?: LineageRef;
24
27
  effective_at: string;
25
28
  };
26
29
  export type TerminalDisposition = { kind: "superseded" | "rejected"; by: string; at: string; reason: string };
@@ -117,6 +120,23 @@ async function writeTask(root: string, projectName: string, id: string, task: Ta
117
120
 
118
121
  /** Records the exact parent head for new bounded topology without rewriting legacy tasks. */
119
122
  async function governedBase(root: string, projectName: string, coordinatorAddress: string, assignee?: string): Promise<GovernedBase | undefined> {
123
+ return (await governedLineage(root, projectName, coordinatorAddress, assignee))?.base;
124
+ }
125
+
126
+ /** The recorded branch when it resolves; otherwise the driver worktree HEAD; otherwise fail closed. */
127
+ async function driverLineageRef(driver: Seat): Promise<LineageRef> {
128
+ const resolveRef = (ref: string) => runOutput(["git", "-C", driver.worktree, "rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
129
+ try { return { ref: driver.branch, commit: await resolveRef(driver.branch) }; }
130
+ catch {
131
+ if (!await exists(driver.worktree)) {
132
+ throw new Error(`Driver ${driver.address} branch ${driver.branch} no longer resolves and its worktree ${driver.worktree} is missing; cannot verify lineage.`);
133
+ }
134
+ try { return { ref: "HEAD", commit: await resolveRef("HEAD"), reason: `recorded branch ${driver.branch} no longer resolves; used worktree HEAD` }; }
135
+ catch { throw new Error(`Driver ${driver.address} branch ${driver.branch} no longer resolves and worktree ${driver.worktree} has no valid HEAD.`); }
136
+ }
137
+ }
138
+
139
+ async function governedLineage(root: string, projectName: string, coordinatorAddress: string, assignee?: string): Promise<{ base: GovernedBase; lineage: LineageRef } | undefined> {
120
140
  if (!assignee) return undefined;
121
141
  const config = await project(root, projectName);
122
142
  if (!config.repository) return undefined;
@@ -146,9 +166,10 @@ async function governedBase(root: string, projectName: string, coordinatorAddres
146
166
  throw new Error(`Task coordinator ${coordinator.address} must be main or a named coordinator.`);
147
167
  }
148
168
  const commit = await runOutput(["git", "-C", coordinator.worktree, "rev-parse", branch]);
149
- const common = await runOutput(["git", "-C", driver.worktree, "merge-base", commit, driver.branch]);
169
+ const lineage = await driverLineageRef(driver);
170
+ const common = await runOutput(["git", "-C", driver.worktree, "merge-base", commit, lineage.commit]);
150
171
  if (common !== commit) throw new Error(`Driver ${driver.address} is not based on the exact coordinator head ${commit}.`);
151
- return { coordinator: coordinator.address, branch, commit };
172
+ return { base: { coordinator: coordinator.address, branch, commit }, lineage };
152
173
  }
153
174
 
154
175
  /**
@@ -390,8 +411,9 @@ export async function transfer(root: string, projectName: string, id: string, ar
390
411
 
391
412
  // governedBase confirms main/named-coordinator identity, exact branch head,
392
413
  // and the driver's descendant relationship before accountability changes.
393
- const base = await governedBase(root, projectName, target, task.assignee);
394
- if (!base) throw new Error(`Task ${id} needs an assigned driver and governed repository before transfer.`);
414
+ const governed = await governedLineage(root, projectName, target, task.assignee);
415
+ const base = governed?.base;
416
+ if (!governed || !base) throw new Error(`Task ${id} needs an assigned driver and governed repository before transfer.`);
395
417
  const authorization = actor === main ? required(words(args, "--authorization"), "an owner-authorized topology plan") : undefined;
396
418
  if (authorization) await operatorAuthorization(root, authorization, main, {
397
419
  schema: "atdd-workflow/task-transfer-authorization/v1",
@@ -408,6 +430,7 @@ export async function transfer(root: string, projectName: string, id: string, ar
408
430
  reason: required(words(args, "--reason"), "--reason"),
409
431
  ...(authorization ? { authorization } : {}),
410
432
  exact_head: { branch: base.branch, commit: base.commit },
433
+ lineage_ref: governed.lineage,
411
434
  effective_at: now(),
412
435
  };
413
436
  task.coordinator = target;