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

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.
@@ -47,6 +47,7 @@ import { spawnSync } from 'node:child_process';
47
47
  import { readFileSync, rmSync, writeFileSync } from 'node:fs';
48
48
  import { resolve, sep } from 'node:path';
49
49
  import { commitCheckpoint, formatCheckpointMessage, initShadowRepo, ShadowGitUnavailableError, } from '../checkpoints/shadow-git.js';
50
+ import { detectEditFormat } from './format-detector.js';
50
51
  import { LayerDDeferredError, applyLayerD } from './layer-d-ast.js';
51
52
  import { applyLayerA } from './layer-a-apply.js';
52
53
  import { applyLayerAFuzzy } from './layer-a-fuzzy-apply.js';
@@ -62,6 +63,15 @@ import { appendEntry, snapshotForDispatch, } from './journal.js';
62
63
  */
63
64
  export async function dispatchEdit(raw, opts) {
64
65
  const family = resolveFamily(opts.modelTag);
66
+ // PUGI-79 — resolve the preferred-layer chain ONCE per dispatch.
67
+ // Caller supplied wins; otherwise the detector derives from modelTag.
68
+ // The chain is informational here (the parser still routes per
69
+ // envelope); a future fuzzy-ladder change can read it to influence
70
+ // retry ordering without revisiting the dispatcher's escalation
71
+ // contract.
72
+ const preferredChain = resolvePreferredChain(opts);
73
+ if (preferredChain)
74
+ opts.onPreferredLayer?.(preferredChain);
65
75
  let parsed;
66
76
  try {
67
77
  parsed = parseMarkers(raw, family);
@@ -75,6 +85,7 @@ export async function dispatchEdit(raw, opts) {
75
85
  bytesWritten: 0,
76
86
  reason: 'marker_parse_error',
77
87
  detail: `${error.message}${error.atLine ? ` (line ${error.atLine})` : ''} — modelHint=${error.modelHint}`,
88
+ ...(preferredChain ? { expectedLayer: preferredChain.primary } : {}),
78
89
  };
79
90
  opts.onResult?.(result);
80
91
  return [result];
@@ -141,6 +152,7 @@ export async function dispatchEdit(raw, opts) {
141
152
  opts.checkpoint.onCheckpointError?.(err, '');
142
153
  }
143
154
  }
155
+ const expectedLayer = preferredChain?.primary;
144
156
  const out = [];
145
157
  let crashError = null;
146
158
  for (const edit of parsed) {
@@ -166,6 +178,13 @@ export async function dispatchEdit(raw, opts) {
166
178
  detail: `dispatch threw: ${msg}`,
167
179
  };
168
180
  }
181
+ if (expectedLayer !== undefined) {
182
+ // PUGI-79: stamp the expected layer on every result so
183
+ // observability surfaces can compute drift (expected vs actual)
184
+ // per family. The actual `layer` field is unchanged — the
185
+ // hint is informational, not a routing override.
186
+ result = { ...result, expectedLayer };
187
+ }
169
188
  out.push(result);
170
189
  opts.onResult?.(result);
171
190
  if (result.ok && checkpointEnabled && opts.checkpoint && !opts.dryRun) {
@@ -339,6 +358,27 @@ function listTrackedFiles(cwd, paths) {
339
358
  }
340
359
  return out;
341
360
  }
361
+ /**
362
+ * PUGI-79 helper — resolve the preferred-layer chain for this
363
+ * dispatch. Caller-supplied `preferredLayer` wins; otherwise the
364
+ * detector derives from `modelTag`. Returns null when neither path
365
+ * yields a chain (no caller hint AND no model tag) — the dispatcher
366
+ * skips the observability hook in that case so we do not emit a
367
+ * misleading wildcard reading.
368
+ */
369
+ function resolvePreferredChain(opts) {
370
+ if (opts.preferredLayer) {
371
+ // Defensive clone so caller mutations after dispatchEdit returns
372
+ // cannot poison subsequent calls that share the chain reference.
373
+ return {
374
+ primary: opts.preferredLayer.primary,
375
+ fallback: [...opts.preferredLayer.fallback],
376
+ };
377
+ }
378
+ if (opts.modelTag)
379
+ return detectEditFormat(opts.modelTag);
380
+ return null;
381
+ }
342
382
  /**
343
383
  * Public helper exposed for the marker parser tests + CLI surface that
344
384
  * may want to know the resolved family without re-running the auto
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Edit-format auto-select (PUGI-79).
3
+ *
4
+ * The 4-layer diff dispatcher (Layer A single-block, B ordered batch,
5
+ * C sha256-gated rewrite, D AST-aware) is layer-agnostic at the wire
6
+ * — the model emits whichever marker family it was prompted with, and
7
+ * `dispatch.ts` routes per envelope. Empirically though, different
8
+ * model families have markedly different success rates per layer:
9
+ *
10
+ * - Anthropic Claude family — native function-calling shape;
11
+ * produces clean single-block Layer A edits with high recall
12
+ * against `+++ NEW / --- OLD / ===` markers.
13
+ * - Open-weight Qwen3 / Kimi / DeepSeek — historically stronger
14
+ * with whole-file Layer C rewrites OR sha256-gated patches; the
15
+ * conflict-marker (Gemini-style) Layer A surface has higher
16
+ * ambiguity rates on these models.
17
+ * - DeepSeek-coder family — anchors well on Layer C primary with
18
+ * Layer A as fallback (their RLHF set leaned hard on patch-style
19
+ * emit).
20
+ * - Reasoning models (o-series, gpt-5) — handle Layer D AST-aware
21
+ * operations when an LSP bridge is available; Layer C primary
22
+ * otherwise.
23
+ *
24
+ * The existing `format-matrix.ts` keyed exact slugs only — fine for
25
+ * the canonical set but blind to vendor-prefixed slugs that come back
26
+ * from the Anvil gateway (e.g. `anthropic/claude-sonnet-4-20250514`,
27
+ * `qwen/qwen3-coder`, `deepseek/deepseek-chat-v3.1`). This detector
28
+ * layers ON TOP of the matrix:
29
+ *
30
+ * 1. exact-key match against `EDIT_FORMAT_MATRIX` — wins outright.
31
+ * 2. vendor-prefix strip → second exact-key probe (so
32
+ * `anthropic/claude-sonnet-4-6` resolves the same as `claude-sonnet-4-6`).
33
+ * 3. family inference from the (possibly prefixed) slug — returns
34
+ * the family-default chain (see `FAMILY_DEFAULTS`).
35
+ * 4. unknown — wildcard `*` from the matrix.
36
+ *
37
+ * The output is the same `EditFormatChain` shape the matrix already
38
+ * exposes so callers (dispatcher hint, persona prompt hint) consume a
39
+ * single contract.
40
+ *
41
+ * NOT a routing decision: the dispatcher's per-layer routing is still
42
+ * driven by what the model actually emits. The detector's output is a
43
+ * HINT — surfaced to the persona prompt so the model has the right
44
+ * marker template loaded, and surfaced to the dispatcher's fuzzy ladder
45
+ * / fallback ordering so an ambiguous response gets retried in an order
46
+ * that matches the model's empirical strengths.
47
+ *
48
+ * Spec: this is the PUGI-79 implementation; the issue ships the
49
+ * detector + dispatcher hint + persona hint together.
50
+ */
51
+ import { EDIT_FORMAT_MATRIX, } from './format-matrix.js';
52
+ /**
53
+ * Family-level default chains. These are deliberately CONSERVATIVE —
54
+ * each family's primary is the layer with the highest observed apply-
55
+ * rate across the 2026-05 dogfood corpus; fallbacks rank in observed
56
+ * success order. The exact-slug entries in `EDIT_FORMAT_MATRIX` can
57
+ * override per-model; family defaults are the floor.
58
+ *
59
+ * Anthropic:
60
+ * The Anvil corpus shows Claude family hitting ~95% Layer A apply on
61
+ * first try; Layer C is the rescue when the file is large enough
62
+ * that the operator's region selection ambiguates. Matrix entries
63
+ * for `claude-opus-4-7` + `claude-sonnet-4-6` override to Layer C
64
+ * primary because the larger context windows let those models hold
65
+ * the whole file comfortably.
66
+ *
67
+ * Open-weight (qwen / deepseek / kimi):
68
+ * These ship whole-file rewrites more reliably than partial diffs —
69
+ * the patch-style envelopes their fine-tune sets prefer match Layer
70
+ * C semantics. Layer A is the fallback because they can still emit
71
+ * sensible search/replace pairs when prompted explicitly.
72
+ *
73
+ * Reasoning (gpt-5 / o-series):
74
+ * Layer C primary because the longer reasoning trace burns through
75
+ * the partial-diff format's positional constraints. Layer D rides
76
+ * the wire when an LSP is bridged; the matrix entries override per-
77
+ * model when that's available (see `qwen3-coder-480b → D`).
78
+ *
79
+ * Unknown:
80
+ * Defers to the wildcard chain from the matrix (Layer A primary).
81
+ */
82
+ const FAMILY_DEFAULTS = {
83
+ anthropic: { primary: 'A', fallback: ['C', 'B'] },
84
+ openai: { primary: 'C', fallback: ['A', 'B'] },
85
+ gemini: { primary: 'A', fallback: ['C', 'B'] },
86
+ qwen: { primary: 'A', fallback: ['C', 'B'] },
87
+ deepseek: { primary: 'C', fallback: ['A', 'B'] },
88
+ kimi: { primary: 'C', fallback: ['A', 'B'] },
89
+ unknown: { primary: 'A', fallback: ['B', 'C'] },
90
+ };
91
+ /**
92
+ * Detect the model family from a slug. Handles vendor-prefixed forms
93
+ * (`<vendor>/<model>` and `<provider>:<model>`) plus bare-name forms.
94
+ * Case-insensitive on the family name; case-preserving on the rest.
95
+ *
96
+ * Order matters: vendor prefix wins when present (the gateway is
97
+ * authoritative about which family routed). Otherwise the bare slug
98
+ * gets prefix-matched against well-known family stems. Unknown slugs
99
+ * surface as `unknown` — the caller falls back to the matrix wildcard.
100
+ */
101
+ export function detectModelFamily(modelSlug) {
102
+ if (!modelSlug)
103
+ return 'unknown';
104
+ const raw = modelSlug.trim();
105
+ if (raw.length === 0)
106
+ return 'unknown';
107
+ const lower = raw.toLowerCase();
108
+ // Vendor-prefix form: `<vendor>/<model>`. The vendor wins outright
109
+ // because the gateway is authoritative — `anthropic/<anything>`
110
+ // routes Claude family regardless of the suffix.
111
+ const slashIdx = lower.indexOf('/');
112
+ if (slashIdx > 0) {
113
+ const vendor = lower.slice(0, slashIdx);
114
+ switch (vendor) {
115
+ case 'anthropic':
116
+ return 'anthropic';
117
+ case 'openai':
118
+ return 'openai';
119
+ case 'google':
120
+ case 'gemini':
121
+ case 'xai':
122
+ return 'gemini';
123
+ case 'qwen':
124
+ case 'alibaba':
125
+ return 'qwen';
126
+ case 'deepseek':
127
+ return 'deepseek';
128
+ case 'moonshot':
129
+ case 'kimi':
130
+ return 'kimi';
131
+ default:
132
+ // Unknown vendor — fall through to bare-name detection on the
133
+ // suffix. Some gateways (OpenRouter style) put the family
134
+ // name AFTER the vendor (e.g. `together/qwen-coder`), so the
135
+ // suffix still carries signal.
136
+ return detectFamilyFromBareName(lower.slice(slashIdx + 1));
137
+ }
138
+ }
139
+ // Provider-prefix form: `ollama:<model>` (engine-VM local runtime).
140
+ // The prefix is informational; strip and continue.
141
+ const colonIdx = lower.indexOf(':');
142
+ if (colonIdx > 0) {
143
+ const prefix = lower.slice(0, colonIdx);
144
+ if (prefix === 'ollama' || prefix === 'lmstudio' || prefix === 'llama-cpp') {
145
+ return detectFamilyFromBareName(lower.slice(colonIdx + 1));
146
+ }
147
+ }
148
+ return detectFamilyFromBareName(lower);
149
+ }
150
+ /**
151
+ * Bare-name detector. Lower-case input. Prefix-match against family
152
+ * stems; longest match wins so `claude-opus-4-7` doesn't accidentally
153
+ * route through the generic `c*` lane.
154
+ */
155
+ function detectFamilyFromBareName(name) {
156
+ if (name.startsWith('claude'))
157
+ return 'anthropic';
158
+ if (name.startsWith('gpt') || name.startsWith('o1') || name.startsWith('o3') || name.startsWith('o4')) {
159
+ return 'openai';
160
+ }
161
+ if (name.startsWith('gemini') || name.startsWith('grok'))
162
+ return 'gemini';
163
+ if (name.startsWith('qwen'))
164
+ return 'qwen';
165
+ if (name.startsWith('deepseek'))
166
+ return 'deepseek';
167
+ if (name.startsWith('kimi') || name.startsWith('moonshot'))
168
+ return 'kimi';
169
+ return 'unknown';
170
+ }
171
+ /**
172
+ * Family-default chain. Exported for callers that want the family-
173
+ * level recommendation without running the full detector pipeline
174
+ * (e.g. observability surfaces that already resolved the family).
175
+ */
176
+ export function familyDefaultChain(family) {
177
+ // Defensive clone: the FAMILY_DEFAULTS table is readonly conceptually
178
+ // but the EditFormatChain type allows fallback array mutation. Clone
179
+ // so callers cannot accidentally mutate the table.
180
+ const base = FAMILY_DEFAULTS[family];
181
+ return { primary: base.primary, fallback: [...base.fallback] };
182
+ }
183
+ /**
184
+ * Resolve the preferred edit-format chain for a model slug.
185
+ *
186
+ * Resolution order:
187
+ * 1. Exact slug match in EDIT_FORMAT_MATRIX — overrides everything.
188
+ * This lets per-model tuning (e.g. `qwen3-coder-480b → D`) win
189
+ * over the family default.
190
+ * 2. Vendor-prefix strip + exact match — so `anthropic/claude-...`
191
+ * resolves to the bare `claude-...` entry when present.
192
+ * 3. Family inference — `FAMILY_DEFAULTS[detectModelFamily(slug)]`.
193
+ * 4. Wildcard fallback — `EDIT_FORMAT_MATRIX['*']`.
194
+ *
195
+ * The returned chain is always a fresh object so caller mutations
196
+ * cannot leak back into the matrix.
197
+ */
198
+ export function detectEditFormat(modelSlug) {
199
+ const fallbackWildcard = EDIT_FORMAT_MATRIX['*'] ?? { primary: 'A', fallback: [] };
200
+ if (!modelSlug || modelSlug.trim().length === 0) {
201
+ return { primary: fallbackWildcard.primary, fallback: [...fallbackWildcard.fallback] };
202
+ }
203
+ const raw = modelSlug.trim();
204
+ // Step 1 — exact match wins.
205
+ const exact = EDIT_FORMAT_MATRIX[raw];
206
+ if (exact)
207
+ return { primary: exact.primary, fallback: [...exact.fallback] };
208
+ // Step 2 — vendor-prefix strip (e.g. `anthropic/claude-sonnet-4-6`).
209
+ const slashIdx = raw.indexOf('/');
210
+ if (slashIdx > 0) {
211
+ const suffix = raw.slice(slashIdx + 1);
212
+ const stripped = EDIT_FORMAT_MATRIX[suffix];
213
+ if (stripped)
214
+ return { primary: stripped.primary, fallback: [...stripped.fallback] };
215
+ }
216
+ // Step 3 — family inference.
217
+ const family = detectModelFamily(raw);
218
+ if (family !== 'unknown')
219
+ return familyDefaultChain(family);
220
+ // Step 4 — wildcard.
221
+ return { primary: fallbackWildcard.primary, fallback: [...fallbackWildcard.fallback] };
222
+ }
223
+ /**
224
+ * Render a one-line marker-format hint for the persona prompt. The
225
+ * dispatcher already accepts every marker family (anthropic / gemini /
226
+ * openai), so the hint is preference, not gating — surface the
227
+ * preferred template + alternative so the model knows which envelope
228
+ * has the highest apply-rate for its family.
229
+ *
230
+ * Returns `null` when the slug is unknown / unset; the caller drops
231
+ * the hint module on null so the prompt is not polluted with empty
232
+ * sections.
233
+ */
234
+ export function renderEditFormatHint(modelSlug) {
235
+ if (!modelSlug || modelSlug.trim().length === 0)
236
+ return null;
237
+ const family = detectModelFamily(modelSlug);
238
+ if (family === 'unknown')
239
+ return null;
240
+ const chain = detectEditFormat(modelSlug);
241
+ const markerStyle = FAMILY_MARKER_TEMPLATE[family];
242
+ return `# Edit-format hint (auto-selected for ${family})
243
+ Preferred layer: Layer ${chain.primary}${chain.fallback.length > 0 ? ` (fallback: ${chain.fallback.map((l) => `Layer ${l}`).join(' -> ')})` : ''}.
244
+ Marker template: ${markerStyle}
245
+ The dispatcher accepts any marker family; this hint optimises apply-rate for the resolved model.`;
246
+ }
247
+ /**
248
+ * Marker template literal per family. Mirrors the three envelopes the
249
+ * marker-parser accepts; used by `renderEditFormatHint` so the model
250
+ * sees a concrete shape next to the layer recommendation.
251
+ */
252
+ const FAMILY_MARKER_TEMPLATE = {
253
+ anthropic: '+++ NEW <file> / <new contents> / --- OLD <file> / <old contents> / ===',
254
+ openai: '@@@ REWRITE <file> sha256=<hex> / <full new contents> / @@@ END',
255
+ gemini: '<<<<<<< SEARCH <file> / <old> / ======= / <new> / >>>>>>> REPLACE',
256
+ qwen: '<<<<<<< SEARCH <file> / <old> / ======= / <new> / >>>>>>> REPLACE',
257
+ deepseek: '@@@ REWRITE <file> sha256=<hex> / <full new contents> / @@@ END',
258
+ kimi: '@@@ REWRITE <file> sha256=<hex> / <full new contents> / @@@ END',
259
+ };
260
+ //# sourceMappingURL=format-detector.js.map
@@ -14,4 +14,6 @@ export { applyLayerC, sha256OfUtf8, } from './layer-c-apply.js';
14
14
  export { applyLayerD, LayerDDeferredError, } from './layer-d-ast.js';
15
15
  export { MarkerParseError, detectFamily, parseMarkers, } from './marker-parser.js';
16
16
  export { dispatchEdit, resolveFamily, } from './dispatch.js';
17
+ export { EDIT_FORMAT_MATRIX, pickEditFormat, } from './format-matrix.js';
18
+ export { detectEditFormat, detectModelFamily, familyDefaultChain, renderEditFormatHint, } from './format-detector.js';
17
19
  //# sourceMappingURL=index.js.map
@@ -1801,6 +1801,11 @@ function parseArgs(argv) {
1801
1801
  ? process.env.PUGI_HIDE_TOOL_STREAM === '1'
1802
1802
  : true,
1803
1803
  noDefaults: process.env.PUGI_INIT_NO_DEFAULTS === '1',
1804
+ // #82 — opt-out for the codebase scan + PUGI.md auto-gen step that
1805
+ // runs as part of `pugi init`. Default OFF (scan runs); env var
1806
+ // PUGI_INIT_NO_SCAN=1 lets wrappers force-disable globally without
1807
+ // editing every invocation.
1808
+ noScan: process.env.PUGI_INIT_NO_SCAN === '1',
1804
1809
  // UX : auto-init / auto-login opt-outs. Default
1805
1810
  // OFF (auto-init + auto-login are on by default on an interactive
1806
1811
  // TTY). PUGI_NO_AUTO_* env vars provide a per-shell escape hatch
@@ -1930,6 +1935,19 @@ function parseArgs(argv) {
1930
1935
  // at the global level for consistency with --no-splash / --no-tool-stream.
1931
1936
  flags.noDefaults = true;
1932
1937
  }
1938
+ else if (arg === '--no-scan') {
1939
+ // #82 init-only flag: skip the codebase scan + PUGI.md auto-gen.
1940
+ // Parsed globally for symmetry with the other init opt-outs.
1941
+ flags.noScan = true;
1942
+ }
1943
+ else if (arg === '--scan') {
1944
+ // #82 init-only flag: explicit opt-in for the codebase scan.
1945
+ // Scan is on by default, so this is a no-op alias kept for
1946
+ // documentation / discoverability via `--help`. The flip exists
1947
+ // so a wrapper that previously exported PUGI_INIT_NO_SCAN=1 can
1948
+ // re-enable scanning per-invocation without unsetting the env.
1949
+ flags.noScan = false;
1950
+ }
1933
1951
  else if (arg === '--live') {
1934
1952
  flags.live = true;
1935
1953
  }
@@ -2285,13 +2303,20 @@ const COMMAND_HELP_BODIES = {
2285
2303
  'pugi init — bootstrap a new Pugi workspace in the current directory.',
2286
2304
  '',
2287
2305
  'Creates .pugi/{PUGI.md, mcp.json, index.json, artifacts/, sessions/} and',
2288
- 'seeds the 6 default skills. Idempotent running again only fills gaps.',
2306
+ 'seeds the 6 default skills. After scaffolding, scans the codebase and',
2307
+ 'auto-generates a project-aware PUGI.md (stack, frameworks, commands,',
2308
+ 'layout). Idempotent — running again only fills gaps.',
2289
2309
  '',
2290
2310
  'Flags:',
2291
2311
  ' --no-defaults Skip the bundled default-skills install.',
2312
+ ' --scan Run the codebase scan + PUGI.md auto-gen (default).',
2313
+ ' --no-scan Skip the codebase scan + PUGI.md auto-gen.',
2314
+ ' --force Overwrite an existing PUGI.md when scanning.',
2315
+ ' --style=<tier> PUGI.md verbosity: minimal | standard | detailed.',
2292
2316
  '',
2293
2317
  'Env:',
2294
2318
  ' PUGI_INIT_NO_DEFAULTS=1 Same as --no-defaults.',
2319
+ ' PUGI_INIT_NO_SCAN=1 Same as --no-scan.',
2295
2320
  ],
2296
2321
  explain: [
2297
2322
  'pugi explain "<question>" — read-only Q&A about the workspace.',
@@ -2710,6 +2735,8 @@ async function help(args, flags, _session) {
2710
2735
  ' Pairs with PUGI_HIDE_TOOL_STREAM=1.',
2711
2736
  ' --no-defaults Skip bundled default-skills install on',
2712
2737
  ' `pugi init`. Pairs with PUGI_INIT_NO_DEFAULTS=1.',
2738
+ ' --no-scan Skip codebase scan + PUGI.md auto-gen on',
2739
+ ' `pugi init`. Pairs with PUGI_INIT_NO_SCAN=1.',
2713
2740
  ' --bare Disable project auto-discovery — no PUGI.md /',
2714
2741
  ' AGENTS.md / CLAUDE.md / GEMINI.md walk-up, no',
2715
2742
  ' auto-init of .pugi/, no persona auto-load.',
@@ -3170,7 +3197,10 @@ async function init(args, flags, _session) {
3170
3197
  // auto-generate PUGI.md. The runner writes its own envelope/output so
3171
3198
  // the standalone summary stays compact. `--json` mode collects the
3172
3199
  // envelope and merges it into the init payload instead of double-emitting.
3173
- const initScanArgs = args.filter((a) => a !== '--no-defaults');
3200
+ // The scan is opt-out via `--no-scan` / PUGI_INIT_NO_SCAN=1 — keeps
3201
+ // the scaffold half of `pugi init` working in CI fixtures that should
3202
+ // not write a PUGI.md sibling to the workspace root.
3203
+ const initScanArgs = args.filter((a) => a !== '--no-defaults' && a !== '--no-scan' && a !== '--scan');
3174
3204
  const style = parseInitStyle(initScanArgs);
3175
3205
  const useYes = args.includes('--yes') || args.includes('-y') || flags.json;
3176
3206
  const force = args.includes('--force');
@@ -3185,22 +3215,30 @@ async function init(args, flags, _session) {
3185
3215
  scanLines.push(` ${line}`);
3186
3216
  }
3187
3217
  };
3188
- try {
3189
- scanEnvelope = await runInitScanCommand(initScanArgs, {
3190
- cwd,
3191
- force,
3192
- style,
3193
- yes: useYes,
3194
- json: flags.json,
3195
- writeOutput: captureWriteOutput,
3196
- });
3218
+ if (flags.noScan) {
3219
+ if (!flags.json) {
3220
+ scanLines.push('');
3221
+ scanLines.push('PUGI.md scan: skipped (--no-scan)');
3222
+ }
3197
3223
  }
3198
- catch (error) {
3199
- // Init scan failure must NOT break the scaffold step. Surface a
3200
- // single warning line so the operator can re-run `pugi init` later.
3201
- const message = error instanceof Error ? error.message : String(error);
3202
- scanLines.push('');
3203
- scanLines.push(`PUGI.md scan failed: ${message}`);
3224
+ else {
3225
+ try {
3226
+ scanEnvelope = await runInitScanCommand(initScanArgs, {
3227
+ cwd,
3228
+ force,
3229
+ style,
3230
+ yes: useYes,
3231
+ json: flags.json,
3232
+ writeOutput: captureWriteOutput,
3233
+ });
3234
+ }
3235
+ catch (error) {
3236
+ // Init scan failure must NOT break the scaffold step. Surface a
3237
+ // single warning line so the operator can re-run `pugi init` later.
3238
+ const message = error instanceof Error ? error.message : String(error);
3239
+ scanLines.push('');
3240
+ scanLines.push(`PUGI.md scan failed: ${message}`);
3241
+ }
3204
3242
  }
3205
3243
  writeOutput(flags, { ...result, codegraph: codegraphLines.envelope, pugiMd: scanEnvelope }, [
3206
3244
  'Pugi initialized',
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
44
44
  * during import). When bumping the CLI version BOTH literals must be
45
45
  * updated; the release smoke-test (`pack:smoke`) verifies they agree.
46
46
  */
47
- export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.89');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.90');
48
48
  /**
49
49
  * Outbound: the CLI's installed semver. Read at request time by
50
50
  * `version-interceptor.ts` and injected on every `fetch` call.
@@ -48,6 +48,23 @@ export const ASK_USER_QUESTION_OPTION_DESC_MAX = 200;
48
48
  /** Option count: 2-4 strict. UI adds "Other" automatically. */
49
49
  export const ASK_USER_QUESTION_OPTIONS_MIN = 2;
50
50
  export const ASK_USER_QUESTION_OPTIONS_MAX = 4;
51
+ /**
52
+ * Preview pane cap (PUGI-130). Lets the model attach a multi-line
53
+ * ASCII / code / diagram preview to an option. When ANY option в a
54
+ * single-select question carries `preview`, the UI switches к
55
+ * side-by-side layout (options column left, preview pane right).
56
+ *
57
+ * Capped at 5000 chars так что a single payload can stash a full
58
+ * component sketch / SQL migration / diagram, but не enough rope для
59
+ * a model to dump the entire codebase в a preview field.
60
+ *
61
+ * **multiSelect rule:** previews are REJECTED on multi-select questions.
62
+ * Rendering N preview panes simultaneously breaks the layout invariant
63
+ * (single 80-col terminal can host ONE side panel, not N). The schema
64
+ * validator enforces this at the tool boundary с a clear error message.
65
+ */
66
+ export const ASK_USER_QUESTION_OPTION_PREVIEW_MIN = 1;
67
+ export const ASK_USER_QUESTION_OPTION_PREVIEW_MAX = 5000;
51
68
  /** PUGI-480 short-format chip rules: ≤ 5 words / option label, ≤ 3 questions / call. */
52
69
  export const ASK_USER_QUESTION_CHIP_LABEL_WORD_MAX = 5;
53
70
  export const ASK_USER_QUESTION_CHIPS_MAX = 3;
@@ -83,15 +100,18 @@ export const askUserQuestionOptionSchema = z.strictObject({
83
100
  .describe('What this option means / implications.'),
84
101
  preview: z
85
102
  .string()
86
- .min(1)
87
- .max(2000)
103
+ .min(ASK_USER_QUESTION_OPTION_PREVIEW_MIN)
104
+ .max(ASK_USER_QUESTION_OPTION_PREVIEW_MAX)
88
105
  .optional()
89
- .describe('Optional ASCII / code preview. When ANY option in the set carries ' +
90
- 'this field, the UI switches к side-by-side layout (options column ' +
91
- 'left, preview pane right). Use для visual comparison of mockups, ' +
92
- 'config snippets, diagram variations.'),
106
+ .describe('Optional ASCII / code preview (≤ 5000 chars, multi-line OK). When ' +
107
+ 'ANY option in the set carries this field, the UI switches к ' +
108
+ 'side-by-side layout (options column left, preview pane right). ' +
109
+ 'Use для visual comparison of mockups, config snippets, diagram ' +
110
+ 'variations. REJECTED on multiSelect questions (one pane fits one ' +
111
+ 'preview).'),
93
112
  });
94
- export const askUserQuestionSchema = z.strictObject({
113
+ export const askUserQuestionSchema = z
114
+ .strictObject({
95
115
  question: z
96
116
  .string()
97
117
  .min(ASK_USER_QUESTION_MIN)
@@ -115,6 +135,26 @@ export const askUserQuestionSchema = z.strictObject({
115
135
  .optional()
116
136
  .default(false)
117
137
  .describe('Allow multiple selections. Default false.'),
138
+ })
139
+ .superRefine((payload, ctx) => {
140
+ // PUGI-130 invariant: previews are single-pane only. Side-by-side
141
+ // layout can host ONE preview at a time; multiSelect implies the
142
+ // operator may toggle several options and each would want its own
143
+ // pane. Reject at the schema boundary so the model gets immediate
144
+ // feedback instead of a silently dropped preview field at render.
145
+ if (payload.multiSelect === true) {
146
+ payload.options.forEach((opt, idx) => {
147
+ if (typeof opt.preview === 'string' && opt.preview.length > 0) {
148
+ ctx.addIssue({
149
+ code: z.ZodIssueCode.custom,
150
+ path: ['options', idx, 'preview'],
151
+ message: 'preview is not allowed on multiSelect questions — ' +
152
+ 'side-by-side layout supports ONE preview pane at a time. ' +
153
+ 'Drop the preview field OR switch к single-select.',
154
+ });
155
+ }
156
+ });
157
+ }
118
158
  });
119
159
  /**
120
160
  * PUGI-480 multi-question chip payload — a bundle of up to 3 short-format
@@ -275,13 +315,22 @@ export const askUserQuestionJsonSchema = {
275
315
  maxLength: ASK_USER_QUESTION_OPTION_DESC_MAX,
276
316
  description: 'What this option means / implications.',
277
317
  },
318
+ preview: {
319
+ type: 'string',
320
+ minLength: ASK_USER_QUESTION_OPTION_PREVIEW_MIN,
321
+ maxLength: ASK_USER_QUESTION_OPTION_PREVIEW_MAX,
322
+ description: 'Optional multi-line ASCII / code / diagram preview (≤ 5000 chars). ' +
323
+ 'When ANY option carries this, UI switches к side-by-side layout. ' +
324
+ 'Single-select only — REJECTED if multiSelect is true.',
325
+ },
278
326
  },
279
327
  },
280
328
  description: '2-4 mutually-exclusive options. NEVER add "Other" — UI auto-adds.',
281
329
  },
282
330
  multiSelect: {
283
331
  type: 'boolean',
284
- description: 'Allow multiple selections. Default false.',
332
+ description: 'Allow multiple selections. Default false. When true, option.preview ' +
333
+ 'is forbidden (one terminal pane fits one preview at a time).',
285
334
  },
286
335
  },
287
336
  };
@@ -58,6 +58,39 @@ export const ASK_CHIPS_LABEL_WORD_CAP = 5;
58
58
  export const ASK_CHIPS_LABEL_CHAR_CAP = 22;
59
59
  export const ASK_CHIPS_QUESTION_CAP = 3;
60
60
  export const ASK_CHIPS_SKIP_LABEL = 'Skip — use defaults';
61
+ /**
62
+ * PUGI-130 preview pane width (columns). Sized for a default 80-col
63
+ * terminal: option column ≈ 32, preview pane ≈ 44 with 4 gutters/borders.
64
+ * The pane wraps long lines defensively but the schema-level 5000-char
65
+ * cap is the primary guard against runaway payloads.
66
+ */
67
+ export const ASK_CHIPS_PREVIEW_PANE_WIDTH = 44;
68
+ /**
69
+ * Defensive char cap applied at render time. Schema enforces 5000 chars
70
+ * at the tool boundary; renderer mirrors the cap so a legacy / malformed
71
+ * payload sneaking past Zod still cannot exhaust the terminal scrollback.
72
+ */
73
+ export const ASK_CHIPS_PREVIEW_CHAR_CAP = 5000;
74
+ /**
75
+ * True if ANY option в ANY question of the bundle carries a non-empty
76
+ * `preview` field. The renderer uses this gate to switch к side-by-side
77
+ * layout (vertical option list + preview pane). When false, the legacy
78
+ * horizontal chip layout is preserved (zero regression for callers
79
+ * shipping previewless payloads — PR #851 contract intact).
80
+ */
81
+ export function hasAnyPreview(questions) {
82
+ return questions.some((q) => q.options.some((opt) => typeof opt.preview === 'string' && opt.preview.length > 0));
83
+ }
84
+ /**
85
+ * Defensive char cap on preview content. Schema enforces 5000 chars at
86
+ * the tool boundary; we mirror the cap here so legacy / malformed
87
+ * payloads slipping past Zod still cannot exhaust the terminal.
88
+ */
89
+ export function clampPreview(raw) {
90
+ if (raw.length <= ASK_CHIPS_PREVIEW_CHAR_CAP)
91
+ return raw;
92
+ return `${raw.slice(0, ASK_CHIPS_PREVIEW_CHAR_CAP - 1)}…`;
93
+ }
61
94
  /**
62
95
  * Truncate a label к the configured word / character caps. Always
63
96
  * append "…" when truncation happens so the operator knows the chip
@@ -219,6 +252,31 @@ export function AskUserQuestionChips(props) {
219
252
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: _jsx(Text, { bold: true, children: `Pugi: ${questions.length} quick ${questions.length === 1 ? 'choice' : 'choices'}` }) }), questions.map((q, qIdx) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: `${q.header}` }), q.options.map((opt, oIdx) => (_jsx(Text, { children: ` ${oIdx + 1}. ${truncateLabel(opt.label)}${oIdx === 0 ? ' (default)' : ''}` }, `q-${qIdx}-o-${oIdx}`)))] }, `q-${qIdx}-${q.header}`))), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, italic: true, children: `(non-interactive — defaults apply)` }) })] }));
220
253
  }
221
254
  // ─── Interactive Ink render ─────────────────────────────────────────
255
+ // PUGI-130: when ANY option carries `preview`, switch layout — chips
256
+ // stack vertically (option list) and a preview pane mounts on the
257
+ // right, updating as the cursor moves. Otherwise fall back to the
258
+ // existing horizontal chip layout (zero-regression contract).
259
+ const previewMode = hasAnyPreview(questions);
260
+ if (previewMode) {
261
+ const focusedQ = questions[focusedQuestion];
262
+ const focusedCursor = cursorPerQuestion[focusedQuestion] ?? 0;
263
+ const focusedOpt = focusedQ?.options[focusedCursor];
264
+ const rawPreview = focusedOpt?.preview ?? '';
265
+ const previewText = clampPreview(rawPreview);
266
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: _jsx(Text, { bold: true, children: `Pugi: ${questions.length} quick ${questions.length === 1 ? 'choice' : 'choices'}` }) }), _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(Box, { flexDirection: "column", marginRight: 2, children: questions.map((q, qIdx) => {
267
+ const isFocused = qIdx === focusedQuestion;
268
+ const cursor = cursorPerQuestion[qIdx] ?? 0;
269
+ const isSkipped = skipped[qIdx] === true;
270
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: isFocused ? 'cyan' : 'gray', paddingX: 1, marginBottom: qIdx < questions.length - 1 ? 1 : 0, minWidth: 28, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: isFocused ? 'cyan' : undefined, children: q.header }), isSkipped ? (_jsx(Text, { dimColor: true, italic: true, children: ' (skipped)' })) : null] }), q.options.map((opt, oIdx) => {
271
+ const isHighlighted = isFocused && oIdx === cursor;
272
+ const label = truncateLabel(opt.label);
273
+ const isSkipOption = label === ASK_CHIPS_SKIP_LABEL ||
274
+ opt.label === ASK_CHIPS_SKIP_LABEL;
275
+ const hasPreview = typeof opt.preview === 'string' && opt.preview.length > 0;
276
+ return (_jsxs(Box, { children: [_jsx(Text, { color: isHighlighted ? 'cyan' : undefined, bold: isHighlighted, children: isHighlighted ? '▸ ' : ' ' }), _jsx(Text, { color: isHighlighted ? 'cyan' : undefined, children: `${oIdx + 1} ` }), isSkipOption ? (_jsx(Text, { dimColor: true, italic: true, children: label })) : (_jsx(Text, { bold: isHighlighted, children: label })), hasPreview ? (_jsx(Text, { dimColor: true, children: ' ⊕' })) : null] }, `opt-${qIdx}-${oIdx}-${opt.label}`));
277
+ })] }, `chip-${qIdx}-${q.header}`));
278
+ }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, minWidth: ASK_CHIPS_PREVIEW_PANE_WIDTH, flexGrow: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, dimColor: true, children: 'Preview' }), focusedOpt ? (_jsx(Text, { dimColor: true, children: ` · ${truncateLabel(focusedOpt.label)}` })) : null] }), previewText.length > 0 ? (previewText.split('\n').map((line, lineIdx) => (_jsx(Text, { children: line.length > 0 ? line : ' ' }, `pv-${focusedQuestion}-${focusedCursor}-${lineIdx}`)))) : (_jsx(Text, { dimColor: true, italic: true, children: '(no preview for this option)' }))] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: `[Enter] use highlighted defaults · [↑↓] navigate · [←→] switch · [s] skip · [Esc] cancel` }) })] }));
279
+ }
222
280
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: _jsx(Text, { bold: true, children: `Pugi: ${questions.length} quick ${questions.length === 1 ? 'choice' : 'choices'}` }) }), _jsx(Box, { flexDirection: "row", marginTop: 1, children: questions.map((q, qIdx) => {
223
281
  const isFocused = qIdx === focusedQuestion;
224
282
  const cursor = cursorPerQuestion[qIdx] ?? 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-beta.89",
3
+ "version": "0.1.0-beta.90",
4
4
  "description": "Pugi CLI - terminal-native software execution system",
5
5
  "homepage": "https://pugi.io",
6
6
  "repository": {
@@ -63,7 +63,7 @@
63
63
  "which": "^6.0.0",
64
64
  "zod": "^3.23.0",
65
65
  "@pugi/personas": "0.1.2",
66
- "@pugi/sdk": "0.1.0-beta.89"
66
+ "@pugi/sdk": "0.1.0-beta.90"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@types/node": "^22.0.0",