@pugi/cli 0.1.0-beta.87 → 0.1.0-beta.89

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/CHANGELOG.md +36 -0
  2. package/LICENSE +1 -1
  3. package/dist/core/agents/registry.js +1 -1
  4. package/dist/core/auth/env-provider.js +1 -1
  5. package/dist/core/checkpoints/shadow-git.js +1 -1
  6. package/dist/core/context/compaction.js +1 -1
  7. package/dist/core/context/markdown-traverse.js +1 -1
  8. package/dist/core/credentials.js +1 -1
  9. package/dist/core/denial-tracking/state.js +1 -1
  10. package/dist/core/edits/fuzzy-ladder.js +1 -1
  11. package/dist/core/edits/layer-a-fuzzy-apply.js +1 -1
  12. package/dist/core/engine/anvil-client.js +76 -2
  13. package/dist/core/engine/native-pugi.js +1 -1
  14. package/dist/core/engine/tool-bridge.js +436 -0
  15. package/dist/core/hooks/events.js +3 -1
  16. package/dist/core/hooks/registry.js +3 -0
  17. package/dist/core/hooks/worktree-events.js +158 -0
  18. package/dist/core/lsp/client.js +453 -0
  19. package/dist/core/lsp/server-detect.js +173 -0
  20. package/dist/core/lsp/symbol-cache.js +162 -0
  21. package/dist/core/lsp/symbol-tools.js +296 -4
  22. package/dist/core/mcp/server-tools.js +1 -1
  23. package/dist/core/mcp/server.js +1 -1
  24. package/dist/core/memory/secret-scanner.js +6 -6
  25. package/dist/core/onboarding/ensure-initialized.js +1 -1
  26. package/dist/core/plans/plan-artifact.js +2 -2
  27. package/dist/core/repl/ask.js +1 -1
  28. package/dist/core/repl/cap-warning.js +1 -1
  29. package/dist/core/repl/session.js +3 -3
  30. package/dist/core/repl/slash-commands.js +1 -1
  31. package/dist/core/routing/pre-flight-estimator.js +1 -1
  32. package/dist/core/settings.js +38 -0
  33. package/dist/core/worktree/include-parser.js +249 -0
  34. package/dist/index.js +8 -0
  35. package/dist/runtime/cli.js +176 -28
  36. package/dist/runtime/commands/agents.js +1 -1
  37. package/dist/runtime/commands/config.js +41 -7
  38. package/dist/runtime/commands/hooks.js +3 -0
  39. package/dist/runtime/commands/review-consensus.js +1 -1
  40. package/dist/runtime/sigint-guard.js +272 -0
  41. package/dist/runtime/version.js +1 -1
  42. package/dist/runtime/worktree-bootstrap.js +579 -0
  43. package/dist/skills/bundled/batch.js +2 -2
  44. package/dist/skills/bundled/index.js +3 -3
  45. package/dist/skills/bundled/loop.js +2 -2
  46. package/dist/skills/bundled/remember.js +1 -1
  47. package/dist/skills/bundled/simplify.js +1 -1
  48. package/dist/skills/bundled/skillify.js +2 -2
  49. package/dist/skills/bundled/stuck.js +1 -1
  50. package/dist/skills/bundled/verify.js +2 -2
  51. package/dist/testing/vcr.js +2 -2
  52. package/dist/tools/ask-user-question.js +66 -0
  53. package/dist/tools/bash.js +2 -2
  54. package/dist/tools/lsp-tools.js +377 -1
  55. package/dist/tools/powershell.js +1 -1
  56. package/dist/tools/registry.js +23 -0
  57. package/dist/tui/ask-user-question-chips.js +257 -0
  58. package/dist/tui/input-box.js +1 -1
  59. package/dist/tui/render.js +1 -1
  60. package/dist/tui/repl.js +1 -1
  61. package/dist/tui/status-bar.js +1 -1
  62. package/dist/tui/update-banner.js +1 -1
  63. package/dist/tui/welcome-data.js +4 -4
  64. package/package.json +4 -3
  65. package/test/scenarios/compact-force.scenario.txt +3 -2
  66. package/test/scenarios/identity.scenario.txt +6 -5
  67. package/test/scenarios/persona-handoff.scenario.txt +2 -1
  68. package/test/scenarios/walkback.scenario.txt +6 -6
@@ -27,6 +27,14 @@ import { stripInternalFields } from './strip-internal-fields.js';
27
27
  import { applyAskAnswer, gate as permissionGate, getToolClass, PermissionDenied, } from '../permissions/index.js';
28
28
  import { RetryBudget, RetryBudgetExhausted, hashArgs } from '../retry-budget/index.js';
29
29
  import { runPostEditDiagnostics, } from '../lsp/post-edit-diagnostics.js';
