@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 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
@@ -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); });
@@ -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
+ }
@@ -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
+ }
@@ -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;