mellos-mapping 0.22.1 → 0.24.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.
@@ -0,0 +1,148 @@
1
+ # Persistent maps and the MCP API
2
+
3
+ Maps survive process restarts and new conversations. Begin with `mmap_read
4
+ {resource: "pages"}`, match the effort to a saved page, then read only relevant
5
+ records. A conversation is not a page identity. `mmap_view` remains the visual
6
+ representation; `mmap_read` is the editable data contract.
7
+
8
+ ## Read
9
+
10
+ `mmap_read` returns JSON text and the same object in `structuredContent`:
11
+ `resource`, `project`, `page`, `revision`, `total`, `items`, `nextCursor`.
12
+ The default page is represented by a null `page` and the record ID `_default`.
13
+ Named page slugs and resource IDs remain stable; change display titles/labels
14
+ instead of renaming identity. Edge IDs are `from->to`.
15
+
16
+ | resource | Records |
17
+ | --- | --- |
18
+ | pages (default) | Page IDs, titles, kind, counts, context and each page's revision; a broken page is listed with its error |
19
+ | map | One page's metadata/context/counts; it does not embed the entire graph |
20
+ | nodes | Node IDs, labels, status and membership; request detail, evidence and sources through fields |
21
+ | edges | ID, from, to and optional label |
22
+ | layers / groups / lanes | The stored editable records |
23
+ | neighborhood | Related nodes selected by id/ids, direction (dependencies/consumers/both) and depth (0–4) |
24
+ | changes | Source-file verification state for selected nodes and up to 100 affected consumer IDs |
25
+
26
+ Use `id` for exact lookup (missing is `NOT_FOUND`), `ids` for a selection,
27
+ `query` for text matching, and status/layer/group/lane for node filtering.
28
+ `fields` projects records while always retaining identity and edge endpoints.
29
+ The default limit is 30, maximum 100. Repeat the same query with nextCursor;
30
+ if the graph changes, the cursor returns `CONFLICT` instead of skipping records.
31
+ An absent page is `NOT_FOUND`; an empty list is successful. The legacy view's
32
+ empty-map behavior is retained for compatibility.
33
+
34
+ Use a page or record response's revision for that page's next write. The top-level
35
+ revision of a pages listing describes the listing, not any individual page.
36
+ `ifRevision` can avoid resending unchanged graph data. It does not suppress
37
+ source checks: files may change without a graph edit. Pagination checks graph
38
+ revisions, not a filesystem snapshot of source files changing during the query.
39
+
40
+ ```json
41
+ {"resource":"nodes","page":"payments","status":"in-progress","fields":["label","detail","evidence"],"limit":20}
42
+ ```
43
+
44
+ ## Create, update and delete
45
+
46
+ The existing tools and fields remain supported. New writes return a revision
47
+ and structured outcome in addition to the existing summary.
48
+
49
+ - `mmap_declare`: create pages, layers, groups, lanes, nodes and edges. Existing
50
+ IDs are refused. Pass expectedRevision: "absent" to require a new page.
51
+ - `mmap_update`: existing node fields, layer names/ranks, group labels/layers,
52
+ lane labels, complete laneOrder, map title/kind/context and edge patches.
53
+ An edge patch identifies from/to, with optional label, newFrom and newTo.
54
+ A null label clears it. A laneOrder contains every lane exactly once.
55
+ - `mmap_remove`: existing per-resource deletion and cascades. Use deletePage:
56
+ true to delete the targeted page, including the default page. Inbound submap
57
+ references are refused unless references: "keep" is explicit. Unlink nodes
58
+ first when the references should disappear. This form cannot include edits
59
+ or legacy pages batches.
60
+
61
+ Optional fields are unchanged when omitted and removed when set to null where
62
+ the schema permits. context and sources are replaced as whole values. Moving
63
+ a group does not silently move members: include their intended node moves in
64
+ the same update or transaction. Normal single-field changes keep the old graph
65
+ constraints; use a batch for changes that require a coordinated final graph.
66
+
67
+ Every graph writer in the current MCP, HTTP viewer and watcher uses a cooperative
68
+ cross-process project lock. MCP expectedRevision is compared inside that lock
69
+ before loading the proposed changes into the saved map. `CONFLICT` requires a
70
+ fresh read and reconsideration of the edit. `BUSY` means another transaction is
71
+ active; retry after it completes. There is no background lock polling. Locks are
72
+ released on normal completion/error; a confirmed dead PID can be recovered.
73
+ An incomplete owner file after an abrupt crash is refused for manual inspection,
74
+ not guessed stale from its age.
75
+
76
+ Low-level library saveMapFile, hand edits, and older running processes do not
77
+ participate in this contract. Restart MCP processes and native watchers after
78
+ upgrading. Opening a Web surface upgrades services that lack format-2 support;
79
+ existing tabs must reconnect with the newly returned URL. Atomic file
80
+ replacement alone does not make a caller's stale read/modify/write safe.
81
+
82
+ Legacy `mmap_remove {pages:[...]}` remains a separately documented batch of file
83
+ deletions, with explicit partial success and historical reference behavior. It
84
+ does not accept expectedRevision; use per-page deletePage for checked deletion.
85
+ It is not a multi-page transaction.
86
+
87
+ ## One-page mixed transactions
88
+
89
+ `mmap_batch` accepts page, expectedRevision and 1–100 ordered operations. Each
90
+ operation is `{op: "declare" | "update" | "remove", data: {...}}`, with the
91
+ same per-page fields as that tool. Per-operation page, expectedRevision and
92
+ page deletion are excluded. Changes are drafted, the final graph is validated,
93
+ and the page is saved once; a refusal preserves the original file bytes.
94
+
95
+ ```json
96
+ {
97
+ "page":"payments",
98
+ "expectedRevision":"<revision returned by mmap_read>",
99
+ "operations":[
100
+ {"op":"remove","data":{"edges":[{"from":"api","to":"old-store"}]}},
101
+ {"op":"declare","data":{"nodes":[{"id":"new-store","label":"Store","layer":"base"}]}},
102
+ {"op":"declare","data":{"edges":[{"from":"api","to":"new-store"}]}},
103
+ {"op":"update","data":{"context":{"summary":"Payment services","next":"Verify the new store contract"}}}
104
+ ]
105
+ }
106
+ ```
107
+
108
+ Machine-readable error codes include NOT_FOUND, INVALID_STORE, REFUSED,
109
+ CONFLICT, BUSY, INVALID_CURSOR, INVALID_ARGUMENT, REFERENCED and SAVE_FAILED.
110
+ Malformed schema inputs remain MCP invalid-argument errors. Read calls do not
111
+ open panels or create page files.
112
+
113
+ ## Checkpoints and source changes
114
+
115
+ Map context has optional summary and next fields (up to 2,000 characters each).
116
+ Save concise decisions and next actions, rather than copying the conversation.
117
+ Nodes can hold up to 100 sources: `{path: "src/store.ts", sha256: "..."}`.
118
+ Paths are project-relative, forward-slash paths with no traversal. SHA256 is
119
+ optional so sources can be linked before verification.
120
+
121
+ `mmap_read {resource:"changes", page, id}` hashes only the selected node's
122
+ listed files, reports currentSha256 and unchanged/changed/unknown node state,
123
+ and identifies affected consumers. Missing files count as changed. Missing
124
+ baselines, inaccessible files, files over 8 MiB and paths resolving outside
125
+ the project remain unverified. There is no whole-repository source scan and
126
+ no automatic status change. After verification, copy the current hashes into
127
+ sources[].sha256 with a revision-checked update.
128
+
129
+ Classic maps continue to serialize as format 1. Maps containing context or
130
+ source references serialize as format 2; this runtime reads both. Older runtimes
131
+ must refuse format 2 instead of silently dropping its new fields. Removing all
132
+ extension fields allows serialization as format 1 again. Both formats preserve
133
+ the same graph, statuses and existing evidence.
134
+
135
+ ## Project identity
136
+
137
+ Without an explicit MELLOS_MAPPING_CWD or CLAUDE_PROJECT_DIR, the server walks
138
+ up from cwd to the nearest existing map store or Git root. An explicit override
139
+ stays explicit. The mmap command and watcher use the same resolver. Nested
140
+ repositories and worktrees are boundaries; separate branches are not silently
141
+ redirected to a shared writable graph. Carry maps through Git or deliberate
142
+ workspace setup when a new worktree needs them, and recheck source baselines.
143
+
144
+ Codex CLI and App skills both start by reading existing pages. MCP initialization
145
+ also advertises the restore workflow, including for MCP-only installations.
146
+ Viewer selection follows host capabilities; the CLI does not try to use a
147
+ desktop panel tool. After context compaction, repeat the lightweight page/context
148
+ read, then load only the affected resources.
@@ -0,0 +1,11 @@
1
+ /** Optional portable provenance; no host paths or I/O in the map format. */
2
+ export interface SourceRef {
3
+ readonly path: string;
4
+ readonly sha256?: string | undefined;
5
+ }
6
+ export interface MapContext {
7
+ readonly summary?: string | undefined;
8
+ readonly next?: string | undefined;
9
+ }
10
+ export declare function sourceError(raw: unknown): string | undefined;
11
+ export declare function contextError(raw: unknown): string | undefined;
@@ -0,0 +1,27 @@
1
+ export function sourceError(raw) {
2
+ if (!Array.isArray(raw) || raw.length > 100)
3
+ return 'sources must be an array of at most 100 file references';
4
+ for (const item of raw) {
5
+ if (!item || typeof item !== 'object' || Array.isArray(item))
6
+ return 'source must be an object';
7
+ const s = item;
8
+ if (Object.keys(s).some(k => k !== 'path' && k !== 'sha256'))
9
+ return 'unknown source field';
10
+ if (typeof s.path !== 'string' || s.path.length > 1024 || !s.path || /[\u0000-\u001f\u007f-\u009f\\:]/.test(s.path) || s.path.startsWith('/') || s.path.split('/').some(p => !p || p === '.' || p === '..'))
11
+ return 'source path must be relative to the project, with forward slashes and no traversal';
12
+ if (s.sha256 !== undefined && (typeof s.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(s.sha256)))
13
+ return 'source sha256 must be a lowercase SHA256 hash';
14
+ }
15
+ return undefined;
16
+ }
17
+ export function contextError(raw) {
18
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
19
+ return 'context must be an object';
20
+ for (const [key, value] of Object.entries(raw)) {
21
+ if (key !== 'summary' && key !== 'next')
22
+ return 'unknown context field';
23
+ if (typeof value !== 'string' || value.length > 2000 || /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/.test(value))
24
+ return 'context fields must be text of at most 2000 characters';
25
+ }
26
+ return undefined;
27
+ }
@@ -1,9 +1,15 @@
1
+ import { contextError, sourceError } from './context.js';
1
2
  // Explicit ranges also work in the JSON Schema published by the MCP adapter.
