agentsys 5.8.6 → 5.9.1

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.
@@ -1,55 +1,37 @@
1
+ 'use strict';
2
+
1
3
  /**
2
- * Repo intel query functions
3
- *
4
4
  * Typed wrappers over `agent-analyzer repo-intel query <type>` subcommands.
5
- * Consumer plugins can call these instead of constructing CLI args by hand:
6
5
  *
7
- * const { repoIntel } = require('@agentsys/lib');
8
- * const hot = repoIntel.queries.hotspots(cwd, { limit: 20 });
6
+ * Each function constructs the exact argv expected by the binary, delegates to
7
+ * `binary.runAnalyzer`, parses the JSON output, and returns the result.
9
8
  *
10
- * Each function resolves the cached `repo-intel.json` via the platform
11
- * state-dir helper and shells out to the binary downloaded by lib/binary.
9
+ * All functions throw `RepoIntelMissingError` when the repo-intel map file
10
+ * has not been generated yet (run `agentsys repo-intel update` first).
12
11
  *
13
12
  * @module lib/repo-intel/queries
14
13
  */
15
14
 
16
- 'use strict';
17
-
18
15
  const fs = require('fs');
19
16
  const path = require('path');
17
+ const { getStateDir } = require('../platform/state-dir');
20
18
  const binary = require('../binary');
21
- const { getStateDirPath } = require('../platform/state-dir');
22
-
23
- const MAP_FILENAME = 'repo-intel.json';
24
19
 
25
- // Conservative bound on a single CLI argument length. Windows cmd-line max
26
- // is ~32 KB total; modern Linux/macOS typically allow ~128 KB. We throw an
27
- // actionable error rather than letting `execFileSync` fail with a cryptic
28
- // E2BIG/ENAMETOOLONG, which would otherwise look like a binary crash to
29
- // the caller.
30
- const MAX_FILES_ARG_LEN = 30000;
20
+ // ---------------------------------------------------------------------------
21
+ // Error types
22
+ // ---------------------------------------------------------------------------
31
23
 
32
24
  /**
33
- * Absolute path to the cached repo-intel artifact for `basePath`.
34
- *
35
- * @param {string} basePath
36
- * @returns {string}
37
- */
38
- function mapFilePath(basePath) {
39
- return path.join(getStateDirPath(basePath), MAP_FILENAME);
40
- }
41
-
42
- /**
43
- * Error thrown when the cached repo-intel artifact is missing.
44
- * Distinguished by a `.code = 'REPO_INTEL_MISSING'` field so callers can
45
- * choose between auto-init, fallback, or surfacing the message.
25
+ * Thrown when the repo-intel map file is absent.
46
26
  */
