mellos-mapping 0.18.0 → 0.20.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,96 @@
1
+ /**
2
+ * Layer 1b — Node-side persistence for a MellosMap.
3
+ *
4
+ * The state file IS the event bus of the whole plugin: the MCP server writes
5
+ * it, the terminal watcher polls it. The file FORMAT (version, page-id
6
+ * grammar, parse/serialize with boundary validation) lives in ./format.ts,
7
+ * pure of I/O so browsers can consume it; this module owns everything that
8
+ * touches the filesystem, and one promise:
9
+ *
10
+ * P2. Writes are atomic: a reader polling the file either sees the previous
11
+ * complete map or the new complete map, never a torn write. Achieved by
12
+ * writing a sibling temp file and renaming it over the target.
13
+ *
14
+ * Expected failures (missing file, malformed JSON, invariant violations) are
15
+ * Result values. Only truly unexpected I/O faults (permissions, disk) are
16
+ * allowed to propagate as exceptions.
17
+ *
18
+ * Node consumers import everything from here; the format surface is
19
+ * re-exported so persistence has one import site per runtime.
20
+ */
21
+ import { type MellosMap, type Result } from '../domain/types.js';
22
+ import { type PageId, type StoreError } from './format.js';
23
+ export { STATE_FILE_VERSION, type PageId, makePageId, type StoreError, describeStoreError, parseMap, serializeMap, } from './format.js';
24
+ /**
25
+ * Project-relative location of the DEFAULT page's state file. The store lives
26
+ * in the tool-owned `.mellos/` directory: the map belongs to mellos-mapping,
27
+ * not to whichever host (Claude Code, Codex, a harness) happens to drive the
28
+ * server, so no host brand appears in the path. Pre-0.19 stores under
29
+ * `.claude/` are moved once by {@link migrateLegacyStore}.
30
+ */
31
+ export declare const STATE_FILE_RELATIVE_PATH: string;
32
+ /** Directory (next to the default file) holding the named pages. */
33
+ export declare const PAGES_DIR_NAME = "pages";
34
+ /** Where a page's map file lives, given the default page's file path. */
35
+ export declare function pageFilePath(defaultFile: string, page?: PageId): string;
36
+ /** The page id a file path denotes; undefined = the default page. */
37
+ export declare function pageIdOfFile(defaultFile: string, path: string): PageId | undefined;
38
+ /** Existing page files: the default page first (when present), then named pages sorted by slug. */
39
+ export declare function listPageFiles(defaultFile: string): string[];
40
+ /** Sibling of the default file carrying a one-shot "show this page" request. */
41
+ export declare const FOCUS_FILE_NAME = "focus";
42
+ export declare function focusFilePath(defaultFile: string): string;
43
+ /** A consumed focus request: the page to show (undefined = the default page). */
44
+ export interface FocusRequest {
45
+ readonly page: PageId | undefined;
46
+ }
47
+ /**
48
+ * Consume a pending focus request: read it, delete the file, return it.
49
+ * Absent file — the overwhelmingly common case — or junk content means no
50
+ * request; the channel is best-effort and junk is swept by the same delete.
51
+ */
52
+ export declare function takeFocusRequest(defaultFile: string): FocusRequest | undefined;
53
+ /** Sibling of the default file holding the project's plugin configuration. */
54
+ export declare const CONFIG_FILE_NAME = "config.json";
55
+ /** On-disk config format version. Bump only with a documented migration. */
56
+ export declare const CONFIG_FILE_VERSION = 1;
57
+ export declare function configFilePath(defaultFile: string): string;
58
+ export declare const MAPPING_POLICIES: readonly ["always", "complex", "on-request"];
59
+ /** How eagerly maps are opened; 'complex' is the behavior of an unconfigured project. */
60
+ export type MappingPolicy = (typeof MAPPING_POLICIES)[number];
61
+ export interface InvalidPolicy {
62
+ readonly kind: 'invalid-policy';
63
+ readonly raw: string;
64
+ readonly allowed: readonly string[];
65
+ }
66
+ export declare function makeMappingPolicy(raw: string): Result<MappingPolicy, InvalidPolicy>;
67
+ /** One line of meaning per policy — the wording every surface repeats. */
68
+ export declare function describeMappingPolicy(policy: MappingPolicy): string;
69
+ /**
70
+ * The configured policy, or ok(undefined) when the project has never been
71
+ * set up (missing file or missing key — both mean "nobody chose yet").
72
+ * A file that exists but does not parse is an error, never silently ignored.
73
+ */
74
+ export declare function loadMappingPolicy(defaultFile: string): Result<MappingPolicy | undefined, StoreError>;
75
+ /** Persist the policy atomically (P2), same temp-and-rename as the map files. */
76
+ export declare function saveMappingPolicy(defaultFile: string, policy: MappingPolicy): void;
77
+ /** Project-relative location of the pre-0.20 default page file. */
78
+ export declare const LEGACY_STATE_FILE_RELATIVE_PATH: string;
79
+ /**
80
+ * Move a legacy `.claude` store — map, pages, and mapping-policy config —
81
+ * into the tool-owned `.mellos` location. Never merges: a project whose new
82
+ * store already holds anything keeps it untouched, whatever the legacy
83
+ * directory still contains.
84
+ * @param defaultFile - the NEW default page path (`<root>/.mellos/map.json`);
85
+ * the legacy store is looked up relative to `<root>`.
86
+ * @returns whether a legacy store was moved.
87
+ */
88
+ export declare function migrateLegacyStore(defaultFile: string): boolean;
89
+ /** Load and validate the map file at `path`. */
90
+ export declare function loadMapFile(path: string): Result<MellosMap, StoreError>;
91
+ /**
92
+ * Write the map to `path` atomically (P2): serialize to `<path>.tmp` in the
93
+ * same directory, then rename over the target. Creates the parent directory
94
+ * if missing.
95
+ */
96
+ export declare function saveMapFile(path: string, map: MellosMap): void;
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Layer 1b — Node-side persistence for a MellosMap.
3
+ *
4
+ * The state file IS the event bus of the whole plugin: the MCP server writes
5
+ * it, the terminal watcher polls it. The file FORMAT (version, page-id
6
+ * grammar, parse/serialize with boundary validation) lives in ./format.ts,
7
+ * pure of I/O so browsers can consume it; this module owns everything that
8
+ * touches the filesystem, and one promise:
9
+ *
10
+ * P2. Writes are atomic: a reader polling the file either sees the previous
11
+ * complete map or the new complete map, never a torn write. Achieved by
12
+ * writing a sibling temp file and renaming it over the target.
13
+ *
14
+ * Expected failures (missing file, malformed JSON, invariant violations) are
15
+ * Result values. Only truly unexpected I/O faults (permissions, disk) are
16
+ * allowed to propagate as exceptions.
17
+ *
18
+ * Node consumers import everything from here; the format surface is
19
+ * re-exported so persistence has one import site per runtime.
20
+ */
21
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
22
+ import { basename, dirname, join } from 'node:path';
23
+ import { err, ok } from '../domain/types.js';
24
+ import { makePageId, parseMap, serializeMap } from './format.js';
25
+ export { STATE_FILE_VERSION, makePageId, describeStoreError, parseMap, serializeMap, } from './format.js';
26
+ /**
27
+ * Project-relative location of the DEFAULT page's state file. The store lives
28
+ * in the tool-owned `.mellos/` directory: the map belongs to mellos-mapping,
29
+ * not to whichever host (Claude Code, Codex, a harness) happens to drive the
30
+ * server, so no host brand appears in the path. Pre-0.19 stores under
31
+ * `.claude/` are moved once by {@link migrateLegacyStore}.
32
+ */
33
+ export const STATE_FILE_RELATIVE_PATH = join('.mellos', 'map.json');
34
+ // ---------------------------------------------------------------------------
35
+ // pages — a project may keep several maps side by side (one effort = one page)
36
+ // ---------------------------------------------------------------------------
37
+ //
38
+ // The default page IS the classic map.json. Named pages live in a sibling
39
+ // directory, one file each: file-per-page keeps concurrent sessions isolated —
40
+ // two writers on two pages can never clobber each other, because every save
41
+ // renames a whole file.
42
+ /** Directory (next to the default file) holding the named pages. */
43
+ export const PAGES_DIR_NAME = 'pages';
44
+ /** Where a page's map file lives, given the default page's file path. */
45
+ export function pageFilePath(defaultFile, page) {
46
+ return page === undefined ? defaultFile : join(dirname(defaultFile), PAGES_DIR_NAME, `${page}.json`);
47
+ }
48
+ /** The page id a file path denotes; undefined = the default page. */
49
+ export function pageIdOfFile(defaultFile, path) {
50
+ if (path === defaultFile)
51
+ return undefined;
52
+ const name = basename(path);
53
+ return name.endsWith('.json') ? name.slice(0, -'.json'.length) : name;
54
+ }
55
+ /** Existing page files: the default page first (when present), then named pages sorted by slug. */
56
+ export function listPageFiles(defaultFile) {
57
+ const out = [];
58
+ if (existsSync(defaultFile))
59
+ out.push(defaultFile);
60
+ let entries = [];
61
+ try {
62
+ entries = readdirSync(join(dirname(defaultFile), PAGES_DIR_NAME));
63
+ }
64
+ catch {
65
+ // no pages directory — a single-page project, the common case
66
+ }
67
+ for (const e of entries.sort()) {
68
+ if (e.endsWith('.json'))
69
+ out.push(join(dirname(defaultFile), PAGES_DIR_NAME, e));
70
+ }
71
+ return out;
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // focus requests — "show this page" messages from pane openers to the watcher
75
+ // ---------------------------------------------------------------------------
76
+ //
77
+ // State files flow one way, MCP server → watcher; a launcher that wants an
78
+ // ALREADY-RUNNING pane to show a particular page has no channel to it. The
79
+ // focus file is that channel, one-shot on purpose: the watcher consumes the
80
+ // request AND DELETES the file, so a request lives about one poll tick —
81
+ // nothing stale survives to misdirect tomorrow's pane, and the project's git
82
+ // status barely ever sees the file exist.
83
+ /** Sibling of the default file carrying a one-shot "show this page" request. */
84
+ export const FOCUS_FILE_NAME = 'focus';
85
+ export function focusFilePath(defaultFile) {
86
+ return join(dirname(defaultFile), FOCUS_FILE_NAME);
87
+ }
88
+ /**
89
+ * Consume a pending focus request: read it, delete the file, return it.
90
+ * Absent file — the overwhelmingly common case — or junk content means no
91
+ * request; the channel is best-effort and junk is swept by the same delete.
92
+ */
93
+ export function takeFocusRequest(defaultFile) {
94
+ const path = focusFilePath(defaultFile);
95
+ let raw;
96
+ try {
97
+ raw = readFileSync(path, 'utf8');
98
+ }
99
+ catch {
100
+ return undefined;
101
+ }
102
+ try {
103
+ rmSync(path, { force: true });
104
+ }
105
+ catch {
106
+ // deletion is a courtesy: re-consuming next tick is harmless because
107
+ // switching to the already-shown page is a no-op
108
+ }
109
+ let parsed;
110
+ try {
111
+ parsed = JSON.parse(raw);
112
+ }
113
+ catch {
114
+ return undefined;
115
+ }
116
+ if (typeof parsed !== 'object' || parsed === null)
117
+ return undefined;
118
+ const page = parsed.page;
119
+ if (page === undefined || page === null)
120
+ return { page: undefined };
121
+ if (typeof page !== 'string')
122
+ return undefined;
123
+ const id = makePageId(page);
124
+ return id.ok ? { page: id.value } : undefined;
125
+ }
126
+ // ---------------------------------------------------------------------------
127
+ // mapping policy — WHEN the assistant should open a map, chosen by the user
128
+ // ---------------------------------------------------------------------------
129
+ //
130
+ // Plugin configuration, not map data: it never enters a MellosMap and the
131
+ // ledger never enforces it (the ledger is not a judge). It lives in its own
132
+ // sibling file so hand-editing or corrupting it can never touch a map.
133
+ /** Sibling of the default file holding the project's plugin configuration. */
134
+ export const CONFIG_FILE_NAME = 'config.json';
135
+ /** On-disk config format version. Bump only with a documented migration. */
136
+ export const CONFIG_FILE_VERSION = 1;
137
+ export function configFilePath(defaultFile) {
138
+ return join(dirname(defaultFile), CONFIG_FILE_NAME);
139
+ }
140
+ export const MAPPING_POLICIES = ['always', 'complex', 'on-request'];
141
+ export function makeMappingPolicy(raw) {
142
+ return MAPPING_POLICIES.includes(raw)
143
+ ? ok(raw)
144
+ : err({ kind: 'invalid-policy', raw, allowed: MAPPING_POLICIES });
145
+ }
146
+ /** One line of meaning per policy — the wording every surface repeats. */
147
+ export function describeMappingPolicy(policy) {
148
+ switch (policy) {
149
+ case 'always':
150
+ return 'map every structured task — workflows, designs, architecture, technical dependencies';
151
+ case 'complex':
152
+ return 'map only medium or complex tasks — several modules, a new subsystem, roughly an hour or more';
153
+ case 'on-request':
154
+ return 'map only when the user explicitly asks';
155
+ }
156
+ }
157
+ function isRecord(v) {
158
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
159
+ }
160
+ /**
161
+ * The configured policy, or ok(undefined) when the project has never been
162
+ * set up (missing file or missing key — both mean "nobody chose yet").
163
+ * A file that exists but does not parse is an error, never silently ignored.
164
+ */
165
+ export function loadMappingPolicy(defaultFile) {
166
+ const path = configFilePath(defaultFile);
167
+ let text;
168
+ try {
169
+ text = readFileSync(path, 'utf8');
170
+ }
171
+ catch (e) {
172
+ if (e.code === 'ENOENT')
173
+ return ok(undefined);
174
+ throw e; // unexpected I/O fault: fail fast, nothing meaningful to recover here
175
+ }
176
+ let raw;
177
+ try {
178
+ raw = JSON.parse(text);
179
+ }
180
+ catch (e) {
181
+ return err({ kind: 'malformed-json', path, detail: e.message });
182
+ }
183
+ if (!isRecord(raw))
184
+ return err({ kind: 'bad-shape', path, detail: 'root is not an object' });
185
+ if (raw['version'] !== CONFIG_FILE_VERSION) {
186
+ return err({ kind: 'bad-shape', path, detail: `version is ${String(raw['version'])}, expected ${CONFIG_FILE_VERSION}` });
187
+ }
188
+ const rawPolicy = raw['policy'];
189
+ if (rawPolicy === undefined)
190
+ return ok(undefined);
191
+ if (typeof rawPolicy !== 'string')
192
+ return err({ kind: 'bad-shape', path, detail: 'policy is not a string' });
193
+ const policy = makeMappingPolicy(rawPolicy);
194
+ return policy.ok
195
+ ? ok(policy.value)
196
+ : err({ kind: 'bad-shape', path, detail: `policy is "${rawPolicy}", expected one of: ${MAPPING_POLICIES.join(' | ')}` });
197
+ }
198
+ /** Persist the policy atomically (P2), same temp-and-rename as the map files. */
199
+ export function saveMappingPolicy(defaultFile, policy) {
200
+ const path = configFilePath(defaultFile);
201
+ mkdirSync(dirname(path), { recursive: true });
202
+ const tmp = path + '.tmp';
203
+ writeFileSync(tmp, JSON.stringify({ version: CONFIG_FILE_VERSION, policy }, null, 2) + '\n', 'utf8');
204
+ renameSync(tmp, path);
205
+ }
206
+ // ---------------------------------------------------------------------------
207
+ // legacy migration — stores written under the old host-coupled location
208
+ // ---------------------------------------------------------------------------
209
+ //
210
+ // Up to 0.18 the store lived in `.claude/mellos-mapping.*`: the map's home
211
+ // was coupled to one host brand, which turned absurd the moment another host
212
+ // (a harness, Codex) drove the same server. The move is one-time and
213
+ // explicit — entry points call it before touching the store; nothing here
214
+ // runs as a hidden side effect of ordinary loads.
215
+ /** Project-relative location of the pre-0.20 default page file. */
216
+ export const LEGACY_STATE_FILE_RELATIVE_PATH = join('.claude', 'mellos-mapping.json');
217
+ const LEGACY_PAGES_DIR_NAME = 'mellos-mapping.pages';
218
+ const LEGACY_CONFIG_FILE_NAME = 'mellos-mapping.config.json';
219
+ /**
220
+ * Move a legacy `.claude` store — map, pages, and mapping-policy config —
221
+ * into the tool-owned `.mellos` location. Never merges: a project whose new
222
+ * store already holds anything keeps it untouched, whatever the legacy
223
+ * directory still contains.
224
+ * @param defaultFile - the NEW default page path (`<root>/.mellos/map.json`);
225
+ * the legacy store is looked up relative to `<root>`.
226
+ * @returns whether a legacy store was moved.
227
+ */
228
+ export function migrateLegacyStore(defaultFile) {
229
+ const projectRoot = dirname(dirname(defaultFile));
230
+ const legacyDefault = join(projectRoot, LEGACY_STATE_FILE_RELATIVE_PATH);
231
+ const legacyPages = join(dirname(legacyDefault), LEGACY_PAGES_DIR_NAME);
232
+ const legacyConfig = join(dirname(legacyDefault), LEGACY_CONFIG_FILE_NAME);
233
+ const hasLegacy = existsSync(legacyDefault) || existsSync(legacyPages) || existsSync(legacyConfig);
234
+ const hasCurrent = existsSync(defaultFile)
235
+ || existsSync(join(dirname(defaultFile), PAGES_DIR_NAME))
236
+ || existsSync(configFilePath(defaultFile));
237
+ if (!hasLegacy || hasCurrent)
238
+ return false;
239
+ mkdirSync(dirname(defaultFile), { recursive: true });
240
+ if (existsSync(legacyDefault))
241
+ renameSync(legacyDefault, defaultFile);
242
+ if (existsSync(legacyPages))
243
+ renameSync(legacyPages, join(dirname(defaultFile), PAGES_DIR_NAME));
244
+ if (existsSync(legacyConfig))
245
+ renameSync(legacyConfig, configFilePath(defaultFile));
246
+ return true;
247
+ }
248
+ /** Load and validate the map file at `path`. */
249
+ export function loadMapFile(path) {
250
+ let text;
251
+ try {
252
+ text = readFileSync(path, 'utf8');
253
+ }
254
+ catch (e) {
255
+ const code = e.code;
256
+ if (code === 'ENOENT')
257
+ return err({ kind: 'not-found', path });
258
+ throw e; // unexpected I/O fault: fail fast, nothing meaningful to recover here
259
+ }
260
+ let raw;
261
+ try {
262
+ raw = JSON.parse(text);
263
+ }
264
+ catch (e) {
265
+ // Expected at this boundary: hand-edited files, or a reader racing a
266
+ // non-atomic writer from a foreign tool.
267
+ return err({ kind: 'malformed-json', path, detail: e.message });
268
+ }
269
+ return parseMap(raw, path);
270
+ }
271
+ /**
272
+ * Write the map to `path` atomically (P2): serialize to `<path>.tmp` in the
273
+ * same directory, then rename over the target. Creates the parent directory
274
+ * if missing.
275
+ */
276
+ export function saveMapFile(path, map) {
277
+ mkdirSync(dirname(path), { recursive: true });
278
+ const tmp = path + '.tmp';
279
+ writeFileSync(tmp, serializeMap(map), 'utf8');
280
+ renameSync(tmp, path);
281
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mellos-mapping",
3
- "version": "0.18.0",
3
+ "version": "0.20.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",
@@ -27,11 +27,39 @@
27
27
  "mellos-mapping": "dist/server.mjs",
28
28
  "mellos-mapping-watch": "dist/watch.mjs"
29
29
  },
30
+ "exports": {
31
+ "./domain/types": {
32
+ "types": "./lib/domain/types.d.ts",
33
+ "default": "./lib/domain/types.js"
34
+ },
35
+ "./domain/ops": {
36
+ "types": "./lib/domain/ops.d.ts",
37
+ "default": "./lib/domain/ops.js"
38
+ },
39
+ "./format": {
40
+ "types": "./lib/store/format.d.ts",
41
+ "default": "./lib/store/format.js"
42
+ },
43
+ "./store": {
44
+ "types": "./lib/store/store.d.ts",
45
+ "default": "./lib/store/store.js"
46
+ },
47
+ "./semantics": {
48
+ "types": "./lib/semantics/semantics.d.ts",
49
+ "default": "./lib/semantics/semantics.js"
50
+ },
51
+ "./render": {
52
+ "types": "./lib/render/render.d.ts",
53
+ "default": "./lib/render/render.js"
54
+ },
55
+ "./package.json": "./package.json"
56
+ },
30
57
  "publishConfig": {
31
58
  "registry": "https://registry.npmjs.org/"
32
59
  },
33
60
  "files": [
34
61
  "dist",
62
+ "lib",
35
63
  "README.zh-CN.md",
36
64
  "scripts/codex-register.mjs",
37
65
  "scripts/open-pane.mjs"
@@ -42,7 +70,7 @@
42
70
  "scripts": {
43
71
  "test": "vitest run",
44
72
  "typecheck": "tsc --noEmit",
45
- "build": "node build.mjs",
73
+ "build": "node build.mjs && tsc -p tsconfig.lib.json",
46
74
  "verify": "npm run typecheck && npm run test && npm run build"
47
75
  },
48
76
  "devDependencies": {
@@ -45,4 +45,4 @@ if (added.error !== undefined || added.status !== 0) {
45
45
  }
46
46
  process.stdout.write(added.stdout ?? '');
47
47
  console.log(`mellos-mapping MCP registered with Codex: node ${serverPath}`);
48
- console.log('State files resolve to each session’s working directory (.claude/mellos-mapping.json).');
48
+ console.log('State files resolve to each session’s working directory (.mellos/map.json).');
@@ -84,7 +84,7 @@ if (!existsSync(watchPath)) {
84
84
  console.error(`watcher not found (is the plugin built?): ${watchPath}`);
85
85
  process.exit(1);
86
86
  }
87
- const mapFile = join(projectDir, '.claude', 'mellos-mapping.json');
87
+ const mapFile = join(projectDir, '.mellos', 'map.json');
88
88
 
89
89
  function runPowerShell(script) {
90
90
  const encoded = Buffer.from(script, 'utf16le').toString('base64');