mellos-mapping 0.20.3 → 0.23.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.
Files changed (75) hide show
  1. package/README.md +130 -69
  2. package/README.zh-CN.md +126 -65
  3. package/dist/hook-session-start.mjs +28 -20
  4. package/dist/mmap.mjs +303 -171
  5. package/dist/preview.mjs +1457 -0
  6. package/dist/server.mjs +1984 -741
  7. package/dist/store-paths.mjs +69 -22
  8. package/dist/terminal-worker.mjs +3331 -0
  9. package/dist/watch.mjs +957 -571
  10. package/dist/web/TERMINAL-LICENSES.txt +70 -0
  11. package/dist/web/app.css +967 -0
  12. package/dist/web/app.js +1356 -0
  13. package/dist/web/index.html +9 -0
  14. package/dist/web/terminal.css +9 -0
  15. package/dist/web/terminal.html +7 -0
  16. package/dist/web/terminal.js +9293 -0
  17. package/dist/web/xterm.css +285 -0
  18. package/dist/web.mjs +5646 -0
  19. package/docs/codex.md +189 -0
  20. package/docs/map-api.md +148 -0
  21. package/lib/domain/context.d.ts +11 -0
  22. package/lib/domain/context.js +27 -0
  23. package/lib/domain/text.d.ts +9 -0
  24. package/lib/domain/text.js +54 -0
  25. package/lib/domain/types.d.ts +3 -0
  26. package/lib/preview/index.d.ts +3 -0
  27. package/lib/preview/index.js +3 -0
  28. package/lib/preview/markdown.d.ts +8 -0
  29. package/lib/preview/markdown.js +54 -0
  30. package/lib/preview/presentation.d.ts +6 -0
  31. package/lib/preview/presentation.js +14 -0
  32. package/lib/preview/publisher.d.ts +23 -0
  33. package/lib/preview/publisher.js +143 -0
  34. package/lib/preview/svg.d.ts +3 -0
  35. package/lib/preview/svg.js +74 -0
  36. package/lib/preview/text.d.ts +4 -0
  37. package/lib/preview/text.js +13 -0
  38. package/lib/render/canvas.d.ts +1 -1
  39. package/lib/render/canvas.js +4 -2
  40. package/lib/render/draw.js +9 -6
  41. package/lib/render/render.d.ts +6 -0
  42. package/lib/render/render.js +50 -18
  43. package/lib/render/width.js +3 -1
  44. package/lib/store/atomic.d.ts +18 -0
  45. package/lib/store/atomic.js +86 -0
  46. package/lib/store/channels.d.ts +40 -0
  47. package/lib/store/channels.js +135 -0
  48. package/lib/store/format.js +21 -4
  49. package/lib/store/json-text.d.ts +9 -0
  50. package/lib/store/json-text.js +16 -0
  51. package/lib/store/maps.d.ts +12 -0
  52. package/lib/store/maps.js +42 -0
  53. package/lib/store/migration.d.ts +12 -0
  54. package/lib/store/migration.js +46 -0
  55. package/lib/store/pages.d.ts +46 -0
  56. package/lib/store/pages.js +89 -0
  57. package/lib/store/policy.d.ts +74 -0
  58. package/lib/store/policy.js +144 -0
  59. package/lib/store/project.d.ts +2 -0
  60. package/lib/store/project.js +29 -0
  61. package/lib/store/store.d.ts +15 -257
  62. package/lib/store/store.js +16 -695
  63. package/lib/store/transaction.d.ts +12 -0
  64. package/lib/store/transaction.js +91 -0
  65. package/lib/store/viewers.d.ts +81 -0
  66. package/lib/store/viewers.js +186 -0
  67. package/package.json +27 -5
  68. package/scripts/codex-cli.mjs +42 -0
  69. package/scripts/codex-register.mjs +27 -94
  70. package/scripts/mmap.mjs +27 -17
  71. package/scripts/open-pane.mjs +37 -18
  72. package/scripts/pane-core.mjs +57 -220
  73. package/scripts/terminal-session.mjs +137 -0
  74. package/scripts/tmux-session.mjs +90 -0
  75. package/scripts/watcher-command.mjs +16 -0
