@afokapu/atdd-flow 0.3.48 → 0.3.50

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.
@@ -318,6 +318,28 @@ export async function awaitLaunchActivation({ root, seat, piSession, herdrSessio
318
318
  throw new Error("Timed out waiting for verified Flow launch receipt and binding.");
319
319
  }
320
320
 
321
+ /**
322
+ * Host-attached Pi has no launcher variables; it may activate only when Herdr's injected pane identity exactly
323
+ * matches the Desk seat's native scoped binding. This reads the seat and never writes or creates a binding.
324
+ */
325
+ export async function hostAttachedActivation({ root, seat, herdrSession, pane, piSession, piSessionPath }: { root: string; seat: string; herdrSession?: string; pane?: string; piSession?: string; piSessionPath?: string }) {
326
+ if (!herdrSession || !pane) return false;
327
+ try {
328
+ const runtime = (await readYaml<Seat>(paths(root).seatFile(seat))).runtime;
329
+ return nativeScopedTo(runtime, herdrSession, pane) && sessionUnsetOrEqual(runtime, piSession, piSessionPath);
330
+ } catch { return false; }
331
+ }
332
+
333
+ function nativeScopedTo(runtime: Seat["runtime"], herdrSession: string, pane: string) {
334
+ const locator = runtime?.addresses.herdr;
335
+ return runtime?.application === "herdr" && runtime.wake === "native" && !runtime.launch_receipt
336
+ && typeof locator === "object" && locator.session === herdrSession && locator.pane === pane;
337
+ }
338
+
339
+ const unsetOrEqual = (recorded: string | undefined, live: string | undefined) => !recorded || recorded === live;
340
+ const sessionUnsetOrEqual = (runtime: Seat["runtime"], piSession?: string, piSessionPath?: string) =>
341
+ unsetOrEqual(runtime?.pi_session, piSession) && unsetOrEqual(runtime?.pi_session_path, piSessionPath);
342
+
321
343
  /** Injects the current task-derived action; failure to derive never blocks mail or startup. */
322
344
  async function sendTaskDirective(pi: ExtensionAPI, root: string, seat: string) {
323
345
  try {
@@ -337,6 +359,8 @@ export default function (pi: ExtensionAPI) {
337
359
  if (!root || !seat) return;
338
360
  let runtime: ReturnType<typeof createPiRuntime> | undefined;
339
361
  let handoverPrompted = false;
362
+ // Host-attach only: mail older than this activation is consumed without a prompt, so a long backlog cannot trigger one turn per message.
363
+ let hostActivatedAt: number | undefined;
340
364
  pi.on("turn_end", async (_event, ctx) => {
341
365
  if (handoverPrompted) return;
342
366
  const percent = ctx.getContextUsage()?.percent;
@@ -369,6 +393,7 @@ export default function (pi: ExtensionAPI) {
369
393
  pi.on("session_start", async (_event, ctx) => {
370
394
  runtime = createPiRuntime({ root, seat, deferActivation: true, deliver: async (mail, thread, path, sendIfOwned) => {
371
395
  if (!sendIfOwned) return false;
396
+ if (hostActivatedAt !== undefined && Date.parse(typeof mail.created_at === "string" ? mail.created_at : "") < hostActivatedAt) return sendIfOwned(async () => undefined);
372
397
  return sendIfOwned(async () => {
373
398
  // Task state is re-read at every wake, so queued mail cannot carry a stale action.
374
399
  await sendTaskDirective(pi, root, seat);
@@ -383,6 +408,14 @@ export default function (pi: ExtensionAPI) {
383
408
  const piSession = process.env.ATDD_FLOW_PI_SESSION;
384
409
  const herdrSession = process.env.ATDD_FLOW_HERDR_SESSION;
385
410
  const pane = process.env.ATDD_FLOW_HERDR_PANE;
411
+ if (!piSession && !herdrSession && !pane) {
412
+ const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string | undefined } }).sessionManager;
413
+ if (await hostAttachedActivation({ root, seat, herdrSession: process.env.HERDR_SESSION, pane: process.env.HERDR_PANE_ID, piSession: sessionManager?.getSessionId?.(), piSessionPath: sessionManager?.getSessionFile?.() })) {
414
+ hostActivatedAt = Date.now();
415
+ if (await runtime.activate()) pi.sendMessage({ customType: "atdd-flow-start", content: `Native mail active for ${seat} (host-attached). Notices created before this session were not replayed; run atdd-flow status seat ${seat} for unanswered mail.`, display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
416
+ }
417
+ return;
418
+ }
386
419
  if (!piSession || !herdrSession || !pane) return;
387
420
  const current = runtime;
388
421
  void awaitLaunchActivation({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.48",
3
+ "version": "0.3.50",
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
@@ -38,7 +38,7 @@ Usage:
38
38
  atdd-flow checkpoint <address> --summary <text> --next <text> [--status active|standby|blocked|complete|unverified]
39
39
  atdd-flow handover <address> --file <path> [--check]
40
40
  atdd-flow handover show <address>
41
- atdd-flow handover replace <address> --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>
41
+ atdd-flow handover replace <address> [--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>
42
42
  atdd-flow handover resumed <address>
43
43
  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> ...]
44
44
  atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>
package/src/handover.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
- import { dirname, join, resolve } from "node:path";
4
- import { type Runtime, type Seat, atomicYaml, canonicalAddress, desk, exists, has, now, paths, project, readYaml, required, seat, words, yaml } from "./core";
2
+ import { link, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { type Runtime, type Seat, atomicYaml, canonicalAddress, desk, exists, has, now, paths, project, readYaml, required, runOutput, seat, words, yaml } from "./core";
5
5
  import { type Task, terminal } from "./tasks";
6
6
  import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizations";
7
7
  import { readRuntimeState } from "./runtime-state";
@@ -102,9 +102,25 @@ export type HandoverReplacementRequest = {
102
102
  authorization: { id: string; hash: string };
103
103
  source: { pi_session: string; pi_session_path: string; herdr_session: string; pane: string };
104
104
  target: { herdr_session: string; pane: string; cwd: string };
105
+ legacy_source?: {
106
+ authorization_hash: string;
107
+ pi_session: string;
108
+ pi_session_path: string;
109
+ herdr_session: string;
110
+ pane: string;
111
+ cwd: string;
112
+ agent: string;
113
+ verified_at: string;
114
+ };
105
115
  created_at: string;
106
116
  };
107
117
 
118
+ export type HandoverReplacementDependencies = {
119
+ command?: (command: string[]) => Promise<string>;
120
+ at?: () => string;
121
+ publish?: (file: string, content: string) => Promise<void>;
122
+ };
123
+
108
124
  export const handoverReplacementPath = (root: string, address: string) => join(root, ".atdd-flow", "handover-replacements", `${encodeURIComponent(address)}.yaml`);
109
125
  const nonEmpty = (value: string | undefined, flag: string) => required(value, flag).trim();
110
126
 
@@ -121,10 +137,27 @@ export async function isExactMainSeat(root: string, record: Seat, address: strin
121
137
  return Boolean(config.repository && resolve(record.worktree) === resolve(config.repository));
122
138
  }
123
139
 
124
- export function replacementMatchesRuntime(runtime: Runtime | undefined, request: HandoverReplacementRequest) {
140
+ function unboundLegacyRuntime(runtime: Runtime | undefined, pane: string) {
125
141
  const locator = runtime?.addresses.herdr;
126
- return typeof locator !== "string" && locator?.session === request.source.herdr_session && locator.pane === request.source.pane
127
- && runtime?.pi_session === request.source.pi_session && runtime.pi_session_path === request.source.pi_session_path;
142
+ return runtime?.application === "herdr" && typeof locator === "string" && locator === pane
143
+ && !runtime.pi_session && !runtime.pi_session_path && !runtime.launch_receipt;
144
+ }
145
+
146
+ function snapshotMatchesRequest(snapshot: NonNullable<HandoverReplacementRequest["legacy_source"]>, request: HandoverReplacementRequest) {
147
+ return snapshot.authorization_hash === request.authorization.hash && snapshot.pi_session === request.source.pi_session
148
+ && snapshot.pi_session_path === request.source.pi_session_path && snapshot.herdr_session === request.source.herdr_session
149
+ && snapshot.pane === request.source.pane && snapshot.cwd === request.target.cwd && Boolean(snapshot.agent) && Boolean(snapshot.verified_at);
150
+ }
151
+
152
+ function canonicalSourceMatchesRuntime(runtime: Runtime | undefined, source: HandoverReplacementRequest["source"]) {
153
+ const locator = runtime?.addresses.herdr;
154
+ return typeof locator !== "string" && locator?.session === source.herdr_session && locator.pane === source.pane
155
+ && runtime?.pi_session === source.pi_session && runtime.pi_session_path === source.pi_session_path;
156
+ }
157
+
158
+ export function replacementMatchesRuntime(runtime: Runtime | undefined, request: HandoverReplacementRequest) {
159
+ if (!request.legacy_source) return canonicalSourceMatchesRuntime(runtime, request.source);
160
+ return unboundLegacyRuntime(runtime, request.source.pane) && snapshotMatchesRequest(request.legacy_source, request);
128
161
  }
129
162
 
130
163
  export async function mainLaunchEligible(root: string, record: Seat, address: string, task: Task) {
@@ -172,48 +205,136 @@ async function exactMainTakeoverTask(root: string, address: string, id: string)
172
205
  return { record };
173
206
  }
174
207
 
175
- /**
176
- * Creates the single immutable intent that an old Pi may observe before it voluntarily exits.
177
- * It deliberately does not stop, bind, attach, project, or launch any process.
178
- */
179
- export async function requestHandoverReplacement(root: string, address: string, args: string[]) {
180
- const resolved = await canonicalAddress(root, address);
181
- const allowed = ["--handover", "--authorization", "--takeover-task", "--source-pi-session", "--source-pi-session-path", "--source-herdr-session", "--source-pane", "--herdr-session", "--target-pane", "--target-cwd"];
182
- if (args.some((arg) => arg.startsWith("--") && !allowed.includes(arg))) throw new Error("Use `handover replace <main-seat> --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>`.");
183
- const handover = await recorded(root, resolved);
184
- const handoverId = nonEmpty(words(args, "--handover"), "--handover");
208
+ type LegacyPaneObservation = { pane: string; agent: string; sessionPath: string; cwd: string };
209
+ const object = (value: unknown): Record<string, unknown> | undefined => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
210
+ const text = (value: unknown) => typeof value === "string" ? value : undefined;
211
+ function result(value: unknown) { const root = object(value); return object(root?.result) ?? root; }
212
+ function herdr(session: string, ...args: string[]) { return ["herdr", "--session", session, ...args]; }
213
+
214
+ function legacyPaneObservation(raw: string): LegacyPaneObservation {
215
+ const value = JSON.parse(raw), pane = object(result(value)?.pane) ?? result(value), session = object(pane?.agent_session);
216
+ const observation = { pane: text(pane?.pane_id), agent: text(pane?.agent), sessionPath: session?.kind === "path" ? text(session.value) : undefined, cwd: text(pane?.cwd) };
217
+ if (!observation.pane || !observation.agent || !observation.sessionPath || !observation.cwd) throw new Error("Legacy source bootstrap requires complete live Herdr pane/session/path/cwd evidence.");
218
+ return observation as LegacyPaneObservation;
219
+ }
220
+
221
+ function assertLegacyPane(observed: LegacyPaneObservation, source: HandoverReplacementRequest["source"], cwd: string) {
222
+ const match = basename(observed.sessionPath).match(/_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
223
+ if (observed.pane !== source.pane || resolve(observed.sessionPath) !== source.pi_session_path || match?.[1] !== source.pi_session || resolve(observed.cwd) !== cwd) {
224
+ throw new Error("Legacy source bootstrap live pane/session/path/cwd does not match the authorized source tuple.");
225
+ }
226
+ }
227
+
228
+ function legacyAgentName(raw: string, source: HandoverReplacementRequest["source"], cwd: string) {
229
+ const value = JSON.parse(raw), agent = object(result(value)?.agent) ?? result(value), session = object(agent?.agent_session);
230
+ const name = text(agent?.name), sessionPath = session?.kind === "path" ? text(session.value) : undefined, agentCwd = text(agent?.cwd);
231
+ if (!name || text(agent?.pane_id) !== source.pane || text(agent?.agent) !== "pi" || !sessionPath || resolve(sessionPath) !== source.pi_session_path
232
+ || !agentCwd || resolve(agentCwd) !== cwd) throw new Error("Legacy source bootstrap Pi agent does not match the authorized source pane/session/path/cwd.");
233
+ return name;
234
+ }
235
+
236
+ function assertSinglePiWriter(raw: string) {
237
+ const value = JSON.parse(raw), report = object(result(value)?.process_info) ?? result(value);
238
+ const processes = Array.isArray(report?.foreground_processes) ? report.foreground_processes.map(object).filter(Boolean) as Record<string, unknown>[] : [];
239
+ const pi = processes.filter((process) => text(process.name) === "pi" || text(process.argv0)?.endsWith("/pi") || text(process.argv0) === "pi" || /(^|\/)pi(?:\s|$)/.test(text(process.cmdline) ?? ""));
240
+ if (pi.length !== 1) throw new Error("Legacy source bootstrap requires exactly one live Pi writer on the authorized source pane.");
241
+ }
242
+
243
+ async function verifyLegacySource(source: HandoverReplacementRequest["source"], target: HandoverReplacementRequest["target"], authorizationHash: string, dependencies: HandoverReplacementDependencies) {
244
+ const command = dependencies.command ?? runOutput;
245
+ const pane = legacyPaneObservation(await command(herdr(source.herdr_session, "pane", "get", source.pane)));
246
+ assertLegacyPane(pane, source, target.cwd);
247
+ const [agentRaw, processRaw] = await Promise.all([
248
+ command(herdr(source.herdr_session, "agent", "get", source.pane)),
249
+ command(herdr(source.herdr_session, "pane", "process-info", "--pane", source.pane)),
250
+ ]);
251
+ const agent = legacyAgentName(agentRaw, source, target.cwd);
252
+ assertSinglePiWriter(processRaw);
253
+ return { authorization_hash: authorizationHash, ...source, cwd: target.cwd, agent, verified_at: dependencies.at?.() ?? now() };
254
+ }
255
+
256
+ function assertLegacySeat(record: Seat, source: HandoverReplacementRequest["source"]) {
257
+ const runtime = record.runtime, locator = runtime?.addresses.herdr;
258
+ if (!runtime || runtime.application !== "herdr" || typeof locator !== "string" || locator !== source.pane
259
+ || runtime.pi_session || runtime.pi_session_path || runtime.launch_receipt) {
260
+ throw new Error("Legacy source bootstrap requires the unchanged scalar Herdr placement without canonical runtime binding.");
261
+ }
262
+ }
263
+
264
+ async function publishCreateOnly(file: string, content: string) {
265
+ await mkdir(dirname(file), { recursive: true });
266
+ const temporary = `${file}.${crypto.randomUUID()}.tmp`;
267
+ try {
268
+ await writeFile(temporary, content, { encoding: "utf8", flag: "wx" });
269
+ await link(temporary, file);
270
+ } finally { await rm(temporary, { force: true }); }
271
+ }
272
+
273
+ const replacementFlags = ["--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"];
274
+ function validateReplacementArgs(args: string[]) {
275
+ if (args.some((arg) => arg.startsWith("--") && !replacementFlags.includes(arg))) throw new Error("Use `handover replace <main-seat> [--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>`.");
276
+ }
277
+
278
+ async function currentReplacementHandover(root: string, resolved: string, args: string[]) {
279
+ const handover = await recorded(root, resolved), handoverId = nonEmpty(words(args, "--handover"), "--handover");
185
280
  if (!handover || handover.handover_id !== handoverId) throw new Error(`Path-A replacement requires the current validated handover ${handoverId} for ${resolved}.`);
186
- const { record } = await exactMainTakeoverTask(root, resolved, nonEmpty(words(args, "--takeover-task"), "--takeover-task"));
187
- const targetCwd = resolve(nonEmpty(words(args, "--target-cwd"), "--target-cwd"));
188
- if (targetCwd !== resolve(record.worktree)) throw new Error("Path-A replacement target CWD must be the exact canonical main worktree.");
189
- const source = {
281
+ return handover;
282
+ }
283
+
284
+ function replacementSource(args: string[]) {
285
+ return {
190
286
  pi_session: nonEmpty(words(args, "--source-pi-session"), "--source-pi-session"), pi_session_path: resolve(nonEmpty(words(args, "--source-pi-session-path"), "--source-pi-session-path")),
191
287
  herdr_session: nonEmpty(words(args, "--source-herdr-session"), "--source-herdr-session"), pane: nonEmpty(words(args, "--source-pane"), "--source-pane"),
192
288
  };
193
- const locator = record.runtime?.addresses.herdr;
194
- if (!record.runtime || typeof locator === "string" || locator?.session !== source.herdr_session || locator.pane !== source.pane
195
- || record.runtime.pi_session !== source.pi_session || record.runtime.pi_session_path !== source.pi_session_path) {
196
- throw new Error("Path-A replacement source tuple must exactly match the recorded live runtime.");
197
- }
198
- const target = { herdr_session: nonEmpty(words(args, "--herdr-session"), "--herdr-session"), pane: nonEmpty(words(args, "--target-pane"), "--target-pane"), cwd: targetCwd };
289
+ }
290
+
291
+ function replacementTarget(args: string[], record: Seat, source: HandoverReplacementRequest["source"]) {
292
+ const cwd = resolve(nonEmpty(words(args, "--target-cwd"), "--target-cwd"));
293
+ if (cwd !== resolve(record.worktree)) throw new Error("Path-A replacement target CWD must be the exact canonical main worktree.");
294
+ const target = { herdr_session: nonEmpty(words(args, "--herdr-session"), "--herdr-session"), pane: nonEmpty(words(args, "--target-pane"), "--target-pane"), cwd };
199
295
  if (target.herdr_session !== source.herdr_session || target.pane !== source.pane) throw new Error("Path-A replacement target must be the exact released source pane.");
200
- const authorization = await resolveExactSessionAdoptionAuthorization(root, nonEmpty(words(args, "--authorization"), "--authorization"), {
201
- seat: resolved, pi_session: source.pi_session, pi_session_path: source.pi_session_path,
202
- source: { herdr_session: source.herdr_session, pane: source.pane }, target,
203
- });
204
- const request: HandoverReplacementRequest = {
205
- schema: "atdd-flow/context-handover-replacement-request/v1",
206
- seat: resolved,
207
- task: nonEmpty(words(args, "--takeover-task"), "--takeover-task"),
208
- handover: { id: handover.handover_id, sha256: createHash("sha256").update(handover.text).digest("hex") },
209
- authorization,
210
- source,
211
- target,
212
- created_at: now(),
296
+ return target;
297
+ }
298
+
299
+ function assertReplacementSource(record: Seat, source: HandoverReplacementRequest["source"], legacy: boolean) {
300
+ if (legacy) return assertLegacySeat(record, source);
301
+ if (!canonicalSourceMatchesRuntime(record.runtime, source)) throw new Error("Path-A replacement source tuple must exactly match the recorded live runtime.");
302
+ }
303
+
304
+ async function prepareReplacement(root: string, resolved: string, args: string[]) {
305
+ validateReplacementArgs(args);
306
+ const legacy = args.includes("--legacy-source-bootstrap"), handover = await currentReplacementHandover(root, resolved, args);
307
+ const task = nonEmpty(words(args, "--takeover-task"), "--takeover-task");
308
+ const { record } = await exactMainTakeoverTask(root, resolved, task);
309
+ const source = replacementSource(args), target = replacementTarget(args, record, source);
310
+ assertReplacementSource(record, source, legacy);
311
+ return { legacy, handover, task, source, target };
312
+ }
313
+
314
+ function replacementRequest(resolved: string, intent: Awaited<ReturnType<typeof prepareReplacement>>, authorization: { id: string; hash: string }, legacySource: HandoverReplacementRequest["legacy_source"], dependencies: HandoverReplacementDependencies): HandoverReplacementRequest {
315
+ return {
316
+ schema: "atdd-flow/context-handover-replacement-request/v1", seat: resolved, task: intent.task,
317
+ handover: { id: intent.handover.handover_id, sha256: createHash("sha256").update(intent.handover.text).digest("hex") },
318
+ authorization, source: intent.source, target: intent.target,
319
+ ...(legacySource ? { legacy_source: legacySource } : {}), created_at: dependencies.at?.() ?? now(),
213
320
  };
321
+ }
322
+
323
+ /**
324
+ * Creates the single immutable intent that an old Pi may observe before it voluntarily exits.
325
+ * It deliberately does not stop, bind, attach, project, or launch any process.
326
+ */
327
+ export async function requestHandoverReplacement(root: string, address: string, args: string[], dependencies: HandoverReplacementDependencies = {}) {
328
+ const resolved = await canonicalAddress(root, address), intent = await prepareReplacement(root, resolved, args);
214
329
  const file = handoverReplacementPath(root, resolved);
215
- await mkdir(dirname(file), { recursive: true });
216
- await writeFile(file, yaml.print(request), { encoding: "utf8", flag: "wx" });
330
+ if (intent.legacy && await exists(file)) throw new Error(`Path-A replacement request already exists for ${resolved}.`);
331
+ const authorization = await resolveExactSessionAdoptionAuthorization(root, nonEmpty(words(args, "--authorization"), "--authorization"), {
332
+ seat: resolved, pi_session: intent.source.pi_session, pi_session_path: intent.source.pi_session_path,
333
+ source: { herdr_session: intent.source.herdr_session, pane: intent.source.pane }, target: intent.target,
334
+ });
335
+ const legacySource = intent.legacy ? await verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies) : undefined;
336
+ const request = replacementRequest(resolved, intent, authorization, legacySource, dependencies);
337
+ await (dependencies.publish ?? publishCreateOnly)(file, yaml.print(request));
217
338
  console.log(`Recorded Path-A replacement request for ${resolved}.`);
218
339
  }
219
340