@happy-nut/monacori 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -22
- package/dist/app-main.js +67 -11
- package/dist/commands.js +57 -216
- package/dist/diff.js +33 -18
- package/dist/git.d.ts +1 -0
- package/dist/git.js +9 -0
- package/dist/i18n.js +10 -0
- package/dist/render.d.ts +1 -1
- package/dist/render.js +11 -2
- package/dist/util.js +13 -3
- package/dist/viewer.client.js +346 -37
- package/dist/viewer.css +94 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,25 +52,6 @@ On first run, `mo` creates `.monacori/`, adds it to `.gitignore`, and includes u
|
|
|
52
52
|
- **Source navigation**: jump between changed files, search indexed files, preview source, and move through hunks from the keyboard.
|
|
53
53
|
- **Plain local artifacts**: generated review files and state are Markdown, JSON, and static HTML under `.monacori/`.
|
|
54
54
|
|
|
55
|
-
## Commands
|
|
56
|
-
|
|
57
|
-
| Command | What it does |
|
|
58
|
-
| --- | --- |
|
|
59
|
-
| `mo` | Open the desktop diff-review app for the current repository. Alias for `monacori open`. |
|
|
60
|
-
| `monacori open` | Launch the review app, auto-initialize `.monacori/`, and include untracked files by default. |
|
|
61
|
-
| `monacori app` | Launch the same desktop app explicitly. |
|
|
62
|
-
| `monacori init` | Initialize `.monacori/` in the current directory. |
|
|
63
|
-
| `monacori install` | Initialize and write agent instruction snippets. Use `--apply-agent-docs` to patch `AGENTS.md` or `CLAUDE.md`. |
|
|
64
|
-
|
|
65
|
-
Useful review options:
|
|
66
|
-
|
|
67
|
-
```bash
|
|
68
|
-
mo --staged # review only staged changes
|
|
69
|
-
mo --tracked-only # exclude untracked files
|
|
70
|
-
mo --base main # compare against a specific base
|
|
71
|
-
mo --context 20 # show more context around each hunk
|
|
72
|
-
```
|
|
73
|
-
|
|
74
55
|
## Development
|
|
75
56
|
|
|
76
57
|
Working on monacori itself? The globally-installed `mo` runs the **published** package, not your
|
|
@@ -82,10 +63,10 @@ Run your checkout directly (builds, then launches in the foreground with DevTool
|
|
|
82
63
|
npm run dev
|
|
83
64
|
```
|
|
84
65
|
|
|
85
|
-
This reviews the monacori repo itself. To
|
|
66
|
+
This reviews the monacori repo itself. To review **another repo** with your local build, pass `--cwd`:
|
|
86
67
|
|
|
87
68
|
```bash
|
|
88
|
-
|
|
69
|
+
npm run dev -- --cwd /path/to/other-repo
|
|
89
70
|
```
|
|
90
71
|
|
|
91
72
|
**Which build is running?** A dev build titles its window `monacori (dev)` and opens DevTools, and
|
|
@@ -120,7 +101,7 @@ suite gates every release.
|
|
|
120
101
|
|
|
121
102
|
## Local State
|
|
122
103
|
|
|
123
|
-
|
|
104
|
+
Running `mo` creates a git-ignored `.monacori/` directory for generated diff reviews, local config, comments, logs, and validation notes. Keep it ignored unless your team intentionally wants to version review artifacts.
|
|
124
105
|
|
|
125
106
|
## Design Principles
|
|
126
107
|
|
package/dist/app-main.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { spawnSync, spawn } from "node:child_process";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { app, BrowserWindow, ipcMain, Menu, nativeImage } from "electron";
|
|
6
6
|
import { buildDiffReview, performHttpRequest } from "./cli.js";
|
|
7
7
|
import { sanitizeTerminalEnv } from "./util.js";
|
|
8
|
+
import { readUnifiedDiff } from "./diff.js";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
8
10
|
import { spawn as spawnPty } from "node-pty";
|
|
9
11
|
// `npm run dev` sets MONACORI_DEV=1 so a locally-built app announces itself — a window-title suffix
|
|
10
12
|
// plus a boot log with its on-disk path — making it obvious whether `mo` launched THIS checkout or
|
|
@@ -15,15 +17,32 @@ const FLOW_DIR = ".monacori";
|
|
|
15
17
|
const REVIEW_FILE = "app-review.html";
|
|
16
18
|
const WATCH_INTERVAL_MS = 1000;
|
|
17
19
|
// Painted immediately while the first review build + HTML render run, so startup shows a spinner instead
|
|
18
|
-
// of a blank window. Inlined as a data: URL so it needs no file on disk and appears before any review
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
// of a blank window. Inlined as a data: URL so it needs no file on disk and appears before any review
|
|
21
|
+
// work. Theme-aware so a light-theme user doesn't get a dark flash before the renderer applies the theme.
|
|
22
|
+
function loadingHtml(light) {
|
|
23
|
+
const bg = light ? "#ffffff" : "#2b2b2b";
|
|
24
|
+
const fg = light ? "#6e7781" : "#9aa4af";
|
|
25
|
+
const ring = light ? "#d0d7de" : "#3a3a3a";
|
|
26
|
+
const accent = light ? "#0969da" : "#4a9eff";
|
|
27
|
+
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
28
|
+
html,body{margin:0;height:100vh;background:${bg};color:${fg};display:flex;flex-direction:column;
|
|
21
29
|
align-items:center;justify-content:center;gap:18px;
|
|
22
30
|
font:13px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
23
|
-
.s{width:34px;height:34px;border:3px solid
|
|
31
|
+
.s{width:34px;height:34px;border:3px solid ${ring};border-top-color:${accent};border-radius:50%;
|
|
24
32
|
animation:spin .8s linear infinite}
|
|
25
33
|
@keyframes spin{to{transform:rotate(360deg)}}
|
|
26
34
|
</style></head><body><div class="s"></div><div>monacori</div></body></html>`;
|
|
35
|
+
}
|
|
36
|
+
// The persisted theme (set by the renderer via monacoriSettings). Read at startup so the native window
|
|
37
|
+
// chrome + loading screen match before the renderer boots. Defaults to dark.
|
|
38
|
+
function isLightTheme() {
|
|
39
|
+
try {
|
|
40
|
+
return readSettings()["monacori-theme"] === "light";
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
27
46
|
app.setName("monacori");
|
|
28
47
|
ipcMain.handle("monacori:http-send", (_event, request) => performHttpRequest(request));
|
|
29
48
|
// Phase 2 lazy-LOAD: serve a single file's diff body to the renderer on demand. Retained from the
|
|
@@ -47,8 +66,27 @@ ipcMain.handle("monacori:self-update", () => {
|
|
|
47
66
|
timeout: 5 * 60 * 1000,
|
|
48
67
|
});
|
|
49
68
|
if ((result.status ?? 1) === 0) {
|
|
50
|
-
// Let the renderer paint "Restarting…"
|
|
51
|
-
|
|
69
|
+
// Let the renderer paint "Restarting…", then start the freshly-installed CLI as a NEW detached
|
|
70
|
+
// process and exit. app.relaunch() re-runs THIS process, but the global install just replaced our
|
|
71
|
+
// on-disk dist (and possibly the bundled Electron), so the current process is stale — relaunching
|
|
72
|
+
// it can boot the old code or fail outright. Spawning `mo` loads the new code; a sanitized env
|
|
73
|
+
// keeps this update run's npm_* vars out of the fresh process. Falls back to relaunch if spawn fails.
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
try {
|
|
76
|
+
const child = spawn("mo", [], {
|
|
77
|
+
cwd: options.root,
|
|
78
|
+
detached: true,
|
|
79
|
+
stdio: "ignore",
|
|
80
|
+
env: sanitizeTerminalEnv(process.env),
|
|
81
|
+
shell: true,
|
|
82
|
+
});
|
|
83
|
+
child.unref();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
app.relaunch();
|
|
87
|
+
}
|
|
88
|
+
app.exit(0);
|
|
89
|
+
}, 400);
|
|
52
90
|
return { ok: true };
|
|
53
91
|
}
|
|
54
92
|
const detail = (result.stderr || result.stdout || (result.error && result.error.message) || "npm install failed").trim();
|
|
@@ -211,6 +249,7 @@ app.whenReady().then(async () => {
|
|
|
211
249
|
if (process.platform === "darwin" && app.dock && !appIcon.isEmpty()) {
|
|
212
250
|
app.dock.setIcon(appIcon);
|
|
213
251
|
}
|
|
252
|
+
const themeLight = isLightTheme();
|
|
214
253
|
mainWindow = new BrowserWindow({
|
|
215
254
|
width: 1440,
|
|
216
255
|
height: 960,
|
|
@@ -219,7 +258,7 @@ app.whenReady().then(async () => {
|
|
|
219
258
|
show: false,
|
|
220
259
|
title: APP_TITLE,
|
|
221
260
|
icon: iconPath,
|
|
222
|
-
backgroundColor: "#2b2b2b",
|
|
261
|
+
backgroundColor: themeLight ? "#ffffff" : "#2b2b2b",
|
|
223
262
|
autoHideMenuBar: true,
|
|
224
263
|
webPreferences: {
|
|
225
264
|
preload: preloadPath,
|
|
@@ -238,7 +277,7 @@ app.whenReady().then(async () => {
|
|
|
238
277
|
// Paint the window with a spinner immediately, then build the (potentially heavy) review off the first
|
|
239
278
|
// paint and swap it in. The first build used to run synchronously *before* the window existed, so the
|
|
240
279
|
// screen stayed blank for the first few seconds of startup; now the user sees a loading screen instead.
|
|
241
|
-
await mainWindow.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(
|
|
280
|
+
await mainWindow.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(loadingHtml(themeLight)));
|
|
242
281
|
// Give the loading spinner a few frames to actually paint before the (synchronous) first build blocks
|
|
243
282
|
// the main process — otherwise the spinner looks frozen until the build finishes. The boot overlay in
|
|
244
283
|
// the review HTML then takes over, so there's no blank gap when loadFile swaps the page in.
|
|
@@ -272,11 +311,27 @@ app.on("window-all-closed", () => {
|
|
|
272
311
|
terms.clear();
|
|
273
312
|
app.quit();
|
|
274
313
|
});
|
|
314
|
+
let lastDiffSig = "";
|
|
275
315
|
async function refreshIfChanged() {
|
|
276
316
|
if (refreshing || !mainWindow || mainWindow.isDestroyed())
|
|
277
317
|
return;
|
|
278
318
|
refreshing = true;
|
|
279
319
|
try {
|
|
320
|
+
// Fast path: hash only the git diff (~120ms) before the full build (~1s). The vast majority of
|
|
321
|
+
// watch ticks see no change, so skip the heavy buildDiffReview entirely then — keeping the main
|
|
322
|
+
// process free for IPC/pty so the UI never stalls on an unchanged tree.
|
|
323
|
+
const diffSig = createHash("sha1")
|
|
324
|
+
.update(readUnifiedDiff({
|
|
325
|
+
base: options.base,
|
|
326
|
+
staged: options.staged,
|
|
327
|
+
context: options.context,
|
|
328
|
+
includeUntracked: options.includeUntracked,
|
|
329
|
+
ignoreWhitespace: options.ignoreWhitespace,
|
|
330
|
+
}))
|
|
331
|
+
.digest("hex");
|
|
332
|
+
if (diffSig === lastDiffSig)
|
|
333
|
+
return;
|
|
334
|
+
lastDiffSig = diffSig;
|
|
280
335
|
const next = writeReviewFile(options);
|
|
281
336
|
if (next.signature !== currentSignature) {
|
|
282
337
|
currentSignature = next.signature;
|
|
@@ -320,8 +375,9 @@ function parseArgs(args) {
|
|
|
320
375
|
const contextValue = readOption(args, "--context");
|
|
321
376
|
return {
|
|
322
377
|
root: resolve(root),
|
|
323
|
-
|
|
324
|
-
|
|
378
|
+
// staged review and custom --base were removed from the CLI; always diff the working tree against
|
|
379
|
+
// HEAD (base omitted → defaults to HEAD downstream).
|
|
380
|
+
staged: false,
|
|
325
381
|
includeUntracked: args.includes("--include-untracked"),
|
|
326
382
|
context: contextValue ? parsePositiveInteger(contextValue, "--context") : 12,
|
|
327
383
|
watch: !args.includes("--no-watch"),
|
package/dist/commands.js
CHANGED
|
@@ -1,46 +1,23 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { basename, dirname, join } from "node:path";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
3
|
import { spawn, spawnSync } from "node:child_process";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { CONFIG_FILE, DECISIONS_FILE, FLOW_DIR, GITIGNORE_FILE, STATE_FILE } from "./constants.js";
|
|
7
|
+
import { readOption } from "./util.js";
|
|
8
8
|
import { git } from "./git.js";
|
|
9
9
|
const nodeRequire = createRequire(import.meta.url);
|
|
10
|
+
// monacori is a single command: open the desktop review app for the current repository. `mo` and
|
|
11
|
+
// `monacori` (with or without flags) all do the same thing. `--cwd <path>` reviews another repo (used by
|
|
12
|
+
// `npm run dev -- --cwd <path>`); `--no-watch` / `--foreground` are dev/internal knobs. `--help` prints help.
|
|
10
13
|
export function main() {
|
|
11
14
|
const rawArgs = process.argv.slice(2);
|
|
12
|
-
const [command, ...args] = rawArgs;
|
|
13
15
|
try {
|
|
14
|
-
if (
|
|
15
|
-
|
|
16
|
+
if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
|
|
17
|
+
printHelp();
|
|
16
18
|
return;
|
|
17
19
|
}
|
|
18
|
-
|
|
19
|
-
openCurrentRepository(rawArgs);
|
|
20
|
-
return;
|
|
21
|
-
}
|
|
22
|
-
switch (command) {
|
|
23
|
-
case "init":
|
|
24
|
-
initFlow(args);
|
|
25
|
-
break;
|
|
26
|
-
case "install":
|
|
27
|
-
installFlow(args);
|
|
28
|
-
break;
|
|
29
|
-
case "app":
|
|
30
|
-
case "review":
|
|
31
|
-
launchReviewApp(args);
|
|
32
|
-
break;
|
|
33
|
-
case "open":
|
|
34
|
-
openCurrentRepository(args);
|
|
35
|
-
break;
|
|
36
|
-
case "--help":
|
|
37
|
-
case "-h":
|
|
38
|
-
case "help":
|
|
39
|
-
printHelp();
|
|
40
|
-
break;
|
|
41
|
-
default:
|
|
42
|
-
throw new Error(`Unknown command: ${command}`);
|
|
43
|
-
}
|
|
20
|
+
launchReviewApp(rawArgs);
|
|
44
21
|
}
|
|
45
22
|
catch (error) {
|
|
46
23
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -48,108 +25,42 @@ export function main() {
|
|
|
48
25
|
process.exit(1);
|
|
49
26
|
}
|
|
50
27
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const root = process.cwd();
|
|
55
|
-
const flowPath = join(root, FLOW_DIR);
|
|
56
|
-
mkdirSync(flowPath, { recursive: true });
|
|
57
|
-
mkdirSync(join(flowPath, "reports"), { recursive: true });
|
|
58
|
-
mkdirSync(join(flowPath, "logs"), { recursive: true });
|
|
59
|
-
mkdirSync(join(flowPath, "diffs"), { recursive: true });
|
|
60
|
-
const config = {
|
|
61
|
-
version: 1,
|
|
62
|
-
projectName: basename(root),
|
|
63
|
-
verification: {
|
|
64
|
-
commands: detectVerificationCommands(root),
|
|
65
|
-
},
|
|
66
|
-
diff: {
|
|
67
|
-
context: 12,
|
|
68
|
-
includeUntracked: false,
|
|
69
|
-
},
|
|
70
|
-
};
|
|
71
|
-
writeIfMissing(join(flowPath, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`, force);
|
|
72
|
-
writeIfMissing(join(flowPath, STATE_FILE), initialState(config), force);
|
|
73
|
-
writeIfMissing(join(flowPath, DECISIONS_FILE), initialDecisions(), force);
|
|
74
|
-
const ignored = ensureMonacoriGitignore(root);
|
|
75
|
-
if (!quiet) {
|
|
76
|
-
console.log(`Initialized ${FLOW_DIR}/ in ${root}`);
|
|
77
|
-
if (ignored) {
|
|
78
|
-
console.log(`Updated ${GITIGNORE_FILE} to ignore ${FLOW_DIR}/ validation artifacts.`);
|
|
79
|
-
}
|
|
80
|
-
console.log("Next: run `mo` to open the diff review app.");
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
function installFlow(args) {
|
|
84
|
-
const force = args.includes("--force");
|
|
85
|
-
const applyAgentDocs = args.includes("--apply-agent-docs");
|
|
86
|
-
initFlow(["--quiet"]);
|
|
87
|
-
writeIfMissing(join(process.cwd(), FLOW_DIR, AGENT_SNIPPET_FILE), agentSnippet(), force);
|
|
88
|
-
if (applyAgentDocs) {
|
|
89
|
-
applyAgentDocSnippet("AGENTS.md");
|
|
90
|
-
applyAgentDocSnippet("CLAUDE.md");
|
|
91
|
-
}
|
|
92
|
-
console.log("Installed monacori validation instructions.");
|
|
93
|
-
console.log(`- ${FLOW_DIR}/${AGENT_SNIPPET_FILE}`);
|
|
94
|
-
if (applyAgentDocs) {
|
|
95
|
-
console.log("- Updated AGENTS.md / CLAUDE.md validation snippets where available.");
|
|
96
|
-
}
|
|
97
|
-
else {
|
|
98
|
-
console.log(`Next: add ${FLOW_DIR}/${AGENT_SNIPPET_FILE} to your agent instructions if desired.`);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
28
|
+
// "Show everything" diff context: large enough that `git diff -U<n>` emits entire files as context, so the
|
|
29
|
+
// diff never folds away unchanged regions. Reviewers skip with F7/Shift+F7 instead of relying on gaps.
|
|
30
|
+
const FULL_DIFF_CONTEXT = 100000;
|
|
101
31
|
function launchReviewApp(args) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
32
|
+
// Review the directory given by --cwd (defaults to the current one), so `npm run dev -- --cwd <path>` can
|
|
33
|
+
// open ANY repo from anywhere. chdir up front so the flow state and the launched app resolve to one repo.
|
|
34
|
+
const targetCwd = resolve(readOption(args, "--cwd") ?? process.cwd());
|
|
35
|
+
if (!existsSync(targetCwd)) {
|
|
36
|
+
throw new Error(`Directory does not exist: ${targetCwd}`);
|
|
105
37
|
}
|
|
38
|
+
process.chdir(targetCwd);
|
|
106
39
|
ensureWritableFlowState();
|
|
107
|
-
const config = loadConfig();
|
|
108
|
-
const contextValue = readOption(args, "--context");
|
|
109
|
-
const context = contextValue ? parsePositiveInteger(contextValue, "--context") : config.diff.context;
|
|
110
40
|
const appArgs = [
|
|
111
41
|
appMainPath(),
|
|
112
42
|
"--cwd",
|
|
113
43
|
process.cwd(),
|
|
114
44
|
"--context",
|
|
115
|
-
String(
|
|
45
|
+
String(FULL_DIFF_CONTEXT),
|
|
46
|
+
"--include-untracked", // new AI-created files are visible by default
|
|
116
47
|
];
|
|
117
|
-
const base = readOption(args, "--base");
|
|
118
|
-
if (base)
|
|
119
|
-
appArgs.push("--base", base);
|
|
120
|
-
if (args.includes("--staged"))
|
|
121
|
-
appArgs.push("--staged");
|
|
122
|
-
if (args.includes("--include-untracked") || config.diff.includeUntracked)
|
|
123
|
-
appArgs.push("--include-untracked");
|
|
124
48
|
if (args.includes("--no-watch"))
|
|
125
49
|
appArgs.push("--no-watch");
|
|
126
50
|
const electronBinary = resolveElectronBinary();
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
51
|
+
// In dev only (`npm run dev` sets MONACORI_DEV=1) announce which build is launching, so a local checkout
|
|
52
|
+
// is distinguishable from the installed package. Normal `mo` runs stay silent.
|
|
53
|
+
if (process.env.MONACORI_DEV === "1") {
|
|
54
|
+
console.error(`monacori: launching ${appMainPath()}`);
|
|
55
|
+
}
|
|
131
56
|
if (args.includes("--foreground")) {
|
|
132
57
|
const result = spawnSync(electronBinary, appArgs, { stdio: "inherit" });
|
|
133
58
|
process.exit(result.status ?? 0);
|
|
134
59
|
}
|
|
135
|
-
const child = spawn(electronBinary, appArgs, {
|
|
136
|
-
detached: true,
|
|
137
|
-
stdio: "ignore",
|
|
138
|
-
});
|
|
60
|
+
const child = spawn(electronBinary, appArgs, { detached: true, stdio: "ignore" });
|
|
139
61
|
child.unref();
|
|
140
62
|
console.log("Opened monacori review app.");
|
|
141
63
|
}
|
|
142
|
-
function openCurrentRepository(args) {
|
|
143
|
-
if (args.includes("--help") || args.includes("-h")) {
|
|
144
|
-
printOpenHelp();
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
const appArgs = args.filter((arg) => arg !== "--tracked-only");
|
|
148
|
-
if (!args.includes("--tracked-only") && !args.includes("--staged") && !args.includes("--include-untracked")) {
|
|
149
|
-
appArgs.push("--include-untracked");
|
|
150
|
-
}
|
|
151
|
-
launchReviewApp(appArgs);
|
|
152
|
-
}
|
|
153
64
|
function resolveElectronBinary() {
|
|
154
65
|
const electronModule = nodeRequire("electron");
|
|
155
66
|
if (typeof electronModule === "string") {
|
|
@@ -166,12 +77,30 @@ function resolveElectronBinary() {
|
|
|
166
77
|
function appMainPath() {
|
|
167
78
|
return join(dirname(fileURLToPath(import.meta.url)), "app-main.js");
|
|
168
79
|
}
|
|
80
|
+
// Create .monacori/ on first run (the app auto-initializes; there is no separate `init` command).
|
|
81
|
+
function initFlow() {
|
|
82
|
+
const root = process.cwd();
|
|
83
|
+
const flowPath = join(root, FLOW_DIR);
|
|
84
|
+
mkdirSync(flowPath, { recursive: true });
|
|
85
|
+
mkdirSync(join(flowPath, "reports"), { recursive: true });
|
|
86
|
+
mkdirSync(join(flowPath, "logs"), { recursive: true });
|
|
87
|
+
mkdirSync(join(flowPath, "diffs"), { recursive: true });
|
|
88
|
+
const config = {
|
|
89
|
+
version: 1,
|
|
90
|
+
projectName: basename(root),
|
|
91
|
+
verification: { commands: detectVerificationCommands(root) },
|
|
92
|
+
diff: { context: 12, includeUntracked: false },
|
|
93
|
+
};
|
|
94
|
+
writeIfMissing(join(flowPath, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`);
|
|
95
|
+
writeIfMissing(join(flowPath, STATE_FILE), initialState(config));
|
|
96
|
+
writeIfMissing(join(flowPath, DECISIONS_FILE), initialDecisions());
|
|
97
|
+
ensureMonacoriGitignore(root);
|
|
98
|
+
}
|
|
169
99
|
function initialState(config) {
|
|
170
100
|
return [
|
|
171
101
|
"# Monacori Validation State",
|
|
172
102
|
"",
|
|
173
103
|
`Project: ${config.projectName}`,
|
|
174
|
-
`Initialized: ${new Date().toISOString()}`,
|
|
175
104
|
"",
|
|
176
105
|
"## Goal",
|
|
177
106
|
"- Keep AI-generated changes reviewable, test-backed, and easy to inspect.",
|
|
@@ -190,74 +119,22 @@ function initialDecisions() {
|
|
|
190
119
|
"",
|
|
191
120
|
].join("\n");
|
|
192
121
|
}
|
|
193
|
-
function agentSnippet() {
|
|
194
|
-
return [
|
|
195
|
-
"<!-- MONACORI:START -->",
|
|
196
|
-
"## monacori Diff Review",
|
|
197
|
-
"",
|
|
198
|
-
"This repository uses monacori to help humans review AI-generated code changes side-by-side.",
|
|
199
|
-
"",
|
|
200
|
-
"After making code changes:",
|
|
201
|
-
"",
|
|
202
|
-
"- The user can run `mo` to open the diff review app and inspect your changes.",
|
|
203
|
-
"- Inspect changed hunks with F7 / Shift+F7.",
|
|
204
|
-
"- Use Shift Shift in the diff review to search indexed files, including unchanged files.",
|
|
205
|
-
"- In source previews, use Cmd/Ctrl+Down to jump to the declaration-like match under the cursor.",
|
|
206
|
-
"- Inline comments left in the review are bundled into a prompt and sent back to the session.",
|
|
207
|
-
"<!-- MONACORI:END -->",
|
|
208
|
-
"",
|
|
209
|
-
].join("\n");
|
|
210
|
-
}
|
|
211
|
-
function applyAgentDocSnippet(fileName) {
|
|
212
|
-
const path = join(process.cwd(), fileName);
|
|
213
|
-
const snippet = agentSnippet();
|
|
214
|
-
if (!existsSync(path)) {
|
|
215
|
-
writeFileSync(path, `# ${fileName}\n\n${snippet}`);
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
const current = readFileSync(path, "utf8");
|
|
219
|
-
const markerPattern = /<!-- MONACORI:START -->[\s\S]*?<!-- MONACORI:END -->\n?/;
|
|
220
|
-
const next = markerPattern.test(current)
|
|
221
|
-
? current.replace(markerPattern, snippet)
|
|
222
|
-
: `${current.trimEnd()}\n\n${snippet}`;
|
|
223
|
-
writeFileSync(path, next);
|
|
224
|
-
}
|
|
225
|
-
function ensureInitialized() {
|
|
226
|
-
if (!existsSync(join(process.cwd(), FLOW_DIR, CONFIG_FILE))) {
|
|
227
|
-
throw new Error(`Missing ${FLOW_DIR}/. Run \`monacori init\` first.`);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
122
|
function ensureWritableFlowState() {
|
|
231
123
|
if (!existsSync(join(process.cwd(), FLOW_DIR, CONFIG_FILE))) {
|
|
232
|
-
initFlow(
|
|
124
|
+
initFlow();
|
|
233
125
|
return;
|
|
234
126
|
}
|
|
235
127
|
ensureMonacoriGitignore(process.cwd());
|
|
236
128
|
}
|
|
237
|
-
function
|
|
238
|
-
|
|
239
|
-
const raw = JSON.parse(readFileSync(join(process.cwd(), FLOW_DIR, CONFIG_FILE), "utf8"));
|
|
240
|
-
return {
|
|
241
|
-
version: 1,
|
|
242
|
-
projectName: raw.projectName ?? basename(process.cwd()),
|
|
243
|
-
verification: {
|
|
244
|
-
commands: Array.isArray(raw.verification?.commands) ? raw.verification.commands : [],
|
|
245
|
-
},
|
|
246
|
-
diff: {
|
|
247
|
-
context: typeof raw.diff?.context === "number" ? raw.diff.context : 12,
|
|
248
|
-
includeUntracked: typeof raw.diff?.includeUntracked === "boolean" ? raw.diff.includeUntracked : false,
|
|
249
|
-
},
|
|
250
|
-
};
|
|
251
|
-
}
|
|
252
|
-
function writeIfMissing(path, content, force) {
|
|
253
|
-
if (!force && existsSync(path)) {
|
|
129
|
+
function writeIfMissing(path, content) {
|
|
130
|
+
if (existsSync(path)) {
|
|
254
131
|
return;
|
|
255
132
|
}
|
|
256
133
|
writeFileSync(path, content);
|
|
257
134
|
}
|
|
258
135
|
function ensureMonacoriGitignore(root) {
|
|
259
136
|
if (git(root, ["rev-parse", "--is-inside-work-tree"]) !== "true") {
|
|
260
|
-
return
|
|
137
|
+
return;
|
|
261
138
|
}
|
|
262
139
|
const path = join(root, GITIGNORE_FILE);
|
|
263
140
|
const content = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
@@ -266,11 +143,10 @@ function ensureMonacoriGitignore(root) {
|
|
|
266
143
|
.map((line) => line.trim())
|
|
267
144
|
.some((line) => line === FLOW_DIR || line === `${FLOW_DIR}/`);
|
|
268
145
|
if (hasEntry) {
|
|
269
|
-
return
|
|
146
|
+
return;
|
|
270
147
|
}
|
|
271
148
|
const prefix = content.length === 0 ? "" : content.endsWith("\n") ? "\n" : "\n\n";
|
|
272
149
|
writeFileSync(path, `${content}${prefix}# monacori local validation artifacts\n${FLOW_DIR}/\n`);
|
|
273
|
-
return true;
|
|
274
150
|
}
|
|
275
151
|
function detectVerificationCommands(root) {
|
|
276
152
|
const commands = new Set();
|
|
@@ -318,51 +194,16 @@ function packageScriptCommand(manager, script) {
|
|
|
318
194
|
return `pnpm ${script}`;
|
|
319
195
|
}
|
|
320
196
|
function printHelp() {
|
|
321
|
-
console.log(`monacori
|
|
322
|
-
|
|
323
|
-
Desktop review app for AI-generated code changes.
|
|
197
|
+
console.log(`monacori — desktop review app for AI-generated code changes.
|
|
324
198
|
|
|
325
199
|
Usage:
|
|
326
|
-
mo
|
|
327
|
-
monacori open [--base HEAD] [--staged] [--tracked-only]
|
|
328
|
-
monacori app [--base HEAD] [--staged] [--include-untracked]
|
|
329
|
-
monacori init [--force]
|
|
330
|
-
monacori install [--force] [--apply-agent-docs]
|
|
200
|
+
mo open the review app for the current repository
|
|
331
201
|
|
|
332
202
|
Diff review keys:
|
|
333
|
-
F7
|
|
334
|
-
|
|
335
|
-
Shift Shift
|
|
336
|
-
Cmd/Ctrl+E
|
|
337
|
-
Cmd/Ctrl+Down
|
|
338
|
-
`);
|
|
339
|
-
}
|
|
340
|
-
function printOpenHelp() {
|
|
341
|
-
console.log(`monacori open
|
|
342
|
-
|
|
343
|
-
Open the local desktop review app for the current directory. This is the default command behind \`mo\` and \`monacori\` with no arguments.
|
|
344
|
-
|
|
345
|
-
It auto-initializes .monacori/ when needed, makes sure .monacori/ is ignored in Git worktrees, and includes untracked files by default so new AI-created files are visible.
|
|
346
|
-
|
|
347
|
-
Usage:
|
|
348
|
-
mo
|
|
349
|
-
monacori open [--base HEAD] [--staged] [--tracked-only] [--context 12] [--no-watch] [--foreground]
|
|
350
|
-
|
|
351
|
-
Options:
|
|
352
|
-
--tracked-only inspect tracked changes only
|
|
353
|
-
`);
|
|
354
|
-
}
|
|
355
|
-
function printAppHelp() {
|
|
356
|
-
console.log(`monacori app
|
|
357
|
-
|
|
358
|
-
Launch the local desktop review app. The app reads Git diff and source files directly from this repository, writes a local review file under .monacori/, and refreshes when the working tree changes. It does not start an HTTP server.
|
|
359
|
-
|
|
360
|
-
Usage:
|
|
361
|
-
monacori app [--base HEAD] [--staged] [--include-untracked] [--context 12] [--no-watch] [--foreground]
|
|
362
|
-
|
|
363
|
-
Aliases:
|
|
364
|
-
mo
|
|
365
|
-
monacori open
|
|
366
|
-
monacori review
|
|
203
|
+
F7 / Shift+F7 next / previous changed hunk
|
|
204
|
+
Cmd/Ctrl+0 / +1 focus the Changes / Files panel (arrows + Enter to open a file)
|
|
205
|
+
Shift Shift file search across indexed files
|
|
206
|
+
Cmd/Ctrl+E recent files
|
|
207
|
+
Cmd/Ctrl+Down jump to symbol under cursor
|
|
367
208
|
`);
|
|
368
209
|
}
|
package/dist/diff.js
CHANGED
|
@@ -3,8 +3,14 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
|
|
3
3
|
import { basename, join } from "node:path";
|
|
4
4
|
import { FLOW_DIR, IMAGE_MAX_BYTES, SOURCE_MAX_FILE_BYTES, SOURCE_MAX_FILES, SOURCE_MAX_TOTAL_BYTES } from "./constants.js";
|
|
5
5
|
import { formatBytes, hashText, isLikelyBinary, languageForPath, stripDiffPath } from "./util.js";
|
|
6
|
-
import { git } from "./git.js";
|
|
6
|
+
import { git, repoRoot } from "./git.js";
|
|
7
|
+
// File content + signature cache, keyed by path and validated on (mtime, size). Under `watch` the app
|
|
8
|
+
// rebuilds every second; without this, collectSourceFiles re-reads + re-hashes EVERY tracked source
|
|
9
|
+
// file each tick (~1.3s for ~6k files on a large repo), pinning the Electron main process and starving
|
|
10
|
+
// IPC/pty. With it an unchanged file costs a single statSync — the per-tick cost collapses to stat-only.
|
|
11
|
+
const sourceContentCache = new Map();
|
|
7
12
|
export function readUnifiedDiff(options) {
|
|
13
|
+
const root = repoRoot();
|
|
8
14
|
const args = ["diff", "--no-ext-diff", "--find-renames", `--unified=${options.context}`];
|
|
9
15
|
if (options.ignoreWhitespace)
|
|
10
16
|
args.push("--ignore-all-space");
|
|
@@ -16,7 +22,7 @@ export function readUnifiedDiff(options) {
|
|
|
16
22
|
}
|
|
17
23
|
args.push("--");
|
|
18
24
|
const result = spawnSync("git", args, {
|
|
19
|
-
cwd:
|
|
25
|
+
cwd: root,
|
|
20
26
|
encoding: "utf8",
|
|
21
27
|
maxBuffer: 1024 * 1024 * 100,
|
|
22
28
|
});
|
|
@@ -25,18 +31,18 @@ export function readUnifiedDiff(options) {
|
|
|
25
31
|
}
|
|
26
32
|
const chunks = [result.stdout ?? ""];
|
|
27
33
|
if (options.includeUntracked && !options.staged) {
|
|
28
|
-
chunks.push(readUntrackedDiff(options.context));
|
|
34
|
+
chunks.push(readUntrackedDiff(options.context, root));
|
|
29
35
|
}
|
|
30
36
|
return chunks.filter(Boolean).join("\n");
|
|
31
37
|
}
|
|
32
|
-
function readUntrackedDiff(context) {
|
|
33
|
-
const files = git(
|
|
38
|
+
function readUntrackedDiff(context, root) {
|
|
39
|
+
const files = git(root, ["ls-files", "--others", "--exclude-standard"])
|
|
34
40
|
.split(/\r?\n/)
|
|
35
41
|
.map((line) => line.trim())
|
|
36
42
|
.filter((line) => line && !line.startsWith(`${FLOW_DIR}/`));
|
|
37
43
|
const chunks = [];
|
|
38
44
|
for (const file of files) {
|
|
39
|
-
const absolute = join(
|
|
45
|
+
const absolute = join(root, file);
|
|
40
46
|
if (!existsSync(absolute) || !statSync(absolute).isFile()) {
|
|
41
47
|
continue;
|
|
42
48
|
}
|
|
@@ -225,14 +231,15 @@ export function collectSourceFiles(diffFiles) {
|
|
|
225
231
|
}
|
|
226
232
|
changedLinesByPath.set(file.displayPath, nums);
|
|
227
233
|
}
|
|
228
|
-
const
|
|
234
|
+
const root = repoRoot();
|
|
235
|
+
const vcsByPath = gitStatusMap(root);
|
|
229
236
|
for (const file of diffFiles) {
|
|
230
237
|
const kind = vcsByPath.get(file.displayPath);
|
|
231
238
|
if (kind)
|
|
232
239
|
file.vcs = kind; // color the Changes list from the same status map
|
|
233
240
|
}
|
|
234
241
|
const paths = new Set();
|
|
235
|
-
const gitFiles = git(
|
|
242
|
+
const gitFiles = git(root, ["ls-files", "--cached", "--others", "--exclude-standard"]);
|
|
236
243
|
for (const file of gitFiles.split(/\r?\n/)) {
|
|
237
244
|
const path = file.trim();
|
|
238
245
|
if (path && isSourceCandidate(path)) {
|
|
@@ -248,7 +255,7 @@ export function collectSourceFiles(diffFiles) {
|
|
|
248
255
|
let embeddedFiles = 0;
|
|
249
256
|
let embeddedBytes = 0;
|
|
250
257
|
for (const path of Array.from(paths).sort((a, b) => a.localeCompare(b))) {
|
|
251
|
-
const absolute = join(
|
|
258
|
+
const absolute = join(root, path);
|
|
252
259
|
const base = {
|
|
253
260
|
path,
|
|
254
261
|
name: basename(path),
|
|
@@ -282,7 +289,11 @@ export function collectSourceFiles(diffFiles) {
|
|
|
282
289
|
}
|
|
283
290
|
continue;
|
|
284
291
|
}
|
|
285
|
-
|
|
292
|
+
// A file we already cached as text (same mtime+size) can't have turned binary — skip the binary
|
|
293
|
+
// sniff (an open+read per file, ~635ms across this repo) on the hot watch path.
|
|
294
|
+
const cached = sourceContentCache.get(path);
|
|
295
|
+
const fresh = Boolean(cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size);
|
|
296
|
+
if (!fresh && isLikelyBinary(absolute)) {
|
|
286
297
|
const skippedReason = "binary file";
|
|
287
298
|
sourceFiles.push({ ...base, size: stats.size, signature: hashText(`${path}\0binary\0${stats.size}`), skippedReason });
|
|
288
299
|
continue;
|
|
@@ -297,14 +308,18 @@ export function collectSourceFiles(diffFiles) {
|
|
|
297
308
|
sourceFiles.push({ ...base, size: stats.size, signature: hashText(`${path}\0budget\0${stats.size}`), skippedReason });
|
|
298
309
|
continue;
|
|
299
310
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
content
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
311
|
+
let content;
|
|
312
|
+
let signature;
|
|
313
|
+
if (fresh) {
|
|
314
|
+
content = cached.content; // unchanged since last build — skip the read + hash
|
|
315
|
+
signature = cached.signature;
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
content = readFileSync(absolute, "utf8");
|
|
319
|
+
signature = hashText(`${path}\0${content}`);
|
|
320
|
+
sourceContentCache.set(path, { mtimeMs: stats.mtimeMs, size: stats.size, content, signature });
|
|
321
|
+
}
|
|
322
|
+
sourceFiles.push({ ...base, content, size: stats.size, embedded: true, signature });
|
|
308
323
|
embeddedFiles += 1;
|
|
309
324
|
embeddedBytes += stats.size;
|
|
310
325
|
}
|