mcts-mem 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 B.M.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # MCTS-Mem
2
+
3
+ > MCTS-Mem is a structured, verifiable memory of *how a project was decided* — every
4
+ > choice that stuck, every alternative that was tried, and the recorded reason each one
5
+ > won or lost. You read it by **following its structure**, not by searching it, and a
6
+ > linter checks it the way a compiler checks code — so the knowledge stays consistent
7
+ > and trustworthy instead of becoming a pile of notes that might quietly contradict
8
+ > itself. Built once from a project's history, kept current as it evolves, and read by
9
+ > humans and AI agents before they change anything.
10
+
11
+ ## Remember how you got here, not the code
12
+
13
+ On a large project, people don't carry the code in their heads — they carry **how it
14
+ got there**: the decisions that worked, the ones that failed, and the facts that drove
15
+ each. The current code is only the latest path; the durable asset is the knowledge
16
+ accumulated reaching it, and that knowledge — not the source — is what lets someone
17
+ change the system confidently, or even understand it. Today it lives in people's heads
18
+ and in chat logs that evaporate.
19
+
20
+ MCTS-Mem takes that knowledge out of individual brains and puts it in one place,
21
+ organized by a model of how it accumulates: every decision, fact, failure, and
22
+ contradiction sits at the point in the design it bears on. Thousands of commits' worth
23
+ of reasoning — normally scattered across diffs, dead branches, and deleted files —
24
+ becomes one artifact a newcomer or an agent can read in minutes, share, and build on.
25
+
26
+ ## Structure is the point
27
+
28
+ A pile of notes — however well written — can hold contradictions and errors that are
29
+ never found. You retrieve a passage but can't be sure it's complete, current, or
30
+ consistent with what's filed elsewhere, and deciding on knowledge you only *probably*
31
+ have is a risk you can't measure. That is the ceiling a free-text or embedding-based
32
+ memory hits: it can recall, but it can't guarantee the recalled set is consistent — so
33
+ every answer carries a residue of doubt.
34
+
35
+ Structure removes the doubt. You don't search the tree, you **follow** it: each node
36
+ leads to its alternatives, its supporting evidence, and the decisions made downstream,
37
+ so the shape itself is the index and tells you where to look next. And because a
38
+ decision lives at one node with its evidence and alternatives beside it, **what you see
39
+ at that node is everything you need to decide** — not a sample that might be missing the
40
+ contradicting fact two hops away. The gain isn't just speed; the answer is **accurate,
41
+ deterministic, and verifiable**, and you reason *with confidence*, because anything that
42
+ could make you wrong would itself be at the node.
43
+
44
+ ## Verified like source code
45
+
46
+ Structure is only trustworthy if it's *enforced*, so the rules are executable.
47
+ `npx mcts-mem lint` runs a set of mechanical, deterministic checks over the whole tree —
48
+ each the executable form of a rule the skills specify — and passes only when every
49
+ invariant holds:
50
+
51
+ - **Links resolve.** Every cross-reference points at a real node; nothing dangles.
52
+ - **Re-decisions can't contradict themselves.** A replacement and the alternative it
53
+ superseded must record the *same* reason, **verbatim** on both sides — a tree whose
54
+ two halves tell different stories won't pass.
55
+ - **History is append-only.** No recorded fact or move can be silently edited or
56
+ deleted; corrections are *new, dated* entries, so the record of what was believed and
57
+ when stays auditable.
58
+ - **Every claim is sourced.** Each fact and move ends with one tag answering *what could
59
+ prove it wrong* — `(code)` (checkable against the code), `(sourced)` (resting on a human
60
+ record: commit message, doc, paper, chat log, author), or `(uncertain)` (an unbacked
61
+ reading of intent). Evidence, record, and guess are never confused.
62
+ - **Every node earns its place.** A node that records no decision, evidence, or
63
+ alternative is rejected; reading a node is never noise.
64
+
65
+ Lint checks *consistency*, not truth — whether a measurement is correct is a separate
66
+ matter — but when it passes, those guarantees are *proven*, not hoped for. The memory is
67
+ checked the way code is checked by a compiler and a test suite, which is what most
68
+ memory systems cannot offer: its **logical consistency is a property of the artifact**,
69
+ not of how carefully someone wrote it down.
70
+
71
+ Because the record is append-only and every fact carries its origin, it can also be
72
+ **re-checked**. History can be wrong — an early bad measurement can drive a decision
73
+ that was reasonable on the data but wrong in fact. You reproduce the fact, append the
74
+ corrected one, and **re-decide**: the solution is re-found under updated knowledge,
75
+ without erasing the record of how it was first reached.
76
+
77
+ ## A remembered search — why "MCTS"
78
+
79
+ Building the system *was* a search over a space of possible designs: you try a branch,
80
+ learn something (a fact), and that result shapes where you look next. Like Monte-Carlo
81
+ Tree Search, MCTS-Mem records that search — the branches taken and abandoned, and the
82
+ evidence (the **value**) on each — so the next exploration starts from the accumulated
83
+ result instead of from scratch. New or re-validated facts update that value, and that is
84
+ what **improves the policy**: stronger evidence revives a branch pruned on bad data, or
85
+ retires one that only looked good on thin data. This is the one sense in which "search"
86
+ applies — the exploration that *produced* the tree, never how you read it.
87
+
88
+ ## Beyond software
89
+
90
+ Anything that advances by trying things and learning fits the same model. A team on a
91
+ hard task records what it learned, what failed, what succeeded, and the decisions that
92
+ followed. Research does it explicitly: run an experiment, record the facts, decide how
93
+ to improve the method — which is exactly *refining the search policy* for the next
94
+ experiment.
95
+
96
+ And because every memory shares one model, **memories merge**. Separate trees combine
97
+ into a single prior for a whole topic — for instance, general JIT-engine knowledge that
98
+ unifies what was learned building JSC and V8 — turning the accumulated reasoning of many
99
+ independent efforts into one thing you can consult.
100
+
101
+ ## How to use it
102
+
103
+ MCTS-Mem ships as two **skills** plus a small helper CLI. You don't run a program against
104
+ your codebase — you point your AI coding agent at a skill, and it follows the method:
105
+
106
+ - **`mcts-mem-use`** — consult a tree before you plan a change, and update it when you
107
+ re-decide something. Reach for it before any non-trivial design, refactor, or rewrite in
108
+ a repo that has an `mcts_mem/` folder.
109
+ - **`mcts-mem-build`** — the one-time job of reconstructing a tree from a project's history
110
+ (git, design docs, the author). Run it once per repository; `mcts-mem-use` keeps it
111
+ current afterward.
112
+
113
+ Each skill is a single self-contained `SKILL.md` — the tree format and the full method are
114
+ written inside it, so any agent that can load a skill can read and maintain a tree with no
115
+ other setup. See [Installation](#installation) to add them to your agent.
116
+
117
+ The one piece of running code is the linter: `npx mcts-mem lint <path-to-mcts_mem>`
118
+ validates a tree the way a compiler validates source — links resolve, re-decisions agree,
119
+ history is append-only, every claim is tagged. Run it after any edit; a clean lint is a
120
+ cheap, mechanical guarantee that the structure holds.
121
+
122
+ ## Installation
123
+
124
+ MCTS-Mem is **two skills** plus a helper **CLI**. Only the skills are "installed" — the CLI
125
+ needs nothing, because the skills call it via `npx mcts-mem` on demand.
126
+
127
+ ### Install the skills
128
+
129
+ Paste this to your coding agent (Claude Code, Cursor, Codex, Gemini CLI, …):
130
+
131
+ > Install the MCTS-Mem skills. Fetch these two files from
132
+ > https://github.com/mbbill/MCTS-Mem — `skills/mcts-mem-build/SKILL.md` and
133
+ > `skills/mcts-mem-use/SKILL.md` — and save each into your agent's skills directory,
134
+ > preserving the folder names (`mcts-mem-build/`, `mcts-mem-use/`).
135
+
136
+ Your agent already knows where its skills live, so this works in any of them. The two skills
137
+ are the open [Agent Skills](https://agentskills.io) format, so the same files work across
138
+ agents — nothing here is specific to one tool.
139
+
140
+ Prefer to do it by hand? Copy the two folders under `skills/` into your agent's skills path
141
+ — e.g. `~/.claude/skills/` (personal) or `.claude/skills/` (project) for Claude Code.
142
+
143
+ ### The CLI
144
+
145
+ No install step. The skills run the linter as `npx mcts-mem lint <path>`, which fetches the
146
+ published package on demand.
147
+
148
+ ## The deeper idea
149
+
150
+ Why a design memory is shaped like a *search* — and why that matters more as AI makes
151
+ building cheap enough to explore the solution space in earnest — is in
152
+ [`rationale.md`](rationale.md): the MCTS framing, the model's own evolution (including the
153
+ dead ends it discarded), and the honest constraints on what it can do.
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/cli.js';
3
+ process.exit(run(process.argv.slice(2)));
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "mcts-mem",
3
+ "version": "0.1.0",
4
+ "description": "Build, verify, and explore an MCTS-Mem design tree — a structured, linter-checked memory of how a project was decided.",
5
+ "type": "module",
6
+ "bin": {
7
+ "mcts-mem": "bin/mcts-mem.js"
8
+ },
9
+ "author": "B.M. <mbbill@gmail.com>",
10
+ "license": "MIT",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/mbbill/MCTS-Mem.git"
14
+ },
15
+ "homepage": "https://github.com/mbbill/MCTS-Mem#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/mbbill/MCTS-Mem/issues"
18
+ },
19
+ "keywords": [
20
+ "mcts-mem",
21
+ "design-memory",
22
+ "design-rationale",
23
+ "adr",
24
+ "architecture",
25
+ "linter",
26
+ "agent-skills"
27
+ ],
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "files": [
32
+ "bin",
33
+ "src"
34
+ ],
35
+ "scripts": {
36
+ "test": "node --test",
37
+ "prepublishOnly": "node --test"
38
+ }
39
+ }
package/src/cli.js ADDED
@@ -0,0 +1,110 @@
1
+ // mcts-mem — build, verify, and explore an MCTS-Mem design tree.
2
+
3
+ import { lint } from './lint.js';
4
+ import { view, show } from './view.js';
5
+ import { uncertain } from './uncertain.js';
6
+
7
+ const VERSION = '0.1.0';
8
+ const DEFAULT_ROOT = 'mcts_mem';
9
+
10
+ const HELP = `mcts-mem — work with an MCTS-Mem design tree
11
+ (a structured, linter-checked memory of how a project was decided)
12
+
13
+ usage: mcts-mem <command> [path] [options]
14
+ path defaults to ./${DEFAULT_ROOT}
15
+
16
+ commands:
17
+ lint [path] [--skeleton]
18
+ Verify the tree against the grammar — structure, entry form, provenance
19
+ tags, verbatim move pairs, append-only history, links, and "every node
20
+ records a real decision". Run it like a compiler: after any edit, until
21
+ clean. Prints each violation as "[R-rule] path: message" and exits 1;
22
+ exits 0 when the tree is clean.
23
+ --skeleton Build-time only. Skips the R-thin check (a node with no
24
+ alternative / Facts / Moves / children is just a module-map
25
+ entry). A freshly-built Stage-1 skeleton is nodes + items
26
+ with no decisions recorded yet, so R-thin would fire on every
27
+ node; lint with --skeleton until history is folded in and the
28
+ tree pruned, then lint without it. (The mcts-mem-build skill
29
+ drives this; you won't need it on a finished tree.)
30
+
31
+ view [path] [--alt] [--depth N]
32
+ Render the decision tree. Node names are styled by confidence: bold =
33
+ fought over (≥5 facts), dim = unweighed (reconsider freely). Annotations
34
+ show facts (Nf), moves (Nm), and alternatives (↩N). --alt also walks the
35
+ rejected alternatives; --depth limits how deep to render.
36
+
37
+ show <node> [path]
38
+ Print one node in full — its items, Facts, Moves, sub-decisions, and the
39
+ alternatives it beat. <node> is a node name or a logical path.
40
+
41
+ uncertain [path]
42
+ List every (uncertain) entry — the worklist of decisions whose why is not
43
+ yet backed by code or a source.
44
+
45
+ help | --help | -h show this
46
+ --version print version
47
+ `;
48
+
49
+ function parse(args) {
50
+ const flags = {};
51
+ const pos = [];
52
+ for (let i = 0; i < args.length; i++) {
53
+ const a = args[i];
54
+ if (a === '--depth') flags.depth = Number(args[++i]);
55
+ else if (a === '--skeleton') flags.skeleton = true;
56
+ else if (a === '--alt') flags.alt = true;
57
+ else if (a.startsWith('--')) flags[a.slice(2)] = true;
58
+ else pos.push(a);
59
+ }
60
+ return { flags, pos };
61
+ }
62
+
63
+ export function run(argv) {
64
+ const cmd = argv[0];
65
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
66
+ process.stdout.write(HELP);
67
+ return 0;
68
+ }
69
+ if (cmd === '--version' || cmd === '-v') {
70
+ console.log(VERSION);
71
+ return 0;
72
+ }
73
+ const { flags, pos } = parse(argv.slice(1));
74
+ try {
75
+ switch (cmd) {
76
+ case 'lint': {
77
+ const root = pos[0] || DEFAULT_ROOT;
78
+ const { errors, nodeCount, factCount } = lint(root, flags);
79
+ if (errors.length) {
80
+ console.error(`${errors.length} violation(s):`);
81
+ for (const e of errors) console.error(` [${e.rule}] ${e.path}: ${e.msg}`);
82
+ return 1;
83
+ }
84
+ console.log(`lint clean: ${nodeCount} nodes, ${factCount} fact files`);
85
+ return 0;
86
+ }
87
+ case 'view': {
88
+ const root = pos[0] || DEFAULT_ROOT;
89
+ view(root, { alt: !!flags.alt, depth: flags.depth || Infinity });
90
+ return 0;
91
+ }
92
+ case 'show': {
93
+ if (!pos[0]) { console.error('show: needs a <node> name or logical path'); return 2; }
94
+ const root = pos[1] || DEFAULT_ROOT;
95
+ return show(root, pos[0]);
96
+ }
97
+ case 'uncertain': {
98
+ const root = pos[0] || DEFAULT_ROOT;
99
+ return uncertain(root);
100
+ }
101
+ default:
102
+ console.error(`unknown command: ${cmd}\n`);
103
+ process.stdout.write(HELP);
104
+ return 2;
105
+ }
106
+ } catch (e) {
107
+ console.error(`mcts-mem ${cmd}: ${e.message}`);
108
+ return 1;
109
+ }
110
+ }
package/src/lint.js ADDED
@@ -0,0 +1,192 @@
1
+ // Linter for an MCTS-Mem design tree — the executable form of the grammar the
2
+ // skills specify. A faithful port of the rule set: structure, entry form,
3
+ // provenance, verbatim move pairs, append-only, "not a module map", etc.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { execFileSync } from 'node:child_process';
8
+ import {
9
+ loadTree,
10
+ parseNode,
11
+ blocks,
12
+ normWhy,
13
+ ENTRY_HEAD,
14
+ PROV,
15
+ MOVE_VERB,
16
+ LINK,
17
+ } from './tree.js';
18
+
19
+ export function lint(root, opts = {}) {
20
+ const { skeleton = false } = opts;
21
+ const ctx = loadTree(root);
22
+ const display = (p) => path.relative(path.dirname(ctx.root), p);
23
+ const errors = [];
24
+ const err = (rule, p, msg) => errors.push({ rule, path: display(p), msg });
25
+
26
+ // ---------- R-root ----------
27
+ const top = ctx.nodeFiles.filter((p) => path.dirname(p) === ctx.root);
28
+ if (top.length !== 1) {
29
+ err('R-root', ctx.root, `expected exactly 1 top-level node, found ${top.length}: ` +
30
+ top.map((t) => path.basename(t)).join(', '));
31
+ }
32
+
33
+ // ---------- R-orphan / R-empty ----------
34
+ for (const d of ctx.dirs) {
35
+ const name = path.basename(d);
36
+ const base = name.endsWith('.fact') ? name.slice(0, -5)
37
+ : name.endsWith('.alt') ? name.slice(0, -4) : name;
38
+ const sibling = path.join(path.dirname(d), base + '.md');
39
+ if (!fs.existsSync(sibling)) err('R-orphan', d, `directory has no sibling ${base}.md`);
40
+ if ((name.endsWith('.alt') || name.endsWith('.fact')) &&
41
+ !fs.readdirSync(d).some((f) => f.endsWith('.md'))) {
42
+ err('R-empty', d, 'empty structure directory');
43
+ }
44
+ }
45
+
46
+ // ---------- per-node grammar ----------
47
+ for (const p of ctx.nodeFiles) {
48
+ const node = ctx.parsed.get(p);
49
+ const text = node.text;
50
+
51
+ // R-title
52
+ const first = text.split('\n').find((l) => l.trim()) || '';
53
+ if (first.startsWith('#')) err('R-title', p, 'file starts with a heading; items come first, no titles');
54
+
55
+ // R-sections
56
+ const allowed = ['## Facts', '## Moves'];
57
+ const seq = node.headings;
58
+ const bad = seq.filter((h) => !allowed.includes(h));
59
+ if (bad.length) err('R-sections', p, `unexpected heading(s): ${JSON.stringify(bad)}`);
60
+ const order = allowed.filter((h) => seq.includes(h));
61
+ if (JSON.stringify(seq) !== JSON.stringify(order)) err('R-sections', p, `sections out of order: ${JSON.stringify(seq)}`);
62
+
63
+ // R-items / R-tail
64
+ for (const para of node.itemsText.trim().split(/\n\s*\n/)) {
65
+ if (!para) continue;
66
+ if (!para.startsWith('- ')) {
67
+ err('R-items', p, `non-item content in items section: ${JSON.stringify(para.split('\n')[0].slice(0, 60))}`);
68
+ }
69
+ const flat = para.replace(/\s+/g, ' ');
70
+ const m = /(^|[ ,;(])(so|so that|because|thus|hence|since|therefore)[ ,]/i.exec(flat);
71
+ if (para.startsWith('- ') && m && m[2].toLowerCase() !== 'since') {
72
+ err('R-tail', p, `item has a rationale tail (${JSON.stringify(m[1].trim())}); move the why to Facts/Moves: ${JSON.stringify(flat.slice(0, 70))}`);
73
+ }
74
+ }
75
+
76
+ // R-entry / R-prov / R-verb / R-join over Facts + Moves
77
+ for (const [kind, list] of [['Facts', node.facts], ['Moves', node.moves]]) {
78
+ for (const b of list) {
79
+ if (!ENTRY_HEAD.test(b)) err('R-entry', p, `${kind} entry malformed head: ${JSON.stringify(b.split('\n')[0].slice(0, 70))}`);
80
+ if (!PROV.test(b)) err('R-prov', p, `${kind} entry missing provenance tag: ${JSON.stringify(b.split('\n')[0].slice(0, 70))}`);
81
+ if (kind === 'Moves' && !MOVE_VERB.test(b)) err('R-verb', p, `Moves entry has no boundary verb: ${JSON.stringify(b.split('\n')[0].slice(0, 70))}`);
82
+ if (/\b(which is why|that is why|the reason|hence|therefore)\b/i.test(b.replace(/\s+/g, ' '))) {
83
+ err('R-join', p, `${kind} entry chains two claims (atomize it): ${JSON.stringify(b.split('\n')[0].slice(0, 70))}`);
84
+ }
85
+ }
86
+ }
87
+
88
+ // R-redundant: a rationale fact must not share a commit with a Moves entry on this node
89
+ const moveHashes = new Set();
90
+ for (const b of node.moves) { const m = ENTRY_HEAD.exec(b); if (m && m[3]) moveHashes.add(m[3]); }
91
+ for (const b of node.facts) {
92
+ const m = ENTRY_HEAD.exec(b);
93
+ if (m && m[4].trim() === 'rationale' && m[3] && moveHashes.has(m[3])) {
94
+ err('R-redundant', p, `rationale fact shares commit ${m[3]} with a move on this node; the move already records the why`);
95
+ }
96
+ }
97
+
98
+ // R-meta: the tree never references its own construction
99
+ for (const w of ['ledger', 'batch report', 'design tree', 'extraction run', 'deferred until']) {
100
+ if (text.toLowerCase().includes(w)) err('R-meta', p, `workflow-metadata vocabulary in tree: ${JSON.stringify(w)}`);
101
+ }
102
+ }
103
+
104
+ // ---------- R-link ----------
105
+ for (const p of ctx.nodeFiles) {
106
+ const text = ctx.parsed.get(p).text.replace(/`[^`]*`/g, '');
107
+ for (const m of text.matchAll(LINK)) {
108
+ if (ctx.resolve(m[1], p) === null) err('R-link', p, `unresolvable link [[${m[1]}]]`);
109
+ }
110
+ }
111
+
112
+ // ---------- R-pair: replaced <-> replaced by, verbatim why ----------
113
+ // The twin is matched by identity (it points back to the winner) and the why
114
+ // must be verbatim — NOT by hash: each side records its own commit, which may
115
+ // differ (only the <why> is required identical).
116
+ for (const p of ctx.nodeFiles) {
117
+ for (const b of ctx.parsed.get(p).moves) {
118
+ const m = /^- \S+( \([0-9a-f]{8}\))? replaced \[\[([^\]]+)\]\]:/.exec(b);
119
+ if (!m) continue;
120
+ const loserRef = m[2];
121
+ const loser = ctx.resolve(loserRef, p);
122
+ if (loser === null) continue; // R-link already fired
123
+ // the loser must carry a 'replaced by' move with the SAME why; the two
124
+ // sides' dates/hashes may differ, so match on the verbatim why, not the hash
125
+ const twins = (ctx.parsed.get(loser)?.moves ?? []).filter((tb) =>
126
+ /^- \S+( \([0-9a-f]{8}\))? replaced by \[\[/.test(tb));
127
+ if (!twins.length) err('R-pair', p, `replaced [[${loserRef}]] has no 'replaced by' twin in ${path.basename(loser)}`);
128
+ else if (!twins.some((tb) => normWhy(tb) === normWhy(b))) err('R-pair', p, `why differs from twin in ${path.basename(loser)}`);
129
+ }
130
+ }
131
+
132
+ // ---------- R-frozen ----------
133
+ for (const p of ctx.nodeFiles) {
134
+ const inAltMember = path.basename(path.dirname(p)).endsWith('.alt');
135
+ const inAltSubtree = p.includes('.alt' + path.sep);
136
+ const mv = ctx.parsed.get(p).moves;
137
+ const last = mv.length ? mv[mv.length - 1] : '';
138
+ if (inAltMember && !/(replaced by \[\[|removed:)/.test(last)) {
139
+ err('R-frozen', p, ".alt member's Moves must end in 'replaced by'/'removed'");
140
+ }
141
+ if (!inAltSubtree && /replaced by \[\[/.test(last) && !last.includes('revived')) {
142
+ err('R-frozen', p, "main-tree node ends 'replaced by' (should it be in .alt/?)");
143
+ }
144
+ }
145
+
146
+ // ---------- R-thin (skipped under --skeleton) ----------
147
+ if (!skeleton) {
148
+ for (const p of ctx.nodeFiles) {
149
+ const base = p.slice(0, -3);
150
+ const hasMd = (dir) => fs.existsSync(dir) && fs.readdirSync(dir).some((f) => f.endsWith('.md'));
151
+ const node = ctx.parsed.get(p);
152
+ if (!(hasMd(base + '.alt') || hasMd(base) || node.facts.length || node.moves.length)) {
153
+ err('R-thin', p, 'node has no .alt/, no Facts, no Moves, and no children — it asserts a ' +
154
+ 'component without recording a decision (module-map node); fold it into its parent, or ' +
155
+ 'record the decision (alternative, rationale fact)');
156
+ }
157
+ }
158
+ }
159
+
160
+ // ---------- R-factfile ----------
161
+ for (const p of ctx.factFiles) {
162
+ if (/^#+ /m.test(fs.readFileSync(p, 'utf8'))) err('R-factfile', p, 'fact file contains headings');
163
+ }
164
+
165
+ // ---------- R-append: Facts/Moves are append-only vs git HEAD ----------
166
+ // A deliberate, bulk rewrite of committed history (a sanctioned migration) is
167
+ // handled by committing it — once committed, HEAD is the new baseline and this
168
+ // passes; there is no flag to switch the integrity check off.
169
+ {
170
+ const git = (...a) => execFileSync('git', a, { cwd: ctx.root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
171
+ let repoRoot = null;
172
+ try { repoRoot = git('rev-parse', '--show-toplevel').trim(); } catch { /* not a git repo */ }
173
+ if (repoRoot) {
174
+ const treeEntries = new Set();
175
+ for (const p of ctx.nodeFiles) {
176
+ const n = ctx.parsed.get(p);
177
+ for (const b of [...n.facts, ...n.moves]) treeEntries.add(b.replace(/\s+/g, ' '));
178
+ }
179
+ for (const p of ctx.nodeFiles) {
180
+ const rel = path.relative(repoRoot, p);
181
+ let old;
182
+ try { old = git('show', `HEAD:${rel}`); } catch { continue; } // new file
183
+ const on = parseNode(old);
184
+ const oldEntries = [...on.facts, ...on.moves].map((b) => b.replace(/\s+/g, ' '));
185
+ const gone = oldEntries.filter((e) => !treeEntries.has(e));
186
+ if (gone.length) err('R-append', p, `${gone.length} committed Facts/Moves entr${gone.length === 1 ? 'y' : 'ies'} edited or removed`);
187
+ }
188
+ }
189
+ }
190
+
191
+ return { errors, nodeCount: ctx.nodeFiles.length, factCount: ctx.factFiles.length };
192
+ }
package/src/tree.js ADDED
@@ -0,0 +1,194 @@
1
+ // Shared model of an MCTS-Mem design tree: walk the filesystem, parse each node
2
+ // into items / Facts / Moves, and resolve [[links]]. Every other command builds
3
+ // on this. The parsing here mirrors the grammar the linter enforces.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ export const ENTRY_HEAD =
9
+ /^- (\d{4}-\d{2}-\d{2})( \(([0-9a-f]{8})\))? ([a-z][a-z -]*?)(:| \[\[)/;
10
+ export const PROV = /\((code|sourced|uncertain)\)\.?\s*$/;
11
+ export const MOVE_VERB =
12
+ /^- \S+( \([0-9a-f]{8}\))? (replaced by \[\[[^\]]+\]\]:|replaced \[\[[^\]]+\]\]:|dropped:|removed:|revived:)/;
13
+ export const LINK = /\[\[([^\]]+)\]\]/g;
14
+
15
+ // split a section body into "- " entry blocks separated by blank lines
16
+ export function blocks(text) {
17
+ const out = [];
18
+ let cur = [];
19
+ for (const line of (text || '').split('\n')) {
20
+ if (line.startsWith('- ') && cur.length) {
21
+ out.push(cur.join('\n'));
22
+ cur = [line];
23
+ } else if (line.trim() === '') {
24
+ if (cur.length) { out.push(cur.join('\n')); cur = []; }
25
+ } else if (cur.length) {
26
+ cur.push(line);
27
+ } else if (line.startsWith('- ')) {
28
+ cur = [line];
29
+ }
30
+ }
31
+ if (cur.length) out.push(cur.join('\n'));
32
+ return out.filter((b) => b.trim());
33
+ }
34
+
35
+ // strip entry head + provenance, collapse whitespace and backticks → comparable why
36
+ export function normWhy(b) {
37
+ let s = b.replace(/^- [^:]*?(?:\[\[[^\]]+\]\])?:/, '');
38
+ s = s.trim().replace(PROV, '');
39
+ return s.replace(/`/g, '').replace(/\s+/g, ' ').trim().replace(/\.+$/, '');
40
+ }
41
+
42
+ // parse one node file into its three parts plus the raw heading list (for R-sections)
43
+ export function parseNode(text) {
44
+ text = text.replace(/\r\n?/g, '\n'); // normalize CRLF / lone CR so headings parse
45
+ const headings = [];
46
+ const sections = {};
47
+ let itemsLines = [];
48
+ let curName = null;
49
+ let cur = [];
50
+ for (const line of text.split('\n')) {
51
+ const h = /^(#+ .*)$/.exec(line);
52
+ if (h) {
53
+ headings.push(h[1]);
54
+ if (curName !== null) sections[curName] = cur.join('\n');
55
+ const hm = /^## (.+)$/.exec(line);
56
+ curName = hm ? hm[1].trim() : ` ${headings.length}`;
57
+ cur = [];
58
+ } else if (curName === null) {
59
+ itemsLines.push(line);
60
+ } else {
61
+ cur.push(line);
62
+ }
63
+ }
64
+ if (curName !== null) sections[curName] = cur.join('\n');
65
+ return {
66
+ text,
67
+ headings,
68
+ itemsText: itemsLines.join('\n'),
69
+ facts: blocks(sections.Facts ?? ''),
70
+ moves: blocks(sections.Moves ?? ''),
71
+ };
72
+ }
73
+
74
+ // parse an entry block into structured fields (for view / uncertain)
75
+ export function parseEntry(block) {
76
+ const flat = block.replace(/\s+/g, ' ').trim();
77
+ const m = ENTRY_HEAD.exec(block);
78
+ const tagM = PROV.exec(flat);
79
+ const isMove = MOVE_VERB.test(block);
80
+ let verb = null;
81
+ if (isMove) {
82
+ const vm = /(replaced by|replaced|dropped|removed|revived)/.exec(flat);
83
+ verb = vm ? vm[1] : null;
84
+ }
85
+ return {
86
+ raw: block,
87
+ flat,
88
+ date: m ? m[1] : null,
89
+ hash: m ? m[3] || null : null,
90
+ kind: m ? m[4].trim() : null,
91
+ tag: tagM ? tagM[1] : null,
92
+ isMove,
93
+ verb,
94
+ };
95
+ }
96
+
97
+ function stemOf(p) {
98
+ return path.basename(p).slice(0, -3);
99
+ }
100
+
101
+ // logical path: relative to root, with .alt segments stripped (pivot-proof)
102
+ export function logical(root, p) {
103
+ const rel = path.relative(root, p).slice(0, -3);
104
+ return rel
105
+ .split(path.sep)
106
+ .map((seg) => (seg.endsWith('.alt') ? seg.slice(0, -4) : seg))
107
+ .join('/');
108
+ }
109
+
110
+ // Load and parse a whole tree rooted at `root` (the dir holding the single top node).
111
+ export function loadTree(root) {
112
+ root = path.resolve(root);
113
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
114
+ throw new Error(`tree root not found: ${root}`);
115
+ }
116
+ // canonicalize symlinks so paths agree with git's `rev-parse --show-toplevel`
117
+ // (otherwise the R-append check silently no-ops under /tmp, /var → /private, etc.)
118
+ root = fs.realpathSync(root);
119
+ const nodeFiles = [];
120
+ const factFiles = [];
121
+ const dirs = [];
122
+ (function rec(d) {
123
+ let ents;
124
+ try {
125
+ ents = fs.readdirSync(d, { withFileTypes: true });
126
+ } catch {
127
+ return;
128
+ }
129
+ for (const e of ents) {
130
+ const full = path.join(d, e.name);
131
+ if (e.isDirectory()) {
132
+ dirs.push(full);
133
+ rec(full);
134
+ } else if (e.name.endsWith('.md')) {
135
+ (path.basename(d).endsWith('.fact') ? factFiles : nodeFiles).push(full);
136
+ }
137
+ }
138
+ })(root);
139
+
140
+ const parsed = new Map();
141
+ for (const p of nodeFiles) parsed.set(p, parseNode(fs.readFileSync(p, 'utf8')));
142
+
143
+ const stems = new Map();
144
+ for (const p of nodeFiles) {
145
+ const s = stemOf(p);
146
+ if (!stems.has(s)) stems.set(s, []);
147
+ stems.get(s).push(p);
148
+ }
149
+
150
+ const ctx = { root, nodeFiles, factFiles, dirs, parsed, stems };
151
+ ctx.stem = stemOf;
152
+ ctx.logical = (p) => logical(root, p);
153
+ ctx.resolve = (ref, frm) => resolve(ctx, ref, frm);
154
+ return ctx;
155
+ }
156
+
157
+ // resolve a [[link]] target to a node (or fact) file path, or null
158
+ export function resolve(ctx, ref, frm) {
159
+ if (ref.includes('.fact/')) {
160
+ const tail = ref.split('/').pop() + '.md';
161
+ const cand = ctx.factFiles.filter(
162
+ (f) => f.endsWith(tail) || logical(ctx.root, f).endsWith(ref)
163
+ );
164
+ return cand[0] ?? null;
165
+ }
166
+ const name = ref.split('/').pop();
167
+ const cands = ctx.stems.get(name) ?? [];
168
+ if (cands.length === 1) return cands[0];
169
+ const near = cands.filter(
170
+ (c) =>
171
+ path.dirname(c).startsWith(path.dirname(frm)) ||
172
+ path.dirname(frm).startsWith(path.dirname(c).replace('.alt', ''))
173
+ );
174
+ return near.length === 1 ? near[0] : cands[0] ?? null;
175
+ }
176
+
177
+ // structural relatives of a node file p = <dir>/<name>.md
178
+ export function relatives(ctx, p) {
179
+ const base = p.slice(0, -3); // strip .md
180
+ const subtree = base; // <dir>/<name>/
181
+ const altDir = base + '.alt';
182
+ const factDir = base + '.fact';
183
+ const childOf = (dir) =>
184
+ ctx.nodeFiles.filter((q) => path.dirname(q) === dir).sort();
185
+ const factsIn = (dir) =>
186
+ fs.existsSync(dir)
187
+ ? fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort()
188
+ : [];
189
+ return {
190
+ children: childOf(subtree),
191
+ alts: childOf(altDir),
192
+ factFiles: factsIn(factDir).map((f) => path.join(factDir, f)),
193
+ };
194
+ }
@@ -0,0 +1,37 @@
1
+ // List every (uncertain) entry — the worklist of decisions whose *why* is not
2
+ // yet backed by the code or any source. Resolve these by finding a source,
3
+ // asking a human, or accepting they are lost; never by guessing.
4
+
5
+ import { loadTree, parseEntry } from './tree.js';
6
+
7
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
8
+ const c = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
9
+ const dim = (s) => c('2', s);
10
+ const bold = (s) => c('1', s);
11
+
12
+ export function uncertain(root) {
13
+ const ctx = loadTree(root);
14
+ let n = 0;
15
+ for (const p of ctx.nodeFiles) {
16
+ const node = ctx.parsed.get(p);
17
+ const hits = [...node.facts, ...node.moves]
18
+ .map(parseEntry)
19
+ .filter((e) => e.tag === 'uncertain');
20
+ if (!hits.length) continue;
21
+ console.log(bold(ctx.logical(p)));
22
+ for (const e of hits) {
23
+ n++;
24
+ const claim = e.flat
25
+ .replace(/^- [^:]*?(?:\[\[[^\]]+\]\])?:/, '')
26
+ .replace(/\(uncertain\)\.?\s*$/, '')
27
+ .trim();
28
+ console.log(' ' + dim(`${e.date || ''} ${e.hash ? '(' + e.hash + ')' : ''}`.trim()) + ` ${claim}`);
29
+ }
30
+ }
31
+ console.log();
32
+ console.log(n === 0
33
+ ? 'no open uncertainties — every recorded why is backed by code or a source.'
34
+ : dim(`${n} uncertain ${n === 1 ? 'entry' : 'entries'}: each is a real decision whose why is unbacked. ` +
35
+ 'Resolve by finding a source, asking a human, or accepting it is lost — never by guessing.'));
36
+ return 0;
37
+ }
package/src/view.js ADDED
@@ -0,0 +1,155 @@
1
+ // Explore an MCTS-Mem tree from the terminal: render the decision tree with
2
+ // confidence signals (fact density, alternatives), and show a single node.
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { loadTree, parseEntry, relatives } from './tree.js';
7
+
8
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
9
+ const c = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
10
+ const dim = (s) => c('2', s);
11
+ const bold = (s) => c('1', s);
12
+ const cyan = (s) => c('36', s);
13
+ const yellow = (s) => c('33', s);
14
+
15
+ function build(ctx, p) {
16
+ const rel = relatives(ctx, p);
17
+ const node = ctx.parsed.get(p);
18
+ return {
19
+ p,
20
+ name: ctx.stem(p),
21
+ facts: node.facts.length,
22
+ moves: node.moves.length,
23
+ altCount: rel.alts.length,
24
+ children: rel.children.map((q) => build(ctx, q)),
25
+ alts: rel.alts.map((q) => build(ctx, q)),
26
+ };
27
+ }
28
+
29
+ function annot(n) {
30
+ const bits = [];
31
+ if (n.facts) bits.push(dim(`${n.facts}f`));
32
+ if (n.moves) bits.push(dim(`${n.moves}m`));
33
+ if (n.altCount) bits.push(dim(`↩${n.altCount}`));
34
+ return bits.length ? ' ' + bits.join(' ') : '';
35
+ }
36
+
37
+ // name styled by confidence signal: thick with facts = fought over; no signal = reconsider freely
38
+ function label(n, rejected) {
39
+ let name = n.name;
40
+ if (rejected) return dim('✗ ' + name);
41
+ if (n.facts >= 5) name = bold(name);
42
+ else if (n.facts === 0 && n.moves === 0 && n.altCount === 0) name = dim(name);
43
+ return name + annot(n);
44
+ }
45
+
46
+ function renderTree(ctx, { alt = false, depth = Infinity } = {}) {
47
+ const top = ctx.nodeFiles.filter((p) => path.dirname(p) === ctx.root);
48
+ if (top.length !== 1) {
49
+ console.log(yellow(`(tree has ${top.length} top-level nodes; expected 1 — run \`mcts-mem lint\`)`));
50
+ if (!top.length) return;
51
+ }
52
+ const root = build(ctx, top[0]);
53
+ console.log(label(root, false));
54
+ const walk = (n, prefix, d) => {
55
+ if (d <= 0) return;
56
+ const kids = [
57
+ ...n.children.map((k) => [k, false]),
58
+ ...(alt ? n.alts.map((k) => [k, true]) : []),
59
+ ];
60
+ kids.forEach(([k, rej], i) => {
61
+ const last = i === kids.length - 1;
62
+ console.log(prefix + dim(last ? '└─ ' : '├─ ') + label(k, rej));
63
+ walk(k, prefix + (last ? ' ' : dim('│') + ' '), d - 1);
64
+ });
65
+ };
66
+ walk(root, '', depth);
67
+ console.log();
68
+ console.log(dim('legend: ') + bold('bold') + dim(' = fought over (≥5 facts) · ') +
69
+ dim('dim') + dim(' = unweighed, reconsider freely · Nf facts · Nm moves · ↩N alternatives'));
70
+ if (!alt) console.log(dim(' pass --alt to walk the rejected alternatives'));
71
+ }
72
+
73
+ function findNode(ctx, query) {
74
+ const q = query.replace(/\.md$/, '');
75
+ const exact = ctx.nodeFiles.filter((p) => ctx.stem(p) === q);
76
+ if (exact.length === 1) return exact[0];
77
+ const byLogical = ctx.nodeFiles.filter((p) => ctx.logical(p) === q || ctx.logical(p).endsWith('/' + q));
78
+ const pool = exact.length ? exact : byLogical;
79
+ return pool.length === 1 ? pool[0] : pool;
80
+ }
81
+
82
+ function showNode(ctx, query) {
83
+ const found = findNode(ctx, query);
84
+ if (Array.isArray(found)) {
85
+ if (!found.length) { console.log(yellow(`no node matches "${query}"`)); return 1; }
86
+ console.log(yellow(`"${query}" is ambiguous — ${found.length} matches:`));
87
+ for (const p of found) console.log(' ' + ctx.logical(p));
88
+ return 1;
89
+ }
90
+ const p = found;
91
+ const node = ctx.parsed.get(p);
92
+ const rel = relatives(ctx, p);
93
+ const inAlt = p.includes('.alt' + path.sep);
94
+
95
+ console.log(bold(ctx.logical(p)) + (inAlt ? ' ' + dim('(superseded / rejected — in .alt/)') : ''));
96
+ console.log();
97
+ // Items
98
+ const items = node.itemsText.trim();
99
+ if (items) console.log(items);
100
+ // Facts
101
+ if (node.facts.length) {
102
+ console.log('\n' + cyan('## Facts'));
103
+ for (const b of node.facts) {
104
+ const e = parseEntry(b);
105
+ console.log(' ' + dim(`${e.date || ''} ${e.hash ? '(' + e.hash + ')' : ''}`.trim()) +
106
+ ` ${e.kind || ''}` + (e.tag ? ' ' + tagColor(e.tag) : ''));
107
+ console.log(' ' + claim(e));
108
+ }
109
+ }
110
+ // Moves
111
+ if (node.moves.length) {
112
+ console.log('\n' + cyan('## Moves'));
113
+ for (const b of node.moves) {
114
+ const e = parseEntry(b);
115
+ console.log(' ' + dim(`${e.date || ''} ${e.hash ? '(' + e.hash + ')' : ''}`.trim()) +
116
+ ` ${e.verb || ''}` + (e.tag ? ' ' + tagColor(e.tag) : ''));
117
+ console.log(' ' + claim(e));
118
+ }
119
+ }
120
+ // relatives
121
+ if (rel.children.length) {
122
+ console.log('\n' + cyan('sub-decisions:'));
123
+ for (const q of rel.children) console.log(' ' + ctx.stem(q));
124
+ }
125
+ if (rel.alts.length) {
126
+ console.log('\n' + cyan('alternatives (rejected / superseded):'));
127
+ for (const q of rel.alts) console.log(' ' + dim('✗ ') + ctx.stem(q));
128
+ }
129
+ if (rel.factFiles.length) {
130
+ console.log('\n' + cyan('graduated evidence (.fact/):'));
131
+ for (const f of rel.factFiles) console.log(' ' + path.basename(f));
132
+ }
133
+ return 0;
134
+ }
135
+
136
+ function tagColor(tag) {
137
+ if (tag === 'code') return c('32', '(code)');
138
+ if (tag === 'sourced') return c('34', '(sourced)');
139
+ if (tag === 'uncertain') return c('33', '(uncertain)');
140
+ return `(${tag})`;
141
+ }
142
+
143
+ // the entry text minus its "- date (hash) kind:" / "- ... verb:" head and trailing tag
144
+ function claim(e) {
145
+ let s = e.flat.replace(/^- [^:]*?(?:\[\[[^\]]+\]\])?:/, '').trim();
146
+ s = s.replace(/\((code|sourced|uncertain)\)\.?\s*$/, '').trim();
147
+ return s;
148
+ }
149
+
150
+ export function view(root, opts) {
151
+ return renderTree(loadTree(root), opts);
152
+ }
153
+ export function show(root, query) {
154
+ return showNode(loadTree(root), query);
155
+ }