@afokapu/atdd-flow 0.3.81 → 0.3.83

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.
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, join } from "node:path";
4
+ import { isDeepStrictEqual } from "node:util";
4
5
  import { parse, stringify } from "yaml";
5
6
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
7
  import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsRuntimeState, recordMailActivation, registerRuntimeState, runtimeOwnerToken, withOwnedRuntimeState } from "../../src/runtime-state";
@@ -413,6 +414,23 @@ function unmatchedWakeRequest(branch: TranscriptEntry[], identity: WakeIdentity)
413
414
  /** The recovery bundle is advisory; any failure falls back to the plain prompt. */
414
415
  const recovery = (root: string, seat: string, mode: RecoveryMode, session?: string) => recoveryFor(root, seat, mode, session).catch(() => undefined);
415
416
 
417
+ type ReceiptWithLaunchRequest = { handover_replacement?: { request?: unknown } };
418
+
419
+ /** The request recorded in this seat's own launcher receipt, or undefined when the seat, receipt or embed is absent or unreadable. */
420
+ async function embeddedLaunchRequest(root: string, seat: string) {
421
+ try {
422
+ const receiptPath = (await readYaml<Seat>(paths(root).seatFile(seat))).runtime?.launch_receipt;
423
+ if (!receiptPath) return undefined;
424
+ return (parse(await readFile(receiptPath, "utf8")) as ReceiptWithLaunchRequest | null)?.handover_replacement?.request;
425
+ } catch { return undefined; }
426
+ }
427
+
428
+ /** True when this Pi was launched by the active request: its own receipt embeds exactly that request, so it is the consumed successor, not a source to shut down. */
429
+ async function launchedByRequest(root: string, seat: string, request: unknown) {
430
+ const embedded = await embeddedLaunchRequest(root, seat);
431
+ return embedded !== undefined && isDeepStrictEqual(embedded, request);
432
+ }
433
+
416
434
  /** Desk mail is authoritative; the queue is a bounded delivery index and fs.watch only shortens latency. */
