@dotdotgod/cli 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdotgod/cli",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "CLI for dotdotgod validation, project memory snapshots, and graph indexing.",
5
5
  "type": "module",
6
6
  "license": "Elastic-2.0",
package/src/cli/usage.mjs CHANGED
@@ -137,17 +137,15 @@ export function isVersionToken(value) {
137
137
  return VERSION_TOKENS.has(value);
138
138
  }
139
139
 
140
- export function readCliVersion() {
140
+ export function printVersion() {
141
+ let version = 'unknown';
141
142
  try {
142
143
  const data = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
143
- return typeof data.version === 'string' ? data.version : 'unknown';
144
+ if (typeof data.version === 'string') version = data.version;
144
145
  } catch {
145
- return 'unknown';
146
+ // keep 'unknown'
146
147
  }
147
- }
148
-
149
- export function printVersion() {
150
- console.log(readCliVersion());
148
+ console.log(version);
151
149
  process.exit(0);
152
150
  }
153
151
 
@@ -46,6 +46,8 @@ function formatConfigOutput(payload) {
46
46
  lines.push(`- impact ranking preset: ${payload.config.impactRanking.preset}`);
47
47
  lines.push(`- load pinned paths: ${(payload.config.load?.pinnedPaths ?? []).join(', ') || 'none'}`);
48
48
  lines.push(`- load pinned bodies: ${(payload.config.load?.pinnedBodies ?? []).join(', ') || 'none'}`);
49
+ lines.push(`- load documentation summary exclude: ${(payload.config.load?.documentationSummary?.exclude ?? []).join(', ') || 'none'}`);
50
+ lines.push(`- plan mode writable paths: ${(payload.config.planMode?.writablePaths ?? []).join(', ') || 'none'}`);
49
51
  lines.push(`- trello sync paths: ${(payload.config.integrations?.trello?.syncPaths ?? []).join(', ') || 'none'}`);
50
52
  const lowSignal = payload.config.referenceExpansion?.fuzzy?.lowSignal ?? { terms: [], add: [], remove: [] };
51
53
  lines.push(`- fuzzy low-signal terms: ${(lowSignal.terms ?? []).join(', ') || 'none'} (add: ${(lowSignal.add ?? []).join(', ') || 'none'}; remove: ${(lowSignal.remove ?? []).join(', ') || 'none'})`);
package/src/core.mjs CHANGED
@@ -15,17 +15,17 @@ export { commandUsage, hasHelpToken, helpCommandFromArgs, isHelpToken, isVersion
15
15
  export { rel } from './common/paths.mjs';
16
16
  export { extractAnchors, extractFirstHeading, extractLinks, headingToAnchor, isKebabCase, isNumberedSeriesFilename, isUpperSnakeMarkdown, removeCodeBlocks, suggestFilenameFromHeading } from './docs/markdown.mjs';
17
17
  export { extractDotdotgodTraceabilityBlocks, findTraceabilityLinksRegion, isLocalRelativeTraceabilityPath, renderCompactTraceabilityBlock, stripTraceabilityLinksRegion, syncTraceabilityLinksInContent, traceabilityExample, validateTraceabilityBlock, validateTraceabilityLinksRegion, validateTraceabilityPlacement } from './docs/traceability.mjs';
18
- export { DEFAULT_IMPACT_RANKING_POLICY, DEFAULT_INTEGRATIONS_POLICY, DEFAULT_LOAD_POLICY, DEFAULT_VALIDATION_POLICY, SEMANTIC_RELATIONS, cloneImpactRankingPolicy, cloneIntegrationsPolicy, cloneLoadPolicy, cloneReferenceExpansionPolicy, cloneValidationPolicy, defaultDotdotgodConfigData, defaultDotdotgodConfigText, defaultMemoryConfig, isMarkdownSizeExcluded, isSecretLikePathPattern, isValidPathPattern, matchPathPattern, memoryAreaForPath, memoryConfigSummary, memoryRoleForPath, normalizeLowSignalTerm, readMemoryConfig, requiresTraceability, resolveMemoryArea, retrievalPriorityForPath, trelloSyncPaths, validateMemoryConfigData } from './memory/config.mjs';
18
+ export { DEFAULT_IMPACT_RANKING_POLICY, DEFAULT_INTEGRATIONS_POLICY, DEFAULT_LOAD_POLICY, DEFAULT_VALIDATION_POLICY, SEMANTIC_RELATIONS, cloneImpactRankingPolicy, cloneIntegrationsPolicy, cloneLoadPolicy, cloneReferenceExpansionPolicy, cloneValidationPolicy, defaultDotdotgodConfigData, defaultDotdotgodConfigText, defaultMemoryConfig, isMarkdownSizeExcluded, isSecretLikePathPattern, isValidPathPattern, matchPathPattern, memoryAreaForPath, memoryConfigSummary, normalizeLowSignalTerm, readMemoryConfig, requiresTraceability, resolveMemoryArea, trelloSyncPaths, validateMemoryConfigData } from './memory/config.mjs';
19
19
  export { buildCommunities, relationWeight } from './graph/communities.mjs';
20
20
  export { addEdge, addNode, compactGraph, expandGraph, graphStats, jsonSize, shardFile, writeJson } from './graph/store.mjs';
21
21
  export { cacheFile, collectIndexFiles, fingerprint, shouldIndexPath } from './index/files.mjs';
22
22
  export { buildMemoryAreas, buildPinnedFiles, detectCommandGuidance, detectPackageManager } from './load-snapshot/summary.mjs';
23
- export { isReadmeIndexPath, retrievalMetadataForPath, fileNodeMetadata } from './graph/metadata.mjs';
24
- export { extractMarkdownGraph, extractPackageGraph, buildGraph } from './graph/extract.mjs';
23
+ export { isReadmeIndexPath, retrievalMetadataForPath } from './graph/metadata.mjs';
24
+ export { buildGraph } from './graph/extract.mjs';
25
25
  export { addDeterministicSemanticEdges } from './graph/semantic.mjs';
26
26
  export { buildIndex, writeIndex, readIndex, getStatus, runIndex, runStatus, readFreshIndex, graphSummary } from './index/cache.mjs';
27
27
  export { runValidate } from './validate/run.mjs';
28
- export { buildImpactReport, buildCompactImpactReport, neighborhood } from './impact/report.mjs';
28
+ export { buildImpactReport, buildCompactImpactReport } from './impact/report.mjs';
29
29
  export { formatCompactImpactOutput, formatYmlGraphImpactError, formatYmlImpactOutput } from './impact/format.mjs';
30
30
  export { runConfig } from './commands/config.mjs';
31
31
  export { runLoadSnapshot } from './commands/load-snapshot.mjs';
@@ -36,11 +36,11 @@ export { parseGraphOptions, runGraph } from './commands/graph.mjs';
36
36
  export { buildPlanValidationRepairPrompt, createPlanStageCheckpoint, formatPlanValidationText, PLAN_REQUIRED_HEADERS, PLAN_STAGE_DIRECTORIES, resolvePlanValidationStage, runPlan, validatePlanArtifact } from './commands/plan.mjs';
37
37
  export { planTrelloDryRun, runTrelloSync } from './trello/sync.mjs';
38
38
  export { parseTrelloMetadata, extractTrelloShortLink } from './trello/metadata.mjs';
39
- export { normalizeGitHubRemote, normalizeGitHubRepositoryKey, resolveGitHubFileUrl, resolveGitHubRepositoryIdentity } from './trello/github-url.mjs';
39
+ export { normalizeGitHubRemote, resolveGitHubFileUrl, resolveGitHubRepositoryIdentity } from './trello/github-url.mjs';
40
40
  export { buildLinkedDocsData, summarizeTraceability } from './trello/summary.mjs';
41
41
  export { formatTrelloDryRunReport, formatTrelloWriteReport } from './trello/report.mjs';
42
42
  export { createTrelloClient, resolveTrelloCredentials } from './trello/client.mjs';
43
- export { buildLinkedDocsCustomFieldData, customFieldTextValue, DEFAULT_DOTDOTGOD_CUSTOM_FIELD_NAME, dotdotgodCustomFieldName, mergeLinkedDocsCustomFieldValue, parseLinkedDocsCustomFieldPayload, serializeLinkedDocsCustomFieldPayload } from './trello/custom-fields.mjs';
43
+ export { customFieldTextValue, DEFAULT_DOTDOTGOD_CUSTOM_FIELD_NAME, dotdotgodCustomFieldName, mergeLinkedDocsCustomFieldValue, parseLinkedDocsCustomFieldPayload, serializeLinkedDocsCustomFieldPayload } from './trello/custom-fields.mjs';
44
44
 
45
45
  export async function runCli(argv = process.argv.slice(2)) {
46
46
  const [command = 'help', ...args] = argv;
@@ -6,7 +6,7 @@ import { defaultMemoryConfig } from '../memory/config.mjs';
6
6
  import { addEdge, addNode } from './store.mjs';
7
7
  import { addMemoryAreaMembership, addPackageResource, addTraceabilityGraph, fileNodeMetadata, headingId, isReadmeIndexPath } from './metadata.mjs';
8
8
 
9
- export function extractMarkdownGraph(root, file, graph, config = defaultMemoryConfig()) {
9
+ function extractMarkdownGraph(root, file, graph, config = defaultMemoryConfig()) {
10
10
  const path = rel(root, file);
11
11
  const content = readFileSync(file, 'utf8');
12
12
  const fileId = `file:${path}`;
@@ -30,7 +30,7 @@ export function extractMarkdownGraph(root, file, graph, config = defaultMemoryCo
30
30
  addTraceabilityGraph(root, fileId, content, graph, config);
31
31
  }
32
32
 
33
- export function extractPackageGraph(root, file, graph) {
33
+ function extractPackageGraph(root, file, graph) {
34
34
  const path = rel(root, file);
35
35
  const fileId = `file:${path}`;
36
36
  let pkg;
@@ -1,8 +1,8 @@
1
1
  import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { SEMANTIC_RELATIONS } from '../memory/config.mjs';
4
+ import { CACHE_DIR } from '../index/constants.mjs';
4
5
 
5
- const CACHE_DIR = '.dotdotgod';
6
6
  const GRAPH_NODE_SHARDS = ['docs', 'packages', 'source'];
7
7
  const GRAPH_EDGE_SHARDS = ['docs-links', 'packages', 'other'];
8
8
 
@@ -121,7 +121,3 @@ export function buildCompactImpactReport(impact, limits = {}) {
121
121
  const top10 = (impact.related ?? []).filter((item) => item.id !== `file:${impact.changed}`).slice(0, 10);
122
122
  return { changed: impact.changed, compact: true, ranking: { method: impact.ranking?.method, preset: impact.ranking?.preset, configSource: impact.ranking?.configSource }, related, groups, omittedRelated: (impact.omittedRelated ?? 0) + Math.max(0, (impact.related?.length ?? 0) - related.length), quality: { rawRelated: impact.related?.length ?? 0, compactRelated: related.length, semanticOnlyTop10: top10.filter((item) => isSemanticOnlyImpactItem(item)).length, curatedTop10: top10.filter((item) => hasCuratedImpactReason(item)).length, lowActionabilityTop10: top10.filter((item) => isLowActionabilityImpactItem(item)).length } };
123
123
  }
124
-
125
- export function neighborhood(index, changedPath) {
126
- return buildImpactReport(index, changedPath).related;
127
- }
@@ -4,9 +4,8 @@ import { spawnSync } from 'node:child_process';
4
4
  import { basename, extname, join } from 'node:path';
5
5
  import { rel } from '../common/paths.mjs';
6
6
  import { defaultMemoryConfig, readMemoryConfig, resolveMemoryArea } from '../memory/config.mjs';
7
+ import { CACHE_DIR, MANIFEST_FILE } from './constants.mjs';
7
8
 
8
- const CACHE_DIR = '.dotdotgod';
9
- const MANIFEST_FILE = 'manifest.json';
10
9
  const INDEX_TEXT_EXTENSIONS = new Set([
11
10
  '.md', '.mdx', '.markdown', '.txt', '.rst', '.adoc', '.org',
12
11
  '.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.py', '.pyw', '.go', '.rs', '.java', '.kt', '.kts', '.swift', '.rb', '.php', '.cs', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.m', '.mm', '.scala', '.clj', '.cljs', '.ex', '.exs', '.erl', '.hrl', '.lua', '.pl', '.pm', '.r', '.R', '.sql',
@@ -71,25 +70,38 @@ function walkIndexCandidates(root, config = readMemoryConfig(root)) {
71
70
  return files;
72
71
  }
73
72
 
74
- function addDotdotgodLocalMemoryCandidates(root, candidates) {
75
- for (const file of ['AGENTS.md', 'CLAUDE.md', 'CODEX.md', 'README.md', 'docs/README.md', 'docs/spec/README.md', 'docs/test/README.md', 'docs/arch/README.md', 'docs/plan/README.md', 'docs/archive/README.md']) {
76
- if (existsSync(join(root, file))) candidates.add(file);
77
- }
78
- const planRoot = join(root, 'docs/plan');
79
- const walkPlan = (dir) => {
80
- if (!existsSync(dir)) return;
81
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
82
- const path = join(dir, entry.name);
83
- if (entry.isDirectory()) walkPlan(path);
84
- else if (entry.isFile()) candidates.add(rel(root, path));
73
+ function addConfiguredLocalMemoryCandidates(root, candidates, config, limit = 5000) {
74
+ let added = 0;
75
+ const addPath = (path) => {
76
+ if (added >= limit || !existsSync(path)) return;
77
+ let stat;
78
+ try { stat = statSync(path); } catch { return; }
79
+ const pathRel = rel(root, path);
80
+ if (stat.isFile()) {
81
+ candidates.add(pathRel);
82
+ added += 1;
83
+ return;
84
+ }
85
+ if (!stat.isDirectory() || isExcludedIndexDir(pathRel)) return;
86
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
87
+ if (added >= limit) return;
88
+ addPath(join(path, entry.name));
85
89
  }
86
90
  };
87
- walkPlan(planRoot);
91
+
92
+ for (const area of config.areas ?? []) {
93
+ if (area.scope !== 'local' || area.includeBodiesByDefault === false) continue;
94
+ for (const pattern of area.paths ?? []) {
95
+ if (pattern.startsWith('**/')) continue;
96
+ const base = pattern.endsWith('/**') ? pattern.slice(0, -3) : pattern;
97
+ addPath(join(root, base));
98
+ }
99
+ }
88
100
  }
89
101
 
90
102
  export function collectIndexFiles(root, config = readMemoryConfig(root)) {
91
103
  const candidates = new Set(gitIndexCandidates(root) ?? walkIndexCandidates(root, config));
92
- addDotdotgodLocalMemoryCandidates(root, candidates);
104
+ addConfiguredLocalMemoryCandidates(root, candidates, config);
93
105
  return [...candidates].filter((path) => shouldIndexPath(path, config)).map((path) => join(root, path)).filter(existsSync).sort();
94
106
  }
95
107
 
package/src/init.mjs CHANGED
@@ -1,10 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { basename, dirname, join, resolve } from 'node:path';
3
3
 
4
- function utcTimestamp() {
5
- return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
6
- }
7
-
8
4
  function action(status, path, extra = {}) {
9
5
  return { status, path, ...extra };
10
6
  }
@@ -16,10 +12,6 @@ function formatAction(item) {
16
12
  return `${item.status.padEnd(13)} ${item.path}${extras.length > 0 ? ` ${extras.join(' ')}` : ''}`;
17
13
  }
18
14
 
19
- function normalizeRoot(root) {
20
- return resolve(root);
21
- }
22
-
23
15
  function parseInitOptions(argv) {
24
16
  const options = { root: null, projectName: '', dotdotSetting: false, force: false, dryRun: false, json: false };
25
17
  for (let i = 0; i < argv.length; i += 1) {
@@ -42,7 +34,7 @@ function parseInitOptions(argv) {
42
34
  }
43
35
  }
44
36
  if (!options.root) throw new Error('Missing required argument: <root>');
45
- options.root = normalizeRoot(options.root);
37
+ options.root = resolve(options.root);
46
38
  if (!options.projectName) options.projectName = basename(options.root);
47
39
  return options;
48
40
  }
@@ -65,7 +57,7 @@ function writeInitFile(options, relativePath, content, actions) {
65
57
 
66
58
  let backup = '';
67
59
  if (existsSync(target) && options.force) {
68
- backup = `${target}.bak.${utcTimestamp()}`;
60
+ backup = `${target}.bak.${new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z')}`;
69
61
  if (!options.dryRun) renameSync(target, backup);
70
62
  }
71
63
 
@@ -27,6 +27,12 @@ export const DEFAULT_INTEGRATIONS_POLICY = {
27
27
  export const DEFAULT_LOAD_POLICY = {
28
28
  pinnedPaths: [],
29
29
  pinnedBodies: [],
30
+ documentationSummary: {
31
+ exclude: ['docs/plan', 'docs/archive'],
32
+ },
33
+ };
34
+ export const DEFAULT_PLAN_MODE_POLICY = {
35
+ writablePaths: ['docs/plan/**', 'docs/archive/**'],
30
36
  };
31
37
  export const DEFAULT_IMPACT_RANKING_POLICY = {
32
38
  preset: 'balanced',
@@ -75,6 +81,7 @@ const DEFAULT_MEMORY_AREAS = [
75
81
  { id: 'active-plan', label: 'Active Plans', paths: ['docs/plan/**'], scope: 'local', freshness: 'fresh', role: 'active-task-intent', priority: 95, includeBodiesByDefault: true },
76
82
  { id: 'archive-map', label: 'Archive Map', paths: ['docs/archive/README.md'], scope: 'local', freshness: 'stale', role: 'historical-memory-map', priority: 65, includeBodiesByDefault: true },
77
83
  { id: 'archive-body', label: 'Archive Body', paths: ['docs/archive/**'], excludePaths: ['docs/archive/README.md'], scope: 'local', freshness: 'stale', role: 'historical-memory-body', priority: 20, includeBodiesByDefault: false },
84
+ { id: 'docs', label: 'Project Documentation', paths: ['docs/**'], scope: 'shared', freshness: 'fresh', role: 'project-documentation', priority: 60, includeBodiesByDefault: true },
78
85
  ];
79
86
 
80
87
  function cloneClarifyGuidance(clarify) {
@@ -178,11 +185,18 @@ export function cloneLoadPolicy(policy = DEFAULT_LOAD_POLICY) {
178
185
  return {
179
186
  pinnedPaths: [...(policy.pinnedPaths ?? [])],
180
187
  pinnedBodies: [...(policy.pinnedBodies ?? [])],
188
+ documentationSummary: {
189
+ exclude: [...(policy.documentationSummary?.exclude ?? DEFAULT_LOAD_POLICY.documentationSummary.exclude)],
190
+ },
181
191
  };
182
192
  }
183
193
 
194
+ export function clonePlanModePolicy(policy = DEFAULT_PLAN_MODE_POLICY) {
195
+ return { writablePaths: [...(policy.writablePaths ?? DEFAULT_PLAN_MODE_POLICY.writablePaths)] };
196
+ }
197
+
184
198
  export function defaultMemoryConfig() {
185
- return { source: 'default', areas: DEFAULT_MEMORY_AREAS.map(cloneArea), traceability: cloneTraceabilityPolicy(), validation: cloneValidationPolicy(), impactRanking: cloneImpactRankingPolicy(), referenceExpansion: cloneReferenceExpansionPolicy(), integrations: cloneIntegrationsPolicy(), load: cloneLoadPolicy() };
199
+ return { source: 'default', areas: DEFAULT_MEMORY_AREAS.map(cloneArea), traceability: cloneTraceabilityPolicy(), validation: cloneValidationPolicy(), impactRanking: cloneImpactRankingPolicy(), referenceExpansion: cloneReferenceExpansionPolicy(), integrations: cloneIntegrationsPolicy(), load: cloneLoadPolicy(), planMode: clonePlanModePolicy() };
186
200
  }
187
201
 
188
202
  export function normalizePathPattern(value = '') {
@@ -225,14 +239,6 @@ export function memoryAreaForPath(path = '', config = defaultMemoryConfig()) {
225
239
  return resolveMemoryArea(path, config)?.id;
226
240
  }
227
241
 
228
- export function memoryRoleForPath(path = '', config = defaultMemoryConfig()) {
229
- return resolveMemoryArea(path, config)?.role;
230
- }
231
-
232
- export function retrievalPriorityForPath(path = '', config = defaultMemoryConfig()) {
233
- return resolveMemoryArea(path, config)?.priority ?? 30;
234
- }
235
-
236
242
  function normalizeTraceabilityPolicy(raw) {
237
243
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return cloneTraceabilityPolicy();
238
244
  return {
@@ -269,10 +275,26 @@ export function isSecretLikePathPattern(value = '') {
269
275
  return /(^|\/)(\.env|\.npmrc|\.pypirc|id_rsa|id_dsa|id_ed25519|credentials?|secrets?)(\.|\/|$)/i.test(normalized);
270
276
  }
271
277
 
278
+ function normalizePlanModePolicy(raw) {
279
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return clonePlanModePolicy();
280
+ const writablePaths = raw.writablePaths === undefined
281
+ ? DEFAULT_PLAN_MODE_POLICY.writablePaths
282
+ : [...new Set((Array.isArray(raw.writablePaths) ? raw.writablePaths : []).map(normalizePathPattern))];
283
+ return clonePlanModePolicy({ writablePaths });
284
+ }
285
+
272
286
  function normalizeLoadPolicy(raw) {
273
287
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return cloneLoadPolicy();
274
288
  const normalize = (values) => (Array.isArray(values) ? [...new Set(values.map(normalizePathPattern))] : []);
275
- return cloneLoadPolicy({ pinnedPaths: normalize(raw.pinnedPaths), pinnedBodies: normalize(raw.pinnedBodies) });
289
+ return cloneLoadPolicy({
290
+ pinnedPaths: normalize(raw.pinnedPaths),
291
+ pinnedBodies: normalize(raw.pinnedBodies),
292
+ documentationSummary: {
293
+ exclude: raw.documentationSummary?.exclude === undefined
294
+ ? DEFAULT_LOAD_POLICY.documentationSummary.exclude
295
+ : normalize(raw.documentationSummary.exclude),
296
+ },
297
+ });
276
298
  }
277
299
 
278
300
  export function trelloSyncPaths(config = defaultMemoryConfig()) {
@@ -392,6 +414,25 @@ export function validateMemoryConfigData(data, root = '.', file = 'dotdotgod.con
392
414
  if (values.some((value) => typeof value === 'string' && isSecretLikePathPattern(value))) add('LOAD_CONFIG_SECRET_PINNED_PATH', `load.${key}`, 'Pinned load paths must not target secrets, credentials, environment files, or private keys.');
393
415
  }
394
416
  }
417
+ if (load.documentationSummary !== undefined) {
418
+ const summary = load.documentationSummary;
419
+ if (!summary || typeof summary !== 'object' || Array.isArray(summary)) {
420
+ add('LOAD_CONFIG_INVALID_DOCUMENTATION_SUMMARY', 'load.documentationSummary', 'Expected an object.');
421
+ } else if (summary.exclude !== undefined) {
422
+ if (!Array.isArray(summary.exclude)) add('LOAD_CONFIG_INVALID_DOCUMENTATION_SUMMARY_EXCLUDE', 'load.documentationSummary.exclude', 'Expected an array of path strings.');
423
+ else if (summary.exclude.some((value) => !isValidPathPattern(value) || (typeof value === 'string' && value.trim().startsWith('/')))) add('LOAD_CONFIG_INVALID_DOCUMENTATION_SUMMARY_EXCLUDE', 'load.documentationSummary.exclude', 'Expected repository-relative path strings using exact paths, /** subtree patterns, or **/suffix patterns.');
424
+ }
425
+ }
426
+ }
427
+ }
428
+ const planMode = data.planMode;
429
+ if (planMode !== undefined) {
430
+ if (!planMode || typeof planMode !== 'object' || Array.isArray(planMode)) {
431
+ add('PLAN_MODE_CONFIG_INVALID', 'planMode', 'Expected an object.');
432
+ } else if (planMode.writablePaths !== undefined) {
433
+ const paths = planMode.writablePaths;
434
+ if (!Array.isArray(paths)) add('PLAN_MODE_CONFIG_INVALID_WRITABLE_PATHS', 'planMode.writablePaths', 'Expected an array of documentation path strings.');
435
+ else if (paths.some((value) => !isValidPathPattern(value) || typeof value !== 'string' || value.trim().startsWith('/') || (!normalizePathPattern(value).startsWith('docs/') && normalizePathPattern(value) !== 'docs') || normalizePathPattern(value).startsWith('docs/.') || isSecretLikePathPattern(value))) add('PLAN_MODE_CONFIG_INVALID_WRITABLE_PATHS', 'planMode.writablePaths', 'Expected safe repository-relative paths under docs/ using exact paths or /** subtree patterns.');
395
436
  }
396
437
  }
397
438
  const referenceExpansion = data.referenceExpansion;
@@ -506,7 +547,8 @@ export function readMemoryConfig(root = '.') {
506
547
  const referenceExpansion = normalizeReferenceExpansionPolicy(data.referenceExpansion);
507
548
  const integrations = normalizeIntegrationsPolicy(data.integrations);
508
549
  const load = normalizeLoadPolicy(data.load);
509
- return configuredAreas.length > 0 ? { source: name, areas: configuredAreas, traceability, validation, impactRanking, referenceExpansion, integrations, load, errors: [] } : { ...defaultMemoryConfig(), traceability, validation, impactRanking, referenceExpansion, integrations, load, source: name, errors: [] };
550
+ const planMode = normalizePlanModePolicy(data.planMode);
551
+ return configuredAreas.length > 0 ? { source: name, areas: configuredAreas, traceability, validation, impactRanking, referenceExpansion, integrations, load, planMode, errors: [] } : { ...defaultMemoryConfig(), traceability, validation, impactRanking, referenceExpansion, integrations, load, planMode, source: name, errors: [] };
510
552
  } catch (error) {
511
553
  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.` }] };
512
554
  }
@@ -540,6 +582,7 @@ export function defaultDotdotgodConfigData() {
540
582
  referenceExpansion: { fuzzy: { lowSignal: { add: [], remove: [] } } },
541
583
  integrations: cloneIntegrationsPolicy(config.integrations),
542
584
  load: cloneLoadPolicy(config.load),
585
+ planMode: clonePlanModePolicy(config.planMode),
543
586
  };
544
587
  }
545
588
 
@@ -567,6 +610,7 @@ export function memoryConfigSummary(config) {
567
610
  referenceExpansion: cloneReferenceExpansionPolicy(config.referenceExpansion),
568
611
  integrations: cloneIntegrationsPolicy(config.integrations),
569
612
  load: cloneLoadPolicy(config.load),
613
+ planMode: clonePlanModePolicy(config.planMode),
570
614
  };
571
615
  }
572
616
 
@@ -5,10 +5,6 @@ import { readFreshIndex } from '../index/cache.mjs';
5
5
  import { buildCompactImpactReport, buildImpactReport } from '../impact/report.mjs';
6
6
  import { DEFAULT_REFERENCE_LIMIT, extractBracketReferences, extractFuzzyReferences, isArchiveBodyPath, normalizeReferenceAlias, referenceCandidateAliases, referencePathForNode } from './extract.mjs';
7
7
 
8
- function incomingRouteBonus(graph, nodeId) {
9
- return graph.edges.some((edge) => edge.target === nodeId && edge.relation === 'routes_to') ? 6 : 0;
10
- }
11
-
12
8
  function exactReferenceScore(ref, alias, kind) {
13
9
  const rawRef = String(ref).trim().replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0].trim().replace(/\\/g, '/').replace(/^\.\//, '');
14
10
  const rawAlias = String(alias).trim().replace(/\\/g, '/').replace(/^\.\//, '');
@@ -40,7 +36,7 @@ export function resolveReferenceCandidates(index, ref, options = {}) {
40
36
  }
41
37
  if (!best) continue;
42
38
  const retrievalPriority = Number(node.retrievalPriority ?? node.retrieval?.priority ?? 30);
43
- const routeBonus = incomingRouteBonus(graph, node.id);
39
+ const routeBonus = graph.edges.some((edge) => edge.target === node.id && edge.relation === 'routes_to') ? 6 : 0;
44
40
  const memoryBonus = Math.min(12, Math.max(0, retrievalPriority / 10));
45
41
  const headingBonus = node.type === 'heading' ? 2 : 0;
46
42
  const score = Number((best.score + routeBonus + memoryBonus + headingBonus).toFixed(1));
@@ -80,13 +76,6 @@ function parseReferenceOptions(argv, command) {
80
76
  return { ...options, root: resolve(operands[0] ?? '.'), json: filtered.includes('--json'), rootArgv: operands.slice(1) };
81
77
  }
82
78
 
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
79
  function formatReferenceOutput(payload) {
91
80
  const refreshNote = payload.metadata.cacheRefreshed ? ', refreshed' : '';
92
81
  const lines = [`${payload.command}: ${payload.refs.length} reference(s) (${payload.status.status}${refreshNote} index)`];
@@ -124,7 +113,12 @@ export function runExpand(argv) {
124
113
  ...refsInPrompt.map((item) => ({ ...resolveReferenceCandidates(index, item.target, options), source: 'explicit', raw: item.raw, label: item.label })),
125
114
  ...fuzzyRefs.map((item) => ({ ...resolveReferenceCandidates(index, item.target, options), source: 'fuzzy', raw: item.raw, confidence: item.confidence, reasons: item.reasons })),
126
115
  ];
127
- if (options.withImpact) refs = refs.map((item) => attachImpactToRef(index, item));
116
+ if (options.withImpact) refs = refs.map((item) => {
117
+ const topPath = item.top?.path;
118
+ if (!topPath) return item;
119
+ const impact = buildCompactImpactReport(buildImpactReport(index, topPath));
120
+ return { ...item, impact: { changed: topPath, related: impact.related, groups: impact.groups, omittedRelated: impact.omittedRelated, quality: impact.quality } };
121
+ });
128
122
  const payload = { ok: status.ok, command: 'expand', root: options.root, prompt, status, metadata, refs, omitted: refs.reduce((sum, item) => sum + item.omitted, 0) };
129
123
  if (options.json) console.log(JSON.stringify(payload, null, 2));
130
124
  else console.log(formatReferenceOutput(payload));
@@ -8,7 +8,7 @@ export function dotdotgodCustomFieldName({ env = process.env, name = '' } = {})
8
8
  return cleanString(name) || cleanString(env.DOTDOTGOD_TRELLO_CUSTOM_FIELD_NAME) || DEFAULT_DOTDOTGOD_CUSTOM_FIELD_NAME;
9
9
  }
10
10
 
11
- export function buildLinkedDocsCustomFieldData({ docsPath, githubUrl, trelloUrl, repositoryKey, repositoryLabel, title, summary, traceabilityState, traceabilityDetails = [] } = {}) {
11
+ function buildLinkedDocsCustomFieldData({ docsPath, githubUrl, trelloUrl, repositoryKey, repositoryLabel, title, summary, traceabilityState, traceabilityDetails = [] } = {}) {
12
12
  return {
13
13
  repositoryKey: cleanString(repositoryKey),
14
14
  repositoryLabel: cleanString(repositoryLabel) || cleanString(repositoryKey).split('/').filter(Boolean).at(-1) || '',
@@ -22,12 +22,8 @@ export function buildLinkedDocsCustomFieldData({ docsPath, githubUrl, trelloUrl,
22
22
  };
23
23
  }
24
24
 
25
- function normalizeEntry(entry = {}) {
26
- return buildLinkedDocsCustomFieldData(entry);
27
- }
28
-
29
25
  export function serializeLinkedDocsCustomFieldPayload(entries = []) {
30
- return JSON.stringify({ version: 1, entries: entries.map(normalizeEntry) });
26
+ return JSON.stringify({ version: 1, entries: entries.map((entry) => buildLinkedDocsCustomFieldData(entry)) });
31
27
  }
32
28
 
33
29
  export function parseLinkedDocsCustomFieldPayload(text = '') {
@@ -54,7 +50,7 @@ export function parseLinkedDocsCustomFieldPayload(text = '') {
54
50
  },
55
51
  };
56
52
  }
57
- const entries = parsed.entries.map(normalizeEntry);
53
+ const entries = parsed.entries.map((entry) => buildLinkedDocsCustomFieldData(entry));
58
54
  const keys = entries.map((entry) => entry.repositoryKey).filter(Boolean);
59
55
  if (keys.length !== new Set(keys).size) {
60
56
  return {
@@ -69,7 +65,7 @@ export function parseLinkedDocsCustomFieldPayload(text = '') {
69
65
  }
70
66
 
71
67
  export function mergeLinkedDocsCustomFieldValue(existingText = '', entry = {}) {
72
- const current = normalizeEntry(entry);
68
+ const current = buildLinkedDocsCustomFieldData(entry);
73
69
  if (!current.repositoryKey) {
74
70
  return {
75
71
  ok: false,
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { spawnSync } from 'node:child_process';
3
3
  import { join } from 'node:path';
4
4
 
5
- export function normalizeGitHubRepositoryKey(value = '') {
5
+ function normalizeGitHubRepositoryKey(value = '') {
6
6
  const text = String(value).trim();
7
7
  const match = text.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/u);
8
8
  if (!match) return null;