@imenam/mcp-documentor 1.0.56 → 1.0.58

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.
@@ -5,6 +5,17 @@ interface GlobalConfig {
5
5
  projects: string[];
6
6
  foldersMigrated?: boolean;
7
7
  }
8
+ /**
9
+ * GitHub sync settings. Sourced exclusively from environment variables — the
10
+ * repository, branch and token are administration concerns and must never be
11
+ * settable by the agent at runtime.
12
+ */
13
+ export interface SyncConfig {
14
+ remoteUrl: string;
15
+ branch: string;
16
+ /** PAT for HTTPS auth (MCP_DOCUMENTOR_SYNC_TOKEN). */
17
+ token?: string;
18
+ }
8
19
  export declare class StorageEngine {
9
20
  private rootDir;
10
21
  private historyStore?;
@@ -13,8 +24,19 @@ export declare class StorageEngine {
13
24
  getDataDir(): string;
14
25
  getProjectDir(project?: string): string;
15
26
  private getConfigPath;
27
+ private getLocalStatePath;
28
+ private readLocalState;
29
+ private writeLocalState;
30
+ /**
31
+ * Returns the merged config. The shared part (projects list, migration flags)
32
+ * comes from config.json (versioned), while currentProject is a machine-local
33
+ * preference sourced from local-state.json (gitignored). Legacy config.json
34
+ * files that still embed currentProject are migrated transparently.
35
+ */
16
36
  getConfig(): GlobalConfig;
17
37
  saveConfig(config: GlobalConfig): void;
38
+ /** Reads sync settings from environment variables (admin-controlled, not agent-settable). */
39
+ getSyncConfig(): SyncConfig | undefined;
18
40
  ensureProject(project: string): void;
19
41
  private walkMdFiles;
20
42
  private docIdToFilePath;
@@ -27,22 +27,81 @@ export class StorageEngine {
27
27
  getConfigPath() {
28
28
  return path.join(this.getDataDir(), "config.json");
29
29
  }
30
+ getLocalStatePath() {
31
+ return path.join(this.getDataDir(), "local-state.json");
32
+ }
33
+ readLocalState() {
34
+ const p = this.getLocalStatePath();
35
+ if (fs.existsSync(p)) {
36
+ try {
37
+ return JSON.parse(fs.readFileSync(p, "utf-8"));
38
+ }
39
+ catch {
40
+ logger.warn("Failed to parse local-state.json, using defaults");
41
+ }
42
+ }
43
+ return {};
44
+ }
45
+ writeLocalState(state) {
46
+ const p = this.getLocalStatePath();
47
+ fs.mkdirSync(path.dirname(p), { recursive: true });
48
+ fs.writeFileSync(p, JSON.stringify(state, null, 2), "utf-8");
49
+ }
50
+ /**
51
+ * Returns the merged config. The shared part (projects list, migration flags)
52
+ * comes from config.json (versioned), while currentProject is a machine-local
53
+ * preference sourced from local-state.json (gitignored). Legacy config.json
54
+ * files that still embed currentProject are migrated transparently.
55
+ */
30
56
  getConfig() {
31
57
  const configPath = this.getConfigPath();
58
+ let shared = {};
32
59
  if (fs.existsSync(configPath)) {
33
60
  try {
34
- return JSON.parse(fs.readFileSync(configPath, "utf-8"));
61
+ shared = JSON.parse(fs.readFileSync(configPath, "utf-8"));
35
62
  }
36
63
  catch {
37
64
  logger.warn("Failed to parse config.json, using defaults");
38
65
  }
39
66
  }
40
- return { currentProject: "default", projects: ["default"] };
67
+ const local = this.readLocalState();
68
+ const projects = shared.projects ?? ["default"];
69
+ // Migrate a legacy currentProject embedded in config.json into local-state.
70
+ if (shared.currentProject !== undefined && local.currentProject === undefined) {
71
+ local.currentProject = shared.currentProject;
72
+ this.writeLocalState(local);
73
+ delete shared.currentProject;
74
+ fs.writeFileSync(configPath, JSON.stringify(shared, null, 2), "utf-8");
75
+ logger.info("Migrated currentProject from config.json to local-state.json");
76
+ }
77
+ return {
78
+ currentProject: local.currentProject ?? projects[0] ?? "default",
79
+ projects,
80
+ foldersMigrated: shared.foldersMigrated,
81
+ };
41
82
  }
42
83
  saveConfig(config) {
84
+ // Shared, versioned part → config.json (currentProject is intentionally excluded).
43
85
  const configPath = this.getConfigPath();
86
+ const shared = {
87
+ projects: config.projects,
88
+ foldersMigrated: config.foldersMigrated,
89
+ };
44
90
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
45
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
91
+ fs.writeFileSync(configPath, JSON.stringify(shared, null, 2), "utf-8");
92
+ // Machine-local part → local-state.json.
93
+ const local = this.readLocalState();
94
+ local.currentProject = config.currentProject;
95
+ this.writeLocalState(local);
96
+ }
97
+ /** Reads sync settings from environment variables (admin-controlled, not agent-settable). */
98
+ getSyncConfig() {
99
+ const remoteUrl = process.env.MCP_DOCUMENTOR_SYNC_REMOTE?.trim();
100
+ if (!remoteUrl)
101
+ return undefined;
102
+ const branch = process.env.MCP_DOCUMENTOR_SYNC_BRANCH?.trim() || "main";
103
+ const token = process.env.MCP_DOCUMENTOR_SYNC_TOKEN?.trim() || undefined;
104
+ return { remoteUrl, branch, token };
46
105
  }
47
106
  ensureProject(project) {
48
107
  const projectDir = path.join(this.getDataDir(), "projects", project);
package/dist/src/index.js CHANGED
@@ -18,6 +18,7 @@ import { updateDoc } from "./tools/update-doc.js";
18
18
  import { searchDocs } from "./tools/search-docs.js";
19
19
  import { checkFreshness, computeFreshness } from "./tools/check-freshness.js";
20
20
  import { findAndHashSection } from "./core/section-tracker.js";
21
+ import { toAbsolutePath } from "./core/path-resolver.js";
21
22
  import { setProject } from "./tools/set-project.js";
22
23
  import { listProjects } from "./tools/list-projects.js";
23
24
  import { refreshFileSnapshot } from "./tools/refresh-file-snapshot.js";
@@ -39,6 +40,8 @@ import { prepareExportBundle } from "./tools/prepare-export-bundle.js";
39
40
  import { deleteExportBundle } from "./tools/delete-export-bundle.js";
40
41
  import { uploadImage } from "./tools/upload-image.js";
41
42
  import { getImage } from "./tools/get-image.js";
43
+ import { SyncEngine } from "./core/git-sync.js";
44
+ import { syncStatus, pullDocs, pushDocs, syncDocs, resolveSyncConflict } from "./tools/sync.js";
42
45
  import { isReadOnly, WRITE_TOOLS } from "../shared/read-only.js";
43
46
  const __filename = fileURLToPath(import.meta.url);
44
47
  const __dirname = path.dirname(__filename);
@@ -75,6 +78,7 @@ embeddingStore.init(storageEngine.getDataDir(), openaiModel);
75
78
  const historyStore = new HistoryStore();
76
79
  historyStore.init(storageEngine.getDataDir());
77
80
  storageEngine.setHistoryStore(historyStore);
81
+ const syncEngine = new SyncEngine(storageEngine);
78
82
  const server = new Server({ name: "mcp-documentor", version: APP_VERSION }, { capabilities: { tools: {} } });
79
83
  let guiLauncher;
80
84
  const sseControllers = new Set();
@@ -155,7 +159,7 @@ async function handleGetInitialState(msg) {
155
159
  for (const doc of docs) {
156
160
  const full = await storageEngine.getDoc(doc.id); // doc.id is now the full path
157
161
  if (full && full.tracked_files.length > 0) {
158
- const report = computeFreshness(full.tracked_files);
162
+ const report = computeFreshness(full.tracked_files, config.currentProject);
159
163
  doc.isStale = report.status === "stale";
160
164
  }
161
165
  }
@@ -193,7 +197,7 @@ async function handleUpdateProject(msg) {
193
197
  for (const doc of docs) {
194
198
  const full = await storageEngine.getDoc(doc.id);
195
199
  if (full && full.tracked_files.length > 0) {
196
- const report = computeFreshness(full.tracked_files);
200
+ const report = computeFreshness(full.tracked_files, project);
197
201
  doc.isStale = report.status === "stale";
198
202
  }
199
203
  }
@@ -229,11 +233,12 @@ async function handleListProjects(msg) {
229
233
  }
230
234
  async function handleListDocs(msg) {
231
235
  try {
236
+ const currentProject = storageEngine.getConfig().currentProject;
232
237
  const docs = await storageEngine.listDocs();
233
238
  for (const doc of docs) {
234
239
  const full = await storageEngine.getDoc(doc.id);
235
240
  if (full && full.tracked_files.length > 0) {
236
- const report = computeFreshness(full.tracked_files);
241
+ const report = computeFreshness(full.tracked_files, currentProject);
237
242
  doc.isStale = report.status === "stale";
238
243
  }
239
244
  }
@@ -341,15 +346,15 @@ async function handleCreateDocIPC(msg) {
341
346
  data: { success: true, id },
342
347
  timestamp: new Date().toISOString(),
343
348
  });
349
+ const config = storageEngine.getConfig();
344
350
  const docs = await storageEngine.listDocs();
345
351
  for (const doc of docs) {
346
352
  const full = await storageEngine.getDoc(doc.id);
347
353
  if (full && full.tracked_files.length > 0) {
348
- const report = computeFreshness(full.tracked_files);
354
+ const report = computeFreshness(full.tracked_files, config.currentProject);
349
355
  doc.isStale = report.status === "stale";
350
356
  }
351
357
  }
352
- const config = storageEngine.getConfig();
353
358
  broadcastToGUI({
354
359
  type: "INITIAL_STATE",
355
360
  data: { config, docs, projects: config.projects },
@@ -417,7 +422,9 @@ async function handleGetFileSection(msg) {
417
422
  const trackedFile = resolved.doc.tracked_files.find((f) => f.path === filePath);
418
423
  if (!trackedFile || !trackedFile.sections?.length)
419
424
  throw new Error("No sections for this file");
420
- const absolutePath = path.resolve(process.cwd(), filePath);
425
+ const absolutePath = toAbsolutePath(trackedFile.path, resolved.project);
426
+ if (!absolutePath)
427
+ throw new Error("Cannot resolve file on this machine (no code root configured)");
421
428
  const fileLines = fs.readFileSync(absolutePath, "utf-8").split("\n");
422
429
  const sections = trackedFile.sections.map((section) => {
423
430
  const result = findAndHashSection(absolutePath, section);
@@ -459,7 +466,7 @@ async function handleCheckFreshness(msg) {
459
466
  });
460
467
  return;
461
468
  }
462
- const report = computeFreshness(doc.tracked_files);
469
+ const report = computeFreshness(doc.tracked_files, resolved.project);
463
470
  broadcastToGUI({
464
471
  type: "CHECK_FRESHNESS_RESPONSE",
465
472
  correlationId: msg.correlationId,
@@ -1030,6 +1037,48 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1030
1037
  required: ["uri"],
1031
1038
  },
1032
1039
  },
1040
+ {
1041
+ name: "sync_status",
1042
+ description: "Show the synchronization status: current branch, how many commits the local repo is ahead/behind the remote, conflicted files, and uncommitted changes. The sync target (repository, branch, token) is fixed by the server administrator via environment variables and cannot be changed from here.",
1043
+ inputSchema: { type: "object", properties: {} },
1044
+ },
1045
+ {
1046
+ name: "pull_docs",
1047
+ description: "Pull documentation updates from the GitHub remote. Defensive: only fast-forwards a clean working tree, never auto-merges a divergent history (local data is never overwritten). Reindexes embeddings for new documents after a successful update.",
1048
+ inputSchema: { type: "object", properties: {} },
1049
+ },
1050
+ {
1051
+ name: "push_docs",
1052
+ description: "Commit local documentation changes (markdown + assets + project list) and push them to the GitHub remote.",
1053
+ inputSchema: {
1054
+ type: "object",
1055
+ properties: {
1056
+ message: { type: "string", description: "Optional commit message (a default with machine name and timestamp is used otherwise)" },
1057
+ },
1058
+ },
1059
+ },
1060
+ {
1061
+ name: "sync_docs",
1062
+ description: "Full synchronization: pull (defensive) then push. If the pull reveals a divergence or conflict, the push is skipped so it can be resolved first.",
1063
+ inputSchema: {
1064
+ type: "object",
1065
+ properties: {
1066
+ message: { type: "string", description: "Optional commit message for the push step" },
1067
+ },
1068
+ },
1069
+ },
1070
+ {
1071
+ name: "resolve_sync_conflict",
1072
+ description: "Bulk-resolve sync merge conflicts by keeping one side for the conflicted files, then stage them. For nuanced resolution, edit the conflicted markdown files directly instead. After resolving, run push_docs.",
1073
+ inputSchema: {
1074
+ type: "object",
1075
+ properties: {
1076
+ strategy: { type: "string", enum: ["mine", "theirs"], description: "'mine' keeps the local version, 'theirs' keeps the incoming remote version" },
1077
+ paths: { type: "array", items: { type: "string" }, description: "Specific conflicted file paths to resolve (defaults to all conflicted files)" },
1078
+ },
1079
+ required: ["strategy"],
1080
+ },
1081
+ },
1033
1082
  ];
1034
1083
  return {
1035
1084
  tools: isReadOnly() ? tools.filter((t) => !WRITE_TOOLS.has(t.name)) : tools,
@@ -1111,6 +1160,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1111
1160
  return await uploadImage(storageEngine, args);
1112
1161
  case "get_image":
1113
1162
  return await getImage(storageEngine, args);
1163
+ case "sync_status":
1164
+ return await syncStatus(syncEngine);
1165
+ case "pull_docs":
1166
+ return await pullDocs(syncEngine, storageEngine, embeddingEngine, embeddingStore);
1167
+ case "push_docs":
1168
+ return await pushDocs(syncEngine, args);
1169
+ case "sync_docs":
1170
+ return await syncDocs(syncEngine, storageEngine, embeddingEngine, embeddingStore, args);
1171
+ case "resolve_sync_conflict":
1172
+ return await resolveSyncConflict(syncEngine, args);
1114
1173
  default:
1115
1174
  throw new Error(`Tool not found: ${name}`);
1116
1175
  }
@@ -1122,9 +1181,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1122
1181
  };
1123
1182
  }
1124
1183
  });
1184
+ async function syncPullOnStartup() {
1185
+ if (!syncEngine.isConfigured())
1186
+ return;
1187
+ if ((process.env.SYNC_PULL_ON_START ?? "").trim().toLowerCase() === "false")
1188
+ return;
1189
+ try {
1190
+ const res = await syncEngine.pull();
1191
+ if (res.status === "updated") {
1192
+ logger.info(`Startup sync: pulled ${res.changed.length} changed file(s).`);
1193
+ if (embeddingEngine.isConfigured()) {
1194
+ await reindexEmbeddings(storageEngine, embeddingEngine, embeddingStore, {});
1195
+ }
1196
+ }
1197
+ else if (res.status === "diverged") {
1198
+ logger.warn(`Startup sync: local history diverged (ahead ${res.ahead}, behind ${res.behind}). Started on local data — run sync_status / sync_docs to reconcile.`);
1199
+ }
1200
+ else if (res.status === "conflict") {
1201
+ logger.warn(`Startup sync: merge conflict in ${res.conflicted.length} file(s). Started on local data — resolve with resolve_sync_conflict.`);
1202
+ }
1203
+ else {
1204
+ logger.info("Startup sync: already up to date.");
1205
+ }
1206
+ }
1207
+ catch (err) {
1208
+ logger.error(`Startup sync failed (non-fatal): ${err?.message ?? err}`);
1209
+ }
1210
+ }
1125
1211
  async function main() {
1126
1212
  await migrateFolders(storageEngine);
1127
1213
  await migrateEmbeddings(embeddingStore, storageEngine);
1214
+ await syncPullOnStartup();
1128
1215
  const transport = new StdioServerTransport();
1129
1216
  await server.connect(transport);
1130
1217
  logger.info("MCP Server connected and running on stdio");
@@ -1,7 +1,7 @@
1
1
  import type { TrackedFile, FreshnessReport } from "../../shared/schemas/doc.schema.js";
2
2
  import type { StorageEngine } from "../core/storage-engine.js";
3
3
  export declare function computeFileHash(absolutePath: string): string;
4
- export declare function computeFreshness(trackedFiles: TrackedFile[]): FreshnessReport;
4
+ export declare function computeFreshness(trackedFiles: TrackedFile[], project: string): FreshnessReport;
5
5
  export declare function formatFreshnessReport(title: string, report: FreshnessReport): string;
6
6
  export declare function checkFreshness(storageEngine: StorageEngine, args: unknown): Promise<{
7
7
  isError: boolean;
@@ -1,12 +1,12 @@
1
1
  import crypto from "crypto";
2
2
  import fs from "fs";
3
- import path from "path";
4
3
  import { CheckFreshnessArgsSchema } from "../../shared/schemas/doc.schema.js";
5
4
  import { findAndHashSection } from "../core/section-tracker.js";
5
+ import { toAbsolutePath } from "../core/path-resolver.js";
6
6
  export function computeFileHash(absolutePath) {
7
7
  return crypto.createHash("sha256").update(fs.readFileSync(absolutePath)).digest("hex");
8
8
  }
9
- export function computeFreshness(trackedFiles) {
9
+ export function computeFreshness(trackedFiles, project) {
10
10
  if (!trackedFiles || trackedFiles.length === 0) {
11
11
  return { status: "no_tracking", modified: [], missing: [], unchanged: [], anchors_lost: [], total: 0 };
12
12
  }
@@ -15,8 +15,11 @@ export function computeFreshness(trackedFiles) {
15
15
  const unchanged = [];
16
16
  const anchors_lost = [];
17
17
  for (const tracked of trackedFiles) {
18
- const absolutePath = path.resolve(process.cwd(), tracked.path);
19
- if (!fs.existsSync(absolutePath)) {
18
+ // A relative path with no code root configured on this machine is not
19
+ // resolvable here — count it as absent so we end up "unverifiable" rather
20
+ // than crying wolf with "stale".
21
+ const absolutePath = toAbsolutePath(tracked.path, project);
22
+ if (!absolutePath || !fs.existsSync(absolutePath)) {
20
23
  missing.push(tracked.path);
21
24
  continue;
22
25
  }
@@ -68,6 +71,15 @@ export function computeFreshness(trackedFiles) {
68
71
  }
69
72
  }
70
73
  }
74
+ // When every tracked file is absent, the source code simply isn't present on
75
+ // this machine (e.g. docs cloned without the codebase) — we can't verify
76
+ // freshness, so report "unverifiable" rather than crying wolf with "stale".
77
+ // A partial absence (some files missing, others present) is treated as a real
78
+ // change signal (likely deletions) and still counts as stale.
79
+ const allMissing = missing.length === trackedFiles.length;
80
+ if (allMissing) {
81
+ return { status: "unverifiable", modified, missing, unchanged, anchors_lost, total: trackedFiles.length };
82
+ }
71
83
  const isStale = modified.length > 0 || missing.length > 0 || anchors_lost.length > 0;
72
84
  return {
73
85
  status: isStale ? "stale" : "up_to_date",
@@ -82,6 +94,9 @@ export function formatFreshnessReport(title, report) {
82
94
  if (report.status === "no_tracking") {
83
95
  return `No tracked files for "${title}". Use tracked_files when creating or updating the doc to enable staleness detection.`;
84
96
  }
97
+ if (report.status === "unverifiable") {
98
+ return `UNVERIFIABLE: "${title}" — none of the ${report.total} tracked file(s) are present on this machine, so freshness cannot be checked here. This is expected when the documentation is cloned without its source codebase.`;
99
+ }
85
100
  let text = report.status === "stale"
86
101
  ? `STALE: "${title}" — tracked files have changed since the documentation was last updated.\n`
87
102
  : `UP TO DATE: "${title}" — no tracked files have been modified.\n`;
@@ -100,6 +115,15 @@ export function formatFreshnessReport(title, report) {
100
115
  }
101
116
  return text;
102
117
  }
118
+ /** Cap on how many "not tracking any file" doc titles are listed before collapsing into a count. */
119
+ const UNTRACKED_LIST_LIMIT = 10;
120
+ /** Renders the untracked-docs list, truncated past UNTRACKED_LIST_LIMIT to avoid flooding the context. */
121
+ function renderUntrackedList(titles, heading) {
122
+ const shown = titles.slice(0, UNTRACKED_LIST_LIMIT).map((t) => ` - ${t}`);
123
+ const remaining = titles.length - UNTRACKED_LIST_LIMIT;
124
+ const suffix = remaining > 0 ? `\n … and ${remaining} more` : "";
125
+ return `\n\n${heading}\n${shown.join("\n")}${suffix}`;
126
+ }
103
127
  export async function checkFreshness(storageEngine, args) {
104
128
  try {
105
129
  const validated = CheckFreshnessArgsSchema.parse(args);
@@ -108,8 +132,8 @@ export async function checkFreshness(storageEngine, args) {
108
132
  if (!resolved) {
109
133
  return { isError: true, content: [{ type: "text", text: `Documentation entry "${validated.path}" not found in any project.` }] };
110
134
  }
111
- const { doc } = resolved;
112
- const report = computeFreshness(doc.tracked_files);
135
+ const { doc, project } = resolved;
136
+ const report = computeFreshness(doc.tracked_files, project);
113
137
  return { content: [{ type: "text", text: formatFreshnessReport(doc.title, report) }] };
114
138
  }
115
139
  if (validated.project) {
@@ -120,14 +144,18 @@ export async function checkFreshness(storageEngine, args) {
120
144
  const stale = [];
121
145
  const noTracking = [];
122
146
  let upToDate = 0;
147
+ let unverifiable = 0;
123
148
  for (const summary of summaries) {
124
149
  const doc = await storageEngine.getDoc(summary.id, validated.project);
125
150
  if (!doc)
126
151
  continue;
127
- const report = computeFreshness(doc.tracked_files);
152
+ const report = computeFreshness(doc.tracked_files, validated.project);
128
153
  if (report.status === "no_tracking") {
129
154
  noTracking.push(doc.title);
130
155
  }
156
+ else if (report.status === "unverifiable") {
157
+ unverifiable++;
158
+ }
131
159
  else if (report.status === "stale") {
132
160
  const details = [
133
161
  ...report.modified.map((f) => ` • modified: ${f}`),
@@ -141,12 +169,12 @@ export async function checkFreshness(storageEngine, args) {
141
169
  }
142
170
  }
143
171
  let text = `# Freshness report — project "${validated.project}"\n\n`;
144
- text += `${summaries.length} doc(s) scanned: **${stale.length} stale**, ${upToDate} up to date, ${noTracking.length} untracked.\n`;
172
+ text += `${summaries.length} doc(s) scanned: **${stale.length} stale**, ${upToDate} up to date, ${unverifiable} unverifiable (source absent), ${noTracking.length} not tracking any file.\n`;
145
173
  if (stale.length > 0) {
146
174
  text += `\n## Stale docs\n\n${stale.join("\n\n")}`;
147
175
  }
148
176
  if (noTracking.length > 0) {
149
- text += `\n\n## Untracked docs (no staleness detection)\n${noTracking.map((t) => ` - ${t}`).join("\n")}`;
177
+ text += renderUntrackedList(noTracking, "## Docs not tracking any file (no staleness detection)");
150
178
  }
151
179
  return { content: [{ type: "text", text }] };
152
180
  }
@@ -161,14 +189,18 @@ export async function checkFreshness(storageEngine, args) {
161
189
  const stale = [];
162
190
  const noTracking = [];
163
191
  let upToDate = 0;
192
+ let unverifiable = 0;
164
193
  for (const summary of summaries) {
165
194
  const doc = await storageEngine.getDoc(summary.id, proj);
166
195
  if (!doc)
167
196
  continue;
168
- const report = computeFreshness(doc.tracked_files);
197
+ const report = computeFreshness(doc.tracked_files, proj);
169
198
  if (report.status === "no_tracking") {
170
199
  noTracking.push(doc.title);
171
200
  }
201
+ else if (report.status === "unverifiable") {
202
+ unverifiable++;
203
+ }
172
204
  else if (report.status === "stale") {
173
205
  const details = [
174
206
  ...report.modified.map((f) => ` • modified: ${f}`),
@@ -184,12 +216,12 @@ export async function checkFreshness(storageEngine, args) {
184
216
  totalDocs += summaries.length;
185
217
  totalStale += stale.length;
186
218
  let block = `## ${proj}\n\n`;
187
- block += `${summaries.length} doc(s): **${stale.length} stale**, ${upToDate} up to date, ${noTracking.length} untracked.`;
219
+ block += `${summaries.length} doc(s): **${stale.length} stale**, ${upToDate} up to date, ${unverifiable} unverifiable (source absent), ${noTracking.length} not tracking any file.`;
188
220
  if (stale.length > 0) {
189
221
  block += `\n\n### Stale docs\n\n${stale.join("\n\n")}`;
190
222
  }
191
223
  if (noTracking.length > 0) {
192
- block += `\n\n### Untracked docs\n${noTracking.map((t) => ` - ${t}`).join("\n")}`;
224
+ block += renderUntrackedList(noTracking, "### Docs not tracking any file");
193
225
  }
194
226
  projectBlocks.push(block);
195
227
  }
@@ -31,11 +31,12 @@ export async function createDoc(storageEngine, embeddingEngine, args) {
31
31
  if (project) {
32
32
  storageEngine.ensureProject(project);
33
33
  }
34
+ const resolvedProject = project ?? storageEngine.getConfig().currentProject;
34
35
  const allTrackedInputs = [
35
36
  ...(validated.tracked_files ?? []),
36
37
  ...additionalTrackedFiles,
37
38
  ];
38
- const trackedFiles = allTrackedInputs.length > 0 ? snapshotTrackedFiles(allTrackedInputs) : [];
39
+ const trackedFiles = allTrackedInputs.length > 0 ? snapshotTrackedFiles(allTrackedInputs, resolvedProject) : [];
39
40
  const folder = validated.folder;
40
41
  const doc = {
41
42
  id,
@@ -48,7 +49,6 @@ export async function createDoc(storageEngine, embeddingEngine, args) {
48
49
  createdAt: now,
49
50
  updatedAt: now,
50
51
  };
51
- const resolvedProject = project ?? storageEngine.getConfig().currentProject;
52
52
  await storageEngine.saveDoc(doc, resolvedProject, "create");
53
53
  if (embeddingEngine.isConfigured()) {
54
54
  const embedding = await embeddingEngine.generateEmbedding(content);
@@ -1,6 +1,8 @@
1
+ import fs from "fs";
1
2
  import { GetDocMetadataArgsSchema } from "../../shared/schemas/doc.schema.js";
2
3
  import { computeFreshness } from "./check-freshness.js";
3
4
  import { findAndHashSection } from "../core/section-tracker.js";
5
+ import { toAbsolutePath } from "../core/path-resolver.js";
4
6
  export async function getDocMetadata(storageEngine, args) {
5
7
  try {
6
8
  const validated = GetDocMetadataArgsSchema.parse(args);
@@ -8,8 +10,8 @@ export async function getDocMetadata(storageEngine, args) {
8
10
  if (!resolved) {
9
11
  return { isError: true, content: [{ type: "text", text: `Documentation entry "${validated.path}" not found in any project.` }] };
10
12
  }
11
- const { doc } = resolved;
12
- const report = computeFreshness(doc.tracked_files);
13
+ const { doc, project } = resolved;
14
+ const report = computeFreshness(doc.tracked_files, project);
13
15
  const fileStatusIcon = (filePath) => {
14
16
  if (report.modified.includes(filePath))
15
17
  return "✗ modified";
@@ -28,8 +30,10 @@ export async function getDocMetadata(storageEngine, args) {
28
30
  text += doc.tracked_files.map((f) => {
29
31
  let entry = ` ${fileStatusIcon(f.path)} — ${f.path}\n File mtime: ${f.lastModifiedAt}`;
30
32
  if (f.sections && f.sections.length > 0) {
33
+ const abs = toAbsolutePath(f.path, project);
34
+ const readable = abs && fs.existsSync(abs);
31
35
  const sectionLines = f.sections.map((section, idx) => {
32
- const result = findAndHashSection(f.path, section);
36
+ const result = readable ? findAndHashSection(abs, section) : { found: false };
33
37
  const location = result.found
34
38
  ? `lines ${result.lines.start}–${result.lines.end}`
35
39
  : "section introuvable (ancres modifiées)";
@@ -1,6 +1,8 @@
1
+ import fs from "fs";
1
2
  import { GetDocArgsSchema } from "../../shared/schemas/doc.schema.js";
2
3
  import { computeFreshness } from "./check-freshness.js";
3
4
  import { findAndHashSection } from "../core/section-tracker.js";
5
+ import { toAbsolutePath } from "../core/path-resolver.js";
4
6
  export async function getDoc(storageEngine, args) {
5
7
  try {
6
8
  const validated = GetDocArgsSchema.parse(args);
@@ -12,8 +14,10 @@ export async function getDoc(storageEngine, args) {
12
14
  const trackedFilesText = doc.tracked_files.length > 0
13
15
  ? doc.tracked_files.map((f) => {
14
16
  if (f.sections && f.sections.length > 0) {
17
+ const abs = toAbsolutePath(f.path, resolvedProject);
18
+ const readable = abs && fs.existsSync(abs);
15
19
  const sectionLines = f.sections.map((section, idx) => {
16
- const result = findAndHashSection(f.path, section);
20
+ const result = readable ? findAndHashSection(abs, section) : { found: false };
17
21
  const location = result.found
18
22
  ? `lines ${result.lines.start}–${result.lines.end}`
19
23
  : "section introuvable";
@@ -24,7 +28,7 @@ export async function getDoc(storageEngine, args) {
24
28
  return ` - ${f.path} (snapshot: ${f.lastModifiedAt})`;
25
29
  }).join("\n")
26
30
  : " (none)";
27
- const freshnessReport = computeFreshness(doc.tracked_files);
31
+ const freshnessReport = computeFreshness(doc.tracked_files, resolvedProject);
28
32
  const stalenessBanner = freshnessReport.status === "stale"
29
33
  ? [
30
34
  `> [!WARNING]`,
@@ -2,8 +2,12 @@ import { execSync } from "child_process";
2
2
  import path from "path";
3
3
  import { GitDiffDocArgsSchema } from "../../shared/schemas/doc.schema.js";
4
4
  import { computeFreshness } from "./check-freshness.js";
5
- function getGitDiffForFile(tracked) {
6
- const filePath = tracked.path;
5
+ import { toAbsolutePath, toStoredPath } from "../core/path-resolver.js";
6
+ function getGitDiffForFile(tracked, project) {
7
+ const filePath = toAbsolutePath(tracked.path, project);
8
+ if (!filePath) {
9
+ return ` [INFO] Cannot resolve "${tracked.path}" on this machine (no code root configured) — source absent.`;
10
+ }
7
11
  const fileDir = path.dirname(filePath);
8
12
  let repoRoot;
9
13
  try {
@@ -46,17 +50,18 @@ export async function gitDiffDoc(storageEngine, args) {
46
50
  docSections.push(`## ${docPath}\n\n[ERROR] Documentation entry not found in any project.`);
47
51
  continue;
48
52
  }
49
- const { doc } = resolved;
53
+ const { doc, project } = resolved;
50
54
  if (!doc.tracked_files || doc.tracked_files.length === 0) {
51
55
  docSections.push(`## ${doc.title}\n\n[INFO] No tracked files.`);
52
56
  continue;
53
57
  }
54
58
  let filesToDiff;
55
59
  if (validated.file_paths) {
56
- filesToDiff = doc.tracked_files.filter((tf) => validated.file_paths.includes(tf.path));
60
+ const wanted = new Set(validated.file_paths.map((p) => toStoredPath(p, project)));
61
+ filesToDiff = doc.tracked_files.filter((tf) => wanted.has(tf.path));
57
62
  }
58
63
  else {
59
- const freshness = computeFreshness(doc.tracked_files);
64
+ const freshness = computeFreshness(doc.tracked_files, project);
60
65
  const changedPaths = new Set([...freshness.modified, ...freshness.missing]);
61
66
  filesToDiff = doc.tracked_files.filter((tf) => changedPaths.has(tf.path));
62
67
  }
@@ -75,7 +80,7 @@ export async function gitDiffDoc(storageEngine, args) {
75
80
  return true;
76
81
  })
77
82
  .map((tf) => {
78
- const diff = getGitDiffForFile(tf);
83
+ const diff = getGitDiffForFile(tf, project);
79
84
  return `### ${tf.path}\nSnapshot date: ${tf.lastModifiedAt}\n\n${diff}`;
80
85
  });
81
86
  if (fileSections.length === 0) {
@@ -41,7 +41,7 @@ export async function listDocs(storageEngine, args) {
41
41
  for (const summary of summaries) {
42
42
  const doc = await storageEngine.getDoc(summary.id, proj);
43
43
  if (doc && doc.tracked_files.length > 0) {
44
- const report = computeFreshness(doc.tracked_files);
44
+ const report = computeFreshness(doc.tracked_files, proj);
45
45
  summary.isStale = report.status === "stale";
46
46
  }
47
47
  }
@@ -59,7 +59,7 @@ export async function listDocs(storageEngine, args) {
59
59
  for (const summary of summaries) {
60
60
  const doc = await storageEngine.getDoc(summary.id, proj);
61
61
  if (doc && doc.tracked_files.length > 0) {
62
- const report = computeFreshness(doc.tracked_files);
62
+ const report = computeFreshness(doc.tracked_files, proj);
63
63
  summary.isStale = report.status === "stale";
64
64
  }
65
65
  }
@@ -28,7 +28,7 @@ export async function patchDoc(storageEngine, embeddingEngine, args) {
28
28
  };
29
29
  }
30
30
  doc.content = doc.content.replace(search, replacement);
31
- doc.tracked_files = mergeTrackedFiles(doc.tracked_files, additionalTrackedFiles);
31
+ doc.tracked_files = mergeTrackedFiles(doc.tracked_files, additionalTrackedFiles, resolvedProject);
32
32
  doc.updatedAt = new Date().toISOString();
33
33
  await storageEngine.saveDoc(doc, resolvedProject, "patch");
34
34
  if (embeddingEngine.isConfigured()) {