@pugi/cli 0.1.0-beta.30 → 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/prd-check/session-review.js +557 -0
- package/dist/core/repl/session.js +419 -15
- package/dist/core/repl/slash-commands.js +82 -7
- 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 +463 -13
- 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/prd-check.js +53 -3
- 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.',
|
|
@@ -1695,10 +1837,11 @@ const COMMAND_HELP_BODIES = {
|
|
|
1695
1837
|
'engine adapter. Safe to run anywhere; no network calls.',
|
|
1696
1838
|
],
|
|
1697
1839
|
'prd-check': [
|
|
1698
|
-
'pugi prd-check <prd-path> | --all — Wave 6 verified-deliverable gate.',
|
|
1840
|
+
'pugi prd-check <prd-path> | --all | --session — Wave 6 verified-deliverable gate.',
|
|
1699
1841
|
'',
|
|
1842
|
+
'DEFAULT MODE — verify acceptance criteria against committed artifacts.',
|
|
1700
1843
|
'Reads a markdown PRD, parses the acceptance-criteria section, and',
|
|
1701
|
-
'runs verifiers
|
|
1844
|
+
'runs verifiers:',
|
|
1702
1845
|
' file:<path> fs.existsSync',
|
|
1703
1846
|
' test:<spec> spec file exists + has ≥1 test()/it() block',
|
|
1704
1847
|
' doc:<path> doc exists + has > 100 chars',
|
|
@@ -1708,6 +1851,13 @@ const COMMAND_HELP_BODIES = {
|
|
|
1708
1851
|
' --all Scan docs/prd/**.md instead of one file.',
|
|
1709
1852
|
' --json Emit a structured envelope to stdout.',
|
|
1710
1853
|
'',
|
|
1854
|
+
'SESSION MODE (Wave 6 final) — review the live session against the PRD.',
|
|
1855
|
+
'Walks up for PRD.md or apps/<app>/PRODUCT.md, reads the last 20 turns',
|
|
1856
|
+
'from .pugi/events.jsonl, and dispatches a cross-review subagent to',
|
|
1857
|
+
'list which requirements are SATISFIED and which remain OUTSTANDING.',
|
|
1858
|
+
'',
|
|
1859
|
+
' --session Run the session-review mode (no <path>, no --all).',
|
|
1860
|
+
'',
|
|
1711
1861
|
'Exit codes: 0 healthy · 1 failing · 2 unparsed / arg error.',
|
|
1712
1862
|
],
|
|
1713
1863
|
status: [
|
|
@@ -2329,7 +2479,16 @@ async function init(_args, flags, _session) {
|
|
|
2329
2479
|
'Default skills:',
|
|
2330
2480
|
...result.defaultSkills.map((entry) => ` ${entry.name.padEnd(18)} ${entry.status}`),
|
|
2331
2481
|
];
|
|
2332
|
-
|
|
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 }, [
|
|
2333
2492
|
'Pugi initialized',
|
|
2334
2493
|
`Root: ${result.root}`,
|
|
2335
2494
|
result.created.length
|
|
@@ -2339,8 +2498,78 @@ async function init(_args, flags, _session) {
|
|
|
2339
2498
|
? `Already present:\n${result.skipped.map((path) => ` ${path}`).join('\n')}`
|
|
2340
2499
|
: 'Already present: none',
|
|
2341
2500
|
...defaultSkillLines,
|
|
2501
|
+
...codegraphLines.text,
|
|
2342
2502
|
].join('\n'));
|
|
2343
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
|
+
}
|
|
2344
2573
|
async function idea(args, flags, session) {
|
|
2345
2574
|
const prompt = args.join(' ').trim();
|
|
2346
2575
|
if (!prompt) {
|
|
@@ -2481,6 +2710,7 @@ async function idea(args, flags, session) {
|
|
|
2481
2710
|
*/
|
|
2482
2711
|
async function offlinePlan(args, flags, session) {
|
|
2483
2712
|
const root = process.cwd();
|
|
2713
|
+
await runAutoInitPreflight(root, flags);
|
|
2484
2714
|
ensureInitialized(root);
|
|
2485
2715
|
const prompt = args.join(' ').trim();
|
|
2486
2716
|
const latestIdea = latestArtifactDir(root);
|
|
@@ -2555,6 +2785,7 @@ async function offlinePlan(args, flags, session) {
|
|
|
2555
2785
|
}
|
|
2556
2786
|
async function offlineBuild(args, flags, session) {
|
|
2557
2787
|
const root = process.cwd();
|
|
2788
|
+
await runAutoInitPreflight(root, flags);
|
|
2558
2789
|
ensureInitialized(root);
|
|
2559
2790
|
const prompt = args.join(' ').trim();
|
|
2560
2791
|
if (!prompt) {
|
|
@@ -2652,6 +2883,7 @@ async function offlineExplain(args, flags, session) {
|
|
|
2652
2883
|
}
|
|
2653
2884
|
async function review(args, flags, session) {
|
|
2654
2885
|
const root = process.cwd();
|
|
2886
|
+
await runAutoInitPreflight(root, flags);
|
|
2655
2887
|
ensureInitialized(root);
|
|
2656
2888
|
const prompt = args.join(' ').trim();
|
|
2657
2889
|
// α6.7: customer-facing consensus review routes here. Distinct from
|
|
@@ -2798,6 +3030,7 @@ async function review(args, flags, session) {
|
|
|
2798
3030
|
}
|
|
2799
3031
|
async function sync(_args, flags, session) {
|
|
2800
3032
|
const root = process.cwd();
|
|
3033
|
+
await runAutoInitPreflight(root, flags);
|
|
2801
3034
|
ensureInitialized(root);
|
|
2802
3035
|
const settings = loadSettings(root);
|
|
2803
3036
|
const mode = flags.privacy ?? privacyModeFromSettings(settings.privacy.mode);
|
|
@@ -3550,6 +3783,7 @@ function parseDiffStats(raw) {
|
|
|
3550
3783
|
}
|
|
3551
3784
|
async function handoff(args, flags, session) {
|
|
3552
3785
|
const root = process.cwd();
|
|
3786
|
+
await runAutoInitPreflight(root, flags);
|
|
3553
3787
|
ensureInitialized(root);
|
|
3554
3788
|
const reason = args[0] || 'web_continue';
|
|
3555
3789
|
const prompt = args.slice(1).join(' ').trim() || 'continue local Pugi session';
|
|
@@ -3585,6 +3819,7 @@ async function sessions(args, flags, _session) {
|
|
|
3585
3819
|
return;
|
|
3586
3820
|
}
|
|
3587
3821
|
const root = process.cwd();
|
|
3822
|
+
await runAutoInitPreflight(root, flags);
|
|
3588
3823
|
ensureInitialized(root);
|
|
3589
3824
|
const rebuild = args.includes('--rebuild');
|
|
3590
3825
|
let index = rebuild ? null : readIndex(root);
|
|
@@ -3769,6 +4004,7 @@ async function resume(args, flags, session) {
|
|
|
3769
4004
|
await resumeLocalSession({ flags, arg0, wantsList });
|
|
3770
4005
|
return;
|
|
3771
4006
|
}
|
|
4007
|
+
await runAutoInitPreflight(root, flags);
|
|
3772
4008
|
ensureInitialized(root);
|
|
3773
4009
|
const target = args[0];
|
|
3774
4010
|
const artifacts = listArtifactSets(root);
|
|
@@ -3976,6 +4212,44 @@ const ENGINE_EXIT_CODES = {
|
|
|
3976
4212
|
function commandLabel(kind) {
|
|
3977
4213
|
return kind === 'build_task' ? 'build' : kind;
|
|
3978
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
|
+
}
|
|
3979
4253
|
/**
|
|
3980
4254
|
* Sprint 2 Track A: wire `pugi code/explain/fix/plan/build` to the real
|
|
3981
4255
|
* `NativePugiEngineAdapter`. Each command:
|
|
@@ -4040,11 +4314,26 @@ function runEngineTask(kind) {
|
|
|
4040
4314
|
return async (args, flags, session) => {
|
|
4041
4315
|
const label = commandLabel(kind);
|
|
4042
4316
|
const root = process.cwd();
|
|
4043
|
-
//
|
|
4044
|
-
//
|
|
4045
|
-
//
|
|
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.
|
|
4046
4327
|
ensureInitialized(root);
|
|
4047
|
-
|
|
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;
|
|
4048
4337
|
const envConfig = loadRuntimeConfig();
|
|
4049
4338
|
const config = credential
|
|
4050
4339
|
? buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey })
|
|
@@ -4093,6 +4382,73 @@ function runEngineTask(kind) {
|
|
|
4093
4382
|
if (kind === 'explain')
|
|
4094
4383
|
return offlineExplain(args, flags, session);
|
|
4095
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
|
+
}
|
|
4096
4452
|
// Engine path prompt gate. (Offline `explain` accepts a path as
|
|
4097
4453
|
// its first positional arg — that branch returned above before
|
|
4098
4454
|
// we reach this gate.)
|
|
@@ -6056,11 +6412,105 @@ function ensureDir(path, created, skipped) {
|
|
|
6056
6412
|
mkdirSync(path, { recursive: true });
|
|
6057
6413
|
created.push(path);
|
|
6058
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
|
+
*/
|
|
6059
6422
|
function ensureInitialized(root) {
|
|
6060
6423
|
if (!existsSync(resolve(root, '.pugi'))) {
|
|
6061
6424
|
throw new Error('Run pugi init first');
|
|
6062
6425
|
}
|
|
6063
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
|
+
}
|
|
6064
6514
|
function createArtifactDir(root, seed) {
|
|
6065
6515
|
const id = `${new Date().toISOString().replace(/[:.]/g, '-')}-${slugify(seed)}`;
|
|
6066
6516
|
const artifactDir = resolve(root, '.pugi', 'artifacts', id);
|