@nfinitmonkeys/clan-engine 0.2.0 → 0.4.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 +57 -8
- package/dist/brain-build.js +179 -17
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +29 -7
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- 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/templates.d.ts +4 -1
- package/dist/templates.js +244 -20
- package/dist/types.d.ts +35 -1
- package/dist/types.js +1 -1
- package/package.json +4 -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);
|
package/dist/brain-build.d.ts
CHANGED
|
@@ -9,10 +9,16 @@
|
|
|
9
9
|
* HELD BACK, never written, and reported for the operator to review. Pages are
|
|
10
10
|
* create-only — accumulated brain content is never clobbered.
|
|
11
11
|
*
|
|
12
|
-
* Mechanical (no LLM required) — the right no-key base layer. LLM curation
|
|
13
|
-
*
|
|
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.
|
|
14
15
|
*/
|
|
15
|
-
import type { ClanIdentity, GeneratedFile } from './types.js';
|
|
16
|
+
import type { ClanIdentity, GeneratedFile, BrainCurator, BrainOwner } from './types.js';
|
|
17
|
+
/** Fence tag that holds a serialized EncryptedPage inside a private page's body.
|
|
18
|
+
* A private page keeps its Markdown frontmatter (with `private: true`) so the
|
|
19
|
+
* vault stays browsable/round-trippable, but the body is replaced by this fenced
|
|
20
|
+
* JSON block — the plaintext body never touches disk. */
|
|
21
|
+
export declare const ENCRYPTED_FENCE = "clan-encrypted";
|
|
16
22
|
export interface HeldBack {
|
|
17
23
|
from: string;
|
|
18
24
|
reasons: string[];
|
|
@@ -21,13 +27,56 @@ export interface BrainBuildResult {
|
|
|
21
27
|
pages: GeneratedFile[];
|
|
22
28
|
heldBack: HeldBack[];
|
|
23
29
|
scanned: number;
|
|
30
|
+
/** Operator-facing report lines (e.g. curation fallback) — never secrets. */
|
|
31
|
+
notes: string[];
|
|
24
32
|
}
|
|
25
|
-
|
|
26
|
-
* Mine existing files into brain pages. Every candidate is scanned; flagged
|
|
27
|
-
* ones are held back. Returns create-candidate pages + the held-back report.
|
|
28
|
-
*/
|
|
29
|
-
export declare function brainBuild(repo: string, identity: ClanIdentity, opts?: {
|
|
33
|
+
export interface BrainBuildOpts {
|
|
30
34
|
home?: string;
|
|
31
35
|
hipaa?: boolean;
|
|
32
36
|
includeMemory?: boolean;
|
|
37
|
+
/** LLM curation hook — API-only (the engine CLI has no LLM to drive one).
|
|
38
|
+
* Curated output replaces the candidates but every returned page re-passes
|
|
39
|
+
* scanSensitive + create-only; a throwing curator falls back to the
|
|
40
|
+
* mechanical pages with a note in the report. */
|
|
41
|
+
curate?: BrainCurator;
|
|
42
|
+
/** The clan itself as an encrypted-brain recipient (alphaId + X25519 public
|
|
43
|
+
* key). Required to WRITE a page whose source frontmatter carries
|
|
44
|
+
* `private: true`: such a page is sanitizer-EXEMPT (that is the whole point)
|
|
45
|
+
* but its body is encrypted to this owner BEFORE any write. Without an owner,
|
|
46
|
+
* a private page is HELD BACK ("run clan keys init first") and its plaintext
|
|
47
|
+
* is NEVER written. The private key never reaches the engine. */
|
|
48
|
+
owner?: BrainOwner;
|
|
49
|
+
}
|
|
50
|
+
/** True when a rendered page's content carries an encrypted (private) body —
|
|
51
|
+
* i.e. it holds a fenced `clan-encrypted` block. Used by scanners/tests to
|
|
52
|
+
* assert a private page never persisted plaintext. */
|
|
53
|
+
export declare function isPrivatePageContent(content: string): boolean;
|
|
54
|
+
/** Extract the EncryptedPage JSON from a private page's rendered content (the
|
|
55
|
+
* fenced `clan-encrypted` block). Returns null when the page is not private /
|
|
56
|
+
* the fence is absent or unparseable. The inverse of the write-time encode. */
|
|
57
|
+
export declare function decodePrivatePage(content: string): unknown | null;
|
|
58
|
+
/**
|
|
59
|
+
* Encrypt a private page's plaintext body to the owner and render the full page
|
|
60
|
+
* content (frontmatter preserved, body replaced by a fenced `clan-encrypted`
|
|
61
|
+
* block). clan-crypto is imported lazily so the engine core loads without it;
|
|
62
|
+
* the owner's private key is NEVER involved — only its public identity. Throws
|
|
63
|
+
* if clan-crypto is unavailable so the caller can hold the page back rather than
|
|
64
|
+
* write plaintext.
|
|
65
|
+
*/
|
|
66
|
+
export declare function encryptPrivatePage(frontmatterBlock: string, title: string, plaintextBody: string, owner: BrainOwner): Promise<string>;
|
|
67
|
+
/**
|
|
68
|
+
* Mine existing files into brain pages. Every NON-PRIVATE candidate is scanned;
|
|
69
|
+
* flagged ones are held back. A candidate whose source frontmatter declares
|
|
70
|
+
* `private: true` is sanitizer-EXEMPT but is ENCRYPTED to opts.owner before its
|
|
71
|
+
* page is emitted (its plaintext body never appears on disk); without an owner
|
|
72
|
+
* it is HELD BACK ("run clan keys init first") — plaintext is never written.
|
|
73
|
+
*
|
|
74
|
+
* The call stays synchronous ONLY when there is neither a curator nor a private
|
|
75
|
+
* candidate (existing callers unchanged); a curator OR any private page (which
|
|
76
|
+
* needs async encryption) makes it return a Promise of the (re-gated) result.
|
|
77
|
+
*/
|
|
78
|
+
export declare function brainBuild(repo: string, identity: ClanIdentity, opts?: BrainBuildOpts & {
|
|
79
|
+
curate?: undefined;
|
|
80
|
+
owner?: undefined;
|
|
33
81
|
}): BrainBuildResult;
|
|
82
|
+
export declare function brainBuild(repo: string, identity: ClanIdentity, opts: BrainBuildOpts): BrainBuildResult | Promise<BrainBuildResult>;
|
package/dist/brain-build.js
CHANGED
|
@@ -9,14 +9,21 @@
|
|
|
9
9
|
* HELD BACK, never written, and reported for the operator to review. Pages are
|
|
10
10
|
* create-only — accumulated brain content is never clobbered.
|
|
11
11
|
*
|
|
12
|
-
* Mechanical (no LLM required) — the right no-key base layer. LLM curation
|
|
13
|
-
*
|
|
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.
|
|
14
15
|
*/
|
|
15
16
|
import { readFileSync, existsSync, readdirSync, lstatSync } from 'fs';
|
|
16
17
|
import { join } from 'path';
|
|
17
18
|
import { homedir } from 'os';
|
|
19
|
+
import YAML from 'yaml';
|
|
18
20
|
import { brainRoot, markerLines } from './templates.js';
|
|
19
21
|
import { scanSensitive } from './sanitize.js';
|
|
22
|
+
/** Fence tag that holds a serialized EncryptedPage inside a private page's body.
|
|
23
|
+
* A private page keeps its Markdown frontmatter (with `private: true`) so the
|
|
24
|
+
* vault stays browsable/round-trippable, but the body is replaced by this fenced
|
|
25
|
+
* JSON block — the plaintext body never touches disk. */
|
|
26
|
+
export const ENCRYPTED_FENCE = 'clan-encrypted';
|
|
20
27
|
function slugify(s) {
|
|
21
28
|
return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'untitled';
|
|
22
29
|
}
|
|
@@ -30,6 +37,66 @@ function read(p) { try {
|
|
|
30
37
|
catch {
|
|
31
38
|
return null;
|
|
32
39
|
} }
|
|
40
|
+
/** Read the YAML frontmatter of a source file (between the first two `---`).
|
|
41
|
+
* Returns null when there is no leading frontmatter block or it won't parse. */
|
|
42
|
+
function frontmatter(content) {
|
|
43
|
+
if (!content.startsWith('---'))
|
|
44
|
+
return null;
|
|
45
|
+
const end = content.indexOf('\n---', 3);
|
|
46
|
+
if (end === -1)
|
|
47
|
+
return null;
|
|
48
|
+
try {
|
|
49
|
+
const doc = YAML.parse(content.slice(3, end));
|
|
50
|
+
return doc && typeof doc === 'object' && !Array.isArray(doc) ? doc : null;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** True when a source file's frontmatter declares `private: true`. A private
|
|
57
|
+
* page is exempt from sanitizer hold-back but MUST be encrypted before write. */
|
|
58
|
+
function declaredPrivate(content) {
|
|
59
|
+
return frontmatter(content)?.private === true;
|
|
60
|
+
}
|
|
61
|
+
/** Serialize an EncryptedPage into a fenced `clan-encrypted` block. This is the
|
|
62
|
+
* on-disk body of a private page: the plaintext body is replaced by this fence
|
|
63
|
+
* so no plaintext ever touches the vault. Round-trippable via decodePrivatePage. */
|
|
64
|
+
function fenceEncrypted(page) {
|
|
65
|
+
return '```' + ENCRYPTED_FENCE + '\n' + JSON.stringify(page) + '\n```';
|
|
66
|
+
}
|
|
67
|
+
/** True when a rendered page's content carries an encrypted (private) body —
|
|
68
|
+
* i.e. it holds a fenced `clan-encrypted` block. Used by scanners/tests to
|
|
69
|
+
* assert a private page never persisted plaintext. */
|
|
70
|
+
export function isPrivatePageContent(content) {
|
|
71
|
+
return new RegExp('```' + ENCRYPTED_FENCE + '\\b').test(content);
|
|
72
|
+
}
|
|
73
|
+
/** Extract the EncryptedPage JSON from a private page's rendered content (the
|
|
74
|
+
* fenced `clan-encrypted` block). Returns null when the page is not private /
|
|
75
|
+
* the fence is absent or unparseable. The inverse of the write-time encode. */
|
|
76
|
+
export function decodePrivatePage(content) {
|
|
77
|
+
const m = new RegExp('```' + ENCRYPTED_FENCE + '\\s*\\n([\\s\\S]*?)\\n```').exec(content);
|
|
78
|
+
if (!m)
|
|
79
|
+
return null;
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(m[1]);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Encrypt a private page's plaintext body to the owner and render the full page
|
|
89
|
+
* content (frontmatter preserved, body replaced by a fenced `clan-encrypted`
|
|
90
|
+
* block). clan-crypto is imported lazily so the engine core loads without it;
|
|
91
|
+
* the owner's private key is NEVER involved — only its public identity. Throws
|
|
92
|
+
* if clan-crypto is unavailable so the caller can hold the page back rather than
|
|
93
|
+
* write plaintext.
|
|
94
|
+
*/
|
|
95
|
+
export async function encryptPrivatePage(frontmatterBlock, title, plaintextBody, owner) {
|
|
96
|
+
const crypto = await import('@nfinitmonkeys/clan-crypto');
|
|
97
|
+
const page = crypto.encryptPage(plaintextBody, [{ alphaId: owner.alphaId, spkiB64: owner.spkiB64 }]);
|
|
98
|
+
return `---\n${frontmatterBlock}\n---\n\n# ${title}\n\n${fenceEncrypted(page)}\n`;
|
|
99
|
+
}
|
|
33
100
|
function listMd(dir, depth = 3) {
|
|
34
101
|
if (depth < 0 || !existsSync(dir))
|
|
35
102
|
return [];
|
|
@@ -88,7 +155,7 @@ function mineDocs(repo) {
|
|
|
88
155
|
continue;
|
|
89
156
|
const rel = f.slice(repo.length + 1);
|
|
90
157
|
const title = (/^#\s+(.+)$/m.exec(body)?.[1] ?? rel).trim();
|
|
91
|
-
out.push({ category: 'sources', slug: slugify(rel.replace(/\.md$/, '')), title, type: 'source', from: rel, body });
|
|
158
|
+
out.push({ category: 'sources', slug: slugify(rel.replace(/\.md$/, '')), title, type: 'source', from: rel, body, private: declaredPrivate(body) });
|
|
92
159
|
}
|
|
93
160
|
return out;
|
|
94
161
|
}
|
|
@@ -184,14 +251,24 @@ function mineMemory(repo, home) {
|
|
|
184
251
|
if (!body || body.length < 40)
|
|
185
252
|
continue;
|
|
186
253
|
const title = (/^name:\s*(.+)$/m.exec(body)?.[1] ?? base.replace(/\.md$/, '')).trim();
|
|
187
|
-
out.push({ category: 'sources', slug: 'memory--' + slugify(base.replace(/\.md$/, '')), title, type: 'source', from: `session-memory/${base}`, body });
|
|
254
|
+
out.push({ category: 'sources', slug: 'memory--' + slugify(base.replace(/\.md$/, '')), title, type: 'source', from: `session-memory/${base}`, body, private: declaredPrivate(body) });
|
|
188
255
|
}
|
|
189
256
|
return out;
|
|
190
257
|
}
|
|
191
|
-
/**
|
|
192
|
-
*
|
|
193
|
-
|
|
194
|
-
|
|
258
|
+
/** Frontmatter block for a mined page. Private pages carry `private: true` so a
|
|
259
|
+
* later query knows to decrypt; the body is JSON-quoted scalars only. */
|
|
260
|
+
function pageFrontmatter(c, title) {
|
|
261
|
+
const lines = [
|
|
262
|
+
`name: ${JSON.stringify(title)}`,
|
|
263
|
+
`description: ${JSON.stringify('mined from ' + c.from)}`,
|
|
264
|
+
`type: ${JSON.stringify(c.type)}`,
|
|
265
|
+
`source: ${JSON.stringify(c.from)}`,
|
|
266
|
+
];
|
|
267
|
+
if (c.private)
|
|
268
|
+
lines.push('private: true');
|
|
269
|
+
lines.push(markerLines());
|
|
270
|
+
return lines.join('\n');
|
|
271
|
+
}
|
|
195
272
|
export function brainBuild(repo, identity, opts = {}) {
|
|
196
273
|
const home = opts.home ?? homedir();
|
|
197
274
|
const candidates = [
|
|
@@ -206,14 +283,22 @@ export function brainBuild(repo, identity, opts = {}) {
|
|
|
206
283
|
const root = brainRoot(identity);
|
|
207
284
|
const pages = [];
|
|
208
285
|
const heldBack = [];
|
|
286
|
+
const notes = [];
|
|
287
|
+
const sourceNotes = [];
|
|
209
288
|
const seen = new Set();
|
|
289
|
+
// Private candidates that survived path/create-only gating, deferred for async
|
|
290
|
+
// encryption. { path, title, frontmatter, body } — body encrypted before write.
|
|
291
|
+
const privateJobs = [];
|
|
210
292
|
for (const c of candidates) {
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
|
|
293
|
+
// A private page is EXEMPT from the sanitizer hold-back (that is the point —
|
|
294
|
+
// it will be encrypted, never written in the clear). Non-private pages are
|
|
295
|
+
// scanned on body + title + source path exactly as before.
|
|
296
|
+
if (!c.private) {
|
|
297
|
+
const scan = scanSensitive(`${c.body}\n${c.title}\n${c.from}`, { hipaa: opts.hipaa });
|
|
298
|
+
if (scan.flagged) {
|
|
299
|
+
heldBack.push({ from: c.from, reasons: scan.reasons });
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
217
302
|
}
|
|
218
303
|
// Unique slug within its category (suffix on collision — never silently drop).
|
|
219
304
|
let path = `${root}/wiki/${c.category}/${c.slug}.md`;
|
|
@@ -224,14 +309,91 @@ export function brainBuild(repo, identity, opts = {}) {
|
|
|
224
309
|
// existing brain page, so a programmatic caller can't overwrite memory.
|
|
225
310
|
if (existsSync(join(repo, path)))
|
|
226
311
|
continue;
|
|
312
|
+
const title = oneLine(c.title);
|
|
313
|
+
const fm = pageFrontmatter(c, title);
|
|
314
|
+
if (c.private) {
|
|
315
|
+
if (!opts.owner) {
|
|
316
|
+
// No clan keypair → refuse to write plaintext. Hold it back with a clear
|
|
317
|
+
// remediation, exactly like a sanitizer trip.
|
|
318
|
+
heldBack.push({ from: c.from, reasons: ['private-page-needs-key: run `clan keys init` first'] });
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
privateJobs.push({ path, title, fm, body: c.body, from: c.from });
|
|
322
|
+
sourceNotes.push(`${path} ← ${c.from} (private)`);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
227
325
|
// Safe frontmatter: JSON-quote scalars so a title/path containing ':' '#'
|
|
228
326
|
// a newline, or a leading '-' can't break the YAML.
|
|
229
|
-
const title = oneLine(c.title);
|
|
230
327
|
pages.push({
|
|
231
328
|
path,
|
|
232
329
|
managed: true,
|
|
233
|
-
content: `---\
|
|
330
|
+
content: `---\n${fm}\n---\n\n# ${title}\n\n${c.body}\n`,
|
|
234
331
|
});
|
|
332
|
+
sourceNotes.push(`${path} ← ${c.from}`);
|
|
333
|
+
}
|
|
334
|
+
const scanned = candidates.length;
|
|
335
|
+
// Assemble: encrypt any private jobs (async), then optionally curate.
|
|
336
|
+
const needsAsync = privateJobs.length > 0 || !!opts.curate;
|
|
337
|
+
if (!needsAsync)
|
|
338
|
+
return { pages, heldBack, scanned, notes };
|
|
339
|
+
return (async () => {
|
|
340
|
+
for (const job of privateJobs) {
|
|
341
|
+
try {
|
|
342
|
+
const content = await encryptPrivatePage(job.fm, job.title, job.body, opts.owner);
|
|
343
|
+
pages.push({ path: job.path, managed: true, content });
|
|
344
|
+
}
|
|
345
|
+
catch (e) {
|
|
346
|
+
// clan-crypto unavailable → hold the page back rather than write plaintext.
|
|
347
|
+
heldBack.push({ from: job.from, reasons: [`private-page-encrypt-failed: ${e.message}`] });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const mech = { pages, heldBack, scanned, notes };
|
|
351
|
+
if (!opts.curate)
|
|
352
|
+
return mech;
|
|
353
|
+
return runCurate(repo, identity, opts, mech, sourceNotes);
|
|
354
|
+
})();
|
|
355
|
+
}
|
|
356
|
+
/** Apply the caller's curation hook, then re-gate its output. The curated set
|
|
357
|
+
* REPLACES the mechanical pages, but the invariants survive curation:
|
|
358
|
+
* scanSensitive on every body+path, create-only, and vault-root confinement
|
|
359
|
+
* (an LLM cannot aim a write at .claude/, .mcp.json, or outside the brain). */
|
|
360
|
+
async function runCurate(repo, identity, opts, mech, sourceNotes) {
|
|
361
|
+
// Encrypted (private) pages are NEVER handed to the curator — the LLM can't
|
|
362
|
+
// (and must not) rewrite ciphertext. They pass through verbatim; only the
|
|
363
|
+
// plaintext mechanical pages are offered for curation.
|
|
364
|
+
const encrypted = mech.pages.filter(p => isPrivatePageContent(p.content));
|
|
365
|
+
const plain = mech.pages.filter(p => !isPrivatePageContent(p.content));
|
|
366
|
+
let curated;
|
|
367
|
+
try {
|
|
368
|
+
curated = await opts.curate({ pages: plain, identity, sourceNotes });
|
|
369
|
+
if (!Array.isArray(curated))
|
|
370
|
+
throw new Error('curator returned non-array');
|
|
371
|
+
}
|
|
372
|
+
catch (e) {
|
|
373
|
+
return { ...mech, notes: [...mech.notes, `curation failed: ${e.message}; mechanical pages used`] };
|
|
374
|
+
}
|
|
375
|
+
const root = brainRoot(identity);
|
|
376
|
+
const pages = [...encrypted];
|
|
377
|
+
const heldBack = [...mech.heldBack];
|
|
378
|
+
const notes = [...mech.notes];
|
|
379
|
+
let outside = 0;
|
|
380
|
+
for (const p of curated) {
|
|
381
|
+
if (typeof p?.path !== 'string' || typeof p?.content !== 'string')
|
|
382
|
+
continue;
|
|
383
|
+
if (p.path.includes('..') || !p.path.startsWith(root + '/')) {
|
|
384
|
+
outside++;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const scan = scanSensitive(`${p.content}\n${p.path}`, { hipaa: opts.hipaa });
|
|
388
|
+
if (scan.flagged) {
|
|
389
|
+
heldBack.push({ from: `curated:${p.path}`, reasons: scan.reasons });
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (existsSync(join(repo, p.path)))
|
|
393
|
+
continue;
|
|
394
|
+
pages.push({ path: p.path, content: p.content, managed: true });
|
|
235
395
|
}
|
|
236
|
-
|
|
396
|
+
if (outside)
|
|
397
|
+
notes.push(`curation dropped ${outside} page(s) targeting paths outside ${root}/`);
|
|
398
|
+
return { pages, heldBack, scanned: mech.scanned, notes };
|
|
237
399
|
}
|
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,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
|
*/
|
|
@@ -63,9 +63,25 @@ function resolveIdentity(ins, flags) {
|
|
|
63
63
|
description: flags['description'] ?? base?.description ?? '',
|
|
64
64
|
alphaId: flags['alpha-id'] ?? base?.alphaId ?? jungle?.alphaId,
|
|
65
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,
|
|
66
71
|
jungle,
|
|
67
72
|
};
|
|
68
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
|
+
}
|
|
69
85
|
function printPlan(plan) {
|
|
70
86
|
const glyph = { create: '+', update: '~', 'backup-replace': '!', merge: '~', append: '»', remove: '-', skip: '·' };
|
|
71
87
|
const acting = plan.actions.filter(a => a.kind !== 'skip');
|
|
@@ -84,7 +100,7 @@ async function main() {
|
|
|
84
100
|
case 'inspect': {
|
|
85
101
|
const ins = inspect(repo, { tierFlag: flags['tier'] });
|
|
86
102
|
if (json) {
|
|
87
|
-
console.log(
|
|
103
|
+
console.log(redactedJson(ins, [ins.identity?.jungle?.apiKey]));
|
|
88
104
|
break;
|
|
89
105
|
}
|
|
90
106
|
console.log(`\nclan-engine inspect — ${repo}`);
|
|
@@ -106,7 +122,7 @@ async function main() {
|
|
|
106
122
|
const plan = reconcile(identity, ins, { jungle: identity.jungle, today: today() });
|
|
107
123
|
if (cmd === 'plan') {
|
|
108
124
|
if (json)
|
|
109
|
-
console.log(
|
|
125
|
+
console.log(redactedJson(plan, [identity.jungle?.apiKey]));
|
|
110
126
|
else {
|
|
111
127
|
printPlan(plan);
|
|
112
128
|
console.log(`\n(dry-run — run \`clan-engine apply\` to write)\n`);
|
|
@@ -118,7 +134,7 @@ async function main() {
|
|
|
118
134
|
printPlan(plan);
|
|
119
135
|
const res = apply(plan, { at: stamp() });
|
|
120
136
|
if (json)
|
|
121
|
-
console.log(
|
|
137
|
+
console.log(redactedJson(res, [identity.jungle?.apiKey]));
|
|
122
138
|
else {
|
|
123
139
|
console.log(`\n✓ applied ${res.applied.length} action(s).`);
|
|
124
140
|
if (res.backupDir)
|
|
@@ -130,9 +146,12 @@ async function main() {
|
|
|
130
146
|
break;
|
|
131
147
|
}
|
|
132
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.
|
|
133
152
|
const ins = inspect(repo, { tierFlag: flags['tier'] });
|
|
134
153
|
const identity = resolveIdentity(ins, flags);
|
|
135
|
-
const { pages, heldBack, scanned } = brainBuild(repo, identity, {
|
|
154
|
+
const { pages, heldBack, scanned, notes } = brainBuild(repo, identity, {
|
|
136
155
|
includeMemory: flags['memory'] === 'true',
|
|
137
156
|
// PHI scanning is ON by default; --no-phi opts out.
|
|
138
157
|
hipaa: flags['no-phi'] === 'true' ? false : undefined,
|
|
@@ -146,7 +165,7 @@ async function main() {
|
|
|
146
165
|
// session memory), so writing is opt-in via --write.
|
|
147
166
|
const doWrite = flags['write'] === 'true' || flags['apply'] === 'true';
|
|
148
167
|
if (json) {
|
|
149
|
-
console.log(JSON.stringify({ scanned, built: toCreate.length, heldBack, wrote: doWrite }, null, 2));
|
|
168
|
+
console.log(JSON.stringify({ scanned, built: toCreate.length, heldBack, notes, wrote: doWrite }, null, 2));
|
|
150
169
|
}
|
|
151
170
|
else {
|
|
152
171
|
console.log(`\nclan-engine brain-build — scanned ${scanned} source(s)`);
|
|
@@ -160,6 +179,8 @@ async function main() {
|
|
|
160
179
|
for (const h of heldBack)
|
|
161
180
|
console.log(` · ${h.from} — ${h.reasons.join(', ')}`);
|
|
162
181
|
}
|
|
182
|
+
for (const n of notes)
|
|
183
|
+
console.log(` ⚠ ${n}`);
|
|
163
184
|
}
|
|
164
185
|
if (doWrite && toCreate.length) {
|
|
165
186
|
const res = apply(plan, { at: stamp() });
|
|
@@ -211,7 +232,8 @@ async function main() {
|
|
|
211
232
|
apply write it (uniform backups + a journal for undo)
|
|
212
233
|
brain-build mine existing files (README/docs/CLAUDE.md/code/inventory,
|
|
213
234
|
+--memory) into brain pages; sensitive content held back.
|
|
214
|
-
dry-run by default; --write to save
|
|
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)
|
|
215
237
|
undo reverse the last apply (--journal <path> to pick one)
|
|
216
238
|
doctor validate the whole loop (config, mcp name, brain, hook, creds)
|
|
217
239
|
`);
|
package/dist/index.d.ts
CHANGED
|
@@ -6,16 +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, type BrainOwner, } 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 { brainBuild, type BrainBuildResult, type HeldBack } from './brain-build.js';
|
|
16
|
+
export { brainBuild, encryptPrivatePage, decodePrivatePage, isPrivatePageContent, ENCRYPTED_FENCE, type BrainBuildResult, type BrainBuildOpts, type HeldBack } from './brain-build.js';
|
|
17
17
|
export { scanSensitive, type ScanResult } from './sanitize.js';
|
|
18
|
-
export { planMcpJson, jungleLocalServer, type JungleCreds } from './mcp.js';
|
|
18
|
+
export { planMcpJson, planDistrictMcpJson, jungleLocalServer, districtBoxCreds, type JungleCreds } from './mcp.js';
|
|
19
19
|
export { planBrainHook } from './hooks.js';
|
|
20
20
|
export { claudeSurface, brainScaffold, claudeMdIdentity, loadBrainScript, geoTierMarker, hasManagedMarker, findGeoTierMarker, markerLines, hookCommand, brainRoot, } from './templates.js';
|
|
21
21
|
import type { ClanIdentity, Plan, ApplyResult } from './types.js';
|
package/dist/index.js
CHANGED
|
@@ -13,9 +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 { brainBuild } from './brain-build.js';
|
|
16
|
+
export { brainBuild, encryptPrivatePage, decodePrivatePage, isPrivatePageContent, ENCRYPTED_FENCE } from './brain-build.js';
|
|
17
17
|
export { scanSensitive } from './sanitize.js';
|
|
18
|
-
export { planMcpJson, jungleLocalServer } from './mcp.js';
|
|
18
|
+
export { planMcpJson, planDistrictMcpJson, jungleLocalServer, districtBoxCreds } from './mcp.js';
|
|
19
19
|
export { planBrainHook } from './hooks.js';
|
|
20
20
|
export { claudeSurface, brainScaffold, claudeMdIdentity, loadBrainScript, geoTierMarker, hasManagedMarker, findGeoTierMarker, markerLines, hookCommand, brainRoot, } from './templates.js';
|
|
21
21
|
/**
|