2
3
  export const NO_CONTROLS = /^[^\u0000-\u001f\u007f-\u009f]*$/;
3
4
  export const NO_CONTROLS_TEXT = 'one line of text; control characters (ESC, newline, tab) are not allowed';
4
5
  export const NO_CONTROLS_BUT_BREAKS = /^[^\u0000-\u0008\u000b-\u001f\u007f-\u009f]*$/;
5
6
  export const NO_CONTROLS_BUT_BREAKS_TEXT = 'text with optional newlines (\\n) and tabs; other control characters (ESC, BEL, CR) are not allowed';
6
7
  export function mapTextError(map) {
8
+ if (map.context !== undefined) {
9
+ const error = contextError(map.context);
10
+ if (error)
11
+ return error;
12
+ }
7
13
  const check = (field, value, multiline = false) => value === undefined || (multiline ? NO_CONTROLS_BUT_BREAKS : NO_CONTROLS).test(value)
8
14
  ? undefined : `${field}: ${multiline ? NO_CONTROLS_BUT_BREAKS_TEXT : NO_CONTROLS_TEXT}`;
9
15
  let error = check('title', map.title);
@@ -22,6 +28,11 @@ export function mapTextError(map) {
22
28
  }
23
29
  }
24
30
  for (const [i, node] of map.nodes.entries()) {
31
+ if (node.sources !== undefined) {
32
+ const error = sourceError(node.sources);
33
+ if (error)
34
+ return `nodes[${i}]: ${error}`;
35
+ }
25
36
  for (const name of ['label', 'evidence', 'detail']) {
26
37
  // Version-1 files and document exports also support multiline evidence.
27
38
  // MCP keeps its narrower one-line evidence input for concise updates.
@@ -171,7 +171,9 @@ export interface MapLane {
171
171
  readonly label: string;
172
172
  }
173
173
  /** A unit of work living in exactly one band. */
174
+ export type { SourceRef, MapContext } from './context.js';
174
175
  export interface MapNode {
176
+ readonly sources?: readonly import('./context.js').SourceRef[];
175
177
  readonly id: NodeId;
176
178
  readonly label: string;
177
179
  readonly layer: LayerId;
@@ -198,6 +200,7 @@ export interface DepEdge {
198
200
  }
199
201
  /** The whole map. A plain immutable value — operations return new maps. */
200
202
  export interface MellosMap {
203
+ readonly context?: import('./context.js').MapContext;
201
204
  readonly title?: string;
202
205
  /** Presentation intent; absent means 'dev' (the progress ledger). */
203
206
  readonly kind?: MapKind;
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { declareGroup, declareLane, declareLayer, declareNode, linkNodes, setKind, setTitle, updateNode } from '../domain/ops.js';
24
24
  import { mapTextError } from '../domain/text.js';
25
+ import { sourceError, contextError } from '../domain/context.js';
25
26
  import { EMPTY_MAP, ID_RULE, ID_RULE_TEXT, describeMapError, err, makeGroupId, makeLaneId, makeLayerId, makeMapKind, makeNodeId, makeNodeKind, makeNodeStatus, makeRank, makeSubmapRef, ok, } from '../domain/types.js';
26
27
  /** On-disk format version. Bump only with a documented migration. */
27
28
  export const STATE_FILE_VERSION = 1;
@@ -99,8 +100,8 @@ function optionalString(rec, key, where, path) {
99
100
  export function parseMap(raw, path) {
100
101
  if (!isRecord(raw))
101
102
  return err({ kind: 'bad-shape', path, detail: 'root is not an object' });
102
- if (raw['version'] !== STATE_FILE_VERSION) {
103
- return err({ kind: 'bad-shape', path, detail: `version is ${String(raw['version'])}, expected ${STATE_FILE_VERSION}` });
103
+ if (raw['version'] !== STATE_FILE_VERSION && raw['version'] !== 2) {
104
+ return err({ kind: 'bad-shape', path, detail: `version is ${String(raw['version'])}, expected ${STATE_FILE_VERSION} or 2` });
104
105
  }
105
106
  const layers = arrayField(raw, 'layers', path, 'required');
106
107
  if (!layers.ok)
@@ -118,6 +119,12 @@ export function parseMap(raw, path) {
118
119
  if (!groups.ok)
119
120
  return groups;
120
121
  let map = EMPTY_MAP;
122
+ if (raw['context'] !== undefined) {
123
+ const error = contextError(raw['context']);
124
+ if (error)
125
+ return err({ kind: 'bad-shape', path, detail: error });
126
+ map = { ...map, context: raw['context'] };
127
+ }
121
128
  const title = optionalString(raw, 'title', 'map', path);
122
129
  if (!title.ok)
123
130
  return title;
@@ -292,6 +299,12 @@ export function parseMap(raw, path) {
292
299
  return err({ kind: 'invariant-violation', path, violation: updated.error });
293
300
  map = updated.value;
294
301
  }
302
+ if (rawNode['sources'] !== undefined) {
303
+ const error = sourceError(rawNode['sources']);
304
+ if (error)
305
+ return err({ kind: 'bad-shape', path, detail: `${where}: ${error}` });
306
+ map = { ...map, nodes: map.nodes.map(n => n.id === id.value ? { ...n, sources: rawNode['sources'] } : n) };
307
+ }
295
308
  }
296
309
  for (const [i, rawEdge] of edges.value.entries()) {
297
310
  const where = `edges[${i}]`;
@@ -323,7 +336,9 @@ export function parseMap(raw, path) {
323
336
  /** Serialize a map into the on-disk shape. Inverse of parseMap for valid maps (F1). */
324
337
  export function serializeMap(map) {
325
338
  const body = {
326
- version: STATE_FILE_VERSION,
339
+ // Older runtimes must refuse maps with provenance rather than silently erasing it.
340
+ version: map.context !== undefined || map.nodes.some(n => n.sources !== undefined) ? 2 : STATE_FILE_VERSION,
341
+ ...(map.context !== undefined ? { context: map.context } : {}),
327
342
  ...(map.title !== undefined ? { title: map.title } : {}),
328
343
  ...(map.kind !== undefined ? { kind: map.kind } : {}),
329
344
  layers: map.layers,
@@ -0,0 +1,2 @@
1
+ /** Nearest existing store or Git root; never cross a nested repository/worktree boundary. */
2
+ export declare function resolveProjectDirectory(cwd: string, stopAt?: readonly string[]): string;
@@ -0,0 +1,29 @@
1
+ import { existsSync, realpathSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ /** Nearest existing store or Git root; never cross a nested repository/worktree boundary. */
5
+ export function resolveProjectDirectory(cwd, stopAt = [homedir(), tmpdir()]) {
6
+ let start = resolve(cwd);
7
+ try {
8
+ start = realpathSync(start);
9
+ }
10
+ catch { /* Explicit test/manual paths may not exist yet. */ }
11
+ let dir = start;
12
+ const boundaries = new Set(stopAt.map(path => { try {
13
+ return realpathSync(path);
14
+ }
15
+ catch {
16
+ return resolve(path);
17
+ } }));
18
+ while (true) {
19
+ // A user's global .mellos directory is not an ancestor project's store.
20
+ if (dir !== start && boundaries.has(dir))
21
+ return start;
22
+ if (existsSync(join(dir, '.git')) || existsSync(join(dir, '.mellos', 'map.json')) || existsSync(join(dir, '.mellos', 'pages')))
23
+ return dir;
24
+ const parent = dirname(dir);
25
+ if (parent === dir)
26
+ return start;
27
+ dir = parent;
28
+ }
29
+ }
@@ -16,10 +16,13 @@
16
16
  * The concurrency model P2 buys, stated plainly:
17
17
  * - Several writers may target one project at once. Each save is atomic and
18
18
  * lands whole, so a reader never sees half a map — but there is NO
19
- * lost-update protection: two saves of the same page race, and the last
19
+ * lost-update protection in this low-level save API: two saves race, and the last
20
20
  * rename wins, silently discarding what the other writer computed from an
21
21
  * older read. Pages are the isolation unit (one effort = one page); two
22
22
  * sessions that must not clobber each other belong on two pages.
23
+ * Current MCP/viewer writers additionally use transaction.ts to lock the
24
+ * complete read/modify/write operation, with optional revision checks.
25
+ * Direct library saves and older processes do not acquire that lock.
23
26
  * - The temp file carries the writer's pid and a random suffix, so
24
27
  * concurrent writers never share one and never install each other's
25
28
  * half-written content.
@@ -47,3 +50,5 @@ export { VIEWERS_DIR_NAME, VIEWER_FILE_VERSION, VIEWER_HEARTBEAT_MS, VIEWER_STAL
47
50
  export { CONFIG_FILE_NAME, CONFIG_FILE_VERSION, configFilePath, userConfigFilePath, MAPPING_POLICIES, type MappingPolicy, type InvalidPolicy, makeMappingPolicy, describeMappingPolicy, loadMappingPolicy, saveMappingPolicy, POLICY_SCOPES, type PolicyScope, type MappingPolicyScopes, effectiveMappingPolicy } from './policy.js';
48
51
  export { LEGACY_STATE_FILE_RELATIVE_PATH, migrateLegacyStore } from './migration.js';
49
52
  export { loadMapFile, saveMapFile } from './maps.js';
53
+ export { withStoreLock, revisionOf, assertRevision, LedgerError } from './transaction.js';
54
+ export { resolveProjectDirectory } from './project.js';
@@ -16,10 +16,13 @@
16
16
  * The concurrency model P2 buys, stated plainly:
17
17
  * - Several writers may target one project at once. Each save is atomic and
18
18
  * lands whole, so a reader never sees half a map — but there is NO
19
- * lost-update protection: two saves of the same page race, and the last
19
+ * lost-update protection in this low-level save API: two saves race, and the last
20
20
  * rename wins, silently discarding what the other writer computed from an
21
21
  * older read. Pages are the isolation unit (one effort = one page); two
22
22
  * sessions that must not clobber each other belong on two pages.
23
+ * Current MCP/viewer writers additionally use transaction.ts to lock the
24
+ * complete read/modify/write operation, with optional revision checks.
25
+ * Direct library saves and older processes do not acquire that lock.
23
26
  * - The temp file carries the writer's pid and a random suffix, so
24
27
  * concurrent writers never share one and never install each other's
25
28
  * half-written content.
@@ -48,3 +51,5 @@ export { VIEWERS_DIR_NAME, VIEWER_FILE_VERSION, VIEWER_HEARTBEAT_MS, VIEWER_STAL
48
51
  export { CONFIG_FILE_NAME, CONFIG_FILE_VERSION, configFilePath, userConfigFilePath, MAPPING_POLICIES, makeMappingPolicy, describeMappingPolicy, loadMappingPolicy, saveMappingPolicy, POLICY_SCOPES, effectiveMappingPolicy } from './policy.js';
49
52
  export { LEGACY_STATE_FILE_RELATIVE_PATH, migrateLegacyStore } from './migration.js';
50
53
  export { loadMapFile, saveMapFile } from './maps.js';
54
+ export { withStoreLock, revisionOf, assertRevision, LedgerError } from './transaction.js';
55
+ export { resolveProjectDirectory } from './project.js';
@@ -0,0 +1,12 @@
1
+ import type { MellosMap } from '../domain/types.js';
2
+ export declare class LedgerError extends Error {
3
+ readonly code: string;
4
+ readonly details: Record<string, unknown>;
5
+ constructor(code: string, message: string, details?: Record<string, unknown>);
6
+ }
7
+ export declare const revisionOf: (map: MellosMap) => string;
8
+ export declare function assertRevision(actual: string, expected?: string): void;
9
+ /** A named page and the default page share the same project lock. */
10
+ export declare function storeDirectory(file: string): string;
11
+ /** No timed polling: contention is an explicit retryable BUSY result. Never steal a live lock. */
12
+ export declare function withStoreLock<T>(file: string, action: () => T): T;
@@ -0,0 +1,91 @@
1
+ /** Cooperative, cross-process transactions for MCP and HTTP writers. */
2
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { randomUUID, createHash } from 'node:crypto';
5
+ import { serializeMap } from './format.js';
6
+ export class LedgerError extends Error {
7
+ code;
8
+ details;
9
+ constructor(code, message, details = {}) {
10
+ super(message);
11
+ this.code = code;
12
+ this.details = details;
13
+ }
14
+ }
15
+ export const revisionOf = (map) => createHash('sha256').update(serializeMap(map)).digest('hex');
16
+ export function assertRevision(actual, expected) {
17
+ if (expected !== undefined && expected !== actual)
18
+ throw new LedgerError('CONFLICT', 'Map changed; read the current revision before retrying.', { expectedRevision: expected, actualRevision: actual });
19
+ }
20
+ /** A named page and the default page share the same project lock. */
21
+ export function storeDirectory(file) {
22
+ const dir = dirname(file);
23
+ return dir.endsWith('/pages') || dir.endsWith('\\pages') ? dirname(dir) : dir;
24
+ }
25
+ function deadOwner(lock) {
26
+ try {
27
+ const owner = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8'));
28
+ if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0)
29
+ return false;
30
+ try {
31
+ process.kill(owner.pid, 0);
32
+ return false;
33
+ }
34
+ catch (e) {
35
+ return e.code === 'ESRCH';
36
+ }
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ /** No timed polling: contention is an explicit retryable BUSY result. Never steal a live lock. */
43
+ export function withStoreLock(file, action) {
44
+ const dir = storeDirectory(file);
45
+ mkdirSync(dir, { recursive: true });
46
+ const lock = join(dir, '.write-lock');
47
+ const owner = join(lock, 'owner.json');
48
+ const token = randomUUID();
49
+ try {
50
+ mkdirSync(lock);
51
+ }
52
+ catch (error) {
53
+ if (error.code !== 'EEXIST')
54
+ throw error;
55
+ // Reapers serialize inside the old directory and recheck its owner after acquiring.
56
+ // Missing/invalid owners are deliberately not reclaimed on an age heuristic.
57
+ if (!deadOwner(lock))
58
+ throw new LedgerError('BUSY', `Another writer owns ${lock}; retry after it completes. An orphan without owner metadata needs manual inspection.`);
59
+ try {
60
+ mkdirSync(join(lock, '.reap'));
61
+ }
62
+ catch {
63
+ throw new LedgerError('BUSY', 'Another process is recovering the writer lock.');
64
+ }
65
+ if (!deadOwner(lock)) {
66
+ rmSync(join(lock, '.reap'), { recursive: true, force: true });
67
+ throw new LedgerError('BUSY', 'Writer ownership changed.');
68
+ }
69
+ rmSync(lock, { recursive: true });
70
+ try {
71
+ mkdirSync(lock);
72
+ }
73
+ catch {
74
+ throw new LedgerError('BUSY', 'Another writer acquired the recovered lock.');
75
+ }
76
+ }
77
+ let initialized = false;
78
+ try {
79
+ writeFileSync(owner, JSON.stringify({ pid: process.pid, token }), { flag: 'wx' });
80
+ initialized = true;
81
+ return action();
82
+ }
83
+ finally {
84
+ // Only remove the directory still owned by this invocation.
85
+ try {
86
+ if (!initialized || JSON.parse(readFileSync(owner, 'utf8')).token === token)
87
+ rmSync(lock, { recursive: true });
88
+ }
89
+ catch { /* A cleanup error must not misreport a successfully committed map as unsaved. */ }
90
+ }
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mellos-mapping",
3
- "version": "0.22.1",
3
+ "version": "0.24.0",
4
4
  "mcpName": "io.github.GuangminJu/mellos-mapping",
5
5
  "description": "A live layered dependency map for bottom-up development — MCP server + terminal pane. Ghost the design first, then light nodes up from the bottom as they are built and verified.",
6
6
  "type": "module",
@@ -70,6 +70,7 @@
70
70
  "lib",
71
71
  "README.zh-CN.md",
72
72
  "docs/codex.md",
73
+ "docs/map-api.md",
73
74
  "scripts/codex-register.mjs",
74
75
  "scripts/codex-cli.mjs",
75
76
  "scripts/install-mmap-command.mjs",
@@ -92,9 +93,10 @@
92
93
  "check:release": "node scripts/check-release.mjs",
93
94
  "check:package": "node scripts/check-package-surface.mjs",
94
95
  "check:codex": "node scripts/check-codex-package.mjs",
96
+ "check:reuse": "node scripts/check-reuse.mjs",
95
97
  "benchmark:render": "node scripts/benchmark-render.mjs",
96
98
  "prepack": "npm run build",
97
- "verify": "npm run typecheck && npm run test && npm run build && npm run check:package && npm run check:codex && npm run check:release"
99
+ "verify": "npm run typecheck && npm run test && npm run build && npm run check:reuse && npm run check:package && npm run check:codex && npm run check:release"
98
100
  },
99
101
  "devDependencies": {
100
102
  "@modelcontextprotocol/sdk": "^1.12.0",
@@ -42,7 +42,7 @@ if (launchedAsEntry(import.meta.url)) {
42
42
  const installed = registerServer(dirname(dirname(fileURLToPath(import.meta.url))));
43
43
  console.log(`mellos-mapping MCP registered with Codex: ${installed.nodePath} ${installed.serverPath}`);
44
44
  console.log('State files resolve to each session’s working directory (.mellos/map.json).');
45
- console.log('Start a new Codex conversation to load the skills and six mmap tools.');
45
+ console.log('Start a new Codex conversation to load the skills and eight mmap tools.');
46
46
  } catch (error) {
47
47
  console.error(error instanceof Error ? error.message : String(error));
48
48
  process.exitCode = 1;
package/scripts/mmap.mjs CHANGED
@@ -170,7 +170,7 @@ async function main() {
170
170
  console.error(parsed.error);
171
171
  process.exit(1);
172
172
  }
173
- const candidates = storeSearchPath(process.cwd());
173
+ const candidates = [store.resolveProjectDirectory(process.cwd())];
174
174
  const marker = storeMarkerOf(store.STATE_FILE_RELATIVE_PATH);
175
175
  const project = nearestProject(candidates, candidates.map((dir) => existsSync(join(dir, marker))));
176
176
  const cfg = { ...parsed.value, projectDir: project.root };