47
27
  class RepoIntelMissingError extends Error {
28
+ /**
29
+ * @param {string} mapFile - Expected path to the map file
30
+ */
48
31
  constructor(mapFile) {
49
32
  super(
50
- `repo-intel artifact not found at ${mapFile}. Run ` +
51
- '`agent-analyzer repo-intel init <path>` (or `/repo-intel init` ' +
52
- 'in a CC plugin) first to create it.'
33
+ `repo-intel map not found at ${mapFile}. ` +
34
+ 'Run `agentsys repo-intel update` to generate it first.'
53
35
  );
54
36
  this.name = 'RepoIntelMissingError';
55
37
  this.code = 'REPO_INTEL_MISSING';
@@ -57,393 +39,260 @@ class RepoIntelMissingError extends Error {
57
39
  }
58
40
  }
59
41
 
42
+ // ---------------------------------------------------------------------------
43
+ // Internal helpers
44
+ // ---------------------------------------------------------------------------
45
+
46
+ const MAP_FILE_NAME = 'repo-intel.json';
47
+
60
48
  /**
61
- * Run a binary query and return the parsed JSON result.
62
- *
63
- * @param {string} basePath - Repository root
64
- * @param {string[]} queryArgs - Arguments after `repo-intel query`
65
- * @returns {Object|Array} Parsed query result
66
- * @throws {RepoIntelMissingError} If the cached artifact is missing.
67
- * @throws {Error} If the binary fails or returns non-JSON output (with
68
- * the failing query and an output preview included in the message).
49
+ * Resolve the map file path for a given project root.
50
+ * @param {string} cwd
51
+ * @returns {string}
69
52
  */
70
- function runQuery(basePath, queryArgs) {
71
- const mapFile = mapFilePath(basePath);
53
+ function resolveMapFile(cwd) {
54
+ const stateDir = getStateDir(cwd);
55
+ return path.join(cwd, stateDir, MAP_FILE_NAME);
56
+ }
72
57
 
73
- // Surface a clear "run init first" message instead of letting the binary
74
- // exit non-zero with a low-level "no such file" error that would bubble
75
- // up unannotated through `binary.runAnalyzer`.
58
+ /**
59
+ * Assert the map file exists, throwing `RepoIntelMissingError` if not.
60
+ * @param {string} cwd
61
+ * @returns {string} resolved map file path
62
+ */
63
+ function requireMapFile(cwd) {
64
+ const mapFile = resolveMapFile(cwd);
76
65
  if (!fs.existsSync(mapFile)) {
77
66
  throw new RepoIntelMissingError(mapFile);
78
67
  }
68
+ return mapFile;
69
+ }
79
70
 
80
- const args = ['repo-intel', 'query', ...queryArgs, '--map-file', mapFile, basePath];
81
-
82
- let output;
71
+ /**
72
+ * Run a repo-intel query and parse JSON output.
73
+ * @param {string} queryName - The subcommand name (e.g. 'hotspots')
74
+ * @param {string[]} extraArgs - Additional argv elements
75
+ * @param {string} cwd - Project root
76
+ * @returns {*} Parsed JSON result
77
+ */
78
+ function runQuery(queryName, extraArgs, cwd) {
79
+ const mapFile = requireMapFile(cwd);
80
+ const args = ['repo-intel', 'query', queryName, ...extraArgs, '--map-file', mapFile, cwd];
81
+ let raw;
83
82
  try {
84
- output = binary.runAnalyzer(args);
83
+ raw = binary.runAnalyzer(args);
85
84
  } catch (err) {
86
- // Wrap so the caller learns which query failed without having to dig
87
- // through the CLI argv. The original error stays as `cause`.
88
- const wrapped = new Error(
89
- `repo-intel query failed (${queryArgs.join(' ')}): ${err.message}`
85
+ throw new Error(
86
+ `repo-intel query failed [${queryName}]: ${err.message}`,
87
+ { cause: err }
90
88
  );
91
- wrapped.cause = err;
92
- throw wrapped;
93
89
  }
94
-
90
+ let parsed;
95
91
  try {
96
- return JSON.parse(output);
97
- } catch (err) {
98
- const preview = output && output.length > 0 ? output.slice(0, 200) : '<empty>';
99
- const wrapped = new Error(
100
- `repo-intel query returned non-JSON output (${queryArgs.join(' ')}): ${preview}`
92
+ parsed = JSON.parse(raw);
93
+ } catch (_parseErr) {
94
+ const preview = raw.slice(0, 200);
95
+ throw new Error(
96
+ `repo-intel query [${queryName}] returned non-JSON output: ${preview}`
101
97
  );
102
- wrapped.cause = err;
103
- throw wrapped;
104
98
  }
99
+ return parsed;
105
100
  }
106
101
 
107
- // ─── Activity ───────────────────────────────────────────────────────────────
102
+ // ---------------------------------------------------------------------------
103
+ // Query functions
104
+ // ---------------------------------------------------------------------------
108
105
 
109
106
  /**
110
- * Return files sorted by recency-weighted change score.
111
- *
112
- * @param {string} basePath
113
- * @param {Object} [options={}]
114
- * @param {number} [options.limit] - Maximum number of results
107
+ * Hotspot files by change frequency.
108
+ * @param {string} cwd
109
+ * @param {{ limit?: number }} [opts]
110
+ * @returns {Array}
115
111
  */
116
- function hotspots(basePath, options = {}) {
117
- const args = ['hotspots'];
118
- if (options.limit) args.push('--top', String(options.limit));
119
- return runQuery(basePath, args);
112
+ function hotspots(cwd, opts = {}) {
113
+ const extra = [];
114
+ if (opts.limit != null) extra.push('--top', String(opts.limit));
115
+ return runQuery('hotspots', extra, cwd);
120
116
  }
121
117
 
122
118
  /**
123
- * Return least-changed files (no recent activity).
119
+ * Co-change coupling for a file.
120
+ * @param {string} cwd
121
+ * @param {string} file
122
+ * @param {{ limit?: number }} [opts]
123
+ * @returns {Array}
124
124
  */
125
- function coldspots(basePath, options = {}) {
126
- const args = ['coldspots'];
127
- if (options.limit) args.push('--top', String(options.limit));
128
- return runQuery(basePath, args);
125
+ function coupling(cwd, file, opts = {}) {
126
+ const extra = [file];
127
+ if (opts.limit != null) extra.push('--top', String(opts.limit));
128
+ return runQuery('coupling', extra, cwd);
129
129
  }
130
130
 
131
131
  /**
132
- * Return change history for a specific file.
132
+ * Bus factor report.
133
+ * @param {string} cwd
134
+ * @param {{ adjustForAi?: boolean, limit?: number }} [opts]
135
+ * @returns {Object}
133
136
  */
134
- function fileHistory(basePath, file) {
135
- return runQuery(basePath, ['file-history', file]);
137
+ function busFactor(cwd, opts = {}) {
138
+ const extra = [];
139
+ if (opts.adjustForAi) extra.push('--adjust-for-ai');
140
+ if (opts.limit != null) extra.push('--top', String(opts.limit));
141
+ return runQuery('bus-factor', extra, cwd);
136
142
  }
137
143
 
138
- // ─── Quality ────────────────────────────────────────────────────────────────
139
-
140
144
  /**
141
- * Return files with highest bug-fix density.
145
+ * Files with low test coverage relative to change frequency.
146
+ * @param {string} cwd
147
+ * @param {{ limit?: number, minChanges?: number }} [opts]
148
+ * @returns {Array}
142
149
  */
143
- function bugspots(basePath, options = {}) {
144
- const args = ['bugspots'];
145
- if (options.limit) args.push('--top', String(options.limit));
146
- return runQuery(basePath, args);
150
+ function testGaps(cwd, opts = {}) {
151
+ const extra = [];
152
+ if (opts.limit != null) extra.push('--top', String(opts.limit));
153
+ if (opts.minChanges != null) extra.push('--min-changes', String(opts.minChanges));
154
+ return runQuery('test-gaps', extra, cwd);
147
155
  }
148
156
 
149
157
  /**
150
- * Return hot source files with no co-changing test file.
158
+ * AI-authored ratio per file or project.
159
+ * @param {string} cwd
160
+ * @param {{ pathFilter?: string }} [opts]
161
+ * @returns {Object}
151
162
  */
152
- function testGaps(basePath, options = {}) {
153
- const args = ['test-gaps'];
154
- if (options.limit) args.push('--top', String(options.limit));
155
- if (options.minChanges) args.push('--min-changes', String(options.minChanges));
156
- return runQuery(basePath, args);
163
+ function aiRatio(cwd, opts = {}) {
164
+ const extra = [];
165
+ if (opts.pathFilter != null) extra.push('--path-filter', opts.pathFilter);
166
+ return runQuery('ai-ratio', extra, cwd);
157
167
  }
158
168
 
159
169
  /**
160
- * Score changed files by composite risk.
161
- *
162
- * Validates the joined file argument fits within the platform's argv length
163
- * cap before shelling out (Windows is the tight constraint at ~32 KB).
164
- * Callers with very large diffs should batch.
165
- *
166
- * @param {string} basePath
167
- * @param {string[]} files - List of changed file paths
168
- * @throws {TypeError} If `files` is not an array of strings.
169
- * @throws {RangeError} If the joined argument exceeds {@link MAX_FILES_ARG_LEN}.
170
+ * Risk score for a diff (set of changed files).
171
+ * @param {string} cwd
172
+ * @param {string[]} files
173
+ * @returns {Array}
170
174
  */
171
- function diffRisk(basePath, files) {
172
- if (!Array.isArray(files) || !files.every((f) => typeof f === 'string')) {
173
- throw new TypeError('diffRisk: `files` must be an array of strings');
175
+ function diffRisk(cwd, files) {
176
+ if (!Array.isArray(files)) {
177
+ throw new TypeError('diffRisk: files must be an array of strings');
178
+ }
179
+ if (!files.every(f => typeof f === 'string')) {
180
+ throw new TypeError('diffRisk: all entries in files must be strings');
174
181
  }
175
182
  const joined = files.join(',');
176
- if (joined.length > MAX_FILES_ARG_LEN) {
183
+ if (joined.length > 30000) {
177
184
  throw new RangeError(
178
- `diffRisk: joined file argument is ${joined.length} chars, ` +
179
- `exceeds platform-safe limit of ${MAX_FILES_ARG_LEN}. ` +
180
- `Split the request into batches (~500 paths each is typically safe).`
185
+ `diffRisk: files argument exceeds 30000 character limit (got ${joined.length})`
181
186
  );
182
187
  }
183
- return runQuery(basePath, ['diff-risk', '--files', joined]);
184
- }
185
-
186
- /**
187
- * Files ranked by hotspot × (1 + bug_rate) × (1 + complexity/30). Requires
188
- * Phase 2 AST data; falls back to git-only when unavailable.
189
- */
190
- function painspots(basePath, options = {}) {
191
- const args = ['painspots'];
192
- if (options.limit) args.push('--top', String(options.limit));
193
- return runQuery(basePath, args);
188
+ const extra = ['--files', joined];
189
+ return runQuery('diff-risk', extra, cwd);
194
190
  }
195
191
 
196
- // ─── People ─────────────────────────────────────────────────────────────────
197
-
198
192
  /**
199
- * Return ownership breakdown for a file or directory.
193
+ * Call-graph dependents of a symbol.
194
+ * @param {string} cwd
195
+ * @param {string} symbol
196
+ * @param {string} [file] - Scope to a specific file
197
+ * @returns {Object}
200
198
  */
201
- function ownership(basePath, file) {
202
- return runQuery(basePath, ['ownership', file]);
199
+ function dependents(cwd, symbol, file) {
200
+ const extra = [symbol];
201
+ if (file != null) extra.push('--file', file);
202
+ return runQuery('dependents', extra, cwd);
203
203
  }
204
204
 
205
205
  /**
206
- * Return contributors sorted by commit count.
206
+ * Bugspot risk scores.
207
+ * @param {string} cwd
208
+ * @param {{ limit?: number }} [opts]
209
+ * @returns {Array}
207
210
  */
208
- function contributors(basePath, options = {}) {
209
- const args = ['contributors'];
210
- if (options.limit) args.push('--top', String(options.limit));
211
- return runQuery(basePath, args);
211
+ function bugspots(cwd, opts = {}) {
212
+ const extra = [];
213
+ if (opts.limit != null) extra.push('--top', String(opts.limit));
214
+ return runQuery('bugspots', extra, cwd);
212
215
  }
213
216
 
214
217
  /**
215
- * Detailed bus factor with critical owners and at-risk areas.
218
+ * Project health summary.
219
+ * @param {string} cwd
220
+ * @returns {Object}
216
221
  */
217
- function busFactor(basePath, options = {}) {
218
- const args = ['bus-factor'];
219
- if (options.adjustForAi) args.push('--adjust-for-ai');
220
- return runQuery(basePath, args);
222
+ function health(cwd) {
223
+ return runQuery('health', [], cwd);
221
224
  }
222
225
 
223
- // ─── Coupling ───────────────────────────────────────────────────────────────
226
+ // ---------------------------------------------------------------------------
227
+ // Graph queries (Phase 5.1 - agent-analyzer v0.4.0+)
228
+ // ---------------------------------------------------------------------------
224
229
 
225
230
  /**
226
- * Files that frequently change together with `file`.
231
+ * Louvain-discovered file communities.
232
+ * @param {string} cwd
233
+ * @returns {Array}
227
234
  */
228
- function coupling(basePath, file) {
229
- return runQuery(basePath, ['coupling', file]);
235
+ function communities(cwd) {
236
+ return runQuery('communities', [], cwd);
230
237
  }
231
238
 
232
- // ─── Standards ──────────────────────────────────────────────────────────────
233
-
234
239
  /**
235
- * Project norms (commit conventions, etc.) detected from git history.
240
+ * Files bridging multiple communities (architectural seams).
241
+ * @param {string} cwd
242
+ * @param {{ limit?: number }} [opts]
243
+ * @returns {Array}
236
244
  */
237
- function norms(basePath) {
238
- return runQuery(basePath, ['norms']);
245
+ function boundaries(cwd, opts = {}) {
246
+ const extra = [];
247
+ if (opts.limit != null) extra.push('--top', String(opts.limit));
248
+ return runQuery('boundaries', extra, cwd);
239
249
  }
240
250
 
241
251
  /**
242
- * Commit message style + prefixes + scope usage.
252
+ * Which community a file belongs to.
253
+ * @param {string} cwd
254
+ * @param {string} file
255
+ * @returns {Object}
243
256
  */
244
- function conventions(basePath) {
245
- return runQuery(basePath, ['conventions']);
257
+ function areaOf(cwd, file) {
258
+ return runQuery('area-of', [file], cwd);
246
259
  }
247
260
 
248
- // ─── Health ─────────────────────────────────────────────────────────────────
249
-
250
261
  /**
251
- * Directory-level health overview.
262
+ * Composite health roll-up for a community.
263
+ * @param {string} cwd
264
+ * @param {number} id - Community id (non-negative integer)
265
+ * @returns {Object}
252
266
  */
253
- function areas(basePath) {
254
- return runQuery(basePath, ['areas']);
255
- }
256
-
257
- /**
258
- * Repository-wide health summary.
259
- */
260
- function health(basePath) {
261
- return runQuery(basePath, ['health']);
262
- }
263
-
264
- /**
265
- * Release cadence and tag history.
266
- */
267
- function releaseInfo(basePath) {
268
- return runQuery(basePath, ['release-info']);
269
- }
270
-
271
- // ─── AI detection ───────────────────────────────────────────────────────────
272
-
273
- /**
274
- * AI vs human contribution ratio.
275
- */
276
- function aiRatio(basePath, options = {}) {
277
- const args = ['ai-ratio'];
278
- if (options.pathFilter) args.push('--path-filter', options.pathFilter);
279
- return runQuery(basePath, args);
280
- }
281
-
282
- /**
283
- * Files with recent AI-authored changes.
284
- */
285
- function recentAi(basePath, options = {}) {
286
- const args = ['recent-ai'];
287
- if (options.limit) args.push('--top', String(options.limit));
288
- return runQuery(basePath, args);
289
- }
290
-
291
- // ─── Contributor guidance ───────────────────────────────────────────────────
292
-
293
- /**
294
- * Newcomer-oriented repo summary (tech stack, key areas, pain points).
295
- */
296
- function onboard(basePath) {
297
- return runQuery(basePath, ['onboard']);
298
- }
299
-
300
- /**
301
- * Contribution guidance: good-first areas, test gaps, doc drift, bugspots.
302
- */
303
- function canIHelp(basePath) {
304
- return runQuery(basePath, ['can-i-help']);
305
- }
306
-
307
- // ─── Documentation ──────────────────────────────────────────────────────────
308
-
309
- /**
310
- * Doc files with low code coupling (likely stale).
311
- */
312
- function docDrift(basePath, options = {}) {
313
- const args = ['doc-drift'];
314
- if (options.limit) args.push('--top', String(options.limit));
315
- return runQuery(basePath, args);
316
- }
317
-
318
- /**
319
- * Doc files with stale references to source symbols. Requires Phase 4
320
- * sync-check data.
321
- */
322
- function staleDocs(basePath, options = {}) {
323
- const args = ['stale-docs'];
324
- if (options.limit) args.push('--top', String(options.limit));
325
- return runQuery(basePath, args);
326
- }
327
-
328
- // ─── AST symbols ────────────────────────────────────────────────────────────
329
-
330
- /**
331
- * AST symbols (exports, imports, definitions) for a specific file. Requires
332
- * Phase 2 AST data.
333
- */
334
- function symbols(basePath, file) {
335
- return runQuery(basePath, ['symbols', file]);
336
- }
337
-
338
- /**
339
- * Files that import a given symbol (reverse dependency lookup). Requires
340
- * Phase 2 AST data.
341
- */
342
- function dependents(basePath, symbol, file) {
343
- const args = ['dependents', symbol];
344
- if (file) args.push('--file', file);
345
- return runQuery(basePath, args);
346
- }
347
-
348
- // ─── Phase 5: Graph-derived (analyzer-graph crate) ──────────────────────────
349
-
350
- /**
351
- * Communities discovered by Louvain modularity over the co-change graph.
352
- * Returns clusters of files that consistently change together - the natural
353
- * feature areas, independent of directory layout. Requires agent-analyzer
354
- * v0.4.0+.
355
- *
356
- * @param {string} basePath
357
- * @returns {Array<{id: number, size: number, files: string[]}>}
358
- */
359
- function communities(basePath) {
360
- return runQuery(basePath, ['communities']);
361
- }
362
-
363
- /**
364
- * Files bridging multiple communities (high betweenness centrality). These
365
- * are the architectural seams - the highest-leverage files for refactoring
366
- * decisions. Requires agent-analyzer v0.4.0+.
367
- *
368
- * @param {string} basePath
369
- * @param {Object} [options={}]
370
- * @param {number} [options.limit] - Maximum number of results
371
- * @returns {Array<{path: string, betweenness: number, community: number|null}>}
372
- */
373
- function boundaries(basePath, options = {}) {
374
- const args = ['boundaries'];
375
- if (options.limit) args.push('--top', String(options.limit));
376
- return runQuery(basePath, args);
377
- }
378
-
379
- /**
380
- * Look up which community a given file belongs to. Requires agent-analyzer
381
- * v0.4.0+.
382
- *
383
- * @param {string} basePath
384
- * @param {string} file - File path (relative to repo root)
385
- * @returns {{file: string, community: number|null, size: number|null}}
386
- */
387
- function areaOf(basePath, file) {
388
- return runQuery(basePath, ['area-of', file]);
389
- }
390
-
391
- /**
392
- * Composite per-community health: total/recent changes, bug-fix rate,
393
- * AI ratio, stale-owner count. Use to identify communities under stress
394
- * (high bug rate or stale ownership). Requires agent-analyzer v0.4.0+.
395
- *
396
- * @param {string} basePath
397
- * @param {number} id - Community id (from `communities()`)
398
- * @returns {Object|null}
399
- */
400
- function communityHealth(basePath, id) {
401
- if (!Number.isInteger(id) || id < 0) {
267
+ function communityHealth(cwd, id) {
268
+ if (typeof id !== 'number' || !Number.isInteger(id) || id < 0) {
402
269
  throw new TypeError(
403
- `communityHealth: \`id\` must be a non-negative integer, got: ${id} (${typeof id})`
270
+ 'communityHealth: id must be a non-negative integer'
404
271
  );
405
272
  }
406
- return runQuery(basePath, ['community-health', String(id)]);
273
+ return runQuery('community-health', [String(id)], cwd);
407
274
  }
408
275
 
276
+ // ---------------------------------------------------------------------------
277
+ // Exports
278
+ // ---------------------------------------------------------------------------
279
+
409
280
  module.exports = {
410
- // Errors
281
+ // Error class
411
282
  RepoIntelMissingError,
412
- // Activity
283
+
284
+ // Core queries
413
285
  hotspots,
414
- coldspots,
415
- fileHistory,
416
- // Quality
417
- bugspots,
418
- testGaps,
419
- diffRisk,
420
- painspots,
421
- // People
422
- ownership,
423
- contributors,
424
- busFactor,
425
- // Coupling
426
286
  coupling,
427
- // Standards
428
- norms,
429
- conventions,
430
- // Health
431
- areas,
432
- health,
433
- releaseInfo,
434
- // AI detection
287
+ busFactor,
288
+ testGaps,
435
289
  aiRatio,
436
- recentAi,
437
- // Contributor guidance
438
- onboard,
439
- canIHelp,
440
- // Documentation
441
- docDrift,
442
- staleDocs,
443
- // AST symbols
444
- symbols,
290
+ diffRisk,
445
291
  dependents,
446
- // Phase 5: graph-derived
292
+ bugspots,
293
+ health,
294
+
295
+ // Graph queries
447
296
  communities,
448
297
  boundaries,
449
298
  areaOf,
@@ -6,7 +6,6 @@
6
6
  */
7
7
 
8
8
  const sourceCache = require('./source-cache');
9
- const { truncate } = require('../cross-platform');
10
9
  const customHandler = require('./custom-handler');
11
10
 
12
11
  /**
@@ -43,7 +42,7 @@ function getPolicyQuestions() {
43
42
  // Truncate to fit within 30 chars: "X (last used)" where X can be max 17 chars
44
43
  const maxBaseLen = 30 - ' (last used)'.length; // 18 chars for base
45
44
  const truncatedLabel = cachedLabel.length > maxBaseLen
46
- ? truncate(cachedLabel, maxBaseLen - 1) + '…'
45
+ ? cachedLabel.substring(0, maxBaseLen - 1) + '…'
47
46
  : cachedLabel;
48
47
 
49
48
  sourceOptions.push({
@@ -156,12 +155,12 @@ function parseAndCachePolicy(responses) {
156
155
  // Validate and merge follow-up responses
157
156
  const rawNum = String(responses.project.number).trim();
158
157
  if (!/^[1-9][0-9]*$/.test(rawNum)) {
159
- const safeNum = truncate(String(responses.project.number).replace(/[^\x20-\x7E]/g, '?'), 32);
158
+ const safeNum = String(responses.project.number).replace(/[^\x20-\x7E]/g, '?').substring(0, 32);
160
159
  throw new Error(`Invalid project number: "${safeNum}". Must be a positive integer.`);
161
160
  }
162
161
  const num = Number(rawNum);
163
162
  const owner = String(responses.project.owner || '').trim();
164
- const safeOwner = truncate(String(responses.project.owner || '').replace(/[^\x20-\x7E]/g, '?'), 64);
163
+ const safeOwner = String(responses.project.owner || '').replace(/[^\x20-\x7E]/g, '?').substring(0, 64);
165
164
  if (!owner || !/^(@me|[a-zA-Z0-9][a-zA-Z0-9_-]*)$/.test(owner)) {
166
165
  throw new Error(`Invalid project owner: "${safeOwner}" (use @me or an org/user name)`);
167
166
  }