@pugi/cli 0.1.0-alpha.9 → 0.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +33 -0
  2. package/assets/pugi-mascot.ansi +41 -0
  3. package/dist/commands/deploy.js +439 -0
  4. package/dist/core/agents/loader.js +104 -0
  5. package/dist/core/agents/registry.js +1 -1
  6. package/dist/core/consensus/anvil-fanout.js +276 -0
  7. package/dist/core/consensus/diff-capture.js +382 -0
  8. package/dist/core/consensus/rubric.js +233 -0
  9. package/dist/core/context/index.js +21 -0
  10. package/dist/core/context/pugiignore.js +316 -0
  11. package/dist/core/context/repo-skeleton.js +533 -0
  12. package/dist/core/context/watcher.js +342 -0
  13. package/dist/core/context/working-set.js +165 -0
  14. package/dist/core/edits/dispatch.js +185 -0
  15. package/dist/core/edits/index.js +15 -0
  16. package/dist/core/edits/layer-a-apply.js +217 -0
  17. package/dist/core/edits/layer-b-apply.js +211 -0
  18. package/dist/core/edits/layer-c-apply.js +160 -0
  19. package/dist/core/edits/layer-d-ast.js +29 -0
  20. package/dist/core/edits/marker-parser.js +401 -0
  21. package/dist/core/edits/security-gate.js +223 -0
  22. package/dist/core/edits/worktree.js +229 -0
  23. package/dist/core/engine/native-pugi.js +6 -1
  24. package/dist/core/engine/prompts.js +4 -1
  25. package/dist/core/engine/tool-bridge.js +33 -1
  26. package/dist/core/lsp/client.js +631 -0
  27. package/dist/core/repl/ask.js +512 -0
  28. package/dist/core/repl/cancellation.js +98 -0
  29. package/dist/core/repl/dispatch-fsm.js +220 -0
  30. package/dist/core/repl/privacy-banner.js +71 -0
  31. package/dist/core/repl/session.js +1896 -13
  32. package/dist/core/repl/slash-commands.js +59 -32
  33. package/dist/core/repl/store/index.js +12 -0
  34. package/dist/core/repl/store/jsonl-log.js +321 -0
  35. package/dist/core/repl/store/lockfile.js +155 -0
  36. package/dist/core/repl/store/session-store.js +792 -0
  37. package/dist/core/repl/store/types.js +44 -0
  38. package/dist/core/repl/store/uuid-v7.js +68 -0
  39. package/dist/core/repl/workspace-context.js +72 -1
  40. package/dist/core/skills/loader.js +454 -0
  41. package/dist/core/skills/sources.js +480 -0
  42. package/dist/core/skills/trust.js +172 -0
  43. package/dist/runtime/cli.js +767 -10
  44. package/dist/runtime/commands/agents.js +385 -0
  45. package/dist/runtime/commands/config.js +338 -8
  46. package/dist/runtime/commands/lsp.js +184 -0
  47. package/dist/runtime/commands/patch.js +111 -0
  48. package/dist/runtime/commands/review-consensus.js +399 -0
  49. package/dist/runtime/commands/skills.js +401 -0
  50. package/dist/runtime/commands/worktree.js +133 -0
  51. package/dist/tools/apply-patch.js +314 -0
  52. package/dist/tools/file-tools.js +90 -0
  53. package/dist/tools/lsp-tools.js +189 -0
  54. package/dist/tools/registry.js +18 -0
  55. package/dist/tools/web-fetch.js +1 -1
  56. package/dist/tui/agent-tree-pane.js +9 -0
  57. package/dist/tui/ask-cli.js +52 -0
  58. package/dist/tui/ask-modal.js +211 -0
  59. package/dist/tui/conversation-pane.js +48 -3
  60. package/dist/tui/input-box.js +48 -5
  61. package/dist/tui/markdown-render.js +266 -0
  62. package/dist/tui/repl-render.js +185 -0
  63. package/dist/tui/repl-splash-mascot.js +130 -0
  64. package/dist/tui/repl-splash.js +7 -1
  65. package/dist/tui/repl.js +82 -11
  66. package/dist/tui/status-bar.js +63 -3
  67. package/dist/tui/tool-stream-pane.js +91 -0
  68. package/package.json +11 -5
