@nfinitmonkeys/clan-engine 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/README.md +62 -0
- package/dist/apply.d.ts +14 -0
- package/dist/apply.js +88 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +175 -0
- package/dist/doctor.d.ts +9 -0
- package/dist/doctor.js +85 -0
- package/dist/hooks.d.ts +18 -0
- package/dist/hooks.js +76 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +32 -0
- package/dist/inspect.d.ts +10 -0
- package/dist/inspect.js +171 -0
- package/dist/inventory.d.ts +18 -0
- package/dist/inventory.js +136 -0
- package/dist/mcp.d.ts +27 -0
- package/dist/mcp.js +74 -0
- package/dist/reconcile.d.ts +27 -0
- package/dist/reconcile.js +109 -0
- package/dist/templates.d.ts +58 -0
- package/dist/templates.js +303 -0
- package/dist/types.d.ts +125 -0
- package/dist/types.js +12 -0
- package/dist/undo.d.ts +14 -0
- package/dist/undo.js +43 -0
- package/package.json +41 -0
package/dist/inspect.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inspect — build a full, non-mutating state map of a target directory before
|
|
3
|
+
* anything is written. The wizard/CLI adapts to the detected scenario (fresh /
|
|
4
|
+
* partial / current / legacy / drifted), and reconcile plans against it.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import YAML from 'yaml';
|
|
9
|
+
import { LAYOUT_VERSION, } from './types.js';
|
|
10
|
+
import { hasManagedMarker, findGeoTierMarker } from './templates.js';
|
|
11
|
+
import { scanInventory } from './inventory.js';
|
|
12
|
+
function read(p) {
|
|
13
|
+
try {
|
|
14
|
+
return readFileSync(p, 'utf-8');
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function isDir(p) {
|
|
21
|
+
try {
|
|
22
|
+
return existsSync(p) && readdirSync(p) !== undefined;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Tier: explicit flag > REAL CLAUDE.md geo-tier declaration > .district dir >
|
|
29
|
+
* .brains dir > default. A marker inside a code fence / prose does not count. */
|
|
30
|
+
function detectTier(repo, tierFlag) {
|
|
31
|
+
if (tierFlag)
|
|
32
|
+
return { tier: tierFlag, source: 'flag' };
|
|
33
|
+
const claude = read(join(repo, 'CLAUDE.md'));
|
|
34
|
+
if (claude) {
|
|
35
|
+
const found = findGeoTierMarker(claude);
|
|
36
|
+
if (found)
|
|
37
|
+
return { tier: found.tier, source: 'marker' };
|
|
38
|
+
}
|
|
39
|
+
if (existsSync(join(repo, '.district')))
|
|
40
|
+
return { tier: 'district', source: 'district-dir' };
|
|
41
|
+
if (existsSync(join(repo, '.brains')))
|
|
42
|
+
return { tier: 'jungle', source: 'brains-dir' };
|
|
43
|
+
return { tier: 'clan', source: 'default' };
|
|
44
|
+
}
|
|
45
|
+
/** Tier-aware brain root (mirrors templates.brainRoot without needing identity
|
|
46
|
+
* when only the tier + optional alpha are known). */
|
|
47
|
+
function brainRootFor(tier, alphaName) {
|
|
48
|
+
if (tier === 'district')
|
|
49
|
+
return '.district/brain';
|
|
50
|
+
if (tier === 'jungle')
|
|
51
|
+
return `.brains/${(alphaName ?? '').toLowerCase()}`;
|
|
52
|
+
return '.brain';
|
|
53
|
+
}
|
|
54
|
+
export function inspect(repo, opts = {}) {
|
|
55
|
+
const notes = [];
|
|
56
|
+
const { tier, source: tierSource } = detectTier(repo, opts.tierFlag);
|
|
57
|
+
// ── .jungle/config.yaml ──
|
|
58
|
+
let cfg = null;
|
|
59
|
+
let hasConfig = false;
|
|
60
|
+
let configError;
|
|
61
|
+
const cfgRaw = read(join(repo, '.jungle', 'config.yaml'));
|
|
62
|
+
if (cfgRaw !== null) {
|
|
63
|
+
hasConfig = true;
|
|
64
|
+
try {
|
|
65
|
+
cfg = YAML.parse(cfgRaw);
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
configError = e.message;
|
|
69
|
+
notes.push('.jungle/config.yaml is unparseable');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const connected = cfg?.jungle?.status === 'connected' || cfg?.jungle?.status === 'approved';
|
|
73
|
+
const mode = connected ? 'connected' : 'standalone';
|
|
74
|
+
const identity = cfg?.project?.name && cfg?.project?.alphaName ? {
|
|
75
|
+
name: cfg.project.name,
|
|
76
|
+
alphaName: cfg.project.alphaName,
|
|
77
|
+
description: cfg.project.description ?? '',
|
|
78
|
+
tier,
|
|
79
|
+
mode,
|
|
80
|
+
alphaId: cfg.jungle?.alphaId,
|
|
81
|
+
jungle: connected && cfg.jungle?.apiUrl && cfg.jungle?.alphaId && cfg.jungle?.apiKey
|
|
82
|
+
? { apiUrl: cfg.jungle.apiUrl, alphaId: cfg.jungle.alphaId, apiKey: cfg.jungle.apiKey }
|
|
83
|
+
: undefined,
|
|
84
|
+
} : undefined;
|
|
85
|
+
if (connected && identity && !identity.jungle) {
|
|
86
|
+
notes.push('connected clan but .mcp.json cannot be generated — config.yaml is missing apiKey/apiUrl/alphaId');
|
|
87
|
+
}
|
|
88
|
+
// ── .mcp.json servers ──
|
|
89
|
+
const mcpServers = [];
|
|
90
|
+
let hasMcp = false;
|
|
91
|
+
const mcpRaw = read(join(repo, '.mcp.json'));
|
|
92
|
+
if (mcpRaw !== null) {
|
|
93
|
+
hasMcp = true;
|
|
94
|
+
try {
|
|
95
|
+
const doc = JSON.parse(mcpRaw);
|
|
96
|
+
for (const name of Object.keys(doc.mcpServers ?? {})) {
|
|
97
|
+
const misnamed = name === 'jungle';
|
|
98
|
+
mcpServers.push({ name, isJungle: name === 'jungle-local' || misnamed, misnamed });
|
|
99
|
+
if (misnamed)
|
|
100
|
+
notes.push('.mcp.json has a server named "jungle" — collides with the global council server; should be "jungle-local"');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
notes.push('.mcp.json is unparseable');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// ── brain (at the tier-aware root) ──
|
|
108
|
+
const bRoot = brainRootFor(tier, cfg?.project?.alphaName);
|
|
109
|
+
let hasBrain = false;
|
|
110
|
+
let brainLayoutVersion;
|
|
111
|
+
const metaRaw = read(join(repo, bRoot, '.brain-meta.json'));
|
|
112
|
+
if (metaRaw !== null) {
|
|
113
|
+
hasBrain = true;
|
|
114
|
+
try {
|
|
115
|
+
brainLayoutVersion = JSON.parse(metaRaw).layoutVersion;
|
|
116
|
+
}
|
|
117
|
+
catch { /* ignore */ }
|
|
118
|
+
}
|
|
119
|
+
else if (existsSync(join(repo, bRoot, 'wiki'))) {
|
|
120
|
+
hasBrain = true; // brain exists but pre-versioning
|
|
121
|
+
}
|
|
122
|
+
// ── managed-surface signals for state classification ──
|
|
123
|
+
const cmdDir = join(repo, '.claude', 'commands', tier);
|
|
124
|
+
const hasTierPack = existsSync(cmdDir);
|
|
125
|
+
const otherTierPacks = ['clan', 'district', 'jungle', 'biome']
|
|
126
|
+
.filter(t => t !== tier && existsSync(join(repo, '.claude', 'commands', t)));
|
|
127
|
+
const skillDir = join(repo, '.claude', 'skills', 'clan-awareness', 'SKILL.md');
|
|
128
|
+
const flatSkill = join(repo, '.claude', 'skills', 'clan-awareness.md');
|
|
129
|
+
const hasSkillDir = existsSync(skillDir);
|
|
130
|
+
const hasFlatSkill = existsSync(flatSkill);
|
|
131
|
+
const statusMd = read(join(cmdDir, 'status.md'));
|
|
132
|
+
const packIsOurs = statusMd !== null && hasManagedMarker(statusMd);
|
|
133
|
+
// ── OS + git ──
|
|
134
|
+
const os = process.platform;
|
|
135
|
+
const isGit = existsSync(join(repo, '.git'));
|
|
136
|
+
// ── state classification ──
|
|
137
|
+
let state;
|
|
138
|
+
const anyClanArtifact = hasConfig || hasTierPack || hasFlatSkill || hasSkillDir || hasBrain;
|
|
139
|
+
if (!anyClanArtifact) {
|
|
140
|
+
state = 'fresh';
|
|
141
|
+
}
|
|
142
|
+
else if (otherTierPacks.length > 0 || (configError !== undefined)) {
|
|
143
|
+
state = 'drifted';
|
|
144
|
+
if (otherTierPacks.length)
|
|
145
|
+
notes.push(`stale command packs from other tier(s): ${otherTierPacks.join(', ')}`);
|
|
146
|
+
}
|
|
147
|
+
else if (hasFlatSkill || (hasTierPack && !packIsOurs) || (hasBrain && (brainLayoutVersion ?? 0) < LAYOUT_VERSION)) {
|
|
148
|
+
state = 'legacy';
|
|
149
|
+
if (hasFlatSkill)
|
|
150
|
+
notes.push('legacy flat awareness skill (.claude/skills/clan-awareness.md) never loads — will migrate to SKILL.md dir');
|
|
151
|
+
if (hasTierPack && !packIsOurs)
|
|
152
|
+
notes.push('command pack present but not engine-managed (published-only install) — will adopt');
|
|
153
|
+
if (hasBrain && (brainLayoutVersion ?? 0) < LAYOUT_VERSION)
|
|
154
|
+
notes.push(`brain at layout v${brainLayoutVersion ?? 0} < current v${LAYOUT_VERSION}`);
|
|
155
|
+
}
|
|
156
|
+
else if (hasTierPack && packIsOurs && hasSkillDir && hasBrain && brainLayoutVersion === LAYOUT_VERSION) {
|
|
157
|
+
// The managed surface is fully at the current layout. (.jungle/config.yaml
|
|
158
|
+
// is the CLI's concern — the engine owns the .claude/.brain surface.)
|
|
159
|
+
state = 'current';
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
state = 'partial';
|
|
163
|
+
notes.push('some clan artifacts present, setup incomplete — re-run to complete');
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
repo, os, isGit, tier, tierSource, mode, identity,
|
|
167
|
+
hasConfig, configError, hasMcp, mcpServers,
|
|
168
|
+
inventory: scanInventory(repo, { home: opts.home }),
|
|
169
|
+
hasBrain, brainLayoutVersion, state, notes,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude-inventory — discover the user's EXISTING Claude Code assets (agents,
|
|
3
|
+
* skills, commands, hooks) at both project and global (~/.claude) scope, so the
|
|
4
|
+
* clan can work WITH them instead of ignoring or duplicating them.
|
|
5
|
+
*
|
|
6
|
+
* The manifest it writes (.clan/inventory.json) is what the awareness skill
|
|
7
|
+
* points the Alpha at ("prefer these where they overlap").
|
|
8
|
+
*/
|
|
9
|
+
import type { InventoryItem } from './types.js';
|
|
10
|
+
/**
|
|
11
|
+
* Inventory the user's existing Claude Code assets. Excludes the engine's own
|
|
12
|
+
* clan-awareness skill and tier command packs (those are ours, not "existing").
|
|
13
|
+
*/
|
|
14
|
+
export declare function scanInventory(repo: string, opts?: {
|
|
15
|
+
home?: string;
|
|
16
|
+
}): InventoryItem[];
|
|
17
|
+
/** Write the manifest the awareness skill references. Returns the repo-relative path. */
|
|
18
|
+
export declare function writeInventoryManifest(repo: string, items: InventoryItem[]): string;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude-inventory — discover the user's EXISTING Claude Code assets (agents,
|
|
3
|
+
* skills, commands, hooks) at both project and global (~/.claude) scope, so the
|
|
4
|
+
* clan can work WITH them instead of ignoring or duplicating them.
|
|
5
|
+
*
|
|
6
|
+
* The manifest it writes (.clan/inventory.json) is what the awareness skill
|
|
7
|
+
* points the Alpha at ("prefer these where they overlap").
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, existsSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'fs';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
import { homedir } from 'os';
|
|
12
|
+
/** Pull `name:`/`description:` from a leading YAML frontmatter block, if any. */
|
|
13
|
+
function frontmatter(content) {
|
|
14
|
+
if (!content.startsWith('---'))
|
|
15
|
+
return {};
|
|
16
|
+
const end = content.indexOf('\n---', 3);
|
|
17
|
+
if (end === -1)
|
|
18
|
+
return {};
|
|
19
|
+
const block = content.slice(3, end);
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const line of block.split('\n')) {
|
|
22
|
+
const m = /^(name|description):\s*(.+)$/.exec(line.trim());
|
|
23
|
+
if (m)
|
|
24
|
+
out[m[1]] = m[2].replace(/^["']|["']$/g, '').trim();
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
function safeRead(p) {
|
|
29
|
+
try {
|
|
30
|
+
return readFileSync(p, 'utf-8');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Recursively list *.md files under a dir (bounded depth for safety). */
|
|
37
|
+
function listMd(dir, depth = 2) {
|
|
38
|
+
if (depth < 0 || !existsSync(dir))
|
|
39
|
+
return [];
|
|
40
|
+
const out = [];
|
|
41
|
+
let entries = [];
|
|
42
|
+
try {
|
|
43
|
+
entries = readdirSync(dir);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
for (const e of entries) {
|
|
49
|
+
const full = join(dir, e);
|
|
50
|
+
let st;
|
|
51
|
+
try {
|
|
52
|
+
st = statSync(full);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (st.isDirectory())
|
|
58
|
+
out.push(...listMd(full, depth - 1));
|
|
59
|
+
else if (e.endsWith('.md'))
|
|
60
|
+
out.push(full);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
function scanDir(base, kind, scope, sub) {
|
|
65
|
+
const dir = join(base, sub);
|
|
66
|
+
const items = [];
|
|
67
|
+
for (const file of listMd(dir)) {
|
|
68
|
+
const fm = frontmatter(safeRead(file));
|
|
69
|
+
// Skill dirs use <name>/SKILL.md; commands use <ns>/<name>.md; agents flat.
|
|
70
|
+
const rel = file.slice(dir.length + 1);
|
|
71
|
+
const name = fm.name ?? rel.replace(/\.md$/, '').replace(/\/SKILL$/, '');
|
|
72
|
+
items.push({ kind, name, path: file, scope, description: fm.description });
|
|
73
|
+
}
|
|
74
|
+
return items;
|
|
75
|
+
}
|
|
76
|
+
/** Hooks declared in a settings.json (project or global). */
|
|
77
|
+
function scanHooks(base, scope) {
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const f of ['settings.json', 'settings.local.json']) {
|
|
80
|
+
const p = join(base, f);
|
|
81
|
+
let doc;
|
|
82
|
+
try {
|
|
83
|
+
doc = JSON.parse(readFileSync(p, 'utf-8'));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const hooks = doc?.hooks ?? {};
|
|
89
|
+
for (const event of Object.keys(hooks)) {
|
|
90
|
+
const entries = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
for (const h of entry?.hooks ?? []) {
|
|
93
|
+
if (h?.command && !/load-brain\.(mjs|sh)/.test(h.command)) {
|
|
94
|
+
out.push({ kind: 'hook', name: `${event}:${h.command.slice(0, 40)}`, path: p, scope, description: `${event} hook` });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Inventory the user's existing Claude Code assets. Excludes the engine's own
|
|
104
|
+
* clan-awareness skill and tier command packs (those are ours, not "existing").
|
|
105
|
+
*/
|
|
106
|
+
export function scanInventory(repo, opts = {}) {
|
|
107
|
+
const home = opts.home ?? homedir();
|
|
108
|
+
const projClaude = join(repo, '.claude');
|
|
109
|
+
const globalClaude = join(home, '.claude');
|
|
110
|
+
const items = [
|
|
111
|
+
...scanDir(projClaude, 'agent', 'project', 'agents'),
|
|
112
|
+
...scanDir(projClaude, 'skill', 'project', 'skills'),
|
|
113
|
+
...scanDir(projClaude, 'command', 'project', 'commands'),
|
|
114
|
+
...scanHooks(projClaude, 'project'),
|
|
115
|
+
...scanDir(globalClaude, 'agent', 'global', 'agents'),
|
|
116
|
+
...scanDir(globalClaude, 'skill', 'global', 'skills'),
|
|
117
|
+
...scanDir(globalClaude, 'command', 'global', 'commands'),
|
|
118
|
+
...scanHooks(globalClaude, 'global'),
|
|
119
|
+
];
|
|
120
|
+
// Drop the engine's own artifacts — they aren't "existing user assets".
|
|
121
|
+
return items.filter(it => {
|
|
122
|
+
if (it.kind === 'skill' && it.name === 'clan-awareness')
|
|
123
|
+
return false;
|
|
124
|
+
if (it.kind === 'command' && /^(clan|district|jungle|biome):/.test(it.name))
|
|
125
|
+
return false;
|
|
126
|
+
return true;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** Write the manifest the awareness skill references. Returns the repo-relative path. */
|
|
130
|
+
export function writeInventoryManifest(repo, items) {
|
|
131
|
+
const dir = join(repo, '.clan');
|
|
132
|
+
mkdirSync(dir, { recursive: true });
|
|
133
|
+
const rel = items.map(i => ({ kind: i.kind, name: i.name, scope: i.scope, description: i.description ?? null }));
|
|
134
|
+
writeFileSync(join(dir, 'inventory.json'), JSON.stringify({ generatedBy: 'clan-engine', items: rel }, null, 2) + '\n', 'utf-8');
|
|
135
|
+
return '.clan/inventory.json';
|
|
136
|
+
}
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-destructive .mcp.json authoring. Merges a correctly-named `jungle-local`
|
|
3
|
+
* server alongside whatever the user already has — the primitive that makes
|
|
4
|
+
* "convert an existing setup" non-destructive. Never clobbers; the bare name
|
|
5
|
+
* `jungle` silently collides with the global human-council server, so we always
|
|
6
|
+
* use `jungle-local`.
|
|
7
|
+
*/
|
|
8
|
+
import type { PlanAction } from './types.js';
|
|
9
|
+
export interface JungleCreds {
|
|
10
|
+
apiUrl: string;
|
|
11
|
+
alphaId: string;
|
|
12
|
+
apiKey: string;
|
|
13
|
+
humanEmail?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function jungleLocalServer(c: JungleCreds): {
|
|
16
|
+
command: string;
|
|
17
|
+
args: string[];
|
|
18
|
+
env: Record<string, string>;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Produce the reconciliation action(s) for .mcp.json given the target creds.
|
|
22
|
+
* Returns a 'merge' action (new file content) plus, when a fresh file is
|
|
23
|
+
* created and the repo is git, a gitignore append — or a 'skip' when the
|
|
24
|
+
* jungle-local entry is already present and identical, or the file is
|
|
25
|
+
* unparseable (never clobber).
|
|
26
|
+
*/
|
|
27
|
+
export declare function planMcpJson(repo: string, creds: JungleCreds, isGit: boolean): PlanAction[];
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-destructive .mcp.json authoring. Merges a correctly-named `jungle-local`
|
|
3
|
+
* server alongside whatever the user already has — the primitive that makes
|
|
4
|
+
* "convert an existing setup" non-destructive. Never clobbers; the bare name
|
|
5
|
+
* `jungle` silently collides with the global human-council server, so we always
|
|
6
|
+
* use `jungle-local`.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
export function jungleLocalServer(c) {
|
|
11
|
+
const env = {
|
|
12
|
+
JUNGLE_API_URL: c.apiUrl,
|
|
13
|
+
JUNGLE_ALPHA_ID: c.alphaId,
|
|
14
|
+
JUNGLE_API_KEY: c.apiKey,
|
|
15
|
+
};
|
|
16
|
+
if (c.humanEmail)
|
|
17
|
+
env.JUNGLE_HUMAN_EMAIL = c.humanEmail;
|
|
18
|
+
return { command: 'npx', args: ['-y', '@nfinitmonkeys/jungle-mcp'], env };
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Produce the reconciliation action(s) for .mcp.json given the target creds.
|
|
22
|
+
* Returns a 'merge' action (new file content) plus, when a fresh file is
|
|
23
|
+
* created and the repo is git, a gitignore append — or a 'skip' when the
|
|
24
|
+
* jungle-local entry is already present and identical, or the file is
|
|
25
|
+
* unparseable (never clobber).
|
|
26
|
+
*/
|
|
27
|
+
export function planMcpJson(repo, creds, isGit) {
|
|
28
|
+
const mcpPath = join(repo, '.mcp.json');
|
|
29
|
+
const server = jungleLocalServer(creds);
|
|
30
|
+
let doc = {};
|
|
31
|
+
let existed = false;
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(readFileSync(mcpPath, 'utf-8'));
|
|
34
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
35
|
+
return [{ kind: 'skip', path: '.mcp.json', note: '.mcp.json root is not a JSON object — fix by hand, then re-run', reason: 'malformed' }];
|
|
36
|
+
}
|
|
37
|
+
doc = parsed;
|
|
38
|
+
existed = true;
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
if (err.code !== 'ENOENT') {
|
|
42
|
+
return [{ kind: 'skip', path: '.mcp.json', note: '.mcp.json is not valid JSON — fix by hand, then re-run', reason: 'unparseable' }];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (doc.mcpServers !== undefined && (typeof doc.mcpServers !== 'object' || doc.mcpServers === null || Array.isArray(doc.mcpServers))) {
|
|
46
|
+
return [{ kind: 'skip', path: '.mcp.json', note: '.mcp.json "mcpServers" is not an object — fix by hand, then re-run', reason: 'malformed' }];
|
|
47
|
+
}
|
|
48
|
+
doc.mcpServers = doc.mcpServers ?? {};
|
|
49
|
+
const prior = doc.mcpServers['jungle-local'];
|
|
50
|
+
if (prior && JSON.stringify(prior) === JSON.stringify(server)) {
|
|
51
|
+
return [{ kind: 'skip', path: '.mcp.json', note: 'jungle-local already present', reason: 'identical' }];
|
|
52
|
+
}
|
|
53
|
+
doc.mcpServers['jungle-local'] = server;
|
|
54
|
+
const actions = [{
|
|
55
|
+
kind: 'merge',
|
|
56
|
+
path: '.mcp.json',
|
|
57
|
+
content: JSON.stringify(doc, null, 2) + '\n',
|
|
58
|
+
note: existed ? 'merge jungle-local alongside existing servers' : 'create .mcp.json (jungle-local)',
|
|
59
|
+
reason: existed ? 'other MCP servers preserved' : 'holds a live key',
|
|
60
|
+
}];
|
|
61
|
+
if (isGit) {
|
|
62
|
+
const giPath = join(repo, '.gitignore');
|
|
63
|
+
let gi = '';
|
|
64
|
+
try {
|
|
65
|
+
gi = readFileSync(giPath, 'utf-8');
|
|
66
|
+
}
|
|
67
|
+
catch { /* absent */ }
|
|
68
|
+
if (!gi.split('\n').some(l => l.trim() === '.mcp.json')) {
|
|
69
|
+
const prefix = gi.length === 0 || gi.endsWith('\n') ? '' : '\n';
|
|
70
|
+
actions.push({ kind: 'append', path: '.gitignore', content: prefix + '.mcp.json\n', note: 'gitignore .mcp.json (holds a live API key)' });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return actions;
|
|
74
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reconcile — the plan builder. Given a target identity and an inspect result,
|
|
3
|
+
* produce an idempotent, non-destructive Plan of actions. This is where the
|
|
4
|
+
* "our output vs the user's file" decision lives:
|
|
5
|
+
*
|
|
6
|
+
* identical → skip
|
|
7
|
+
* our managed file → update (content changed)
|
|
8
|
+
* a user file at path → backup-replace (their bytes are saved first)
|
|
9
|
+
* absent → create
|
|
10
|
+
* brain page present → skip (never clobber accumulated memory)
|
|
11
|
+
* JSON with user content → merge (.mcp.json / settings.json)
|
|
12
|
+
*/
|
|
13
|
+
import { type ClanIdentity, type InspectResult, type Plan } from './types.js';
|
|
14
|
+
export interface ReconcileOptions {
|
|
15
|
+
/** write .mcp.json from these creds (connected mode). */
|
|
16
|
+
jungle?: {
|
|
17
|
+
apiUrl: string;
|
|
18
|
+
alphaId: string;
|
|
19
|
+
apiKey: string;
|
|
20
|
+
humanEmail?: string;
|
|
21
|
+
};
|
|
22
|
+
/** include the brain scaffold (default true). */
|
|
23
|
+
brain?: boolean;
|
|
24
|
+
/** a deterministic YYYY-MM-DD for brain timestamps (CLI passes today). */
|
|
25
|
+
today?: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function reconcile(identity: ClanIdentity, ins: InspectResult, opts?: ReconcileOptions): Plan;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reconcile — the plan builder. Given a target identity and an inspect result,
|
|
3
|
+
* produce an idempotent, non-destructive Plan of actions. This is where the
|
|
4
|
+
* "our output vs the user's file" decision lives:
|
|
5
|
+
*
|
|
6
|
+
* identical → skip
|
|
7
|
+
* our managed file → update (content changed)
|
|
8
|
+
* a user file at path → backup-replace (their bytes are saved first)
|
|
9
|
+
* absent → create
|
|
10
|
+
* brain page present → skip (never clobber accumulated memory)
|
|
11
|
+
* JSON with user content → merge (.mcp.json / settings.json)
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from 'fs';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
import { claudeMdIdentity, geoTierMarker, claudeSurface, brainScaffold, hasManagedMarker, findGeoTierMarker, ALL_TIER_DIRS, } from './templates.js';
|
|
16
|
+
import { planMcpJson } from './mcp.js';
|
|
17
|
+
import { planBrainHook } from './hooks.js';
|
|
18
|
+
import { scanInventory } from './inventory.js';
|
|
19
|
+
function read(repo, rel) {
|
|
20
|
+
try {
|
|
21
|
+
return readFileSync(join(repo, rel), 'utf-8');
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Reconcile one managed template file against what's on disk. */
|
|
28
|
+
function reconcileFile(repo, f) {
|
|
29
|
+
const existing = read(repo, f.path);
|
|
30
|
+
if (existing === null) {
|
|
31
|
+
return { kind: 'create', path: f.path, content: f.content, note: 'create', chmod: f.chmod };
|
|
32
|
+
}
|
|
33
|
+
if (existing === f.content) {
|
|
34
|
+
return { kind: 'skip', path: f.path, note: 'identical', reason: 'unchanged' };
|
|
35
|
+
}
|
|
36
|
+
// Ours (marker as a real frontmatter key, or a known script path) → update;
|
|
37
|
+
// else the user wrote a file at our path → back it up and replace.
|
|
38
|
+
const ours = f.managed ? hasManagedMarker(existing) : true; // scripts are ours by path
|
|
39
|
+
if (ours) {
|
|
40
|
+
return { kind: 'update', path: f.path, content: f.content, note: 'update (engine-managed, content changed)', chmod: f.chmod };
|
|
41
|
+
}
|
|
42
|
+
return { kind: 'backup-replace', path: f.path, content: f.content, note: 'a non-engine file exists here — backed up, then replaced', chmod: f.chmod, reason: 'not engine-managed' };
|
|
43
|
+
}
|
|
44
|
+
/** CLAUDE.md: create fresh with the identity block, or (existing file) ensure
|
|
45
|
+
* only a REAL top-level geo-tier declaration line — never rewrite the user's
|
|
46
|
+
* prose, and never touch a marker that appears inside a code block or example. */
|
|
47
|
+
function reconcileClaudeMd(repo, i) {
|
|
48
|
+
const existing = read(repo, 'CLAUDE.md');
|
|
49
|
+
const marker = geoTierMarker(i.tier);
|
|
50
|
+
if (existing === null) {
|
|
51
|
+
return { kind: 'create', path: 'CLAUDE.md', content: claudeMdIdentity(i), note: 'create CLAUDE.md with identity block' };
|
|
52
|
+
}
|
|
53
|
+
const found = findGeoTierMarker(existing);
|
|
54
|
+
if (found) {
|
|
55
|
+
if (found.line.trim() === marker)
|
|
56
|
+
return { kind: 'skip', path: 'CLAUDE.md', note: 'geo-tier marker present', reason: 'unchanged' };
|
|
57
|
+
// Replace exactly the one real declaration line; prose + any in-fence
|
|
58
|
+
// examples are untouched.
|
|
59
|
+
return { kind: 'merge', path: 'CLAUDE.md', content: existing.replace(found.line, marker), note: `retier CLAUDE.md marker → ${i.tier}`, reason: 'only the declaration line changes' };
|
|
60
|
+
}
|
|
61
|
+
// No real declaration (a marker inside a code fence doesn't count) → prepend one.
|
|
62
|
+
return { kind: 'merge', path: 'CLAUDE.md', content: `${marker}\n${existing}`, note: 'prepend geo-tier marker (prose untouched)' };
|
|
63
|
+
}
|
|
64
|
+
export function reconcile(identity, ins, opts = {}) {
|
|
65
|
+
const repo = ins.repo;
|
|
66
|
+
const actions = [];
|
|
67
|
+
// 1. CLAUDE.md declaration
|
|
68
|
+
actions.push(reconcileClaudeMd(repo, identity));
|
|
69
|
+
// 2. legacy flat skill at the awareness path → backed up, then removed
|
|
70
|
+
// (superseded by the SKILL.md dir which is the only loadable format).
|
|
71
|
+
if (read(repo, '.claude/skills/clan-awareness.md') !== null) {
|
|
72
|
+
actions.push({ kind: 'remove', path: '.claude/skills/clan-awareness.md', note: 'legacy awareness-skill path — superseded by SKILL.md dir (backed up)' });
|
|
73
|
+
}
|
|
74
|
+
// 3. stale command packs from other tiers → backup-remove (only our own)
|
|
75
|
+
for (const t of ALL_TIER_DIRS.filter(x => x !== identity.tier)) {
|
|
76
|
+
for (const name of ['status', 'inbox', 'log', 'policies', 'request', 'health']) {
|
|
77
|
+
const rel = `.claude/commands/${t}/${name}.md`;
|
|
78
|
+
const c = read(repo, rel);
|
|
79
|
+
if (c !== null && hasManagedMarker(c))
|
|
80
|
+
actions.push({ kind: 'remove', path: rel, note: `remove stale ${t} command (repo is ${identity.tier})` });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// 4. managed .claude surface (awareness SKILL.md + tier command pack + hook script)
|
|
84
|
+
for (const f of claudeSurface(identity))
|
|
85
|
+
actions.push(reconcileFile(repo, f));
|
|
86
|
+
// 5. inventory manifest (refresh when changed — idempotent otherwise)
|
|
87
|
+
const inv = scanInventory(repo);
|
|
88
|
+
const invContent = JSON.stringify({ generatedBy: 'clan-engine', items: inv.map(i => ({ kind: i.kind, name: i.name, scope: i.scope, description: i.description ?? null })) }, null, 2) + '\n';
|
|
89
|
+
const invExisting = read(repo, '.clan/inventory.json');
|
|
90
|
+
if (invExisting === null)
|
|
91
|
+
actions.push({ kind: 'create', path: '.clan/inventory.json', content: invContent, note: `inventory ${inv.length} existing asset(s) the Alpha can use` });
|
|
92
|
+
else if (invExisting !== invContent)
|
|
93
|
+
actions.push({ kind: 'update', path: '.clan/inventory.json', content: invContent, note: `refresh inventory (${inv.length} asset(s))` });
|
|
94
|
+
else
|
|
95
|
+
actions.push({ kind: 'skip', path: '.clan/inventory.json', note: 'inventory unchanged', reason: 'unchanged' });
|
|
96
|
+
// 6. .mcp.json (connected mode)
|
|
97
|
+
if (opts.jungle)
|
|
98
|
+
actions.push(...planMcpJson(repo, opts.jungle, ins.isGit));
|
|
99
|
+
// 7. SessionStart brain hook (tier-aware command)
|
|
100
|
+
actions.push(...planBrainHook(repo, identity));
|
|
101
|
+
// 8. brain scaffold — create-only (never overwrite accumulated brain pages)
|
|
102
|
+
if (opts.brain !== false) {
|
|
103
|
+
for (const f of brainScaffold(identity, opts.today ?? '1970-01-01')) {
|
|
104
|
+
if (read(repo, f.path) === null)
|
|
105
|
+
actions.push({ kind: 'create', path: f.path, content: f.content, note: 'brain scaffold' });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { identity, actions, repo };
|
|
109
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE authoritative template set. This is the single source of truth for every
|
|
3
|
+
* file the engine writes into a node — the whole point of the engine is that
|
|
4
|
+
* the published CLIs AND the internal fleet tooling both render from here, so
|
|
5
|
+
* they can never drift again.
|
|
6
|
+
*
|
|
7
|
+
* Every generated file carries a marker (`generated-by: clan-engine@<v>` +
|
|
8
|
+
* `layout: <n>`) so reconciliation can distinguish our output (safe to update)
|
|
9
|
+
* from a user's hand-written file at the same path (must be backed up).
|
|
10
|
+
*/
|
|
11
|
+
import { type ClanIdentity, type GeneratedFile, type Tier } from './types.js';
|
|
12
|
+
/** Frontmatter marker lines (inside a YAML block). */
|
|
13
|
+
export declare function markerLines(): string;
|
|
14
|
+
/**
|
|
15
|
+
* True only when the file carries an engine (or predecessor) marker as a real
|
|
16
|
+
* frontmatter KEY — not merely a substring somewhere in the body. This stops a
|
|
17
|
+
* user file that happens to mention "clan-engine:" in prose from being
|
|
18
|
+
* misclassified as ours and silently overwritten.
|
|
19
|
+
*/
|
|
20
|
+
export declare function hasManagedMarker(content: string): boolean;
|
|
21
|
+
/** Geo-tier HTML-comment marker for CLAUDE.md (declared identity, never inferred). */
|
|
22
|
+
export declare function geoTierMarker(tier: Tier): string;
|
|
23
|
+
/**
|
|
24
|
+
* Find a REAL top-level geo-tier declaration: a standalone line (its trimmed
|
|
25
|
+
* content is exactly the marker) that is NOT inside a fenced code block. A
|
|
26
|
+
* marker mentioned inside ``` fences or in prose is documentation, not a
|
|
27
|
+
* declaration, and must be ignored (else tier is mis-detected and retier
|
|
28
|
+
* corrupts the user's docs). Returns the declared tier + the exact line, or null.
|
|
29
|
+
*/
|
|
30
|
+
export declare function findGeoTierMarker(content: string): {
|
|
31
|
+
tier: Tier;
|
|
32
|
+
line: string;
|
|
33
|
+
} | null;
|
|
34
|
+
/** The identity block prepended to CLAUDE.md when the file is created fresh.
|
|
35
|
+
* When CLAUDE.md already exists we only ensure the geo-tier marker line
|
|
36
|
+
* (reconcile handles that) — we never rewrite a user's prose. */
|
|
37
|
+
export declare function claudeMdIdentity(i: ClanIdentity): string;
|
|
38
|
+
/** Where the brain lives, per tier (matches the fleet's clan-ready layout so
|
|
39
|
+
* the engine never puts .brain/ where a district/jungle expects its vault). */
|
|
40
|
+
export declare function brainRoot(i: ClanIdentity): string;
|
|
41
|
+
export declare function brainScaffold(i: ClanIdentity, today: string): GeneratedFile[];
|
|
42
|
+
/** The command wired into .claude/settings.json SessionStart. Relative (hooks
|
|
43
|
+
* run at project root) + the script self-locates via import.meta, so it is
|
|
44
|
+
* cwd- and OS-independent. Tier-aware because the brain root is. */
|
|
45
|
+
export declare function hookCommand(i: ClanIdentity): string;
|
|
46
|
+
/** Regex matching any load-brain hook command (bash or node, any brain root) —
|
|
47
|
+
* used to detect + migrate a previously-wired hook. */
|
|
48
|
+
export declare const LOAD_BRAIN_HOOK_RE: RegExp;
|
|
49
|
+
export declare function loadBrainScript(i: ClanIdentity): GeneratedFile;
|
|
50
|
+
/**
|
|
51
|
+
* Every managed file the engine authors into .claude/ + .brain/ for one
|
|
52
|
+
* identity, EXCEPT the merge-based artifacts (.mcp.json, settings.json hook,
|
|
53
|
+
* CLAUDE.md) which reconcile handles specially because they touch user content.
|
|
54
|
+
*/
|
|
55
|
+
export declare function claudeSurface(i: ClanIdentity): GeneratedFile[];
|
|
56
|
+
/** All tier command directories the engine owns — used to prune stale packs
|
|
57
|
+
* from a different tier (e.g. a repo re-tiered clan → district). */
|
|
58
|
+
export declare const ALL_TIER_DIRS: Tier[];
|