@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.
@@ -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
@@ -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));
@@ -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
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, 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, 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,
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.2",
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.2"
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