@pugi/cli 0.1.0-beta.2 → 0.1.0-beta.20
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/THIRD_PARTY_NOTICES.md +40 -0
- package/assets/pugi-mascot.ansi +15 -40
- package/bin/run.js +33 -1
- package/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +15 -0
- package/dist/core/agent-progress/cleanup.js +134 -0
- package/dist/core/agent-progress/schema.js +144 -0
- package/dist/core/agent-progress/writer.js +101 -0
- package/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +196 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/consensus/diff-capture.js +73 -0
- package/dist/core/context/index.js +7 -0
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/cost/rate-card.js +129 -0
- package/dist/core/cost/tracker.js +221 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +86 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/edits/dispatch.js +218 -2
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/edits/worktree.js +111 -18
- package/dist/core/engine/anvil-client.js +115 -5
- package/dist/core/engine/budgets.js +89 -0
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +744 -210
- package/dist/core/engine/prompts.js +61 -6
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +818 -31
- package/dist/core/file-cache.js +113 -1
- package/dist/core/init/scaffold.js +195 -0
- package/dist/core/lsp/client.js +174 -29
- package/dist/core/mcp/client.js +75 -6
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/permissions/gate.js +187 -0
- package/dist/core/permissions/index.js +18 -0
- package/dist/core/permissions/mode.js +102 -0
- package/dist/core/permissions/state.js +160 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/repl/codebase-survey.js +308 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/init-interview.js +457 -0
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/onboarding-state.js +297 -0
- package/dist/core/repl/session.js +719 -29
- package/dist/core/repl/slash-commands.js +133 -9
- package/dist/core/retry-budget/budget.js +284 -0
- package/dist/core/retry-budget/index.js +5 -0
- package/dist/core/settings.js +71 -0
- package/dist/core/skills/defaults.js +457 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +113 -24
- package/dist/core/subagents/index.js +18 -5
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +1588 -266
- package/dist/runtime/commands/compact.js +296 -0
- package/dist/runtime/commands/cost.js +199 -0
- package/dist/runtime/commands/delegate.js +289 -0
- package/dist/runtime/commands/doctor.js +369 -0
- package/dist/runtime/commands/lsp.js +187 -5
- package/dist/runtime/commands/mcp.js +824 -0
- package/dist/runtime/commands/patch.js +17 -0
- package/dist/runtime/commands/permissions.js +87 -0
- package/dist/runtime/commands/report.js +299 -0
- package/dist/runtime/commands/review-consensus.js +17 -2
- package/dist/runtime/commands/roster.js +117 -0
- package/dist/runtime/commands/status.js +178 -0
- package/dist/runtime/commands/worktree.js +50 -6
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/plan-decompose.js +531 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +206 -0
- package/dist/tools/apply-patch.js +281 -39
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/file-tools.js +85 -14
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/registry.js +22 -2
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/compact-banner.js +54 -0
- package/dist/tui/conversation-pane.js +69 -8
- package/dist/tui/cost-table.js +111 -0
- package/dist/tui/doctor-table.js +31 -0
- package/dist/tui/input-box.js +1 -1
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/repl-render.js +276 -37
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +25 -6
- package/dist/tui/splash.js +1 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/status-table.js +7 -0
- package/dist/tui/tool-stream-pane.js +7 -0
- package/dist/tui/update-banner.js +20 -2
- package/docs/examples/codegraph.mcp.json +10 -0
- package/package.json +9 -6
package/dist/core/file-cache.js
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session file-read cache + stale-read gate.
|
|
3
|
+
*
|
|
4
|
+
* Leak intel L1 (openclaude `FileEditTool.ts`, 2026-05-27 gap analysis
|
|
5
|
+
* §5.1): every FileEdit must validate the operator's last-known view of
|
|
6
|
+
* the file before mutating disk. The gate compares BOTH `mtimeMs` and
|
|
7
|
+
* `sha256(content)` of the file on disk against the record captured at
|
|
8
|
+
* read time:
|
|
9
|
+
*
|
|
10
|
+
* - mtimeMs is a cheap fast-path. If the inode mtime hasn't moved
|
|
11
|
+
* since the read, the content hash cannot have changed (barring a
|
|
12
|
+
* filesystem with hash-on-mtime-skew bugs) and we can short-circuit.
|
|
13
|
+
* - sha256 is the authoritative gate. A user editor that writes back
|
|
14
|
+
* identical content can leave mtime untouched on some filesystems
|
|
15
|
+
* (atomic-rename with preserved metadata), and conversely `touch`
|
|
16
|
+
* bumps mtime without changing content. Hash is the truth.
|
|
17
|
+
*
|
|
18
|
+
* Both signals must agree for the gate to PASS. Any divergence => STALE
|
|
19
|
+
* => refuse the edit, force the model to re-read.
|
|
20
|
+
*
|
|
21
|
+
* Cache lifetime: per-session. `FileReadCache.clear()` is called at
|
|
22
|
+
* session.end (see `core/session.ts`). The cache is intentionally NOT
|
|
23
|
+
* durable across sessions — a re-read after restart is cheap and stale
|
|
24
|
+
* cross-session entries would themselves be a soundness hazard.
|
|
25
|
+
*
|
|
26
|
+
* Exception: writeTool for create-new (path doesn't exist on disk) does
|
|
27
|
+
* not consult the cache. Creating a brand new file has no "last-known
|
|
28
|
+
* view" to invalidate.
|
|
29
|
+
*/
|
|
1
30
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { statSync } from 'node:fs';
|
|
31
|
+
import { existsSync, statSync } from 'node:fs';
|
|
3
32
|
import { resolve } from 'node:path';
|
|
33
|
+
export class StaleReadError extends Error {
|
|
34
|
+
reason;
|
|
35
|
+
path;
|
|
36
|
+
constructor(path, reason, detail) {
|
|
37
|
+
super(`stale_read: ${path} — ${detail}. Re-read the file before editing.`);
|
|
38
|
+
this.name = 'StaleReadError';
|
|
39
|
+
this.reason = reason;
|
|
40
|
+
this.path = path;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
4
43
|
export class FileReadCache {
|
|
5
44
|
records = new Map();
|
|
6
45
|
set(record) {
|
|
@@ -9,6 +48,70 @@ export class FileReadCache {
|
|
|
9
48
|
get(root, path) {
|
|
10
49
|
return this.records.get(resolve(root, path));
|
|
11
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Validate a candidate edit against the cached read record. Returns
|
|
53
|
+
* a tagged-union: `{ stale: false }` when the edit may proceed, or
|
|
54
|
+
* `{ stale: true, reason, detail }` when the gate must refuse.
|
|
55
|
+
*
|
|
56
|
+
* Pure function over the cache + supplied `currentMtimeMs` /
|
|
57
|
+
* `currentContent` — does NOT touch disk. Callers (editTool /
|
|
58
|
+
* writeTool) do their own `statSync` + `readFileSync` because they
|
|
59
|
+
* also need the content for the diff/edit itself.
|
|
60
|
+
*
|
|
61
|
+
* @param root workspace root (used to resolve relative path)
|
|
62
|
+
* @param path workspace-relative file path
|
|
63
|
+
* @param currentMtimeMs `fs.statSync().mtimeMs` of the on-disk file
|
|
64
|
+
* @param currentContent UTF-8 contents of the on-disk file
|
|
65
|
+
*/
|
|
66
|
+
validate(root, path, currentMtimeMs, currentContent) {
|
|
67
|
+
const record = this.get(root, path);
|
|
68
|
+
if (!record) {
|
|
69
|
+
return {
|
|
70
|
+
stale: true,
|
|
71
|
+
reason: 'no_prior_read',
|
|
72
|
+
detail: 'file must be read first',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// Fast-path: mtime hasn't moved. Hash check is redundant in the
|
|
76
|
+
// common case but cheap, so we still verify below. Skipping hash
|
|
77
|
+
// when mtime matches would allow a subtle bug class (in-place
|
|
78
|
+
// writers that preserve mtime) to slip through.
|
|
79
|
+
if (currentMtimeMs > record.mtimeMs) {
|
|
80
|
+
// mtime advanced — confirm with hash before flagging. A bump
|
|
81
|
+
// without a content change (e.g. `touch`) shouldn't fire stale.
|
|
82
|
+
const currentHash = hashContent(currentContent);
|
|
83
|
+
if (currentHash !== record.sha256) {
|
|
84
|
+
return {
|
|
85
|
+
stale: true,
|
|
86
|
+
reason: 'mtime_drift',
|
|
87
|
+
detail: `mtime advanced (${record.mtimeMs} → ${currentMtimeMs}) and content hash diverged`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// mtime bumped but content identical — treat as fresh. The cache
|
|
91
|
+
// entry's mtime is intentionally NOT refreshed here; the next
|
|
92
|
+
// edit will hit the same path and the gate will keep agreeing.
|
|
93
|
+
return { stale: false };
|
|
94
|
+
}
|
|
95
|
+
// mtime hasn't moved — hash MUST still match the record. A
|
|
96
|
+
// mismatch is a filesystem-level inconsistency or an in-place
|
|
97
|
+
// editor that preserves mtime; either way, refuse.
|
|
98
|
+
const currentHash = hashContent(currentContent);
|
|
99
|
+
if (currentHash !== record.sha256) {
|
|
100
|
+
return {
|
|
101
|
+
stale: true,
|
|
102
|
+
reason: 'hash_drift',
|
|
103
|
+
detail: 'content hash diverged from last read (mtime unchanged)',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return { stale: false };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Drop every cached record. Called by session.end so a fresh REPL
|
|
110
|
+
* session never inherits stale cross-session entries.
|
|
111
|
+
*/
|
|
112
|
+
clear() {
|
|
113
|
+
this.records.clear();
|
|
114
|
+
}
|
|
12
115
|
}
|
|
13
116
|
export function hashContent(content) {
|
|
14
117
|
return createHash('sha256').update(content).digest('hex');
|
|
@@ -26,4 +129,13 @@ export function createReadRecord(root, path, content, source) {
|
|
|
26
129
|
source,
|
|
27
130
|
};
|
|
28
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Convenience helper: does this absolute path exist on disk? Wraps the
|
|
134
|
+
* existsSync import so file-tools.ts can decide between create-new
|
|
135
|
+
* (skip stale gate) and update-existing (apply stale gate) without
|
|
136
|
+
* pulling in another fs import.
|
|
137
|
+
*/
|
|
138
|
+
export function pathExists(absolutePath) {
|
|
139
|
+
return existsSync(absolutePath);
|
|
140
|
+
}
|
|
29
141
|
//# sourceMappingURL=file-cache.js.map
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace scaffold — extracted from `pugi init` so the bare REPL boot
|
|
3
|
+
* can call it automatically when the operator launches `pugi` in a
|
|
4
|
+
* fresh directory (CEO directive 2026-05-26).
|
|
5
|
+
*
|
|
6
|
+
* Before this module, `pugi init` was the only path that materialised
|
|
7
|
+
* `.pugi/` + the canonical config files. Launching the REPL in an empty
|
|
8
|
+
* directory printed `workspace: (not bound - run /init OR cd into
|
|
9
|
+
* project)` and instructed the operator to Ctrl+C, run `pugi init`,
|
|
10
|
+
* relaunch. That round trip is hostile on a first-touch install — CEO
|
|
11
|
+
* escalated "auto = решение" on 2026-05-26.
|
|
12
|
+
*
|
|
13
|
+
* The module is intentionally side-effect free at import time: the
|
|
14
|
+
* scaffold runs only when `ensureWorkspaceInitialized` is called. The
|
|
15
|
+
* scaffold is also idempotent — every file write is gated by an
|
|
16
|
+
* `existsSync` check, so re-running against a workspace that already has
|
|
17
|
+
* `.pugi/settings.json` (e.g. a manual `pugi init` followed by auto-init
|
|
18
|
+
* on next REPL launch) is a no-op. The function is safe to call before
|
|
19
|
+
* any other init logic.
|
|
20
|
+
*
|
|
21
|
+
* Two CRITICAL invariants:
|
|
22
|
+
*
|
|
23
|
+
* 1. **Atomic per-file.** Every write uses `existsSync` + `writeFileSync`
|
|
24
|
+
* against the final path. There is no read-modify-write pattern that
|
|
25
|
+
* could lose data on a concurrent `pugi init` race. The one path
|
|
26
|
+
* that DOES mutate an existing file — `.gitignore` (append `.pugi/`
|
|
27
|
+
* marker) — also gates on the marker being absent before appending,
|
|
28
|
+
* so the worst-case race is a duplicate marker line that the next
|
|
29
|
+
* run skips.
|
|
30
|
+
*
|
|
31
|
+
* 2. **Silent by default.** When `opts.silent` is true (the REPL
|
|
32
|
+
* auto-init path) the scaffold writes NOTHING to stderr/stdout.
|
|
33
|
+
* The REPL bootstrap runs before Ink mounts, and a stray
|
|
34
|
+
* stdout/stderr write at that point would land on the operator's
|
|
35
|
+
* shell ABOVE the alt-screen entry — visible until they scroll up,
|
|
36
|
+
* and noisy in a CI tail. The explicit `pugi init` path stays
|
|
37
|
+
* verbose via the standalone command in `runtime/cli.ts`.
|
|
38
|
+
*/
|
|
39
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
40
|
+
import { resolve } from 'node:path';
|
|
41
|
+
import { emptyIndex } from '../index-store.js';
|
|
42
|
+
/**
|
|
43
|
+
* Materialise the canonical `.pugi/` workspace scaffold under `cwd`.
|
|
44
|
+
* Returns a `{created, dir, createdPaths, skippedPaths}` summary so the
|
|
45
|
+
* caller can log a one-shot "initialized" line on the first call without
|
|
46
|
+
* re-checking the filesystem.
|
|
47
|
+
*
|
|
48
|
+
* The scaffold mirrors `pugi init` minus the bundled default-skills
|
|
49
|
+
* install (that is a heavier operation gated on the `--no-defaults`
|
|
50
|
+
* flag, and the standalone `pugi init` command keeps owning it).
|
|
51
|
+
*
|
|
52
|
+
* Idempotent: every file write gates on `existsSync`, so re-running
|
|
53
|
+
* against an existing workspace is a no-op and returns
|
|
54
|
+
* `{created: false}` with every path in `skippedPaths`.
|
|
55
|
+
*/
|
|
56
|
+
export function ensureWorkspaceInitialized(cwd, opts = {}) {
|
|
57
|
+
const silent = opts.silent !== false;
|
|
58
|
+
const pugiDir = resolve(cwd, '.pugi');
|
|
59
|
+
// Local trackers so the existing helpers (mkdirIfMissing /
|
|
60
|
+
// writeJsonIfMissing / writeTextIfMissing) keep their (created, skipped)
|
|
61
|
+
// signature. The explicit `pugi init` command forwards these straight
|
|
62
|
+
// into its JSON payload.
|
|
63
|
+
const created = [];
|
|
64
|
+
const skipped = [];
|
|
65
|
+
mkdirIfMissing(pugiDir, created, skipped);
|
|
66
|
+
mkdirIfMissing(resolve(pugiDir, 'artifacts'), created, skipped);
|
|
67
|
+
mkdirIfMissing(resolve(pugiDir, 'sessions'), created, skipped);
|
|
68
|
+
mkdirIfMissing(resolve(pugiDir, 'skills'), created, skipped);
|
|
69
|
+
writeJsonIfMissing(resolve(pugiDir, 'settings.json'), {
|
|
70
|
+
schema: 1,
|
|
71
|
+
workflow: {
|
|
72
|
+
brand: 'pugi',
|
|
73
|
+
legacyName: 'codeforge',
|
|
74
|
+
approvals: 'auto',
|
|
75
|
+
notAutomatic: [],
|
|
76
|
+
defaultBaseBranch: 'dev',
|
|
77
|
+
branchPrefixes: ['feature', 'fix', 'refactor', 'chore'],
|
|
78
|
+
aiCoAuthorTrailers: false,
|
|
79
|
+
},
|
|
80
|
+
permissions: {
|
|
81
|
+
mode: 'auto',
|
|
82
|
+
allow: [],
|
|
83
|
+
deny: [],
|
|
84
|
+
notAutomatic: [],
|
|
85
|
+
},
|
|
86
|
+
privacy: {
|
|
87
|
+
mode: 'balanced',
|
|
88
|
+
telemetry: 'off',
|
|
89
|
+
},
|
|
90
|
+
artifacts: {
|
|
91
|
+
defaultPath: '.pugi/artifacts',
|
|
92
|
+
promoteExplicitly: true,
|
|
93
|
+
},
|
|
94
|
+
}, created, skipped);
|
|
95
|
+
writeJsonIfMissing(resolve(pugiDir, 'mcp.json'), { schema: 1, servers: [] }, created, skipped);
|
|
96
|
+
writeJsonIfMissing(resolve(pugiDir, 'index.json'), emptyIndex(), created, skipped);
|
|
97
|
+
writeTextIfMissing(resolve(pugiDir, 'PUGI.md'), [
|
|
98
|
+
'# Pugi Project Context',
|
|
99
|
+
'',
|
|
100
|
+
'## Product Workflow',
|
|
101
|
+
'',
|
|
102
|
+
'- Public product name: Pugi',
|
|
103
|
+
'- Default flow: idea -> build -> review',
|
|
104
|
+
'- Approvals are automatic by default until a repo, environment, workflow, or action is marked notAutomatic.',
|
|
105
|
+
'- Do not add AI Co-Authored-By trailers.',
|
|
106
|
+
'- Generated code, comments, commits, PR text, and technical docs default to English.',
|
|
107
|
+
'',
|
|
108
|
+
'## Project Notes',
|
|
109
|
+
'',
|
|
110
|
+
'- Add repo-specific architecture, commands, and business rules here.',
|
|
111
|
+
'- Do not store secrets, real IPs, private key paths, tokens, or credentials here.',
|
|
112
|
+
'',
|
|
113
|
+
].join('\n'), created, skipped);
|
|
114
|
+
writeTextIfMissing(resolve(cwd, '.pugiignore'), [
|
|
115
|
+
'# Pugi ignore rules',
|
|
116
|
+
'.env',
|
|
117
|
+
'.env.*',
|
|
118
|
+
'!.env.example',
|
|
119
|
+
'node_modules/',
|
|
120
|
+
'dist/',
|
|
121
|
+
'.next/',
|
|
122
|
+
'coverage/',
|
|
123
|
+
'*.log',
|
|
124
|
+
'*.pem',
|
|
125
|
+
'*.key',
|
|
126
|
+
'*.crt',
|
|
127
|
+
'*.p12',
|
|
128
|
+
'*.sql',
|
|
129
|
+
'*.dump',
|
|
130
|
+
'',
|
|
131
|
+
].join('\n'), created, skipped);
|
|
132
|
+
ensurePugiGitIgnore(cwd, created, skipped);
|
|
133
|
+
// `silent` is honoured implicitly — this module never writes to
|
|
134
|
+
// stdout/stderr. The flag exists so the standalone `pugi init` command
|
|
135
|
+
// can layer its own logger on top (it does, in runtime/cli.ts), while
|
|
136
|
+
// the auto-init REPL path leaves the boot stream untouched. We
|
|
137
|
+
// reference the flag here to defeat the lint "unused" warning and to
|
|
138
|
+
// document the contract in the source.
|
|
139
|
+
void silent;
|
|
140
|
+
return {
|
|
141
|
+
created: created.length > 0,
|
|
142
|
+
dir: pugiDir,
|
|
143
|
+
createdPaths: created,
|
|
144
|
+
skippedPaths: skipped,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/* ------------------------------------------------------------------ */
|
|
148
|
+
/* Helpers (mirror the previous in-file implementations in cli.ts) */
|
|
149
|
+
/* ------------------------------------------------------------------ */
|
|
150
|
+
function mkdirIfMissing(path, created, skipped) {
|
|
151
|
+
if (existsSync(path)) {
|
|
152
|
+
skipped.push(path);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
mkdirSync(path, { recursive: true });
|
|
156
|
+
created.push(path);
|
|
157
|
+
}
|
|
158
|
+
function writeJsonIfMissing(path, value, created, skipped) {
|
|
159
|
+
writeTextIfMissing(path, `${JSON.stringify(value, null, 2)}\n`, created, skipped);
|
|
160
|
+
}
|
|
161
|
+
function writeTextIfMissing(path, value, created, skipped) {
|
|
162
|
+
if (existsSync(path)) {
|
|
163
|
+
skipped.push(path);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
writeFileSync(path, value, { encoding: 'utf8', mode: 0o600 });
|
|
167
|
+
created.push(path);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Ensure the workspace `.gitignore` ignores `.pugi/`. The function is
|
|
171
|
+
* additive: it leaves an existing `.gitignore` body intact and appends
|
|
172
|
+
* the marker only when none of `.pugi/`, `/.pugi/`, or `.pugi` is
|
|
173
|
+
* already present. On a fresh repo with no `.gitignore` it creates the
|
|
174
|
+
* file with the single marker line. Mode 0o600 matches the rest of the
|
|
175
|
+
* scaffold so a paranoid CI does not surface "world-readable" warnings.
|
|
176
|
+
*/
|
|
177
|
+
function ensurePugiGitIgnore(cwd, created, skipped) {
|
|
178
|
+
const gitignorePath = resolve(cwd, '.gitignore');
|
|
179
|
+
const marker = '.pugi/';
|
|
180
|
+
if (!existsSync(gitignorePath)) {
|
|
181
|
+
writeFileSync(gitignorePath, `${marker}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
182
|
+
created.push(gitignorePath);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const current = readFileSync(gitignorePath, 'utf8');
|
|
186
|
+
const lines = current.split('\n').map((line) => line.trim());
|
|
187
|
+
if (lines.includes(marker) || lines.includes('/.pugi/') || lines.includes('.pugi')) {
|
|
188
|
+
skipped.push(gitignorePath);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const next = current.endsWith('\n') ? `${current}${marker}\n` : `${current}\n${marker}\n`;
|
|
192
|
+
writeFileSync(gitignorePath, next, { encoding: 'utf8' });
|
|
193
|
+
created.push(`${gitignorePath} (+${marker})`);
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=scaffold.js.map
|
package/dist/core/lsp/client.js
CHANGED
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
*/
|
|
63
63
|
import { spawn, spawnSync } from 'node:child_process';
|
|
64
64
|
import { pathToFileURL } from 'node:url';
|
|
65
|
-
import { readFileSync } from 'node:fs';
|
|
65
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
66
66
|
import { resolve, sep } from 'node:path';
|
|
67
67
|
import { OperatorAbortedError } from '../../tools/file-tools.js';
|
|
68
68
|
const LANGUAGE_SERVERS = {
|
|
@@ -130,6 +130,13 @@ export class LspClient {
|
|
|
130
130
|
child.stderr.on('data', () => { });
|
|
131
131
|
}
|
|
132
132
|
child.on('exit', () => this.onExit());
|
|
133
|
+
// R1 fix (2026-05-26, PR #413 r1, P2 #11): mirror onExit for the
|
|
134
|
+
// 'error' event. A late-fired spawn error (EIO, ENOMEM, etc.) or
|
|
135
|
+
// any unhandled child-process error would otherwise leave
|
|
136
|
+
// in-flight pending requests dangling until their per-request
|
|
137
|
+
// timer fired, which can be up to `requestTimeoutMs` later.
|
|
138
|
+
// Failing fast here matches the exit-time semantics.
|
|
139
|
+
child.on('error', () => this.onExit());
|
|
133
140
|
}
|
|
134
141
|
/**
|
|
135
142
|
* Send `shutdown` + `exit`, then SIGKILL after a 1s grace window so
|
|
@@ -253,6 +260,35 @@ export class LspClient {
|
|
|
253
260
|
detail: error instanceof Error ? error.message : String(error),
|
|
254
261
|
};
|
|
255
262
|
}
|
|
263
|
+
// R1 fix (2026-05-26, PR #413 r1, Fix 8): realpath containment.
|
|
264
|
+
// Without this gate, a workspace-local symlink (e.g. `alias` ->
|
|
265
|
+
// `/etc/passwd`) passed the lexical `absPath.startsWith(cwd)`
|
|
266
|
+
// check, then `readFileSync(absPath, 'utf8')` happily followed the
|
|
267
|
+
// symlink and shipped `/etc/passwd` into the LSP `textDocument/didOpen`
|
|
268
|
+
// payload. Parity with `applySecurityGate`'s symlink-escape rule:
|
|
269
|
+
// when the file exists, the realpath MUST stay inside the workspace
|
|
270
|
+
// realpath. Missing files (newly-typed paths the operator is
|
|
271
|
+
// querying) skip the check — there's no symlink target to escape.
|
|
272
|
+
if (existsSync(absPath)) {
|
|
273
|
+
try {
|
|
274
|
+
const realRoot = realpathSync.native(this.cwd);
|
|
275
|
+
const realTarget = realpathSync.native(absPath);
|
|
276
|
+
if (realTarget !== realRoot && !realTarget.startsWith(realRoot + sep)) {
|
|
277
|
+
return {
|
|
278
|
+
ok: false,
|
|
279
|
+
reason: 'lsp_error',
|
|
280
|
+
detail: `symlink escapes workspace: ${file} -> ${realTarget}`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
return {
|
|
286
|
+
ok: false,
|
|
287
|
+
reason: 'lsp_error',
|
|
288
|
+
detail: `cannot realpath ${file}: ${error instanceof Error ? error.message : String(error)}`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
256
292
|
const uri = pathToFileURL(absPath).toString();
|
|
257
293
|
if (!this.openedFiles.has(uri)) {
|
|
258
294
|
try {
|
|
@@ -349,8 +385,22 @@ export class LspClient {
|
|
|
349
385
|
const headerText = this.buffer.subarray(0, headerEnd).toString('ascii');
|
|
350
386
|
const lengthMatch = headerText.match(/Content-Length:\s*(\d+)/i);
|
|
351
387
|
if (!lengthMatch || lengthMatch[1] === undefined) {
|
|
352
|
-
//
|
|
353
|
-
|
|
388
|
+
// R1 fix (2026-05-26, PR #413 r1, Fix 7): malformed header —
|
|
389
|
+
// instead of nuking the entire buffer (which would discard ANY
|
|
390
|
+
// subsequent valid messages already queued in `this.buffer`),
|
|
391
|
+
// scan forward for the next `Content-Length:` marker and resync
|
|
392
|
+
// from there. A misbehaving server that emits one bad header
|
|
393
|
+
// followed by a normal stream of responses must not freeze the
|
|
394
|
+
// client. When no recoverable next marker is in the buffer, we
|
|
395
|
+
// keep the buffer as-is and wait for more data — the broken
|
|
396
|
+
// bytes will be re-evaluated on the next chunk.
|
|
397
|
+
const nextHeaderIdx = this.buffer.indexOf(Buffer.from('Content-Length:'), 1);
|
|
398
|
+
if (nextHeaderIdx > 0) {
|
|
399
|
+
this.buffer = this.buffer.subarray(nextHeaderIdx);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
// No next marker visible — wait for more data, do not nuke the
|
|
403
|
+
// buffer. A subsequent chunk may complete a valid header.
|
|
354
404
|
return;
|
|
355
405
|
}
|
|
356
406
|
const length = Number.parseInt(lengthMatch[1], 10);
|
|
@@ -415,14 +465,59 @@ export class LspClient {
|
|
|
415
465
|
}
|
|
416
466
|
}
|
|
417
467
|
}
|
|
468
|
+
/**
|
|
469
|
+
* Map a short LSP language slug to the settings.json key. β7 L9 — the
|
|
470
|
+
* settings schema spells out the full language name (`typescript`,
|
|
471
|
+
* `python`, ...) for human readability; the short slug (`ts`, `py`) is
|
|
472
|
+
* what every internal call site uses. Keep this map narrow and explicit.
|
|
473
|
+
*/
|
|
474
|
+
const SETTINGS_KEY_BY_LANG = {
|
|
475
|
+
ts: 'typescript',
|
|
476
|
+
js: 'javascript',
|
|
477
|
+
py: 'python',
|
|
478
|
+
go: 'go',
|
|
479
|
+
rust: 'rust',
|
|
480
|
+
};
|
|
481
|
+
/**
|
|
482
|
+
* Report whether the operator has explicitly disabled this language via
|
|
483
|
+
* `.pugi/settings.json::lsp.<language> = false`. Absent section or
|
|
484
|
+
* absent key means "enabled by default" — backwards-compatible with the
|
|
485
|
+
* α7.7 surface that ignored settings entirely. Returns true ONLY when
|
|
486
|
+
* the operator explicitly set the value to false.
|
|
487
|
+
*/
|
|
488
|
+
export function isLspLanguageDisabled(lang, lspSettings) {
|
|
489
|
+
if (!lspSettings)
|
|
490
|
+
return false;
|
|
491
|
+
const key = SETTINGS_KEY_BY_LANG[lang];
|
|
492
|
+
return lspSettings[key] === false;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Probe every registered language server. Operator-facing helper for
|
|
496
|
+
* `pugi lsp servers` — returns one row per language with the binary
|
|
497
|
+
* name, whether it was found on PATH, and whether the settings toggle
|
|
498
|
+
* has explicitly disabled it.
|
|
499
|
+
*/
|
|
500
|
+
export function inspectLspServers(lspSettings) {
|
|
501
|
+
const out = [];
|
|
502
|
+
for (const lang of Object.keys(LANGUAGE_SERVERS)) {
|
|
503
|
+
const server = LANGUAGE_SERVERS[lang];
|
|
504
|
+
out.push({
|
|
505
|
+
language: lang,
|
|
506
|
+
command: server.command + (server.args.length > 0 ? ` ${server.args.join(' ')}` : ''),
|
|
507
|
+
available: detectBinary(server.probe),
|
|
508
|
+
enabled: !isLspLanguageDisabled(lang, lspSettings),
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
return out;
|
|
512
|
+
}
|
|
418
513
|
/**
|
|
419
514
|
* Start an LSP client for the given language. Returns either an `LspClient`
|
|
420
515
|
* ready to use, or a structured failure (`lsp_unavailable`,
|
|
421
516
|
* `language_unsupported`).
|
|
422
517
|
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
518
|
+
* β7 L9: respects `.pugi/settings.json::lsp.<language> = false` —
|
|
519
|
+
* a disabled language reports `lsp_disabled` so the caller surface can
|
|
520
|
+
* tell the operator the binary IS available but settings says no.
|
|
426
521
|
*/
|
|
427
522
|
export async function startLspClient(lang, opts) {
|
|
428
523
|
const server = opts.serverOverride ?? LANGUAGE_SERVERS[lang];
|
|
@@ -433,6 +528,14 @@ export async function startLspClient(lang, opts) {
|
|
|
433
528
|
detail: `no LSP server registered for language: ${lang}`,
|
|
434
529
|
};
|
|
435
530
|
}
|
|
531
|
+
if (!opts.serverOverride && isLspLanguageDisabled(lang, opts.lspSettings)) {
|
|
532
|
+
return {
|
|
533
|
+
ok: false,
|
|
534
|
+
reason: 'lsp_disabled',
|
|
535
|
+
detail: `${lang} is disabled in .pugi/settings.json::lsp.${SETTINGS_KEY_BY_LANG[lang]}. ` +
|
|
536
|
+
`Remove the override (or set it to true) to enable.`,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
436
539
|
if (!opts.serverOverride) {
|
|
437
540
|
const available = detectBinary(server.probe);
|
|
438
541
|
if (!available) {
|
|
@@ -459,12 +562,51 @@ export async function startLspClient(lang, opts) {
|
|
|
459
562
|
detail: `failed to spawn ${server.command}: ${error instanceof Error ? error.message : String(error)}`,
|
|
460
563
|
};
|
|
461
564
|
}
|
|
565
|
+
// `child_process.spawn` reports a missing binary asynchronously via
|
|
566
|
+
// the 'error' event, NOT via a synchronous throw — the synchronous
|
|
567
|
+
// spawn returns a ChildProcess object even when the binary does not
|
|
568
|
+
// exist. Attach an error listener immediately so the missing-binary
|
|
569
|
+
// case never becomes an uncaught exception. Wait one microtask tick
|
|
570
|
+
// for the event-loop to fire the 'error' event before we attempt
|
|
571
|
+
// the handshake; if the spawn failed, return early with
|
|
572
|
+
// `lsp_unavailable`.
|
|
573
|
+
let spawnError = null;
|
|
574
|
+
child.on('error', (err) => {
|
|
575
|
+
spawnError = err;
|
|
576
|
+
});
|
|
577
|
+
// Yield one tick so Node's spawn-error path lands before we
|
|
578
|
+
// proceed. The error event lives on the same nextTick queue as the
|
|
579
|
+
// initial spawn handshake, so a single setImmediate-equivalent
|
|
580
|
+
// delay is enough to observe it.
|
|
581
|
+
await new Promise((resolveFn) => {
|
|
582
|
+
setImmediate(resolveFn);
|
|
583
|
+
});
|
|
584
|
+
if (spawnError) {
|
|
585
|
+
try {
|
|
586
|
+
child.kill('SIGKILL');
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
// ignore — process never started
|
|
590
|
+
}
|
|
591
|
+
return {
|
|
592
|
+
ok: false,
|
|
593
|
+
reason: 'lsp_unavailable',
|
|
594
|
+
detail: `failed to spawn ${server.command}: ${spawnError.message}`,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
462
597
|
const client = new LspClient(child, server, opts);
|
|
463
598
|
try {
|
|
464
599
|
await initializeHandshake(client, opts.cwd);
|
|
465
600
|
}
|
|
466
601
|
catch (error) {
|
|
467
602
|
await client.stop();
|
|
603
|
+
if (spawnError) {
|
|
604
|
+
return {
|
|
605
|
+
ok: false,
|
|
606
|
+
reason: 'lsp_unavailable',
|
|
607
|
+
detail: `failed to spawn ${server.command}: ${spawnError.message}`,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
468
610
|
return {
|
|
469
611
|
ok: false,
|
|
470
612
|
reason: 'lsp_error',
|
|
@@ -475,10 +617,11 @@ export async function startLspClient(lang, opts) {
|
|
|
475
617
|
}
|
|
476
618
|
async function initializeHandshake(client, cwd) {
|
|
477
619
|
const rootUri = pathToFileURL(cwd).toString();
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
// surface (
|
|
481
|
-
//
|
|
620
|
+
// Reach the private send-request / send-notification surface through
|
|
621
|
+
// a typed accessor cast. The two methods are intentionally not part
|
|
622
|
+
// of the public class surface (callers should use `hover`/`definition`
|
|
623
|
+
// etc.), but the handshake is a single-shot bootstrap and exposing
|
|
624
|
+
// the raw methods would weaken the type story.
|
|
482
625
|
const internal = client;
|
|
483
626
|
await internal.sendRequest('initialize', {
|
|
484
627
|
processId: process.pid,
|
|
@@ -520,27 +663,29 @@ function normalizeHover(raw) {
|
|
|
520
663
|
const obj = raw;
|
|
521
664
|
const range = parseRange(obj.range);
|
|
522
665
|
const body = obj.contents;
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
666
|
+
const result = (() => {
|
|
667
|
+
if (typeof body === 'string')
|
|
668
|
+
return { content: body, raw, ...(range ? { range } : {}) };
|
|
669
|
+
if (Array.isArray(body)) {
|
|
670
|
+
const parts = [];
|
|
671
|
+
for (const item of body) {
|
|
672
|
+
if (typeof item === 'string')
|
|
673
|
+
parts.push(item);
|
|
674
|
+
else if (item && typeof item === 'object' && 'value' in item) {
|
|
675
|
+
const value = item.value;
|
|
676
|
+
if (typeof value === 'string')
|
|
677
|
+
parts.push(value);
|
|
678
|
+
}
|
|
535
679
|
}
|
|
680
|
+
return { content: parts.join('\n'), raw, ...(range ? { range } : {}) };
|
|
536
681
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
return { content:
|
|
542
|
-
}
|
|
543
|
-
return
|
|
682
|
+
if (body && typeof body === 'object' && 'value' in body) {
|
|
683
|
+
const value = body.value;
|
|
684
|
+
return { content: typeof value === 'string' ? value : '', raw, ...(range ? { range } : {}) };
|
|
685
|
+
}
|
|
686
|
+
return { content: '', raw, ...(range ? { range } : {}) };
|
|
687
|
+
})();
|
|
688
|
+
return result;
|
|
544
689
|
}
|
|
545
690
|
function normalizeLocations(raw, cwd) {
|
|
546
691
|
if (!raw)
|