mellos-mapping 0.20.2 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -88
- package/README.zh-CN.md +122 -84
- package/dist/hook-session-start.mjs +25 -19
- package/dist/mmap.mjs +302 -170
- package/dist/preview.mjs +1418 -0
- package/dist/server.mjs +1198 -400
- package/dist/store-paths.mjs +40 -21
- package/dist/terminal-worker.mjs +3188 -0
- package/dist/watch.mjs +523 -280
- package/dist/web/TERMINAL-LICENSES.txt +70 -0
- package/dist/web/app.css +967 -0
- package/dist/web/app.js +1356 -0
- package/dist/web/index.html +9 -0
- package/dist/web/terminal.css +9 -0
- package/dist/web/terminal.html +7 -0
- package/dist/web/terminal.js +9293 -0
- package/dist/web/xterm.css +285 -0
- package/dist/web.mjs +5535 -0
- package/docs/codex.md +183 -0
- package/lib/domain/text.d.ts +9 -0
- package/lib/domain/text.js +43 -0
- package/lib/domain/types.js +10 -1
- package/lib/preview/index.d.ts +3 -0
- package/lib/preview/index.js +3 -0
- package/lib/preview/markdown.d.ts +8 -0
- package/lib/preview/markdown.js +54 -0
- package/lib/preview/presentation.d.ts +6 -0
- package/lib/preview/presentation.js +14 -0
- package/lib/preview/publisher.d.ts +23 -0
- package/lib/preview/publisher.js +143 -0
- package/lib/preview/svg.d.ts +3 -0
- package/lib/preview/svg.js +74 -0
- package/lib/preview/text.d.ts +4 -0
- package/lib/preview/text.js +13 -0
- package/lib/render/canvas.d.ts +1 -1
- package/lib/render/canvas.js +4 -2
- package/lib/render/draw.js +9 -6
- package/lib/render/render.d.ts +6 -0
- package/lib/render/render.js +50 -18
- package/lib/render/width.js +3 -1
- package/lib/store/atomic.d.ts +18 -0
- package/lib/store/atomic.js +86 -0
- package/lib/store/channels.d.ts +40 -0
- package/lib/store/channels.js +135 -0
- package/lib/store/format.js +3 -1
- package/lib/store/json-text.d.ts +9 -0
- package/lib/store/json-text.js +16 -0
- package/lib/store/maps.d.ts +12 -0
- package/lib/store/maps.js +42 -0
- package/lib/store/migration.d.ts +12 -0
- package/lib/store/migration.js +46 -0
- package/lib/store/pages.d.ts +46 -0
- package/lib/store/pages.js +89 -0
- package/lib/store/policy.d.ts +74 -0
- package/lib/store/policy.js +144 -0
- package/lib/store/store.d.ts +9 -256
- package/lib/store/store.js +10 -694
- package/lib/store/viewers.d.ts +81 -0
- package/lib/store/viewers.js +186 -0
- package/package.json +25 -6
- package/scripts/codex-cli.mjs +42 -0
- package/scripts/codex-register.mjs +27 -94
- package/scripts/mmap.mjs +26 -16
- package/scripts/open-pane.mjs +37 -18
- package/scripts/pane-core.mjs +57 -220
- package/scripts/terminal-session.mjs +137 -0
- package/scripts/tmux-session.mjs +90 -0
- package/scripts/watcher-command.mjs +16 -0
|
@@ -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>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { err, ok } from '../domain/types.js';
|
|
4
|
+
import { STORE_DIR_NAME } from './pages.js';
|
|
5
|
+
import { writeFileAtomic } from './atomic.js';
|
|
6
|
+
import { isRecord, stripBom } from './json-text.js';
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// mapping policy — WHEN the assistant should open a map, chosen by the user
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
//
|
|
11
|
+
// Plugin configuration, not map data: it never enters a MellosMap and the
|
|
12
|
+
// ledger never enforces it (the ledger is not a judge). It lives in its own
|
|
13
|
+
// file so hand-editing or corrupting it can never touch a map.
|
|
14
|
+
//
|
|
15
|
+
// TWO SCOPES, one file format:
|
|
16
|
+
//
|
|
17
|
+
// user <home>/.mellos/config.json — the normal one. The question "when
|
|
18
|
+
// should maps open?" is about how somebody works, not about a
|
|
19
|
+
// particular repository, so it is asked ONCE, right after install,
|
|
20
|
+
// and answered for every project they will ever open.
|
|
21
|
+
// project <root>/.mellos/config.json — the override. A project that needs a
|
|
22
|
+
// different answer from its owner's habit says so, and wins.
|
|
23
|
+
//
|
|
24
|
+
// PROJECT beats USER wherever both are set (effectiveMappingPolicy); neither
|
|
25
|
+
// set means nobody has chosen yet, which is the one state that still prompts.
|
|
26
|
+
// Both are read and written by the same pair of functions, which take the
|
|
27
|
+
// CONFIG FILE PATH — not a map path — precisely so neither scope can grow a
|
|
28
|
+
// loader of its own.
|
|
29
|
+
/** Name of the file holding a mapping-policy configuration, in either scope. */
|
|
30
|
+
export const CONFIG_FILE_NAME = 'config.json';
|
|
31
|
+
/** On-disk config format version. Bump only with a documented migration. */
|
|
32
|
+
export const CONFIG_FILE_VERSION = 1;
|
|
33
|
+
/** The PROJECT-scope configuration file: sibling of the default page. */
|
|
34
|
+
export function configFilePath(defaultFile) {
|
|
35
|
+
return join(dirname(defaultFile), CONFIG_FILE_NAME);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The USER-scope configuration file: the same store directory name, under the
|
|
39
|
+
* user's own base directory.
|
|
40
|
+
*
|
|
41
|
+
* @param userBase - the user's home directory. Always passed in, never read
|
|
42
|
+
* from the environment down here: a function that reached for os.homedir()
|
|
43
|
+
* itself would make every spec a gamble on the developer's real
|
|
44
|
+
* configuration, and one of them would eventually write it. Entry points
|
|
45
|
+
* resolve the home once and hand it down.
|
|
46
|
+
*/
|
|
47
|
+
export function userConfigFilePath(userBase) {
|
|
48
|
+
return join(userBase, STORE_DIR_NAME, CONFIG_FILE_NAME);
|
|
49
|
+
}
|
|
50
|
+
export const MAPPING_POLICIES = ['always', 'complex', 'on-request'];
|
|
51
|
+
export function makeMappingPolicy(raw) {
|
|
52
|
+
return MAPPING_POLICIES.includes(raw)
|
|
53
|
+
? ok(raw)
|
|
54
|
+
: err({ kind: 'invalid-policy', raw, allowed: MAPPING_POLICIES });
|
|
55
|
+
}
|
|
56
|
+
/** One line of meaning per policy — the wording every surface repeats. */
|
|
57
|
+
export function describeMappingPolicy(policy) {
|
|
58
|
+
switch (policy) {
|
|
59
|
+
case 'always':
|
|
60
|
+
return 'map every structured task — workflows, designs, architecture, technical dependencies';
|
|
61
|
+
case 'complex':
|
|
62
|
+
return 'map only medium or complex tasks — several modules, a new subsystem, roughly an hour or more';
|
|
63
|
+
case 'on-request':
|
|
64
|
+
return 'map only when the user explicitly asks';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The policy recorded in ONE configuration file, or ok(undefined) when nobody
|
|
69
|
+
* has chosen there (missing file or missing key — both mean the same thing).
|
|
70
|
+
* A file that exists but does not parse is an error, never silently ignored.
|
|
71
|
+
*
|
|
72
|
+
* @param path - the configuration file itself: {@link configFilePath} for a
|
|
73
|
+
* project, {@link userConfigFilePath} for the user. One loader, two scopes.
|
|
74
|
+
*/
|
|
75
|
+
export function loadMappingPolicy(path) {
|
|
76
|
+
let text;
|
|
77
|
+
try {
|
|
78
|
+
text = readFileSync(path, 'utf8');
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
if (e.code === 'ENOENT')
|
|
82
|
+
return ok(undefined);
|
|
83
|
+
throw e; // unexpected I/O fault: fail fast, nothing meaningful to recover here
|
|
84
|
+
}
|
|
85
|
+
let raw;
|
|
86
|
+
try {
|
|
87
|
+
raw = JSON.parse(stripBom(text));
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
return err({ kind: 'malformed-json', path, detail: e.message });
|
|
91
|
+
}
|
|
92
|
+
if (!isRecord(raw))
|
|
93
|
+
return err({ kind: 'bad-shape', path, detail: 'root is not an object' });
|
|
94
|
+
if (raw['version'] !== CONFIG_FILE_VERSION) {
|
|
95
|
+
return err({ kind: 'bad-shape', path, detail: `version is ${String(raw['version'])}, expected ${CONFIG_FILE_VERSION}` });
|
|
96
|
+
}
|
|
97
|
+
const rawPolicy = raw['policy'];
|
|
98
|
+
if (rawPolicy === undefined)
|
|
99
|
+
return ok(undefined);
|
|
100
|
+
if (typeof rawPolicy !== 'string')
|
|
101
|
+
return err({ kind: 'bad-shape', path, detail: 'policy is not a string' });
|
|
102
|
+
const policy = makeMappingPolicy(rawPolicy);
|
|
103
|
+
return policy.ok
|
|
104
|
+
? ok(policy.value)
|
|
105
|
+
: err({ kind: 'bad-shape', path, detail: `policy is "${rawPolicy}", expected one of: ${MAPPING_POLICIES.join(' | ')}` });
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Persist the policy atomically (P2), same write as the map files.
|
|
109
|
+
* @param path - the configuration file to write; see {@link loadMappingPolicy}.
|
|
110
|
+
* @returns ok when the file holds the policy; save-failed leaves the previous
|
|
111
|
+
* configuration in place.
|
|
112
|
+
*/
|
|
113
|
+
export function saveMappingPolicy(path, policy) {
|
|
114
|
+
const body = JSON.stringify({ version: CONFIG_FILE_VERSION, policy }, null, 2) + '\n';
|
|
115
|
+
return writeFileAtomic(path, body);
|
|
116
|
+
}
|
|
117
|
+
/** The two places a mapping policy can be recorded, in override order. */
|
|
118
|
+
export const POLICY_SCOPES = ['user', 'project'];
|
|
119
|
+
/**
|
|
120
|
+
* Resolve the policy that governs this project: the PROJECT file if it names
|
|
121
|
+
* one, otherwise the USER file, otherwise nothing.
|
|
122
|
+
*
|
|
123
|
+
* Both scopes are reported, not just the winner — a surface that says "always"
|
|
124
|
+
* without saying where it came from cannot tell a user why changing their
|
|
125
|
+
* user-level choice did nothing here.
|
|
126
|
+
*
|
|
127
|
+
* @param projectConfigFile - see {@link configFilePath}.
|
|
128
|
+
* @param userConfigFile - see {@link userConfigFilePath}.
|
|
129
|
+
* @returns err as soon as EITHER file exists and is broken, project first: a
|
|
130
|
+
* configuration nobody can read is not the same as a configuration nobody
|
|
131
|
+
* wrote, and silently falling through to the other scope would act on a
|
|
132
|
+
* choice the user did not make.
|
|
133
|
+
*/
|
|
134
|
+
export function effectiveMappingPolicy(projectConfigFile, userConfigFile) {
|
|
135
|
+
const project = loadMappingPolicy(projectConfigFile);
|
|
136
|
+
if (!project.ok)
|
|
137
|
+
return project;
|
|
138
|
+
const user = loadMappingPolicy(userConfigFile);
|
|
139
|
+
if (!user.ok)
|
|
140
|
+
return user;
|
|
141
|
+
const effective = project.value ?? user.value;
|
|
142
|
+
const source = project.value !== undefined ? 'project' : user.value !== undefined ? 'user' : undefined;
|
|
143
|
+
return ok({ project: project.value, user: user.value, effective, source });
|
|
144
|
+
}
|
package/lib/store/store.d.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The state file IS the event bus of the whole plugin: the MCP server writes
|
|
5
5
|
* it, the terminal watcher polls it — and the watcher reports back what it is
|
|
6
|
-
* showing (see viewers below)
|
|
7
|
-
* is
|
|
6
|
+
* showing (see viewers below). These are process reports; terminal visibility
|
|
7
|
+
* is checked separately by the host adapter. The file FORMAT (version, page-id
|
|
8
8
|
* grammar, parse/serialize with boundary validation) lives in ./format.ts,
|
|
9
9
|
* pure of I/O so browsers can consume it; this module owns everything that
|
|
10
10
|
* touches the filesystem, and one promise:
|
|
@@ -39,258 +39,11 @@
|
|
|
39
39
|
* Node consumers import everything from here; the format surface is
|
|
40
40
|
* re-exported so persistence has one import site per runtime.
|
|
41
41
|
*/
|
|
42
|
-
import { type MellosMap, type Result } from '../domain/types.js';
|
|
43
|
-
import { type PageId, type StoreError } from './format.js';
|
|
44
42
|
export { STATE_FILE_VERSION, type PageId, makePageId, type StoreError, describeStoreError, parseMap, serializeMap, } from './format.js';
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
export declare const STORE_DIR_NAME = ".mellos";
|
|
53
|
-
/** Project-relative location of the DEFAULT page's state file. */
|
|
54
|
-
export declare const STATE_FILE_RELATIVE_PATH: string;
|
|
55
|
-
/** Directory (next to the default file) holding the named pages. */
|
|
56
|
-
export declare const PAGES_DIR_NAME = "pages";
|
|
57
|
-
/** Where a page's map file lives, given the default page's file path. */
|
|
58
|
-
export declare function pageFilePath(defaultFile: string, page?: PageId): string;
|
|
59
|
-
/** The page id a file path denotes; undefined = the default page. */
|
|
60
|
-
export declare function pageIdOfFile(defaultFile: string, path: string): PageId | undefined;
|
|
61
|
-
/** Existing page files: the default page first (when present), then named pages sorted by slug. */
|
|
62
|
-
export declare function listPageFiles(defaultFile: string): string[];
|
|
63
|
-
/**
|
|
64
|
-
* Delete one page's file — a named page, or the DEFAULT page (whose file is
|
|
65
|
-
* optional by design, so removing it is a legal state, not a mutilation).
|
|
66
|
-
*
|
|
67
|
-
* Preconditions: none. Postcondition on ok: no file at `path` — an already
|
|
68
|
-
* absent one is ok too, because the goal state is what is promised, not the
|
|
69
|
-
* act. Postcondition on error: the file is still there and the caller may
|
|
70
|
-
* retry or report; the errno is carried in the detail.
|
|
71
|
-
*
|
|
72
|
-
* Concurrency, stated plainly: deletion races a concurrent writer and THE
|
|
73
|
-
* WRITER WINS. A server saving that page while this runs simply recreates the
|
|
74
|
-
* file (its rename is atomic and needs no existing target), so the page comes
|
|
75
|
-
* back. That is accepted rather than defended against — the store has no
|
|
76
|
-
* lost-update protection anywhere (see the module header), and locking one
|
|
77
|
-
* operation would only make the race rarer, never absent, while claiming
|
|
78
|
-
* otherwise. Pages are the isolation unit: nobody deletes a page another
|
|
79
|
-
* session is writing.
|
|
80
|
-
*
|
|
81
|
-
* What it deliberately does NOT do: sweep `<path>.<pid>.<random>.tmp`
|
|
82
|
-
* siblings. Those temps are private to a save IN FLIGHT, and a live writer
|
|
83
|
-
* whose temp vanished would fail its rename — turning a harmless leftover
|
|
84
|
-
* into a broken save. A stray temp only exists when a write failed AND its
|
|
85
|
-
* own cleanup failed; it is inert, and the README documents it.
|
|
86
|
-
*/
|
|
87
|
-
export declare function deletePageFile(path: string): Result<void, StoreError>;
|
|
88
|
-
/** Sibling of the default file carrying a one-shot "show this page" request. */
|
|
89
|
-
export declare const FOCUS_FILE_NAME = "focus";
|
|
90
|
-
export declare function focusFilePath(defaultFile: string): string;
|
|
91
|
-
/** A consumed focus request: the page to show (undefined = the default page). */
|
|
92
|
-
export interface FocusRequest {
|
|
93
|
-
readonly page: PageId | undefined;
|
|
94
|
-
}
|
|
95
|
-
/**
|
|
96
|
-
* Consume a pending focus request: read it, delete the file, return it.
|
|
97
|
-
* Absent file — the overwhelmingly common case — or junk content means no
|
|
98
|
-
* request; the channel is best-effort and junk is swept by the same delete.
|
|
99
|
-
*/
|
|
100
|
-
export declare function takeFocusRequest(defaultFile: string): FocusRequest | undefined;
|
|
101
|
-
/** Sibling of the default file carrying a one-shot "close the pane" request. */
|
|
102
|
-
export declare const QUIT_FILE_NAME = "quit";
|
|
103
|
-
export declare function quitFilePath(defaultFile: string): string;
|
|
104
|
-
/**
|
|
105
|
-
* Consume a pending quit request: read it, delete the file, say whether there
|
|
106
|
-
* was one. Absent file — the overwhelmingly common case — or content that is
|
|
107
|
-
* not a JSON object means NO request; the channel is best-effort and junk is
|
|
108
|
-
* swept by the same delete.
|
|
109
|
-
*
|
|
110
|
-
* The empty JSON object is the whole grammar. It exists so that a stray file
|
|
111
|
-
* of this name — an editor backup, a half-written write from a foreign tool —
|
|
112
|
-
* cannot take a live pane down by accident; a pane closing is the one thing
|
|
113
|
-
* in this channel a user cannot undo by waiting.
|
|
114
|
-
*/
|
|
115
|
-
export declare function takeQuitRequest(defaultFile: string): boolean;
|
|
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 declare function sweepQuitRequest(defaultFile: string): void;
|
|
127
|
-
/** Directory (next to the default file) holding one report per live pane. */
|
|
128
|
-
export declare const VIEWERS_DIR_NAME = "viewers";
|
|
129
|
-
/** On-disk report format version. Bump only with a documented migration. */
|
|
130
|
-
export declare const VIEWER_FILE_VERSION = 1;
|
|
131
|
-
/**
|
|
132
|
-
* How often a pane refreshes its report — the pane's timer, and the unit the
|
|
133
|
-
* two thresholds below are counted in.
|
|
134
|
-
*/
|
|
135
|
-
export declare const VIEWER_HEARTBEAT_MS = 1000;
|
|
136
|
-
/**
|
|
137
|
-
* A report older than this is not a pane, it is a pane's remains. Five missed
|
|
138
|
-
* heartbeats: long enough to survive a garbage collection, a slow repaint or
|
|
139
|
-
* a busy disk, short enough that a closed pane is known closed before anyone
|
|
140
|
-
* asks twice.
|
|
141
|
-
*/
|
|
142
|
-
export declare const VIEWER_STALE_MS = 5000;
|
|
143
|
-
/**
|
|
144
|
-
* A report this old is deleted on sight by whoever reads it. A pane that was
|
|
145
|
-
* killed leaves its file behind forever, and a file that outlives every pane
|
|
146
|
-
* would keep answering for one. The gap to VIEWER_STALE_MS is deliberate
|
|
147
|
-
* slack: a machine that just woke from sleep has stale reports whose panes
|
|
148
|
-
* are alive and about to beat again, and there is no reason to make them pay
|
|
149
|
-
* for the sleep with a deleted file.
|
|
150
|
-
*/
|
|
151
|
-
export declare const VIEWER_SWEEP_MS = 60000;
|
|
152
|
-
/** Where a store's viewer reports live, given the default page's file path. */
|
|
153
|
-
export declare function viewersDirPath(defaultFile: string): string;
|
|
154
|
-
/** Where ONE pane's report lives. The pid in the name is the pane's identity. */
|
|
155
|
-
export declare function viewerFilePath(defaultFile: string, pid: number): string;
|
|
156
|
-
/** What a pane says about itself while it is up. */
|
|
157
|
-
export interface ViewerReport {
|
|
158
|
-
/** The page on screen right now; undefined = the default page. */
|
|
159
|
-
readonly page: PageId | undefined;
|
|
160
|
-
/** Auto-follow: whether the pane will switch to whatever page is written next. */
|
|
161
|
-
readonly follow: boolean;
|
|
162
|
-
}
|
|
163
|
-
/** A report fresh enough to be a pane, with whose it is and how old. */
|
|
164
|
-
export interface LiveViewer extends ViewerReport {
|
|
165
|
-
readonly pid: number;
|
|
166
|
-
/** Age of the report in ms — how long ago that pane last said anything. */
|
|
167
|
-
readonly ageMs: number;
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* Publish this pane's report, atomically (P2) — a reader polling the file
|
|
171
|
-
* sees the previous whole report or the new one, never half of one.
|
|
172
|
-
*
|
|
173
|
-
* Preconditions: none; the viewers directory is created if missing.
|
|
174
|
-
* Postcondition on ok: the pane's file holds `report` and its mtime is now,
|
|
175
|
-
* which is what says the pane is alive. On error nothing about the pane is
|
|
176
|
-
* claimed, and the next heartbeat is the whole recovery — so a caller reports
|
|
177
|
-
* a failed beat at most once and keeps beating.
|
|
178
|
-
*/
|
|
179
|
-
export declare function publishViewer(defaultFile: string, pid: number, report: ViewerReport): Result<void, StoreError>;
|
|
180
|
-
/**
|
|
181
|
-
* Take this pane's report back — the pane is going away and says so, rather
|
|
182
|
-
* than leaving readers to wait out VIEWER_STALE_MS for the same conclusion.
|
|
183
|
-
*
|
|
184
|
-
* Best-effort by contract: an exit path is the worst place to raise, and a
|
|
185
|
-
* report nobody could delete goes stale on its own within seconds.
|
|
186
|
-
*/
|
|
187
|
-
export declare function retireViewer(defaultFile: string, pid: number): void;
|
|
188
|
-
/**
|
|
189
|
-
* Every pane currently showing this store, youngest report first.
|
|
190
|
-
*
|
|
191
|
-
* @param nowMs - the caller's clock (Date.now()), passed in so the ageing
|
|
192
|
-
* rules can be tested without waiting for real seconds to pass.
|
|
193
|
-
* @returns the live reports; an EMPTY array means nobody is seeing this map.
|
|
194
|
-
*
|
|
195
|
-
* Reading sweeps: a report past VIEWER_SWEEP_MS is deleted here, because the
|
|
196
|
-
* pane that would have refreshed it is provably gone and no other code runs
|
|
197
|
-
* often enough to notice. A report between stale and sweep is ignored but
|
|
198
|
-
* kept — see VIEWER_SWEEP_MS. Nothing here has an opinion about WHOSE pane a
|
|
199
|
-
* report is: a viewer started by hand counts exactly like one the launcher
|
|
200
|
-
* opened, because the user can see both.
|
|
201
|
-
*/
|
|
202
|
-
export declare function readLiveViewers(defaultFile: string, nowMs: number): readonly LiveViewer[];
|
|
203
|
-
/** Name of the file holding a mapping-policy configuration, in either scope. */
|
|
204
|
-
export declare const CONFIG_FILE_NAME = "config.json";
|
|
205
|
-
/** On-disk config format version. Bump only with a documented migration. */
|
|
206
|
-
export declare const CONFIG_FILE_VERSION = 1;
|
|
207
|
-
/** The PROJECT-scope configuration file: sibling of the default page. */
|
|
208
|
-
export declare function configFilePath(defaultFile: string): string;
|
|
209
|
-
/**
|
|
210
|
-
* The USER-scope configuration file: the same store directory name, under the
|
|
211
|
-
* user's own base directory.
|
|
212
|
-
*
|
|
213
|
-
* @param userBase - the user's home directory. Always passed in, never read
|
|
214
|
-
* from the environment down here: a function that reached for os.homedir()
|
|
215
|
-
* itself would make every spec a gamble on the developer's real
|
|
216
|
-
* configuration, and one of them would eventually write it. Entry points
|
|
217
|
-
* resolve the home once and hand it down.
|
|
218
|
-
*/
|
|
219
|
-
export declare function userConfigFilePath(userBase: string): string;
|
|
220
|
-
export declare const MAPPING_POLICIES: readonly ["always", "complex", "on-request"];
|
|
221
|
-
/** How eagerly maps are opened; 'complex' is the behavior of an unconfigured project. */
|
|
222
|
-
export type MappingPolicy = (typeof MAPPING_POLICIES)[number];
|
|
223
|
-
export interface InvalidPolicy {
|
|
224
|
-
readonly kind: 'invalid-policy';
|
|
225
|
-
readonly raw: string;
|
|
226
|
-
readonly allowed: readonly string[];
|
|
227
|
-
}
|
|
228
|
-
export declare function makeMappingPolicy(raw: string): Result<MappingPolicy, InvalidPolicy>;
|
|
229
|
-
/** One line of meaning per policy — the wording every surface repeats. */
|
|
230
|
-
export declare function describeMappingPolicy(policy: MappingPolicy): string;
|
|
231
|
-
/**
|
|
232
|
-
* The policy recorded in ONE configuration file, or ok(undefined) when nobody
|
|
233
|
-
* has chosen there (missing file or missing key — both mean the same thing).
|
|
234
|
-
* A file that exists but does not parse is an error, never silently ignored.
|
|
235
|
-
*
|
|
236
|
-
* @param path - the configuration file itself: {@link configFilePath} for a
|
|
237
|
-
* project, {@link userConfigFilePath} for the user. One loader, two scopes.
|
|
238
|
-
*/
|
|
239
|
-
export declare function loadMappingPolicy(path: string): Result<MappingPolicy | undefined, StoreError>;
|
|
240
|
-
/**
|
|
241
|
-
* Persist the policy atomically (P2), same write as the map files.
|
|
242
|
-
* @param path - the configuration file to write; see {@link loadMappingPolicy}.
|
|
243
|
-
* @returns ok when the file holds the policy; save-failed leaves the previous
|
|
244
|
-
* configuration in place.
|
|
245
|
-
*/
|
|
246
|
-
export declare function saveMappingPolicy(path: string, policy: MappingPolicy): Result<void, StoreError>;
|
|
247
|
-
/** The two places a mapping policy can be recorded, in override order. */
|
|
248
|
-
export declare const POLICY_SCOPES: readonly ["user", "project"];
|
|
249
|
-
export type PolicyScope = (typeof POLICY_SCOPES)[number];
|
|
250
|
-
/** What both scopes say, and which of them actually governs. */
|
|
251
|
-
export interface MappingPolicyScopes {
|
|
252
|
-
readonly project: MappingPolicy | undefined;
|
|
253
|
-
readonly user: MappingPolicy | undefined;
|
|
254
|
-
/** What to act on. undefined = nobody has chosen yet, anywhere. */
|
|
255
|
-
readonly effective: MappingPolicy | undefined;
|
|
256
|
-
/** Where `effective` came from; undefined exactly when `effective` is. */
|
|
257
|
-
readonly source: PolicyScope | undefined;
|
|
258
|
-
}
|
|
259
|
-
/**
|
|
260
|
-
* Resolve the policy that governs this project: the PROJECT file if it names
|
|
261
|
-
* one, otherwise the USER file, otherwise nothing.
|
|
262
|
-
*
|
|
263
|
-
* Both scopes are reported, not just the winner — a surface that says "always"
|
|
264
|
-
* without saying where it came from cannot tell a user why changing their
|
|
265
|
-
* user-level choice did nothing here.
|
|
266
|
-
*
|
|
267
|
-
* @param projectConfigFile - see {@link configFilePath}.
|
|
268
|
-
* @param userConfigFile - see {@link userConfigFilePath}.
|
|
269
|
-
* @returns err as soon as EITHER file exists and is broken, project first: a
|
|
270
|
-
* configuration nobody can read is not the same as a configuration nobody
|
|
271
|
-
* wrote, and silently falling through to the other scope would act on a
|
|
272
|
-
* choice the user did not make.
|
|
273
|
-
*/
|
|
274
|
-
export declare function effectiveMappingPolicy(projectConfigFile: string, userConfigFile: string): Result<MappingPolicyScopes, StoreError>;
|
|
275
|
-
/** Project-relative location of the pre-0.20 default page file. */
|
|
276
|
-
export declare const LEGACY_STATE_FILE_RELATIVE_PATH: string;
|
|
277
|
-
/**
|
|
278
|
-
* Move a legacy `.claude` store — map, pages, and mapping-policy config —
|
|
279
|
-
* into the tool-owned `.mellos` location. Never merges: a project whose new
|
|
280
|
-
* store already holds anything keeps it untouched, whatever the legacy
|
|
281
|
-
* directory still contains.
|
|
282
|
-
* @param defaultFile - the NEW default page path (`<root>/.mellos/map.json`);
|
|
283
|
-
* the legacy store is looked up relative to `<root>`.
|
|
284
|
-
* @returns whether a legacy store was moved.
|
|
285
|
-
*/
|
|
286
|
-
export declare function migrateLegacyStore(defaultFile: string): boolean;
|
|
287
|
-
/** Load and validate the map file at `path`. */
|
|
288
|
-
export declare function loadMapFile(path: string): Result<MellosMap, StoreError>;
|
|
289
|
-
/**
|
|
290
|
-
* Write the map to `path` atomically (P2): serialize to a private sibling
|
|
291
|
-
* temp file, then rename it over the target, retrying a rename the OS
|
|
292
|
-
* refuses transiently. Creates the parent directory if missing.
|
|
293
|
-
* @returns ok when the file holds the new map; save-failed when it does not,
|
|
294
|
-
* in which case the previous content is intact and the call may be retried.
|
|
295
|
-
*/
|
|
296
|
-
export declare function saveMapFile(path: string, map: MellosMap): Result<void, StoreError>;
|
|
43
|
+
export { writeFileAtomic } from './atomic.js';
|
|
44
|
+
export { STORE_DIR_NAME, STATE_FILE_RELATIVE_PATH, PAGES_DIR_NAME, pageFilePath, pageIdOfFile, listPageFiles, deletePageFile } from './pages.js';
|
|
45
|
+
export { FOCUS_FILE_NAME, focusFilePath, type FocusRequest, takeFocusRequest, QUIT_FILE_NAME, quitFilePath, takeQuitRequest, sweepQuitRequest } from './channels.js';
|
|
46
|
+
export { VIEWERS_DIR_NAME, VIEWER_FILE_VERSION, VIEWER_HEARTBEAT_MS, VIEWER_STALE_MS, VIEWER_SWEEP_MS, viewersDirPath, viewerFilePath, type ViewerReport, type LiveViewer, publishViewer, retireViewer, readLiveViewers } from './viewers.js';
|
|
47
|
+
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
|
+
export { LEGACY_STATE_FILE_RELATIVE_PATH, migrateLegacyStore } from './migration.js';
|
|
49
|
+
export { loadMapFile, saveMapFile } from './maps.js';
|