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

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 (45) 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/mcp/orchestrator-tools.js +595 -0
  10. package/dist/core/onboarding/ensure-initialized.js +133 -0
  11. package/dist/core/repl/session.js +370 -9
  12. package/dist/core/repl/slash-commands.js +68 -5
  13. package/dist/core/smoke/headless-driver.js +174 -0
  14. package/dist/core/smoke/orchestrator.js +194 -0
  15. package/dist/core/smoke/runner.js +238 -0
  16. package/dist/core/smoke/scenario-parser.js +316 -0
  17. package/dist/runtime/cli.js +453 -11
  18. package/dist/runtime/commands/cancel.js +231 -0
  19. package/dist/runtime/commands/codegraph-status.js +227 -0
  20. package/dist/runtime/commands/mcp.js +66 -11
  21. package/dist/runtime/commands/permissions.js +23 -0
  22. package/dist/runtime/commands/redo-blob-store.js +92 -0
  23. package/dist/runtime/commands/redo.js +361 -0
  24. package/dist/runtime/commands/status.js +11 -3
  25. package/dist/runtime/commands/undo.js +32 -0
  26. package/dist/runtime/headless-repl.js +195 -0
  27. package/dist/runtime/version.js +1 -1
  28. package/dist/tui/permissions-picker.js +78 -0
  29. package/dist/tui/render.js +35 -0
  30. package/dist/tui/status-bar.js +1 -1
  31. package/dist/tui/tool-stream-pane.js +45 -3
  32. package/package.json +7 -4
  33. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  34. package/test/scenarios/compact-force.scenario.txt +11 -0
  35. package/test/scenarios/identity.scenario.txt +11 -0
  36. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  37. package/test/scenarios/walkback.scenario.txt +12 -0
  38. package/dist/core/engine/compaction-hook.js +0 -154
  39. package/dist/core/init/scaffold.js +0 -195
  40. package/dist/core/memory/dual-write.spec.js +0 -297
  41. package/dist/core/memory-sync/queue.spec.js +0 -105
  42. package/dist/core/repl/codebase-survey.js +0 -308
  43. package/dist/core/repl/init-interview.js +0 -457
  44. package/dist/core/repl/onboarding-state.js +0 -297
  45. 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)