@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,231 @@
1
+ /**
2
+ * `pugi /cancel` slash command — Wave 6 small-CC-parity batch (2026-05-27).
3
+ *
4
+ * Bridges the gap between `/jobs` (read-only list of in-flight agents)
5
+ * and `/stop <persona>` (kill-by-persona via admin-api). Cancel is
6
+ * dispatch-id-keyed — operators read `.pugi/agent-progress/*.json` for
7
+ * the active list, then run `/cancel <id>` (or `/cancel all`) to halt.
8
+ *
9
+ * Behaviour matrix:
10
+ *
11
+ * /cancel → table of active dispatches (id · persona
12
+ * · started-at · elapsed · status)
13
+ * /cancel <id> → mark the progress JSON as failed (closest
14
+ * valid schema status to "cancelled" — the
15
+ * schema's closed status set is running/
16
+ * completed/failed) + best-effort POST to
17
+ * the future `/api/pugi/dispatches/:id/cancel`
18
+ * endpoint. If the endpoint 404s we still
19
+ * update the local JSON so /jobs reflects
20
+ * the operator's intent.
21
+ * /cancel all → loop over every running entry
22
+ *
23
+ * # Module contract
24
+ *
25
+ * - Single entry point: `runCancelCommand(args, io, opts)`. The slash
26
+ * dispatcher in `core/repl/session.ts` calls this with the parsed
27
+ * verdict; a future `pugi cancel` shell surface would call it with
28
+ * the same shape. Exit code semantics mirror `/feedback` — cancel
29
+ * never returns non-zero from inside the REPL because the slash
30
+ * surface ignores it; the shell wrapper can branch on the returned
31
+ * result variant.
32
+ *
33
+ * - No real network I/O is required for the unit test path. The
34
+ * `fetcher` seam accepts a stub that resolves to `{ ok: false,
35
+ * status: 404 }` so the spec exercises the local-fallback branch
36
+ * deterministically.
37
+ *
38
+ * - Filesystem reads are best-effort. A missing progress dir / a
39
+ * malformed JSON file simply degrades to "no active dispatches"
40
+ * rather than throwing — the cancel command is a brand surface,
41
+ * never a gate.
42
+ *
43
+ * - Wave 6 followup: a dedicated `/api/pugi/dispatches/:id/cancel`
44
+ * endpoint on admin-api ships in a sibling PR. Until then, the
45
+ * local progress JSON write is the source of truth so `/jobs`
46
+ * and the live `pugi jobs --watch` TUI both reflect the operator
47
+ * verdict instantly.
48
+ */
49
+ import { existsSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
50
+ import { join } from 'node:path';
51
+ import { resolveProgressDir, } from '../../core/agent-progress/writer.js';
52
+ import { validateAgentProgress, } from '../../core/agent-progress/schema.js';
53
+ /**
54
+ * Read every progress file under `dir` and return the running ones.
55
+ * Malformed entries are silently skipped — operator sees a smaller list
56
+ * rather than a crash banner.
57
+ */
58
+ export function readActiveDispatches(dir) {
59
+ if (!existsSync(dir))
60
+ return [];
61
+ const out = [];
62
+ const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
63
+ for (const file of files) {
64
+ try {
65
+ const body = readFileSync(join(dir, file), 'utf8');
66
+ const parsed = JSON.parse(body);
67
+ const verdict = validateAgentProgress(parsed);
68
+ if (verdict.ok && verdict.value.status === 'running') {
69
+ out.push(verdict.value);
70
+ }
71
+ }
72
+ catch {
73
+ // Skip malformed files; the watcher / writer is the source of
74
+ // truth, not the snapshot — a partial read on a half-written
75
+ // tmp file would otherwise crash the cancel surface.
76
+ }
77
+ }
78
+ // Newest-first so the operator's eye lands on the freshest agent.
79
+ return out.sort((a, b) => Date.parse(b.lastUpdate) - Date.parse(a.lastUpdate));
80
+ }
81
+ /**
82
+ * Atomic update: rewrite the progress JSON as `status='failed'`
83
+ * (closest valid status to "cancelled" in the closed schema set) with
84
+ * a marker step description so `/jobs` and the live TUI both display
85
+ * the cancellation. Returns the path on success; bubbles I/O errors.
86
+ *
87
+ * The write uses the same `<file>.tmp-<pid>-<seq>` → rename pattern as
88
+ * the writer module so a chokidar watcher never reads a half-written
89
+ * document. We do NOT import the writer's `writeProgress` because that
90
+ * helper validates and re-clamps every field, which would also reset
91
+ * fields the operator may want to inspect post-cancel (elapsedMs,
92
+ * percentComplete). Cancel preserves the snapshot and only swaps the
93
+ * status + stepDescription + lastUpdate.
94
+ */
95
+ let writeSequence = 0;
96
+ export function markCancelled(dir, agentId, nowIso) {
97
+ const path = join(dir, `${agentId}.json`);
98
+ const body = readFileSync(path, 'utf8');
99
+ const parsed = JSON.parse(body);
100
+ parsed.status = 'failed';
101
+ parsed.stepDescription = '(cancelled by /cancel)';
102
+ parsed.lastUpdate = nowIso;
103
+ writeSequence += 1;
104
+ const tmp = `${path}.tmp-${process.pid}-${writeSequence}`;
105
+ writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8');
106
+ renameSync(tmp, path);
107
+ return path;
108
+ }
109
+ /**
110
+ * Render a single-line cancel snapshot row. Pure helper so the spec
111
+ * can pin the exact shape without standing up an Ink renderer.
112
+ */
113
+ export function renderCancelRow(entry, nowEpochMs) {
114
+ const id = entry.agentId.length > 24
115
+ ? `${entry.agentId.slice(0, 23)}…`
116
+ : entry.agentId;
117
+ const persona = entry.agentType.length > 18
118
+ ? `${entry.agentType.slice(0, 17)}…`
119
+ : entry.agentType;
120
+ const startedMs = Date.parse(entry.startedAt);
121
+ const elapsedSec = Number.isFinite(startedMs)
122
+ ? Math.max(0, Math.floor((nowEpochMs - startedMs) / 1000))
123
+ : Math.max(0, Math.floor(entry.elapsedMs / 1000));
124
+ const elapsed = elapsedSec >= 60
125
+ ? `${Math.floor(elapsedSec / 60)}m${String(elapsedSec % 60).padStart(2, '0')}s`
126
+ : `${elapsedSec}s`;
127
+ return ` ${id.padEnd(24, ' ')} ${persona.padEnd(18, ' ')} ${elapsed.padStart(6, ' ')} ${entry.status}`;
128
+ }
129
+ /**
130
+ * Main entry point. The slash dispatcher passes a tokenised
131
+ * `CancelMode`; the function returns a structured outcome so the
132
+ * caller can keep / drop the verdict line without re-parsing the I/O
133
+ * sink.
134
+ */
135
+ export async function runCancelCommand(mode, io, opts = {}) {
136
+ const dir = opts.dir ?? resolveProgressDir();
137
+ const now = opts.now ?? Date.now;
138
+ const nowIso = new Date(now()).toISOString();
139
+ let active;
140
+ try {
141
+ active = readActiveDispatches(dir);
142
+ }
143
+ catch (err) {
144
+ const message = err instanceof Error ? err.message : String(err);
145
+ io.write(`/cancel failed reading progress dir: ${message}`);
146
+ return { kind: 'error', message };
147
+ }
148
+ if (mode.kind === 'list') {
149
+ if (active.length === 0) {
150
+ io.write('No active dispatches. Brief one with /brief <text>.');
151
+ return { kind: 'listed', count: 0 };
152
+ }
153
+ io.write(`Active dispatches (${active.length}):`);
154
+ io.write(' dispatch-id persona elapsed status');
155
+ for (const entry of active) {
156
+ io.write(renderCancelRow(entry, now()));
157
+ }
158
+ io.write('Tip: /cancel <dispatch-id> to halt one, /cancel all к halt every running.');
159
+ return { kind: 'listed', count: active.length };
160
+ }
161
+ if (mode.kind === 'all') {
162
+ if (active.length === 0) {
163
+ io.write('Nothing к cancel — no active dispatches.');
164
+ return { kind: 'empty' };
165
+ }
166
+ const cancelled = [];
167
+ let endpointHit = false;
168
+ for (const entry of active) {
169
+ try {
170
+ if (opts.fetcher) {
171
+ const verdict = await opts.fetcher(entry.agentId);
172
+ if (verdict.ok)
173
+ endpointHit = true;
174
+ }
175
+ markCancelled(dir, entry.agentId, nowIso);
176
+ cancelled.push(entry.agentId);
177
+ io.write(`Cancelled ${entry.agentId}`);
178
+ }
179
+ catch (err) {
180
+ const message = err instanceof Error ? err.message : String(err);
181
+ io.write(` could not cancel ${entry.agentId}: ${message}`);
182
+ }
183
+ }
184
+ io.write(`Cancelled ${cancelled.length} of ${active.length} dispatch(es).`);
185
+ return { kind: 'cancelled', count: cancelled.length, ids: cancelled, endpointHit };
186
+ }
187
+ // kind === 'one'
188
+ const target = active.find((a) => a.agentId === mode.dispatchId);
189
+ if (!target) {
190
+ // Try prefix match — operators often type the first 8 chars.
191
+ const prefix = mode.dispatchId.toLowerCase();
192
+ const prefixMatch = active.find((a) => a.agentId.toLowerCase().startsWith(prefix));
193
+ if (!prefixMatch) {
194
+ io.write(`No active dispatch matching '${mode.dispatchId}'. Run /cancel to see the list.`);
195
+ return { kind: 'not-found', query: mode.dispatchId };
196
+ }
197
+ return cancelOne(prefixMatch, dir, nowIso, io, opts);
198
+ }
199
+ return cancelOne(target, dir, nowIso, io, opts);
200
+ }
201
+ async function cancelOne(entry, dir, nowIso, io, opts) {
202
+ let endpointHit = false;
203
+ if (opts.fetcher) {
204
+ try {
205
+ const verdict = await opts.fetcher(entry.agentId);
206
+ endpointHit = verdict.ok;
207
+ }
208
+ catch {
209
+ // network fail → fall through to local-only cancel
210
+ }
211
+ }
212
+ try {
213
+ markCancelled(dir, entry.agentId, nowIso);
214
+ io.write(`Cancelled dispatch ${entry.agentId}.`);
215
+ if (!endpointHit && opts.fetcher) {
216
+ io.write(' (backend cancel endpoint not reachable; progress JSON marked locally)');
217
+ }
218
+ return {
219
+ kind: 'cancelled',
220
+ count: 1,
221
+ ids: [entry.agentId],
222
+ endpointHit,
223
+ };
224
+ }
225
+ catch (err) {
226
+ const message = err instanceof Error ? err.message : String(err);
227
+ io.write(`/cancel ${entry.agentId} failed: ${message}`);
228
+ return { kind: 'error', message };
229
+ }
230
+ }
231
+ //# sourceMappingURL=cancel.js.map
@@ -0,0 +1,227 @@
1
+ /**
2
+ * `/codegraph-status` runner — Wave 6 BIG TRACK 9 Phase 2 (2026-05-27).
3
+ *
4
+ * Single source of truth for the codegraph adoption + index-freshness
5
+ * surface. Used by:
6
+ *
7
+ * - REPL slash `/codegraph-status` (the discoverable surface)
8
+ * - REPL slash `/codegraph` (short alias)
9
+ *
10
+ * The runner is intentionally narrow — every information surface
11
+ * (installed? / index age / symbol count / last reindex / refresh CTA)
12
+ * is computed from the workspace-local `.pugi/codegraph-decision.json`
13
+ * + the live `.pugi/mcp.json` + the bounded scanner at
14
+ * `core/codegraph/detect-repo.ts`. NO network round-trip; everything
15
+ * runs offline so an air-gapped operator still gets the status table.
16
+ *
17
+ * Flags:
18
+ * --install — merge codegraph into .pugi/mcp.json (accept decision)
19
+ * --reindex — stamp lastIndexedAt now + hint operator к run
20
+ * `codegraph index` from a fresh shell. Pugi does NOT
21
+ * spawn codegraph itself (no upstream dep guarantee).
22
+ * --offer — surface the install prompt even after a decline,
23
+ * useful when the operator declined three weeks ago and
24
+ * wants to revisit без waiting for the 30-day cadence.
25
+ *
26
+ * Brand voice gate: ASCII output, no emoji, no decorative dividers.
27
+ */
28
+ import { resolve } from 'node:path';
29
+ import { detectRepo } from '../../core/codegraph/detect-repo.js';
30
+ import { detectCodegraphInstalled, CODEGRAPH_DOCS_URL, } from '../../core/codegraph/install.js';
31
+ import { readDecision, markIndexed, indexAgeDays, STALE_INDEX_DAYS, } from '../../core/codegraph/decision-store.js';
32
+ import { evaluateOffer, applyOfferDecision, emitOfferShown, } from '../../core/codegraph/offer-hook.js';
33
+ export function parseCodegraphStatusArgs(args) {
34
+ const out = {
35
+ install: false,
36
+ reindex: false,
37
+ offer: false,
38
+ unknown: null,
39
+ };
40
+ for (const arg of args) {
41
+ switch (arg) {
42
+ case '--install':
43
+ case '-i':
44
+ out.install = true;
45
+ break;
46
+ case '--reindex':
47
+ case '-r':
48
+ out.reindex = true;
49
+ break;
50
+ case '--offer':
51
+ case '-o':
52
+ out.offer = true;
53
+ break;
54
+ default:
55
+ out.unknown = arg;
56
+ return out;
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+ /**
62
+ * Render the status table. Pure copy assembly — separates rendering
63
+ * from side effects so spec callers can pin the exact layout.
64
+ */
65
+ export function renderStatusLines(input) {
66
+ const lines = ['Codegraph status:', ''];
67
+ lines.push(` Installed: ${input.installed ? 'yes' : 'no'}`);
68
+ if (input.installed) {
69
+ lines.push(` Trust state: ${input.trust ?? 'pending'}`);
70
+ lines.push(` Config path: ${input.configPath}`);
71
+ }
72
+ if (input.primarySymbolCount !== null) {
73
+ lines.push(` Symbol count: ~${input.primarySymbolCount} source files`);
74
+ }
75
+ if (input.languages && input.languages.length > 0) {
76
+ lines.push(` Languages: ${input.languages.join(', ')}`);
77
+ }
78
+ if (input.sizeCategory) {
79
+ lines.push(` Size category: ${input.sizeCategory}`);
80
+ }
81
+ if (input.installed) {
82
+ if (input.lastIndexedAt) {
83
+ lines.push(` Last indexed: ${input.lastIndexedAt}${input.ageDays !== null ? ` (${input.ageDays} day${input.ageDays === 1 ? '' : 's'} ago)` : ''}`);
84
+ }
85
+ else {
86
+ lines.push(' Last indexed: never recorded — run `codegraph index` and `/codegraph-status --reindex`');
87
+ }
88
+ if (input.staleNudge) {
89
+ lines.push('');
90
+ lines.push(` Index is ${input.ageDays} day${input.ageDays === 1 ? '' : 's'} old (threshold ${STALE_INDEX_DAYS}). ` +
91
+ 'Run `codegraph index` then `/codegraph-status --reindex` to refresh.');
92
+ }
93
+ }
94
+ else {
95
+ lines.push('');
96
+ lines.push(` Docs: ${CODEGRAPH_DOCS_URL}`);
97
+ lines.push(' Install via `/codegraph-status --install` OR `pugi mcp install codegraph codegraph serve --mcp`.');
98
+ }
99
+ return lines;
100
+ }
101
+ /**
102
+ * Single entry-point used by the slash dispatch + (future) standalone
103
+ * `pugi codegraph status` shell command. Pure orchestration — every
104
+ * side effect lives behind one of the flag branches.
105
+ */
106
+ export async function runCodegraphStatusCommand(args, ctx) {
107
+ const flags = parseCodegraphStatusArgs(args);
108
+ if (flags.unknown) {
109
+ ctx.writeOutput({ command: 'codegraph-status', error: 'unknown-flag', flag: flags.unknown }, `/codegraph-status: unknown flag "${flags.unknown}". Allowed: --install, --reindex, --offer.`);
110
+ return;
111
+ }
112
+ const workspaceRoot = resolve(ctx.workspaceRoot);
113
+ // --reindex — stamp lastIndexedAt + remind the operator how to actually
114
+ // refresh the index. We do NOT spawn `codegraph index` — that is the
115
+ // operator's call (upstream codegraph may or may not be installed; we
116
+ // refuse to silently spawn a binary we did not vet).
117
+ if (flags.reindex) {
118
+ const decision = markIndexed(workspaceRoot);
119
+ if (!decision) {
120
+ ctx.writeOutput({ command: 'codegraph-status', error: 'no-decision' }, '/codegraph-status --reindex: no decision recorded yet. Accept the install first via `--install`.');
121
+ return;
122
+ }
123
+ ctx.writeOutput({ command: 'codegraph-status', reindexed: true, lastIndexedAt: decision.lastIndexedAt }, [
124
+ `Codegraph index timestamp updated to ${decision.lastIndexedAt}.`,
125
+ 'Run `codegraph index` from a fresh shell to actually rebuild the SQLite cache.',
126
+ ].join('\n'));
127
+ return;
128
+ }
129
+ // --install / --offer — surface the install prompt OR accept it
130
+ // directly. `--install` skips the prompt + writes the entry; `--offer`
131
+ // renders the prompt copy even after a decline (operator-initiated
132
+ // revisit). The two are mutually exclusive at runtime — `--install`
133
+ // wins when both are set.
134
+ if (flags.install || flags.offer) {
135
+ const evaluation = evaluateOffer({
136
+ workspaceRoot,
137
+ ...(ctx.nowIso ? { nowIso: ctx.nowIso } : {}),
138
+ ignorePriorDecision: flags.offer,
139
+ });
140
+ if (!evaluation.shouldPrompt) {
141
+ const detection = detectRepo(workspaceRoot);
142
+ // Already-installed is the success path for `--install`; treat
143
+ // it as PASS rather than ERROR so a re-run is idempotent.
144
+ if (evaluation.reason === 'already-installed') {
145
+ ctx.writeOutput({ command: 'codegraph-status', alreadyInstalled: true }, 'Codegraph is already declared in .pugi/mcp.json. Run `pugi mcp trust codegraph` if not yet trusted.');
146
+ return;
147
+ }
148
+ ctx.writeOutput({ command: 'codegraph-status', skipped: true, reason: evaluation.reason }, `/codegraph-status --${flags.install ? 'install' : 'offer'}: nothing to do (${evaluation.reason}).` +
149
+ (detection.isRepo
150
+ ? ` Repo: ${detection.sizeCategory}, ${detection.primarySymbolCount} src files.`
151
+ : ''));
152
+ return;
153
+ }
154
+ if (flags.install) {
155
+ const result = applyOfferDecision({
156
+ workspaceRoot,
157
+ accepted: true,
158
+ detection: evaluation.detection,
159
+ ...(ctx.nowIso ? { nowIso: ctx.nowIso } : {}),
160
+ });
161
+ if (result.kind === 'declined') {
162
+ // Defensive — applyOfferDecision with accepted=true cannot
163
+ // surface a `declined` verdict by construction. We still
164
+ // narrow the union so TS understands the safe path below.
165
+ ctx.writeOutput({ command: 'codegraph-status', installFailed: true, reason: 'unexpected-decline' }, '/codegraph-status --install: install path returned a decline verdict (internal bug).');
166
+ return;
167
+ }
168
+ if (result.kind === 'accepted-install-failed') {
169
+ ctx.writeOutput({ command: 'codegraph-status', installFailed: true, reason: result.install.reason }, `/codegraph-status --install failed: ${result.install.reason}`);
170
+ return;
171
+ }
172
+ ctx.writeOutput({
173
+ command: 'codegraph-status',
174
+ installed: true,
175
+ configPath: result.install.status === 'installed' ? result.install.configPath : null,
176
+ docsUrl: result.docsUrl,
177
+ trustCommand: result.trustCommand,
178
+ }, [
179
+ result.install.status === 'installed'
180
+ ? `Codegraph added к .pugi/mcp.json. Trust gate: ${result.trustCommand}`
181
+ : 'Codegraph already declared. Trust gate: ' + result.trustCommand,
182
+ `Docs: ${result.docsUrl}`,
183
+ ].join('\n'));
184
+ return;
185
+ }
186
+ // --offer path — surface the copy but do not install yet
187
+ emitOfferShown(evaluation.detection);
188
+ ctx.writeOutput({
189
+ command: 'codegraph-status',
190
+ offered: true,
191
+ copy: evaluation.promptCopy,
192
+ docsUrl: evaluation.docsUrl,
193
+ }, [
194
+ evaluation.promptCopy,
195
+ `Docs: ${evaluation.docsUrl}`,
196
+ 'Accept: /codegraph-status --install',
197
+ 'Decline: do nothing (the prompt re-appears in 30 days).',
198
+ ].join('\n'));
199
+ return;
200
+ }
201
+ // Bare invocation — render the status table.
202
+ const installed = detectCodegraphInstalled(workspaceRoot);
203
+ const detection = detectRepo(workspaceRoot);
204
+ const decision = readDecision(workspaceRoot);
205
+ const ageDays = decision ? indexAgeDays(decision, ctx.nowIso) : null;
206
+ const lines = renderStatusLines({
207
+ installed: installed.installed,
208
+ trust: installed.trust,
209
+ configPath: installed.configPath,
210
+ ageDays,
211
+ primarySymbolCount: detection.isRepo ? detection.primarySymbolCount : null,
212
+ languages: detection.isRepo ? detection.languages : null,
213
+ sizeCategory: detection.isRepo ? detection.sizeCategory : null,
214
+ lastIndexedAt: decision?.lastIndexedAt ?? null,
215
+ staleNudge: ageDays !== null && ageDays >= STALE_INDEX_DAYS,
216
+ });
217
+ ctx.writeOutput({
218
+ command: 'codegraph-status',
219
+ installed: installed.installed,
220
+ trust: installed.trust,
221
+ configPath: installed.configPath,
222
+ detection,
223
+ decision,
224
+ ageDays,
225
+ }, lines.join('\n'));
226
+ }
227
+ //# sourceMappingURL=codegraph-status.js.map
@@ -84,4 +84,27 @@ function renderModeTable(ctx) {
84
84
  export function renderBypassBanner(writeOutput) {
85
85
  writeOutput('BYPASS MODE — all tools execute without prompts AND policy hooks disabled. Switch back with /permissions allow.');
86
86
  }
87
+ /**
88
+ * Resolve the effective mode + the layered source label used by the
89
+ * Ink picker. Shares the merge order with `renderCurrentMode` above so
90
+ * the picker title and the bare `/permissions` print stay in lock-step.
91
+ *
92
+ * Exposed publicly because the host (session.ts) needs the same merge
93
+ * shape to seed the Ink picker BEFORE it mounts.
94
+ */
95
+ export function resolveLayeredMode(workspaceRoot, homeDir) {
96
+ const workspace = getCurrentMode(workspaceRoot);
97
+ const global = getGlobalDefaultMode(homeDir);
98
+ const effective = workspace ?? global ?? DEFAULT_PERMISSION_MODE;
99
+ const source = workspace
100
+ ? 'workspace session.json'
101
+ : global
102
+ ? 'global ~/.pugi/config.json'
103
+ : 'default (no override)';
104
+ return {
105
+ effective,
106
+ source,
107
+ firstRun: !workspace && !global,
108
+ };
109
+ }
87
110
  //# sourceMappingURL=permissions.js.map
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Redo blob store — Wave 6 (2026-05-27).
3
+ *
4
+ * `/undo` walks the most recent successful mutating tool result and
5
+ * reverts each file on disk. `/redo` reapplies that change. To reapply,
6
+ * we need the post-mutation content — but the event log records only
7
+ * sha256 hashes, not content (see core/file-cache.ts comment).
8
+ *
9
+ * Solution: a content-addressable sidecar store at
10
+ * `<workspaceRoot>/.pugi/undo-blobs/<sha256>`. The undo runner captures
11
+ * each file's CURRENT content (which equals the original AFTER state)
12
+ * into the store BEFORE reverting on disk. The redo runner reads the
13
+ * blob keyed by `beforeHash` of the inverse mutation (which records
14
+ * the pre-revert hash = the original AFTER hash) and writes it back.
15
+ *
16
+ * The store is deliberately untracked (`.pugi/` already lives in
17
+ * .gitignore) and self-cleaning — after a successful redo we delete
18
+ * the blob so a second redo without a fresh undo is a noop. Stale
19
+ * blobs left behind by an interrupted undo are reaped after 7 days
20
+ * by the existing `.pugi/cleanup` cadence (best-effort; not a
21
+ * correctness requirement).
22
+ */
23
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
24
+ import { resolve } from 'node:path';
25
+ import { hashContent } from '../../core/file-cache.js';
26
+ /** Sha256-keyed blob path under `<root>/.pugi/undo-blobs/`. */
27
+ export function blobPathFor(root, sha) {
28
+ return resolve(root, '.pugi/undo-blobs', sha);
29
+ }
30
+ /** Ensure the blob directory exists. Idempotent. */
31
+ function ensureBlobDir(root) {
32
+ const dir = resolve(root, '.pugi/undo-blobs');
33
+ if (!existsSync(dir)) {
34
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
35
+ }
36
+ return dir;
37
+ }
38
+ /**
39
+ * Write `content` into the blob store. Returns the resolved blob path.
40
+ * Atomic tmp+rename so partial writes never present a half-blob к
41
+ * future redo invocations.
42
+ *
43
+ * Idempotent: if a blob with the same content already exists, the
44
+ * second write is a noop (the rename target already matches).
45
+ */
46
+ export function writeBlob(root, content) {
47
+ ensureBlobDir(root);
48
+ const sha = hashContent(content);
49
+ const dst = blobPathFor(root, sha);
50
+ if (existsSync(dst)) {
51
+ // Same content already cached; nothing to do.
52
+ return { sha, path: dst };
53
+ }
54
+ const tmp = `${dst}.tmp-${process.pid}-${Date.now()}`;
55
+ writeFileSync(tmp, content, { encoding: 'utf8', mode: 0o600 });
56
+ renameSync(tmp, dst);
57
+ return { sha, path: dst };
58
+ }
59
+ /**
60
+ * Read a blob by sha. Returns `undefined` when the blob is missing
61
+ * (cleanup ran, repo cloned fresh, blob never captured). Callers
62
+ * MUST treat undefined as "redo not available" rather than crashing.
63
+ */
64
+ export function readBlob(root, sha) {
65
+ const path = blobPathFor(root, sha);
66
+ if (!existsSync(path))
67
+ return undefined;
68
+ try {
69
+ return readFileSync(path, 'utf8');
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
75
+ /**
76
+ * Best-effort blob deletion. Used by the redo runner after a successful
77
+ * reapply so the blob does not get reused on a second redo (which would
78
+ * be incorrect — once redone, the next undo must capture fresh state).
79
+ * Missing-file errors are swallowed — the store self-heals.
80
+ */
81
+ export function deleteBlob(root, sha) {
82
+ const path = blobPathFor(root, sha);
83
+ if (!existsSync(path))
84
+ return;
85
+ try {
86
+ unlinkSync(path);
87
+ }
88
+ catch {
89
+ // Best-effort.
90
+ }
91
+ }
92
+ //# sourceMappingURL=redo-blob-store.js.map