@ngockhoale/ukit 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/package.json +1 -1
  3. package/src/bug/triageBug.js +1 -33
  4. package/src/cli/commands/install.js +5 -10
  5. package/src/context/detectProjectContext.js +3 -24
  6. package/src/core/compact/index.js +19 -27
  7. package/src/core/ensureGitignore.js +1 -1
  8. package/src/core/fileOps.js +41 -2
  9. package/src/core/memory/hygiene.js +17 -1
  10. package/src/core/memory/store.js +14 -36
  11. package/src/core/metadata.js +5 -5
  12. package/src/core/output/index.js +20 -20
  13. package/src/core/packageManager.js +51 -0
  14. package/src/core/router/router.js +22 -6
  15. package/src/core/runInstallPipeline.js +1 -36
  16. package/src/core/runtimeConfig.js +71 -3
  17. package/src/core/token/index.js +21 -1
  18. package/src/core/uninstall.js +15 -38
  19. package/src/index/buildIndex.js +217 -49
  20. package/src/index/gitHooks.js +32 -7
  21. package/src/index/impactContext.js +16 -6
  22. package/src/index/importResolution.js +105 -28
  23. package/src/index/paths.js +29 -0
  24. package/src/index/queryIndex.js +20 -35
  25. package/src/index/relatedTests.js +15 -2
  26. package/src/index/routeCatalog.js +1 -1
  27. package/src/index/taskRouting.js +438 -18
  28. package/src/index/verificationPlan.js +2 -36
  29. package/templates/.claude/hooks/reinject-context.sh +2 -0
  30. package/templates/.claude/hooks/session-start.md +2 -0
  31. package/templates/.claude/hooks/skill-router.sh +657 -15
  32. package/templates/.claude/ukit/index/route-catalog.mjs +1 -1
  33. package/templates/.claude/ukit/index/route-task.mjs +475 -5
  34. package/templates/.claude/ukit/runtime/reinject-context.mjs +120 -3
  35. package/templates/.codex/README.md +8 -1
  36. package/templates/.codex/settings.json +53 -0
  37. package/templates/AGENTS.md +3 -0
  38. package/templates/CLAUDE.md +5 -1
  39. package/templates/ukit/README.md +1 -1
  40. package/templates/ukit/storage/config.json +61 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,30 @@ All notable changes to UKit are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 1.4.2 - 2026-05-10
8
+
9
+ ### Added
10
+
11
+ - Added a quality-first internal orchestration ladder with seven execution layers: `tiny-fix`, `local-fix`, `local-build`, `find-cause`, `shared-edit`, `map-impact`, and `review-release`.
12
+ - Added structured route continuity state so installed helpers and hooks can carry `completionState`, `continuationState`, and stuck-lane rescue signals across turns instead of forgetting unfinished execution debt.
13
+ - Added orchestration/runtime config defaults and Codex adapter metadata for the internal advisor/orchestrator contract while keeping the teammate workflow centered on `ukit install`.
14
+
15
+ ### Fixed
16
+
17
+ - Kept context routing and context-mode hints internal so UKit no longer surfaces `pull-indexed-context`, `resolve-context`, or `mode=LITE/FULL` in the main route prose for ordinary work.
18
+ - Preserved the structured route state (`nextActionType`, `helperHint`, `contextMode`) for internal orchestration, while removing the user/model-facing leakage that was nudging sessions into micro-step stop-and-wait behavior.
19
+ - Hardened implementation routing and installed agent guidance so explicit implement/apply/fix requests keep moving through bounded read → edit → verify flow instead of stopping after read-only inspection.
20
+ - Escalated repeated unfinished returns with `repeatCount`, `stuckRisk`, and `rescueMode` so UKit can rescue a stuck lane instead of quietly looping the same partial milestone.
21
+ - Fixed the installed `route-task` continuation handoff so previous route summaries are carried through correctly during rescue escalation.
22
+
23
+ ### Tests
24
+
25
+ - Added/updated route, hook, reinject, install-pipeline, and package-artifact coverage for internal helper hiding, continuation rescue escalation, and the expanded orchestration contract.
26
+
27
+ ## 1.4.1 - 2026-05-09
28
+
29
+ - Completed cleanup and 1.4.1 template alignment.
30
+
7
31
  ## 1.4.0 - 2026-05-09
