@pugi/cli 0.1.0-beta.31 → 0.1.0-beta.35

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 (43) hide show
  1. package/dist/commands/smoke.js +133 -0
  2. package/dist/core/auth/ensure-authenticated.js +129 -0
  3. package/dist/core/bash-classifier.js +108 -1
  4. package/dist/core/codegraph/decision-store.js +248 -0
  5. package/dist/core/codegraph/detect-repo.js +459 -0
  6. package/dist/core/codegraph/install.js +134 -0
  7. package/dist/core/codegraph/offer-hook.js +220 -0
  8. package/dist/core/diagnostics/probes/status-snapshot.js +50 -4
  9. package/dist/core/onboarding/ensure-initialized.js +133 -0
  10. package/dist/core/repl/session.js +370 -9
  11. package/dist/core/repl/slash-commands.js +68 -5
  12. package/dist/core/smoke/headless-driver.js +174 -0
  13. package/dist/core/smoke/orchestrator.js +194 -0
  14. package/dist/core/smoke/runner.js +238 -0
  15. package/dist/core/smoke/scenario-parser.js +316 -0
  16. package/dist/runtime/cli.js +453 -11
  17. package/dist/runtime/commands/cancel.js +231 -0
  18. package/dist/runtime/commands/codegraph-status.js +227 -0
  19. package/dist/runtime/commands/permissions.js +23 -0
  20. package/dist/runtime/commands/redo-blob-store.js +92 -0
  21. package/dist/runtime/commands/redo.js +361 -0
  22. package/dist/runtime/commands/status.js +11 -3
  23. package/dist/runtime/commands/undo.js +32 -0
  24. package/dist/runtime/headless-repl.js +195 -0
  25. package/dist/runtime/version.js +1 -1
  26. package/dist/tui/permissions-picker.js +78 -0
  27. package/dist/tui/render.js +35 -0
  28. package/dist/tui/status-bar.js +1 -1
  29. package/dist/tui/tool-stream-pane.js +45 -3
  30. package/package.json +7 -4
  31. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  32. package/test/scenarios/compact-force.scenario.txt +11 -0
  33. package/test/scenarios/identity.scenario.txt +11 -0
  34. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  35. package/test/scenarios/walkback.scenario.txt +12 -0
  36. package/dist/core/engine/compaction-hook.js +0 -154
  37. package/dist/core/init/scaffold.js +0 -195
  38. package/dist/core/memory/dual-write.spec.js +0 -297
  39. package/dist/core/memory-sync/queue.spec.js +0 -105
  40. package/dist/core/repl/codebase-survey.js +0 -308
  41. package/dist/core/repl/init-interview.js +0 -457
  42. package/dist/core/repl/onboarding-state.js +0 -297
  43. package/dist/runtime/commands/memory.spec.js +0 -174
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
- import { statSync } from 'node:fs';
4
+ import { realpathSync, statSync } from 'node:fs';
5
5
  import { homedir } from 'node:os';
6
6
  import { dirname, relative, resolve } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
@@ -33,6 +33,8 @@ import { runThemeCommand } from './commands/theme.js';
33
33
  import { runOnboardingCommand } from './commands/onboarding.js';
34
34
  import { runVimCommand } from './commands/vim.js';
35
35
  import { isOnboarded } from '../core/onboarding/marker.js';
36
+ import { ensureInitialized as ensureInitializedHelper } from '../core/onboarding/ensure-initialized.js';
37
+ import { ensureAuthenticated as ensureAuthenticatedHelper } from '../core/auth/ensure-authenticated.js';
36
38
  import { runPrivacyCommand } from './commands/privacy.js';
37
39
  import { runReport } from './commands/report.js';
38
40
  import { runDoctorCommand, defaultHome as defaultDoctorHome } from './commands/doctor.js';
@@ -67,6 +69,7 @@ import { runMcpCommand } from './commands/mcp.js';
67
69
  import { runPermissionsCommand } from './commands/permissions.js';
68
70
  import { runPlanCommand } from './commands/plan.js';
69
71
  import { parsePermissionMode } from '../core/permissions/index.js';
72
+ import { protectedTargetReason } from '../core/permission.js';
70
73
  import { DECOMPOSE_PROMPT_SUFFIX, parseDecompositionFromText, writeDecomposition, } from './plan-decompose.js';
71
74
  import { FtsSyntaxError, SqliteSessionStore, resolveProjectStoreDir } from '../core/repl/store/index.js';
72
75
  import { slugForCwd } from '../core/repl/history.js';
