@astrosheep/keiyaku 0.1.18 → 0.1.19

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.
@@ -70,57 +70,17 @@ function hintForFlowCode(code, message) {
70
70
  return `\`${preset.tools.start.name}\` requires a clean slate. Remove existing \`KEIYAKU.md\` before starting.`;
71
71
  case "DRAFT_FILE_EXISTS":
72
72
  return `Existing \`KEIYAKU.draft.md\` detected. Use it via \`from_file\` or delete it before starting.`;
73
+ case "DETACHED_HEAD":
74
+ return "Repository is in detached HEAD state. Switch to a branch and retry.";
75
+ case "INVALID_CLOSE_PETITION":
76
+ return "Invalid close petition. Use `CLAIM` or `FORFEIT`.";
77
+ case "INTERNAL_STATE":
78
+ return message;
73
79
  }
74
80
  }
75
- const MESSAGE_HINT_PATTERNS = [
76
- { code: "NOT_GIT_REPO", patterns: ["is not a git repository"] },
77
- { code: "ACTIVE_KEIYAKU_EXISTS", patterns: ["active keiyaku already exists"] },
78
- { code: "EXISTING_KEIYAKU_BRANCH_FOUND", patterns: ["existing keiyaku branch found"] },
79
- { code: "EMPTY_PARAM", patterns: ["cannot be empty"] },
80
- { code: "DIRTY_WORKTREE", patterns: ["working tree has uncommitted changes"] },
81
- { code: "NOT_ACTIVE_KEIYAKU_BRANCH", patterns: ["current branch is not an active keiyaku branch"] },
82
- { code: "MISSING_KEIYAKU_BASE", patterns: ["is missing base metadata"] },
83
- { code: "MISSING_PROTOCOL_FILES", patterns: ["missing protocol files"] },
84
- { code: "DONE_MERGE_CONFLICT", patterns: ["DONE merge conflict"] },
85
- { code: "CLOSE_QUALITY_GATE_FAILED", patterns: ["God's Wrath: CLAIM denied"] },
86
- {
87
- code: "OATH_MISMATCH",
88
- patterns: [
89
- "requires the sacred oath to exactly equal",
90
- "requires oath to exactly match configured value",
91
- "requires oath to match configured value. If template contains",
92
- "To declare DONE, you must solemnly swear the sacred oath.",
93
- "To declare DONE, oath mismatch.",
94
- "Oath mismatch.",
95
- ],
96
- },
97
- {
98
- code: "SUBAGENT_DID_NOT_ADVANCE_ROUND",
99
- patterns: ["subagent did not advance round", "did not append KEIYAKU_TRACE"],
100
- },
101
- { code: "ROUND_SUBAGENT_FAILED", patterns: ["failed during subagent execution"] },
102
- { code: "INVALID_BRANCH_TITLE", patterns: ["cannot be converted to a valid branch name"] },
103
- { code: "UNKNOWN_SUBAGENT", patterns: ["Unknown subagent"] },
104
- { code: "FROM_FILE_NOT_FOUND", patterns: ["from_file path does not exist"] },
105
- { code: "INVALID_KEIYAKU_DRAFT", patterns: ["invalid keiyaku draft"] },
106
- { code: "KEIYAKU_FILE_EXISTS", patterns: ["pre-flight failed: KEIYAKU.md already exists"] },
107
- { code: "DRAFT_FILE_EXISTS", patterns: ["pre-flight failed: KEIYAKU.draft.md exists but from_file does not target it"] },
108
- ];
109
- function inferFlowCodeFromMessage(message) {
110
- for (const entry of MESSAGE_HINT_PATTERNS) {
111
- if (entry.patterns.some((pattern) => message.includes(pattern))) {
112
- return entry.code;
113
- }
114
- }
115
- return null;
116
- }
117
- export function pickHintFromError(err, message) {
81
+ export function pickHintFromError(err) {
118
82
  if (isFlowError(err)) {
119
83
  return hintForFlowCode(err.code, err.message);
120
84
  }
121
- const inferredCode = inferFlowCodeFromMessage(message);
122
- if (inferredCode) {
123
- return hintForFlowCode(inferredCode, message);
124
- }
125
85
  return "Review the error details, fix the issue, and retry.";
126
86
  }
@@ -57,7 +57,7 @@ export const DEFAULT_PRESET = {
57
57
  title: 'Sign Keiyaku',
58
58
  description: 'Initialize a Keiyaku (Contract). Bind a Servant to a dedicated workspace (branch).\nYou are the Architect; they are the Instrument. Define the Goal and Scope clearly.\nThe contract isolates their existence until the objective is met.\nCall ONCE to seal the bond.\n\nFlow: ${start} → [${drive} x N] → ${close}',
59
59
  args: {
60
- from_file: "Optional markdown draft path. Loads `# title` + required sections from file (relative to cwd unless absolute).",
60
+ from_file: "Optional markdown draft path. Loads `# title` + required sections from file (relative to cwd unless absolute). Note: this file is auto-ignored by dirty-tree checks.",
61
61
  title: 'Required unless provided via `from_file`. A concise codename for this hunt.',
62
62
  goal: 'Required unless provided via `from_file`. The Kill Condition for this mission.',
63
63
  directive: 'Optional First Step. Overrides `## Directive` from `from_file` when both are provided.',
@@ -162,7 +162,7 @@ export const POCKET_PRESET = {
162
162
  title: 'I Choose You!',
163
163
  description: 'Initialize the Battle Phase. Send a Critter (Servant) into a dedicated Arena (branch).\nYou are the Trainer; they are the Fighter. Define the Victory Condition (Goal) clearly.\nThe battle continues in this Arena until the Badge is won.\nCall ONCE to start the encounter.\n\nFlow: ${start} → [${drive} x N] → ${close}',
164
164
  args: {
165
- from_file: 'Optional battle plan markdown path with title + sections.',
165
+ from_file: 'Optional battle plan markdown path with title + sections. This file is auto-ignored by dirty-tree checks.',
166
166
  title: 'Required unless provided via `from_file`. Battle card title for this encounter.',
167
167
  goal: 'Required unless provided via `from_file`. Victory condition for this battle.',
168
168
  directive: 'Optional Turn 1 strategy (overrides draft directive).',
@@ -258,7 +258,7 @@ export const MISCHIEF_PRESET = {
258
258
  title: 'Oi!',
259
259
  description: "Initiate Grand Scheme. Summon a Minion to the Lair (branch).\nYou are the Mastermind; they are the Henchman. Define the Conquest (Goal) with dominance.\nThe minion is bound to this Lair until the plot is realized.\nCall ONCE to start the machine.\n\nFlow: ${start} → [${drive} x N] → ${close}",
260
260
  args: {
261
- from_file: 'Optional scheme markdown path with title + sectioned objectives.',
261
+ from_file: 'Optional scheme markdown path with title + sectioned objectives. This file is auto-ignored by dirty-tree checks.',
262
262
  title: 'Required unless provided via `from_file`. Operation codename.',
263
263
  goal: 'Required unless provided via `from_file`. Conquest objective and end-state.',
264
264
  directive: 'Optional first-order command (overrides draft directive).',
@@ -61,10 +61,10 @@ export function createCloseHandler() {
61
61
  const outcome = await presentWork(closeInput);
62
62
  if (input.petition === "CLAIM") {
63
63
  if (!claimInput) {
64
- throw new Error("Unexpected CLAIM input shape");
64
+ throw new FlowError("INTERNAL_STATE", "Unexpected CLAIM input shape");
65
65
  }
66
66
  if (!("result" in outcome) || outcome.result !== "merged") {
67
- throw new Error("Unexpected CLAIM outcome shape");
67
+ throw new FlowError("INTERNAL_STATE", "Unexpected CLAIM outcome shape");
68
68
  }
69
69
  const finalOutcome = outcome;
70
70
  appendDebugLog(`tool close CLAIM success branch=${finalOutcome.currentBranch} base=${finalOutcome.baseBranch}`, {
@@ -84,7 +84,7 @@ export function createCloseHandler() {
84
84
  });
85
85
  }
86
86
  if (!("result" in outcome) || outcome.result !== "dropped") {
87
- throw new Error("Unexpected FORFEIT outcome shape");
87
+ throw new FlowError("INTERNAL_STATE", "Unexpected FORFEIT outcome shape");
88
88
  }
89
89
  const finalOutcome = outcome;
90
90
  appendDebugLog(`tool close FORFEIT success branch=${finalOutcome.currentBranch} base=${finalOutcome.baseBranch}`, {
@@ -17,7 +17,7 @@ export function classifyToolError(error) {
17
17
  export function handleToolError(input) {
18
18
  const message = asMessage(input.error);
19
19
  appendDebugLog(`${input.logLabel} error: ${message}`, { cwd: input.cwd, section: "script" });
20
- const hint = pickHintFromError(input.error, message);
20
+ const hint = pickHintFromError(input.error);
21
21
  const { errorType, errorCode } = classifyToolError(input.error);
22
22
  return buildToolErrorResponse({
23
23
  tool: input.toolName,
@@ -1,4 +1,5 @@
1
1
  import { simpleGit } from "simple-git";
2
+ import { FlowError } from "../common/errors.js";
2
3
  import { appendDebugBlock } from "./debug-log.js";
3
4
  const DEFAULT_GIT_TIMEOUT_MS = 15 * 1000;
4
5
  function readPositiveIntEnv(name, fallback) {
@@ -80,7 +81,7 @@ export async function getCurrentBranch(cwd) {
80
81
  throw wrapGitError("status", err, cwd);
81
82
  }
82
83
  if (!status.current) {
83
- throw new Error("Detached HEAD or no branch found");
84
+ throw new FlowError("DETACHED_HEAD", "Detached HEAD or no branch found");
84
85
  }
85
86
  return status.current;
86
87
  }
@@ -234,6 +235,34 @@ export async function getDirtyPaths(cwd) {
234
235
  const files = await getDirtyFiles(cwd);
235
236
  return Array.from(new Set(files.map(f => f.path)));
236
237
  }
238
+ export async function isPathTracked(cwd, filePath) {
239
+ const git = createGit(cwd);
240
+ let output;
241
+ try {
242
+ output = await git.raw(["ls-files", "--", filePath]);
243
+ }
244
+ catch (err) {
245
+ throw wrapGitError(`ls-files -- ${filePath}`, err, cwd);
246
+ }
247
+ return output
248
+ .split(/\r?\n/)
249
+ .some((line) => line.trim() === filePath);
250
+ }
251
+ export async function discardAllWorkingTreeChanges(cwd) {
252
+ const git = createGit(cwd);
253
+ try {
254
+ await git.raw(["reset", "--hard", "HEAD"]);
255
+ }
256
+ catch (err) {
257
+ throw wrapGitError("reset --hard HEAD", err, cwd);
258
+ }
259
+ try {
260
+ await git.raw(["clean", "-fd"]);
261
+ }
262
+ catch (err) {
263
+ throw wrapGitError("clean -fd", err, cwd);
264
+ }
265
+ }
237
266
  export async function assertValidBranchName(cwd, branchName) {
238
267
  const git = createGit(cwd);
239
268
  try {
@@ -8,17 +8,27 @@ function isPlainObject(value) {
8
8
  export function flattenMarkdownList(text) {
9
9
  const ast = parseToAST(text, { allowSections: false });
10
10
  const items = [];
11
- let currentTitle;
11
+ const headingStack = [];
12
12
  const pushItem = (content) => {
13
13
  const trimmed = content.trim();
14
14
  if (!trimmed)
15
15
  return;
16
- const prefix = currentTitle ? `**${currentTitle.replace(/^\d+\.\s*/, "")}**: ` : "";
16
+ const headingPath = headingStack.map((entry) => entry.title);
17
+ const prefix = headingPath.length > 0 ? `**${headingPath.join(" > ")}**: ` : "";
17
18
  items.push(`${prefix}${trimmed}`);
18
19
  };
20
+ const updateHeadingPath = (level, rawText) => {
21
+ const title = rawText.trim().replace(/^\d+\.\s*/, "");
22
+ if (!title)
23
+ return;
24
+ while (headingStack.length > 0 && headingStack[headingStack.length - 1].level >= level) {
25
+ headingStack.pop();
26
+ }
27
+ headingStack.push({ level, title });
28
+ };
19
29
  for (const node of ast.children) {
20
30
  if (node.type === "heading") {
21
- currentTitle = node.text.trim();
31
+ updateHeadingPath(node.level, node.text);
22
32
  continue;
23
33
  }
24
34
  if (node.type === "list") {
@@ -6,6 +6,7 @@ import { FlowError } from "../common/errors.js";
6
6
  import * as git from "../utils/git.js";
7
7
  import { resolveOath } from "./oath.js";
8
8
  export { resolveOath };
9
+ const DIRTY_WORKTREE_LIST_LIMIT = 10;
9
10
  export async function ensureKeiyakuFiles(cwd) {
10
11
  const keiyakuPath = path.join(cwd, KEIYAKU_FILE);
11
12
  const tracePath = path.join(cwd, TRACE_FILE);
@@ -36,13 +37,21 @@ export async function assertCleanWorkingTree(cwd, ignorePatterns) {
36
37
  .getDirtyFiles(cwd)
37
38
  .then((files) => files.filter((file) => !ignoredPaths.has(normalizePathForDirtyMatch(cwd, file.path))));
38
39
  if (dirtyFiles.length > 0) {
39
- const list = dirtyFiles
40
+ const shown = dirtyFiles.slice(0, DIRTY_WORKTREE_LIST_LIMIT);
41
+ const list = shown
40
42
  .map((f) => {
41
43
  const status = `${f.index}${f.working_dir}`;
42
44
  return ` ${status} ${f.path}`;
43
45
  })
44
46
  .join("\n");
45
- throw new FlowError("DIRTY_WORKTREE", `Uncommitted changes detected in working tree:\n${list}\n\nPlease commit or stash them before proceeding to ensure a stable state.`);
47
+ const overflowCount = dirtyFiles.length - shown.length;
48
+ const overflowLine = overflowCount > 0 ? `\n ... (+${overflowCount} more)` : "";
49
+ const ignoredList = ignoredPaths.size > 0
50
+ ? `\n\nIgnored uncommitted paths (not blocking):\n${Array.from(ignoredPaths)
51
+ .map((pathValue) => ` - ${pathValue}`)
52
+ .join("\n")}`
53
+ : "";
54
+ throw new FlowError("DIRTY_WORKTREE", `Uncommitted changes detected in working tree:\n${list}${overflowLine}${ignoredList}\n\nPlease commit or stash blocking changes before proceeding.`);
46
55
  }
47
56
  }
48
57
  export async function buildNoActiveKeiyakuGuidance(cwd, toolName) {
@@ -55,6 +55,7 @@ export async function driveServant(input) {
55
55
  await appendReview(cwd, plan.targetRound, plan.reviewReason);
56
56
  await git.addFiles(cwd, TRACE_FILE);
57
57
  await git.commit(cwd, `keiyaku(${title}): iterate round ${plan.targetRound}`);
58
+ const commit = await git.getLatestCommitHash(cwd);
58
59
  const { roundSummary } = await runAndRecordRound(cwd, title, plan.targetRound, plan.prompt, {
59
60
  signal,
60
61
  name,
@@ -63,6 +64,7 @@ export async function driveServant(input) {
63
64
  await appendRoundSystemNote(cwd, plan.targetRound, "Subagent execution cancelled by user/client.");
64
65
  await git.addFiles(cwd, TRACE_FILE);
65
66
  await git.commit(cwd, `keiyaku(${title}): round ${plan.targetRound} cancelled`);
67
+ await git.getLatestCommitHash(cwd);
66
68
  },
67
69
  });
68
70
  const summary = renderRoundSummary(roundSummary, TOOL_DEFAULT_POLICY);
@@ -77,6 +79,7 @@ export async function driveServant(input) {
77
79
  criteria,
78
80
  currentBranch: keiyakuBranch,
79
81
  baseBranch,
82
+ commit,
80
83
  };
81
84
  }
82
85
  catch (err) {
@@ -1,6 +1,6 @@
1
1
  import * as fs from "fs/promises";
2
2
  import * as path from "path";
3
- import { KEIYAKU_FILE, TRACE_FILE } from "../common/constants.js";
3
+ import { KEIYAKU_DRAFT_FILE, KEIYAKU_DRAFT_NEW_FILE, KEIYAKU_FILE, TRACE_FILE } from "../common/constants.js";
4
4
  import { resolveTermPreset } from "../config/term-presets.js";
5
5
  import { appendDebugLog } from "../utils/debug-log.js";
6
6
  import { FlowError, wrapFlowError } from "../common/errors.js";
@@ -95,6 +95,27 @@ function requireChecks(name, values) {
95
95
  }
96
96
  return values;
97
97
  }
98
+ async function removeClaimProtocolFiles(cwd) {
99
+ await fs.unlink(path.join(cwd, KEIYAKU_FILE));
100
+ await fs.unlink(path.join(cwd, TRACE_FILE));
101
+ const draftCandidates = [KEIYAKU_DRAFT_FILE, KEIYAKU_DRAFT_NEW_FILE];
102
+ for (const draftPath of draftCandidates) {
103
+ const tracked = await git.isPathTracked(cwd, draftPath);
104
+ if (tracked)
105
+ continue;
106
+ try {
107
+ await fs.unlink(path.join(cwd, draftPath));
108
+ }
109
+ catch (error) {
110
+ if (error?.code !== "ENOENT") {
111
+ throw error;
112
+ }
113
+ }
114
+ }
115
+ }
116
+ function formatCloseDiffSummary(stats, baseBranch) {
117
+ return `Range ${baseBranch}...HEAD | Files ${stats.filesChanged} | +${stats.insertions} / -${stats.deletions}`;
118
+ }
98
119
  export async function presentWork(input) {
99
120
  const { cwd } = input;
100
121
  const isRepo = await git.isGitRepo(cwd);
@@ -120,8 +141,12 @@ export async function presentWork(input) {
120
141
  catch {
121
142
  round = 0;
122
143
  }
123
- await assertCleanWorkingTree(cwd);
144
+ const dirtyFiles = await git.getDirtyFiles(cwd);
145
+ const droppedChanges = dirtyFiles.map((file) => `${file.index}${file.working_dir} ${file.path}`);
124
146
  try {
147
+ if (droppedChanges.length > 0) {
148
+ await git.discardAllWorkingTreeChanges(cwd);
149
+ }
125
150
  await git.checkoutBranch(cwd, baseBranch);
126
151
  await git.deleteBranch(cwd, keiyakuBranch, true);
127
152
  await git.clearKeiyakuBase(cwd, keiyakuBranch);
@@ -136,7 +161,10 @@ export async function presentWork(input) {
136
161
  currentBranch: baseBranch,
137
162
  baseBranch,
138
163
  deletedBranch: keiyakuBranch,
139
- diff: "Forfeited without merge.",
164
+ diff: droppedChanges.length > 0
165
+ ? `Forfeited without merge. Dropped ${droppedChanges.length} local change(s).`
166
+ : "Forfeited without merge.",
167
+ droppedChanges,
140
168
  };
141
169
  }
142
170
  await ensureKeiyakuFiles(cwd);
@@ -169,7 +197,7 @@ export async function presentWork(input) {
169
197
  if (!oathMatches(input.oath, expectedOath)) {
170
198
  throw new FlowError("OATH_MISMATCH", `Oath mismatch. Correct oath: ${expectedOath}`);
171
199
  }
172
- await assertCleanWorkingTree(cwd);
200
+ await assertCleanWorkingTree(cwd, [KEIYAKU_DRAFT_FILE, KEIYAKU_DRAFT_NEW_FILE]);
173
201
  try {
174
202
  const invokeDiffLog = `[CLAIM] Collecting diff preview against base '${baseBranch}'`;
175
203
  console.error(invokeDiffLog);
@@ -179,14 +207,14 @@ export async function presentWork(input) {
179
207
  appendDebugLog(invokeReadLog, { cwd, section: "script" });
180
208
  const keiyakuContent = await fs.readFile(path.join(cwd, KEIYAKU_FILE), "utf-8");
181
209
  const message = buildMergeMessage(title, keiyakuContent, traceContent);
210
+ const diffStats = await git.getDiffStats(cwd, baseBranch);
211
+ const diff = formatCloseDiffSummary(diffStats, baseBranch);
182
212
  const invokeCleanupLog = "[CLAIM] Removing protocol files and creating cleanup commit";
183
213
  console.error(invokeCleanupLog);
184
214
  appendDebugLog(invokeCleanupLog, { cwd, section: "script" });
185
- await fs.unlink(path.join(cwd, KEIYAKU_FILE));
186
- await fs.unlink(path.join(cwd, TRACE_FILE));
215
+ await removeClaimProtocolFiles(cwd);
187
216
  await git.addFiles(cwd, "-A");
188
217
  await git.commit(cwd, `keiyaku(${title}): cleanup`);
189
- const diff = await git.getDiffPreviewText(cwd, baseBranch);
190
218
  const invokeCheckoutLog = `[CLAIM] Checking out base branch '${baseBranch}'`;
191
219
  console.error(invokeCheckoutLog);
192
220
  appendDebugLog(invokeCheckoutLog, { cwd, section: "script" });
@@ -195,7 +223,7 @@ export async function presentWork(input) {
195
223
  console.error(invokeMergeLog);
196
224
  appendDebugLog(invokeMergeLog, { cwd, section: "script" });
197
225
  await git.merge(cwd, keiyakuBranch, message);
198
- const mergedCommit = await git.getLatestCommitHash(cwd);
226
+ const commit = await git.getLatestCommitHash(cwd);
199
227
  const invokeFinalizeLog = `[CLAIM] Deleting merged branch '${keiyakuBranch}' and clearing metadata`;
200
228
  console.error(invokeFinalizeLog);
201
229
  appendDebugLog(invokeFinalizeLog, { cwd, section: "script" });
@@ -208,7 +236,7 @@ export async function presentWork(input) {
208
236
  round,
209
237
  currentBranch: baseBranch,
210
238
  baseBranch,
211
- mergedCommit,
239
+ commit,
212
240
  mergedInto: baseBranch,
213
241
  deletedBranch: keiyakuBranch,
214
242
  diff,
@@ -222,5 +250,5 @@ export async function presentWork(input) {
222
250
  throw wrapFlowError(`execute CLAIM (merge ${keiyakuBranch} into ${baseBranch})`, err);
223
251
  }
224
252
  }
225
- throw new Error(`unsupported close petition: ${petition}`);
253
+ throw new FlowError("INVALID_CLOSE_PETITION", `unsupported close petition: ${petition}`);
226
254
  }
@@ -135,8 +135,12 @@ export function buildKeiyakuSuccessResponse(result, input) {
135
135
  ...formatMaybe("Path", input.cwd, 300),
136
136
  ...formatMaybe("Current Branch", result.currentBranch, 200),
137
137
  ...formatMaybe("Base Branch", result.baseBranch, 200),
138
+ ...formatMaybe("Commit", result.commit, 100),
138
139
  ];
139
- const text = assembleResponse(`◆ Started (Round ${result.round})`, `Created branch '${result.currentBranch}' (base: '${result.baseBranch}').`, [summarySection, constraintsSection, diffSection].filter((section) => section !== null), infoLines);
140
+ const summaryLine = result.commit
141
+ ? `Created branch '${result.currentBranch}' (base: '${result.baseBranch}') [${result.commit}].`
142
+ : `Created branch '${result.currentBranch}' (base: '${result.baseBranch}').`;
143
+ const text = assembleResponse(`◆ Started (Round ${result.round})`, summaryLine, [summarySection, constraintsSection, diffSection].filter((section) => section !== null), infoLines);
140
144
  return {
141
145
  content: [{ type: "text", text }],
142
146
  structuredContent: buildSuccessStructuredContent(getStartToolName(), resultData),
@@ -154,8 +158,12 @@ export function buildDriveResponse(result, input) {
154
158
  ...formatMaybe("Path", input.cwd, 300),
155
159
  ...formatMaybe("Current Branch", result.currentBranch, 200),
156
160
  ...formatMaybe("Base Branch", result.baseBranch, 200),
161
+ ...formatMaybe("Commit", result.commit, 100),
157
162
  ];
158
- const text = assembleResponse(`◆ Driven (Round ${result.round})`, `Updated branch '${result.currentBranch}'.`, [summarySection, goalSection, constraintsSection, criteriaSection, diffSection].filter((section) => section !== null), infoLines);
163
+ const summaryLine = result.commit
164
+ ? `Updated branch '${result.currentBranch}' [${result.commit}].`
165
+ : `Updated branch '${result.currentBranch}'.`;
166
+ const text = assembleResponse(`◆ Driven (Round ${result.round})`, summaryLine, [summarySection, goalSection, constraintsSection, criteriaSection, diffSection].filter((section) => section !== null), infoLines);
159
167
  return {
160
168
  content: [{ type: "text", text }],
161
169
  structuredContent: buildSuccessStructuredContent(getDriveToolName(), resultData),
@@ -181,7 +189,7 @@ export function buildCloseDoneResponse(result, input) {
181
189
  const infoLines = [
182
190
  ...formatMaybe("Path", input.cwd, 300),
183
191
  ...formatMaybe("Result", result.result, 100),
184
- ...formatMaybe("Merged Commit", result.mergedCommit, 100),
192
+ ...formatMaybe("Commit", result.commit, 100),
185
193
  ...formatMaybe("Merged Into", result.mergedInto, 200),
186
194
  ...formatMaybe("Deleted Branch", result.deletedBranch, 200),
187
195
  ...formatMaybe("Current Branch", result.currentBranch, 200),
@@ -191,7 +199,9 @@ export function buildCloseDoneResponse(result, input) {
191
199
  `Scores: precise=${input.score_precise}/10 minimal=${input.score_minimal}/10 isolated=${input.score_isolated}/10 idiomatic=${input.score_idiomatic}/10 cohesive=${input.score_cohesive}/10`,
192
200
  ...formatMaybe("Oath", input.oath, 220),
193
201
  ];
194
- const text = assembleResponse("✓ Keiyaku Fulfilled (CLAIM)", `Merged '${result.deletedBranch}' into '${result.mergedInto}' (commit: ${result.mergedCommit}). Deleted feature branch.`, [typeof diffSection === "string" ? null : diffSection].filter((section) => section !== null), infoLines);
202
+ const text = assembleResponse("✓ Keiyaku Fulfilled (CLAIM)", result.commit
203
+ ? `Merged '${result.deletedBranch}' into '${result.mergedInto}' [${result.commit}]. Deleted feature branch.`
204
+ : `Merged '${result.deletedBranch}' into '${result.mergedInto}'. Deleted feature branch.`, [typeof diffSection === "string" ? null : diffSection].filter((section) => section !== null), infoLines);
195
205
  return {
196
206
  content: [{ type: "text", text }],
197
207
  structuredContent: buildSuccessStructuredContent(closeToolName, resultData),
@@ -200,13 +210,17 @@ export function buildCloseDoneResponse(result, input) {
200
210
  export function buildCloseDropResponse(result, input) {
201
211
  const { status: _status, ...resultData } = result;
202
212
  const closeToolName = getCloseToolName();
213
+ const droppedChanges = result.droppedChanges ?? [];
214
+ const warningSection = buildSection("Warning", formatList("Dropped Local Changes", droppedChanges, { maxItems: 20, maxItemChars: 220 }));
203
215
  const infoLines = [
204
216
  ...formatMaybe("Path", input.cwd, 300),
205
217
  ...formatMaybe("Deleted Branch", result.deletedBranch, 200),
206
218
  ...formatMaybe("Current Branch", result.currentBranch, 200),
207
219
  ...formatMaybe("Base Branch", result.baseBranch, 200),
208
220
  ];
209
- const text = assembleResponse("✗ Keiyaku Forfeited (FORFEIT)", `Deleted '${result.deletedBranch}'. Switched back to '${result.baseBranch}'.`, [], infoLines);
221
+ const text = assembleResponse("✗ Keiyaku Forfeited (FORFEIT)", droppedChanges.length > 0
222
+ ? `Deleted '${result.deletedBranch}'. Switched back to '${result.baseBranch}'. Dropped ${droppedChanges.length} local change(s).`
223
+ : `Deleted '${result.deletedBranch}'. Switched back to '${result.baseBranch}'.`, [warningSection].filter((section) => section !== null), infoLines);
210
224
  return {
211
225
  content: [{ type: "text", text }],
212
226
  structuredContent: buildSuccessStructuredContent(closeToolName, resultData),
@@ -15,6 +15,7 @@ import { runAndRecordRound } from "./round.js";
15
15
  const BASE_CONSTRAINTS_FILE = path.join(".keiyaku", "base-constraints.md");
16
16
  const ACTIVE_KEIYAKU_PREVIEW_MAX_CHARS = 8000;
17
17
  const KNOWN_SECTIONS = new Set(["goal", "directive", "context", "constraints", "criteria", "acceptance criteria"]);
18
+ const INTERNAL_START_DIRTY_ALLOWLIST = [];
18
19
  function normalizeSectionTitle(title) {
19
20
  return title.trim().toLowerCase().replace(/\s+/g, " ");
20
21
  }
@@ -268,6 +269,7 @@ function normalizeTitleForBranch(title) {
268
269
  }
269
270
  async function resolveStartInput(cwd, input) {
270
271
  const fromFile = normalizeOptionalText(input.from_file);
272
+ const dirtyAllowlist = [...INTERNAL_START_DIRTY_ALLOWLIST];
271
273
  const provided = {
272
274
  title: normalizeOptionalText(input.title),
273
275
  goal: normalizeOptionalText(input.goal),
@@ -297,6 +299,7 @@ async function resolveStartInput(cwd, input) {
297
299
  criteria: normalizeMarkdownListItems("criteria", provided.criteria),
298
300
  constraints: normalizeMarkdownListItems("constraints", provided.constraints ?? []),
299
301
  fromFile: undefined,
302
+ dirtyAllowlist,
300
303
  };
301
304
  }
302
305
  const draftPath = path.isAbsolute(fromFile) ? fromFile : path.join(cwd, fromFile);
@@ -337,6 +340,7 @@ async function resolveStartInput(cwd, input) {
337
340
  criteria: normalizeMarkdownListItems("criteria", criteria),
338
341
  constraints: normalizeMarkdownListItems("constraints", constraints),
339
342
  fromFile,
343
+ dirtyAllowlist,
340
344
  };
341
345
  }
342
346
  async function readBaseConstraints(cwd) {
@@ -431,7 +435,8 @@ export async function startKeiyaku(input) {
431
435
  appendDebugLog(branchWarning, { cwd, section: "script" });
432
436
  }
433
437
  await assertStartPreFlight(cwd, resolved.fromFile);
434
- await assertCleanWorkingTree(cwd, resolved.fromFile ? [resolved.fromFile] : undefined);
438
+ const dirtyAllowlist = Array.from(new Set([...(resolved.fromFile ? [resolved.fromFile] : []), ...resolved.dirtyAllowlist]));
439
+ await assertCleanWorkingTree(cwd, dirtyAllowlist.length > 0 ? dirtyAllowlist : undefined);
435
440
  baseBranch = await git.getCurrentBranch(cwd);
436
441
  const branchToken = normalizeTitleForBranch(resolved.title);
437
442
  keiyakuBranch = `keiyaku/${branchToken}`;
@@ -454,6 +459,7 @@ export async function startKeiyaku(input) {
454
459
  await fs.writeFile(path.join(cwd, TRACE_FILE), "# Keiyaku Trace\n");
455
460
  await git.addFiles(cwd, [KEIYAKU_FILE, TRACE_FILE]);
456
461
  await git.commit(cwd, `keiyaku(${branchToken}): open`);
462
+ const commit = await git.getLatestCommitHash(cwd);
457
463
  const prompt = buildStartPrompt(resolved.title, resolved.goal, resolved.directive);
458
464
  const { roundSummary } = await runAndRecordRound(cwd, branchToken, 1, prompt, {
459
465
  signal,
@@ -480,6 +486,7 @@ export async function startKeiyaku(input) {
480
486
  constraints: constraintsSection,
481
487
  currentBranch: keiyakuBranch,
482
488
  baseBranch,
489
+ commit,
483
490
  };
484
491
  }
485
492
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/keiyaku",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "MCP server for running iterative keiyaku workflows with Codex subagents.",
5
5
  "license": "MIT",
6
6
  "type": "module",