@afokapu/atdd-flow 0.3.68 → 0.3.69

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.68",
3
+ "version": "0.3.69",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -21,7 +21,7 @@ import { linearCommand } from "./linear-cli";
21
21
  import * as ownerAlerts from "./owner-escalations";
22
22
  import { recoveryFor } from "./recovery-bundle";
23
23
  import { reconcileOnce } from "./reconcile";
24
- import { recordHandover, requestHandoverReplacement, resumedHandoverReplacement, showHandover } from "./handover";
24
+ import { recordHandover, requestHandoverReplacement, resumedHandover, showHandover } from "./handover";
25
25
  import { migrateProjectVcs } from "./vcs-migration";
26
26
 
27
27
  const usage = `atdd-flow — filesystem-first agent seats and tasks
@@ -140,7 +140,7 @@ async function main() {
140
140
  checkpoint: () => checkpoint(root, required(rest[0], "address"), rest.slice(1)),
141
141
  handover: () => rest[0] === "show" ? showHandover(root, required(rest[1], "address"))
142
142
  : rest[0] === "replace" ? requestHandoverReplacement(root, required(rest[1], "address"), rest.slice(2))
143
- : rest[0] === "resumed" ? resumedHandoverReplacement(root, required(rest[1], "address"))
143
+ : rest[0] === "resumed" ? resumedHandover(root, required(rest[1], "address"))
144
144
  : recordHandover(root, required(rest[0], "address"), rest.slice(1)),
145
145
  task: async () => {
146
146
  const [subcommand, projectName, taskId, ...tail] = rest;
package/src/handover.ts CHANGED
@@ -12,6 +12,7 @@ import { type Runtime, type Seat, atomicYaml, canonicalAddress, desk, exists, ha
12
12
  import { type Task, terminal } from "./tasks";
13
13
  import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizations";
14
14
  import { exactFailedAdoptionProof } from "./exact-adoption-recovery";
15
+ import { isExactOperatorSeat, operatorActivation, operatorRelaunchSection } from "./operator-launch";
15
16
  import { readRuntimeState } from "./runtime-state";
16
17
 
17
18
  export type Handover = {
@@ -96,10 +97,11 @@ export async function recordHandover(root: string, address: string, args: string
96
97
 
97
98
  export async function showHandover(root: string, address: string) {
98
99
  const resolved = await canonicalAddress(root, address);
99
- await seat(root, resolved);
100
+ const seatRecord = await seat(root, resolved);
100
101
  const record = await recorded(root, resolved);
101
102
  if (!record) throw new Error(`No handover is recorded for ${resolved}.`);
102
- process.stdout.write(record.text.endsWith("\n") ? record.text : `${record.text}\n`);
103
+ const relaunch = await isExactOperatorSeat(root, seatRecord, resolved) ? await operatorRelaunchSection(root, seatRecord) : "";
104
+ process.stdout.write((record.text.endsWith("\n") ? record.text : `${record.text}\n`) + relaunch);
103
105
  }
104
106
 
105
107
  export type HandoverReplacementRequest = {
@@ -751,6 +753,18 @@ export async function readHandoverReplacement(root: string, address: string) {
751
753
  return request;
752
754
  }
753
755
 
756
+ async function operatorResumed(root: string, record: Seat) {
757
+ const { verified, total, discrepancies } = await operatorActivation(root, record, await recorded(root, record.address));
758
+ console.log(`RESUMED; verified ${verified}/${total}; discrepancies: ${discrepancies.join(", ") || "none"}.`);
759
+ if (discrepancies.length) throw new Error("Operator successor is not reconciled against the live Desk.");
760
+ }
761
+
762
+ /** Operator successors report through the launcher-activation check; every other seat keeps the Path-A reconciliation. */
763
+ export async function resumedHandover(root: string, address: string) {
764
+ const resolved = await canonicalAddress(root, address), record = await seat(root, resolved);
765
+ return await isExactOperatorSeat(root, record, resolved) ? operatorResumed(root, record) : resumedHandoverReplacement(root, resolved);
766
+ }
767
+
754
768
  export async function resumedHandoverReplacement(root: string, address: string) {
755
769
  const resolved = await canonicalAddress(root, address);
756
770
  const [request, record] = await Promise.all([readHandoverReplacement(root, resolved), seat(root, resolved)]);
@@ -0,0 +1,74 @@
1
+ // URN: component:flow:e003:operator-launch:backend:integration
2
+ // Tested-By:
3
+ // - test:atdd-flow:context-handover-session-replacement
4
+ // Runtime: bun
5
+ // Purpose: Define operator seat topology, relaunch command, and activation proof.
6
+
7
+ import { resolve } from "node:path";
8
+ import { type Seat, desk, exists, project, readYaml } from "./core";
9
+ import { readRuntimeState } from "./runtime-state";
10
+
11
+ type Receipt = Record<string, string | undefined>;
12
+ type Recorded = { recorded_at: string; text: string };
13
+
14
+ /** The single generated relaunch command: the Flow launcher resolves the pane, so no --pane, --resume, or hand-edited binding is involved. */
15
+ export const canonicalLaunchCommand = (address: string, herdrSession: string) => `atdd-flow pi runtime launch ${address} --herdr-session ${herdrSession}`;
16
+
17
+ /** Only operator@<project> on the project's primary main checkout is the supported operator seat. */
18
+ export async function isExactOperatorSeat(root: string, record: Seat, address: string) {
19
+ if (record.address !== address || record.role !== "operator" || address !== `operator@${record.project}` || record.branch !== "main") return false;
20
+ const config = await project(root, record.project);
21
+ return Boolean(config.repository && resolve(record.worktree) === resolve(config.repository));
22
+ }
23
+
24
+ /** The text appended to `handover show`; the recorded handover itself is never rewritten. */
25
+ export async function operatorRelaunchSection(root: string, record: Seat) {
26
+ const locator = record.runtime?.addresses.herdr;
27
+ const session = (typeof locator === "object" ? locator.session : undefined) ?? (await desk(root)).herdr_session;
28
+ const command = session ? canonicalLaunchCommand(record.address, session) : "UNAVAILABLE: no Herdr session is recorded for this seat or Desk.";
29
+ return `\nCANONICAL RELAUNCH COMMAND (generated by Flow; run it in a vacant shell pane after this Pi exits, then run \`atdd-flow handover resumed ${record.address}\`):\n${command}\n`;
30
+ }
31
+
32
+ const notBefore = (value: string | undefined, floor: string | undefined) => Boolean(value && floor && Date.parse(value) >= Date.parse(floor));
33
+ const handoverSession = (handover: Recorded | undefined) => handover?.text.match(/^Runtime:[^|]*\|\s*([^|\s]+)/m)?.[1];
34
+
35
+ async function readReceipt(path: string | undefined): Promise<Receipt | undefined> {
36
+ return path && await exists(path) ? readYaml<Receipt>(path) : undefined;
37
+ }
38
+
39
+ function nativeBinding(record: Seat) {
40
+ const runtime = record.runtime;
41
+ return runtime?.application === "herdr" && runtime.wake === "native" && typeof runtime.addresses.herdr === "object";
42
+ }
43
+
44
+ function receiptMatchesBinding(record: Seat, receipt: Receipt | undefined) {
45
+ const runtime = record.runtime, locator = runtime?.addresses.herdr;
46
+ if (!receipt || !runtime?.pi_session || typeof locator !== "object") return false;
47
+ return receipt.schema === "atdd-flow/pi-runtime-launch-receipt/v1" && receipt.seat === record.address
48
+ && receipt.pi_session === runtime.pi_session && receipt.pi_session_path === runtime.pi_session_path
49
+ && receipt.herdr_session === locator.session && receipt.pane === locator.pane;
50
+ }
51
+
52
+ async function heartbeatMatches(root: string, record: Seat, handover: Recorded | undefined) {
53
+ const state = await readRuntimeState(root, record.address);
54
+ return Boolean(state && state.seat === record.address && state.pi_session === record.runtime?.pi_session && notBefore(state.heartbeat_at, handover?.recorded_at));
55
+ }
56
+
57
+ /**
58
+ * An operator successor has no Path-A request. It is reported resumed only when the
59
+ * launcher's fresh receipt, the native-wake binding, and a matching heartbeat all agree.
60
+ */
61
+ export async function operatorActivation(root: string, record: Seat, handover: Recorded | undefined) {
62
+ const receipt = await readReceipt(record.runtime?.launch_receipt);
63
+ const session = record.runtime?.pi_session;
64
+ const checks: Array<[string, boolean]> = [
65
+ ["recorded handover", Boolean(handover)],
66
+ ["native delivery", nativeBinding(record)],
67
+ ["launcher receipt", receiptMatchesBinding(record, receipt)],
68
+ ["fresh receipt", notBefore(receipt?.created_at, handover?.recorded_at)],
69
+ ["fresh heartbeat", await heartbeatMatches(root, record, handover)],
70
+ ["fresh Pi session", Boolean(session) && session !== handoverSession(handover)],
71
+ ];
72
+ const discrepancies = checks.filter(([, ok]) => !ok).map(([name]) => name);
73
+ return { verified: checks.length - discrepancies.length, total: checks.length, discrepancies };
74
+ }
@@ -32,6 +32,7 @@ import { type Task, seatTasks, terminal } from "./tasks";
32
32
  import { projectHerdrSeat } from "./multiplexer";
