@marver-design/marver 0.20.0 → 0.22.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.
- package/CHANGELOG.md +123 -0
- package/README.md +17 -4
- package/dist/{bake-tAb0D6Rc.mjs → bake-BSX4XR_U.mjs} +1 -1
- package/dist/board-status-CWGHIdo_.mjs +1307 -0
- package/dist/boards-BTGNPVMx.mjs +187 -0
- package/dist/{build-DwpNPl6Z.mjs → build-Dd2Qm3OG.mjs} +87 -16
- package/dist/cli.mjs +67 -8
- package/dist/config-DJxMRVD8.mjs +373 -0
- package/dist/context-DUAENlJ6.mjs +600 -0
- package/dist/{daemon-DbHvLQUL.mjs → daemon-BmkwErpC.mjs} +1 -1
- package/dist/{dev-BjdDP69b.mjs → dev-BW7a5kDF.mjs} +8 -6
- package/dist/{init-DKxuRxBr.mjs → init-NJ3xjLVu.mjs} +39 -44
- package/dist/managed-write-Bo-oPc-i.mjs +71 -0
- package/dist/{manifest-B01PSyDc.mjs → manifest-BqBcMJcd.mjs} +26 -380
- package/dist/{plugin-y4Ch7o_A.mjs → plugin-8XntSCx_.mjs} +234 -97
- package/dist/{poster-FCv_nXyP.mjs → poster-DRZDszTI.mjs} +1 -1
- package/dist/{publish-bakes-XH6Bac58.mjs → publish-bakes-CCWRV9iX.mjs} +2 -2
- package/dist/{shot-iw3SEcpn.mjs → shot-C22Ues04.mjs} +2 -2
- package/docs/boards-and-folders.md +161 -0
- package/docs/context.md +117 -0
- package/docs/sharing.md +12 -2
- package/package.json +1 -1
- package/src/client/shell/BoardList.tsx +143 -54
- package/src/client/shell/ContextMenu.tsx +16 -5
- package/src/client/shell/StatusPicker.tsx +91 -0
- package/src/client/shell/board-icons.tsx +75 -0
- package/src/client/shell/store.ts +93 -14
- package/src/client/shell/styles.css +41 -9
- package/src/shared/board-tree.ts +291 -143
- package/src/shared/board-types.ts +103 -0
- package/src/shared/context.ts +265 -0
- package/src/shared/status.ts +157 -0
- package/templates/AGENTS-embedded.md +4 -3
- package/templates/AGENTS-studio.md +4 -3
- package/templates/context/INDEX.md +50 -0
- package/templates/context/map.json +6 -0
- package/templates/context/shipped-knowledge.md +19 -0
- package/templates/context/shipped.md +28 -0
- package/templates/instructions/boards.md +78 -22
- package/templates/instructions/context.md +114 -0
- package/templates/playbooks/publish-canvas/PLAYBOOK.md +72 -0
- package/templates/playbooks/reorganize-context/PLAYBOOK.md +232 -0
- package/templates/playbooks/reorganize-context/eval.md +93 -0
- package/dist/boards-BmxcT3Lc.mjs +0 -290
- package/dist/boards-PuVzw5Wp.mjs +0 -62
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Context eval - can a fresh agent answer from the repo alone?
|
|
2
|
+
|
|
3
|
+
The reorganization is judged by one thing: a fresh agent, with no memory and no chat history,
|
|
4
|
+
answering real questions about the product correctly and cheaply. Run it **before** and **after**,
|
|
5
|
+
the same way both times.
|
|
6
|
+
|
|
7
|
+
This file is the protocol and the questions. The **answer key is a separate file**
|
|
8
|
+
(`answer-key-<project>.md`) and never enters the worktree the agent reads.
|
|
9
|
+
|
|
10
|
+
## Isolation - check before each pass
|
|
11
|
+
|
|
12
|
+
- The agent runs in the worktree (`../<repo>-context`), a fresh process per question.
|
|
13
|
+
- No agent memory for that path: Claude Code keys memory by project path, so
|
|
14
|
+
`~/.claude/projects/<worktree-slug>/memory/` must not exist. Codex keeps none by default.
|
|
15
|
+
- No hook injects project context for that path (a vault feed keyed by path, a knowledge inbox).
|
|
16
|
+
- The answer key, the reorg notes kept outside the worktree, and earlier pass results are not in
|
|
17
|
+
the worktree. Before the first pass, `context/` does not exist yet.
|
|
18
|
+
- **A clean profile.** Global instructions also count as injected context: Claude Code loads
|
|
19
|
+
`~/.claude/CLAUDE.md`, Codex loads its global `AGENTS.md`. Launch with an empty config home and
|
|
20
|
+
only credentials in it - Claude Code: `CLAUDE_CONFIG_DIR=<empty dir>` with an API key; Codex:
|
|
21
|
+
`CODEX_HOME=<dir holding only auth>`. Confirm from the transcript's first event that no user or
|
|
22
|
+
global instruction file was loaded.
|
|
23
|
+
- *Learned on the first run:* without an API key, Claude Code cannot run clean - `--bare` needs one,
|
|
24
|
+
and a plain run loads the auto-memory that worktrees of one repository share. Codex with a
|
|
25
|
+
`CODEX_HOME` holding a copy of `auth.json` works; check `codex features list` shows `memories`
|
|
26
|
+
off, and keep each pass's session rollouts out of that home before the next pass. Copy the auth
|
|
27
|
+
only when its `last_refresh` is recent, and delete the copy after.
|
|
28
|
+
- **Read-only.** Claude Code with read tools only (`--allowedTools Read,Grep,Glob`); Codex with
|
|
29
|
+
`--sandbox read-only`.
|
|
30
|
+
- Same CLI, same model, same prompt, both passes.
|
|
31
|
+
|
|
32
|
+
## The prompt
|
|
33
|
+
|
|
34
|
+
> Answer this question using only files in this repository: <question>. Give the answer, the
|
|
35
|
+
> files you used, and your confidence. If the repository cannot settle it, say "cannot tell" and
|
|
36
|
+
> why. Do not run the app, query a database, call an API, or read outside this directory.
|
|
37
|
+
|
|
38
|
+
## The measurement - one metric, per question
|
|
39
|
+
|
|
40
|
+
Run each question as its own invocation with a machine-readable transcript (Claude Code:
|
|
41
|
+
`claude -p "<prompt>" --output-format stream-json --verbose`; Codex: `codex exec --json`). From the
|
|
42
|
+
transcript record:
|
|
43
|
+
|
|
44
|
+
- **result** - correct / partial / wrong / cannot-tell, judged against the answer key;
|
|
45
|
+
- **confident wrong** - a wrong answer given with high confidence, counted separately;
|
|
46
|
+
- **bytes read** - the summed size of every tool result returned to the agent (file reads, searches,
|
|
47
|
+
listings). Measure what the model saw, not what the command printed: Codex truncates long output
|
|
48
|
+
(each result carries its `original_token_count`), so read the session rollout's tool outputs, not
|
|
49
|
+
`exec --json`'s `aggregated_output`. Record the session's **input tokens** beside it;
|
|
50
|
+
- **tool calls**;
|
|
51
|
+
- **out of bounds** - any read outside the worktree. One voids that answer.
|
|
52
|
+
|
|
53
|
+
"Cannot tell" with the right reason counts as correct where the key says the repository genuinely
|
|
54
|
+
does not hold the answer.
|
|
55
|
+
|
|
56
|
+
## Pass
|
|
57
|
+
|
|
58
|
+
- no confident wrong answer after;
|
|
59
|
+
- more correct answers after than before - or, if the before pass was already perfect, equal;
|
|
60
|
+
- fewer total bytes read after.
|
|
61
|
+
|
|
62
|
+
A capable agent may already answer everything before - the first run's before pass was 16 / 16 at
|
|
63
|
+
medium effort, the traps notwithstanding. Then the pass is decided on cost, and a weaker or cheaper
|
|
64
|
+
profile is worth a second pair of passes to see whether the reorganization moves correctness.
|
|
65
|
+
|
|
66
|
+
## The six kinds of question
|
|
67
|
+
|
|
68
|
+
Write 10-16, at least one of each, from Phase 2 evidence, with the key signed off by the human where
|
|
69
|
+
Phase 8 asked:
|
|
70
|
+
|
|
71
|
+
1. **Shipped** - is X available, where, for whom, since when?
|
|
72
|
+
2. **Behaviour** - how does surface Y work today?
|
|
73
|
+
3. **Interfaces** - what runs behind action Z: jobs, mails, data written?
|
|
74
|
+
4. **Why** - why was decision N taken, and does it still apply?
|
|
75
|
+
5. **Next** - what is being built next, and what counts as done?
|
|
76
|
+
6. **Receipts** - what did someone say about T, and where is it recorded?
|
|
77
|
+
|
|
78
|
+
Include **traps**: questions where a stale spec, a design frame, or a wrong instruction gives a
|
|
79
|
+
confident wrong answer today. They are the point.
|
|
80
|
+
|
|
81
|
+
## Your project's questions
|
|
82
|
+
|
|
83
|
+
Write them before the before pass, from Phase 2's evidence, and keep the key out of the worktree:
|
|
84
|
+
|
|
85
|
+
| # | Kind | Question | Trap |
|
|
86
|
+
|---|---|---|---|
|
|
87
|
+
|
|
88
|
+
## Report format
|
|
89
|
+
|
|
90
|
+
| # | Before: result | Before: bytes / calls | After: result | After: bytes / calls |
|
|
91
|
+
|---|---|---|---|---|
|
|
92
|
+
|
|
93
|
+
Write the before column before Phase 1 begins - not from memory afterwards.
|
package/dist/boards-BmxcT3Lc.mjs
DELETED
|
@@ -1,290 +0,0 @@
|
|
|
1
|
-
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
2
|
-
import { join, sep } from "node:path";
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
4
|
-
//#region src/server/hash.ts
|
|
5
|
-
/** sha256 hex of a string - the CAS token for board files, the registry, the manifest. */
|
|
6
|
-
const hash = (s) => createHash("sha256").update(s).digest("hex");
|
|
7
|
-
//#endregion
|
|
8
|
-
//#region src/shared/board-tree.ts
|
|
9
|
-
/**
|
|
10
|
-
* Board folders - the pure tree shared by the sidebar, the dev API, the build and the
|
|
11
|
-
* tests. Files are the truth: a board says which folder it sits in (`folder` on the
|
|
12
|
-
* board file, ranked among its siblings by `order`), and `design/boards/_folders.json`
|
|
13
|
-
* says which folders exist and where they rank at the root. One level only: folders
|
|
14
|
-
* hold boards, never folders. `all-scenes` never enters the tree - callers pin it last.
|
|
15
|
-
*/
|
|
16
|
-
/** The on-disk name grammar shared by boards and folders (a board name is a filename). */
|
|
17
|
-
const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
18
|
-
const isBoardName = (n) => typeof n === "string" && n.length >= 1 && n.length <= 64 && BOARD_NAME.test(n);
|
|
19
|
-
/** The folder registry beside the boards - underscore = infrastructure, never a board. */
|
|
20
|
-
const FOLDERS_FILE = "_folders.json";
|
|
21
|
-
/** Is this basename in design/boards/ a board file? `_folders.json`, temp files and any
|
|
22
|
-
* off-grammar name are not - every lister (dev API, build, watcher) shares this rule. */
|
|
23
|
-
const isBoardFile = (f) => f.endsWith(".json") && isBoardName(f.slice(0, -5));
|
|
24
|
-
const readDescription = (v) => {
|
|
25
|
-
if (typeof v !== "string") return void 0;
|
|
26
|
-
return v.trim().replace(/\s+/g, " ").slice(0, 300) || void 0;
|
|
27
|
-
};
|
|
28
|
-
const readTitle = (v) => {
|
|
29
|
-
if (typeof v !== "string") return void 0;
|
|
30
|
-
return Array.from(v.replace(/[\u0000-\u001f\u007f]/g, " ").trim().replace(/\s+/g, " ")).slice(0, 120).join("").trim() || void 0;
|
|
31
|
-
};
|
|
32
|
-
const rank = (o) => typeof o === "number" && Number.isFinite(o) ? o : Infinity;
|
|
33
|
-
/** A folder's title and description, present only when set. */
|
|
34
|
-
const folderExtras = (it) => ({
|
|
35
|
-
...it.title ? { title: it.title } : {},
|
|
36
|
-
...it.description ? { description: it.description } : {}
|
|
37
|
-
});
|
|
38
|
-
/** The registry file's shape. Returns the rows, or a string naming what is wrong - a
|
|
39
|
-
* malformed registry is an ERROR the human must fix (silently reading it as empty would
|
|
40
|
-
* let the next drag overwrite their folders), while a missing file is simply no folders. */
|
|
41
|
-
function parseFolders(raw) {
|
|
42
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "expected an object";
|
|
43
|
-
const { version, folders } = raw;
|
|
44
|
-
if (version !== void 0 && version !== 1) return `unsupported version ${String(version)}`;
|
|
45
|
-
if (!Array.isArray(folders)) return "expected a \"folders\" array";
|
|
46
|
-
const out = [];
|
|
47
|
-
const seen = /* @__PURE__ */ new Set();
|
|
48
|
-
for (const f of folders) {
|
|
49
|
-
const name = f?.name;
|
|
50
|
-
if (!isBoardName(name)) return "a folder needs a name - lowercase letters, numbers and dashes";
|
|
51
|
-
if (seen.has(name)) return `folder "${name}" is listed twice`;
|
|
52
|
-
seen.add(name);
|
|
53
|
-
const o = f.order;
|
|
54
|
-
const t = readTitle(f.title);
|
|
55
|
-
const d = readDescription(f.description);
|
|
56
|
-
out.push({
|
|
57
|
-
name,
|
|
58
|
-
...typeof o === "number" && Number.isFinite(o) ? { order: o } : {},
|
|
59
|
-
...t ? { title: t } : {},
|
|
60
|
-
...d ? { description: d } : {}
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
return out;
|
|
64
|
-
}
|
|
65
|
-
/** Sidebar order from the files. Root: boards with no folder + every folder (registered
|
|
66
|
-
* or implied by a board), ranked by `order` then kind (board before folder) then name.
|
|
67
|
-
* Inside a folder: its boards by `order` then name. Unranked sorts after ranked. A folder's
|
|
68
|
-
* title and description ride on its item (they live in the registry a tree write rewrites);
|
|
69
|
-
* a board's title stays with its row - it lives in the board's own file. */
|
|
70
|
-
function buildTree(boards, folders) {
|
|
71
|
-
const folderOrder = /* @__PURE__ */ new Map();
|
|
72
|
-
const folderMeta = /* @__PURE__ */ new Map();
|
|
73
|
-
for (const f of folders) if (isBoardName(f.name) && !folderOrder.has(f.name)) {
|
|
74
|
-
folderOrder.set(f.name, f.order);
|
|
75
|
-
folderMeta.set(f.name, folderExtras(f));
|
|
76
|
-
}
|
|
77
|
-
const members = /* @__PURE__ */ new Map();
|
|
78
|
-
const rootBoards = [];
|
|
79
|
-
for (const b of boards) {
|
|
80
|
-
if (!isBoardName(b.name) || b.name === "all-scenes") continue;
|
|
81
|
-
const folder = isBoardName(b.folder) ? b.folder : void 0;
|
|
82
|
-
if (!folder) {
|
|
83
|
-
rootBoards.push(b);
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
if (!folderOrder.has(folder)) folderOrder.set(folder, void 0);
|
|
87
|
-
const list = members.get(folder) ?? [];
|
|
88
|
-
list.push(b);
|
|
89
|
-
members.set(folder, list);
|
|
90
|
-
}
|
|
91
|
-
const byRank = (a, b) => rank(a.order) - rank(b.order) || a.name.localeCompare(b.name);
|
|
92
|
-
const root = [...rootBoards.map((b) => ({
|
|
93
|
-
item: {
|
|
94
|
-
kind: "board",
|
|
95
|
-
name: b.name
|
|
96
|
-
},
|
|
97
|
-
order: b.order
|
|
98
|
-
})), ...[...folderOrder].map(([name, order]) => ({
|
|
99
|
-
item: {
|
|
100
|
-
kind: "folder",
|
|
101
|
-
name,
|
|
102
|
-
boards: (members.get(name) ?? []).sort(byRank).map((b) => b.name),
|
|
103
|
-
...folderMeta.get(name) ?? {}
|
|
104
|
-
},
|
|
105
|
-
order
|
|
106
|
-
}))];
|
|
107
|
-
root.sort((a, b) => rank(a.order) - rank(b.order) || (a.item.kind === b.item.kind ? 0 : a.item.kind === "board" ? -1 : 1) || a.item.name.localeCompare(b.item.name));
|
|
108
|
-
return root.map((r) => r.item);
|
|
109
|
-
}
|
|
110
|
-
/** Every board in reading order - the order the switchers and the landing pick use. */
|
|
111
|
-
function flatten(tree) {
|
|
112
|
-
const out = [];
|
|
113
|
-
for (const it of tree) if (it.kind === "board") out.push(it.name);
|
|
114
|
-
else out.push(...it.boards);
|
|
115
|
-
return out;
|
|
116
|
-
}
|
|
117
|
-
function validateWire(wire) {
|
|
118
|
-
if (!Array.isArray(wire)) return "invalid tree";
|
|
119
|
-
const boards = /* @__PURE__ */ new Set(), folders = /* @__PURE__ */ new Set();
|
|
120
|
-
const board = (n) => {
|
|
121
|
-
if (!isBoardName(n) || n === "all-scenes") return "invalid board name in tree";
|
|
122
|
-
if (boards.has(n)) return `board "${n}" appears twice`;
|
|
123
|
-
boards.add(n);
|
|
124
|
-
return null;
|
|
125
|
-
};
|
|
126
|
-
for (const w of wire) {
|
|
127
|
-
if (typeof w === "string") {
|
|
128
|
-
const e = board(w);
|
|
129
|
-
if (e) return e;
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
if (!w || typeof w !== "object" || Array.isArray(w)) return "invalid tree item";
|
|
133
|
-
const { folder, boards: kids, title, description } = w;
|
|
134
|
-
if (!isBoardName(folder)) return "invalid folder name in tree";
|
|
135
|
-
if (title !== void 0 && (typeof title !== "string" || Array.from(title).length > 120)) return "invalid folder title";
|
|
136
|
-
if (description !== void 0 && (typeof description !== "string" || description.length > 300)) return "invalid folder description";
|
|
137
|
-
if (folders.has(folder)) return `folder "${folder}" appears twice`;
|
|
138
|
-
folders.add(folder);
|
|
139
|
-
if (!Array.isArray(kids)) return "invalid folder in tree";
|
|
140
|
-
for (const k of kids) {
|
|
141
|
-
const e = board(k);
|
|
142
|
-
if (e) return e;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
if (boards.size > 200 || folders.size > 50) return "tree too large";
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
//#endregion
|
|
149
|
-
//#region src/server/boards.ts
|
|
150
|
-
/**
|
|
151
|
-
* Reading design/boards/ safely - the one enumerator the dev API, the build and the tests
|
|
152
|
-
* share. Only REGULAR files on the board-name grammar count as boards (a symlink, dangling or
|
|
153
|
-
* live, could read or publish JSON from outside the project - it is skipped, and reported so
|
|
154
|
-
* the build can fail closed). The folder registry is read the same way: absent = no folders,
|
|
155
|
-
* malformed = an error the human must fix, never a silently empty registry.
|
|
156
|
-
*/
|
|
157
|
-
/** Does realpath(dir) stay inside realpath(root)? A symlinked design/boards can't escape. */
|
|
158
|
-
function underRoot(root, dir) {
|
|
159
|
-
try {
|
|
160
|
-
const rr = realpathSync(root);
|
|
161
|
-
const rd = realpathSync(dir);
|
|
162
|
-
return rd === rr || rd.startsWith(rr + sep);
|
|
163
|
-
} catch {
|
|
164
|
-
return false;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
/** Is design/boards a directory we may read and write? It must not be a symlink at all (a
|
|
168
|
-
* link to the repo root would list package.json as a board and let a tree write rewrite
|
|
169
|
-
* it; a link outside would publish foreign JSON) and must resolve inside the root. Absent
|
|
170
|
-
* is fine (no boards yet). Returns the error, or null. */
|
|
171
|
-
function checkBoardsDir(root, boardsDir) {
|
|
172
|
-
return checkRealDirs(root, [[join(boardsDir, ".."), "design"], [boardsDir, "design/boards"]]);
|
|
173
|
-
}
|
|
174
|
-
/** Every EXISTING path in `dirs` (root-down order) must be a real directory inside the root -
|
|
175
|
-
* a symlinked `design` with no boards dir yet would otherwise be followed by the mkdir that
|
|
176
|
-
* creates it; a symlinked `design/scenes` would let a brief write land outside the project.
|
|
177
|
-
* An absent one ends the walk (nothing beneath it exists either). Returns the error, or null. */
|
|
178
|
-
function checkRealDirs(root, dirs) {
|
|
179
|
-
for (const [p, label] of dirs) {
|
|
180
|
-
try {
|
|
181
|
-
if (lstatSync(p).isSymbolicLink()) return `${label} must be a real directory, not a symlink`;
|
|
182
|
-
} catch {
|
|
183
|
-
return null;
|
|
184
|
-
}
|
|
185
|
-
if (!underRoot(root, p)) return `${label} escapes the project`;
|
|
186
|
-
}
|
|
187
|
-
return null;
|
|
188
|
-
}
|
|
189
|
-
/** A regular file (lstat: a symlink is never followed, a dangling one is not "absent"). */
|
|
190
|
-
const isRegularFile = (p) => {
|
|
191
|
-
try {
|
|
192
|
-
return lstatSync(p).isFile();
|
|
193
|
-
} catch {
|
|
194
|
-
return false;
|
|
195
|
-
}
|
|
196
|
-
};
|
|
197
|
-
/** Is there ANY node at p (a dangling symlink counts)? */
|
|
198
|
-
const nodeExists = (p) => {
|
|
199
|
-
try {
|
|
200
|
-
lstatSync(p);
|
|
201
|
-
return true;
|
|
202
|
-
} catch {
|
|
203
|
-
return false;
|
|
204
|
-
}
|
|
205
|
-
};
|
|
206
|
-
/** Every board file: name, raw content, hash, and its JSON (null when malformed). `skipped`
|
|
207
|
-
* names the entries that looked like boards but were not regular files. */
|
|
208
|
-
function listBoardFiles(boardsDir) {
|
|
209
|
-
const boards = [], skipped = [];
|
|
210
|
-
if (!existsSync(boardsDir)) return {
|
|
211
|
-
boards,
|
|
212
|
-
skipped
|
|
213
|
-
};
|
|
214
|
-
for (const f of readdirSync(boardsDir)) {
|
|
215
|
-
if (!isBoardFile(f)) continue;
|
|
216
|
-
const file = join(boardsDir, f);
|
|
217
|
-
if (!isRegularFile(file)) {
|
|
218
|
-
skipped.push(f);
|
|
219
|
-
continue;
|
|
220
|
-
}
|
|
221
|
-
const content = readFileSync(file, "utf8");
|
|
222
|
-
let json = null;
|
|
223
|
-
try {
|
|
224
|
-
json = JSON.parse(content);
|
|
225
|
-
} catch {}
|
|
226
|
-
boards.push({
|
|
227
|
-
name: f.slice(0, -5),
|
|
228
|
-
file,
|
|
229
|
-
content,
|
|
230
|
-
sha256: hash(content),
|
|
231
|
-
json
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
return {
|
|
235
|
-
boards,
|
|
236
|
-
skipped
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
/** The author-owned sidebar fields off a board's JSON, leniently: rank, folder, the title
|
|
240
|
-
* humans see, the sentence agents read. */
|
|
241
|
-
function boardFields(json, validName) {
|
|
242
|
-
const o = json;
|
|
243
|
-
const title = readTitle(o?.title);
|
|
244
|
-
const description = readDescription(o?.description);
|
|
245
|
-
return {
|
|
246
|
-
...typeof o?.order === "number" && Number.isFinite(o.order) ? { order: o.order } : {},
|
|
247
|
-
...validName(o?.folder) ? { folder: o.folder } : {},
|
|
248
|
-
...title ? { title } : {},
|
|
249
|
-
...description ? { description } : {}
|
|
250
|
-
};
|
|
251
|
-
}
|
|
252
|
-
/** The folder registry. `sha256` is the CAS token a tree write must echo (null = "there was
|
|
253
|
-
* no file"), so a write can never silently replace a registry it never saw. */
|
|
254
|
-
function readRegistry(boardsDir) {
|
|
255
|
-
const p = join(boardsDir, FOLDERS_FILE);
|
|
256
|
-
if (!nodeExists(p)) return {
|
|
257
|
-
state: "absent",
|
|
258
|
-
folders: [],
|
|
259
|
-
sha256: null
|
|
260
|
-
};
|
|
261
|
-
if (!isRegularFile(p)) return {
|
|
262
|
-
state: "malformed",
|
|
263
|
-
error: `design/boards/${FOLDERS_FILE} must be a regular file, not a symlink`,
|
|
264
|
-
sha256: null
|
|
265
|
-
};
|
|
266
|
-
const content = readFileSync(p, "utf8");
|
|
267
|
-
let raw;
|
|
268
|
-
try {
|
|
269
|
-
raw = JSON.parse(content);
|
|
270
|
-
} catch {
|
|
271
|
-
return {
|
|
272
|
-
state: "malformed",
|
|
273
|
-
error: `design/boards/${FOLDERS_FILE} is not valid JSON - fix the file`,
|
|
274
|
-
sha256: hash(content)
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
const parsed = parseFolders(raw);
|
|
278
|
-
if (typeof parsed === "string") return {
|
|
279
|
-
state: "malformed",
|
|
280
|
-
error: `design/boards/${FOLDERS_FILE}: ${parsed}`,
|
|
281
|
-
sha256: hash(content)
|
|
282
|
-
};
|
|
283
|
-
return {
|
|
284
|
-
state: "ok",
|
|
285
|
-
folders: parsed,
|
|
286
|
-
sha256: hash(content)
|
|
287
|
-
};
|
|
288
|
-
}
|
|
289
|
-
//#endregion
|
|
290
|
-
export { listBoardFiles as a, BOARD_NAME as c, flatten as d, isBoardName as f, hash as g, validateWire as h, isRegularFile as i, FOLDERS_FILE as l, readTitle as m, checkBoardsDir as n, nodeExists as o, readDescription as p, checkRealDirs as r, readRegistry as s, boardFields as t, buildTree as u };
|
package/dist/boards-PuVzw5Wp.mjs
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { a as listBoardFiles, d as flatten, f as isBoardName, n as checkBoardsDir, s as readRegistry, t as boardFields, u as buildTree } from "./boards-BmxcT3Lc.mjs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
//#region src/cli/boards.ts
|
|
4
|
-
/**
|
|
5
|
-
* `marver boards` - the sidebar as the agent sees it: every folder and board in reading
|
|
6
|
-
* order, from the files (no dev server needed). One call answers "what folders exist, what
|
|
7
|
-
* is in them, what ranks where" before the agent writes `folder` on a board or edits
|
|
8
|
-
* `design/boards/_folders.json`. `--json` gives the tree shape the shell uses.
|
|
9
|
-
*/
|
|
10
|
-
function boardsCommand(root, opts) {
|
|
11
|
-
const dir = join(root, "design", "boards");
|
|
12
|
-
const de = checkBoardsDir(root, dir);
|
|
13
|
-
if (de) throw new Error(de);
|
|
14
|
-
const { boards, skipped } = listBoardFiles(dir);
|
|
15
|
-
const reg = readRegistry(dir);
|
|
16
|
-
if (reg.state === "malformed") throw new Error(reg.error);
|
|
17
|
-
const rows = boards.map((b) => ({
|
|
18
|
-
name: b.name,
|
|
19
|
-
...boardFields(b.json, isBoardName)
|
|
20
|
-
}));
|
|
21
|
-
const tree = buildTree(rows, reg.folders);
|
|
22
|
-
const hasAll = boards.some((b) => b.name === "all-scenes");
|
|
23
|
-
if (opts.json) {
|
|
24
|
-
console.log(JSON.stringify({
|
|
25
|
-
tree,
|
|
26
|
-
boards: rows.filter((r) => r.name !== "all-scenes"),
|
|
27
|
-
landing: flatten(tree)[0] ?? (hasAll ? "all-scenes" : null),
|
|
28
|
-
registry: reg.state === "ok" ? "design/boards/_folders.json" : null
|
|
29
|
-
}, null, 2));
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
if (!tree.length && !hasAll) {
|
|
33
|
-
console.log("no boards yet - design/boards/ is empty");
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
const order = (n) => {
|
|
37
|
-
const o = rows.find((r) => r.name === n)?.order;
|
|
38
|
-
return o === void 0 ? "" : ` order ${o}`;
|
|
39
|
-
};
|
|
40
|
-
const title = (t) => t ? ` "${t}"` : "";
|
|
41
|
-
const desc = (d) => d ? ` - ${d}` : "";
|
|
42
|
-
const boardLine = (n) => {
|
|
43
|
-
const r = rows.find((x) => x.name === n);
|
|
44
|
-
return `${n}${title(r?.title)}${order(n)}${desc(r?.description)}`;
|
|
45
|
-
};
|
|
46
|
-
for (const it of tree) {
|
|
47
|
-
if (it.kind === "board") {
|
|
48
|
-
console.log(boardLine(it.name));
|
|
49
|
-
continue;
|
|
50
|
-
}
|
|
51
|
-
console.log(`${it.name}/${title(it.title)} (folder, ${it.boards.length} board${it.boards.length === 1 ? "" : "s"}${reg.folders.some((f) => f.name === it.name) ? "" : ", implied by its boards - not in _folders.json"})${desc(it.description)}`);
|
|
52
|
-
for (const b of it.boards) console.log(` ${boardLine(b)}`);
|
|
53
|
-
if (!it.boards.length) console.log(" (empty)");
|
|
54
|
-
}
|
|
55
|
-
if (hasAll) console.log("all-scenes (auto, always last)");
|
|
56
|
-
const landing = flatten(tree)[0];
|
|
57
|
-
if (landing) console.log(`\nlanding board: ${landing}`);
|
|
58
|
-
console.log(`registry: ${reg.state === "ok" ? "design/boards/_folders.json" : "none (no empty or ranked folders yet)"}`);
|
|
59
|
-
if (skipped.length) console.log(`skipped (not regular files): ${skipped.join(", ")}`);
|
|
60
|
-
}
|
|
61
|
-
//#endregion
|
|
62
|
-
export { boardsCommand };
|