@astrosheep/keiyaku 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/build/codex.js +2 -0
- package/build/constants.js +3 -0
- package/build/debug-log.js +36 -0
- package/build/errors.js +114 -0
- package/build/git.js +462 -0
- package/build/index.js +423 -0
- package/build/logic.js +530 -0
- package/build/prompts.js +77 -0
- package/build/response-builders.js +289 -0
- package/build/subagent-exec/codex-exec.js +33 -0
- package/build/subagent-exec/gemini-exec.js +37 -0
- package/build/subagent-exec/index.js +39 -0
- package/build/subagent-exec/process-runner.js +207 -0
- package/build/subagent-exec/string-tail-buffer.js +44 -0
- package/build/subagent-exec/types.js +19 -0
- package/build/term-presets.js +109 -0
- package/build/tool-schemas.js +91 -0
- package/build/trace.js +113 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# 🤝 Keiyaku
|
|
2
|
+
|
|
3
|
+
**Keiyaku** is an MCP server designed for the **Architect-Minion workflow**. It enforces a strict, review-driven protocol for iterative coding tasks.
|
|
4
|
+
|
|
5
|
+
Instead of messy, unstructured chat, Keiyaku turns every task into a formal "Keiyaku": a branch-based, multi-round journey with a clear beginning, a traced middle, and a definitive end.
|
|
6
|
+
|
|
7
|
+
## 🚀 Why Keiyaku?
|
|
8
|
+
|
|
9
|
+
- **Zero Mess**: Every task lives in its own `keiyaku/*` branch. Your main branch stays pristine.
|
|
10
|
+
- **Traceable Logic**: Every iteration is documented in `KEIYAKU_TRACE.md`. You can literally see the AI's "learning curve" (or mistakes).
|
|
11
|
+
- **Strict Guardrails**: No "Done" until you say so. No merging until the criteria are met.
|
|
12
|
+
- **Human-in-the-Loop**: Designed for humans who actually want to review the code before it hits production.
|
|
13
|
+
|
|
14
|
+
## 🔄 The Lifecycle
|
|
15
|
+
|
|
16
|
+
The workflow is a simple, non-negotiable loop:
|
|
17
|
+
|
|
18
|
+
1. **`summon`**: Initiate the keiyaku. Creates a branch, locks the mission in `KEIYAKU.md`, and starts Round 1.
|
|
19
|
+
2. **`drive`** (N times): Review the work, give feedback, and launch the next round.
|
|
20
|
+
3. **`seal`**: The final verdict. `DONE` (merge & clean) or `DROP` (nuke the branch).
|
|
21
|
+
|
|
22
|
+
## 🛠 Available Tools
|
|
23
|
+
|
|
24
|
+
| Tool | Action | Description |
|
|
25
|
+
| :--- | :--- | :--- |
|
|
26
|
+
| **`summon`** | Start | Define the goal, constraints, and criteria. Starts the first round. |
|
|
27
|
+
| **`drive`** | Iterate | Provide feedback based on the previous round's output. |
|
|
28
|
+
| **`ask`** | Reason | Pure read-only analysis session. No code changes, just brain power. |
|
|
29
|
+
| **`seal`** | Finish | Finalize the task. Requires a quality check (the "Oath"). |
|
|
30
|
+
|
|
31
|
+
## 🎨 Flavor Your Workflow
|
|
32
|
+
|
|
33
|
+
Bored with generic tool names? Keiyaku supports **Term Presets**. Change the vibe via the `KEIYAKU_TERM_PRESET` environment variable.
|
|
34
|
+
|
|
35
|
+
- **`default`**: `summon` → `drive` → `seal` (Professional)
|
|
36
|
+
- **`pokemon`**: `choose_you` → `command` → `capture` (Gotta code 'em all)
|
|
37
|
+
- **`faye`**: `oi` → `neh` → `hora` (For those who like a little attitude)
|
|
38
|
+
|
|
39
|
+
## 📦 Setup
|
|
40
|
+
|
|
41
|
+
### 1. Install
|
|
42
|
+
```bash
|
|
43
|
+
npm install -g keiyaku
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 2. Configure MCP (Example: Claude Desktop)
|
|
47
|
+
Add this to your `claude_desktop_config.json`:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"mcpServers": {
|
|
52
|
+
"keiyaku": {
|
|
53
|
+
"command": "npx",
|
|
54
|
+
"args": ["-y", "keiyaku"],
|
|
55
|
+
"env": {
|
|
56
|
+
"KEIYAKU_TERM_PRESET": "faye"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 📜 Protocol Files
|
|
64
|
+
|
|
65
|
+
When a keiyaku is active, two files are maintained in your repo:
|
|
66
|
+
- `KEIYAKU.md`: The immutable "Constitution" of the task.
|
|
67
|
+
- `KEIYAKU_TRACE.md`: The history of every round, feedback, and result.
|
|
68
|
+
|
|
69
|
+
*Note: These files are automatically cleaned up (or committed) when you `seal` the keiyaku.*
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
"Keep your branches clean and your minions in line." — Faye
|
package/build/codex.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as fs from "fs/promises";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
let writeQueue = Promise.resolve();
|
|
4
|
+
function resolveLogPath(cwd) {
|
|
5
|
+
const raw = process.env.KEIYAKU_DEBUG_LOG_PATH?.trim();
|
|
6
|
+
if (!raw)
|
|
7
|
+
return null;
|
|
8
|
+
if (path.isAbsolute(raw))
|
|
9
|
+
return raw;
|
|
10
|
+
return path.resolve(cwd || process.cwd(), raw);
|
|
11
|
+
}
|
|
12
|
+
function formatLine(message, section) {
|
|
13
|
+
const timestamp = new Date().toISOString();
|
|
14
|
+
const sec = section?.trim() ? ` [${section.trim()}]` : "";
|
|
15
|
+
return `[${timestamp}] [pid:${process.pid}]${sec} ${message}\n`;
|
|
16
|
+
}
|
|
17
|
+
export function appendDebugLog(message, options = {}) {
|
|
18
|
+
const logPath = resolveLogPath(options.cwd);
|
|
19
|
+
if (!logPath)
|
|
20
|
+
return;
|
|
21
|
+
const line = formatLine(message, options.section);
|
|
22
|
+
writeQueue = writeQueue
|
|
23
|
+
.then(async () => {
|
|
24
|
+
await fs.mkdir(path.dirname(logPath), { recursive: true });
|
|
25
|
+
await fs.appendFile(logPath, line, "utf-8");
|
|
26
|
+
})
|
|
27
|
+
.catch(() => {
|
|
28
|
+
// Best-effort logging should never break runtime behavior.
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export function appendDebugBlock(title, content, options = {}) {
|
|
32
|
+
const text = content.trim();
|
|
33
|
+
if (!text)
|
|
34
|
+
return;
|
|
35
|
+
appendDebugLog(`${title}\n${text}`, options);
|
|
36
|
+
}
|
package/build/errors.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { ROUND_SUBAGENTS } from "./constants.js";
|
|
2
|
+
import { resolveTermPreset } from "./term-presets.js";
|
|
3
|
+
function unknownSubagentHint() {
|
|
4
|
+
const names = ROUND_SUBAGENTS.join(", ");
|
|
5
|
+
const { identity } = resolveTermPreset();
|
|
6
|
+
return `Unknown ${identity.toLowerCase()} name. Configured ${identity.toLowerCase()}s: ${names}. Choose one via tool input 'subagentName' or env 'KEIYAKU_SUBAGENT_NAME_OVERRIDE'.`;
|
|
7
|
+
}
|
|
8
|
+
export class FlowError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor(code, message, cause) {
|
|
11
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
12
|
+
this.name = "FlowError";
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function isFlowError(err) {
|
|
17
|
+
return err instanceof FlowError;
|
|
18
|
+
}
|
|
19
|
+
export function asMessage(err) {
|
|
20
|
+
return err instanceof Error ? err.message : String(err);
|
|
21
|
+
}
|
|
22
|
+
export function wrapFlowError(action, err) {
|
|
23
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
24
|
+
return err;
|
|
25
|
+
}
|
|
26
|
+
if (isFlowError(err)) {
|
|
27
|
+
return new FlowError(err.code, `${action} failed: ${err.message}`, err);
|
|
28
|
+
}
|
|
29
|
+
return new Error(`${action} failed: ${asMessage(err)}`);
|
|
30
|
+
}
|
|
31
|
+
export function pickHintFromError(err, message) {
|
|
32
|
+
if (isFlowError(err)) {
|
|
33
|
+
switch (err.code) {
|
|
34
|
+
case "NOT_GIT_REPO":
|
|
35
|
+
return "The provided `cwd` is not a git repository.";
|
|
36
|
+
case "ACTIVE_KEIYAKU_EXISTS":
|
|
37
|
+
return "An active keiyaku branch already exists in this repository.";
|
|
38
|
+
case "EXISTING_KEIYAKU_BRANCH_FOUND":
|
|
39
|
+
return "At least one local `keiyaku/*` branch already exists in this repository.";
|
|
40
|
+
case "EMPTY_PARAM":
|
|
41
|
+
return "One or more required parameters are empty.";
|
|
42
|
+
case "DIRTY_WORKTREE":
|
|
43
|
+
return "The working tree has uncommitted changes.";
|
|
44
|
+
case "NOT_ACTIVE_KEIYAKU_BRANCH":
|
|
45
|
+
return "Current branch is not an active keiyaku branch (`keiyaku/*`).";
|
|
46
|
+
case "MISSING_KEIYAKU_BASE":
|
|
47
|
+
return "Current keiyaku branch is missing `keiyakuBase` metadata.";
|
|
48
|
+
case "MISSING_PROTOCOL_FILES":
|
|
49
|
+
return "Required protocol files are missing (`KEIYAKU.md` or `KEIYAKU_TRACE.md`).";
|
|
50
|
+
case "DONE_MERGE_CONFLICT":
|
|
51
|
+
return "DONE encountered a git merge conflict.";
|
|
52
|
+
case "JUDGMENT_QUALITY_GATE_FAILED":
|
|
53
|
+
return "DONE requires metPrecise/metMinimal/metIsolated/metIdiomatic/metCohesive all set to true.";
|
|
54
|
+
case "JUDGMENT_OATH_MISMATCH":
|
|
55
|
+
return err.message;
|
|
56
|
+
case "SUBAGENT_DID_NOT_ADVANCE_ROUND":
|
|
57
|
+
return "Subagent run did not append the expected new round in `KEIYAKU_TRACE.md`.";
|
|
58
|
+
case "INVALID_BRANCH_TITLE":
|
|
59
|
+
return "Title cannot be converted to a valid branch token for `keiyaku/<title>`.";
|
|
60
|
+
case "UNKNOWN_SUBAGENT":
|
|
61
|
+
return unknownSubagentHint();
|
|
62
|
+
default:
|
|
63
|
+
return "Review the error details, fix the issue, and retry.";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Fallback for untyped runtime errors.
|
|
67
|
+
if (message.includes("is not a git repository")) {
|
|
68
|
+
return "The provided `cwd` is not a git repository.";
|
|
69
|
+
}
|
|
70
|
+
if (message.includes("active keiyaku already exists")) {
|
|
71
|
+
return "An active keiyaku branch already exists in this repository.";
|
|
72
|
+
}
|
|
73
|
+
if (message.includes("existing keiyaku branch found")) {
|
|
74
|
+
return "At least one local `keiyaku/*` branch already exists in this repository.";
|
|
75
|
+
}
|
|
76
|
+
if (message.includes("parameter") && message.includes("cannot be empty")) {
|
|
77
|
+
return "One or more required parameters are empty.";
|
|
78
|
+
}
|
|
79
|
+
if (message.includes("working tree has uncommitted changes")) {
|
|
80
|
+
return "The working tree has uncommitted changes.";
|
|
81
|
+
}
|
|
82
|
+
if (message.includes("current branch is not an active keiyaku branch")) {
|
|
83
|
+
return "Current branch is not an active keiyaku branch (`keiyaku/*`).";
|
|
84
|
+
}
|
|
85
|
+
if (message.includes("is missing base metadata")) {
|
|
86
|
+
return "Current keiyaku branch is missing `keiyakuBase` metadata.";
|
|
87
|
+
}
|
|
88
|
+
if (message.includes("missing protocol files")) {
|
|
89
|
+
return "Required protocol files are missing (`KEIYAKU.md` or `KEIYAKU_TRACE.md`).";
|
|
90
|
+
}
|
|
91
|
+
if (message.includes("DONE merge conflict")) {
|
|
92
|
+
return "DONE encountered a git merge conflict.";
|
|
93
|
+
}
|
|
94
|
+
if (message.includes("requires metPrecise/metMinimal/metIsolated/metIdiomatic/metCohesive all set to true")) {
|
|
95
|
+
return "DONE requires metPrecise/metMinimal/metIsolated/metIdiomatic/metCohesive all set to true.";
|
|
96
|
+
}
|
|
97
|
+
if (message.includes("requires the sacred oath to exactly equal") ||
|
|
98
|
+
message.includes("requires oath to exactly match configured value") ||
|
|
99
|
+
message.includes("requires oath to match configured value. If template contains") ||
|
|
100
|
+
message.includes("To declare DONE, you must solemnly swear the sacred oath.") ||
|
|
101
|
+
message.includes("To declare DONE, oath mismatch.")) {
|
|
102
|
+
return message;
|
|
103
|
+
}
|
|
104
|
+
if (message.includes("subagent did not advance round") || message.includes("did not append KEIYAKU_TRACE")) {
|
|
105
|
+
return "Subagent run did not append the expected new round in `KEIYAKU_TRACE.md`.";
|
|
106
|
+
}
|
|
107
|
+
if (message.includes("cannot be converted to a valid branch name")) {
|
|
108
|
+
return "Title cannot be converted to a valid branch token for `keiyaku/<title>`.";
|
|
109
|
+
}
|
|
110
|
+
if (message.includes("Unknown subagent")) {
|
|
111
|
+
return unknownSubagentHint();
|
|
112
|
+
}
|
|
113
|
+
return "Review the error details, fix the issue, and retry.";
|
|
114
|
+
}
|
package/build/git.js
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { simpleGit } from "simple-git";
|
|
2
|
+
import { appendDebugBlock } from "./debug-log.js";
|
|
3
|
+
const DEFAULT_MAX_LINES_PER_FILE = 80;
|
|
4
|
+
const DEFAULT_MAX_TOTAL_LINES = 500;
|
|
5
|
+
const DEFAULT_GIT_TIMEOUT_MS = 15 * 1000;
|
|
6
|
+
const DIFF_EXCLUDES = [":(exclude)KEIYAKU.md", ":(exclude)KEIYAKU_TRACE.md"];
|
|
7
|
+
// Diff preview limits (no env/config knobs on purpose).
|
|
8
|
+
const DIFF_PREVIEW_MAX_FILES = 12;
|
|
9
|
+
const DIFF_PREVIEW_MAX_HUNKS_PER_FILE = 2;
|
|
10
|
+
const DIFF_PREVIEW_MAX_LINES_PER_HUNK = 40;
|
|
11
|
+
const DIFF_PREVIEW_MAX_PRELUDE_LINES = 30;
|
|
12
|
+
function readPositiveIntEnv(name, fallback) {
|
|
13
|
+
const raw = process.env[name]?.trim();
|
|
14
|
+
if (!raw)
|
|
15
|
+
return fallback;
|
|
16
|
+
const value = Number.parseInt(raw, 10);
|
|
17
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
18
|
+
}
|
|
19
|
+
function readBooleanEnv(name, fallback) {
|
|
20
|
+
const raw = process.env[name]?.trim().toLowerCase();
|
|
21
|
+
if (!raw)
|
|
22
|
+
return fallback;
|
|
23
|
+
if (raw === "1" || raw === "true" || raw === "yes" || raw === "on")
|
|
24
|
+
return true;
|
|
25
|
+
if (raw === "0" || raw === "false" || raw === "no" || raw === "off")
|
|
26
|
+
return false;
|
|
27
|
+
return fallback;
|
|
28
|
+
}
|
|
29
|
+
function compactText(input, maxChars = 4000) {
|
|
30
|
+
const text = input.trim();
|
|
31
|
+
if (!text)
|
|
32
|
+
return "";
|
|
33
|
+
if (text.length <= maxChars)
|
|
34
|
+
return text;
|
|
35
|
+
const marker = `\n...[truncated ${text.length - maxChars} chars]...\n`;
|
|
36
|
+
const side = Math.floor((maxChars - marker.length) / 2);
|
|
37
|
+
return `${text.slice(0, side)}${marker}${text.slice(text.length - side)}`;
|
|
38
|
+
}
|
|
39
|
+
function wrapGitError(commandLabel, err, cwd) {
|
|
40
|
+
const source = (err ?? {});
|
|
41
|
+
const message = source.message ?? String(err);
|
|
42
|
+
const stderr = compactText(source.stderr ?? source.stdErr ?? "");
|
|
43
|
+
const stdout = compactText(source.stdout ?? source.stdOut ?? "");
|
|
44
|
+
let detailedMessage = `[git ${commandLabel}] ${message}`;
|
|
45
|
+
if (stderr)
|
|
46
|
+
detailedMessage += `\n\n--- GIT STDERR ---\n${stderr}\n------------------`;
|
|
47
|
+
if (stdout)
|
|
48
|
+
detailedMessage += `\n\n--- GIT STDOUT ---\n${stdout}\n------------------`;
|
|
49
|
+
if (stderr || stdout) {
|
|
50
|
+
appendDebugBlock(`git ${commandLabel} failure details`, `${detailedMessage}`, { cwd, section: "git-error" });
|
|
51
|
+
}
|
|
52
|
+
const wrapped = new Error(detailedMessage, err === undefined ? undefined : { cause: err });
|
|
53
|
+
if (source.git !== undefined) {
|
|
54
|
+
Object.assign(wrapped, { git: source.git });
|
|
55
|
+
}
|
|
56
|
+
return wrapped;
|
|
57
|
+
}
|
|
58
|
+
function createGit(cwd) {
|
|
59
|
+
const timeoutMs = readPositiveIntEnv("KEIYAKU_GIT_TIMEOUT_MS", DEFAULT_GIT_TIMEOUT_MS);
|
|
60
|
+
const git = simpleGit(cwd, {
|
|
61
|
+
timeout: { block: timeoutMs, stdErr: true, stdOut: true },
|
|
62
|
+
});
|
|
63
|
+
git.env("GIT_TERMINAL_PROMPT", "0");
|
|
64
|
+
git.env("GCM_INTERACTIVE", "Never");
|
|
65
|
+
git.env("GIT_ASKPASS", "false");
|
|
66
|
+
git.env("SSH_ASKPASS", "false");
|
|
67
|
+
git.env("GIT_EDITOR", "true");
|
|
68
|
+
git.env("GIT_MERGE_AUTOEDIT", "no");
|
|
69
|
+
return git;
|
|
70
|
+
}
|
|
71
|
+
export async function isGitRepo(cwd) {
|
|
72
|
+
const git = createGit(cwd);
|
|
73
|
+
try {
|
|
74
|
+
return await git.checkIsRepo();
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
throw wrapGitError("check-is-repo", err, cwd);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export async function getCurrentBranch(cwd) {
|
|
81
|
+
const git = createGit(cwd);
|
|
82
|
+
let status;
|
|
83
|
+
try {
|
|
84
|
+
status = await git.status();
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
throw wrapGitError("status", err, cwd);
|
|
88
|
+
}
|
|
89
|
+
if (!status.current) {
|
|
90
|
+
throw new Error("Detached HEAD or no branch found");
|
|
91
|
+
}
|
|
92
|
+
return status.current;
|
|
93
|
+
}
|
|
94
|
+
export async function getActiveKeiyakuBranch(cwd) {
|
|
95
|
+
const current = await getCurrentBranch(cwd);
|
|
96
|
+
return current.startsWith("keiyaku/") ? current : null;
|
|
97
|
+
}
|
|
98
|
+
export async function listLocalKeiyakuBranches(cwd) {
|
|
99
|
+
const git = createGit(cwd);
|
|
100
|
+
let branches;
|
|
101
|
+
try {
|
|
102
|
+
branches = await git.branchLocal();
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
throw wrapGitError("branch --list", err, cwd);
|
|
106
|
+
}
|
|
107
|
+
return branches.all.filter((name) => name.startsWith("keiyaku/"));
|
|
108
|
+
}
|
|
109
|
+
export async function createAndCheckoutBranch(cwd, branchName) {
|
|
110
|
+
const git = createGit(cwd);
|
|
111
|
+
try {
|
|
112
|
+
await git.checkoutLocalBranch(branchName);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
throw wrapGitError(`checkout -b ${branchName}`, err, cwd);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export async function checkoutBranch(cwd, branchName) {
|
|
119
|
+
const git = createGit(cwd);
|
|
120
|
+
try {
|
|
121
|
+
await git.checkout(branchName);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
throw wrapGitError(`checkout ${branchName}`, err, cwd);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export async function deleteBranch(cwd, branchName, force = false) {
|
|
128
|
+
const git = createGit(cwd);
|
|
129
|
+
try {
|
|
130
|
+
await git.deleteLocalBranch(branchName, force);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
throw wrapGitError(`branch -d${force ? " -D" : ""} ${branchName}`, err, cwd);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
export async function addFiles(cwd, files) {
|
|
137
|
+
const git = createGit(cwd);
|
|
138
|
+
try {
|
|
139
|
+
await git.add(files);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
throw wrapGitError("add", err, cwd);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
export async function commit(cwd, message) {
|
|
146
|
+
const git = createGit(cwd);
|
|
147
|
+
const skipVerify = readBooleanEnv("KEIYAKU_GIT_NO_VERIFY", true);
|
|
148
|
+
const noGpgSign = readBooleanEnv("KEIYAKU_GIT_NO_GPG_SIGN", true);
|
|
149
|
+
const args = ["commit", "-m", message];
|
|
150
|
+
if (skipVerify)
|
|
151
|
+
args.push("--no-verify");
|
|
152
|
+
if (noGpgSign)
|
|
153
|
+
args.push("--no-gpg-sign");
|
|
154
|
+
try {
|
|
155
|
+
await git.raw(args);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
throw wrapGitError(args.join(" "), err, cwd);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export async function merge(cwd, branchName, message) {
|
|
162
|
+
const git = createGit(cwd);
|
|
163
|
+
const skipVerify = readBooleanEnv("KEIYAKU_GIT_NO_VERIFY", true);
|
|
164
|
+
const noGpgSign = readBooleanEnv("KEIYAKU_GIT_NO_GPG_SIGN", true);
|
|
165
|
+
const options = [branchName, "--no-ff", "--no-edit", "-m", message];
|
|
166
|
+
if (skipVerify)
|
|
167
|
+
options.push("--no-verify");
|
|
168
|
+
if (noGpgSign)
|
|
169
|
+
options.push("--no-gpg-sign");
|
|
170
|
+
try {
|
|
171
|
+
await git.merge(options);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
throw wrapGitError(`merge ${options.join(" ")}`, err, cwd);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export async function hasLocalBranch(cwd, branchName) {
|
|
178
|
+
const git = createGit(cwd);
|
|
179
|
+
let branches;
|
|
180
|
+
try {
|
|
181
|
+
branches = await git.branchLocal();
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
throw wrapGitError("branch --list", err, cwd);
|
|
185
|
+
}
|
|
186
|
+
return branches.all.includes(branchName);
|
|
187
|
+
}
|
|
188
|
+
export async function getDirtyPaths(cwd) {
|
|
189
|
+
const git = createGit(cwd);
|
|
190
|
+
let status;
|
|
191
|
+
try {
|
|
192
|
+
status = await git.status();
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
throw wrapGitError("status --porcelain", err, cwd);
|
|
196
|
+
}
|
|
197
|
+
const paths = status.files.map((f) => f.path);
|
|
198
|
+
return Array.from(new Set(paths));
|
|
199
|
+
}
|
|
200
|
+
export async function assertValidBranchName(cwd, branchName) {
|
|
201
|
+
const git = createGit(cwd);
|
|
202
|
+
try {
|
|
203
|
+
await git.raw(["check-ref-format", "--branch", branchName]);
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
throw wrapGitError(`check-ref-format --branch ${branchName}`, err, cwd);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function branchBaseKey(branchName) {
|
|
210
|
+
return `branch.${branchName}.keiyakuBase`;
|
|
211
|
+
}
|
|
212
|
+
export async function setKeiyakuBase(cwd, branchName, baseBranch) {
|
|
213
|
+
const git = createGit(cwd);
|
|
214
|
+
try {
|
|
215
|
+
await git.raw(["config", branchBaseKey(branchName), baseBranch]);
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
throw wrapGitError(`config ${branchBaseKey(branchName)} ${baseBranch}`, err, cwd);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
export async function getKeiyakuBase(cwd, branchName) {
|
|
222
|
+
const git = createGit(cwd);
|
|
223
|
+
try {
|
|
224
|
+
const value = await git.raw(["config", "--get", branchBaseKey(branchName)]);
|
|
225
|
+
return value.trim() || null;
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
export async function clearKeiyakuBase(cwd, branchName) {
|
|
232
|
+
const git = createGit(cwd);
|
|
233
|
+
try {
|
|
234
|
+
await git.raw(["config", "--unset", branchBaseKey(branchName)]);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
// Branch config may already be absent.
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function buildDiffPathspec(baseBranch) {
|
|
241
|
+
return [`${baseBranch}...HEAD`, "--", ".", ...DIFF_EXCLUDES];
|
|
242
|
+
}
|
|
243
|
+
function parseNumStat(content) {
|
|
244
|
+
const rows = [];
|
|
245
|
+
for (const line of content.split(/\r?\n/)) {
|
|
246
|
+
if (!line.trim())
|
|
247
|
+
continue;
|
|
248
|
+
const [addRaw, delRaw, ...pathParts] = line.split("\t");
|
|
249
|
+
const filePath = pathParts.join("\t").trim();
|
|
250
|
+
if (!filePath)
|
|
251
|
+
continue;
|
|
252
|
+
const binary = addRaw === "-" || delRaw === "-";
|
|
253
|
+
rows.push({
|
|
254
|
+
path: filePath,
|
|
255
|
+
additions: binary ? 0 : Number.parseInt(addRaw, 10) || 0,
|
|
256
|
+
deletions: binary ? 0 : Number.parseInt(delRaw, 10) || 0,
|
|
257
|
+
binary,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
return rows;
|
|
261
|
+
}
|
|
262
|
+
function parseNameStatus(content) {
|
|
263
|
+
const map = new Map();
|
|
264
|
+
for (const line of content.split(/\r?\n/)) {
|
|
265
|
+
if (!line.trim())
|
|
266
|
+
continue;
|
|
267
|
+
const parts = line.split("\t");
|
|
268
|
+
const statusRaw = (parts[0] ?? "").trim();
|
|
269
|
+
if (!statusRaw)
|
|
270
|
+
continue;
|
|
271
|
+
if ((statusRaw.startsWith("R") || statusRaw.startsWith("C")) && parts.length >= 3) {
|
|
272
|
+
const scoreRaw = statusRaw.slice(1);
|
|
273
|
+
const score = Number.parseInt(scoreRaw, 10);
|
|
274
|
+
const oldPath = (parts[1] ?? "").trim();
|
|
275
|
+
const path = (parts[2] ?? "").trim();
|
|
276
|
+
if (!path)
|
|
277
|
+
continue;
|
|
278
|
+
const status = statusRaw[0] === "R" ? "R" : "C";
|
|
279
|
+
map.set(path, { status, score: Number.isFinite(score) ? score : 0, oldPath, path });
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const path = (parts[1] ?? "").trim();
|
|
283
|
+
if (!path)
|
|
284
|
+
continue;
|
|
285
|
+
// Git can emit single-letter statuses, possibly in combinations; we keep just the first.
|
|
286
|
+
const status = statusRaw[0];
|
|
287
|
+
map.set(path, { status, path });
|
|
288
|
+
}
|
|
289
|
+
return map;
|
|
290
|
+
}
|
|
291
|
+
function splitDiffByFile(content) {
|
|
292
|
+
const sections = [];
|
|
293
|
+
let current = [];
|
|
294
|
+
for (const line of content.split(/\r?\n/)) {
|
|
295
|
+
if (line.startsWith("diff --git ")) {
|
|
296
|
+
if (current.length > 0)
|
|
297
|
+
sections.push(current);
|
|
298
|
+
current = [line];
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (current.length > 0)
|
|
302
|
+
current.push(line);
|
|
303
|
+
}
|
|
304
|
+
if (current.length > 0)
|
|
305
|
+
sections.push(current);
|
|
306
|
+
return sections;
|
|
307
|
+
}
|
|
308
|
+
export async function getKeiyakuDiffStats(cwd, baseBranch) {
|
|
309
|
+
const git = createGit(cwd);
|
|
310
|
+
const pathspec = buildDiffPathspec(baseBranch);
|
|
311
|
+
let rawNumStat;
|
|
312
|
+
try {
|
|
313
|
+
rawNumStat = await git.raw(["diff", "--numstat", ...pathspec]);
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
throw wrapGitError(`diff --numstat ${baseBranch}...HEAD`, err, cwd);
|
|
317
|
+
}
|
|
318
|
+
const rows = parseNumStat(rawNumStat);
|
|
319
|
+
return {
|
|
320
|
+
filesChanged: rows.length,
|
|
321
|
+
insertions: rows.reduce((sum, row) => sum + row.additions, 0),
|
|
322
|
+
deletions: rows.reduce((sum, row) => sum + row.deletions, 0),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function parseDiffPathFromHeader(diffGitLine) {
|
|
326
|
+
// Format: diff --git a/<path> b/<path>
|
|
327
|
+
const parts = diffGitLine.split(" ");
|
|
328
|
+
const bPart = parts[3] ?? "";
|
|
329
|
+
if (!bPart.startsWith("b/"))
|
|
330
|
+
return null;
|
|
331
|
+
return bPart.slice(2);
|
|
332
|
+
}
|
|
333
|
+
function buildPatchPreview(section) {
|
|
334
|
+
// Keep a small, representative prelude (diff header, index, ---/+++), then first N hunks.
|
|
335
|
+
const firstHunkIdx = section.findIndex((l) => l.startsWith("@@ "));
|
|
336
|
+
const prelude = firstHunkIdx === -1 ? section : section.slice(0, firstHunkIdx).slice(0, DIFF_PREVIEW_MAX_PRELUDE_LINES);
|
|
337
|
+
if (firstHunkIdx === -1) {
|
|
338
|
+
const truncated = section.length > DIFF_PREVIEW_MAX_PRELUDE_LINES;
|
|
339
|
+
const lines = truncated
|
|
340
|
+
? [...prelude, `... [truncated ${section.length - prelude.length} prelude line(s)]`]
|
|
341
|
+
: prelude;
|
|
342
|
+
return { patch: lines.join("\n"), truncated };
|
|
343
|
+
}
|
|
344
|
+
const hunks = [];
|
|
345
|
+
let current = [];
|
|
346
|
+
for (const line of section.slice(firstHunkIdx)) {
|
|
347
|
+
if (line.startsWith("@@ ")) {
|
|
348
|
+
if (current.length > 0)
|
|
349
|
+
hunks.push(current);
|
|
350
|
+
current = [line];
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (current.length > 0)
|
|
354
|
+
current.push(line);
|
|
355
|
+
}
|
|
356
|
+
if (current.length > 0)
|
|
357
|
+
hunks.push(current);
|
|
358
|
+
let truncated = false;
|
|
359
|
+
const shownHunks = hunks.slice(0, DIFF_PREVIEW_MAX_HUNKS_PER_FILE).map((hunk) => {
|
|
360
|
+
if (hunk.length <= DIFF_PREVIEW_MAX_LINES_PER_HUNK)
|
|
361
|
+
return hunk;
|
|
362
|
+
truncated = true;
|
|
363
|
+
return [
|
|
364
|
+
...hunk.slice(0, DIFF_PREVIEW_MAX_LINES_PER_HUNK),
|
|
365
|
+
`... [truncated ${hunk.length - DIFF_PREVIEW_MAX_LINES_PER_HUNK} line(s) in this hunk]`,
|
|
366
|
+
];
|
|
367
|
+
});
|
|
368
|
+
if (hunks.length > DIFF_PREVIEW_MAX_HUNKS_PER_FILE) {
|
|
369
|
+
truncated = true;
|
|
370
|
+
}
|
|
371
|
+
const lines = [
|
|
372
|
+
...prelude,
|
|
373
|
+
...shownHunks.flat(),
|
|
374
|
+
...(hunks.length > DIFF_PREVIEW_MAX_HUNKS_PER_FILE
|
|
375
|
+
? [`... [omitted ${hunks.length - DIFF_PREVIEW_MAX_HUNKS_PER_FILE} hunk(s)]`]
|
|
376
|
+
: []),
|
|
377
|
+
];
|
|
378
|
+
return { patch: lines.join("\n"), truncated };
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Gets a compact, structured diff preview for quick review.
|
|
382
|
+
* - Always includes full stats for the whole range.
|
|
383
|
+
* - Includes patch previews for the top churn files only (hunk-based truncation).
|
|
384
|
+
*/
|
|
385
|
+
export async function getKeiyakuDiff(cwd, baseBranch) {
|
|
386
|
+
const git = createGit(cwd);
|
|
387
|
+
const pathspec = buildDiffPathspec(baseBranch);
|
|
388
|
+
const range = `${baseBranch}...HEAD`;
|
|
389
|
+
let rawNumStat;
|
|
390
|
+
try {
|
|
391
|
+
rawNumStat = await git.raw(["diff", "--numstat", ...pathspec]);
|
|
392
|
+
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
throw wrapGitError(`diff --numstat ${range}`, err);
|
|
395
|
+
}
|
|
396
|
+
const rows = parseNumStat(rawNumStat);
|
|
397
|
+
const stats = {
|
|
398
|
+
filesChanged: rows.length,
|
|
399
|
+
insertions: rows.reduce((sum, row) => sum + row.additions, 0),
|
|
400
|
+
deletions: rows.reduce((sum, row) => sum + row.deletions, 0),
|
|
401
|
+
};
|
|
402
|
+
let rawNameStatus = "";
|
|
403
|
+
try {
|
|
404
|
+
rawNameStatus = await git.raw(["diff", "--name-status", ...pathspec]);
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
throw wrapGitError(`diff --name-status ${range}`, err);
|
|
408
|
+
}
|
|
409
|
+
const statusByPath = parseNameStatus(rawNameStatus);
|
|
410
|
+
const sorted = [...rows].sort((a, b) => b.additions + b.deletions - (a.additions + a.deletions));
|
|
411
|
+
const selected = sorted.slice(0, DIFF_PREVIEW_MAX_FILES);
|
|
412
|
+
const omittedFileCount = Math.max(0, rows.length - selected.length);
|
|
413
|
+
const selectedPaths = selected.map((r) => r.path);
|
|
414
|
+
let rawPatch = "";
|
|
415
|
+
if (selectedPaths.length > 0) {
|
|
416
|
+
try {
|
|
417
|
+
rawPatch = await git.raw([
|
|
418
|
+
"diff",
|
|
419
|
+
"--no-color",
|
|
420
|
+
"--no-ext-diff",
|
|
421
|
+
"--unified=3",
|
|
422
|
+
range,
|
|
423
|
+
"--",
|
|
424
|
+
...selectedPaths,
|
|
425
|
+
...DIFF_EXCLUDES,
|
|
426
|
+
]);
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
throw wrapGitError(`diff --no-color --no-ext-diff --unified=3 ${range} -- <top files>`, err);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const sections = rawPatch ? splitDiffByFile(rawPatch) : [];
|
|
433
|
+
const patchByPath = new Map();
|
|
434
|
+
for (const section of sections) {
|
|
435
|
+
const path = parseDiffPathFromHeader(section[0] ?? "");
|
|
436
|
+
if (!path)
|
|
437
|
+
continue;
|
|
438
|
+
patchByPath.set(path, buildPatchPreview(section));
|
|
439
|
+
}
|
|
440
|
+
const files = selected.map((row) => {
|
|
441
|
+
const status = statusByPath.get(row.path);
|
|
442
|
+
const patch = patchByPath.get(row.path)?.patch ?? "";
|
|
443
|
+
const truncated = patchByPath.get(row.path)?.truncated ?? false;
|
|
444
|
+
return {
|
|
445
|
+
path: row.path,
|
|
446
|
+
status: status?.status ?? "M",
|
|
447
|
+
oldPath: status && (status.status === "R" || status.status === "C") ? status.oldPath : undefined,
|
|
448
|
+
additions: row.additions,
|
|
449
|
+
deletions: row.deletions,
|
|
450
|
+
binary: row.binary,
|
|
451
|
+
patch,
|
|
452
|
+
truncated,
|
|
453
|
+
};
|
|
454
|
+
});
|
|
455
|
+
return {
|
|
456
|
+
range,
|
|
457
|
+
excludes: DIFF_EXCLUDES,
|
|
458
|
+
stats,
|
|
459
|
+
files,
|
|
460
|
+
omittedFileCount,
|
|
461
|
+
};
|
|
462
|
+
}
|