@bbliong/aimp 0.2.0-beta.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0-beta.1 — unreleased
4
+
5
+ - Rebuilt sync around Git porcelain NUL records and explicit path policy.
6
+ - Added recoverable transactions, immutable payloads, safe-copy checks, branch guards, and state migration.
7
+ - Added Linux/WSL beta TUI with wrapped scrolling, fragmented mouse input, and command/path completion.
8
+ - Removed automatic credential sanitization from the active workflow.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 aimp contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # aimp
2
+
3
+ AIMP (AI Mirror Project) adalah CLI lokal untuk bekerja dengan AI pada repo mirror terpisah. AI mengedit mirror, pengguna meninjau laporan dan perubahan, kemudian AIMP menerapkan batch yang disetujui ke project original. AIMP tidak memanggil model, tidak menjalankan aplikasi, tidak membuat commit di original, dan tidak melakukan push.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @bbliong/aimp@beta
9
+ cd /path/to/original-project
10
+ aimp
11
+ ```
12
+
13
+ Node.js 22.14+ dan Git 2.34+ diperlukan. Linux dan WSL2 pada filesystem Linux adalah platform beta. Untuk terminal tanpa TUI, gunakan `aimp --plain status --json`.
14
+
15
+ ## Workflow
16
+
17
+ 1. Jalankan `/init` dari original. AIMP membuat repo mirror independen untuk branch aktif.
18
+ 2. Buka mirror dengan harness atau AI pilihan Anda. AIMP membuat `AGENTS-AIMP.md` dan `AIMP_REPORT.md`.
19
+ 3. AI mengedit mirror dan mengisi laporan dengan `Status: ready`.
20
+ 4. Dari original, jalankan `/status`, `/diff`, lalu `/sync`. Tinjau path, penghapusan, dan konfirmasi. File diterapkan ke original tanpa commit original; checkpoint lokal dibuat di mirror dengan pesan `(synced)`.
21
+ 5. Commit perubahan original dengan workflow project Anda. Untuk memperbarui mirror dari commit original, gunakan `/sync-original-to-ai` setelah original bersih.
22
+
23
+ Perintah penting: `/init`, `/reinit`, `/use`, `/status`, `/diff`, `/sync`, `/sync-original-to-ai`, `/serialize`, `/get-summary`, `/get-commit-message`, `/recover`, `/doctor`, `/language en|id`, `/help`, `/exit`.
24
+
25
+ `.aimpignore` berada di root original dan menjadi pengecualian dua arah, termasuk untuk file yang sudah tracked. Polanya mengikuti Git:
26
+
27
+ ```gitignore
28
+ *.env
29
+ private/
30
+ !private/example.env
31
+ ```
32
+
33
+ File yang dikecualikan tidak disalin, dihapus, atau diterapkan. Gunakan placeholder sendiri untuk konfigurasi AI; AIMP tidak melakukan sanitasi credential otomatis. `AGENTS-AIMP.md` adalah instruksi untuk harness, bukan sandbox OS. Proses AI yang berjalan sebagai user yang sama tetap dapat membaca file lain menurut izin OS.
34
+
35
+ Jika transaksi terhenti, jalankan `/recover` untuk melanjutkan atau `/recover rollback` untuk memulihkan backup. Jangan menghapus folder state sebelum recovery selesai. Detail kontrak, batas platform, dan rencana rilis ada di `docs/`.
36
+
37
+ ## Development
38
+
39
+ ```bash
40
+ npm ci
41
+ npm run verify
42
+ npm pack --dry-run --json
43
+ ```
44
+
45
+ Laporan bug: <https://github.com/bbliong/aimp.dev/issues>. Security report: lihat `SECURITY.md`.
package/SECURITY.md ADDED
@@ -0,0 +1,7 @@
1
+ # Security policy
2
+
3
+ AIMP is a local review and file synchronization tool. It is not a sandbox. An AI process running under the same OS account may access any file that account can access. Run untrusted agents in a separate OS account, container, VM, or other sandbox.
4
+
5
+ Do not put real credentials in the AI mirror. Keep them in files excluded by `.aimpignore` and use placeholders in tracked files. AIMP does not promise to discover every secret.
6
+
7
+ Report security issues privately through the repository security contact or a private GitHub security advisory. Do not publish credentials, private project content, or an unpatched exploit in an issue.
package/bin/aimp.js ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+
4
+ main().catch((error) => {
5
+ console.error(error.message);
6
+ process.exitCode = 1;
7
+ });
@@ -0,0 +1,9 @@
1
+ # Release runbook
2
+
3
+ 1. Pastikan `package.json` version, `repository`, changelog, README, dan supported platforms benar.
4
+ 2. Jalankan `npm ci` dan `npm run verify` pada Node 22 dan 24.
5
+ 3. Review `npm pack --dry-run --json`, lalu install tarball ke prefix sementara.
6
+ 4. Push tag `v<version>` dari commit yang sudah diverifikasi. Workflow GitHub Actions menjalankan test dan publish beta melalui npm trusted publishing.
7
+ 5. Setelah smoke test `npm install -g @bbliong/aimp@beta` dan `npx @bbliong/aimp@beta --version`, dokumentasikan hasil pilot sebelum memindahkan dist-tag ke `latest`.
8
+
9
+ Jangan memakai token publish pada job pull request. Versi yang sudah dipublikasikan tidak dapat digunakan ulang.
@@ -0,0 +1,7 @@
1
+ # Panduan singkat Bahasa Indonesia
2
+
3
+ `aimp` membuat repo mirror lokal terpisah agar AI bekerja tanpa menyentuh file project original secara langsung. Jalankan dari original, gunakan `/init`, lalu buka folder mirror dengan AI pilihan Anda. Setelah pekerjaan selesai, AI mengisi `AIMP_REPORT.md` dan mengubah statusnya menjadi `ready`.
4
+
5
+ Jalankan `/diff` untuk meninjau path, kemudian `/sync` untuk menyalin batch yang disetujui ke original. AIMP tidak membuat commit di original. Commit checkpoint hanya dibuat di mirror dan diberi akhiran `(synced)`. Gunakan `/recover` jika proses terputus.
6
+
7
+ Buat `.aimpignore` di original untuk file yang tidak boleh masuk mirror atau sync, misalnya `.env`, database lokal, dan credential. File ini berlaku untuk tracked maupun untracked.
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@bbliong/aimp",
3
+ "version": "0.2.0-beta.1",
4
+ "description": "Manual Git-based AI workspace mirrors with reviewed sync and recoverable local checkpoints",
5
+ "type": "module",
6
+ "bin": {
7
+ "aimp": "bin/aimp.js"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "bin",
12
+ "README.md",
13
+ "LICENSE",
14
+ "CHANGELOG.md",
15
+ "SECURITY.md",
16
+ "docs/USAGE_ID.md",
17
+ "docs/RELEASE.md"
18
+ ],
19
+ "engines": {
20
+ "node": ">=22.14.0"
21
+ },
22
+ "scripts": {
23
+ "start": "node bin/aimp.js",
24
+ "test": "node --test test/*.test.js",
25
+ "lint": "eslint src bin test scripts --max-warnings=0",
26
+ "typecheck": "tsc -p tsconfig.json",
27
+ "test:terminal": "python3 scripts/terminal-test.py",
28
+ "test:package": "node scripts/package-test.js",
29
+ "bench": "node scripts/benchmark.js",
30
+ "verify": "npm run lint && npm run typecheck && npm test && npm run test:terminal && npm run test:package",
31
+ "prepublishOnly": "npm run verify && node scripts/release-check.js"
32
+ },
33
+ "keywords": [
34
+ "ai",
35
+ "git",
36
+ "cli",
37
+ "workflow"
38
+ ],
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/bbliong/aimp.dev.git"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/bbliong/aimp.dev/issues"
46
+ },
47
+ "homepage": "https://github.com/bbliong/aimp.dev#readme",
48
+ "dependencies": {
49
+ "ink": "^5.2.1",
50
+ "react": "^18.3.1"
51
+ },
52
+ "devDependencies": {
53
+ "@eslint/js": "^10.0.1",
54
+ "@types/node": "^22.20.2",
55
+ "eslint": "^10.10.0",
56
+ "typescript": "^5.9.3"
57
+ },
58
+ "publishConfig": {
59
+ "access": "public",
60
+ "tag": "beta"
61
+ }
62
+ }
package/src/cli.js ADDED
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env node
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { createInterface } from 'node:readline/promises';
6
+ import { stdin, stdout } from 'node:process';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { performance } from 'node:perf_hooks';
9
+ import { git, text, branch, status, metrics } from './core/git.js';
10
+ import { canonical } from './core/files.js';
11
+ import { loadState, saveState, stateRoot, findMirror, withLock, loadJournal, migrate, newState } from './core/state.js';
12
+ import { policy } from './core/policy.js';
13
+ import { requirePair, changes, preparePlan, readReport, transact, recover, validateRepository } from './core/engine.js';
14
+ import { initialize, recoverInitialization, useBranch } from './core/projects.js';
15
+
16
+ const pkg = JSON.parse(await fs.readFile(new URL('../package.json', import.meta.url), 'utf8'));
17
+ export const originalCommands = ['/init', '/reinit', '/use', '/status', '/diff', '/sync', '/sync-original-to-ai', '/get-summary', '/get-commit-message', '/list', '/log', '/doctor', '/recover', '/migrate', '/adopt-baseline', '/adopt-policy', '/language', '/help', '/exit'];
18
+ export const mirrorCommands = ['/serialize', '/status', '/diff', '/get-summary', '/get-commit-message', '/language', '/help', '/exit'];
19
+ export const safeText = value => String(value).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, c => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`);
20
+ const descriptions = {
21
+ '/init': ['Create an independent mirror for the current branch.', 'Buat mirror independen untuk branch aktif.'],
22
+ '/reinit': ['Replace or relocate the shared mirror, retaining a backup.', 'Ganti atau pindahkan mirror bersama, dengan backup.'],
23
+ '/use': ['Explicitly switch the mirror to the original branch.', 'Pindahkan mirror ke branch original secara eksplisit.'],
24
+ '/status': ['Inspect paths, branches, working changes, and pending commits.', 'Periksa folder, branch, perubahan file, dan commit pending.'],
25
+ '/diff': ['Preview changed paths and text diff; no file writes.', 'Preview path dan diff teks; tanpa menulis file.'],
26
+ '/sync': ['Review AI changes, apply to original, checkpoint AI only.', 'Review perubahan AI, terapkan ke original, checkpoint hanya AI.'],
27
+ '/sync-original-to-ai': ['Refresh the mirror from a clean original.', 'Perbarui mirror dari original yang bersih.'],
28
+ '/serialize': ['Adopt selected changes as a local mirror baseline; no sanitizing.', 'Jadikan perubahan terpilih baseline mirror lokal; tanpa sanitasi.'],
29
+ '/recover': ['Resume an interrupted operation; /recover rollback restores backups.', 'Lanjutkan transaksi terhenti; /recover rollback memulihkan backup.'],
30
+ '/migrate': ['Back up and migrate legacy state, then review the baseline.', 'Backup dan migrasi state lama, lalu review baseline.'],
31
+ '/adopt-baseline': ['Acknowledge the reviewed legacy baseline without discarding changes.', 'Setujui baseline lama yang sudah ditinjau tanpa membuang perubahan.'],
32
+ '/adopt-policy': ['Review and adopt the current .aimpignore policy.', 'Review dan setujui kebijakan .aimpignore saat ini.'],
33
+ '/doctor': ['Check repository shape, registry, Git, branch and recovery readiness.', 'Periksa repo, registry, Git, branch, dan kesiapan recovery.'],
34
+ '/get-summary': ['Read the ready report summary.', 'Baca ringkasan laporan yang ready.'],
35
+ '/get-commit-message': ['Read the proposed commit subject.', 'Baca usulan pesan commit.'],
36
+ '/list': ['List branch pairs and mirror locations.', 'Daftar pasangan branch dan lokasi mirror.'],
37
+ '/log': ['Show recent transaction receipts.', 'Tampilkan riwayat transaksi terbaru.'],
38
+ '/language': ['Set language: /language en|id.', 'Atur bahasa: /language en|id.'],
39
+ '/help': ['Show commands available in this workspace.', 'Tampilkan command yang tersedia di workspace ini.'],
40
+ '/exit': ['Exit and restore the previous terminal screen.', 'Keluar dan kembalikan layar terminal sebelumnya.'],
41
+ };
42
+ export const localized = (language, en, id) => language === 'id' ? id : en;
43
+ export function tokenize(value) {
44
+ const tokens = []; let current = '', quote = null, escaped = false, started = false;
45
+ for (const c of value) {
46
+ if (escaped) { current += c; escaped = false; started = true; }
47
+ else if (c === '\\' && quote !== "'") escaped = true;
48
+ else if (quote) { if (c === quote) quote = null; else current += c; }
49
+ else if (c === '"' || c === "'") { quote = c; started = true; }
50
+ else if (/\s/.test(c)) { if (started) tokens.push(current); current = ''; started = false; }
51
+ else { current += c; started = true; }
52
+ }
53
+ if (quote || escaped) throw new Error('Unclosed quote or escape.');
54
+ if (started) tokens.push(current);
55
+ return tokens;
56
+ }
57
+ export async function pathCompleter(value) {
58
+ const expanded = value.startsWith('~/') ? path.join(os.homedir(), value.slice(2)) : value;
59
+ const slash = expanded.lastIndexOf('/'), dir = slash < 0 ? '.' : expanded.slice(0, slash + 1), prefix = expanded.slice(slash + 1);
60
+ try { const entries = await fs.readdir(dir, { withFileTypes: true }); return [entries.filter(e => e.isDirectory() && e.name.startsWith(prefix)).map(e => `${slash < 0 ? '' : dir}${e.name}/`).sort(), value]; }
61
+ catch { return [[], value]; }
62
+ }
63
+ export async function context(start) {
64
+ if (process.platform !== 'linux') throw new Error('This beta supports Linux and WSL2 Linux filesystems.');
65
+ const root = await canonical(await text(start, ['rev-parse', '--show-toplevel']));
66
+ const original = await findMirror(root);
67
+ return { root, original: original || root, mode: original ? 'mirror' : 'original' };
68
+ }
69
+ export async function inspect(ctx, state) {
70
+ const currentBranch = await branch(ctx.root), stateBranch = ctx.mode === 'mirror' ? currentBranch : await branch(ctx.original);
71
+ const pair = state?.pairs?.[stateBranch];
72
+ const info = { mode: ctx.mode, original: ctx.original, mirror: pair?.mirror || null, branch: stateBranch, mirrorBranch: null, state: 'NOT_INITIALIZED', files: [], originalDirty: false, aiPending: false, committedPaths: 0 };
73
+ if (!pair) return info;
74
+ info.mirrorBranch = await branch(pair.mirror);
75
+ const active = state.pairs[info.mirrorBranch];
76
+ if (!active) { info.state = 'BRANCH_MISMATCH'; return info; }
77
+ const result = await changes(pair.mirror, active, await policy(ctx.original));
78
+ info.files = result.changes.map(e => `${e.kind}\t${JSON.stringify(e.path)}`);
79
+ info.aiPending = result.changes.length > 0; info.committedPaths = result.committed;
80
+ info.originalDirty = (await status(ctx.original)).length > 0;
81
+ info.state = await loadJournal(ctx.original) ? 'RECOVERY_REQUIRED' : info.mirrorBranch !== stateBranch ? 'BRANCH_MISMATCH' : pair.baselineNeedsReview ? 'BASELINE_UNKNOWN' : info.aiPending ? 'AI_PENDING' : 'CLEAN';
82
+ return info;
83
+ }
84
+ export function createSession(ctx, { output = console.log, prompt = async () => { throw new Error('Interactive confirmation requires a TTY.'); }, signal, profile = false } = {}) {
85
+ const emit = value => output(safeText(value));
86
+ async function execute(command) {
87
+ const tokens = Array.isArray(command) ? command : tokenize(command), raw = tokens.shift() || '', name = raw.startsWith('/') ? raw : `/${raw}`;
88
+ const started = performance.now(), calls = metrics.calls, gitTime = metrics.milliseconds;
89
+ let state = await loadState(ctx.original), language = state?.language || 'en';
90
+ const t = (en, id) => localized(language, en, id);
91
+ const confirm = async message => (await prompt(`${message} [y/N] `, { kind: 'confirm' })).trim().toLowerCase() === 'y';
92
+ const allowed = ctx.mode === 'mirror' ? mirrorCommands : originalCommands;
93
+ if (name === '/exit' || name === '/quit') return { exit: true };
94
+ if (name === '/help') { emit(allowed.map(k => `${k.padEnd(24)} ${descriptions[k][language === 'id' ? 1 : 0]}`).join('\n')); return {}; }
95
+ if (!allowed.includes(name)) throw new Error(name === '/resolve' ? 'Automatic merge was removed. Review and edit files manually before /sync.' : `Command unavailable in ${ctx.mode} mode: ${name}`);
96
+ const mutating = ['/init', '/reinit', '/use', '/sync', '/sync-original-to-ai', '/serialize', '/recover', '/migrate', '/adopt-baseline', '/adopt-policy', '/language'].includes(name) && !tokens.includes('--dry-run');
97
+ const run = async () => {
98
+ // Always reload after taking the project lock.
99
+ state = await loadState(ctx.original); language = state?.language || 'en';
100
+ if (mutating && name !== '/recover' && await loadJournal(ctx.original)) throw new Error('RECOVERY_REQUIRED: run /recover.');
101
+ if (state?.schemaVersion === 2 && mutating && !['/migrate', '/recover'].includes(name)) throw new Error('Legacy state detected. Run /migrate first.');
102
+ if (name === '/language') {
103
+ if (!['en', 'id'].includes(tokens[0])) throw new Error('Usage: /language en|id');
104
+ state ||= newState(ctx.original); state.language = tokens[0]; await saveState(ctx.original, state); emit(`Language: ${tokens[0]}`); return { language: tokens[0] };
105
+ }
106
+ if (name === '/migrate') {
107
+ if (!state || state.schemaVersion === 3) { emit(t('State is current.', 'State sudah terbaru.')); return {}; }
108
+ if (!await confirm(t('Back up legacy state (which may contain credentials) and migrate?', 'Backup state lama (mungkin berisi credential) lalu migrasikan?'))) return {};
109
+ const result = await migrate(ctx.original, state); emit(`Backup: ${result.backup}\n/adopt-baseline`); return {};
110
+ }
111
+ if (name === '/recover') {
112
+ const tx = await loadJournal(ctx.original); if (!tx) { emit(t('No recovery needed.', 'Tidak ada recovery.')); return {}; }
113
+ if (!await confirm(t(`Recover transaction ${tx.id} (${tokens[0] || 'resume'})?`, `Pulihkan transaksi ${tx.id} (${tokens[0] || 'resume'})?`))) return {};
114
+ if (tx.kind === 'initialize') await recoverInitialization(ctx.original, tx); else await recover(ctx.original, tokens[0]);
115
+ emit(t('Recovery complete.', 'Recovery selesai.')); return {};
116
+ }
117
+ if (name === '/init' || name === '/reinit') {
118
+ state ||= newState(ctx.original);
119
+ const b = await branch(ctx.original), pair = state.pairs[b];
120
+ if (pair && name === '/init') { await useBranch(ctx.original, state); emit(`Mirror: ${pair.mirror}`); return {}; }
121
+ const existing = Object.values(state.pairs)[0];
122
+ const fallback = existing?.mirror || path.resolve(ctx.original, '..', `${path.basename(ctx.original)}-ai`);
123
+ const requested = tokens[0] || (await prompt(t(`Mirror folder [${fallback}]: `, `Folder mirror [${fallback}]: `), { kind: 'path' })).trim() || fallback;
124
+ const destination = requested.startsWith('~/') ? path.join(os.homedir(), requested.slice(2)) : requested;
125
+ const created = await initialize(ctx.original, state, destination, { confirm, replacing: name === '/reinit', signal });
126
+ if (created) emit(`Mirror: ${created.mirror}${created.backup ? `\nBackup: ${created.backup}` : ''}`);
127
+ return {};
128
+ }
129
+ if (name === '/status') {
130
+ const info = await inspect(ctx, state);
131
+ emit(tokens.includes('--json') ? JSON.stringify(info) : `Original: ${info.original}\nBranch: ${info.branch}\nAI copy: ${info.mirror || '—'}\nAI branch: ${info.mirrorBranch || '—'}\nOriginal dirty: ${info.originalDirty}\nAI pending: ${info.aiPending}\nCommitted paths since baseline: ${info.committedPaths}\nState: ${info.state}\n${info.files.join('\n')}`);
132
+ return { monitor: info };
133
+ }
134
+ if (name === '/doctor') {
135
+ const checks = { node: process.version, git: await text(ctx.root, ['--version']), stateRoot: stateRoot(), schema: state?.schemaVersion || null, platform: process.platform, sandbox: false };
136
+ try { await validateRepository(ctx.original); checks.original = 'ok'; if (state && Object.keys(state.pairs).length) await requirePair(ctx.original, state); checks.pair = 'ok'; } catch (e) { checks.problem = e.message; }
137
+ checks.recoveryRequired = Boolean(await loadJournal(ctx.original)); emit(JSON.stringify(checks, null, 2)); return {};
138
+ }
139
+ if (name === '/list') { emit(Object.entries(state?.pairs || {}).map(([b, p]) => `${b}\t${p.mirror}`).join('\n') || t('No mirrors.', 'Belum ada mirror.')); return {}; }
140
+ if (name === '/log') { emit(JSON.stringify(state?.history || [], null, 2)); return {}; }
141
+ if (name === '/use') { const pair = await useBranch(ctx.original, state); emit(`${pair.branch} → ${pair.mirror}`); return {}; }
142
+ const b = await branch(ctx.root), pair = state?.pairs?.[b];
143
+ if (!pair) throw new Error('Branch is not initialized. Run /init.');
144
+ if (name === '/adopt-baseline') {
145
+ await requirePair(ctx.original, state);
146
+ emit(`Original baseline: ${pair.baselineOriginal}\nMirror baseline: ${pair.baselineAi}`);
147
+ if (!await confirm(t('I reviewed these baselines; keep pending changes and enable sync?', 'Saya sudah memeriksa baseline; pertahankan perubahan pending dan aktifkan sync?'))) return {};
148
+ pair.baselineNeedsReview = false; pair.policyHash = (await policy(ctx.original)).hash; await saveState(ctx.original, state); return {};
149
+ }
150
+ if (name === '/adopt-policy') {
151
+ const rules = await policy(ctx.original);
152
+ emit(rules.content.toString('utf8') || '(empty .aimpignore)');
153
+ if (!await confirm(t('Adopt this policy? Re-included files require explicit reinit to restore skipped historical changes.', 'Setujui policy ini? File yang dimasukkan kembali membutuhkan reinit untuk perubahan historis yang pernah dilewati.'))) return {};
154
+ pair.policyHash = rules.hash; await saveState(ctx.original, state); return {};
155
+ }
156
+ if (name === '/get-summary' || name === '/get-commit-message') { const report = await readReport(pair.mirror, pair, state.projectId); emit(name === '/get-summary' ? report.summary : report.message); return {}; }
157
+ if (name === '/diff') {
158
+ const result = await changes(pair.mirror, pair, await policy(ctx.original));
159
+ emit(result.changes.map(e => `${e.kind}\t${JSON.stringify(e.path)}`).join('\n') || t('No changes.', 'Tidak ada perubahan.'));
160
+ for (const entry of result.changes) {
161
+ if ((entry.after?.size || 0) > 1024 * 1024) { emit(`${JSON.stringify(entry.path)}: large file; review externally.`); continue; }
162
+ emit((await git(pair.mirror, ['diff', '--no-ext-diff', '--no-textconv', pair.baselineAi, '--', entry.path])).stdout.toString('utf8'));
163
+ }
164
+ return {};
165
+ }
166
+ if (name === '/serialize') {
167
+ if (ctx.mode !== 'mirror') throw new Error('/serialize is only available in the mirror.');
168
+ const detected = await changes(pair.mirror, pair, await policy(ctx.original));
169
+ let selected = detected.changes;
170
+ emit(selected.map((f, i) => `${i + 1}. ${f.kind}\t${JSON.stringify(f.path)}`).join('\n'));
171
+ if (!selected.length) { emit(t('No changes.', 'Tidak ada perubahan.')); return {}; }
172
+ const selection = tokens.length ? tokens : tokenize(await prompt(t('Select exact paths (quote spaces), or * for all: ', 'Pilih path tepat (kutip spasi), atau * untuk semua: '), { kind: 'text' }));
173
+ if (!selection.includes('*')) { const wanted = new Set(selection); if ([...wanted].some(p => !selected.some(f => f.path === p))) throw new Error('Selection contains an unchanged or excluded path.'); selected = selected.filter(f => wanted.has(f.path)); }
174
+ if (!selected.length) return {};
175
+ if (!await confirm(t('Adopt these paths locally? This does not protect them from future refresh; use .aimpignore for that.', 'Jadikan path ini baseline lokal? Untuk melindunginya dari refresh berikutnya, gunakan .aimpignore.'))) return {};
176
+ const plan = await preparePlan(ctx.original, state);
177
+ plan.changes = selected.map(f => ({ ...f, before: f.after })); plan.direction = 'serialize'; plan.source = pair.mirror; plan.target = pair.mirror;
178
+ await transact(state, plan, `chore(aimp): serialize ${pair.branch}`, { signal }); emit(t('Local baseline adopted.', 'Baseline lokal diperbarui.')); return {};
179
+ }
180
+ if (name === '/sync' || name === '/sync-original-to-ai') {
181
+ const direction = name === '/sync' ? 'ai-to-original' : 'original-to-ai';
182
+ const plan = await preparePlan(ctx.original, state, direction);
183
+ if (tokens.includes('--dry-run')) { emit(JSON.stringify(plan, null, 2)); return { plan }; }
184
+ if (!plan.changes.length) { emit(t('No changes to sync.', 'Tidak ada perubahan untuk sync.')); return {}; }
185
+ const report = direction === 'ai-to-original' ? await readReport(pair.mirror, pair, state.projectId) : null;
186
+ const message = report ? `${report.message} (synced)` : `chore(aimp): refresh ${pair.branch}`;
187
+ if (report) emit(report.summary);
188
+ emit(plan.changes.map(f => `${f.kind}\t${JSON.stringify(f.path)}`).join('\n'));
189
+ const dirtyPaths = new Set((await status(ctx.original)).map(f => f.path));
190
+ const overlap = plan.changes.filter(f => dirtyPaths.has(f.path));
191
+ if (overlap.length) { emit(overlap.map(f => JSON.stringify(f.path)).join('\n')); if (!await confirm(t('Overwrite these uncommitted original paths?', 'Timpa path original di atas yang belum di-commit?'))) return {}; }
192
+ if (plan.changes.some(f => !f.after) && !await confirm(t('Approve the listed deletions?', 'Setujui penghapusan yang ditampilkan?'))) return {};
193
+ if (!await confirm(t('Apply these files and create a local AI checkpoint? Original remains uncommitted.', 'Terapkan file ini dan buat checkpoint lokal AI? Original tetap tanpa commit.'))) return {};
194
+ if (report && (await readReport(pair.mirror, pair, state.projectId)).hash !== report.hash) throw new Error('Report changed after preview.');
195
+ const result = await transact(state, plan, message, { signal }); emit(`Checkpoint AI: ${result.aiCommit}`); return {};
196
+ }
197
+ return {};
198
+ };
199
+ try { return mutating ? await withLock(ctx.original, run) : await run(); }
200
+ finally { if (profile) emit(JSON.stringify({ profile: name, machineAndPromptMs: Math.round(performance.now() - started), gitCalls: metrics.calls - calls, gitMs: Math.round(metrics.milliseconds - gitTime) })); }
201
+ }
202
+ return { execute, context: ctx, commands: ctx.mode === 'mirror' ? mirrorCommands : originalCommands };
203
+ }
204
+ export async function main(argv = process.argv.slice(2)) {
205
+ if (argv.includes('--version') || argv.includes('-v')) { console.log(`aimp ${pkg.version}`); return; }
206
+ if (argv.includes('--help') || argv.includes('-h')) {
207
+ console.log('aimp — manual AI project mirror\nUsage: aimp [--plain] [--profile]\n aimp status --json\n aimp sync --dry-run\n\n' + originalCommands.map(c => `${c.padEnd(24)} ${descriptions[c][0]}`).join('\n')); return;
208
+ }
209
+ const ctx = await context(process.cwd()), plain = argv.includes('--plain') || !stdin.isTTY || !stdout.isTTY, profile = argv.includes('--profile');
210
+ const args = argv.filter(a => !['--plain', '--profile'].includes(a));
211
+ if (!plain && !args.length) { const { runInk } = await import('./ui.js'); await runInk({ ctx, profile }); return; }
212
+ const rl = stdin.isTTY ? createInterface({ input: stdin, output: stdout }) : null;
213
+ const controller = new AbortController(); const stop = () => controller.abort(); process.on('SIGINT', stop);
214
+ const session = createSession(ctx, { profile, signal: controller.signal, prompt: rl ? p => rl.question(p, { signal: controller.signal }) : undefined });
215
+ try {
216
+ if (args.length) { await session.execute(args); return; }
217
+ if (!rl) throw new Error('Use a command in non-interactive mode, e.g. aimp status --json.');
218
+ while (!controller.signal.aborted) {
219
+ const command = await rl.question(ctx.mode === 'mirror' ? 'aimp-ai> ' : 'aimp> ', { signal: controller.signal });
220
+ try { if ((await session.execute(command)).exit) break; } catch (e) { console.error(safeText(e.message)); }
221
+ }
222
+ } finally { rl?.close(); process.off('SIGINT', stop); }
223
+ }
224
+ export { loadState };
225
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch(e => { console.error(safeText(e.message)); process.exitCode = 1; });
@@ -0,0 +1,35 @@
1
+ // @ts-check
2
+
3
+ /** @typedef {{hash:string, oid:string, size:number, mode:number}} Fingerprint */
4
+ /** @typedef {{path:string, kind:string, after:Fingerprint|null, before:Fingerprint|null}} Change */
5
+ /** @typedef {{version:3, id:string, root:string, mirror:string, source:string, target:string,
6
+ * branch:string, direction:'ai-to-original'|'original-to-ai'|'serialize', originalHead:string,
7
+ * originalIndex:string|null, mirrorHead:string, mirrorIndex:string|null, policyHash:string,
8
+ * changes:Change[]}} Plan */
9
+
10
+ /** @param {unknown} value @returns {value is Fingerprint|null} */
11
+ function validFingerprint(value) {
12
+ if (value === null) return true;
13
+ if (typeof value !== 'object' || !value) return false;
14
+ const f = /** @type {Record<string, unknown>} */ (value);
15
+ return typeof f.hash === 'string' && /^[a-f0-9]{64}$/.test(f.hash) && typeof f.oid === 'string' && /^[a-f0-9]{40}$/.test(f.oid)
16
+ && typeof f.mode === 'number' && Number.isInteger(f.mode) && f.mode >= 0 && f.mode <= 0o777
17
+ && typeof f.size === 'number' && Number.isSafeInteger(f.size) && f.size >= 0;
18
+ }
19
+ /** Validate disk data before recovery performs any mutations.
20
+ * @param {unknown} input @returns {asserts input is Plan}
21
+ */
22
+ export function validatePlan(input) {
23
+ if (typeof input !== 'object' || input === null) throw new Error('Invalid transaction plan.');
24
+ const p = /** @type {Record<string, unknown>} */ (input);
25
+ if (p.version !== 3 || typeof p.id !== 'string' || !/^[a-f0-9-]{36}$/.test(p.id)
26
+ || !['ai-to-original', 'original-to-ai', 'serialize'].includes(String(p.direction))
27
+ || ['root','mirror','source','target','branch'].some(k => typeof p[k] !== 'string' || !p[k])
28
+ || ['originalHead','mirrorHead'].some(k => !/^[a-f0-9]{40}$/.test(String(p[k])))
29
+ || !Array.isArray(p.changes)) throw new Error('Invalid transaction plan.');
30
+ const seen = new Set();
31
+ for (const file of p.changes) {
32
+ if (!file || typeof file.path !== 'string' || seen.has(file.path) || !validFingerprint(file.after) || !validFingerprint(file.before)) throw new Error('Invalid transaction file entry.');
33
+ seen.add(file.path);
34
+ }
35
+ }
@@ -0,0 +1,240 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { git, text, head, branch, status, names, tree, guardRepo } from './git.js';
5
+ import { fingerprint, equal, gitMode, safePath, present, hash, copy, atomicJson, syncDirectory, canonical, overlaps } from './files.js';
6
+ import { validatePlan } from './contracts.js';
7
+ import { policy, included, guardAttributes } from './policy.js';
8
+ import { stateRoot, saveJournal, loadJournal, clearJournal, saveState } from './state.js';
9
+
10
+ export const REPORT = 'AIMP_REPORT.md';
11
+ export const RULES = 'AGENTS-AIMP.md';
12
+ export async function validateRepository(root) {
13
+ if (await canonical(root) !== root || !(await present(path.join(root, '.git')))?.isDirectory()) throw new Error('A regular repository with a canonical root is required.');
14
+ for (const name of ['rebase-apply', 'rebase-merge', 'sequencer', 'worktrees', 'objects/info/alternates']) if (await present(path.join(root, '.git', name))) throw new Error(`Unsupported Git repository state: ${name}`);
15
+ await guardRepo(root);
16
+ }
17
+ export async function requirePair(root, state) {
18
+ await validateRepository(root);
19
+ const name = await branch(root), pair = state?.pairs?.[name];
20
+ if (!pair) throw new Error('Branch is not initialized. Run /init.');
21
+ if (overlaps(root, pair.mirror)) throw new Error('Original and mirror roots overlap.');
22
+ await validateRepository(pair.mirror);
23
+ if (await branch(pair.mirror) !== name) throw new Error('BRANCH_MISMATCH: run /use after resolving pending mirror work.');
24
+ const exists = await git(pair.mirror, ['cat-file', '-e', `${pair.baselineAi}^{commit}`], { allowFailure: true });
25
+ if (exists.code !== 0 || (await git(pair.mirror, ['merge-base', '--is-ancestor', pair.baselineAi, 'HEAD'], { allowFailure: true })).code !== 0) throw new Error('BASELINE_UNKNOWN: mirror history changed; review and reinitialize explicitly.');
26
+ return pair;
27
+ }
28
+ export async function changes(root, pair, rules) {
29
+ const records = await status(root);
30
+ const byPath = new Map(records.map(record => [record.path, record]));
31
+ if (records.some(r => /U/.test(r.code) || ['AA', 'DD'].includes(r.code))) throw new Error('Resolve Git index conflicts first.');
32
+ const committed = await names(root, ['diff', '--no-ext-diff', '--no-renames', '--name-only', '-z', pair.baselineAi, 'HEAD']);
33
+ const candidates = await included([...committed, ...records.map(r => r.path), ...Object.keys(pair.acknowledgements || {})], rules);
34
+ const base = await tree(root, pair.baselineAi), result = [];
35
+ await guardAttributes(root, candidates);
36
+ for (const rel of candidates) {
37
+ const st = byPath.get(rel);
38
+ if (st && st.code[0] !== ' ' && st.code[0] !== '?' && st.code[1] !== ' ') throw new Error(`Partially staged path: ${JSON.stringify(rel)}. Stage or unstage it completely first.`);
39
+ const current = await fingerprint(root, rel);
40
+ const previous = Object.hasOwn(pair.acknowledgements || {}, rel) ? pair.acknowledgements[rel] : base.get(rel);
41
+ if (!current && !previous) continue;
42
+ if (current && previous && current.oid === previous.oid && gitMode(current) === previous.mode) continue;
43
+ if (previous?.type === 'commit') throw new Error('Submodules are not supported.');
44
+ result.push({ path: rel, after: current, kind: !current ? 'deleted' : !previous ? 'added' : 'modified' });
45
+ }
46
+ return { changes: result, records, committed: committed.length };
47
+ }
48
+ export function reportTemplate(id, name, batch) {
49
+ return `Mirror-ID: ${id}\nBranch: ${name}\nBatch-ID: ${batch}\nStatus: draft\n\n## Summary\n<!-- Describe the changes. -->\n\n## Commit Message\n<!-- One-line subject. -->\n\n## Tests\n<!-- Commands and results, or Not run: reason. -->\n\n## Notes\n<!-- Limitations, or None. -->\n`;
50
+ }
51
+ export async function writeMetadata(mirror, id, pair) {
52
+ const values = {
53
+ [REPORT]: reportTemplate(id, pair.branch, pair.batch),
54
+ [RULES]: `# AIMP workspace\n\nAIMP (AI Mirror Project) is a local tool for reviewing and manually synchronizing a separate AI workspace. This is the mirror for branch ${pair.branch}, not the original project.\n\n- Read and edit only this workspace. Do not access the original, parent directories, or credential stores.\n- These instructions are advisory, not an OS sandbox. Load this file manually if your harness does not discover it.\n- The user runs the original application and shares errors manually.\n- Do not push, add remotes, switch branches, reset history, or run synchronization commands.\n- Keep user placeholders intact. AIMP does not sanitize credentials automatically.\n- Complete AIMP_REPORT.md and set Status: ready after each batch. Report tests honestly.\n`,
55
+ };
56
+ for (const [rel, content] of Object.entries(values)) {
57
+ const target = await safePath(mirror, rel), tmp = `${target}.${crypto.randomUUID()}.tmp`;
58
+ await fs.writeFile(tmp, content, { mode: 0o644, flag: 'wx' }); await fs.rename(tmp, target);
59
+ }
60
+ }
61
+ export async function readReport(mirror, pair, id) {
62
+ const file = await safePath(mirror, REPORT);
63
+ if ((await fs.stat(file)).size > 1024 * 1024) throw new Error('Report is too large.');
64
+ const raw = await fs.readFile(file, 'utf8');
65
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(raw)) throw new Error('Report contains control characters.');
66
+ const field = name => raw.match(new RegExp(`^${name}: *(.*)$`, 'm'))?.[1].trim();
67
+ if (field('Mirror-ID') !== id || field('Branch') !== pair.branch || field('Batch-ID') !== pair.batch) throw new Error('Report identity does not match the active branch/batch.');
68
+ const sections = {};
69
+ for (const match of raw.matchAll(/^## (Summary|Commit Message|Tests|Notes)\s*\n([\s\S]*?)(?=^## |$(?![\s\S]))/gm)) sections[match[1]] = match[2].trim();
70
+ if (field('Status') !== 'ready' || ['Summary', 'Commit Message', 'Tests', 'Notes'].some(k => !sections[k] || sections[k].includes('<!--'))) throw new Error('Complete Summary, Commit Message, Tests and Notes, then set Status: ready.');
71
+ const message = sections['Commit Message'];
72
+ if (message.includes('\n') || message.length > 120) throw new Error('Commit Message must be one line, at most 120 characters.');
73
+ return { raw, hash: hash(raw), message, summary: sections.Summary };
74
+ }
75
+ export async function indexFingerprint(root) {
76
+ const p = path.join(root, '.git/index');
77
+ const st = await present(p);
78
+ if (!st) return null;
79
+ if (!st.isFile() || st.nlink !== 1) throw new Error('Unsupported Git index.');
80
+ return hash(await fs.readFile(p));
81
+ }
82
+ async function updateIndex(root, index, entries) {
83
+ const zero = '0'.repeat(40);
84
+ const input = entries.map(e => e.after ? `${gitMode(e.after)} ${e.after.oid}\t${e.path}\0` : `0 ${zero}\t${e.path}\0`).join('');
85
+ if (input) await git(root, ['update-index', '-z', '--index-info'], { env: { GIT_INDEX_FILE: index }, input });
86
+ }
87
+ export async function createCheckpoint(root, parent, entries, directory, message) {
88
+ const index = path.join(directory, 'checkpoint-index');
89
+ const env = { GIT_INDEX_FILE: index, GIT_AUTHOR_NAME: 'aimp', GIT_AUTHOR_EMAIL: 'aimp@local.invalid', GIT_COMMITTER_NAME: 'aimp', GIT_COMMITTER_EMAIL: 'aimp@local.invalid' };
90
+ await git(root, parent ? ['read-tree', parent] : ['read-tree', '--empty'], { env });
91
+ for (let i = 0; i < entries.length; i++) if (entries[i].after) {
92
+ const oid = await text(root, ['hash-object', '-w', '--no-filters', '--', path.join(directory, 'payload', String(i))]);
93
+ if (oid !== entries[i].after.oid) throw new Error('Payload checksum mismatch.');
94
+ }
95
+ await updateIndex(root, index, entries);
96
+ const treeId = await text(root, ['write-tree'], { env });
97
+ return text(root, ['commit-tree', treeId, ...(parent ? ['-p', parent] : [])], { env, input: `${message}\n` });
98
+ }
99
+ async function replaceIndex(root, tx, dir) {
100
+ if (await indexFingerprint(root) === tx.finalIndexHash) return;
101
+ const guard = path.join(root, '.git/index.lock'), handle = await fs.open(guard, 'wx', 0o600);
102
+ let renamed = false;
103
+ try {
104
+ if (await indexFingerprint(root) !== tx.mirrorIndex) throw new Error('Mirror index changed; recovery requires manual review.');
105
+ await handle.writeFile(await fs.readFile(path.join(dir, 'final-index'))); await handle.sync(); await handle.close();
106
+ await fs.rename(guard, path.join(root, '.git/index')); renamed = true;
107
+ } finally { if (!renamed) { await handle.close().catch(() => {}); await fs.rm(guard, { force: true }); } }
108
+ }
109
+ export async function preparePlan(root, state, direction = 'ai-to-original') {
110
+ const pair = await requirePair(root, state), rules = await policy(root);
111
+ if (pair.baselineNeedsReview) throw new Error('Migrated baseline needs review. Run /adopt-baseline after inspecting both repositories.');
112
+ if (pair.policyHash && pair.policyHash !== rules.hash) throw new Error('Ignore policy changed. Run /adopt-policy to review and accept it first.');
113
+ const detected = await changes(pair.mirror, pair, rules);
114
+ let files = detected.changes;
115
+ if (direction === 'original-to-ai') {
116
+ if ((await status(root)).length) throw new Error('Original has uncommitted changes. Commit or resolve them before reverse sync.');
117
+ if (files.length) throw new Error('Mirror has pending work. Sync or serialize it first.');
118
+ const originalBase = pair.baselineOriginal;
119
+ if (!originalBase || (await git(root, ['merge-base', '--is-ancestor', originalBase, 'HEAD'], { allowFailure: true })).code !== 0) throw new Error('Original baseline history changed. Reinitialize after review.');
120
+ const candidates = await included([...await names(root, ['diff', '--no-ext-diff', '--no-renames', '--name-only', '-z', originalBase, 'HEAD']), ...Object.keys(pair.applied || {})], rules);
121
+ await guardAttributes(root, candidates);
122
+ files = [];
123
+ for (const rel of candidates) {
124
+ const after = await fingerprint(root, rel), before = await fingerprint(pair.mirror, rel);
125
+ if (!equal(after, before)) files.push({ path: rel, after, kind: !after ? 'deleted' : !before ? 'added' : 'modified' });
126
+ }
127
+ }
128
+ const source = direction === 'ai-to-original' ? pair.mirror : root;
129
+ const target = direction === 'ai-to-original' ? root : pair.mirror;
130
+ for (const file of files) file.before = await fingerprint(target, file.path);
131
+ return { version: 3, id: crypto.randomUUID(), direction, root, source, target, branch: pair.branch,
132
+ mirror: pair.mirror, originalHead: await head(root), originalIndex: await indexFingerprint(root),
133
+ mirrorHead: await head(pair.mirror), mirrorIndex: await indexFingerprint(pair.mirror), policyHash: rules.hash, changes: files };
134
+ }
135
+ async function verifyPlan(tx) {
136
+ if (await branch(tx.root) !== tx.branch || await branch(tx.mirror) !== tx.branch || await head(tx.root) !== tx.originalHead || await head(tx.mirror) !== tx.mirrorHead || await indexFingerprint(tx.root) !== tx.originalIndex || await indexFingerprint(tx.mirror) !== tx.mirrorIndex || (await policy(tx.root)).hash !== tx.policyHash) throw new Error('Repository or policy changed after preview. Review a new plan.');
137
+ for (const file of tx.changes) {
138
+ if (!equal(await fingerprint(tx.source, file.path), file.after) || !equal(await fingerprint(tx.target, file.path), file.before)) throw new Error(`File changed after preview: ${JSON.stringify(file.path)}`);
139
+ }
140
+ }
141
+ export async function transact(state, plan, message, { fault = async () => {}, signal } = {}) {
142
+ validatePlan(plan);
143
+ if (await loadJournal(plan.root)) throw new Error('RECOVERY_REQUIRED: run /recover before another mutation.');
144
+ await verifyPlan(plan);
145
+ if (signal?.aborted) throw new Error('Cancelled.');
146
+ const dir = path.join(stateRoot(), 'transactions', plan.id);
147
+ await fs.mkdir(path.join(dir, 'payload'), { recursive: true, mode: 0o700 });
148
+ await fs.mkdir(path.join(dir, 'backup'), { recursive: true, mode: 0o700 });
149
+ const tx = { ...plan, stage: 'PREPARED', message, createdAt: new Date().toISOString() };
150
+ let journaled = false;
151
+ try {
152
+ for (let i = 0; i < tx.changes.length; i++) {
153
+ const file = tx.changes[i];
154
+ if (file.after) await copy(tx.source, file.path, dir, `payload/${i}`, file.after.mode);
155
+ if (file.before) await copy(tx.target, file.path, dir, `backup/${i}`, file.before.mode);
156
+ if (!equal(file.after, await fingerprint(dir, `payload/${i}`)) || !equal(file.before, await fingerprint(dir, `backup/${i}`))) throw new Error('Files changed while preparing transaction.');
157
+ }
158
+ await fault('CHECKPOINT_PREPARE');
159
+ tx.aiCommit = await createCheckpoint(tx.mirror, tx.mirrorHead, tx.changes, dir, message);
160
+ const finalIndex = path.join(dir, 'final-index');
161
+ await fs.copyFile(path.join(tx.mirror, '.git/index'), finalIndex);
162
+ await updateIndex(tx.mirror, finalIndex, tx.changes); tx.finalIndexHash = hash(await fs.readFile(finalIndex));
163
+ const next = structuredClone(state), pair = next.pairs[tx.branch];
164
+ pair.batch = crypto.randomUUID(); pair.policyHash = tx.policyHash;
165
+ if (tx.direction === 'serialize') {
166
+ for (const file of tx.changes) (pair.acknowledgements ||= {})[file.path] = file.after ? { oid: file.after.oid, mode: gitMode(file.after), type: 'blob' } : null;
167
+ } else {
168
+ pair.baselineAi = tx.aiCommit; pair.baselineOriginal = tx.originalHead;
169
+ for (const file of tx.changes) { delete (pair.acknowledgements ||= {})[file.path]; (pair.applied ||= {})[file.path] = file.after; }
170
+ }
171
+ next.history ||= []; next.history.push({ id: tx.id, branch: tx.branch, direction: tx.direction, ai: tx.aiCommit, originalBase: tx.originalHead, originalCommitted: false, message, at: tx.createdAt });
172
+ // Archive overflow independently, retaining a bounded active registry.
173
+ if (next.history.length > 200) { await atomicJson(path.join(dir, 'history-archive.json'), next.history.slice(0, -200)); next.history = next.history.slice(-200); }
174
+ tx.nextState = next;
175
+ await verifyPlan(tx); if (signal?.aborted) throw new Error('Cancelled.');
176
+ await saveJournal(tx.root, tx); journaled = true; await fault('PREPARED');
177
+ for (let i = 0; i < tx.changes.length; i++) {
178
+ if (signal?.aborted) throw new Error('Cancelled. Run /recover.');
179
+ const file = tx.changes[i];
180
+ if (!equal(await fingerprint(tx.target, file.path), file.before)) throw new Error('Target changed during apply.');
181
+ if (file.after) await copy(dir, `payload/${i}`, tx.target, file.path, file.after.mode);
182
+ else { await fs.rm(await safePath(tx.target, file.path), { force: true }); await syncDirectory(path.dirname(path.join(tx.target, file.path))); }
183
+ await fault(`FILE_${i}`);
184
+ }
185
+ tx.stage = 'ORIGINAL_APPLIED'; await saveJournal(tx.root, tx); await fault(tx.stage);
186
+ await finalize(tx, dir, fault);
187
+ return tx;
188
+ } catch (error) {
189
+ if (!journaled) await fs.rm(dir, { recursive: true, force: true });
190
+ throw error;
191
+ }
192
+ }
193
+ async function finalize(tx, dir, fault = async () => {}) {
194
+ if (await branch(tx.root) !== tx.branch || await branch(tx.mirror) !== tx.branch || await head(tx.root) !== tx.originalHead || await indexFingerprint(tx.root) !== tx.originalIndex) throw new Error('Original Git state changed; review recovery.');
195
+ for (const file of tx.changes) if (!equal(await fingerprint(tx.target, file.path), file.after)) throw new Error('Applied files changed; review recovery before finalizing.');
196
+ const current = await head(tx.mirror);
197
+ if (current !== tx.aiCommit) {
198
+ if (current !== tx.mirrorHead || await indexFingerprint(tx.mirror) !== tx.mirrorIndex) throw new Error('Mirror HEAD/index changed.');
199
+ await git(tx.mirror, ['update-ref', `refs/heads/${tx.branch}`, tx.aiCommit, tx.mirrorHead]);
200
+ }
201
+ await fault('REF_UPDATED'); await replaceIndex(tx.mirror, tx, dir);
202
+ tx.stage = 'AI_CHECKPOINTED'; await saveJournal(tx.root, tx); await fault(tx.stage);
203
+ await saveState(tx.root, tx.nextState); await fault('STATE_SAVED');
204
+ await writeMetadata(tx.mirror, tx.nextState.projectId, tx.nextState.pairs[tx.branch]);
205
+ tx.stage = 'FINALIZED'; await saveJournal(tx.root, tx); await fault(tx.stage);
206
+ await clearJournal(tx.root);
207
+ // Retain only a compact receipt and any history archive after successful finalization.
208
+ await atomicJson(path.join(dir, 'receipt.json'), { id: tx.id, message: tx.message, aiCommit: tx.aiCommit, direction: tx.direction });
209
+ for (const name of ['payload', 'backup', 'checkpoint-index', 'final-index']) await fs.rm(path.join(dir, name), { recursive: true, force: true });
210
+ }
211
+ export async function recover(root, action = 'resume', options = {}) {
212
+ const tx = await loadJournal(root);
213
+ if (!tx) return null;
214
+ if (tx.version !== 3 || tx.root !== root || !/^[a-f0-9-]{36}$/.test(tx.id)) throw new Error('Legacy/invalid journal: automatic recovery is unavailable. Preserve backups for manual recovery.');
215
+ validatePlan(tx);
216
+ if (overlaps(root, tx.mirror) || tx.source !== (tx.direction === 'original-to-ai' ? root : tx.mirror) || tx.target !== (tx.direction === 'ai-to-original' ? root : tx.mirror)) throw new Error('Invalid recovery roots.');
217
+ if (await branch(root) !== tx.branch || await head(root) !== tx.originalHead || await indexFingerprint(root) !== tx.originalIndex || await branch(tx.mirror) !== tx.branch) throw new Error('Git state changed since interruption. Review recovery.');
218
+ if (!['resume', 'rollback'].includes(action)) throw new Error('Usage: /recover [resume|rollback]');
219
+ await validateRepository(root); await validateRepository(tx.mirror);
220
+ const dir = path.join(stateRoot(), 'transactions', tx.id);
221
+ if (action === 'rollback') {
222
+ if (await head(tx.mirror) !== tx.mirrorHead) throw new Error('Checkpoint already exists. Resume recovery instead of rolling back Git history.');
223
+ if (await branch(root) !== tx.branch || await head(root) !== tx.originalHead || await indexFingerprint(root) !== tx.originalIndex) throw new Error('Original Git state changed since the transaction.');
224
+ for (const file of tx.changes) { const actual = await fingerprint(tx.target, file.path); if (!equal(actual, file.before) && !equal(actual, file.after)) throw new Error(`New edits after interruption: ${JSON.stringify(file.path)}`); }
225
+ for (let i = 0; i < tx.changes.length; i++) {
226
+ const file = tx.changes[i];
227
+ if (file.before) { if (!equal(await fingerprint(dir, `backup/${i}`), file.before)) throw new Error('Backup checksum mismatch.'); await copy(dir, `backup/${i}`, tx.target, file.path, file.before.mode); }
228
+ else await fs.rm(await safePath(tx.target, file.path), { force: true });
229
+ }
230
+ await clearJournal(root); return { ...tx, rolledBack: true };
231
+ }
232
+ for (let i = 0; i < tx.changes.length; i++) {
233
+ const file = tx.changes[i], actual = await fingerprint(tx.target, file.path);
234
+ if (equal(actual, file.after)) continue;
235
+ if (!equal(actual, file.before)) throw new Error(`New edits after interruption: ${JSON.stringify(file.path)}`);
236
+ if (file.after) { if (!equal(await fingerprint(dir, `payload/${i}`), file.after)) throw new Error('Payload checksum mismatch.'); await copy(dir, `payload/${i}`, tx.target, file.path, file.after.mode); }
237
+ else await fs.rm(await safePath(tx.target, file.path), { force: true });
238
+ }
239
+ await finalize(tx, dir, options.fault); return tx;
240
+ }
@@ -0,0 +1,81 @@
1
+ import { promises as fs, constants } from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import crypto from 'node:crypto';
5
+
6
+ export const MANAGED = new Set(['AIMP_REPORT.md', 'AGENTS-AIMP.md', 'AGENTS.md', '.aimpignore']);
7
+ export const hash = data => crypto.createHash('sha256').update(data).digest('hex');
8
+ export const present = async file => { try { return await fs.lstat(file); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } };
9
+ export function validatePath(rel) {
10
+ const parts = rel.split('/');
11
+ if (!rel || path.isAbsolute(rel) || rel.includes('\0') || rel.includes('\\') || parts.some(p => !p || p === '.' || p === '..' || ['.git', '.aimp'].includes(p.toLowerCase()))) throw new Error(`Unsafe path: ${JSON.stringify(rel)}`);
12
+ return rel;
13
+ }
14
+ export async function safePath(root, rel) {
15
+ validatePath(rel);
16
+ let current = root;
17
+ const parts = rel.split('/');
18
+ for (let i = 0; i < parts.length; i++) {
19
+ current = path.join(current, parts[i]);
20
+ const st = await present(current);
21
+ if (!st) continue;
22
+ if (st.isSymbolicLink() || (st.isFile() && st.nlink > 1)) throw new Error(`Link is not supported: ${JSON.stringify(rel)}`);
23
+ if (i < parts.length - 1) {
24
+ if (!st.isDirectory()) throw new Error(`Parent is not a directory: ${JSON.stringify(rel)}`);
25
+ if (await present(path.join(current, '.git'))) throw new Error(`Nested repository: ${JSON.stringify(rel)}`);
26
+ } else if (!st.isFile()) throw new Error(`Not a regular file: ${JSON.stringify(rel)}`);
27
+ }
28
+ return current;
29
+ }
30
+ export async function canonical(target) {
31
+ const absolute = path.resolve(target), st = await present(absolute);
32
+ if (st) { if (st.isSymbolicLink()) throw new Error('Root must not be a symlink.'); return fs.realpath(absolute); }
33
+ return path.join(await canonical(path.dirname(absolute)), path.basename(absolute));
34
+ }
35
+ export const overlaps = (a, b) => a === b || a.startsWith(b + path.sep) || b.startsWith(a + path.sep);
36
+ export async function mirrorTarget(original, requested, stateRoot) {
37
+ const target = await canonical(requested), home = await fs.realpath(os.homedir());
38
+ if (overlaps(original, target) || overlaps(stateRoot, target) || target === '/' || target === home || home.startsWith(target + path.sep)) throw new Error('Mirror target overlaps a protected directory.');
39
+ return target;
40
+ }
41
+ export async function fingerprint(root, rel) {
42
+ const full = await safePath(root, rel), st = await present(full);
43
+ if (!st) return null;
44
+ const file = await fs.open(full, constants.O_RDONLY | constants.O_NOFOLLOW);
45
+ try {
46
+ const before = await file.stat(), sha = crypto.createHash('sha256'), blob = crypto.createHash('sha1').update(`blob ${before.size}\0`);
47
+ for await (const chunk of file.createReadStream({ autoClose: false })) { sha.update(chunk); blob.update(chunk); }
48
+ const after = await file.stat();
49
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ino !== after.ino) throw new Error(`File changed while reading: ${JSON.stringify(rel)}`);
50
+ return { hash: sha.digest('hex'), oid: blob.digest('hex'), size: before.size, mode: before.mode & 0o777 };
51
+ } finally { await file.close(); }
52
+ }
53
+ export const equal = (a, b) => a === b || Boolean(a && b && a.hash === b.hash && a.mode === b.mode);
54
+ export const gitMode = info => info.mode & 0o111 ? '100755' : '100644';
55
+ export async function copy(sourceRoot, sourceRel, targetRoot, targetRel, mode) {
56
+ const source = await safePath(sourceRoot, sourceRel), target = await safePath(targetRoot, targetRel);
57
+ await fs.mkdir(path.dirname(target), { recursive: true });
58
+ const tmp = `${target}.aimp-${crypto.randomUUID()}.tmp`;
59
+ const src = await fs.open(source, constants.O_RDONLY | constants.O_NOFOLLOW);
60
+ let dest;
61
+ try {
62
+ dest = await fs.open(tmp, 'wx', 0o600);
63
+ const buffer = Buffer.allocUnsafe(256 * 1024);
64
+ for (;;) {
65
+ const { bytesRead } = await src.read(buffer, 0, buffer.length, null);
66
+ if (!bytesRead) break;
67
+ await dest.writeFile(buffer.subarray(0, bytesRead));
68
+ }
69
+ await dest.chmod(mode); await dest.sync(); await dest.close(); dest = null;
70
+ await safePath(targetRoot, targetRel);
71
+ await fs.rename(tmp, target); await syncDirectory(path.dirname(target));
72
+ } finally { await src.close(); if (dest) await dest.close(); await fs.rm(tmp, { force: true }); }
73
+ }
74
+ export async function syncDirectory(dir) { const handle = await fs.open(dir, 'r'); try { await handle.sync(); } finally { await handle.close(); } }
75
+ export async function atomicJson(file, value) {
76
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
77
+ if ((await present(file))?.isSymbolicLink()) throw new Error('State file is a symlink.');
78
+ const tmp = `${file}.${crypto.randomUUID()}.tmp`, handle = await fs.open(tmp, 'wx', 0o600);
79
+ try { await handle.writeFile(JSON.stringify(value) + '\n'); await handle.sync(); } finally { await handle.close(); }
80
+ await fs.rename(tmp, file); await syncDirectory(path.dirname(file));
81
+ }
@@ -0,0 +1,70 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { performance } from 'node:perf_hooks';
3
+
4
+ export const metrics = { calls: 0, milliseconds: 0 };
5
+ export async function git(root, args, { input, env: extra = {}, allowFailure = false } = {}) {
6
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
7
+ Object.assign(env, { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0',
8
+ GIT_OPTIONAL_LOCKS: '0', GIT_LITERAL_PATHSPECS: '1', LC_ALL: 'C', ...extra });
9
+ const started = performance.now(); metrics.calls++;
10
+ try {
11
+ return await new Promise((resolve, reject) => {
12
+ const child = spawn('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgSign=false',
13
+ '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-c', 'core.fsmonitor=false', ...args], { cwd: root, env, shell: false });
14
+ const stdout = [], stderr = []; let bytes = 0, timedOut = false;
15
+ const timer = setTimeout(() => { timedOut = true; child.kill('SIGTERM'); }, 120_000);
16
+ child.stdout.on('data', chunk => { bytes += chunk.length; if (bytes > 128 * 1024 * 1024) child.kill(); else stdout.push(chunk); });
17
+ child.stderr.on('data', chunk => { if (stderr.length < 100) stderr.push(chunk); });
18
+ child.stdin.on('error', () => {});
19
+ child.once('error', error => { clearTimeout(timer); reject(error); });
20
+ child.once('close', code => {
21
+ clearTimeout(timer);
22
+ const result = { code, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr).toString('utf8') };
23
+ if (timedOut || bytes > 128 * 1024 * 1024) return reject(new Error('Git operation exceeded its time/output limit.'));
24
+ if (code !== 0 && !allowFailure) return reject(new Error(`Git ${args[0]} failed: ${result.stderr.trim() || code}`));
25
+ resolve(result);
26
+ });
27
+ child.stdin.end(input);
28
+ });
29
+ } finally { metrics.milliseconds += performance.now() - started; }
30
+ }
31
+ export const text = async (root, args, options) => (await git(root, args, options)).stdout.toString('utf8').trim();
32
+ export const head = root => text(root, ['rev-parse', '--verify', 'HEAD']);
33
+ export const branch = root => text(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
34
+ export function nul(buffer) {
35
+ const value = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
36
+ if (value && !value.endsWith('\0')) throw new Error('Incomplete Git NUL output.');
37
+ return value ? value.slice(0, -1).split('\0') : [];
38
+ }
39
+ export function parseStatus(buffer) {
40
+ const records = nul(buffer), result = [];
41
+ for (let i = 0; i < records.length; i++) {
42
+ const record = records[i];
43
+ if (record.length < 4 || record[2] !== ' ') throw new Error('Invalid Git status record.');
44
+ const code = record.slice(0, 2), name = record.slice(3);
45
+ result.push({ path: name, code });
46
+ if (/[RC]/.test(code)) {
47
+ if (!records[++i]) throw new Error('Incomplete Git rename record.');
48
+ result.push({ path: records[i], code: ' D' });
49
+ }
50
+ }
51
+ return result;
52
+ }
53
+ export const status = async root => parseStatus((await git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=none'])).stdout);
54
+ export const names = async (root, args) => nul((await git(root, args)).stdout);
55
+ export async function tree(root, ref) {
56
+ const entries = new Map();
57
+ for (const row of await names(root, ['ls-tree', '-r', '-z', ref])) {
58
+ const tab = row.indexOf('\t'), [mode, type, oid] = row.slice(0, tab).split(' ');
59
+ entries.set(row.slice(tab + 1), { mode, type, oid });
60
+ }
61
+ return entries;
62
+ }
63
+ export async function guardRepo(root) {
64
+ if (await text(root, ['rev-parse', '--show-object-format']) !== 'sha1') throw new Error('Only SHA-1 Git repositories are supported in this beta.');
65
+ await branch(root); await head(root);
66
+ for (const marker of ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD']) {
67
+ if ((await git(root, ['rev-parse', '--verify', marker], { allowFailure: true })).code === 0) throw new Error(`Finish the Git operation first: ${marker}`);
68
+ }
69
+ if ((await names(root, ['ls-files', '-u', '-z'])).length) throw new Error('Resolve Git index conflicts first.');
70
+ }
@@ -0,0 +1,37 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { git, names, nul } from './git.js';
5
+ import { MANAGED, safePath, present, hash, validatePath } from './files.js';
6
+
7
+ export async function policy(root) {
8
+ const file = await safePath(root, '.aimpignore');
9
+ const content = await present(file) ? await fs.readFile(file) : Buffer.alloc(0);
10
+ if (content.length > 1024 * 1024) throw new Error('.aimpignore is too large.');
11
+ return { content, hash: hash(content) };
12
+ }
13
+ export async function included(paths, rules, { includeManaged = false } = {}) {
14
+ const candidates = [...new Set(paths)].filter(rel => { validatePath(rel); return includeManaged || !MANAGED.has(rel); });
15
+ if (!candidates.length || !rules.content.length) return candidates;
16
+ const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'aimp-policy-'));
17
+ try {
18
+ await git(temp, ['init', '--quiet']);
19
+ const excludes = path.join(temp, 'rules'); await fs.writeFile(excludes, rules.content, { mode: 0o600 });
20
+ const result = await git(temp, ['-c', `core.excludesFile=${excludes}`, 'check-ignore', '--no-index', '-z', '--stdin'], {
21
+ input: Buffer.from(candidates.map(p => `./${p}`).join('\0') + '\0'), allowFailure: true,
22
+ env: { GIT_LITERAL_PATHSPECS: '0' },
23
+ });
24
+ if (![0, 1].includes(result.code)) throw new Error(`Invalid ignore policy: ${result.stderr}`);
25
+ const ignored = new Set(nul(result.stdout).map(p => p.replace(/^\.\//, '')));
26
+ return candidates.filter(rel => !ignored.has(rel));
27
+ } finally { await fs.rm(temp, { recursive: true, force: true }); }
28
+ }
29
+ export async function projectFiles(root, rules) {
30
+ return included(await names(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']), rules);
31
+ }
32
+ export async function guardAttributes(root, paths) {
33
+ if (!paths.length) return;
34
+ const result = await git(root, ['check-attr', '-z', '--stdin', 'filter', 'working-tree-encoding'], { input: Buffer.from(paths.join('\0') + '\0') });
35
+ const values = nul(result.stdout);
36
+ for (let i = 0; i < values.length; i += 3) if (!['unspecified', 'unset'].includes(values[i + 2])) throw new Error(`Unsupported Git attribute ${values[i + 1]}: ${JSON.stringify(values[i])}`);
37
+ }
@@ -0,0 +1,114 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { git, branch, head, status } from './git.js';
5
+ import { mirrorTarget, present, copy, fingerprint, equal, safePath, atomicJson } from './files.js';
6
+ import { stateRoot, saveState, saveJournal, clearJournal, findMirror } from './state.js';
7
+ import { policy, projectFiles, guardAttributes } from './policy.js';
8
+ import { changes, createCheckpoint, writeMetadata, validateRepository } from './engine.js';
9
+
10
+ export async function initialize(root, state, target, { confirm, replacing = false, signal } = {}) {
11
+ await validateRepository(root);
12
+ if ((await status(root)).length) throw new Error('Original must be clean before initialization.');
13
+ const name = await branch(root), originalHead = await head(root);
14
+ target = await mirrorTarget(root, target, stateRoot());
15
+ const owner = await findMirror(target);
16
+ if (owner && owner !== root) throw new Error('Target belongs to another original project.');
17
+ if (await present(target)) {
18
+ if (!(await fs.stat(target)).isDirectory()) throw new Error('Mirror target is not a directory.');
19
+ if ((await fs.readdir(target)).length && !replacing) throw new Error('Target is not empty. Use /reinit to review replacement.');
20
+ }
21
+ // A shared mirror is relocated as a unit, preserving other branches.
22
+ const existing = Object.values(state.pairs)[0];
23
+ if (existing && existing.mirror !== target && !replacing) target = existing.mirror;
24
+ if (existing) {
25
+ await validateRepository(existing.mirror);
26
+ const active = state.pairs[await branch(existing.mirror)];
27
+ if (!active || (await changes(existing.mirror, active, await policy(root))).changes.length) throw new Error('Active mirror branch has pending work. Sync or serialize it before initialization.');
28
+ }
29
+ const rules = await policy(root), files = await projectFiles(root, rules);
30
+ await guardAttributes(root, files);
31
+ const id = crypto.randomUUID(), stage = `${target}.aimp-stage-${id}`, payload = path.join(stateRoot(), 'initializations', id);
32
+ await fs.mkdir(path.join(payload, 'payload'), { recursive: true, mode: 0o700 });
33
+ await fs.mkdir(path.dirname(stage), { recursive: true });
34
+ await fs.mkdir(stage, { recursive: false });
35
+ let journaled = false;
36
+ try {
37
+ const entries = [];
38
+ for (const rel of files) {
39
+ if (signal?.aborted) throw new Error('Cancelled.');
40
+ const after = await fingerprint(root, rel);
41
+ if (!after) continue;
42
+ await copy(root, rel, payload, `payload/${entries.length}`, after.mode);
43
+ await copy(root, rel, stage, rel, after.mode);
44
+ entries.push({ path: rel, after });
45
+ }
46
+ await git(stage, ['init', '--quiet', '--initial-branch', name]);
47
+ await git(stage, ['config', 'user.name', 'aimp']); await git(stage, ['config', 'user.email', 'aimp@local.invalid']);
48
+ const commit = await createCheckpoint(stage, null, entries, payload, `chore(aimp): initialize ${name}`);
49
+ await git(stage, ['update-ref', `refs/heads/${name}`, commit]); await git(stage, ['read-tree', commit]);
50
+ const pair = { branch: name, mirror: target, baselineAi: commit, baselineOriginal: originalHead,
51
+ policyHash: rules.hash, batch: crypto.randomUUID(), acknowledgements: {}, applied: {} };
52
+ await writeMetadata(stage, state.projectId, pair);
53
+ await fs.writeFile(path.join(stage, '.git/info/exclude'), '/AIMP_REPORT.md\n/AGENTS-AIMP.md\n/.aimpignore\n');
54
+ if (rules.content.length) await fs.writeFile(path.join(stage, '.aimpignore'), rules.content);
55
+ if (existing && !replacing) {
56
+ // Transfer only the freshly created mirror commit, never original Git history.
57
+ if ((await git(existing.mirror, ['show-ref', '--verify', `refs/heads/${name}`], { allowFailure: true })).code === 0) throw new Error('Mirror branch already exists. Use /use.');
58
+ await git(existing.mirror, ['fetch', '--quiet', '--no-tags', stage, `refs/heads/${name}`]);
59
+ await git(existing.mirror, ['switch', '-c', name, 'FETCH_HEAD']);
60
+ pair.mirror = existing.mirror;
61
+ await writeMetadata(existing.mirror, state.projectId, pair);
62
+ state.pairs[name] = pair; await saveState(root, state); return pair;
63
+ }
64
+ if (existing && replacing) {
65
+ // Retain all old branch histories in the replacement; only the active branch is rebuilt.
66
+ for (const other of Object.keys(state.pairs).filter(b => b !== name)) {
67
+ await git(stage, ['fetch', '--quiet', '--no-tags', existing.mirror, `refs/heads/${other}:refs/heads/${other}`]);
68
+ }
69
+ }
70
+ if (await head(root) !== originalHead || (await policy(root)).hash !== rules.hash) throw new Error('Original changed during initialization.');
71
+ for (const e of entries) if (!equal(await fingerprint(root, e.path), e.after)) throw new Error('Original file changed during initialization.');
72
+ const occupied = await present(target) && (await fs.readdir(target)).length > 0;
73
+ if (occupied && !await confirm(`Replace ${target}? Its current contents will be retained in a backup folder.`)) return null;
74
+ if (signal?.aborted) throw new Error('Cancelled.');
75
+ const backup = `${target}.aimp-backup-${id}`;
76
+ const next = structuredClone(state);
77
+ for (const p of Object.values(next.pairs)) p.mirror = target;
78
+ next.pairs[name] = pair;
79
+ const tx = { version: 3, kind: 'initialize', id, root, target, stagePath: stage, backup, nextState: next };
80
+ await saveJournal(root, tx); journaled = true;
81
+ if (await present(target)) await fs.rename(target, backup);
82
+ await fs.rename(stage, target); await saveState(root, next); await clearJournal(root); journaled = false;
83
+ await atomicJson(path.join(payload, 'receipt.json'), { target, backup: occupied ? backup : null });
84
+ return { ...pair, backup: occupied ? backup : null };
85
+ } finally {
86
+ if (!journaled) { await fs.rm(stage, { recursive: true, force: true }); await fs.rm(path.join(payload, 'payload'), { recursive: true, force: true }); }
87
+ }
88
+ }
89
+ export async function recoverInitialization(root, tx) {
90
+ if (tx.root !== root || tx.kind !== 'initialize') throw new Error('Invalid initialization journal.');
91
+ await mirrorTarget(root, tx.target, stateRoot());
92
+ if (!/^[a-f0-9-]{36}$/.test(tx.id) || tx.stagePath !== `${tx.target}.aimp-stage-${tx.id}` || tx.backup !== `${tx.target}.aimp-backup-${tx.id}`) throw new Error('Invalid initialization recovery paths.');
93
+ if (await present(tx.stagePath)) {
94
+ if (await present(tx.target)) {
95
+ if (await present(tx.backup)) throw new Error('Both destination and backup exist; review reinit recovery manually.');
96
+ await fs.rename(tx.target, tx.backup);
97
+ }
98
+ await fs.rename(tx.stagePath, tx.target);
99
+ }
100
+ await validateRepository(tx.target);
101
+ await saveState(root, tx.nextState); await clearJournal(root);
102
+ }
103
+ export async function useBranch(root, state) {
104
+ const name = await branch(root), pair = state.pairs[name];
105
+ if (!pair) throw new Error('Branch is not initialized. Run /init.');
106
+ await validateRepository(pair.mirror);
107
+ const active = state.pairs[await branch(pair.mirror)];
108
+ if (!active) throw new Error('Unregistered mirror branch.');
109
+ if ((await changes(pair.mirror, active, await policy(root))).changes.length) throw new Error('Mirror has pending changes, including commits. Finish that branch first.');
110
+ await git(pair.mirror, ['switch', name]);
111
+ await safePath(pair.mirror, 'AIMP_REPORT.md');
112
+ await writeMetadata(pair.mirror, state.projectId, pair);
113
+ return pair;
114
+ }
@@ -0,0 +1,85 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import crypto from 'node:crypto';
5
+ import { atomicJson, canonical, hash, present, syncDirectory } from './files.js';
6
+
7
+ export const stateRoot = () => path.join(process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'), 'aimp');
8
+ export const projectId = root => hash(root).slice(0, 16);
9
+ export const stateFile = root => path.join(stateRoot(), `${projectId(root)}.json`);
10
+ export const journalFile = root => path.join(stateRoot(), `${projectId(root)}.transaction.json`);
11
+ export async function readJson(file) { if (!await present(file)) return null; if ((await present(file)).isSymbolicLink()) throw new Error('Refusing linked state.'); return JSON.parse(await fs.readFile(file, 'utf8')); }
12
+ export async function loadState(root) {
13
+ const state = await readJson(stateFile(root));
14
+ if (!state) return null;
15
+ if (![2, 3].includes(state.schemaVersion) || !state.pairs || !state.original || state.projectId !== projectId(root) || path.resolve(state.original) !== root) throw new Error('Invalid or unsupported AIMP state. Restore a state backup before continuing.');
16
+ for (const [name, pair] of Object.entries(state.pairs)) {
17
+ if (pair.branch !== name || typeof pair.mirror !== 'string' || !path.isAbsolute(pair.mirror) || !/^[a-f0-9]{40}$/.test(pair.baselineAi || '')) throw new Error('Invalid branch state.');
18
+ }
19
+ return state;
20
+ }
21
+ export const saveState = (root, state) => atomicJson(stateFile(root), state);
22
+ export const loadJournal = root => readJson(journalFile(root));
23
+ export const saveJournal = (root, tx) => atomicJson(journalFile(root), tx);
24
+ export async function clearJournal(root) { await fs.rm(journalFile(root), { force: true }); await syncDirectory(stateRoot()); }
25
+ export function newState(root) { return { schemaVersion: 3, projectId: projectId(root), original: root, language: 'en', pairs: {}, history: [] }; }
26
+ export async function migrate(root, old) {
27
+ if (old.schemaVersion === 3) return old;
28
+ if (await loadJournal(root)) throw new Error('Legacy recovery journal exists. Preserve its backup; finish legacy recovery before migration.');
29
+ const backup = `${stateFile(root)}.v2-${Date.now()}.bak`;
30
+ await fs.copyFile(stateFile(root), backup); await fs.chmod(backup, 0o600);
31
+ const next = { ...old, schemaVersion: 3 };
32
+ next.pairs = Object.fromEntries(Object.entries(old.pairs).map(([name, pair]) => [name, {
33
+ branch: name, mirror: pair.mirror, baselineAi: pair.baselineAi, baselineOriginal: pair.baselineOriginal,
34
+ batch: pair.batch, policyHash: null, acknowledgements: {}, applied: {}, baselineNeedsReview: true,
35
+ }]));
36
+ await saveState(root, next);
37
+ return { state: next, backup };
38
+ }
39
+ async function processIdentity(pid) {
40
+ try { const stat = await fs.readFile(`/proc/${pid}/stat`, 'utf8'); return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]; }
41
+ catch { return null; }
42
+ }
43
+ async function alive(owner) {
44
+ if (!Number.isSafeInteger(owner?.pid) || owner.pid <= 0) return true;
45
+ try { process.kill(owner.pid, 0); } catch (e) { return e.code !== 'ESRCH'; }
46
+ const identity = await processIdentity(owner.pid);
47
+ return !owner.start || !identity || identity === owner.start;
48
+ }
49
+ export async function withLock(root, operation) {
50
+ await fs.mkdir(stateRoot(), { recursive: true, mode: 0o700 });
51
+ if (await canonical(stateRoot()) !== path.resolve(stateRoot())) throw new Error('State directory must not contain symlinks.');
52
+ const lock = path.join(stateRoot(), `${projectId(root)}.lock`), token = crypto.randomUUID();
53
+ async function acquire() {
54
+ try { await fs.mkdir(lock); }
55
+ catch (e) {
56
+ if (e.code !== 'EEXIST') throw e;
57
+ const reap = `${lock}.reap`;
58
+ try { await fs.mkdir(reap); } catch { throw new Error('Project is locked by another AIMP process.'); }
59
+ try {
60
+ let owner;
61
+ try { owner = await readJson(path.join(lock, 'owner.json')); } catch { /* Unknown owner is never stolen. */ }
62
+ if (!owner || await alive(owner)) throw new Error('Project is locked by another AIMP process.', { cause: e });
63
+ await fs.rm(lock, { recursive: true });
64
+ await fs.mkdir(lock);
65
+ } finally { await fs.rmdir(reap); }
66
+ }
67
+ await atomicJson(path.join(lock, 'owner.json'), { pid: process.pid, start: await processIdentity(process.pid), token });
68
+ }
69
+ await acquire();
70
+ try { return await operation(); }
71
+ finally {
72
+ const owner = await readJson(path.join(lock, 'owner.json'));
73
+ if (owner?.token === token) await fs.rm(lock, { recursive: true });
74
+ }
75
+ }
76
+ export async function findMirror(root) {
77
+ let entries; try { entries = await fs.readdir(stateRoot()); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
78
+ const matches = [];
79
+ for (const name of entries.filter(n => /^[a-f0-9]{16}\.json$/.test(n))) {
80
+ const state = await readJson(path.join(stateRoot(), name));
81
+ for (const pair of Object.values(state?.pairs || {})) if (path.resolve(pair.mirror) === root) matches.push({ original: state.original, pair });
82
+ }
83
+ if (new Set(matches.map(m => m.original)).size > 1) throw new Error('Mirror belongs to multiple original projects. Resolve the registry first.');
84
+ return matches[0]?.original || null;
85
+ }
@@ -0,0 +1,38 @@
1
+ // The viewport counts terminal cells, not source lines. ANSI input is escaped by the CLI.
2
+ export function cellWidth(character) {
3
+ if (/\p{Mark}/u.test(character)) return 0;
4
+ const cp = character.codePointAt(0);
5
+ return cp >= 0x1100 && (cp <= 0x115f || cp >= 0x2e80 && cp <= 0xa4cf || cp >= 0xac00 && cp <= 0xd7af || cp >= 0xf900 && cp <= 0xfaff || cp >= 0xfe10 && cp <= 0xfe6f || cp >= 0xff01 && cp <= 0xff60 || cp >= 0x1f300) ? 2 : 1;
6
+ }
7
+ export function wrapRows(lines, width) {
8
+ const rows = [];
9
+ for (const source of lines) {
10
+ let row = '', cells = 0;
11
+ for (const character of source.replace(/\t/g, ' ')) {
12
+ const size = cellWidth(character);
13
+ if (cells + size > width && row) { rows.push(row); row = ''; cells = 0; }
14
+ row += character; cells += size;
15
+ }
16
+ rows.push(row);
17
+ }
18
+ return rows;
19
+ }
20
+ export function decodeMouse(scroll) {
21
+ let pending = Buffer.alloc(0);
22
+ return { push(chunk) {
23
+ pending = Buffer.concat([pending, chunk]); const output = [];
24
+ while (pending.length) {
25
+ if (pending[0] !== 27) { output.push(pending.subarray(0, 1)); pending = pending.subarray(1); continue; }
26
+ if (pending.length < 3 && Buffer.from('\x1b[<').subarray(0, pending.length).equals(pending)) break;
27
+ if (pending.subarray(0, 3).toString() !== '\x1b[<') { output.push(pending.subarray(0, 1)); pending = pending.subarray(1); continue; }
28
+ const match = pending.toString().match(/^\x1b\[<(\d+);\d+;\d+([mM])/);
29
+ if (!match) {
30
+ if (pending.length <= 40 && /^\x1b\[<[\d;]*$/.test(pending.toString())) break;
31
+ pending = pending.subarray(3); continue;
32
+ }
33
+ if (match[2] === 'M' && (match[1] === '64' || match[1] === '65')) scroll(match[1] === '64' ? 3 : -3);
34
+ pending = pending.subarray(Buffer.byteLength(match[0]));
35
+ }
36
+ return Buffer.concat(output);
37
+ } };
38
+ }
package/src/ui.js ADDED
@@ -0,0 +1,119 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { Box, Text, render, useApp, useInput, useStdout } from 'ink';
3
+ import { PassThrough } from 'node:stream';
4
+ import { stdin, stdout } from 'node:process';
5
+ import { createSession, loadState, pathCompleter, localized, safeText } from './cli.js';
6
+ import { branch } from './core/git.js';
7
+ import { wrapRows, decodeMouse } from './ui-utils.js';
8
+ const h = React.createElement;
9
+
10
+ function App({ ctx, profile, onScroll, controller }) {
11
+ const { exit } = useApp(), { stdout: screen } = useStdout();
12
+ const [size, setSize] = useState({ columns: screen.columns || 80, rows: screen.rows || 24 });
13
+ const [language, setLanguage] = useState('en'), [activeBranch, setActiveBranch] = useState('…');
14
+ const [value, setValue] = useState(''), [logs, setLogs] = useState([]), [offset, setOffset] = useState(0);
15
+ const [pending, setPending] = useState(null), [busy, setBusy] = useState(false), [monitor, setMonitor] = useState(null);
16
+ const busyRef = useRef(false), pendingRef = useRef(null), completion = useRef(null), quitting = useRef(false);
17
+ const t = (en, id) => localized(language, en, id);
18
+ const log = message => { setLogs(old => [...old, ...safeText(message).split('\n')].slice(-5000)); setOffset(0); };
19
+ const session = useMemo(() => createSession(ctx, {
20
+ profile, signal: controller.signal, output: log,
21
+ prompt: (message, options) => new Promise((resolve, reject) => {
22
+ const abort = () => { pendingRef.current = null; setPending(null); reject(new Error('Cancelled.')); };
23
+ const request = { message, kind: options.kind, resolve: v => { controller.signal.removeEventListener('abort', abort); resolve(v); } };
24
+ pendingRef.current = request; setPending(request); controller.signal.addEventListener('abort', abort, { once: true });
25
+ }),
26
+ }), [ctx, profile, controller]);
27
+ useEffect(() => {
28
+ const resize = () => setSize({ columns: screen.columns || 80, rows: screen.rows || 24 });
29
+ screen.on('resize', resize);
30
+ Promise.all([loadState(ctx.original), branch(ctx.root)]).then(([state, name]) => { setLanguage(state?.language || 'en'); setActiveBranch(name); }).catch(e => log(e.message));
31
+ return () => screen.off('resize', resize);
32
+ }, [ctx, screen]);
33
+ const rows = Math.max(6, size.rows - 1), width = Math.max(16, size.columns - 2), small = rows < 22;
34
+ const showMonitor = Boolean(monitor && width >= 100 && rows >= 18);
35
+ const leftWidth = showMonitor ? Math.floor(width * 0.65) : width;
36
+ const textWidth = Math.max(10, leftWidth - 2);
37
+ const candidates = !pending && value.startsWith('/') ? session.commands.filter(c => c.startsWith(value)) : [];
38
+ const headerRows = small ? 3 : 7;
39
+ const promptLabel = pending ? pending.message : ctx.mode === 'mirror' ? '$ aimp-ai> ' : '$ aimp> ';
40
+ const inputRows = wrapRows([`${promptLabel}${value}▏`], textWidth).slice(-Math.min(4, Math.max(1, rows - headerRows - 4)));
41
+ const suggestionRows = candidates.length ? wrapRows([candidates.join(' ')], textWidth).slice(0, 2) : [];
42
+ const chatHeight = Math.max(1, rows - headerRows - inputRows.length - suggestionRows.length - 3);
43
+ const wrappedLogs = wrapRows(logs, textWidth), maxOffset = Math.max(0, wrappedLogs.length - chatHeight), scroll = Math.min(offset, maxOffset);
44
+ const visible = wrappedLogs.slice(Math.max(0, wrappedLogs.length - scroll - chatHeight), wrappedLogs.length - scroll);
45
+ useEffect(() => { onScroll.current = amount => setOffset(old => Math.max(0, Math.min(maxOffset, old + amount))); return () => { onScroll.current = null; }; }, [maxOffset, onScroll]);
46
+ async function submit() {
47
+ const answer = value; setValue(''); completion.current = null;
48
+ if (pendingRef.current) { const p = pendingRef.current; pendingRef.current = null; setPending(null); p.resolve(answer); return; }
49
+ if (busyRef.current || !answer.trim()) return;
50
+ busyRef.current = true; setBusy(true); setMonitor(null); log(`$ ${answer}`);
51
+ try {
52
+ const result = await session.execute(answer);
53
+ if (result.exit) return exit();
54
+ if (result.monitor) { setMonitor(result.monitor); setActiveBranch(result.monitor.branch); }
55
+ const state = await loadState(ctx.original); setLanguage(state?.language || 'en');
56
+ } catch (error) { log(`Error: ${error.message}`); }
57
+ finally { busyRef.current = false; setBusy(false); if (quitting.current) exit(); }
58
+ }
59
+ useInput((input, key) => {
60
+ if (key.ctrl && input === 'c') {
61
+ quitting.current = true; controller.abort();
62
+ if (!busyRef.current) exit();
63
+ return;
64
+ }
65
+ if (key.up || key.pageUp || key.home) return setOffset(old => key.home ? maxOffset : Math.min(maxOffset, old + (key.pageUp ? chatHeight : 1)));
66
+ if (key.down || key.pageDown || key.end) return setOffset(old => key.end ? 0 : Math.max(0, old - (key.pageDown ? chatHeight : 1)));
67
+ if (busyRef.current && !pendingRef.current) return;
68
+ if (key.return) { void submit(); return; }
69
+ if (key.tab) {
70
+ if (pending && pending.kind !== 'path') return;
71
+ if (completion.current) {
72
+ const cycle = completion.current; cycle.index = (cycle.index + (key.shift ? -1 : 1) + cycle.matches.length) % cycle.matches.length;
73
+ setValue(cycle.matches[cycle.index]); return;
74
+ }
75
+ const typed = value;
76
+ const resolve = matches => { if (!matches.length) return; completion.current = { matches, index: 0 }; setValue(matches[0]); };
77
+ if (pending) void pathCompleter(typed).then(([matches]) => resolve(matches)); else resolve(candidates);
78
+ return;
79
+ }
80
+ completion.current = null;
81
+ if (key.backspace || key.delete) { setValue(old => Array.from(old).slice(0, -1).join('')); return; }
82
+ if (!key.ctrl && !key.meta && !key.escape && input) setValue(old => old + input.replace(/[\x00-\x1f\x7f]/g, ''));
83
+ });
84
+ const logo = small ? ['╭─ AIMP ─╮'] : ['╭────────────────────╮', '│ A I M P │', '│ AI MIRROR PROJECT │', '╰────────────────────╯'];
85
+ const header = [...logo, `${ctx.mode === 'mirror' ? 'AI MIRROR' : 'ORIGINAL'} · ${activeBranch}`, ctx.root];
86
+ const left = h(Box, { width: leftWidth, flexDirection: 'column', height: rows },
87
+ ...header.slice(0, headerRows - 1).map((line, i) => h(Text, { key: `h${i}`, color: i < logo.length ? 'green' : undefined, wrap: 'truncate-end' }, safeText(line))),
88
+ h(Text, { dimColor: true, wrap: 'truncate-end' }, pending ? t('Waiting for input', 'Menunggu input') : busy ? t('Processing… Ctrl+C stops safely', 'Memproses… Ctrl+C berhenti aman') : t('Ready · Ctrl+C exit', 'Siap · Ctrl+C keluar')),
89
+ h(Box, { flexDirection: 'column', height: chatHeight, overflow: 'hidden' }, ...visible.map((line, i) => h(Text, { key: i }, line))),
90
+ h(Text, { dimColor: true, wrap: 'truncate-end' }, `↑↓ PgUp/PgDn Home/End · ${scroll ? `↑ ${scroll}` : t('latest', 'terbaru')}`),
91
+ ...suggestionRows.map((line, i) => h(Text, { key: `s${i}`, color: 'yellow' }, line)),
92
+ h(Text, { dimColor: true, wrap: 'truncate-end' }, t('Type / · Tab/Shift+Tab selects commands or paths', 'Ketik / · Tab/Shift+Tab pilih command atau path')),
93
+ ...inputRows.map((line, i) => h(Text, { key: `p${i}`, color: 'green' }, safeText(line)))
94
+ );
95
+ const right = showMonitor ? h(Box, { width: width - leftWidth, flexDirection: 'column', borderStyle: 'round', height: Math.min(rows, 18), paddingX: 1 },
96
+ h(Text, { color: 'blue' }, '$ status --snapshot'),
97
+ ...wrapRows([monitor.state, `Original dirty: ${monitor.originalDirty}`, `AI pending: ${monitor.aiPending}`, `Branch: ${monitor.mirrorBranch}`, `Path: ${monitor.mirror}`, ...monitor.files], width - leftWidth - 4).slice(0, 14).map((line, i) => h(Text, { key: i }, safeText(line)))
98
+ ) : null;
99
+ return h(Box, { flexDirection: 'row', height: rows, width }, left, right);
100
+ }
101
+
102
+ export async function runInk({ ctx, profile }) {
103
+ const controller = new AbortController(), scroll = { current: null }, filtered = new PassThrough();
104
+ filtered.isTTY = stdin.isTTY; filtered.setRawMode = enabled => stdin.setRawMode(enabled);
105
+ filtered.ref = () => stdin.ref(); filtered.unref = () => stdin.unref();
106
+ const decoder = decodeMouse(direction => scroll.current?.(direction));
107
+ const onData = chunk => { const cleaned = decoder.push(chunk); if (cleaned.length) filtered.write(cleaned); };
108
+ const stop = () => controller.abort();
109
+ stdin.on('data', onData); process.on('SIGTERM', stop);
110
+ stdout.write('\x1b[?1049h\x1b[?1000h\x1b[?1006h\x1b[2J\x1b[H');
111
+ try {
112
+ const app = render(h(App, { ctx, profile, controller, onScroll: scroll }), { stdin: filtered, stdout, exitOnCtrlC: false, patchConsole: false });
113
+ await app.waitUntilExit();
114
+ } finally {
115
+ controller.abort(); stdin.off('data', onData); process.off('SIGTERM', stop); filtered.destroy();
116
+ if (stdin.isTTY) stdin.setRawMode(false);
117
+ stdout.write('\x1b[?1006l\x1b[?1000l\x1b[?1049l');
118
+ }
119
+ }