8
32
 
9
33
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, Antigravity, OpenAI Codex, and OpenCode.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,7 @@
1
1
  import { queryCodeIndex } from '../index/queryIndex.js';
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { detectPackageManager } from '../core/packageManager.js';
4
5
  import { inferRelatedTestsFromArtifacts, loadRelatedTestArtifacts } from '../index/relatedTests.js';
5
6
 
6
7
  const DEEP_KEYWORDS = ['race', 'flaky', 'intermittent', 'timeout', 'deadlock'];
@@ -88,36 +89,3 @@ async function buildTestCommand(rootDir, testFile) {
88
89
  // npm default
89
90
  return `npm test -- ${testFile}`;
90
91
  }
91
-
92
- async function detectPackageManager(rootDir) {
93
- const packageJsonPath = path.join(rootDir, 'package.json');
94
- try {
95
- const raw = await fs.readFile(packageJsonPath, 'utf8');
96
- const pkg = JSON.parse(raw);
97
- const declared = String(pkg?.packageManager ?? '').toLowerCase();
98
- if (declared.startsWith('pnpm')) return 'pnpm';
99
- if (declared.startsWith('yarn')) return 'yarn';
100
- if (declared.startsWith('bun')) return 'bun';
101
- if (declared.startsWith('npm')) return 'npm';
102
- } catch {
103
- // fall through to lockfile detection
104
- }
105
-
106
- const checks = [
107
- ['pnpm-lock.yaml', 'pnpm'],
108
- ['yarn.lock', 'yarn'],
109
- ['bun.lockb', 'bun'],
110
- ['package-lock.json', 'npm'],
111
- ];
112
-
113
- for (const [lockfile, pm] of checks) {
114
- try {
115
- await fs.access(path.join(rootDir, lockfile));
116
- return pm;
117
- } catch {
118
- // continue
119
- }
120
- }
121
-
122
- return 'npm';
123
- }
@@ -106,6 +106,9 @@ export async function pruneDeselectedAdapters({
106
106
  createInterface = readline.createInterface,
107
107
  }) {
108
108
  const retainedManagedPaths = [];
109
+ const retainPaths = (paths) => {
110
+ retainedManagedPaths.push(...paths);
111
+ };
109
112
  const installMetaPath = path.join(projectRoot, '.claude', 'ukit', '.ukit', 'install.json');
110
113
  const deselectedKeys = getDeselectedAdapterKeys(optionalToolKeys);
111
114
  if (deselectedKeys.length === 0) {
@@ -139,11 +142,7 @@ export async function pruneDeselectedAdapters({
139
142
  const { input: inputStream, output: outputStream } = io;
140
143
  if (!inputStream?.isTTY || !outputStream?.isTTY) {
141
144
  const adapterNames = existingDeselectedAdapters.map(({ adapter }) => adapter.label).join(', ');
142
- await removeTrackedPathsFromMetadata({
143
- installMetaPath,
144
- projectRoot,
145
- removeRelativePaths: existingDeselectedAdapters.flatMap(({ existingManagedPaths }) => existingManagedPaths),
146
- });
145
+ retainPaths(existingDeselectedAdapters.flatMap(({ existingManagedPaths }) => existingManagedPaths));
147
146
  console.log(
148
147
  `[UKit] Deselected adapters with existing files detected (${adapterNames}). Skipping removal in non-interactive mode.`,
149
148
  );
@@ -160,11 +159,7 @@ export async function pruneDeselectedAdapters({
160
159
  );
161
160
 
162
161
  if (!shouldRemove) {
163
- await removeTrackedPathsFromMetadata({
164
- installMetaPath,
165
- projectRoot,
166
- removeRelativePaths: existingManagedPaths,
167
- });
162
+ retainPaths(existingManagedPaths);
168
163
  continue;
169
164
  }
170
165
 
@@ -1,27 +1,6 @@
1
1
  import path from 'node:path';
2
- import { pathExists, readJsonIfExists } from '../core/fileOps.js';
3
-
4
- async function inferPackageManager(projectRoot, packageJson) {
5
- // packageManager field takes priority over lockfiles — it is an explicit
6
- // declaration of intent, while lockfiles may be stale (e.g. old yarn.lock
7
- // leftover after switching to npm).
8
- const declared = packageJson?.packageManager;
9
- if (typeof declared === 'string') {
10
- if (declared.startsWith('yarn@')) return 'yarn';
11
- if (declared.startsWith('pnpm@')) return 'pnpm';
12
- if (declared.startsWith('npm@')) return 'npm';
13
- }
14
-
15
- if (await pathExists(path.join(projectRoot, 'yarn.lock'))) {
16
- return 'yarn';
17
- }
18
-
19
- if (await pathExists(path.join(projectRoot, 'pnpm-lock.yaml'))) {
20
- return 'pnpm';
21
- }
22
-
23
- return 'npm';
24
- }
2
+ import { readJsonIfExists } from '../core/fileOps.js';
3
+ import { detectPackageManager } from '../core/packageManager.js';
25
4
 
26
5
  function inferProjectName(projectRoot, packageJson) {
27
6
  if (typeof packageJson?.name === 'string' && packageJson.name.trim() !== '') {
@@ -41,7 +20,7 @@ export async function detectProjectContext(projectRoot) {
41
20
  name: inferProjectName(projectRoot, packageJson),
42
21
  },
43
22
  runtime: {
44
- packageManager: await inferPackageManager(projectRoot, packageJson),
23
+ packageManager: await detectPackageManager(projectRoot, packageJson),
45
24
  os: process.platform,
46
25
  nodeVersion: process.version,
47
26
  },
@@ -1,6 +1,6 @@
1
1
  import { readJsonIfExists, writeJson } from '../fileOps.js';
2
2
  import { buildRuntimePaths } from '../runtimePaths.js';
3
- import { compressLine, compressMarkdownLines, estimateTokenCount } from '../token/index.js';
3
+ import { compressLine, compressMarkdownLines, estimateTokenCount, normalizeLineForDedupe, uniqueLines } from '../token/index.js';
4
4
 
5
5
  export const DEFAULT_COMPACT_HISTORY_MAX_ENTRIES = 50;
6
6
  const DEFAULT_MAX_COMPACT_ANCHORS = 3;
@@ -132,27 +132,6 @@ function normalizeAnchorPattern(pattern) {
132
132
  return null;
133
133
  }
134
134
 
135
- function normalizeLineForDedupe(line) {
136
- return String(line ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
137
- }
138
-
139
- function uniqueLines(lines) {
140
- const seen = new Set();
141
- const unique = [];
142
-
143
- for (const line of lines) {
144
- const trimmed = String(line ?? '').trim();
145
- const normalized = normalizeLineForDedupe(trimmed);
146
- if (!normalized || seen.has(normalized)) {
147
- continue;
148
- }
149
- seen.add(normalized);
150
- unique.push(trimmed);
151
- }
152
-
153
- return unique;
154
- }
155
-
156
135
  function resolveAnchorLines(rawLines, { header = null, anchorLines = [], anchorPatterns = [], maxAnchors = DEFAULT_MAX_COMPACT_ANCHORS } = {}) {
157
136
  const predicates = anchorPatterns
158
137
  .map((pattern) => normalizeAnchorPattern(pattern))
@@ -182,6 +161,7 @@ function buildCompactedContextLines(
182
161
  forceFirstCount = 0,
183
162
  } = {},
184
163
  ) {
164
+ const hardTokenCap = Math.max(maxTokens, Math.ceil(maxTokens * 2));
185
165
  const sourceLines = Array.isArray(lines) ? lines : [];
186
166
  const selected = [];
187
167
  const seen = new Set();
@@ -201,19 +181,24 @@ function buildCompactedContextLines(
201
181
 
202
182
  const tokens = estimateTokenCount(compressed);
203
183
  const forceLine = nonEmptyCount < forceFirstCount;
204
- const wouldOverflow = selected.length > 0 && (usedTokens + tokens) > maxTokens;
205
184
  const wouldExceedLines = nonEmptyCount >= maxLines;
206
185
 
207
- if (!forceLine && (wouldOverflow || wouldExceedLines)) {
186
+ if (wouldExceedLines || (!forceLine && selected.length > 0 && (usedTokens + tokens) > maxTokens)) {
208
187
  break;
209
188
  }
210
- if (forceLine && wouldExceedLines) {
189
+
190
+ const selectedLine = forceLine && (usedTokens + tokens) > hardTokenCap
191
+ ? truncateToTokenBudget(compressed, hardTokenCap - usedTokens)
192
+ : compressed;
193
+ const selectedTokens = estimateTokenCount(selectedLine);
194
+
195
+ if (!selectedLine) {
211
196
  break;
212
197
  }
213
198
 
214
- selected.push(compressed);
199
+ selected.push(selectedLine);
215
200
  seen.add(dedupeKey);
216
- usedTokens += tokens;
201
+ usedTokens += selectedTokens;
217
202
  nonEmptyCount += 1;
218
203
  }
219
204
 
@@ -236,6 +221,13 @@ function findMissingAnchors(text, anchorLines) {
236
221
  });
237
222
  }
238
223
 
224
+ function truncateToTokenBudget(line, maxTokens) {
225
+ if (maxTokens <= 0) return '';
226
+ const maxChars = Math.max(1, (maxTokens * 4) - 4);
227
+ const value = String(line ?? '');
228
+ return value.length <= maxChars ? value : `${value.slice(0, Math.max(1, maxChars - 1)).trimEnd()}…`;
229
+ }
230
+
239
231
  export async function readCompactHistory(projectRoot, options = {}) {
240
232
  const runtimePaths = buildRuntimePaths(projectRoot);
241
233
  const raw = await readJsonIfExists(runtimePaths.compactHistoryPath);
@@ -59,7 +59,7 @@ export async function ensureGitignore(projectRoot) {
59
59
  .split('\n')
60
60
  .map((l) => l.trim())
61
61
  .filter((trimmed) => trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('!'));
62
- const missing = UKIT_ENTRIES.filter((e) => !activeLines.some((l) => l.includes(e)));
62
+ const missing = UKIT_ENTRIES.filter((e) => !activeLines.some((l) => l === e));
63
63
  if (missing.length === 0) {
64
64
  return false;
65
65
  }
@@ -14,8 +14,11 @@ export async function readJsonIfExists(filePath) {
14
14
  try {
15
15
  const raw = await fs.readFile(filePath, 'utf8');
16
16
  return JSON.parse(raw);
17
- } catch {
18
- return null;
17
+ } catch (error) {
18
+ if (error?.code === 'ENOENT') {
19
+ return null;
20
+ }
21
+ throw error;
19
22
  }
20
23
  }
21
24
 
@@ -23,6 +26,42 @@ export function escapeRegExp(str) {
23
26
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
24
27
  }
25
28
 
29
+ async function isDirEmpty(dirPath) {
30
+ try {
31
+ const entries = await fs.readdir(dirPath);
32
+ return entries.length === 0;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ export async function cleanupEmptyParents(targetPath, projectRoot) {
39
+ let currentDir = path.dirname(targetPath);
40
+ const stopDir = path.resolve(projectRoot);
41
+
42
+ while (currentDir !== stopDir) {
43
+ const relative = path.relative(stopDir, currentDir);
44
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
45
+ break;
46
+ }
47
+
48
+ let stat;
49
+ try {
50
+ stat = await fs.lstat(currentDir);
51
+ } catch {
52
+ currentDir = path.dirname(currentDir);
53
+ continue;
54
+ }
55
+
56
+ if (!stat.isDirectory() || !(await isDirEmpty(currentDir))) {
57
+ break;
58
+ }
59
+
60
+ await fs.rmdir(currentDir);
61
+ currentDir = path.dirname(currentDir);
62
+ }
63
+ }
64
+
26
65
  export function resolveProjectRelativePath(projectRoot, relPath) {
27
66
  if (typeof relPath !== 'string') {
28
67
  return null;
@@ -141,8 +141,24 @@ function archiveSessions(projectMemory, config) {
141
141
  return archived;
142
142
  }
143
143
 
144
- export function runHygiene(projectMemory, config = {}) {
144
+ function normalizeConfig(config = {}) {
145
145
  const mergedConfig = { ...DEFAULT_CONFIG, ...config };
146
+ return {
147
+ ...mergedConfig,
148
+ archiveAfterDays: Number.isFinite(mergedConfig.archiveAfterDays) && mergedConfig.archiveAfterDays > 0
149
+ ? mergedConfig.archiveAfterDays
150
+ : DEFAULT_CONFIG.archiveAfterDays,
151
+ maxSessionsKept: Number.isInteger(mergedConfig.maxSessionsKept) && mergedConfig.maxSessionsKept > 0
152
+ ? mergedConfig.maxSessionsKept
153
+ : DEFAULT_CONFIG.maxSessionsKept,
154
+ redactPatterns: Array.isArray(mergedConfig.redactPatterns)
155
+ ? mergedConfig.redactPatterns
156
+ : DEFAULT_CONFIG.redactPatterns,
157
+ };
158
+ }
159
+
160
+ export function runHygiene(projectMemory, config = {}) {
161
+ const mergedConfig = normalizeConfig(config);
146
162
  const nextMemory = redactValue(clone(projectMemory), mergedConfig.redactPatterns);
147
163
 
148
164
  nextMemory.conventions = uniqueStrings(nextMemory.conventions);
@@ -85,28 +85,6 @@ function createSearchableText(type, content) {
85
85
  ].filter(Boolean).join('\n');
86
86
  }
87
87
 
88
- function computeRelevance(searchableText, query) {
89
- if (!query) {
90
- return 1;
91
- }
92
-
93
- const haystack = searchableText.toLowerCase();
94
- const needle = query.toLowerCase();
95
- if (!haystack.includes(needle)) {
96
- return 0;
97
- }
98
-
99
- const parts = needle.split(/\s+/).filter(Boolean);
100
- let score = 1;
101
- for (const part of parts) {
102
- if (haystack.includes(part)) {
103
- score += 1;
104
- }
105
- }
106
-
107
- return score;
108
- }
109
-
110
88
  export async function exportMemory(projectRoot) {
111
89
  const runtimePaths = buildRuntimePaths(projectRoot);
112
90
  const user = (await readJsonIfExists(runtimePaths.userMemoryPath)) ?? defaultUserMemory();
@@ -150,18 +128,6 @@ export async function listMemoryItems(projectRoot) {
150
128
  ];
151
129
  }
152
130
 
153
- export async function searchMemoryItems(projectRoot, query, { limit = 10 } = {}) {
154
- const items = await listMemoryItems(projectRoot);
155
- return items
156
- .map((item) => ({
157
- ...item,
158
- relevanceScore: computeRelevance(`${item.summary}\n${item.searchableText}`, query),
159
- }))
160
- .filter((item) => item.relevanceScore > 0)
161
- .sort((left, right) => right.relevanceScore - left.relevanceScore)
162
- .slice(0, limit);
163
- }
164
-
165
131
  export async function forgetMemoryItem(projectRoot, memoryId) {
166
132
  const runtimePaths = buildRuntimePaths(projectRoot);
167
133
  if (memoryId === 'user:user' || memoryId === 'user') {
@@ -169,11 +135,18 @@ export async function forgetMemoryItem(projectRoot, memoryId) {
169
135
  return { removed: true, type: 'user', path: runtimePaths.userMemoryPath };
170
136
  }
171
137
 
172
- const [type, rawId] = String(memoryId).split(':');
173
- if (!type || !rawId) {
138
+ const id = String(memoryId);
139
+ const separatorIndex = id.indexOf(':');
140
+ if (separatorIndex <= 0 || separatorIndex === id.length - 1) {
174
141
  return { removed: false, type: null, path: null };
175
142
  }
176
143
 
144
+ const type = id.slice(0, separatorIndex);
145
+ const rawId = id.slice(separatorIndex + 1);
146
+ if (rawId.includes('/') || rawId.includes('\\') || rawId === '..') {
147
+ return { removed: false, type, path: null };
148
+ }
149
+
177
150
  const baseDir = type === 'project'
178
151
  ? runtimePaths.projectsDir
179
152
  : type === 'session'
@@ -184,6 +157,11 @@ export async function forgetMemoryItem(projectRoot, memoryId) {
184
157
  }
185
158
 
186
159
  const targetPath = path.join(baseDir, `${rawId}.json`);
160
+ const relativeTarget = path.relative(path.resolve(baseDir), path.resolve(targetPath));
161
+ if (!relativeTarget || relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
162
+ return { removed: false, type, path: null };
163
+ }
164
+
187
165
  try {
188
166
  await fs.unlink(targetPath);
189
167
  return { removed: true, type, path: targetPath };
@@ -111,17 +111,17 @@ export async function removeTrackedPathsFromMetadata({
111
111
  return;
112
112
  }
113
113
 
114
- const normalizedTargets = new Set(
115
- removeRelativePaths.map((relPath) => relPath && relPath.replace(/\\/g, '/')),
116
- );
114
+ const targetPaths = [...new Set(removeRelativePaths
115
+ .map((relPath) => (typeof relPath === 'string' ? relPath.trim().replace(/\\/g, '/') : ''))
116
+ .filter(Boolean))];
117
117
 
118
118
  const filteredFiles = metadata.files.filter((entry) => {
119
119
  const p = typeof entry === 'string' ? entry : entry?.p;
120
120
  if (!p) {
121
121
  return true;
122
122
  }
123
- const normalized = p.replace(/\\/g, '/');
124
- return ![...normalizedTargets].some((targetPath) => isSameOrDescendantPath(normalized, targetPath));
123
+ const normalized = p.trim().replace(/\\/g, '/');
124
+ return !targetPaths.some((targetPath) => isSameOrDescendantPath(normalized, targetPath));
125
125
  });
126
126
 
127
127
  if (filteredFiles.length === metadata.files.length) {
@@ -8,7 +8,9 @@ import {
8
8
  compressLine,
9
9
  compressMarkdownLines,
10
10
  estimateTokenCount,
11
+ normalizeLineForDedupe,
11
12
  readPromptCacheEntry,
13
+ uniqueLines,
12
14
  writePromptCacheEntry,
13
15
  } from '../token/index.js';
14
16
 
@@ -191,10 +193,6 @@ function splitLines(value) {
191
193
  .map((line) => line.replace(/\s+$/g, ''));
192
194
  }
193
195
 
194
- function normalizeLineForDedupe(line) {
195
- return String(line ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
196
- }
197
-
198
196
  function matchesAnyPattern(line, patterns = []) {
199
197
  return patterns.some((pattern) => pattern.test(line));
200
198
  }
@@ -284,7 +282,8 @@ function buildRecoveryFileName({
284
282
  profile: String(profile ?? 'generic').trim(),
285
283
  exitCode: Number.isFinite(Number(exitCode)) ? Number(exitCode) : null,
286
284
  }).split(':').at(-1)?.replace(/[^a-z0-9_-]/gi, '-').slice(0, 16) || 'recovery';
287
- return `${slug}-${fingerprint}.log`;
285
+ const uniqueSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
286
+ return `${slug}-${uniqueSuffix}-${fingerprint}.log`;
288
287
  }
289
288
 
290
289
  function buildRawOutputText({ command = '', stdout = '', stderr = '', exitCode = null } = {}) {
@@ -425,6 +424,7 @@ function isTailSummaryLine(line, profile = null) {
425
424
  }
426
425
 
427
426
  function buildCompactedSummaryLines(lines, { maxTokens = 180, maxLines = 10, forceFirstCount = 1 } = {}) {
427
+ const hardTokenCap = Math.max(maxTokens, Math.ceil(maxTokens * 2));
428
428
  const sourceLines = Array.isArray(lines) ? lines : [];
429
429
  const selected = [];
430
430
  const seen = new Set();
@@ -440,19 +440,24 @@ function buildCompactedSummaryLines(lines, { maxTokens = 180, maxLines = 10, for
440
440
 
441
441
  const tokens = estimateTokenCount(compressed);
442
442
  const forceLine = nonEmptyCount < forceFirstCount;
443
- const wouldOverflow = selected.length > 0 && (usedTokens + tokens) > maxTokens;
444
443
  const wouldExceedLines = nonEmptyCount >= maxLines;
445
444
 
446
- if (!forceLine && (wouldOverflow || wouldExceedLines)) {
445
+ if (wouldExceedLines || (!forceLine && selected.length > 0 && (usedTokens + tokens) > maxTokens)) {
447
446
  break;
448
447
  }
449
- if (forceLine && wouldExceedLines) {
448
+
449
+ const selectedLine = forceLine && (usedTokens + tokens) > hardTokenCap
450
+ ? truncateToTokenBudget(compressed, hardTokenCap - usedTokens)
451
+ : compressed;
452
+ const selectedTokens = estimateTokenCount(selectedLine);
453
+
454
+ if (!selectedLine) {
450
455
  break;
451
456
  }
452
457
 
453
- selected.push(compressed);
458
+ selected.push(selectedLine);
454
459
  seen.add(dedupeKey);
455
- usedTokens += tokens;
460
+ usedTokens += selectedTokens;
456
461
  nonEmptyCount += 1;
457
462
  }
458
463
 
@@ -471,16 +476,11 @@ function findMissingAnchors(summary, anchorLines) {
471
476
  });
472
477
  }
473
478
 
474
- function uniqueLines(lines) {
475
- const seen = new Set();
476
- const unique = [];
477
- for (const line of lines) {
478
- const normalized = normalizeLineForDedupe(line);
479
- if (!normalized || seen.has(normalized)) continue;
480
- seen.add(normalized);
481
- unique.push(String(line ?? '').trim());
482
- }
483
- return unique;
479
+ function truncateToTokenBudget(line, maxTokens) {
480
+ if (maxTokens <= 0) return '';
481
+ const maxChars = Math.max(1, (maxTokens * 4) - 4);
482
+ const value = String(line ?? '');
483
+ return value.length <= maxChars ? value : `${value.slice(0, Math.max(1, maxChars - 1)).trimEnd()}…`;
484
484
  }
485
485
 
486
486
  function looksLikeSearchPath(filePath) {
@@ -0,0 +1,51 @@
1
+ import path from 'node:path';
2
+ import { pathExists } from './fileOps.js';
3
+
4
+ export const PACKAGE_MANAGER_LOCKFILES = [
5
+ ['pnpm-lock.yaml', 'pnpm'],
6
+ ['yarn.lock', 'yarn'],
7
+ ['bun.lockb', 'bun'],
8
+ ['bun.lock', 'bun'],
9
+ ['package-lock.json', 'npm'],
10
+ ];
11
+
12
+ export function getDeclaredPackageManager(pkg = null) {
13
+ const declared = String(pkg?.packageManager ?? '').toLowerCase();
14
+ if (declared.startsWith('pnpm')) return 'pnpm';
15
+ if (declared.startsWith('yarn')) return 'yarn';
16
+ if (declared.startsWith('bun')) return 'bun';
17
+ if (declared.startsWith('npm')) return 'npm';
18
+ return null;
19
+ }
20
+
21
+ export async function detectPackageManager(projectRoot, packageJson = null) {
22
+ const declaredPackageManager = getDeclaredPackageManager(packageJson);
23
+ if (declaredPackageManager) return declaredPackageManager;
24
+
25
+ for (const [lockfile, packageManager] of PACKAGE_MANAGER_LOCKFILES) {
26
+ if (await pathExists(path.join(projectRoot, lockfile))) {
27
+ return packageManager;
28
+ }
29
+ }
30
+
31
+ return 'npm';
32
+ }
33
+
34
+ export function detectPackageManagerFromFingerprint({ fingerprintEntries = [], pkg = null } = {}) {
35
+ const declaredPackageManager = getDeclaredPackageManager(pkg);
36
+ if (declaredPackageManager) return declaredPackageManager;
37
+
38
+ const existingFiles = new Set(
39
+ fingerprintEntries
40
+ .filter((entry) => entry && entry.mtimeMs !== null)
41
+ .map((entry) => entry.filePath),
42
+ );
43
+
44
+ for (const [lockfile, packageManager] of PACKAGE_MANAGER_LOCKFILES) {
45
+ if (existingFiles.has(lockfile)) {
46
+ return packageManager;
47
+ }
48
+ }
49
+
50
+ return 'npm';
51
+ }
@@ -30,6 +30,22 @@ function normalize(text) {
30
30
  return String(text ?? '').toLowerCase();
31
31
  }
32
32
 
33
+ function escapeRegExp(value) {
34
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
35
+ }
36
+
37
+ function matchesKeyword(text, keyword) {
38
+ if (keyword.startsWith('.')) {
39
+ return new RegExp(`(?:^|[\\s/\\\\])[^\\s/\\\\]+${escapeRegExp(keyword)}(?:$|[\\s)\\],.;:])`).test(text);
40
+ }
41
+
42
+ if (keyword.includes(' ')) {
43
+ return new RegExp(`\\b${keyword.split(/\\s+/).map(escapeRegExp).join('\\\\s+')}\\b`).test(text);
44
+ }
45
+
46
+ return new RegExp(`\\b${escapeRegExp(keyword)}\\b`).test(text);
47
+ }
48
+
33
49
  function countFileHints(text) {
34
50
  const explicitCount = Number.parseInt(normalize(text).match(/\b(\d+)\s+files?\b/)?.[1] ?? '0', 10);
35
51
  const pathMatches = normalize(text).match(/\b[\w./-]+\.(?:js|ts|tsx|jsx|json|md|yaml|yml)\b/g) ?? [];
@@ -41,8 +57,8 @@ export function detectComplexity(message, context = '') {
41
57
  const wordCount = combined.split(/\s+/).filter(Boolean).length;
42
58
  const fileHints = countFileHints(combined);
43
59
  const hasRetryPattern = /\b(retry|failed|failure|attempt)\b/.test(combined);
44
- const highSignals = HIGH_COMPLEXITY_KEYWORDS.filter((keyword) => combined.includes(keyword)).length;
45
- const lowSignals = LOW_COMPLEXITY_KEYWORDS.filter((keyword) => combined.includes(keyword)).length;
60
+ const highSignals = HIGH_COMPLEXITY_KEYWORDS.filter((keyword) => matchesKeyword(combined, keyword)).length;
61
+ const lowSignals = LOW_COMPLEXITY_KEYWORDS.filter((keyword) => matchesKeyword(combined, keyword)).length;
46
62
 
47
63
  if (highSignals > 0 || fileHints > 5 || (hasRetryPattern && /\b(debug|error|crash|stack trace)\b/.test(combined))) {
48
64
  return 'high';
@@ -58,19 +74,19 @@ export function detectComplexity(message, context = '') {
58
74
  function detectTaskType(message, context = '', complexity = 'medium') {
59
75
  const combined = normalize(`${message}\n${context}`);
60
76
 
61
- if (DEBUG_KEYWORDS.some((keyword) => combined.includes(keyword)) && complexity === 'high') {
77
+ if (DEBUG_KEYWORDS.some((keyword) => matchesKeyword(combined, keyword)) && complexity === 'high') {
62
78
  return 'debug_hard';
63
79
  }
64
80
 
65
- if (HIGH_COMPLEXITY_KEYWORDS.some((keyword) => combined.includes(keyword))) {
81
+ if (HIGH_COMPLEXITY_KEYWORDS.some((keyword) => matchesKeyword(combined, keyword))) {
66
82
  return 'reasoning';
67
83
  }
68
84
 
69
- if (REVIEW_KEYWORDS.some((keyword) => combined.includes(keyword))) {
85
+ if (REVIEW_KEYWORDS.some((keyword) => matchesKeyword(combined, keyword))) {
70
86
  return 'review';
71
87
  }
72
88
 
73
- if (CODING_HINTS.some((keyword) => combined.includes(keyword))) {
89
+ if (CODING_HINTS.some((keyword) => matchesKeyword(combined, keyword))) {
74
90
  return 'coding';
75
91
  }
76
92