30
+ // PUGI-78 Phase 1: symbols.* tool wrappers + LSP client cache. The
31
+ // dispatcher resolves the LSP client by `lang` via `getOrStartLspClient`
32
+ // (cold-start cost amortised across the session) and hands it to the
33
+ // matching wrapper. The wrappers serialise the result for the engine
34
+ // envelope.
35
+ import { getOrStartLspClient } from '../lsp/cache.js';
36
+ import { symbolsCallHierarchyTool, symbolsCodeActionsTool, symbolsDiagnosticsTool, symbolsFindDefinitionTool, symbolsFindReferencesTool, symbolsFormatTool, symbolsHoverTool, symbolsImplementationsTool, symbolsListInFileTool, symbolsRenameTool, symbolsSignatureTool, symbolsTypeDefinitionTool, symbolsWorkspaceSymbolsTool, } from '../../tools/lsp-tools.js';
37
+ import { getGlobalSymbolCache } from '../lsp/symbol-cache.js';
30
38
  /**
31
39
  * Tool-bridge: turns the abstract tool registry into:
32
40
  * 1. An OpenAI-shaped tools schema for `EngineLoopClient.send`.
@@ -94,6 +102,27 @@ const READ_ONLY_TOOLS = new Set([
94
102
  // web_fetch. Plan mode keeps the tool available because reading the
95
103
  // web is part of how a plan is researched.
96
104
  'web_search',
105
+ // PUGI-78 Phase 1: symbols.* namespace is read-only in Phase 1
106
+ // (rename / format / code_actions return PREVIEW edits, the
107
+ // dispatcher applies via apply_patch in Phase 2). Plan-mode KEEPS
108
+ // these tools available — navigation / outline / call-hierarchy
109
+ // questions are the bread-and-butter of a planning loop, and the
110
+ // surface explicitly never mutates source in this phase. When Phase
111
+ // 2 lifts to "apply on confirm", the apply path moves OUT of
112
+ // symbols.* and into apply_patch, which is already plan-mode-gated.
113
+ 'symbols_call_hierarchy',
114
+ 'symbols_code_actions',
115
+ 'symbols_diagnostics',
116
+ 'symbols_find_definition',
117
+ 'symbols_find_references',
118
+ 'symbols_format',
119
+ 'symbols_hover',
120
+ 'symbols_implementations',
121
+ 'symbols_list_in_file',
122
+ 'symbols_rename',
123
+ 'symbols_signature',
124
+ 'symbols_type_definition',
125
+ 'symbols_workspace_symbols',
97
126
  ]);
98
127
  /**
99
128
  * Tools the engine loop dispatches. β1 expands the M1 cornerstone six
@@ -152,6 +181,24 @@ const WIRED_TOOLS = new Set([
152
181
  // through the same security gate as Layer A/B/C; not advertised in
153
182
  // plan mode (mutation surface).
154
183
  'multi_edit',
184
+ // PUGI-78 Phase 1: symbols.* namespace (13 LSP-bridged tools). All
185
+ // read-only in Phase 1 — `rename` / `format` / `code_actions`
186
+ // return PREVIEW edits; the dispatcher applies via apply_patch in
187
+ // Phase 2 (PUGI-134). The executor dispatch wires each name to the
188
+ // matching `symbols*Tool` wrapper in `src/tools/lsp-tools.ts`.
189
+ 'symbols_call_hierarchy',
190
+ 'symbols_code_actions',
191
+ 'symbols_diagnostics',
192
+ 'symbols_find_definition',
193
+ 'symbols_find_references',
194
+ 'symbols_format',
195
+ 'symbols_hover',
196
+ 'symbols_implementations',
197
+ 'symbols_list_in_file',
198
+ 'symbols_rename',
199
+ 'symbols_signature',
200
+ 'symbols_type_definition',
201
+ 'symbols_workspace_symbols',
155
202
  ]);
156
203
  export function buildToolsSchema(kind, options = { allowFetch: false, allowSearch: false }) {
157
204
  const planMode = kind === 'plan';
@@ -372,6 +419,162 @@ export function buildToolsSchema(kind, options = { allowFetch: false, allowSearc
372
419
  properties: { name: { type: 'string' } },
373
420
  },
374
421
  });
422
+ // PUGI-78 Phase 1: symbols.* namespace (13 tools). LSP-bridged
423
+ // symbol-aware operations that replace whole-file reads for
424
+ // navigation questions. Each tool returns 1-2 KB of shaped JSON
425
+ // vs 5-50 KB for the raw file read it replaces — 10-100x token
426
+ // savings per refactor turn. All read-only in Phase 1 — `rename`
427
+ // / `format` / `code_actions` return PREVIEW edits the dispatcher
428
+ // applies via apply_patch in a future ticket.
429
+ //
430
+ // Schemas use the same JSON-Schema shape as the rest of the
431
+ // toolbox: required positional args, additionalProperties: false.
432
+ // The `lang` field is a closed enum of the 5 supported language
433
+ // server slugs (ts/js/py/go/rust). The dispatcher resolves the
434
+ // LSP client by lang BEFORE calling the underlying primitive.
435
+ const symbolsLangEnum = { type: 'string', enum: ['ts', 'js', 'py', 'go', 'rust'], description: 'LSP language slug.' };
436
+ const symbolsPosArgs = {
437
+ lang: symbolsLangEnum,
438
+ file: { type: 'string', description: 'Workspace-relative file path.' },
439
+ line: { type: 'integer', minimum: 0, description: 'Zero-based line index.' },
440
+ col: { type: 'integer', minimum: 0, description: 'Zero-based character index.' },
441
+ };
442
+ toolDefs.push({
443
+ name: 'symbols_find_definition',
444
+ description: 'Locate the definition of the symbol at (line, col) in <file>. Returns {file, line, character}. PREFER over read for "where is X defined?" questions.',
445
+ parameters: {
446
+ type: 'object',
447
+ additionalProperties: false,
448
+ required: ['lang', 'file', 'line', 'col'],
449
+ properties: symbolsPosArgs,
450
+ },
451
+ }, {
452
+ name: 'symbols_find_references',
453
+ description: 'List every reference to the symbol at (line, col). Returns {file, line, character}[]. PREFER over grep for "find callers of X" questions.',
454
+ parameters: {
455
+ type: 'object',
456
+ additionalProperties: false,
457
+ required: ['lang', 'file', 'line', 'col'],
458
+ properties: symbolsPosArgs,
459
+ },
460
+ }, {
461
+ name: 'symbols_list_in_file',
462
+ description: 'Outline the symbols (functions, classes, methods) defined in <file>. Returns flat {name, kind, line, character, containerName}[]. PREFER over read for "outline" questions.',
463
+ parameters: {
464
+ type: 'object',
465
+ additionalProperties: false,
466
+ required: ['lang', 'file'],
467
+ properties: { lang: symbolsLangEnum, file: { type: 'string' } },
468
+ },
469
+ }, {
470
+ name: 'symbols_rename',
471
+ description: 'Preview a rename refactor for the symbol at (line, col) to <newName>. Returns {files, edits}[]. PREVIEW ONLY in Phase 1 — operator confirms then dispatches apply_patch.',
472
+ parameters: {
473
+ type: 'object',
474
+ additionalProperties: false,
475
+ required: ['lang', 'file', 'line', 'col', 'newName'],
476
+ properties: {
477
+ ...symbolsPosArgs,
478
+ newName: { type: 'string', minLength: 1, description: 'New symbol name.' },
479
+ },
480
+ },
481
+ }, {
482
+ name: 'symbols_hover',
483
+ description: 'Type info + docstring at (line, col). Returns {content, range?}. Body capped at 4 KB.',
484
+ parameters: {
485
+ type: 'object',
486
+ additionalProperties: false,
487
+ required: ['lang', 'file', 'line', 'col'],
488
+ properties: symbolsPosArgs,
489
+ },
490
+ }, {
491
+ name: 'symbols_signature',
492
+ description: 'Function signature at a call site. Returns {label, parameters, activeParameter?}. NULL when cursor is not inside a call.',
493
+ parameters: {
494
+ type: 'object',
495
+ additionalProperties: false,
496
+ required: ['lang', 'file', 'line', 'col'],
497
+ properties: symbolsPosArgs,
498
+ },
499
+ }, {
500
+ name: 'symbols_workspace_symbols',
501
+ description: 'Workspace-wide fuzzy symbol search. Server-defined match (substring / fuzzy / prefix). Returns {name, file, line, kind, containerName?}[].',
502
+ parameters: {
503
+ type: 'object',
504
+ additionalProperties: false,
505
+ required: ['lang', 'query'],
506
+ properties: {
507
+ lang: symbolsLangEnum,
508
+ query: { type: 'string', minLength: 1, description: 'Symbol query.' },
509
+ },
510
+ },
511
+ }, {
512
+ name: 'symbols_call_hierarchy',
513
+ description: 'Incoming + outgoing callers for the symbol at (line, col). Returns {incoming, outgoing}[].',
514
+ parameters: {
515
+ type: 'object',
516
+ additionalProperties: false,
517
+ required: ['lang', 'file', 'line', 'col'],
518
+ properties: symbolsPosArgs,
519
+ },
520
+ }, {
521
+ name: 'symbols_implementations',
522
+ description: 'Concrete implementations of the interface / abstract method at (line, col). Returns flat references list.',
523
+ parameters: {
524
+ type: 'object',
525
+ additionalProperties: false,
526
+ required: ['lang', 'file', 'line', 'col'],
527
+ properties: symbolsPosArgs,
528
+ },
529
+ }, {
530
+ name: 'symbols_type_definition',
531
+ description: 'Type definition (vs value definition) of the symbol at (line, col). Returns the type declaration location.',
532
+ parameters: {
533
+ type: 'object',
534
+ additionalProperties: false,
535
+ required: ['lang', 'file', 'line', 'col'],
536
+ properties: symbolsPosArgs,
537
+ },
538
+ }, {
539
+ name: 'symbols_code_actions',
540
+ description: 'Quick-fix list at the given range. Returns {title, kind?, isPreferred?}[].',
541
+ parameters: {
542
+ type: 'object',
543
+ additionalProperties: false,
544
+ required: ['lang', 'file', 'startLine', 'startChar', 'endLine', 'endChar'],
545
+ properties: {
546
+ lang: symbolsLangEnum,
547
+ file: { type: 'string' },
548
+ startLine: { type: 'integer', minimum: 0 },
549
+ startChar: { type: 'integer', minimum: 0 },
550
+ endLine: { type: 'integer', minimum: 0 },
551
+ endChar: { type: 'integer', minimum: 0 },
552
+ },
553
+ },
554
+ }, {
555
+ name: 'symbols_format',
556
+ description: 'Formatter — returns the text edits the LSP server would apply. PREVIEW ONLY in Phase 1.',
557
+ parameters: {
558
+ type: 'object',
559
+ additionalProperties: false,
560
+ required: ['lang', 'file'],
561
+ properties: {
562
+ lang: symbolsLangEnum,
563
+ file: { type: 'string' },
564
+ tabSize: { type: 'integer', minimum: 1 },
565
+ insertSpaces: { type: 'boolean' },
566
+ },
567
+ },
568
+ }, {
569
+ name: 'symbols_diagnostics',
570
+ description: 'Cached LSP diagnostics (error / warning / info / hint) for <file>. Returns up to ~50 entries.',
571
+ parameters: {
572
+ type: 'object',
573
+ additionalProperties: false,
574
+ required: ['lang', 'file'],
575
+ properties: { lang: symbolsLangEnum, file: { type: 'string' } },
576
+ },
577
+ });
375
578
  // β1 T5 → β1a r1 (gating fix): WebFetch wire-in. Schema
376
579
  // mirrors the existing tool surface in
377
580
  // `apps/pugi-cli/src/tools/web-fetch.ts`. SSRF guard runs inside the
@@ -989,6 +1192,16 @@ export function buildExecutor(input) {
989
1192
  }
990
1193
  return dispatchAgent(args, agentDispatch);
991
1194
  }
1195
+ // PUGI-78 Phase 1: symbols.* namespace dispatch. Every name in
1196
+ // SYMBOLS_TOOL_NAMES routes through `dispatchSymbolsTool` which
1197
+ // resolves the LSP client by `lang`, calls the matching
1198
+ // `symbols*Tool` wrapper in `src/tools/lsp-tools.ts`, and
1199
+ // serialises the result for the engine envelope. Read-only —
1200
+ // matches the registry posture; the post-edit diagnostics hook
1201
+ // below is the only mutation the symbols.* surface participates in.
1202
+ if (SYMBOLS_TOOL_NAMES.has(name)) {
1203
+ return dispatchSymbolsTool(name, args, ctx);
1204
+ }
992
1205
  return dispatchTool(name, args, ctx);
993
1206
  };
994
1207
  try {
@@ -1514,9 +1727,232 @@ const POST_EDIT_TOOLS = new Set(['edit', 'write', 'multi_edit']);
1514
1727
  * OR `PUGI_LSP_POST_EDIT=1`. Off by default until dogfood validates
1515
1728
  * the cold-start cost vs the model-loop benefit ().
1516
1729
  */
