@astrosheep/keiyaku 0.1.3 → 0.1.5

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.
@@ -28,92 +28,85 @@ export function wrapFlowError(action, err) {
28
28
  }
29
29
  return new Error(`${action} failed: ${asMessage(err)}`);
30
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 message;
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 "CLOSE_QUALITY_GATE_FAILED":
53
- return "INVOKE was denied by Divine Judgment because one or more commandment thresholds or the minimum total score were not met.";
54
- case "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 "ROUND_SUBAGENT_FAILED":
59
- return "Subagent execution failed. Review KEIYAKU_TRACE.md for details, then continue with a narrower directive.";
60
- case "INVALID_BRANCH_TITLE":
61
- return "Title cannot be converted to a valid branch token for `keiyaku/<title>`.";
62
- case "UNKNOWN_SUBAGENT":
63
- return unknownSubagentHint();
64
- default:
65
- return "Review the error details, fix the issue, and retry.";
66
- }
67
- }
68
- // Fallback for untyped runtime errors.
69
- if (message.includes("is not a git repository")) {
70
- return "The provided `cwd` is not a git repository.";
71
- }
72
- if (message.includes("active keiyaku already exists")) {
73
- return "An active keiyaku branch already exists in this repository.";
74
- }
75
- if (message.includes("existing keiyaku branch found")) {
76
- return "At least one local `keiyaku/*` branch already exists in this repository.";
77
- }
78
- if (message.includes("parameter") && message.includes("cannot be empty")) {
79
- return "One or more required parameters are empty.";
80
- }
81
- if (message.includes("working tree has uncommitted changes")) {
82
- return "The working tree has uncommitted changes.";
83
- }
84
- if (message.includes("current branch is not an active keiyaku branch")) {
85
- return "No active keiyaku branch (`keiyaku/*`). If this task is not using keiyaku workflow, skip this tool call.";
86
- }
87
- if (message.includes("is missing base metadata")) {
88
- return "Current keiyaku branch is missing `keiyakuBase` metadata.";
31
+ function hintForFlowCode(code, message) {
32
+ switch (code) {
33
+ case "NOT_GIT_REPO":
34
+ return "The provided `cwd` is not a git repository.";
35
+ case "ACTIVE_KEIYAKU_EXISTS":
36
+ return "An active keiyaku branch already exists in this repository. Continue with `drive`, or use `close` + `ABANDON` to drop it.";
37
+ case "EXISTING_KEIYAKU_BRANCH_FOUND":
38
+ return "At least one local `keiyaku/*` branch already exists in this repository.";
39
+ case "EMPTY_PARAM":
40
+ return "One or more required parameters are empty.";
41
+ case "DIRTY_WORKTREE":
42
+ return "The working tree has uncommitted changes.";
43
+ case "NOT_ACTIVE_KEIYAKU_BRANCH":
44
+ return message;
45
+ case "MISSING_KEIYAKU_BASE":
46
+ return "Current keiyaku branch is missing `keiyakuBase` metadata.";
47
+ case "MISSING_PROTOCOL_FILES":
48
+ return "Required protocol files are missing (`KEIYAKU.md` or `KEIYAKU_TRACE.md`).";
49
+ case "DONE_MERGE_CONFLICT":
50
+ return "DONE encountered a git merge conflict.";
51
+ case "CLOSE_QUALITY_GATE_FAILED":
52
+ return "INVOKE was denied by Divine Judgment because one or more commandment thresholds or the minimum total score were not met.";
53
+ case "OATH_MISMATCH":
54
+ return message;
55
+ case "SUBAGENT_DID_NOT_ADVANCE_ROUND":
56
+ return "Subagent run did not append the expected new round in `KEIYAKU_TRACE.md`.";
57
+ case "ROUND_SUBAGENT_FAILED":
58
+ return "Subagent execution failed. Review KEIYAKU_TRACE.md for details, then continue with a narrower directive.";
59
+ case "INVALID_BRANCH_TITLE":
60
+ return "Title cannot be converted to a valid branch token for `keiyaku/<title>`.";
61
+ case "UNKNOWN_SUBAGENT":
62
+ return unknownSubagentHint();
89
63
  }
90
- if (message.includes("missing protocol files")) {
91
- return "Required protocol files are missing (`KEIYAKU.md` or `KEIYAKU_TRACE.md`).";
92
- }
93
- if (message.includes("DONE merge conflict")) {
94
- return "DONE encountered a git merge conflict.";
95
- }
96
- if (message.includes("God's Wrath: INVOKE denied")) {
97
- return "INVOKE was denied by Divine Judgment because one or more commandment thresholds or the minimum total score were not met.";
98
- }
99
- if (message.includes("requires the sacred oath to exactly equal") ||
100
- message.includes("requires oath to exactly match configured value") ||
101
- message.includes("requires oath to match configured value. If template contains") ||
102
- message.includes("To declare DONE, you must solemnly swear the sacred oath.") ||
103
- message.includes("To declare DONE, oath mismatch.")) {
104
- return message;
105
- }
106
- if (message.includes("subagent did not advance round") || message.includes("did not append KEIYAKU_TRACE")) {
107
- return "Subagent run did not append the expected new round in `KEIYAKU_TRACE.md`.";
108
- }
109
- if (message.includes("failed during subagent execution")) {
110
- return "Subagent execution failed. Review KEIYAKU_TRACE.md for details, then continue with a narrower directive.";
64
+ }
65
+ const MESSAGE_HINT_PATTERNS = [
66
+ { code: "NOT_GIT_REPO", patterns: ["is not a git repository"] },
67
+ { code: "ACTIVE_KEIYAKU_EXISTS", patterns: ["active keiyaku already exists"] },
68
+ { code: "EXISTING_KEIYAKU_BRANCH_FOUND", patterns: ["existing keiyaku branch found"] },
69
+ { code: "EMPTY_PARAM", patterns: ["cannot be empty"] },
70
+ { code: "DIRTY_WORKTREE", patterns: ["working tree has uncommitted changes"] },
71
+ { code: "NOT_ACTIVE_KEIYAKU_BRANCH", patterns: ["current branch is not an active keiyaku branch"] },
72
+ { code: "MISSING_KEIYAKU_BASE", patterns: ["is missing base metadata"] },
73
+ { code: "MISSING_PROTOCOL_FILES", patterns: ["missing protocol files"] },
74
+ { code: "DONE_MERGE_CONFLICT", patterns: ["DONE merge conflict"] },
75
+ { code: "CLOSE_QUALITY_GATE_FAILED", patterns: ["God's Wrath: INVOKE denied"] },
76
+ {
77
+ code: "OATH_MISMATCH",
78
+ patterns: [
79
+ "requires the sacred oath to exactly equal",
80
+ "requires oath to exactly match configured value",
81
+ "requires oath to match configured value. If template contains",
82
+ "To declare DONE, you must solemnly swear the sacred oath.",
83
+ "To declare DONE, oath mismatch.",
84
+ "Oath mismatch.",
85
+ ],
86
+ },
87
+ {
88
+ code: "SUBAGENT_DID_NOT_ADVANCE_ROUND",
89
+ patterns: ["subagent did not advance round", "did not append KEIYAKU_TRACE"],
90
+ },
91
+ { code: "ROUND_SUBAGENT_FAILED", patterns: ["failed during subagent execution"] },
92
+ { code: "INVALID_BRANCH_TITLE", patterns: ["cannot be converted to a valid branch name"] },
93
+ { code: "UNKNOWN_SUBAGENT", patterns: ["Unknown subagent"] },
94
+ ];
95
+ function inferFlowCodeFromMessage(message) {
96
+ for (const entry of MESSAGE_HINT_PATTERNS) {
97
+ if (entry.patterns.some((pattern) => message.includes(pattern))) {
98
+ return entry.code;
99
+ }
111
100
  }
112
- if (message.includes("cannot be converted to a valid branch name")) {
113
- return "Title cannot be converted to a valid branch token for `keiyaku/<title>`.";
101
+ return null;
102
+ }
103
+ export function pickHintFromError(err, message) {
104
+ if (isFlowError(err)) {
105
+ return hintForFlowCode(err.code, err.message);
114
106
  }
115
- if (message.includes("Unknown subagent")) {
116
- return unknownSubagentHint();
107
+ const inferredCode = inferFlowCodeFromMessage(message);
108
+ if (inferredCode) {
109
+ return hintForFlowCode(inferredCode, message);
117
110
  }
118
111
  return "Review the error details, fix the issue, and retry.";
119
112
  }
@@ -52,37 +52,37 @@ export const DEFAULT_PRESET = {
52
52
  start: {
53
53
  name: 'summon',
54
54
  title: 'Sign Keiyaku',
55
- description: 'Sign the Keiyaku. Creates a dedicated branch, locks in the mission, and initiates the iterative loop.\nCall ONCE per task to start the cycle. Workspace must be clean.\nBefore signing, define the kill condition clearly. If the target is blurry, use ask to scout first.\n\nFlow: summon → [drive x N] → request_verdict',
55
+ description: 'Sign the Keiyaku and assign the mission to a servant. This creates a dedicated branch where the servant will labor until the goal is met.\nCall ONCE per task to start the cycle. Workspace must be clean.\n\nFlow: summon → [drive x N] → request_verdict',
56
56
  args: {
57
57
  title: 'REQUIRED. A concise codename for this hunt.',
58
- goal: 'REQUIRED. The Kill Condition. State exactly what success looks like in the code.',
59
- directive: 'Optional Round 1 Focus. Use for complex hunts to leash the servant to a specific starting point. Skip for simple tasks.',
60
- context: 'REQUIRED. Mission Intel. The complete knowledge base: current vs. expected behavior, relevant file paths, error logs, and any critical background info.',
58
+ goal: 'REQUIRED. The Kill Condition. State exactly what success looks like for the servant to achieve.',
59
+ directive: 'Optional Round 1 Focus. Use to leash the servant to a specific starting point. Skip for simple tasks.',
60
+ context: 'REQUIRED. Mission Intel. The complete knowledge base for the servant: current vs. expected behavior, relevant file paths, error logs, and any critical background info.',
61
61
  constraints: 'REQUIRED. Non-negotiable Rules. Architectural and stylistic boundaries the servant must obey.',
62
- criteria: 'REQUIRED. Acceptance Criteria. Verifiable checks to prove the job is done.',
63
- name: 'Optional ${IDENTITY} profile for this run. Presets: ${PRESET_IDENTITIES}.',
62
+ criteria: 'REQUIRED. Acceptance Criteria. Verifiable checks to prove the servant has finished the job.',
63
+ name: 'Optional ${IDENTITY} profile to execute this mission. Presets: ${PRESET_IDENTITIES}.',
64
64
  cwd: "Optional repository path. Defaults to the server's current working directory.",
65
65
  },
66
66
  },
67
67
  drive: {
68
68
  name: 'drive',
69
69
  title: 'Iterate',
70
- description: "Push the mission forward. Use this to issue the next command, whether it's correcting a mistake or advancing to the next phase of a complex kill.\nMANDATORY: Review the code (git diff) before iterating. Verify the last strike before ordering the next.\n\nFlow: summon → [drive x N] → request_verdict",
70
+ description: "Direct the active servant's next step. Provide feedback or new orders for the servant to implement in the current branch.\nMANDATORY: Review the servant's work (git diff) before iterating.\n\nFlow: summon → [drive x N] → request_verdict",
71
71
  args: {
72
- directive: 'REQUIRED. The Next Order. Precise instructions for this round. Can be a correction ("fix the leak") or a continuation ("now add the tests").',
73
- context: 'Optional. New Intel. New error logs, discovered edge cases, or details for the next phase.',
74
- name: 'Optional ${IDENTITY} profile for this round. Presets: ${PRESET_IDENTITIES}.',
72
+ directive: 'REQUIRED. The Next Order. Precise instructions for the servant. Can be a correction ("fix the leak") or a continuation ("now add the tests").',
73
+ context: 'Optional. New Intel. New error logs or details discovered after the servant\'s last strike.',
74
+ name: 'Optional ${IDENTITY} profile to process this turn. Presets: ${PRESET_IDENTITIES}.',
75
75
  cwd: "Optional repository path. Defaults to the server's current working directory.",
76
76
  },
77
77
  },
78
78
  ask: {
79
79
  name: 'ask',
80
80
  title: 'Ask',
81
- description: 'Versatile, lightweight task runner. Use for investigative scouting, quick analysis, strategic consultation, or one-off executive tasks (like scripts or docs) that don\'t require the full keiyaku ceremony.\nResults and session logs are saved to .keiyaku/notes/.',
81
+ description: 'Delegate a quick task or analysis to a servant. Use for scouting, strategic consultation, or one-off tasks (like scripts or docs) where you want the servant to handle it without a full keiyaku.',
82
82
  args: {
83
- request: 'REQUIRED. The question, analysis request, consultation topic, or tactical mission (e.g., "write a cleanup script").',
84
- context: 'REQUIRED. Relevant background or data needed to execute the request.',
85
- name: 'Optional ${IDENTITY} profile for this ask. Presets: ${PRESET_IDENTITIES}.',
83
+ request: 'REQUIRED. The task, question, or mission to delegate to the servant.',
84
+ context: 'REQUIRED. Relevant background or data the servant needs to execute the request.',
85
+ name: 'Optional ${IDENTITY} profile to perform this task. Presets: ${PRESET_IDENTITIES}.',
86
86
  cwd: "Optional repository path. Defaults to the server's current working directory.",
87
87
  },
88
88
  },
@@ -93,11 +93,11 @@ export const DEFAULT_PRESET = {
93
93
  args: {
94
94
  petition: 'REQUIRED. INVOKE begs acceptance; ABANDON confesses surrender.\nREQUIRES AN ACTIVE KEIYAKU (started via ${START_TOOL_NAME}).\nIf scores are weak, return to ${DRIVE_TOOL_NAME}.',
95
95
  criteriaChecks: 'REQUIRED. Evidence for INVOKE, or explicit reasons for ABANDON.',
96
- score_precise: 'REQUIRED score (0-5). 5 means the change lands exactly in the rightful architectural location.',
97
- score_minimal: 'REQUIRED score (0-5). 5 means no excess diff beyond necessity.',
98
- score_isolated: 'REQUIRED score (0-5). 5 means unrelated work is absent.',
99
- score_idiomatic: 'REQUIRED score (0-5). 5 means naming and structure honor repository conventions.',
100
- score_cohesive: 'REQUIRED score (0-5). 5 means responsibilities are clear and boundaries are pure.',
96
+ score_precise: 'REQUIRED score (0-5). 5 means architecturally flawless placement: exact layer, exact boundary, zero meaningful misplacement risk.',
97
+ score_minimal: 'REQUIRED score (0-5). 5 means ruthlessly minimal: no avoidable lines, no speculative edits, no hidden bloat.',
98
+ score_isolated: 'REQUIRED score (0-5). 5 means surgically isolated: zero unrelated files, zero opportunistic cleanup, zero collateral change.',
99
+ score_idiomatic: 'REQUIRED score (0-5). 5 means idiomatically perfect: naming, structure, and style are native-quality with no visible friction.',
100
+ score_cohesive: 'REQUIRED score (0-5). 5 means cohesion is pristine: each unit has one clear responsibility and boundaries are uncompromised.',
101
101
  oath: 'Sacred confession text. Required for INVOKE. Verbatim: ${OATH_TEXT}',
102
102
  cwd: "Optional repository path. Defaults to the server's current working directory.",
103
103
  },
@@ -189,11 +189,11 @@ export const POKEMON_PRESET = {
189
189
  args: {
190
190
  petition: 'REQUIRED. INVOKE seeks blessing; ABANDON retreats in humility.\nREQUIRES AN ACTIVE BATTLE (started via ${START_TOOL_NAME}).\nIf scores are unworthy, continue with ${DRIVE_TOOL_NAME}.',
191
191
  criteriaChecks: 'REQUIRED. Badge-by-badge proof for INVOKE, or confession for ABANDON.',
192
- score_precise: 'REQUIRED score (0-5). 5 means each strike hit the intended layer perfectly.',
193
- score_minimal: 'REQUIRED score (0-5). 5 means no unnecessary moves were used.',
194
- score_isolated: 'REQUIRED score (0-5). 5 means no side-quests polluted this battle.',
195
- score_idiomatic: "REQUIRED score (0-5). 5 means tactics match the team's doctrine.",
196
- score_cohesive: 'REQUIRED score (0-5). 5 means each action held one clear purpose.',
192
+ score_precise: 'REQUIRED score (0-5). 5 means a flawless hit: exact layer, exact target, zero meaningful misplacement.',
193
+ score_minimal: 'REQUIRED score (0-5). 5 means perfectly efficient: no unnecessary moves, no extra motion, no bloat.',
194
+ score_isolated: 'REQUIRED score (0-5). 5 means pure battle focus: zero side-quests, zero unrelated damage.',
195
+ score_idiomatic: "REQUIRED score (0-5). 5 means doctrine-perfect execution: reads exactly like the team's strongest native style.",
196
+ score_cohesive: 'REQUIRED score (0-5). 5 means immaculate role clarity: each action serves one purpose with clean boundaries.',
197
197
  oath: "Trainer's sacred confession. Required for INVOKE. Verbatim: ${OATH_TEXT}",
198
198
  cwd: 'Optional battlefield path (repository root). Defaults to current arena.',
199
199
  },
@@ -285,11 +285,11 @@ export const MISCHIEF_PRESET = {
285
285
  args: {
286
286
  petition: 'REQUIRED. INVOKE pleads for ascension; ABANDON confesses failure.\nREQUIRES AN ACTIVE SCHEME (started via ${START_TOOL_NAME}).\nIf scores are low, continue via ${DRIVE_TOOL_NAME}.',
287
287
  criteriaChecks: 'REQUIRED. Proof of mastery for INVOKE, or explicit confession for ABANDON.',
288
- score_precise: 'REQUIRED score (0-5). 5 means the strike landed in the ordained layer.',
289
- score_minimal: 'REQUIRED score (0-5). 5 means no needless machinery was summoned.',
290
- score_isolated: 'REQUIRED score (0-5). 5 means unrelated chaos stayed outside the diff.',
291
- score_idiomatic: 'REQUIRED score (0-5). 5 means the code speaks native doctrine.',
292
- score_cohesive: 'REQUIRED score (0-5). 5 means each unit bears one sacred duty.',
288
+ score_precise: 'REQUIRED score (0-5). 5 means ordained precision without flaw: exact layer, exact cut, zero meaningful drift.',
289
+ score_minimal: 'REQUIRED score (0-5). 5 means ascetic minimality: no needless machinery, no decorative motion, no excess.',
290
+ score_isolated: 'REQUIRED score (0-5). 5 means perfect containment: unrelated chaos remains fully outside this diff.',
291
+ score_idiomatic: 'REQUIRED score (0-5). 5 means native-tongue perfection: style and structure match house doctrine without strain.',
292
+ score_cohesive: 'REQUIRED score (0-5). 5 means sacred cohesion: each unit bears exactly one duty with uncompromised boundaries.',
293
293
  oath: "Architect's sacred confession. Required for INVOKE. Verbatim: ${OATH_TEXT}",
294
294
  cwd: 'Optional lair path (repository root). Defaults to current command chamber.',
295
295
  },
@@ -1,9 +1,8 @@
1
1
  import { appendDebugLog } from "../utils/debug-log.js";
2
- import { asMessage, pickHintFromError } from "../common/errors.js";
3
2
  import { handleAsk } from "../workflow/orchestrator.js";
4
- import { buildAskResponse, buildToolErrorResponse, } from "../workflow/response-builders.js";
3
+ import { buildAskResponse, } from "../workflow/response-builders.js";
5
4
  import { resolveTermPreset } from "../config/term-presets.js";
6
- import { classifyToolError } from "./shared.js";
5
+ import { handleToolError } from "./shared.js";
7
6
  export function createAskHandler() {
8
7
  return async ({ request, context, name, cwd }, extra) => {
9
8
  const workingDir = cwd || process.cwd();
@@ -20,22 +19,16 @@ export function createAskHandler() {
20
19
  return buildAskResponse(result, { request, context, name, cwd: workingDir });
21
20
  }
22
21
  catch (err) {
23
- const message = asMessage(err);
24
- appendDebugLog(`tool ask error: ${message}`, { cwd: workingDir, section: "script" });
25
- const hint = pickHintFromError(err, message);
26
- const { errorType, errorCode } = classifyToolError(err);
27
- const { identity } = resolveTermPreset();
28
- return buildToolErrorResponse({
29
- tool: "ask",
30
- title: "Ask",
31
- message,
32
- hint,
33
- errorType,
34
- errorCode,
22
+ const preset = resolveTermPreset();
23
+ return handleToolError({
24
+ error: err,
25
+ toolName: preset.tools.ask.name,
26
+ cwd: workingDir,
27
+ logLabel: "tool ask",
35
28
  inputEcho: [
36
29
  `Request: ${request}`,
37
30
  `Context: ${context}`,
38
- ...(name ? [`${identity}: ${name}`] : []),
31
+ ...(name ? [`${preset.identity}: ${name}`] : []),
39
32
  `CWD: ${workingDir}`,
40
33
  ],
41
34
  });
@@ -1,9 +1,9 @@
1
1
  import { appendDebugLog } from "../utils/debug-log.js";
2
- import { asMessage, pickHintFromError } from "../common/errors.js";
3
2
  import { handleClose } from "../workflow/orchestrator.js";
4
- import { buildCloseDoneResponse, buildCloseDropResponse, buildToolErrorResponse, } from "../workflow/response-builders.js";
5
- import { classifyToolError } from "./shared.js";
6
- export function createCloseHandler(toolInfo) {
3
+ import { buildCloseDoneResponse, buildCloseDropResponse, } from "../workflow/response-builders.js";
4
+ import { resolveTermPreset } from "../config/term-presets.js";
5
+ import { handleToolError } from "./shared.js";
6
+ export function createCloseHandler() {
7
7
  return async ({ petition, criteriaChecks, 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;
@@ -64,17 +64,12 @@ export function createCloseHandler(toolInfo) {
64
64
  });
65
65
  }
66
66
  catch (err) {
67
- const message = asMessage(err);
68
- appendDebugLog(`tool close error: ${message}`, { cwd: workingDir, section: "script" });
69
- const hint = pickHintFromError(err, message);
70
- const { errorType, errorCode } = classifyToolError(err);
71
- return buildToolErrorResponse({
72
- tool: toolInfo.name,
73
- title: toolInfo.title,
74
- message,
75
- hint,
76
- errorType,
77
- errorCode,
67
+ const preset = resolveTermPreset();
68
+ return handleToolError({
69
+ error: err,
70
+ toolName: preset.tools.close.name,
71
+ cwd: workingDir,
72
+ logLabel: "tool close",
78
73
  inputEcho: [
79
74
  `Petition: ${petition}`,
80
75
  `Criteria checks (${criteriaCheckParts.length}): ${criteriaCheckParts.join("; ")}`,
@@ -1,9 +1,8 @@
1
1
  import { appendDebugLog } from "../utils/debug-log.js";
2
- import { asMessage, pickHintFromError } from "../common/errors.js";
3
2
  import { handleDrive } from "../workflow/orchestrator.js";
4
- import { buildDriveResponse, buildToolErrorResponse, } from "../workflow/response-builders.js";
3
+ import { buildDriveResponse, } from "../workflow/response-builders.js";
5
4
  import { resolveTermPreset } from "../config/term-presets.js";
6
- import { classifyToolError } from "./shared.js";
5
+ import { handleToolError } from "./shared.js";
7
6
  export function createDriveHandler() {
8
7
  return async ({ directive, context, name, cwd }, extra) => {
9
8
  const workingDir = cwd || process.cwd();
@@ -26,22 +25,16 @@ export function createDriveHandler() {
26
25
  return buildDriveResponse(outcome, { directive, context, name, cwd: workingDir });
27
26
  }
28
27
  catch (err) {
29
- const message = asMessage(err);
30
- appendDebugLog(`tool drive error: ${message}`, { cwd: workingDir, section: "script" });
31
- const hint = pickHintFromError(err, message);
32
- const { errorType, errorCode } = classifyToolError(err);
33
- const { identity } = resolveTermPreset();
34
- return buildToolErrorResponse({
35
- tool: "drive",
36
- title: "Drive",
37
- message,
38
- hint,
39
- errorType,
40
- errorCode,
28
+ const preset = resolveTermPreset();
29
+ return handleToolError({
30
+ error: err,
31
+ toolName: preset.tools.drive.name,
32
+ cwd: workingDir,
33
+ logLabel: "tool drive",
41
34
  inputEcho: [
42
35
  `Directive: ${directive}`,
43
36
  ...(context ? [`Context: ${context}`] : []),
44
- ...(name ? [`${identity}: ${name}`] : []),
37
+ ...(name ? [`${preset.identity}: ${name}`] : []),
45
38
  `CWD: ${workingDir}`,
46
39
  ],
47
40
  });
@@ -1,28 +1,8 @@
1
- import * as git from '../utils/git.js';
2
1
  export function createHelpHandler(preset) {
3
2
  return async ({ question }) => {
4
- const workingDir = process.cwd();
5
- let branchState = 'Unknown';
6
- try {
7
- const currentBranch = await git.getCurrentBranch(workingDir);
8
- const activeKeiyaku = await git.getActiveKeiyakuBranch(workingDir);
9
- if (activeKeiyaku) {
10
- const base = await git.getKeiyakuBase(workingDir, activeKeiyaku);
11
- branchState = `Active Keiyaku: ${activeKeiyaku}\n Base Branch: ${base || 'unknown'}`;
12
- }
13
- else {
14
- branchState = `No active keiyaku. Current branch: ${currentBranch}`;
15
- }
16
- }
17
- catch {
18
- branchState = 'Not a git repository or git error.';
19
- }
20
3
  const helpContent = [
21
4
  '# Keiyaku System Help',
22
5
  '',
23
- '## Current State',
24
- branchState,
25
- '',
26
6
  '## Core Files (.keiyaku/)',
27
7
  "These files define the 'Law' of the project. **CRITICAL**: Use Markdown level 2 headers (## header).",
28
8
  '',
@@ -39,7 +19,7 @@ export function createHelpHandler(preset) {
39
19
  ].join('\n');
40
20
  return {
41
21
  content: [{ type: 'text', text: helpContent }],
42
- structuredContent: { tool: 'cli_help', status: 'success' },
22
+ structuredContent: { tool: preset.tools.help.name, status: 'success' },
43
23
  };
44
24
  };
45
25
  }
@@ -1,5 +1,7 @@
1
1
  import { isSubagentExecError } from "../agents/index.js";
2
- import { isFlowError } from "../common/errors.js";
2
+ import { asMessage, isFlowError, pickHintFromError } from "../common/errors.js";
3
+ import { appendDebugLog } from "../utils/debug-log.js";
4
+ import { buildToolErrorResponse } from "../workflow/response-builders.js";
3
5
  export function classifyToolError(error) {
4
6
  if (isFlowError(error)) {
5
7
  return { errorType: "keiyaku_error", errorCode: error.code };
@@ -12,3 +14,17 @@ export function classifyToolError(error) {
12
14
  }
13
15
  return { errorType: "runtime_error", errorCode: "INTERNAL_ERROR" };
14
16
  }
17
+ export function handleToolError(input) {
18
+ const message = asMessage(input.error);
19
+ appendDebugLog(`${input.logLabel} error: ${message}`, { cwd: input.cwd, section: "script" });
20
+ const hint = pickHintFromError(input.error, message);
21
+ const { errorType, errorCode } = classifyToolError(input.error);
22
+ return buildToolErrorResponse({
23
+ tool: input.toolName,
24
+ message,
25
+ hint,
26
+ errorType,
27
+ errorCode,
28
+ inputEcho: input.inputEcho,
29
+ });
30
+ }
@@ -1,25 +1,20 @@
1
1
  import { appendDebugLog } from "../utils/debug-log.js";
2
- import { asMessage, pickHintFromError } from "../common/errors.js";
3
2
  import { handleStart } from "../workflow/orchestrator.js";
4
- import { buildKeiyakuSuccessResponse, buildToolErrorResponse, } from "../workflow/response-builders.js";
3
+ import { buildKeiyakuSuccessResponse, } from "../workflow/response-builders.js";
5
4
  import { resolveTermPreset } from "../config/term-presets.js";
6
- import { classifyToolError } from "./shared.js";
7
- function formatCriteria(criteria) {
8
- return criteria.map((item) => `- ${item}`).join("\n");
9
- }
10
- export function createStartHandler(toolName) {
5
+ import { handleToolError } from "./shared.js";
6
+ export function createStartHandler() {
11
7
  return async ({ title, goal, directive, context, constraints, criteria, name, cwd }, extra) => {
12
8
  const workingDir = cwd || process.cwd();
13
9
  try {
14
10
  appendDebugLog(`tool start cwd=${workingDir}`, { cwd: workingDir, section: "script" });
15
- const finalContext = context?.trim() || "No additional context provided.";
16
11
  const result = await handleStart({
17
12
  cwd: workingDir,
18
13
  title,
19
14
  goal,
20
15
  directive,
21
- context: finalContext,
22
- criteria: formatCriteria(criteria),
16
+ context,
17
+ criteria,
23
18
  constraints,
24
19
  name,
25
20
  signal: extra.signal,
@@ -40,18 +35,12 @@ export function createStartHandler(toolName) {
40
35
  });
41
36
  }
42
37
  catch (err) {
43
- const message = asMessage(err);
44
- appendDebugLog(`tool start error: ${message}`, { cwd: workingDir, section: "script" });
45
- const hint = pickHintFromError(err, message);
46
- const { errorType, errorCode } = classifyToolError(err);
47
- const { identity } = resolveTermPreset();
48
- return buildToolErrorResponse({
49
- tool: toolName,
50
- title: "Keiyaku",
51
- message,
52
- hint,
53
- errorType,
54
- errorCode,
38
+ const preset = resolveTermPreset();
39
+ return handleToolError({
40
+ error: err,
41
+ toolName: preset.tools.start.name,
42
+ cwd: workingDir,
43
+ logLabel: "tool start",
55
44
  inputEcho: [
56
45
  `Title: ${title}`,
57
46
  `Goal: ${goal}`,
@@ -59,7 +48,7 @@ export function createStartHandler(toolName) {
59
48
  `Criteria (${criteria.length}): ${criteria.join("; ")}`,
60
49
  ...(context ? [`Context: ${context}`] : []),
61
50
  ...(constraints ? [`Constraints: ${constraints}`] : []),
62
- ...(name ? [`${identity}: ${name}`] : []),
51
+ ...(name ? [`${preset.identity}: ${name}`] : []),
63
52
  `CWD: ${workingDir}`,
64
53
  ],
65
54
  });
package/build/index.js CHANGED
@@ -62,7 +62,7 @@ function registerTools(server) {
62
62
  title: startPreset.title,
63
63
  description: dynamicStartDescription,
64
64
  inputSchema: dynamicStartSchema,
65
- }, createStartHandler(startPreset.name));
65
+ }, createStartHandler());
66
66
  server.registerTool(drivePreset.name, {
67
67
  title: drivePreset.title,
68
68
  description: dynamicDriveDescription,
@@ -77,10 +77,7 @@ function registerTools(server) {
77
77
  title: closePreset.title,
78
78
  description: dynamicCloseDescription,
79
79
  inputSchema: dynamicCloseSchema,
80
- }, createCloseHandler({
81
- name: closePreset.name,
82
- title: closePreset.title,
83
- }));
80
+ }, createCloseHandler());
84
81
  server.registerTool(helpPreset.name, {
85
82
  title: helpPreset.title,
86
83
  description: [dynamicHelpDescription, dynamicHelpUsageGuide].join("\n\n"),
@@ -24,11 +24,11 @@ export const askToolSchema = z.object({
24
24
  export const closeToolSchema = z.object({
25
25
  petition: z.enum(["INVOKE", "ABANDON"]),
26
26
  criteriaChecks: z.array(z.string().trim().min(1)).min(1),
27
- score_precise: z.number().int().min(0).max(5),
28
- score_minimal: z.number().int().min(0).max(5),
29
- score_isolated: z.number().int().min(0).max(5),
30
- score_idiomatic: z.number().int().min(0).max(5),
31
- score_cohesive: z.number().int().min(0).max(5),
27
+ score_precise: z.number().min(0).max(5),
28
+ score_minimal: z.number().min(0).max(5),
29
+ score_isolated: z.number().min(0).max(5),
30
+ score_idiomatic: z.number().min(0).max(5),
31
+ score_cohesive: z.number().min(0).max(5),
32
32
  oath: z.string().optional(),
33
33
  cwd: z.string().optional(),
34
34
  });
@@ -362,8 +362,18 @@ export async function getIncrementalDiff(cwd) {
362
362
  ]);
363
363
  }
364
364
  catch (err) {
365
- // If there's no HEAD~1 (e.g. first commit), fallback
366
- return "No incremental diff available.";
365
+ const source = (err ?? {});
366
+ const text = [source.message, source.stderr, source.stdErr, source.stdout, source.stdOut]
367
+ .filter((value) => typeof value === "string" && value.length > 0)
368
+ .join("\n");
369
+ const isMissingBaseCommit = text.includes("bad revision") ||
370
+ text.includes("unknown revision or path not in the working tree") ||
371
+ text.includes("ambiguous argument");
372
+ if (isMissingBaseCommit) {
373
+ // If there's no HEAD~1 (e.g. first commit), fallback
374
+ return "No incremental diff available.";
375
+ }
376
+ throw wrapGitError(`diff --no-color --no-ext-diff --unified=3 ${range}`, err, cwd);
367
377
  }
368
378
  const sections = splitDiffByFile(rawPatch);
369
379
  if (sections.length === 0)
@@ -1,4 +1,4 @@
1
- const DEFAULT_OATH = "I, [your name], solemnly swear that I have scrutinized this change line by line with my own eyes. This oath is handwritten as a testament to my responsibility. If logic fails where I claimed it sound, I shall bear the mark of this oversight forever.\n\nSigned: [your name]";
1
+ const DEFAULT_OATH = "I, [your name], solemnly swear that I have scrutinized this change line by line with my own eyes. I further swear that every score I submit is fully objective, with no exaggeration whatsoever. This oath is handwritten as a testament to my responsibility. If logic fails where I claimed it sound, I shall bear the mark of this oversight forever.\n\nSigned: [your name]";
2
2
  const OATH_ENV_KEY = "KEIYAKU_CLOSE_OATH";
3
3
  const LEGACY_CLOSE_OATH_ENV_KEY = "KEIYAKU_JUDGMENT_OATH";
4
4
  const LEGACY_OATH_ENV_KEY = "KEIYAKU_SEAL_OATH";
@@ -36,6 +36,7 @@ const JUDGMENT_SETTINGS_FILE = path.join(".keiyaku", "settings.json");
36
36
  export { resolveOath };
37
37
  const BASE_CRITERIA_FILE = path.join(".keiyaku", "base-criteria.md");
38
38
  const BASE_CONSTRAINTS_FILE = path.join(".keiyaku", "base-constraints.md");
39
+ const ACTIVE_KEIYAKU_PREVIEW_MAX_CHARS = 8000;
39
40
  function clampThreshold(value, fallback) {
40
41
  if (typeof value !== "number" || !Number.isFinite(value))
41
42
  return fallback;
@@ -52,6 +53,55 @@ function clampMinTotalScore(value, fallback) {
52
53
  return fallback;
53
54
  return candidate;
54
55
  }
56
+ function truncateForMessage(text, maxChars) {
57
+ if (text.length <= maxChars)
58
+ return text;
59
+ return `${text.slice(0, maxChars)}\n...[truncated ${text.length - maxChars} chars]...`;
60
+ }
61
+ async function buildActiveKeiyakuStartMessage(cwd, branch) {
62
+ const lines = [
63
+ `active keiyaku already exists (${branch})`,
64
+ "This task is still active. Decide whether to continue or abandon it.",
65
+ "Continue: run drive on the current keiyaku branch.",
66
+ "Abandon: run close with petition=ABANDON to drop the branch.",
67
+ ];
68
+ try {
69
+ const keiyakuPath = path.join(cwd, KEIYAKU_FILE);
70
+ const keiyaku = await fs.readFile(keiyakuPath, "utf-8");
71
+ const preview = truncateForMessage(keiyaku.trim(), ACTIVE_KEIYAKU_PREVIEW_MAX_CHARS);
72
+ lines.push("", `[Current ${KEIYAKU_FILE}]`, preview || "(empty file)");
73
+ }
74
+ catch (error) {
75
+ if (error?.code === "ENOENT") {
76
+ lines.push("", `${KEIYAKU_FILE} not found on current branch.`);
77
+ }
78
+ else {
79
+ throw error;
80
+ }
81
+ }
82
+ try {
83
+ const trace = await readTraceContent(cwd);
84
+ const traceState = computeTraceState(trace);
85
+ if (traceState.pendingReviewRound !== null) {
86
+ lines.push(`Trace status: pending review ${traceState.pendingReviewRound}; latest completed round ${traceState.maxRound}.`);
87
+ }
88
+ else if (traceState.maxRound > 0) {
89
+ lines.push(`Trace status: completed through round ${traceState.maxRound}; no pending review.`);
90
+ }
91
+ else {
92
+ lines.push("Trace status: no completed rounds yet.");
93
+ }
94
+ }
95
+ catch (error) {
96
+ if (error?.code === "ENOENT") {
97
+ lines.push(`${TRACE_FILE} not found on current branch.`);
98
+ }
99
+ else {
100
+ throw error;
101
+ }
102
+ }
103
+ return lines.join("\n");
104
+ }
55
105
  async function resolveJudgmentConfig(cwd) {
56
106
  const defaults = resolveTermPreset().divineJudgment;
57
107
  const merged = {
@@ -239,9 +289,14 @@ export async function handleStart(input) {
239
289
  const finalTitle = requireText("title", input.title);
240
290
  const finalGoal = requireText("goal", input.goal);
241
291
  const finalContext = requireText("context", input.context);
242
- const finalCriteria = requireText("criteria", input.criteria);
243
- const taskCriteria = parseMarkdownHeaders(finalCriteria);
244
- const normalizedTaskCriteria = taskCriteria.length > 0 ? taskCriteria : [finalCriteria];
292
+ if (input.criteria.length === 0) {
293
+ throw new FlowError("EMPTY_PARAM", "parameter 'criteria' cannot be empty");
294
+ }
295
+ const normalizedTaskCriteria = input.criteria.flatMap((criterion, index) => {
296
+ const normalizedCriterion = requireText(`criteria[${index}]`, criterion);
297
+ const parsed = parseMarkdownHeaders(normalizedCriterion);
298
+ return parsed.length > 0 ? parsed : [normalizedCriterion];
299
+ });
245
300
  const finalConstraints = input.constraints?.trim();
246
301
  const taskConstraints = finalConstraints ? parseMarkdownHeaders(finalConstraints) : [];
247
302
  const normalizedTaskConstraints = finalConstraints && taskConstraints.length > 0 ? taskConstraints : finalConstraints ? [finalConstraints] : [];
@@ -252,7 +307,7 @@ export async function handleStart(input) {
252
307
  }
253
308
  const existingKeiyaku = await git.getActiveKeiyakuBranch(cwd);
254
309
  if (existingKeiyaku) {
255
- throw new FlowError("ACTIVE_KEIYAKU_EXISTS", `active keiyaku already exists (${existingKeiyaku}); close or drop it first`);
310
+ throw new FlowError("ACTIVE_KEIYAKU_EXISTS", await buildActiveKeiyakuStartMessage(cwd, existingKeiyaku));
256
311
  }
257
312
  const existingBranches = await git.listLocalKeiyakuBranches(cwd);
258
313
  if (existingBranches.length > 0) {
@@ -306,7 +361,6 @@ export async function handleStart(input) {
306
361
  });
307
362
  const trace = await fs.readFile(path.join(cwd, TRACE_FILE), "utf-8");
308
363
  const diff = await git.getIncrementalDiff(cwd);
309
- const content = `Round ${1} complete on ${keiyakuBranch}.`;
310
364
  return {
311
365
  status: "success",
312
366
  round: 1,
@@ -315,7 +369,6 @@ export async function handleStart(input) {
315
369
  summary,
316
370
  branch: keiyakuBranch,
317
371
  baseBranch,
318
- content,
319
372
  };
320
373
  }
321
374
  catch (err) {
@@ -365,6 +418,14 @@ export async function handleClose(input) {
365
418
  const title = keiyakuBranch.slice("keiyaku/".length);
366
419
  const petition = input.petition;
367
420
  if (petition === "ABANDON") {
421
+ let round = 0;
422
+ try {
423
+ const trace = await fs.readFile(path.join(cwd, TRACE_FILE), "utf-8");
424
+ round = computeTraceState(trace).maxRound;
425
+ }
426
+ catch {
427
+ round = 0;
428
+ }
368
429
  await assertCleanWorkingTree(cwd);
369
430
  try {
370
431
  await git.checkoutBranch(cwd, baseBranch);
@@ -374,14 +435,6 @@ export async function handleClose(input) {
374
435
  catch (err) {
375
436
  throw wrapFlowError(`execute ABANDON (${keiyakuBranch} -> ${baseBranch})`, err);
376
437
  }
377
- let round = 0;
378
- try {
379
- const trace = await fs.readFile(path.join(cwd, TRACE_FILE), "utf-8");
380
- round = computeTraceState(trace).maxRound;
381
- }
382
- catch {
383
- round = 0;
384
- }
385
438
  return {
386
439
  status: "success",
387
440
  result: "dropped",
@@ -389,7 +442,6 @@ export async function handleClose(input) {
389
442
  branch: keiyakuBranch,
390
443
  baseBranch,
391
444
  diff: "Abandoned without merge.",
392
- content: `Abandoned ${keiyakuBranch}.`,
393
445
  };
394
446
  }
395
447
  await ensureKeiyakuFiles(cwd);
@@ -461,7 +513,6 @@ export async function handleClose(input) {
461
513
  branch: keiyakuBranch,
462
514
  baseBranch,
463
515
  diff,
464
- content: `Merged ${keiyakuBranch} into ${baseBranch}.`,
465
516
  };
466
517
  }
467
518
  catch (err) {
@@ -510,7 +561,6 @@ export async function handleDrive(input) {
510
561
  });
511
562
  const trace = await fs.readFile(path.join(cwd, TRACE_FILE), "utf-8");
512
563
  const diff = await git.getIncrementalDiff(cwd);
513
- const content = `Round ${plan.targetRound} complete on ${keiyakuBranch}.`;
514
564
  return {
515
565
  status: "success",
516
566
  round: plan.targetRound,
@@ -519,7 +569,6 @@ export async function handleDrive(input) {
519
569
  summary,
520
570
  branch: keiyakuBranch,
521
571
  baseBranch,
522
- content,
523
572
  };
524
573
  }
525
574
  catch (err) {
@@ -8,7 +8,7 @@ Round: 1 (initial implementation).
8
8
  - Directive: round scope.
9
9
 
10
10
  1. Read KEIYAKU.md. Use it as source of truth.
11
- 2. If Focus exists, prioritize it.
11
+ 2. If Directive exists, prioritize it.
12
12
  3. Treat all constraints/criteria as required.
13
13
  4. Read KEIYAKU_TRACE.md if present.
14
14
  5. Execute:
@@ -38,7 +38,7 @@ Address this review reason:
38
38
  ${reason}
39
39
 
40
40
  1. Read KEIYAKU.md. Keep constraints/criteria unchanged.
41
- 2. If Focus exists, prioritize it.
41
+ 2. If Directive exists, prioritize it.
42
42
  3. Read KEIYAKU_TRACE.md. Locate the latest "## Review ${round}" section.
43
43
  4. Fix review reason first.
44
44
  5. No drift from constraints/criteria.
@@ -102,7 +102,6 @@ export function buildKeiyakuSuccessResponse(result, input) {
102
102
  nextHint: nextHints.join("\n"),
103
103
  inputEcho,
104
104
  ...result,
105
- content: text,
106
105
  },
107
106
  };
108
107
  }
@@ -130,7 +129,6 @@ export function buildDriveResponse(result, input) {
130
129
  nextHint: nextHints.join("\n"),
131
130
  inputEcho,
132
131
  ...result,
133
- content: text,
134
132
  },
135
133
  };
136
134
  }
@@ -183,7 +181,6 @@ export function buildCloseDoneResponse(result, input) {
183
181
  nextHint: nextHints.join("\n"),
184
182
  inputEcho,
185
183
  ...result,
186
- content: text,
187
184
  },
188
185
  };
189
186
  }
@@ -211,12 +208,12 @@ export function buildCloseDropResponse(result, input) {
211
208
  nextHint: nextHints.join("\n"),
212
209
  inputEcho,
213
210
  ...result,
214
- content: text,
215
211
  },
216
212
  };
217
213
  }
218
214
  export function buildToolErrorResponse(input) {
219
215
  const inputEcho = (input.inputEcho ?? []).map((line) => truncateForDisplay(line, 800));
216
+ const shouldRaiseProtocolError = input.errorType === "runtime_error";
220
217
  const text = assembleResponse("Failed", input.message, [buildSection("Input Context", inputEcho)], [input.hint]);
221
218
  return {
222
219
  content: [{ type: "text", text }],
@@ -229,6 +226,6 @@ export function buildToolErrorResponse(input) {
229
226
  hint: input.hint,
230
227
  inputEcho,
231
228
  },
232
- isError: true,
229
+ ...(shouldRaiseProtocolError ? { isError: true } : {}),
233
230
  };
234
231
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/keiyaku",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "MCP server for running iterative keiyaku workflows with Codex subagents.",
5
5
  "license": "MIT",
6
6
  "type": "module",