preflight-mcp 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -15,8 +15,12 @@ Each bundle contains:
15
15
 
16
16
  ## Features
17
17
 
18
- - **12 MCP tools** to create/update/repair/search/read bundles, generate evidence graphs, and manage trace links
19
- - **De-duplication**: prevent repeated indexing of the same normalized inputs
18
+ - **13 MCP tools** to create/update/repair/search/read bundles, generate evidence graphs, and manage trace links
19
+ - **Progress tracking**: Real-time progress reporting for long-running operations (create/update bundles)
20
+ - **Bundle integrity check**: Prevents operations on incomplete bundles with helpful error messages
21
+ - **De-duplication with in-progress lock**: Prevent duplicate bundle creation even during MCP timeouts
22
+ - **Global dependency graph**: Generate project-wide import relationship graphs
23
+ - **Batch file reading**: Read all key bundle files in a single call
20
24
  - **Resilient GitHub fetching**: configurable git clone timeout + GitHub archive (zipball) fallback
21
25
  - **Offline repair**: rebuild missing/empty derived artifacts (index/guides/overview) without re-fetching
22
26
  - **Static facts extraction** via `analysis/FACTS.json` (non-LLM)
@@ -117,7 +121,7 @@ Run end-to-end smoke test:
117
121
  npm run smoke
118
122
  ```
119
123
 
120
- ## Tools (12 total)
124
+ ## Tools (13 total)
121
125
 
122
126
  ### `preflight_list_bundles`
123
127
  List bundle IDs in storage.
@@ -146,8 +150,10 @@ Input (example):
146
150
  **Note**: If the bundle contains code files, consider using `preflight_evidence_dependency_graph` for dependency analysis or `preflight_trace_upsert` for trace links.
147
151
 
148
152
  ### `preflight_read_file`
149
- Read a file from bundle (OVERVIEW.md, START_HERE.md, AGENTS.md, manifest.json, or any repo file).
150
- - Triggers: "查看概览", "项目概览", "看README", "bundle详情", "bundle状态", "仓库信息"
153
+ Read file(s) from bundle. Two modes:
154
+ - **Batch mode** (omit `file`): Returns ALL key files (OVERVIEW.md, START_HERE.md, AGENTS.md, manifest.json, repo READMEs) in one call
155
+ - **Single file mode** (provide `file`): Returns that specific file
156
+ - Triggers: "查看bundle", "bundle概览", "项目信息", "show bundle"
151
157
  - Use `file: "manifest.json"` to get bundle metadata (repos, timestamps, tags, etc.)
152
158
 
153
159
  ### `preflight_delete_bundle`
@@ -189,10 +195,12 @@ Optional parameters:
189
195
  - `limit`: Max total hits across all bundles
190
196
 
191
197
  ### `preflight_evidence_dependency_graph`
192
- Generate an evidence-based dependency graph for a target file/symbol (imports + callers).
198
+ Generate an evidence-based dependency graph. Two modes:
199
+ - **Target mode** (provide `target.file`): Analyze a specific file's imports and callers
200
+ - **Global mode** (omit `target`): Generate project-wide import graph of all code files
193
201
  - Deterministic output with source ranges for edges.
194
202
  - Uses Tree-sitter parsing when `PREFLIGHT_AST_ENGINE=wasm`; falls back to regex extraction otherwise.
195
- - Emits `imports` edges (file → module) and, when resolvable, `imports_resolved` edges (file → internal file).
203
+ - Emits `imports` edges (file → module) and `imports_resolved` edges (file → internal file).
196
204
 
197
205
  ### `preflight_trace_upsert`
198
206
  Upsert traceability links (commit↔ticket, symbol↔test, code↔doc, etc.) for a bundle.
@@ -210,6 +218,12 @@ Parameters:
210
218
 
211
219
  Note: This is also automatically executed on server startup (background, non-blocking).
212
220
 
221
+ ### `preflight_get_task_status`
222
+ Check status of bundle creation/update tasks (progress tracking).
223
+ - Triggers: "check progress", "what is the status", "查看任务状态", "下载进度"
224
+ - Query by `taskId` (from error), `fingerprint`, or `repos`
225
+ - Shows: phase, progress percentage, message, elapsed time
226
+
213
227
  ## Resources
214
228
 
215
229
  ### `preflight://bundles`
