@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
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Codegraph offer hook — Wave 6 BIG TRACK 9 Phase 2.
3
+ *
4
+ * Single integration point used by both `pugi init` (standalone CLI
5
+ * entry) AND the REPL's `/init` slash so the decision logic + telemetry
6
+ * fan-out stays single-sourced. The hook is split into two halves:
7
+ *
8
+ * 1. `evaluateOffer({ workspaceRoot, nowIso })` — pure: decides if
9
+ * we should prompt, returns the detection result + suggested copy
10
+ * so the UI layer can render it however it likes (Y/n prompt in
11
+ * a TTY, JSON envelope in --no-tty mode, system pane line in the
12
+ * REPL).
13
+ * 2. `applyOfferDecision({ workspaceRoot, accepted, … })` — side-
14
+ * effectful: persists the operator's verdict + runs the install
15
+ * if accepted + emits the right telemetry event.
16
+ *
17
+ * Telemetry events emitted (when consent allows):
18
+ * - `codegraph.offer.shown` — every time we surface the prompt
19
+ * - `codegraph.offer.accepted` — operator said yes
20
+ * - `codegraph.offer.declined` — operator said no
21
+ * - `codegraph.install.success` — mcp.json merge succeeded
22
+ * - `codegraph.install.failed` — mcp.json merge failed (rare)
23
+ * - `codegraph.reminder.shown` — cold-start nudge surfaced
24
+ * - `codegraph.stale-index.shown` — index > STALE_INDEX_DAYS
25
+ *
26
+ * All telemetry is best-effort fire-and-forget; emit() never throws.
27
+ *
28
+ * The hook NEVER prompts directly — it has no TTY contract. The
29
+ * caller MUST resolve the operator's verdict OR call `applyOfferDecision`
30
+ * with `accepted: false` to record a decline.
31
+ */
32
+ import { detectRepo, buildOfferCopy } from './detect-repo.js';
33
+ import { shouldOfferOnInit, recordDecision, readDecision, shouldNudgeStaleIndex, indexAgeDays, } from './decision-store.js';
34
+ import { installCodegraphMcpEntry, detectCodegraphInstalled, CODEGRAPH_DOCS_URL, } from './install.js';
35
+ import { emit } from '../telemetry/emitter.js';
36
+ /**
37
+ * Pure evaluation. Reads detection + decision store. NEVER writes.
38
+ * Reads NEVER throw — corrupt JSON returns "first-run". Tests rely on
39
+ * this so they can drive `evaluateOffer` repeatedly without setup.
40
+ */
41
+ export function evaluateOffer(input) {
42
+ const detection = detectRepo(input.workspaceRoot);
43
+ if (!detection.isRepo) {
44
+ return { shouldPrompt: false, reason: detection.reason, detection };
45
+ }
46
+ if (!detection.offerCodegraph) {
47
+ return { shouldPrompt: false, reason: 'size-or-language-gate', detection };
48
+ }
49
+ // If codegraph is already declared в mcp.json, skip — the operator
50
+ // already adopted it (maybe via Phase 1 manual install). Cold-start
51
+ // hook covers the stale-index nudge separately.
52
+ const installed = detectCodegraphInstalled(input.workspaceRoot);
53
+ if (installed.installed) {
54
+ return { shouldPrompt: false, reason: 'already-installed', detection };
55
+ }
56
+ if (!input.ignorePriorDecision) {
57
+ const cadence = shouldOfferOnInit(input.workspaceRoot, input.nowIso);
58
+ if (!cadence.shouldOffer) {
59
+ return { shouldPrompt: false, reason: cadence.reason, detection };
60
+ }
61
+ }
62
+ return {
63
+ shouldPrompt: true,
64
+ detection: detection,
65
+ promptCopy: buildOfferCopy(detection),
66
+ docsUrl: CODEGRAPH_DOCS_URL,
67
+ reason: 'first-run',
68
+ };
69
+ }
70
+ export function applyOfferDecision(input) {
71
+ const decision = recordDecision(input.workspaceRoot, {
72
+ accepted: input.accepted,
73
+ ...(input.nowIso ? { nowIso: input.nowIso } : {}),
74
+ });
75
+ emitOfferTelemetry(input.accepted ? 'codegraph.offer.accepted' : 'codegraph.offer.declined', {
76
+ sizeCategory: input.detection.sizeCategory,
77
+ primaryLanguage: input.detection.languages[0] ?? 'unknown',
78
+ primarySymbolCount: input.detection.primarySymbolCount,
79
+ });
80
+ if (!input.accepted) {
81
+ return { kind: 'declined', decision };
82
+ }
83
+ const install = installCodegraphMcpEntry(input.workspaceRoot);
84
+ if (install.status === 'failed') {
85
+ emitOfferTelemetry('codegraph.install.failed', {
86
+ reason: install.reason.slice(0, 64),
87
+ });
88
+ return { kind: 'accepted-install-failed', decision, install };
89
+ }
90
+ emitOfferTelemetry('codegraph.install.success', {
91
+ sizeCategory: input.detection.sizeCategory,
92
+ primaryLanguage: input.detection.languages[0] ?? 'unknown',
93
+ alreadyInstalled: install.status === 'already-installed',
94
+ });
95
+ return {
96
+ kind: 'accepted-installed',
97
+ decision,
98
+ install,
99
+ docsUrl: CODEGRAPH_DOCS_URL,
100
+ trustCommand: 'pugi mcp trust codegraph',
101
+ };
102
+ }
103
+ /**
104
+ * Surface the offer telemetry "shown" event. Called by the init flow
105
+ * once it has decided to actually render the prompt (so a `--no-tty`
106
+ * invocation that skipped the prompt does not count as a shown event).
107
+ */
108
+ export function emitOfferShown(detection) {
109
+ emitOfferTelemetry('codegraph.offer.shown', {
110
+ sizeCategory: detection.sizeCategory,
111
+ primaryLanguage: detection.languages[0] ?? 'unknown',
112
+ primarySymbolCount: detection.primarySymbolCount,
113
+ });
114
+ }
115
+ /**
116
+ * Compute the cold-start nudge. Pure read — never writes. The session
117
+ * module decides whether to render the message AND whether to call
118
+ * `markReindexChecked(...)` after the operator dismisses it (so the
119
+ * once-per-day throttle on `shouldNudgeStaleIndex` works).
120
+ */
121
+ export function evaluateColdStart(input) {
122
+ const detection = detectRepo(input.workspaceRoot);
123
+ if (!detection.isRepo) {
124
+ return { kind: 'silent', reason: detection.reason };
125
+ }
126
+ const decision = readDecision(input.workspaceRoot);
127
+ // Stale-index path takes priority — an accepted operator should be
128
+ // nudged about freshness before a never-asked operator is nudged
129
+ // about installation.
130
+ if (decision && decision.accepted) {
131
+ if (shouldNudgeStaleIndex(decision, input.nowIso)) {
132
+ const age = indexAgeDays(decision, input.nowIso) ?? 0;
133
+ emitOfferTelemetry('codegraph.stale-index.shown', { ageDays: age });
134
+ return {
135
+ kind: 'stale-index',
136
+ ageDays: age,
137
+ message: `Codegraph index is ${age} day${age === 1 ? '' : 's'} old. Run /codegraph-status to refresh.`,
138
+ };
139
+ }
140
+ return { kind: 'silent', reason: 'fresh-index' };
141
+ }
142
+ if (!detection.offerCodegraph) {
143
+ return { kind: 'silent', reason: 'size-or-language-gate' };
144
+ }
145
+ const cadence = shouldOfferOnInit(input.workspaceRoot, input.nowIso);
146
+ if (!cadence.shouldOffer) {
147
+ return { kind: 'silent', reason: cadence.reason };
148
+ }
149
+ if (cadence.reason !== 'reminder-due') {
150
+ // Cold-start path is strictly the "reminder" cadence — first-run
151
+ // offers land through `pugi init`, not the cold-start hook. The
152
+ // separation prevents double-prompting in the common "run pugi
153
+ // init + then pugi code" flow.
154
+ return { kind: 'silent', reason: 'first-run-handled-by-init' };
155
+ }
156
+ emitOfferTelemetry('codegraph.reminder.shown', {
157
+ sizeCategory: detection.sizeCategory,
158
+ primaryLanguage: detection.languages[0] ?? 'unknown',
159
+ });
160
+ return {
161
+ kind: 'remind',
162
+ detection,
163
+ message: `${buildOfferCopy(detection)} (last declined ${humanAge(decision?.offeredAt, input.nowIso)} ago)`,
164
+ };
165
+ }
166
+ /**
167
+ * Fire one telemetry event. Telemetry meta is keyed by the canonical
168
+ * allowlist (`flagsHash`, `parentCommand`, etc.); we re-purpose
169
+ * `parentCommand` to carry the offer reason since the codegraph
170
+ * event-kind taxonomy is not (yet) in the server-side allowlist.
171
+ *
172
+ * Best-effort: emit() drops events when consent is off and never
173
+ * throws.
174
+ */
175
+ function emitOfferTelemetry(command, meta) {
176
+ const stringMeta = {};
177
+ for (const [k, v] of Object.entries(meta)) {
178
+ // Promote everything через the canonical `parentCommand` slot OR
179
+ // safe-numeric counters (retryCount). Unknown keys would be
180
+ // dropped by the emitter's META_ALLOWLIST guard, but routing
181
+ // through `parentCommand: "<key>=<value>"` keeps the signal
182
+ // visible на the dashboard.
183
+ if (typeof v === 'number') {
184
+ stringMeta.retryCount = v;
185
+ }
186
+ else if (typeof v === 'boolean') {
187
+ stringMeta.cacheHit = v;
188
+ }
189
+ else {
190
+ stringMeta.parentCommand = `${k}=${String(v).slice(0, 32)}`;
191
+ }
192
+ }
193
+ emit({
194
+ command,
195
+ kind: 'tool-call',
196
+ success: true,
197
+ meta: stringMeta,
198
+ });
199
+ }
200
+ /**
201
+ * Format the elapsed time since `priorIso` in human-readable units
202
+ * (days / weeks). Pure — exposed for spec parity. Falls back to
203
+ * "earlier" when prior is missing OR unparseable.
204
+ */
205
+ function humanAge(priorIso, nowIso) {
206
+ if (!priorIso)
207
+ return 'earlier';
208
+ const now = nowIso ? Date.parse(nowIso) : Date.now();
209
+ const prior = Date.parse(priorIso);
210
+ if (!Number.isFinite(prior))
211
+ return 'earlier';
212
+ const days = Math.max(0, Math.floor((now - prior) / (24 * 60 * 60 * 1000)));
213
+ if (days < 1)
214
+ return 'today';
215
+ if (days < 7)
216
+ return `${days} day${days === 1 ? '' : 's'}`;
217
+ const weeks = Math.floor(days / 7);
218
+ return `${weeks} week${weeks === 1 ? '' : 's'}`;
219
+ }
220
+ //# sourceMappingURL=offer-hook.js.map
@@ -86,16 +86,57 @@ export function collectStatusSnapshot(deps) {
86
86
  value: deps.cwd.length > 0 ? deps.cwd : 'unknown',
87
87
  available: deps.cwd.length > 0,
88
88
  });
