@pugi/cli 0.1.0-beta.2 → 0.1.0-beta.4
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/assets/pugi-mascot.ansi +25 -40
- package/dist/core/engine/prompts.js +1 -4
- package/dist/core/repl/session.js +13 -14
- package/dist/core/repl/slash-commands.js +30 -0
- package/dist/runtime/cli.js +63 -48
- package/dist/runtime/commands/delegate.js +81 -0
- package/dist/runtime/commands/roster.js +117 -0
- package/dist/tools/registry.js +0 -18
- package/dist/tui/repl-render.js +0 -28
- package/dist/tui/repl.js +10 -1
- package/package.json +2 -2
- package/dist/core/edits/worktree.js +0 -229
- package/dist/core/lsp/client.js +0 -631
- package/dist/runtime/commands/lsp.js +0 -184
- package/dist/runtime/commands/patch.js +0 -111
- package/dist/runtime/commands/worktree.js +0 -133
- package/dist/tools/apply-patch.js +0 -314
- package/dist/tools/lsp-tools.js +0 -189
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pugi roster` command - α7.5 Tier 1 instantiation Phase 1.
|
|
3
|
+
*
|
|
4
|
+
* Lists the live Tier 1 personas with display name, role, and routing
|
|
5
|
+
* tag. The CLI walks two sources in order:
|
|
6
|
+
*
|
|
7
|
+
* 1. The local @pugi/personas roster (THE_TEN). Always succeeds; the
|
|
8
|
+
* ten brand-canonical personas are baked into the SDK.
|
|
9
|
+
* 2. The remote `GET /api/pugi/sessions/roster` endpoint when the
|
|
10
|
+
* operator has a valid credential. The remote response carries the
|
|
11
|
+
* server-side dispatch role + dispatchTag for each slug so the
|
|
12
|
+
* operator sees the actual routing decision the dispatcher will
|
|
13
|
+
* apply on a `pugi delegate <slug>` call.
|
|
14
|
+
*
|
|
15
|
+
* The command never fails if the network is unreachable - it falls back
|
|
16
|
+
* to local-only output with a one-line warning. This matches the
|
|
17
|
+
* local-first contract (ADR-0037): the operator can still see who is on
|
|
18
|
+
* the team without an API key.
|
|
19
|
+
*
|
|
20
|
+
* Output:
|
|
21
|
+
* - text default: a 3-column table (slug | name | role).
|
|
22
|
+
* - --json: a structured array of { slug, name, role, totem,
|
|
23
|
+
* dispatchTag } records, used by scripted callers.
|
|
24
|
+
*/
|
|
25
|
+
import { THE_TEN } from '@pugi/personas';
|
|
26
|
+
import { fetchPersonaRoster, } from '@pugi/sdk';
|
|
27
|
+
/**
|
|
28
|
+
* Fallback role + tag table the CLI uses when the runtime is unreachable
|
|
29
|
+
* (no credentials, network error, older runtime without the
|
|
30
|
+
* /sessions/roster endpoint). Mirrors the server-side
|
|
31
|
+
* persona-dispatch.ts PERSONA_REGISTRY so a CLI that ran without
|
|
32
|
+
* credentials still shows the operator the right routing intent.
|
|
33
|
+
*/
|
|
34
|
+
const FALLBACK_ROLE_BY_SLUG = Object.freeze({
|
|
35
|
+
main: { role: 'orchestrator', dispatchTag: 'reason' },
|
|
36
|
+
architect: { role: 'architect', dispatchTag: 'reason' },
|
|
37
|
+
dev: { role: 'coder', dispatchTag: 'codegen' },
|
|
38
|
+
qa: { role: 'verifier', dispatchTag: 'reason' },
|
|
39
|
+
pm: { role: 'release', dispatchTag: 'reason' },
|
|
40
|
+
devops: { role: 'devops', dispatchTag: 'reason' },
|
|
41
|
+
researcher: { role: 'researcher', dispatchTag: 'reason' },
|
|
42
|
+
analyst: { role: 'analyst', dispatchTag: 'summarize' },
|
|
43
|
+
designer: { role: 'design_qa', dispatchTag: 'reason' },
|
|
44
|
+
frontend: { role: 'frontend', dispatchTag: 'codegen' },
|
|
45
|
+
});
|
|
46
|
+
/**
|
|
47
|
+
* Build the roster rows by merging the local @pugi/personas brand
|
|
48
|
+
* roster with the remote dispatch metadata when a credential is
|
|
49
|
+
* available. Pure function so the runtime CLI command can unit-test it
|
|
50
|
+
* without standing up an Anvil endpoint.
|
|
51
|
+
*/
|
|
52
|
+
export function mergeRoster(brandRoster, remote) {
|
|
53
|
+
const remoteIndex = new Map((remote ?? []).map((entry) => [entry.slug, entry]));
|
|
54
|
+
return brandRoster.map((persona) => {
|
|
55
|
+
const fromRemote = remoteIndex.get(persona.slug);
|
|
56
|
+
const fallback = FALLBACK_ROLE_BY_SLUG[persona.slug] ?? {
|
|
57
|
+
role: persona.role,
|
|
58
|
+
dispatchTag: 'reason',
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
slug: persona.slug,
|
|
62
|
+
name: persona.name,
|
|
63
|
+
totem: persona.animal,
|
|
64
|
+
role: fromRemote?.role ?? fallback.role,
|
|
65
|
+
dispatchTag: fromRemote?.dispatchTag ?? fallback.dispatchTag,
|
|
66
|
+
oneLiner: persona.oneLiner,
|
|
67
|
+
source: fromRemote ? 'remote' : 'local-fallback',
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Render a roster as a plain-text 3-column table the operator reads in
|
|
73
|
+
* the terminal. The column widths grow to fit the longest cell so a
|
|
74
|
+
* future displayName drift does not truncate silently.
|
|
75
|
+
*/
|
|
76
|
+
export function renderRosterTable(rows) {
|
|
77
|
+
if (rows.length === 0)
|
|
78
|
+
return 'Roster is empty.';
|
|
79
|
+
const head = { slug: 'slug', name: 'name', totem: 'totem', role: 'role', dispatchTag: 'tag' };
|
|
80
|
+
const widths = {
|
|
81
|
+
slug: Math.max(head.slug.length, ...rows.map((r) => r.slug.length)),
|
|
82
|
+
name: Math.max(head.name.length, ...rows.map((r) => r.name.length)),
|
|
83
|
+
totem: Math.max(head.totem.length, ...rows.map((r) => r.totem.length)),
|
|
84
|
+
role: Math.max(head.role.length, ...rows.map((r) => r.role.length)),
|
|
85
|
+
dispatchTag: Math.max(head.dispatchTag.length, ...rows.map((r) => r.dispatchTag.length)),
|
|
86
|
+
};
|
|
87
|
+
const pad = (s, width) => s + ' '.repeat(Math.max(0, width - s.length));
|
|
88
|
+
const line = (r) => [pad(r.slug, widths.slug), pad(r.name, widths.name), pad(r.totem, widths.totem), pad(r.role, widths.role), pad(r.dispatchTag, widths.dispatchTag)].join(' ');
|
|
89
|
+
const header = line(head);
|
|
90
|
+
const sep = '-'.repeat(header.length);
|
|
91
|
+
return [header, sep, ...rows.map((r) => line(r))].join('\n');
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the roster by walking remote + local sources. The CLI command
|
|
95
|
+
* is a thin wrapper around this function so unit tests can exercise the
|
|
96
|
+
* merge logic without hitting the runtime.
|
|
97
|
+
*/
|
|
98
|
+
export async function resolveRoster(config) {
|
|
99
|
+
if (!config) {
|
|
100
|
+
return {
|
|
101
|
+
rows: mergeRoster(THE_TEN, null),
|
|
102
|
+
warning: 'no credential configured; showing local @pugi/personas roster only',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const result = await fetchPersonaRoster(config);
|
|
106
|
+
if (result.status === 'ok') {
|
|
107
|
+
return { rows: mergeRoster(THE_TEN, result.response.personas), warning: null };
|
|
108
|
+
}
|
|
109
|
+
const reason = result.status === 'endpoint_missing'
|
|
110
|
+
? 'runtime does not expose /api/pugi/sessions/roster (upgrade admin-api to α7.5+)'
|
|
111
|
+
: result.message;
|
|
112
|
+
return {
|
|
113
|
+
rows: mergeRoster(THE_TEN, null),
|
|
114
|
+
warning: `roster fetch failed (${result.status}): ${reason}; showing local roster only`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=roster.js.map
|
package/dist/tools/registry.js
CHANGED
|
@@ -1,19 +1,8 @@
|
|
|
1
1
|
const registry = [
|
|
2
|
-
// α7.7: unified-diff patch apply. Routes through the same security
|
|
3
|
-
// gate as Layer A/B/C, so the risk class matches `edit`/`write`
|
|
4
|
-
// (medium — writes inside the workspace, never to protected files).
|
|
5
|
-
{ name: 'apply_patch', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
|
|
6
2
|
{ name: 'bash', permission: 'bash', risk: 'high', concurrencySafe: false, m1: true },
|
|
7
3
|
{ name: 'edit', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
|
|
8
4
|
{ name: 'glob', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
|
|
9
5
|
{ name: 'grep', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
|
|
10
|
-
// α7.7: LSP read-only surface. Server runs locally, no Anvil
|
|
11
|
-
// round-trip. Concurrency-safe because every operation reads
|
|
12
|
-
// server state without mutating workspace files.
|
|
13
|
-
{ name: 'lsp_definition', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
|
|
14
|
-
{ name: 'lsp_diagnostics', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
|
|
15
|
-
{ name: 'lsp_hover', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
|
|
16
|
-
{ name: 'lsp_references', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
|
|
17
6
|
{ name: 'question', permission: 'none', risk: 'low', concurrencySafe: false, m1: true },
|
|
18
7
|
{ name: 'read', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
|
|
19
8
|
{ name: 'skill', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
|
|
@@ -22,13 +11,6 @@ const registry = [
|
|
|
22
11
|
{ name: 'task_list', permission: 'none', risk: 'low', concurrencySafe: true, m1: true },
|
|
23
12
|
{ name: 'task_update', permission: 'none', risk: 'low', concurrencySafe: false, m1: true },
|
|
24
13
|
{ name: 'web_fetch', permission: 'network', risk: 'medium', concurrencySafe: true, m1: true },
|
|
25
|
-
// α7.7: scratch worktree management. `worktree_create` writes nothing
|
|
26
|
-
// dangerous (a clone under `.pugi/worktrees/`); `worktree_promote`
|
|
27
|
-
// applies a diff back to the main tree, so it shares the `edit`
|
|
28
|
-
// risk class. `worktree_drop` is the cleanup primitive.
|
|
29
|
-
{ name: 'worktree_create', permission: 'edit', risk: 'low', concurrencySafe: false, m1: true },
|
|
30
|
-
{ name: 'worktree_drop', permission: 'edit', risk: 'low', concurrencySafe: false, m1: true },
|
|
31
|
-
{ name: 'worktree_promote', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
|
|
32
14
|
{ name: 'write', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
|
|
33
15
|
];
|
|
34
16
|
export const toolRegistry = registry.sort((a, b) => a.name.localeCompare(b.name));
|
package/dist/tui/repl-render.js
CHANGED
|
@@ -95,18 +95,6 @@ export async function renderRepl(options) {
|
|
|
95
95
|
// When skipSplash is true (operator opted out via --no-splash), we
|
|
96
96
|
// suppress the pre-print too so the boot stays silent.
|
|
97
97
|
const mascotPrePrinted = options.skipSplash === true ? false : printPugMascotPreInk(process.stdout);
|
|
98
|
-
// α6.14.4 CEO dogfood 2026-05-25 (parity with Claude Code): enter
|
|
99
|
-
// the terminal's alternate screen buffer so the REPL renders on a
|
|
100
|
-
// fresh "screen" the operator cannot scroll above. On exit, leave
|
|
101
|
-
// restores the previous terminal contents — the conversation does
|
|
102
|
-
// not pollute the operator's shell history. Skipped under --no-tty
|
|
103
|
-
// and when stdout is not a TTY (pipe/CI), where the escapes would
|
|
104
|
-
// appear as literal characters.
|
|
105
|
-
const supportsAltScreen = process.stdout.isTTY === true;
|
|
106
|
-
if (supportsAltScreen) {
|
|
107
|
-
process.stdout.write('\x1b[?1049h');
|
|
108
|
-
process.stdout.write('\x1b[H');
|
|
109
|
-
}
|
|
110
98
|
const instance = render(React.createElement(Repl, {
|
|
111
99
|
session,
|
|
112
100
|
updateBanner: options.updateBanner ?? null,
|
|
@@ -114,26 +102,10 @@ export async function renderRepl(options) {
|
|
|
114
102
|
hideToolStream: options.hideToolStream === true,
|
|
115
103
|
mascotPrePrinted,
|
|
116
104
|
}));
|
|
117
|
-
const restoreAltScreen = () => {
|
|
118
|
-
if (supportsAltScreen) {
|
|
119
|
-
try {
|
|
120
|
-
process.stdout.write('\x1b[?1049l');
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
/* shutdown race — terminal already detached */
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
};
|
|
127
|
-
// Make sure we leave the alt screen on abrupt exits too. Without
|
|
128
|
-
// this the operator's shell stays "frozen" on the Pugi splash.
|
|
129
|
-
process.once('exit', restoreAltScreen);
|
|
130
|
-
process.once('SIGINT', restoreAltScreen);
|
|
131
|
-
process.once('SIGTERM', restoreAltScreen);
|
|
132
105
|
try {
|
|
133
106
|
await instance.waitUntilExit();
|
|
134
107
|
}
|
|
135
108
|
finally {
|
|
136
|
-
restoreAltScreen();
|
|
137
109
|
session.close();
|
|
138
110
|
if (store) {
|
|
139
111
|
try {
|
package/dist/tui/repl.js
CHANGED
|
@@ -178,7 +178,16 @@ export function Repl(props) {
|
|
|
178
178
|
return undefined;
|
|
179
179
|
return props.session.cancel();
|
|
180
180
|
}, [props.session, modalActive]);
|
|
181
|
-
|
|
181
|
+
// α6.14.5 CEO dogfood 2026-05-25 (parity with Claude Code): the
|
|
182
|
+
// input box must pin to the BOTTOM of the alt-screen viewport, not
|
|
183
|
+
// float right under the conversation. Beta.3 attempt at full-height
|
|
184
|
+
// broke keystroke focus (raw echo at row 79). The right pattern is
|
|
185
|
+
// minHeight on the root + flexGrow on the conversation pane so the
|
|
186
|
+
// empty space sits ABOVE the input, not below it. The input then
|
|
187
|
+
// captures all keystrokes because it is the only Ink-focusable
|
|
188
|
+
// surface adjacent to the cursor row.
|
|
189
|
+
const altScreenRows = process.stdout.rows ?? 24;
|
|
190
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, minHeight: altScreenRows, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, splashVisible ? (_jsx(ReplSplash, { cliVersion: state.cliVersion, workspaceLabel: state.workspaceLabel, plan: props.splashPlan, model: props.splashModel, tenant: props.splashTenant, onDismiss: dismissSplash, mascotPrePrinted: props.mascotPrePrinted === true })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, flexGrow: 1, children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow, hideToolStream: props.hideToolStream === true, toolStreamCollapsed: toolStreamCollapsed })) }), state.pendingAsk ? (_jsx(Box, { marginTop: 1, children: _jsx(AskModal, { tag: state.pendingAsk, onResolve: handleAskResolve }) })) : null, state.pendingPlanReview ? (_jsx(Box, { marginTop: 1, children: _jsx(PlanReviewModal, { tag: state.pendingPlanReview, onResolve: handlePlanReviewResolve }) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' || modalActive ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, onCancel: handleCancel, now: props.now,
|
|
182
191
|
// Slug from process.cwd() (full path) so two workspaces with
|
|
183
192
|
// the same basename do not share history. state.workspaceLabel
|
|
184
193
|
// is the basename only. Codex review P2.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pugi/cli",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.4",
|
|
4
4
|
"description": "Pugi CLI - terminal-native software execution system",
|
|
5
5
|
"homepage": "https://pugi.io",
|
|
6
6
|
"repository": {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"undici": "^8.3.0",
|
|
53
53
|
"zod": "^3.23.0",
|
|
54
54
|
"@pugi/personas": "0.1.2",
|
|
55
|
-
"@pugi/sdk": "0.1.0-beta.
|
|
55
|
+
"@pugi/sdk": "0.1.0-beta.4"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/node": "^22.0.0",
|
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Worktree isolation — α7.7 Phase 1.
|
|
3
|
-
*
|
|
4
|
-
* Wraps `git worktree add` so a long agent loop (build / consensus
|
|
5
|
-
* review / multi-file refactor) can land its edits into a scratch
|
|
6
|
-
* workspace, run the validators against THAT path, and only then promote
|
|
7
|
-
* the resulting diff back to the operator's main working tree. The
|
|
8
|
-
* primary win is safety: a half-applied refactor never corrupts the
|
|
9
|
-
* operator's branch.
|
|
10
|
-
*
|
|
11
|
-
* Three operations:
|
|
12
|
-
*
|
|
13
|
-
* - `createWorktree(branch)` — spawns `git worktree add --detach`
|
|
14
|
-
* under `.pugi/worktrees/<uuid>` based on the supplied branch (or
|
|
15
|
-
* HEAD when omitted). Returns the absolute path + a `cleanup()`
|
|
16
|
-
* callback. The dir lives under `.pugi/` so the existing `.gitignore`
|
|
17
|
-
* for that subtree applies (no accidental commits of the scratch
|
|
18
|
-
* state to the main repo).
|
|
19
|
-
*
|
|
20
|
-
* - `promoteWorktree(worktreePath, cwd)` — diffs the worktree against
|
|
21
|
-
* its base commit and applies the diff to the main `cwd` via
|
|
22
|
-
* `git apply`. Refuses if the main cwd has staged changes that
|
|
23
|
-
* would conflict; the operator must commit or stash first.
|
|
24
|
-
*
|
|
25
|
-
* - `dropWorktree(worktreePath)` — removes the worktree both from
|
|
26
|
-
* git's bookkeeping (`git worktree remove --force`) and from disk.
|
|
27
|
-
* Idempotent; a partially-removed worktree (`git` already cleaned
|
|
28
|
-
* up but dir survived) is handled.
|
|
29
|
-
*
|
|
30
|
-
* Brand voice: ASCII only, no emoji, no banned words.
|
|
31
|
-
*/
|
|
32
|
-
import { spawnSync } from 'node:child_process';
|
|
33
|
-
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
34
|
-
import { randomUUID } from 'node:crypto';
|
|
35
|
-
import { resolve, sep } from 'node:path';
|
|
36
|
-
import { OperatorAbortedError } from '../../tools/file-tools.js';
|
|
37
|
-
/**
|
|
38
|
-
* Create a scratch worktree under `.pugi/worktrees/<uuid>`. The path is
|
|
39
|
-
* guaranteed unique (uuid) so multiple agent loops can run in parallel
|
|
40
|
-
* without collision.
|
|
41
|
-
*/
|
|
42
|
-
export function createWorktree(opts) {
|
|
43
|
-
if (opts.cancellation && opts.cancellation.isAborted) {
|
|
44
|
-
return { ok: false, reason: 'operator_aborted', detail: 'createWorktree aborted' };
|
|
45
|
-
}
|
|
46
|
-
// Confirm we're inside a git repo. `git rev-parse --git-dir` is the
|
|
47
|
-
// canonical check and avoids a misleading error message later when
|
|
48
|
-
// `git worktree add` runs in a non-repo.
|
|
49
|
-
const gitDir = runGit(['rev-parse', '--git-dir'], opts.cwd);
|
|
50
|
-
if (gitDir.status !== 0) {
|
|
51
|
-
return {
|
|
52
|
-
ok: false,
|
|
53
|
-
reason: 'not_a_git_repo',
|
|
54
|
-
detail: `not a git repo: ${opts.cwd}`,
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
// Resolve base SHA. When the operator named a branch we honor it; the
|
|
58
|
-
// default is HEAD. We capture the SHA up-front so `promoteWorktree`
|
|
59
|
-
// can `git diff <baseSha>..HEAD` deterministically even if the main
|
|
60
|
-
// working tree has moved forward since.
|
|
61
|
-
const baseRef = opts.branch ?? 'HEAD';
|
|
62
|
-
const baseShaResult = runGit(['rev-parse', baseRef], opts.cwd);
|
|
63
|
-
if (baseShaResult.status !== 0) {
|
|
64
|
-
return {
|
|
65
|
-
ok: false,
|
|
66
|
-
reason: 'git_command_failed',
|
|
67
|
-
detail: `cannot resolve base ref ${baseRef}: ${baseShaResult.stderr}`,
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
const baseSha = baseShaResult.stdout.trim();
|
|
71
|
-
const worktreeRoot = resolve(opts.cwd, '.pugi', 'worktrees');
|
|
72
|
-
mkdirSync(worktreeRoot, { recursive: true });
|
|
73
|
-
const worktreePath = resolve(worktreeRoot, randomUUID());
|
|
74
|
-
// `--detach` keeps the worktree on a detached HEAD so we don't
|
|
75
|
-
// collide with branch checkouts on the main tree. The worktree is
|
|
76
|
-
// throwaway — there is no branch name to track.
|
|
77
|
-
const create = runGit(['worktree', 'add', '--detach', worktreePath, baseSha], opts.cwd);
|
|
78
|
-
if (create.status !== 0) {
|
|
79
|
-
return {
|
|
80
|
-
ok: false,
|
|
81
|
-
reason: 'git_command_failed',
|
|
82
|
-
detail: `git worktree add failed: ${create.stderr}`,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
const handle = {
|
|
86
|
-
path: worktreePath,
|
|
87
|
-
baseSha,
|
|
88
|
-
cleanup: () => {
|
|
89
|
-
const r = dropWorktree(worktreePath, opts.cwd);
|
|
90
|
-
if (!r.ok && r.reason !== 'worktree_missing') {
|
|
91
|
-
// Swallow non-fatal cleanup failures so the agent loop doesn't
|
|
92
|
-
// hard-crash on the happy path. The diagnostic still surfaces
|
|
93
|
-
// via the JSON output on the `pugi worktree drop` command.
|
|
94
|
-
}
|
|
95
|
-
},
|
|
96
|
-
};
|
|
97
|
-
return { ok: true, value: handle };
|
|
98
|
-
}
|
|
99
|
-
/**
|
|
100
|
-
* Diff the worktree against its base and apply the diff to the main cwd.
|
|
101
|
-
*
|
|
102
|
-
* Implementation notes:
|
|
103
|
-
*
|
|
104
|
-
* - We run `git diff --binary <baseSha>` inside the worktree (NOT
|
|
105
|
-
* `git diff <worktree>..HEAD` from the main tree — the worktree's
|
|
106
|
-
* HEAD is detached at `baseSha`, so the meaningful diff is the
|
|
107
|
-
* UNCOMMITTED changes the agent wrote into it).
|
|
108
|
-
* - `--binary` ensures non-text files (assets, images) survive the
|
|
109
|
-
* round-trip; without it `git apply` fails on any binary delta.
|
|
110
|
-
* - We always run `git apply --check` first so a refusal does not
|
|
111
|
-
* leave the main tree half-modified.
|
|
112
|
-
*/
|
|
113
|
-
export function promoteWorktree(opts) {
|
|
114
|
-
if (opts.cancellation && opts.cancellation.isAborted) {
|
|
115
|
-
return { ok: false, reason: 'operator_aborted', detail: 'promoteWorktree aborted' };
|
|
116
|
-
}
|
|
117
|
-
if (!existsSync(opts.worktreePath)) {
|
|
118
|
-
return {
|
|
119
|
-
ok: false,
|
|
120
|
-
reason: 'worktree_missing',
|
|
121
|
-
detail: `worktree path does not exist: ${opts.worktreePath}`,
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
// Capture the diff. Include both unstaged AND staged changes — the
|
|
125
|
-
// agent loop typically stages nothing (it just writes files), but
|
|
126
|
-
// honoring both is forward-compatible with hand-edits inside the
|
|
127
|
-
// worktree.
|
|
128
|
-
const diff = runGit(['diff', '--binary', opts.baseSha], opts.worktreePath);
|
|
129
|
-
if (diff.status !== 0) {
|
|
130
|
-
return {
|
|
131
|
-
ok: false,
|
|
132
|
-
reason: 'git_command_failed',
|
|
133
|
-
detail: `git diff failed: ${diff.stderr}`,
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
if (diff.stdout.trim().length === 0) {
|
|
137
|
-
return { ok: true, value: { filesChanged: 0 } };
|
|
138
|
-
}
|
|
139
|
-
// `git apply --check` validates the diff against the main tree first.
|
|
140
|
-
// Refuse early on conflict so the operator can resolve before we
|
|
141
|
-
// touch any file.
|
|
142
|
-
const check = runGit(['apply', '--check', '-'], opts.cwd, diff.stdout);
|
|
143
|
-
if (check.status !== 0) {
|
|
144
|
-
return {
|
|
145
|
-
ok: false,
|
|
146
|
-
reason: 'apply_conflict',
|
|
147
|
-
detail: `git apply --check rejected: ${check.stderr}`,
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
if (opts.dryRun) {
|
|
151
|
-
return { ok: true, value: { filesChanged: countDiffFiles(diff.stdout) } };
|
|
152
|
-
}
|
|
153
|
-
const apply = runGit(['apply', '-'], opts.cwd, diff.stdout);
|
|
154
|
-
if (apply.status !== 0) {
|
|
155
|
-
return {
|
|
156
|
-
ok: false,
|
|
157
|
-
reason: 'apply_failed',
|
|
158
|
-
detail: `git apply failed: ${apply.stderr}`,
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
return { ok: true, value: { filesChanged: countDiffFiles(diff.stdout) } };
|
|
162
|
-
}
|
|
163
|
-
/**
|
|
164
|
-
* Drop a worktree both from git's bookkeeping and from disk. Idempotent —
|
|
165
|
-
* a missing path returns `worktree_missing` which the caller can ignore
|
|
166
|
-
* on the cleanup-after-error path.
|
|
167
|
-
*/
|
|
168
|
-
export function dropWorktree(worktreePath, cwd) {
|
|
169
|
-
// `git worktree remove --force` cleans the metadata in `.git/worktrees`.
|
|
170
|
-
// If the worktree was created by another process and already pruned,
|
|
171
|
-
// git returns non-zero — we still try to `rmSync` the dir to leave the
|
|
172
|
-
// filesystem consistent.
|
|
173
|
-
const remove = runGit(['worktree', 'remove', '--force', worktreePath], cwd);
|
|
174
|
-
const gitCleanFailed = remove.status !== 0;
|
|
175
|
-
if (existsSync(worktreePath)) {
|
|
176
|
-
try {
|
|
177
|
-
rmSync(worktreePath, { recursive: true, force: true });
|
|
178
|
-
}
|
|
179
|
-
catch (error) {
|
|
180
|
-
if (gitCleanFailed) {
|
|
181
|
-
return {
|
|
182
|
-
ok: false,
|
|
183
|
-
reason: 'git_command_failed',
|
|
184
|
-
detail: `git worktree remove failed AND rmSync failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
if (gitCleanFailed && !worktreePath.includes(`${sep}.pugi${sep}worktrees${sep}`)) {
|
|
190
|
-
// A worktree that wasn't created by us (path is outside our naming
|
|
191
|
-
// convention) is suspicious — surface the failure so the operator
|
|
192
|
-
// can diagnose.
|
|
193
|
-
return {
|
|
194
|
-
ok: false,
|
|
195
|
-
reason: 'git_command_failed',
|
|
196
|
-
detail: `git worktree remove failed: ${remove.stderr}`,
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
return { ok: true, value: undefined };
|
|
200
|
-
}
|
|
201
|
-
function countDiffFiles(diff) {
|
|
202
|
-
// Count `diff --git a/... b/...` headers. Cheap and unambiguous.
|
|
203
|
-
let count = 0;
|
|
204
|
-
for (const line of diff.split('\n')) {
|
|
205
|
-
if (line.startsWith('diff --git '))
|
|
206
|
-
count += 1;
|
|
207
|
-
}
|
|
208
|
-
return count;
|
|
209
|
-
}
|
|
210
|
-
function runGit(args, cwd, stdin) {
|
|
211
|
-
return spawnSync('git', args, {
|
|
212
|
-
cwd,
|
|
213
|
-
input: stdin,
|
|
214
|
-
encoding: 'utf8',
|
|
215
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
/**
|
|
219
|
-
* Test-only helper exporting the internal git runner so specs can stub
|
|
220
|
-
* the spawn surface when running on a CI host without a global git.
|
|
221
|
-
*/
|
|
222
|
-
export const __test__ = { runGit, countDiffFiles };
|
|
223
|
-
/**
|
|
224
|
-
* Re-export the abort marker so the worktree CLI surface can fold the
|
|
225
|
-
* exception into a clean exit code without needing to import from the
|
|
226
|
-
* tools layer.
|
|
227
|
-
*/
|
|
228
|
-
export { OperatorAbortedError };
|
|
229
|
-
//# sourceMappingURL=worktree.js.map
|