@north-light/crouter 0.3.275 → 0.3.277

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.
Files changed (36) hide show
  1. package/dist/builtin-memory/02-turn-lifecycle/01-terminal.md +1 -1
  2. package/dist/clients/attach/viewer.js +1 -1
  3. package/dist/commands/cron.js +3 -3
  4. package/dist/commands/node/create.js +3 -1
  5. package/dist/commands/node-worktree.js +7 -7
  6. package/dist/commands/pkg/plugin-manage.js +19 -8
  7. package/dist/commands/push.js +1 -1
  8. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  9. package/dist/core/__tests__/helpers/harness.js +12 -10
  10. package/dist/core/__tests__/integration/command-plugins.test.js +6 -6
  11. package/dist/core/__tests__/integration/worktree-land.test.js +7 -2
  12. package/dist/core/__tests__/seam/broker-provider-retry.test.js +3 -16
  13. package/dist/core/canvas/types.d.ts +18 -0
  14. package/dist/core/command-manifests/manifest.d.ts +4 -2
  15. package/dist/core/command-plugins/bundle.d.ts +7 -0
  16. package/dist/core/command-plugins/bundle.js +18 -4
  17. package/dist/core/command-plugins/help-addenda.d.ts +12 -3
  18. package/dist/core/command-plugins/help-addenda.js +17 -4
  19. package/dist/core/command.js +2 -2
  20. package/dist/core/git.d.ts +5 -1
  21. package/dist/core/git.js +18 -4
  22. package/dist/core/runtime/spawn.d.ts +2 -0
  23. package/dist/core/runtime/spawn.js +1 -1
  24. package/dist/core/worktree-sweep.js +28 -5
  25. package/dist/core/worktree.d.ts +1 -12
  26. package/dist/core/worktree.js +222 -10
  27. package/dist/daemon/api/handlers/crons.js +5 -4
  28. package/dist/daemon/api/handlers/messages.js +5 -0
  29. package/dist/daemon/api/handlers/nodes.js +2 -0
  30. package/dist/daemon/api/handlers/reports.js +11 -7
  31. package/dist/daemon/reconcilers/live-obligation.d.ts +3 -2
  32. package/dist/daemon/reconcilers/live-obligation.js +4 -0
  33. package/dist/types.d.ts +3 -3
  34. package/dist/types.js +2 -2
  35. package/package.json +3 -1
  36. package/runtime.lock.json +2 -2
