@timurproko/a1 0.2.7-dev.1287 → 0.2.7-dev.1288

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.
@@ -23,6 +23,7 @@ export function cliUsage(capabilities) {
23
23
  "worktree enter <branch> [--base <branch>]",
24
24
  "worktree attach <path>",
25
25
  "worktree detach",
26
+ "worktree complete [--change <id>] [--pr <number>]",
26
27
  "worktree list",
27
28
  "session worktrees",
28
29
  "session link-worktree <path>",
@@ -59,6 +60,7 @@ export function cliHelp(capabilities) {
59
60
  ` ${command} worktree enter <branch> [--base <branch>]`,
60
61
  ` ${command} worktree attach <path>`,
61
62
  ` ${command} worktree detach`,
63
+ ` ${command} worktree complete [--change <id>] [--pr <number>]`,
62
64
  ` ${command} worktree list`,
63
65
  "",
64
66
  "Session context (compatibility aliases):",
@@ -155,7 +157,24 @@ function parseWorktree(rest) {
155
157
  if ((action === "detach" || action === "list") && value === undefined) {
156
158
  return { kind: "session-context", request: { action: action === "detach" ? "unlink-worktree" : "worktrees" } };
157
159
  }
158
- return { kind: "error", message: PRODUCT_TEXT.diagnostic("worktree accepts enter <branch> [--base <branch>], attach <path>, detach, or list.") };
160
+ if (action === "complete") {
161
+ const selectors = rest.slice(1);
162
+ let change, pr;
163
+ let valid = selectors.length % 2 === 0;
164
+ for (let index = 0; valid && index < selectors.length; index += 2) {
165
+ const name = selectors[index], selected = selectors[index + 1] ?? "";
166
+ if (name === "--change" && change === undefined && /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(selected))
167
+ change = selected;
168
+ else if (name === "--pr" && pr === undefined && /^[1-9][0-9]{0,8}$/u.test(selected))
169
+ pr = Number(selected);
170
+ else
171
+ valid = false;
172
+ }
173
+ if (valid) {
174
+ return { kind: "session-context", request: { action: "complete-worktree", ...(change === undefined ? {} : { change }), ...(pr === undefined ? {} : { pr }) } };
175
+ }
176
+ }
177
+ return { kind: "error", message: PRODUCT_TEXT.diagnostic("worktree accepts enter <branch> [--base <branch>], attach <path>, detach, complete [--change <id>] [--pr <number>], or list.") };
159
178
  }
160
179
  function parseSessionContext(rest) {
161
180
  const [action, value, extra] = rest;
@@ -15,6 +15,9 @@ export async function runSessionContextCommand(request, options = {}) {
15
15
  const outcome = await updateSessionRepositoryContext({ sessionId: identity.sessionId, sessionFile: identity.sessionFile }, request, { runtimeId: identity.runtimeId, inspectProcess: options.inspectProcess ?? inspectLiveProcess });
16
16
  if (outcome.kind === "unlinked")
17
17
  stdout("Session worktree context cleared.\n");
18
+ if (outcome.kind === "completing") {
19
+ stdout(`Session returned to ${outcome.startupRoot}; left ${outcome.cwd} (${outcome.branch}). Repository cleanup runs when this command's shell exits and is reported with its result.\n`);
20
+ }
18
21
  if (outcome.kind === "linked")
19
22
  stdout(`Session worktree context linked: ${outcome.cwd} (${outcome.branch})\n`);
20
23
  if (outcome.kind === "worktrees") {
@@ -57,6 +60,8 @@ function recoveryDetail(code) {
57
60
  "worktree-primary-checkout": "attach a task worktree, not the primary checkout; use worktree enter to create one",
58
61
  "worktree-git-locked": "Git has locked this worktree; retain it and ask its owner to release the lock",
59
62
  "worktree-foreign-repository": "the selected worktree belongs to a different repository; the previous attachment was preserved",
63
+ "worktree-not-attached": "no task worktree is attached to this session; nothing was changed",
64
+ "worktree-attachment-invalid": "the attached worktree is no longer a valid Git worktree; run worktree detach to return to the startup checkout",
60
65
  "worktree-context-changed": "the attachment changed during the operation; no ownership was overridden",
61
66
  };
62
67
  return code === null ? undefined : details[code];
@@ -17,7 +17,9 @@ export declare class SessionRuntimeRegistry {
17
17
  revision: string;
18
18
  } | null>;
19
19
  /** Strict tool cwd lookup: transient startup failure never redirects a write to primary. */
20
- toolReaderFor(agentId: string | undefined): (sessionId: string, sessionFile: string, signal: AbortSignal) => Promise<string>;
20
+ toolReaderFor(agentId: string | undefined): ((sessionId: string, sessionFile: string, signal: AbortSignal) => Promise<string>) & {
21
+ settleShell: (sessionId: string, sessionFile: string, signal: AbortSignal) => Promise<string | null>;
22
+ };
21
23
  /** Drain publication before releasing claims; never republish after disposal. */
22
24
  dispose(): Promise<void>;
23
25
  }
@@ -3,6 +3,8 @@ export class SessionRuntimeRegistry {
3
3
  #options;
4
4
  #identities = new Map();
5
5
  #activated = new Map();
6
+ // Invariant: a released invalid attachment is reported by the agent's next tool call instead of running elsewhere.
7
+ #released = new Map();
6
8
  #abort = new AbortController();
7
9
  #owner = null;
8
10
  #timer = null;
@@ -26,7 +28,7 @@ export class SessionRuntimeRegistry {
26
28
  const owner = await this.#ownerIdentity();
27
29
  if (owner === null || !this.#current(agentId, identity))
28
30
  return null;
29
- const { activateSessionRepositoryContext, readActiveSessionRepositoryContext } = await import("../foundation/lifecycle/session-repository-context.js");
31
+ const { activateSessionRepositoryContext, readActiveSessionRepositoryContext, releaseInvalidSessionRepositoryContext } = await import("../foundation/lifecycle/session-repository-context.js");
30
32
  if (!this.#current(agentId, identity))
31
33
  return null;
32
34
  const options = {
@@ -51,6 +53,14 @@ export class SessionRuntimeRegistry {
51
53
  }
52
54
  catch (error) {
53
55
  const code = error instanceof Error && "code" in error ? error.code : null;
56
+ if (code === "worktree-attachment-invalid") {
57
+ const released = await releaseInvalidSessionRepositoryContext(identity, options);
58
+ if (released !== null && this.#current(agentId, identity)) {
59
+ this.#released.set(agentId, { identity, message: `attached worktree ${released.worktreeRoot} is no longer a valid Git worktree; `
60
+ + `this session was detached and now uses ${released.startupRoot}. Nothing was deleted and this call did not run; retry it from that directory.` });
61
+ return null;
62
+ }
63
+ }
54
64
  if ((code === "session-runtime-unavailable" || code === "worktree-context-changed") && this.#current(agentId, identity)) {
55
65
  this.#activated.delete(agentId);
56
66
  }
@@ -62,7 +72,18 @@ export class SessionRuntimeRegistry {
62
72
  /** Strict tool cwd lookup: transient startup failure never redirects a write to primary. */
63
73
  toolReaderFor(agentId) {
64
74
  const read = this.readerFor(agentId);
65
- return async (sessionId, sessionFile, signal) => {
75
+ /** Run a completion the just-finished shell requested, from this stable process rather than that shell. */
76
+ const settleShell = async (sessionId, sessionFile, signal) => {
77
+ const { runtimeId, inspectProcess } = this.#options;
78
+ if (this.#disposed || runtimeId === undefined || inspectProcess === undefined)
79
+ return null;
80
+ const { describeWorktreeCompletion, settleSessionWorktreeCompletion } = await import("../foundation/lifecycle/session-worktree-completion.js");
81
+ const result = await settleSessionWorktreeCompletion({ sessionId, sessionFile }, {
82
+ runtimeId, inspectProcess, signal, ...(agentId === undefined ? {} : { agentId }),
83
+ });
84
+ return result === null ? null : describeWorktreeCompletion(result);
85
+ };
86
+ return Object.assign(async (sessionId, sessionFile, signal) => {
66
87
  const { retrySessionCoordination } = await import("../foundation/lifecycle/session-context-retry.js");
67
88
  return await retrySessionCoordination(async () => {
68
89
  signal.throwIfAborted();
@@ -75,12 +96,20 @@ export class SessionRuntimeRegistry {
75
96
  if (active?.sessionId !== sessionId || active.sessionFile !== sessionFile) {
76
97
  throw Object.assign(new Error("worktree runtime registration is still recovering"), { code: "session-runtime-unavailable" });
77
98
  }
99
+ const released = this.#released.get(agentId);
100
+ if (released !== undefined) {
101
+ this.#released.delete(agentId);
102
+ // Invariant: work addressed to the lost checkout fails once; it is never silently applied to the startup checkout.
103
+ if (released.identity.sessionId === sessionId && released.identity.sessionFile === sessionFile) {
104
+ throw Object.assign(new Error(released.message), { code: "worktree-attachment-invalid" });
105
+ }
106
+ }
78
107
  if (context !== null)
79
108
  return context.cwd;
80
109
  const { readSessionStartupRoot } = await import("../foundation/lifecycle/session-repository-context.js");
81
110
  return await readSessionStartupRoot({ sessionId, sessionFile });
82
111
  }, 8_000, signal);
83
- };
112
+ }, { settleShell });
84
113
  }
85
114
  /** Drain publication before releasing claims; never republish after disposal. */
86
115
  async dispose() {
@@ -9,6 +9,10 @@ export type SessionContextRequest = {
9
9
  readonly base?: string;
10
10
  } | {
11
11
  readonly action: "unlink-worktree";
12
+ } | {
13
+ readonly action: "complete-worktree";
14
+ readonly change?: string;
15
+ readonly pr?: number;
12
16
  } | {
13
17
  readonly action: "worktrees";
14
18
  };
@@ -18,6 +22,11 @@ export type SessionContextOutcome = {
18
22
  readonly branch: string;
19
23
  } | {
20
24
  readonly kind: "unlinked";
25
+ } | {
26
+ readonly kind: "completing";
27
+ readonly cwd: string;
28
+ readonly branch: string;
29
+ readonly startupRoot: string;
21
30
  } | {
22
31
  readonly kind: "worktrees";
23
32
  readonly entries: readonly SessionWorktreeInventoryEntry[];
@@ -1,4 +1,4 @@
1
- import { clearSessionRepositoryContext, enterSessionRepositoryWorktree, listSessionRepositoryWorktrees, retrySessionCoordination, setSessionRepositoryContext, } from "../../foundation/lifecycle/index.js";
1
+ import { clearSessionRepositoryContext, enterSessionRepositoryWorktree, listSessionRepositoryWorktrees, requestSessionWorktreeCompletion, retrySessionCoordination, setSessionRepositoryContext, } from "../../foundation/lifecycle/index.js";
2
2
  export { PRIMARY_SESSION_AGENT_ID } from "../../foundation/lifecycle/index.js";
3
3
  export async function updateSessionRepositoryContext(identity, request, runtime) {
4
4
  const options = {
@@ -14,6 +14,12 @@ export async function updateSessionRepositoryContext(identity, request, runtime)
14
14
  await clearSessionRepositoryContext(identity, options);
15
15
  return { kind: "unlinked" };
16
16
  }
17
+ if (request.action === "complete-worktree") {
18
+ return { kind: "completing", ...await requestSessionWorktreeCompletion(identity, {
19
+ ...(request.change === undefined ? {} : { change: request.change }),
20
+ ...(request.pr === undefined ? {} : { pr: request.pr }),
21
+ }, options) };
22
+ }
17
23
  if (request.action === "worktrees") {
18
24
  return { kind: "worktrees", entries: await listSessionRepositoryWorktrees(identity, options) };
19
25
  }
@@ -5,7 +5,9 @@ export { resolveProductPaths } from "./paths.js";
5
5
  export type { ProductPaths } from "./paths.js";
6
6
  export { parseSessionSelection, sessionSelectionArguments } from "./session-selection.js";
7
7
  export type { SessionSelection } from "./session-selection.js";
8
- export { PRIMARY_SESSION_AGENT_ID, activateSessionRepositoryContext, clearSessionRepositoryContext, listSessionRepositoryWorktrees, readActiveSessionRepositoryContext, readSessionStartupRoot, readSessionRepositoryContext, registerSessionRepositoryRuntime, releaseSessionRepositoryRuntime, setSessionRepositoryContext, withSessionRepositoryObservation, } from "./session-repository-context.js";
8
+ export { PRIMARY_SESSION_AGENT_ID, activateSessionRepositoryContext, clearSessionRepositoryContext, listSessionRepositoryWorktrees, readActiveSessionRepositoryContext, readSessionStartupRoot, readSessionRepositoryContext, registerSessionRepositoryRuntime, releaseInvalidSessionRepositoryContext, releaseSessionRepositoryRuntime, setSessionRepositoryContext, withSessionRepositoryObservation, } from "./session-repository-context.js";
9
9
  export { retrySessionCoordination } from "./session-context-retry.js";
10
10
  export { enterSessionRepositoryWorktree } from "./session-worktree-entry.js";
11
- export type { SessionRepositoryContext, SessionRepositoryContextOptions, SessionRepositoryIdentity, SessionRepositoryObservation, SessionWorktreeInventoryEntry, SessionWorktreeStatus, } from "./session-repository-context.js";
11
+ export { describeWorktreeCompletion, requestSessionWorktreeCompletion, settleSessionWorktreeCompletion, } from "./session-worktree-completion.js";
12
+ export type { WorktreeCompletionRequest, WorktreeCompletionResult, WorktreeCompletionSelectors, } from "./session-worktree-completion.js";
13
+ export type { ReleasedSessionAttachment, SessionRepositoryContext, SessionRepositoryContextOptions, SessionRepositoryIdentity, SessionRepositoryObservation, SessionWorktreeInventoryEntry, SessionWorktreeStatus, } from "./session-repository-context.js";
@@ -2,6 +2,7 @@ export { assertLaunchProfileId } from "./model.js";
2
2
  export { PID_LIVENESS_IDENTITY, inspectLiveProcess } from "./process-liveness.js";
3
3
  export { resolveProductPaths } from "./paths.js";
4
4
  export { parseSessionSelection, sessionSelectionArguments } from "./session-selection.js";
5
- export { PRIMARY_SESSION_AGENT_ID, activateSessionRepositoryContext, clearSessionRepositoryContext, listSessionRepositoryWorktrees, readActiveSessionRepositoryContext, readSessionStartupRoot, readSessionRepositoryContext, registerSessionRepositoryRuntime, releaseSessionRepositoryRuntime, setSessionRepositoryContext, withSessionRepositoryObservation, } from "./session-repository-context.js";
5
+ export { PRIMARY_SESSION_AGENT_ID, activateSessionRepositoryContext, clearSessionRepositoryContext, listSessionRepositoryWorktrees, readActiveSessionRepositoryContext, readSessionStartupRoot, readSessionRepositoryContext, registerSessionRepositoryRuntime, releaseInvalidSessionRepositoryContext, releaseSessionRepositoryRuntime, setSessionRepositoryContext, withSessionRepositoryObservation, } from "./session-repository-context.js";
6
6
  export { retrySessionCoordination } from "./session-context-retry.js";
7
7
  export { enterSessionRepositoryWorktree } from "./session-worktree-entry.js";
8
+ export { describeWorktreeCompletion, requestSessionWorktreeCompletion, settleSessionWorktreeCompletion, } from "./session-worktree-completion.js";
@@ -57,3 +57,30 @@ export declare function readSessionStartupRoot(identity: SessionRepositoryIdenti
57
57
  export declare function readActiveSessionRepositoryContext(identity: SessionRepositoryIdentity, options: SessionRepositoryContextOptions): Promise<SessionRepositoryObservation | null>;
58
58
  /** List same-repository worktrees without acquiring, releasing, or repairing an editing claim. */
59
59
  export declare function listSessionRepositoryWorktrees(identity: SessionRepositoryIdentity, options?: SessionRepositoryContextOptions): Promise<readonly SessionWorktreeInventoryEntry[]>;
60
+ /** The released checkout and the verified startup root the session continues from. */
61
+ export interface ReleasedSessionAttachment {
62
+ readonly worktreeRoot: string;
63
+ readonly startupRoot: string;
64
+ }
65
+ /**
66
+ * Release this session's own association and claim when its checkout is definitively gone.
67
+ * Uncertain validation keeps the attachment; files, branches, and other owners' claims are never touched.
68
+ */
69
+ export declare function releaseInvalidSessionRepositoryContext(identity: SessionRepositoryIdentity, options: SessionRepositoryContextOptions): Promise<ReleasedSessionAttachment | null>;
70
+ /** The checkout a session left for completion, with the verified startup root it continues from. */
71
+ export interface DetachedSessionWorktree {
72
+ readonly startupRoot: string;
73
+ readonly commonDir: string;
74
+ readonly worktreeRoot: string;
75
+ readonly worktreeGitDir: string;
76
+ readonly branch: string;
77
+ }
78
+ /**
79
+ * Release a valid attachment this session owns, after `record` durably notes what was left.
80
+ * A failed check or record keeps the attachment; nothing on disk is deleted.
81
+ */
82
+ export declare function detachSessionRepositoryContextForCompletion(identity: SessionRepositoryIdentity, options: SessionRepositoryContextOptions, record: (detached: DetachedSessionWorktree) => Promise<void>): Promise<DetachedSessionWorktree>;
83
+ /** Whether any live runtime holds the editing claim of one checkout; an unreadable claim is never treated as absent. */
84
+ export declare function sessionWorktreeClaimState(commonDir: string, gitDir: string, options?: SessionRepositoryContextOptions): Promise<"absent" | "live" | "unverifiable">;
85
+ /** Private per-agent store for a completion requested by a shell and settled by its runtime. */
86
+ export declare function sessionWorktreeCompletionPath(identity: SessionRepositoryIdentity, options: SessionRepositoryContextOptions): string;
@@ -16,6 +16,8 @@ const GIT_TIMEOUT_MS = 5_000;
16
16
  /** Records without an agent id were written by a single-agent release and belong to its one agent. */
17
17
  export const PRIMARY_SESSION_AGENT_ID = "primary";
18
18
  const RECORD_FORMAT_VERSION = 2;
19
+ const NOT_A_WORKTREE_ROOT = "associated path must be a Git worktree root";
20
+ const INVALID_ATTACHMENT = "worktree-attachment-invalid";
19
21
  /** Publish the exact live runtime that may acquire a delivery worktree. */
20
22
  export async function registerSessionRepositoryRuntime(identity, runtimeId, runtimeIdentity, options = {}) {
21
23
  const header = await readSessionHeader(identity);
@@ -417,16 +419,113 @@ async function validateContextRecord(header, record, signal) {
417
419
  if (record.sessionId !== header.id || record.sessionFile !== header.file)
418
420
  throw new Error("associated session identity changed");
419
421
  const startup = await readGitWorktreeIdentity(header.cwd, false, signal);
420
- const selected = await readGitWorktreeIdentity(record.worktreeRoot, true, signal);
421
- if (!samePath(startup.root, record.startupRoot)
422
- || !samePath(startup.commonDir, record.startupCommonDir)
423
- || !samePath(selected.root, record.worktreeRoot)
424
- || !samePath(selected.commonDir, record.worktreeCommonDir)
425
- || !samePath(selected.gitDir, record.worktreeGitDir)
422
+ if (!samePath(startup.root, record.startupRoot) || !samePath(startup.commonDir, record.startupCommonDir)) {
423
+ throw new Error("associated worktree identity changed");
424
+ }
425
+ // Invariant: only a verified startup checkout makes the attached checkout's absence definitive.
426
+ const invalid = (detail) => Object.assign(contextError(`attached worktree ${record.worktreeRoot} ${detail}`, INVALID_ATTACHMENT), { worktreeRoot: record.worktreeRoot, startupRoot: startup.root });
427
+ const missing = (error) => { if (isMissingPathError(error))
428
+ return null; throw error; };
429
+ const root = await realpath(record.worktreeRoot).catch(missing);
430
+ if (root === null)
431
+ throw invalid("no longer exists");
432
+ if (await lstat(join(root, ".git")).catch(missing) === null)
433
+ throw invalid("no longer has its Git pointer");
434
+ let selected;
435
+ try {
436
+ selected = await readGitWorktreeIdentity(root, true, signal);
437
+ }
438
+ catch (error) {
439
+ if (error instanceof Error && (error.message === NOT_A_WORKTREE_ROOT
440
+ || ("stderr" in error && String(error.stderr).includes("not a git repository"))))
441
+ throw invalid("is no longer a Git worktree");
442
+ throw error;
443
+ }
444
+ if (!samePath(selected.commonDir, record.worktreeCommonDir) || !samePath(selected.gitDir, record.worktreeGitDir)
426
445
  || !samePath(startup.commonDir, selected.commonDir))
446
+ throw invalid("was replaced by a different Git worktree");
447
+ if (!samePath(selected.root, record.worktreeRoot))
427
448
  throw new Error("associated worktree identity changed");
428
449
  return selected;
429
450
  }
451
+ /**
452
+ * Release this session's own association and claim when its checkout is definitively gone.
453
+ * Uncertain validation keeps the attachment; files, branches, and other owners' claims are never touched.
454
+ */
455
+ export async function releaseInvalidSessionRepositoryContext(identity, options) {
456
+ const header = await readSessionHeader(identity);
457
+ const owner = { runtimeId: requireRuntimeId(options), agentId: requireAgentId(options) };
458
+ return await withClaimLock(options, async () => {
459
+ await requireLiveRuntime(header, owner, options);
460
+ const record = await readContextRecord(header, options);
461
+ if (record === null)
462
+ return null;
463
+ const failure = await validateContextRecord(header, record, options.signal).then(() => null, error => error);
464
+ if (!isInvalidAttachment(failure))
465
+ return null;
466
+ await rm(contextRecordPath(header, options), { force: true });
467
+ await releaseClaimsOwnedBy(owner, null, options);
468
+ return { worktreeRoot: failure.worktreeRoot, startupRoot: failure.startupRoot };
469
+ });
470
+ }
471
+ /**
472
+ * Release a valid attachment this session owns, after `record` durably notes what was left.
473
+ * A failed check or record keeps the attachment; nothing on disk is deleted.
474
+ */
475
+ export async function detachSessionRepositoryContextForCompletion(identity, options, record) {
476
+ const header = await readSessionHeader(identity);
477
+ const owner = { runtimeId: requireRuntimeId(options), agentId: requireAgentId(options) };
478
+ return await withClaimLock(options, async () => {
479
+ await requireLiveRuntime(header, owner, options);
480
+ const stored = await readContextRecord(header, options);
481
+ if (stored === null)
482
+ throw contextError("no task worktree is attached", "worktree-not-attached");
483
+ const selected = await validateContextRecord(header, stored, options.signal);
484
+ const claim = await readClaim(selected, options);
485
+ if (claim === null || claim === undefined || !claimMatchesWorktree(claim, selected) || !ownedBy(claim, owner)) {
486
+ throw contextError("the session no longer owns its attached worktree", "worktree-owner-unverifiable");
487
+ }
488
+ if (await lstat(join(selected.gitDir, "locked")).then(() => true, error => { if (isMissingPathError(error))
489
+ return false; throw error; })) {
490
+ throw contextError("selected worktree is locked in Git", "worktree-git-locked");
491
+ }
492
+ const detached = {
493
+ startupRoot: stored.startupRoot, commonDir: selected.commonDir, worktreeRoot: selected.root,
494
+ worktreeGitDir: selected.gitDir, branch: requireBranch(selected),
495
+ };
496
+ await record(detached);
497
+ await rm(contextRecordPath(header, options), { force: true });
498
+ await releaseClaimsOwnedBy(owner, null, options);
499
+ return detached;
500
+ });
501
+ }
502
+ /** Whether any live runtime holds the editing claim of one checkout; an unreadable claim is never treated as absent. */
503
+ export async function sessionWorktreeClaimState(commonDir, gitDir, options = {}) {
504
+ const path = claimRecordPath(commonDir, gitDir, options);
505
+ if (await lstat(path).then(() => false, error => { if (isMissingPathError(error))
506
+ return true; throw error; }))
507
+ return "absent";
508
+ const claim = await readClaimRecordAt(path);
509
+ if (claim === null)
510
+ return "unverifiable";
511
+ try {
512
+ const observed = await requireInspector(options)(claim.pid);
513
+ return observed !== null && observed.pid === claim.pid && observed.startIdentity === claim.startIdentity ? "live" : "absent";
514
+ }
515
+ catch {
516
+ return "unverifiable";
517
+ }
518
+ }
519
+ /** Private per-agent store for a completion requested by a shell and settled by its runtime. */
520
+ export function sessionWorktreeCompletionPath(identity, options) {
521
+ const key = `${requireRuntimeId(options)}\0${requireAgentId(options)}\0${identity.sessionId}`;
522
+ return join(claimStoreRoot(options), "completions", `${digest(key)}.json`);
523
+ }
524
+ function isInvalidAttachment(error) {
525
+ return error instanceof Error && "code" in error && error.code === INVALID_ATTACHMENT
526
+ && "worktreeRoot" in error && typeof error.worktreeRoot === "string"
527
+ && "startupRoot" in error && typeof error.startupRoot === "string";
528
+ }
430
529
  /** Release one agent's claims except the one it keeps; sibling agents of the runtime are untouched. */
431
530
  async function releaseClaimsOwnedBy(owner, keepGitDir, options) {
432
531
  await releaseClaimsWhere(claim => ownedBy(claim, owner) && (keepGitDir === null || !samePath(claim.worktreeGitDir, keepGitDir)), options);
@@ -492,7 +591,7 @@ async function readGitWorktreeIdentity(path, requireWorktreeRoot, signal) {
492
591
  throw new Error("associated worktree identity is incomplete");
493
592
  const root = await realpath(rootValue);
494
593
  if (requireWorktreeRoot && !samePath(root, cwd))
495
- throw new Error("associated path must be a Git worktree root");
594
+ throw new Error(NOT_A_WORKTREE_ROOT);
496
595
  const commonDir = await realpath(commonValue);
497
596
  const gitDir = await realpath(gitValue);
498
597
  let branch = null;
@@ -0,0 +1,35 @@
1
+ import { type SessionRepositoryContextOptions, type SessionRepositoryIdentity } from "./session-repository-context.js";
2
+ export interface WorktreeCompletionSelectors {
3
+ readonly change?: string;
4
+ readonly pr?: number;
5
+ }
6
+ export interface WorktreeCompletionRequest {
7
+ readonly startupRoot: string;
8
+ readonly cwd: string;
9
+ readonly branch: string;
10
+ }
11
+ /**
12
+ * `cleanup-finished` relays the repository command's own report, which stays authoritative:
13
+ * A1 never claims a removal it did not perform.
14
+ */
15
+ export interface WorktreeCompletionResult extends WorktreeCompletionRequest {
16
+ readonly kind: "cleanup-finished" | "cleanup-failed" | "cleanup-unavailable" | "cleanup-skipped";
17
+ readonly detail: string;
18
+ }
19
+ export interface WorktreeCompletionSettleOptions extends SessionRepositoryContextOptions {
20
+ readonly now?: () => number;
21
+ /** Test seam for the repository cleanup command; production runs it without a shell. */
22
+ readonly run?: (command: string, arguments_: readonly string[], cwd: string, signal?: AbortSignal) => Promise<string>;
23
+ }
24
+ /**
25
+ * Leave the attached checkout so its cleanup can run after the issuing shell has exited.
26
+ * Unpublished content, a Git lock, or uncertain ownership keeps the session attached.
27
+ */
28
+ export declare function requestSessionWorktreeCompletion(identity: SessionRepositoryIdentity, selectors: WorktreeCompletionSelectors, options: SessionRepositoryContextOptions): Promise<WorktreeCompletionRequest>;
29
+ /**
30
+ * Run a pending completion's repository cleanup from the startup checkout. The request is consumed
31
+ * before anything runs, so it executes at most once; null means nothing was pending.
32
+ */
33
+ export declare function settleSessionWorktreeCompletion(identity: SessionRepositoryIdentity, options: WorktreeCompletionSettleOptions): Promise<WorktreeCompletionResult | null>;
34
+ /** The agent-facing account of a settled completion; the session is on the startup checkout in every case. */
35
+ export declare function describeWorktreeCompletion(result: WorktreeCompletionResult): string;
@@ -0,0 +1,190 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { detachSessionRepositoryContextForCompletion, readActiveSessionRepositoryContext, readSessionStartupRoot, sessionWorktreeClaimState, sessionWorktreeCompletionPath, } from "./session-repository-context.js";
6
+ const SCHEMA = "a1-session-worktree-completion-v1";
7
+ const CLEANUP_DECLARATION = ".a1/worktree.json";
8
+ const MAX_RECORD_BYTES = 16 * 1024;
9
+ const MAX_REPORT_CHARACTERS = 4_000;
10
+ /** A request the runtime did not settle promptly is dropped, never run later by surprise. */
11
+ const REQUEST_LIFETIME_MS = 5 * 60_000;
12
+ const CLEANUP_TIMEOUT_MS = 5 * 60_000;
13
+ /**
14
+ * Leave the attached checkout so its cleanup can run after the issuing shell has exited.
15
+ * Unpublished content, a Git lock, or uncertain ownership keeps the session attached.
16
+ */
17
+ export async function requestSessionWorktreeCompletion(identity, selectors, options) {
18
+ if (selectors.change !== undefined && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(selectors.change)) {
19
+ throw failure("worktree-completion-selector", "the change selector must be a kebab-case change id");
20
+ }
21
+ if (selectors.pr !== undefined && (!Number.isSafeInteger(selectors.pr) || selectors.pr < 1)) {
22
+ throw failure("worktree-completion-selector", "the pull-request selector must be a positive number");
23
+ }
24
+ const attached = await readActiveSessionRepositoryContext(identity, options);
25
+ if (attached === null)
26
+ throw failure("worktree-not-attached", "no task worktree is attached");
27
+ const status = await execute("git", ["-c", "core.hooksPath=/dev/null", "status", "--porcelain", "--untracked-files=all"], attached.cwd, options.signal);
28
+ const changed = status.split(/\r?\n/u).filter(Boolean);
29
+ if (changed.length > 0) {
30
+ const listed = changed.slice(0, 5).map(line => line.slice(3)).join(", ");
31
+ throw failure("worktree-content", `the attached worktree holds uncommitted or untracked content (${listed}${changed.length > 5 ? ", ..." : ""})`);
32
+ }
33
+ const path = sessionWorktreeCompletionPath(identity, options);
34
+ const detached = await detachSessionRepositoryContextForCompletion(identity, options, async (left) => {
35
+ if (!samePath(left.worktreeRoot, attached.cwd))
36
+ throw failure("worktree-context-changed", "the attachment changed during completion");
37
+ const record = {
38
+ schema: SCHEMA, sessionId: identity.sessionId, startupRoot: left.startupRoot, commonDir: left.commonDir,
39
+ worktreeRoot: left.worktreeRoot, worktreeGitDir: left.worktreeGitDir, branch: left.branch,
40
+ ...(selectors.change === undefined ? {} : { change: selectors.change }),
41
+ ...(selectors.pr === undefined ? {} : { pr: selectors.pr }),
42
+ requestedAt: Date.now(),
43
+ };
44
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
45
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
46
+ try {
47
+ await writeFile(temporary, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
48
+ await rename(temporary, path);
49
+ }
50
+ finally {
51
+ await rm(temporary, { force: true }).catch(() => undefined);
52
+ }
53
+ });
54
+ return { startupRoot: detached.startupRoot, cwd: detached.worktreeRoot, branch: detached.branch };
55
+ }
56
+ /**
57
+ * Run a pending completion's repository cleanup from the startup checkout. The request is consumed
58
+ * before anything runs, so it executes at most once; null means nothing was pending.
59
+ */
60
+ export async function settleSessionWorktreeCompletion(identity, options) {
61
+ const path = sessionWorktreeCompletionPath(identity, options);
62
+ const record = await readRecord(path);
63
+ await rm(path, { force: true });
64
+ if (record === null || record.sessionId !== identity.sessionId)
65
+ return null;
66
+ const request = { startupRoot: record.startupRoot, cwd: record.worktreeRoot, branch: record.branch };
67
+ const result = (kind, detail) => ({ ...request, kind, detail });
68
+ try {
69
+ if ((options.now ?? Date.now)() - record.requestedAt > REQUEST_LIFETIME_MS)
70
+ return result("cleanup-skipped", "the completion request expired before the runtime could settle it");
71
+ const startup = await realpath(await readSessionStartupRoot(identity));
72
+ if (!samePath(startup, record.startupRoot))
73
+ return result("cleanup-skipped", "the session's startup checkout is not the recorded primary checkout");
74
+ const claim = await sessionWorktreeClaimState(record.commonDir, record.worktreeGitDir, options);
75
+ if (claim === "live")
76
+ return result("cleanup-skipped", "a live session attached the checkout again");
77
+ if (claim === "unverifiable")
78
+ return result("cleanup-skipped", "the checkout's editing claim cannot be verified");
79
+ // Security: the command is the primary checkout's committed declaration, never a file of the task checkout being left.
80
+ const declared = await readCleanupDeclaration(join(startup, CLEANUP_DECLARATION));
81
+ if (declared === null)
82
+ return result("cleanup-unavailable", `this repository declares no cleanup command in ${CLEANUP_DECLARATION}; the checkout was retained`);
83
+ if (declared === "invalid")
84
+ return result("cleanup-unavailable", `${CLEANUP_DECLARATION} must hold {"cleanup": ["program", "argument", ...]}; the checkout was retained`);
85
+ const values = {
86
+ primary: startup, worktree: record.worktreeRoot, branch: record.branch,
87
+ change: record.change, pr: record.pr === undefined ? undefined : String(record.pr),
88
+ };
89
+ const missing = new Set();
90
+ const [command, ...arguments_] = declared.map(part => part.replace(/\{([a-z]+)\}/gu, (_match, name) => {
91
+ const value = values[name];
92
+ if (value === undefined)
93
+ missing.add(name);
94
+ return value ?? "";
95
+ }));
96
+ if (missing.size > 0 || command === undefined) {
97
+ return result("cleanup-unavailable", `the declared cleanup needs ${[...missing].map(name => `{${name}}`).join(" and ")}; run completion with the matching selector or the repository cleanup from primary`);
98
+ }
99
+ const run = options.run ?? execute;
100
+ try {
101
+ return result("cleanup-finished", bounded(await run(command, arguments_, startup, options.signal)));
102
+ }
103
+ catch (error) {
104
+ const output = isRecord(error) ? [error.stdout, error.stderr].filter(value => typeof value === "string" && value.length > 0).join("\n") : "";
105
+ return result("cleanup-failed", bounded(output || (error instanceof Error ? error.message : String(error))));
106
+ }
107
+ }
108
+ catch (error) {
109
+ return result("cleanup-skipped", error instanceof Error ? error.message : String(error));
110
+ }
111
+ }
112
+ /** The agent-facing account of a settled completion; the session is on the startup checkout in every case. */
113
+ export function describeWorktreeCompletion(result) {
114
+ const headline = {
115
+ "cleanup-finished": "the repository cleanup command ran; its report below is authoritative",
116
+ "cleanup-failed": "the repository cleanup command failed; the checkout may remain and is retried with repository cleanup from primary",
117
+ "cleanup-unavailable": "cleanup is unavailable; the checkout was retained",
118
+ "cleanup-skipped": "cleanup was skipped; the checkout was retained",
119
+ };
120
+ return [
121
+ `Worktree completion (${result.kind}): ${headline[result.kind]}.`,
122
+ `Session uses ${result.startupRoot}; left ${result.cwd} (${result.branch}). Do not attach it again while cleanup is partial.`,
123
+ result.detail,
124
+ ].join("\n");
125
+ }
126
+ /** Null when the repository declares nothing; "invalid" when the declaration cannot be used as an argument list. */
127
+ async function readCleanupDeclaration(path) {
128
+ const metadata = await lstat(path).catch(error => { if (isRecord(error) && (error.code === "ENOENT" || error.code === "ENOTDIR"))
129
+ return null; throw error; });
130
+ if (metadata === null)
131
+ return null;
132
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size === 0 || metadata.size > MAX_RECORD_BYTES)
133
+ return "invalid";
134
+ try {
135
+ const value = JSON.parse(await readFile(path, "utf8"));
136
+ const cleanup = isRecord(value) ? value.cleanup : undefined;
137
+ return Array.isArray(cleanup) && cleanup.length > 0 && cleanup.length <= 64
138
+ && cleanup.every(part => typeof part === "string" && part.length > 0 && part.length <= 2_048 && !part.includes("\0"))
139
+ // Compatibility: the element check above establishes the string array.
140
+ ? cleanup : "invalid";
141
+ }
142
+ catch {
143
+ return "invalid";
144
+ }
145
+ }
146
+ async function readRecord(path) {
147
+ try {
148
+ const metadata = await lstat(path);
149
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size === 0 || metadata.size > MAX_RECORD_BYTES)
150
+ return null;
151
+ const value = JSON.parse(await readFile(path, "utf8"));
152
+ if (!isRecord(value) || value.schema !== SCHEMA)
153
+ return null;
154
+ const text = (name) => typeof value[name] === "string" && value[name].length > 0 && value[name].length <= 32_768;
155
+ if (!["sessionId", "startupRoot", "commonDir", "worktreeRoot", "worktreeGitDir", "branch"].every(text))
156
+ return null;
157
+ if (typeof value.requestedAt !== "number" || !Number.isFinite(value.requestedAt))
158
+ return null;
159
+ if (value.change !== undefined && typeof value.change !== "string")
160
+ return null;
161
+ if (value.pr !== undefined && (typeof value.pr !== "number" || !Number.isSafeInteger(value.pr) || value.pr < 1))
162
+ return null;
163
+ // Compatibility: the checks above establish every field of the stored shape.
164
+ return value;
165
+ }
166
+ catch {
167
+ return null;
168
+ }
169
+ }
170
+ async function execute(command, arguments_, cwd, signal) {
171
+ const { execFile } = await import("node:child_process");
172
+ const { stdout } = await promisify(execFile)(command, [...arguments_], {
173
+ cwd, encoding: "utf8", windowsHide: true, timeout: CLEANUP_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, signal,
174
+ });
175
+ return stdout;
176
+ }
177
+ function bounded(text) {
178
+ const trimmed = text.trim();
179
+ return trimmed.length <= MAX_REPORT_CHARACTERS ? trimmed : `...${trimmed.slice(-MAX_REPORT_CHARACTERS)}`;
180
+ }
181
+ function samePath(left, right) {
182
+ const key = (value) => process.platform === "win32" ? resolve(value).toLowerCase() : resolve(value);
183
+ return key(left) === key(right);
184
+ }
185
+ function isRecord(value) {
186
+ return typeof value === "object" && value !== null && !Array.isArray(value);
187
+ }
188
+ function failure(code, message) {
189
+ return Object.assign(new Error(message), { code });
190
+ }
@@ -142,7 +142,7 @@ export async function createPiRuntimeIntegration(options) {
142
142
  services,
143
143
  sessionManager: targetSessionManager,
144
144
  ...(options.worktreeCwdReader === undefined ? {} : {
145
- customTools: createSessionWorktreeTools(cwd, signal => options.worktreeCwdReader(targetSessionManager.getSessionId(), targetSessionManager.getSessionFile(), signal ?? options.signal ?? new AbortController().signal)).filter(tool => !services.resourceLoader.getExtensions().extensions.some(extension => extension.tools.has(tool.name))),
145
+ customTools: createSessionWorktreeTools(cwd, signal => options.worktreeCwdReader(targetSessionManager.getSessionId(), targetSessionManager.getSessionFile(), signal ?? options.signal ?? new AbortController().signal), options.worktreeCwdReader.settleShell === undefined ? undefined : signal => options.worktreeCwdReader.settleShell(targetSessionManager.getSessionId(), targetSessionManager.getSessionFile(), signal ?? options.signal ?? new AbortController().signal)).filter(tool => !services.resourceLoader.getExtensions().extensions.some(extension => extension.tools.has(tool.name))),
146
146
  }),
147
147
  ...(sessionStartEvent ? { sessionStartEvent } : {}),
148
148
  ...(modelScope.model && !hasExistingSession ? { model: modelScope.model } : {}),
@@ -1,4 +1,7 @@
1
1
  import { type ToolDefinition } from "../startup-public.js";
2
- export type PiWorktreeCwdReader = (sessionId: string, sessionFile: string, signal: AbortSignal) => Promise<string>;
2
+ export type PiWorktreeCwdReader = ((sessionId: string, sessionFile: string, signal: AbortSignal) => Promise<string>) & {
3
+ /** Settles work a finished shell command left for the runtime; the text is appended to that command's result. */
4
+ readonly settleShell?: (sessionId: string, sessionFile: string, signal: AbortSignal) => Promise<string | null>;
5
+ };
3
6
  /** Built-in tool definitions borrow the current attachment per call, without process-global chdir. */
4
- export declare function createSessionWorktreeTools(startupCwd: string, resolveCwd: (signal?: AbortSignal) => Promise<string>): ToolDefinition[];
7
+ export declare function createSessionWorktreeTools(startupCwd: string, resolveCwd: (signal?: AbortSignal) => Promise<string>, settleShell?: (signal?: AbortSignal) => Promise<string | null>): ToolDefinition[];
@@ -1,20 +1,35 @@
1
1
  import { createBashToolDefinition, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, defineTool, } from "../startup-public.js";
2
2
  /** Built-in tool definitions borrow the current attachment per call, without process-global chdir. */
3
- export function createSessionWorktreeTools(startupCwd, resolveCwd) {
3
+ export function createSessionWorktreeTools(startupCwd, resolveCwd, settleShell) {
4
4
  function wrap(factory) {
5
5
  const definition = factory(startupCwd);
6
6
  return defineTool({
7
7
  ...definition,
8
8
  ...(definition.name === "bash" ? { promptGuidelines: [
9
9
  ...(definition.promptGuidelines ?? []),
10
- "For task worktrees, use a1 worktree enter <branch> or a1 worktree attach <absolute-path>; success confirms attachment and subsequent built-in tools use that root. a1 worktree detach preserves files. Coordination and recovery are internal; no follow-up inventory check is required.",
10
+ "For task worktrees, use a1 worktree enter <branch> or a1 worktree attach <absolute-path>; success confirms attachment and subsequent built-in tools use that root. a1 worktree detach preserves files. After a verified merge run a1 worktree complete instead of cleaning the attached checkout yourself; it returns this session to the startup checkout first and reports the repository cleanup result. Coordination and recovery are internal; no follow-up inventory check is required.",
11
11
  ] } : {}),
12
12
  defaultActive: false,
13
13
  async execute(...args) {
14
14
  const [id, params, signal, onUpdate, context] = args;
15
15
  const cwd = await resolveCwd(signal);
16
16
  signal?.throwIfAborted();
17
- return await factory(cwd).execute(id, params, signal, onUpdate, { ...context, cwd });
17
+ if (definition.name !== "bash" || settleShell === undefined)
18
+ return await factory(cwd).execute(id, params, signal, onUpdate, { ...context, cwd });
19
+ // Invariant: the shell has exited before settlement, so it no longer pins the checkout it was started in.
20
+ const settle = () => settleShell(signal).catch(error => `Worktree completion could not be settled: ${error instanceof Error ? error.message : String(error)}`);
21
+ let result;
22
+ try {
23
+ result = await factory(cwd).execute(id, params, signal, onUpdate, { ...context, cwd });
24
+ }
25
+ catch (error) {
26
+ const settled = await settle();
27
+ if (settled !== null && error instanceof Error)
28
+ error.message = `${error.message}\n\n${settled}`;
29
+ throw error;
30
+ }
31
+ const settled = await settle();
32
+ return settled === null ? result : { ...result, content: [...result.content, { type: "text", text: settled }] };
18
33
  },
19
34
  });
20
35
  }
@@ -119,7 +119,7 @@ A1 owns creation/attachment, bounded retries, and ordinary recovery behind its w
119
119
 
120
120
  Claude Code uses its established `EnterWorktree` tool by task name. The tracked `.claude/settings.json` wires `scripts/governance/claude-worktree-create.mjs` to create `.worktrees/<task-id>` detached at fresh `origin/develop`, or return an already registered path. The agent must still establish ownership before reusing that path and create the topic branch in a new detached worktree before editing. Passing an absolute `path` to `EnterWorktree` is reserved for a worktree created by hand, from primary before any shell command in that session. The matching `WorktreeRemove` hook declines removal: leave with `ExitWorktree` `keep` and use only the guarded cleanup tool for removal. Other agents do not need these tools or hooks.
121
121
 
122
- A1 attachment updates footer repository/PR discovery and subsequent built-in tool working directories without changing process-global cwd. Detach/disposal releases the runtime's claim and preserves Git data. An existing PR is not required. Explicit absolute paths retain their meaning; custom extensions and independent terminals keep their own cwd. Attachment never creates a Git lock, changes cleanup registration, or grants acceptance, finalization, merge, or deletion authority.
122
+ A1 attachment updates footer repository/PR discovery and subsequent built-in tool working directories without changing process-global cwd. Detach/disposal releases the runtime's claim and preserves Git data. After a verified merge, `a1 worktree complete` detaches first and then runs the repository's configured cleanup from the startup checkout; an attachment whose checkout is gone is released automatically. An existing PR is not required. Explicit absolute paths retain their meaning; custom extensions and independent terminals keep their own cwd. Attachment never creates a Git lock, changes cleanup registration, or grants acceptance, finalization, merge, or deletion authority.
123
123
 
124
124
  An agent that directly chooses a transient file path for a PR/comment body, command payload, captured output, temporary patch/diff, or ad hoc log must place it beneath the verified task worktree's exact `.artifacts/` root, never the primary checkout, another worktree, OS temp, home/desktop, or a sibling path. Such scratch remains ignored, unstaged, uncommitted, disposable, non-authoritative, and secret-free. This repository path rule does not relocate storage internally selected by tools, product runtime code, or hermetic tests.
125
125
 
@@ -11,9 +11,10 @@ Run these commands through an active A1 session's shell tool:
11
11
  | `a1 worktree enter <type/short-description> [--base <branch>]` | Create or resume the matching task checkout and attach it. |
12
12
  | `a1 worktree attach <absolute-path>` | Attach an existing registered task checkout in this session's repository. |
13
13
  | `a1 worktree detach` | Release this session's attachment without deleting files or branches. |
14
+ | `a1 worktree complete [--change <id>] [--pr <number>]` | After a verified merge: return this session to the startup checkout, then run the repository's configured cleanup. |
14
15
  | `a1 worktree list` | Informational same-repository inventory; never required after success. |
15
16
 
16
- Successful enter/attach returns the canonical root and branch after acquiring the editing claim. It is the complete attachment result: agents do not run a second inventory approval gate or implement their own retry loop. Temporary registration/coordination contention is retried within an operation budget. Actual ownership conflicts and uncertain records produce actionable failures rather than unsafe takeovers. A failed switch preserves the prior attachment. No command removes worktrees, branches, user files, or private workspace data.
17
+ Successful enter/attach returns the canonical root and branch after acquiring the editing claim. It is the complete attachment result: agents do not run a second inventory approval gate or implement their own retry loop. Temporary registration/coordination contention is retried within an operation budget. Actual ownership conflicts and uncertain records produce actionable failures rather than unsafe takeovers. A failed switch preserves the prior attachment. No command removes worktrees, branches, user files, or private workspace data itself; `complete` only runs the cleanup command the repository declares.
17
18
 
18
19
  Enter creates `.worktrees/<short-description>` under the primary checkout. It fetches the selected origin base before creating a new topic branch. Base selection is explicit `--base`, local Git configuration `a1.worktreeBase`, then the origin default branch; local-only repositories fall back to the primary branch. Existing matching worktrees keep their local edits and history. Path/branch collisions, primary checkouts, foreign repositories, and Git-locked worktrees are retained, not forced or adopted. Setup commands, dependency installation, private-clone provisioning, and repository hooks are not executed by A1.
19
20
 
@@ -27,12 +28,39 @@ Background footer polling activates persisted claims once per session binding, t
27
28
 
28
29
  Implementation completion and review handoff do not end the task attachment. Keep it active for review and follow-up fixes so built-in tools and footer PR discovery continue using the task checkout. Detach only when explicitly requested or switching tasks; runtime shutdown releases editing claims automatically. Repository cleanup handoff is independent and does not require runtime detach.
29
30
 
31
+ ## Completing a merged task
32
+
33
+ Never run repository cleanup against the checkout a session is attached to. The shell A1 starts for an attached session has that checkout as its working directory, and on Windows that pins the directory until the shell exits even after `cd`, so cleanup issued from it removes part of the checkout and then fails on the directory itself.
34
+
35
+ `a1 worktree complete` sequences the two steps. In the issuing shell it only checks and detaches: the attachment must be valid and owned by this session, the checkout must not be locked in Git, and `git status` must show no tracked, staged, or untracked content. A blocker leaves the session attached and changes nothing. Otherwise the session returns to its startup checkout. Once that shell has exited, the A1 process runs the configured cleanup from the startup checkout and appends the outcome to the same command's result:
36
+
37
+ | Outcome | Meaning |
38
+ | --- | --- |
39
+ | `cleanup-finished` | The cleanup command ran. Its report follows and is authoritative; A1 does not claim a removal itself. |
40
+ | `cleanup-failed` | The command exited with an error. The checkout may remain; retry with the repository's cleanup from primary. |
41
+ | `cleanup-unavailable` | The repository declares no command, or it needs `--change`/`--pr` that was not given. The checkout is retained. |
42
+ | `cleanup-skipped` | Another live session attached the checkout, its claim is unverifiable, or the request was not settled within five minutes. |
43
+
44
+ In every case the session is already on the startup checkout and its tools keep working. Do not attach a partially removed checkout again.
45
+
46
+ The cleanup command is declared once in the repository, in a committed `.a1/worktree.json`, so no machine needs any setup:
47
+
48
+ ```json
49
+ { "cleanup": ["node", "scripts/governance/local-worktree-cleanup.mjs", "complete", "--repo", "{primary}", "--path", "{worktree}", "--change", "{change}", "--pr", "{pr}"] }
50
+ ```
51
+
52
+ A1 reads the file from the primary checkout the session started in, never from the task checkout being left, so a task branch cannot change what runs until it is merged. The list is run without a shell from the primary checkout, with `{primary}`, `{worktree}`, `{branch}`, `{change}`, and `{pr}` substituted in each element; the first element must be a real executable such as `node`, not a `.cmd` shim. A repository without the file needs nothing: completion detaches, keeps the checkout, and reports `cleanup-unavailable`, and the repository's ordinary cleanup still applies. The command keeps every eligibility and deletion safeguard; A1 adds no deletion of its own. Delivery evidence is the command's to judge, so completing an unmerged task ends detached with the files intact and the command's blocker.
53
+
54
+ ## Invalid attachments
55
+
56
+ If the attached checkout disappears, loses its Git pointer, or is replaced by a different worktree while the startup checkout still verifies, A1 releases this session's own association and editing claim. The built-in tool call that discovers it fails once with `worktree-attachment-invalid`, naming the released path and the startup checkout now in use, so work addressed to the lost checkout is never silently applied elsewhere. The next call runs from the startup checkout, and a resumed session recovers the same way. Uncertain results such as a timeout or an unverifiable startup checkout release nothing. `a1 worktree detach` works without validating the attached checkout. No file, branch, or other session's claim is removed.
57
+
30
58
  Claims coordinate participating A1 sessions. They are not a sandbox against arbitrary external editors, an editing lock in Git, repository cleanup ownership, or permission to merge/delete anything. Delivery and protected cleanup remain independent.
31
59
 
32
60
  ## Shared repository guidance
33
61
 
34
62
  Use this identical block in repositories, without adding another inventory approval gate:
35
63
 
36
- > In an active A1 session, use `a1 worktree enter <branch>` or `a1 worktree attach <absolute-path>`. Work in the returned directory; successful completion is the attachment confirmation. Keep the task attached after implementation completion and review handoff so follow-up fixes retain its checkout and PR. Use `a1 worktree detach` only when explicitly requested or switching tasks; it preserves files and branches. Runtime shutdown releases claims automatically. A1 owns coordination, retries, and recovery; `a1 worktree list` is informational. Outside A1, use ordinary Git worktrees.
64
+ > In an active A1 session, use `a1 worktree enter <branch>` or `a1 worktree attach <absolute-path>`. Work in the returned directory; success is the attachment confirmation. Keep the task attached after implementation completion and review handoff so follow-up fixes retain its checkout and PR. After a verified merge, run `a1 worktree complete`: it returns the session to primary before repository cleanup runs, so never clean the attached checkout directly. Use `a1 worktree detach` only when explicitly requested or switching tasks; it preserves files and branches. Runtime shutdown releases claims automatically. A1 owns coordination, retries, and recovery; `a1 worktree list` is informational. Outside A1, use ordinary Git worktrees.
37
65
 
38
66
  Install an A1 release exposing this interface before integrating a repository's instruction migration. Older installations only expose the session-context aliases; unknown commands in those older releases may return quietly without performing an operation. A migration PR is not evidence that a compatible installed release is active.
@@ -103,6 +103,8 @@ An exact `complete` invocation may additionally use `--stop-dev-processes`. This
103
103
  cd /d/Git/a1 && node scripts/governance/local-worktree-cleanup.mjs complete --repo D:/Git/a1 --path D:/Git/a1/.worktrees/example-task --change example-change --pr 123 --stop-dev-processes
104
104
  ```
105
105
 
106
+ Inside an A1 session attached to the candidate, do not run this command yourself: run `a1 worktree complete --change example-change --pr 123`, which returns the session to primary and then runs this same command as declared in the repository's `.a1/worktree.json` (see [worktree commands](features/worktrees.md#completing-a-merged-task)). `complete`, sweep, and the queue report `blocked` with `attached-session` while a running A1 session still has the checkout attached, and `attached-session-unverifiable` when that checkout's claim record cannot be read; the check reads A1's claim record only, changes nothing, and needs no A1 installation. Explicit `discard` and `retire-redundant` do not yet apply it.
107
+
106
108
  Agents do not manually remove generated content, call `git worktree remove`, or delete the local branch after delivery. The JSON result is authoritative: report success only for `removed` or verified `already-absent`; otherwise retain the worktree and report the exact blocker. A handed-off entry whose worktree was deleted by hand completes through its journal, and its branch is deleted under the accepted-ancestry rule. Legacy roles can supply separate `--source-pr`, `--candidate-pr`, and `--role` values.
107
109
 
108
110
  ## Explicit closed-unmerged discard
@@ -319,7 +321,7 @@ Disable writes a stop sentinel without waiting for a busy state or resource lock
319
321
  Dependency-free focused fixtures (temporary repositories only, no live PR mutation or watcher activation):
320
322
 
321
323
  ```bash
322
- node --test test/repository-governance/local-cleanup.node.mjs test/repository-governance/local-cleanup-evidence.node.mjs test/repository-governance/local-cleanup-watch.node.mjs
324
+ node --test test/repository-governance/local-cleanup.node.mjs test/repository-governance/local-cleanup-evidence.node.mjs test/repository-governance/local-cleanup-watch.node.mjs test/repository-governance/local-cleanup-attachment.node.mjs
323
325
  ```
324
326
 
325
327
  The Vitest bridge includes these fixtures in ordinary fast CI, whose runner is Windows. On Windows the suite exercises an actual exclusive file handle and a junction; a non-Windows run explicitly skips the Windows-only handle case rather than claiming it passed. The handle fixture starts one throwaway `powershell.exe` when the file loads so the hosted runner's interpreter cold start overlaps the earlier tests instead of the fixture's timed hold; its bounds and release protocol are unchanged. Required current-head CI and a separately authorized live archive lifecycle remain necessary before acceptance; fixtures and preview do not replace those gates.
@@ -306,7 +306,7 @@ After version-3 merge, verify:
306
306
  - the declared archive, conditional manifest, and synchronized specs match their digests; and
307
307
  - the unchanged remote topic ref is absent.
308
308
 
309
- Only then shall the owning agent invoke the exact-candidate `complete` operation documented in [local cleanup](local-worktree-cleanup.md) from the primary checkout. The command owns registration/release, repository-generated disposables, one bounded evidence pass, non-force worktree removal, and unchanged local-ref cleanup; agents do not manually delete generated content, worktrees, or branches. Version 3 uses the implementation PR as both source and candidate and does not wait for nonexistent acceptance/archive PRs.
309
+ Only then shall the owning agent invoke the exact-candidate `complete` operation documented in [local cleanup](local-worktree-cleanup.md) from the primary checkout. An A1 session still attached to that checkout uses `a1 worktree complete --change <change> --pr <pr>` instead, which leaves the checkout before the same operation runs. The command owns registration/release, repository-generated disposables, one bounded evidence pass, non-force worktree removal, and unchanged local-ref cleanup; agents do not manually delete generated content, worktrees, or branches. Version 3 uses the implementation PR as both source and candidate and does not wait for nonexistent acceptance/archive PRs.
310
310
 
311
311
  Closing an unmerged PR does not by itself authorize local or remote deletion. After explicit candidate-specific rejection and remote-deletion confirmation, the repository-owned local `discard` command may compare-and-delete only that closed-unmerged PR's exact unchanged same-repository unprotected topic ref, then apply the ordinary non-force local safeguards. Automatic remote cleanup remains merge-only and never touches local worktrees.
312
312
 
@@ -33,5 +33,7 @@ When the old gate is absent, updated A1 initializes its compatibility barrier au
33
33
  | `session-runtime-unverifiable` | The runtime's recorded process cannot be checked. Retry; no ownership takeover is authorized. |
34
34
  | `worktree-active-in-another-session` | Another live agent owns the target. Use a different worktree or have that owner release it. |
35
35
  | `worktree-owner-unverifiable` | The editing claim itself is uncertain. Gate recovery cannot make that claim available. |
36
+ | `worktree-attachment-invalid` | The attached checkout is gone or replaced. A1 released this session's attachment; retry the call from the startup checkout. |
37
+ | `worktree-not-attached` / `worktree-content` | Completion needs an attached checkout without uncommitted or untracked content. Nothing was changed. |
36
38
 
37
39
  Diagnostics do not expose another session's process identity, session file, transcript, or credentials. The mechanism does not repair arbitrary damaged claim records, change tool cwd, or grant Git/cleanup authority.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timurproko/a1",
3
- "version": "0.2.7-dev.1287",
3
+ "version": "0.2.7-dev.1288",
4
4
  "description": "Standalone single-agent terminal UI on pinned Pi",
5
5
  "type": "module",
6
6
  "privateLaunchContract": "neutral-launch-v1",
@@ -113,7 +113,7 @@
113
113
  "@mariozechner/clipboard": "^0.3.9"
114
114
  },
115
115
  "publication": {
116
- "source": "df8f3fea78404f0413ce40de8d39b7102ead643a",
117
- "pullRequest": 914
116
+ "source": "bfbed9a4849fe7ba1b6a879cef7a800a132e18e9",
117
+ "pullRequest": 915
118
118
  }
119
119
  }