@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.
@@ -0,0 +1,303 @@
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 { ENGINE_VERSION, LAYOUT_VERSION, GENERATED_BY } from './types.js';
12
+ /** Frontmatter marker lines (inside a YAML block). */
13
+ export function markerLines() {
14
+ return `${GENERATED_BY}: ${ENGINE_VERSION}\nlayout: ${LAYOUT_VERSION}`;
15
+ }
16
+ /** Frontmatter keys that mean "this file is engine-managed" — our own marker
17
+ * plus predecessor markers so a first engine pass over a clan-ready-managed
18
+ * repo ADOPTS (updates) the files instead of backup-replacing all of them. */
19
+ const MANAGED_KEYS = [GENERATED_BY, 'generated-by'];
20
+ /** Extract the leading YAML frontmatter block (between the first two `---`). */
21
+ function frontmatterBlock(content) {
22
+ if (!content.startsWith('---'))
23
+ return null;
24
+ const end = content.indexOf('\n---', 3);
25
+ return end === -1 ? null : content.slice(3, end);
26
+ }
27
+ /**
28
+ * True only when the file carries an engine (or predecessor) marker as a real
29
+ * frontmatter KEY — not merely a substring somewhere in the body. This stops a
30
+ * user file that happens to mention "clan-engine:" in prose from being
31
+ * misclassified as ours and silently overwritten.
32
+ */
33
+ export function hasManagedMarker(content) {
34
+ const fm = frontmatterBlock(content);
35
+ if (fm === null)
36
+ return false;
37
+ return fm.split('\n').some(line => {
38
+ const m = /^([A-Za-z0-9_-]+):/.exec(line.trim());
39
+ return !!m && MANAGED_KEYS.includes(m[1]);
40
+ });
41
+ }
42
+ /** Geo-tier HTML-comment marker for CLAUDE.md (declared identity, never inferred). */
43
+ export function geoTierMarker(tier) {
44
+ return `<!-- geo-tier: ${tier} -->`;
45
+ }
46
+ /**
47
+ * Find a REAL top-level geo-tier declaration: a standalone line (its trimmed
48
+ * content is exactly the marker) that is NOT inside a fenced code block. A
49
+ * marker mentioned inside ``` fences or in prose is documentation, not a
50
+ * declaration, and must be ignored (else tier is mis-detected and retier
51
+ * corrupts the user's docs). Returns the declared tier + the exact line, or null.
52
+ */
53
+ export function findGeoTierMarker(content) {
54
+ const lineRe = /^<!--\s*geo-tier:\s*(clan|district|jungle|biome)\s*-->$/;
55
+ let inFence = false;
56
+ for (const raw of content.split('\n')) {
57
+ const trimmed = raw.trim();
58
+ if (/^(```|~~~)/.test(trimmed)) {
59
+ inFence = !inFence;
60
+ continue;
61
+ }
62
+ if (inFence)
63
+ continue;
64
+ const m = lineRe.exec(trimmed);
65
+ if (m)
66
+ return { tier: m[1], line: raw };
67
+ }
68
+ return null;
69
+ }
70
+ // ─── CLAUDE.md identity block ───────────────────────────────────────────────
71
+ /** The identity block prepended to CLAUDE.md when the file is created fresh.
72
+ * When CLAUDE.md already exists we only ensure the geo-tier marker line
73
+ * (reconcile handles that) — we never rewrite a user's prose. */
74
+ export function claudeMdIdentity(i) {
75
+ const noun = i.tier;
76
+ const idLine = i.alphaId ? ` (\`${i.alphaId}\`)` : '';
77
+ return `${geoTierMarker(i.tier)}
78
+ # Identity
79
+
80
+ You are operating as **${i.alphaName}** — the Alpha of ${noun} **${i.name}**${idLine}.
81
+
82
+ This repo is a **${noun.toUpperCase()}** node in the Nfinit Monkeys geography
83
+ (Biome ⊃ Jungle ⊃ District ⊃ Clan). Identity is DECLARED here, never inferred.
84
+
85
+ ## Tool defaults
86
+
87
+ - Default MCP prefix: \`mcp__jungle-local__*\` (this node's voice).
88
+ - The Alpha's brain (persistent memory) lives under \`.brain/\` — hydrated at
89
+ session start by the SessionStart hook.
90
+
91
+ ## Scope
92
+
93
+ Routine work here is node-local. Slash commands are tier-named:
94
+ \`/${noun}:status\` · \`/${noun}:inbox\` · \`/${noun}:log\` · \`/${noun}:policies\` · \`/${noun}:request\`.
95
+ `;
96
+ }
97
+ // ─── awareness skill (SKILL.md directory format — loadable) ─────────────────
98
+ function awarenessSkill(i) {
99
+ const noun = i.tier;
100
+ const modeLine = i.mode === 'connected'
101
+ ? `- **Mode:** connected${i.jungle?.apiUrl ? ` to ${i.jungle.apiUrl}` : ''}${i.alphaId ? `\n- **Alpha ID:** \`${i.alphaId}\`` : ''}`
102
+ : `- **Mode:** standalone — no Jungle, no approval queue. The developer IS the Human Council.`;
103
+ const governance = i.mode === 'connected'
104
+ ? `## Governance (connected)
105
+
106
+ Alphas **propose**, humans **approve**. Sensitive actions go through a gated
107
+ approval flow (\`request_approval\` → Human Council reviews in BOSS → execute →
108
+ logged). To change this node's permissions, tell the user to message the Alpha
109
+ in natural language — it will propose the change for approval.`
110
+ : `## Governance (standalone)
111
+
112
+ There is no remote council. The developer at this terminal IS the council. When
113
+ they want to change a policy: confirm it, edit \`.jungle/config.yaml\`, restart
114
+ the Alpha. Explicit confirmation IS the approval — never mutate config silently.`;
115
+ const usingExisting = `## Use what this project already has
116
+
117
+ If the user already has agents, skills, or slash commands (e.g. from other
118
+ tools), PREFER them where they overlap instead of duplicating. The clan is a
119
+ teammate to the existing setup, not a replacement — check \`.clan/inventory.json\`
120
+ (written by the engine) for what's available, and route to it by name.`;
121
+ return {
122
+ path: '.claude/skills/clan-awareness/SKILL.md',
123
+ managed: true,
124
+ content: `---
125
+ name: clan-awareness
126
+ description: This project is a ${noun} node (${i.alphaName} / ${i.name}) run via @nfinitmonkeys/clan — how to answer status, governance, and "talk to the Alpha" questions
127
+ ${markerLines()}
128
+ ---
129
+
130
+ # Clan Awareness — ${i.name}
131
+
132
+ You are inside a **${noun}** node run via \`@nfinitmonkeys/clan\`, with its own
133
+ Alpha (**${i.alphaName}**), policies, and accumulated memory (a "brain").
134
+
135
+ ## Working *as* the Alpha
136
+
137
+ In this repo you act **as ${i.alphaName}**. But ${i.alphaName} is bigger than one
138
+ chat: it has a **brain** (\`npx -y @nfinitmonkeys/clan brain "…"\` — persistent
139
+ memory), an **inbox** (\`npx -y @nfinitmonkeys/clan talk "…"\`), and an
140
+ **autonomous loop** (\`npx -y @nfinitmonkeys/clan start\`). If the user says "I
141
+ want to talk to the Alpha," don't say "you already are" — point them to those.
142
+
143
+ ## This node
144
+
145
+ - **Name:** ${i.name}
146
+ - **Alpha:** ${i.alphaName}
147
+ - **Tier:** ${noun}
148
+ ${modeLine}
149
+
150
+ ## Slash commands (tier-named)
151
+
152
+ \`/${noun}:status\` · \`/${noun}:inbox\` · \`/${noun}:log\` · \`/${noun}:policies\` · \`/${noun}:request\`
153
+
154
+ If the user types a different tier's command (e.g. \`/clan:status\` in a
155
+ district), point them to the right one — don't silently alias.
156
+
157
+ ${usingExisting}
158
+
159
+ ${governance}
160
+ `,
161
+ };
162
+ }
163
+ // ─── tier-named command pack ────────────────────────────────────────────────
164
+ function cmd(tier, name, description, body) {
165
+ return {
166
+ path: `.claude/commands/${tier}/${name}.md`,
167
+ managed: true,
168
+ content: `---
169
+ name: ${tier}:${name}
170
+ description: ${description}
171
+ ${markerLines()}
172
+ ---
173
+
174
+ ${body}
175
+ `,
176
+ };
177
+ }
178
+ function commandPack(i) {
179
+ const t = i.tier;
180
+ const files = [
181
+ cmd(t, 'status', `Show this ${t}'s live status — identity, policies, inbox, telemetry`, `Run the clan status command and present it as a clean dashboard. If MCP is wired, you may also call \`mcp__jungle-local__jungle_whoami\` and \`mcp__jungle-local__jungle_get_self\`.
182
+
183
+ \`\`\`bash
184
+ npx -y @nfinitmonkeys/clan status
185
+ \`\`\``),
186
+ cmd(t, 'policies', `Show this ${t}'s autonomous / gated / denied actions`, `\`\`\`bash
187
+ npx -y @nfinitmonkeys/clan policies
188
+ \`\`\`
189
+
190
+ Explain what each action bucket means for this project. To change one in
191
+ connected mode, tell the user to message the Alpha; standalone, edit
192
+ \`.jungle/config.yaml\` directly.`),
193
+ cmd(t, 'request', `Propose a policy or governance change for this ${t}`, i.mode === 'connected'
194
+ ? `Compose the natural-language request the user sends to the Alpha (which files a \`request_approval\` with actionType=policy_change). Confirm the target bucket + rationale, then:
195
+
196
+ \`\`\`bash
197
+ npx -y @nfinitmonkeys/clan talk "your request here"
198
+ \`\`\``
199
+ : `Standalone — the user is the approver. Confirm the change (actionType, bucket, why), edit \`.jungle/config.yaml\`, restart the Alpha. Never fabricate a remote approval flow.`),
200
+ ];
201
+ if (i.mode === 'connected') {
202
+ files.push(cmd(t, 'inbox', `View recent messages in ${i.alphaName}'s inbox`, `\`\`\`bash
203
+ npx -y @nfinitmonkeys/clan inbox
204
+ \`\`\`
205
+
206
+ Summarize pending (●) vs read (○); highlight anything urgent. Requires a Jungle
207
+ connection.`), cmd(t, 'log', `Governance log — every policy change with who/when/why`, `\`\`\`bash
208
+ npx -y @nfinitmonkeys/clan status --json 2>/dev/null | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{const j=JSON.parse(s);console.log(JSON.stringify((j.self&&j.self.governanceLog||[]).slice(0,10),null,2))}catch{console.log('[]')}})"
209
+ \`\`\`
210
+
211
+ Format as a timeline (newest first): timestamp, operation, action, requester,
212
+ approver, rationale.`), cmd(t, 'health', `Probe this ${t}'s health and report it to the Jungle`, `\`\`\`bash
213
+ npx -y @nfinitmonkeys/clan health
214
+ \`\`\`
215
+
216
+ Probes come from \`.jungle/config.yaml\` under \`health:\`. Summarize each check
217
+ (ok / degraded / down) after it reports.`));
218
+ }
219
+ return files;
220
+ }
221
+ // ─── brain scaffold (canonical layout — <brainRoot>/wiki + .brain-meta.json) ─
222
+ /** Where the brain lives, per tier (matches the fleet's clan-ready layout so
223
+ * the engine never puts .brain/ where a district/jungle expects its vault). */
224
+ export function brainRoot(i) {
225
+ if (i.tier === 'district')
226
+ return '.district/brain';
227
+ if (i.tier === 'jungle')
228
+ return `.brains/${i.alphaName.toLowerCase()}`;
229
+ return '.brain';
230
+ }
231
+ export function brainScaffold(i, today) {
232
+ const root = brainRoot(i);
233
+ const files = [];
234
+ const push = (rel, content, managed = true) => files.push({ path: `${root}/${rel}`, content, managed });
235
+ push('.brain-meta.json', JSON.stringify({
236
+ schemaVersion: '1.0',
237
+ layoutVersion: LAYOUT_VERSION,
238
+ generatedBy: `${GENERATED_BY}@${ENGINE_VERSION}`,
239
+ alphaId: i.alphaId ?? null,
240
+ createdAt: today,
241
+ }, null, 2) + '\n');
242
+ push('CLAUDE.md', `# ${i.alphaName}'s Brain — ${i.name} Memory Vault\n\nPersistent, compounding memory. Read \`wiki/hot.md\` first.\n\n| Field | Value |\n|---|---|\n| alphaId | \`${i.alphaId ?? '(unset)'}\` |\n| alphaName | ${i.alphaName} |\n| node | ${i.name} |\n| tier | ${i.tier} |\n`);
243
+ push('wiki/identity.md', `---\nname: Identity — ${i.alphaName}\ndescription: Who I am\ntype: identity\nupdated: ${today}\n---\n\n# ${i.alphaName} — Alpha of ${i.name}\n\n| Field | Value |\n|---|---|\n| alphaId | \`${i.alphaId ?? '(unset)'}\` |\n| alphaName | **${i.alphaName}** |\n| node | ${i.name} |\n| geoTier | ${i.tier} |\n\n## Mission\n\n${i.description || `${i.alphaName} is the Alpha of ${i.name}.`}\n`);
244
+ push('wiki/hot.md', `---\nname: Hot Cache\ndescription: Recent context — read at session start\ntype: hot\nupdated: ${today}\n---\n\n# Hot Cache\n\nBrain scaffolded ${today} by ${GENERATED_BY}. Observations and decisions accumulate here.\n`);
245
+ push('wiki/index.md', `---\nname: Index\ntype: index\nupdated: ${today}\n---\n\n# Index\n\n- [[identity]] · [[hot]] · [[log]]\n- entities/ · concepts/ · decisions/ · observations/ · goals/ · relationships/ · questions/ · sources/\n`);
246
+ push('wiki/log.md', `---\nname: Action Log\ntype: log\nupdated: ${today}\n---\n\n# Action Log\n\n## ${today}\n- Brain vault scaffolded by ${GENERATED_BY}.\n`);
247
+ for (const cat of ['entities', 'concepts', 'decisions', 'observations', 'goals', 'relationships', 'questions', 'sources']) {
248
+ push(`wiki/${cat}/_index.md`, `---\nname: ${cat} — Index\ntype: domain-index\nupdated: ${today}\n---\n\n# ${cat}\n\n*Populates as ${i.alphaName} accumulates ${cat}.*\n`);
249
+ }
250
+ return files;
251
+ }
252
+ // ─── cross-platform SessionStart hook (Node, not bash) ──────────────────────
253
+ /** The command wired into .claude/settings.json SessionStart. Relative (hooks
254
+ * run at project root) + the script self-locates via import.meta, so it is
255
+ * cwd- and OS-independent. Tier-aware because the brain root is. */
256
+ export function hookCommand(i) {
257
+ return `node ${brainRoot(i)}/scripts/load-brain.mjs`;
258
+ }
259
+ /** Regex matching any load-brain hook command (bash or node, any brain root) —
260
+ * used to detect + migrate a previously-wired hook. */
261
+ export const LOAD_BRAIN_HOOK_RE = /load-brain\.(mjs|sh)/;
262
+ export function loadBrainScript(i) {
263
+ const header = `# ${i.alphaName}'s brain — loaded at session start`;
264
+ const sub = `Persistent memory for ${i.name} (${i.tier}). Authoritative on identity, recent context, standing goals.`;
265
+ return {
266
+ path: `${brainRoot(i)}/scripts/load-brain.mjs`,
267
+ managed: false, // a runnable script; content-compare handles idempotency
268
+ chmod: 0o755,
269
+ content: `#!/usr/bin/env node
270
+ // load-brain.mjs — hydrate ${i.alphaName}'s brain at session start (${GENERATED_BY}).
271
+ // Cross-platform (macOS/Windows/Linux). Self-noops (exit 0, no output) with no brain.
272
+ import { readFileSync, existsSync } from 'node:fs';
273
+ import { fileURLToPath } from 'node:url';
274
+ import { dirname, join } from 'node:path';
275
+
276
+ const wiki = join(dirname(dirname(fileURLToPath(import.meta.url))), 'wiki');
277
+ if (!existsSync(wiki)) process.exit(0);
278
+
279
+ const out = [${JSON.stringify(header)}, '', ${JSON.stringify(sub)}, '---'];
280
+ for (const f of ['identity.md', 'hot.md']) {
281
+ const p = join(wiki, f);
282
+ if (existsSync(p)) { out.push('## From wiki/' + f, '', readFileSync(p, 'utf-8'), '---'); }
283
+ }
284
+ const today = new Date().toISOString().slice(0, 10);
285
+ const obs = join(wiki, 'observations', today + '.md');
286
+ if (existsSync(obs)) { out.push("## Today's observations", '', readFileSync(obs, 'utf-8')); }
287
+ process.stdout.write(out.join('\\n') + '\\n');
288
+ process.exit(0);
289
+ `,
290
+ };
291
+ }
292
+ // ─── the full managed .claude surface for an identity ───────────────────────
293
+ /**
294
+ * Every managed file the engine authors into .claude/ + .brain/ for one
295
+ * identity, EXCEPT the merge-based artifacts (.mcp.json, settings.json hook,
296
+ * CLAUDE.md) which reconcile handles specially because they touch user content.
297
+ */
298
+ export function claudeSurface(i) {
299
+ return [awarenessSkill(i), ...commandPack(i), loadBrainScript(i)];
300
+ }
301
+ /** All tier command directories the engine owns — used to prune stale packs
302
+ * from a different tier (e.g. a repo re-tiered clan → district). */
303
+ export const ALL_TIER_DIRS = ['clan', 'district', 'jungle', 'biome'];
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Shared contract for the clan-engine. Every module imports from here; nothing
3
+ * else is cross-imported, so the pieces stay independently testable.
4
+ */
5
+ /** Engine + layout versions. LAYOUT_VERSION is stamped into every generated
6
+ * file's marker so a future engine can tell "my old output" from "my current
7
+ * output" and run migrations. Bump LAYOUT_VERSION when the template SHAPE
8
+ * changes (a path moves, a format changes); bump ENGINE_VERSION freely. */
9
+ export declare const ENGINE_VERSION = "0.1.0";
10
+ export declare const LAYOUT_VERSION = 1;
11
+ /** The marker key written into generated-file frontmatter / headers. */
12
+ export declare const GENERATED_BY = "clan-engine";
13
+ export type Tier = 'clan' | 'district' | 'jungle' | 'biome';
14
+ export type Mode = 'connected' | 'standalone';
15
+ /** How the tier was decided (declared always wins over inferred). */
16
+ export type TierSource = 'flag' | 'marker' | 'district-dir' | 'brains-dir' | 'default';
17
+ export interface ClanIdentity {
18
+ /** Clan / node display name. */
19
+ name: string;
20
+ alphaName: string;
21
+ alphaId?: string;
22
+ description: string;
23
+ tier: Tier;
24
+ mode: Mode;
25
+ /** connected-mode credentials (drive .mcp.json). */
26
+ jungle?: {
27
+ apiUrl: string;
28
+ alphaId: string;
29
+ apiKey: string;
30
+ humanEmail?: string;
31
+ };
32
+ }
33
+ /** A file the templates want to exist, before reconciliation. */
34
+ export interface GeneratedFile {
35
+ path: string;
36
+ content: string;
37
+ /** true when this file carries a clan-engine marker (so reconciliation can
38
+ * tell our output from a user's hand-written file at the same path). */
39
+ managed: boolean;
40
+ /** POSIX chmod to apply after write (e.g. 0o755 for scripts). */
41
+ chmod?: number;
42
+ }
43
+ export type ActionKind = 'create' | 'update' | 'backup-replace' | 'merge' | 'append' | 'remove' | 'skip';
44
+ export interface PlanAction {
45
+ kind: ActionKind;
46
+ path: string;
47
+ content?: string;
48
+ note: string;
49
+ chmod?: number;
50
+ /** why this action was chosen — surfaced in dry-run output. */
51
+ reason?: string;
52
+ }
53
+ export interface Plan {
54
+ identity: ClanIdentity;
55
+ actions: PlanAction[];
56
+ /** repo root the plan targets. */
57
+ repo: string;
58
+ }
59
+ /** One inventoried Claude Code asset the user already has. */
60
+ export interface InventoryItem {
61
+ kind: 'agent' | 'skill' | 'command' | 'hook';
62
+ name: string;
63
+ path: string;
64
+ scope: 'project' | 'global';
65
+ description?: string;
66
+ }
67
+ export type NodeState = 'fresh' | 'partial' | 'current' | 'legacy' | 'drifted';
68
+ export interface McpServerInfo {
69
+ name: string;
70
+ isJungle: boolean;
71
+ misnamed: boolean;
72
+ }
73
+ export interface InspectResult {
74
+ repo: string;
75
+ os: string;
76
+ isGit: boolean;
77
+ tier: Tier;
78
+ tierSource: TierSource;
79
+ mode: Mode;
80
+ identity?: ClanIdentity;
81
+ /** .jungle/config.yaml present + parsed? */
82
+ hasConfig: boolean;
83
+ configError?: string;
84
+ /** .mcp.json present? which servers? */
85
+ hasMcp: boolean;
86
+ mcpServers: McpServerInfo[];
87
+ /** the user's existing Claude Code assets (project + global). */
88
+ inventory: InventoryItem[];
89
+ /** brain vault present + its layout version (from .brain-meta.json). */
90
+ hasBrain: boolean;
91
+ brainLayoutVersion?: number;
92
+ /** the reconciled state classification. */
93
+ state: NodeState;
94
+ /** actionable notes for the wizard / caller. */
95
+ notes: string[];
96
+ }
97
+ /** One recorded mutation, enabling undo. */
98
+ export interface JournalOp {
99
+ kind: ActionKind;
100
+ path: string;
101
+ /** backup location (repo-relative) if the prior file was saved. */
102
+ backup?: string;
103
+ /** true if this op CREATED the file (undo = delete). */
104
+ created?: boolean;
105
+ }
106
+ export interface Journal {
107
+ engineVersion: string;
108
+ layoutVersion: number;
109
+ /** stamped by the caller (the engine forbids Date.now for determinism in
110
+ * tests; the CLI passes a timestamp). */
111
+ at: string;
112
+ repo: string;
113
+ identity: ClanIdentity;
114
+ ops: JournalOp[];
115
+ }
116
+ export interface ApplyResult {
117
+ applied: PlanAction[];
118
+ journalPath?: string;
119
+ backupDir?: string;
120
+ }
121
+ export interface DoctorCheck {
122
+ name: string;
123
+ ok: boolean;
124
+ detail: string;
125
+ }
package/dist/types.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Shared contract for the clan-engine. Every module imports from here; nothing
3
+ * else is cross-imported, so the pieces stay independently testable.
4
+ */
5
+ /** Engine + layout versions. LAYOUT_VERSION is stamped into every generated
6
+ * file's marker so a future engine can tell "my old output" from "my current
7
+ * output" and run migrations. Bump LAYOUT_VERSION when the template SHAPE
8
+ * changes (a path moves, a format changes); bump ENGINE_VERSION freely. */
9
+ export const ENGINE_VERSION = '0.1.0';
10
+ export const LAYOUT_VERSION = 1;
11
+ /** The marker key written into generated-file frontmatter / headers. */
12
+ export const GENERATED_BY = 'clan-engine';
package/dist/undo.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * undo — reverse an apply() run from its journal. Restores every backed-up file
3
+ * to its prior bytes and deletes every file the run created. The inverse of
4
+ * apply, so "try the clan and cleanly walk away" actually works.
5
+ */
6
+ export interface UndoResult {
7
+ restored: string[];
8
+ removed: string[];
9
+ journal: string;
10
+ }
11
+ /** Undo the run described by a journal file (repo-relative or absolute path). */
12
+ export declare function undo(repo: string, journalPath: string): UndoResult;
13
+ /** List journals present in a repo (newest name last — they are timestamp-named). */
14
+ export declare function listJournals(repo: string): string[];
package/dist/undo.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * undo — reverse an apply() run from its journal. Restores every backed-up file
3
+ * to its prior bytes and deletes every file the run created. The inverse of
4
+ * apply, so "try the clan and cleanly walk away" actually works.
5
+ */
6
+ import { readFileSync, existsSync, rmSync, mkdirSync, cpSync, readdirSync } from 'fs';
7
+ import { join, dirname, isAbsolute } from 'path';
8
+ /** Undo the run described by a journal file (repo-relative or absolute path). */
9
+ export function undo(repo, journalPath) {
10
+ const jAbs = isAbsolute(journalPath) ? journalPath : join(repo, journalPath);
11
+ const journal = JSON.parse(readFileSync(jAbs, 'utf-8'));
12
+ const restored = [];
13
+ const removed = [];
14
+ // Reverse order so appends/creates unwind cleanly.
15
+ for (const op of [...journal.ops].reverse()) {
16
+ const abs = join(repo, op.path);
17
+ if (op.backup) {
18
+ const bkpAbs = join(repo, op.backup);
19
+ if (existsSync(bkpAbs)) {
20
+ mkdirSync(dirname(abs), { recursive: true });
21
+ rmSync(abs, { recursive: true, force: true });
22
+ cpSync(bkpAbs, abs, { recursive: true });
23
+ restored.push(op.path);
24
+ }
25
+ }
26
+ else if (op.created) {
27
+ rmSync(abs, { recursive: true, force: true });
28
+ removed.push(op.path);
29
+ }
30
+ }
31
+ return { restored, removed, journal: journalPath };
32
+ }
33
+ /** List journals present in a repo (newest name last — they are timestamp-named). */
34
+ export function listJournals(repo) {
35
+ const dir = join(repo, '.claude', '.clan-engine');
36
+ try {
37
+ return readdirSync(dir).filter(f => f.startsWith('journal-') && f.endsWith('.json')).sort()
38
+ .map(f => join('.claude', '.clan-engine', f));
39
+ }
40
+ catch {
41
+ return [];
42
+ }
43
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@nfinitmonkeys/clan-engine",
3
+ "version": "0.1.0",
4
+ "description": "Deterministic setup engine for Nfinit Monkeys clans — the single template + reconciliation authority behind the published CLIs and the internal fleet tooling. Idempotent, dry-run-by-default, non-destructive.",
5
+ "type": "module",
6
+ "bin": {
7
+ "clan-engine": "./dist/cli.js"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "files": [
12
+ "dist",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "test": "npm run build && node test/engine.test.mjs",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "keywords": [
24
+ "nfinitmonkeys",
25
+ "clan",
26
+ "jungle",
27
+ "claude-code",
28
+ "mcp"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "license": "MIT",
34
+ "devDependencies": {
35
+ "@types/node": "^22.20.1",
36
+ "typescript": "^5.6.3"
37
+ },
38
+ "dependencies": {
39
+ "yaml": "^2.9.0"
40
+ }
41
+ }