@pugi/cli 0.1.0-beta.100 → 0.1.0-beta.101

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 (32) hide show
  1. package/README.md +2 -0
  2. package/dist/core/codegraph/parser.js +574 -47
  3. package/dist/core/codegraph/queries/go.scm +57 -0
  4. package/dist/core/codegraph/queries/javascript.scm +56 -0
  5. package/dist/core/codegraph/queries/python.scm +55 -0
  6. package/dist/core/codegraph/queries/rust.scm +63 -0
  7. package/dist/core/codegraph/queries/typescript.scm +91 -0
  8. package/dist/core/codegraph/reindex.js +218 -0
  9. package/dist/core/codegraph/resolve-edges.js +107 -0
  10. package/dist/core/codegraph/watcher.js +440 -0
  11. package/dist/core/diagnostics/probes/sandbox.js +7 -12
  12. package/dist/core/engine/prompts.js +32 -0
  13. package/dist/core/eval/v1/ledger.js +83 -0
  14. package/dist/core/eval/v1/runner.js +280 -0
  15. package/dist/core/eval/v1/scoring.js +68 -0
  16. package/dist/core/eval/v1/task-loader.js +191 -0
  17. package/dist/core/eval/v1/types.js +14 -0
  18. package/dist/core/eval/v1/verifier.js +176 -0
  19. package/dist/core/eval/v1/yaml-parser.js +250 -0
  20. package/dist/core/sandboxing/adapter.js +31 -17
  21. package/dist/core/sandboxing/bubblewrap.js +209 -0
  22. package/dist/core/sandboxing/index.js +32 -3
  23. package/dist/core/sandboxing/policy.js +97 -0
  24. package/dist/core/sandboxing/seatbelt.js +69 -21
  25. package/dist/core/settings.js +31 -7
  26. package/dist/runtime/cli.js +58 -0
  27. package/dist/runtime/commands/eval-v1.js +266 -0
  28. package/dist/runtime/commands/index-cmd.js +125 -19
  29. package/dist/runtime/commands/servers-cli.js +182 -0
  30. package/dist/runtime/version.js +1 -1
  31. package/dist/tools/bash.js +187 -3
  32. package/package.json +10 -3
@@ -73,6 +73,7 @@ import { installDefaultSkills } from '../core/skills/defaults.js';
73
73
  import { runRememberCommand, runSimplifyCommand, runStuckCommand, runBatchCommand, runVerifyCommand, runLoopCommand, runSkillifyCommand, } from '../skills/bundled/index.js';
74
74
  import { runAgentsCommand } from './commands/agents.js';
75
75
  import { runIndexCommand } from './commands/index-cmd.js';
76
+ import { runServersCliCommand } from './commands/servers-cli.js';
76
77
  import { runLspCommand } from './commands/lsp.js';
77
78
  import { runPatchCommand } from './commands/patch.js';
78
79
  import { runWorktreeCommand } from './commands/worktree.js';
