@north-light/crouter 0.3.275 → 0.3.276

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.
@@ -134,7 +134,7 @@ const addLeaf = defineLeaf({
134
134
  { kind: 'flag', name: 'at', type: 'string', required: false, constraint: 'One-shot: run the stored bash once at <when> — a duration ("90s","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00", interpreted in --tz else host zone). Exactly one of --at / --every is required. The row is deleted after its run unless the run\'s disposition pauses it.' },
135
135
  { kind: 'flag', name: 'every', type: 'string', required: false, constraint: 'Recurring: run the stored bash at each cadence — a fixed interval ("6h" — first fire one interval from now), a 5-field cron ("0 9 * * *"), or an @alias ("@daily"). Each fire is another bash run. Minimum cadence 60s. Exactly one of --at / --every is required. For a gated cron (a command that exits 75 while ineligible), pick a LONG cadence: each natural slot is only the backstop re-check, and the daemon poke supplies the latency — "6h" plus poke beats "15m" polling on both latency and cost.' },
136
136
  { kind: 'flag', name: 'tz', type: 'string', required: false, constraint: 'IANA zone (e.g. "America/New_York") for a bare-ISO --at or a calendar --every. Defaults to the host zone.' },
137
- { kind: 'flag', name: 'on-output', type: 'enum', choices: ['silent', 'on-failure', 'always', 'on-change'], required: false, default: 'on-failure', constraint: 'What each run does with its output. silent: record in the run log, tell nobody — never escalate. on-failure (default): no success delivery; a failing run pauses the cron and spawns a node to deal with it. always: deliver stdout every run to --sink at --tier. on-change: deliver stdout only when it differs from the previous run\'s — the shape polling wants.' },
137
+ { kind: 'flag', name: 'on-output', type: 'enum', choices: ['silent', 'on-failure', 'always', 'on-change'], required: false, constraint: 'What each run does with its output. Defaults to on-change for a node:<id> sink; otherwise on-failure. silent: record in the run log, tell nobody — never escalate. on-failure: no success delivery; a failing run pauses the cron and spawns a node to deal with it. always: deliver stdout every run to --sink at --tier. on-change: deliver stdout only when it differs from the previous run\'s — the shape polling wants.' },
138
138
  { kind: 'flag', name: 'sink', type: 'string', required: false, constraint: 'Where always/on-change stdout deliveries go: "node:<id>" (an existing node\'s inbox — must exist now; gone/finalized at fire time is a failure), "spawn:<kind>" (each delivered output creates a self-finishing node with stdout as kickoff — a managed child while its creator exists, otherwise a terminal parentless root), or "human" (the humanloop inbox). Required by always/on-change, rejected otherwise.' },
139
139
  { kind: 'flag', name: 'tier', type: 'enum', choices: ['normal', 'urgent', 'critical'], required: false, default: 'urgent', constraint: 'Inbox priority of a node-sink delivery. urgent (default): steers the target mid-turn, or wakes it when dormant — a fire is news the node asked for on a schedule, so it should land now. normal: waits for the running turn to settle. critical: aborts the running turn outright.' },
140
140
  { kind: 'flag', name: 'expires', type: 'string', required: false, constraint: 'Clock bound (same grammar as --at): the row is deleted once this instant passes. The self-limiting half of a poll-until pattern. For a held one-shot (last run exited 75), expiry bounds the wait for eligibility — the row is deleted UNFIRED, never fired blind at expiry.' },
@@ -254,7 +254,7 @@ const addLeaf = defineLeaf({
254
254
  env_json: envJson,
255
255
  profile: profile ?? null,
256
256
  scope,
257
- on_output: input['onOutput'] ?? 'on-failure',
257
+ on_output: input['onOutput'],
258
258
  sink: typeof input['sink'] === 'string' && input['sink'] !== '' ? input['sink'] : null,
259
259
  tier: input['tier'] ?? 'urgent',
260
260
  expires_at: expiresAt,
@@ -525,7 +525,7 @@ export function registerCron() {
525
525
  help: {
526
526
  name: 'cron',
527
527
  summary: 'scheduled bash commands, daemon-run',
528
- model: 'A cron stores and daemon-runs arbitrary bash at its clock. Use ordinary bash for ordinary work; choose node:<id> for stdout as inbox information to an existing node, an existing node\'s lifecycle fresh-revive action in the bash for a clean re-check without inbox output, or spawn:<kind> to route each output-producing fire to a fresh self-finishing node (a managed child while its creator exists, otherwise a terminal parentless root). Per-fire fresh agent work belongs on the existing spawn:<kind> sink, not an independent resident-root birth. A gate is bash, at the top of the command. Exit 0 after deciding not to act and the occurrence is simply spent — right when the recurrence is your polling cadence. Exit 75 and the occurrence is OWED: the row is held, fires again the moment the daemon receives an eligibility poke from the host platform, and otherwise re-checks at its next natural slot (a held one-shot instead waits for a poke until --expires deletes it unfired). Any other nonzero exit is a real failure and escalates per --on-output. Use 75 for "not yet — retry when conditions change" (a device coming online); keep gate ERRORS nonzero so a broken probe is loud instead of silently parked. Beyond the exit-code contract, cron has no native predicate-trigger semantics. --at runs once and deletes the row unless the run\'s disposition pauses it; --every runs each cadence. Output disposition (--on-output) records silently, escalates failures, or routes stdout to its --sink. Anchor a cron to delete it with a node; --cancel-on-wake instead deletes it when the anchor wakes. An unanchored recurring cron ends through --expires, explicit cancellation, or self-cancellation from its bash. Every run lands in a bounded per-cron run log (`cron show`); cwd, env, and profile are snapshotted at arm time, and --scope controls who lists and cancels it.',
528
+ model: 'A cron stores and daemon-runs arbitrary bash at its clock. Use ordinary bash for ordinary work; choose node:<id> for stdout as inbox information to an existing node (its disposition defaults to on-change), an existing node\'s lifecycle fresh-revive action in the bash for a clean re-check without inbox output, or spawn:<kind> to route each output-producing fire to a fresh self-finishing node (a managed child while its creator exists, otherwise a terminal parentless root). Per-fire fresh agent work belongs on the existing spawn:<kind> sink, not an independent resident-root birth. A gate is bash, at the top of the command. Exit 0 after deciding not to act and the occurrence is simply spent — right when the recurrence is your polling cadence. Exit 75 and the occurrence is OWED: the row is held, fires again the moment the daemon receives an eligibility poke from the host platform, and otherwise re-checks at its next natural slot (a held one-shot instead waits for a poke until --expires deletes it unfired). Any other nonzero exit is a real failure and escalates per --on-output. Use 75 for "not yet — retry when conditions change" (a device coming online); keep gate ERRORS nonzero so a broken probe is loud instead of silently parked. Beyond the exit-code contract, cron has no native predicate-trigger semantics. --at runs once and deletes the row unless the run\'s disposition pauses it; --every runs each cadence. Output disposition (--on-output) records silently, escalates failures, or routes stdout to its --sink. Anchor a cron to delete it with a node; --cancel-on-wake instead deletes it when the anchor wakes. An unanchored recurring cron ends through --expires, explicit cancellation, or self-cancellation from its bash. Every run lands in a bounded per-cron run log (`cron show`); cwd, env, and profile are snapshotted at arm time, and --scope controls who lists and cancels it.',
529
529
  },
530
530
  children: [addLeaf, listLeaf, showLeaf, runLeaf, pauseLeaf, resumeLeaf, cancelLeaf],
531
531
  });
@@ -12,6 +12,7 @@ import { stateBlock } from '../../core/help.js';
12
12
  import { cliClient, getNodeOrNull, rethrowAsCliError } from '../api-client.js';
13
13
  import { inTmux } from '../../core/runtime/placement-tmux.js';
14
14
  import { openSpawnViewer } from '../surface/node/placement.js';
15
+ import { resolve } from 'node:path';
15
16
  const YIELD_NUDGE_THRESHOLD = 100_000;
16
17
  const NODE_NEW_OUTPUT_SCHEMA_EFFECT = '--output-schema writes the accepted result to the node’s context/result.json and pushes it as the node’s final report.';
17
18
  const STD_CHILD_FOLLOW_UP = "Do not wait, poll, or duplicate this child's assignment in your own context — there is no result to await, stopping will not strand you, and duplicated work wastes the delegated context. You're auto-subscribed, so its finish wakes you on its own. Two moves only: do other independent work right now, or stop and end your turn — the wake brings you back.";
@@ -177,7 +178,8 @@ async function runNodeCreation(input) {
177
178
  }
178
179
  const kind = input['kind'];
179
180
  const mode = (input['mode'] ?? 'base');
180
- const pinCwd = input['cwd']?.trim();
181
+ const pinCwdRaw = input['cwd']?.trim();
182
+ const pinCwd = pinCwdRaw !== undefined && pinCwdRaw !== '' ? resolve(pinCwdRaw) : undefined;
181
183
  const name = input['name'];
182
184
  const parent = input['parent'];
183
185
  const root = input['root'] === true;
@@ -71,16 +71,16 @@ const worktreeAbandon = defineLeaf({
71
71
  });
72
72
  const worktreeClose = defineLeaf({
73
73
  name: 'close',
74
- description: 'land and close the current node-managed worktree',
75
- whenToUse: 'you are inside the node that owns an open managed worktree and are ready to rebase it onto its recorded local base branch and fast-forward that base before finishing; its active checkout is retained for deferred cleanup',
74
+ description: 'land or safely close the current node-managed worktree',
75
+ whenToUse: 'you are inside the node that owns an open managed worktree and are ready to land its recorded branch into the local base, or to close a moved real branch whose already-delivered content can be safely preserved while its checkout is disposed later',
76
76
  help: {
77
77
  name: 'node worktree close',
78
- summary: 'land and close the current node-managed worktree',
78
+ summary: 'land or safely close the current node-managed worktree',
79
79
  params: [],
80
80
  output: [
81
81
  { name: 'node_id', type: 'string', required: true, constraint: 'The caller node id whose managed worktree was closed.' },
82
- { name: 'branch', type: 'string', required: true, constraint: 'The worktree branch that was fast-forwarded into its recorded local base branch.' },
83
- { name: 'landed_sha', type: 'string', required: true, constraint: 'The commit SHA that the recorded local base branch was fast-forwarded onto.' },
82
+ { name: 'branch', type: 'string', required: true, constraint: 'The branch whose delivered content is retained: fast-forwarded into the recorded local base in an ordinary close, or preserved unchanged when a moved real branch is authorized for disposal.' },
83
+ { name: 'landed_sha', type: 'string', required: true, constraint: 'The retained branch tip: fast-forwarded into the local base in an ordinary close, or proven already contained in the local base or its upstream before a moved real branch is authorized for disposal.' },
84
84
  { name: 'worktree_path', type: 'string', required: true, constraint: 'The managed worktree path whose cleanup is deferred until its caller exits.' },
85
85
  { name: 'worktree_removed', type: 'boolean', required: true, constraint: 'Always false for an active caller: its checkout remains intact rather than being removed beneath the node.' },
86
86
  { name: 'worktree_remove_error', type: 'string', required: true, constraint: 'The named deferred-cleanup state and safe manual follow-up after the caller exits.' },
@@ -89,7 +89,7 @@ const worktreeClose = defineLeaf({
89
89
  ],
90
90
  outputKind: 'object',
91
91
  effects: [
92
- 'Rebases the worktree onto its recorded local base branch, fast-forwards that base onto it (never pushes to origin), marks the node-managed worktree closed with cleanup pending, and leaves the active caller checkout and branch intact.',
92
+ 'Rebases the worktree onto its recorded local base branch and fast-forwards that base onto it (never pushes to origin), marking the node-managed worktree closed with cleanup pending and leaving the active caller checkout and branch intact. When the recorded branch is gone because ordinary work moved this checkout onto a real branch, and that branch\'s content is already contained in the recorded base, closes by authorizing the checkout\'s later removal instead of landing — preserving that branch rather than deleting it.',
93
93
  ],
94
94
  },
95
95
  run: async () => {
@@ -105,7 +105,7 @@ const worktreeClose = defineLeaf({
105
105
  const branch = String(r['branch'] ?? '');
106
106
  const sha = String(r['landed_sha'] ?? '');
107
107
  const path = String(r['worktree_path'] ?? '');
108
- let base = `Managed worktree landed — its local base branch fast-forwarded to ${branch} at ${sha}.`;
108
+ let base = `Managed worktree closed — ${branch} at ${sha} is retained.`;
109
109
  const notes = [];
110
110
  if (r['worktree_removed'] === false) {
111
111
  notes.push(String(r['worktree_remove_error'] ?? `worktree checkout at ${path} could not be removed automatically.`));
@@ -1,6 +1,6 @@
1
1
  import { envProfileId } from '../../shared/env.js';
2
2
  import { dirname, join, resolve, isAbsolute, relative, sep } from 'node:path';
3
- import { renameSync, writeFileSync } from 'node:fs';
3
+ import { chmodSync, renameSync, writeFileSync } from 'node:fs';
4
4
  import { createHash } from 'node:crypto';
5
5
  import { defineLeaf } from '../../core/command.js';
6
6
  import { notFound, usage, general, network } from '../../core/errors.js';
@@ -282,6 +282,15 @@ function writeStagedBundle(stagingRoot, manifest, bundle) {
282
282
  ensureDir(resolve(target, '..'));
283
283
  writeFileSync(target, document.bytes);
284
284
  }
285
+ if (bundle.hooks !== undefined) {
286
+ writeFileSync(stagedPath(stagingRoot, 'hooks.json'), bundle.hooks.manifest);
287
+ for (const file of bundle.hooks.files) {
288
+ const target = stagedPath(stagingRoot, file.path);
289
+ ensureDir(resolve(target, '..'));
290
+ writeFileSync(target, file.bytes);
291
+ }
292
+ chmodSync(stagedPath(stagingRoot, 'hooks/dispatch'), 0o755);
293
+ }
285
294
  }
286
295
  async function validateStagedBundle(name, scope, root, scopeRootPath, manifest) {
287
296
  const memory = await assertPluginCandidateValid(manifest, root, scope, scopeRootPath, `${root}/.crouter-plugin/plugin.json`);
@@ -345,6 +354,7 @@ async function replaceBundlePlugin(name, bundleSource, scope, options) {
345
354
  // later archive that no longer declares them is what retires them.
346
355
  ...(validated.bundle.page_components !== undefined ? { page_components: validated.bundle.page_components } : {}),
347
356
  ...(validated.bundle.memory_extensions !== undefined ? { memory_extensions: validated.bundle.memory_extensions } : {}),
357
+ ...(validated.bundle.hooks !== undefined ? { hooks: 'hooks.json', hookExecutable: 'hooks/dispatch' } : {}),
348
358
  };
349
359
  const tmpRoot = join(scopeRootPath, 'tmp');
350
360
  const staging = join(tmpRoot, `${name}.${process.pid}`);
@@ -390,7 +400,8 @@ async function replaceBundlePlugin(name, bundleSource, scope, options) {
390
400
  const commands = await commandReport(name, scope);
391
401
  if (commands === undefined)
392
402
  throw general(`bundle plugin "${name}" has no staged command report`);
393
- return { name, scope, path: root, transport: 'http', version, docs: validated.bundle.memory.length, kinds: Object.keys(validated.bundle.kinds ?? {}).length, pageComponents: (validated.bundle.page_components ?? []).length, commands, ...(memory !== undefined ? { memory } : {}) };
403
+ const hooks = await hookLifecycleReport(name, scope);
404
+ return { name, scope, path: root, transport: 'http', version, docs: validated.bundle.memory.length, kinds: Object.keys(validated.bundle.kinds ?? {}).length, pageComponents: (validated.bundle.page_components ?? []).length, commands, ...(hooks !== undefined ? { hooks } : {}), ...(memory !== undefined ? { memory } : {}) };
394
405
  }
395
406
  async function installHttpPlugin(name, endpoint, authEnv, scope) {
396
407
  const source = validateHttpInstall(name, endpoint, authEnv);
@@ -674,7 +685,7 @@ async function updateOnePlugin(plugin, marketplaceCache, opts) {
674
685
  const sourceInstalled = isSourceInstalled(plugin);
675
686
  if (!sourceInstalled && plugin.manifest.bundle !== undefined) {
676
687
  const replaced = await replaceBundlePlugin(plugin.name, plugin.manifest.bundle, plugin.scope, { enable: plugin.enabled });
677
- return { name: plugin.name, transport: 'http', updated: true, version: replaced.version, docs: replaced.docs, kinds: replaced.kinds, pageComponents: replaced.pageComponents, commands: replaced.commands, ...(replaced.memory !== undefined ? { memory: replaced.memory } : {}) };
688
+ return { name: plugin.name, transport: 'http', updated: true, version: replaced.version, docs: replaced.docs, kinds: replaced.kinds, pageComponents: replaced.pageComponents, commands: replaced.commands, ...(replaced.hooks !== undefined ? { hooks: replaced.hooks } : {}), ...(replaced.memory !== undefined ? { memory: replaced.memory } : {}) };
678
689
  }
679
690
  if (plugin.manifest.transport?.kind === 'http' && !sourceInstalled)
680
691
  rejectLegacyHttpPlugin(plugin.name);
@@ -729,16 +740,16 @@ export const pluginInstall = defineLeaf({
729
740
  { name: 'kinds', type: 'integer', required: false, constraint: 'Kind-registry entry count declared by an --endpoint archive\u2019s bundle.json.' },
730
741
  { name: 'pageComponents', type: 'integer', required: false, constraint: 'Page-component registration count declared by an --endpoint archive\u2019s bundle.json.' },
731
742
  { name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest. {mounts: string[] (accepted top-level command names now live for the next invocation), issues: object[] (typed validation issues that rejected a contribution — {code, path?, message, received, expected, next})}. Validated statically; commands are never executed.' },
732
- { name: 'hooks', type: 'object', required: false, constraint: 'Present only when the source plugin declares hooks. {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], lifecycle?: {event, phase, op, description, effects}[], issues: object[], trust: string}. Command targets, lifecycle events, and replacement collisions are validated statically; hook executables are never run.' },
743
+ { name: 'hooks', type: 'object', required: false, constraint: 'Present only when the plugin declares hooks. {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], lifecycle?: {event, phase, op, description, effects}[], issues: object[], trust: string}. Command targets, lifecycle events, and replacement collisions are validated statically; hook executables are never run.' },
733
744
  { name: 'warnings', type: 'string[]', required: false, constraint: 'Advisory missing PATH executable requirements declared by the installed plugin. The install still succeeds and leaves the plugin enabled.' },
734
745
  { name: 'memory', type: 'object', required: false, constraint: 'Present only when the plugin ships a memory store. {docs: integer (documents checked), migrated: string[] (store-relative documents rewritten to reach exact canonical identity), linkedSource?: string (the caller-owned directory converged in place, present only when the install keeps that directory as a live link)}.' },
735
746
  ],
736
747
  outputKind: 'object',
737
748
  effects: [
738
749
  'A ref install clones, links, or copies the plugin into the scope plugins directory and registers it with enabled=true. Marketplace installs refresh the source marketplace before resolving the plugin entry.',
739
- 'An --endpoint install fetches and validates one authenticated uncompressed tar archive before writing. It replaces the complete plugin directory with synthesized provenance and invocation metadata, commands.json, memory docs, any kind-registry entries the bundle.json declares (they join the launch registry below this scope\u2019s own config.json kinds), and any page components it declares (they join the active page-component catalog alongside the user config entries); a fetch or validation failure preserves the prior package.',
750
+ 'An --endpoint install fetches and validates one authenticated uncompressed tar archive before writing. It replaces the complete plugin directory with synthesized provenance and invocation metadata, commands.json, memory docs, and, when declared, hooks.json plus the hooks/ tree; it also copies any kind-registry entries the bundle.json declares (they join the launch registry below this scope\u2019s own config.json kinds), and any page components it declares (they join the active page-component catalog alongside the user config entries); a fetch or validation failure preserves the prior package.',
740
751
  'If the plugin declares a command manifest, its commands go live on the next crtr invocation. Exec commands run trusted local code only when explicitly invoked; HTTP commands call their declared endpoint only when explicitly invoked.',
741
- 'Source plugin command hooks go live on the next crtr invocation. They run implicitly with caller authority, may receive normalized inputs and after results, may block or replace declared targets, and may transmit received data. Lifecycle hooks run unprompted at every declared event before node launch and may transmit node identity and resolved paths. Hook executables are never run during install or update validation.',
752
+ 'Plugin command hooks go live on the next crtr invocation. They run implicitly with caller authority, may receive normalized inputs and after results, may block or replace declared targets, and may transmit received data. Lifecycle hooks run unprompted at every declared event before node launch and may transmit node identity and resolved paths. Hook executables are never run during install or update validation.',
742
753
  'A plugin manifest `requires` declaration is checked against the PATH a node bash receives after install. Missing executables emit a warning with the declared install hint but never fail the install, disable the plugin, or remove its bare-binary contributions.',
743
754
  'Before activation the candidate\u2019s memory corpus is migrated to exact canonical identity and refused if it cannot mount. A staged candidate is migrated in staging and rolls back with the package; a local path or marketplace-relative source stays a live link, so its own directory is rewritten in place, keeps that rewrite whether or not the install completes, and is named as `memory.linkedSource`. A refused corpus writes nothing and installs nothing.',
744
755
  ],
@@ -895,7 +906,7 @@ export const pluginDisable = defineLeaf({
895
906
  export const pluginUpdate = defineLeaf({
896
907
  name: 'update',
897
908
  description: 'update installed plugin content',
898
- whenToUse: 'refreshing one named plugin or all installed plugins: archive plugins replace commands and memory together, while source-installed plugins retain their source update behavior',
909
+ whenToUse: 'refreshing one named plugin or all installed plugins: archive plugins replace their complete package, while source-installed plugins retain their source update behavior',
899
910
  help: {
900
911
  name: 'pkg plugin update',
901
912
  summary: 'refresh one or all installed plugins',
@@ -904,7 +915,7 @@ export const pluginUpdate = defineLeaf({
904
915
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Narrows resolution.' },
905
916
  ],
906
917
  output: [
907
- { name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, transport?, updated, sha?, version?, docs?, kinds?, pageComponents?, commands?, hooks?, memory?, warnings?, error?}. memory is present for a plugin shipping a memory store and reports the same corpus migration install performs: {docs, migrated, linkedSource?}. Archive plugins report transport=http and replace their complete directory after an authenticated refetch; version is the archive content identifier, docs is the written memory-document count, kinds is the declared kind-registry entry count, and pageComponents is the declared page-component registration count. sha is present for git updates. A bulk update reports and skips a failed archive plugin. commands is present for validated command plugins: {mounts: string[], issues: object[]}. hooks is present for source hook plugins: {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], lifecycle?: {event, phase, op, description, effects}[], issues: object[], trust: string}; it is static and never executes hooks. warnings lists advisory PATH requirements that remain absent after update.' }
918
+ { name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, transport?, updated, sha?, version?, docs?, kinds?, pageComponents?, commands?, hooks?, memory?, warnings?, error?}. memory is present for a plugin shipping a memory store and reports the same corpus migration install performs: {docs, migrated, linkedSource?}. Archive plugins report transport=http and replace their complete directory after an authenticated refetch; version is the archive content identifier, docs is the written memory-document count, kinds is the declared kind-registry entry count, and pageComponents is the declared page-component registration count. sha is present for git updates. A bulk update reports and skips a failed archive plugin. commands is present for validated command plugins: {mounts: string[], issues: object[]}. hooks is present for plugins declaring hooks: {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], lifecycle?: {event, phase, op, description, effects}[], issues: object[], trust: string}; it is static and never executes hooks. warnings lists advisory PATH requirements that remain absent after update.' }
908
919
  ],
909
920
  outputKind: 'object',
910
921
  effects: [
@@ -71,7 +71,7 @@ function makePushLeaf(kind) {
71
71
  ...(kind === 'final'
72
72
  ? [
73
73
  'Marks the node done (status + intent) server-side; its engine shuts down on next stop.',
74
- 'When this node owns a managed worktree with zero commits ahead of its base and no uncommitted changes, the server auto-drops it instead of blocking on `node worktree close`; a worktree with real commits or real uncommitted changes still blocks with open_managed_worktree.',
74
+ 'When this node owns a clean managed worktree already contained in its local base, the server auto-drops it instead of blocking on `node worktree close`. A clean worktree whose exact branch tip is already on origin may also finalize while retaining its branch and checkout; uncommitted changes or a branch tip not on origin still block with open_managed_worktree.',
75
75
  ]
76
76
  : []),
77
77
  ],
@@ -10,6 +10,7 @@ export interface WaitOpts {
10
10
  intervalMs?: number;
11
11
  label?: string;
12
12
  }
13
+ export declare function readLines(path: string): string[];
13
14
  export interface Injected {
14
15
  content: string;
15
16
  deliverAs?: string;
@@ -123,6 +123,18 @@ async function waitFor(probe, opts = {}) {
123
123
  await new Promise((r) => setTimeout(r, intervalMs));
124
124
  }
125
125
  }
126
+ export function readLines(path) {
127
+ try {
128
+ const content = readFileSync(path, 'utf8');
129
+ const lines = content.split('\n');
130
+ if (!content.endsWith('\n'))
131
+ lines.pop();
132
+ return lines.filter((l) => l.trim() !== '');
133
+ }
134
+ catch {
135
+ return [];
136
+ }
137
+ }
126
138
  /** Thin wrapper: a headless-only harness (broker-hosted, paneless nodes). */
127
139
  export async function createHeadlessHarness(opts = {}) {
128
140
  return createHarness({ ...opts, headless: true });
@@ -377,16 +389,6 @@ export async function createHarness(opts = {}) {
377
389
  return [];
378
390
  }
379
391
  }
380
- function readLines(path) {
381
- try {
382
- return readFileSync(path, 'utf8')
383
- .split('\n')
384
- .filter((l) => l.trim() !== '');
385
- }
386
- catch {
387
- return [];
388
- }
389
- }
390
392
  function cli(nodeId, args) {
391
393
  const runtimeArgs = TSX_ESM === null ? [] : ['--import', TSX_ESM];
392
394
  const res = spawnSync(process.execPath, [...runtimeArgs, CLI_ENTRY, ...args], {
@@ -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
@@ -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
  };
@@ -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 {};