@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.
- package/dist/builtin-memory/02-turn-lifecycle/01-terminal.md +1 -1
- package/dist/clients/attach/viewer.js +406 -406
- package/dist/commands/cron.js +3 -3
- package/dist/commands/node/create.js +3 -1
- package/dist/commands/node-worktree.js +7 -7
- package/dist/commands/pkg/plugin-manage.js +19 -8
- package/dist/commands/push.js +1 -1
- package/dist/core/__tests__/helpers/harness.d.ts +1 -0
- package/dist/core/__tests__/helpers/harness.js +12 -10
- package/dist/core/__tests__/integration/worktree-land.test.js +7 -2
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +3 -16
- package/dist/core/canvas/types.d.ts +18 -0
- package/dist/core/command-plugins/bundle.d.ts +7 -0
- package/dist/core/command-plugins/bundle.js +18 -4
- package/dist/core/git.d.ts +5 -1
- package/dist/core/git.js +18 -4
- package/dist/core/runtime/spawn.d.ts +2 -0
- package/dist/core/runtime/spawn.js +1 -1
- package/dist/core/worktree-sweep.js +28 -5
- package/dist/core/worktree.d.ts +1 -12
- package/dist/core/worktree.js +222 -10
- package/dist/daemon/api/handlers/crons.js +5 -4
- package/dist/daemon/api/handlers/messages.js +5 -0
- package/dist/daemon/api/handlers/nodes.js +2 -0
- package/dist/daemon/api/handlers/reports.js +11 -7
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
package/dist/commands/cron.js
CHANGED
|
@@ -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,
|
|
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']
|
|
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
|
|
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
|
|
75
|
-
whenToUse: 'you are inside the node that owns an open managed worktree and are ready to
|
|
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
|
|
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
|
|
83
|
-
{ name: 'landed_sha', type: 'string', required: true, constraint: 'The
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
'
|
|
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
|
|
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
|
|
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: [
|
package/dist/commands/push.js
CHANGED
|
@@ -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
|
|
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
|
],
|
|
@@ -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
|
-
|
|
444
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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,
|
|
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
|
|
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
|
};
|
package/dist/core/git.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
40
|
+
settle({ status: code, stdout, stderr, ...(timedOut ? { timedOut: true, executionFailed: true } : {}) });
|
|
27
41
|
});
|
|
28
|
-
child.on('error', (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
|
|
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
|
-
|
|
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/${
|
|
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() !==
|
|
443
|
-
return { status: 'refused', reason: 'wrong_branch', detail: `expected ${
|
|
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) {
|
package/dist/core/worktree.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 {};
|