obsidian-second-brain 0.1.1 → 0.2.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 +22 -0
- package/config/projects.example.json +10 -0
- package/dist/claude-runner.js +34 -0
- package/dist/cli-claude-backend.js +2 -18
- package/dist/cli.js +72 -22
- package/dist/code-boundaries.js +154 -0
- package/dist/code-capture.js +122 -0
- package/dist/code-classify.js +150 -0
- package/dist/code-evidence.js +113 -0
- package/dist/code-frontmatter.js +147 -0
- package/dist/code-ingest.js +289 -0
- package/dist/code-map-backend.js +70 -0
- package/dist/code-map-config.js +0 -0
- package/dist/code-map-prompt.js +84 -0
- package/dist/code-scan.js +168 -0
- package/dist/config.js +5 -0
- package/dist/consolidation.js +1 -1
- package/dist/managed-section.js +40 -19
- package/dist/map-command.js +191 -0
- package/dist/report.js +34 -0
- package/dist/shell.js +55 -1
- package/dist/vault-paths.js +23 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -106,6 +106,28 @@ If init warned `claude CLI preflight failed`, synthesis fell back to `noop` (mir
|
|
|
106
106
|
|
|
107
107
|
Paths support `${ENV_VAR}` expansion; the file is validated with clear errors. Edit it and the next run picks it up — no reload step.
|
|
108
108
|
|
|
109
|
+
## Code maps
|
|
110
|
+
|
|
111
|
+
`second-brain map <repoPath>` captures a repository's structure as
|
|
112
|
+
deterministic evidence under `raw/code/<project>/` ($0 — no LLM in capture)
|
|
113
|
+
and renders wiki pages under `wiki/code/<project>/`: one architecture
|
|
114
|
+
overview (with a Mermaid component diagram) plus one page per module.
|
|
115
|
+
Registered repos are re-captured by the hourly job; only changed evidence
|
|
116
|
+
re-renders (content-hash manifest), so idle repos cost nothing.
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
second-brain map <repoPath> [--project <name>] [--path <subdir>]
|
|
120
|
+
[--max-modules N] [--force]
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- `--path` scopes a monorepo package; register each package as its own project.
|
|
124
|
+
- `--force` clears the project's code manifest entries and re-renders every page.
|
|
125
|
+
- Module boundaries are deterministic (top-level directories, with descent
|
|
126
|
+
into a dominant directory); override per repo via `codeMap.repos[].modules`
|
|
127
|
+
in `config/projects.json` when directories do not reflect architecture.
|
|
128
|
+
- Page bodies are generated artifacts — manual edits are overwritten. Evidence
|
|
129
|
+
under `raw/code/` is the LLM-readable source of truth for structure.
|
|
130
|
+
|
|
109
131
|
## Uninstall
|
|
110
132
|
|
|
111
133
|
```bash
|
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
"vaultRoot": "${HOME}/Documents/Obsidian Vault/raw/projects",
|
|
3
3
|
"logsRoot": "../logs",
|
|
4
4
|
"synthesis": { "backend": "noop" },
|
|
5
|
+
"codeMap": {
|
|
6
|
+
"repos": [
|
|
7
|
+
{
|
|
8
|
+
"path": "/Users/me/Sources/my-repo",
|
|
9
|
+
"project": "my-repo"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"models": { "structure": "sonnet", "pages": "haiku" },
|
|
13
|
+
"maxModules": 24
|
|
14
|
+
},
|
|
5
15
|
"providers": [
|
|
6
16
|
{
|
|
7
17
|
"name": "claude",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { runCommand, runCommandWithInput } from "./shell.js";
|
|
2
|
+
const defaultDependencies = {
|
|
3
|
+
run: runCommand,
|
|
4
|
+
runWithInput: runCommandWithInput
|
|
5
|
+
};
|
|
6
|
+
export function createClaudeRunner(options = {}, dependencies = defaultDependencies) {
|
|
7
|
+
const claudeBin = options.claudeBin ?? "claude";
|
|
8
|
+
const delivery = options.delivery ?? "argv";
|
|
9
|
+
return async (input) => {
|
|
10
|
+
const args = ["-p", "--model", input.model, "--output-format", "json"];
|
|
11
|
+
const result = delivery === "stdin"
|
|
12
|
+
? await dependencies.runWithInput(claudeBin, args, process.cwd(), input.prompt)
|
|
13
|
+
: await dependencies.run(claudeBin, [...args, input.prompt], process.cwd());
|
|
14
|
+
return extractResultText(result.stdout);
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function extractResultText(stdout) {
|
|
18
|
+
let parsed;
|
|
19
|
+
try {
|
|
20
|
+
parsed = JSON.parse(stdout);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// The CLI can print a banner, login prompt, or usage-limit notice instead
|
|
24
|
+
// of the JSON envelope; a bare SyntaxError would be useless in failures[].
|
|
25
|
+
throw new Error(`claude CLI emitted non-JSON output: ${stdout.slice(0, 120).trim()}`);
|
|
26
|
+
}
|
|
27
|
+
if (typeof parsed === "object" &&
|
|
28
|
+
parsed !== null &&
|
|
29
|
+
"result" in parsed &&
|
|
30
|
+
typeof parsed.result === "string") {
|
|
31
|
+
return parsed.result;
|
|
32
|
+
}
|
|
33
|
+
throw new Error("Unexpected claude CLI output envelope: missing result text");
|
|
34
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { extractResultText } from "./claude-runner.js";
|
|
2
3
|
import { parseEpisodePatch } from "./episode-patch.js";
|
|
3
4
|
import { assertEpisodesMatchProject, buildEpisodePrompt } from "./episode-prompt.js";
|
|
4
5
|
import { runCommand } from "./shell.js";
|
|
6
|
+
export { extractResultText } from "./claude-runner.js";
|
|
5
7
|
const defaultDependencies = {
|
|
6
8
|
readSource: (path) => readFile(path, "utf8"),
|
|
7
9
|
run: runCommand
|
|
@@ -20,21 +22,3 @@ export function createCliClaudeBackend(options = {}, dependencies = defaultDepen
|
|
|
20
22
|
}
|
|
21
23
|
};
|
|
22
24
|
}
|
|
23
|
-
export function extractResultText(stdout) {
|
|
24
|
-
let parsed;
|
|
25
|
-
try {
|
|
26
|
-
parsed = JSON.parse(stdout);
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
// The CLI can print a banner, login prompt, or usage-limit notice instead
|
|
30
|
-
// of the JSON envelope; a bare SyntaxError would be useless in failures[].
|
|
31
|
-
throw new Error(`claude CLI emitted non-JSON output: ${stdout.slice(0, 120).trim()}`);
|
|
32
|
-
}
|
|
33
|
-
if (typeof parsed === "object" &&
|
|
34
|
-
parsed !== null &&
|
|
35
|
-
"result" in parsed &&
|
|
36
|
-
typeof parsed.result === "string") {
|
|
37
|
-
return parsed.result;
|
|
38
|
-
}
|
|
39
|
-
throw new Error("Unexpected claude CLI output envelope: missing result text");
|
|
40
|
-
}
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { join, resolve
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
5
|
import { createCliClaudeBackend } from "./cli-claude-backend.js";
|
|
6
|
+
import { captureRepo, defaultCaptureDependencies } from "./code-capture.js";
|
|
7
|
+
import { runCodeMapIngest } from "./code-ingest.js";
|
|
8
|
+
import { createCliClaudeCodeMapBackend, resolveCodeMapBackend } from "./code-map-backend.js";
|
|
6
9
|
import { loadConfig, parseCliArgs, selectProviders } from "./config.js";
|
|
7
10
|
import { createCliClaudeConsolidationBackend, resolveConsolidationBackend } from "./consolidation-backend.js";
|
|
8
11
|
import { runConsolidation } from "./consolidation.js";
|
|
@@ -13,6 +16,7 @@ import { createOllamaBackend } from "./ollama-backend.js";
|
|
|
13
16
|
import { createReportWriter } from "./report.js";
|
|
14
17
|
import { resolveBackend } from "./synthesis.js";
|
|
15
18
|
import { syncProviderTree } from "./sync.js";
|
|
19
|
+
import { resolveVaultPaths } from "./vault-paths.js";
|
|
16
20
|
const defaultDependencies = {
|
|
17
21
|
createLogger,
|
|
18
22
|
loadConfig,
|
|
@@ -54,6 +58,30 @@ export async function runCli(argv, dependencies = defaultDependencies) {
|
|
|
54
58
|
vaultRoot: vaultPaths.vaultDir
|
|
55
59
|
})
|
|
56
60
|
: undefined;
|
|
61
|
+
// Code capture (design 6.1): deterministic, $0 — runs even when the
|
|
62
|
+
// synthesis backend is noop, so evidence accumulates for later.
|
|
63
|
+
let evidenceCaptured = 0;
|
|
64
|
+
const captureFailures = [];
|
|
65
|
+
const codeMap = config.codeMap;
|
|
66
|
+
if (vaultPaths && args.ingest && codeMap !== undefined) {
|
|
67
|
+
for (const repo of codeMap.repos) {
|
|
68
|
+
try {
|
|
69
|
+
const captureSummary = await captureRepo({
|
|
70
|
+
date: generatedAt.slice(0, 10),
|
|
71
|
+
globalMaxModules: codeMap.maxModules,
|
|
72
|
+
rawCodeRoot: vaultPaths.rawCodeRoot,
|
|
73
|
+
repo
|
|
74
|
+
}, defaultCaptureDependencies(repo.path));
|
|
75
|
+
evidenceCaptured += captureSummary.evidenceWritten;
|
|
76
|
+
logger.info(`[code-map] ${repo.project}: written=${captureSummary.evidenceWritten}, unchanged=${captureSummary.evidenceUnchanged}, modules=${captureSummary.moduleCount}, pruned=${captureSummary.digestsDeleted}`);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
const reason = error instanceof Error ? error.message : "Unknown error";
|
|
80
|
+
captureFailures.push({ project: repo.project, reason });
|
|
81
|
+
logger.info(`[code-map] ${repo.project} capture error: ${reason}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
57
85
|
if (vaultPaths && args.ingest) {
|
|
58
86
|
const backend = resolveBackend(config.synthesis.backend, {
|
|
59
87
|
"cli-claude": createCliClaudeBackend({
|
|
@@ -75,6 +103,36 @@ export async function runCli(argv, dependencies = defaultDependencies) {
|
|
|
75
103
|
? "Ingest: skipped (no changes)"
|
|
76
104
|
: `Ingest: episodes=${ingestSummary.episodePaths.length}, transcripts_tracked=${ingestSummary.trackedTranscripts}, failures=${ingestSummary.failures.length}, quarantined=${ingestSummary.quarantined.length}`);
|
|
77
105
|
}
|
|
106
|
+
let codeMapSummary;
|
|
107
|
+
if (vaultPaths && args.ingest && codeMap !== undefined) {
|
|
108
|
+
const codeBackend = resolveCodeMapBackend(config.synthesis.backend, () => createCliClaudeCodeMapBackend({
|
|
109
|
+
...(config.synthesis.claudeBin === undefined
|
|
110
|
+
? {}
|
|
111
|
+
: { claudeBin: config.synthesis.claudeBin }),
|
|
112
|
+
logsRoot: config.logsRoot,
|
|
113
|
+
models: codeMap.models
|
|
114
|
+
}));
|
|
115
|
+
if (codeBackend === undefined) {
|
|
116
|
+
logger.info("Code map: skipped (noop backend)");
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
codeMapSummary = await runCodeMapIngest({
|
|
120
|
+
backend: codeBackend,
|
|
121
|
+
captureFailures,
|
|
122
|
+
evidenceCaptured,
|
|
123
|
+
indexPath: vaultPaths.indexPath,
|
|
124
|
+
logPath: vaultPaths.wikiLogPath,
|
|
125
|
+
manifestPath: vaultPaths.manifestPath,
|
|
126
|
+
now: dependencies.now,
|
|
127
|
+
rawCodeRoot: vaultPaths.rawCodeRoot,
|
|
128
|
+
...(reportWriter === undefined ? {} : { reportWriter }),
|
|
129
|
+
wikiCodeRoot: vaultPaths.wikiCodeRoot
|
|
130
|
+
});
|
|
131
|
+
logger.info(codeMapSummary.skipped
|
|
132
|
+
? "Code map: skipped (no changes)"
|
|
133
|
+
: `Code map: pages_rendered=${codeMapSummary.pagesRendered.length}, pages_pruned=${codeMapSummary.pagesPruned.length}, failures=${codeMapSummary.failures.length}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
78
136
|
if (vaultPaths && args.consolidate) {
|
|
79
137
|
const backend = resolveConsolidationBackend(config.synthesis.backend, {
|
|
80
138
|
"cli-claude": createCliClaudeConsolidationBackend({
|
|
@@ -96,7 +154,9 @@ export async function runCli(argv, dependencies = defaultDependencies) {
|
|
|
96
154
|
: `Consolidation: pages_created=${consolidationSummary.pagesCreated.length}, pages_updated=${consolidationSummary.pagesUpdated.length}, episodes=${consolidationSummary.episodesConsolidated}, failures=${consolidationSummary.failures.length}`);
|
|
97
155
|
}
|
|
98
156
|
// Both stages write into the same report file, so either path works.
|
|
99
|
-
const reportPath = ingestSummary?.reportPath ??
|
|
157
|
+
const reportPath = ingestSummary?.reportPath ??
|
|
158
|
+
codeMapSummary?.reportPath ??
|
|
159
|
+
consolidationSummary?.reportPath;
|
|
100
160
|
if (reportPath !== undefined) {
|
|
101
161
|
logger.info(`Run report: ${reportPath}`);
|
|
102
162
|
}
|
|
@@ -104,6 +164,7 @@ export async function runCli(argv, dependencies = defaultDependencies) {
|
|
|
104
164
|
const logPath = await logger.flush();
|
|
105
165
|
dependencies.stdout(`Log written to ${logPath}\n`);
|
|
106
166
|
return {
|
|
167
|
+
...(codeMapSummary ? { codeMap: codeMapSummary } : {}),
|
|
107
168
|
...(consolidationSummary ? { consolidation: consolidationSummary } : {}),
|
|
108
169
|
errorCount,
|
|
109
170
|
...(ingestSummary ? { ingest: ingestSummary } : {}),
|
|
@@ -117,26 +178,6 @@ export async function runCli(argv, dependencies = defaultDependencies) {
|
|
|
117
178
|
await lock?.release();
|
|
118
179
|
}
|
|
119
180
|
}
|
|
120
|
-
function resolveVaultPaths(vaultRoot) {
|
|
121
|
-
// Defense-in-depth (design 7.6): the prune scope must stay confined to
|
|
122
|
-
// raw/projects, so a hand-edited config cannot point ingest at a stray tree.
|
|
123
|
-
const normalized = resolve(vaultRoot);
|
|
124
|
-
if (!normalized.endsWith(join(`${sep}raw`, "projects"))) {
|
|
125
|
-
throw new Error(`vaultRoot must end in raw/projects to run ingest, got: ${vaultRoot}`);
|
|
126
|
-
}
|
|
127
|
-
const vault = resolve(normalized, "..", "..");
|
|
128
|
-
return {
|
|
129
|
-
artifactsRoot: join(vault, "raw", "artifacts"),
|
|
130
|
-
episodesRoot: join(vault, "wiki", "episodes"),
|
|
131
|
-
indexPath: join(vault, "wiki", "index.md"),
|
|
132
|
-
lockPath: join(vault, "wiki", ".ingest.lock"),
|
|
133
|
-
manifestPath: join(vault, "wiki", ".ingest-state.json"),
|
|
134
|
-
reportsRoot: join(vault, "wiki", "reports"),
|
|
135
|
-
vaultDir: vault,
|
|
136
|
-
wikiLogPath: join(vault, "wiki", "log.md"),
|
|
137
|
-
wikiRoot: join(vault, "wiki")
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
181
|
async function syncProvider(provider, vaultRoot, generatedAt, logger, dependencies) {
|
|
141
182
|
const destinationRoot = join(vaultRoot, provider.destinationPath);
|
|
142
183
|
try {
|
|
@@ -307,6 +348,15 @@ async function main() {
|
|
|
307
348
|
await runUninstallCommand(argv.slice(1));
|
|
308
349
|
return;
|
|
309
350
|
}
|
|
351
|
+
if (command === "map") {
|
|
352
|
+
const { runMapCommand } = await import("./map-command.js");
|
|
353
|
+
await runMapCommand(argv.slice(1), {
|
|
354
|
+
defaultConfigPath: join(import.meta.dirname, "..", "config", "projects.json"),
|
|
355
|
+
log: (message) => stdout(`${message}\n`),
|
|
356
|
+
now: () => new Date()
|
|
357
|
+
});
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
310
360
|
const syncArgv = command === "sync" ? argv.slice(1) : argv;
|
|
311
361
|
const result = await runCli(syncArgv);
|
|
312
362
|
if (result.errorCount > 0) {
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { slugify } from "./slug.js";
|
|
2
|
+
const DESCEND_SHARE = 0.4;
|
|
3
|
+
const MIN_MODULE_FILES = 3;
|
|
4
|
+
const MISC_NAME = "misc";
|
|
5
|
+
const RESERVED_MODULE_SLUG = "overview";
|
|
6
|
+
// UTF-16 code-unit order — identical to bytewise order for the ASCII paths
|
|
7
|
+
// git emits. Every tie in the algorithm resolves through this comparator
|
|
8
|
+
// (design 5: same tree in, same boundaries out).
|
|
9
|
+
export function compareBytewise(a, b) {
|
|
10
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
11
|
+
}
|
|
12
|
+
export function proposeModules(input) {
|
|
13
|
+
const files = [...input.files].sort(compareBytewise);
|
|
14
|
+
if (files.length === 0) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
if (input.overrides !== undefined && input.overrides.length > 0) {
|
|
18
|
+
// The override replaces the heuristic entirely (design 5) — no small-
|
|
19
|
+
// candidate merging, the user's decomposition is taken as-is.
|
|
20
|
+
return finalize(applyOverrides(files, input.overrides), input.maxModules, false);
|
|
21
|
+
}
|
|
22
|
+
let candidates = splitByDirectory(files, "");
|
|
23
|
+
const total = files.length;
|
|
24
|
+
// Descend one level into every dominant directory, in bytewise order.
|
|
25
|
+
for (const candidate of [...candidates]) {
|
|
26
|
+
if (candidate.kind === "directory" &&
|
|
27
|
+
candidate.files.length / total > DESCEND_SHARE) {
|
|
28
|
+
candidates = [
|
|
29
|
+
...candidates.filter((entry) => entry !== candidate),
|
|
30
|
+
...splitByDirectory(candidate.files, `${candidate.name}/`)
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return finalize(candidates, input.maxModules, true);
|
|
35
|
+
}
|
|
36
|
+
function splitByDirectory(files, prefix) {
|
|
37
|
+
const groups = new Map();
|
|
38
|
+
const loose = [];
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
const rest = file.slice(prefix.length);
|
|
41
|
+
const slashIndex = rest.indexOf("/");
|
|
42
|
+
if (slashIndex === -1) {
|
|
43
|
+
loose.push(file);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const name = prefix + rest.slice(0, slashIndex);
|
|
47
|
+
const group = groups.get(name);
|
|
48
|
+
if (group === undefined) {
|
|
49
|
+
groups.set(name, [file]);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
group.push(file);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const candidates = [...groups.entries()].map(([name, groupFiles]) => ({
|
|
56
|
+
files: groupFiles,
|
|
57
|
+
kind: "directory",
|
|
58
|
+
name
|
|
59
|
+
}));
|
|
60
|
+
if (loose.length > 0) {
|
|
61
|
+
candidates.push({
|
|
62
|
+
files: loose,
|
|
63
|
+
kind: "loose",
|
|
64
|
+
name: prefix === "" ? "root" : prefix.slice(0, -1)
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return candidates.sort((a, b) => compareBytewise(a.name, b.name));
|
|
68
|
+
}
|
|
69
|
+
function applyOverrides(files, overrides) {
|
|
70
|
+
const candidates = overrides.map((override) => ({
|
|
71
|
+
files: [],
|
|
72
|
+
kind: "directory",
|
|
73
|
+
name: override.name
|
|
74
|
+
}));
|
|
75
|
+
const misc = [];
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
const index = overrides.findIndex((override) => override.paths.some((path) => matchesPrefix(file, path)));
|
|
78
|
+
if (index === -1) {
|
|
79
|
+
misc.push(file);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
candidates[index].files.push(file);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (misc.length > 0) {
|
|
86
|
+
candidates.push({ files: misc, kind: "loose", name: MISC_NAME });
|
|
87
|
+
}
|
|
88
|
+
return candidates.filter((candidate) => candidate.files.length > 0);
|
|
89
|
+
}
|
|
90
|
+
// Prefix entries are repo-relative files or directories, POSIX separators —
|
|
91
|
+
// deliberately not globs (design 5).
|
|
92
|
+
function matchesPrefix(file, prefix) {
|
|
93
|
+
const cleaned = prefix.replace(/\/+$/u, "");
|
|
94
|
+
return file === cleaned || file.startsWith(`${cleaned}/`);
|
|
95
|
+
}
|
|
96
|
+
function finalize(input, maxModules, mergeSmall) {
|
|
97
|
+
let candidates = input
|
|
98
|
+
.filter((candidate) => candidate.files.length > 0)
|
|
99
|
+
.map((candidate) => ({
|
|
100
|
+
...candidate,
|
|
101
|
+
files: [...candidate.files].sort(compareBytewise)
|
|
102
|
+
}));
|
|
103
|
+
if (mergeSmall) {
|
|
104
|
+
const small = candidates
|
|
105
|
+
.filter((candidate) => candidate.files.length < MIN_MODULE_FILES && candidate.name !== MISC_NAME)
|
|
106
|
+
.sort(byCountThenName);
|
|
107
|
+
if (small.length > 0) {
|
|
108
|
+
const smallSet = new Set(small);
|
|
109
|
+
candidates = addToMisc(candidates.filter((candidate) => !smallSet.has(candidate)), small.flatMap((candidate) => candidate.files));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Cap: merge ascending (file count, then name) into misc until within
|
|
113
|
+
// bounds. Merging into a fresh misc keeps the count constant on the first
|
|
114
|
+
// iteration, then every iteration strictly shrinks it.
|
|
115
|
+
while (candidates.length > maxModules) {
|
|
116
|
+
const nonMisc = candidates
|
|
117
|
+
.filter((candidate) => candidate.name !== MISC_NAME)
|
|
118
|
+
.sort(byCountThenName);
|
|
119
|
+
const smallest = nonMisc[0];
|
|
120
|
+
if (smallest === undefined) {
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
candidates = addToMisc(candidates.filter((candidate) => candidate !== smallest), smallest.files);
|
|
124
|
+
}
|
|
125
|
+
const ordered = [...candidates].sort((a, b) => compareBytewise(a.name, b.name));
|
|
126
|
+
const usedSlugs = new Set([RESERVED_MODULE_SLUG]);
|
|
127
|
+
const modules = ordered.map((candidate) => {
|
|
128
|
+
const base = slugify(candidate.name.replaceAll("/", "-")) || MISC_NAME;
|
|
129
|
+
let slug = base;
|
|
130
|
+
let suffix = 2;
|
|
131
|
+
while (usedSlugs.has(slug)) {
|
|
132
|
+
slug = `${base}-${suffix}`;
|
|
133
|
+
suffix += 1;
|
|
134
|
+
}
|
|
135
|
+
usedSlugs.add(slug);
|
|
136
|
+
return { files: candidate.files, name: candidate.name, slug };
|
|
137
|
+
});
|
|
138
|
+
return modules.sort((a, b) => compareBytewise(a.slug, b.slug));
|
|
139
|
+
}
|
|
140
|
+
function addToMisc(candidates, files) {
|
|
141
|
+
const misc = candidates.find((candidate) => candidate.name === MISC_NAME);
|
|
142
|
+
if (misc === undefined) {
|
|
143
|
+
return [...candidates, { files: [...files].sort(compareBytewise), kind: "loose", name: MISC_NAME }];
|
|
144
|
+
}
|
|
145
|
+
return candidates.map((candidate) => candidate === misc
|
|
146
|
+
? {
|
|
147
|
+
...candidate,
|
|
148
|
+
files: [...candidate.files, ...files].sort(compareBytewise)
|
|
149
|
+
}
|
|
150
|
+
: candidate);
|
|
151
|
+
}
|
|
152
|
+
function byCountThenName(a, b) {
|
|
153
|
+
return a.files.length - b.files.length || compareBytewise(a.name, b.name);
|
|
154
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readdir, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { proposeModules } from "./code-boundaries.js";
|
|
4
|
+
import { parseCodeEvidence, renderCodeEvidence } from "./code-frontmatter.js";
|
|
5
|
+
import { buildModuleDigestBody, buildSkeletonBody, DIGEST_BUDGET_BYTES } from "./code-evidence.js";
|
|
6
|
+
import { scanRepository } from "./code-scan.js";
|
|
7
|
+
import { ensureDirectory, writeFileAtomic } from "./filesystem.js";
|
|
8
|
+
export function defaultCaptureDependencies(repoPath) {
|
|
9
|
+
return {
|
|
10
|
+
readText: async (repoRelativePath) => {
|
|
11
|
+
try {
|
|
12
|
+
return await readFile(join(repoPath, repoRelativePath), "utf8");
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export async function captureRepo(input, dependencies) {
|
|
21
|
+
const { repo } = input;
|
|
22
|
+
// READ PHASE — every git command and file read completes before the first
|
|
23
|
+
// byte is written; a throw anywhere here leaves raw/code untouched
|
|
24
|
+
// (design 6.1, codex F2).
|
|
25
|
+
const scan = await (dependencies.scan === undefined
|
|
26
|
+
? scanRepository({ repoPath: repo.path, ...(repo.scope === undefined ? {} : { scope: repo.scope }) })
|
|
27
|
+
: scanRepository({ repoPath: repo.path, ...(repo.scope === undefined ? {} : { scope: repo.scope }) }, dependencies.scan));
|
|
28
|
+
const scopePrefix = repo.scope === undefined ? "" : `${repo.scope}/`;
|
|
29
|
+
const scopedPaths = scan.files.map((file) => file.path.startsWith(scopePrefix) ? file.path.slice(scopePrefix.length) : file.path);
|
|
30
|
+
const scopedModules = proposeModules({
|
|
31
|
+
files: scopedPaths,
|
|
32
|
+
maxModules: repo.maxModules ?? input.globalMaxModules,
|
|
33
|
+
...(repo.modules === undefined ? {} : { overrides: repo.modules })
|
|
34
|
+
});
|
|
35
|
+
// Boundary names/slugs are scope-relative; evidence paths stay repo-relative.
|
|
36
|
+
const modules = scopedModules.map((module) => ({
|
|
37
|
+
...module,
|
|
38
|
+
files: module.files.map((file) => `${scopePrefix}${file}`)
|
|
39
|
+
}));
|
|
40
|
+
const projectDir = join(input.rawCodeRoot, repo.project);
|
|
41
|
+
const modulesDir = join(projectDir, "modules");
|
|
42
|
+
const sizeByPath = new Map(scan.files.map((file) => [file.path, file.size]));
|
|
43
|
+
const planned = [
|
|
44
|
+
{
|
|
45
|
+
body: buildSkeletonBody({
|
|
46
|
+
modules,
|
|
47
|
+
scan,
|
|
48
|
+
...(repo.scope === undefined ? {} : { scope: repo.scope })
|
|
49
|
+
}),
|
|
50
|
+
path: join(projectDir, "skeleton.md")
|
|
51
|
+
}
|
|
52
|
+
];
|
|
53
|
+
for (const module of modules) {
|
|
54
|
+
const digest = await buildModuleDigestBody({
|
|
55
|
+
activity: scan.activity,
|
|
56
|
+
budgetBytes: DIGEST_BUDGET_BYTES,
|
|
57
|
+
files: module.files.map((path) => ({ path, size: sizeByPath.get(path) ?? 0 })),
|
|
58
|
+
readText: dependencies.readText
|
|
59
|
+
});
|
|
60
|
+
planned.push({
|
|
61
|
+
body: digest.body,
|
|
62
|
+
...(digest.coverage === undefined ? {} : { coverage: digest.coverage }),
|
|
63
|
+
module,
|
|
64
|
+
path: join(modulesDir, `${module.slug}.md`)
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
// WRITE PHASE — mirror with churn guard.
|
|
68
|
+
await ensureDirectory(modulesDir);
|
|
69
|
+
let evidenceWritten = 0;
|
|
70
|
+
let evidenceUnchanged = 0;
|
|
71
|
+
for (const evidence of planned) {
|
|
72
|
+
const frontmatter = {
|
|
73
|
+
captured: input.date,
|
|
74
|
+
commit: scan.commit,
|
|
75
|
+
...(evidence.coverage === undefined ? {} : { coverage: evidence.coverage }),
|
|
76
|
+
...(evidence.module === undefined
|
|
77
|
+
? {}
|
|
78
|
+
: { module: evidence.module.slug, paths: [...evidence.module.files] }),
|
|
79
|
+
project: repo.project,
|
|
80
|
+
repo: repo.path
|
|
81
|
+
};
|
|
82
|
+
if (await isBodyUnchanged(evidence.path, evidence.body)) {
|
|
83
|
+
evidenceUnchanged += 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
await writeFileAtomic(evidence.path, renderCodeEvidence(frontmatter, evidence.body));
|
|
87
|
+
evidenceWritten += 1;
|
|
88
|
+
}
|
|
89
|
+
// Prune digests for modules that no longer exist — mirror semantics,
|
|
90
|
+
// confined to this project's modules directory (design 4).
|
|
91
|
+
const desired = new Set(modules.map((module) => `${module.slug}.md`));
|
|
92
|
+
let digestsDeleted = 0;
|
|
93
|
+
for (const entry of (await readdir(modulesDir)).sort()) {
|
|
94
|
+
if (entry.endsWith(".md") && !desired.has(entry)) {
|
|
95
|
+
await rm(join(modulesDir, entry), { force: true });
|
|
96
|
+
digestsDeleted += 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
digestsDeleted,
|
|
101
|
+
evidenceUnchanged,
|
|
102
|
+
evidenceWritten,
|
|
103
|
+
moduleCount: modules.length,
|
|
104
|
+
project: repo.project
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
async function isBodyUnchanged(path, nextBody) {
|
|
108
|
+
let existing;
|
|
109
|
+
try {
|
|
110
|
+
existing = await readFile(path, "utf8");
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
return parseCodeEvidence(existing).body === nextBody.trim();
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Unparseable evidence is replaced wholesale.
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|