@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/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# @nfinitmonkeys/clan-engine
|
|
2
|
+
|
|
3
|
+
The deterministic setup engine for Nfinit Monkeys **clans** — the single
|
|
4
|
+
template + reconciliation authority. The published CLIs
|
|
5
|
+
(`@nfinitmonkeys/clan`, `@nfinitmonkeys/clan-claude`) and the internal fleet
|
|
6
|
+
tooling all render from here, so their output can never drift apart again.
|
|
7
|
+
|
|
8
|
+
**Non-destructive by construction.** It merges into an existing `.claude/` +
|
|
9
|
+
`.mcp.json` instead of clobbering, backs up every file it touches, and journals
|
|
10
|
+
every run so it can be undone exactly.
|
|
11
|
+
|
|
12
|
+
## Verbs
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
clan-engine inspect [--repo .] [--json] # map a repo's state (never mutates)
|
|
16
|
+
clan-engine plan [--repo .] --alpha X --name Y [--tier clan] [--json]
|
|
17
|
+
clan-engine apply [--repo .] --alpha X --name Y # write it (backups + journal)
|
|
18
|
+
clan-engine undo [--repo .] [--journal <path>] # reverse the last apply
|
|
19
|
+
clan-engine doctor [--repo .] [--json] # validate the whole loop
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`plan` is a dry run; `apply` writes. Both are idempotent — a second `apply`
|
|
23
|
+
is all skips.
|
|
24
|
+
|
|
25
|
+
## What it authors
|
|
26
|
+
|
|
27
|
+
- **`CLAUDE.md`** — geo-tier declaration + identity block (existing prose is
|
|
28
|
+
never rewritten; only the `<!-- geo-tier: X -->` marker is ensured).
|
|
29
|
+
- **`.claude/skills/clan-awareness/SKILL.md`** — the awareness skill in the
|
|
30
|
+
directory format Claude Code actually loads (a flat file never loads).
|
|
31
|
+
- **`.claude/commands/<tier>/*.md`** — tier-named slash-command pack.
|
|
32
|
+
- **`.mcp.json`** — a correctly-named `jungle-local` server **merged** alongside
|
|
33
|
+
any servers you already have, and gitignored (it holds a live key). Never the
|
|
34
|
+
bare name `jungle` (it collides with the global council server).
|
|
35
|
+
- **`.claude/settings.json`** — a cross-platform Node SessionStart hook that
|
|
36
|
+
hydrates the brain and self-noops when there's no brain (safe to commit).
|
|
37
|
+
- **`.brain/`** — a versioned vault (`.brain-meta.json` carries `layoutVersion`
|
|
38
|
+
so future engines can migrate it).
|
|
39
|
+
- **`.clan/inventory.json`** — the user's EXISTING agents/skills/commands/hooks,
|
|
40
|
+
so the Alpha works *with* them instead of duplicating.
|
|
41
|
+
|
|
42
|
+
## Reconciliation
|
|
43
|
+
|
|
44
|
+
Every generated file carries a `clan-engine@<v>` + `layout: <n>` marker. That's
|
|
45
|
+
how the engine tells its own output (safe to update) from a file you wrote at
|
|
46
|
+
the same path (backed up, then replaced). Brain pages are create-only — your
|
|
47
|
+
accumulated memory is never clobbered.
|
|
48
|
+
|
|
49
|
+
## Programmatic API
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
import { inspect, reconcile, apply, undo, doctor, setup } from '@nfinitmonkeys/clan-engine';
|
|
53
|
+
|
|
54
|
+
const { plan, result } = setup(repo, identity, { at, today }); // inspect → reconcile → apply
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The conversational setup wizard drives these verbs — the LLM improvises the
|
|
58
|
+
conversation, the engine does every write.
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT
|
package/dist/apply.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* apply — execute a Plan with the safety DNA: before modifying or removing any
|
|
3
|
+
* existing file, copy it into a per-run backup dir; write a journal of every op
|
|
4
|
+
* so `undo` can reverse the run exactly. One uniform rule, no per-path
|
|
5
|
+
* exceptions to forget.
|
|
6
|
+
*/
|
|
7
|
+
import { type Plan, type ApplyResult } from './types.js';
|
|
8
|
+
export interface ApplyOptions {
|
|
9
|
+
/** timestamp string used for the backup dir + journal name (determinism). */
|
|
10
|
+
at: string;
|
|
11
|
+
/** don't write a journal (e.g. transient/test). Default false. */
|
|
12
|
+
noJournal?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function apply(plan: Plan, opts: ApplyOptions): ApplyResult;
|
package/dist/apply.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* apply — execute a Plan with the safety DNA: before modifying or removing any
|
|
3
|
+
* existing file, copy it into a per-run backup dir; write a journal of every op
|
|
4
|
+
* so `undo` can reverse the run exactly. One uniform rule, no per-path
|
|
5
|
+
* exceptions to forget.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, chmodSync, cpSync } from 'fs';
|
|
8
|
+
import { join, dirname } from 'path';
|
|
9
|
+
import { ENGINE_VERSION, LAYOUT_VERSION } from './types.js';
|
|
10
|
+
/** Flatten a repo-relative path to a single backup filename. */
|
|
11
|
+
function flat(rel) { return rel.replace(/[\\/]/g, '__'); }
|
|
12
|
+
export function apply(plan, opts) {
|
|
13
|
+
const repo = plan.repo;
|
|
14
|
+
const runDir = join('.claude', '.clan-engine');
|
|
15
|
+
// Collision-proof run id: if the backup dir OR journal for this `at` already
|
|
16
|
+
// exists, suffix it. Reusing an `at` (two runs in the same ms, or a fleet
|
|
17
|
+
// driver passing a fixed stamp) must NEVER overwrite a prior run's backup or
|
|
18
|
+
// journal — that is the one unrecoverable data-loss path.
|
|
19
|
+
let runId = opts.at;
|
|
20
|
+
for (let n = 1; existsSync(join(repo, runDir, `backup-${runId}`)) || existsSync(join(repo, runDir, `journal-${runId}.json`)); n++) {
|
|
21
|
+
runId = `${opts.at}-${n}`;
|
|
22
|
+
}
|
|
23
|
+
const backupRel = join(runDir, `backup-${runId}`);
|
|
24
|
+
const backupAbs = join(repo, backupRel);
|
|
25
|
+
const ops = [];
|
|
26
|
+
const applied = [];
|
|
27
|
+
const backup = (rel) => {
|
|
28
|
+
const abs = join(repo, rel);
|
|
29
|
+
if (!existsSync(abs))
|
|
30
|
+
return undefined;
|
|
31
|
+
const dest = join(backupAbs, flat(rel));
|
|
32
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
33
|
+
cpSync(abs, dest, { recursive: true });
|
|
34
|
+
return join(backupRel, flat(rel));
|
|
35
|
+
};
|
|
36
|
+
for (const a of plan.actions) {
|
|
37
|
+
if (a.kind === 'skip')
|
|
38
|
+
continue;
|
|
39
|
+
const abs = join(repo, a.path);
|
|
40
|
+
const existedBefore = existsSync(abs);
|
|
41
|
+
switch (a.kind) {
|
|
42
|
+
case 'create':
|
|
43
|
+
case 'update':
|
|
44
|
+
case 'backup-replace':
|
|
45
|
+
case 'merge': {
|
|
46
|
+
const bkp = (a.kind !== 'create' && existedBefore) ? backup(a.path) : undefined;
|
|
47
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
48
|
+
writeFileSync(abs, a.content ?? '', 'utf-8');
|
|
49
|
+
if (a.chmod !== undefined) {
|
|
50
|
+
try {
|
|
51
|
+
chmodSync(abs, a.chmod);
|
|
52
|
+
}
|
|
53
|
+
catch { /* best-effort on win */ }
|
|
54
|
+
}
|
|
55
|
+
ops.push({ kind: a.kind, path: a.path, backup: bkp, created: !existedBefore });
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
case 'append': {
|
|
59
|
+
const bkp = existedBefore ? backup(a.path) : undefined;
|
|
60
|
+
const prev = existedBefore ? readFileSync(abs, 'utf-8') : '';
|
|
61
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
62
|
+
writeFileSync(abs, prev + (a.content ?? ''), 'utf-8');
|
|
63
|
+
ops.push({ kind: a.kind, path: a.path, backup: bkp, created: !existedBefore });
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
case 'remove': {
|
|
67
|
+
const bkp = backup(a.path);
|
|
68
|
+
rmSync(abs, { recursive: true, force: true });
|
|
69
|
+
ops.push({ kind: a.kind, path: a.path, backup: bkp, created: false });
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
applied.push(a);
|
|
74
|
+
}
|
|
75
|
+
let journalPath;
|
|
76
|
+
if (!opts.noJournal && ops.length > 0) {
|
|
77
|
+
const journal = {
|
|
78
|
+
engineVersion: ENGINE_VERSION, layoutVersion: LAYOUT_VERSION,
|
|
79
|
+
at: runId, repo, identity: plan.identity, ops,
|
|
80
|
+
};
|
|
81
|
+
const jRel = join(runDir, `journal-${runId}.json`);
|
|
82
|
+
const jAbs = join(repo, jRel);
|
|
83
|
+
mkdirSync(dirname(jAbs), { recursive: true });
|
|
84
|
+
writeFileSync(jAbs, JSON.stringify(journal, null, 2) + '\n', 'utf-8');
|
|
85
|
+
journalPath = jRel;
|
|
86
|
+
}
|
|
87
|
+
return { applied, journalPath, backupDir: ops.some(o => o.backup) ? backupRel : undefined };
|
|
88
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* clan-engine — the deterministic setup door. Verbs: inspect · plan · apply ·
|
|
4
|
+
* undo · doctor. Non-interactive and JSON-capable so the conversational wizard
|
|
5
|
+
* (Claude Code) and the internal fleet tooling both drive the SAME engine.
|
|
6
|
+
*
|
|
7
|
+
* clan-engine inspect [--repo .] [--json]
|
|
8
|
+
* clan-engine plan [--repo .] [--tier clan] [--alpha X] [--name Y] [--json]
|
|
9
|
+
* clan-engine apply [--repo .] [--alpha X] [--name Y] [--yes]
|
|
10
|
+
* clan-engine undo [--repo .] [--journal <path>]
|
|
11
|
+
* clan-engine doctor [--repo .] [--json]
|
|
12
|
+
*/
|
|
13
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* clan-engine — the deterministic setup door. Verbs: inspect · plan · apply ·
|
|
4
|
+
* undo · doctor. Non-interactive and JSON-capable so the conversational wizard
|
|
5
|
+
* (Claude Code) and the internal fleet tooling both drive the SAME engine.
|
|
6
|
+
*
|
|
7
|
+
* clan-engine inspect [--repo .] [--json]
|
|
8
|
+
* clan-engine plan [--repo .] [--tier clan] [--alpha X] [--name Y] [--json]
|
|
9
|
+
* clan-engine apply [--repo .] [--alpha X] [--name Y] [--yes]
|
|
10
|
+
* clan-engine undo [--repo .] [--journal <path>]
|
|
11
|
+
* clan-engine doctor [--repo .] [--json]
|
|
12
|
+
*/
|
|
13
|
+
import { resolve } from 'path';
|
|
14
|
+
import { inspect } from './inspect.js';
|
|
15
|
+
import { reconcile } from './reconcile.js';
|
|
16
|
+
import { apply } from './apply.js';
|
|
17
|
+
import { undo, listJournals } from './undo.js';
|
|
18
|
+
import { doctor } from './doctor.js';
|
|
19
|
+
import { ENGINE_VERSION, LAYOUT_VERSION } from './types.js';
|
|
20
|
+
function parseFlags(argv) {
|
|
21
|
+
const out = {};
|
|
22
|
+
for (let i = 0; i < argv.length; i++) {
|
|
23
|
+
const t = argv[i];
|
|
24
|
+
if (t.startsWith('--')) {
|
|
25
|
+
const k = t.slice(2);
|
|
26
|
+
const v = argv[i + 1];
|
|
27
|
+
if (v && !v.startsWith('--')) {
|
|
28
|
+
out[k] = v;
|
|
29
|
+
i++;
|
|
30
|
+
}
|
|
31
|
+
else
|
|
32
|
+
out[k] = 'true';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
function stamp() {
|
|
38
|
+
// ISO time + pid so two runs in the same millisecond can't collide (apply
|
|
39
|
+
// additionally suffixes on any residual collision).
|
|
40
|
+
return `${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`;
|
|
41
|
+
}
|
|
42
|
+
function today() {
|
|
43
|
+
return new Date().toISOString().slice(0, 10);
|
|
44
|
+
}
|
|
45
|
+
/** Build the target identity from the existing config (inspect) + flag overrides. */
|
|
46
|
+
function resolveIdentity(ins, flags) {
|
|
47
|
+
const base = ins.identity;
|
|
48
|
+
const name = flags['name'] ?? base?.name;
|
|
49
|
+
const alphaName = flags['alpha'] ?? base?.alphaName;
|
|
50
|
+
if (!name || !alphaName) {
|
|
51
|
+
console.error('✗ Need an identity: pass --name <node> --alpha <AlphaName> (or run in a repo with .jungle/config.yaml).');
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
const tier = flags['tier'] ?? ins.tier;
|
|
55
|
+
// connected iff we have full creds (from config or flags)
|
|
56
|
+
const jungle = base?.jungle ?? (flags['api-url'] && flags['alpha-id'] && flags['api-key']
|
|
57
|
+
? { apiUrl: flags['api-url'], alphaId: flags['alpha-id'], apiKey: flags['api-key'] }
|
|
58
|
+
: undefined);
|
|
59
|
+
return {
|
|
60
|
+
name, alphaName, tier,
|
|
61
|
+
description: flags['description'] ?? base?.description ?? '',
|
|
62
|
+
alphaId: flags['alpha-id'] ?? base?.alphaId ?? jungle?.alphaId,
|
|
63
|
+
mode: jungle ? 'connected' : 'standalone',
|
|
64
|
+
jungle,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function printPlan(plan) {
|
|
68
|
+
const glyph = { create: '+', update: '~', 'backup-replace': '!', merge: '~', append: '»', remove: '-', skip: '·' };
|
|
69
|
+
const acting = plan.actions.filter(a => a.kind !== 'skip');
|
|
70
|
+
console.log(`\nPlan for ${plan.identity.alphaName} of ${plan.identity.name} (${plan.identity.tier}, ${plan.identity.mode}) — ${acting.length} action(s):\n`);
|
|
71
|
+
for (const a of plan.actions) {
|
|
72
|
+
const g = glyph[a.kind] ?? '?';
|
|
73
|
+
console.log(` ${g} ${a.path} ${a.kind === 'skip' ? '' : `— ${a.note}`}`.trimEnd());
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function main() {
|
|
77
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
78
|
+
const flags = parseFlags(rest);
|
|
79
|
+
const repo = resolve(flags['repo'] ?? process.cwd());
|
|
80
|
+
const json = flags['json'] === 'true';
|
|
81
|
+
switch (cmd) {
|
|
82
|
+
case 'inspect': {
|
|
83
|
+
const ins = inspect(repo, { tierFlag: flags['tier'] });
|
|
84
|
+
if (json) {
|
|
85
|
+
console.log(JSON.stringify(ins, null, 2));
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
console.log(`\nclan-engine inspect — ${repo}`);
|
|
89
|
+
console.log(` state: ${ins.state}`);
|
|
90
|
+
console.log(` tier: ${ins.tier} (from ${ins.tierSource})`);
|
|
91
|
+
console.log(` mode: ${ins.mode}${ins.identity ? ` — ${ins.identity.alphaName} of ${ins.identity.name}` : ''}`);
|
|
92
|
+
console.log(` os/git: ${ins.os} / ${ins.isGit ? 'git' : 'no-git'}`);
|
|
93
|
+
console.log(` mcp: ${ins.mcpServers.map(s => s.name).join(', ') || '(none)'}`);
|
|
94
|
+
console.log(` brain: ${ins.hasBrain ? `yes (layout v${ins.brainLayoutVersion ?? '?'})` : 'no'}`);
|
|
95
|
+
console.log(` inventory: ${ins.inventory.length} existing asset(s)${ins.inventory.length ? ' — ' + ins.inventory.slice(0, 6).map(i => i.name).join(', ') : ''}`);
|
|
96
|
+
for (const n of ins.notes)
|
|
97
|
+
console.log(` ⚠ ${n}`);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case 'plan':
|
|
101
|
+
case 'apply': {
|
|
102
|
+
const ins = inspect(repo, { tierFlag: flags['tier'] });
|
|
103
|
+
const identity = resolveIdentity(ins, flags);
|
|
104
|
+
const plan = reconcile(identity, ins, { jungle: identity.jungle, today: today() });
|
|
105
|
+
if (cmd === 'plan') {
|
|
106
|
+
if (json)
|
|
107
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
108
|
+
else {
|
|
109
|
+
printPlan(plan);
|
|
110
|
+
console.log(`\n(dry-run — run \`clan-engine apply\` to write)\n`);
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
// apply
|
|
115
|
+
if (!json)
|
|
116
|
+
printPlan(plan);
|
|
117
|
+
const res = apply(plan, { at: stamp() });
|
|
118
|
+
if (json)
|
|
119
|
+
console.log(JSON.stringify(res, null, 2));
|
|
120
|
+
else {
|
|
121
|
+
console.log(`\n✓ applied ${res.applied.length} action(s).`);
|
|
122
|
+
if (res.backupDir)
|
|
123
|
+
console.log(` backups: ${res.backupDir}`);
|
|
124
|
+
if (res.journalPath)
|
|
125
|
+
console.log(` undo with: clan-engine undo --journal ${res.journalPath}`);
|
|
126
|
+
console.log();
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
case 'undo': {
|
|
131
|
+
let jp = flags['journal'];
|
|
132
|
+
if (!jp) {
|
|
133
|
+
const all = listJournals(repo);
|
|
134
|
+
jp = all[all.length - 1];
|
|
135
|
+
}
|
|
136
|
+
if (!jp) {
|
|
137
|
+
console.error('✗ no journal found to undo (nothing was applied here).');
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
const res = undo(repo, jp);
|
|
141
|
+
if (json)
|
|
142
|
+
console.log(JSON.stringify(res, null, 2));
|
|
143
|
+
else
|
|
144
|
+
console.log(`\n✓ undo ${res.journal}: restored ${res.restored.length}, removed ${res.removed.length}.\n`);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case 'doctor': {
|
|
148
|
+
const checks = await doctor(repo);
|
|
149
|
+
if (json) {
|
|
150
|
+
console.log(JSON.stringify(checks, null, 2));
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
console.log(`\nclan-engine doctor — ${repo}\n`);
|
|
154
|
+
for (const c of checks)
|
|
155
|
+
console.log(` ${c.ok ? '✓' : '✗'} ${c.name}: ${c.detail}`);
|
|
156
|
+
const bad = checks.filter(c => !c.ok).length;
|
|
157
|
+
console.log(`\n${bad === 0 ? '✓ all checks passed' : `✗ ${bad} check(s) failed`}\n`);
|
|
158
|
+
process.exit(bad === 0 ? 0 : 1);
|
|
159
|
+
}
|
|
160
|
+
case 'version':
|
|
161
|
+
case '--version':
|
|
162
|
+
console.log(`clan-engine ${ENGINE_VERSION} (layout v${LAYOUT_VERSION})`);
|
|
163
|
+
break;
|
|
164
|
+
default:
|
|
165
|
+
console.log(`clan-engine ${ENGINE_VERSION} — deterministic clan setup
|
|
166
|
+
|
|
167
|
+
inspect map a repo's state (add --json for machine output)
|
|
168
|
+
plan show what setup would write (dry-run; --alpha --name --tier)
|
|
169
|
+
apply write it (uniform backups + a journal for undo)
|
|
170
|
+
undo reverse the last apply (--journal <path> to pick one)
|
|
171
|
+
doctor validate the whole loop (config, mcp name, brain, hook, creds)
|
|
172
|
+
`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
main().catch(e => { console.error('clan-engine error:', e.message); process.exit(1); });
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doctor — validate that a clan is actually wired correctly, end to end. Catches
|
|
3
|
+
* the silent-failure footguns (mis-named MCP server, flat skill that never
|
|
4
|
+
* loads, a brain hook that errors, bad credentials) before the user hits them.
|
|
5
|
+
*/
|
|
6
|
+
import type { DoctorCheck } from './types.js';
|
|
7
|
+
export declare function doctor(repo: string, opts?: {
|
|
8
|
+
home?: string;
|
|
9
|
+
}): Promise<DoctorCheck[]>;
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doctor — validate that a clan is actually wired correctly, end to end. Catches
|
|
3
|
+
* the silent-failure footguns (mis-named MCP server, flat skill that never
|
|
4
|
+
* loads, a brain hook that errors, bad credentials) before the user hits them.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { execFileSync } from 'child_process';
|
|
9
|
+
import { inspect } from './inspect.js';
|
|
10
|
+
import { LAYOUT_VERSION } from './types.js';
|
|
11
|
+
import { brainRoot } from './templates.js';
|
|
12
|
+
async function verifyCreds(apiUrl, apiKey, alphaId) {
|
|
13
|
+
try {
|
|
14
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, '')}/mcp`, {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey, 'X-Alpha-Id': alphaId },
|
|
17
|
+
body: JSON.stringify({ toolCall: { tool: 'jungle_whoami', arguments: {} } }),
|
|
18
|
+
});
|
|
19
|
+
if (!res.ok)
|
|
20
|
+
return { name: 'credentials', ok: false, detail: `jungle_whoami → HTTP ${res.status} (check api-key / alpha-id)` };
|
|
21
|
+
const body = await res.json();
|
|
22
|
+
if (body.success)
|
|
23
|
+
return { name: 'credentials', ok: true, detail: `authenticated as ${body.data?.alphaName ?? body.data?.clanName ?? alphaId}` };
|
|
24
|
+
return { name: 'credentials', ok: false, detail: 'jungle_whoami returned success:false' };
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
return { name: 'credentials', ok: false, detail: `could not reach the Jungle: ${e.message}` };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function doctor(repo, opts = {}) {
|
|
31
|
+
const checks = [];
|
|
32
|
+
const ins = inspect(repo, { home: opts.home });
|
|
33
|
+
// identity / config
|
|
34
|
+
if (ins.configError)
|
|
35
|
+
checks.push({ name: 'config', ok: false, detail: `.jungle/config.yaml unparseable: ${ins.configError}` });
|
|
36
|
+
else if (!ins.hasConfig)
|
|
37
|
+
checks.push({ name: 'config', ok: false, detail: 'no .jungle/config.yaml — run attach/wild' });
|
|
38
|
+
else
|
|
39
|
+
checks.push({ name: 'config', ok: true, detail: `${ins.identity?.alphaName ?? '?'} of ${ins.identity?.name ?? '?'} (${ins.mode})` });
|
|
40
|
+
// awareness skill in the loadable directory format
|
|
41
|
+
const skillDir = existsSync(join(repo, '.claude/skills/clan-awareness/SKILL.md'));
|
|
42
|
+
const flatSkill = existsSync(join(repo, '.claude/skills/clan-awareness.md'));
|
|
43
|
+
checks.push(skillDir
|
|
44
|
+
? { name: 'skill', ok: true, detail: 'clan-awareness SKILL.md (loadable dir format)' }
|
|
45
|
+
: { name: 'skill', ok: false, detail: flatSkill ? 'flat clan-awareness.md never loads — re-run to migrate' : 'awareness skill missing' });
|
|
46
|
+
// .mcp.json name hygiene
|
|
47
|
+
if (ins.mode === 'connected') {
|
|
48
|
+
const jl = ins.mcpServers.find(s => s.name === 'jungle-local');
|
|
49
|
+
const bad = ins.mcpServers.find(s => s.misnamed);
|
|
50
|
+
if (jl)
|
|
51
|
+
checks.push({ name: 'mcp', ok: true, detail: 'jungle-local server present' });
|
|
52
|
+
else if (bad)
|
|
53
|
+
checks.push({ name: 'mcp', ok: false, detail: 'server named "jungle" collides with the global council server — rename to "jungle-local"' });
|
|
54
|
+
else
|
|
55
|
+
checks.push({ name: 'mcp', ok: false, detail: '.mcp.json has no jungle-local server' });
|
|
56
|
+
}
|
|
57
|
+
// brain + layout version
|
|
58
|
+
if (!ins.hasBrain)
|
|
59
|
+
checks.push({ name: 'brain', ok: false, detail: 'no .brain/ vault' });
|
|
60
|
+
else if (ins.brainLayoutVersion !== undefined && ins.brainLayoutVersion < LAYOUT_VERSION)
|
|
61
|
+
checks.push({ name: 'brain', ok: false, detail: `brain at layout v${ins.brainLayoutVersion} < v${LAYOUT_VERSION} — re-run to migrate` });
|
|
62
|
+
else
|
|
63
|
+
checks.push({ name: 'brain', ok: true, detail: `brain present (layout v${ins.brainLayoutVersion ?? '?'})` });
|
|
64
|
+
// brain hook actually runs (cross-platform Node) and exits 0. Spawn the
|
|
65
|
+
// CURRENT interpreter (process.execPath) — a bare 'node' via execFileSync
|
|
66
|
+
// does no shell/PATHEXT resolution and throws on Windows node.cmd shims.
|
|
67
|
+
const bRoot = ins.identity ? brainRoot(ins.identity) : '.brain';
|
|
68
|
+
const hookScript = join(repo, bRoot, 'scripts/load-brain.mjs');
|
|
69
|
+
if (!existsSync(hookScript))
|
|
70
|
+
checks.push({ name: 'hook', ok: false, detail: 'SessionStart brain hook script missing' });
|
|
71
|
+
else {
|
|
72
|
+
try {
|
|
73
|
+
execFileSync(process.execPath, [hookScript], { cwd: repo, stdio: 'ignore', timeout: 10000 });
|
|
74
|
+
checks.push({ name: 'hook', ok: true, detail: 'brain hook runs (exit 0)' });
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
checks.push({ name: 'hook', ok: false, detail: `brain hook errored: ${e.message}` });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// live credential check (connected only)
|
|
81
|
+
if (ins.mode === 'connected' && ins.identity?.jungle) {
|
|
82
|
+
checks.push(await verifyCreds(ins.identity.jungle.apiUrl, ins.identity.jungle.apiKey, ins.identity.jungle.alphaId));
|
|
83
|
+
}
|
|
84
|
+
return checks;
|
|
85
|
+
}
|
package/dist/hooks.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform Claude Code hook wiring. The SessionStart hook hydrates the
|
|
3
|
+
* Alpha's brain; it is a Node command (not bash), so it runs on macOS, Windows,
|
|
4
|
+
* and Linux, and it self-noops when there is no brain — safe in a committed
|
|
5
|
+
* settings.json for collaborators who clone the repo.
|
|
6
|
+
*
|
|
7
|
+
* The settings.json merge is STRUCTURAL: existing hooks and keys are preserved,
|
|
8
|
+
* and a stale entry pointing at an old load-brain path (bash .sh or a moved
|
|
9
|
+
* location) is migrated in place.
|
|
10
|
+
*/
|
|
11
|
+
import type { PlanAction, ClanIdentity } from './types.js';
|
|
12
|
+
/**
|
|
13
|
+
* Plan the SessionStart brain hook in .claude/settings.json. Returns a single
|
|
14
|
+
* 'merge' action with the new settings content, a 'skip' if already wired, or a
|
|
15
|
+
* 'skip' if settings.json is unparseable OR malformed (never clobber a user's
|
|
16
|
+
* config, never throw mid-plan on a valid-JSON-but-wrong-shape file).
|
|
17
|
+
*/
|
|
18
|
+
export declare function planBrainHook(repo: string, identity: ClanIdentity): PlanAction[];
|
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform Claude Code hook wiring. The SessionStart hook hydrates the
|
|
3
|
+
* Alpha's brain; it is a Node command (not bash), so it runs on macOS, Windows,
|
|
4
|
+
* and Linux, and it self-noops when there is no brain — safe in a committed
|
|
5
|
+
* settings.json for collaborators who clone the repo.
|
|
6
|
+
*
|
|
7
|
+
* The settings.json merge is STRUCTURAL: existing hooks and keys are preserved,
|
|
8
|
+
* and a stale entry pointing at an old load-brain path (bash .sh or a moved
|
|
9
|
+
* location) is migrated in place.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import { hookCommand, LOAD_BRAIN_HOOK_RE } from './templates.js';
|
|
14
|
+
function isPlainObject(v) {
|
|
15
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Plan the SessionStart brain hook in .claude/settings.json. Returns a single
|
|
19
|
+
* 'merge' action with the new settings content, a 'skip' if already wired, or a
|
|
20
|
+
* 'skip' if settings.json is unparseable OR malformed (never clobber a user's
|
|
21
|
+
* config, never throw mid-plan on a valid-JSON-but-wrong-shape file).
|
|
22
|
+
*/
|
|
23
|
+
export function planBrainHook(repo, identity) {
|
|
24
|
+
const settingsPath = join(repo, '.claude', 'settings.json');
|
|
25
|
+
const cmd = hookCommand(identity);
|
|
26
|
+
let settings = {};
|
|
27
|
+
let existed = false;
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
30
|
+
if (!isPlainObject(parsed)) {
|
|
31
|
+
return [{ kind: 'skip', path: '.claude/settings.json', note: 'settings.json is not a JSON object — brain hook NOT wired, fix by hand', reason: 'malformed' }];
|
|
32
|
+
}
|
|
33
|
+
settings = parsed;
|
|
34
|
+
existed = true;
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
if (err.code !== 'ENOENT') {
|
|
38
|
+
return [{ kind: 'skip', path: '.claude/settings.json', note: 'settings.json unparseable — brain hook NOT wired, fix by hand', reason: 'unparseable' }];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (!isPlainObject(settings.hooks))
|
|
42
|
+
settings.hooks = {};
|
|
43
|
+
if (!Array.isArray(settings.hooks.SessionStart)) {
|
|
44
|
+
// A non-array SessionStart is malformed — don't iterate/clobber it.
|
|
45
|
+
if (settings.hooks.SessionStart !== undefined) {
|
|
46
|
+
return [{ kind: 'skip', path: '.claude/settings.json', note: 'hooks.SessionStart is not an array — brain hook NOT wired, fix by hand', reason: 'malformed' }];
|
|
47
|
+
}
|
|
48
|
+
settings.hooks.SessionStart = [];
|
|
49
|
+
}
|
|
50
|
+
let found = false, migrated = false;
|
|
51
|
+
for (const entry of settings.hooks.SessionStart) {
|
|
52
|
+
for (const h of entry?.hooks ?? []) {
|
|
53
|
+
if (h?.type === 'command' && LOAD_BRAIN_HOOK_RE.test(h.command ?? '')) {
|
|
54
|
+
if (h.command === cmd)
|
|
55
|
+
found = true;
|
|
56
|
+
else {
|
|
57
|
+
h.command = cmd;
|
|
58
|
+
found = true;
|
|
59
|
+
migrated = true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!found)
|
|
65
|
+
settings.hooks.SessionStart.push({ hooks: [{ type: 'command', command: cmd }] });
|
|
66
|
+
if (found && !migrated) {
|
|
67
|
+
return [{ kind: 'skip', path: '.claude/settings.json', note: 'SessionStart brain hook already wired', reason: 'present' }];
|
|
68
|
+
}
|
|
69
|
+
return [{
|
|
70
|
+
kind: 'merge',
|
|
71
|
+
path: '.claude/settings.json',
|
|
72
|
+
content: JSON.stringify(settings, null, 2) + '\n',
|
|
73
|
+
note: migrated ? 'SessionStart brain hook (path migrated to cross-platform Node)' : (existed ? 'SessionStart brain hook (merged into existing settings)' : 'SessionStart brain hook (settings.json created)'),
|
|
74
|
+
reason: 'existing hooks preserved',
|
|
75
|
+
}];
|
|
76
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nfinitmonkeys/clan-engine — deterministic, non-destructive clan setup.
|
|
3
|
+
*
|
|
4
|
+
* The single template + reconciliation authority. The published CLIs
|
|
5
|
+
* (@nfinitmonkeys/clan, clan-claude) and the internal fleet tooling all render
|
|
6
|
+
* from here, so their output can never drift again. The conversational wizard
|
|
7
|
+
* drives these same verbs; the LLM improvises the conversation, never the writes.
|
|
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';
|
|
10
|
+
export { inspect } from './inspect.js';
|
|
11
|
+
export { reconcile, type ReconcileOptions } from './reconcile.js';
|
|
12
|
+
export { apply, type ApplyOptions } from './apply.js';
|
|
13
|
+
export { undo, listJournals, type UndoResult } from './undo.js';
|
|
14
|
+
export { doctor } from './doctor.js';
|
|
15
|
+
export { scanInventory, writeInventoryManifest } from './inventory.js';
|
|
16
|
+
export { planMcpJson, jungleLocalServer, type JungleCreds } from './mcp.js';
|
|
17
|
+
export { planBrainHook } from './hooks.js';
|
|
18
|
+
export { claudeSurface, brainScaffold, claudeMdIdentity, loadBrainScript, geoTierMarker, hasManagedMarker, findGeoTierMarker, markerLines, hookCommand, brainRoot, } from './templates.js';
|
|
19
|
+
import type { ClanIdentity, Plan, ApplyResult } from './types.js';
|
|
20
|
+
export declare function setup(repo: string, identity: ClanIdentity, opts: {
|
|
21
|
+
at: string;
|
|
22
|
+
today: string;
|
|
23
|
+
dryRun?: boolean;
|
|
24
|
+
}): {
|
|
25
|
+
plan: Plan;
|
|
26
|
+
result?: ApplyResult;
|
|
27
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nfinitmonkeys/clan-engine — deterministic, non-destructive clan setup.
|
|
3
|
+
*
|
|
4
|
+
* The single template + reconciliation authority. The published CLIs
|
|
5
|
+
* (@nfinitmonkeys/clan, clan-claude) and the internal fleet tooling all render
|
|
6
|
+
* from here, so their output can never drift again. The conversational wizard
|
|
7
|
+
* drives these same verbs; the LLM improvises the conversation, never the writes.
|
|
8
|
+
*/
|
|
9
|
+
export { ENGINE_VERSION, LAYOUT_VERSION, GENERATED_BY, } from './types.js';
|
|
10
|
+
export { inspect } from './inspect.js';
|
|
11
|
+
export { reconcile } from './reconcile.js';
|
|
12
|
+
export { apply } from './apply.js';
|
|
13
|
+
export { undo, listJournals } from './undo.js';
|
|
14
|
+
export { doctor } from './doctor.js';
|
|
15
|
+
export { scanInventory, writeInventoryManifest } from './inventory.js';
|
|
16
|
+
export { planMcpJson, jungleLocalServer } from './mcp.js';
|
|
17
|
+
export { planBrainHook } from './hooks.js';
|
|
18
|
+
export { claudeSurface, brainScaffold, claudeMdIdentity, loadBrainScript, geoTierMarker, hasManagedMarker, findGeoTierMarker, markerLines, hookCommand, brainRoot, } from './templates.js';
|
|
19
|
+
/**
|
|
20
|
+
* One-call convenience: inspect → reconcile → apply. Returns the plan + result.
|
|
21
|
+
* `at` and `today` are injected by the caller for determinism.
|
|
22
|
+
*/
|
|
23
|
+
import { inspect as _inspect } from './inspect.js';
|
|
24
|
+
import { reconcile as _reconcile } from './reconcile.js';
|
|
25
|
+
import { apply as _apply } from './apply.js';
|
|
26
|
+
export function setup(repo, identity, opts) {
|
|
27
|
+
const ins = _inspect(repo, { tierFlag: identity.tier });
|
|
28
|
+
const plan = _reconcile(identity, ins, { jungle: identity.jungle, today: opts.today });
|
|
29
|
+
if (opts.dryRun)
|
|
30
|
+
return { plan };
|
|
31
|
+
return { plan, result: _apply(plan, { at: opts.at }) };
|
|
32
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
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 { type InspectResult, type Tier } from './types.js';
|
|
7
|
+
export declare function inspect(repo: string, opts?: {
|
|
8
|
+
tierFlag?: Tier;
|
|
9
|
+
home?: string;
|
|
10
|
+
}): InspectResult;
|