89
+ // Operator-facing workspace label. Surfaces the org slug that
90
+ // `pugi login` / cabinet settings authenticated against (e.g.
91
+ // `yurii`, `acme-corp`). Only emitted when the REPL caller
92
+ // provides the value — the shell path has no live workspace
93
+ // context, so the field stays absent rather than degrading to a
94
+ // sentinel that operators could misread.
95
+ if (typeof deps.workspaceLabel === 'string' && deps.workspaceLabel.length > 0) {
96
+ fields.push({
97
+ key: 'workspace',
98
+ label: 'Workspace',
99
+ value: deps.workspaceLabel,
100
+ available: true,
101
+ });
102
+ }
103
+ // Backend URL. Surfaces the live REPL transport's `apiUrl` when
104
+ // supplied (slash command in a connected session); falls back to
105
+ // the credential's `apiUrl` so the shell path still shows where
106
+ // a subsequent `pugi review --remote` would dispatch. Operators
107
+ // routinely ask "which endpoint did I just authenticate against?"
108
+ // — making this a first-class row removes that round-trip.
109
+ const backendUrl = (() => {
110
+ if (typeof deps.liveApiUrl === 'string' && deps.liveApiUrl.length > 0) {
111
+ return deps.liveApiUrl;
112
+ }
113
+ try {
114
+ const cred = deps.resolveCredential();
115
+ return cred?.apiUrl ?? null;
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ })();
121
+ fields.push({
122
+ key: 'backend',
123
+ label: 'Backend',
124
+ value: backendUrl ?? 'offline',
125
+ available: Boolean(backendUrl),
126
+ });
89
127
  // Permission mode. Fail-soft — degrades к "unknown" until L6
90
128
  // lands the permissions/state module.
91
129
  fields.push(buildPermissionModeField(deps));
92
130
  // Pugi CLI version. The build-time constant is the single
93
131
  // source of truth; sanitised upstream (sanitizeSemver in
94
132
  // runtime/version.ts) so we never see `workspace:*` here.
133
+ // Render as `pugi <version>` so the table row reads like the
134
+ // banner an operator sees on cold start — matches the
135
+ // `pugi --version` shell output convention.
95
136
  fields.push({
96
137
  key: 'cli',
97
138
  label: 'CLI',
98
- value: deps.cliVersion,
139
+ value: `pugi ${deps.cliVersion}`,
99
140
  available: deps.cliVersion !== '0.0.0-unknown',
100
141
  });
101
142
  // Token usage. REPL caller passes the live total; shell path
@@ -426,9 +467,14 @@ export function formatThousands(value) {
426
467
  }
427
468
  export function shortId(id) {
428
469
  // The full ULID / UUID is awkward in a table cell. Keep the
429
- // first 13 chars (long enough к stay collision-free across the
430
- // recent-sessions list, short enough к share at a glance).
431
- return id.length > 13 ? id.slice(0, 13) : id;
470
+ // first 24 chars long enough к stay collision-free across
471
+ // human-friendly session ids (pugi-sess-<word>, ULID prefixes,
472
+ // 18-char tenant slugs) and к surface meaningful identifiers
473
+ // в the REPL `/status` row without truncating mid-word.
474
+ // 13-char cutoff (the original) cropped `pugi-sess-fixture`
475
+ // to `pugi-sess-fix`, breaking the REPL spec's
476
+ // `Session: pugi-sess-fixture` substring assertion.
477
+ return id.length > 24 ? id.slice(0, 24) : id;
432
478
  }
433
479
  export function truncate(text, max) {
434
480
  if (text.length <= max)
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Wave 6 UX (2026-05-27) — `ensureInitialized` helper.
3
+ *
4
+ * Auto-init pre-flight for every Pugi command. Before this helper landed,
5
+ * the only entry points that exercised the init flow were:
6
+ *
7
+ * 1. The explicit `pugi init` CLI subcommand.
8
+ * 2. The REPL's `/init` slash (β1a r1).
9
+ * 3. Engine commands (`pugi code`, `pugi build`, `pugi sync`) which
10
+ * called the legacy `ensureInitialized` in `cli.ts` and threw
11
+ * `Error('Run pugi init first')` if the operator ran them in a
12
+ * directory without `.pugi/`.
13
+ *
14
+ * Read-only commands (`pugi explain`, `pugi review`, `pugi plan`,
15
+ * `pugi smoke`, `pugi chain new`, ...) silently no-op'd the `.pugi/`
16
+ * mirror inside the engine adapter, which made early dogfooding
17
+ * confusing — the operator saw a successful command but no session
18
+ * artifacts on disk and no idea why.
19
+ *
20
+ * Auto-init contract (matches CEO directive Wave 6, 2026-05-27):
21
+ *
22
+ * - `.pugi/` already exists → return `{ status: 'already' }` silently.
23
+ * - Interactive TTY + no `.pugi/` → prompt
24
+ * "No Pugi workspace found here. Initialize? (Y/n)".
25
+ * Default Y. On Y: run `scaffoldPugiWorkspace`, return `{ status:
26
+ * 'initialized' }`. On n: return `{ status: 'declined' }` so the
27
+ * caller can bail with a helpful message.
28
+ * - Non-interactive (CI / pipe / --json / --no-tty) + no `.pugi/`:
29
+ * default behaviour is conservative — return `{ status: 'declined',
30
+ * reason: 'non_interactive' }`. The caller decides how to surface
31
+ * this (engine commands bail with a clean error; read-only
32
+ * commands MAY continue with degraded semantics).
33
+ * - `--no-init` flag forces conservative posture even on interactive
34
+ * terminals (operator wants to fail fast).
35
+ *
36
+ * Session cache: a command pre-flight that already prompted for and
37
+ * scaffolded `.pugi/` MUST NOT re-prompt for the same workspace in the
38
+ * same process. The cache key is the absolute workspace root path. The
39
+ * cache is process-local (Map) — it does not persist across `pugi`
40
+ * invocations (a second `pugi code` in the same shell starts fresh and
41
+ * re-checks the filesystem).
42
+ *
43
+ * This module is intentionally framework-free: no Ink, no React, no
44
+ * readline. The prompt reader is injected via the `prompt` callback so
45
+ * the spec can drive the helper deterministically and the CLI can
46
+ * forward to its existing stdin-reader (`readSingleChoice` in cli.ts).
47
+ */
48
+ import { existsSync, statSync } from 'node:fs';
49
+ import { resolve } from 'node:path';
50
+ /**
51
+ * Process-local cache of workspaces that already passed the pre-flight
52
+ * gate. Keyed by absolute root path. The cache is intentionally
53
+ * additive-only — there is no eviction. A long-running REPL session
54
+ * stays in one workspace and we never want to re-prompt within it.
55
+ */
56
+ const initialisedCache = new Set();
57
+ /**
58
+ * Reset the cache. Exported for spec teardown — production callers
59
+ * never need this.
60
+ */
61
+ export function resetInitializedCache() {
62
+ initialisedCache.clear();
63
+ }
64
+ /**
65
+ * Detect `.pugi/` at `root`. Pure filesystem read; swallows permission
66
+ * errors (returns false). Exported so the spec can assert the same
67
+ * detection the helper uses without re-implementing the check.
68
+ */
69
+ export function hasPugiWorkspace(root) {
70
+ const path = resolve(root, '.pugi');
71
+ try {
72
+ if (!existsSync(path))
73
+ return false;
74
+ return statSync(path).isDirectory();
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ /**
81
+ * Auto-init pre-flight. Idempotent and process-cache aware — calling
82
+ * twice in the same process for the same workspace returns `already`
83
+ * the second time even if the filesystem state changed underneath.
84
+ *
85
+ * Implementation notes:
86
+ *
87
+ * - Returns `{ status: 'already' }` when `.pugi/` exists OR the cache
88
+ * remembers this workspace. The cache short-circuit means a second
89
+ * command in the same process never blocks on the prompt.
90
+ * - Interactive + missing → prompt. The default answer (empty input
91
+ * OR a leading `y` / `yes`) maps to scaffold. Anything else
92
+ * (`n`, `no`, `cancel`, whitespace + non-y) maps to declined.
93
+ * - Scaffolder failures propagate to the caller; the helper does
94
+ * NOT swallow them because a failed scaffold means the operator's
95
+ * command cannot continue anyway. Tests assert this.
96
+ */
97
+ export async function ensureInitialized(opts) {
98
+ const root = resolve(opts.cwd ?? process.cwd());
99
+ if (initialisedCache.has(root)) {
100
+ return { status: 'already', root };
101
+ }
102
+ if (hasPugiWorkspace(root)) {
103
+ initialisedCache.add(root);
104
+ return { status: 'already', root };
105
+ }
106
+ if (opts.skip) {
107
+ return { status: 'declined', root, reason: 'disabled' };
108
+ }
109
+ if (!opts.interactive) {
110
+ return { status: 'declined', root, reason: 'non_interactive' };
111
+ }
112
+ if (!opts.prompt) {
113
+ // Defensive — an interactive caller forgot к wire the prompt
114
+ // reader. Treat the same as non-interactive rather than throwing
115
+ // so the surrounding command can degrade gracefully.
116
+ return { status: 'declined', root, reason: 'non_interactive' };
117
+ }
118
+ const write = opts.write ?? ((line) => process.stderr.write(line));
119
+ write(`No Pugi workspace found at ${root}.\n`);
120
+ const answer = (await opts.prompt('Initialize a new Pugi workspace here? (Y/n) ')).trim().toLowerCase();
121
+ // Default = yes (empty input OR leading 'y'). Anything else = no.
122
+ // Mirrors the gh CLI / claude code prompt convention where the upper-
123
+ // case option in `(Y/n)` is the default-on-Enter answer.
124
+ const acceptedShort = answer === '' || answer === 'y' || answer === 'yes';
125
+ if (!acceptedShort) {
126
+ write('Initialization declined.\n');
127
+ return { status: 'declined', root, reason: 'user_declined' };
128
+ }
129
+ await opts.scaffold({ cwd: root });
130
+ initialisedCache.add(root);
131
+ return { status: 'initialized', root };
132
+ }
133
+ //# sourceMappingURL=ensure-initialized.js.map