1730
+ /**
1731
+ * PUGI-78 Phase 1: dispatched-name allowlist for the symbols.* router.
1732
+ * Sourced from the same list as the JSON-schema additions in
1733
+ * `buildToolsSchema` + the registry entries in `tools/registry.ts` — a
1734
+ * mismatch would surface as either an advertised-but-unrouted tool
1735
+ * (codex review P1 from this PR) or an unknown-tool denial.
1736
+ */
1737
+ const SYMBOLS_TOOL_NAMES = new Set([
1738
+ 'symbols_call_hierarchy',
1739
+ 'symbols_code_actions',
1740
+ 'symbols_diagnostics',
1741
+ 'symbols_find_definition',
1742
+ 'symbols_find_references',
1743
+ 'symbols_format',
1744
+ 'symbols_hover',
1745
+ 'symbols_implementations',
1746
+ 'symbols_list_in_file',
1747
+ 'symbols_rename',
1748
+ 'symbols_signature',
1749
+ 'symbols_type_definition',
1750
+ 'symbols_workspace_symbols',
1751
+ ]);
1752
+ /**
1753
+ * PUGI-78 Phase 1: dispatch a symbols.* tool call. Common pre-flight
1754
+ * (validate `lang`, resolve / spawn the LSP client via the warm cache,
1755
+ * build an `LspToolContext` from the engine `ToolContext`) lives here so
1756
+ * each per-tool branch stays a thin shim around the matching wrapper.
1757
+ *
1758
+ * Failure shape: the wrappers return a structured `{ok, value, reason}`
1759
+ * record — we JSON-stringify it for the engine envelope. The engine
1760
+ * adapter treats a string return as the tool result text; the wrappers
1761
+ * never throw (errors are caught and folded into the structured
1762
+ * `ok: false` record), so the dispatch path is safe to call without a
1763
+ * try/catch wrapper here.
1764
+ */
1765
+ async function dispatchSymbolsTool(name, args, ctx) {
1766
+ const langRaw = args['lang'];
1767
+ const validLangs = ['ts', 'js', 'py', 'go', 'rust'];
1768
+ if (typeof langRaw !== 'string' || !validLangs.includes(langRaw)) {
1769
+ return JSON.stringify({
1770
+ ok: false,
1771
+ reason: 'invalid_argument',
1772
+ detail: `${name}: 'lang' must be one of ts | js | py | go | rust (got: ${langRaw})`,
1773
+ });
1774
+ }
1775
+ const lang = langRaw;
1776
+ // Resolve (and lazily start) the LSP client. The cache holds the
1777
+ // client across the session so the cold start is paid once.
1778
+ const lspOpts = {
1779
+ cwd: ctx.root,
1780
+ ...(ctx.settings.lsp ? { lspSettings: ctx.settings.lsp } : {}),
1781
+ };
1782
+ const lspResult = await getOrStartLspClient(lang, lspOpts);
1783
+ const lspToolCtx = {
1784
+ ...ctx,
1785
+ ...(lspResult.ok
1786
+ ? { lspClients: new Map([[lang, lspResult.client]]) }
1787
+ : {}),
1788
+ symbolCache: getGlobalSymbolCache(),
1789
+ };
1790
+ // Validate required positional args per tool. Each tool that needs
1791
+ // a position rejects missing / non-finite / negative coordinates
1792
+ // with a structured `invalid_argument` envelope so the model sees
1793
+ // a clear correction signal instead of a silent (0,0) coercion that
1794
+ // would otherwise yield `lsp_not_found`.
1795
+ const requiresFile = name !== 'symbols_workspace_symbols';
1796
+ const requiresPosition = name === 'symbols_find_definition' ||
1797
+ name === 'symbols_find_references' ||
1798
+ name === 'symbols_hover' ||
1799
+ name === 'symbols_signature' ||
1800
+ name === 'symbols_implementations' ||
1801
+ name === 'symbols_type_definition' ||
1802
+ name === 'symbols_call_hierarchy' ||
1803
+ name === 'symbols_rename';
1804
+ const file = typeof args['file'] === 'string' ? args['file'] : '';
1805
+ if (requiresFile && file.length === 0) {
1806
+ return JSON.stringify({
1807
+ ok: false,
1808
+ reason: 'invalid_argument',
1809
+ detail: `${name}: 'file' must be a non-empty workspace-relative path`,
1810
+ });
1811
+ }
1812
+ const lineRaw = args['line'];
1813
+ const colRaw = args['col'];
1814
+ if (requiresPosition) {
1815
+ if (typeof lineRaw !== 'number' || !Number.isFinite(lineRaw) || lineRaw < 0) {
1816
+ return JSON.stringify({
1817
+ ok: false,
1818
+ reason: 'invalid_argument',
1819
+ detail: `${name}: 'line' must be a non-negative integer`,
1820
+ });
1821
+ }
1822
+ if (typeof colRaw !== 'number' || !Number.isFinite(colRaw) || colRaw < 0) {
1823
+ return JSON.stringify({
1824
+ ok: false,
1825
+ reason: 'invalid_argument',
1826
+ detail: `${name}: 'col' must be a non-negative integer`,
1827
+ });
1828
+ }
1829
+ }
1830
+ const line = typeof lineRaw === 'number' ? lineRaw : 0;
1831
+ const col = typeof colRaw === 'number' ? colRaw : 0;
1832
+ try {
1833
+ switch (name) {
1834
+ case 'symbols_find_definition': {
1835
+ const result = await symbolsFindDefinitionTool(lspToolCtx, lang, file, line, col);
1836
+ return JSON.stringify(result);
1837
+ }
1838
+ case 'symbols_find_references': {
1839
+ const result = await symbolsFindReferencesTool(lspToolCtx, lang, file, line, col);
1840
+ return JSON.stringify(result);
1841
+ }
1842
+ case 'symbols_list_in_file': {
1843
+ const result = await symbolsListInFileTool(lspToolCtx, lang, file);
1844
+ return JSON.stringify(result);
1845
+ }
1846
+ case 'symbols_rename': {
1847
+ const newName = typeof args['newName'] === 'string' ? args['newName'] : '';
1848
+ const result = await symbolsRenameTool(lspToolCtx, lang, file, line, col, newName);
1849
+ return JSON.stringify(result);
1850
+ }
1851
+ case 'symbols_hover': {
1852
+ const result = await symbolsHoverTool(lspToolCtx, lang, file, line, col);
1853
+ return JSON.stringify(result);
1854
+ }
1855
+ case 'symbols_signature': {
1856
+ const result = await symbolsSignatureTool(lspToolCtx, lang, file, line, col);
1857
+ return JSON.stringify(result);
1858
+ }
1859
+ case 'symbols_workspace_symbols': {
1860
+ const query = typeof args['query'] === 'string' ? args['query'] : '';
1861
+ const result = await symbolsWorkspaceSymbolsTool(lspToolCtx, lang, query);
1862
+ return JSON.stringify(result);
1863
+ }
1864
+ case 'symbols_call_hierarchy': {
1865
+ const result = await symbolsCallHierarchyTool(lspToolCtx, lang, file, line, col);
1866
+ return JSON.stringify(result);
1867
+ }
1868
+ case 'symbols_implementations': {
1869
+ const result = await symbolsImplementationsTool(lspToolCtx, lang, file, line, col);
1870
+ return JSON.stringify(result);
1871
+ }
1872
+ case 'symbols_type_definition': {
1873
+ const result = await symbolsTypeDefinitionTool(lspToolCtx, lang, file, line, col);
1874
+ return JSON.stringify(result);
1875
+ }
1876
+ case 'symbols_code_actions': {
1877
+ // Validate every coordinate of the range. Same gate as the
1878
+ // position-args check above — silent (0,0,0,0) coercion would
1879
+ // hide schema-noncompliant calls and yield an empty action
1880
+ // list instead of a clear correction signal.
1881
+ const coords = ['startLine', 'startChar', 'endLine', 'endChar'];
1882
+ for (const k of coords) {
1883
+ const v = args[k];
1884
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) {
1885
+ return JSON.stringify({
1886
+ ok: false,
1887
+ reason: 'invalid_argument',
1888
+ detail: `symbols_code_actions: '${k}' must be a non-negative integer`,
1889
+ });
1890
+ }
1891
+ }
1892
+ const startLine = args['startLine'];
1893
+ const startChar = args['startChar'];
1894
+ const endLine = args['endLine'];
1895
+ const endChar = args['endChar'];
1896
+ const result = await symbolsCodeActionsTool(lspToolCtx, lang, file, startLine, startChar, endLine, endChar);
1897
+ return JSON.stringify(result);
1898
+ }
1899
+ case 'symbols_format': {
1900
+ const tabSize = typeof args['tabSize'] === 'number' ? args['tabSize'] : undefined;
1901
+ const insertSpaces = typeof args['insertSpaces'] === 'boolean' ? args['insertSpaces'] : undefined;
1902
+ const options = {};
1903
+ if (typeof tabSize === 'number')
1904
+ options.tabSize = tabSize;
1905
+ if (typeof insertSpaces === 'boolean')
1906
+ options.insertSpaces = insertSpaces;
1907
+ const result = await symbolsFormatTool(lspToolCtx, lang, file, options);
1908
+ return JSON.stringify(result);
1909
+ }
1910
+ case 'symbols_diagnostics': {
1911
+ const result = await symbolsDiagnosticsTool(lspToolCtx, lang, file);
1912
+ return JSON.stringify(result);
1913
+ }
1914
+ default:
1915
+ return JSON.stringify({
1916
+ ok: false,
1917
+ reason: 'unknown_symbols_tool',
1918
+ detail: `${name} is not a recognised symbols.* tool`,
1919
+ });
1920
+ }
1921
+ }
1922
+ catch (error) {
1923
+ // The wrappers fold errors into structured ok:false records, so
1924
+ // this branch is defense-in-depth for a refactor regression. If
1925
+ // any wrapper ever throws, we surface a stable shape to the model
1926
+ // instead of leaking the raw exception type.
1927
+ return JSON.stringify({
1928
+ ok: false,
1929
+ reason: 'symbols_dispatch_error',
1930
+ detail: error instanceof Error ? error.message : String(error),
1931
+ });
1932
+ }
1933
+ }
1517
1934
  async function appendPostEditDiagnostics(name, args, ctx, result) {
1518
1935
  if (!POST_EDIT_TOOLS.has(name))
1519
1936
  return result;
1937
+ // PUGI-78 Phase 1 (codex P2 fix): a successful edit / write / multi_edit
1938
+ // invalidates the symbol cache for this workspace. Without this, a
1939
+ // subsequent symbols.* query inside the same 5-minute TTL window could
1940
+ // return stale line numbers / outlines / references / hover from
1941
+ // before the edit. We invalidate BEFORE the post-edit diagnostics
1942
+ // gate so the invalidation fires even when post-edit-diagnostics is
1943
+ // disabled (the cache concern is independent of the diagnostics
1944
+ // surface). The invalidation is process-global; subagents that share
1945
+ // the same Node process share the cache, so the invalidation
1946
+ // propagates without an additional hop.
1947
+ try {
1948
+ const cache = getGlobalSymbolCache();
1949
+ cache.invalidateWorkspace(ctx.root);
1950
+ }
1951
+ catch {
1952
+ // Defense-in-depth — cache invalidation must never block the
1953
+ // engine's tool envelope. A throw here is a soft contract
1954
+ // violation but recoverable.
1955
+ }
1520
1956
  if (!isPostEditEnabled(ctx))
1521
1957
  return result;
1522
1958
  const paths = extractEditedPaths(name, args);
@@ -25,7 +25,7 @@
25
25
  *
26
26
  * Brand voice: ASCII only, no emoji, no em-dashes, no marketing prose.
27
27
  */
28
- /** Events the MVP actually fires. The 6 deferred events live in the */
28
+ /** Events the MVP actually fires. The remaining events live in the */
29
29
  /** type but no integration point emits them yet. */
30
30
  export const MVP_HOOK_EVENTS = [
31
31
  'SessionStart',
@@ -40,5 +40,7 @@ export const ALL_HOOK_EVENTS_V2 = [
40
40
  'SubagentStop',
41
41
  'PreCompact',
42
42
  'Notification',
43
+ 'WorktreeCreate',
44
+ 'WorktreeRemove',
43
45
  ];
44
46
  //# sourceMappingURL=events.js.map
@@ -71,6 +71,9 @@ const hookEventEnum = z.enum([
71
71
  'SubagentStop',
72
72
  'PreCompact',
73
73
  'Notification',
74
+ // PUGI-487 - user-facing worktree lifecycle events.
75
+ 'WorktreeCreate',
76
+ 'WorktreeRemove',
74
77
  ]);
75
78
  const hooksFileSchema = z
76
79
  .object({
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Worktree lifecycle hooks - PUGI-487.
3
+ *
4
+ * Extends the existing hook lifecycle (`SessionStart` / `PreToolUse` /
5
+ * etc.) with two new events targeted at the user-facing `--worktree`
6
+ * flag:
7
+ *
8
+ * - `WorktreeCreate`: fired when the operator runs
9
+ * `pugi --worktree <name>` BEFORE git has touched the filesystem.
10
+ * Receives a JSON document on stdin describing the requested
11
+ * worktree. If the hook's stdout has a JSON envelope with a
12
+ * `directory` field, the runtime uses that path instead of the
13
+ * default `.claude/worktrees/<name>/` location. Non-zero exit aborts
14
+ * worktree creation when `blocking: true` is set.
15
+ *
16
+ * - `WorktreeRemove`: fired when the operator (or the cleanup sweep)
17
+ * is about to delete a worktree. Receives a JSON document on stdin
18
+ * describing the target. Non-zero exit aborts removal when
19
+ * `blocking: true`.
20
+ *
21
+ * Both events follow the JSON-on-stdin pattern of the existing v2
22
+ * hook contract. The dispatcher is intentionally small because the
23
+ * heavy lifting (file load, schema validation, timeout enforcement)
24
+ * lives in `core/hooks/registry.ts` and `core/hooks/runner.ts`.
25
+ *
26
+ * Brand voice: ASCII only, no emoji, no banned words.
27
+ */
28
+ import { spawnSync } from 'node:child_process';
29
+ import { DEFAULT_HOOK_TIMEOUT_MS } from './registry.js';
30
+ /**
31
+ * Fire every hook configured for the given worktree event. Returns the
32
+ * aggregated outcome including any directory override and the
33
+ * blocking-failure short circuit.
34
+ *
35
+ * Each hook receives the JSON payload on stdin and is expected to
36
+ * produce its response on stdout. Hooks are run sequentially (not in
37
+ * parallel) so that ordering across a multi-hook chain stays
38
+ * deterministic - the first directory override wins.
39
+ */
40
+ export function fireWorktreeHooks(config, event, payload, options = {}) {
41
+ const entries = config.list(event);
42
+ const spawn = options.spawn ?? defaultSpawn;
43
+ const results = [];
44
+ let directoryOverride;
45
+ let anyBlocked = false;
46
+ const json = JSON.stringify(payload);
47
+ for (const entry of entries) {
48
+ const start = Date.now();
49
+ const ret = spawn(entry.command, {
50
+ input: json,
51
+ encoding: 'utf8',
52
+ shell: '/bin/sh',
53
+ timeout: entry.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
54
+ maxBuffer: 1 * 1024 * 1024,
55
+ });
56
+ const elapsedMs = Date.now() - start;
57
+ const stdout = bufToString(ret.stdout);
58
+ const stderr = bufToString(ret.stderr);
59
+ const ok = ret.status === 0;
60
+ const blocked = !ok && entry.blocking === true;
61
+ if (blocked)
62
+ anyBlocked = true;
63
+ let dirOverride;
64
+ if (event === 'WorktreeCreate' && ok && stdout.length > 0) {
65
+ dirOverride = extractDirectoryOverride(stdout);
66
+ if (dirOverride && !directoryOverride) {
67
+ directoryOverride = dirOverride;
68
+ }
69
+ }
70
+ results.push({
71
+ command: entry.command.slice(0, 200),
72
+ exitCode: ret.status,
73
+ stdout,
74
+ stderr,
75
+ elapsedMs,
76
+ ok,
77
+ blocked,
78
+ ...(dirOverride ? { directoryOverride: dirOverride } : {}),
79
+ });
80
+ if (blocked)
81
+ break;
82
+ }
83
+ return {
84
+ event,
85
+ results,
86
+ anyBlocked,
87
+ ...(directoryOverride ? { directoryOverride } : {}),
88
+ };
89
+ }
90
+ /**
91
+ * Extract a `directory` override from hook stdout. Two accepted shapes:
92
+ *
93
+ * 1. JSON object with `{ "directory": "<path>" }`.
94
+ * 2. Bare path string (trimmed first non-empty line) when the hook
95
+ * writes plain text. We accept the trimmed line as the override
96
+ * ONLY if it looks like a non-flag path (no leading `-`, no `\n`,
97
+ * no shell metacharacters).
98
+ *
99
+ * Returns undefined when no override is detected.
100
+ */
101
+ export function extractDirectoryOverride(stdout) {
102
+ const trimmed = stdout.trim();
103
+ if (trimmed.length === 0)
104
+ return undefined;
105
+ if (trimmed.startsWith('{')) {
106
+ try {
107
+ const parsed = JSON.parse(trimmed);
108
+ if (parsed &&
109
+ typeof parsed === 'object' &&
110
+ !Array.isArray(parsed) &&
111
+ typeof parsed.directory === 'string') {
112
+ const dir = parsed.directory.trim();
113
+ if (isSafePathToken(dir))
114
+ return dir;
115
+ }
116
+ }
117
+ catch {
118
+ // fall through to bare-path detection
119
+ }
120
+ }
121
+ const firstLine = trimmed.split(/\r?\n/, 1)[0] ?? '';
122
+ if (firstLine.length === 0)
123
+ return undefined;
124
+ if (isSafePathToken(firstLine))
125
+ return firstLine;
126
+ return undefined;
127
+ }
128
+ /**
129
+ * Cheap defence-in-depth check: a directory override must not look
130
+ * like a shell injection vector. The override is later validated for
131
+ * containment (the runtime refuses paths that escape the repo root),
132
+ * but rejecting obvious metacharacters here surfaces a cleaner error.
133
+ */
134
+ function isSafePathToken(value) {
135
+ if (value.length === 0)
136
+ return false;
137
+ if (value.startsWith('-'))
138
+ return false;
139
+ if (/[;`$&|<>\n\r\t]/.test(value))
140
+ return false;
141
+ return true;
142
+ }
143
+ function defaultSpawn(command, options) {
144
+ const result = spawnSync(command, [], options);
145
+ return {
146
+ status: result.status,
147
+ stdout: result.stdout ?? '',
148
+ stderr: result.stderr ?? '',
149
+ };
150
+ }
151
+ function bufToString(v) {
152
+ if (v === undefined || v === null)
153
+ return '';
154
+ if (typeof v === 'string')
155
+ return v;
156
+ return v.toString('utf8');
157
+ }
158
+ //# sourceMappingURL=worktree-events.js.map