@dotdotgod/cli 0.6.6 → 0.7.0
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/README.md +8 -7
- package/package.json +2 -4
- package/src/cli/usage.mjs +9 -5
- package/src/commands/graph.mjs +19 -2
- package/src/commands/query.mjs +36 -8
- package/src/config/templates.mjs +8 -1
- package/src/core.mjs +1 -2
- package/src/graph-view/assets/app.css +27 -113
- package/src/graph-view/assets/app.js +75 -253
- package/src/graph-view/assets/file-list.js +82 -0
- package/src/graph-view/assets/graph-client.js +21 -0
- package/src/graph-view/assets/index.html +18 -75
- package/src/graph-view/assets/layout.js +47 -0
- package/src/graph-view/node-style.cjs +12 -0
- package/src/graph-view/policy.cjs +13 -0
- package/src/graph-view/server.mjs +18 -31
- package/src/graph-view/traceability.mjs +30 -0
- package/src/init.mjs +13 -111
- package/src/query/cache-session.mjs +82 -0
- package/src/query/store.mjs +7 -2
- package/src/graph-view/payload.mjs +0 -114
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Shared by the standalone browser and the Obsidian adapter.
|
|
2
|
+
function selectTraceability(graph, { documentsOnly = false, disabledRelations = [] } = {}) {
|
|
3
|
+
const disabled = new Set(disabledRelations);
|
|
4
|
+
const active = graph.edges.filter(e => !disabled.has(e.relation) && (!documentsOnly || [e.source, e.target].every(p => /\.md$/i.test(p))));
|
|
5
|
+
const pairs = new Map();
|
|
6
|
+
for (const edge of [...active].sort((a, b) => b.weight - a.weight || a.relation.localeCompare(b.relation))) {
|
|
7
|
+
const key = `${edge.source}\0${edge.target}`;
|
|
8
|
+
if (!pairs.has(key)) pairs.set(key, edge);
|
|
9
|
+
}
|
|
10
|
+
return { edges: [...pairs.values()], paths: [...new Set(active.flatMap(e => [e.source, e.target]))] };
|
|
11
|
+
}
|
|
12
|
+
if (typeof module !== 'undefined') module.exports = { selectTraceability };
|
|
13
|
+
else globalThis.selectTraceability = selectTraceability;
|
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { dirname, extname, join } from 'node:path';
|
|
3
|
+
import { dirname, extname, join, resolve } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import { canonicalizeChangedPath } from '../impact/vector-profile.mjs';
|
|
8
|
-
import { readFreshIndex } from '../index/cache.mjs';
|
|
9
|
-
import { buildImpactGraphPayload } from './payload.mjs';
|
|
5
|
+
import { exportTraceability } from './traceability.mjs';
|
|
6
|
+
import nodeStyle from './node-style.cjs';
|
|
10
7
|
|
|
11
8
|
const ASSET_ROOT = join(dirname(fileURLToPath(import.meta.url)), 'assets');
|
|
12
|
-
const CONTENT_TYPES = { '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.map': 'application/json; charset=utf-8' };
|
|
9
|
+
const CONTENT_TYPES = { '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.cjs': 'text/javascript; charset=utf-8', '.map': 'application/json; charset=utf-8' };
|
|
13
10
|
const VENDOR_FILES = {
|
|
14
|
-
'/vendor/sigma.js': new URL('../../node_modules/sigma/dist/sigma.min.js', import.meta.url),
|
|
15
|
-
'/vendor/graphology.js': new URL('../../node_modules/graphology/dist/graphology.umd.min.js', import.meta.url),
|
|
16
11
|
'/vendor/d3.js': new URL('../../node_modules/d3/dist/d3.min.js', import.meta.url),
|
|
17
12
|
};
|
|
18
13
|
|
|
@@ -32,38 +27,30 @@ function asset(response, file) {
|
|
|
32
27
|
}
|
|
33
28
|
}
|
|
34
29
|
|
|
35
|
-
export async function buildServedImpact(root, changedPaths) {
|
|
36
|
-
const normalized = [...new Set(changedPaths.map((path) => canonicalizeChangedPath(root, path)).filter(Boolean))];
|
|
37
|
-
const { status, index, metadata } = readFreshIndex(root);
|
|
38
|
-
const overlay = await buildVectorImpactOverlay(root, index, normalized);
|
|
39
|
-
const impact = buildImpactReport(index, normalized, { root, overlay, verboseSemantic: false, related: 40 });
|
|
40
|
-
const graph = buildImpactGraphPayload(index, impact);
|
|
41
|
-
const graphIds = new Set(graph.nodes.map((node) => node.id));
|
|
42
|
-
const explorerImpact = {
|
|
43
|
-
...impact,
|
|
44
|
-
related: impact.related.filter((item) => graphIds.has(item.id)),
|
|
45
|
-
};
|
|
46
|
-
return { ok: status.ok, status, metadata, impact: explorerImpact, graph };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
30
|
export async function startImpactGraphServer({ root, changed, host = '127.0.0.1', port = 0 }) {
|
|
50
|
-
|
|
31
|
+
root = resolve(root);
|
|
32
|
+
if (!['127.0.0.1', 'localhost', '::1'].includes(host)) throw new Error('Graph UI is local-only; use a loopback host.');
|
|
33
|
+
const build = async () => {
|
|
34
|
+
const data = await exportTraceability(root);
|
|
35
|
+
const paths = [...new Set(data.edges.flatMap(e => [e.source, e.target]))];
|
|
36
|
+
return { ...data, projectRoot: undefined, nodes: paths.map(path => ({ id: path, ...nodeStyle.describeNode(path, data.config.documentation.root) })) };
|
|
37
|
+
};
|
|
38
|
+
let payload = await build();
|
|
51
39
|
const server = createServer(async (request, response) => {
|
|
52
40
|
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? host}`);
|
|
53
|
-
if (
|
|
54
|
-
if (url.pathname === '/api/
|
|
55
|
-
const paths = url.searchParams.getAll('changed');
|
|
56
|
-
if (paths.length === 0) return json(response, 400, { ok: false, error: { code: 'MISSING_CHANGED', message: 'At least one changed path is required.' } });
|
|
41
|
+
if (!['GET', 'HEAD'].includes(request.method)) return json(response, 405, { error: 'Method not allowed' });
|
|
42
|
+
if (url.pathname === '/api/graph') {
|
|
57
43
|
try {
|
|
58
|
-
payload = await
|
|
44
|
+
payload = await build();
|
|
59
45
|
return json(response, 200, payload);
|
|
60
46
|
} catch (error) {
|
|
61
47
|
return json(response, 500, { ok: false, error: { code: 'GRAPH_SERVE_FAILED', message: error instanceof Error ? error.message : String(error) } });
|
|
62
48
|
}
|
|
63
49
|
}
|
|
64
50
|
if (VENDOR_FILES[url.pathname]) return asset(response, fileURLToPath(VENDOR_FILES[url.pathname]));
|
|
51
|
+
if (url.pathname === '/policy.js') return asset(response, join(ASSET_ROOT, '../policy.cjs'));
|
|
65
52
|
const relative = url.pathname === '/' ? 'index.html' : url.pathname.slice(1);
|
|
66
|
-
if (!['index.html', 'app.css', 'app.js'].includes(relative)) {
|
|
53
|
+
if (!['index.html', 'app.css', 'app.js', 'file-list.js', 'graph-client.js', 'layout.js'].includes(relative)) {
|
|
67
54
|
response.writeHead(404);
|
|
68
55
|
return response.end('Not found');
|
|
69
56
|
}
|
|
@@ -75,5 +62,5 @@ export async function startImpactGraphServer({ root, changed, host = '127.0.0.1'
|
|
|
75
62
|
});
|
|
76
63
|
const address = server.address();
|
|
77
64
|
const actualPort = typeof address === 'object' && address ? address.port : port;
|
|
78
|
-
return { server, url: `http://${host}:${actualPort}`, payload };
|
|
65
|
+
return { server, url: `http://${host.includes(':') ? `[${host}]` : host}:${actualPort}`, payload };
|
|
79
66
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import { readMemoryConfig, memoryConfigSummary, resolveMemoryArea } from '../memory/config.mjs';
|
|
4
|
+
import { readFreshIndex } from '../index/cache.mjs';
|
|
5
|
+
|
|
6
|
+
export async function exportTraceability(root) {
|
|
7
|
+
const config = readMemoryConfig(root);
|
|
8
|
+
if (config.errors?.length) throw new Error(JSON.stringify(config.errors));
|
|
9
|
+
const { index } = readFreshIndex(root);
|
|
10
|
+
const nodes = new Map(index.graph.nodes.map(n => [n.id, n]));
|
|
11
|
+
const colors = ['#b9a3ff', '#70c9a2', '#72b9e6', '#d6b879', '#e393a9', '#78c9cd'];
|
|
12
|
+
const types = config.traceability.keys.map((k, i) => ({ ...k, color: colors[i % colors.length] }));
|
|
13
|
+
const typesByRelation = new Map(types.map(t => [t.relation, t]));
|
|
14
|
+
const edges = [];
|
|
15
|
+
for (const edge of index.graph.edges) {
|
|
16
|
+
const type = typesByRelation.get(edge.relation);
|
|
17
|
+
const source = nodes.get(edge.source)?.path;
|
|
18
|
+
const target = nodes.get(edge.target)?.path;
|
|
19
|
+
if (!type || !source || !target || source === target) continue;
|
|
20
|
+
if ([source, target].some(p => resolveMemoryArea(p, config)?.scope === 'local')) continue;
|
|
21
|
+
if (!(await Promise.all([source, target].map(async p => {
|
|
22
|
+
const absolute = resolve(root, p);
|
|
23
|
+
if (!absolute.startsWith(resolve(root) + '/')) return false;
|
|
24
|
+
try { return (await stat(absolute)).isFile(); } catch { return false; }
|
|
25
|
+
}))).every(Boolean)) continue;
|
|
26
|
+
edges.push({ source, target, relation: type.relation, weight: type.weight, color: type.color });
|
|
27
|
+
}
|
|
28
|
+
return { version: 1, projectRoot: resolve(root), config: memoryConfigSummary(config), types,
|
|
29
|
+
edges: [...new Map(edges.map(e => [`${e.source}\0${e.target}\0${e.relation}`, e])).values()] };
|
|
30
|
+
}
|
package/src/init.mjs
CHANGED
|
@@ -14,11 +14,10 @@ function formatAction(item) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
function parseInitOptions(argv) {
|
|
17
|
-
const options = { root: null, projectName: '',
|
|
17
|
+
const options = { root: null, projectName: '', dryRun: false, json: false, template: null, documentationRoot: 'docs', documentationRootExplicit: false };
|
|
18
18
|
for (let i = 0; i < argv.length; i += 1) {
|
|
19
19
|
const arg = argv[i];
|
|
20
20
|
if (arg === '--json') options.json = true;
|
|
21
|
-
else if (arg === '--dotdot-setting') options.dotdotSetting = true;
|
|
22
21
|
else if (arg === '--dry-run') options.dryRun = true;
|
|
23
22
|
else if (arg === '--claude-plans') options.claudePlans = true;
|
|
24
23
|
else if (arg === '--project-name' || arg === '--template' || arg === '--documentation-root') {
|
|
@@ -100,8 +99,7 @@ function ensureGitignoreEntry(options, entry, actions) {
|
|
|
100
99
|
actions.push(action(existed ? 'updated' : 'created', target, { add: entry }));
|
|
101
100
|
}
|
|
102
101
|
|
|
103
|
-
function agentContent(projectName,
|
|
104
|
-
const dotdotAgentRule = dotdotSetting ? '\n- Follow the project documentation structure in `docs/arch/DOCS_STRUCTURE.md` and code conventions in `docs/arch/CODE_CONVENTIONS.md`.' : '';
|
|
102
|
+
function agentContent(projectName, domainMap) {
|
|
105
103
|
return `# AGENTS.md
|
|
106
104
|
|
|
107
105
|
Canonical instructions for AI coding agents working in this repository.
|
|
@@ -119,7 +117,7 @@ Canonical instructions for AI coding agents working in this repository.
|
|
|
119
117
|
- Preserve user edits and unrelated dirty worktree changes.
|
|
120
118
|
- Prefer existing local patterns over introducing new abstractions.
|
|
121
119
|
- Update docs when behavior, architecture, or test strategy changes.
|
|
122
|
-
- When using the dotdotgod CLI, run \`dotdotgod validate\` after docs changes and follow its traceability guidance
|
|
120
|
+
- When using the dotdotgod CLI, run \`dotdotgod validate\` after docs changes and follow its traceability guidance.
|
|
123
121
|
|
|
124
122
|
## dotdotgod
|
|
125
123
|
|
|
@@ -144,15 +142,12 @@ TODO
|
|
|
144
142
|
|
|
145
143
|
## Documentation Map
|
|
146
144
|
|
|
147
|
-
|
|
148
|
-
- \`docs/test/\`: test strategy, regression cases, manual verification notes.
|
|
149
|
-
- \`docs/arch/\`: architecture decisions, code conventions, module boundaries, data flow, infrastructure/runtime dependencies, integration boundaries, and migration design.
|
|
145
|
+
${domainMap}
|
|
150
146
|
- \`docs/\`: all directories use kebab-case; all markdown file names use UPPER_SNAKE_CASE, including \`README.md\`.
|
|
151
147
|
- \`docs/\`: prefer keeping individual markdown files under the configured markdown validation budgets (default 200 lines and 10,000 characters); split larger docs into focused UPPER_SNAKE_CASE files and keep \`README.md\` as the index/overview unless a narrow size-check exception is configured.
|
|
152
148
|
- \`docs/\`: when adding, renaming, splitting, moving, or archiving docs, update the nearest relevant \`README.md\` index/table of contents in the same change.
|
|
153
149
|
- \`docs/\`: each docs subdirectory \`README.md\` acts as the local table of contents; list important files, task directories, status, and a one-line purpose for each entry.
|
|
154
150
|
- \`docs/\`: start small with a single focused markdown file; when one domain grows into multiple docs, promote it to \`docs/<area>/<domain>/README.md\` plus related UPPER_SNAKE_CASE files in that directory.
|
|
155
|
-
- \`docs/arch/\`: code conventions may start as \`CODE_CONVENTIONS.md\`; when they grow across multiple topics, use \`docs/arch/conventions/README.md\` as the index with supporting UPPER_SNAKE_CASE files.
|
|
156
151
|
- \`docs/plan/\`: local active implementation plans. Create one kebab-case directory per task (\`docs/plan/<task-slug>/\`), keep the task overview/index in that directory's \`README.md\`, and add supporting UPPER_SNAKE_CASE plan files alongside it. Ignored by git by default.
|
|
157
152
|
- \`docs/archive/\`: local completed plans, temporary reports, historical notes, payload captures. Move completed plan task directories to \`docs/archive/plan/<task-slug>/\`; put temporary reports and investigations under \`docs/archive/report/<report-slug>/\`. Ignored by git by default.
|
|
158
153
|
|
|
@@ -164,7 +159,7 @@ TODO
|
|
|
164
159
|
Keep long-lived instructions here so agent-specific files do not drift.`;
|
|
165
160
|
}
|
|
166
161
|
|
|
167
|
-
function docsReadmeContent() {
|
|
162
|
+
function docsReadmeContent(domainMap) {
|
|
168
163
|
return `# Docs
|
|
169
164
|
|
|
170
165
|
This directory keeps project knowledge close to the code.
|
|
@@ -183,115 +178,21 @@ This directory keeps project knowledge close to the code.
|
|
|
183
178
|
|
|
184
179
|
## Map
|
|
185
180
|
|
|
186
|
-
|
|
187
|
-
- \`test/\`: test strategy, regression cases, manual verification notes.
|
|
188
|
-
- \`arch/\`: architecture decisions, code conventions, module boundaries, data flow, infrastructure/runtime dependencies, integration boundaries, and migration design.
|
|
181
|
+
${domainMap.replaceAll('`docs/', '`')}
|
|
189
182
|
- \`plan/\`: local active implementation plans. Create one kebab-case directory per task (\`plan/<task-slug>/\`), keep the task overview/index in that directory's \`README.md\`, and add supporting UPPER_SNAKE_CASE plan files alongside it. Ignored by git by default.
|
|
190
183
|
- \`archive/\`: local completed plans, temporary reports, historical notes, payload captures. Move completed plan task directories to \`archive/plan/<task-slug>/\`; put temporary reports and investigations under \`archive/report/<report-slug>/\`. Ignored by git by default.`;
|
|
191
184
|
}
|
|
192
185
|
|
|
193
|
-
function
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
Long-term documentation structure for this project.
|
|
197
|
-
|
|
198
|
-
## Top-Level Areas
|
|
199
|
-
|
|
200
|
-
- \`docs/spec/\`: product behavior, API contracts, user-facing requirements, and feature contracts.
|
|
201
|
-
- \`docs/test/\`: test strategy, coverage notes, regression cases, and manual verification records.
|
|
202
|
-
- \`docs/arch/\`: architecture decisions, code conventions, module boundaries, data flow, infrastructure/runtime dependencies, integration boundaries, and migration design.
|
|
203
|
-
- \`docs/plan/\`: local active implementation plans.
|
|
204
|
-
- \`docs/archive/\`: local completed plans, historical notes, payload captures, and investigation notes.
|
|
205
|
-
|
|
206
|
-
## Naming
|
|
207
|
-
|
|
208
|
-
- Directories under \`docs/\` use kebab-case.
|
|
209
|
-
- Markdown files under \`docs/\` use UPPER_SNAKE_CASE.
|
|
210
|
-
- \`README.md\` is the only mixed-case markdown filename exception and is required for index/overview files.
|
|
211
|
-
|
|
212
|
-
## File Size Guideline
|
|
213
|
-
|
|
214
|
-
Prefer keeping individual markdown files under 200 lines and 10,000 characters. When either guideline is exceeded, split the document into focused UPPER_SNAKE_CASE files and keep \`README.md\` as the index/overview. Configured validation exceptions should stay narrow and intentional.
|
|
215
|
-
|
|
216
|
-
## README Indexes
|
|
217
|
-
|
|
218
|
-
Each docs subdirectory \`README.md\` acts as the local table of contents. It should list important files, task directories, status, and a one-line purpose for each entry.
|
|
219
|
-
|
|
220
|
-
When adding, renaming, splitting, moving, or archiving docs, update the nearest relevant \`README.md\` in the same change.
|
|
221
|
-
|
|
222
|
-
## Domain Directory Promotion
|
|
223
|
-
|
|
224
|
-
Start small with one focused markdown file. When one domain grows into multiple docs, promote it to \`docs/<area>/<domain>/README.md\` and place related UPPER_SNAKE_CASE markdown files in that directory.
|
|
225
|
-
|
|
226
|
-
## Spec Writing Contract
|
|
227
|
-
|
|
228
|
-
Behavior specs describe the current product contract: supported commands, API shapes, user-visible behavior, defaults, constraints, and validation outcomes.
|
|
229
|
-
|
|
230
|
-
Specs should not describe how behavior changed over time. Rewrite historical-change wording into direct current-state rules. Historical context, migration rationale, future extension ideas, and completed-plan notes belong in \`docs/arch/\`, \`docs/test/\`, \`docs/archive/\`, or active \`docs/plan/\` files rather than behavior specs. If compatibility behavior is still user-visible, keep it in the spec but phrase it as a current supported or unsupported rule.
|
|
231
|
-
|
|
232
|
-
Config/action terms such as \`remove\`, \`exclude\`, \`fallback\`, and \`replacement semantics\` are allowed when they name current behavior precisely.
|
|
233
|
-
|
|
234
|
-
## Traceability Blocks
|
|
235
|
-
|
|
236
|
-
Behavior specs may include fenced \`json dotdotgod\` traceability blocks as the final section to connect specs to source, tests, related docs, and verification commands. The dotdotgod CLI owns the schema and validation behavior.
|
|
237
|
-
|
|
238
|
-
## Plan and Archive Directories
|
|
239
|
-
|
|
240
|
-
Active task plans use \`docs/plan/<task-slug>/README.md\`. Completed or superseded plan task directories move to \`docs/archive/plan/<task-slug>/\`. Temporary investigations, reports, payload captures, and historical notes move to \`docs/archive/report/<report-slug>/\`.
|
|
241
|
-
|
|
242
|
-
\`docs/plan\` and \`docs/archive\` are ignored by git by default.`;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function codeConventionsContent() {
|
|
246
|
-
return `# Code Conventions
|
|
247
|
-
|
|
248
|
-
Dotdot code conventions for keeping implementation simple and maintainable.
|
|
249
|
-
|
|
250
|
-
## Abstraction Boundaries
|
|
251
|
-
|
|
252
|
-
- Do not introduce unnecessary abstractions.
|
|
253
|
-
- Do not abstract code that is not reused.
|
|
254
|
-
- Do not abstract reused code when the reused behavior is likely to split into separate features or flows later.
|
|
255
|
-
- Prefer local, explicit code until a stable reuse pattern appears.
|
|
256
|
-
|
|
257
|
-
## Source File Size
|
|
258
|
-
|
|
259
|
-
- Keep source files small enough to read in one focused pass by humans and coding agents.
|
|
260
|
-
- If code grows beyond 150 lines, consider splitting or extracting focused units even when it is not reused.
|
|
261
|
-
- Review files approaching 250 lines for focused extraction by responsibility.
|
|
262
|
-
- Split by behavior or responsibility, not by arbitrary layers.
|
|
263
|
-
|
|
264
|
-
## Dotdotgod Impact Hotspots
|
|
265
|
-
|
|
266
|
-
- Treat repeated \`dotdotgod graph impact\` results that collapse onto one large file as a design signal, not as normal precision.
|
|
267
|
-
- Dotdotgod impact reveals mixed-responsibility hotspots; it does not replace focused module boundaries.
|
|
268
|
-
- When unrelated changes keep pointing to the same source file, split the file by behavior so impact results, tests, and docs can map to narrower responsibilities.
|
|
269
|
-
|
|
270
|
-
## Extraction and Testability
|
|
271
|
-
|
|
272
|
-
- Prefer extracting pure helpers when behavior can be tested without runtime dependencies.
|
|
273
|
-
- Keep runtime integration explicit and local until reuse is stable.
|
|
274
|
-
- Put testable logic in focused modules before adding broad framework abstractions.
|
|
275
|
-
- Preserve plain-text readability: avoid dense clever code, hidden control flow, and large mixed-responsibility files.`;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function initFiles(options) {
|
|
279
|
-
const archReadmeExtra = options.dotdotSetting ? '\n\n## Index\n\n- `DOCS_STRUCTURE.md`: documentation layout, naming, README index, spec current-state writing contract, and domain directory promotion rules.\n- `CODE_CONVENTIONS.md`: dotdot code conventions, including abstraction boundaries, source file size guidance, impact hotspot handling, and extraction/testability rules. If conventions grow across multiple topics, promote them to `conventions/README.md` with supporting UPPER_SNAKE_CASE files.' : '';
|
|
186
|
+
function initFiles(options, scaffold) {
|
|
187
|
+
const domainMap = scaffold.filter(item => item.type === 'file').map(item => `- \`${item.path.replace(/README\.md$/, '')}\`: ${item.content.split('\n\n')[1]}`).join('\n');
|
|
280
188
|
const files = [
|
|
281
|
-
['AGENTS.md', agentContent(options.projectName,
|
|
189
|
+
['AGENTS.md', agentContent(options.projectName, domainMap)],
|
|
282
190
|
['CLAUDE.md', '# CLAUDE.md\n\n@AGENTS.md'],
|
|
283
191
|
['CODEX.md', '# CODEX.md\n\nSee [AGENTS.md](./AGENTS.md).'],
|
|
284
|
-
['docs/README.md', docsReadmeContent()],
|
|
285
|
-
['docs/spec/README.md', '# Specs\n\nUse this area for behavior specs, API contracts, and product requirements.\n\nFor projects using the dotdotgod CLI, behavior specs may be required by `dotdotgod validate` to include fenced `json dotdotgod` traceability blocks as the final section. The CLI owns the schema and prints property-level repair guidance when validation fails.'],
|
|
286
|
-
['docs/test/README.md', '# Tests\n\nUse this area for test strategy, coverage notes, regression cases, and manual verification records.'],
|
|
287
|
-
['docs/arch/README.md', `# Architecture\n\nUse this area for architecture decisions, code conventions, module boundaries, data flow notes, infrastructure/runtime dependencies, integration boundaries, and migration design.${archReadmeExtra}`],
|
|
192
|
+
['docs/README.md', docsReadmeContent(domainMap)],
|
|
288
193
|
['docs/plan/README.md', '# Plans\n\nUse this area for active implementation plans.\n\n## Naming\n\n- Task directories use kebab-case: `docs/plan/<task-slug>/`.\n- Markdown file names use UPPER_SNAKE_CASE: `README.md`, `RESEARCH_NOTES.md`, `VERIFICATION.md`.\n\n## Structure\n\n- Create one directory per task: `docs/plan/<task-slug>/`.\n- Put the task overview, index, scope, status, and main plan in `docs/plan/<task-slug>/README.md`.\n- Add supporting research, checklists, payload captures, or verification notes as additional UPPER_SNAKE_CASE markdown files in the same task directory.\n- Move completed or superseded task directories to `docs/archive/plan/<task-slug>/`.\n\nThis directory is local-only and ignored by git by default.'],
|
|
289
194
|
['docs/archive/README.md', '# Archive\n\nUse this area for local completed plans, temporary reports, historical notes, payload captures, and investigation notes.\n\n## Naming\n\n- Archived plan task directories preserve their kebab-case task slug.\n- Archived report directories use a focused kebab-case report slug.\n- Markdown file names use UPPER_SNAKE_CASE, including `README.md`.\n\n## Structure\n\n- Move completed plan task directories from `docs/plan/<task-slug>/` to `docs/archive/plan/<task-slug>/`.\n- Put temporary investigations, reports, payload captures, and historical notes under `docs/archive/report/<report-slug>/`.\n- Preserve each archive directory\'s `README.md` overview/index and supporting UPPER_SNAKE_CASE markdown files.\n- Additional archive categories can be added later as focused kebab-case subdirectories when needed.\n\nThis directory is local-only and ignored by git by default.'],
|
|
290
195
|
];
|
|
291
|
-
if (options.dotdotSetting) {
|
|
292
|
-
files.push(['docs/arch/DOCS_STRUCTURE.md', docsStructureContent()]);
|
|
293
|
-
files.push(['docs/arch/CODE_CONVENTIONS.md', codeConventionsContent()]);
|
|
294
|
-
}
|
|
295
196
|
return files.map(([path, content]) => [path.replace(/^docs(?=\/|$)/, options.documentationRoot), content.replaceAll('docs/', `${options.documentationRoot}/`).replaceAll('`docs`', `\`${options.documentationRoot}\``)]);
|
|
296
197
|
}
|
|
297
198
|
|
|
@@ -334,7 +235,8 @@ export function runInit(argv, usage) {
|
|
|
334
235
|
if (template?.source === 'bundled') { const data = builtInTemplateData(template.name, { documentationRoot: options.documentationRoot }); template = { ...template, data, text: `${JSON.stringify(data, null, 2)}\n` }; }
|
|
335
236
|
|
|
336
237
|
const actions = [];
|
|
337
|
-
|
|
238
|
+
const scaffold = template?.source === 'bundled' ? builtInTemplateScaffold(template.name) : [];
|
|
239
|
+
for (const [relativePath, content] of initFiles(options, scaffold)) writeInitFile(options, relativePath, content, actions);
|
|
338
240
|
if (template?.source === 'bundled') {
|
|
339
241
|
for (const item of builtInTemplateScaffold(template.name, { documentationRoot: options.documentationRoot }) ?? []) {
|
|
340
242
|
if (item.type === 'directory') ensureInitDirectory(options, item.path, actions);
|
|
@@ -351,7 +253,7 @@ export function runInit(argv, usage) {
|
|
|
351
253
|
catch (error) { initError(options, 'CLAUDE_SETTINGS_CHANGED', error.message); }
|
|
352
254
|
}
|
|
353
255
|
|
|
354
|
-
const payload = { ok: true, command: 'init', root: options.root, projectName: options.projectName, documentationRoot: options.documentationRoot, dryRun: options.dryRun,
|
|
256
|
+
const payload = { ok: true, command: 'init', root: options.root, projectName: options.projectName, documentationRoot: options.documentationRoot, dryRun: options.dryRun, template: template ? { name: template.name, source: template.source, selectedBy: template.selectedBy } : null, actions };
|
|
355
257
|
if (options.json) console.log(JSON.stringify(payload, null, 2));
|
|
356
258
|
else {
|
|
357
259
|
if (template) console.log(`template ${template.source}:${template.name} (${template.selectedBy})`);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, openSync, closeSync, readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
5
|
+
import { isValidNormalizedVectorValues, readVectorCache, profileFingerprint } from './store.mjs';
|
|
6
|
+
|
|
7
|
+
// One atomic state file keeps completed snapshots and unfinished batches consistent.
|
|
8
|
+
export async function vectorCacheSession(root, identity, action) {
|
|
9
|
+
const directory = join(root, '.dotdotgod', 'vectors');
|
|
10
|
+
mkdirSync(directory, { recursive: true });
|
|
11
|
+
const lock = join(directory, 'build.lock');
|
|
12
|
+
const token = randomUUID();
|
|
13
|
+
const started = Date.now();
|
|
14
|
+
let waited = false;
|
|
15
|
+
while (true) {
|
|
16
|
+
try {
|
|
17
|
+
const fd = openSync(lock, 'wx');
|
|
18
|
+
try { writeFileSync(fd, JSON.stringify({ pid: process.pid, token })); } finally { closeSync(fd); }
|
|
19
|
+
break;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error.code !== 'EEXIST') throw error;
|
|
22
|
+
waited = true;
|
|
23
|
+
if (Date.now() - started >= 30000) throw new Error('Vector cache lock timed out; another build may be running or the lock needs inspection.');
|
|
24
|
+
try {
|
|
25
|
+
const owner = JSON.parse(readFileSync(lock, 'utf8'));
|
|
26
|
+
if (!Number.isInteger(owner.pid) || owner.pid < 1) throw new Error('Invalid lock owner.');
|
|
27
|
+
try { process.kill(owner.pid, 0); }
|
|
28
|
+
catch (probe) {
|
|
29
|
+
if (probe.code !== 'ESRCH') throw probe;
|
|
30
|
+
const recovery = `${lock}.recovery`;
|
|
31
|
+
try { mkdirSync(recovery); }
|
|
32
|
+
catch (claim) { if (claim.code === 'EEXIST') { await sleep(50); continue; } throw claim; }
|
|
33
|
+
try {
|
|
34
|
+
const current = JSON.parse(readFileSync(lock, 'utf8'));
|
|
35
|
+
if (current.token === owner.token && current.pid === owner.pid) rmSync(lock);
|
|
36
|
+
} finally { rmSync(recovery, { recursive: true, force: true }); }
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
} catch (probe) { if (probe.code === 'ENOENT') continue; }
|
|
40
|
+
await sleep(50);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const path = join(directory, 'snapshots.json');
|
|
45
|
+
const fingerprint = profileFingerprint(identity);
|
|
46
|
+
let state; let reason = 'missing';
|
|
47
|
+
try {
|
|
48
|
+
state = JSON.parse(readFileSync(path, 'utf8'));
|
|
49
|
+
if (state.schema !== 1) { reason = 'schema-mismatch'; state = null; }
|
|
50
|
+
else if (state.profile !== fingerprint) { reason = 'profile-mismatch'; state = null; }
|
|
51
|
+
else {
|
|
52
|
+
if (state.dimensions !== null && (!Number.isInteger(state.dimensions) || state.dimensions < 1)) throw new Error('Invalid cache dimensions.');
|
|
53
|
+
for (const rows of [state.docs, state.code, state.pending]) {
|
|
54
|
+
if (!Array.isArray(rows) || rows.some((row) => typeof row.chunk?.fingerprint !== 'string' || !Array.isArray(row.vector) || row.vector.length !== state.dimensions || !isValidNormalizedVectorValues(row.vector, state.dimensions))) throw new Error('Invalid cache rows.');
|
|
55
|
+
}
|
|
56
|
+
reason = 'valid';
|
|
57
|
+
}
|
|
58
|
+
} catch (error) { state = null; reason = error.code === 'ENOENT' ? 'missing' : 'corrupt'; }
|
|
59
|
+
if (!state) {
|
|
60
|
+
state = { schema: 1, profile: fingerprint, dimensions: null, docs: [], code: [], pending: [] };
|
|
61
|
+
if (reason === 'missing') {
|
|
62
|
+
const legacyDiagnostics = {};
|
|
63
|
+
const legacy = readVectorCache(root, identity, legacyDiagnostics);
|
|
64
|
+
reason = legacyDiagnostics.reason;
|
|
65
|
+
if (legacy) {
|
|
66
|
+
state.dimensions = legacy.manifest.dimensions;
|
|
67
|
+
legacy.chunks.forEach((chunk, i) => state[chunk.kind === 'symbol' ? 'code' : 'docs'].push({ chunk, vector: Array.from(legacy.vectors.subarray(i * state.dimensions, (i + 1) * state.dimensions)) }));
|
|
68
|
+
reason = 'legacy-migrated';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const save = () => {
|
|
73
|
+
const temporary = `${path}.tmp-${token}`;
|
|
74
|
+
try { writeFileSync(temporary, JSON.stringify(state)); renameSync(temporary, path); }
|
|
75
|
+
finally { rmSync(temporary, { force: true }); }
|
|
76
|
+
};
|
|
77
|
+
return await action(state, save, { reason, lockWaitMs: waited ? Date.now() - started : 0 });
|
|
78
|
+
} finally {
|
|
79
|
+
try { if (JSON.parse(readFileSync(lock, 'utf8')).token === token) rmSync(lock); }
|
|
80
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/query/store.mjs
CHANGED
|
@@ -29,20 +29,25 @@ export function vectorCachePaths(root) {
|
|
|
29
29
|
return { directory, manifest: join(directory, 'manifest.json'), chunks: join(directory, 'chunks.jsonl'), embeddings: join(directory, 'embeddings.f32') };
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export function readVectorCache(root, identity = null) {
|
|
32
|
+
export function readVectorCache(root, identity = null, diagnostics = {}) {
|
|
33
|
+
diagnostics.reason = 'missing';
|
|
33
34
|
const paths = vectorCachePaths(root);
|
|
34
35
|
if (!existsSync(paths.manifest) || !existsSync(paths.chunks) || !existsSync(paths.embeddings)) return null;
|
|
35
36
|
try {
|
|
36
37
|
const manifest = JSON.parse(readFileSync(paths.manifest, 'utf8'));
|
|
38
|
+
diagnostics.reason = 'schema-mismatch';
|
|
37
39
|
if (manifest.schemaVersion !== VECTOR_SCHEMA_VERSION || !Number.isInteger(manifest.dimensions) || manifest.dimensions < 1) return null;
|
|
40
|
+
diagnostics.reason = 'profile-mismatch';
|
|
38
41
|
if (identity && manifest.profileFingerprint !== profileFingerprint(identity)) return null;
|
|
42
|
+
diagnostics.reason = 'corrupt';
|
|
39
43
|
const chunks = readFileSync(paths.chunks, 'utf8').split('\n').filter(Boolean).map((line) => JSON.parse(line));
|
|
40
44
|
const buffer = readFileSync(paths.embeddings);
|
|
41
45
|
if (buffer.byteLength !== chunks.length * manifest.dimensions * 4) return null;
|
|
42
46
|
const vectors = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4);
|
|
43
47
|
if (!isValidNormalizedVectorValues(vectors, manifest.dimensions)) return null;
|
|
48
|
+
diagnostics.reason = 'valid';
|
|
44
49
|
return { manifest, chunks, vectors: new Float32Array(vectors) };
|
|
45
|
-
} catch { return null; }
|
|
50
|
+
} catch { diagnostics.reason = 'corrupt'; return null; }
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
function atomicWrite(path, data) {
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
import { resolveMemoryArea } from '../memory/config.mjs';
|
|
2
|
-
|
|
3
|
-
function edgeKey(edge) {
|
|
4
|
-
return `${edge.source}\0${edge.target}\0${edge.relation}`;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
function labelForNode(node) {
|
|
8
|
-
if (node.type === 'contract') return node.title ?? node.contractId ?? node.id;
|
|
9
|
-
return node.path ?? node.command ?? node.name ?? node.target ?? node.id;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function adjacencyFor(nodes, edges) {
|
|
13
|
-
const adjacency = new Map(nodes.map((node) => [node.id, new Set()]));
|
|
14
|
-
for (const edge of edges) {
|
|
15
|
-
adjacency.get(edge.source)?.add(edge.target);
|
|
16
|
-
adjacency.get(edge.target)?.add(edge.source);
|
|
17
|
-
}
|
|
18
|
-
return adjacency;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function rootReachability(nodes, edges, seedIds) {
|
|
22
|
-
const adjacency = adjacencyFor(nodes, edges);
|
|
23
|
-
const reachable = new Set();
|
|
24
|
-
const depthByNode = new Map();
|
|
25
|
-
const nearestRootByNode = new Map();
|
|
26
|
-
const queue = [];
|
|
27
|
-
for (const node of nodes) {
|
|
28
|
-
if (!seedIds.has(node.id)) continue;
|
|
29
|
-
reachable.add(node.id);
|
|
30
|
-
depthByNode.set(node.id, 0);
|
|
31
|
-
nearestRootByNode.set(node.id, node.id);
|
|
32
|
-
queue.push(node.id);
|
|
33
|
-
}
|
|
34
|
-
for (let cursor = 0; cursor < queue.length; cursor += 1) {
|
|
35
|
-
const current = queue[cursor];
|
|
36
|
-
for (const neighbor of adjacency.get(current) ?? []) {
|
|
37
|
-
if (reachable.has(neighbor)) continue;
|
|
38
|
-
reachable.add(neighbor);
|
|
39
|
-
depthByNode.set(neighbor, (depthByNode.get(current) ?? 0) + 1);
|
|
40
|
-
nearestRootByNode.set(neighbor, nearestRootByNode.get(current));
|
|
41
|
-
queue.push(neighbor);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
return { reachable, depthByNode, nearestRootByNode };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function buildImpactGraphPayload(index, impact) {
|
|
48
|
-
const graph = impact.structuralGraph ?? index?.graph ?? { nodes: [], edges: [] };
|
|
49
|
-
const config = index?.memoryConfig;
|
|
50
|
-
const seedIds = new Set((impact.changedFiles ?? [impact.changed]).map((path) => `file:${path}`));
|
|
51
|
-
const impactItems = new Map((impact.related ?? []).map((item) => [item.id, item]));
|
|
52
|
-
const isAllowedNode = (node) => {
|
|
53
|
-
if (!node) return false;
|
|
54
|
-
if (node.path && resolveMemoryArea(node.path, config)?.scope === 'local') return false;
|
|
55
|
-
return true;
|
|
56
|
-
};
|
|
57
|
-
const sourceNodes = (graph.nodes ?? []).filter(isAllowedNode);
|
|
58
|
-
const indexedNodes = new Map(sourceNodes.map((node) => [node.id, node]));
|
|
59
|
-
|
|
60
|
-
const seenEdges = new Set();
|
|
61
|
-
const edges = [];
|
|
62
|
-
for (const edge of graph.edges ?? []) {
|
|
63
|
-
if (!indexedNodes.has(edge.source) || !indexedNodes.has(edge.target)) continue;
|
|
64
|
-
const key = edgeKey(edge);
|
|
65
|
-
if (seenEdges.has(key)) continue;
|
|
66
|
-
seenEdges.add(key);
|
|
67
|
-
edges.push({ source: edge.source, target: edge.target, relation: edge.relation, confidence: edge.confidence, weight: edge.weight ?? edge.relationWeight, score: edge.score, traceabilityKey: edge.traceabilityKey });
|
|
68
|
-
}
|
|
69
|
-
const { reachable, depthByNode, nearestRootByNode } = rootReachability(sourceNodes, edges, seedIds);
|
|
70
|
-
const retainedNodes = sourceNodes.filter((node) => reachable.has(node.id));
|
|
71
|
-
const retainedEdges = edges.filter((edge) => reachable.has(edge.source) && reachable.has(edge.target));
|
|
72
|
-
const nodes = retainedNodes.map((source) => {
|
|
73
|
-
const item = impactItems.get(source.id);
|
|
74
|
-
const seed = seedIds.has(source.id);
|
|
75
|
-
return {
|
|
76
|
-
id: source.id,
|
|
77
|
-
type: source.type ?? item?.type ?? 'file',
|
|
78
|
-
path: source.path ?? item?.path,
|
|
79
|
-
label: labelForNode({ ...source, ...item }),
|
|
80
|
-
seed,
|
|
81
|
-
ranked: Boolean(item),
|
|
82
|
-
depth: depthByNode.get(source.id) ?? 0,
|
|
83
|
-
nearestRoot: nearestRootByNode.get(source.id),
|
|
84
|
-
component: 0,
|
|
85
|
-
seededComponent: true,
|
|
86
|
-
impactScore: seed ? 100 : item?.impactScore ?? 0,
|
|
87
|
-
reasons: seed ? ['changed-file'] : item?.reasons ?? [],
|
|
88
|
-
scoreBreakdown: seed ? { seed: 100 } : item?.scoreBreakdown,
|
|
89
|
-
retrieval: item?.retrieval ?? source.retrieval,
|
|
90
|
-
contractId: item?.contractId ?? source.contractId,
|
|
91
|
-
title: item?.title ?? source.title,
|
|
92
|
-
sections: item?.sections ?? source.sections,
|
|
93
|
-
vectorEvidence: item?.vectorEvidence,
|
|
94
|
-
};
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
return {
|
|
98
|
-
changed: impact.changed,
|
|
99
|
-
changedFiles: impact.changedFiles ?? [impact.changed],
|
|
100
|
-
ranking: impact.ranking,
|
|
101
|
-
semantic: impact.semantic,
|
|
102
|
-
complete: true,
|
|
103
|
-
nodes,
|
|
104
|
-
edges: retainedEdges,
|
|
105
|
-
components: retainedNodes.length > 0 ? [{ id: 0, size: retainedNodes.length, seeded: true }] : [],
|
|
106
|
-
diagnostics: {
|
|
107
|
-
rootNodes: retainedNodes.filter((node) => seedIds.has(node.id)).length,
|
|
108
|
-
connectedNodes: retainedNodes.length,
|
|
109
|
-
disconnectedNodesOmitted: sourceNodes.length - retainedNodes.length,
|
|
110
|
-
maximumDepth: Math.max(0, ...depthByNode.values()),
|
|
111
|
-
},
|
|
112
|
-
omittedRelated: impact.omittedRelated ?? 0,
|
|
113
|
-
};
|
|
114
|
-
}
|