@@ -214,6 +215,21 @@ const handlers = {
214
215
  // scenario. Subcommand-only — no slash counterpart per the Phase 1
215
216
  // scope ("no new slash commands; harness is CLI subcommand only").
216
217
  smoke: dispatchSmoke,
218
+ // Backlog #120 ( Reviewer foundation): `pugi eval-v1` runs
219
+ // the frozen 20-task benchmark and produces a scalar pugi_score per
220
+ // task plus an aggregate mean. Ledger lives at `eval/v1/results.tsv`
221
+ // (append-only per backlog #110). Real-engine runs are operator-
222
+ // triggered via `--all`; CI wires the same command on a nightly
223
+ // schedule in `.github/workflows/eval-v1.yml`.
224
+ 'eval-v1': dispatchEvalV1,
225
+ // PR M (2026-06-05): `pugi servers` top-level CLI mirroring the
226
+ // `/servers` REPL slash. Lists tracked server_start processes and
227
+ // kills them via the SIGTERM ladder. Same primitive
228
+ // (`runServersCommand`) as the slash; this wrapper only owns the
229
+ // argv contract + exit codes (0 / 2 / 3) + the `--workspace`
230
+ // override so the operator can rescue an orphaned server from any
231
+ // shell after the REPL is gone.
232
+ servers: dispatchServers,
217
233
  sync,
218
234
  style: dispatchStyle,
219
235
  // `pugi theme` flips the local TUI color
@@ -623,6 +639,29 @@ async function dispatchSmoke(args, flags, _session) {
623
639
  if (rc !== 0)
624
640
  process.exitCode = rc;
625
641
  }
642
+ /**
643
+ * Backlog #120 - `pugi eval-v1` dispatcher.
644
+ *
645
+ * Forwards к `runEvalV1Command`. The handler stays thin: the eval
646
+ * runner owns workspace setup + scoring + ledger; the dispatcher only
647
+ * routes argv + JSON flag + exit code.
648
+ *
649
+ * Exit codes:
650
+ * 0 - every task passed (or `--list` / `--help`).
651
+ * 1 - at least one task failed.
652
+ * 2 - invalid args / task load failure.
653
+ */
654
+ async function dispatchEvalV1(args, flags, _session) {
655
+ const { runEvalV1Command } = await import('./commands/eval-v1.js');
656
+ const rc = await runEvalV1Command({
657
+ args,
658
+ cwd: process.cwd(),
659
+ json: flags.json,
660
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
661
+ });
662
+ if (rc !== 0)
663
+ process.exitCode = rc;
664
+ }
626
665
  /**
627
666
  * Backlog — `pugi stuck` top-level dispatcher.
628
667
  *
@@ -975,6 +1014,25 @@ async function dispatchIndex(args, flags, _session) {
975
1014
  if (rc !== 0)
976
1015
  process.exitCode = rc;
977
1016
  }
1017
+ /**
1018
+ * PR M (2026-06-05): `pugi servers [stop <target>] [--workspace <path>]`
1019
+ * top-level dispatcher. Thin wrapper around `runServersCliCommand` -
1020
+ * the runner owns argv parsing, IO sink wiring, and the exit-code
1021
+ * mapping. Mirrors the `dispatchIndex` shape.
1022
+ *
1023
+ * Exit-code policy (forwarded from the runner):
1024
+ * 0 - success / empty / stopped / --help
1025
+ * 2 - usage error (unknown flag, missing stop target)
1026
+ * 3 - not-found (stop target did not match)
1027
+ */
1028
+ async function dispatchServers(args, flags, _session) {
1029
+ const rc = await runServersCliCommand(args, {
1030
+ workspaceRoot: process.cwd(),
1031
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
1032
+ });
1033
+ if (rc !== 0)
1034
+ process.exitCode = rc;
1035
+ }
978
1036
  /**
979
1037
  * — `pugi onboarding` top-level dispatcher.
980
1038
  *
@@ -0,0 +1,266 @@
1
+ /**
2
+ * `pugi eval-v1` - run the frozen 20-task benchmark harness (#120).
3
+ *
4
+ * Surface:
5
+ *
6
+ * pugi eval-v1 --list # list task ids + briefs
7
+ * pugi eval-v1 --task 01-snake-html # run one task
8
+ * pugi eval-v1 --task 01,02,03 # run a subset
9
+ * pugi eval-v1 --all # run the full 20
10
+ * pugi eval-v1 --all --model qwen3-coder # pin the engine model
11
+ * pugi eval-v1 --all --json # JSON envelope output
12
+ * pugi eval-v1 --all --no-ledger # skip results.tsv append
13
+ *
14
+ * The handler stays thin: it resolves the tasks directory + pugi
15
+ * binary, calls `runHarness`, appends every result к the ledger
16
+ * (unless `--no-ledger`), and renders either a text table or a JSON
17
+ * envelope.
18
+ *
19
+ * Aggregate scoring (mean across all per-task scores) drives the CI
20
+ * regression gate in `.github/workflows/eval-v1.yml`. The gate is
21
+ * "aggregatePugiScore must not drop more than 10% vs the last week
22
+ * median" - the workflow computes the median itself from the
23
+ * committed ledger; the CLI's only job is к produce a fresh row.
24
+ */
25
+ import { execFileSync } from 'node:child_process';
26
+ import { existsSync } from 'node:fs';
27
+ import { dirname, resolve } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { defaultLedgerPath, defaultTasksDir, loadAllTasks, } from '../../core/eval/v1/task-loader.js';
30
+ import { aggregateScore } from '../../core/eval/v1/scoring.js';
31
+ import { appendLedgerRows } from '../../core/eval/v1/ledger.js';
32
+ import { runHarness } from '../../core/eval/v1/runner.js';
33
+ function parseFlags(args) {
34
+ const out = {
35
+ list: false,
36
+ all: false,
37
+ task: [],
38
+ model: undefined,
39
+ ledger: true,
40
+ tasksDir: undefined,
41
+ ledgerPath: undefined,
42
+ help: false,
43
+ };
44
+ for (let i = 0; i < args.length; i += 1) {
45
+ const arg = args[i] ?? '';
46
+ if (arg === '--list')
47
+ out.list = true;
48
+ else if (arg === '--all')
49
+ out.all = true;
50
+ else if (arg === '--no-ledger')
51
+ out.ledger = false;
52
+ else if (arg === '--help' || arg === '-h')
53
+ out.help = true;
54
+ else if (arg === '--task') {
55
+ const next = args[i + 1];
56
+ if (!next || next.startsWith('--')) {
57
+ throw new Error('--task requires a comma-separated list of ids');
58
+ }
59
+ out.task.push(...next.split(',').map((s) => s.trim()).filter(Boolean));
60
+ i += 1;
61
+ }
62
+ else if (arg.startsWith('--task=')) {
63
+ out.task.push(...arg.slice('--task='.length).split(',').map((s) => s.trim()).filter(Boolean));
64
+ }
65
+ else if (arg === '--model') {
66
+ const next = args[i + 1];
67
+ if (!next)
68
+ throw new Error('--model requires a value');
69
+ out.model = next;
70
+ i += 1;
71
+ }
72
+ else if (arg.startsWith('--model=')) {
73
+ out.model = arg.slice('--model='.length);
74
+ }
75
+ else if (arg === '--tasks-dir') {
76
+ const next = args[i + 1];
77
+ if (!next)
78
+ throw new Error('--tasks-dir requires a path');
79
+ out.tasksDir = next;
80
+ i += 1;
81
+ }
82
+ else if (arg.startsWith('--tasks-dir=')) {
83
+ out.tasksDir = arg.slice('--tasks-dir='.length);
84
+ }
85
+ else if (arg === '--ledger') {
86
+ const next = args[i + 1];
87
+ if (!next)
88
+ throw new Error('--ledger requires a path');
89
+ out.ledgerPath = next;
90
+ i += 1;
91
+ }
92
+ else if (arg.startsWith('--ledger=')) {
93
+ out.ledgerPath = arg.slice('--ledger='.length);
94
+ }
95
+ else if (arg === '--json') {
96
+ // accepted at top-level; ignore here
97
+ }
98
+ else {
99
+ throw new Error(`unknown eval-v1 flag: ${arg}`);
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+ function resolvePackageRoot(override) {
105
+ if (override)
106
+ return resolve(override);
107
+ // The compiled file lives at `dist/runtime/commands/eval-v1.js`;
108
+ // climb three directories to reach the package root in both dev
109
+ // (`src/runtime/commands/eval-v1.ts`) and dist builds.
110
+ const here = dirname(fileURLToPath(import.meta.url));
111
+ return resolve(here, '..', '..', '..');
112
+ }
113
+ function resolvePugiBin(override, packageRoot) {
114
+ if (override)
115
+ return resolve(override);
116
+ const local = resolve(packageRoot, 'bin', 'run.js');
117
+ if (existsSync(local))
118
+ return local;
119
+ // Fall back к PATH lookup; spawn will pick it up.
120
+ return 'pugi';
121
+ }
122
+ function captureGitSha(packageRoot) {
123
+ try {
124
+ const out = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
125
+ cwd: packageRoot,
126
+ encoding: 'utf8',
127
+ stdio: ['ignore', 'pipe', 'ignore'],
128
+ });
129
+ const trimmed = out.trim();
130
+ return trimmed === '' ? '(unknown)' : trimmed;
131
+ }
132
+ catch {
133
+ return '(unknown)';
134
+ }
135
+ }
136
+ function renderTable(report) {
137
+ const headers = ['id', 'difficulty', 'status', 'score', 'tokens', 'wall_ms', 'verifs'];
138
+ const rows = report.results.map((r) => [
139
+ r.taskId,
140
+ findDifficulty(r.taskId, report) ?? '?',
141
+ r.status,
142
+ r.pugiScore.toFixed(2),
143
+ String(r.tokensUsed),
144
+ String(r.wallClockMs),
145
+ `${r.verifications.filter((v) => v.passed).length}/${r.verifications.length}`,
146
+ ]);
147
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)));
148
+ const renderRow = (cells) => cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(' ');
149
+ const lines = [renderRow(headers), renderRow(widths.map((w) => '-'.repeat(w)))];
150
+ for (const row of rows)
151
+ lines.push(renderRow(row));
152
+ lines.push('');
153
+ lines.push(`aggregate pugi_score: ${report.aggregatePugiScore.toFixed(2)} (mean of ${report.results.length})`);
154
+ lines.push(`model: ${report.model}`);
155
+ lines.push(`git_sha: ${report.gitSha}`);
156
+ return lines.join('\n');
157
+ }
158
+ const difficultyMap = new WeakMap();
159
+ function findDifficulty(taskId, report) {
160
+ const cached = difficultyMap.get(report);
161
+ return cached?.get(taskId);
162
+ }
163
+ function indexDifficulty(report, specs) {
164
+ const map = new Map();
165
+ for (const s of specs)
166
+ map.set(s.id, s.difficulty);
167
+ difficultyMap.set(report, map);
168
+ }
169
+ export async function runEvalV1Command(ctx) {
170
+ let flags;
171
+ try {
172
+ flags = parseFlags(ctx.args);
173
+ }
174
+ catch (err) {
175
+ ctx.writeOutput({ command: 'eval-v1', status: 'invalid_args', reason: err.message }, `pugi eval-v1: ${err.message}`);
176
+ return 2;
177
+ }
178
+ if (flags.help) {
179
+ const help = [
180
+ 'pugi eval-v1 - frozen 20-task benchmark harness (#120)',
181
+ '',
182
+ 'usage:',
183
+ ' pugi eval-v1 --list',
184
+ ' pugi eval-v1 --task <id[,id...]>',
185
+ ' pugi eval-v1 --all [--model <slug>] [--no-ledger] [--json]',
186
+ ].join('\n');
187
+ ctx.writeOutput({ command: 'eval-v1', status: 'help', help }, help);
188
+ return 0;
189
+ }
190
+ const packageRoot = resolvePackageRoot(ctx.packageRoot);
191
+ const tasksDir = flags.tasksDir ?? defaultTasksDir(packageRoot);
192
+ const ledgerPath = flags.ledgerPath ?? defaultLedgerPath(packageRoot);
193
+ const log = ctx.log ?? ((line) => process.stderr.write(`${line}\n`));
194
+ let loaded;
195
+ try {
196
+ loaded = loadAllTasks(tasksDir);
197
+ }
198
+ catch (err) {
199
+ ctx.writeOutput({
200
+ command: 'eval-v1',
201
+ status: 'tasks_load_failed',
202
+ reason: err.message,
203
+ }, `pugi eval-v1: ${err.message}`);
204
+ return 2;
205
+ }
206
+ const specs = loaded.map((l) => l.spec);
207
+ if (flags.list) {
208
+ const indexLines = specs.map((s) => `${s.id} [${s.command}/${s.difficulty}] ${s.brief}`);
209
+ const out = indexLines.join('\n');
210
+ ctx.writeOutput({ command: 'eval-v1', status: 'listed', tasks: specs.map((s) => ({
211
+ id: s.id, command: s.command, difficulty: s.difficulty, brief: s.brief,
212
+ })) }, out);
213
+ return 0;
214
+ }
215
+ if (!flags.all && flags.task.length === 0) {
216
+ const reason = 'pass --all OR --task <id[,id...]> OR --list';
217
+ ctx.writeOutput({ command: 'eval-v1', status: 'invalid_args', reason }, `pugi eval-v1: ${reason}`);
218
+ return 2;
219
+ }
220
+ const only = flags.all ? undefined : flags.task;
221
+ if (only) {
222
+ const known = new Set(specs.map((s) => s.id));
223
+ const unknown = only.filter((id) => !known.has(id));
224
+ if (unknown.length) {
225
+ const reason = `unknown task id(s): ${unknown.join(', ')}`;
226
+ ctx.writeOutput({ command: 'eval-v1', status: 'invalid_args', reason }, `pugi eval-v1: ${reason}`);
227
+ return 2;
228
+ }
229
+ }
230
+ const pugiBin = resolvePugiBin(ctx.pugiBin, packageRoot);
231
+ const startedAt = new Date().toISOString();
232
+ const gitSha = captureGitSha(packageRoot);
233
+ const results = await runHarness({
234
+ specs,
235
+ options: {
236
+ pugiBin,
237
+ ...(flags.model !== undefined ? { model: flags.model } : {}),
238
+ ...(only !== undefined ? { only } : {}),
239
+ ...(ctx.runner ? { runner: ctx.runner } : {}),
240
+ ...(ctx.env !== undefined ? { env: ctx.env } : {}),
241
+ onTaskStart: (s) => log(`[eval-v1] ${s.id} starting (${s.command}/${s.difficulty})`),
242
+ onTaskFinish: (r) => log(`[eval-v1] ${r.taskId} ${r.status} score=${r.pugiScore.toFixed(2)} tokens=${r.tokensUsed} wall=${r.wallClockMs}ms`),
243
+ },
244
+ });
245
+ const report = {
246
+ schemaVersion: 1,
247
+ startedAt,
248
+ model: flags.model ?? '(default)',
249
+ results,
250
+ aggregatePugiScore: aggregateScore(results),
251
+ gitSha,
252
+ };
253
+ indexDifficulty(report, specs);
254
+ if (flags.ledger && results.length > 0) {
255
+ appendLedgerRows(ledgerPath, results.map((r) => ({
256
+ timestamp: startedAt,
257
+ gitSha,
258
+ model: report.model,
259
+ result: r,
260
+ })));
261
+ }
262
+ ctx.writeOutput(report, renderTable(report));
263
+ const anyFail = results.some((r) => r.status !== 'pass');
264
+ return anyFail ? 1 : 0;
265
+ }
266
+ //# sourceMappingURL=eval-v1.js.map
@@ -40,6 +40,8 @@
40
40
  */
