@afokapu/atdd-flow 0.3.69 → 0.3.71

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
@@ -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
 
@@ -366,9 +366,9 @@ const sessionUnsetOrEqual = (runtime: Seat["runtime"], piSession?: string, piSes
366
366
  const recovery = (root: string, seat: string, mode: RecoveryMode, session?: string) => recoveryFor(root, seat, mode, session).catch(() => undefined);
367
367
 
368
368
  /** Desk mail is authoritative; the queue is a bounded delivery index and fs.watch only shortens latency. */
369
- export default function (pi: ExtensionAPI) {
370
- const root = process.env.ATDD_WORKFLOW_ROOT;
371
- const seat = process.env.ATDD_WORKFLOW_SEAT;
369
+ export default function (pi: ExtensionAPI, environment: NodeJS.ProcessEnv = process.env) {
370
+ const root = environment.ATDD_WORKFLOW_ROOT;
371
+ const seat = environment.ATDD_WORKFLOW_SEAT;
372
372
  if (!root || !seat) return;
373
373
  let runtime: ReturnType<typeof createPiRuntime> | undefined;
374
374
  let handoverPrompted = false;
@@ -395,7 +395,7 @@ export default function (pi: ExtensionAPI) {
395
395
  const threshold = await contextHandoverThreshold(root);
396
396
  if (percent < threshold) return;
397
397
  handoverPrompted = true;
398
- const bundle = await recovery(root, seat, "threshold", process.env.ATDD_FLOW_PI_SESSION);
398
+ const bundle = await recovery(root, seat, "threshold", environment.ATDD_FLOW_PI_SESSION);
399
399
  pi.sendMessage({
400
400
  customType: "atdd-flow-context-handover-replacement",
401
401
  content: handoverThresholdInstruction(percent, threshold, bundle),
@@ -404,7 +404,7 @@ export default function (pi: ExtensionAPI) {
404
404
  } catch { /* Desk policy is re-read on the next safe boundary. */ }
405
405
  });
406
406
  pi.on("turn_end", async (_event, ctx) => {
407
- const sourceSession = process.env.ATDD_FLOW_PI_SESSION;
407
+ const sourceSession = environment.ATDD_FLOW_PI_SESSION;
408
408
  if (!sourceSession) return;
409
409
  try {
410
410
  const request = await readHandoverReplacement(root, seat);
@@ -419,13 +419,13 @@ export default function (pi: ExtensionAPI) {
419
419
  });
420
420
  pi.on("session_start", async (_event, ctx) => {
421
421
  const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string | undefined } }).sessionManager;
422
- const liveSession = process.env.ATDD_FLOW_PI_SESSION ?? sessionManager?.getSessionId?.();
422
+ const liveSession = environment.ATDD_FLOW_PI_SESSION ?? sessionManager?.getSessionId?.();
423
423
  // One display-only notice per session: a refused, timed-out or failed activation must never be silent, and never wakes the agent.
424
424
  let noticed = false;
425
425
  const noticeActivation = async (transport: "active" | "inactive", error?: string) => {
426
426
  if (noticed) return;
427
427
  noticed = true;
428
- 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 };
428
+ const live = { pi_session: liveSession, herdr_session: environment.ATDD_FLOW_HERDR_SESSION ?? environment.HERDR_SESSION, pane: environment.ATDD_FLOW_HERDR_PANE ?? environment.HERDR_PANE_ID };
429
429
  const activation = await diagnoseMailActivation(root, seat, live, transport, error).catch(() => undefined);
430
430
  if (!activation) return;
431
431
  if (runtime) await recordMailActivation(root, seat, runtime.ownerToken, activation).catch(() => undefined);
@@ -449,11 +449,11 @@ export default function (pi: ExtensionAPI) {
449
449
  });
450
450
  } });
451
451
  await runtime.start();
452
- const piSession = process.env.ATDD_FLOW_PI_SESSION;
453
- const herdrSession = process.env.ATDD_FLOW_HERDR_SESSION;
454
- const pane = process.env.ATDD_FLOW_HERDR_PANE;
452
+ const piSession = environment.ATDD_FLOW_PI_SESSION;
453
+ const herdrSession = environment.ATDD_FLOW_HERDR_SESSION;
454
+ const pane = environment.ATDD_FLOW_HERDR_PANE;
455
455
  if (!piSession && !herdrSession && !pane) {
456
- if (await hostAttachedActivation({ root, seat, herdrSession: process.env.HERDR_SESSION, pane: process.env.HERDR_PANE_ID, piSession: sessionManager?.getSessionId?.(), piSessionPath: sessionManager?.getSessionFile?.() })) {
456
+ if (await hostAttachedActivation({ root, seat, herdrSession: environment.HERDR_SESSION, pane: environment.HERDR_PANE_ID, piSession: sessionManager?.getSessionId?.(), piSessionPath: sessionManager?.getSessionFile?.() })) {
457
457
  hostActivatedAt = Date.now();
458
458
  if (await runtime.activate()) {
459
459
  const bundle = await recovery(root, seat, "start", sessionManager?.getSessionId?.());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.69",
3
+ "version": "0.3.71",
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
@@ -34,7 +34,7 @@ Usage:
34
34
  atdd-flow desk migrate
35
35
  atdd-flow project init <project>
36
36
  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]
37
+ atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary [--retain-large-primary-file <path>:<sha256>:<bytes>:<mode> ...]] [--dry-run]
38
38
  atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>
39
39
  atdd-flow spawn <project> <role> <name> [--worktree <path>] [--branch <branch>] [--agent <legacy-executable>]
40
40
  atdd-flow bind <address> [--application <application>] --address <native-address> [--session <name>] [--agent <executable>] [--worktree <path>] [--wake host|native]
@@ -48,7 +48,7 @@ Usage:
48
48
  atdd-flow checkpoint <address> --summary <text> --next <text> [--status active|standby|blocked|complete|unverified]
49
49
  atdd-flow handover <address> --file <path> [--check]
50
50
  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>
51
+ 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
52
  atdd-flow handover resumed <address>
53
53
  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
54
  atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>
@@ -115,7 +115,7 @@ async function main() {
115
115
  if (rest[0] === "configure") return configureProject(root, required(rest[1], "project"), rest.slice(2));
116
116
  if (rest[0] === "migrate-vcs") return migrateProjectVcs(root, required(rest[1], "project"), rest.slice(2));
117
117
  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>`.");
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 [--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
119
  },
120
120
  spawn: () => spawn(root, required(rest[0], "project"), required(rest[1], "role"), required(rest[2], "name"), rest.slice(3)),
121
121
  bind: () => bind(root, required(rest[0], "address"), rest.slice(1)),
@@ -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";
@@ -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;
package/src/reconcile.ts CHANGED
@@ -20,6 +20,7 @@ export type ReconcileOptions = {
20
20
  maxNudges?: number;
21
21
  inspect?: (locator: HerdrLocator, seat: string) => Promise<string>;
22
22
  wake?: (locator: HerdrLocator, notice: string) => Promise<void>;
23
+ warn?: (message: string) => void;
23
24
  };
24
25
 
25
26
  type Attempt = { schema: "atdd-flow/continuation-attempt/v1"; key: string; at: string; count: number };
@@ -46,13 +47,17 @@ async function inspectHerdr(locator: HerdrLocator, address: string) {
46
47
  /** Discover durable seat identity from the existing Desk directories.
47
48
  * No second fleet registry is created or synchronized.
48
49
  */
49
- async function seats(root: string): Promise<Seat[]> {
50
+ async function seats(root: string, warn: (message: string) => void): Promise<Seat[]> {
50
51
  const found: Seat[] = [];
51
52
  for (const project of await readdir(paths(root).work, { withFileTypes: true })) {
52
53
  if (!project.isDirectory()) continue;
53
54
  for (const local of await readdir(paths(root).seats(project.name), { withFileTypes: true }).catch(() => [])) {
54
55
  if (!local.isDirectory()) continue;
55
- found.push(await readYaml<Seat>(join(paths(root).seats(project.name), local.name, "seat.yaml")));
56
+ const folder = join(paths(root).seats(project.name), local.name);
57
+ // A completely empty directory (hidden entries included) is a ghost, not a seat: skip it once, never delete it.
58
+ // Any directory with content but no seat.yaml still fails closed in readYaml.
59
+ if ((await readdir(folder)).length === 0) { warn(`Skipping empty seat directory without a seat record: ${folder}`); continue; }
60
+ found.push(await readYaml<Seat>(join(folder, "seat.yaml")));
56
61
  }
57
62
  }
58
63
  return found;
@@ -143,7 +148,7 @@ async function reconcileSeat(root: string, owner: Seat, config: Settings): Promi
143
148
  export async function reconcileOnce(root: string, options: ReconcileOptions = {}): Promise<Reconciliation[]> {
144
149
  const config = settings(options);
145
150
  const results: Reconciliation[] = [];
146
- for (const owner of await seats(root)) {
151
+ for (const owner of await seats(root, options.warn ?? ((message) => process.stderr.write(`warning: ${message}\n`)))) {
147
152
  const result = await reconcileSeat(root, owner, config);
148
153
  if (result) results.push(result);
149
154
  }
@@ -1,3 +1,10 @@
1
+ // URN: component:flow:e010:runtime-state:backend:application
2
+ // Tested-By:
3
+ // - test:flow:inactive-mail-notice-diagnosable
4
+ // - test:flow:relaunched-seat-mail-activation
5
+ // Runtime: bun
6
+ // Purpose: Record and read advisory seat runtime state.
7
+
1
8
  import { mkdir, readFile, rm, stat, unlink } from "node:fs/promises";
2
9
  import { randomUUID } from "node:crypto";
3
10
  import { dirname, join } from "node:path";
@@ -0,0 +1,164 @@
1
+ // URN: component:flow:e021:stale-main-recovery:backend:integration
2
+ // Tested-By:
3
+ // - test:atdd-flow:stale-main-binding-recovery
4
+ // - test:atdd-flow:stale-main-recovery-resumed
5
+ // Runtime: bun
6
+ // Purpose: Evidence rules for operator-authorized stale-main Pi binding recovery.
7
+
8
+ import { createHash } from "node:crypto";
9
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
10
+ import { basename, dirname, join } from "node:path";
11
+ import { type Runtime, type Seat, yaml } from "./core";
12
+ import type { HandoverReplacementRequest, LiveSourceProof } from "./handover";
13
+
14
+ /**
15
+ * Operator-authorized recovery of a main seat whose recorded Pi is stale while a
16
+ * newer Pi holds the exact canonical pane. The request names both sources and the
17
+ * stale launch receipt, archived by content hash.
18
+ */
19
+ export type StaleMainRecovery = {
20
+ canonical_source: { pi_session: string; pi_session_path: string; herdr_session: string; pane: string; cwd: string; launch_receipt: string };
21
+ prior_receipt: { path: string; sha256: string; archive: string };
22
+ /** A superseded active request that no longer matches the recorded runtime, preserved by hash. */
23
+ prior_request?: { path: string; sha256: string; archive: string };
24
+ live_source: LiveSourceProof;
25
+ };
26
+
27
+ type Source = HandoverReplacementRequest["source"];
28
+ type Target = HandoverReplacementRequest["target"];
29
+ type PriorReceipt = { schema?: string; seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string };
30
+
31
+ const sha256 = (content: string | Uint8Array) => createHash("sha256").update(content).digest("hex");
32
+
33
+ /** A complete recorded herdr runtime with a receipt, or no recovery is possible. */
34
+ function completeRuntime(runtime: Runtime | undefined) {
35
+ const locator = runtime?.addresses.herdr;
36
+ if (runtime?.application !== "herdr" || typeof locator === "string" || !locator
37
+ || !runtime.pi_session || !runtime.pi_session_path || !runtime.launch_receipt) {
38
+ throw new Error("Stale main recovery requires a complete canonical main runtime receipt.");
39
+ }
40
+ return { locator, session: runtime.pi_session, sessionPath: runtime.pi_session_path, receipt: runtime.launch_receipt };
41
+ }
42
+
43
+ /** The stale source is the recorded runtime in the exact target pane; the live source must be a different Pi. */
44
+ export function staleCanonicalSource(record: Seat, source: Source, target: Target): StaleMainRecovery["canonical_source"] {
45
+ const { locator, session, sessionPath, receipt } = completeRuntime(record.runtime);
46
+ if (locator.session !== target.herdr_session || locator.pane !== target.pane) {
47
+ throw new Error("Stale main recovery target must be the recorded canonical Herdr placement.");
48
+ }
49
+ if (session === source.pi_session || sessionPath === source.pi_session_path) {
50
+ throw new Error("Stale main recovery requires a live source that differs from the recorded runtime; use the ordinary Path-A replacement.");
51
+ }
52
+ return { pi_session: session, pi_session_path: sessionPath, herdr_session: locator.session, pane: locator.pane, cwd: target.cwd, launch_receipt: receipt };
53
+ }
54
+
55
+ /** The archived receipt must name exactly the recorded canonical seat, session, path and placement. */
56
+ export function receiptProvesCanonical(receipt: PriorReceipt, seat: string, canonical: StaleMainRecovery["canonical_source"]) {
57
+ return receipt.schema === "atdd-flow/pi-runtime-launch-receipt/v1" && receipt.seat === seat
58
+ && receipt.pi_session === canonical.pi_session && receipt.pi_session_path === canonical.pi_session_path
59
+ && receipt.herdr_session === canonical.herdr_session && receipt.pane === canonical.pane;
60
+ }
61
+
62
+ const archivePath = (root: string, folder: string, seat: string, hash: string) => join(root, ".atdd-flow", folder, encodeURIComponent(seat), `${hash}.yaml`);
63
+
64
+ async function publishOrVerify(file: string, bytes: string) {
65
+ await mkdir(dirname(file), { recursive: true });
66
+ try { await writeFile(file, bytes, { encoding: "utf8", flag: "wx" }); }
67
+ catch (error) { if ((error as { code?: string }).code !== "EEXIST") throw error; }
68
+ if (await readFile(file, "utf8") !== bytes) throw new Error("Stale main recovery receipt archive collides with different immutable evidence.");
69
+ }
70
+
71
+ /** Preserve the stale receipt by content hash, create-only; an existing copy must be identical. */
72
+ export async function archiveStaleReceipt(root: string, seat: string, canonical: StaleMainRecovery["canonical_source"]): Promise<StaleMainRecovery["prior_receipt"]> {
73
+ let bytes: string;
74
+ try { bytes = await readFile(canonical.launch_receipt, "utf8"); }
75
+ catch { throw new Error("Stale main recovery requires its immutable recorded launch receipt."); }
76
+ if (!receiptProvesCanonical(yaml.parse<PriorReceipt>(bytes), seat, canonical)) {
77
+ throw new Error("Stale main recovery recorded launch receipt does not prove the recorded canonical runtime.");
78
+ }
79
+ return { path: canonical.launch_receipt, ...await archiveBytes(root, "stale-main-receipt-archive", seat, bytes) };
80
+ }
81
+
82
+ async function archiveBytes(root: string, folder: string, seat: string, bytes: string) {
83
+ const hash = sha256(bytes), archive = archivePath(root, folder, seat, hash);
84
+ await publishOrVerify(archive, bytes);
85
+ return { sha256: hash, archive };
86
+ }
87
+
88
+ /**
89
+ * An existing active request may be replaced only when it no longer matches the recorded runtime;
90
+ * a request that still matches is live and must go through the ordinary flow. The old bytes are archived by hash.
91
+ */
92
+ async function archivePriorRequest(root: string, seat: string, file: string, isLive: (request: HandoverReplacementRequest) => boolean) {
93
+ let bytes: string;
94
+ try { bytes = await readFile(file, "utf8"); } catch { return undefined; }
95
+ const prior = yaml.parse<HandoverReplacementRequest>(bytes);
96
+ if (prior.seat !== seat || isLive(prior)) throw new Error("A live Path-A replacement request already exists; stale main recovery only replaces a request that no longer matches the recorded runtime.");
97
+ return { path: file, ...await archiveBytes(root, "stale-main-request-archive", seat, bytes) };
98
+ }
99
+
100
+ /** Swap the active request through a staged file so readers never observe a partial request. */
101
+ export async function replaceActiveRequest(file: string, content: string) {
102
+ const staged = `${file}.${crypto.randomUUID()}.tmp`;
103
+ try { await writeFile(staged, content, { encoding: "utf8", flag: "wx" }); await rename(staged, file); }
104
+ finally { await rm(staged, { force: true }); }
105
+ }
106
+
107
+ /** An archive file must still hash to its recorded digest. */
108
+ async function archiveIntact(prior: { sha256: string; archive: string }) {
109
+ try { return sha256(await readFile(prior.archive, "utf8")) === prior.sha256; } catch { return false; }
110
+ }
111
+
112
+ /** The archived receipt must prove the canonical source, and any archived prior request must be intact. */
113
+ export async function archivedReceiptMatches(request: HandoverReplacementRequest, recovery: StaleMainRecovery) {
114
+ try {
115
+ const bytes = await readFile(recovery.prior_receipt.archive, "utf8");
116
+ const priorRequest = !recovery.prior_request || await archiveIntact(recovery.prior_request);
117
+ return priorRequest && await archiveIntact(recovery.prior_receipt) && receiptProvesCanonical(yaml.parse<PriorReceipt>(bytes), request.seat, recovery.canonical_source);
118
+ } catch { return false; }
119
+ }
120
+
121
+ function placementMatches(runtime: Runtime | undefined, canonical: StaleMainRecovery["canonical_source"]) {
122
+ const locator = runtime?.addresses.herdr;
123
+ return runtime?.application === "herdr" && typeof locator !== "string"
124
+ && locator?.session === canonical.herdr_session && locator.pane === canonical.pane;
125
+ }
126
+
127
+ function sessionMatches(runtime: Runtime | undefined, recovery: StaleMainRecovery) {
128
+ const canonical = recovery.canonical_source;
129
+ return runtime?.pi_session === canonical.pi_session && runtime.pi_session_path === canonical.pi_session_path
130
+ && runtime.launch_receipt === canonical.launch_receipt && recovery.prior_receipt.path === runtime.launch_receipt;
131
+ }
132
+
133
+ function targetMatches(request: HandoverReplacementRequest, canonical: StaleMainRecovery["canonical_source"]) {
134
+ return canonical.cwd === request.target.cwd && canonical.herdr_session === request.target.herdr_session
135
+ && canonical.pane === request.target.pane && canonical.pi_session !== request.source.pi_session;
136
+ }
137
+
138
+ function snapshotMatches(request: HandoverReplacementRequest, live: LiveSourceProof) {
139
+ return live.authorization_hash === request.authorization.hash && live.pi_session === request.source.pi_session
140
+ && live.pi_session_path === request.source.pi_session_path && live.herdr_session === request.source.herdr_session
141
+ && live.pane === request.source.pane && live.cwd === request.target.cwd && Boolean(live.agent && live.verified_at);
142
+ }
143
+
144
+ function archiveNamed(prior: { sha256: string; archive: string } | undefined) {
145
+ return !prior || (/^[a-f0-9]{64}$/i.test(prior.sha256) && basename(prior.archive) === `${prior.sha256}.yaml`);
146
+ }
147
+
148
+ /** Request, canonical source and live-source snapshot must describe one exact target tuple. */
149
+ export function recoveryEvidenceMatches(request: HandoverReplacementRequest, recovery: StaleMainRecovery) {
150
+ return targetMatches(request, recovery.canonical_source) && snapshotMatches(request, recovery.live_source) && archiveNamed(recovery.prior_receipt) && archiveNamed(recovery.prior_request);
151
+ }
152
+
153
+ /** The recorded runtime must still be the exact stale canonical source named by the recovery request. */
154
+ export function staleRecoveryMatches(runtime: Runtime | undefined, request: HandoverReplacementRequest, recovery: StaleMainRecovery) {
155
+ return placementMatches(runtime, recovery.canonical_source) && sessionMatches(runtime, recovery) && recoveryEvidenceMatches(request, recovery);
156
+ }
157
+
158
+ /** Build the recovery record: verify the live source (injected), then archive the stale receipt and any stale active request by hash. */
159
+ export async function buildStaleMainRecovery(root: string, seat: string, intent: { record: Seat; source: Source; target: Target }, active: { file: string; isLive: (request: HandoverReplacementRequest) => boolean }, verify: (canonical: StaleMainRecovery["canonical_source"]) => Promise<LiveSourceProof>): Promise<StaleMainRecovery> {
160
+ const canonical = staleCanonicalSource(intent.record, intent.source, intent.target);
161
+ const live_source = await verify(canonical);
162
+ const prior_request = await archivePriorRequest(root, seat, active.file, active.isLive);
163
+ return { canonical_source: canonical, prior_receipt: await archiveStaleReceipt(root, seat, canonical), ...(prior_request ? { prior_request } : {}), live_source };
164
+ }
@@ -2,6 +2,7 @@
2
2
  // Tested-By:
3
3
  // - test:flow:jj-vcs-migration:E013-UNIT-001
4
4
  // - test:flow:jj-vcs-migration:E014-UNIT-001
5
+ // - test:flow:jj-vcs-migration:E017-UNIT-001
5
6
  // Runtime: bun
6
7
  // Purpose: Migrate one legacy Flow Git project to colocated JJ without losing lineage.
7
8
  //
@@ -21,7 +22,7 @@
21
22
  // state from a partial JJ conversion. The durable project VCS is written last.
22
23
 
23
24
  import { lstat, readFile, readdir, readlink, realpath, rename, rm, writeFile } from "node:fs/promises";
24
- import { join, resolve } from "node:path";
25
+ import { isAbsolute, join, normalize, resolve } from "node:path";
25
26
  import { createHash } from "node:crypto";
26
27
  import {
27
28
  type Project, type Seat, atomicYaml, exists, paths, project, readYaml, required, yaml,
@@ -37,12 +38,19 @@ type ReviewsLite = { attempts?: Array<{ delivery_head?: string }> };
37
38
  /** JJ silently skips new files above this size (snapshot.max-new-file-size default). */
38
39
  const SNAPSHOT_MAX_NEW_FILE_BYTES = 1048576;
39
40
  const DIRTY_PRIMARY_FLAG = "--allow-dirty-primary";
41
+ const RETAIN_LARGE_FLAG = "--retain-large-primary-file";
42
+ /** Plain literals (no interpolation): angle-bracket placeholders must never look like an HTML fragment. */
43
+ const SPEC_FORMAT = "<path>:<sha256>:<bytes>:<mode>";
44
+ const USAGE = "Use `atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary [--retain-large-primary-file <path>:<sha256>:<bytes>:<mode> ...]] [--dry-run]`.";
45
+
46
+ /** One operator-authorized over-limit primary file, matched against disk exactly. */
47
+ type LargeFile = { path: string; sha256: string; bytes: number; mode: string };
40
48
 
41
49
  type DiskState =
42
50
  | { kind: "absent" }
43
51
  | { kind: "file"; sha256: string; exec: boolean }
44
52
  | { kind: "symlink"; target: string };
45
- type DirtyEntry = { path: string; status: string; disk: DiskState };
53
+ type DirtyEntry = { path: string; status: string; disk: DiskState; retained?: LargeFile };
46
54
 
47
55
  export type VcsMigrationPlan = {
48
56
  schema: "atdd-flow/vcs-migration-plan/v1";
@@ -53,6 +61,7 @@ export type VcsMigrationPlan = {
53
61
  workspaces: SeatState[];
54
62
  lineage: { tasks_checked: number; reviews_checked: number };
55
63
  dirty_primary?: { entries: Array<{ path: string; status: string }> };
64
+ large_primary?: LargeFile[];
56
65
  };
57
66
 
58
67
  type PreparedMigration = {
@@ -64,6 +73,31 @@ type PreparedMigration = {
64
73
  index: Buffer | null;
65
74
  };
66
75
 
76
+ /** Parse `<path>:<sha256>:<bytes>:<mode>` from the right so the path alone may contain colons. */
77
+ function parseLargeFile(spec: string): LargeFile {
78
+ const match = spec.match(/^(.+):([0-9a-f]{64}):(\d+):([0-7]{4})$/);
79
+ if (!match) throw new Error(`${RETAIN_LARGE_FLAG} expects ${SPEC_FORMAT} (lowercase 64-hex sha256, decimal bytes, four-digit octal mode); got ${spec}.`);
80
+ const [, path, sha256, bytes, mode] = match as unknown as [string, string, string, string, string];
81
+ if (isAbsolute(path) || path.split("/").includes("..") || normalize(path) !== path || path.endsWith("/")) {
82
+ throw new Error(`${RETAIN_LARGE_FLAG} path must be a normalized repository-relative path with no '..' (outside the primary checkout is refused): ${path}`);
83
+ }
84
+ return { path, sha256, bytes: Number(bytes), mode };
85
+ }
86
+
87
+ /** Every authorization must name a path that is actually dirty in the primary checkout. */
88
+ function assertAuthorizationsDirty(authorized: LargeFile[], dirty: DirtyEntry[]) {
89
+ for (const auth of authorized) {
90
+ if (!dirty.some(entry => entry.path === auth.path)) throw new Error(`Authorized large file ${auth.path} is not a dirty primary path (it does not exist or is unchanged).`);
91
+ }
92
+ }
93
+
94
+ /** Compare one over-limit new file with its authorization; every field must match exactly. */
95
+ function assertAuthorizedLarge(auth: LargeFile, size: number, mode: string, sha256: string) {
96
+ if (size !== auth.bytes) throw new Error(`Authorized large file ${auth.path} is ${size} bytes on disk, not the authorized ${auth.bytes} bytes.`);
97
+ if (sha256 !== auth.sha256) throw new Error(`Authorized large file ${auth.path} sha256 ${sha256} does not match the authorized sha256 ${auth.sha256}.`);
98
+ if (mode !== auth.mode) throw new Error(`Authorized large file ${auth.path} has mode ${mode} on disk, not the authorized mode ${auth.mode}.`);
99
+ }
100
+
67
101
  /** Run one external VCS command without throwing so rollback can inspect failures. */
68
102
  async function command(cwd: string, executable: string, args: string[]): Promise<CommandResult> {
69
103
  const child = Bun.spawn([executable, ...args], { cwd, stdout: "pipe", stderr: "pipe" });
@@ -123,30 +157,48 @@ async function diskState(worktree: string, path: string): Promise<DiskState> {
123
157
  return { kind: "file", sha256, exec: (info.mode & 0o111) !== 0 };
124
158
  }
125
159
 
160
+ /** Classify one dirty path: retained exactly, plain, or an unauthorized over-limit refusal. */
161
+ async function classifyDirty(worktree: string, flags: string, path: string, authorized: LargeFile[]) {
162
+ const disk = await diskState(worktree, path);
163
+ const auth = authorized.find(item => item.path === path);
164
+ const info = disk.kind === "file" ? await lstat(join(worktree, path)) : null;
165
+ const isNew = flags.includes("?") || flags.includes("A");
166
+ const overLimit = isNew && info !== null && info.size > SNAPSHOT_MAX_NEW_FILE_BYTES;
167
+ const entry: DirtyEntry = { path, status: flags, disk };
168
+ if (auth) {
169
+ if (!overLimit || disk.kind !== "file" || !info) {
170
+ throw new Error(`Authorized file ${path} does not exceed the JJ snapshot size limit of ${SNAPSHOT_MAX_NEW_FILE_BYTES} bytes as a new regular file; the authorization is refused.`);
171
+ }
172
+ assertAuthorizedLarge(auth, info.size, (info.mode & 0o777).toString(8).padStart(4, "0"), disk.sha256);
173
+ entry.retained = auth;
174
+ }
175
+ const refusal = overLimit && !auth
176
+ ? `Dirty primary file ${path} (${info!.size} bytes) exceeds the JJ snapshot size limit of ${SNAPSHOT_MAX_NEW_FILE_BYTES} bytes and would not be retained; commit it, move it, ignore it, or authorize it exactly with ${RETAIN_LARGE_FLAG}.`
177
+ : undefined;
178
+ return { entry, refusal };
179
+ }
180
+
126
181
  /** Capture the primary's uncommitted paths read-only, refusing anything JJ would drop. */
127
- async function captureDirtyPrimary(worktree: string): Promise<DirtyEntry[]> {
182
+ async function captureDirtyPrimary(worktree: string, authorized: LargeFile[]): Promise<DirtyEntry[]> {
128
183
  // Raw, untrimmed: the first record's leading status space is significant.
129
184
  const status = await Bun.spawn(["git", "status", "--porcelain=v1", "-z", "--untracked-files=all", "--no-renames"], { cwd: worktree, stdout: "pipe", stderr: "pipe" });
130
185
  const [raw, failure, code] = await Promise.all([new Response(status.stdout).text(), new Response(status.stderr).text(), status.exited]);
131
186
  if (code !== 0) throw new Error(`git status failed: ${failure.trim()}`);
132
187
  const entries: DirtyEntry[] = [];
188
+ const refused: string[] = [];
133
189
  for (const record of raw.split("\0").filter(Boolean)) {
134
- const flags = record.slice(0, 2), path = record.slice(3);
135
- const disk = await diskState(worktree, path);
136
- const isNew = flags.includes("?") || flags.includes("A");
137
- if (isNew && disk.kind === "file") {
138
- const size = (await lstat(join(worktree, path))).size;
139
- if (size > SNAPSHOT_MAX_NEW_FILE_BYTES) {
140
- throw new Error(`Dirty primary file ${path} (${size} bytes) exceeds the JJ snapshot size limit of ${SNAPSHOT_MAX_NEW_FILE_BYTES} bytes and would not be retained; commit it, move it, or ignore it first.`);
141
- }
142
- }
143
- entries.push({ path, status: flags, disk });
190
+ const { entry, refusal } = await classifyDirty(worktree, record.slice(0, 2), record.slice(3), authorized);
191
+ entries.push(entry);
192
+ if (refusal) refused.push(refusal);
144
193
  }
194
+ // Report a stale authorization before an unauthorized file so the operator fixes the spec first.
195
+ assertAuthorizationsDirty(authorized, entries);
196
+ if (refused.length) throw new Error(refused[0]!);
145
197
  return entries;
146
198
  }
147
199
 
148
200
  /** Capture the exact clean Git tuple that migration must preserve or parent. */
149
- async function inspectSeat(repository: string, record: Seat, allowDirtyPrimary: boolean): Promise<{ state: SeatState; dirty: DirtyEntry[] }> {
201
+ async function inspectSeat(repository: string, record: Seat, allowDirtyPrimary: boolean, authorized: LargeFile[]): Promise<{ state: SeatState; dirty: DirtyEntry[] }> {
150
202
  const worktree = await realpath(resolve(record.worktree)).catch(() => null);
151
203
  if (!worktree) throw new Error(`Seat ${record.address} worktree does not exist: ${record.worktree}`);
152
204
  const top = await realpath(await output(worktree, "git", ["rev-parse", "--show-toplevel"]));
@@ -156,7 +208,7 @@ async function inspectSeat(repository: string, record: Seat, allowDirtyPrimary:
156
208
  if (await output(worktree, "git", ["status", "--porcelain"])) {
157
209
  if (!primary) throw new Error(`Seat ${record.address} worktree is dirty: ${worktree}`);
158
210
  if (!allowDirtyPrimary) throw new Error(`Seat ${record.address} worktree is dirty: ${worktree}. Pass ${DIRTY_PRIMARY_FLAG} to retain the primary checkout's uncommitted changes as JJ working-copy state.`);
159
- dirty = await captureDirtyPrimary(worktree);
211
+ dirty = await captureDirtyPrimary(worktree, authorized);
160
212
  }
161
213
  const branch = await output(worktree, "git", ["branch", "--show-current"]);
162
214
  if (branch !== record.branch) throw new Error(`Seat ${record.address} is checked out at ${branch || "detached HEAD"}, not ${record.branch}.`);
@@ -249,7 +301,7 @@ async function assertLineage(root: string, name: string, repository: string, sta
249
301
  }
250
302
 
251
303
  /** Build the complete deterministic migration plan before creating .jj metadata. */
252
- async function prepare(root: string, name: string, allowDirtyPrimary: boolean): Promise<PreparedMigration> {
304
+ async function prepare(root: string, name: string, allowDirtyPrimary: boolean, authorized: LargeFile[]): Promise<PreparedMigration> {
253
305
  const config = await project(root, name);
254
306
  if (vcsMode(config) !== "git") throw new Error(`Project ${name} is not a Git project.`);
255
307
  const repository = await realpath(resolve(required(config.repository, `project ${name} repository`))).catch(() => null);
@@ -259,9 +311,10 @@ async function prepare(root: string, name: string, allowDirtyPrimary: boolean):
259
311
  await assertToolVersions(repository);
260
312
  const records = await activeSeats(root, name);
261
313
  if (!records.length) throw new Error("Project has no active seats; configure vcs: jj directly before creating seats.");
262
- const inspected = await Promise.all(records.map(record => inspectSeat(repository, record, allowDirtyPrimary)));
314
+ const inspected = await Promise.all(records.map(record => inspectSeat(repository, record, allowDirtyPrimary, authorized)));
263
315
  const states = inspected.map(entry => entry.state);
264
316
  const dirty = inspected.flatMap(entry => entry.dirty);
317
+ assertAuthorizationsDirty(authorized, dirty);
265
318
  const primary = states.filter(state => state.primary);
266
319
  if (primary.length !== 1) throw new Error(`Migration requires exactly one active seat in the primary checkout; found ${primary.length}.`);
267
320
  if (new Set(states.map(state => state.worktree)).size !== states.length) throw new Error("Active Flow seats must have distinct worktree paths before migration.");
@@ -272,6 +325,8 @@ async function prepare(root: string, name: string, allowDirtyPrimary: boolean):
272
325
  const index = await readIndex(repository);
273
326
  const plan: VcsMigrationPlan = { schema: "atdd-flow/vcs-migration-plan/v1", project: name, from: "git", to: "jj", primary: primary[0]!, workspaces, lineage };
274
327
  if (dirty.length) plan.dirty_primary = { entries: dirty.map(({ path, status }) => ({ path, status })) };
328
+ const large = dirty.flatMap(entry => entry.retained ? [entry.retained] : []);
329
+ if (large.length) plan.large_primary = large;
275
330
  return { config, repository, states, plan, dirty, index };
276
331
  }
277
332
 
@@ -309,6 +364,17 @@ async function verifyDirtyPath(state: SeatState, head: string, entry: DirtyEntry
309
364
  const found = await treeEntry(state, head, entry.path);
310
365
  const retained = entry.disk.kind === "absent" ? found === null : found !== null && await entryMatches(state, entry, found);
311
366
  if (!retained) throw new Error(`Primary path ${entry.path} was not retained exactly in the JJ working copy.`);
367
+ if (entry.retained) await verifyLargeBlob(state, head, entry.retained);
368
+ }
369
+
370
+ /** Hash-preservation: the bytes stored in the JJ working-copy commit equal the authorization. */
371
+ async function verifyLargeBlob(state: SeatState, head: string, auth: LargeFile) {
372
+ const child = Bun.spawn(["git", "cat-file", "blob", `${head}:${auth.path}`], { cwd: state.worktree, stdout: "pipe", stderr: "pipe" });
373
+ const [bytes, code] = await Promise.all([new Response(child.stdout).arrayBuffer(), child.exited]);
374
+ const digest = createHash("sha256").update(new Uint8Array(bytes)).digest("hex");
375
+ if (code !== 0 || bytes.byteLength !== auth.bytes || digest !== auth.sha256) {
376
+ throw new Error(`Authorized large file ${auth.path} in the JJ working copy does not match the authorized sha256 ${auth.sha256} and ${auth.bytes} bytes.`);
377
+ }
312
378
  }
313
379
 
314
380
  /** A dirty primary must be exactly `main` plus the captured manifest, nothing more. */
@@ -399,35 +465,63 @@ async function rollback(repository: string, primary: SeatState, displaced: SeatS
399
465
  for (const state of [...displaced].reverse()) await removePartialWorkspace(repository, state);
400
466
  await rm(join(repository, ".jj"), { recursive: true, force: true }).catch(error => errors.push(String(error)));
401
467
  await removeJjRefs(repository).catch(error => errors.push(String(error)));
402
- const switched = await command(repository, "git", ["switch", primary.branch]);
403
- if (switched.code) errors.push(switched.err || switched.out);
468
+ // Skip the switch when HEAD already names the branch so rollback adds no HEAD reflog entry.
469
+ const onBranch = await command(repository, "git", ["symbolic-ref", "-q", "HEAD"]);
470
+ if (onBranch.out !== `refs/heads/${primary.branch}`) {
471
+ const switched = await command(repository, "git", ["switch", primary.branch]);
472
+ if (switched.code) errors.push(switched.err || switched.out);
473
+ }
404
474
  for (const state of displaced) errors.push(...await restoreGitWorkspace(repository, state));
405
475
  await restoreIndex(repository, index).catch(error => errors.push(String(error)));
406
476
  return errors;
407
477
  }
408
478
 
409
- /** Accept exactly `--to jj` plus at most one each of the known flags. */
479
+ /** Require exactly one `--to jj` pair and return the remaining arguments. */
480
+ function withoutTarget(args: string[]) {
481
+ const toIndex = args.indexOf("--to");
482
+ const valid = toIndex >= 0 && args[toIndex + 1] === "jj" && args.filter(arg => arg === "--to").length === 1;
483
+ if (!valid) throw new Error(USAGE);
484
+ return args.filter((_arg, index) => index !== toIndex && index !== toIndex + 1);
485
+ }
486
+
487
+ /** Parse the value after one `--retain-large-primary-file`, refusing duplicates. */
488
+ function parseRetainValue(value: string | undefined, authorized: LargeFile[]) {
489
+ if (value === undefined || value.startsWith("--")) throw new Error(USAGE);
490
+ const file = parseLargeFile(value);
491
+ if (authorized.some(item => item.path === file.path)) throw new Error(`${RETAIN_LARGE_FLAG} names ${file.path} more than once (duplicate authorization).`);
492
+ return file;
493
+ }
494
+
495
+ /** Accept exactly `--to jj`, at most one each of the known flags, and any number of distinct file authorizations. */
410
496
  function parseMigrationArgs(args: string[]) {
411
- const toIndex = args.indexOf("--to"), to = toIndex < 0 ? undefined : args[toIndex + 1];
412
- const flags = ["--dry-run", DIRTY_PRIMARY_FLAG];
413
- const rest = args.filter((_arg, index) => index !== toIndex && index !== toIndex + 1);
414
- const valid = toIndex >= 0 && to === "jj"
415
- && args.filter(arg => arg === "--to").length === 1
416
- && rest.every(arg => flags.includes(arg))
417
- && new Set(rest).size === rest.length;
418
- if (!valid) throw new Error("Use `atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary] [--dry-run]`.");
419
- return { dryRun: rest.includes("--dry-run"), allowDirtyPrimary: rest.includes(DIRTY_PRIMARY_FLAG) };
497
+ const rest = withoutTarget(args);
498
+ const flags = new Set<string>();
499
+ const authorized: LargeFile[] = [];
500
+ for (let index = 0; index < rest.length; index += 1) {
501
+ const arg = rest[index]!;
502
+ if (arg === RETAIN_LARGE_FLAG) {
503
+ authorized.push(parseRetainValue(rest[index + 1], authorized));
504
+ index += 1;
505
+ } else if (["--dry-run", DIRTY_PRIMARY_FLAG].includes(arg) && !flags.has(arg)) flags.add(arg);
506
+ else throw new Error(USAGE);
507
+ }
508
+ if (authorized.length && !flags.has(DIRTY_PRIMARY_FLAG)) throw new Error(`${RETAIN_LARGE_FLAG} requires ${DIRTY_PRIMARY_FLAG}.`);
509
+ return { dryRun: flags.has("--dry-run"), allowDirtyPrimary: flags.has(DIRTY_PRIMARY_FLAG), authorized };
420
510
  }
421
511
 
422
512
  /** Execute the only supported legacy conversion: Git project to colocated JJ. */
423
513
  export async function migrateProjectVcs(root: string, name: string, args: string[]) {
424
- const { dryRun, allowDirtyPrimary } = parseMigrationArgs(args);
425
- const prepared = await prepare(root, name, allowDirtyPrimary);
514
+ const { dryRun, allowDirtyPrimary, authorized } = parseMigrationArgs(args);
515
+ const prepared = await prepare(root, name, allowDirtyPrimary, authorized);
426
516
  if (dryRun) { process.stdout.write(yaml.print(prepared.plan)); return; }
427
517
 
428
518
  const displaced: SeatState[] = [];
429
519
  try {
430
- await output(prepared.repository, "jj", ["git", "init", "--colocate"]);
520
+ // The raised snapshot limit is scoped to this one command and equals the largest authorized file;
521
+ // preflight proved every over-limit new file is authorized and verification rejects any extra path.
522
+ const limit = Math.max(0, ...(prepared.plan.large_primary ?? []).map(file => file.bytes));
523
+ const config = limit > SNAPSHOT_MAX_NEW_FILE_BYTES ? [`--config=snapshot.max-new-file-size=${limit}`] : [];
524
+ await output(prepared.repository, "jj", [...config, "git", "init", "--colocate"]);
431
525
  await assertRepository("jj", prepared.repository);
432
526
  await verifyPrimary(prepared.plan.primary, prepared.dirty);
433
527
  for (const state of prepared.plan.workspaces) {