@nfinitmonkeys/clan-engine 0.1.1 → 0.3.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/README.md +62 -10
- package/dist/apply.js +7 -1
- package/dist/brain-build.d.ts +49 -0
- package/dist/brain-build.js +278 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +75 -8
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/inspect.js +102 -5
- package/dist/inventory.d.ts +3 -1
- package/dist/inventory.js +4 -2
- package/dist/mcp.d.ts +10 -0
- package/dist/mcp.js +11 -0
- package/dist/reconcile.js +24 -4
- package/dist/sanitize.d.ts +23 -0
- package/dist/sanitize.js +71 -0
- package/dist/templates.d.ts +4 -1
- package/dist/templates.js +244 -20
- package/dist/types.d.ts +24 -1
- package/dist/types.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,18 +9,27 @@ tooling all render from here, so their output can never drift apart again.
|
|
|
9
9
|
`.mcp.json` instead of clobbering, backs up every file it touches, and journals
|
|
10
10
|
every run so it can be undone exactly.
|
|
11
11
|
|
|
12
|
+
## Tiers
|
|
13
|
+
|
|
14
|
+
As of 0.3.0 the engine is authoritative for **clan, district, and jungle**
|
|
15
|
+
tiers — one template source renders the right `/clan:*`, `/district:*`, or
|
|
16
|
+
`/jungle:*` pack from the repo's declared `<!-- geo-tier: X -->`. **Biome is
|
|
17
|
+
unbuilt**: asking for a biome pack throws (`no pack template for tier biome
|
|
18
|
+
yet`) rather than emitting something half-real.
|
|
19
|
+
|
|
12
20
|
## Verbs
|
|
13
21
|
|
|
14
22
|
```
|
|
15
|
-
clan-engine inspect
|
|
16
|
-
clan-engine plan
|
|
17
|
-
clan-engine apply
|
|
18
|
-
clan-engine
|
|
19
|
-
clan-engine
|
|
23
|
+
clan-engine inspect [--repo .] [--json] # map a repo's state (never mutates)
|
|
24
|
+
clan-engine plan [--repo .] --alpha X --name Y [--tier clan] [--json]
|
|
25
|
+
clan-engine apply [--repo .] --alpha X --name Y # write it (backups + journal)
|
|
26
|
+
clan-engine brain-build [--repo .] [--write] [--memory] [--json] # mine the brain
|
|
27
|
+
clan-engine undo [--repo .] [--journal <path>] # reverse the last apply
|
|
28
|
+
clan-engine doctor [--repo .] [--json] # validate the whole loop
|
|
20
29
|
```
|
|
21
30
|
|
|
22
31
|
`plan` is a dry run; `apply` writes. Both are idempotent — a second `apply`
|
|
23
|
-
is all skips.
|
|
32
|
+
is all skips. `--json` output masks live key values as `[redacted]` everywhere.
|
|
24
33
|
|
|
25
34
|
## What it authors
|
|
26
35
|
|
|
@@ -28,7 +37,13 @@ is all skips.
|
|
|
28
37
|
never rewritten; only the `<!-- geo-tier: X -->` marker is ensured).
|
|
29
38
|
- **`.claude/skills/clan-awareness/SKILL.md`** — the awareness skill in the
|
|
30
39
|
directory format Claude Code actually loads (a flat file never loads).
|
|
31
|
-
- **`.claude/commands/<tier>/*.md`** — tier-named slash-command pack
|
|
40
|
+
- **`.claude/commands/<tier>/*.md`** — tier-named slash-command pack, including
|
|
41
|
+
**`/<tier>:up`** — the wizard door. Clan tier gets the full conversational
|
|
42
|
+
setup/convert/repair playbook (Claude Code improvises the conversation; the
|
|
43
|
+
engine does every write); district/jungle get a short repair wizard (their
|
|
44
|
+
identity is provisioned by node-up, never interviewed). Generated wizard text
|
|
45
|
+
**version-pins** the engine (`npx -y @nfinitmonkeys/clan-engine@0.3`) so an
|
|
46
|
+
older published engine can never overwrite the pack that invoked it.
|
|
32
47
|
- **`.mcp.json`** — a correctly-named `jungle-local` server **merged** alongside
|
|
33
48
|
any servers you already have, and gitignored (it holds a live key). Never the
|
|
34
49
|
bare name `jungle` (it collides with the global council server).
|
|
@@ -39,6 +54,39 @@ is all skips.
|
|
|
39
54
|
- **`.clan/inventory.json`** — the user's EXISTING agents/skills/commands/hooks,
|
|
40
55
|
so the Alpha works *with* them instead of duplicating.
|
|
41
56
|
|
|
57
|
+
## brain-build
|
|
58
|
+
|
|
59
|
+
Mines README, `docs/` (recursive), CLAUDE.md, package manifests, code structure,
|
|
60
|
+
the inventory, and (`--memory`) Claude Code session memory into brain pages.
|
|
61
|
+
Every source passes `scanSensitive` first — secrets, infra identifiers, and PHI
|
|
62
|
+
are **held back and reported, never written**. Dry-run by default; `--write` to
|
|
63
|
+
save; create-only (your accumulated pages are never touched).
|
|
64
|
+
|
|
65
|
+
**LLM curation is API-only.** The engine itself has no LLM and the CLI has no
|
|
66
|
+
`--curate` flag. Callers pass a hook:
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
const res = await brainBuild(repo, identity, { curate: async ({ pages, identity, sourceNotes }) => curatedPages });
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The caller brings the LLM (this is what `clan brain build --curate` does with
|
|
73
|
+
the clan's configured provider); the engine re-gates **every** curated page
|
|
74
|
+
through `scanSensitive` + create-only, and falls back to the mechanical pages
|
|
75
|
+
with a note if the curator throws. Sanitization is not optional at any layer.
|
|
76
|
+
|
|
77
|
+
## Existing-vault detection
|
|
78
|
+
|
|
79
|
+
`inspect` finds the repo's REAL vault so setup and brain-build never scaffold a
|
|
80
|
+
tier-default duplicate nothing loads. Precedence:
|
|
81
|
+
|
|
82
|
+
1. the vault a live `load-brain` SessionStart hook already hydrates — wins
|
|
83
|
+
outright (the hook is ground truth for what sessions actually load);
|
|
84
|
+
2. vaults containing `wiki/identity.md`, in order `.district/brain` >
|
|
85
|
+
tier-default root > `.brain` > `.brains/<alpha>` > other `.brains/*`
|
|
86
|
+
(preferring `obsidian`) — a real identity beats debris;
|
|
87
|
+
3. bare-existing vaults in the same order;
|
|
88
|
+
4. none → the tier default applies.
|
|
89
|
+
|
|
42
90
|
## Reconciliation
|
|
43
91
|
|
|
44
92
|
Every generated file carries a `clan-engine@<v>` + `layout: <n>` marker. That's
|
|
@@ -46,16 +94,20 @@ how the engine tells its own output (safe to update) from a file you wrote at
|
|
|
46
94
|
the same path (backed up, then replaced). Brain pages are create-only — your
|
|
47
95
|
accumulated memory is never clobbered.
|
|
48
96
|
|
|
97
|
+
`ENGINE_VERSION` (in `src/types.ts`) always equals the package version — the
|
|
98
|
+
test suite fails on drift. `LAYOUT_VERSION` bumps only when generated output
|
|
99
|
+
changes shape (that's what triggers migrations); `ENGINE_VERSION` bumps freely.
|
|
100
|
+
|
|
49
101
|
## Programmatic API
|
|
50
102
|
|
|
51
103
|
```js
|
|
52
|
-
import { inspect, reconcile, apply, undo, doctor, setup } from '@nfinitmonkeys/clan-engine';
|
|
104
|
+
import { inspect, reconcile, apply, undo, doctor, setup, brainBuild } from '@nfinitmonkeys/clan-engine';
|
|
53
105
|
|
|
54
106
|
const { plan, result } = setup(repo, identity, { at, today }); // inspect → reconcile → apply
|
|
55
107
|
```
|
|
56
108
|
|
|
57
|
-
The conversational setup wizard drives these verbs — the LLM
|
|
58
|
-
conversation, the engine does every write.
|
|
109
|
+
The conversational setup wizard (`/clan:up`) drives these verbs — the LLM
|
|
110
|
+
improvises the conversation, the engine does every write.
|
|
59
111
|
|
|
60
112
|
## License
|
|
61
113
|
|
package/dist/apply.js
CHANGED
|
@@ -74,9 +74,15 @@ export function apply(plan, opts) {
|
|
|
74
74
|
}
|
|
75
75
|
let journalPath;
|
|
76
76
|
if (!opts.noJournal && ops.length > 0) {
|
|
77
|
+
// NEVER journal a live key. The journal lives under .claude/ (which many
|
|
78
|
+
// repos commit); identity here is undo metadata, not a credential store —
|
|
79
|
+
// undo only reads ops. The key itself stays in .jungle/config.yaml.
|
|
80
|
+
const journalIdentity = plan.identity.jungle
|
|
81
|
+
? { ...plan.identity, jungle: { ...plan.identity.jungle, apiKey: '[redacted]' } }
|
|
82
|
+
: plan.identity;
|
|
77
83
|
const journal = {
|
|
78
84
|
engineVersion: ENGINE_VERSION, layoutVersion: LAYOUT_VERSION,
|
|
79
|
-
at: runId, repo, identity:
|
|
85
|
+
at: runId, repo, identity: journalIdentity, ops,
|
|
80
86
|
};
|
|
81
87
|
const jRel = join(runDir, `journal-${runId}.json`);
|
|
82
88
|
const jAbs = join(repo, jRel);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brain-build — mine a clan's EXISTING files into brain wiki pages, so a new
|
|
3
|
+
* clan doesn't start with an empty brain. Sources: README, docs/, CLAUDE.md,
|
|
4
|
+
* package manifests, top-level code structure, the engine's inventory of the
|
|
5
|
+
* user's existing Claude Code assets, and (opt-in) Claude Code session memory.
|
|
6
|
+
*
|
|
7
|
+
* SAFETY (non-negotiable): every candidate body is scanned with scanSensitive
|
|
8
|
+
* BEFORE it becomes a page; anything that trips (secrets / infra ids / PHI) is
|
|
9
|
+
* HELD BACK, never written, and reported for the operator to review. Pages are
|
|
10
|
+
* create-only — accumulated brain content is never clobbered.
|
|
11
|
+
*
|
|
12
|
+
* Mechanical (no LLM required) — the right no-key base layer. LLM curation is
|
|
13
|
+
* an opt-in HOOK (opts.curate): the caller brings the LLM, the engine stays
|
|
14
|
+
* zero-LLM and re-gates everything the curator returns.
|
|
15
|
+
*/
|
|
16
|
+
import type { ClanIdentity, GeneratedFile, BrainCurator } from './types.js';
|
|
17
|
+
export interface HeldBack {
|
|
18
|
+
from: string;
|
|
19
|
+
reasons: string[];
|
|
20
|
+
}
|
|
21
|
+
export interface BrainBuildResult {
|
|
22
|
+
pages: GeneratedFile[];
|
|
23
|
+
heldBack: HeldBack[];
|
|
24
|
+
scanned: number;
|
|
25
|
+
/** Operator-facing report lines (e.g. curation fallback) — never secrets. */
|
|
26
|
+
notes: string[];
|
|
27
|
+
}
|
|
28
|
+
export interface BrainBuildOpts {
|
|
29
|
+
home?: string;
|
|
30
|
+
hipaa?: boolean;
|
|
31
|
+
includeMemory?: boolean;
|
|
32
|
+
/** LLM curation hook — API-only (the engine CLI has no LLM to drive one).
|
|
33
|
+
* Curated output replaces the candidates but every returned page re-passes
|
|
34
|
+
* scanSensitive + create-only; a throwing curator falls back to the
|
|
35
|
+
* mechanical pages with a note in the report. */
|
|
36
|
+
curate?: BrainCurator;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Mine existing files into brain pages. Every candidate is scanned; flagged
|
|
40
|
+
* ones are held back. Returns create-candidate pages + the held-back report.
|
|
41
|
+
* Without opts.curate the call stays synchronous (existing callers unchanged);
|
|
42
|
+
* with a curator it returns a Promise of the curated (re-gated) result.
|
|
43
|
+
*/
|
|
44
|
+
export declare function brainBuild(repo: string, identity: ClanIdentity, opts?: BrainBuildOpts & {
|
|
45
|
+
curate?: undefined;
|
|
46
|
+
}): BrainBuildResult;
|
|
47
|
+
export declare function brainBuild(repo: string, identity: ClanIdentity, opts: BrainBuildOpts & {
|
|
48
|
+
curate: BrainCurator;
|
|
49
|
+
}): Promise<BrainBuildResult>;
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brain-build — mine a clan's EXISTING files into brain wiki pages, so a new
|
|
3
|
+
* clan doesn't start with an empty brain. Sources: README, docs/, CLAUDE.md,
|
|
4
|
+
* package manifests, top-level code structure, the engine's inventory of the
|
|
5
|
+
* user's existing Claude Code assets, and (opt-in) Claude Code session memory.
|
|
6
|
+
*
|
|
7
|
+
* SAFETY (non-negotiable): every candidate body is scanned with scanSensitive
|
|
8
|
+
* BEFORE it becomes a page; anything that trips (secrets / infra ids / PHI) is
|
|
9
|
+
* HELD BACK, never written, and reported for the operator to review. Pages are
|
|
10
|
+
* create-only — accumulated brain content is never clobbered.
|
|
11
|
+
*
|
|
12
|
+
* Mechanical (no LLM required) — the right no-key base layer. LLM curation is
|
|
13
|
+
* an opt-in HOOK (opts.curate): the caller brings the LLM, the engine stays
|
|
14
|
+
* zero-LLM and re-gates everything the curator returns.
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, existsSync, readdirSync, lstatSync } from 'fs';
|
|
17
|
+
import { join } from 'path';
|
|
18
|
+
import { homedir } from 'os';
|
|
19
|
+
import { brainRoot, markerLines } from './templates.js';
|
|
20
|
+
import { scanSensitive } from './sanitize.js';
|
|
21
|
+
function slugify(s) {
|
|
22
|
+
return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'untitled';
|
|
23
|
+
}
|
|
24
|
+
/** Collapse to a single clean line for a YAML scalar / title. */
|
|
25
|
+
function oneLine(s) {
|
|
26
|
+
return String(s).replace(/\s+/g, ' ').trim();
|
|
27
|
+
}
|
|
28
|
+
function read(p) { try {
|
|
29
|
+
return readFileSync(p, 'utf-8');
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
} }
|
|
34
|
+
function listMd(dir, depth = 3) {
|
|
35
|
+
if (depth < 0 || !existsSync(dir))
|
|
36
|
+
return [];
|
|
37
|
+
const out = [];
|
|
38
|
+
let entries = [];
|
|
39
|
+
try {
|
|
40
|
+
entries = readdirSync(dir);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
for (const e of entries) {
|
|
46
|
+
if (e.startsWith('.') || e === 'node_modules')
|
|
47
|
+
continue;
|
|
48
|
+
const full = join(dir, e);
|
|
49
|
+
// lstat, not stat: never follow a symlink (a docs/x -> / link must not
|
|
50
|
+
// pull the whole filesystem into the brain).
|
|
51
|
+
let st;
|
|
52
|
+
try {
|
|
53
|
+
st = lstatSync(full);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (st.isSymbolicLink())
|
|
59
|
+
continue;
|
|
60
|
+
if (st.isDirectory())
|
|
61
|
+
out.push(...listMd(full, depth - 1));
|
|
62
|
+
else if (e.endsWith('.md'))
|
|
63
|
+
out.push(full);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/** README H2 sections → concept candidates. */
|
|
68
|
+
function mineReadme(repo) {
|
|
69
|
+
const body = read(join(repo, 'README.md')) ?? read(join(repo, 'readme.md'));
|
|
70
|
+
if (!body)
|
|
71
|
+
return [];
|
|
72
|
+
const out = [];
|
|
73
|
+
const parts = body.split(/^##\s+/m);
|
|
74
|
+
for (let i = 1; i < parts.length; i++) {
|
|
75
|
+
const seg = parts[i];
|
|
76
|
+
const title = seg.split('\n')[0].trim();
|
|
77
|
+
const content = seg.slice(seg.indexOf('\n') + 1).trim();
|
|
78
|
+
if (title && content.length > 20)
|
|
79
|
+
out.push({ category: 'concepts', slug: slugify(title), title, type: 'concept', from: 'README.md', body: content });
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/** docs/**.md → source candidates (recursive, bounded). */
|
|
84
|
+
function mineDocs(repo) {
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const f of listMd(join(repo, 'docs'))) {
|
|
87
|
+
const body = read(f);
|
|
88
|
+
if (!body || body.length < 40)
|
|
89
|
+
continue;
|
|
90
|
+
const rel = f.slice(repo.length + 1);
|
|
91
|
+
const title = (/^#\s+(.+)$/m.exec(body)?.[1] ?? rel).trim();
|
|
92
|
+
out.push({ category: 'sources', slug: slugify(rel.replace(/\.md$/, '')), title, type: 'source', from: rel, body });
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/** CLAUDE.md → a source page (the thing bootstrap-clan never converted). Strips
|
|
97
|
+
* the engine's own geo-tier/identity block so we don't re-ingest boilerplate. */
|
|
98
|
+
function mineClaudeMd(repo) {
|
|
99
|
+
const body = read(join(repo, 'CLAUDE.md'));
|
|
100
|
+
if (!body || body.length < 40)
|
|
101
|
+
return [];
|
|
102
|
+
const cleaned = body.replace(/<!--\s*geo-tier:[^>]*-->/g, '').trim();
|
|
103
|
+
if (cleaned.length < 40)
|
|
104
|
+
return [];
|
|
105
|
+
return [{ category: 'sources', slug: 'project-claude-md', title: 'Project CLAUDE.md', type: 'source', from: 'CLAUDE.md', body: cleaned }];
|
|
106
|
+
}
|
|
107
|
+
/** package.json / pyproject / Cargo → an entity overview. */
|
|
108
|
+
function mineManifest(repo) {
|
|
109
|
+
const pj = read(join(repo, 'package.json'));
|
|
110
|
+
if (pj) {
|
|
111
|
+
try {
|
|
112
|
+
const p = JSON.parse(pj);
|
|
113
|
+
const depsObj = (p.dependencies && typeof p.dependencies === 'object' && !Array.isArray(p.dependencies)) ? p.dependencies : {};
|
|
114
|
+
const devObj = (p.devDependencies && typeof p.devDependencies === 'object' && !Array.isArray(p.devDependencies)) ? p.devDependencies : {};
|
|
115
|
+
const deps = Object.keys({ ...depsObj, ...devObj });
|
|
116
|
+
const name = String(p.name ?? 'project');
|
|
117
|
+
const body = `**Name:** ${name}\n**Version:** ${String(p.version ?? '?')}\n**Description:** ${String(p.description ?? '(none)')}\n\n**Dependencies (${deps.length}):** ${deps.slice(0, 40).join(', ')}`;
|
|
118
|
+
return [{ category: 'entities', slug: 'project', title: name, type: 'entity', from: 'package.json', body }];
|
|
119
|
+
}
|
|
120
|
+
catch { /* ignore */ }
|
|
121
|
+
}
|
|
122
|
+
for (const [file, label] of [['pyproject.toml', 'Python project'], ['Cargo.toml', 'Rust crate']]) {
|
|
123
|
+
const c = read(join(repo, file));
|
|
124
|
+
if (c)
|
|
125
|
+
return [{ category: 'entities', slug: 'project', title: label, type: 'entity', from: file, body: c.slice(0, 800) }];
|
|
126
|
+
}
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
/** Top-level code directories → entity stubs. */
|
|
130
|
+
function mineCodeDirs(repo) {
|
|
131
|
+
const skip = new Set(['.git', '.claude', '.clan', '.brain', '.jungle', '.district', '.brains', 'node_modules', 'dist', 'build', 'docs', '.github', '.vscode']);
|
|
132
|
+
const out = [];
|
|
133
|
+
let entries = [];
|
|
134
|
+
try {
|
|
135
|
+
entries = readdirSync(repo);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
for (const e of entries) {
|
|
141
|
+
if (skip.has(e) || e.startsWith('.'))
|
|
142
|
+
continue;
|
|
143
|
+
let st;
|
|
144
|
+
try {
|
|
145
|
+
st = lstatSync(join(repo, e));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (st.isDirectory())
|
|
151
|
+
out.push({ category: 'entities', slug: slugify(e), title: e, type: 'entity', from: e + '/', body: `Top-level code directory \`${e}/\`. (Stub — the Alpha fills this in as it learns the module.)` });
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
/** The engine's inventory of existing assets → a "tools this project already has" concept. */
|
|
156
|
+
function mineInventory(repo) {
|
|
157
|
+
const raw = read(join(repo, '.clan', 'inventory.json'));
|
|
158
|
+
if (!raw)
|
|
159
|
+
return [];
|
|
160
|
+
try {
|
|
161
|
+
const items = (JSON.parse(raw).items ?? []);
|
|
162
|
+
if (!items.length)
|
|
163
|
+
return [];
|
|
164
|
+
const lines = items.map(i => `- **${i.name}** (${i.kind}, ${i.scope})${i.description ? ` — ${i.description}` : ''}`);
|
|
165
|
+
return [{ category: 'concepts', slug: 'existing-tools', title: 'Existing tools in this project', type: 'concept', from: '.clan/inventory.json', body: `The Alpha should PREFER these existing assets where they overlap, rather than duplicating:\n\n${lines.join('\n')}` }];
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Claude Code project-path encoding: every non-alphanumeric char → '-'
|
|
172
|
+
* (covers /, ., _, and on Windows \ and the drive colon). */
|
|
173
|
+
function encodeRepoPath(repo) {
|
|
174
|
+
return repo.replace(/[^a-zA-Z0-9]/g, '-');
|
|
175
|
+
}
|
|
176
|
+
/** Opt-in: Claude Code session memory for THIS repo → source candidates. */
|
|
177
|
+
function mineMemory(repo, home) {
|
|
178
|
+
const dir = join(home, '.claude', 'projects', encodeRepoPath(repo), 'memory');
|
|
179
|
+
const out = [];
|
|
180
|
+
for (const f of listMd(dir, 1)) {
|
|
181
|
+
const base = f.slice(dir.length + 1);
|
|
182
|
+
if (base === 'MEMORY.md')
|
|
183
|
+
continue; // the index, not content
|
|
184
|
+
const body = read(f);
|
|
185
|
+
if (!body || body.length < 40)
|
|
186
|
+
continue;
|
|
187
|
+
const title = (/^name:\s*(.+)$/m.exec(body)?.[1] ?? base.replace(/\.md$/, '')).trim();
|
|
188
|
+
out.push({ category: 'sources', slug: 'memory--' + slugify(base.replace(/\.md$/, '')), title, type: 'source', from: `session-memory/${base}`, body });
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
export function brainBuild(repo, identity, opts = {}) {
|
|
193
|
+
const home = opts.home ?? homedir();
|
|
194
|
+
const candidates = [
|
|
195
|
+
...mineManifest(repo),
|
|
196
|
+
...mineReadme(repo),
|
|
197
|
+
...mineClaudeMd(repo),
|
|
198
|
+
...mineDocs(repo),
|
|
199
|
+
...mineCodeDirs(repo),
|
|
200
|
+
...mineInventory(repo),
|
|
201
|
+
...(opts.includeMemory ? mineMemory(repo, home) : []),
|
|
202
|
+
];
|
|
203
|
+
const root = brainRoot(identity);
|
|
204
|
+
const pages = [];
|
|
205
|
+
const heldBack = [];
|
|
206
|
+
const sourceNotes = [];
|
|
207
|
+
const seen = new Set();
|
|
208
|
+
for (const c of candidates) {
|
|
209
|
+
// Scan the body AND the derived title AND the source path — a secret/PHI in
|
|
210
|
+
// a filename or heading is just as leaky as one in the body.
|
|
211
|
+
const scan = scanSensitive(`${c.body}\n${c.title}\n${c.from}`, { hipaa: opts.hipaa });
|
|
212
|
+
if (scan.flagged) {
|
|
213
|
+
heldBack.push({ from: c.from, reasons: scan.reasons });
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
// Unique slug within its category (suffix on collision — never silently drop).
|
|
217
|
+
let path = `${root}/wiki/${c.category}/${c.slug}.md`;
|
|
218
|
+
for (let n = 2; seen.has(path); n++)
|
|
219
|
+
path = `${root}/wiki/${c.category}/${c.slug}-${n}.md`;
|
|
220
|
+
seen.add(path);
|
|
221
|
+
// Create-only guaranteed here (not just in the CLI): never clobber an
|
|
222
|
+
// existing brain page, so a programmatic caller can't overwrite memory.
|
|
223
|
+
if (existsSync(join(repo, path)))
|
|
224
|
+
continue;
|
|
225
|
+
// Safe frontmatter: JSON-quote scalars so a title/path containing ':' '#'
|
|
226
|
+
// a newline, or a leading '-' can't break the YAML.
|
|
227
|
+
const title = oneLine(c.title);
|
|
228
|
+
pages.push({
|
|
229
|
+
path,
|
|
230
|
+
managed: true,
|
|
231
|
+
content: `---\nname: ${JSON.stringify(title)}\ndescription: ${JSON.stringify('mined from ' + c.from)}\ntype: ${JSON.stringify(c.type)}\nsource: ${JSON.stringify(c.from)}\n${markerLines()}\n---\n\n# ${title}\n\n${c.body}\n`,
|
|
232
|
+
});
|
|
233
|
+
sourceNotes.push(`${path} ← ${c.from}`);
|
|
234
|
+
}
|
|
235
|
+
const mech = { pages, heldBack, scanned: candidates.length, notes: [] };
|
|
236
|
+
if (!opts.curate)
|
|
237
|
+
return mech;
|
|
238
|
+
return runCurate(repo, identity, opts, mech, sourceNotes);
|
|
239
|
+
}
|
|
240
|
+
/** Apply the caller's curation hook, then re-gate its output. The curated set
|
|
241
|
+
* REPLACES the mechanical pages, but the invariants survive curation:
|
|
242
|
+
* scanSensitive on every body+path, create-only, and vault-root confinement
|
|
243
|
+
* (an LLM cannot aim a write at .claude/, .mcp.json, or outside the brain). */
|
|
244
|
+
async function runCurate(repo, identity, opts, mech, sourceNotes) {
|
|
245
|
+
let curated;
|
|
246
|
+
try {
|
|
247
|
+
curated = await opts.curate({ pages: mech.pages, identity, sourceNotes });
|
|
248
|
+
if (!Array.isArray(curated))
|
|
249
|
+
throw new Error('curator returned non-array');
|
|
250
|
+
}
|
|
251
|
+
catch (e) {
|
|
252
|
+
return { ...mech, notes: [...mech.notes, `curation failed: ${e.message}; mechanical pages used`] };
|
|
253
|
+
}
|
|
254
|
+
const root = brainRoot(identity);
|
|
255
|
+
const pages = [];
|
|
256
|
+
const heldBack = [...mech.heldBack];
|
|
257
|
+
const notes = [...mech.notes];
|
|
258
|
+
let outside = 0;
|
|
259
|
+
for (const p of curated) {
|
|
260
|
+
if (typeof p?.path !== 'string' || typeof p?.content !== 'string')
|
|
261
|
+
continue;
|
|
262
|
+
if (p.path.includes('..') || !p.path.startsWith(root + '/')) {
|
|
263
|
+
outside++;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const scan = scanSensitive(`${p.content}\n${p.path}`, { hipaa: opts.hipaa });
|
|
267
|
+
if (scan.flagged) {
|
|
268
|
+
heldBack.push({ from: `curated:${p.path}`, reasons: scan.reasons });
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (existsSync(join(repo, p.path)))
|
|
272
|
+
continue;
|
|
273
|
+
pages.push({ path: p.path, content: p.content, managed: true });
|
|
274
|
+
}
|
|
275
|
+
if (outside)
|
|
276
|
+
notes.push(`curation dropped ${outside} page(s) targeting paths outside ${root}/`);
|
|
277
|
+
return { pages, heldBack, scanned: mech.scanned, notes };
|
|
278
|
+
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* clan-engine inspect [--repo .] [--json]
|
|
8
8
|
* clan-engine plan [--repo .] [--tier clan] [--alpha X] [--name Y] [--json]
|
|
9
|
-
* clan-engine apply [--repo .] [--alpha X] [--name Y]
|
|
9
|
+
* clan-engine apply [--repo .] [--alpha X] [--name Y]
|
|
10
10
|
* clan-engine undo [--repo .] [--journal <path>]
|
|
11
11
|
* clan-engine doctor [--repo .] [--json]
|
|
12
12
|
*/
|
package/dist/cli.js
CHANGED
|
@@ -6,16 +6,18 @@
|
|
|
6
6
|
*
|
|
7
7
|
* clan-engine inspect [--repo .] [--json]
|
|
8
8
|
* clan-engine plan [--repo .] [--tier clan] [--alpha X] [--name Y] [--json]
|
|
9
|
-
* clan-engine apply [--repo .] [--alpha X] [--name Y]
|
|
9
|
+
* clan-engine apply [--repo .] [--alpha X] [--name Y]
|
|
10
10
|
* clan-engine undo [--repo .] [--journal <path>]
|
|
11
11
|
* clan-engine doctor [--repo .] [--json]
|
|
12
12
|
*/
|
|
13
|
-
import { resolve } from 'path';
|
|
13
|
+
import { resolve, join } from 'path';
|
|
14
|
+
import { existsSync } from 'fs';
|
|
14
15
|
import { inspect } from './inspect.js';
|
|
15
16
|
import { reconcile } from './reconcile.js';
|
|
16
17
|
import { apply } from './apply.js';
|
|
17
18
|
import { undo, listJournals } from './undo.js';
|
|
18
19
|
import { doctor } from './doctor.js';
|
|
20
|
+
import { brainBuild } from './brain-build.js';
|
|
19
21
|
import { ENGINE_VERSION, LAYOUT_VERSION } from './types.js';
|
|
20
22
|
function parseFlags(argv) {
|
|
21
23
|
const out = {};
|
|
@@ -61,9 +63,25 @@ function resolveIdentity(ins, flags) {
|
|
|
61
63
|
description: flags['description'] ?? base?.description ?? '',
|
|
62
64
|
alphaId: flags['alpha-id'] ?? base?.alphaId ?? jungle?.alphaId,
|
|
63
65
|
mode: jungle ? 'connected' : 'standalone',
|
|
66
|
+
boxPort: flags['box-port'] ?? base?.boxPort,
|
|
67
|
+
// Existing-vault detection must survive into every verb — without this,
|
|
68
|
+
// brain-build would target the tier-default vault on multi-root repos
|
|
69
|
+
// (reconcile has its own safety net; brainBuild does not).
|
|
70
|
+
brainRoot: base?.brainRoot ?? ins.brainRoot,
|
|
64
71
|
jungle,
|
|
65
72
|
};
|
|
66
73
|
}
|
|
74
|
+
/** JSON output with secret VALUES masked. The /tier:up wizard pipes --json
|
|
75
|
+
* straight into an LLM conversation, so a live jungle apiKey must never
|
|
76
|
+
* appear — neither in identity.jungle nor embedded in a planned .mcp.json
|
|
77
|
+
* merge content. */
|
|
78
|
+
function redactedJson(value, secrets) {
|
|
79
|
+
let out = JSON.stringify(value, null, 2);
|
|
80
|
+
for (const s of secrets)
|
|
81
|
+
if (s)
|
|
82
|
+
out = out.split(s).join('[redacted]');
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
67
85
|
function printPlan(plan) {
|
|
68
86
|
const glyph = { create: '+', update: '~', 'backup-replace': '!', merge: '~', append: '»', remove: '-', skip: '·' };
|
|
69
87
|
const acting = plan.actions.filter(a => a.kind !== 'skip');
|
|
@@ -82,7 +100,7 @@ async function main() {
|
|
|
82
100
|
case 'inspect': {
|
|
83
101
|
const ins = inspect(repo, { tierFlag: flags['tier'] });
|
|
84
102
|
if (json) {
|
|
85
|
-
console.log(
|
|
103
|
+
console.log(redactedJson(ins, [ins.identity?.jungle?.apiKey]));
|
|
86
104
|
break;
|
|
87
105
|
}
|
|
88
106
|
console.log(`\nclan-engine inspect — ${repo}`);
|
|
@@ -104,7 +122,7 @@ async function main() {
|
|
|
104
122
|
const plan = reconcile(identity, ins, { jungle: identity.jungle, today: today() });
|
|
105
123
|
if (cmd === 'plan') {
|
|
106
124
|
if (json)
|
|
107
|
-
console.log(
|
|
125
|
+
console.log(redactedJson(plan, [identity.jungle?.apiKey]));
|
|
108
126
|
else {
|
|
109
127
|
printPlan(plan);
|
|
110
128
|
console.log(`\n(dry-run — run \`clan-engine apply\` to write)\n`);
|
|
@@ -116,7 +134,7 @@ async function main() {
|
|
|
116
134
|
printPlan(plan);
|
|
117
135
|
const res = apply(plan, { at: stamp() });
|
|
118
136
|
if (json)
|
|
119
|
-
console.log(
|
|
137
|
+
console.log(redactedJson(res, [identity.jungle?.apiKey]));
|
|
120
138
|
else {
|
|
121
139
|
console.log(`\n✓ applied ${res.applied.length} action(s).`);
|
|
122
140
|
if (res.backupDir)
|
|
@@ -127,6 +145,51 @@ async function main() {
|
|
|
127
145
|
}
|
|
128
146
|
break;
|
|
129
147
|
}
|
|
148
|
+
case 'brain-build': {
|
|
149
|
+
// LLM curation is API-only: brainBuild({ curate }) from a caller that has
|
|
150
|
+
// an LLM (e.g. @nfinitmonkeys/clan `brain build --curate`). This CLI has
|
|
151
|
+
// no LLM to drive one, so it deliberately grows no --curate flag.
|
|
152
|
+
const ins = inspect(repo, { tierFlag: flags['tier'] });
|
|
153
|
+
const identity = resolveIdentity(ins, flags);
|
|
154
|
+
const { pages, heldBack, scanned, notes } = brainBuild(repo, identity, {
|
|
155
|
+
includeMemory: flags['memory'] === 'true',
|
|
156
|
+
// PHI scanning is ON by default; --no-phi opts out.
|
|
157
|
+
hipaa: flags['no-phi'] === 'true' ? false : undefined,
|
|
158
|
+
});
|
|
159
|
+
// Create-only: never clobber an existing brain page.
|
|
160
|
+
const toCreate = pages
|
|
161
|
+
.filter(p => !existsSync(join(repo, p.path)))
|
|
162
|
+
.map(p => ({ kind: 'create', path: p.path, content: p.content, note: `brain page` }));
|
|
163
|
+
const plan = { identity, repo, actions: toCreate };
|
|
164
|
+
// Default dry-run — brain-build reads broadly (docs, code, optionally
|
|
165
|
+
// session memory), so writing is opt-in via --write.
|
|
166
|
+
const doWrite = flags['write'] === 'true' || flags['apply'] === 'true';
|
|
167
|
+
if (json) {
|
|
168
|
+
console.log(JSON.stringify({ scanned, built: toCreate.length, heldBack, notes, wrote: doWrite }, null, 2));
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
console.log(`\nclan-engine brain-build — scanned ${scanned} source(s)`);
|
|
172
|
+
console.log(` ${toCreate.length} new brain page(s)${doWrite ? ' written' : ' (dry-run — pass --write to save)'}`);
|
|
173
|
+
for (const a of toCreate.slice(0, 12))
|
|
174
|
+
console.log(` + ${a.path}`);
|
|
175
|
+
if (toCreate.length > 12)
|
|
176
|
+
console.log(` … +${toCreate.length - 12} more`);
|
|
177
|
+
if (heldBack.length) {
|
|
178
|
+
console.log(`\n ⚠ ${heldBack.length} source(s) HELD BACK (sensitive — review before publishing):`);
|
|
179
|
+
for (const h of heldBack)
|
|
180
|
+
console.log(` · ${h.from} — ${h.reasons.join(', ')}`);
|
|
181
|
+
}
|
|
182
|
+
for (const n of notes)
|
|
183
|
+
console.log(` ⚠ ${n}`);
|
|
184
|
+
}
|
|
185
|
+
if (doWrite && toCreate.length) {
|
|
186
|
+
const res = apply(plan, { at: stamp() });
|
|
187
|
+
if (!json && res.journalPath)
|
|
188
|
+
console.log(`\n undo: clan-engine undo --journal ${res.journalPath}`);
|
|
189
|
+
}
|
|
190
|
+
console.log();
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
130
193
|
case 'undo': {
|
|
131
194
|
let jp = flags['journal'];
|
|
132
195
|
if (!jp) {
|
|
@@ -166,9 +229,13 @@ async function main() {
|
|
|
166
229
|
|
|
167
230
|
inspect map a repo's state (add --json for machine output)
|
|
168
231
|
plan show what setup would write (dry-run; --alpha --name --tier)
|
|
169
|
-
apply
|
|
170
|
-
|
|
171
|
-
|
|
232
|
+
apply write it (uniform backups + a journal for undo)
|
|
233
|
+
brain-build mine existing files (README/docs/CLAUDE.md/code/inventory,
|
|
234
|
+
+--memory) into brain pages; sensitive content held back.
|
|
235
|
+
dry-run by default; --write to save. LLM curation is API-only
|
|
236
|
+
(brainBuild({curate}) — this CLI has no LLM, so no --curate flag)
|
|
237
|
+
undo reverse the last apply (--journal <path> to pick one)
|
|
238
|
+
doctor validate the whole loop (config, mcp name, brain, hook, creds)
|
|
172
239
|
`);
|
|
173
240
|
}
|
|
174
241
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,14 +6,16 @@
|
|
|
6
6
|
* from here, so their output can never drift again. The conversational wizard
|
|
7
7
|
* drives these same verbs; the LLM improvises the conversation, never the writes.
|
|
8
8
|
*/
|
|
9
|
-
export { ENGINE_VERSION, LAYOUT_VERSION, GENERATED_BY, type Tier, type Mode, type ClanIdentity, type GeneratedFile, type PlanAction, type Plan, type InspectResult, type InventoryItem, type NodeState, type McpServerInfo, type Journal, type JournalOp, type ApplyResult, type DoctorCheck, } from './types.js';
|
|
9
|
+
export { ENGINE_VERSION, LAYOUT_VERSION, GENERATED_BY, type Tier, type Mode, type ClanIdentity, type GeneratedFile, type PlanAction, type Plan, type InspectResult, type InventoryItem, type NodeState, type McpServerInfo, type Journal, type JournalOp, type ApplyResult, type DoctorCheck, type BrainCurateInput, type BrainCurator, } from './types.js';
|
|
10
10
|
export { inspect } from './inspect.js';
|
|
11
11
|
export { reconcile, type ReconcileOptions } from './reconcile.js';
|
|
12
12
|
export { apply, type ApplyOptions } from './apply.js';
|
|
13
13
|
export { undo, listJournals, type UndoResult } from './undo.js';
|
|
14
14
|
export { doctor } from './doctor.js';
|
|
15
15
|
export { scanInventory, writeInventoryManifest } from './inventory.js';
|
|
16
|
-
export {
|
|
16
|
+
export { brainBuild, type BrainBuildResult, type BrainBuildOpts, type HeldBack } from './brain-build.js';
|
|
17
|
+
export { scanSensitive, type ScanResult } from './sanitize.js';
|
|
18
|
+
export { planMcpJson, planDistrictMcpJson, jungleLocalServer, districtBoxCreds, type JungleCreds } from './mcp.js';
|
|
17
19
|
export { planBrainHook } from './hooks.js';
|
|
18
20
|
export { claudeSurface, brainScaffold, claudeMdIdentity, loadBrainScript, geoTierMarker, hasManagedMarker, findGeoTierMarker, markerLines, hookCommand, brainRoot, } from './templates.js';
|
|
19
21
|
import type { ClanIdentity, Plan, ApplyResult } from './types.js';
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,9 @@ export { apply } from './apply.js';
|
|
|
13
13
|
export { undo, listJournals } from './undo.js';
|
|
14
14
|
export { doctor } from './doctor.js';
|
|
15
15
|
export { scanInventory, writeInventoryManifest } from './inventory.js';
|
|
16
|
-
export {
|
|
16
|
+
export { brainBuild } from './brain-build.js';
|
|
17
|
+
export { scanSensitive } from './sanitize.js';
|
|
18
|
+
export { planMcpJson, planDistrictMcpJson, jungleLocalServer, districtBoxCreds } from './mcp.js';
|
|
17
19
|
export { planBrainHook } from './hooks.js';
|
|
18
20
|
export { claudeSurface, brainScaffold, claudeMdIdentity, loadBrainScript, geoTierMarker, hasManagedMarker, findGeoTierMarker, markerLines, hookCommand, brainRoot, } from './templates.js';
|
|
19
21
|
/**
|