@@ -170,6 +173,11 @@ const handlers = {
170
173
  // same handler as the in-REPL `/feedback` slash; the wrapper just
171
174
  // routes TTY vs non-TTY before mounting Ink.
172
175
  feedback: dispatchFeedback,
176
+ // BIG TRACK 10 Phase 1 (2026-05-27): `pugi smoke` runs the scenario
177
+ // corpus through `pugi --headless` and reports pass/fail per
178
+ // scenario. Subcommand-only — no slash counterpart per the Phase 1
179
+ // scope ("no new slash commands; harness is CLI subcommand only").
180
+ smoke: dispatchSmoke,
173
181
  sync,
174
182
  style: dispatchStyle,
175
183
  // Leak L30 (2026-05-27): `pugi theme` flips the local TUI color
@@ -482,6 +490,33 @@ async function dispatchTheme(args, flags, _session) {
482
490
  if (rc !== 0)
483
491
  process.exitCode = rc;
484
492
  }
493
+ /**
494
+ * BIG TRACK 10 Phase 1 (2026-05-27) — `pugi smoke` top-level dispatcher.
495
+ *
496
+ * Loads the bundled scenario corpus (`apps/pugi-cli/test/scenarios/`),
497
+ * runs each scenario through `pugi --headless` via the smoke
498
+ * orchestrator, and surfaces the pass/fail summary. `--filter <pat>`
499
+ * subsets the corpus; `--scenarios-dir <path>` swaps in an external
500
+ * dir (handy for project-local scenarios in customer repos).
501
+ *
502
+ * Exit-code policy:
503
+ * 0 — every scenario passed (or filter matched nothing)
504
+ * 1 — at least one scenario failed (assertion, parse error, executor crash)
505
+ * 2 — invalid CLI args (--filter without a value, unknown flag)
506
+ */
507
+ async function dispatchSmoke(args, flags, _session) {
508
+ const { runSmokeCommand } = await import('../commands/smoke.js');
509
+ const ctx = {
510
+ args,
511
+ json: flags.json,
512
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
513
+ };
514
+ if (flags.smokeFilter !== undefined)
515
+ ctx.filter = flags.smokeFilter;
516
+ const rc = await runSmokeCommand(ctx);
517
+ if (rc !== 0)
518
+ process.exitCode = rc;
519
+ }
485
520
  /**
486
521
  * Leak L25 (2026-05-27) — `pugi onboarding` top-level dispatcher.
487
522
  *
@@ -611,12 +646,22 @@ async function dispatchDelegate(args, flags, _session) {
611
646
  * stays single-sourced.
612
647
  */
613
648
  async function dispatchChain(args, flags, _session) {
649
+ const root = process.cwd();
650
+ // Wave 6 UX: chain reads / writes `.pugi/chains/*` so the auto-init
651
+ // pre-flight matches the engine commands. Auto-login resolves so a
652
+ // first-run `pugi chain new` from a cold cwd surfaces a login prompt
653
+ // instead of a silent unauthenticated error one layer deeper.
654
+ await runAutoInitPreflight(root, flags);
655
+ const auth = await runAutoAuthPreflight(flags);
656
+ const cachedCred = auth.status === 'ready' ? auth.credential : null;
614
657
  await runChainCommand(args, {
615
- cwd: process.cwd(),
658
+ cwd: root,
616
659
  json: flags.json,
617
660
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
618
661
  resolveConfig: () => {
619
- const credential = resolveActiveCredential();
662
+ // Prefer the pre-flight cached credential to avoid the second
663
+ // disk read (resolveActiveCredential reads ~/.pugi/credentials.json).
664
+ const credential = cachedCred ?? resolveActiveCredential();
620
665
  if (!credential)
621
666
  return null;
622
667
  return buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey });
@@ -715,6 +760,42 @@ async function dispatchPermissions(args, flags, _session) {
715
760
  return;
716
761
  }
717
762
  const mode = head ? parsePermissionMode(head) : undefined;
763
+ // Wave 6 cleanup (2026-05-27): no positional mode + interactive TTY
764
+ // → mount the Ink picker so the operator can arrow-select. Falls back
765
+ // to the legacy text table on non-TTY / --json / CI so scripted
766
+ // callers (and the deferred follow-up from PR #617) keep working.
767
+ // `bypass` selected from the picker still routes through
768
+ // `runPermissionsCommand` with `confirmBypass: true` — the picker IS
769
+ // the confirm gesture (arrow + Enter is the explicit acknowledge).
770
+ if (!mode && isInteractive(flags) && !flags.json) {
771
+ const { resolveLayeredMode } = await import('./commands/permissions.js');
772
+ const layered = resolveLayeredMode(process.cwd());
773
+ const { renderPermissionsPicker, PermissionsPickerCancelledError } = await import('../tui/render.js');
774
+ try {
775
+ const chosen = await renderPermissionsPicker({
776
+ currentMode: layered.effective,
777
+ sourceLabel: layered.source,
778
+ firstRun: layered.firstRun,
779
+ });
780
+ await runPermissionsCommand({
781
+ mode: chosen,
782
+ persist: Boolean(flags.persist),
783
+ // The picker selection IS the confirm gesture for `bypass`.
784
+ confirmBypass: chosen === 'bypass' ? true : Boolean(flags.confirm),
785
+ }, {
786
+ workspaceRoot: process.cwd(),
787
+ writeOutput: (text) => writeOutput(flags, { text }, text),
788
+ });
789
+ return;
790
+ }
791
+ catch (err) {
792
+ if (err instanceof PermissionsPickerCancelledError) {
793
+ writeOutput(flags, { cancelled: true }, 'Permissions picker cancelled. No change.');
794
+ return;
795
+ }
796
+ throw err;
797
+ }
798
+ }
718
799
  await runPermissionsCommand({
719
800
  ...(mode ? { mode } : {}),
720
801
  persist: Boolean(flags.persist),
@@ -1043,6 +1124,22 @@ export async function runCli(argv) {
1043
1124
  process.exitCode = exitCode;
1044
1125
  return;
1045
1126
  }
1127
+ // BIG TRACK 10 Phase 1 (2026-05-27) — `--headless` flag. When the
1128
+ // operator (or harness) passes `--headless` on a bare/repl
1129
+ // invocation we route into the multi-turn line-by-line headless
1130
+ // loop. Differs from `--print` (one-shot): headless reads stdin
1131
+ // until close. The dispatch lives BEFORE the REPL / splash branches
1132
+ // so the Ink TUI never mounts. Suppressed when `--print` is also
1133
+ // set (the one-shot variant wins — explicit single-turn overrides
1134
+ // the multi-turn loop).
1135
+ if (flags.headless && typeof flags.print !== 'string') {
1136
+ const { runHeadlessRepl } = await import('./headless-repl.js');
1137
+ const exitCode = await runHeadlessRepl({
1138
+ cwd: flags.cwd ?? process.cwd(),
1139
+ });
1140
+ process.exitCode = exitCode;
1141
+ return;
1142
+ }
1046
1143
  // Leak L25 (2026-05-27): first-run hint. When the operator types a
1047
1144
  // bare `pugi` on a real TTY AND the onboarding marker is absent, drop
1048
1145
  // a one-line hint on stderr BEFORE the REPL splash mounts. Stderr so
@@ -1146,6 +1243,12 @@ function parseArgs(argv) {
1146
1243
  ? process.env.PUGI_HIDE_TOOL_STREAM === '1'
1147
1244
  : true,
1148
1245
  noDefaults: process.env.PUGI_INIT_NO_DEFAULTS === '1',
1246
+ // Wave 6 UX (2026-05-27): auto-init / auto-login opt-outs. Default
1247
+ // OFF (auto-init + auto-login are on by default on an interactive
1248
+ // TTY). PUGI_NO_AUTO_* env vars provide a per-shell escape hatch
1249
+ // without needing к thread the flag through every invocation.
1250
+ noInit: process.env.PUGI_NO_AUTO_INIT === '1',
1251
+ noLogin: process.env.PUGI_NO_AUTO_LOGIN === '1',
1149
1252
  decompose: false,
1150
1253
  // β-headless: --no-tools default OFF so existing flag-free invocations
1151
1254
  // keep tool advertisement. Flipped only by explicit operator opt-in.
@@ -1172,6 +1275,11 @@ function parseArgs(argv) {
1172
1275
  // bare invocation hits the cache when mtime + size match; opt-in
1173
1276
  // for a cold rebuild from the source tree.
1174
1277
  refresh: false,
1278
+ // BIG TRACK 10 Phase 1 — `--headless` for multi-turn programmatic
1279
+ // drive. Default off; explicit opt-in only. The CLI ALSO honors
1280
+ // `PUGI_HEADLESS=1` so the smoke harness can pre-set the env when
1281
+ // a wrapper script forgets the flag.
1282
+ headless: process.env.PUGI_HEADLESS === '1',
1175
1283
  };
1176
1284
  const args = [];
1177
1285
  // Leak L22: scan for `--bare` BEFORE the early-return short-circuits
@@ -1442,6 +1550,40 @@ function parseArgs(argv) {
1442
1550
  flags.bare = true;
1443
1551
  setBareMode();
1444
1552
  }
1553
+ else if (arg === '--no-init') {
1554
+ // Wave 6 UX (2026-05-27): opt-out for the auto-init pre-flight
1555
+ // wrapper. The flag-driven path mirrors PUGI_NO_AUTO_INIT=1 so a
1556
+ // single invocation can override the env state and vice versa.
1557
+ flags.noInit = true;
1558
+ }
1559
+ else if (arg === '--no-login') {
1560
+ // Wave 6 UX (2026-05-27): opt-out for the auto-login pre-flight
1561
+ // wrapper. The auth resolution still runs (env / file paths) —
1562
+ // only the inline device-flow launch is suppressed.
1563
+ flags.noLogin = true;
1564
+ }
1565
+ else if (arg === '--headless') {
1566
+ // BIG TRACK 10 Phase 1 — line-by-line stdin → engine → JSON
1567
+ // envelopes on stdout. Distinct from `--print` (single-shot).
1568
+ // The dispatcher routes to `runHeadlessRepl` BEFORE the Ink
1569
+ // REPL when this flag is set on a bare/repl invocation.
1570
+ flags.headless = true;
1571
+ }
1572
+ else if (arg === '--filter') {
1573
+ // BIG TRACK 10 Phase 1 — `pugi smoke --filter <pattern>`.
1574
+ // Generic flag name so future commands (e.g. `pugi sessions
1575
+ // --filter`) can reuse it without a second flag wired through
1576
+ // parseArgs.
1577
+ const next = argv[index + 1];
1578
+ if (!next || next.startsWith('--')) {
1579
+ throw new Error('--filter requires a pattern (substring or *-glob)');
1580
+ }
1581
+ flags.smokeFilter = next;
1582
+ index += 1;
1583
+ }
1584
+ else if (arg.startsWith('--filter=')) {
1585
+ flags.smokeFilter = arg.slice('--filter='.length);
1586
+ }
1445
1587
  else {
1446
1588
  args.push(arg);
1447
1589
  }
@@ -1646,9 +1788,9 @@ const COMMAND_HELP_BODIES = {
1646
1788
  accounts: [
1647
1789
  'pugi accounts — manage stored credentials across endpoints.',
1648
1790
  '',
1649
- ' list Every account + its endpoint + active flag.',
1650
- ' switch <label> Re-point the active account.',
1651
- ' remove <label> Delete a stored credential.',
1791
+ ' pugi accounts list Every account + its endpoint + active flag.',
1792
+ ' pugi accounts switch <label> Re-point the active account.',
1793
+ ' pugi accounts remove <label> Delete a stored credential.',
1652
1794
  ],
1653
1795
  jobs: [
1654
1796
  'pugi jobs — list, tail, or kill background dispatch jobs.',
@@ -2337,7 +2479,16 @@ async function init(_args, flags, _session) {
2337
2479
  'Default skills:',
2338
2480
  ...result.defaultSkills.map((entry) => ` ${entry.name.padEnd(18)} ${entry.status}`),
2339
2481
  ];
2340
- writeOutput(flags, result, [
2482
+ // Wave 6 BT 9 Phase 2 (2026-05-27): codegraph context-aware auto-install.
2483
+ // After scaffold, evaluate whether the repo looks big-enough + matches a
2484
+ // supported language. The init flow surfaces the offer copy + the docs
2485
+ // URL; the operator decides via the interactive Yes/no prompt OR (in
2486
+ // --json / --no-tty mode) explicitly via `pugi mcp install codegraph
2487
+ // codegraph serve --mcp` later. We DO NOT auto-install here on the
2488
+ // non-interactive path — silently writing к .pugi/mcp.json without a
2489
+ // visible operator confirmation would violate the trust contract.
2490
+ const codegraphLines = await maybeOfferCodegraphInline(result.root, flags);
2491
+ writeOutput(flags, { ...result, codegraph: codegraphLines.envelope }, [
2341
2492
  'Pugi initialized',
2342
2493
  `Root: ${result.root}`,
2343
2494
  result.created.length
@@ -2347,8 +2498,78 @@ async function init(_args, flags, _session) {
2347
2498
  ? `Already present:\n${result.skipped.map((path) => ` ${path}`).join('\n')}`
2348
2499
  : 'Already present: none',
2349
2500
  ...defaultSkillLines,
2501
+ ...codegraphLines.text,
2350
2502
  ].join('\n'));
2351
2503
  }
2504
+ /**
2505
+ * Codegraph offer inline branch for `pugi init` (Wave 6 BT 9 Phase 2).
2506
+ *
2507
+ * Pure information surface — does NOT prompt synchronously. Returns:
2508
+ *
2509
+ * - `text[]` — lines к append к the human-facing init summary
2510
+ * - `envelope` — structured JSON payload included in `--json` output
2511
+ * so a CI harness can branch на the verdict без
2512
+ * re-running detection.
2513
+ *
2514
+ * The interactive Yes/no prompt lives one layer up (the `/init` REPL
2515
+ * slash handles it). The standalone `pugi init` is intentionally non-
2516
+ * interactive — operators wanting a one-liner install can run
2517
+ * `pugi mcp install codegraph codegraph serve --mcp` after seeing the
2518
+ * hint here.
2519
+ */
2520
+ async function maybeOfferCodegraphInline(workspaceRoot, flags) {
2521
+ try {
2522
+ const { evaluateOffer, emitOfferShown } = await import('../core/codegraph/offer-hook.js');
2523
+ const verdict = evaluateOffer({ workspaceRoot });
2524
+ if (!verdict.shouldPrompt) {
2525
+ return {
2526
+ text: [],
2527
+ envelope: {
2528
+ status: 'skipped',
2529
+ reason: verdict.reason,
2530
+ },
2531
+ };
2532
+ }
2533
+ // Surface the telemetry shown-event only for surfaces that
2534
+ // actually rendered к the operator. `--json` consumers still see
2535
+ // the verdict в the envelope so we count those as shown too.
2536
+ emitOfferShown(verdict.detection);
2537
+ const noTty = flags.noTty || flags.json;
2538
+ const lines = [
2539
+ '',
2540
+ 'Codegraph context-aware install (Wave 6):',
2541
+ ` ${verdict.promptCopy}`,
2542
+ ` Docs: ${verdict.docsUrl}`,
2543
+ ];
2544
+ if (!noTty) {
2545
+ lines.push(' Accept: `pugi mcp install codegraph codegraph serve --mcp && pugi mcp trust codegraph`', ' Skip: `pugi mcp install codegraph` will not run automatically — your call.');
2546
+ }
2547
+ else {
2548
+ lines.push(' Non-interactive mode — codegraph NOT auto-installed.', ' Run `pugi mcp install codegraph codegraph serve --mcp` to opt in.');
2549
+ }
2550
+ return {
2551
+ text: lines,
2552
+ envelope: {
2553
+ status: 'offered',
2554
+ sizeCategory: verdict.detection.sizeCategory,
2555
+ languages: verdict.detection.languages,
2556
+ primarySymbolCount: verdict.detection.primarySymbolCount,
2557
+ copy: verdict.promptCopy,
2558
+ docsUrl: verdict.docsUrl,
2559
+ },
2560
+ };
2561
+ }
2562
+ catch (error) {
2563
+ // Defensive — codegraph offer is best-effort, must not fail init.
2564
+ return {
2565
+ text: [],
2566
+ envelope: {
2567
+ status: 'error',
2568
+ reason: error.message,
2569
+ },
2570
+ };
2571
+ }
2572
+ }
2352
2573
  async function idea(args, flags, session) {
2353
2574
  const prompt = args.join(' ').trim();
2354
2575
  if (!prompt) {
@@ -2489,6 +2710,7 @@ async function idea(args, flags, session) {
2489
2710
  */
2490
2711
  async function offlinePlan(args, flags, session) {
2491
2712
  const root = process.cwd();
2713
+ await runAutoInitPreflight(root, flags);
2492
2714
  ensureInitialized(root);
2493
2715
  const prompt = args.join(' ').trim();
2494
2716
  const latestIdea = latestArtifactDir(root);
@@ -2563,6 +2785,7 @@ async function offlinePlan(args, flags, session) {
2563
2785
  }
2564
2786
  async function offlineBuild(args, flags, session) {
2565
2787
  const root = process.cwd();
2788
+ await runAutoInitPreflight(root, flags);
2566
2789
  ensureInitialized(root);
2567
2790
  const prompt = args.join(' ').trim();
2568
2791
  if (!prompt) {
@@ -2660,6 +2883,7 @@ async function offlineExplain(args, flags, session) {
2660
2883
  }
2661
2884
  async function review(args, flags, session) {
2662
2885
  const root = process.cwd();
2886
+ await runAutoInitPreflight(root, flags);
2663
2887
  ensureInitialized(root);
2664
2888
  const prompt = args.join(' ').trim();
2665
2889
  // α6.7: customer-facing consensus review routes here. Distinct from
@@ -2806,6 +3030,7 @@ async function review(args, flags, session) {
2806
3030
  }
2807
3031
  async function sync(_args, flags, session) {
2808
3032
  const root = process.cwd();
3033
+ await runAutoInitPreflight(root, flags);
2809
3034
  ensureInitialized(root);
2810
3035
  const settings = loadSettings(root);
2811
3036
  const mode = flags.privacy ?? privacyModeFromSettings(settings.privacy.mode);
@@ -3558,6 +3783,7 @@ function parseDiffStats(raw) {
3558
3783
  }
3559
3784
  async function handoff(args, flags, session) {
3560
3785
  const root = process.cwd();
3786
+ await runAutoInitPreflight(root, flags);
3561
3787
  ensureInitialized(root);
3562
3788
  const reason = args[0] || 'web_continue';
3563
3789
  const prompt = args.slice(1).join(' ').trim() || 'continue local Pugi session';
@@ -3593,6 +3819,7 @@ async function sessions(args, flags, _session) {
3593
3819
  return;
3594
3820
  }
3595
3821
  const root = process.cwd();
3822
+ await runAutoInitPreflight(root, flags);
3596
3823
  ensureInitialized(root);
3597
3824
  const rebuild = args.includes('--rebuild');
3598
3825
  let index = rebuild ? null : readIndex(root);
@@ -3777,6 +4004,7 @@ async function resume(args, flags, session) {
3777
4004
  await resumeLocalSession({ flags, arg0, wantsList });
3778
4005
  return;
3779
4006
  }
4007
+ await runAutoInitPreflight(root, flags);
3780
4008
  ensureInitialized(root);
3781
4009
  const target = args[0];
3782
4010
  const artifacts = listArtifactSets(root);
@@ -3984,6 +4212,44 @@ const ENGINE_EXIT_CODES = {
3984
4212
  function commandLabel(kind) {
3985
4213
  return kind === 'build_task' ? 'build' : kind;
3986
4214
  }
4215
+ /**
4216
+ * Heuristic: does the user-supplied first arg look like a file or
4217
+ * directory path the operator wants `pugi explain` to inspect? Used to
4218
+ * decide whether to run the pre-engine path-security gate vs treat the
4219
+ * arg as a free-form natural-language prompt.
4220
+ *
4221
+ * Triggers when the arg:
4222
+ * - starts with `.` (`.env`, `./src/foo`, `..`)
4223
+ * - starts with `/` (absolute path)
4224
+ * - contains `/` (`apps/admin-api/src/index.ts`)
4225
+ * - contains no spaces AND exists on disk relative to the workspace
4226
+ *
4227
+ * Misses (treated as free-form prompts):
4228
+ * - "what does this package.json define?" (has spaces)
4229
+ * - "trace the auth flow" (has spaces)
4230
+ *
4231
+ * The pre-engine gate is a defence in depth — the bash classifier and
4232
+ * file-tools `resolveWorkspacePath` already refuse the bad paths inside
4233
+ * the engine, but failing fast at the CLI seam lets the operator see a
4234
+ * crisp permission error with exit code 8 instead of the engine
4235
+ * pretending to "explain" the protected file.
4236
+ */
4237
+ function looksLikePath(arg) {
4238
+ if (!arg)
4239
+ return false;
4240
+ if (arg.includes(' '))
4241
+ return false;
4242
+ if (arg.startsWith('.') || arg.startsWith('/') || arg.includes('/'))
4243
+ return true;
4244
+ // Last-resort check: bare-token paths that exist on disk
4245
+ // (`README.md`, `package.json`) still benefit from the gate.
4246
+ try {
4247
+ return existsSync(resolve(process.cwd(), arg));
4248
+ }
4249
+ catch {
4250
+ return false;
4251
+ }
4252
+ }
3987
4253
  /**
3988
4254
  * Sprint 2 Track A: wire `pugi code/explain/fix/plan/build` to the real
3989
4255
  * `NativePugiEngineAdapter`. Each command:
@@ -4048,11 +4314,26 @@ function runEngineTask(kind) {
4048
4314
  return async (args, flags, session) => {
4049
4315
  const label = commandLabel(kind);
4050
4316
  const root = process.cwd();
4051
- // `.pugi/` is created by `pugi init`. The engine writes the per-
4052
- // session events mirror under it, so we fail fast here instead of
4053
- // silently no-op'ing the mirror inside the adapter.
4317
+ // Wave 6 UX (2026-05-27): auto-init pre-flight. On an interactive
4318
+ // TTY in a workspace без `.pugi/` we prompt
4319
+ // "Initialize a new Pugi workspace here? (Y/n)" and scaffold
4320
+ // inline on Y. Falls back к the legacy strict-assert (throw `Run
4321
+ // pugi init first`) in CI / `--no-init`, keeping pinned CI
4322
+ // assertions green.
4323
+ await runAutoInitPreflight(root, flags);
4324
+ // Post-condition assertion — narrows for the type checker and
4325
+ // matches the pre-Wave-6 invariant that the engine adapter
4326
+ // expects `.pugi/` к exist before it writes the events mirror.
4054
4327
  ensureInitialized(root);
4055
- const credential = resolveActiveCredential();
4328
+ // Wave 6 UX (2026-05-27): auto-login pre-flight. Read-only
4329
+ // operators (`pugi explain` against a public repo) and `plan`/
4330
+ // `build` still have legitimate offline fallbacks below, so the
4331
+ // helper output is informational here — we capture it for the
4332
+ // engine_unavailable branch below but never bail unconditionally
4333
+ // on `missing`. `code` / `fix` reject offline runs explicitly,
4334
+ // mirroring the pre-existing contract.
4335
+ const auth = await runAutoAuthPreflight(flags);
4336
+ const credential = auth.status === 'ready' ? auth.credential : null;
4056
4337
  const envConfig = loadRuntimeConfig();
4057
4338
  const config = credential
4058
4339
  ? buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey })
@@ -4101,6 +4382,73 @@ function runEngineTask(kind) {
4101
4382
  if (kind === 'explain')
4102
4383
  return offlineExplain(args, flags, session);
4103
4384
  }
4385
+ // P0 fix 2026-05-28 (Codex audit): pre-engine path validation for
4386
+ // `pugi explain <path>`. Without this gate, when the first arg
4387
+ // resolves to an on-disk path the engine would happily forward it
4388
+ // to the model — which could then `bash cat .env` or `cat ../X` and
4389
+ // sidestep the file-tools `resolveWorkspacePath`/
4390
+ // `permissionGatedResolve` checks. The bash-classifier now refuses
4391
+ // those reads (PROTECTED_BASENAME_PATTERNS + detectParentTraversalRead),
4392
+ // but we ALSO fail fast at the CLI seam so:
4393
+ // - `pugi explain .env` exits non-zero with a permission error
4394
+ // - `pugi explain ..` exits non-zero with a path-escape error
4395
+ // - `pugi explain alias-to-env` (symlink to .env) exits non-zero
4396
+ // because `permissionGatedResolve` re-checks the realpath
4397
+ // matching the offlineExplain behaviour the spec asserts.
4398
+ if (kind === 'explain' && args.length > 0) {
4399
+ const firstArg = args[0];
4400
+ if (firstArg && looksLikePath(firstArg)) {
4401
+ const targetExists = (() => {
4402
+ try {
4403
+ // First reject parent-traversal patterns OUTRIGHT — even a
4404
+ // path that does not currently exist must not address a
4405
+ // location above the workspace.
4406
+ const resolved = resolveWorkspacePath(root, firstArg);
4407
+ // For paths that exist, run the realpath-aware permission
4408
+ // re-check so symlink aliases to protected files refuse
4409
+ // the same way the file-tools gate would.
4410
+ const settings = loadSettings(root);
4411
+ const protectedReason = protectedTargetReason({ tool: 'explain', kind: 'read', target: firstArg }, root);
4412
+ if (protectedReason) {
4413
+ throw new Error(`Permission deny for explain ${firstArg}: ${protectedReason}`);
4414
+ }
4415
+ // Symlink alias re-check: resolve to realpath and re-test
4416
+ // the basename. Mirrors `permissionGatedResolve` in
4417
+ // file-tools.ts so `alias-to-env -> .env` is refused.
4418
+ try {
4419
+ const real = realpathSync.native(resolved);
4420
+ if (real !== resolved) {
4421
+ const realProtected = protectedTargetReason({ tool: 'explain', kind: 'read', target: relative(root, real) }, root);
4422
+ if (realProtected) {
4423
+ throw new Error(`Permission deny for explain ${firstArg} (via symlink): ${realProtected}`);
4424
+ }
4425
+ }
4426
+ }
4427
+ catch (e) {
4428
+ const code = e.code;
4429
+ if (code !== 'ENOENT' && code !== 'ENOTDIR')
4430
+ throw e;
4431
+ }
4432
+ // Suppress unused-var warning while keeping settings load
4433
+ // explicit (some lint configs treat the const as dead).
4434
+ void settings;
4435
+ return true;
4436
+ }
4437
+ catch (error) {
4438
+ const message = error.message;
4439
+ writeOutput(flags, {
4440
+ command: label,
4441
+ status: 'blocked',
4442
+ reason: message,
4443
+ }, [`pugi ${label} refused: ${message}`].join('\n'));
4444
+ process.exitCode = ENGINE_EXIT_CODES.blocked;
4445
+ return false;
4446
+ }
4447
+ })();
4448
+ if (!targetExists)
4449
+ return;
4450
+ }
4451
+ }
4104
4452
  // Engine path prompt gate. (Offline `explain` accepts a path as
4105
4453
  // its first positional arg — that branch returned above before
4106
4454
  // we reach this gate.)
@@ -6064,11 +6412,105 @@ function ensureDir(path, created, skipped) {
6064
6412
  mkdirSync(path, { recursive: true });
6065
6413
  created.push(path);
6066
6414
  }
6415
+ /**
6416
+ * Strict assertion — the workspace MUST already be initialised. Used
6417
+ * AFTER `runAutoInitPreflight` so the surrounding engine command can
6418
+ * narrow on the precondition. Kept synchronous because the async
6419
+ * pre-flight (with the optional prompt + scaffold) is a separate
6420
+ * step at command entry; this is the post-condition assertion.
6421
+ */
6067
6422
  function ensureInitialized(root) {
6068
6423
  if (!existsSync(resolve(root, '.pugi'))) {
6069
6424
  throw new Error('Run pugi init first');
6070
6425
  }
6071
6426
  }
6427
+ /**
6428
+ * Wave 6 UX (2026-05-27): async pre-flight wrapper around the
6429
+ * `ensureInitializedHelper` from `core/onboarding/ensure-initialized.ts`.
6430
+ * Called at command entry for every command that touches `.pugi/`.
6431
+ *
6432
+ * - `.pugi/` already exists → no-op (helper short-circuits).
6433
+ * - Interactive TTY + missing → prompt "Initialize? (Y/n)". On Y,
6434
+ * scaffold inline and continue. On n, throw a clean error so the
6435
+ * surrounding command bails without dropping into a half-state.
6436
+ * - Non-interactive + missing → throw (matches the legacy
6437
+ * `ensureInitialized` strict assertion). The caller MUST run
6438
+ * `pugi init` explicitly before piping into Pugi from CI.
6439
+ *
6440
+ * Operator opt-out: `--no-init` (parsed into `flags.noInit`) OR
6441
+ * `PUGI_NO_AUTO_INIT=1` forces the strict assertion даже on TTY so
6442
+ * shells / wrappers that own init orchestration can disable us.
6443
+ */
6444
+ async function runAutoInitPreflight(root, flags) {
6445
+ const result = await ensureInitializedHelper({
6446
+ cwd: root,
6447
+ interactive: isInteractive(flags),
6448
+ skip: flags.noInit || process.env.PUGI_NO_AUTO_INIT === '1',
6449
+ prompt: async (question) => readSingleChoice(question),
6450
+ scaffold: async (input) => {
6451
+ // Forward to the real scaffolder. The helper does not import
6452
+ // `scaffoldPugiWorkspace` directly to keep its module import-
6453
+ // cycle free; threading it via the callback also lets the
6454
+ // spec swap in a fake.
6455
+ await scaffoldPugiWorkspace({ cwd: input.cwd, noDefaults: flags.noDefaults });
6456
+ },
6457
+ });
6458
+ if (result.status === 'declined') {
6459
+ if (result.reason === 'user_declined') {
6460
+ throw new Error('Initialization declined. Run `pugi init` when ready.');
6461
+ }
6462
+ // non_interactive / disabled → match the legacy strict-assert
6463
+ // message so CI scripts that grep for "Run pugi init first" keep
6464
+ // working. The helper's structured `reason` field is still
6465
+ // available via the spec for finer-grained branching.
6466
+ throw new Error('Run pugi init first');
6467
+ }
6468
+ }
6469
+ /**
6470
+ * Wave 6 UX (2026-05-27): async pre-flight wrapper around the
6471
+ * `ensureAuthenticatedHelper` from `core/auth/ensure-authenticated.ts`.
6472
+ * Called at command entry for every command that authenticates against
6473
+ * Anvil. Returns a structured envelope; the caller decides how к
6474
+ * handle the `missing` path (engine commands fall back к offline OR
6475
+ * raise `engine_unavailable`, write commands raise unauthenticated,
6476
+ * read commands MAY proceed in degraded mode).
6477
+ *
6478
+ * The inline login launches `performDeviceFlowLogin` against the
6479
+ * detected apiUrl. Operator opt-out via `--no-login` flag OR
6480
+ * `PUGI_NO_AUTO_LOGIN=1` matches the auto-init equivalent.
6481
+ */
6482
+ async function runAutoAuthPreflight(flags) {
6483
+ return ensureAuthenticatedHelper({
6484
+ resolve: () => resolveActiveCredential(),
6485
+ interactive: isInteractive(flags),
6486
+ skip: flags.noLogin || process.env.PUGI_NO_AUTO_LOGIN === '1',
6487
+ // Headless mode (`--headless` / `--print`) cannot block on a
6488
+ // browser-popup login. The helper refuses the inline branch when
6489
+ // this flag is set даже on a TTY.
6490
+ headless: Boolean(flags.headless || flags.print !== undefined),
6491
+ login: async () => {
6492
+ // Best-effort inline device-flow. Returns true on success
6493
+ // (credential persisted), false on cancel. Errors propagate up
6494
+ // and the helper converts them к `login_failed`.
6495
+ const apiUrl = normalizeApiUrl(process.env.PUGI_API_URL ?? DEFAULT_API_URL);
6496
+ const before = resolveActiveCredential();
6497
+ try {
6498
+ await performDeviceFlowLogin(apiUrl, flags, null);
6499
+ }
6500
+ catch {
6501
+ return false;
6502
+ }
6503
+ // The device-flow handler may set process.exitCode on cancel;
6504
+ // we reset it so the surrounding command does not inherit a
6505
+ // 130 from the login surface даже on success. Re-resolution
6506
+ // below is the source of truth.
6507
+ if (process.exitCode === 130)
6508
+ process.exitCode = 0;
6509
+ const after = resolveActiveCredential();
6510
+ return Boolean(after && after.apiKey !== before?.apiKey) || Boolean(after && !before);
6511
+ },
6512
+ });
6513
+ }
6072
6514
  function createArtifactDir(root, seed) {
6073
6515
  const id = `${new Date().toISOString().replace(/[:.]/g, '-')}-${slugify(seed)}`;
6074
6516
  const artifactDir = resolve(root, '.pugi', 'artifacts', id);