@@ -0,0 +1,135 @@
1
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { makePageId } from './format.js';
4
+ import { isRecord, stripBom } from './json-text.js';
5
+ import { viewersDirPath } from './viewers.js';
6
+ // ---------------------------------------------------------------------------
7
+ // focus requests — "show this page" messages from pane openers to the watcher
8
+ // ---------------------------------------------------------------------------
9
+ //
10
+ // State files flow one way, MCP server → watcher; a launcher that wants an
11
+ // ALREADY-RUNNING pane to show a particular page has no channel to it. The
12
+ // focus file is that channel, one-shot on purpose: the watcher consumes the
13
+ // request AND DELETES the file, so a request lives about one poll tick —
14
+ // nothing stale survives to misdirect tomorrow's pane, and the project's git
15
+ // status barely ever sees the file exist.
16
+ /** Sibling of the default file carrying a one-shot "show this page" request. */
17
+ export const FOCUS_FILE_NAME = 'focus';
18
+ export function focusFilePath(defaultFile, pid) {
19
+ return paneChannelPath(defaultFile, FOCUS_FILE_NAME, pid);
20
+ }
21
+ /** A targeted message cannot be consumed by another pane of the same project. */
22
+ function paneChannelPath(defaultFile, channel, pid) {
23
+ if (pid === undefined)
24
+ return join(dirname(defaultFile), channel);
25
+ if (!Number.isSafeInteger(pid) || pid <= 0)
26
+ throw new Error('Invalid pane process id');
27
+ return join(viewersDirPath(defaultFile), `${pid}.${channel}`);
28
+ }
29
+ /**
30
+ * Consume a pending focus request: read it, delete the file, return it.
31
+ * Absent file — the overwhelmingly common case — or junk content means no
32
+ * request; the channel is best-effort and junk is swept by the same delete.
33
+ */
34
+ export function takeFocusRequest(defaultFile, pid) {
35
+ const targeted = pid === undefined ? undefined : focusFilePath(defaultFile, pid);
36
+ const path = targeted !== undefined && existsSync(targeted) ? targeted : focusFilePath(defaultFile);
37
+ let raw;
38
+ try {
39
+ raw = readFileSync(path, 'utf8');
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ try {
45
+ rmSync(path, { force: true });
46
+ }
47
+ catch {
48
+ // deletion is a courtesy: re-consuming next tick is harmless because
49
+ // switching to the already-shown page is a no-op
50
+ }
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(raw);
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ if (typeof parsed !== 'object' || parsed === null)
59
+ return undefined;
60
+ const page = parsed.page;
61
+ if (page === undefined || page === null)
62
+ return { page: undefined };
63
+ if (typeof page !== 'string')
64
+ return undefined;
65
+ const id = makePageId(page);
66
+ return id.ok ? { page: id.value } : undefined;
67
+ }
68
+ // ---------------------------------------------------------------------------
69
+ // quit requests — "close yourself" messages from the toggle to the watcher
70
+ // ---------------------------------------------------------------------------
71
+ //
72
+ // The mirror of the focus file, and there for the same reason: a human who
73
+ // types `mmap` in some OTHER terminal has no channel to the pane that is
74
+ // already running. The quit file is that channel, one-shot on purpose — the
75
+ // watcher consumes the request AND DELETES the file, so a request lives about
76
+ // one poll tick and nothing stale survives to close tomorrow's pane.
77
+ //
78
+ // The request carries no payload. A pane belongs to one store, so "close the
79
+ // pane watching this store" has nothing to say beyond being asked.
80
+ /** Sibling of the default file carrying a one-shot "close the pane" request. */
81
+ export const QUIT_FILE_NAME = 'quit';
82
+ export function quitFilePath(defaultFile, pid) {
83
+ return paneChannelPath(defaultFile, QUIT_FILE_NAME, pid);
84
+ }
85
+ /**
86
+ * Consume a pending quit request: read it, delete the file, say whether there
87
+ * was one. Absent file — the overwhelmingly common case — or content that is
88
+ * not a JSON object means NO request; the channel is best-effort and junk is
89
+ * swept by the same delete.
90
+ *
91
+ * The empty JSON object is the whole grammar. It exists so that a stray file
92
+ * of this name — an editor backup, a half-written write from a foreign tool —
93
+ * cannot take a live pane down by accident; a pane closing is the one thing
94
+ * in this channel a user cannot undo by waiting.
95
+ */
96
+ export function takeQuitRequest(defaultFile, pid) {
97
+ const targeted = pid === undefined ? undefined : quitFilePath(defaultFile, pid);
98
+ const path = targeted !== undefined && existsSync(targeted) ? targeted : quitFilePath(defaultFile);
99
+ let raw;
100
+ try {
101
+ raw = readFileSync(path, 'utf8');
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ sweepQuitRequest(defaultFile, path === targeted ? pid : undefined);
107
+ let parsed;
108
+ try {
109
+ parsed = JSON.parse(stripBom(raw));
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ return isRecord(parsed);
115
+ }
116
+ /**
117
+ * Delete a quit request WITHOUT acting on it — the same file, read as a
118
+ * leftover rather than as a message.
119
+ *
120
+ * A toggle that wrote the request and then lost its watcher (a crash, a
121
+ * closed window, a `taskkill`) leaves the file behind, and the next pane to
122
+ * open would consume it on its first tick and close instantly. The watcher
123
+ * sweeps at STARTUP for exactly that: a request that predates the pane cannot
124
+ * have been addressed to it. Best-effort, like every delete in this channel.
125
+ */
126
+ export function sweepQuitRequest(defaultFile, pid) {
127
+ try {
128
+ rmSync(quitFilePath(defaultFile, pid), { force: true });
129
+ }
130
+ catch {
131
+ // The file is unreachable for some reason the next tick will meet again;
132
+ // re-consuming a request we cannot delete only closes a pane the user
133
+ // asked to close.
134
+ }
135
+ }
@@ -21,6 +21,8 @@
21
21
  * ./store.ts, the Node-side half.
22
22
  */
23
23
  import { declareGroup, declareLane, declareLayer, declareNode, linkNodes, setKind, setTitle, updateNode } from '../domain/ops.js';
24
+ import { mapTextError } from '../domain/text.js';
25
+ import { sourceError, contextError } from '../domain/context.js';
24
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';
25
27
  /** On-disk format version. Bump only with a documented migration. */
26
28
  export const STATE_FILE_VERSION = 1;
@@ -98,8 +100,8 @@ function optionalString(rec, key, where, path) {
98
100
  export function parseMap(raw, path) {
99
101
  if (!isRecord(raw))
100
102
  return err({ kind: 'bad-shape', path, detail: 'root is not an object' });
101
- if (raw['version'] !== STATE_FILE_VERSION) {
102
- 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` });
103
105
  }
104
106
  const layers = arrayField(raw, 'layers', path, 'required');
105
107
  if (!layers.ok)
@@ -117,6 +119,12 @@ export function parseMap(raw, path) {
117
119
  if (!groups.ok)
118
120
  return groups;
119
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
+ }
120
128
  const title = optionalString(raw, 'title', 'map', path);
121
129
  if (!title.ok)
122
130
  return title;
@@ -291,6 +299,12 @@ export function parseMap(raw, path) {
291
299
  return err({ kind: 'invariant-violation', path, violation: updated.error });
292
300
  map = updated.value;
293
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
+ }
294
308
  }
295
309
  for (const [i, rawEdge] of edges.value.entries()) {
296
310
  const where = `edges[${i}]`;
@@ -316,12 +330,15 @@ export function parseMap(raw, path) {
316
330
  return err({ kind: 'invariant-violation', path, violation: linked.error });
317
331
  map = linked.value;
318
332
  }
319
- return ok(map);
333
+ const textError = mapTextError(map);
334
+ return textError ? err({ kind: 'bad-shape', path, detail: textError }) : ok(map);
320
335
  }
321
336
  /** Serialize a map into the on-disk shape. Inverse of parseMap for valid maps (F1). */
322
337
  export function serializeMap(map) {
323
338
  const body = {
324
- 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 } : {}),
325
342
  ...(map.title !== undefined ? { title: map.title } : {}),
326
343
  ...(map.kind !== undefined ? { kind: map.kind } : {}),
327
344
  layers: map.layers,
@@ -0,0 +1,9 @@
1
+ export declare function isRecord(v: unknown): v is Record<string, unknown>;
2
+ /**
3
+ * Drop a leading UTF-8 byte-order mark. Windows editors (Notepad, some
4
+ * PowerShell redirections) add one when a human edits a state file by hand,
5
+ * and JSON.parse refuses the result — an invisible character would otherwise
6
+ * read as "your map is corrupt". The BOM carries no meaning for us: the
7
+ * files are UTF-8 by contract.
8
+ */
9
+ export declare function stripBom(text: string): string;
@@ -0,0 +1,16 @@
1
+ // ---------------------------------------------------------------------------
2
+ // reading text that a human may have touched — shared by every load below
3
+ // ---------------------------------------------------------------------------
4
+ export function isRecord(v) {
5
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
6
+ }
7
+ /**
8
+ * Drop a leading UTF-8 byte-order mark. Windows editors (Notepad, some
9
+ * PowerShell redirections) add one when a human edits a state file by hand,
10
+ * and JSON.parse refuses the result — an invisible character would otherwise
11
+ * read as "your map is corrupt". The BOM carries no meaning for us: the
12
+ * files are UTF-8 by contract.
13
+ */
14
+ export function stripBom(text) {
15
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
16
+ }
@@ -0,0 +1,12 @@
1
+ import { type MellosMap, type Result } from '../domain/types.js';
2
+ import { type StoreError } from './format.js';
3
+ /** Load and validate the map file at `path`. */
4
+ export declare function loadMapFile(path: string): Result<MellosMap, StoreError>;
5
+ /**
6
+ * Write the map to `path` atomically (P2): serialize to a private sibling
7
+ * temp file, then rename it over the target, retrying a rename the OS
8
+ * refuses transiently. Creates the parent directory if missing.
9
+ * @returns ok when the file holds the new map; save-failed when it does not,
10
+ * in which case the previous content is intact and the call may be retried.
11
+ */
12
+ export declare function saveMapFile(path: string, map: MellosMap): Result<void, StoreError>;
@@ -0,0 +1,42 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { err } from '../domain/types.js';
3
+ import { mapTextError } from '../domain/text.js';
4
+ import { parseMap, serializeMap } from './format.js';
5
+ import { writeFileAtomic } from './atomic.js';
6
+ import { stripBom } from './json-text.js';
7
+ /** Load and validate the map file at `path`. */
8
+ export function loadMapFile(path) {
9
+ let text;
10
+ try {
11
+ text = readFileSync(path, 'utf8');
12
+ }
13
+ catch (e) {
14
+ const code = e.code;
15
+ if (code === 'ENOENT')
16
+ return err({ kind: 'not-found', path });
17
+ throw e; // unexpected I/O fault: fail fast, nothing meaningful to recover here
18
+ }
19
+ let raw;
20
+ try {
21
+ raw = JSON.parse(stripBom(text));
22
+ }
23
+ catch (e) {
24
+ // Expected at this boundary: hand-edited files, or a reader racing a
25
+ // non-atomic writer from a foreign tool.
26
+ return err({ kind: 'malformed-json', path, detail: e.message });
27
+ }
28
+ return parseMap(raw, path);
29
+ }
30
+ /**
31
+ * Write the map to `path` atomically (P2): serialize to a private sibling
32
+ * temp file, then rename it over the target, retrying a rename the OS
33
+ * refuses transiently. Creates the parent directory if missing.
34
+ * @returns ok when the file holds the new map; save-failed when it does not,
35
+ * in which case the previous content is intact and the call may be retried.
36
+ */
37
+ export function saveMapFile(path, map) {
38
+ const textError = mapTextError(map);
39
+ if (textError)
40
+ return err({ kind: 'save-failed', path, detail: textError });
41
+ return writeFileAtomic(path, serializeMap(map));
42
+ }
@@ -0,0 +1,12 @@
1
+ /** Project-relative location of the pre-0.20 default page file. */
2
+ export declare const LEGACY_STATE_FILE_RELATIVE_PATH: string;
3
+ /**
4
+ * Move a legacy `.claude` store — map, pages, and mapping-policy config —
5
+ * into the tool-owned `.mellos` location. Never merges: a project whose new
6
+ * store already holds anything keeps it untouched, whatever the legacy
7
+ * directory still contains.
8
+ * @param defaultFile - the NEW default page path (`<root>/.mellos/map.json`);
9
+ * the legacy store is looked up relative to `<root>`.
10
+ * @returns whether a legacy store was moved.
11
+ */
12
+ export declare function migrateLegacyStore(defaultFile: string): boolean;
@@ -0,0 +1,46 @@
1
+ import { existsSync, mkdirSync, renameSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { PAGES_DIR_NAME } from './pages.js';
4
+ import { configFilePath } from './policy.js';
5
+ // ---------------------------------------------------------------------------
6
+ // legacy migration — stores written under the old host-coupled location
7
+ // ---------------------------------------------------------------------------
8
+ //
9
+ // Up to 0.19 the store lived in `.claude/mellos-mapping.*`: the map's home
10
+ // was coupled to one host brand, which turned absurd the moment another host
11
+ // (a harness, Codex) drove the same server. The move is one-time and
12
+ // explicit — entry points call it before touching the store; nothing here
13
+ // runs as a hidden side effect of ordinary loads.
14
+ /** Project-relative location of the pre-0.20 default page file. */
15
+ export const LEGACY_STATE_FILE_RELATIVE_PATH = join('.claude', 'mellos-mapping.json');
16
+ const LEGACY_PAGES_DIR_NAME = 'mellos-mapping.pages';
17
+ const LEGACY_CONFIG_FILE_NAME = 'mellos-mapping.config.json';
18
+ /**
19
+ * Move a legacy `.claude` store — map, pages, and mapping-policy config —
20
+ * into the tool-owned `.mellos` location. Never merges: a project whose new
21
+ * store already holds anything keeps it untouched, whatever the legacy
22
+ * directory still contains.
23
+ * @param defaultFile - the NEW default page path (`<root>/.mellos/map.json`);
24
+ * the legacy store is looked up relative to `<root>`.
25
+ * @returns whether a legacy store was moved.
26
+ */
27
+ export function migrateLegacyStore(defaultFile) {
28
+ const projectRoot = dirname(dirname(defaultFile));
29
+ const legacyDefault = join(projectRoot, LEGACY_STATE_FILE_RELATIVE_PATH);
30
+ const legacyPages = join(dirname(legacyDefault), LEGACY_PAGES_DIR_NAME);
31
+ const legacyConfig = join(dirname(legacyDefault), LEGACY_CONFIG_FILE_NAME);
32
+ const hasLegacy = existsSync(legacyDefault) || existsSync(legacyPages) || existsSync(legacyConfig);
33
+ const hasCurrent = existsSync(defaultFile)
34
+ || existsSync(join(dirname(defaultFile), PAGES_DIR_NAME))
35
+ || existsSync(configFilePath(defaultFile));
36
+ if (!hasLegacy || hasCurrent)
37
+ return false;
38
+ mkdirSync(dirname(defaultFile), { recursive: true });
39
+ if (existsSync(legacyDefault))
40
+ renameSync(legacyDefault, defaultFile);
41
+ if (existsSync(legacyPages))
42
+ renameSync(legacyPages, join(dirname(defaultFile), PAGES_DIR_NAME));
43
+ if (existsSync(legacyConfig))
44
+ renameSync(legacyConfig, configFilePath(defaultFile));
45
+ return true;
46
+ }
@@ -0,0 +1,46 @@
1
+ import { type Result } from '../domain/types.js';
2
+ import { type StoreError } from './format.js';
3
+ import { type PageId } from './format.js';
4
+ /**
5
+ * The directory a store lives in, under a project root or under a user's home.
6
+ * It is tool-owned: the map belongs to mellos-mapping, not to whichever host
7
+ * (Claude Code, Codex, a harness) happens to drive the server, so no host
8
+ * brand appears in the path. Pre-0.20 stores under `.claude/` are moved once
9
+ * by {@link migrateLegacyStore}.
10
+ */
11
+ export declare const STORE_DIR_NAME = ".mellos";
12
+ /** Project-relative location of the DEFAULT page's state file. */
13
+ export declare const STATE_FILE_RELATIVE_PATH: string;
14
+ /** Directory (next to the default file) holding the named pages. */
15
+ export declare const PAGES_DIR_NAME = "pages";
16
+ /** Where a page's map file lives, given the default page's file path. */
17
+ export declare function pageFilePath(defaultFile: string, page?: PageId): string;
18
+ /** The page id a file path denotes; undefined = the default page. */
19
+ export declare function pageIdOfFile(defaultFile: string, path: string): PageId | undefined;
20
+ /** Existing page files: the default page first (when present), then named pages sorted by slug. */
21
+ export declare function listPageFiles(defaultFile: string): string[];
22
+ /**
23
+ * Delete one page's file — a named page, or the DEFAULT page (whose file is
24
+ * optional by design, so removing it is a legal state, not a mutilation).
25
+ *
26
+ * Preconditions: none. Postcondition on ok: no file at `path` — an already
27
+ * absent one is ok too, because the goal state is what is promised, not the
28
+ * act. Postcondition on error: the file is still there and the caller may
29
+ * retry or report; the errno is carried in the detail.
30
+ *
31
+ * Concurrency, stated plainly: deletion races a concurrent writer and THE
32
+ * WRITER WINS. A server saving that page while this runs simply recreates the
33
+ * file (its rename is atomic and needs no existing target), so the page comes
34
+ * back. That is accepted rather than defended against — the store has no
35
+ * lost-update protection anywhere (see the module header), and locking one
36
+ * operation would only make the race rarer, never absent, while claiming
37
+ * otherwise. Pages are the isolation unit: nobody deletes a page another
38
+ * session is writing.
39
+ *
40
+ * What it deliberately does NOT do: sweep `<path>.<pid>.<random>.tmp`
41
+ * siblings. Those temps are private to a save IN FLIGHT, and a live writer
42
+ * whose temp vanished would fail its rename — turning a harmless leftover
43
+ * into a broken save. A stray temp only exists when a write failed AND its
44
+ * own cleanup failed; it is inert, and the README documents it.
45
+ */
46
+ export declare function deletePageFile(path: string): Result<void, StoreError>;
@@ -0,0 +1,89 @@
1
+ import { existsSync, readdirSync, rmSync } from 'node:fs';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import { err, ok } from '../domain/types.js';
4
+ import { errnoOf } from './atomic.js';
5
+ /**
6
+ * The directory a store lives in, under a project root or under a user's home.
7
+ * It is tool-owned: the map belongs to mellos-mapping, not to whichever host
8
+ * (Claude Code, Codex, a harness) happens to drive the server, so no host
9
+ * brand appears in the path. Pre-0.20 stores under `.claude/` are moved once
10
+ * by {@link migrateLegacyStore}.
11
+ */
12
+ export const STORE_DIR_NAME = '.mellos';
13
+ /** Project-relative location of the DEFAULT page's state file. */
14
+ export const STATE_FILE_RELATIVE_PATH = join(STORE_DIR_NAME, 'map.json');
15
+ // ---------------------------------------------------------------------------
16
+ // pages — a project may keep several maps side by side (one effort = one page)
17
+ // ---------------------------------------------------------------------------
18
+ //
19
+ // The default page IS the classic map.json. Named pages live in a sibling
20
+ // directory, one file each: file-per-page keeps concurrent sessions isolated —
21
+ // two writers on two pages can never clobber each other, because every save
22
+ // renames a whole file.
23
+ /** Directory (next to the default file) holding the named pages. */
24
+ export const PAGES_DIR_NAME = 'pages';
25
+ /** Where a page's map file lives, given the default page's file path. */
26
+ export function pageFilePath(defaultFile, page) {
27
+ return page === undefined ? defaultFile : join(dirname(defaultFile), PAGES_DIR_NAME, `${page}.json`);
28
+ }
29
+ /** The page id a file path denotes; undefined = the default page. */
30
+ export function pageIdOfFile(defaultFile, path) {
31
+ if (path === defaultFile)
32
+ return undefined;
33
+ const name = basename(path);
34
+ return name.endsWith('.json') ? name.slice(0, -'.json'.length) : name;
35
+ }
36
+ /** Existing page files: the default page first (when present), then named pages sorted by slug. */
37
+ export function listPageFiles(defaultFile) {
38
+ const out = [];
39
+ if (existsSync(defaultFile))
40
+ out.push(defaultFile);
41
+ let entries = [];
42
+ try {
43
+ entries = readdirSync(join(dirname(defaultFile), PAGES_DIR_NAME));
44
+ }
45
+ catch {
46
+ // no pages directory — a single-page project, the common case
47
+ }
48
+ for (const e of entries.sort()) {
49
+ if (e.endsWith('.json'))
50
+ out.push(join(dirname(defaultFile), PAGES_DIR_NAME, e));
51
+ }
52
+ return out;
53
+ }
54
+ /**
55
+ * Delete one page's file — a named page, or the DEFAULT page (whose file is
56
+ * optional by design, so removing it is a legal state, not a mutilation).
57
+ *
58
+ * Preconditions: none. Postcondition on ok: no file at `path` — an already
59
+ * absent one is ok too, because the goal state is what is promised, not the
60
+ * act. Postcondition on error: the file is still there and the caller may
61
+ * retry or report; the errno is carried in the detail.
62
+ *
63
+ * Concurrency, stated plainly: deletion races a concurrent writer and THE
64
+ * WRITER WINS. A server saving that page while this runs simply recreates the
65
+ * file (its rename is atomic and needs no existing target), so the page comes
66
+ * back. That is accepted rather than defended against — the store has no
67
+ * lost-update protection anywhere (see the module header), and locking one
68
+ * operation would only make the race rarer, never absent, while claiming
69
+ * otherwise. Pages are the isolation unit: nobody deletes a page another
70
+ * session is writing.
71
+ *
72
+ * What it deliberately does NOT do: sweep `<path>.<pid>.<random>.tmp`
73
+ * siblings. Those temps are private to a save IN FLIGHT, and a live writer
74
+ * whose temp vanished would fail its rename — turning a harmless leftover
75
+ * into a broken save. A stray temp only exists when a write failed AND its
76
+ * own cleanup failed; it is inert, and the README documents it.
77
+ */
78
+ export function deletePageFile(path) {
79
+ try {
80
+ // force: an absent file is the goal state already, not a failure.
81
+ // No `recursive`: a DIRECTORY where a page file belongs is a fault to
82
+ // report, never a tree to erase.
83
+ rmSync(path, { force: true });
84
+ return ok(undefined);
85
+ }
86
+ catch (e) {
87
+ return err({ kind: 'delete-failed', path, detail: errnoOf(e) });
88
+ }
89
+ }
@@ -0,0 +1,74 @@
1
+ import { type Result } from '../domain/types.js';
2
+ import { type StoreError } from './format.js';
3
+ /** Name of the file holding a mapping-policy configuration, in either scope. */
4
+ export declare const CONFIG_FILE_NAME = "config.json";
5
+ /** On-disk config format version. Bump only with a documented migration. */
6
+ export declare const CONFIG_FILE_VERSION = 1;
7
+ /** The PROJECT-scope configuration file: sibling of the default page. */
8
+ export declare function configFilePath(defaultFile: string): string;
9
+ /**
10
+ * The USER-scope configuration file: the same store directory name, under the
11
+ * user's own base directory.
12
+ *
13
+ * @param userBase - the user's home directory. Always passed in, never read
14
+ * from the environment down here: a function that reached for os.homedir()
15
+ * itself would make every spec a gamble on the developer's real
16
+ * configuration, and one of them would eventually write it. Entry points
17
+ * resolve the home once and hand it down.
18
+ */
19
+ export declare function userConfigFilePath(userBase: string): string;
20
+ export declare const MAPPING_POLICIES: readonly ["always", "complex", "on-request"];
21
+ /** How eagerly maps are opened; 'complex' is the behavior of an unconfigured project. */
22
+ export type MappingPolicy = (typeof MAPPING_POLICIES)[number];
23
+ export interface InvalidPolicy {
24
+ readonly kind: 'invalid-policy';
25
+ readonly raw: string;
26
+ readonly allowed: readonly string[];
27
+ }
28
+ export declare function makeMappingPolicy(raw: string): Result<MappingPolicy, InvalidPolicy>;
29
+ /** One line of meaning per policy — the wording every surface repeats. */
30
+ export declare function describeMappingPolicy(policy: MappingPolicy): string;
31
+ /**
32
+ * The policy recorded in ONE configuration file, or ok(undefined) when nobody
33
+ * has chosen there (missing file or missing key — both mean the same thing).
34
+ * A file that exists but does not parse is an error, never silently ignored.
35
+ *
36
+ * @param path - the configuration file itself: {@link configFilePath} for a
37
+ * project, {@link userConfigFilePath} for the user. One loader, two scopes.
38
+ */
39
+ export declare function loadMappingPolicy(path: string): Result<MappingPolicy | undefined, StoreError>;
40
+ /**
41
+ * Persist the policy atomically (P2), same write as the map files.
42
+ * @param path - the configuration file to write; see {@link loadMappingPolicy}.
43
+ * @returns ok when the file holds the policy; save-failed leaves the previous
44
+ * configuration in place.
45
+ */
46
+ export declare function saveMappingPolicy(path: string, policy: MappingPolicy): Result<void, StoreError>;
47
+ /** The two places a mapping policy can be recorded, in override order. */
48
+ export declare const POLICY_SCOPES: readonly ["user", "project"];
49
+ export type PolicyScope = (typeof POLICY_SCOPES)[number];
50
+ /** What both scopes say, and which of them actually governs. */
51
+ export interface MappingPolicyScopes {
52
+ readonly project: MappingPolicy | undefined;
53
+ readonly user: MappingPolicy | undefined;
54
+ /** What to act on. undefined = nobody has chosen yet, anywhere. */
55
+ readonly effective: MappingPolicy | undefined;
56
+ /** Where `effective` came from; undefined exactly when `effective` is. */
57
+ readonly source: PolicyScope | undefined;
58
+ }
59
+ /**
60
+ * Resolve the policy that governs this project: the PROJECT file if it names
61
+ * one, otherwise the USER file, otherwise nothing.
62
+ *
63
+ * Both scopes are reported, not just the winner — a surface that says "always"
64
+ * without saying where it came from cannot tell a user why changing their
65
+ * user-level choice did nothing here.
66
+ *
67
+ * @param projectConfigFile - see {@link configFilePath}.
68
+ * @param userConfigFile - see {@link userConfigFilePath}.
69
+ * @returns err as soon as EITHER file exists and is broken, project first: a
70
+ * configuration nobody can read is not the same as a configuration nobody
71
+ * wrote, and silently falling through to the other scope would act on a
72
+ * choice the user did not make.
73
+ */
74
+ export declare function effectiveMappingPolicy(projectConfigFile: string, userConfigFile: string): Result<MappingPolicyScopes, StoreError>;