417
435
  export default function (pi: ExtensionAPI, environment: NodeJS.ProcessEnv = process.env) {
418
436
  const root = environment.ATDD_WORKFLOW_ROOT;
@@ -456,7 +474,7 @@ export default function (pi: ExtensionAPI, environment: NodeJS.ProcessEnv = proc
456
474
  if (!sourceSession) return;
457
475
  try {
458
476
  const request = await readHandoverReplacement(root, seat);
459
- if (request.source.pi_session !== sourceSession) return;
477
+ if (request.source.pi_session !== sourceSession || await launchedByRequest(root, seat, request)) return;
460
478
  pi.sendMessage({
461
479
  customType: "atdd-flow-context-handover-replacement",
462
480
  content: `SYSTEM: Validated Path-A request for ${seat} observed. The successor must not start until this Pi has shut down and Herdr reports the exact source pane released.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.81",
3
+ "version": "0.3.83",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,54 @@
1
+ // URN: component:flow:terminal-seat-retirement:retire-merge-proof:backend:integration
2
+ // Tested-By:
3
+ // - test:atdd-flow:terminal-seat-retire-merge-proof
4
+ // Runtime: bun
5
+ // Purpose: Independent merge proof for retiring a driver that worktree finish refuses.
6
+
7
+ import { type Seat, runOutput } from "./core";
8
+ import { isAncestor } from "./vcs";
9
+
10
+ type PullRequest = { state?: string; headRefOid?: string; mergeCommit?: { oid?: string } | null };
11
+
12
+ /** A clean worktree is required; only generated hook files may be untracked, as `worktree finish` allows. */
13
+ async function worktreeIsClean(worktree: string) {
14
+ const status = await runOutput(["git", "-C", worktree, "status", "--porcelain"]);
15
+ return status.split("\n").filter(Boolean).every((line) => line.endsWith(" .githooks/") || line.endsWith(" .githooks"));
16
+ }
17
+
18
+ /** No uncommitted work, and the worktree is on the exact branch tip being proven merged. */
19
+ async function worktreeIsAtTip(worktree: string, tip: string) {
20
+ return await worktreeIsClean(worktree) && await runOutput(["git", "-C", worktree, "rev-parse", "HEAD"]) === tip;
21
+ }
22
+
23
+ /** Merged PRs for the branch, from GitHub; an unavailable or malformed answer is no evidence. */
24
+ async function mergedPullRequests(repository: string, branch: string): Promise<PullRequest[]> {
25
+ try {
26
+ const listed = JSON.parse(await runOutput(["gh", "pr", "list", "--head", branch, "--state", "merged", "--limit", "20", "--json", "state,headRefOid,mergeCommit"], repository));
27
+ return Array.isArray(listed) ? listed : [];
28
+ } catch { return []; }
29
+ }
30
+
31
+ /** A merged PR whose head is exactly the branch tip and whose merge (or squash) commit is on the primary branch. */
32
+ async function squashMergeProof(repository: string, branch: string, tip: string, primary: string) {
33
+ for (const pull of await mergedPullRequests(repository, branch)) {
34
+ const merge = pull.mergeCommit?.oid;
35
+ if (pull.state === "MERGED" && pull.headRefOid === tip && merge && await isAncestor(repository, merge, primary)) return merge;
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ /**
41
+ * Proves a retiring driver has no unmerged work without `worktree finish`: the worktree is clean and at the branch tip, and that tip
42
+ * is reachable from the primary branch or is the head of a merged PR whose merge commit is on it. Returns the evidence, or undefined.
43
+ * Nothing is removed or deleted: the caller retains the worktree and branch.
44
+ */
45
+ export async function terminalMergeProof(repository: string, owner: Seat): Promise<string | undefined> {
46
+ try {
47
+ const primary = await runOutput(["git", "-C", repository, "branch", "--show-current"]);
48
+ const tip = await runOutput(["git", "-C", repository, "rev-parse", "--verify", `refs/heads/${owner.branch}`]);
49
+ if (!primary || !await worktreeIsAtTip(owner.worktree, tip)) return undefined;
50
+ if (await isAncestor(repository, tip, primary)) return `branch tip ${tip} is reachable from ${primary}`;
51
+ const merge = await squashMergeProof(repository, owner.branch, tip, primary);
52
+ return merge ? `branch tip ${tip} is the head of a merged PR whose merge commit ${merge} is on ${primary}` : undefined;
53
+ } catch { return undefined; }
54
+ }
@@ -0,0 +1,49 @@
1
+ // URN: component:flow:terminal-seat-retirement:retire-merge-proof:backend:integration
2
+ // Tested-By:
3
+ // - test:atdd-flow:terminal-seat-retire-merge-proof
4
+ // Runtime: bun
5
+ // Purpose: Independent merge proof for retiring a terminal driver that worktree finish refuses.
6
+
7
+ import { type Seat, runOutput } from "./core";
8
+ import { isAncestor } from "./vcs";
9
+
10
+ type PullRequest = { state?: string; headRefOid?: string; mergeCommit?: { oid?: string } | null };
11
+
12
+ /** A clean worktree is required; only generated hook files may be untracked, as `worktree finish` allows. */
13
+ async function worktreeIsClean(worktree: string) {
14
+ const status = await runOutput(["git", "-C", worktree, "status", "--porcelain"]);
15
+ return status.split("\n").filter(Boolean).every((line) => line.endsWith(" .githooks/") || line.endsWith(" .githooks"));
16
+ }
17
+
18
+ /** Merged PRs for the branch, from GitHub; an unavailable or malformed answer is no evidence. */
19
+ async function mergedPullRequests(repository: string, branch: string): Promise<PullRequest[]> {
20
+ try {
21
+ const listed = JSON.parse(await runOutput(["gh", "pr", "list", "--head", branch, "--state", "merged", "--limit", "20", "--json", "state,headRefOid,mergeCommit"], repository));
22
+ return Array.isArray(listed) ? listed : [];
23
+ } catch { return []; }
24
+ }
25
+
26
+ /** A merged PR whose head is exactly the branch tip and whose merge (or squash) commit is on the primary branch. */
27
+ async function squashMergeProof(repository: string, branch: string, tip: string, primary: string) {
28
+ for (const pull of await mergedPullRequests(repository, branch)) {
29
+ const merge = pull.mergeCommit?.oid;
30
+ if (pull.state === "MERGED" && pull.headRefOid === tip && merge && await isAncestor(repository, merge, primary)) return merge;
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ /**
36
+ * Proves a retiring driver has no unmerged work without `worktree finish`: the worktree is clean and at the branch tip, and that tip
37
+ * is reachable from the primary branch or is the head of a merged PR whose merge commit is on it. Returns the evidence, or undefined.
38
+ * Nothing is removed or deleted: the caller retains the worktree and branch.
39
+ */
40
+ export async function terminalMergeProof(repository: string, owner: Seat): Promise<string | undefined> {
41
+ try {
42
+ const primary = await runOutput(["git", "-C", repository, "branch", "--show-current"]);
43
+ const tip = await runOutput(["git", "-C", repository, "rev-parse", "--verify", `refs/heads/${owner.branch}`]);
44
+ if (!primary || !await worktreeIsClean(owner.worktree) || await runOutput(["git", "-C", owner.worktree, "rev-parse", "HEAD"]) !== tip) return undefined;
45
+ if (await isAncestor(repository, tip, primary)) return `branch tip ${tip} is reachable from ${primary}`;
46
+ const merge = await squashMergeProof(repository, owner.branch, tip, primary);
47
+ return merge ? `branch tip ${tip} is the head of a merged PR whose merge commit ${merge} is on ${primary}` : undefined;
48
+ } catch { return undefined; }
49
+ }
package/src/tasks.ts CHANGED
@@ -3,6 +3,7 @@ import { basename, dirname, join, resolve } from "node:path";
3
3
  import {
4
4
  type Checkpoint, type Seat, atomicYaml, canonicalAddress, exists, now, paths, project, readYaml, required, runOutput, seat, taskId, values, words,
5
5
  } from "./core";
6
+ import { terminalMergeProof } from "./retire-merge-proof";
6
7
  import { operatorAuthorizationPayload } from "./task-supersede-authorization";
7
8
  import { branchCommit, currentCommit, isAncestor, vcsMode, type VcsMode } from "./vcs";
8
9
 
@@ -258,6 +259,21 @@ async function retirementBun(root: string, projectName: string) {
258
259
  return [required(Bun.which("atdd-bun") ?? undefined, "atdd-bun in the primary checkout node_modules/.bin or on PATH")];
259
260
  }
260
261
 
262
+ /**
263
+ * ATDD Bun removes a clean merged worktree and its branch. When it refuses (a squash-merged or post-JJ branch it cannot prove
264
+ * merged), retirement proceeds only on independent merge proof; the worktree and branch are then retained and the refusal stands otherwise.
265
+ */
266
+ async function finishOrProveMerged(root: string, projectName: string, owner: Seat) {
267
+ const config = await project(root, projectName);
268
+ try {
269
+ return await runOutput([...await retirementBun(root, projectName), "worktree", "finish", "--delete-branch", "--vcs", vcsMode(config)], owner.worktree);
270
+ } catch (error) {
271
+ const proof = config.repository && await exists(owner.worktree) ? await terminalMergeProof(config.repository, owner) : undefined;
272
+ if (!proof) throw error;
273
+ return `Worktree finish refused (${(error as Error).message}); merge proof: ${proof}; worktree ${owner.worktree} and branch ${owner.branch} retained.`;
274
+ }
275
+ }
276
+
261
277
  async function retireDriver(root: string, projectName: string, assignee: string, id: string, allowAbsentWorktree = false, allowPrimaryCheckout = false) {
262
278
  const unfinished = (await allTasks(root, projectName))
263
279
  .filter((entry) => entry.id !== id && (entry.task.assignee === assignee || entry.task.coordinator === assignee) && !terminal(entry.task))
@@ -271,7 +287,7 @@ async function retireDriver(root: string, projectName: string, assignee: string,
271
287
  const primaryCheckout = allowPrimaryCheckout && primary && await canonicalPath(owner.worktree) === await canonicalPath(primary);
272
288
  const summary = (primaryCheckout ? `Primary checkout ${owner.worktree} retained; retired without worktree finish.` : undefined)
273
289
  ?? (allowAbsentWorktree ? await absentUnregisteredWorktree(root, projectName, owner) : undefined)
274
- ?? await runOutput([...await retirementBun(root, projectName), "worktree", "finish", "--delete-branch", "--vcs", vcsMode(await project(root, projectName))], owner.worktree);
290
+ ?? await finishOrProveMerged(root, projectName, owner);
275
291
  owner.retired = { task: `${projectName}/${id}`, completed_at: now(), summary };
276
292
  await atomicYaml(paths(root).seatFile(assignee), owner);
277
293
  const checkpoint: Checkpoint = {