@dotdotgod/cli 0.2.28 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdotgod/cli",
3
- "version": "0.2.28",
3
+ "version": "0.3.1",
4
4
  "description": "CLI for dotdotgod validation, local documentation search, and graph indexing.",
5
5
  "type": "module",
6
6
  "license": "Elastic-2.0",
package/src/cli/usage.mjs CHANGED
@@ -11,7 +11,7 @@ export function commandUsage(command = 'root') {
11
11
  dotdotgod validate <root> [--include-local-memory] [--check-index] [--max-lines n] [--max-chars n] [--no-link-check] [--json]`;
12
12
  case 'init':
13
13
  return `Usage:
14
- dotdotgod init <root> [--project-name NAME] [--template NAME] [--dotdot-setting] [--dry-run] [--json]
14
+ dotdotgod init <root> [--project-name NAME] [--template NAME] [--documentation-root PATH] [--dotdot-setting] [--dry-run] [--json]
15
15
 
16
16
  Create AGENTS.md, agent entrypoints, docs indexes, a project config from an initialization template, and local memory gitignore entries.`;
17
17
  case 'index':
@@ -36,6 +36,11 @@ Create dotdotgod.config.json from a bundled or user template. Without --template
36
36
  dotdotgod query <root> <query> [--limit n] [--json]
37
37
 
38
38
  Search shared project documentation with the resolved local or remote embedding provider.`;
39
+ case 'map':
40
+ return `Usage:
41
+ dotdotgod map <root> [--depth <positive-integer>] [--json]
42
+
43
+ Render the configured shared Markdown documentation map without modifying the project.`;
39
44
  case 'resolve':
40
45
  return `Usage:
41
46
  dotdotgod resolve <root> <ref> [--max-results n] [--include-archive] [--json]`;
@@ -73,6 +78,7 @@ Commands:
73
78
  init Initialize project memory files.
74
79
  config Inspect or initialize project configuration.
75
80
  query Search project documentation.
81
+ map Render the project documentation map.
76
82
  resolve Resolve a project reference.
77
83
  expand Expand references in a prompt.
78
84
  validate Validate project memory and documentation.
@@ -40,6 +40,7 @@ function formatConfigOutput(payload) {
40
40
  const errors = payload.errors ?? [];
41
41
  const lines = [`dotdotgod config: ${payload.source}${errors.length > 0 ? ' (invalid; using defaults)' : ''}`];
42
42
  lines.push(`- path: ${payload.path ?? 'none'}`);
43
+ lines.push(`- documentation root: ${payload.config.documentation.root}`);
43
44
  lines.push(`- memory areas: ${payload.config.areas.length}`);
44
45
  lines.push(`- embedding: ${payload.embedding.profile.provider}/${payload.embedding.profile.model} (${payload.embedding.source})`);
45
46
  lines.push(`- traceability required: ${(payload.config.traceability.required ?? []).join(', ') || 'none'}`);
@@ -0,0 +1,41 @@
1
+ import { resolve } from 'node:path';
2
+ import { buildDocumentationMap } from '../memory/documentation-map.mjs';
3
+
4
+ function fail(json, code, message) {
5
+ if (json) console.log(JSON.stringify({ ok: false, error: { code, message } }, null, 2));
6
+ else console.error(message);
7
+ process.exit(2);
8
+ }
9
+
10
+ function parseMapOptions(argv) {
11
+ const options = { root: '.', depth: 5, json: false };
12
+ let rootSet = false;
13
+ for (let index = 0; index < argv.length; index += 1) {
14
+ const arg = argv[index];
15
+ if (arg === '--json') options.json = true;
16
+ }
17
+ for (let index = 0; index < argv.length; index += 1) {
18
+ const arg = argv[index];
19
+ if (arg === '--json') continue;
20
+ if (arg === '--depth') {
21
+ const value = argv[index + 1];
22
+ if (!value || !/^\d+$/.test(value) || Number(value) < 1) fail(options.json, 'INVALID_DEPTH', '--depth requires a positive integer.');
23
+ options.depth = Number(value);
24
+ index += 1;
25
+ } else if (!arg.startsWith('-') && !rootSet) {
26
+ options.root = arg;
27
+ rootSet = true;
28
+ } else if (!arg.startsWith('-')) fail(options.json, 'UNEXPECTED_ARGUMENT', `Unexpected argument: ${arg}`);
29
+ else fail(options.json, 'UNKNOWN_OPTION', `Unknown option: ${arg}`);
30
+ }
31
+ options.root = resolve(options.root);
32
+ return options;
33
+ }
34
+
35
+ export function runMap(argv) {
36
+ const options = parseMapOptions(argv);
37
+ const result = buildDocumentationMap(options.root, { depth: options.depth });
38
+ if (!result.ok) fail(options.json, result.error.code, result.error.message);
39
+ if (options.json) console.log(JSON.stringify(result, null, 2));
40
+ else console.log(result.tree);
41
+ }
@@ -63,7 +63,7 @@ export async function buildVectorIndex(root, embedOrOptions = {}) {
63
63
  const resolved = await resolveEmbedder(root, options);
64
64
  const config = readMemoryConfig(root);
65
65
  const exclude = config.load?.documentationSummary?.exclude ?? ['docs/plan', 'docs/archive'];
66
- const chunks = collectDocumentationChunks(root, exclude);
66
+ const chunks = collectDocumentationChunks(root, exclude, config.documentation?.root ?? 'docs');
67
67
  const cached = readVectorCache(root, resolved.identity);
68
68
  const cachedOffsets = new Map((cached?.chunks ?? []).map((chunk, index) => [chunk.fingerprint, index]));
69
69
  const rows = new Array(chunks.length);
@@ -42,11 +42,11 @@ const BUILT_IN_TEMPLATE_SCAFFOLDS = {
42
42
  ],
43
43
  };
44
44
 
45
- export function builtInTemplateScaffold(name) {
45
+ export function builtInTemplateScaffold(name, { documentationRoot = 'docs' } = {}) {
46
46
  const entries = BUILT_IN_TEMPLATE_SCAFFOLDS[name];
47
47
  if (!entries) return null;
48
48
  return entries.map(([path, heading, description]) => ({
49
- path,
49
+ path: path.replace(/^docs(?=\/|$)/, documentationRoot),
50
50
  type: heading ? 'file' : 'directory',
51
51
  ...(heading ? { content: `# ${heading}\n\n${description}` } : {}),
52
52
  }));
@@ -122,7 +122,13 @@ function withPolicy(areas, required, definitionsList) {
122
122
  return config;
123
123
  }
124
124
 
125
- export function builtInTemplateData(name) {
125
+ function rebaseBuiltIn(value, root) {
126
+ if (Array.isArray(value)) return value.map(item => rebaseBuiltIn(item, root));
127
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rebaseBuiltIn(item, root)]));
128
+ return typeof value === 'string' ? value.replace(/^docs(?=\/|$)/, root) : value;
129
+ }
130
+
131
+ function rawBuiltInTemplateData(name) {
126
132
  if (name === 'software') return defaultDotdotgodConfigData();
127
133
  if (name === 'research') return withPolicy([
128
134
  area('spec', 'Research Specifications', ['docs/spec/**'], 'research-contract', 85),
@@ -162,8 +168,13 @@ export function builtInTemplateData(name) {
162
168
  return null;
163
169
  }
164
170
 
165
- export function builtInTemplateText(name) {
166
- const data = builtInTemplateData(name);
171
+ export function builtInTemplateData(name, { documentationRoot = 'docs' } = {}) {
172
+ const data = rawBuiltInTemplateData(name);
173
+ return data ? rebaseBuiltIn(data, documentationRoot) : null;
174
+ }
175
+
176
+ export function builtInTemplateText(name, options) {
177
+ const data = builtInTemplateData(name, options);
167
178
  return data ? `${JSON.stringify(data, null, 2)}\n` : null;
168
179
  }
169
180
 
package/src/core.mjs CHANGED
@@ -4,6 +4,7 @@ import { runValidate } from './validate/run.mjs';
4
4
  import { runIndex, runStatus } from './index/cache.mjs';
5
5
  import { runConfig } from './commands/config.mjs';
6
6
  import { runQuery } from './commands/query.mjs';
7
+ import { runMap } from './commands/map.mjs';
7
8
  import { runResolve, runExpand } from './reference/resolve.mjs';
8
9
  import { runTraceability } from './commands/traceability.mjs';
9
10
  import { runGraph } from './commands/graph.mjs';
@@ -13,12 +14,13 @@ export { commandUsage, hasHelpToken, helpCommandFromArgs, isHelpToken, isVersion
13
14
  export { rel } from './common/paths.mjs';
14
15
  export { extractAnchors, extractFirstHeading, extractLinks, headingToAnchor, isKebabCase, isNumberedSeriesFilename, isUpperSnakeMarkdown, removeCodeBlocks, suggestFilenameFromHeading } from './docs/markdown.mjs';
15
16
  export { extractDotdotgodTraceabilityBlocks, findTraceabilityLinksRegion, isLocalRelativeTraceabilityPath, renderCompactTraceabilityBlock, stripTraceabilityLinksRegion, syncTraceabilityLinksInContent, traceabilityExample, validateTraceabilityBlock, validateTraceabilityLinksRegion, validateTraceabilityPlacement } from './docs/traceability.mjs';
16
- export { DEFAULT_IMPACT_RANKING_POLICY, DEFAULT_LOAD_POLICY, DEFAULT_VALIDATION_POLICY, SEMANTIC_RELATIONS, cloneImpactRankingPolicy, cloneLoadPolicy, cloneReferenceExpansionPolicy, cloneValidationPolicy, defaultDotdotgodConfigData, defaultDotdotgodConfigText, defaultMemoryConfig, isMarkdownSizeExcluded, isSecretLikePathPattern, isValidPathPattern, matchPathPattern, memoryAreaForPath, memoryConfigSummary, normalizeLowSignalTerm, readMemoryConfig, requiresTraceability, resolveMemoryArea, validateMemoryConfigData } from './memory/config.mjs';
17
+ export { DEFAULT_IMPACT_RANKING_POLICY, DEFAULT_LOAD_POLICY, DEFAULT_VALIDATION_POLICY, SEMANTIC_RELATIONS, cloneImpactRankingPolicy, cloneLoadPolicy, cloneReferenceExpansionPolicy, cloneValidationPolicy, defaultDotdotgodConfigData, defaultDotdotgodConfigText, defaultMemoryConfig, isValidDocumentationRoot, normalizeDocumentationRoot, isMarkdownSizeExcluded, isSecretLikePathPattern, isValidPathPattern, matchPathPattern, memoryAreaForPath, memoryConfigSummary, normalizeLowSignalTerm, readMemoryConfig, requiresTraceability, resolveMemoryArea, validateMemoryConfigData } from './memory/config.mjs';
17
18
  export { BUILT_IN_TEMPLATE_NAMES, DEFAULT_TEMPLATE_NAME, builtInTemplateData, builtInTemplateScaffold, builtInTemplateText, dotdotgodHome, resolveInitializationTemplate } from './config/templates.mjs';
18
19
  export { buildCommunities, relationWeight } from './graph/communities.mjs';
19
20
  export { addEdge, addNode, compactGraph, expandGraph, graphStats, jsonSize, shardFile, writeJson } from './graph/store.mjs';
20
21
  export { cacheFile, collectIndexFiles, fingerprint, shouldIndexPath } from './index/files.mjs';
21
22
  export { buildMemoryAreas, detectCommandGuidance, detectPackageManager } from './memory/summary.mjs';
23
+ export { buildDocumentationMap, formatDocumentationTree } from './memory/documentation-map.mjs';
22
24
  export { isReadmeIndexPath, retrievalMetadataForPath } from './graph/metadata.mjs';
23
25
  export { buildGraph } from './graph/extract.mjs';
24
26
  export { addDeterministicSemanticEdges } from './graph/semantic.mjs';
@@ -29,6 +31,7 @@ export { buildChangedFileProfile, MAX_VECTOR_PROFILE_CHARS, MAX_VECTOR_PROFILE_F
29
31
  export { buildVectorImpactOverlay, VECTOR_RELATION_WEIGHT } from './impact/vector-overlay.mjs';
30
32
  export { formatCompactImpactOutput, formatYmlGraphImpactError, formatYmlImpactOutput } from './impact/format.mjs';
31
33
  export { runConfig } from './commands/config.mjs';
34
+ export { runMap } from './commands/map.mjs';
32
35
  export { buildVectorIndex, cosineScore, parseQueryOptions, queryDocumentation, rankVectorFiles, runQuery } from './commands/query.mjs';
33
36
  export { chunkMarkdown, collectDocumentationChunks, collectDocumentationMarkdown, textFingerprint } from './query/chunks.mjs';
34
37
  export { readVectorCache, VECTOR_DIMENSIONS, VECTOR_MODEL, VECTOR_SCHEMA_VERSION, profileFingerprint, vectorCachePaths, writeVectorCache } from './query/store.mjs';
@@ -51,6 +54,7 @@ export async function runCli(argv = process.argv.slice(2)) {
51
54
  else if (command === 'config') runConfig(args);
52
55
  else if (command === 'status') runStatus(args);
53
56
  else if (command === 'query') await runQuery(args);
57
+ else if (command === 'map') runMap(args);
54
58
  else if (command === 'resolve') runResolve(args);
55
59
  else if (command === 'expand') runExpand(args);
56
60
  else if (command === 'traceability') runTraceability(args);
@@ -1,17 +1,15 @@
1
1
  import { Graph, leiden } from 'leiden-ts';
2
+ import { resolveMemoryArea } from '../memory/config.mjs';
2
3
 
3
4
  const DURABLE_COMMUNITY_NODE_TYPES = new Set(['file', 'memory_area', 'package_resource', 'package', 'script', 'binary']);
4
5
 
5
- function docsArea(path = '') {
6
- if (path.startsWith('docs/spec/')) return 'spec';
7
- if (path.startsWith('docs/test/')) return 'test';
8
- if (path.startsWith('docs/arch/')) return 'architecture';
9
- if (path.startsWith('docs/plan/')) return 'active-plan';
10
- if (path.startsWith('docs/archive/')) return 'archive';
11
- return null;
6
+ function docsArea(path = '', config) {
7
+ const area = resolveMemoryArea(path, config);
8
+ const roles = { 'behavior-truth': 'spec', 'verification-knowledge': 'test', 'architecture-rationale': 'architecture', 'active-task-intent': 'active-plan', 'historical-memory-map': 'archive', 'historical-memory-body': 'archive', 'project-documentation': 'docs' };
9
+ return roles[area?.role] ?? null;
12
10
  }
13
11
 
14
- function communityKeyForNode(node) {
12
+ function communityKeyForNode(node, config) {
15
13
  if (node.type === 'memory_area') return `memory-${node.area}`;
16
14
  const path = node.path ?? node.id?.replace(/^file:/, '').replace(/^test:/, '') ?? '';
17
15
  if (path.startsWith('packages/pi/extensions/plan-mode/')) return 'pi-plan-mode';
@@ -21,7 +19,7 @@ function communityKeyForNode(node) {
21
19
  if (path.startsWith('packages/claude-code/')) return 'claude-code-adapter';
22
20
  if (path.startsWith('packages/codex/')) return 'codex-adapter';
23
21
  if (path.startsWith('packages/shared/')) return 'shared-adapter-resources';
24
- const area = docsArea(path);
22
+ const area = docsArea(path, config);
25
23
  if (area) return `docs-${area}`;
26
24
  if (node.type === 'package' || node.type === 'script' || node.type === 'binary' || node.type === 'dependency' || node.type === 'package_resource') return 'package-metadata';
27
25
  if (node.type === 'command') return `command-${node.name}`;
@@ -47,11 +45,11 @@ export function relationWeight(relation) {
47
45
  return 1;
48
46
  }
49
47
 
50
- function addCommunityDetails(community, node, itemLimit) {
48
+ function addCommunityDetails(community, node, itemLimit, config) {
51
49
  community.nodeCount += 1;
52
50
  const path = node.path ?? node.id?.replace(/^file:/, '').replace(/^test:/, '');
53
51
  if (node.type === 'file') {
54
- const area = docsArea(path);
52
+ const area = docsArea(path, config);
55
53
  community.omitted += addBounded(area ? community.docs : community.files, path, itemLimit);
56
54
  } else if (node.type === 'heading' && node.path) community.omitted += addBounded(community.docs, node.path, itemLimit);
57
55
  else if (node.type === 'memory_area') community.omitted += addBounded(community.docs, `memory_area:${node.area}`, itemLimit);
@@ -65,14 +63,14 @@ function makeCommunity(id, label = communityLabel(id)) {
65
63
  return { id, label, files: [], docs: [], commands: [], events: [], tests: [], packageResources: [], nodeCount: 0, edgeCount: 0, omitted: 0 };
66
64
  }
67
65
 
68
- function deterministicCommunities(graph, maxCommunities, itemLimit, fallback = false) {
66
+ function deterministicCommunities(graph, maxCommunities, itemLimit, config, fallback = false) {
69
67
  const map = new Map();
70
68
  for (const node of graph.nodes) {
71
- const id = communityKeyForNode(node);
69
+ const id = communityKeyForNode(node, config);
72
70
  if (!map.has(id)) map.set(id, makeCommunity(id));
73
- addCommunityDetails(map.get(id), node, itemLimit);
71
+ addCommunityDetails(map.get(id), node, itemLimit, config);
74
72
  }
75
- const nodeToCommunity = new Map(graph.nodes.map((node) => [node.id, communityKeyForNode(node)]));
73
+ const nodeToCommunity = new Map(graph.nodes.map((node) => [node.id, communityKeyForNode(node, config)]));
76
74
  for (const edge of graph.edges) {
77
75
  const source = nodeToCommunity.get(edge.source);
78
76
  const target = nodeToCommunity.get(edge.target);
@@ -112,18 +110,19 @@ function buildLeidenProjection(graph) {
112
110
  return { durable, edges: [...adjacency.entries()].map(([key, weight]) => [...key.split(':').map(Number), weight]) };
113
111
  }
114
112
 
115
- function labelForLeidenCommunity(nodes) {
113
+ function labelForLeidenCommunity(nodes, config) {
116
114
  const counts = new Map();
117
- for (const node of nodes) counts.set(communityKeyForNode(node), (counts.get(communityKeyForNode(node)) ?? 0) + 1);
115
+ for (const node of nodes) counts.set(communityKeyForNode(node, config), (counts.get(communityKeyForNode(node, config)) ?? 0) + 1);
118
116
  return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ?? 'community';
119
117
  }
120
118
 
121
119
  export function buildCommunities(index, limits = {}) {
120
+ const config = index?.memoryConfig;
122
121
  const graph = index?.graph ?? { nodes: [], edges: [] };
123
122
  const maxCommunities = limits.communities ?? 8;
124
123
  const itemLimit = limits.items ?? 8;
125
124
  const projection = buildLeidenProjection(graph);
126
- if (projection.durable.length < 3 || projection.edges.length === 0) return deterministicCommunities(graph, maxCommunities, itemLimit, true);
125
+ if (projection.durable.length < 3 || projection.edges.length === 0) return deterministicCommunities(graph, maxCommunities, itemLimit, config, true);
127
126
 
128
127
  try {
129
128
  const result = leiden(Graph.fromEdgeList(projection.durable.length, projection.edges), { seed: 42, resolution: limits.resolution ?? 1.0 });
@@ -133,9 +132,9 @@ export function buildCommunities(index, limits = {}) {
133
132
  byCommunity.get(communityId).push(projection.durable[index]);
134
133
  });
135
134
  const communities = [...byCommunity.entries()].map(([communityId, nodes]) => {
136
- const labelKey = labelForLeidenCommunity(nodes);
135
+ const labelKey = labelForLeidenCommunity(nodes, config);
137
136
  const community = makeCommunity(`leiden-${communityId}`, communityLabel(labelKey));
138
- for (const node of nodes.sort((a, b) => a.id.localeCompare(b.id))) addCommunityDetails(community, node, itemLimit);
137
+ for (const node of nodes.sort((a, b) => a.id.localeCompare(b.id))) addCommunityDetails(community, node, itemLimit, config);
139
138
  return community;
140
139
  });
141
140
  const nodeToCommunity = new Map();
@@ -148,6 +147,6 @@ export function buildCommunities(index, limits = {}) {
148
147
  const all = communities.sort((a, b) => (b.nodeCount + b.edgeCount) - (a.nodeCount + a.edgeCount) || a.id.localeCompare(b.id));
149
148
  return { communities: all.slice(0, maxCommunities), omitted: Math.max(0, all.length - maxCommunities), total: all.length, method: 'leiden', fallback: false, modularity: result.modularity };
150
149
  } catch {
151
- return deterministicCommunities(graph, maxCommunities, itemLimit, true);
150
+ return deterministicCommunities(graph, maxCommunities, itemLimit, config, true);
152
151
  }
153
152
  }
@@ -96,13 +96,13 @@ function buildCombinedImpactReport(index, changedPaths, limits = {}) {
96
96
  const retrieval = node.retrieval ?? retrievalMetadataForPath(path);
97
97
  const reasonSignals = reasonList.map((reason) => `reason:${reason}`);
98
98
  const hasCuratedEvidence = reasonList.some((reason) => curatedRelations.has(reason.replace(/^incoming:/, '')));
99
- const scored = scoreImpactItem({ ...node, reasons: reasonList, retrieval }, seedSet, changedPaths, policy, pprScores);
99
+ const scored = scoreImpactItem({ ...node, reasons: reasonList, retrieval }, seedSet, changedPaths, policy, pprScores, config);
100
100
  return { ...node, reasons: reasonList, hasCuratedEvidence, ...(vectorEvidence.has(id) ? { vectorEvidence: vectorEvidence.get(id) } : {}), retrieval: { ...retrieval, signals: [...new Set([...(retrieval.signals ?? []), ...reasonSignals])] }, ...scored };
101
101
  }).sort(compareImpactItems(seeds));
102
102
  const related = selectImpactItems(relatedAll, maxRelated, seeds);
103
103
  for (const item of related) {
104
104
  if (item.type === 'file') {
105
- const area = docsArea(item.path);
105
+ const area = docsArea(item.path, config);
106
106
  if (area) addImpactItem(groups.docs, { ...item, area }, limits.docs ?? 10);
107
107
  else if (isTestPath(item.path)) addImpactItem(groups.tests, item, limits.tests ?? 10);
108
108
  else addImpactItem(groups.files, item, limits.files ?? 10);
@@ -1,18 +1,14 @@
1
- import { SEMANTIC_RELATIONS } from '../memory/config.mjs';
1
+ import { SEMANTIC_RELATIONS, resolveMemoryArea } from '../memory/config.mjs';
2
2
  import { relationWeight } from '../graph/communities.mjs';
3
3
 
4
4
  export function isTestPath(path = '') {
5
5
  return /(^|\/)(test|tests)\//.test(path) || /\.(test|spec)\.(mjs|cjs|js|jsx|ts|tsx)$/.test(path);
6
6
  }
7
7
 
8
- export function docsArea(path = '') {
9
- if (path.startsWith('docs/spec/')) return 'spec';
10
- if (path.startsWith('docs/arch/')) return 'arch';
11
- if (path.startsWith('docs/test/')) return 'test-docs';
12
- if (path.startsWith('docs/plan/')) return 'plan';
13
- if (path.startsWith('docs/archive/')) return 'archive-index';
14
- if (path.startsWith('docs/')) return 'docs';
15
- return undefined;
8
+ export function docsArea(path = '', config) {
9
+ const area = resolveMemoryArea(path, config);
10
+ const byRole = { 'behavior-truth': 'spec', 'architecture-rationale': 'arch', 'verification-knowledge': 'test-docs', 'active-task-intent': 'plan', 'historical-memory-map': 'archive-index', 'historical-memory-body': 'archive-index', 'project-documentation': 'docs', 'documentation-routing-map': 'docs' };
11
+ return byRole[area?.role] ?? (area?.id === 'spec' ? 'spec' : area?.id === 'architecture' ? 'arch' : area?.id === 'test' ? 'test-docs' : undefined);
16
12
  }
17
13
 
18
14
  function clamp(value, min, max) {
@@ -61,10 +57,10 @@ export function buildPersonalizedPageRank(graph, seeds, policy) {
61
57
  return ranks;
62
58
  }
63
59
 
64
- export function scoreImpactItem(item, seeds, changedPaths, policy, pprScores) {
60
+ export function scoreImpactItem(item, seeds, changedPaths, policy, pprScores, config) {
65
61
  const seedSet = seeds instanceof Set ? seeds : new Set(Array.isArray(seeds) ? seeds : [seeds]);
66
62
  const paths = Array.isArray(changedPaths) ? changedPaths : [changedPaths];
67
- const archiveSeeded = paths.some((path) => path.startsWith('docs/archive/'));
63
+ const archiveSeeded = paths.some((path) => resolveMemoryArea(path, config)?.role?.startsWith('historical-memory'));
68
64
  if (seedSet.has(item.id)) return { impactScore: 100, scoreBreakdown: { seed: 100, connection: { ppr: 80, reference: policy.ppr.reference }, memory: { priority: 20, policyAdjustments: 0 } } };
69
65
  const retrieval = item.retrieval ?? {};
70
66
  const probability = pprScores.get(item.id) ?? 0;
package/src/init.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { basename, dirname, join, resolve } from 'node:path';
3
- import { builtInTemplateScaffold, resolveInitializationTemplate } from './config/templates.mjs';
3
+ import { builtInTemplateData, builtInTemplateScaffold, resolveInitializationTemplate } from './config/templates.mjs';
4
+ import { isValidDocumentationRoot, normalizeDocumentationRoot, readMemoryConfig } from './memory/config.mjs';
4
5
 
5
6
  function action(status, path, extra = {}) {
6
7
  return { status, path, ...extra };
@@ -12,17 +13,18 @@ function formatAction(item) {
12
13
  }
13
14
 
14
15
  function parseInitOptions(argv) {
15
- const options = { root: null, projectName: '', dotdotSetting: false, dryRun: false, json: false, template: null };
16
+ const options = { root: null, projectName: '', dotdotSetting: false, dryRun: false, json: false, template: null, documentationRoot: 'docs', documentationRootExplicit: false };
16
17
  for (let i = 0; i < argv.length; i += 1) {
17
18
  const arg = argv[i];
18
19
  if (arg === '--json') options.json = true;
19
20
  else if (arg === '--dotdot-setting') options.dotdotSetting = true;
20
21
  else if (arg === '--dry-run') options.dryRun = true;
21
- else if (arg === '--project-name' || arg === '--template') {
22
+ else if (arg === '--project-name' || arg === '--template' || arg === '--documentation-root') {
22
23
  const value = argv[i + 1];
23
24
  if (!value || value.startsWith('-')) throw new Error(`${arg} requires a value`);
24
25
  if (arg === '--project-name') options.projectName = value;
25
- else options.template = value;
26
+ else if (arg === '--template') options.template = value;
27
+ else { options.documentationRoot = normalizeDocumentationRoot(value); options.documentationRootExplicit = true; }
26
28
  i += 1;
27
29
  } else if (!arg.startsWith('-') && !options.root) {
28
30
  options.root = arg;
@@ -33,6 +35,7 @@ function parseInitOptions(argv) {
33
35
  }
34
36
  }
35
37
  if (!options.root) throw new Error('Missing required argument: <root>');
38
+ if (!isValidDocumentationRoot(options.documentationRoot)) throw new Error('Invalid --documentation-root: expected a safe repository-relative directory');
36
39
  options.root = resolve(options.root);
37
40
  if (!options.projectName) options.projectName = basename(options.root);
38
41
  return options;
@@ -287,7 +290,7 @@ function initFiles(options) {
287
290
  files.push(['docs/arch/DOCS_STRUCTURE.md', docsStructureContent()]);
288
291
  files.push(['docs/arch/CODE_CONVENTIONS.md', codeConventionsContent()]);
289
292
  }
290
- return files;
293
+ return files.map(([path, content]) => [path.replace(/^docs(?=\/|$)/, options.documentationRoot), content.replaceAll('docs/', `${options.documentationRoot}/`).replaceAll('`docs`', `\`${options.documentationRoot}\``)]);
291
294
  }
292
295
 
293
296
  export function runInit(argv, usage) {
@@ -310,13 +313,19 @@ export function runInit(argv, usage) {
310
313
  if (!options.dryRun) mkdirSync(options.root, { recursive: true });
311
314
 
312
315
  const configExists = existsSync(join(options.root, 'dotdotgod.config.json'));
313
- const template = configExists ? null : resolveInitializationTemplate(options.template);
316
+ if (configExists && options.documentationRootExplicit) {
317
+ const existingRoot = readMemoryConfig(options.root).documentation.root;
318
+ if (existingRoot !== options.documentationRoot) initError(options, 'DOCUMENTATION_ROOT_CONFLICT', `Existing config uses documentation root ${existingRoot}; initialization does not migrate documentation.`);
319
+ }
320
+ let template = configExists ? null : resolveInitializationTemplate(options.template);
314
321
  if (template && !template.ok) initError(options, template.error.code, template.error.message);
322
+ if (template?.source === 'custom' && normalizeDocumentationRoot(template.data.documentation?.root ?? 'docs') !== options.documentationRoot) initError(options, 'DOCUMENTATION_ROOT_CONFLICT', 'Custom template documentation.root conflicts with --documentation-root; custom policy paths are never rewritten.');
323
+ if (template?.source === 'bundled') { const data = builtInTemplateData(template.name, { documentationRoot: options.documentationRoot }); template = { ...template, data, text: `${JSON.stringify(data, null, 2)}\n` }; }
315
324
 
316
325
  const actions = [];
317
326
  for (const [relativePath, content] of initFiles(options)) writeInitFile(options, relativePath, content, actions);
318
327
  if (template?.source === 'bundled') {
319
- for (const item of builtInTemplateScaffold(template.name) ?? []) {
328
+ for (const item of builtInTemplateScaffold(template.name, { documentationRoot: options.documentationRoot }) ?? []) {
320
329
  if (item.type === 'directory') ensureInitDirectory(options, item.path, actions);
321
330
  else writeInitFile(options, item.path, item.content, actions);
322
331
  }
@@ -324,9 +333,9 @@ export function runInit(argv, usage) {
324
333
 
325
334
  writeInitFile(options, 'dotdotgod.config.json', template?.text ?? '', actions);
326
335
 
327
- for (const entry of ['docs/plan', 'docs/archive', '.dotdotgod']) ensureGitignoreEntry(options, entry, actions);
336
+ for (const entry of [`${options.documentationRoot}/plan`, `${options.documentationRoot}/archive`, '.dotdotgod']) ensureGitignoreEntry(options, entry, actions);
328
337
 
329
- const payload = { ok: true, command: 'init', root: options.root, projectName: options.projectName, dryRun: options.dryRun, dotdotSetting: options.dotdotSetting, template: template ? { name: template.name, source: template.source, selectedBy: template.selectedBy } : null, actions };
338
+ const payload = { ok: true, command: 'init', root: options.root, projectName: options.projectName, documentationRoot: options.documentationRoot, dryRun: options.dryRun, dotdotSetting: options.dotdotSetting, template: template ? { name: template.name, source: template.source, selectedBy: template.selectedBy } : null, actions };
330
339
  if (options.json) console.log(JSON.stringify(payload, null, 2));
331
340
  else {
332
341
  if (template) console.log(`template ${template.source}:${template.name} (${template.selectedBy})`);
@@ -5,6 +5,7 @@ import { isKebabCase } from '../docs/markdown.mjs';
5
5
  import { validateEmbeddingProfile } from '../query/embedding-config.mjs';
6
6
 
7
7
  const MEMORY_CONFIG_FILE = 'dotdotgod.config.json';
8
+ export const DEFAULT_DOCUMENTATION_ROOT = 'docs';
8
9
  const MEMORY_SCOPES = new Set(['shared', 'local']);
9
10
  const MEMORY_FRESHNESS = new Set(['fresh', 'stale']);
10
11
  export const DEFAULT_TRACEABILITY_KEYS = [
@@ -65,7 +66,9 @@ const DEFAULT_FUZZY_LOW_SIGNAL_TERMS = [
65
66
  'a', 'an', 'and', 'are', 'as', 'by', 'docs', 'document', 'for', 'from', 'it', 'of', 'on', 'plan', 'test', 'the', 'to', 'update', 'version', 'with',
66
67
  '계획', '문서', '수정', '업데이트', '버전', '정보', '확인', '테스트',
67
68
  ];
68
- const DEFAULT_MEMORY_AREAS = [
69
+ function memoryAreasForRoot(documentationRoot = DEFAULT_DOCUMENTATION_ROOT) {
70
+ const d = (path = '') => path ? `${documentationRoot}/${path}` : documentationRoot;
71
+ return [
69
72
  { id: 'rules', label: 'Agent Rules', paths: ['AGENTS.md'], scope: 'shared', freshness: 'fresh', role: 'agent-working-rules', priority: 100, includeBodiesByDefault: true },
70
73
  { id: 'agent-entrypoint', label: 'Agent Entrypoints', paths: ['CLAUDE.md', 'CODEX.md'], scope: 'shared', freshness: 'fresh', role: 'agent-specific-entrypoint', priority: 85, includeBodiesByDefault: true },
71
74
  { id: 'project-overview', label: 'Project Overview', paths: ['README.md'], scope: 'shared', freshness: 'fresh', role: 'project-map', priority: 85, includeBodiesByDefault: true },
@@ -76,8 +79,10 @@ const DEFAULT_MEMORY_AREAS = [
76
79
  { id: 'active-plan', label: 'Active Plans', paths: ['docs/plan/**'], scope: 'local', freshness: 'fresh', role: 'active-task-intent', priority: 95, includeBodiesByDefault: true },
77
80
  { id: 'archive-map', label: 'Archive Map', paths: ['docs/archive/README.md'], scope: 'local', freshness: 'stale', role: 'historical-memory-map', priority: 65, includeBodiesByDefault: true },
78
81
  { 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 },
79
- { id: 'docs', label: 'Project Documentation', paths: ['docs/**'], scope: 'shared', freshness: 'fresh', role: 'project-documentation', priority: 60, includeBodiesByDefault: true },
80
- ];
82
+ { id: 'docs', label: 'Project Documentation', paths: [d('**')], scope: 'shared', freshness: 'fresh', role: 'project-documentation', priority: 60, includeBodiesByDefault: true },
83
+ ].map(area => ({ ...area, paths: area.paths.map(path => path.replace(/^docs(?=\/|$)/, documentationRoot)), excludePaths: (area.excludePaths ?? []).map(path => path.replace(/^docs(?=\/|$)/, documentationRoot)) }));
84
+ }
85
+ const DEFAULT_MEMORY_AREAS = memoryAreasForRoot();
81
86
 
82
87
  function cloneClarifyGuidance(clarify) {
83
88
  if (!clarify || typeof clarify !== 'object' || Array.isArray(clarify)) return undefined;
@@ -198,8 +203,19 @@ export function clonePlanModePolicy(policy = DEFAULT_PLAN_MODE_POLICY) {
198
203
  return { writablePaths: [...(policy.writablePaths ?? DEFAULT_PLAN_MODE_POLICY.writablePaths)] };
199
204
  }
200
205
 
201
- export function defaultMemoryConfig() {
202
- return { source: 'default', areas: DEFAULT_MEMORY_AREAS.map(cloneArea), traceability: cloneTraceabilityPolicy(), validation: cloneValidationPolicy(), impactRanking: cloneImpactRankingPolicy(), referenceExpansion: cloneReferenceExpansionPolicy(), load: cloneLoadPolicy(), planMode: clonePlanModePolicy() };
206
+ export function normalizeDocumentationRoot(value = DEFAULT_DOCUMENTATION_ROOT) {
207
+ return String(value).trim().replaceAll('\\', '/').replace(/^\.\//, '').replace(/\/{2,}/g, '/').replace(/^\/+|\/+$/g, '');
208
+ }
209
+ export function isValidDocumentationRoot(value) {
210
+ if (typeof value !== 'string' || !value.trim() || /^[A-Za-z]:[\\/]/.test(value) || /^[/\\]{2}/.test(value) || value.trim().startsWith('/')) return false;
211
+ const root = normalizeDocumentationRoot(value);
212
+ if (!root || root === '.' || root.split('/').some(part => part === '..' || !part || /[*?\[\]{}]/.test(part)) || isSecretLikePathPattern(root)) return false;
213
+ const first = root.split('/')[0];
214
+ return first !== 'dotdotgod.config.json' && first !== '.dotdotgod';
215
+ }
216
+ export function defaultMemoryConfig({ documentationRoot = DEFAULT_DOCUMENTATION_ROOT } = {}) {
217
+ const root = normalizeDocumentationRoot(documentationRoot);
218
+ return { source: 'default', documentation: { root }, areas: memoryAreasForRoot(root).map(cloneArea), traceability: cloneTraceabilityPolicy({ ...DEFAULT_TRACEABILITY_POLICY, required: [`${root}/spec/**`] }), validation: cloneValidationPolicy(), impactRanking: cloneImpactRankingPolicy(), referenceExpansion: cloneReferenceExpansionPolicy(), load: cloneLoadPolicy({ ...DEFAULT_LOAD_POLICY, documentationSummary: { exclude: [`${root}/plan`, `${root}/archive`] } }), planMode: clonePlanModePolicy({ writablePaths: [`${root}/plan/**`, `${root}/archive/**`] }) };
203
219
  }
204
220
 
205
221
  export function normalizePathPattern(value = '') {
@@ -336,6 +352,10 @@ export function validateMemoryConfigData(data, root = '.', file = 'dotdotgod.con
336
352
  add('MEMORY_CONFIG_INVALID', null, 'Config must be a JSON object.');
337
353
  return errors;
338
354
  }
355
+ if (data.documentation !== undefined) {
356
+ if (!data.documentation || typeof data.documentation !== 'object' || Array.isArray(data.documentation)) add('DOCUMENTATION_CONFIG_INVALID', 'documentation', 'Expected an object.');
357
+ else if (!isValidDocumentationRoot(data.documentation.root)) add('DOCUMENTATION_CONFIG_INVALID_ROOT', 'documentation.root', 'Expected a safe repository-relative directory without traversal, glob syntax, secrets, or reserved config/cache overlap.');
358
+ }
339
359
  if (data.embedding !== undefined) {
340
360
  try { validateEmbeddingProfile(data.embedding, 'embedding'); }
341
361
  catch (error) { add('EMBEDDING_CONFIG_INVALID', 'embedding', error instanceof Error ? error.message : String(error)); }
@@ -420,7 +440,7 @@ export function validateMemoryConfigData(data, root = '.', file = 'dotdotgod.con
420
440
  } else if (planMode.writablePaths !== undefined) {
421
441
  const paths = planMode.writablePaths;
422
442
  if (!Array.isArray(paths)) add('PLAN_MODE_CONFIG_INVALID_WRITABLE_PATHS', 'planMode.writablePaths', 'Expected an array of documentation path strings.');
423
- 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.');
443
+ else if (paths.some((value) => !isValidPathPattern(value) || typeof value !== 'string' || value.trim().startsWith('/') || (!normalizePathPattern(value).startsWith(`${normalizeDocumentationRoot(data.documentation?.root ?? DEFAULT_DOCUMENTATION_ROOT)}/`)) || normalizePathPattern(value).startsWith(`${normalizeDocumentationRoot(data.documentation?.root ?? DEFAULT_DOCUMENTATION_ROOT)}/.`) || isSecretLikePathPattern(value))) add('PLAN_MODE_CONFIG_INVALID_WRITABLE_PATHS', 'planMode.writablePaths', 'Expected safe repository-relative paths under documentation.root using exact paths or /** subtree patterns.');
424
444
  }
425
445
  }
426
446
  const referenceExpansion = data.referenceExpansion;
@@ -493,14 +513,16 @@ export function readMemoryConfig(root = '.') {
493
513
  const data = JSON.parse(readFileSync(path, 'utf8'));
494
514
  const errors = validateMemoryConfigData(data, root, MEMORY_CONFIG_FILE);
495
515
  if (errors.length > 0) return { ...defaultMemoryConfig(), source: MEMORY_CONFIG_FILE, errors };
516
+ const documentationRoot = normalizeDocumentationRoot(data.documentation?.root ?? DEFAULT_DOCUMENTATION_ROOT);
517
+ const defaults = defaultMemoryConfig({ documentationRoot });
496
518
  const configuredAreas = data.memory?.areas?.map(normalizeMemoryArea) ?? [];
497
- const traceability = data.traceability === undefined ? cloneTraceabilityPolicy() : normalizeTraceabilityPolicy(data.traceability);
519
+ const traceability = data.traceability === undefined ? defaults.traceability : normalizeTraceabilityPolicy(data.traceability);
498
520
  const validation = data.validation === undefined ? cloneValidationPolicy() : normalizeValidationPolicy(data.validation);
499
521
  const impactRanking = normalizeImpactRankingPolicy(data.impactRanking);
500
522
  const referenceExpansion = normalizeReferenceExpansionPolicy(data.referenceExpansion);
501
- const load = normalizeLoadPolicy(data.load);
502
- const planMode = normalizePlanModePolicy(data.planMode);
503
- return configuredAreas.length > 0 ? { source: MEMORY_CONFIG_FILE, areas: configuredAreas, traceability, validation, impactRanking, referenceExpansion, load, planMode, errors: [] } : { ...defaultMemoryConfig(), traceability, validation, impactRanking, referenceExpansion, load, planMode, source: MEMORY_CONFIG_FILE, errors: [] };
523
+ const load = data.load === undefined ? defaults.load : normalizeLoadPolicy(data.load);
524
+ const planMode = data.planMode === undefined ? defaults.planMode : normalizePlanModePolicy(data.planMode);
525
+ return configuredAreas.length > 0 ? { source: MEMORY_CONFIG_FILE, documentation: { root: documentationRoot }, areas: configuredAreas, traceability, validation, impactRanking, referenceExpansion, load, planMode, errors: [] } : { ...defaults, traceability, validation, impactRanking, referenceExpansion, load, planMode, source: MEMORY_CONFIG_FILE, errors: [] };
504
526
  } catch (error) {
505
527
  return { ...defaultMemoryConfig(), source: MEMORY_CONFIG_FILE, errors: [{ file: MEMORY_CONFIG_FILE, code: 'MEMORY_CONFIG_INVALID_JSON', message: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}\nFix: repair ${MEMORY_CONFIG_FILE} so it is valid JSON, or remove it before regenerating the default config with \`dotdotgod config init <root>\`.` }] };
506
528
  }
@@ -520,9 +542,10 @@ function serializableMemoryArea(area) {
520
542
  }, area);
521
543
  }
522
544
 
523
- export function defaultDotdotgodConfigData() {
524
- const config = defaultMemoryConfig();
545
+ export function defaultDotdotgodConfigData({ documentationRoot = DEFAULT_DOCUMENTATION_ROOT } = {}) {
546
+ const config = defaultMemoryConfig({ documentationRoot });
525
547
  return {
548
+ documentation: { ...config.documentation },
526
549
  memory: {
527
550
  areas: (config.areas ?? []).map(serializableMemoryArea),
528
551
  },
@@ -542,6 +565,7 @@ export function defaultDotdotgodConfigText() {
542
565
  export function memoryConfigSummary(config) {
543
566
  return {
544
567
  source: config.source ?? 'default',
568
+ documentation: { root: config.documentation?.root ?? DEFAULT_DOCUMENTATION_ROOT },
545
569
  areas: (config.areas ?? []).map((area) => withOptionalAreaMetadata({
546
570
  id: area.id,
547
571
  label: area.label,
@@ -0,0 +1,111 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { isSecretLikePathPattern, matchPathPattern, readMemoryConfig } from './config.mjs';
4
+
5
+ const SKIPPED_DIRECTORIES = new Set(['node_modules', 'dist', 'build', 'coverage', '.git', '.dotdotgod']);
6
+
7
+ function node() {
8
+ return { directories: new Map(), files: [] };
9
+ }
10
+
11
+ function buildTree(paths) {
12
+ const root = node();
13
+ for (const path of [...new Set(paths)].sort()) {
14
+ const parts = path.split('/');
15
+ let current = root;
16
+ for (const part of parts.slice(0, -1)) {
17
+ if (!current.directories.has(part)) current.directories.set(part, node());
18
+ current = current.directories.get(part);
19
+ }
20
+ current.files.push(parts.at(-1));
21
+ }
22
+ return root;
23
+ }
24
+
25
+ function descendantCounts(current) {
26
+ let directories = 0;
27
+ let files = current.files.length;
28
+ for (const child of current.directories.values()) {
29
+ directories += 1;
30
+ const nested = descendantCounts(child);
31
+ directories += nested.directories;
32
+ files += nested.files;
33
+ }
34
+ return { directories, files };
35
+ }
36
+
37
+ function plural(count, singular, pluralForm = `${singular}s`) {
38
+ return `${count} ${count === 1 ? singular : pluralForm}`;
39
+ }
40
+
41
+ export function formatDocumentationTree(paths, documentationRoot = 'docs', maxDepth = 5) {
42
+ const tree = buildTree(paths);
43
+ let docs = tree;
44
+ for (const part of documentationRoot.split('/')) {
45
+ docs = docs.directories.get(part);
46
+ if (!docs) return `- ${documentationRoot}/: missing`;
47
+ }
48
+ const lines = [`${documentationRoot}/`];
49
+ const render = (current, depth, indent) => {
50
+ for (const file of current.files.sort()) lines.push(`${indent}- ${file}`);
51
+ if (depth >= maxDepth && current.directories.size > 0) {
52
+ for (const [name, child] of [...current.directories.entries()].sort(([a], [b]) => a.localeCompare(b))) {
53
+ const counts = descendantCounts(child);
54
+ lines.push(`${indent}- ${name}/`);
55
+ lines.push(`${indent} - … ${plural(counts.directories, 'directory', 'directories')}, ${plural(counts.files, 'Markdown file')}`);
56
+ }
57
+ return;
58
+ }
59
+ for (const [name, child] of [...current.directories.entries()].sort(([a], [b]) => a.localeCompare(b))) {
60
+ lines.push(`${indent}- ${name}/`);
61
+ render(child, depth + 1, `${indent} `);
62
+ }
63
+ };
64
+ render(docs, 1, ' ');
65
+ return lines.join('\n');
66
+ }
67
+
68
+ function discoverMarkdown(root, documentationRoot, exclude) {
69
+ const paths = [];
70
+ const walk = (relativeDirectory) => {
71
+ let entries;
72
+ try {
73
+ entries = readdirSync(join(root, relativeDirectory), { withFileTypes: true, encoding: 'utf8' });
74
+ } catch {
75
+ return;
76
+ }
77
+ entries.sort((a, b) => a.name.localeCompare(b.name));
78
+ for (const entry of entries) {
79
+ const path = `${relativeDirectory}/${entry.name}`.replaceAll('\\', '/');
80
+ if (entry.name.startsWith('.') || isSecretLikePathPattern(path) || exclude.some(pattern => matchPathPattern(path, pattern))) continue;
81
+ if (entry.isDirectory()) {
82
+ if (!SKIPPED_DIRECTORIES.has(entry.name)) walk(path);
83
+ } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) paths.push(path);
84
+ }
85
+ };
86
+ walk(documentationRoot);
87
+ return paths.sort();
88
+ }
89
+
90
+ export function buildDocumentationMap(projectRoot = '.', { depth = 5 } = {}) {
91
+ const root = resolve(projectRoot);
92
+ if (!existsSync(root)) return { ok: false, error: { code: 'ROOT_NOT_FOUND', message: `Project root not found: ${root}` } };
93
+ try {
94
+ if (!statSync(root).isDirectory()) return { ok: false, error: { code: 'ROOT_NOT_FOUND', message: `Project root not found: ${root}` } };
95
+ } catch {
96
+ return { ok: false, error: { code: 'ROOT_NOT_FOUND', message: `Project root not found: ${root}` } };
97
+ }
98
+ const config = readMemoryConfig(root);
99
+ const documentationRoot = config.documentation?.root ?? 'docs';
100
+ const exclude = [...(config.load?.documentationSummary?.exclude ?? [])].sort();
101
+ const paths = discoverMarkdown(root, documentationRoot, exclude);
102
+ return {
103
+ ok: true,
104
+ root,
105
+ documentationRoot,
106
+ depth,
107
+ exclude,
108
+ paths,
109
+ tree: formatDocumentationTree(paths, documentationRoot, depth),
110
+ };
111
+ }
@@ -10,8 +10,8 @@ export function textFingerprint(text) {
10
10
  return createHash('sha256').update(text).digest('hex');
11
11
  }
12
12
 
13
- export function collectDocumentationMarkdown(root, exclude = ['docs/plan', 'docs/archive']) {
14
- const docsRoot = join(root, 'docs');
13
+ export function collectDocumentationMarkdown(root, exclude = ['docs/plan', 'docs/archive'], documentationRoot = 'docs') {
14
+ const docsRoot = join(root, documentationRoot);
15
15
  const files = [];
16
16
  const walk = (directory) => {
17
17
  let entries;
@@ -84,8 +84,8 @@ export function chunkMarkdown(path, content, maxChars = MAX_CHUNK_CHARS) {
84
84
  return sections;
85
85
  }
86
86
 
87
- export function collectDocumentationChunks(root, exclude) {
88
- return collectDocumentationMarkdown(root, exclude).flatMap((path) => {
87
+ export function collectDocumentationChunks(root, exclude, documentationRoot = 'docs') {
88
+ return collectDocumentationMarkdown(root, exclude, documentationRoot).flatMap((path) => {
89
89
  const content = readFileSync(join(root, path), 'utf8');
90
90
  return chunkMarkdown(path, content);
91
91
  });
@@ -54,8 +54,12 @@ export function referencePathForNode(node) {
54
54
  return '';
55
55
  }
56
56
 
57
- export function isArchiveBodyPath(path = '') {
58
- return path.startsWith('docs/archive/') && path !== 'docs/archive/README.md';
57
+ export function isArchiveBodyPath(path = '', memoryConfig) {
58
+ const area = memoryConfig?.areas?.find((candidate) => candidate.id === 'archive-body' || candidate.role === 'historical-memory-body');
59
+ if (!area) return path.startsWith('docs/archive/') && path !== 'docs/archive/README.md';
60
+ const normalize = (value) => value.replace(/\/\*\*$/, '');
61
+ return (area.paths ?? []).some((pattern) => path === normalize(pattern) || path.startsWith(`${normalize(pattern)}/`))
62
+ && !(area.excludePaths ?? []).some((excluded) => path === excluded);
59
63
  }
60
64
 
61
65
  function aliasEntriesForPath(path = '') {
@@ -112,7 +116,7 @@ export function extractFuzzyReferences(prompt = '', index = null, options = {})
112
116
  for (const node of graph.nodes ?? []) {
113
117
  if (!['file', 'heading'].includes(node.type)) continue;
114
118
  const path = referencePathForNode(node);
115
- if (options.includeArchive !== true && isArchiveBodyPath(path)) continue;
119
+ if (options.includeArchive !== true && isArchiveBodyPath(path, options.memoryConfig ?? index?.memoryConfig)) continue;
116
120
  for (const entry of referenceCandidateAliases(node)) {
117
121
  const alias = String(entry.alias ?? '').replace(/\.md$/i, '').replace(/[_-]+/g, ' ').trim();
118
122
  if (!fuzzyPhraseAllowed(alias, lowSignalTerms)) continue;
@@ -16,6 +16,7 @@ function exactReferenceScore(ref, alias, kind) {
16
16
  export function resolveReferenceCandidates(index, ref, options = {}) {
17
17
  const graph = index?.graph ?? { nodes: [], edges: [] };
18
18
  const includeArchive = options.includeArchive === true;
19
+ const memoryConfig = options.memoryConfig ?? index?.memoryConfig;
19
20
  const limit = Number.isFinite(options.maxResults) ? options.maxResults : DEFAULT_REFERENCE_LIMIT;
20
21
  const query = String(ref ?? '').trim().replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0].trim();
21
22
  const normalized = normalizeReferenceAlias(query);
@@ -23,7 +24,7 @@ export function resolveReferenceCandidates(index, ref, options = {}) {
23
24
  for (const node of graph.nodes ?? []) {
24
25
  if (!['file', 'heading'].includes(node.type)) continue;
25
26
  const path = referencePathForNode(node);
26
- if (!includeArchive && isArchiveBodyPath(path)) continue;
27
+ if (!includeArchive && isArchiveBodyPath(path, memoryConfig)) continue;
27
28
  let best = null;
28
29
  for (const entry of referenceCandidateAliases(node)) {
29
30
  const aliasKey = normalizeReferenceAlias(entry.alias);
@@ -95,7 +96,7 @@ export function runResolve(argv) {
95
96
  const ref = options.rootArgv?.join(' ');
96
97
  if (!ref) usage('Missing required argument: <ref>.', 'resolve');
97
98
  const { status, index, metadata } = readFreshIndex(options.root);
98
- const refs = [resolveReferenceCandidates(index, ref, options)];
99
+ const refs = [resolveReferenceCandidates(index, ref, { ...options, memoryConfig: readMemoryConfig(options.root) })];
99
100
  const payload = { ok: status.ok, command: 'resolve', root: options.root, status, metadata, refs, omitted: refs.reduce((sum, item) => sum + item.omitted, 0) };
100
101
  if (options.json) console.log(JSON.stringify(payload, null, 2));
101
102
  else console.log(formatReferenceOutput(payload));
@@ -110,8 +111,8 @@ export function runExpand(argv) {
110
111
  const { status, index, metadata } = readFreshIndex(options.root);
111
112
  const fuzzyRefs = options.fuzzy ? extractFuzzyReferences(prompt, index, { ...options, memoryConfig: readMemoryConfig(options.root), existingTargets: refsInPrompt.map((item) => item.target) }) : [];
112
113
  let refs = [
113
- ...refsInPrompt.map((item) => ({ ...resolveReferenceCandidates(index, item.target, options), source: 'explicit', raw: item.raw, label: item.label })),
114
- ...fuzzyRefs.map((item) => ({ ...resolveReferenceCandidates(index, item.target, options), source: 'fuzzy', raw: item.raw, confidence: item.confidence, reasons: item.reasons })),
114
+ ...refsInPrompt.map((item) => ({ ...resolveReferenceCandidates(index, item.target, { ...options, memoryConfig: readMemoryConfig(options.root) }), source: 'explicit', raw: item.raw, label: item.label })),
115
+ ...fuzzyRefs.map((item) => ({ ...resolveReferenceCandidates(index, item.target, { ...options, memoryConfig: readMemoryConfig(options.root) }), source: 'fuzzy', raw: item.raw, confidence: item.confidence, reasons: item.reasons })),
115
116
  ];
116
117
  if (options.withImpact) refs = refs.map((item) => {
117
118
  const topPath = item.top?.path;
@@ -24,7 +24,9 @@ export function runValidate(argv) {
24
24
  }
25
25
 
26
26
  const root = resolve(options.root);
27
- const docs = join(root, 'docs');
27
+ const memoryConfig = readMemoryConfig(root);
28
+ const documentationRoot = memoryConfig.documentation?.root ?? 'docs';
29
+ const docs = join(root, documentationRoot);
28
30
  const errors = [];
29
31
  const warnings = [];
30
32
  const markdownFiles = [];
@@ -37,7 +39,7 @@ export function runValidate(argv) {
37
39
  if (path.includes('/node_modules') || path === 'node_modules') return true;
38
40
  if (path.includes('/.git') || path === '.git') return true;
39
41
  if (path === CACHE_DIR || path.startsWith(`${CACHE_DIR}/`)) return true;
40
- if (!options.includeLocalMemory && (path === 'docs/plan' || path.startsWith('docs/plan/') || path === 'docs/archive' || path.startsWith('docs/archive/'))) return true;
42
+ if (!options.includeLocalMemory && (resolveMemoryArea(path, memoryConfig)?.scope === 'local')) return true;
41
43
  return false;
42
44
  };
43
45
  const walk = (dir) => {
@@ -55,7 +57,6 @@ export function runValidate(argv) {
55
57
  };
56
58
 
57
59
  if (!existsSync(docs)) usage(`docs directory not found: ${docs}`);
58
- const memoryConfig = readMemoryConfig(root);
59
60
  const validationPolicy = cloneValidationPolicy(memoryConfig.validation ?? DEFAULT_VALIDATION_POLICY);
60
61
  const maxLines = options.maxLines ?? validationPolicy.markdown.maxLines;
61
62
  const maxChars = options.maxChars ?? validationPolicy.markdown.maxChars;
@@ -78,10 +79,10 @@ export function runValidate(argv) {
78
79
  (memoryConfig.areas ?? [])
79
80
  .filter(area => area.scope === 'shared')
80
81
  .flatMap(area => area.paths ?? [])
81
- .map(p => { const m = p.match(/^docs\/([^/*]+)\/\*\*$/); return m ? m[1] : null; })
82
+ .map(p => { const prefix = `${documentationRoot}/`; return p.startsWith(prefix) && p.endsWith('/**') ? p.slice(prefix.length, -3) : null; })
82
83
  .filter(Boolean)
83
84
  );
84
- const filenameQualityScope = [...FILENAME_QUALITY_AREAS].sort().map(area => `docs/${area}`).join(', ') || 'configured shared documentation areas';
85
+ const filenameQualityScope = [...FILENAME_QUALITY_AREAS].sort().map(area => `${documentationRoot}/${area}`).join(', ') || 'configured shared documentation areas';
85
86
  const FILENAME_QUALITY_PROMPT =
86
87
  'Filenames and paths are LLM/agent context signals used for impact ranking, search, and retrieval.\n' +
87
88
  `Use meaning-based filenames in ${filenameQualityScope}. A filename should identify its primary endpoint, screen, policy, state, response type, or domain subject. ` +
@@ -191,15 +192,15 @@ export function runValidate(argv) {
191
192
  if (!entry.isDirectory()) continue;
192
193
  if (!isKebabCase(entry.name)) addError(join(areaRoot, entry.name), 'SLUG_NAMING', `Archive/plan/report slug must be kebab-case: ${entry.name}`, null, 'rename the task/report directory to kebab-case and update any README index links that reference it.');
193
194
  const readme = join(areaRoot, entry.name, 'README.md');
194
- if (!existsSync(readme)) addError(readme, 'MISSING_README', `Expected README.md in docs/${area}/${entry.name}/`, null, 'add a README.md that summarizes the task/report and links any supporting files.');
195
+ if (!existsSync(readme)) addError(readme, 'MISSING_README', `Expected README.md in ${documentationRoot}/${area}/${entry.name}/`, null, 'add a README.md that summarizes the task/report and links any supporting files.');
195
196
  }
196
197
  }
197
198
  }
198
199
  const gitignore = join(root, '.gitignore');
199
- if (!existsSync(gitignore)) addError(gitignore, 'MISSING_GITIGNORE', 'Expected .gitignore', null, 'create .gitignore and include docs/plan, docs/archive, and .dotdotgod entries.');
200
+ if (!existsSync(gitignore)) addError(gitignore, 'MISSING_GITIGNORE', 'Expected .gitignore', null, `create .gitignore and include ${documentationRoot}/plan, ${documentationRoot}/archive, and .dotdotgod entries.`);
200
201
  else {
201
202
  const content = readFileSync(gitignore, 'utf8').split('\n').map((line) => line.trim());
202
- for (const required of ['docs/plan', 'docs/archive', CACHE_DIR]) if (!content.includes(required)) addError(gitignore, 'MISSING_GITIGNORE_ENTRY', `Expected .gitignore entry: ${required}`, null, `add ${required} to .gitignore so local plans, archives, and cache files stay untracked.`);
203
+ for (const required of [`${documentationRoot}/plan`, `${documentationRoot}/archive`, CACHE_DIR]) if (!content.includes(required)) addError(gitignore, 'MISSING_GITIGNORE_ENTRY', `Expected .gitignore entry: ${required}`, null, `add ${required} to .gitignore so local plans, archives, and cache files stay untracked.`);
203
204
  }
204
205
  if (options.json) console.log(JSON.stringify({ ok: errors.length === 0, errors, warnings }, null, 2));
205
206
  else {