@dotdotgod/cli 0.1.35 → 0.1.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,128 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { parseCommon } from '../cli/usage.mjs';
4
+ import { rel } from '../common/paths.mjs';
5
+ import { readMemoryConfig, memoryConfigSummary } from '../memory/config.mjs';
6
+ import { addEdge, addNode, compactGraph, expandGraph, graphStats, jsonSize, shardFile, writeJson } from '../graph/store.mjs';
7
+ import { cacheFile, collectIndexFiles, fingerprint } from './files.mjs';
8
+ import { CACHE_VERSION } from './constants.mjs';
9
+ import { buildGraph } from '../graph/extract.mjs';
10
+ import { addDeterministicSemanticEdges } from '../graph/semantic.mjs';
11
+
12
+ function collectFingerprints(root, config = readMemoryConfig(root)) {
13
+ return collectIndexFiles(root, config).map((file) => {
14
+ const stats = statSync(file);
15
+ return { path: rel(root, file), sha256: fingerprint(file), size: stats.size, mtimeMs: Math.round(stats.mtimeMs) };
16
+ });
17
+ }
18
+
19
+ function nodeOwnedByPath(node, paths) {
20
+ return paths.has(node.path) || paths.has(node.id?.replace(/^file:/, '')) || paths.has(node.id?.replace(/^test:/, ''));
21
+ }
22
+
23
+ function mergeIncrementalGraph(previousGraph, changedGraph, changedPaths) {
24
+ if (!previousGraph) return changedGraph;
25
+ const changedSet = new Set(changedPaths);
26
+ const changedNodeIds = new Set(previousGraph.nodes.filter((node) => nodeOwnedByPath(node, changedSet)).map((node) => node.id));
27
+ for (const node of changedGraph.nodes) changedNodeIds.add(node.id);
28
+ const graph = { nodes: [], edges: [] };
29
+ for (const node of previousGraph.nodes) if (!changedNodeIds.has(node.id) && !nodeOwnedByPath(node, changedSet)) addNode(graph, node.id, node.type, Object.fromEntries(Object.entries(node).filter(([key]) => key !== 'id' && key !== 'type')));
30
+ for (const edge of previousGraph.edges) if (!changedNodeIds.has(edge.source) && !changedNodeIds.has(edge.target)) addEdge(graph, edge.source, edge.target, edge.relation, Object.fromEntries(Object.entries(edge).filter(([key]) => key !== 'source' && key !== 'target' && key !== 'relation')));
31
+ for (const node of changedGraph.nodes) addNode(graph, node.id, node.type, Object.fromEntries(Object.entries(node).filter(([key]) => key !== 'id' && key !== 'type')));
32
+ for (const edge of changedGraph.edges) addEdge(graph, edge.source, edge.target, edge.relation, Object.fromEntries(Object.entries(edge).filter(([key]) => key !== 'source' && key !== 'target' && key !== 'relation')));
33
+ return graph;
34
+ }
35
+
36
+ export function buildIndex(root, previous = readIndex(root)) {
37
+ const startedAt = Date.now();
38
+ const memoryConfig = readMemoryConfig(root);
39
+ const files = collectFingerprints(root, memoryConfig);
40
+ const indexed = new Map((previous?.files ?? []).map((file) => [file.path, file.sha256]));
41
+ const currentPaths = new Set(files.map((file) => file.path));
42
+ const removedPaths = (previous?.files ?? []).filter((file) => !currentPaths.has(file.path)).map((file) => file.path);
43
+ const changedPaths = [...files.filter((file) => indexed.get(file.path) !== file.sha256).map((file) => file.path), ...removedPaths];
44
+ const changedFiles = files.filter((file) => changedPaths.includes(file.path)).map((file) => join(root, file.path));
45
+ const fullRebuild = !previous?.graph || previous.version !== CACHE_VERSION;
46
+ const refreshReason = !previous ? 'missing' : previous.version !== CACHE_VERSION ? 'schema-mismatch' : removedPaths.length > 0 ? 'content-removed' : changedPaths.length > 0 ? 'content-changed' : 'fresh';
47
+ const rawGraph = fullRebuild ? buildGraph(root, files.map((file) => join(root, file.path)), memoryConfig) : mergeIncrementalGraph(previous.graph, buildGraph(root, changedFiles, memoryConfig), changedPaths);
48
+ const graph = addDeterministicSemanticEdges(rawGraph, memoryConfig);
49
+ const archiveBodiesIncluded = (memoryConfig.areas ?? []).some((area) => area.id === 'archive-body' && area.includeBodiesByDefault !== false);
50
+ return { version: CACHE_VERSION, schemaVersion: CACHE_VERSION, generatedAt: new Date().toISOString(), archiveBodiesIncluded, memoryConfig: memoryConfigSummary(memoryConfig), files, graph, stats: graphStats(graph), incremental: { enabled: true, fullRebuild, changedFiles: changedPaths.length, refreshReason, elapsedMs: Date.now() - startedAt } };
51
+ }
52
+
53
+ export function writeIndex(root, index) {
54
+ const compact = compactGraph(index.graph);
55
+ for (const [name, rows] of Object.entries(compact.nodes)) writeJson(shardFile(root, 'nodes', name), rows);
56
+ for (const [name, rows] of Object.entries(compact.edges)) writeJson(shardFile(root, 'edges', name), rows);
57
+ const graphShards = { nodes: Object.fromEntries(Object.keys(compact.nodes).map((name) => [name, rel(root, shardFile(root, 'nodes', name))])), edges: Object.fromEntries(Object.keys(compact.edges).map((name) => [name, rel(root, shardFile(root, 'edges', name))])) };
58
+ const manifest = { version: index.version, schemaVersion: index.schemaVersion ?? index.version, generatedAt: index.generatedAt, archiveBodiesIncluded: index.archiveBodiesIncluded, memoryConfig: index.memoryConfig, files: index.files, graph: { ...graphStats(index.graph), compactSchema: true, shards: graphShards }, incremental: index.incremental };
59
+ writeJson(cacheFile(root), manifest);
60
+ const shardBytes = [...Object.values(graphShards.nodes), ...Object.values(graphShards.edges)].reduce((sum, path) => sum + jsonSize(join(root, path)), 0);
61
+ const manifestBytes = jsonSize(cacheFile(root));
62
+ return { ...manifest, indexSizeBytes: manifestBytes + shardBytes, manifestBytes, shardBytes };
63
+ }
64
+
65
+ export function readIndex(root) {
66
+ const file = cacheFile(root);
67
+ if (!existsSync(file)) return null;
68
+ try {
69
+ const manifest = JSON.parse(readFileSync(file, 'utf8'));
70
+ const shards = manifest.graph?.shards;
71
+ if (!shards) return manifest;
72
+ const compact = { nodes: {}, edges: {} };
73
+ for (const [name, path] of Object.entries(shards.nodes ?? {})) compact.nodes[name] = existsSync(join(root, path)) ? JSON.parse(readFileSync(join(root, path), 'utf8')) : [];
74
+ for (const [name, path] of Object.entries(shards.edges ?? {})) compact.edges[name] = existsSync(join(root, path)) ? JSON.parse(readFileSync(join(root, path), 'utf8')) : [];
75
+ return { ...manifest, graph: expandGraph(compact) };
76
+ } catch { return null; }
77
+ }
78
+
79
+ export function getStatus(root) {
80
+ const index = readIndex(root);
81
+ const memoryConfig = readMemoryConfig(root);
82
+ const currentFiles = collectFingerprints(root, memoryConfig);
83
+ if (!index) return { ok: false, status: 'missing', cachePath: rel(root, cacheFile(root)), indexedFiles: 0, currentFiles: currentFiles.length, staleFiles: currentFiles.length, archiveBodiesIncluded: false, schemaVersion: null, expectedSchemaVersion: CACHE_VERSION, schemaOk: false, reason: 'missing' };
84
+ const indexed = new Map((index.files ?? []).map((file) => [file.path, file.sha256]));
85
+ const currentMap = new Map(currentFiles.map((file) => [file.path, file.sha256]));
86
+ const stale = currentFiles.filter((file) => indexed.get(file.path) !== file.sha256).map((file) => file.path);
87
+ const removed = [...indexed.keys()].filter((path) => !currentMap.has(path));
88
+ const staleFiles = [...stale, ...removed];
89
+ const schemaVersion = index.schemaVersion ?? index.version ?? null;
90
+ const schemaOk = schemaVersion === CACHE_VERSION;
91
+ const ok = staleFiles.length === 0 && schemaOk;
92
+ const reason = ok ? 'fresh' : !schemaOk ? 'schema-mismatch' : staleFiles.length > 0 ? 'content-changed' : 'unknown';
93
+ return { ok, status: ok ? 'fresh' : 'stale', cachePath: rel(root, cacheFile(root)), indexedFiles: index.files?.length ?? 0, currentFiles: currentFiles.length, staleFiles: staleFiles.length, examples: staleFiles.slice(0, 10), archiveBodiesIncluded: index.archiveBodiesIncluded === true, schemaVersion, expectedSchemaVersion: CACHE_VERSION, schemaOk, reason, graph: graphStats(index.graph ?? { nodes: [], edges: [] }) };
94
+ }
95
+
96
+ export function runIndex(argv) {
97
+ const options = parseCommon(argv);
98
+ const index = buildIndex(options.root);
99
+ const manifest = writeIndex(options.root, index);
100
+ const result = { ok: true, cachePath: rel(options.root, cacheFile(options.root)), schemaVersion: CACHE_VERSION, indexedFiles: index.files.length, nodes: index.graph.nodes.length, edges: index.graph.edges.length, indexSizeBytes: manifest.indexSizeBytes, shards: manifest.graph.shards, incremental: index.incremental, archiveBodiesIncluded: index.archiveBodiesIncluded === true, memoryConfig: index.memoryConfig };
101
+ if (options.json) console.log(JSON.stringify(result, null, 2));
102
+ else console.log(`✅ index written (${result.indexedFiles} files, ${result.nodes} nodes, ${result.edges} edges, ${(result.indexSizeBytes / 1024).toFixed(1)} KiB, cache: ${result.cachePath})`);
103
+ }
104
+
105
+ export function runStatus(argv) {
106
+ const options = parseCommon(argv);
107
+ const status = getStatus(options.root);
108
+ if (options.json) console.log(JSON.stringify(status, null, 2));
109
+ else console.log(`dotdotgod index status: ${status.status} (${status.indexedFiles}/${status.currentFiles} files, cache: ${status.cachePath})`);
110
+ process.exit(status.ok ? 0 : 1);
111
+ }
112
+
113
+ export function readFreshIndex(root) {
114
+ const startedAt = Date.now();
115
+ const initialStatus = getStatus(root);
116
+ if (initialStatus.ok) return { status: initialStatus, index: readIndex(root), metadata: { cacheRefreshed: false, elapsedMs: Date.now() - startedAt, refreshReason: 'fresh', schemaVersion: CACHE_VERSION } };
117
+ const index = buildIndex(root);
118
+ const manifest = writeIndex(root, index);
119
+ const status = getStatus(root);
120
+ return { status, index, metadata: { cacheRefreshed: true, previousStatus: initialStatus.status, previousReason: initialStatus.reason, refreshReason: index.incremental?.refreshReason ?? initialStatus.reason, changedFiles: index.incremental?.changedFiles ?? initialStatus.staleFiles, fullRebuild: index.incremental?.fullRebuild === true, indexedFiles: index.files.length, indexSizeBytes: manifest.indexSizeBytes, manifestBytes: manifest.manifestBytes, shardBytes: manifest.shardBytes, elapsedMs: Date.now() - startedAt, indexElapsedMs: index.incremental?.elapsedMs, schemaVersion: CACHE_VERSION, archiveBodiesIncluded: index.archiveBodiesIncluded === true } };
121
+ }
122
+
123
+ export function graphSummary(index) {
124
+ const graph = index?.graph ?? { nodes: [], edges: [] };
125
+ const byType = graph.nodes.reduce((acc, node) => ({ ...acc, [node.type]: (acc[node.type] ?? 0) + 1 }), {});
126
+ const byRelation = graph.edges.reduce((acc, edge) => ({ ...acc, [edge.relation]: (acc[edge.relation] ?? 0) + 1 }), {});
127
+ return { nodes: graph.nodes.length, edges: graph.edges.length, byType, byRelation };
128
+ }
@@ -0,0 +1,3 @@
1
+ export const CACHE_VERSION = 11;
2
+ export const CACHE_DIR = '.dotdotgod';
3
+ export const MANIFEST_FILE = 'manifest.json';
@@ -13,6 +13,9 @@ export const DEFAULT_TRACEABILITY_POLICY = {
13
13
  export const DEFAULT_VALIDATION_POLICY = {
14
14
  markdown: { maxLines: 200, maxChars: 10000, exclude: [] },
15
15
  };
16
+ export const DEFAULT_INTEGRATIONS_POLICY = {
17
+ trello: { syncPaths: [] },
18
+ };
16
19
  export const DEFAULT_IMPACT_RANKING_POLICY = {
17
20
  preset: 'balanced',
18
21
  weights: { ppr: 40, traceability: 30, memoryPolicy: 10, verification: 15, proximity: 10, semantic: 10, freshness: 5, archivePenalty: -25 },
@@ -151,15 +154,19 @@ function normalizeImpactRankingPolicy(raw) {
151
154
  return cloneImpactRankingPolicy({ ...preset, ...raw, preset: presetName, weights: { ...(preset.weights ?? {}), ...(raw?.weights ?? {}) }, ppr: { ...(preset.ppr ?? {}), ...(raw?.ppr ?? {}) }, semantic: { ...(preset.semantic ?? {}), ...(raw?.semantic ?? {}) } });
152
155
  }
153
156
 
157
+ export function cloneIntegrationsPolicy(policy = DEFAULT_INTEGRATIONS_POLICY) {
158
+ return { trello: { syncPaths: [...(policy.trello?.syncPaths ?? [])] } };
159
+ }
160
+
154
161
  export function defaultMemoryConfig() {
155
- return { source: 'default', areas: DEFAULT_MEMORY_AREAS.map(cloneArea), traceability: cloneTraceabilityPolicy(), validation: cloneValidationPolicy(), impactRanking: cloneImpactRankingPolicy(), referenceExpansion: cloneReferenceExpansionPolicy() };
162
+ return { source: 'default', areas: DEFAULT_MEMORY_AREAS.map(cloneArea), traceability: cloneTraceabilityPolicy(), validation: cloneValidationPolicy(), impactRanking: cloneImpactRankingPolicy(), referenceExpansion: cloneReferenceExpansionPolicy(), integrations: cloneIntegrationsPolicy() };
156
163
  }
157
164
 
158
- function normalizePathPattern(value = '') {
165
+ export function normalizePathPattern(value = '') {
159
166
  return value.replaceAll('\\', '/').replace(/^\.\//, '').replace(/\/+$|^\/+/, '');
160
167
  }
161
168
 
162
- function isValidPathPattern(value) {
169
+ export function isValidPathPattern(value) {
163
170
  if (typeof value !== 'string' || !value.trim()) return false;
164
171
  const normalized = normalizePathPattern(value);
165
172
  if (!normalized || normalized.startsWith('../') || normalized.includes('/../') || normalized === '..') return false;
@@ -167,7 +174,7 @@ function isValidPathPattern(value) {
167
174
  return true;
168
175
  }
169
176
 
170
- function matchMemoryPattern(path, pattern) {
177
+ export function matchPathPattern(path, pattern) {
171
178
  const normalized = normalizePathPattern(path);
172
179
  const normalizedPattern = normalizePathPattern(pattern);
173
180
  if (normalizedPattern.endsWith('/**')) {
@@ -182,9 +189,9 @@ function matchMemoryPattern(path, pattern) {
182
189
  }
183
190
 
184
191
  function areaMatchesPath(area, path) {
185
- const excluded = (area.excludePaths ?? []).some((pattern) => matchMemoryPattern(path, pattern));
192
+ const excluded = (area.excludePaths ?? []).some((pattern) => matchPathPattern(path, pattern));
186
193
  if (excluded) return false;
187
- return (area.paths ?? []).some((pattern) => matchMemoryPattern(path, pattern));
194
+ return (area.paths ?? []).some((pattern) => matchPathPattern(path, pattern));
188
195
  }
189
196
 
190
197
  export function resolveMemoryArea(path = '', config = defaultMemoryConfig()) {
@@ -223,16 +230,31 @@ function normalizeValidationPolicy(raw) {
223
230
  });
224
231
  }
225
232
 
233
+ function normalizeIntegrationsPolicy(raw) {
234
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return cloneIntegrationsPolicy();
235
+ const syncPaths = Array.isArray(raw.trello?.syncPaths) ? raw.trello.syncPaths.map(normalizePathPattern) : [];
236
+ return cloneIntegrationsPolicy({ trello: { syncPaths: [...new Set(syncPaths)] } });
237
+ }
238
+
239
+ function isSecretLikePathPattern(value = '') {
240
+ const normalized = normalizePathPattern(value);
241
+ return /(^|\/)(\.env|\.npmrc|\.pypirc|id_rsa|id_dsa|id_ed25519|credentials?|secrets?)(\.|\/|$)/i.test(normalized);
242
+ }
243
+
244
+ export function trelloSyncPaths(config = defaultMemoryConfig()) {
245
+ return [...new Set(['docs/trello/**', ...((config.integrations ?? DEFAULT_INTEGRATIONS_POLICY).trello?.syncPaths ?? [])])];
246
+ }
247
+
226
248
  export function isMarkdownSizeExcluded(path = '', config = defaultMemoryConfig()) {
227
249
  const policy = config.validation ?? DEFAULT_VALIDATION_POLICY;
228
- return (policy.markdown?.exclude ?? []).some((pattern) => matchMemoryPattern(path, pattern));
250
+ return (policy.markdown?.exclude ?? []).some((pattern) => matchPathPattern(path, pattern));
229
251
  }
230
252
 
231
253
  export function requiresTraceability(path = '', config = defaultMemoryConfig()) {
232
254
  const policy = config.traceability ?? DEFAULT_TRACEABILITY_POLICY;
233
- const excluded = (policy.exclude ?? []).some((pattern) => matchMemoryPattern(path, pattern));
255
+ const excluded = (policy.exclude ?? []).some((pattern) => matchPathPattern(path, pattern));
234
256
  if (excluded) return false;
235
- return (policy.required ?? []).some((pattern) => matchMemoryPattern(path, pattern));
257
+ return (policy.required ?? []).some((pattern) => matchPathPattern(path, pattern));
236
258
  }
237
259
 
238
260
  function normalizeMemoryArea(raw) {
@@ -295,6 +317,23 @@ export function validateMemoryConfigData(data, root = '.', file = 'dotdotgod.con
295
317
  }
296
318
  }
297
319
  }
320
+ const integrations = data.integrations;
321
+ if (integrations !== undefined) {
322
+ if (!integrations || typeof integrations !== 'object' || Array.isArray(integrations)) {
323
+ add('INTEGRATIONS_CONFIG_INVALID', 'integrations', 'Expected an object.');
324
+ } else if (integrations.trello !== undefined) {
325
+ const trello = integrations.trello;
326
+ if (!trello || typeof trello !== 'object' || Array.isArray(trello)) {
327
+ add('INTEGRATIONS_TRELLO_CONFIG_INVALID', 'integrations.trello', 'Expected an object.');
328
+ } else if (trello.syncPaths !== undefined) {
329
+ if (!Array.isArray(trello.syncPaths)) add('INTEGRATIONS_TRELLO_INVALID_SYNC_PATHS', 'integrations.trello.syncPaths', 'Expected an array of path strings.');
330
+ else {
331
+ if (trello.syncPaths.some((value) => !isValidPathPattern(value) || (typeof value === 'string' && value.trim().startsWith('/')))) add('INTEGRATIONS_TRELLO_INVALID_SYNC_PATHS', 'integrations.trello.syncPaths', 'Expected repository-relative path strings using exact paths, /** subtree patterns, or **/suffix patterns.');
332
+ if (trello.syncPaths.some((value) => typeof value === 'string' && isSecretLikePathPattern(value))) add('INTEGRATIONS_TRELLO_SECRET_SYNC_PATH', 'integrations.trello.syncPaths', 'Sync paths must not target secrets, credentials, environment files, or private keys.');
333
+ }
334
+ }
335
+ }
336
+ }
298
337
  const referenceExpansion = data.referenceExpansion;
299
338
  if (referenceExpansion !== undefined) {
300
339
  if (!referenceExpansion || typeof referenceExpansion !== 'object' || Array.isArray(referenceExpansion)) {
@@ -405,7 +444,8 @@ export function readMemoryConfig(root = '.') {
405
444
  const validation = data.validation === undefined ? cloneValidationPolicy() : normalizeValidationPolicy(data.validation);
406
445
  const impactRanking = normalizeImpactRankingPolicy(data.impactRanking);
407
446
  const referenceExpansion = normalizeReferenceExpansionPolicy(data.referenceExpansion);
408
- return configuredAreas.length > 0 ? { source: name, areas: configuredAreas, traceability, validation, impactRanking, referenceExpansion, errors: [] } : { ...defaultMemoryConfig(), traceability, validation, impactRanking, referenceExpansion, source: name, errors: [] };
447
+ const integrations = normalizeIntegrationsPolicy(data.integrations);
448
+ return configuredAreas.length > 0 ? { source: name, areas: configuredAreas, traceability, validation, impactRanking, referenceExpansion, integrations, errors: [] } : { ...defaultMemoryConfig(), traceability, validation, impactRanking, referenceExpansion, integrations, source: name, errors: [] };
409
449
  } catch (error) {
410
450
  return { ...defaultMemoryConfig(), source: name, errors: [{ file: name, code: 'MEMORY_CONFIG_INVALID_JSON', message: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}\nFix: repair ${name} so it is valid JSON, or regenerate the default config with \`dotdotgod config init <root> --force\` if you want to reset it.` }] };
411
451
  }
@@ -437,6 +477,7 @@ export function defaultDotdotgodConfigData() {
437
477
  validation: cloneValidationPolicy(config.validation),
438
478
  impactRanking: cloneImpactRankingPolicy(config.impactRanking),
439
479
  referenceExpansion: { fuzzy: { lowSignal: { add: [], remove: [] } } },
480
+ integrations: cloneIntegrationsPolicy(config.integrations),
440
481
  };
441
482
  }
442
483
 
@@ -462,6 +503,7 @@ export function memoryConfigSummary(config) {
462
503
  validation: cloneValidationPolicy(config.validation ?? DEFAULT_VALIDATION_POLICY),
463
504
  impactRanking: cloneImpactRankingPolicy(config.impactRanking ?? DEFAULT_IMPACT_RANKING_POLICY),
464
505
  referenceExpansion: cloneReferenceExpansionPolicy(config.referenceExpansion),
506
+ integrations: cloneIntegrationsPolicy(config.integrations),
465
507
  };
466
508
  }
467
509
 
@@ -0,0 +1,135 @@
1
+ import { basename, dirname, extname } from 'node:path';
2
+ import { cloneReferenceExpansionPolicy, defaultMemoryConfig, normalizeLowSignalTerm } from '../memory/config.mjs';
3
+
4
+ export const DEFAULT_REFERENCE_LIMIT = 5;
5
+
6
+ export function extractBracketReferences(prompt = '') {
7
+ const refs = [];
8
+ const seen = new Set();
9
+ for (const match of String(prompt).matchAll(/\[\[([^\]]+)\]\]/g)) {
10
+ const raw = match[1].trim();
11
+ if (!raw) continue;
12
+ const target = raw.split('|')[0].trim();
13
+ if (!target || seen.has(target)) continue;
14
+ seen.add(target);
15
+ refs.push({ raw: match[0], target, label: raw.includes('|') ? raw.split('|').slice(1).join('|').trim() : undefined });
16
+ }
17
+ return refs;
18
+ }
19
+
20
+ function fuzzyLowSignalSet(policy = defaultMemoryConfig().referenceExpansion) {
21
+ return new Set(cloneReferenceExpansionPolicy(policy).fuzzy.lowSignal.terms);
22
+ }
23
+
24
+ function fuzzyPhraseAllowed(value = '', lowSignalTerms = fuzzyLowSignalSet()) {
25
+ const normalized = normalizeLowSignalTerm(value);
26
+ if (normalized.length < 3) return false;
27
+ if (lowSignalTerms.has(normalized)) return false;
28
+ return /[a-z0-9가-힣]/i.test(normalized);
29
+ }
30
+
31
+ function fuzzyTokenKey(value = '') {
32
+ return String(value).trim().replace(/\s+/g, ' ').toLowerCase();
33
+ }
34
+
35
+ export function normalizeReferenceAlias(value = '') {
36
+ return String(value)
37
+ .trim()
38
+ .replace(/^\[\[/, '')
39
+ .replace(/\]\]$/, '')
40
+ .split('|')[0]
41
+ .trim()
42
+ .replace(/\\/g, '/')
43
+ .replace(/^\.\//, '')
44
+ .replace(/\.md$/i, '')
45
+ .toLowerCase()
46
+ .replace(/[\s_-]+/g, '')
47
+ .replace(/[^a-z0-9/#.]/g, '');
48
+ }
49
+
50
+ export function referencePathForNode(node) {
51
+ if (node?.path) return node.path;
52
+ if (typeof node?.id === 'string' && node.id.startsWith('file:')) return node.id.slice(5);
53
+ if (typeof node?.id === 'string' && node.id.startsWith('heading:')) return node.id.slice(8).split('#')[0];
54
+ return '';
55
+ }
56
+
57
+ export function isArchiveBodyPath(path = '') {
58
+ return path.startsWith('docs/archive/') && path !== 'docs/archive/README.md';
59
+ }
60
+
61
+ function aliasEntriesForPath(path = '') {
62
+ const entries = [];
63
+ const base = basename(path);
64
+ const ext = extname(base);
65
+ const stem = ext ? base.slice(0, -ext.length) : base;
66
+ const withoutMd = path.replace(/\.md$/i, '');
67
+ const parts = withoutMd.split('/').filter(Boolean);
68
+ for (let i = 0; i < parts.length; i += 1) entries.push({ alias: parts.slice(i).join('/'), kind: 'path-suffix' });
69
+ if (base === 'README.md') {
70
+ const dir = dirname(path).replace(/\\/g, '/');
71
+ entries.push({ alias: basename(dir), kind: 'path' });
72
+ }
73
+ for (const alias of [path, withoutMd, base, stem]) entries.push({ alias, kind: 'path' });
74
+ return entries.filter((entry) => entry.alias);
75
+ }
76
+
77
+ export function referenceCandidateAliases(node) {
78
+ const path = referencePathForNode(node);
79
+ const entries = [];
80
+ if (node.type === 'file' && path) entries.push(...aliasEntriesForPath(path));
81
+ if (node.type === 'heading') {
82
+ const fileStem = path ? basename(path, extname(path)) : '';
83
+ const anchor = typeof node.id === 'string' && node.id.includes('#') ? node.id.split('#').pop() : '';
84
+ for (const alias of [node.title, anchor, fileStem && node.title ? `${fileStem}#${node.title}` : '', fileStem && anchor ? `${fileStem}#${anchor}` : '', path && node.title ? `${path}#${node.title}` : '', path && anchor ? `${path}#${anchor}` : '']) entries.push({ alias, kind: 'heading' });
85
+ }
86
+ return entries.filter((entry) => entry.alias);
87
+ }
88
+
89
+ export function extractFuzzyReferences(prompt = '', index = null, options = {}) {
90
+ const text = String(prompt ?? '');
91
+ const masked = text.replace(/\[\[[^\]\n]+\]\]/g, ' ');
92
+ const seen = new Set((options.existingTargets ?? []).map((value) => fuzzyTokenKey(value)));
93
+ const lowSignalTerms = fuzzyLowSignalSet(options.referenceExpansion ?? options.memoryConfig?.referenceExpansion ?? index?.memoryConfig?.referenceExpansion);
94
+ const refs = [];
95
+ const add = (target, reason, confidence = 'medium') => {
96
+ const clean = String(target ?? '').trim().replace(/^['"`]+|['"`]+$/g, '').replace(/\s+/g, ' ');
97
+ if (!fuzzyPhraseAllowed(clean, lowSignalTerms)) return;
98
+ const key = fuzzyTokenKey(clean);
99
+ if (seen.has(key)) return;
100
+ seen.add(key);
101
+ refs.push({ raw: clean, target: clean, source: 'fuzzy', confidence, reasons: [reason] });
102
+ };
103
+
104
+ for (const match of masked.matchAll(/(?:^|\s)([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+(?:#[A-Za-z0-9 _-]+)?|[A-Z0-9]{3,})(?=$|\s|[.,:;!?])/g)) add(match[1], 'uppercase_identifier', 'high');
105
+ for (const match of masked.matchAll(/(?:^|\s)((?:\.?\/?(?:docs|packages|src|test|spec|arch|plan|archive)\/)?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+(?:\.md)?(?:#[A-Za-z0-9 _-]+)?)(?=$|\s|[.,:;!?])/g)) add(match[1].replace(/^\.\//, ''), 'path_like', 'high');
106
+ for (const match of masked.matchAll(/[`"']([^`"'\n]{4,80})[`"']/g)) add(match[1], 'quoted_phrase', 'medium');
107
+
108
+ const graph = index?.graph ?? null;
109
+ if (graph) {
110
+ const lower = ` ${masked.toLowerCase().replace(/[^a-z0-9가-힣/#._-]+/gi, ' ')} `;
111
+ const aliasByKey = new Map();
112
+ for (const node of graph.nodes ?? []) {
113
+ if (!['file', 'heading'].includes(node.type)) continue;
114
+ const path = referencePathForNode(node);
115
+ if (options.includeArchive !== true && isArchiveBodyPath(path)) continue;
116
+ for (const entry of referenceCandidateAliases(node)) {
117
+ const alias = String(entry.alias ?? '').replace(/\.md$/i, '').replace(/[_-]+/g, ' ').trim();
118
+ if (!fuzzyPhraseAllowed(alias, lowSignalTerms)) continue;
119
+ const words = alias.split(/[\s/]+/).filter(Boolean);
120
+ if (words.length > 3 || words.some((word) => lowSignalTerms.has(normalizeLowSignalTerm(word)))) continue;
121
+ const key = fuzzyTokenKey(alias);
122
+ const previous = aliasByKey.get(key);
123
+ const priority = Number(node.retrievalPriority ?? node.retrieval?.priority ?? 30);
124
+ if (!previous || priority > previous.priority) aliasByKey.set(key, { alias, priority });
125
+ }
126
+ }
127
+ for (const { alias } of [...aliasByKey.values()].sort((a, b) => b.priority - a.priority || b.alias.length - a.alias.length)) {
128
+ const escaped = alias.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '[\\s_-]+');
129
+ const pattern = new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, 'i');
130
+ if (pattern.test(lower)) add(alias, 'known_alias', 'medium');
131
+ if (refs.length >= (options.maxFuzzyRefs ?? 5)) break;
132
+ }
133
+ }
134
+ return refs.slice(0, options.maxFuzzyRefs ?? 5);
135
+ }
@@ -0,0 +1,131 @@
1
+ import { resolve } from 'node:path';
2
+ import { usage } from '../cli/usage.mjs';
3
+ import { readMemoryConfig } from '../memory/config.mjs';
4
+ import { readFreshIndex } from '../index/cache.mjs';
5
+ import { buildCompactImpactReport, buildImpactReport } from '../impact/report.mjs';
6
+ import { DEFAULT_REFERENCE_LIMIT, extractBracketReferences, extractFuzzyReferences, isArchiveBodyPath, normalizeReferenceAlias, referenceCandidateAliases, referencePathForNode } from './extract.mjs';
7
+
8
+ function incomingRouteBonus(graph, nodeId) {
9
+ return graph.edges.some((edge) => edge.target === nodeId && edge.relation === 'routes_to') ? 6 : 0;
10
+ }
11
+
12
+ function exactReferenceScore(ref, alias, kind) {
13
+ const rawRef = String(ref).trim().replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0].trim().replace(/\\/g, '/').replace(/^\.\//, '');
14
+ const rawAlias = String(alias).trim().replace(/\\/g, '/').replace(/^\.\//, '');
15
+ if (rawRef === rawAlias) return kind === 'heading' ? 92 : 110;
16
+ if (rawRef.toLowerCase() === rawAlias.toLowerCase()) return kind === 'heading' ? 88 : 105;
17
+ return 0;
18
+ }
19
+
20
+ export function resolveReferenceCandidates(index, ref, options = {}) {
21
+ const graph = index?.graph ?? { nodes: [], edges: [] };
22
+ const includeArchive = options.includeArchive === true;
23
+ const limit = Number.isFinite(options.maxResults) ? options.maxResults : DEFAULT_REFERENCE_LIMIT;
24
+ const query = String(ref ?? '').trim().replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0].trim();
25
+ const normalized = normalizeReferenceAlias(query);
26
+ const byId = new Map();
27
+ for (const node of graph.nodes ?? []) {
28
+ if (!['file', 'heading'].includes(node.type)) continue;
29
+ const path = referencePathForNode(node);
30
+ if (!includeArchive && isArchiveBodyPath(path)) continue;
31
+ let best = null;
32
+ for (const entry of referenceCandidateAliases(node)) {
33
+ const aliasKey = normalizeReferenceAlias(entry.alias);
34
+ if (!aliasKey) continue;
35
+ let score = exactReferenceScore(query, entry.alias, entry.kind);
36
+ if (aliasKey === normalized) score = Math.max(score, entry.kind === 'heading' ? 86 : 96);
37
+ else if (aliasKey.endsWith(normalized) && normalized.length >= 4) score = Math.max(score, entry.kind === 'heading' ? 62 : 70);
38
+ if (score <= 0) continue;
39
+ if (!best || score > best.score) best = { score, alias: entry.alias, aliasKind: entry.kind };
40
+ }
41
+ if (!best) continue;
42
+ const retrievalPriority = Number(node.retrievalPriority ?? node.retrieval?.priority ?? 30);
43
+ const routeBonus = incomingRouteBonus(graph, node.id);
44
+ const memoryBonus = Math.min(12, Math.max(0, retrievalPriority / 10));
45
+ const headingBonus = node.type === 'heading' ? 2 : 0;
46
+ const score = Number((best.score + routeBonus + memoryBonus + headingBonus).toFixed(1));
47
+ const reasons = [best.aliasKind === 'heading' ? 'heading_alias' : 'path_alias'];
48
+ if (routeBonus) reasons.push('readme_routed');
49
+ if (memoryBonus) reasons.push('memory_priority');
50
+ const candidate = { id: node.id, type: node.type, path, title: node.title, score, matchedAlias: best.alias, reasons, retrieval: node.retrieval, memoryArea: node.memoryArea };
51
+ const previous = byId.get(node.id);
52
+ if (!previous || candidate.score > previous.score) byId.set(node.id, candidate);
53
+ }
54
+ const candidates = [...byId.values()].sort((a, b) => b.score - a.score || a.path.localeCompare(b.path) || a.id.localeCompare(b.id));
55
+ const shown = candidates.slice(0, limit);
56
+ return { input: ref, query, normalized, candidates: shown, top: shown[0] ?? null, ambiguous: shown.length > 1 && shown[0].score - shown[1].score < 5, omitted: Math.max(0, candidates.length - shown.length) };
57
+ }
58
+
59
+ function parseReferenceOptions(argv, command) {
60
+ const filtered = [];
61
+ const options = { maxResults: DEFAULT_REFERENCE_LIMIT, includeArchive: false, withImpact: false, fuzzy: false };
62
+ for (let i = 0; i < argv.length; i += 1) {
63
+ const arg = argv[i];
64
+ if (arg === '--max-results') {
65
+ const next = argv[i + 1];
66
+ if (!next || next.startsWith('-') || Number.isNaN(Number.parseInt(next, 10))) usage('Missing or invalid value for --max-results.', command);
67
+ options.maxResults = Math.max(1, Number.parseInt(next, 10));
68
+ i += 1;
69
+ } else if (arg === '--include-archive') options.includeArchive = true;
70
+ else if (arg === '--with-impact') options.withImpact = true;
71
+ else if (arg === '--fuzzy') {
72
+ if (command !== 'expand') usage(`Unknown option: ${arg}`, command);
73
+ options.fuzzy = true;
74
+ }
75
+ else if (arg === '--json') filtered.push(arg);
76
+ else if (arg.startsWith('-')) usage(`Unknown option: ${arg}`, command);
77
+ else filtered.push(arg);
78
+ }
79
+ const operands = filtered.filter((arg) => !arg.startsWith('-'));
80
+ return { ...options, root: resolve(operands[0] ?? '.'), json: filtered.includes('--json'), rootArgv: operands.slice(1) };
81
+ }
82
+
83
+ function attachImpactToRef(index, refResult) {
84
+ const topPath = refResult.top?.path;
85
+ if (!topPath) return refResult;
86
+ const impact = buildCompactImpactReport(buildImpactReport(index, topPath));
87
+ return { ...refResult, impact: { changed: topPath, related: impact.related, groups: impact.groups, omittedRelated: impact.omittedRelated, quality: impact.quality } };
88
+ }
89
+
90
+ function formatReferenceOutput(payload) {
91
+ const refreshNote = payload.metadata.cacheRefreshed ? ', refreshed' : '';
92
+ const lines = [`${payload.command}: ${payload.refs.length} reference(s) (${payload.status.status}${refreshNote} index)`];
93
+ for (const ref of payload.refs) {
94
+ const marker = ref.ambiguous ? ' ambiguous' : '';
95
+ lines.push(`- ${ref.query || ref.input}:${marker} ${ref.candidates.length} candidate(s), ${ref.omitted} omitted`);
96
+ for (const candidate of ref.candidates) {
97
+ const title = candidate.title ? `#${candidate.title}` : '';
98
+ lines.push(` - ${candidate.path}${title} (${candidate.score}; ${candidate.reasons.join(', ')})`);
99
+ }
100
+ }
101
+ return lines.join('\n');
102
+ }
103
+
104
+ export function runResolve(argv) {
105
+ const options = parseReferenceOptions(argv, 'resolve');
106
+ const ref = options.rootArgv?.join(' ');
107
+ if (!ref) usage('Missing required argument: <ref>.', 'resolve');
108
+ const { status, index, metadata } = readFreshIndex(options.root);
109
+ const refs = [resolveReferenceCandidates(index, ref, options)];
110
+ const payload = { ok: status.ok, command: 'resolve', root: options.root, status, metadata, refs, omitted: refs.reduce((sum, item) => sum + item.omitted, 0) };
111
+ if (options.json) console.log(JSON.stringify(payload, null, 2));
112
+ else console.log(formatReferenceOutput(payload));
113
+ }
114
+
115
+ export function runExpand(argv) {
116
+ const options = parseReferenceOptions(argv, 'expand');
117
+ const prompt = options.rootArgv?.join(' ');
118
+ if (!prompt) usage('Missing required argument: <prompt>.', 'expand');
119
+ const refsInPrompt = extractBracketReferences(prompt);
120
+ if (refsInPrompt.length === 0 && !options.fuzzy) usage('No [[refs]] found in prompt.', 'expand');
121
+ const { status, index, metadata } = readFreshIndex(options.root);
122
+ const fuzzyRefs = options.fuzzy ? extractFuzzyReferences(prompt, index, { ...options, memoryConfig: readMemoryConfig(options.root), existingTargets: refsInPrompt.map((item) => item.target) }) : [];
123
+ let refs = [
124
+ ...refsInPrompt.map((item) => ({ ...resolveReferenceCandidates(index, item.target, options), source: 'explicit', raw: item.raw, label: item.label })),
125
+ ...fuzzyRefs.map((item) => ({ ...resolveReferenceCandidates(index, item.target, options), source: 'fuzzy', raw: item.raw, confidence: item.confidence, reasons: item.reasons })),
126
+ ];
127
+ if (options.withImpact) refs = refs.map((item) => attachImpactToRef(index, item));
128
+ const payload = { ok: status.ok, command: 'expand', root: options.root, prompt, status, metadata, refs, omitted: refs.reduce((sum, item) => sum + item.omitted, 0) };
129
+ if (options.json) console.log(JSON.stringify(payload, null, 2));
130
+ else console.log(formatReferenceOutput(payload));
131
+ }
@@ -0,0 +1,93 @@
1
+ const TRELLO_API_BASE = 'https://api.trello.com';
2
+
3
+ function clean(value) {
4
+ return typeof value === 'string' ? value.trim() : '';
5
+ }
6
+
7
+ function credentialError(message) {
8
+ return {
9
+ ok: false,
10
+ error: {
11
+ code: 'TRELLO_CREDENTIALS_MISSING',
12
+ message: `${message} Fix: configure GitHub Actions secrets or environment variables TRELLO_API_KEY and TRELLO_TOKEN, then retry from the trusted Trello sync workflow.`,
13
+ },
14
+ };
15
+ }
16
+
17
+ export function resolveTrelloCredentials({ env = process.env } = {}) {
18
+ const apiKey = clean(env.TRELLO_API_KEY);
19
+ const token = clean(env.TRELLO_TOKEN);
20
+ if (!apiKey || !token) return credentialError('Trello write mode credentials are missing or empty.');
21
+ return { ok: true, credentials: { apiKey, token, source: 'env' } };
22
+ }
23
+
24
+ function trelloApiError({ status = 0, statusText = '', body = '', retryAfter = null, operation = 'Trello API request', idOrShortLink = '', redact = (value) => value } = {}) {
25
+ const bodyText = redact(typeof body === 'string' ? body : JSON.stringify(body ?? ''));
26
+ const code = bodyText.includes('API_TOKEN_LIMIT_EXCEEDED') ? 'API_TOKEN_LIMIT_EXCEEDED' : bodyText.includes('API_KEY_LIMIT_EXCEEDED') ? 'API_KEY_LIMIT_EXCEEDED' : 'TRELLO_API_FAILED';
27
+ const rateLimitGuidance = status === 429 ? ` Wait and retry${retryAfter ? ` after ${retryAfter} seconds` : ''}.` : '';
28
+ return {
29
+ code,
30
+ status,
31
+ retryAfter,
32
+ message: `${operation} failed${idOrShortLink ? ` for Trello card ${idOrShortLink}` : ''}${status ? ` with HTTP ${status}${statusText ? ` ${statusText}` : ''}` : ''}${bodyText ? `: ${bodyText}` : ''}. Fix:${rateLimitGuidance || ' check card access, credentials, and Trello response details, then retry.'}`,
33
+ };
34
+ }
35
+
36
+ export function createTrelloClient({ credentials, fetchImpl = globalThis.fetch, baseUrl = TRELLO_API_BASE } = {}) {
37
+ if (typeof fetchImpl !== 'function') throw new Error('Trello client requires a fetch implementation.');
38
+ const auth = { key: credentials?.apiKey ?? '', token: credentials?.token ?? '' };
39
+ const redact = (value = '') => String(value).split(auth.key).join('[REDACTED_API_KEY]').split(auth.token).join('[REDACTED_TOKEN]');
40
+
41
+ async function request(path, { method = 'GET', params = {}, body = null, operation, idOrShortLink } = {}) {
42
+ const url = new URL(path, baseUrl);
43
+ for (const [key, value] of Object.entries({ ...params, ...auth })) url.searchParams.set(key, value);
44
+ const init = { method };
45
+ if (body !== null) {
46
+ init.headers = { 'Content-Type': 'application/json' };
47
+ init.body = JSON.stringify(body);
48
+ }
49
+ let response;
50
+ try {
51
+ response = await fetchImpl(url, init);
52
+ } catch (error) {
53
+ throw trelloApiError({ operation, idOrShortLink, body: error?.message ?? 'network error', redact });
54
+ }
55
+ const bodyText = await response.text();
56
+ if (!response.ok) {
57
+ throw trelloApiError({ status: response.status, statusText: response.statusText, body: bodyText, retryAfter: response.headers?.get?.('Retry-After') ?? null, operation, idOrShortLink, redact });
58
+ }
59
+ if (!bodyText) return null;
60
+ try {
61
+ return JSON.parse(bodyText);
62
+ } catch {
63
+ throw trelloApiError({ status: response.status, statusText: response.statusText, body: 'Malformed JSON response', operation, idOrShortLink, redact });
64
+ }
65
+ }
66
+
67
+ return {
68
+ async getCard(idOrShortLink) {
69
+ const card = await request(`/1/cards/${encodeURIComponent(idOrShortLink)}`, { params: { fields: 'id,idBoard,desc', customFieldItems: 'true' }, operation: 'Card lookup', idOrShortLink });
70
+ if (!card || typeof card.id !== 'string' || typeof card.idBoard !== 'string') throw trelloApiError({ body: 'Malformed card response', operation: 'Card lookup', idOrShortLink, redact });
71
+ return card;
72
+ },
73
+ async listAttachments(idOrShortLink) {
74
+ const attachments = await request(`/1/cards/${encodeURIComponent(idOrShortLink)}/attachments`, { operation: 'Attachment lookup', idOrShortLink });
75
+ if (!Array.isArray(attachments)) throw trelloApiError({ body: 'Malformed attachments response', operation: 'Attachment lookup', idOrShortLink, redact });
76
+ return attachments;
77
+ },
78
+ async createAttachment(idOrShortLink, githubUrl) {
79
+ return request(`/1/cards/${encodeURIComponent(idOrShortLink)}/attachments`, { method: 'POST', params: { url: githubUrl }, operation: 'Attachment creation', idOrShortLink });
80
+ },
81
+ async listCustomFields(idBoard) {
82
+ const fields = await request(`/1/boards/${encodeURIComponent(idBoard)}/customFields`, { operation: 'Custom field lookup' });
83
+ if (!Array.isArray(fields)) throw trelloApiError({ body: 'Malformed custom fields response', operation: 'Custom field lookup', redact });
84
+ return fields;
85
+ },
86
+ async createCustomField(idBoard, { name, type = 'text' } = {}) {
87
+ return request('/1/customFields', { method: 'POST', params: { idModel: idBoard, modelType: 'board', name, type, pos: 'bottom', display_cardFront: 'false' }, operation: 'Custom field creation' });
88
+ },
89
+ async updateCardCustomFieldText(idCard, idCustomField, text) {
90
+ return request(`/1/cards/${encodeURIComponent(idCard)}/customField/${encodeURIComponent(idCustomField)}/item`, { method: 'PUT', body: { value: { text } }, operation: 'Custom field update', idOrShortLink: idCard });
91
+ },
92
+ };
93
+ }