@@ -13,8 +13,26 @@ export function parseOwnerRepo(input) {
13
13
  export function toCloneUrl(ref) {
14
14
  return `https://github.com/${ref.owner}/${ref.repo}.git`;
15
15
  }
16
+ /**
17
+ * Parse git clone progress from stderr.
18
+ * Git outputs progress like:
19
+ * - "Receiving objects: 45% (1234/2741)"
20
+ * - "Resolving deltas: 60% (100/167)"
21
+ */
22
+ function parseGitProgress(line) {
23
+ // Match patterns like "Receiving objects: 45% (1234/2741)"
24
+ const match = line.match(/(Receiving objects|Resolving deltas|Counting objects|Compressing objects):\s+(\d+)%/);
25
+ if (match) {
26
+ return {
27
+ phase: match[1],
28
+ percent: parseInt(match[2], 10),
29
+ };
30
+ }
31
+ return null;
32
+ }
16
33
  async function runGit(args, opts) {
17
34
  const timeoutMs = opts?.timeoutMs ?? 5 * 60_000;
35
+ const onProgress = opts?.onProgress;
18
36
  return new Promise((resolve, reject) => {
19
37
  const child = spawn('git', args, {
20
38
  cwd: opts?.cwd,
@@ -65,7 +83,19 @@ async function runGit(args, opts) {
65
83
  stdout += data.toString('utf8');
66
84
  });
67
85
  child.stderr?.on('data', (data) => {
68
- stderr += data.toString('utf8');
86
+ const chunk = data.toString('utf8');
87
+ stderr += chunk;
88
+ // Parse and report progress
89
+ if (onProgress) {
90
+ // Git progress can come in chunks, split by lines
91
+ const lines = chunk.split(/[\r\n]+/);
92
+ for (const line of lines) {
93
+ const progress = parseGitProgress(line);
94
+ if (progress) {
95
+ onProgress(progress.phase, progress.percent, `${progress.phase}: ${progress.percent}%`);
96
+ }
97
+ }
98
+ }
69
99
  });
70
100
  child.on('error', (err) => {
71
101
  cleanup();
@@ -125,14 +155,15 @@ export async function shallowClone(cloneUrl, destDir, opts) {
125
155
  await fs.mkdir(path.dirname(destDir), { recursive: true });
126
156
  // Clean dest if exists.
127
157
  await fs.rm(destDir, { recursive: true, force: true });
128
- const args = ['-c', 'core.autocrlf=false', 'clone', '--depth', '1', '--no-tags', '--single-branch'];
158
+ // Use --progress to force progress output even when not attached to a terminal
159
+ const args = ['-c', 'core.autocrlf=false', 'clone', '--depth', '1', '--no-tags', '--single-branch', '--progress'];
129
160
  if (opts?.ref) {
130
161
  // Validate ref before using it in git command
131
162
  validateGitRef(opts.ref);
132
163
  args.push('--branch', opts.ref);
133
164
  }
134
165
  args.push(cloneUrl, destDir);
135
- await runGit(args, { timeoutMs: opts?.timeoutMs ?? 15 * 60_000 });
166
+ await runGit(args, { timeoutMs: opts?.timeoutMs ?? 15 * 60_000, onProgress: opts?.onProgress });
136
167
  }
137
168
  export async function getLocalHeadSha(repoDir) {
138
169
  const { stdout } = await runGit(['-C', repoDir, 'rev-parse', 'HEAD']);
@@ -7,7 +7,7 @@ function nowIso() {
7
7
  }
8
8
  function githubHeaders(cfg) {
9
9
  const headers = {
10
- 'User-Agent': 'preflight-mcp/0.1.3',
10
+ 'User-Agent': 'preflight-mcp/0.1.5',
11
11
  Accept: 'application/vnd.github+json',
12
12
  };
13
13
  if (cfg.githubToken) {
@@ -36,7 +36,7 @@ async function fetchJson(url, headers, timeoutMs = DEFAULT_API_TIMEOUT_MS) {
36
36
  clearTimeout(timeoutId);
37
37
  }
38
38
  }
39
- async function downloadToFile(url, headers, destPath, timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS) {
39
+ async function downloadToFile(url, headers, destPath, timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS, onProgress) {
40
40
  const controller = new AbortController();
41
41
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
42
42
  try {
@@ -44,12 +44,51 @@ async function downloadToFile(url, headers, destPath, timeoutMs = DEFAULT_DOWNLO
44
44
  if (!res.ok) {
45
45
  throw new Error(`Download error ${res.status}: ${res.statusText}`);
46
46
  }
47
- // Use streaming if possible; otherwise fallback to arrayBuffer.
47
+ // Get content length for progress reporting
48
+ const contentLengthHeader = res.headers.get('content-length');
49
+ const totalBytes = contentLengthHeader ? parseInt(contentLengthHeader, 10) : undefined;
50
+ await ensureDir(path.dirname(destPath));
51
+ // Use streaming to report progress
48
52
  const anyRes = res;
49
53
  const body = anyRes.body;
50
- await ensureDir(path.dirname(destPath));
54
+ if (body && typeof body[Symbol.asyncIterator] === 'function') {
55
+ // Async iterator for progress tracking
56
+ const fsModule = await import('node:fs');
57
+ const ws = fsModule.createWriteStream(destPath);
58
+ let downloadedBytes = 0;
59
+ let lastReportTime = Date.now();
60
+ const reportIntervalMs = 500; // Report at most every 500ms
61
+ try {
62
+ for await (const chunk of body) {
63
+ ws.write(chunk);
64
+ downloadedBytes += chunk.length;
65
+ // Throttle progress reports
66
+ const now = Date.now();
67
+ if (onProgress && (now - lastReportTime > reportIntervalMs)) {
68
+ lastReportTime = now;
69
+ const percent = totalBytes ? Math.round((downloadedBytes / totalBytes) * 100) : 0;
70
+ const msg = totalBytes
71
+ ? `Downloaded ${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)} (${percent}%)`
72
+ : `Downloaded ${formatBytes(downloadedBytes)}`;
73
+ onProgress(downloadedBytes, totalBytes, msg);
74
+ }
75
+ }
76
+ }
77
+ finally {
78
+ ws.end();
79
+ await new Promise((resolve) => ws.on('finish', () => resolve()));
80
+ }
81
+ // Final progress report
82
+ if (onProgress) {
83
+ const msg = totalBytes
84
+ ? `Downloaded ${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)} (100%)`
85
+ : `Downloaded ${formatBytes(downloadedBytes)}`;
86
+ onProgress(downloadedBytes, totalBytes, msg);
87
+ }
88
+ return;
89
+ }
51
90
  if (body && typeof body.pipe === 'function') {
52
- // Node.js stream
91
+ // Node.js stream (fallback without progress)
53
92
  const ws = (await import('node:fs')).createWriteStream(destPath);
54
93
  await new Promise((resolve, reject) => {
55
94
  body.pipe(ws);
@@ -59,14 +98,27 @@ async function downloadToFile(url, headers, destPath, timeoutMs = DEFAULT_DOWNLO
59
98
  });
60
99
  return;
61
100
  }
62
- // Web stream or no stream support.
101
+ // Web stream or no stream support (fallback without progress)
63
102
  const buf = Buffer.from(await res.arrayBuffer());
64
103
  await fs.writeFile(destPath, buf);
104
+ if (onProgress) {
105
+ onProgress(buf.length, buf.length, `Downloaded ${formatBytes(buf.length)}`);
106
+ }
65
107
  }
66
108
  finally {
67
109
  clearTimeout(timeoutId);
68
110
  }
69
111
  }
112
+ /** Format bytes for display */
113
+ function formatBytes(bytes) {
114
+ if (bytes < 1024)
115
+ return `${bytes}B`;
116
+ if (bytes < 1024 * 1024)
117
+ return `${(bytes / 1024).toFixed(1)}KB`;
118
+ if (bytes < 1024 * 1024 * 1024)
119
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
120
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`;
121
+ }
70
122
  async function extractZip(zipPath, destDir) {
71
123
  await ensureDir(destDir);
72
124
  const zip = new AdmZip(zipPath);
@@ -91,7 +143,7 @@ export async function downloadAndExtractGitHubArchive(params) {
91
143
  // Use the API zipball endpoint so ref can be branch/tag/SHA (including slashes via URL-encoding).
92
144
  const zipballUrl = `https://api.github.com/repos/${params.owner}/${params.repo}/zipball/${encodeURIComponent(refUsed)}`;
93
145
  await ensureDir(params.destDir);
94
- await downloadToFile(zipballUrl, headers, zipPath);
146
+ await downloadToFile(zipballUrl, headers, zipPath, DEFAULT_DOWNLOAD_TIMEOUT_MS, params.onProgress);
95
147
  const extractDir = path.join(params.destDir, `extracted-${Date.now()}`);
96
148
  await extractZip(zipPath, extractDir);
97
149
  const repoRoot = await findSingleTopLevelDir(extractDir);