@@ -0,0 +1,229 @@
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
@@ -241,6 +241,9 @@ export class NativePugiEngineAdapter {
241
241
  for (const event of buffer)
242
242
  yield event;
243
243
  // Translate the loop outcome into an EngineResult.
244
+ // `aborted` (α6.9: operator cancelled mid-tool) maps to `blocked`
245
+ // because the operator chose the outcome, same shape as
246
+ // budget_exhausted / tool_refused.
244
247
  const status = outcome.status === 'completed'
245
248
  ? 'done'
246
249
  : outcome.status === 'failed'
@@ -252,7 +255,9 @@ export class NativePugiEngineAdapter {
252
255
  ? '[budget_exhausted] '
253
256
  : outcome.status === 'tool_refused'
254
257
  ? '[plan_mode_refused] '
255
- : '[failed] ';
258
+ : outcome.status === 'aborted'
259
+ ? '[operator_aborted] '
260
+ : '[failed] ';
256
261
  const filesChangedList = Array.from(filesChanged).sort();
257
262
  appendSessionMirror(sessionEventsPath, {
258
263
  type: 'outcome',
@@ -22,7 +22,10 @@ import { getJobRegistry, summarizeJobsForPrompt, } from '../jobs/registry.js';
22
22
  const COMMON_LOCAL_FIRST_PREAMBLE = [
23
23
  'You are the Pugi CLI agent running locally inside the operator\'s repository.',
24
24
  'The local filesystem is the source of truth. Every change you make is committed locally; nothing is uploaded by default (ADR-0037 local-first).',
25
- 'You have a tool registry: read, write, edit, grep, glob, bash. Call tools to inspect and modify the workspace.',
25
+ 'You have a tool registry: read, write, edit, grep, glob, bash, apply_patch, lsp_hover, lsp_definition, lsp_references, lsp_diagnostics, worktree_create, worktree_promote, worktree_drop. Call tools to inspect and modify the workspace.',
26
+ 'Use lsp_hover / lsp_definition / lsp_references when you need precise symbol intel — they are faster and more reliable than grep for typed languages (TS/JS/Python/Go/Rust). Fall back to grep when the LSP server is unavailable.',
27
+ 'Use apply_patch when you have a complete unified diff in hand (e.g. forwarded from an external review). For incremental edits, prefer edit/write over apply_patch — the failure modes are clearer.',
28
+ 'Use worktree_create + worktree_promote when a multi-file change needs validation before landing on the operator\'s working tree. Drop the worktree with worktree_drop when done; never leave scratch worktrees around.',
26
29
  'Cite file paths relative to the workspace root. Keep edits minimal and reversible.',
27
30
  'When you are done, return a single final text answer that the operator can read on the CLI.',
28
31
  ].join(' ');
@@ -1,4 +1,4 @@
1
- import { editTool, globTool, grepTool, readTool, writeTool, } from '../../tools/file-tools.js';
1
+ import { editTool, globTool, grepTool, OperatorAbortedError, readTool, writeTool, } from '../../tools/file-tools.js';
2
2
  import { bashToolSync } from '../../tools/bash.js';
3
3
  /**
4
4
  * Tool-bridge: turns the abstract tool registry into:
@@ -147,6 +147,14 @@ export function buildExecutor(input) {
147
147
  // outcome, not a failure, because plan mode is doing its job.
148
148
  throw new Error(`PLAN_MODE_REFUSED: ${name} is not allowed in plan mode`);
149
149
  }
150
+ // α6.9: refuse cancelled-token tool dispatch BEFORE PreToolUse
151
+ // hooks fire so a cancelled brief never reaches user-defined
152
+ // hook scripts. Sentinel `OPERATOR_ABORTED:<tool>` is recognised
153
+ // by `runEngineLoop` as a terminal-cancel signal so the loop
154
+ // returns control to the caller rather than retrying the model.
155
+ if (ctx.cancellation && ctx.cancellation.isAborted) {
156
+ throw new Error(`OPERATOR_ABORTED: ${name} refused — operator cancelled the dispatch.`);
157
+ }
150
158
  // Fire PreToolUse hooks. The match grammar takes the tool name and
151
159
  // (when extractable) the target path. Each new tool dispatch starts a
152
160
  // fresh dedup batch so a hook fires once per dispatch, not once per
@@ -194,6 +202,30 @@ export function buildExecutor(input) {
194
202
  return result;
195
203
  }
196
204
  catch (error) {
205
+ // α6.9: re-shape OperatorAbortedError throws from the
206
+ // file-tools layer into the same `OPERATOR_ABORTED:` sentinel
207
+ // the upstream cancellation gate uses so `runEngineLoop` sees
208
+ // a consistent terminal-cancel signal regardless of whether
209
+ // the abort landed pre-dispatch or mid-tool (e.g. inside the
210
+ // grep file-loop).
211
+ if (error instanceof OperatorAbortedError) {
212
+ if (hooks && sessionId) {
213
+ const path = extractToolPath(name, argsRaw);
214
+ await hooks.fire({
215
+ sessionId,
216
+ event: 'PostToolUseFailure',
217
+ tool: name,
218
+ path,
219
+ payload: {
220
+ tool: name,
221
+ arguments: argsRaw,
222
+ ok: false,
223
+ error: `OPERATOR_ABORTED: ${name}`,
224
+ },
225
+ });
226
+ }
227
+ throw new Error(`OPERATOR_ABORTED: ${name} aborted mid-execution.`);
228
+ }
197
229
  if (hooks && sessionId) {
198
230
  const path = extractToolPath(name, argsRaw);
199
231
  await hooks.fire({