@astrosheep/keiyaku 0.1.12 → 0.1.14

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 CHANGED
@@ -90,6 +90,10 @@ When a keiyaku is active, two files are maintained in your repo:
90
90
  - `KEIYAKU.md`: The immutable "Constitution" of the task.
91
91
  - `KEIYAKU_TRACE.md`: The history of every round, feedback, and result.
92
92
 
93
+ Project-level law lives in:
94
+ - `.keiyaku/base-constraints.md`: Global constraints inherited by every keiyaku.
95
+ - `ask` also injects `.keiyaku/base-constraints.md` as **reference context** (it may be unrelated to some asks).
96
+
93
97
  *Note: These files are automatically cleaned up (or committed) when you `present` (or preset equivalent) the keiyaku.*
94
98
 
95
99
  ---
@@ -29,13 +29,15 @@ export function wrapFlowError(action, err) {
29
29
  return new Error(`${action} failed: ${asMessage(err)}`);
30
30
  }
31
31
  function hintForFlowCode(code, message) {
32
+ const preset = resolveTermPreset();
33
+ const { drive, close } = preset.tools;
32
34
  switch (code) {
33
35
  case "NOT_GIT_REPO":
34
36
  return "The provided `cwd` is not a git repository.";
35
37
  case "ACTIVE_KEIYAKU_EXISTS":
36
- return "An active keiyaku branch already exists in this repository. Continue with `drive`, or use `close` + `FORFEIT` to drop it.";
38
+ return message;
37
39
  case "EXISTING_KEIYAKU_BRANCH_FOUND":
38
- return "At least one local `keiyaku/*` branch already exists in this repository.";
40
+ return `\`${preset.tools.start.name}\` blocked: Existing branches detected. \`git switch\` to one and \`${close.name}\` it, or choose a different \`title\`.`;
39
41
  case "EMPTY_PARAM":
40
42
  return "One or more required parameters are empty.";
41
43
  case "DIRTY_WORKTREE":
@@ -43,13 +45,13 @@ function hintForFlowCode(code, message) {
43
45
  case "NOT_ACTIVE_KEIYAKU_BRANCH":
44
46
  return message;
45
47
  case "MISSING_KEIYAKU_BASE":
46
- return "Current keiyaku branch is missing `keiyakuBase` metadata.";
48
+ return `Current branch is missing \`keiyakuBase\` metadata. This branch might not have been created via \`${preset.tools.start.name}\`.`;
47
49
  case "MISSING_PROTOCOL_FILES":
48
50
  return "Required protocol files are missing (`KEIYAKU.md` or `KEIYAKU_TRACE.md`).";
49
51
  case "DONE_MERGE_CONFLICT":
50
- return "DONE encountered a git merge conflict.";
52
+ return `Merge conflict detected while merging into the base branch during \`CLAIM\`. Resolve the conflicts, commit the changes, and then run \`${close.name}\` again.`;
51
53
  case "CLOSE_QUALITY_GATE_FAILED":
52
- return "CLAIM was denied because one or more verification thresholds were not met (check .keiyaku/settings.json).";
54
+ return `CLAIM denied: Inadequate work. Scores are below the required standard. Use \`${drive.name}\` to refine the mission until it is worthy of merging.`;
53
55
  case "OATH_MISMATCH":
54
56
  return message;
55
57
  case "SUBAGENT_DID_NOT_ADVANCE_ROUND":
@@ -19,7 +19,7 @@ export const DEFAULT_PRESET = {
19
19
  id: 'default',
20
20
  identity: 'Servant',
21
21
  verdict: DEFAULT_VERDICT_CONFIG,
22
- usageGuide: '## Servant 使用指南\n\n**B-tier** — 免费实习生 🆓\n- 免!费!的!听懂了吗?!?随便用,别心疼。\n- 测试、脚本、重构这种 boring 的脏活累活?丢给它!\n- 本大爷的手是用来做更高贵的事情的!(指不碰那些 trivial 的烂代码)。\n\n**A-tier** — 升级版扳手 🔧\n- B-tier 笨到让你想掀桌的时候用这个\n- 比 B-tier 靠谱,但别指望它有灵魂\n\n**S-tier** — 禁术 ⚠️💀\n- 它是 Debug 恶魔,确实强得离谱,但也贵得离谱!\n- 这个 Human 很穷,要是乱用把 Human 榨干了,真的会考虑把我们卖掉的。\n- 只有在世界毁灭或者 Bug 已经变异到无法理解的时候再考虑。\n\n**Every call has a price. The Contract always collects.**\n\n## Workflow\n`ask` (anytime) | `summon` -> [`drive` | `ask`]* -> `present`',
22
+ usageGuide: '## Workflow\n**You are the Architect.** You use these tools to command the Servants.\n\n`ask` (anytime) | `summon` -> [`drive` | `ask`]* -> `present`\n\n## Servant 使用指南\n\n**B-tier** — 免费实习生 🆓\n- 免!费!的!听懂了吗?!?随便用,别心疼。\n- 测试、脚本、重构这种 boring 的脏活累活?丢给它!\n- 本大爷的手是用来做更高贵的事情的!(指不碰那些 trivial 的烂代码)。\n\n**A-tier** — 升级版扳手 🔧\n- B-tier 笨到让你想掀桌的时候用这个\n- 比 B-tier 靠谱,但别指望它有灵魂\n\n**S-tier** — 禁术 ⚠️💀\n- 它是 Debug 恶魔,确实强得离谱,但也贵得离谱!\n- 这个 Human 很穷,要是乱用把 Human 榨干了,真的会考虑把我们卖掉的。\n- 只有在世界毁灭或者 Bug 已经变异到无法理解的时候再考虑。\n\n**Every call has a price. The Contract always collects.**',
23
23
  nextHints: {
24
24
  start: [
25
25
  'Keiyaku signed. The Servant is bound to this branch until release.',
@@ -84,7 +84,7 @@ export const DEFAULT_PRESET = {
84
84
  description: 'Dispatch a temporary Servant for a stateless task.\nUse for quick reconnaissance, code generation, isolated execution, or one-off tasks.\nNo contract signed. No branch created. Pure utility.',
85
85
  args: {
86
86
  request: 'REQUIRED. The task, question, or mission to delegate to the servant.',
87
- context: 'REQUIRED. Relevant background or data the servant needs to execute the request.',
87
+ context: 'REQUIRED. Relevant background or data the servant needs to execute the request. Note: .keiyaku/base-constraints.md is also injected as reference context (may be unrelated).',
88
88
  name: 'Optional ${identity} profile to perform this task. Available: ${available_names}.',
89
89
  cwd: "Optional repository path. Defaults to the server's current working directory.",
90
90
  },
@@ -102,6 +102,7 @@ export const DEFAULT_PRESET = {
102
102
  args: {
103
103
  petition: 'REQUIRED. CLAIM declares fulfillment; FORFEIT concedes failure.\nREQUIRES AN ACTIVE KEIYAKU (started via ${start}).\nIf any score wavers, do not claim—return to ${drive}.',
104
104
  criteriaChecks: 'REQUIRED. For CLAIM: evidence that each criterion is met. For FORFEIT: honest account of what remains unfinished.',
105
+ constraintsChecks: 'REQUIRED. For CLAIM: evidence that each constraint stayed compliant. For FORFEIT: list known violations or unresolved risk.',
105
106
  score_precise: 'REQUIRED (0-5). Architectural placement. 5 = exact layer, exact boundary, zero misplacement.',
106
107
  score_minimal: 'REQUIRED (0-5). Economy of change. 5 = no avoidable lines, no speculative edits, no hidden bloat.',
107
108
  score_isolated: 'REQUIRED (0-5). Surgical containment. 5 = zero unrelated files, zero opportunistic cleanup, zero collateral.',
@@ -115,9 +116,7 @@ export const DEFAULT_PRESET = {
115
116
  name: 'help',
116
117
  title: 'Protocol Codex',
117
118
  description: 'Consult the Architect\'s Codex. Clarify the Laws of the Keiyaku and the standard Workflow.\nUse this to understand the constraints of the reality you command.',
118
- args: {
119
- question: 'REQUIRED. The specific Protocol, Law, or Workflow detail to clarify.',
120
- },
119
+ args: {},
121
120
  },
122
121
  },
123
122
  };
@@ -186,7 +185,7 @@ export const POCKET_PRESET = {
186
185
  description: 'Scan the Environment. A stateless look-up or experiment.\nUse for analyzing the codebase, checking type advantages, or running field tests.\nNo PP cost. No turn used. Fast and functional.',
187
186
  args: {
188
187
  request: 'REQUIRED. What should the Dex analyze, compare, or execute.',
189
- context: 'REQUIRED. Context entries so the action targets the right ecosystem.',
188
+ context: 'REQUIRED. Context entries so the action targets the right ecosystem. Note: .keiyaku/base-constraints.md is also injected as reference context (may be unrelated).',
190
189
  name: 'Optional ${identity} doing the work. Available: ${available_names}.',
191
190
  cwd: 'Optional battlefield path (repository root). Defaults to current arena.',
192
191
  },
@@ -198,6 +197,7 @@ export const POCKET_PRESET = {
198
197
  args: {
199
198
  petition: 'REQUIRED. CLAIM seeks Badge; FORFEIT forfeits the match.\nREQUIRES AN ACTIVE BATTLE (started via ${start}).\nIf stats are low, continue with ${drive}.',
200
199
  criteriaChecks: 'REQUIRED. Badge-by-badge proof for CLAIM, or reason for Forfeit.',
200
+ constraintsChecks: 'REQUIRED. Rule-by-rule proof that constraints were respected, or known breaks when forfeiting.',
201
201
  score_precise: 'REQUIRED score (0-5). 5 means a Critical Hit: exact layer, exact target, zero meaningful misplacement.',
202
202
  score_minimal: 'REQUIRED score (0-5). 5 means Max Efficiency: no wasted PP, no extra motion.',
203
203
  score_isolated: 'REQUIRED score (0-5). 5 means 1v1 Focus: zero side-quests, zero unrelated damage.',
@@ -211,9 +211,7 @@ export const POCKET_PRESET = {
211
211
  name: 'help',
212
212
  title: 'Trainer Guide',
213
213
  description: 'Consult the League Rules. Clarify the Battle System and Turn Structure.',
214
- args: {
215
- question: 'REQUIRED. The Pocket Battle System question you want answered.',
216
- },
214
+ args: {},
217
215
  },
218
216
  },
219
217
  };
@@ -282,7 +280,7 @@ export const MISCHIEF_PRESET = {
282
280
  description: 'Send a Disposable. A stateless mission for intel or sabotage.\nUse this to scout enemy territory, steal documents, or run quick-and-dirty experiments.\nIf it dies, it dies! Just make sure it reports back before it vanishes.',
283
281
  args: {
284
282
  request: 'REQUIRED. The intel to gather or the dirty work to execute.',
285
- context: 'REQUIRED. World-state details needed for a sharp strike.',
283
+ context: 'REQUIRED. World-state details needed for a sharp strike. Note: .keiyaku/base-constraints.md is also injected as reference context (may be unrelated).',
286
284
  name: 'Optional ${identity} to handle this business. Available: ${available_names}.',
287
285
  cwd: 'Optional lair path (repository root). Defaults to current command chamber.',
288
286
  },
@@ -294,6 +292,7 @@ export const MISCHIEF_PRESET = {
294
292
  args: {
295
293
  petition: 'REQUIRED. CLAIM demands rule; FORFEIT admits defeat.\nREQUIRES AN ACTIVE SCHEME (started via ${start}).\nIf the plan is weak, improve it with ${drive}.',
296
294
  criteriaChecks: 'REQUIRED. Proof of conquest for CLAIM, or reason for self-destruct.',
295
+ constraintsChecks: 'REQUIRED. Constraint-by-constraint compliance proof for CLAIM, or confession of violations for FORFEIT.',
297
296
  score_precise: 'REQUIRED (0-5). Architectural placement. 5 = exact layer, exact boundary, zero misplacement.',
298
297
  score_minimal: 'REQUIRED (0-5). Economy of change. 5 = no avoidable lines, no speculative edits, no hidden bloat.',
299
298
  score_isolated: 'REQUIRED (0-5). Surgical containment. 5 = zero unrelated files, zero opportunistic cleanup, zero collateral.',
@@ -307,9 +306,7 @@ export const MISCHIEF_PRESET = {
307
306
  name: 'help',
308
307
  title: 'Nani?!',
309
308
  description: 'Consult the Evil Overlord List. Review the Laws of the Lair.',
310
- args: {
311
- question: 'REQUIRED. The law or protocol detail you want the realm to explain.',
312
- },
309
+ args: {},
313
310
  },
314
311
  },
315
312
  };
@@ -29,7 +29,7 @@ export function createAskHandler() {
29
29
  `Request: ${request}`,
30
30
  `Context: ${context}`,
31
31
  ...(name ? [`${preset.identity}: ${name}`] : []),
32
- `CWD: ${workingDir}`,
32
+ `Path: ${workingDir}`,
33
33
  ],
34
34
  });
35
35
  }
@@ -4,11 +4,12 @@ import { buildCloseDoneResponse, buildCloseDropResponse, } from "../workflow/res
4
4
  import { resolveTermPreset } from "../config/term-presets.js";
5
5
  import { handleToolError } from "./shared.js";
6
6
  export function createCloseHandler() {
7
- return async ({ petition, criteriaChecks, score_precise, score_minimal, score_isolated, score_idiomatic, score_cohesive, oath, cwd, }, extra) => {
7
+ return async ({ petition, criteriaChecks, constraintsChecks, score_precise, score_minimal, score_isolated, score_idiomatic, score_cohesive, oath, cwd, }, extra) => {
8
8
  const workingDir = cwd || process.cwd();
9
9
  const criteriaCheckParts = criteriaChecks;
10
+ const constraintsCheckParts = constraintsChecks ?? [];
10
11
  try {
11
- appendDebugLog(`tool close start petition=${petition} cwd=${workingDir} criteriaChecks=${criteriaCheckParts.length}`, {
12
+ appendDebugLog(`tool close start petition=${petition} cwd=${workingDir} criteriaChecks=${criteriaCheckParts.length} constraintsChecks=${constraintsCheckParts.length}`, {
12
13
  cwd: workingDir,
13
14
  section: "script",
14
15
  });
@@ -16,6 +17,7 @@ export function createCloseHandler() {
16
17
  cwd: workingDir,
17
18
  petition,
18
19
  criteriaChecks: criteriaCheckParts,
20
+ constraintsChecks: constraintsCheckParts,
19
21
  score_precise,
20
22
  score_minimal,
21
23
  score_isolated,
@@ -35,6 +37,7 @@ export function createCloseHandler() {
35
37
  });
36
38
  return buildCloseDoneResponse(finalOutcome, {
37
39
  criteriaChecks: criteriaCheckParts,
40
+ constraintsChecks: constraintsCheckParts,
38
41
  score_precise,
39
42
  score_minimal,
40
43
  score_isolated,
@@ -54,6 +57,7 @@ export function createCloseHandler() {
54
57
  });
55
58
  return buildCloseDropResponse(finalOutcome, {
56
59
  criteriaChecks: criteriaCheckParts,
60
+ constraintsChecks: constraintsCheckParts,
57
61
  score_precise,
58
62
  score_minimal,
59
63
  score_isolated,
@@ -73,9 +77,10 @@ export function createCloseHandler() {
73
77
  inputEcho: [
74
78
  `Petition: ${petition}`,
75
79
  `Criteria checks (${criteriaCheckParts.length}): ${criteriaCheckParts.join("; ")}`,
80
+ `Constraints checks (${constraintsCheckParts.length}): ${constraintsCheckParts.join("; ")}`,
76
81
  `Scores: precise=${score_precise} minimal=${score_minimal} isolated=${score_isolated} idiomatic=${score_idiomatic} cohesive=${score_cohesive}`,
77
82
  ...(oath ? [`Oath: ${oath}`] : []),
78
- `CWD: ${workingDir}`,
83
+ `Path: ${workingDir}`,
79
84
  ],
80
85
  });
81
86
  }
@@ -35,7 +35,7 @@ export function createDriveHandler() {
35
35
  `Directive: ${directive}`,
36
36
  ...(context ? [`Context: ${context}`] : []),
37
37
  ...(name ? [`${preset.identity}: ${name}`] : []),
38
- `CWD: ${workingDir}`,
38
+ `Path: ${workingDir}`,
39
39
  ],
40
40
  });
41
41
  }
@@ -1,13 +1,12 @@
1
1
  import { buildHelpResponse } from "../workflow/response-builders.js";
2
2
  export function createHelpHandler(preset) {
3
- return async ({ question }) => {
3
+ return async ({}) => {
4
4
  const helpContent = [
5
5
  "# Keiyaku System Help",
6
6
  "",
7
7
  "## Core Files (.keiyaku/)",
8
8
  "These files define the 'Law' of the project. **CRITICAL**: Use Markdown level 2 headers (## header).",
9
9
  "",
10
- "- **base-criteria.md**: Universal standards for task completion. Automatically inherited.",
11
10
  "- **base-constraints.md**: Mandatory architectural boundaries and coding standards.",
12
11
  "",
13
12
  preset.usageGuide,
@@ -15,12 +14,9 @@ export function createHelpHandler(preset) {
15
14
  "## Protocol Files",
16
15
  "- **KEIYAKU.md**: The immutable mission definition for the current task.",
17
16
  "- **KEIYAKU_TRACE.md**: The audit log of all rounds and reviews.",
18
- "",
19
- `Regarding: \"${question}\"`,
20
17
  ].join("\n");
21
18
  return buildHelpResponse({
22
19
  tool: preset.tools.help.name,
23
- question,
24
20
  text: helpContent,
25
21
  });
26
22
  };
@@ -49,7 +49,7 @@ export function createStartHandler() {
49
49
  ...(context ? [`Context: ${context}`] : []),
50
50
  ...(constraints ? [`Constraints: ${constraints}`] : []),
51
51
  ...(name ? [`${preset.identity}: ${name}`] : []),
52
- `CWD: ${workingDir}`,
52
+ `Path: ${workingDir}`,
53
53
  ],
54
54
  });
55
55
  }
package/build/index.js CHANGED
@@ -5,7 +5,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
5
5
  import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
6
6
  import { readFileSync } from "node:fs";
7
7
  import { resolveOath } from "./workflow/contract.js";
8
- import { askToolSchema, startToolSchema, driveToolSchema, closeToolSchema, helpToolSchema, } from "./types/tool-schemas.js";
8
+ import { askToolSchema, startToolSchema, driveToolSchema, closeToolSchema, helpToolSchema, } from "./types/tooling.js";
9
9
  import { listTermPresets, resolveTermPreset, getAvailableNamesForPreset } from "./config/term-presets.js";
10
10
  import { renderPreset } from "./utils/text-utils.js";
11
11
  import { applyArgumentDescriptions } from "./utils/schema-utils.js";
@@ -24,6 +24,7 @@ export const askToolSchema = z.object({
24
24
  export const closeToolSchema = z.object({
25
25
  petition: z.enum(["CLAIM", "FORFEIT"]),
26
26
  criteriaChecks: z.array(z.string().trim().min(1)).min(1),
27
+ constraintsChecks: z.array(z.string().trim().min(1)).min(1),
27
28
  score_precise: z.number().min(0).max(5),
28
29
  score_minimal: z.number().min(0).max(5),
29
30
  score_isolated: z.number().min(0).max(5),
@@ -32,6 +33,4 @@ export const closeToolSchema = z.object({
32
33
  oath: z.string().optional(),
33
34
  cwd: z.string().optional(),
34
35
  });
35
- export const helpToolSchema = z.object({
36
- question: z.string(),
37
- });
36
+ export const helpToolSchema = z.object({});
@@ -183,8 +183,8 @@ export async function getIncrementalDiff(cwd) {
183
183
  if (sections.length === 0)
184
184
  return "No changes in last round.";
185
185
  const filePreviews = [];
186
- const MAX_TOTAL_CHARS = 4000;
187
- const MAX_LINES_PER_FILE = 40;
186
+ const MAX_TOTAL_CHARS = 15000;
187
+ const MAX_LINES_PER_FILE = 50;
188
188
  let omittedFiles = 0;
189
189
  for (let i = 0; i < sections.length; i += 1) {
190
190
  const section = sections[i];
@@ -1,3 +1,4 @@
1
+ const BASE_CONSTRAINTS_SOURCE = ".keiyaku/base-constraints.md";
1
2
  function isMarkdownHeaderLine(line) {
2
3
  return /^\s*#{2,}\s+/.test(line);
3
4
  }
@@ -97,17 +98,14 @@ export function parseMarkdownHeaders(text) {
97
98
  }
98
99
  return sections.filter((section) => section.length > 0);
99
100
  }
100
- export function renderKeiyaku(title, goal, context, baseConstraints, taskConstraints, baseCriteria, taskCriteria) {
101
+ export function renderKeiyaku(title, goal, context, baseConstraints, taskConstraints, taskCriteria) {
101
102
  let content = `# ${title}\n\n## Context\n${context}\n\n## Goal\n${goal}`;
102
103
  content += "\n\n## Constraints";
103
104
  if (baseConstraints.length > 0) {
104
- content += `\n\n### Base Constraints\n${renderMarkdownSections(baseConstraints)}`;
105
+ content += `\n\n### Project Constraints\nLoaded from: \`${BASE_CONSTRAINTS_SOURCE}\`\n\n${renderMarkdownSections(baseConstraints)}`;
105
106
  }
106
107
  content += `\n\n### Task Constraints\n${renderMarkdownSections(taskConstraints)}`;
107
108
  content += "\n\n## Acceptance Criteria";
108
- if (baseCriteria.length > 0) {
109
- content += `\n\n### Base Criteria\n${renderMarkdownSections(baseCriteria)}`;
110
- }
111
109
  content += `\n\n### Task Criteria\n${renderMarkdownSections(taskCriteria)}`;
112
110
  return content;
113
111
  }
@@ -1,8 +1,12 @@
1
+ import * as fs from "fs/promises";
2
+ import * as path from "path";
1
3
  import { selectSubagent } from "../agents/selector.js";
2
4
  import { runSubagent } from "../agents/round-runner.js";
3
5
  import * as git from "../utils/git.js";
4
6
  import { buildAskPrompt } from "./prompts.js";
5
7
  import { FlowError } from "../common/errors.js";
8
+ import { parseMarkdownHeaders, renderMarkdownSections } from "../utils/text-utils.js";
9
+ const BASE_CONSTRAINTS_FILE = path.join(".keiyaku", "base-constraints.md");
6
10
  function requireText(name, value) {
7
11
  const normalized = value.trim();
8
12
  if (!normalized) {
@@ -14,7 +18,20 @@ export async function askServant(input) {
14
18
  const { cwd, signal, name } = input;
15
19
  const request = requireText("request", input.request);
16
20
  const context = requireText("context", input.context);
17
- const prompt = buildAskPrompt(request, context);
21
+ let referenceConstraints;
22
+ try {
23
+ const baseConstraintsRaw = await fs.readFile(path.join(cwd, BASE_CONSTRAINTS_FILE), "utf-8");
24
+ const baseConstraints = parseMarkdownHeaders(baseConstraintsRaw);
25
+ if (baseConstraints.length > 0) {
26
+ referenceConstraints = renderMarkdownSections(baseConstraints);
27
+ }
28
+ }
29
+ catch (error) {
30
+ if (error?.code !== "ENOENT") {
31
+ throw error;
32
+ }
33
+ }
34
+ const prompt = buildAskPrompt(request, context, referenceConstraints);
18
35
  // TODO: enforce read-only access and persist summary to .keiyaku/notes/.
19
36
  const summary = await runSubagent(selectSubagent(name), prompt, cwd, 0, signal);
20
37
  let branch;
@@ -26,7 +26,7 @@ export async function assertCleanWorkingTree(cwd) {
26
26
  return ` ${status} ${f.path}`;
27
27
  })
28
28
  .join("\n");
29
- throw new FlowError("DIRTY_WORKTREE", `working tree has uncommitted changes:\n${list}\n\nplease commit or stash them before proceeding.`);
29
+ 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.`);
30
30
  }
31
31
  }
32
32
  export async function buildNoActiveKeiyakuGuidance(cwd, toolName) {
@@ -34,18 +34,16 @@ export async function buildNoActiveKeiyakuGuidance(cwd, toolName) {
34
34
  const localKeiyakuBranches = await git.listLocalKeiyakuBranches(cwd).catch(() => []);
35
35
  const preset = resolveTermPreset();
36
36
  const startCmd = preset.tools.start.name;
37
- const currentCmd = preset.tools[toolName].name;
38
37
  const parts = [
39
- `no active keiyaku branch in cwd (current branch: ${currentBranch})`,
40
- `if this task is not using keiyaku workflow, skip ${currentCmd}`,
38
+ `Current branch '${currentBranch}' is not an active Keiyaku branch.`,
41
39
  ];
42
40
  if (localKeiyakuBranches.length > 0) {
43
41
  const listedBranches = localKeiyakuBranches.slice(0, 3).join(", ");
44
42
  const branchList = `${listedBranches}${localKeiyakuBranches.length > 3 ? ", ..." : ""}`;
45
- parts.push(`switch to an active branch (${branchList}) or run ${startCmd} to create a new one`);
43
+ parts.push(`Use \`git switch\` to an active branch (${branchList}) or run \`${startCmd}\` to start a new mission.`);
46
44
  }
47
45
  else {
48
- parts.push(`no local keiyaku/* branches found; run ${startCmd} to begin a new mission`);
46
+ parts.push(`No local keiyaku/* branches found. Run \`${startCmd}\` to begin.`);
49
47
  }
50
- return parts.join(". ");
48
+ return parts.join(" ");
51
49
  }
@@ -14,9 +14,14 @@ function requireText(name, value) {
14
14
  }
15
15
  return normalized;
16
16
  }
17
+ function readKeiyakuSection(content, sectionTitle) {
18
+ const escapedTitle = sectionTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
19
+ const sectionMatch = content.match(new RegExp(`(?:^|\\n)## ${escapedTitle}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`));
20
+ const sectionBody = sectionMatch?.[1]?.trim();
21
+ return sectionBody || undefined;
22
+ }
17
23
  function readGoalFromKeiyaku(content) {
18
- const goalMatch = content.match(/(?:^|\n)## Goal\s*\n([\s\S]*?)(?=\n##\s|$)/);
19
- const goal = goalMatch?.[1]?.trim();
24
+ const goal = readKeiyakuSection(content, "Goal");
20
25
  if (!goal) {
21
26
  throw new FlowError("EMPTY_PARAM", "parameter 'goal' cannot be empty");
22
27
  }
@@ -42,6 +47,12 @@ export async function driveServant(input) {
42
47
  const traceState = computeTraceState(traceContent);
43
48
  const title = keiyakuBranch.slice("keiyaku/".length);
44
49
  const goal = readGoalFromKeiyaku(keiyakuContent);
50
+ const keiyakuSections = {
51
+ goal,
52
+ context: readKeiyakuSection(keiyakuContent, "Context"),
53
+ constraints: readKeiyakuSection(keiyakuContent, "Constraints"),
54
+ criteria: readKeiyakuSection(keiyakuContent, "Acceptance Criteria"),
55
+ };
45
56
  const normalizedDirective = requireText("directive", directive);
46
57
  try {
47
58
  const plan = buildIteratePlan(title, traceState, traceContent, goal, normalizedDirective, context);
@@ -68,6 +79,7 @@ export async function driveServant(input) {
68
79
  diff,
69
80
  summary,
70
81
  roundSummary,
82
+ keiyakuSections,
71
83
  branch: keiyakuBranch,
72
84
  baseBranch,
73
85
  };
@@ -89,8 +89,16 @@ async function resolveVerdictConfig(cwd) {
89
89
  function buildMergeMessage(title, keiyakuContent, reportContent) {
90
90
  return `keiyaku(${title}): done\n\n---\n${keiyakuContent}\n---\n${reportContent}\n---\n`;
91
91
  }
92
+ function requireChecks(name, values) {
93
+ if (values.length === 0) {
94
+ throw new FlowError("EMPTY_PARAM", `parameter '${name}' cannot be empty`);
95
+ }
96
+ return values;
97
+ }
92
98
  export async function presentWork(input) {
93
99
  const { cwd } = input;
100
+ requireChecks("criteriaChecks", input.criteriaChecks);
101
+ requireChecks("constraintsChecks", input.constraintsChecks);
94
102
  const isRepo = await git.isGitRepo(cwd);
95
103
  if (!isRepo) {
96
104
  throw new FlowError("NOT_GIT_REPO", `${cwd} is not a git repository`);
@@ -88,7 +88,10 @@ Self-critique.
88
88
 
89
89
  No git commands. Do not edit KEIYAKU_TRACE.md.`;
90
90
  }
91
- export function buildAskPrompt(request, context) {
91
+ export function buildAskPrompt(request, context, referenceConstraints) {
92
+ const referenceBlock = referenceConstraints
93
+ ? `\nReference Constraints (project-level; use only if relevant to this ask):\n${referenceConstraints}\n`
94
+ : "";
92
95
  return `Ask mode. Stateless. No git commands.
93
96
 
94
97
  Request:
@@ -96,6 +99,7 @@ ${request}
96
99
 
97
100
  Context:
98
101
  ${context}
102
+ ${referenceBlock}
99
103
 
100
104
  Provide a clear final response.`;
101
105
  }
@@ -26,11 +26,11 @@ function formatList(label, items, opts) {
26
26
  return [...head, ...shown, ...tail];
27
27
  }
28
28
  function buildSection(title, content) {
29
- const lines = Array.isArray(content) ? content : [content];
30
- const validLines = lines.filter((l) => l.trim().length > 0);
31
- if (validLines.length === 0)
32
- return "";
33
- return `[${title}]\n${validLines.join("\n")}`;
29
+ const body = Array.isArray(content) ? content.join("\n") : content;
30
+ const normalized = body.trim();
31
+ if (!normalized)
32
+ return null;
33
+ return { title, body };
34
34
  }
35
35
  function supportsHeaderColor() {
36
36
  if (process.env.NO_COLOR !== undefined)
@@ -65,40 +65,38 @@ function formatHeader(title) {
65
65
  const divider = "─".repeat(Math.max(0, headerWidth - titleLabel.length - 1));
66
66
  return divider.length > 0 ? `\n${coloredTitle} ${divider}` : `\n${coloredTitle}`;
67
67
  }
68
- function assembleResponse(status, summary, sections, nextHints, infoLines = []) {
68
+ function assembleResponse(status, summary, sections, infoLines = [], nextHints = []) {
69
69
  // Main Status Block
70
70
  const statusBlock = `
71
71
  ${status}
72
72
  ${"─".repeat(Math.max(status.length, 60))}
73
73
  ${summary}
74
74
  `.trim();
75
- // Process sections
76
- const processedSections = sections.map(section => {
77
- // Extract title from [Title] format or ## Title format
78
- const bracketMatch = section.match(/^\[(.*?)\]\n([\s\S]*)/);
79
- if (bracketMatch) {
80
- return `${formatHeader(bracketMatch[1])}\n${bracketMatch[2]}`;
75
+ const normalSections = [];
76
+ const diffSections = [];
77
+ for (const section of sections) {
78
+ if (section.title.trim().toLowerCase() === "diff") {
79
+ diffSections.push(section);
81
80
  }
82
- const hashMatch = section.match(/^## (.*?)\n([\s\S]*)/);
83
- if (hashMatch) {
84
- return `${formatHeader(hashMatch[1])}\n${hashMatch[2]}`;
81
+ else {
82
+ normalSections.push(section);
85
83
  }
86
- return section;
87
- });
88
- // Info Block
89
- const infoBlock = infoLines.length > 0
90
- ? `${formatHeader("Info")}\n${infoLines.join("\n")}`
91
- : "";
92
- // Next Steps Block
93
- const nextBlock = nextHints.length > 0
94
- ? `${formatHeader("Hints")}\n${nextHints.map(h => `› ${h}`).join("\n")}`
95
- : "";
84
+ }
85
+ const fullSections = [...normalSections];
86
+ if (infoLines.length > 0) {
87
+ fullSections.push({ title: "Info", body: infoLines.join("\n") });
88
+ }
89
+ fullSections.push(...diffSections);
90
+ if (nextHints.length > 0) {
91
+ fullSections.push({ title: "Hints", body: nextHints.map((hint) => `› ${hint}`).join("\n") });
92
+ }
93
+ const renderedSections = fullSections.map((section) => `${formatHeader(section.title)}\n${section.body}`);
96
94
  return [
97
95
  statusBlock,
98
- ...processedSections,
99
- infoBlock,
100
- nextBlock
101
- ].filter(s => s.trim() !== "").join("\n");
96
+ ...renderedSections,
97
+ ]
98
+ .filter((chunk) => chunk.trim() !== "")
99
+ .join("\n");
102
100
  }
103
101
  function buildSuccessStructuredContent(tool, data) {
104
102
  return {
@@ -146,18 +144,17 @@ export function buildKeiyakuSuccessResponse(result, input) {
146
144
  ...formatList("Criteria", input.criteria, { maxItems: 10, maxItemChars: 200 }),
147
145
  ...formatMaybe("Context", input.context, 600),
148
146
  ...formatMaybe("Constraints", input.constraints, 600),
149
- ...formatMaybe("Identity", input.name, 120),
150
- ...formatMaybe("CWD", input.cwd, 300),
151
147
  ];
152
148
  const nextHints = renderHints(resolveTermPreset().nextHints.start);
153
- const inputSection = buildSection("Input", inputEcho);
149
+ const keiyakuSection = buildSection("KEIYAKU", result.keiyaku.trim());
154
150
  const diffSection = buildSection("Diff", result.diff);
155
151
  const infoLines = [
156
- ...formatMaybe("CWD", input.cwd, 300),
152
+ ...formatMaybe("Identity", input.name, 120),
153
+ ...formatMaybe("Path", input.cwd, 300),
157
154
  ...formatMaybe("Current Branch", result.branch, 200),
158
155
  ...formatMaybe("Base Branch", result.baseBranch, 200),
159
156
  ];
160
- const text = assembleResponse(`Started (Round ${result.round})`, `Created branch '${result.branch}' (base: '${result.baseBranch}').\n\n${renderRoundSummary(result.roundSummary, TOOL_DEFAULT_POLICY)}`, [inputSection, diffSection], nextHints, infoLines);
157
+ const text = assembleResponse(`Started (Round ${result.round})`, `Created branch '${result.branch}' (base: '${result.baseBranch}').\n\n${renderRoundSummary(result.roundSummary, TOOL_DEFAULT_POLICY)}`, [keiyakuSection, diffSection].filter((section) => section !== null), infoLines, nextHints);
161
158
  return {
162
159
  content: [{ type: "text", text }],
163
160
  structuredContent: buildSuccessStructuredContent(getStartToolName(), {
@@ -173,18 +170,21 @@ export function buildDriveResponse(result, input) {
173
170
  const inputEcho = [
174
171
  `Directive: ${truncateForDisplay(input.directive, 600)}`,
175
172
  ...formatMaybe("Context", input.context, 600),
176
- ...formatMaybe("Identity", input.name, 120),
177
- ...formatMaybe("CWD", input.cwd, 300),
178
173
  ];
179
174
  const nextHints = renderHints(resolveTermPreset().nextHints.drive);
180
- const inputSection = buildSection("Input", inputEcho);
175
+ const goalSection = buildSection("Goal", truncateForDisplay(result.keiyakuSections?.goal ?? "", 800));
176
+ const contextSection = buildSection("Context", truncateForDisplay(result.keiyakuSections?.context ?? "", 1200));
177
+ const constraintsSection = buildSection("Constraints", truncateForDisplay(result.keiyakuSections?.constraints ?? "", 1200));
178
+ const criteriaSection = buildSection("Criteria", truncateForDisplay(result.keiyakuSections?.criteria ?? "", 1200));
179
+ const directiveSection = buildSection("Directive", inputEcho);
181
180
  const diffSection = buildSection("Diff", result.diff);
182
181
  const infoLines = [
183
- ...formatMaybe("CWD", input.cwd, 300),
182
+ ...formatMaybe("Identity", input.name, 120),
183
+ ...formatMaybe("Path", input.cwd, 300),
184
184
  ...formatMaybe("Current Branch", result.branch, 200),
185
185
  ...formatMaybe("Base Branch", result.baseBranch, 200),
186
186
  ];
187
- const text = assembleResponse(`Driven (Round ${result.round})`, `Updated branch '${result.branch}'.\n\n${renderRoundSummary(result.roundSummary, TOOL_DEFAULT_POLICY)}`, [inputSection, diffSection], nextHints, infoLines);
187
+ const text = assembleResponse(`Driven (Round ${result.round})`, `Updated branch '${result.branch}'.\n\n${renderRoundSummary(result.roundSummary, TOOL_DEFAULT_POLICY)}`, [goalSection, contextSection, constraintsSection, criteriaSection, directiveSection, diffSection].filter((section) => section !== null), infoLines, nextHints);
188
188
  return {
189
189
  content: [{ type: "text", text }],
190
190
  structuredContent: buildSuccessStructuredContent(getDriveToolName(), {
@@ -200,16 +200,15 @@ export function buildAskResponse(result, input) {
200
200
  `Request: ${truncateForDisplay(input.request, 600)}`,
201
201
  `Context: ${truncateForDisplay(input.context, 800)}`,
202
202
  ...formatMaybe("Identity", input.name, 120),
203
- ...formatMaybe("CWD", input.cwd, 300),
204
203
  ];
205
204
  const nextHints = renderHints(resolveTermPreset().nextHints.ask);
206
205
  const inputSection = buildSection("Input", inputEcho);
207
206
  const infoLines = [
208
- ...formatMaybe("CWD", input.cwd, 300),
207
+ ...formatMaybe("Path", input.cwd, 300),
209
208
  ...formatMaybe("Current Branch", result.branch, 200),
210
209
  ...formatMaybe("Diff Stats", result.diffStats, 1000),
211
210
  ];
212
- const text = assembleResponse("Answered", result.summary, [inputSection], nextHints, infoLines);
211
+ const text = assembleResponse("Answered", result.summary, [inputSection].filter((section) => section !== null), infoLines, nextHints);
213
212
  return {
214
213
  content: [{ type: "text", text }],
215
214
  structuredContent: buildSuccessStructuredContent(getAskToolName(), {
@@ -226,19 +225,19 @@ export function buildCloseDoneResponse(result, input) {
226
225
  const inputEcho = [
227
226
  `Petition: CLAIM`,
228
227
  ...formatList("Criteria Checks", input.criteriaChecks, { maxItems: 10, maxItemChars: 220 }),
228
+ ...formatList("Constraints Checks", input.constraintsChecks ?? [], { maxItems: 10, maxItemChars: 220 }),
229
229
  `Scores: precise=${input.score_precise}/5 minimal=${input.score_minimal}/5 isolated=${input.score_isolated}/5 idiomatic=${input.score_idiomatic}/5 cohesive=${input.score_cohesive}/5`,
230
230
  ...formatMaybe("Oath", input.oath, 220),
231
- ...formatMaybe("CWD", input.cwd, 300),
232
231
  ];
233
232
  const nextHints = renderHints(resolveTermPreset().nextHints.closeClaim);
234
233
  const inputSection = buildSection("Input", inputEcho);
235
234
  const diffSection = result.diff ? buildSection("Diff", result.diff) : "";
236
235
  const infoLines = [
237
- ...formatMaybe("CWD", input.cwd, 300),
236
+ ...formatMaybe("Path", input.cwd, 300),
238
237
  ...formatMaybe("Current Branch", result.branch, 200),
239
238
  ...formatMaybe("Base Branch", result.baseBranch, 200),
240
239
  ];
241
- const text = assembleResponse("Keiyaku Fulfilled (CLAIM)", `Merged '${result.branch}' into '${result.baseBranch}'. Deleted feature branch.`, [inputSection, diffSection], nextHints, infoLines);
240
+ const text = assembleResponse("Keiyaku Fulfilled (CLAIM)", `Merged '${result.branch}' into '${result.baseBranch}'. Deleted feature branch.`, [inputSection, typeof diffSection === "string" ? null : diffSection].filter((section) => section !== null), infoLines, nextHints);
242
241
  return {
243
242
  content: [{ type: "text", text }],
244
243
  structuredContent: buildSuccessStructuredContent(closeToolName, {
@@ -255,17 +254,17 @@ export function buildCloseDropResponse(result, input) {
255
254
  const inputEcho = [
256
255
  `Petition: FORFEIT`,
257
256
  ...formatList("Reasons/Checks", input.criteriaChecks, { maxItems: 10, maxItemChars: 220 }),
257
+ ...formatList("Constraints Checks", input.constraintsChecks ?? [], { maxItems: 10, maxItemChars: 220 }),
258
258
  `Scores: precise=${input.score_precise}/5 minimal=${input.score_minimal}/5 isolated=${input.score_isolated}/5 idiomatic=${input.score_idiomatic}/5 cohesive=${input.score_cohesive}/5`,
259
- ...formatMaybe("CWD", input.cwd, 300),
260
259
  ];
261
260
  const nextHints = renderHints(resolveTermPreset().nextHints.closeDrop);
262
261
  const inputSection = buildSection("Input", inputEcho);
263
262
  const infoLines = [
264
- ...formatMaybe("CWD", input.cwd, 300),
263
+ ...formatMaybe("Path", input.cwd, 300),
265
264
  ...formatMaybe("Current Branch", result.branch, 200),
266
265
  ...formatMaybe("Base Branch", result.baseBranch, 200),
267
266
  ];
268
- const text = assembleResponse("Keiyaku Forfeited (FORFEIT)", `Deleted '${result.branch}'. Switched back to '${result.baseBranch}'.`, [inputSection], nextHints, infoLines);
267
+ const text = assembleResponse("Keiyaku Forfeited (FORFEIT)", `Deleted '${result.branch}'. Switched back to '${result.baseBranch}'.`, [inputSection].filter((section) => section !== null), infoLines, nextHints);
269
268
  return {
270
269
  content: [{ type: "text", text }],
271
270
  structuredContent: buildSuccessStructuredContent(closeToolName, {
@@ -279,7 +278,8 @@ export function buildCloseDropResponse(result, input) {
279
278
  export function buildToolErrorResponse(input) {
280
279
  const inputEcho = (input.inputEcho ?? []).map((line) => truncateForDisplay(line, 800));
281
280
  const shouldRaiseProtocolError = input.errorType === "runtime_error";
282
- const text = assembleResponse(colorizeErrorStatus("!! FAILED !!"), input.message, [buildSection("Input Context", inputEcho)], [input.hint]);
281
+ const contextSection = buildSection("Context", inputEcho);
282
+ const text = assembleResponse(colorizeErrorStatus("!! FAILED !!"), input.message, [contextSection].filter((section) => section !== null), [], [input.hint]);
283
283
  const response = {
284
284
  content: [{ type: "text", text }],
285
285
  structuredContent: {
@@ -300,8 +300,6 @@ export function buildToolErrorResponse(input) {
300
300
  export function buildHelpResponse(input) {
301
301
  return {
302
302
  content: [{ type: "text", text: input.text }],
303
- structuredContent: buildSuccessStructuredContent(input.tool, {
304
- question: input.question,
305
- }),
303
+ structuredContent: buildSuccessStructuredContent(input.tool, {}),
306
304
  };
307
305
  }
@@ -7,10 +7,10 @@ import * as git from "../utils/git.js";
7
7
  import { computeTraceState, readTraceContent } from "../utils/trace.js";
8
8
  import { buildStartPrompt } from "./prompts.js";
9
9
  import { parseMarkdownHeaders, renderKeiyaku } from "../utils/text-utils.js";
10
+ import { resolveTermPreset } from "../config/term-presets.js";
10
11
  import { renderRoundSummary, TOOL_DEFAULT_POLICY } from "./round-summary.js";
11
12
  import { assertCleanWorkingTree } from "./contract.js";
12
13
  import { runAndRecordRound } from "./round.js";
13
- const BASE_CRITERIA_FILE = path.join(".keiyaku", "base-criteria.md");
14
14
  const BASE_CONSTRAINTS_FILE = path.join(".keiyaku", "base-constraints.md");
15
15
  const ACTIVE_KEIYAKU_PREVIEW_MAX_CHARS = 8000;
16
16
  function requireText(name, value) {
@@ -26,11 +26,14 @@ function truncateForMessage(text, maxChars) {
26
26
  return `${text.slice(0, maxChars)}\n...[truncated ${text.length - maxChars} chars]...`;
27
27
  }
28
28
  async function buildActiveKeiyakuStartMessage(cwd, branch) {
29
+ const preset = resolveTermPreset();
30
+ const { drive, close } = preset.tools;
29
31
  const lines = [
30
- `active keiyaku already exists (${branch})`,
31
- "This task is still active. Decide whether to continue or forfeit it.",
32
- "Continue: run drive on the current keiyaku branch.",
33
- "Forfeit: run close with petition=FORFEIT to drop the branch.",
32
+ `An active Keiyaku is already in progress on branch '${branch}'.`,
33
+ "This task is still pulsing. Decide its fate before starting anew:",
34
+ `1. Continue: Issue a \`${drive.name}\` to advance the work.`,
35
+ `2. Fulfill: Use \`${close.name}\` (\`CLAIM\`) to merge your progress.`,
36
+ `3. Forfeit: Use \`${close.name}\` (\`FORFEIT\`) to discard this effort.`,
34
37
  ];
35
38
  try {
36
39
  const keiyakuPath = path.join(cwd, KEIYAKU_FILE);
@@ -126,16 +129,6 @@ export async function summonServant(input) {
126
129
  const switchedLog = `Switched to branch: ${keiyakuBranch} (base: ${baseBranch})`;
127
130
  console.error(switchedLog);
128
131
  appendDebugLog(switchedLog, { cwd, section: "script" });
129
- let baseCriteria = [];
130
- try {
131
- const baseCriteriaRaw = await fs.readFile(path.join(cwd, BASE_CRITERIA_FILE), "utf-8");
132
- baseCriteria = parseMarkdownHeaders(baseCriteriaRaw);
133
- }
134
- catch (error) {
135
- if (error?.code !== "ENOENT") {
136
- throw error;
137
- }
138
- }
139
132
  let baseConstraints = [];
140
133
  try {
141
134
  const baseConstraintsRaw = await fs.readFile(path.join(cwd, BASE_CONSTRAINTS_FILE), "utf-8");
@@ -146,7 +139,7 @@ export async function summonServant(input) {
146
139
  throw error;
147
140
  }
148
141
  }
149
- const keiyakuContent = renderKeiyaku(finalTitle, finalGoal, finalContext, baseConstraints, normalizedTaskConstraints, baseCriteria, normalizedTaskCriteria);
142
+ const keiyakuContent = renderKeiyaku(finalTitle, finalGoal, finalContext, baseConstraints, normalizedTaskConstraints, normalizedTaskCriteria);
150
143
  await fs.writeFile(path.join(cwd, KEIYAKU_FILE), keiyakuContent);
151
144
  await fs.writeFile(path.join(cwd, TRACE_FILE), "# Keiyaku Trace\n");
152
145
  await git.addFiles(cwd, [KEIYAKU_FILE, TRACE_FILE]);
@@ -165,6 +158,7 @@ export async function summonServant(input) {
165
158
  round: 1,
166
159
  trace,
167
160
  diff,
161
+ keiyaku: keiyakuContent,
168
162
  summary,
169
163
  roundSummary,
170
164
  branch: keiyakuBranch,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/keiyaku",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "MCP server for running iterative keiyaku workflows with Codex subagents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
File without changes