@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.
- package/dist/commands/smoke.js +133 -0
- package/dist/core/auth/ensure-authenticated.js +129 -0
- package/dist/core/bash-classifier.js +108 -1
- package/dist/core/codegraph/decision-store.js +248 -0
- package/dist/core/codegraph/detect-repo.js +459 -0
- package/dist/core/codegraph/install.js +134 -0
- package/dist/core/codegraph/offer-hook.js +220 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +50 -4
- package/dist/core/onboarding/ensure-initialized.js +133 -0
- package/dist/core/repl/session.js +370 -9
- package/dist/core/repl/slash-commands.js +68 -5
- package/dist/core/smoke/headless-driver.js +174 -0
- package/dist/core/smoke/orchestrator.js +194 -0
- package/dist/core/smoke/runner.js +238 -0
- package/dist/core/smoke/scenario-parser.js +316 -0
- package/dist/runtime/cli.js +453 -11
- package/dist/runtime/commands/cancel.js +231 -0
- package/dist/runtime/commands/codegraph-status.js +227 -0
- package/dist/runtime/commands/permissions.js +23 -0
- package/dist/runtime/commands/redo-blob-store.js +92 -0
- package/dist/runtime/commands/redo.js +361 -0
- package/dist/runtime/commands/status.js +11 -3
- package/dist/runtime/commands/undo.js +32 -0
- package/dist/runtime/headless-repl.js +195 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tui/permissions-picker.js +78 -0
- package/dist/tui/render.js +35 -0
- package/dist/tui/status-bar.js +1 -1
- package/dist/tui/tool-stream-pane.js +45 -3
- package/package.json +7 -4
- package/test/scenarios/codegen-create-file.scenario.txt +13 -0
- package/test/scenarios/compact-force.scenario.txt +11 -0
- package/test/scenarios/identity.scenario.txt +11 -0
- package/test/scenarios/persona-handoff.scenario.txt +11 -0
- package/test/scenarios/walkback.scenario.txt +12 -0
- package/dist/core/engine/compaction-hook.js +0 -154
- package/dist/core/init/scaffold.js +0 -195
- package/dist/core/memory/dual-write.spec.js +0 -297
- package/dist/core/memory-sync/queue.spec.js +0 -105
- package/dist/core/repl/codebase-survey.js +0 -308
- package/dist/core/repl/init-interview.js +0 -457
- package/dist/core/repl/onboarding-state.js +0 -297
- package/dist/runtime/commands/memory.spec.js +0 -174
package/dist/runtime/cli.js
CHANGED
|
@@ -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:
|
|
658
|
+
cwd: root,
|
|
616
659
|
json: flags.json,
|
|
617
660
|
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
618
661
|
resolveConfig: () => {
|
|
619
|
-
|
|
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
|
|
1650
|
-
' switch <label>
|
|
1651
|
-
' remove <label>
|
|
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
|
-
|
|
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
|
-
//
|
|
4052
|
-
//
|
|
4053
|
-
//
|
|
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
|
-
|
|
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);
|