@@ -821,9 +821,9 @@ describe('typed validation issues', () => {
821
821
  describe('plugin help addenda', () => {
822
822
  const gate = (paths) => ({
823
823
  reservedCoreNames: RESERVED,
824
- coreCommandPaths: new Set(paths),
824
+ coreCommandPaths: () => Promise.resolve(new Set(paths)),
825
825
  });
826
- test('a helpAddenda entry survives validation and is collected attributed to its plugin', () => {
826
+ test('a helpAddenda entry survives validation and is collected attributed to its plugin', async () => {
827
827
  const m = commandsJson();
828
828
  m['helpAddenda'] = { cron: 'Gate on the device: exit 75 parks the occurrence.' };
829
829
  installPlugin(userRoot, 'northlight-fixture', { manifest: m });
@@ -833,12 +833,12 @@ describe('plugin help addenda', () => {
833
833
  assert.equal(v.issues.length, 0, JSON.stringify(v.issues));
834
834
  assert.deepEqual(v.manifest?.helpAddenda, { cron: 'Gate on the device: exit 75 parks the occurrence.' });
835
835
  // The help-render lookup: attributed, and empty for an unaddended path.
836
- assert.deepEqual(collectHelpAddenda('cron', gate(['cron', 'node']), emptyStart), [
836
+ assert.deepEqual(await collectHelpAddenda('cron', gate(['cron', 'node']), emptyStart), [
837
837
  { plugin: 'northlight-fixture', text: 'Gate on the device: exit 75 parks the occurrence.' },
838
838
  ]);
839
- assert.deepEqual(collectHelpAddenda('node', gate(['cron', 'node']), emptyStart), []);
839
+ assert.deepEqual(await collectHelpAddenda('node', gate(['cron', 'node']), emptyStart), []);
840
840
  });
841
- test('a key naming no core command path fails the strict gate and renders nothing, even beside a valid key', () => {
841
+ test('a key naming no core command path fails the strict gate and renders nothing, even beside a valid key', async () => {
842
842
  const m = commandsJson();
843
843
  m['helpAddenda'] = {
844
844
  cron: 'a valid target beside the typo',
@@ -860,7 +860,7 @@ describe('plugin help addenda', () => {
860
860
  // The render lookup reproduces the GATE verdict, not the compose one: the
861
861
  // rejected map contributes nothing — including its valid key — so render
862
862
  // can never show what the install report said was rejected.
863
- assert.deepEqual(collectHelpAddenda('cron', gate(['cron', 'cron add']), emptyStart), []);
863
+ assert.deepEqual(await collectHelpAddenda('cron', gate(['cron', 'cron add']), emptyStart), []);
864
864
  });
865
865
  test('an installed addendum renders through the real help path as an attributed block', async () => {
866
866
  const m = commandsJson();
@@ -440,8 +440,13 @@ test('close concludes a clean child after parent-first closure removes its check
440
440
  run(['commit', '-m', 'init'], repo);
441
441
  const parent = createManagedWorktree(repo, 'wt-parent-first-parent');
442
442
  createNode(node('wt-parent-first-parent', { cwd: parent.path, managed_worktree: parent }));
443
- const child = createManagedWorktree(parent.path, 'wt-parent-first-child', parent.branch);
444
- createNode(node('wt-parent-first-child', { cwd: child.path, managed_worktree: child }));
443
+ // Creation now refuses a managed worktree as its base (`managed_worktree_base`),
444
+ // so the child is cut from the real repository against the parent's BRANCH, and
445
+ // its record carries the parent checkout as `repo_root` — the exact legacy shape
446
+ // this case is about, still on disk for every record created before that guard.
447
+ const child = createManagedWorktree(repo, 'wt-parent-first-child', parent.branch);
448
+ const childRecord = { ...child, repo_root: parent.path };
449
+ createNode(node('wt-parent-first-child', { cwd: child.path, managed_worktree: childRecord }));
445
450
  assert.equal(gitSync(['rev-parse', 'HEAD'], child.path).stdout.trim(), child.base_sha, 'the child must be a clean zero-commit checkout at its recorded base');
446
451
  closeManagedWorktree('wt-parent-first-parent');
447
452
  run(['worktree', 'remove', parent.path], repo);
@@ -9,7 +9,7 @@ import { test, before, after, afterEach } from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
10
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
- import { createHeadlessHarness } from '../helpers/harness.js';
12
+ import { createHeadlessHarness, readLines } from '../helpers/harness.js';
13
13
  import { createAttachKit } from '../helpers/broker-clients.js';
14
14
  import { defaultModelLaddersConfig } from '../../../types.js';
15
15
  import { admitProviderRetryEpisode, readFault, readProviderRetryEpisode } from '../../runtime/fault.js';
@@ -63,21 +63,8 @@ function overflowOutcome(id) {
63
63
  // re-drive is a fresh session.prompt → emitTurn, which fires one 'agent_start';
64
64
  // counting them is the observable proof of how many times the turn was re-driven.
65
65
  function agentStartCount(id) {
66
- try {
67
- return readFileSync(join(h.home, 'nodes', id, 'fake-pi.events.jsonl'), 'utf8')
68
- .split('\n')
69
- .filter((l) => {
70
- try {
71
- return JSON.parse(l).event === 'agent_start';
72
- }
73
- catch {
74
- return false;
75
- }
76
- }).length;
77
- }
78
- catch {
79
- return 0;
80
- }
66
+ return readLines(join(h.home, 'nodes', id, 'fake-pi.events.jsonl'))
67
+ .filter((line) => JSON.parse(line).event === 'agent_start').length;
81
68
  }
82
69
  async function crashAndRespawn(id, boots) {
83
70
  const pid = h.node(id)?.pi_pid;
@@ -106,6 +106,24 @@ export interface ManagedWorktree {
106
106
  blocked: string;
107
107
  };
108
108
  sweep?: ManagedWorktreeSweep;
109
+ /** Recorded ONLY by an explicit branch-moved `close` when the checkout's
110
+ * actual branch has drifted from `branch` (its recorded `crtr/*` ref is
111
+ * gone — ordinary PR work moved it there) but that branch's content was
112
+ * proven, immediately before this was written, already contained in the
113
+ * recorded base or its upstream. Authorizes ONLY the daemon sweep's later
114
+ * checkout removal for this EXACT path + git common dir + observed branch
115
+ * + observed SHA — the sweep re-verifies all four before acting and NEVER
116
+ * deletes `observed_branch`, since it is not a `crtr/*` branch crouter
117
+ * created. A mismatch on re-verification invalidates the authorization; a
118
+ * fresh explicit close must re-establish it. Absent for every ordinary
119
+ * managed worktree, and for `abandon`'s branch-moved path (which disposes
120
+ * immediately instead of deferring, so it needs no persisted grant). */
121
+ disposal?: {
122
+ observed_branch: string;
123
+ observed_sha: string;
124
+ common_dir: string;
125
+ authorized: string;
126
+ };
109
127
  }
110
128
  /** Immutable companion provenance. The origin session/leaf/model coordinates
111
129
  * remain canonical in the review row rather than being duplicated into node
@@ -29,8 +29,10 @@ export declare function validateCommandManifest(raw: unknown, options: {
29
29
  * (install, bundle parse, doctor/inspect reports) so a helpAddenda key
30
30
  * naming no core command fails there — and by the help-render lookup
31
31
  * (`collectHelpAddenda`), which reproduces the gate's verdict so render
32
- * and ingress report can never disagree. Omitted only on the tolerant
33
- * compose path, where mounting ignores addenda entirely. */
32
+ * and ingress report can never disagree. Omitted on the tolerant compose
33
+ * path, where mounting ignores addenda entirely, and on that lookup's
34
+ * claimant pass, which only needs to know whether any addendum targets the
35
+ * rendered path at all. */
34
36
  coreCommandPaths?: ReadonlySet<string>;
35
37
  /** A repository fragment attaches roots below an existing extensible branch. */
36
38
  extensionFragment?: boolean;
@@ -32,6 +32,13 @@ export interface ValidatedBundle {
32
32
  path: string;
33
33
  bytes: Uint8Array;
34
34
  }>;
35
+ hooks?: {
36
+ manifest: Uint8Array;
37
+ files: ReadonlyArray<{
38
+ path: string;
39
+ bytes: Uint8Array;
40
+ }>;
41
+ };
35
42
  }
36
43
  export interface PluginBundleValidation {
37
44
  bundle?: ValidatedBundle;
@@ -38,13 +38,13 @@ function validateMemberName(path, type) {
38
38
  }
39
39
  function validateMemberShape(path, type) {
40
40
  if (type === 'file') {
41
- if (path === 'bundle.json' || path === 'commands.json' || (path.startsWith('memory/') && path.endsWith('.md')))
41
+ if (path === 'bundle.json' || path === 'commands.json' || path === 'hooks.json' || (path.startsWith('memory/') && path.endsWith('.md')) || path.startsWith('hooks/'))
42
42
  return undefined;
43
- return bundleInvalid('bundle contains an unsupported file member', path, 'bundle.json, commands.json, or memory/**/*.md', 'Remove unsupported members from the archive.', path);
43
+ return bundleInvalid('bundle contains an unsupported file member', path, 'bundle.json, commands.json, hooks.json, memory/**/*.md, or hooks/**', 'Remove unsupported members from the archive.', path);
44
44
  }
45
- if (path === 'memory' || path.startsWith('memory/'))
45
+ if (path === 'memory' || path.startsWith('memory/') || path === 'hooks' || path.startsWith('hooks/'))
46
46
  return undefined;
47
- return bundleInvalid('bundle contains an unsupported directory member', path, 'memory or a directory below memory/', 'Remove unsupported directory members from the archive.', path);
47
+ return bundleInvalid('bundle contains an unsupported directory member', path, 'memory or hooks, or a directory below either subtree', 'Remove unsupported directory members from the archive.', path);
48
48
  }
49
49
  /** A member path as it will exist on disk: directories carry no trailing slash. */
50
50
  function materializedPath(member) {
@@ -221,6 +221,14 @@ export async function validatePluginBundle(archive, options) {
221
221
  issues: [bundleInvalid('bundle must contain exactly one bundle.json and one commands.json regular file', `bundle.json=${bundle !== undefined}, commands.json=${commands !== undefined}`, 'one regular bundle.json and one regular commands.json member', 'Add the required metadata members to the archive.')],
222
222
  };
223
223
  }
224
+ const hookManifest = parsed.members.find((member) => member.path === 'hooks.json' && member.type === 'file');
225
+ const hookFiles = parsed.members.filter((member) => member.type === 'file' && member.path.startsWith('hooks/'));
226
+ const carriesHooks = hookManifest !== undefined || parsed.members.some((member) => member.path === 'hooks' || member.path.startsWith('hooks/'));
227
+ if (carriesHooks && (hookManifest === undefined || !hookFiles.some((member) => member.path === 'hooks/dispatch'))) {
228
+ return {
229
+ issues: [bundleInvalid('bundle hook payload must contain both hooks.json and hooks/dispatch regular files', `hooks.json=${hookManifest !== undefined}, hooks/dispatch=${hookFiles.some((member) => member.path === 'hooks/dispatch')}`, 'both hooks.json and hooks/dispatch regular files', 'Add both required hook members or remove the hook payload.')],
230
+ };
231
+ }
224
232
  const metadata = parseBundleMetadata(bundle.bytes);
225
233
  if (metadata.issue !== undefined)
226
234
  return { issues: [metadata.issue] };
@@ -241,6 +249,12 @@ export async function validatePluginBundle(archive, options) {
241
249
  memory: parsed.members
242
250
  .filter((member) => member.type === 'file' && member.path.startsWith('memory/'))
243
251
  .map((member) => ({ path: member.path, bytes: member.bytes })),
252
+ ...(hookManifest !== undefined ? {
253
+ hooks: {
254
+ manifest: hookManifest.bytes,
255
+ files: hookFiles.map((member) => ({ path: member.path, bytes: member.bytes })),
256
+ },
257
+ } : {}),
244
258
  },
245
259
  issues: [],
246
260
  };
@@ -5,8 +5,17 @@ export interface HelpAddendum {
5
5
  /** Collect every enabled plugin's addendum targeting `commandPath` (the
6
6
  * space-joined walked path, e.g. "cron add"), in plugin-name order. `gate`
7
7
  * carries the strict-validation inputs the install/bundle gates use; the
8
- * help renderer already holds both (build-root exports them). */
8
+ * help renderer already holds both (build-root exports them).
9
+ *
10
+ * `coreCommandPaths` arrives as a thunk because producing that set walks EVERY
11
+ * core subtree — the whole command tree loaded on a path that renders one
12
+ * leaf's help. So the claimant pass below runs the SAME validator without it:
13
+ * the core path set can only reject an addendum, never introduce one, so a
14
+ * commandPath no manifest claims tolerantly claims none under the gate either,
15
+ * and the expensive input is never forced. Claimants then re-validate strictly,
16
+ * reproducing the gate's exact verdict — a manifest the gate rejects renders
17
+ * nothing, including its valid keys. */
9
18
  export declare function collectHelpAddenda(commandPath: string, gate: {
10
19
  reservedCoreNames: ReadonlySet<string>;
11
- coreCommandPaths: ReadonlySet<string>;
12
- }, startDir?: string, profileId?: string | null): HelpAddendum[];
20
+ coreCommandPaths: () => Promise<ReadonlySet<string>>;
21
+ }, startDir?: string, profileId?: string | null): Promise<HelpAddendum[]>;
@@ -15,13 +15,26 @@ import { effectiveCommandPlugins, validatePluginCommands } from './discovery.js'
15
15
  /** Collect every enabled plugin's addendum targeting `commandPath` (the
16
16
  * space-joined walked path, e.g. "cron add"), in plugin-name order. `gate`
17
17
  * carries the strict-validation inputs the install/bundle gates use; the
18
- * help renderer already holds both (build-root exports them). */
19
- export function collectHelpAddenda(commandPath, gate, startDir, profileId) {
18
+ * help renderer already holds both (build-root exports them).
19
+ *
20
+ * `coreCommandPaths` arrives as a thunk because producing that set walks EVERY
21
+ * core subtree — the whole command tree loaded on a path that renders one
22
+ * leaf's help. So the claimant pass below runs the SAME validator without it:
23
+ * the core path set can only reject an addendum, never introduce one, so a
24
+ * commandPath no manifest claims tolerantly claims none under the gate either,
25
+ * and the expensive input is never forced. Claimants then re-validate strictly,
26
+ * reproducing the gate's exact verdict — a manifest the gate rejects renders
27
+ * nothing, including its valid keys. */
28
+ export async function collectHelpAddenda(commandPath, gate, startDir, profileId) {
20
29
  if (commandPath.length === 0)
21
30
  return [];
31
+ const claimants = effectiveCommandPlugins(startDir, profileId).filter((plugin) => validatePluginCommands(plugin, gate.reservedCoreNames).manifest?.helpAddenda?.[commandPath] !== undefined);
32
+ if (claimants.length === 0)
33
+ return [];
34
+ const coreCommandPaths = await gate.coreCommandPaths();
22
35
  const out = [];
23
- for (const plugin of effectiveCommandPlugins(startDir, profileId)) {
24
- const text = validatePluginCommands(plugin, gate.reservedCoreNames, gate.coreCommandPaths).manifest?.helpAddenda?.[commandPath];
36
+ for (const plugin of claimants) {
37
+ const text = validatePluginCommands(plugin, gate.reservedCoreNames, coreCommandPaths).manifest?.helpAddenda?.[commandPath];
25
38
  if (text !== undefined)
26
39
  out.push({ plugin: plugin.name, text });
27
40
  }
@@ -209,9 +209,9 @@ async function renderNodeWithAddenda(node, path, focusedFlag) {
209
209
  import('./command-plugins/help-addenda.js'),
210
210
  import('../build-root.js'),
211
211
  ]);
212
- const addenda = collectHelpAddenda(path.join(' '), {
212
+ const addenda = await collectHelpAddenda(path.join(' '), {
213
213
  reservedCoreNames: new Set(SUBTREE_NAMES),
214
- coreCommandPaths: await coreCommandPaths(),
214
+ coreCommandPaths,
215
215
  });
216
216
  return body + addenda.map((a) => `\n\n<plugin-help plugin="${a.plugin}">\n${a.text}\n</plugin-help>`).join('');
217
217
  }
@@ -11,7 +11,11 @@ export interface GitSyncOptions {
11
11
  timeoutMs?: number;
12
12
  }
13
13
  export declare function gitSync(args: string[], cwd?: string, input?: string, options?: GitSyncOptions): GitResult;
14
- export declare function gitAsync(args: string[], cwd?: string, input?: string): Promise<GitResult>;
14
+ export interface GitAsyncOptions {
15
+ timeoutMs?: number;
16
+ env?: NodeJS.ProcessEnv;
17
+ }
18
+ export declare function gitAsync(args: string[], cwd?: string, input?: string, options?: GitAsyncOptions): Promise<GitResult>;
15
19
  export declare function clone(url: string, dest: string, opts?: {
16
20
  ref?: string;
17
21
  depth?: number;
package/dist/core/git.js CHANGED
@@ -10,22 +10,36 @@ export function gitSync(args, cwd, input, options = {}) {
10
10
  const timedOut = res.error?.code === 'ETIMEDOUT';
11
11
  return { status, stdout, stderr, ...(completed ? {} : { executionFailed: true }), ...(processError === undefined ? {} : { processError }), ...(timedOut ? { timedOut: true } : {}) };
12
12
  }
13
- export async function gitAsync(args, cwd, input) {
13
+ export async function gitAsync(args, cwd, input, options = {}) {
14
14
  return new Promise((resolve) => {
15
- const child = spawn('git', args, { cwd });
15
+ const child = spawn('git', args, { cwd, ...(options.env === undefined ? {} : { env: options.env }) });
16
16
  if (input === undefined)
17
17
  child.stdin.end();
18
18
  else
19
19
  child.stdin.end(input);
20
20
  let stdout = '';
21
21
  let stderr = '';
22
+ let timedOut = false;
23
+ let settled = false;
24
+ const timer = options.timeoutMs === undefined ? undefined : setTimeout(() => {
25
+ timedOut = true;
26
+ child.kill('SIGTERM');
27
+ }, options.timeoutMs);
28
+ const settle = (result) => {
29
+ if (settled)
30
+ return;
31
+ settled = true;
32
+ if (timer !== undefined)
33
+ clearTimeout(timer);
34
+ resolve(result);
35
+ };
22
36
  child.stdout.on('data', (d) => (stdout += d.toString()));
23
37
  child.stderr.on('data', (d) => (stderr += d.toString()));
24
38
  child.on('close', (status) => {
25
39
  const code = typeof status === 'number' ? status : 1;
26
- resolve({ status: code, stdout, stderr });
40
+ settle({ status: code, stdout, stderr, ...(timedOut ? { timedOut: true, executionFailed: true } : {}) });
27
41
  });
28
- child.on('error', (e) => resolve({ status: 1, stdout: '', stderr: String(e) }));
42
+ child.on('error', (e) => settle({ status: 1, stdout: '', stderr: String(e), executionFailed: true, processError: String(e) }));
29
43
  });
30
44
  }
31
45
  export function clone(url, dest, opts = {}) {
@@ -62,6 +62,8 @@ export interface SpawnChildOpts {
62
62
  worktree?: boolean;
63
63
  /** Local branch the managed worktree is cut from and later landed onto. */
64
64
  worktreeBase?: string;
65
+ /** Invocation-selected repository from which to cut a managed worktree. */
66
+ worktreeCwd?: string;
65
67
  /** Preallocated node id, used when a managed worktree must be named before birth. */
66
68
  nodeId?: string;
67
69
  /** Hidden ambient "current situation" text — NOT the visible prompt/body.
@@ -251,7 +251,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
251
251
  let spawnCwd = opts.cwd;
252
252
  try {
253
253
  if (wantsWorktree) {
254
- managedWorktree = createManagedWorktree(opts.cwd, nodeId, opts.worktreeBase);
254
+ managedWorktree = createManagedWorktree(opts.worktreeCwd ?? opts.cwd, nodeId, opts.worktreeBase);
255
255
  spawnCwd = managedWorktree.path;
256
256
  }
257
257
  // Spine: a managed child reports up to its spawner (has a manager); an
@@ -336,7 +336,7 @@ export async function reconcileManagedWorktree(nodeId, guard, now = Date.now())
336
336
  return { status: 'skipped', reason: 'already_reconciled' };
337
337
  const cwd = existsSync(wt.path) ? wt.path : wt.repo_root;
338
338
  if (!existsSync(cwd)) {
339
- return finish(nodeId, wt, { status: 'refused', reason: 'repo_unavailable', detail: `neither the managed checkout (${wt.path}) nor its repository root (${wt.repo_root}) is present` }, { base: null, branch: null, checkout: null }, now, guard);
339
+ return persistComplete(nodeId, wt, guard);
340
340
  }
341
341
  const commonDir = await commonGitDirAsync(cwd);
342
342
  if (commonDir === null) {
@@ -423,9 +423,15 @@ async function reconcilePresentCheckout(nodeId, wt, guard) {
423
423
  const list = await gitAsync(['worktree', 'list', '--porcelain'], wt.path);
424
424
  if (list.status !== 0)
425
425
  return { status: 'failed', reason: 'worktree_list_failed', detail: detailOf(list) };
426
- const registeredPath = registeredCheckoutPath(list.stdout, wt.path, wt.branch);
426
+ // An explicit `close` already proved a branch-moved checkout's identity and
427
+ // authorized its later disposal for THIS exact branch — never adopted here.
428
+ // Only the recorded authorization changes which branch this pass expects;
429
+ // everything else below is re-verified exactly as for an ordinary record.
430
+ const disposal = wt.disposal;
431
+ const expectedBranch = disposal?.observed_branch ?? wt.branch;
432
+ const registeredPath = registeredCheckoutPath(list.stdout, wt.path, expectedBranch);
427
433
  if (registeredPath === null) {
428
- return { status: 'refused', reason: 'not_registered', detail: `${wt.path} is not registered to refs/heads/${wt.branch}; crtr will not act on a path it cannot prove it owns` };
434
+ return { status: 'refused', reason: 'not_registered', detail: `${wt.path} is not registered to refs/heads/${expectedBranch}; crtr will not act on a path it cannot prove it owns` };
429
435
  }
430
436
  const main = mainWorktreePath(list.stdout);
431
437
  if (main === null || samePath(main, registeredPath) || !existsSync(main)) {
@@ -439,8 +445,8 @@ async function reconcilePresentCheckout(nodeId, wt, guard) {
439
445
  const head = await gitAsync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path);
440
446
  if (head.status !== 0)
441
447
  return { status: 'failed', reason: 'branch_check_failed', detail: detailOf(head) };
442
- if (head.stdout.trim() !== wt.branch) {
443
- return { status: 'refused', reason: 'wrong_branch', detail: `expected ${wt.branch}, found ${head.stdout.trim() === 'HEAD' ? 'detached HEAD' : head.stdout.trim()}` };
448
+ if (head.stdout.trim() !== expectedBranch) {
449
+ return { status: 'refused', reason: 'wrong_branch', detail: `expected ${expectedBranch}, found ${head.stdout.trim() === 'HEAD' ? 'detached HEAD' : head.stdout.trim()}` };
444
450
  }
445
451
  // Uncommitted work blocks removal unconditionally. No containment proof
446
452
  // overrides this: those reason about committed content, and nothing here has
@@ -454,6 +460,23 @@ async function reconcilePresentCheckout(nodeId, wt, guard) {
454
460
  let branchSha = await revParseRef(wt.path, 'HEAD');
455
461
  if (branchSha === null)
456
462
  return { status: 'failed', reason: 'head_unreadable', detail: `could not read HEAD in ${wt.path}` };
463
+ if (disposal !== undefined) {
464
+ // Re-verify the exact identity `close` authorized before mutating anything.
465
+ // A mismatch invalidates the authorization instead of adopting whatever is
466
+ // observed now — only the explicit command that wrote it may refresh it.
467
+ const commonDir = await commonGitDirAsync(wt.path);
468
+ if (commonDir !== disposal.common_dir || branchSha !== disposal.observed_sha) {
469
+ return { status: 'refused', reason: 'disposal_authorization_stale', detail: `${wt.path} changed since \`close\` authorized its disposal; rerun \`crtr node worktree close\` from that node to re-authorize, or ask the user with \`crtr human send\`` };
470
+ }
471
+ if (!guard.stillEligible())
472
+ return NODE_REVIVED;
473
+ const removed = await gitAsync(['worktree', 'remove', registeredPath], main);
474
+ if (removed.status !== 0)
475
+ return { status: 'refused', reason: 'worktree_remove_failed', detail: detailOf(removed) };
476
+ // `disposal.observed_branch` is not a `crtr/*` branch crouter created — it
477
+ // is preserved, never deleted, unlike the ordinary path below.
478
+ return persistComplete(nodeId, wt, guard);
479
+ }
457
480
  let proof = await proveContained(wt.path, wt, branchSha);
458
481
  let stash;
459
482
  if (proof === null && wt.land_intent !== undefined) {
@@ -115,17 +115,6 @@ export interface AutoDroppedWorktree {
115
115
  * explicit close: removal and branch deletion are deferred until that owner
116
116
  * has exited. */
117
117
  export declare function autoDropCleanManagedWorktreeLocked(nodeId: string, wt: ManagedWorktree, proof: CleanEmptyProof): AutoDroppedWorktree | null;
118
- /** `push final`'s escape hatch: a managed worktree that has
119
- * nothing to land is auto-dropped instead of blocking on an explicit
120
- * `node worktree close` — one the caller may have been told NOT to run
121
- * because a manager already landed the same change another way, or
122
- * that never had anything to close in the first place (a read-only
123
- * review node). Returns null and changes nothing when the worktree is not
124
- * PROVABLY empty-and-clean by `proveCleanEmptyManagedWorktree`, OR when a
125
- * concurrent mutation invalidates that proof before the record is closed
126
- * (see `autoDropCleanManagedWorktreeLocked`); the caller's existing
127
- * `open_managed_worktree` guard then still applies unchanged — real
128
- * commits, real uncommitted changes, and a lost race all require the
129
- * explicit path. */
118
+ export declare function branchIsPushedAtCurrentTip(wt: ManagedWorktree): Promise<boolean>;
130
119
  export declare function autoDropCleanManagedWorktree(nodeId: string): AutoDroppedWorktree | null;
131
120
  export {};