@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.
- 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/mcp/orchestrator-tools.js +595 -0
- package/dist/core/onboarding/ensure-initialized.js +133 -0
- package/dist/core/repl/session.js +370 -9
- package/dist/core/repl/slash-commands.js +68 -5
- package/dist/core/smoke/headless-driver.js +174 -0
- package/dist/core/smoke/orchestrator.js +194 -0
- package/dist/core/smoke/runner.js +238 -0
- package/dist/core/smoke/scenario-parser.js +316 -0
- package/dist/runtime/cli.js +453 -11
- package/dist/runtime/commands/cancel.js +231 -0
- package/dist/runtime/commands/codegraph-status.js +227 -0
- package/dist/runtime/commands/mcp.js +66 -11
- package/dist/runtime/commands/permissions.js +23 -0
- package/dist/runtime/commands/redo-blob-store.js +92 -0
- package/dist/runtime/commands/redo.js +361 -0
- package/dist/runtime/commands/status.js +11 -3
- package/dist/runtime/commands/undo.js +32 -0
- package/dist/runtime/headless-repl.js +195 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tui/permissions-picker.js +78 -0
- package/dist/tui/render.js +35 -0
- package/dist/tui/status-bar.js +1 -1
- package/dist/tui/tool-stream-pane.js +45 -3
- package/package.json +7 -4
- package/test/scenarios/codegen-create-file.scenario.txt +13 -0
- package/test/scenarios/compact-force.scenario.txt +11 -0
- package/test/scenarios/identity.scenario.txt +11 -0
- package/test/scenarios/persona-handoff.scenario.txt +11 -0
- package/test/scenarios/walkback.scenario.txt +12 -0
- package/dist/core/engine/compaction-hook.js +0 -154
- package/dist/core/init/scaffold.js +0 -195
- package/dist/core/memory/dual-write.spec.js +0 -297
- package/dist/core/memory-sync/queue.spec.js +0 -105
- package/dist/core/repl/codebase-survey.js +0 -308
- package/dist/core/repl/init-interview.js +0 -457
- package/dist/core/repl/onboarding-state.js +0 -297
- package/dist/runtime/commands/memory.spec.js +0 -174
|
@@ -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
|
|
@@ -10,7 +10,9 @@ import { loadMcpRegistry, mcpLogPath } from '../../core/mcp/registry.js';
|
|
|
10
10
|
import { listMcpTrust, setMcpTrust } from '../../core/mcp/trust.js';
|
|
11
11
|
import { createPugiMcpServer, serveStdio } from '../../core/mcp/server.js';
|
|
12
12
|
import { buildPugiMcpTools } from '../../core/mcp/server-tools.js';
|
|
13
|
+
import { buildOrchestratorTools } from '../../core/mcp/orchestrator-tools.js';
|
|
13
14
|
import { serveHttp } from '../../core/mcp/http-server.js';
|
|
15
|
+
import { resolveActiveCredential, DEFAULT_API_URL } from '../../core/credentials.js';
|
|
14
16
|
import { listMcpPermissions, clearMcpPermission, } from '../../core/mcp/permission.js';
|
|
15
17
|
export async function runMcpCommand(args, ctx) {
|
|
16
18
|
const sub = args[0] ?? 'list';
|
|
@@ -74,6 +76,15 @@ const USAGE_LINES = [
|
|
|
74
76
|
' --allow-write Expose edit/write (default off — explicit opt-in).',
|
|
75
77
|
' --allow-bash Expose the bash tool (default off — explicit opt-in).',
|
|
76
78
|
' --no-bash Deprecated alias (bash is already off by default).',
|
|
79
|
+
' --orchestrator Expose pugi.run / pugi.read / pugi.write /',
|
|
80
|
+
' pugi.dispatch / pugi.publish / pugi.deploy instead of',
|
|
81
|
+
' the engine surface. Designed for external Claude Code',
|
|
82
|
+
' / Cursor sessions driving fix-publish-test loops.',
|
|
83
|
+
' Each tool family is gated by an env switch:',
|
|
84
|
+
' PUGI_MCP_EXEC_ENABLED=1 enables pugi.run',
|
|
85
|
+
' PUGI_MCP_PUBLISH_ENABLED=1 enables pugi.publish',
|
|
86
|
+
' PUGI_MCP_DEPLOY_ENABLED=1 enables pugi.deploy',
|
|
87
|
+
' PUGI_MCP_WORKSPACE_ROOT=... overrides cwd for path validation',
|
|
77
88
|
' perms list Show cached per-(server, tool) decisions',
|
|
78
89
|
' perms reset <server>:<tool> Forget one cached decision',
|
|
79
90
|
];
|
|
@@ -536,16 +547,23 @@ async function runMcpServe(args, ctx) {
|
|
|
536
547
|
const readOnly = flags.readOnly === true;
|
|
537
548
|
const writeAllowed = !readOnly && flags.writeAllowed;
|
|
538
549
|
const bashAllowed = !readOnly && flags.bashAllowed;
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
550
|
+
// Wave 7 P1 — when `--orchestrator` is set the surface swaps to the
|
|
551
|
+
// CLI-orchestrator family (pugi.run / pugi.read / pugi.write /
|
|
552
|
+
// pugi.dispatch / pugi.publish / pugi.deploy). The engine surface is
|
|
553
|
+
// intentionally dropped — the two are mutually exclusive on the wire
|
|
554
|
+
// to keep tool-name resolution unambiguous on the consumer side.
|
|
555
|
+
const tools = flags.orchestrator
|
|
556
|
+
? buildOrchestratorTools(buildOrchestratorContext(ctx.workspaceRoot))
|
|
557
|
+
: buildPugiMcpTools(toolCtx, {
|
|
558
|
+
bashAllowed,
|
|
559
|
+
// Keep the legacy contract: `readOnly` for the tool-builder means
|
|
560
|
+
// "do not advertise edit/write tools". Bash advertisement is gated
|
|
561
|
+
// by the independent `bashAllowed` knob. So the builder sees
|
|
562
|
+
// `readOnly = true` whenever the operator did not opt into write
|
|
563
|
+
// explicitly, which preserves the deny-by-default surface for
|
|
564
|
+
// edit/write but no longer accidentally suppresses bash.
|
|
565
|
+
readOnly: readOnly || !writeAllowed,
|
|
566
|
+
});
|
|
549
567
|
// β4 r1 P1 #2 — deny-by-default permissionGate. The MCP cache + FSM
|
|
550
568
|
// are consulted on every dispatch; allow_always-cached entries pass
|
|
551
569
|
// silently, allow_once entries pass and self-clear, deny entries
|
|
@@ -604,6 +622,7 @@ async function runMcpServe(args, ctx) {
|
|
|
604
622
|
command: 'mcp.serve',
|
|
605
623
|
transport: 'http',
|
|
606
624
|
url: handle.url,
|
|
625
|
+
surface: flags.orchestrator ? 'orchestrator' : 'engine',
|
|
607
626
|
bearerTokenSource: handle.bearerTokenAutoGenerated
|
|
608
627
|
? 'auto-generated (see stderr)'
|
|
609
628
|
: explicitToken === envToken
|
|
@@ -649,7 +668,7 @@ async function runMcpServe(args, ctx) {
|
|
|
649
668
|
// the wire; nothing is printed unless the parent agent sends a
|
|
650
669
|
// request that returns a response. Operator sees one info line on
|
|
651
670
|
// stderr so they know the server is up.
|
|
652
|
-
process.stderr.write(`pugi-mcp (stdio): ${tools.length} tool(s) — ${tools.map((t) => t.name).join(', ')}\n`);
|
|
671
|
+
process.stderr.write(`pugi-mcp (stdio, ${flags.orchestrator ? 'orchestrator' : 'engine'}): ${tools.length} tool(s) — ${tools.map((t) => t.name).join(', ')}\n`);
|
|
653
672
|
await serveStdio({
|
|
654
673
|
server,
|
|
655
674
|
stdin: ctx.stdin ?? process.stdin,
|
|
@@ -665,6 +684,7 @@ function parseServeFlags(args) {
|
|
|
665
684
|
readOnly: false,
|
|
666
685
|
writeAllowed: false,
|
|
667
686
|
bashAllowed: false,
|
|
687
|
+
orchestrator: false,
|
|
668
688
|
};
|
|
669
689
|
for (let i = 0; i < args.length; i += 1) {
|
|
670
690
|
const arg = args[i] ?? '';
|
|
@@ -722,6 +742,9 @@ function parseServeFlags(args) {
|
|
|
722
742
|
// so existing operator scripts do not error.
|
|
723
743
|
flags.bashAllowed = false;
|
|
724
744
|
}
|
|
745
|
+
else if (arg === '--orchestrator') {
|
|
746
|
+
flags.orchestrator = true;
|
|
747
|
+
}
|
|
725
748
|
else if (arg === '--help') {
|
|
726
749
|
// Caller renders USAGE_LINES. We surface the same via top-level
|
|
727
750
|
// dispatch — nothing to do here, just don't error.
|
|
@@ -755,9 +778,41 @@ function buildServePermissionGate(opts) {
|
|
|
755
778
|
return false;
|
|
756
779
|
if (tool.permission === 'edit' && !opts.writeAllowed)
|
|
757
780
|
return false;
|
|
781
|
+
// `network` is the permission class used by orchestrator tools
|
|
782
|
+
// (pugi.dispatch / pugi.publish / pugi.deploy). The env capability
|
|
783
|
+
// gates inside each tool's `execute` body provide the per-family
|
|
784
|
+
// kill switch, so the serve-time gate is permissive here. The
|
|
785
|
+
// server's overall `permissionGate` is already deny-most-other —
|
|
786
|
+
// adding a third boolean knob (`networkAllowed`) would create more
|
|
787
|
+
// ways to misconfigure than to protect. Wave 7 P1 (2026-05-28).
|
|
758
788
|
return true;
|
|
759
789
|
};
|
|
760
790
|
}
|
|
791
|
+
/**
|
|
792
|
+
* Build the OrchestratorToolContext for `pugi mcp serve --orchestrator`.
|
|
793
|
+
* Reads from process.env + the credentials store. Encapsulated so tests
|
|
794
|
+
* never need to mock the resolveActiveCredential path — they call
|
|
795
|
+
* `buildOrchestratorTools` directly with a hand-rolled context.
|
|
796
|
+
*
|
|
797
|
+
* Wave 7 P1 (2026-05-28).
|
|
798
|
+
*/
|
|
799
|
+
function buildOrchestratorContext(workspaceRoot) {
|
|
800
|
+
const envRoot = process.env.PUGI_MCP_WORKSPACE_ROOT;
|
|
801
|
+
const root = envRoot && envRoot.length > 0 ? resolve(envRoot) : workspaceRoot;
|
|
802
|
+
const credential = resolveActiveCredential();
|
|
803
|
+
return {
|
|
804
|
+
workspaceRoot: root,
|
|
805
|
+
pugiBin: process.env.PUGI_MCP_PUGI_BIN ?? 'pugi',
|
|
806
|
+
apiUrl: credential?.apiUrl ?? DEFAULT_API_URL,
|
|
807
|
+
apiKey: credential?.apiKey ?? null,
|
|
808
|
+
capabilities: {
|
|
809
|
+
exec: process.env.PUGI_MCP_EXEC_ENABLED === '1',
|
|
810
|
+
publish: process.env.PUGI_MCP_PUBLISH_ENABLED === '1',
|
|
811
|
+
deploy: process.env.PUGI_MCP_DEPLOY_ENABLED === '1',
|
|
812
|
+
},
|
|
813
|
+
sshAlias: process.env.PUGI_MCP_SSH_ALIAS ?? 'codeforge',
|
|
814
|
+
};
|
|
815
|
+
}
|
|
761
816
|
function parseHttpBinding(input) {
|
|
762
817
|
// Accept `:7100`, `7100`, or `host:7100`.
|
|
763
818
|
let host = '127.0.0.1';
|
|
@@ -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
|