pi-jev-wiki 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/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/package.json +63 -0
- package/skills/llm-wiki/SKILL.md +143 -0
- package/skills/llm-wiki/references/decision.md +32 -0
- package/skills/llm-wiki/references/flow.md +32 -0
- package/skills/llm-wiki/references/gotcha.md +16 -0
- package/skills/llm-wiki/references/invariant.md +20 -0
- package/skills/llm-wiki/references/module.md +30 -0
- package/src/config.ts +168 -0
- package/src/doctor.ts +171 -0
- package/src/extension.ts +1674 -0
- package/src/git.ts +69 -0
- package/src/grounding.ts +46 -0
- package/src/jev.ts +207 -0
- package/src/ledger.ts +85 -0
- package/src/lint.ts +407 -0
- package/src/metrics.ts +61 -0
- package/src/pipeline/adjudicate.ts +416 -0
- package/src/pipeline/capture.ts +109 -0
- package/src/pipeline/extract.ts +150 -0
- package/src/pipeline/write.ts +263 -0
- package/src/provenance.ts +137 -0
- package/src/redact.ts +46 -0
- package/src/review.ts +146 -0
- package/src/sessionlog.ts +107 -0
- package/src/structure.ts +215 -0
- package/src/sync.ts +295 -0
- package/src/wiki/frontmatter.ts +164 -0
- package/src/wiki/layout.ts +126 -0
- package/src/wiki/links.ts +17 -0
- package/src/wiki/lock.ts +86 -0
- package/src/wiki/search.ts +263 -0
- package/src/wiki/toc.ts +198 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session log: every captured candidate with its verdict, so recurrence can be
|
|
3
|
+
* counted in code. Recurring rejected candidates are promoted to the review queue.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { appendFile, readFile } from "node:fs/promises";
|
|
7
|
+
import type { WikiLayout } from "./wiki/layout.ts";
|
|
8
|
+
import { enqueueReview } from "./review.ts";
|
|
9
|
+
|
|
10
|
+
export interface SessionLogEntry {
|
|
11
|
+
ts: string;
|
|
12
|
+
text: string;
|
|
13
|
+
kind?: string;
|
|
14
|
+
source: "tool" | "compact" | "settled";
|
|
15
|
+
action: string;
|
|
16
|
+
reason?: string;
|
|
17
|
+
grounded?: number;
|
|
18
|
+
derivable?: number;
|
|
19
|
+
importance?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function appendSessionLog(layout: WikiLayout, entry: Omit<SessionLogEntry, "ts">): Promise<void> {
|
|
23
|
+
try {
|
|
24
|
+
await appendFile(layout.sessionLogPath, `${JSON.stringify({ ts: new Date().toISOString(), ...entry })}\n`, "utf8");
|
|
25
|
+
} catch {
|
|
26
|
+
/* session log is best-effort */
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function readSessionLog(layout: WikiLayout): Promise<SessionLogEntry[]> {
|
|
31
|
+
if (!existsSync(layout.sessionLogPath)) return [];
|
|
32
|
+
const text = await readFile(layout.sessionLogPath, "utf8");
|
|
33
|
+
return text
|
|
34
|
+
.split(/\r?\n/)
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.map((line) => {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(line) as SessionLogEntry;
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
.filter((entry): entry is SessionLogEntry => Boolean(entry));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function tokens(text: string): Set<string> {
|
|
47
|
+
return new Set(
|
|
48
|
+
text
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.split(/[^a-z0-9_]+/)
|
|
51
|
+
.filter((token) => token.length > 3),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function similarity(a: Set<string>, b: Set<string>): number {
|
|
56
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
57
|
+
let shared = 0;
|
|
58
|
+
for (const token of a) if (b.has(token)) shared++;
|
|
59
|
+
return shared / Math.min(a.size, b.size);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** How many prior log entries describe substantially the same candidate. */
|
|
63
|
+
export function countRecurrence(entries: SessionLogEntry[], text: string, threshold = 0.7): number {
|
|
64
|
+
const needle = tokens(text);
|
|
65
|
+
return entries.filter((entry) => similarity(tokens(entry.text), needle) >= threshold).length - 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Promote candidates that keep recurring after rejection. A fact that appears in
|
|
70
|
+
* three sessions but never gets filed is usually evidence of a documentation gap
|
|
71
|
+
* or a claim that needs a stronger artifact.
|
|
72
|
+
*/
|
|
73
|
+
export async function promoteRecurring(layout: WikiLayout, minimum = 3): Promise<number> {
|
|
74
|
+
const entries = await readSessionLog(layout);
|
|
75
|
+
const groups: Array<{ text: string; entries: SessionLogEntry[] }> = [];
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
const group = groups.find((candidate) => similarity(tokens(candidate.text), tokens(entry.text)) >= 0.7);
|
|
78
|
+
if (group) group.entries.push(entry);
|
|
79
|
+
else groups.push({ text: entry.text, entries: [entry] });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let promoted = 0;
|
|
83
|
+
for (const group of groups) {
|
|
84
|
+
if (group.entries.length < minimum) continue;
|
|
85
|
+
const accepted = group.entries.some((entry) => ["file", "reinforce", "file_user_stated"].includes(entry.action));
|
|
86
|
+
if (accepted) continue;
|
|
87
|
+
const latest = group.entries[group.entries.length - 1];
|
|
88
|
+
const alreadyQueued = group.entries.some((entry) => entry.action === "promoted");
|
|
89
|
+
if (alreadyQueued) continue;
|
|
90
|
+
await enqueueReview(layout, {
|
|
91
|
+
kind: "claim_review",
|
|
92
|
+
claimText: latest.text,
|
|
93
|
+
criticality: 0.5,
|
|
94
|
+
reason: `recurred ${group.entries.length} times without filing; needs a stronger artifact or a decision`,
|
|
95
|
+
verdicts: { recurrences: group.entries.length, lastAction: latest.action },
|
|
96
|
+
});
|
|
97
|
+
await appendSessionLog(layout, {
|
|
98
|
+
text: latest.text,
|
|
99
|
+
kind: latest.kind,
|
|
100
|
+
source: latest.source,
|
|
101
|
+
action: "promoted",
|
|
102
|
+
reason: `recurred ${group.entries.length} times`,
|
|
103
|
+
});
|
|
104
|
+
promoted++;
|
|
105
|
+
}
|
|
106
|
+
return promoted;
|
|
107
|
+
}
|
package/src/structure.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structure scan: a deterministic map of modules, dependency edges, entry points,
|
|
3
|
+
* and test surface — plus coverage checks against the wiki's architecture pages.
|
|
4
|
+
* No model calls: this is the reproducible substrate an agent can reason over.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
8
|
+
import { dirname, extname, join, relative, resolve } from "node:path";
|
|
9
|
+
import { listMarkdownFiles, readPage, type WikiLayout } from "./wiki/layout.ts";
|
|
10
|
+
import { readIndex } from "./wiki/toc.ts";
|
|
11
|
+
|
|
12
|
+
export interface ModuleInfo {
|
|
13
|
+
dir: string;
|
|
14
|
+
files: number;
|
|
15
|
+
imports: string[];
|
|
16
|
+
importedBy: string[];
|
|
17
|
+
entry: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface StructureReport {
|
|
21
|
+
manifests: string[];
|
|
22
|
+
entryPoints: string[];
|
|
23
|
+
totalFiles: number;
|
|
24
|
+
testFiles: number;
|
|
25
|
+
modules: ModuleInfo[];
|
|
26
|
+
wiki: {
|
|
27
|
+
architecturePages: number;
|
|
28
|
+
undocumentedModules: string[];
|
|
29
|
+
staleFileReferences: string[];
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const CODE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".rs", ".java", ".rb"]);
|
|
34
|
+
const SKIP_DIRS = new Set([
|
|
35
|
+
"node_modules",
|
|
36
|
+
".git",
|
|
37
|
+
"dist",
|
|
38
|
+
"build",
|
|
39
|
+
"out",
|
|
40
|
+
".next",
|
|
41
|
+
"coverage",
|
|
42
|
+
"vendor",
|
|
43
|
+
"target",
|
|
44
|
+
".venv",
|
|
45
|
+
"venv",
|
|
46
|
+
"__pycache__",
|
|
47
|
+
".jev-wiki",
|
|
48
|
+
".pi",
|
|
49
|
+
]);
|
|
50
|
+
const ENTRY_CANDIDATES = ["src/index.ts", "src/main.ts", "src/extension.ts", "src/index.js", "index.js", "main.py", "src/main.rs", "src/main.go"];
|
|
51
|
+
const MAX_FILES = 6000;
|
|
52
|
+
|
|
53
|
+
async function walk(root: string, dir: string, files: string[], depth = 0): Promise<void> {
|
|
54
|
+
if (files.length >= MAX_FILES || depth > 7) return;
|
|
55
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
const full = join(dir, entry.name);
|
|
58
|
+
if (entry.isDirectory()) {
|
|
59
|
+
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
60
|
+
await walk(root, full, files, depth + 1);
|
|
61
|
+
} else if (entry.isFile() && CODE_EXTENSIONS.has(extname(entry.name))) {
|
|
62
|
+
files.push(full);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function moduleOf(root: string, file: string): string {
|
|
68
|
+
const rel = relative(root, file).split("\\").join("/");
|
|
69
|
+
const parts = rel.split("/");
|
|
70
|
+
if (parts.length === 1) return "(root)";
|
|
71
|
+
if (["src", "packages", "apps", "lib", "modules"].includes(parts[0]) && parts.length > 2) return `${parts[0]}/${parts[1]}`;
|
|
72
|
+
return parts[0];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function importSpecifiers(content: string, extension: string): string[] {
|
|
76
|
+
const specs: string[] = [];
|
|
77
|
+
if (extension === ".py") {
|
|
78
|
+
for (const match of content.matchAll(/^\s*(?:from|import)\s+([.\w/]+)/gm)) specs.push(match[1]);
|
|
79
|
+
return specs;
|
|
80
|
+
}
|
|
81
|
+
for (const match of content.matchAll(/from\s+["']([^"']+)["']|import\s+["']([^"']+)["']|require\(\s*["']([^"']+)["']\s*\)/g)) {
|
|
82
|
+
const spec = match[1] ?? match[2] ?? match[3];
|
|
83
|
+
if (spec) specs.push(spec);
|
|
84
|
+
}
|
|
85
|
+
return specs;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function findManifests(root: string): Promise<string[]> {
|
|
89
|
+
const candidates = ["package.json", "pyproject.toml", "go.mod", "Cargo.toml", "requirements.txt", "pom.xml", "build.gradle"];
|
|
90
|
+
const found: string[] = [];
|
|
91
|
+
for (const candidate of candidates) if (existsSync(join(root, candidate))) found.push(candidate);
|
|
92
|
+
if (existsSync(join(root, "packages"))) {
|
|
93
|
+
const entries = await readdir(join(root, "packages"), { withFileTypes: true }).catch(() => []);
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (entry.isDirectory() && existsSync(join(root, "packages", entry.name, "package.json"))) {
|
|
96
|
+
found.push(`packages/${entry.name}/package.json`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return found;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function scanStructure(root: string, layout?: WikiLayout): Promise<StructureReport> {
|
|
104
|
+
const files: string[] = [];
|
|
105
|
+
await walk(root, root, files);
|
|
106
|
+
|
|
107
|
+
const moduleMap = new Map<string, { files: number; imports: Set<string>; importedBy: Set<string>; entry: boolean }>();
|
|
108
|
+
const entryPoints: string[] = [];
|
|
109
|
+
let testFiles = 0;
|
|
110
|
+
|
|
111
|
+
for (const file of files) {
|
|
112
|
+
const rel = relative(root, file).split("\\").join("/");
|
|
113
|
+
const moduleName = moduleOf(root, file);
|
|
114
|
+
const record = moduleMap.get(moduleName) ?? { files: 0, imports: new Set<string>(), importedBy: new Set<string>(), entry: false };
|
|
115
|
+
record.files++;
|
|
116
|
+
if (ENTRY_CANDIDATES.includes(rel) || /^src\/(cli|server|index)\.(ts|js|py)$/.test(rel)) {
|
|
117
|
+
record.entry = true;
|
|
118
|
+
entryPoints.push(rel);
|
|
119
|
+
}
|
|
120
|
+
if (/(^|[._-])(test|spec)\./i.test(rel) || rel.includes("/tests/") || rel.startsWith("tests/")) testFiles++;
|
|
121
|
+
moduleMap.set(moduleName, record);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// import edges (second pass, so all modules exist)
|
|
125
|
+
for (const file of files) {
|
|
126
|
+
const content = await readFile(file, "utf8").catch(() => "");
|
|
127
|
+
if (!content) continue;
|
|
128
|
+
if (content.length > 200_000) continue;
|
|
129
|
+
const from = moduleOf(root, file);
|
|
130
|
+
for (const spec of importSpecifiers(content, extname(file))) {
|
|
131
|
+
if (!spec.startsWith(".")) continue;
|
|
132
|
+
const target = resolve(dirname(file), spec);
|
|
133
|
+
const targetModule = moduleOf(root, target);
|
|
134
|
+
if (targetModule === from) continue;
|
|
135
|
+
const fromRecord = moduleMap.get(from);
|
|
136
|
+
const toRecord = moduleMap.get(targetModule);
|
|
137
|
+
if (fromRecord && toRecord) {
|
|
138
|
+
fromRecord.imports.add(targetModule);
|
|
139
|
+
toRecord.importedBy.add(from);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const manifests = await findManifests(root);
|
|
145
|
+
const modules: ModuleInfo[] = [...moduleMap.entries()]
|
|
146
|
+
.map(([dir, record]) => ({
|
|
147
|
+
dir,
|
|
148
|
+
files: record.files,
|
|
149
|
+
imports: [...record.imports].sort(),
|
|
150
|
+
importedBy: [...record.importedBy].sort(),
|
|
151
|
+
entry: record.entry,
|
|
152
|
+
}))
|
|
153
|
+
.sort((a, b) => b.files - a.files);
|
|
154
|
+
|
|
155
|
+
const wiki = { architecturePages: 0, undocumentedModules: [] as string[], staleFileReferences: [] as string[] };
|
|
156
|
+
if (layout && existsSync(layout.wikiDir)) {
|
|
157
|
+
const entries = await readIndex(layout);
|
|
158
|
+
const architecture = entries.filter((entry) => entry.type.startsWith("architecture/"));
|
|
159
|
+
wiki.architecturePages = architecture.length;
|
|
160
|
+
const documentedText = architecture.map((entry) => `${entry.title} ${entry.summary} ${entry.path}`.toLowerCase());
|
|
161
|
+
const documentedFiles: string[] = [];
|
|
162
|
+
for (const file of await listMarkdownFiles(layout.wikiDir)) {
|
|
163
|
+
if (file.endsWith("index.md") || file.endsWith("log.md")) continue;
|
|
164
|
+
const page = await readPage(file).catch(() => undefined);
|
|
165
|
+
if (!page) continue;
|
|
166
|
+
const referenced = Array.isArray(page.data.files) ? page.data.files.map(String) : [];
|
|
167
|
+
const pageType = String(page.data.type ?? "");
|
|
168
|
+
if (pageType.startsWith("architecture/")) documentedFiles.push(...referenced);
|
|
169
|
+
for (const ref of referenced) {
|
|
170
|
+
if (!existsSync(resolve(root, ref))) {
|
|
171
|
+
wiki.staleFileReferences.push(`${relative(layout.wikiDir, file).split("\\").join("/")} → ${ref}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const module of modules) {
|
|
176
|
+
if (module.dir === "(root)") continue;
|
|
177
|
+
const name = module.dir.split("/").pop()!.toLowerCase();
|
|
178
|
+
const prefix = `${module.dir}/`;
|
|
179
|
+
const documented =
|
|
180
|
+
documentedText.some((text) => text.includes(name)) || documentedFiles.some((ref) => ref === module.dir || ref.startsWith(prefix));
|
|
181
|
+
if (!documented) wiki.undocumentedModules.push(module.dir);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { manifests, entryPoints, totalFiles: files.length, testFiles, modules, wiki };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Compact markdown rendering for the wiki_structure tool. */
|
|
189
|
+
export function renderStructure(report: StructureReport): string {
|
|
190
|
+
const lines = [
|
|
191
|
+
"# Repository structure",
|
|
192
|
+
"",
|
|
193
|
+
`Files scanned: ${report.totalFiles} · test files: ${report.testFiles}`,
|
|
194
|
+
`Manifests: ${report.manifests.join(", ") || "(none)"}`,
|
|
195
|
+
`Entry points: ${report.entryPoints.join(", ") || "(none detected)"}`,
|
|
196
|
+
"",
|
|
197
|
+
"| Module | Files | Imports | Imported by | Entry |",
|
|
198
|
+
"|--------|-------|---------|-------------|-------|",
|
|
199
|
+
];
|
|
200
|
+
for (const module of report.modules.slice(0, 40)) {
|
|
201
|
+
lines.push(
|
|
202
|
+
`| \`${module.dir}\` | ${module.files} | ${module.imports.slice(0, 5).join(", ") || "—"} | ${module.importedBy.slice(0, 5).join(", ") || "—"} | ${module.entry ? "yes" : ""} |`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (report.wiki.architecturePages > 0 || report.wiki.undocumentedModules.length > 0) {
|
|
206
|
+
lines.push(
|
|
207
|
+
"",
|
|
208
|
+
`Architecture pages: ${report.wiki.architecturePages}`,
|
|
209
|
+
`Modules without an architecture page: ${report.wiki.undocumentedModules.join(", ") || "none"}`,
|
|
210
|
+
`Stale file references in wiki pages: ${report.wiki.staleFileReferences.length}`,
|
|
211
|
+
...report.wiki.staleFileReferences.slice(0, 10).map((ref) => `- ${ref}`),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return lines.join("\n");
|
|
215
|
+
}
|
package/src/sync.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Change-driven invalidation: diff since the last synced commit, ask Jev which
|
|
3
|
+
* file-linked claims are affected, and update their status (recheck/supersede/dispute).
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { readFile } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import type { ResolvedConfig } from "./config.ts";
|
|
9
|
+
import { changedFiles, diffForFiles, fileMatches, headCommit, isGitRepo } from "./git.ts";
|
|
10
|
+
import { choice, isChoice, isNoul, noul, type JevClient } from "./jev.ts";
|
|
11
|
+
import { appendLedger } from "./ledger.ts";
|
|
12
|
+
import { enqueueReview } from "./review.ts";
|
|
13
|
+
import { listMarkdownFiles, readPage, todayISO, writePage, writeTextAtomic, type WikiLayout } from "./wiki/layout.ts";
|
|
14
|
+
import { appendLog } from "./wiki/toc.ts";
|
|
15
|
+
|
|
16
|
+
export interface SyncState {
|
|
17
|
+
lastSyncCommit?: string;
|
|
18
|
+
lastSyncAt?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MappedClaim {
|
|
22
|
+
page: string;
|
|
23
|
+
pagePath: string;
|
|
24
|
+
claimId?: string;
|
|
25
|
+
text: string;
|
|
26
|
+
status: string;
|
|
27
|
+
files: string[];
|
|
28
|
+
claim: Record<string, unknown>;
|
|
29
|
+
pageData: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SyncImpact {
|
|
33
|
+
page: string;
|
|
34
|
+
claimId?: string;
|
|
35
|
+
text: string;
|
|
36
|
+
impact: string;
|
|
37
|
+
confidence: number;
|
|
38
|
+
stillTrue: number;
|
|
39
|
+
changedFiles: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SyncReport {
|
|
43
|
+
repo: boolean;
|
|
44
|
+
baseline?: string;
|
|
45
|
+
head?: string;
|
|
46
|
+
baselineInitialized: boolean;
|
|
47
|
+
changedFiles: string[];
|
|
48
|
+
matchedClaims: number;
|
|
49
|
+
impacts: SyncImpact[];
|
|
50
|
+
applied: string[];
|
|
51
|
+
dryRun: boolean;
|
|
52
|
+
usage: { input_tokens: number; output_tokens: number };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SyncOptions {
|
|
56
|
+
baseline?: string;
|
|
57
|
+
dryRun?: boolean;
|
|
58
|
+
maxClaims?: number;
|
|
59
|
+
maxDiffChars?: number;
|
|
60
|
+
signal?: AbortSignal;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function statePath(layout: WikiLayout): string {
|
|
64
|
+
return join(layout.stateDir, "state.json");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function readSyncState(layout: WikiLayout): Promise<SyncState> {
|
|
68
|
+
if (!existsSync(statePath(layout))) return {};
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(await readFile(statePath(layout), "utf8")) as SyncState;
|
|
71
|
+
} catch {
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function writeSyncState(layout: WikiLayout, state: SyncState): Promise<void> {
|
|
77
|
+
await writeTextAtomic(statePath(layout), `${JSON.stringify(state, null, "\t")}\n`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function collectMappedClaims(layout: WikiLayout): Promise<MappedClaim[]> {
|
|
81
|
+
const files = (await listMarkdownFiles(layout.wikiDir)).filter(
|
|
82
|
+
(file) => !file.endsWith("index.md") && !file.endsWith("log.md"),
|
|
83
|
+
);
|
|
84
|
+
const mapped: MappedClaim[] = [];
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
try {
|
|
87
|
+
const page = await readPage(file);
|
|
88
|
+
const pageFiles = normalizeFiles(page.data.files);
|
|
89
|
+
const claims = Array.isArray(page.data.claims) ? (page.data.claims as Record<string, unknown>[]) : [];
|
|
90
|
+
const pagePath = file; // listMarkdownFiles returns absolute paths
|
|
91
|
+
const rel = file.split("\\").join("/").replace(`${layout.wikiDir.split("\\").join("/")}/`, "");
|
|
92
|
+
for (const claim of claims) {
|
|
93
|
+
if (typeof claim.text !== "string") continue;
|
|
94
|
+
const claimFiles = normalizeFiles(claim.files);
|
|
95
|
+
const relevant = claimFiles.length > 0 ? claimFiles : pageFiles;
|
|
96
|
+
if (relevant.length === 0) continue;
|
|
97
|
+
mapped.push({
|
|
98
|
+
page: rel,
|
|
99
|
+
pagePath,
|
|
100
|
+
claimId: typeof claim.id === "string" ? claim.id : undefined,
|
|
101
|
+
text: claim.text,
|
|
102
|
+
status: typeof claim.status === "string" ? claim.status : "unknown",
|
|
103
|
+
files: relevant,
|
|
104
|
+
claim,
|
|
105
|
+
pageData: page.data,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
/* skip unreadable */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return mapped;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeFiles(value: unknown): string[] {
|
|
116
|
+
if (Array.isArray(value)) return value.map(String).filter(Boolean);
|
|
117
|
+
if (typeof value === "string" && value) return [value];
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function syncWiki(
|
|
122
|
+
layout: WikiLayout,
|
|
123
|
+
client: JevClient,
|
|
124
|
+
config: ResolvedConfig,
|
|
125
|
+
cwd: string,
|
|
126
|
+
options?: SyncOptions,
|
|
127
|
+
): Promise<SyncReport> {
|
|
128
|
+
const usage = { input_tokens: 0, output_tokens: 0 };
|
|
129
|
+
const empty: SyncReport = {
|
|
130
|
+
repo: false,
|
|
131
|
+
baselineInitialized: false,
|
|
132
|
+
changedFiles: [],
|
|
133
|
+
matchedClaims: 0,
|
|
134
|
+
impacts: [],
|
|
135
|
+
applied: [],
|
|
136
|
+
dryRun: Boolean(options?.dryRun),
|
|
137
|
+
usage,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
if (!(await isGitRepo(cwd))) return empty;
|
|
141
|
+
const head = await headCommit(cwd);
|
|
142
|
+
if (!head) return { ...empty, repo: true };
|
|
143
|
+
|
|
144
|
+
const state = await readSyncState(layout);
|
|
145
|
+
const baseline = options?.baseline ?? state.lastSyncCommit;
|
|
146
|
+
if (!baseline) {
|
|
147
|
+
if (!options?.dryRun) await writeSyncState(layout, { lastSyncCommit: head, lastSyncAt: new Date().toISOString() });
|
|
148
|
+
return { ...empty, repo: true, head, baselineInitialized: true };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const changed = await changedFiles(cwd, baseline, "HEAD");
|
|
152
|
+
if (changed.length === 0) {
|
|
153
|
+
if (!options?.dryRun) await writeSyncState(layout, { lastSyncCommit: head, lastSyncAt: new Date().toISOString() });
|
|
154
|
+
return { ...empty, repo: true, baseline, head, baselineInitialized: false };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const mapped = await collectMappedClaims(layout);
|
|
158
|
+
const affected = mapped
|
|
159
|
+
.filter((claim) => claim.files.some((pattern) => changed.some((path) => fileMatches(path, pattern))))
|
|
160
|
+
.slice(0, options?.maxClaims ?? 40);
|
|
161
|
+
|
|
162
|
+
const impacts: SyncImpact[] = [];
|
|
163
|
+
const applied: string[] = [];
|
|
164
|
+
const touchedPages = new Map<string, Record<string, unknown>>();
|
|
165
|
+
|
|
166
|
+
for (const claim of affected) {
|
|
167
|
+
const relevantChanges = changed.filter((path) => claim.files.some((pattern) => fileMatches(path, pattern)));
|
|
168
|
+
const diff = await diffForFiles(cwd, baseline, "HEAD", relevantChanges, options?.maxDiffChars ?? 6000);
|
|
169
|
+
const response = await client.systemOne(
|
|
170
|
+
{
|
|
171
|
+
page: { title: claim.pageData.title ?? claim.page, summary: claim.pageData.summary ?? null },
|
|
172
|
+
claim: { id: claim.claimId ?? null, text: claim.text, status: claim.status, files: claim.files },
|
|
173
|
+
code_change: { files: relevantChanges, diff },
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
impact: choice("Given this code change, how should the wiki claim be updated?", {
|
|
177
|
+
no_impact: "The change does not affect what the claim says",
|
|
178
|
+
needs_recheck: "The change may invalidate the claim; it should be re-verified against current code",
|
|
179
|
+
supersede: "The claim is now outdated and should be marked superseded",
|
|
180
|
+
contradict: "The change directly contradicts the claim",
|
|
181
|
+
}),
|
|
182
|
+
still_true: noul("The claim is still true of the current code after this change."),
|
|
183
|
+
},
|
|
184
|
+
{ signal: options?.signal },
|
|
185
|
+
);
|
|
186
|
+
usage.input_tokens += response.usage.input_tokens;
|
|
187
|
+
usage.output_tokens += response.usage.output_tokens;
|
|
188
|
+
|
|
189
|
+
const impactAnswer = response.answers.impact;
|
|
190
|
+
const impact = isChoice(impactAnswer) ? impactAnswer.choice : "needs_recheck";
|
|
191
|
+
const confidence = isChoice(impactAnswer) ? impactAnswer.confidence : 0;
|
|
192
|
+
const stillTrue = isNoul(response.answers.still_true) ? response.answers.still_true.noul : 0.5;
|
|
193
|
+
const record: SyncImpact = {
|
|
194
|
+
page: claim.page,
|
|
195
|
+
claimId: claim.claimId,
|
|
196
|
+
text: claim.text,
|
|
197
|
+
impact,
|
|
198
|
+
confidence,
|
|
199
|
+
stillTrue,
|
|
200
|
+
changedFiles: relevantChanges,
|
|
201
|
+
};
|
|
202
|
+
impacts.push(record);
|
|
203
|
+
|
|
204
|
+
await appendLedger(layout, {
|
|
205
|
+
actor: "jev",
|
|
206
|
+
op: "sync.impact",
|
|
207
|
+
subject: `${claim.page}#${claim.claimId ?? claim.text.slice(0, 40)}`,
|
|
208
|
+
verdict: { impact, confidence, stillTrue },
|
|
209
|
+
action: impact,
|
|
210
|
+
usage: { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens },
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (options?.dryRun || impact === "no_impact") {
|
|
214
|
+
if (!options?.dryRun) {
|
|
215
|
+
claim.claim.last_checked = todayISO();
|
|
216
|
+
touchedPages.set(claim.pagePath, claim.pageData);
|
|
217
|
+
}
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const criticality = impact === "contradict" ? Math.max(0.85, 1 - stillTrue) : Math.max(0.4, 1 - stillTrue);
|
|
222
|
+
if (impact === "needs_recheck") {
|
|
223
|
+
claim.claim.status = "needs_recheck";
|
|
224
|
+
await enqueueReview(layout, {
|
|
225
|
+
kind: "needs_recheck",
|
|
226
|
+
claimText: claim.text,
|
|
227
|
+
page: claim.page,
|
|
228
|
+
claimId: claim.claimId,
|
|
229
|
+
criticality,
|
|
230
|
+
reason: `code changed: ${relevantChanges.slice(0, 4).join(", ")}`,
|
|
231
|
+
verdicts: { impact, confidence, stillTrue },
|
|
232
|
+
});
|
|
233
|
+
} else if (impact === "supersede") {
|
|
234
|
+
claim.claim.status = "superseded";
|
|
235
|
+
claim.claim.superseded_by = `commit ${head.slice(0, 7)}`;
|
|
236
|
+
await enqueueReview(layout, {
|
|
237
|
+
kind: "needs_recheck",
|
|
238
|
+
claimText: claim.text,
|
|
239
|
+
page: claim.page,
|
|
240
|
+
claimId: claim.claimId,
|
|
241
|
+
criticality,
|
|
242
|
+
reason: `diff supersedes claim: ${relevantChanges.slice(0, 4).join(", ")}`,
|
|
243
|
+
verdicts: { impact, confidence, stillTrue },
|
|
244
|
+
});
|
|
245
|
+
} else if (impact === "contradict") {
|
|
246
|
+
claim.claim.status = "disputed";
|
|
247
|
+
await enqueueReview(layout, {
|
|
248
|
+
kind: "dispute",
|
|
249
|
+
claimText: claim.text,
|
|
250
|
+
page: claim.page,
|
|
251
|
+
claimId: claim.claimId,
|
|
252
|
+
criticality,
|
|
253
|
+
reason: `diff contradicts claim: ${relevantChanges.slice(0, 4).join(", ")}`,
|
|
254
|
+
verdicts: { impact, confidence, stillTrue },
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
claim.claim.last_checked = todayISO();
|
|
258
|
+
claim.pageData.updated = todayISO();
|
|
259
|
+
touchedPages.set(claim.pagePath, claim.pageData);
|
|
260
|
+
applied.push(`${claim.page}#${claim.claimId ?? "?"} → ${impact}`);
|
|
261
|
+
await appendLedger(layout, {
|
|
262
|
+
actor: "code",
|
|
263
|
+
op: "sync.apply",
|
|
264
|
+
subject: `${claim.page}#${claim.claimId ?? claim.text.slice(0, 40)}`,
|
|
265
|
+
action: impact,
|
|
266
|
+
verdict: { status: claim.claim.status ?? null, changedFiles: relevantChanges },
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!options?.dryRun) {
|
|
271
|
+
for (const [pagePath, data] of touchedPages) {
|
|
272
|
+
const page = await readPage(pagePath);
|
|
273
|
+
await writePage(pagePath, data, page.body);
|
|
274
|
+
}
|
|
275
|
+
await writeSyncState(layout, { lastSyncCommit: head, lastSyncAt: new Date().toISOString() });
|
|
276
|
+
await appendLog(layout, "sync", `${impacts.length} claim(s) checked`, [
|
|
277
|
+
`Baseline: ${baseline.slice(0, 7)} → ${head.slice(0, 7)}`,
|
|
278
|
+
`Changed files: ${changed.length}`,
|
|
279
|
+
...applied.map((line) => `Applied: ${line}`),
|
|
280
|
+
]);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
repo: true,
|
|
285
|
+
baseline,
|
|
286
|
+
head,
|
|
287
|
+
baselineInitialized: false,
|
|
288
|
+
changedFiles: changed,
|
|
289
|
+
matchedClaims: affected.length,
|
|
290
|
+
impacts,
|
|
291
|
+
applied,
|
|
292
|
+
dryRun: Boolean(options?.dryRun),
|
|
293
|
+
usage,
|
|
294
|
+
};
|
|
295
|
+
}
|