41
41
  import { resolve } from 'node:path';
42
42
  import { closeIndex, countSymbols, findCallers, findDefinition, openIndex, resolveIndexPath, searchSymbols, } from '../../core/codegraph/db.js';
43
+ import { reindexWorkspace } from '../../core/codegraph/reindex.js';
44
+ import { startWatcher } from '../../core/codegraph/watcher.js';
43
45
  import { INDEXED_LANGUAGES } from '../../core/codegraph/types.js';
44
46
  /**
45
47
  * Single entry-point. Returns the process exit code; the caller in
@@ -106,7 +108,7 @@ function printHelp(ctx) {
106
108
  '',
107
109
  'Usage:',
108
110
  ' pugi index Re-index the workspace.',
109
- ' pugi index --watch Start file watcher (PR L2, not yet wired).',
111
+ ' pugi index --watch Live auto-sync via chokidar (2s debounce).',
110
112
  ' pugi index search <query> FTS5 search across symbol names.',
111
113
  ' pugi index definition <symbol> Find the definition of <symbol>.',
112
114
  ' pugi index callers <symbol> List call sites referencing <symbol>.',
@@ -121,37 +123,34 @@ function printUnknown(ctx, raw) {
121
123
  ctx.writeOutput({ command: 'index', error: 'unknown-subcommand', got: raw }, `pugi index: unknown subcommand "${raw}". Allowed: search, definition, callers, --watch, --help.`);
122
124
  return 2;
123
125
  }
124
- function runReindex(ctx, watch) {
126
+ async function runReindex(ctx, watch) {
125
127
  if (watch) {
126
- ctx.writeOutput({
127
- command: 'index',
128
- sub: 'reindex',
129
- watch: true,
130
- status: 'not-implemented',
131
- followup: 'PR L2',
132
- }, [
133
- 'pugi index --watch: file watcher not implemented in this scaffold PR.',
134
- 'Tracked in follow-up PR L2 (chokidar + 2s debounce + incremental).',
135
- ].join('\n'));
136
- return 3;
128
+ return runWatch(ctx);
137
129
  }
138
130
  let db = null;
139
131
  try {
140
132
  db = openIndex(ctx.workspaceRoot);
133
+ const summary = await reindexWorkspace(db, { quiet: true });
141
134
  const symCount = countSymbols(db);
142
135
  ctx.writeOutput({
143
136
  command: 'index',
144
137
  sub: 'reindex',
145
138
  watch: false,
146
- status: 'scaffold-ready',
147
- followup: 'PR L1',
139
+ status: 'ok',
148
140
  dbPath: db.dbPath,
149
141
  schemaVersion: db.version,
150
- symbolCount: symCount,
142
+ filesScanned: summary.filesScanned,
143
+ filesIndexed: summary.filesIndexed,
144
+ symbolsInserted: summary.symbolsInserted,
145
+ edgesInserted: summary.edgesInserted,
146
+ edgesOrphaned: summary.edgesOrphaned,
147
+ totalSymbols: symCount,
148
+ elapsedMs: summary.totalMs,
151
149
  }, [
152
150
  `Index DB ready at ${db.dbPath} (schema v${db.version}).`,
153
- `Indexed symbols: ${symCount}.`,
154
- 'Parser is stubbed in this PR. Real tree-sitter extraction lands in PR L1.',
151
+ `Scanned ${summary.filesScanned} files, indexed ${summary.filesIndexed}.`,
152
+ `Symbols inserted: ${summary.symbolsInserted}. Edges inserted: ${summary.edgesInserted} (${summary.edgesOrphaned} orphans dropped).`,
153
+ `Elapsed: ${summary.totalMs} ms. Total symbols on disk: ${symCount}.`,
155
154
  ].join('\n'));
156
155
  return 0;
157
156
  }
@@ -165,11 +164,94 @@ function runReindex(ctx, watch) {
165
164
  closeIndex(db);
166
165
  }
167
166
  }
167
+ /**
168
+ * `pugi index --watch` runtime: do one full re-index up-front so the
169
+ * index reflects the workspace as it stood at startup, then hand off
170
+ * к the chokidar watcher and block until SIGINT / SIGTERM. The DB
171
+ * handle stays open для the watcher's lifetime - the watcher writes
172
+ * incrementally, the operator quits with Ctrl+C, we close.
173
+ */
174
+ async function runWatch(ctx) {
175
+ const writeStderr = ctx.writeStderr ?? ((text) => void process.stderr.write(text));
176
+ let db = null;
177
+ try {
178
+ db = openIndex(ctx.workspaceRoot);
179
+ // Baseline reindex so the watcher starts on a known-current index.
180
+ // Reindex is idempotent + cheap on subsequent runs (no-op for
181
+ // unchanged files via the sha256 fingerprint).
182
+ const summary = await reindexWorkspace(db, { quiet: true });
183
+ ctx.writeOutput({
184
+ command: 'index',
185
+ sub: 'reindex',
186
+ watch: true,
187
+ status: 'watching',
188
+ dbPath: db.dbPath,
189
+ schemaVersion: db.version,
190
+ filesScanned: summary.filesScanned,
191
+ filesIndexed: summary.filesIndexed,
192
+ symbolsInserted: summary.symbolsInserted,
193
+ edgesInserted: summary.edgesInserted,
194
+ baselineMs: summary.totalMs,
195
+ }, [
196
+ `Baseline index ready (${summary.filesIndexed} files, ${summary.symbolsInserted} symbols, ${summary.edgesInserted} edges).`,
197
+ `watching ${ctx.workspaceRoot}...`,
198
+ ' Press Ctrl+C to stop.',
199
+ ].join('\n'));
200
+ const handle = startWatcher({
201
+ db,
202
+ workspaceRoot: ctx.workspaceRoot,
203
+ writeStderr,
204
+ });
205
+ // Set up SIGINT / SIGTERM traps. Resolve the promise that gates
206
+ // `runWatch` once the handle has fully closed.
207
+ return await new Promise((resolvePromise) => {
208
+ let shuttingDown = false;
209
+ const shutdown = async (signal) => {
210
+ if (shuttingDown)
211
+ return;
212
+ shuttingDown = true;
213
+ try {
214
+ await handle.close();
215
+ writeStderr(`pugi index --watch: stopped (${signal}).\n`);
216
+ }
217
+ catch (err) {
218
+ const message = err instanceof Error ? err.message : String(err);
219
+ writeStderr(`pugi index --watch: shutdown error: ${message}\n`);
220
+ }
221
+ finally {
222
+ if (db)
223
+ closeIndex(db);
224
+ db = null;
225
+ process.off('SIGINT', sigintListener);
226
+ process.off('SIGTERM', sigtermListener);
227
+ resolvePromise(0);
228
+ }
229
+ };
230
+ const sigintListener = () => void shutdown('SIGINT');
231
+ const sigtermListener = () => void shutdown('SIGTERM');
232
+ process.on('SIGINT', sigintListener);
233
+ process.on('SIGTERM', sigtermListener);
234
+ });
235
+ }
236
+ catch (err) {
237
+ const message = err instanceof Error ? err.message : String(err);
238
+ ctx.writeOutput({ command: 'index', sub: 'reindex', watch: true, error: 'watcher_failed', message }, `pugi index --watch failed: ${message}`);
239
+ if (db)
240
+ closeIndex(db);
241
+ return 1;
242
+ }
243
+ }
168
244
  function runSearch(ctx, query) {
169
245
  let db = null;
170
246
  try {
171
247
  db = openIndex(ctx.workspaceRoot);
172
- const results = searchSymbols(db, query, { limit: 50 });
248
+ // Wrap a bare identifier in a prefix-match phrase so operators
249
+ // can type `pugi index search foo` and find `fooBar`. FTS5 will
250
+ // happily accept a more specific raw expression too (e.g.
251
+ // `foo*` or `"exact phrase"`) - we only inject the convenience
252
+ // wrapping when the input looks like a plain identifier.
253
+ const wrapped = wrapSearchQuery(query);
254
+ const results = searchSymbols(db, wrapped, { limit: 50 });
173
255
  ctx.writeOutput({
174
256
  command: 'index',
175
257
  sub: 'search',
@@ -347,6 +429,30 @@ function renderCallersText(symbol, callers) {
347
429
  export function resolveWorkspaceRoot(input) {
348
430
  return resolve(input);
349
431
  }
432
+ /**
433
+ * Best-effort prefix wrap for FTS5. When the operator types a plain
434
+ * identifier (`fooBar`, `my_func`, `Greeter`), wrap it as a prefix
435
+ * match. When the input already looks like an FTS5 expression
436
+ * (contains `*` / `"` / `:` / leading `+` `-`), pass it through
437
+ * untouched. Exported so the spec can pin the behaviour.
438
+ *
439
+ * `unicode61` (the FTS5 default tokenizer) splits on punctuation
440
+ * other than ASCII letters/digits; an unquoted identifier with
441
+ * `_` или `-` would be tokenised к multiple terms. We avoid that
442
+ * by quoting before appending `*` for prefix-match.
443
+ */
444
+ export function wrapSearchQuery(query) {
445
+ const trimmed = query.trim();
446
+ if (trimmed.length === 0)
447
+ return trimmed;
448
+ if (/["*:+\-^()]/.test(trimmed))
449
+ return trimmed;
450
+ // Single identifier-like token: quote + prefix.
451
+ if (!/\s/.test(trimmed)) {
452
+ return `"${trimmed}"*`;
453
+ }
454
+ return trimmed;
455
+ }
350
456
  // Re-export `resolveIndexPath` from db.ts for callers that want to know
351
457
  // where the DB lives without opening it (e.g. `pugi doctor`).
352
458
  export { resolveIndexPath };