33
33
  import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizations";
34
34
  import { assertReplacementTarget, replacementForMainLaunch } from "./handover";
35
+ import { canonicalLaunchCommand, isExactOperatorSeat } from "./operator-launch";
35
36
 
36
37
  export type PiRuntimeLaunchDependencies = {
37
38
  select?: (input: ModelSelectionInput) => Promise<ModelSelectionResponse>;
@@ -96,9 +97,10 @@ async function isExactNamedCoordinator(root: string, record: Seat, address: stri
96
97
 
97
98
  async function launchEligibleTasks(root: string, record: Seat, address: string) {
98
99
  const coordinator = await isExactNamedCoordinator(root, record, address);
100
+ const operator = await isExactOperatorSeat(root, record, address);
99
101
  const tasks = await seatTasks(root, record.project, record.address);
100
102
  const eligible = await Promise.all(tasks.map(async (entry) => {
101
- const accountable = record.role === "driver" ? entry.task.assignee === record.address
103
+ const accountable = record.role === "driver" || operator ? entry.task.assignee === record.address
102
104
  : coordinator && entry.task.coordinator === record.address;
103
105
  if (!accountable || entry.task.blocker) return undefined;
104
106
  if (entry.task.status === "in_progress") return entry;
@@ -282,7 +284,8 @@ async function prepareLaunch(root: string, address: string, args: string[], depe
282
284
  const options = parseLaunchOptions(args), existingPane = existingPaneForResume(record, options);
283
285
  const replacement = await replacementForMainLaunch(root, record, resolved);
284
286
  const active = replacement ? [{ id: replacement.request.task, task: replacement.task }] : await launchEligibleTasks(root, record, resolved);
285
- if (!active.length) throw new Error(`${resolved} has no launch-eligible task; refusing runtime launch.`);
287
+ const operator = await isExactOperatorSeat(root, record, resolved);
288
+ if (!active.length && !operator) throw new Error(`${resolved} has no launch-eligible task; refusing runtime launch.`);
286
289
  const selection = await chooseLaunchModel(root, config, record, required(await modelPortfolio(root), "models.yaml for Pi runtime launch"), dependencies.select);
287
290
  const model = resolveModelCommand(config, selection.candidate);
288
291
  if (basename(model.agent) !== "pi") throw new Error(`Pi runtime launch requires a Pi candidate, received ${selection.candidate.id}.`);
@@ -291,7 +294,7 @@ async function prepareLaunch(root: string, address: string, args: string[], depe
291
294
  if (!piSession) throw new Error("Resume requires the exact Pi session stored on the seat.");
292
295
  const adoptedPath = options.adopting ? resolve(required(options.adoptedSessionPath, "an exact adopted Pi session path")) : undefined;
293
296
  const authorizationRecord = await adoptionAuthorization(root, resolved, record, options, piSession, adoptedPath);
294
- return { root, resolved, record, options, existingPane, replacement, active, selection, model, command, piSession, adoptedPath, authorizationRecord, dependencies };
297
+ return { root, resolved, record, operator, options, existingPane, replacement, active, selection, model, command, piSession, adoptedPath, authorizationRecord, dependencies };
295
298
  }
296
299
 
297
300
  /** Match the seat-scoped projection to all caller and replacement assertions. */
@@ -357,7 +360,8 @@ function buildLaunchPlan(launch: PreparedLaunch, pane: string, sessionPath?: str
357
360
  const request: HerdrRequest = { session: options.herdrSession, pane, seat: resolved, root, piSession,
358
361
  ...(sessionPath ? { sessionPath } : {}), resume: options.resume || options.adopting, args: [...model.args, "--extension", piExtensionPath()] };
359
362
  const plan = { candidate: selection.candidate.id, piSession, pane, herdrSession: options.herdrSession,
360
- receipt: receiptPath(root, piSession), command: startCommand(request), ...(options.dryRun ? { dryRun: true } : {}) };
363
+ receipt: receiptPath(root, piSession), command: startCommand(request),
364
+ ...(launch.operator ? { canonicalCommand: canonicalLaunchCommand(resolved, options.herdrSession) } : {}), ...(options.dryRun ? { dryRun: true } : {}) };
361
365
  return { request, plan };
362
366
  }
363
367