@astrosheep/keiyaku 0.1.21 → 0.1.22

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.
@@ -19,7 +19,7 @@ export const DEFAULT_PRESET = {
19
19
  id: 'default',
20
20
  identity: 'Servant',
21
21
  verdict: DEFAULT_VERDICT_CONFIG,
22
- usageGuide: '## Workflow\n**You are the Architect.** You use these tools to command the Servants.\n\n`${ask}` (anytime) | `${start}` -> [`${drive}` | `${ask}`]* -> `${close}`\n\n## Draft Recovery\n- Keiyaku may write `KEIYAKU.draft.md` on a failed start to help you retry.\n- Keiyaku never stages/commits `KEIYAKU.draft.md` during rounds, and `${close}` (CLAIM) preserves it.\n- To use it, pass `from_file: KEIYAKU.draft.md` to `${start}`.\n\n## Constraints Protocol\n\nProject-level constraints in `.keiyaku/base-constraints.md` are automatically parsed and injected into every Keiyaku.\n- **Atomic Items**: Use standard Markdown lists (`-`, `*`, `1.`) for individual, actionable constraints.\n- **Header Flattening**: Root-level headers (e.g., `## Title`) are automatically transformed into bolded prefixes (`**Title**: Content`).\n- **Paragraph Splitting**: Multi-paragraph sections are split by blank lines into separate list items, inheriting the parent header.\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.**',
22
+ usageGuide: '## Workflow\n**You are the Architect.** You use these tools to command the Servants.\n\n`${ask}` (anytime) | `${start}` -> [`${drive}` | `${ask}`]* -> `${close}`\n\n## Draft Recovery\n- Keiyaku may write `KEIYAKU.draft.md` on a failed start to help you retry.\n- Keiyaku never stages/commits `KEIYAKU.draft.md` during rounds, and `${close}` (CLAIM) preserves it.\n- To use it, pass `from_file: KEIYAKU.draft.md` to `${start}`.\n\n## Rules Protocol\n\nProject-level rules in `.keiyaku/base-rules.md` are automatically parsed and injected into every Keiyaku.\n- **Atomic Items**: Use standard Markdown lists (`-`, `*`, `1.`) for individual, actionable rules.\n- **Header Flattening**: Root-level headers (e.g., `## Title`) are automatically transformed into bolded prefixes (`**Title**: Content`).\n- **Paragraph Splitting**: Multi-paragraph sections are split by blank lines into separate list items, inheriting the parent header.\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.',
@@ -30,7 +30,7 @@ export const DEFAULT_PRESET = {
30
30
  ],
31
31
  drive: [
32
32
  "Review Diffs: Use 'git diff HEAD~1 -- <path>' to inspect specific files.",
33
- 'Audit: Check against Constraints. Did it drift? Did it break the Law?',
33
+ "Audit: Check against Rules. Did it drift? Did it break the Law?",
34
34
  'Verify: Do not trust. Independently confirm that Criteria are met.',
35
35
  'If incomplete or non-compliant, continue with ${drive}.',
36
36
  'If ALL criteria are genuinely satisfied, you may ${close}.',
@@ -62,8 +62,8 @@ export const DEFAULT_PRESET = {
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.',
64
64
  context: 'Required unless provided via `from_file`. Mission intel: behavior gap, key files, logs, and critical background.',
65
- constraints: 'Optional. Non-negotiable rules as a list of strings. If omitted, the draft value (if any) is used.',
66
- criteria: 'Required unless provided via `from_file`. Verifiable checks proving completion.',
65
+ rules: 'Optional. Non-negotiable rules for this mission, in addition to project-level rules in `.keiyaku/base-rules.md`. Markdown list format (`- item`, `* item`, `1. item`). If omitted, the draft value (if any) is used.',
66
+ criteria: 'Required unless provided via `from_file`. Verifiable checks in markdown list format (`- item`, `* item`, `1. item`).',
67
67
  name: 'Optional ${identity} profile to execute this mission. Available: ${available_names}.',
68
68
  cwd: "Optional repository path. Defaults to the server's current working directory.",
69
69
  },
@@ -85,7 +85,7 @@ export const DEFAULT_PRESET = {
85
85
  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.',
86
86
  args: {
87
87
  request: 'REQUIRED. The task, question, or mission to delegate to the servant.',
88
- 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
+ context: 'REQUIRED. Relevant background or data the servant needs to execute the request. Note: .keiyaku/base-rules.md is also injected as reference context (may be unrelated).',
89
89
  name: 'Optional ${identity} profile to perform this task. Available: ${available_names}.',
90
90
  cwd: "Optional repository path. Defaults to the server's current working directory.",
91
91
  },
@@ -100,13 +100,13 @@ export const DEFAULT_PRESET = {
100
100
  'If it is messy, **YOU** left it there.\n\n' +
101
101
  'Stand by your work. If uncertain, return to ${drive}. Premature claims are not forgiven.\n\n' +
102
102
  'Input rules:\n' +
103
- '- CLAIM: fill criteriaChecks, constraintsChecks, all five scores, and oath.\n' +
103
+ '- CLAIM: fill criteriaChecks, rulesChecks, all five scores, and oath.\n' +
104
104
  '- FORFEIT: only petition is required; the other fields can be left empty.\n\n' +
105
105
  'Flow: ${start} → [${drive} x N] → ${close}',
106
106
  args: {
107
107
  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}.',
108
- criteriaChecks: 'Required for CLAIM. Evidence that each criterion is met. For FORFEIT, optional (can be left empty).',
109
- constraintsChecks: 'Required for CLAIM. Evidence that each constraint stayed compliant. For FORFEIT, optional (can be left empty).',
108
+ criteriaChecks: 'Required for CLAIM. Evidence in markdown list format (`- item`, `* item`, `1. item`). For FORFEIT, optional (can be left empty).',
109
+ rulesChecks: 'Required for CLAIM. Rule compliance evidence in markdown list format (`- item`, `* item`, `1. item`). For FORFEIT, optional (can be left empty).',
110
110
  score_precise: 'Required for CLAIM (0-10). Architectural placement. 10 = exact layer, exact boundary, zero misplacement. For FORFEIT, optional.',
111
111
  score_minimal: 'Required for CLAIM (0-10). Economy of change. 10 = no avoidable lines, no speculative edits, no hidden bloat. For FORFEIT, optional.',
112
112
  score_isolated: 'Required for CLAIM (0-10). Surgical containment. 10 = zero unrelated files, zero opportunistic cleanup, zero collateral. For FORFEIT, optional.',
@@ -119,7 +119,7 @@ export const DEFAULT_PRESET = {
119
119
  help: {
120
120
  name: 'help',
121
121
  title: 'Protocol Codex',
122
- 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.',
122
+ description: "Consult the Architect's Codex. Clarify the Laws of the Keiyaku and the standard Workflow.\nUse this to understand the rules of the reality you command.",
123
123
  args: {},
124
124
  },
125
125
  },
@@ -138,7 +138,7 @@ export const POCKET_PRESET = {
138
138
  ],
139
139
  drive: [
140
140
  "Review Strategy: Use 'git diff HEAD~1 -- <path>' for precise field inspection.",
141
- "Rule Check: Verify moves don't violate Arena Constraints.",
141
+ "Rule Check: Verify moves don't violate Arena Rules.",
142
142
  "Field Test: Do not trust the log. Independently confirm Criteria fulfillment.",
143
143
  "Command: '${drive}' to continue the combo, or '${close}' to attempt Capture.",
144
144
  ],
@@ -167,8 +167,8 @@ export const POCKET_PRESET = {
167
167
  goal: 'Required unless provided via `from_file`. Victory condition for this battle.',
168
168
  directive: 'Optional Turn 1 strategy (overrides draft directive).',
169
169
  context: 'Required unless provided via `from_file`. Battle background and repro clues.',
170
- constraints: 'Optional battle rules as a list of strings. Uses draft constraints if omitted.',
171
- criteria: 'Required unless provided via `from_file`. Concrete checks proving victory.',
170
+ rules: 'Optional battle rules in markdown list format (`- item`, `* item`, `1. item`) in addition to project-level rules from `.keiyaku/base-rules.md`. Uses draft rules if omitted.',
171
+ criteria: 'Required unless provided via `from_file`. Concrete checks in markdown list format (`- item`, `* item`, `1. item`).',
172
172
  name: 'Optional ${identity} to send into battle. Available: ${available_names}.',
173
173
  cwd: 'Optional battlefield path (repository root). Defaults to current arena.',
174
174
  },
@@ -190,7 +190,7 @@ export const POCKET_PRESET = {
190
190
  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.',
191
191
  args: {
192
192
  request: 'REQUIRED. What should the Dex analyze, compare, or execute.',
193
- 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).',
193
+ context: 'REQUIRED. Context entries so the action targets the right ecosystem. Note: .keiyaku/base-rules.md is also injected as reference context (may be unrelated).',
194
194
  name: 'Optional ${identity} doing the work. Available: ${available_names}.',
195
195
  cwd: 'Optional battlefield path (repository root). Defaults to current arena.',
196
196
  },
@@ -201,8 +201,8 @@ export const POCKET_PRESET = {
201
201
  description: 'Attempt Capture. Present the weakened target for League Inspection.\nWARNING: The League (System) checks every Badge. If you attempt to `CLAIM` with fainted code, Disqualification (FORFEIT) is immediate.\nOnly throw the Ball when the target is 100% ready.\n\nFlow: ${start} → [${drive} x N] → ${close}',
202
202
  args: {
203
203
  petition: 'REQUIRED. CLAIM seeks Badge; FORFEIT forfeits the match.\nREQUIRES AN ACTIVE BATTLE (started via ${start}).\nIf stats are low, continue with ${drive}.',
204
- criteriaChecks: 'Required for CLAIM. Badge-by-badge proof. For FORFEIT, optional (can be left empty).',
205
- constraintsChecks: 'Required for CLAIM. Rule-by-rule proof that constraints were respected. For FORFEIT, optional (can be left empty).',
204
+ criteriaChecks: 'Required for CLAIM. Badge-by-badge proof in markdown list format (`- item`, `* item`, `1. item`). For FORFEIT, optional (can be left empty).',
205
+ rulesChecks: 'Required for CLAIM. Rule-by-rule proof in markdown list format (`- item`, `* item`, `1. item`). For FORFEIT, optional (can be left empty).',
206
206
  score_precise: 'Required for CLAIM score (0-10). 10 means a Critical Hit: exact layer, exact target, zero meaningful misplacement. For FORFEIT, optional.',
207
207
  score_minimal: 'Required for CLAIM score (0-10). 10 means Max Efficiency: no wasted PP, no extra motion. For FORFEIT, optional.',
208
208
  score_isolated: 'Required for CLAIM score (0-10). 10 means 1v1 Focus: zero side-quests, zero unrelated damage. For FORFEIT, optional.',
@@ -234,7 +234,7 @@ export const MISCHIEF_PRESET = {
234
234
  ],
235
235
  drive: [
236
236
  "Inspect Payload: Use 'git diff HEAD~1 -- <path>' to scrutinize specific sabotage.",
237
- "Decree Audit: Did the Minion violate your Constraints? Punish drift.",
237
+ "Decree Audit: Did the Minion violate your Rules? Punish drift.",
238
238
  "Verification: Don't take their word for it. Independently confirm Criteria.",
239
239
  "Command: '${drive}' to push further, or '${close}' to reveal the masterpiece.",
240
240
  ],
@@ -263,8 +263,8 @@ export const MISCHIEF_PRESET = {
263
263
  goal: 'Required unless provided via `from_file`. Conquest objective and end-state.',
264
264
  directive: 'Optional first-order command (overrides draft directive).',
265
265
  context: 'Required unless provided via `from_file`. Briefing dossier and technical clues.',
266
- constraints: 'Optional decrees as a list of strings. Uses draft constraints when omitted.',
267
- criteria: 'Required unless provided via `from_file`. Verifiable triumph conditions.',
266
+ rules: 'Optional decrees in markdown list format (`- item`, `* item`, `1. item`) in addition to project-level rules from `.keiyaku/base-rules.md`. Uses draft rules when omitted.',
267
+ criteria: 'Required unless provided via `from_file`. Verifiable triumph conditions in markdown list format (`- item`, `* item`, `1. item`).',
268
268
  name: 'Optional ${identity} to command this operation. Available: ${available_names}.',
269
269
  cwd: 'Optional lair path (repository root). Defaults to current command chamber.',
270
270
  },
@@ -286,7 +286,7 @@ export const MISCHIEF_PRESET = {
286
286
  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.',
287
287
  args: {
288
288
  request: 'REQUIRED. The intel to gather or the dirty work to execute.',
289
- context: 'REQUIRED. World-state details needed for a sharp strike. Note: .keiyaku/base-constraints.md is also injected as reference context (may be unrelated).',
289
+ context: 'REQUIRED. World-state details needed for a sharp strike. Note: .keiyaku/base-rules.md is also injected as reference context (may be unrelated).',
290
290
  name: 'Optional ${identity} to handle this business. Available: ${available_names}.',
291
291
  cwd: 'Optional lair path (repository root). Defaults to current command chamber.',
292
292
  },
@@ -297,8 +297,8 @@ export const MISCHIEF_PRESET = {
297
297
  description: 'The Final Reveal. Present your Masterpiece to the Dark Council (System).\nWARNING: The Council destroys failure. If you `CLAIM` with weak plans, you will be eaten (FORFEIT).\nOnly throw the switch when the Doomsday Device is 100% operational.\n\nFlow: ${start} → [${drive} x N] → ${close}',
298
298
  args: {
299
299
  petition: 'REQUIRED. CLAIM demands rule; FORFEIT admits defeat.\nREQUIRES AN ACTIVE SCHEME (started via ${start}).\nIf the plan is weak, improve it with ${drive}.',
300
- criteriaChecks: 'Required for CLAIM. Proof of conquest. For FORFEIT, optional (can be left empty).',
301
- constraintsChecks: 'Required for CLAIM. Constraint-by-constraint compliance proof. For FORFEIT, optional (can be left empty).',
300
+ criteriaChecks: 'Required for CLAIM. Proof of conquest in markdown list format (`- item`, `* item`, `1. item`). For FORFEIT, optional (can be left empty).',
301
+ rulesChecks: 'Required for CLAIM. Rule-by-rule compliance proof in markdown list format (`- item`, `* item`, `1. item`). For FORFEIT, optional (can be left empty).',
302
302
  score_precise: 'Required for CLAIM (0-10). Architectural placement. 10 = exact layer, exact boundary, zero misplacement. For FORFEIT, optional.',
303
303
  score_minimal: 'Required for CLAIM (0-10). Economy of change. 10 = no avoidable lines, no speculative edits, no hidden bloat. For FORFEIT, optional.',
304
304
  score_isolated: 'Required for CLAIM (0-10). Surgical containment. 10 = zero unrelated files, zero opportunistic cleanup, zero collateral. For FORFEIT, optional.',
@@ -4,6 +4,7 @@ import { buildCloseDoneResponse, buildCloseDropResponse, } from "../workflow/res
4
4
  import { resolveTermPreset } from "../config/term-presets.js";
5
5
  import { FlowError } from "../common/errors.js";
6
6
  import { closeToolSchema } from "../types/tooling.js";
7
+ import { flattenMarkdownList } from "../utils/text-utils.js";
7
8
  import { handleToolError } from "./shared.js";
8
9
  function requireClaimField(value, name) {
9
10
  if (value === undefined) {
@@ -11,12 +12,19 @@ function requireClaimField(value, name) {
11
12
  }
12
13
  return value;
13
14
  }
15
+ function parseRequiredCheckField(value, name) {
16
+ const parsed = flattenMarkdownList(value);
17
+ if (parsed.length === 0) {
18
+ throw new FlowError("EMPTY_PARAM", `parameter '${name}' cannot be empty`);
19
+ }
20
+ return parsed;
21
+ }
14
22
  export function createCloseHandler() {
15
23
  return async (args, extra) => {
16
24
  let petition = "UNKNOWN";
17
25
  let workingDir = process.cwd();
18
26
  let criteriaCheckParts = [];
19
- let constraintsCheckParts = [];
27
+ let rulesCheckParts = [];
20
28
  let oath;
21
29
  let scoreLine;
22
30
  try {
@@ -24,22 +32,28 @@ export function createCloseHandler() {
24
32
  petition = input.petition;
25
33
  workingDir = input.cwd || process.cwd();
26
34
  if (input.petition === "CLAIM") {
27
- criteriaCheckParts = input.criteriaChecks ?? [];
28
- constraintsCheckParts = input.constraintsChecks ?? [];
35
+ criteriaCheckParts = input.criteriaChecks ? flattenMarkdownList(input.criteriaChecks) : [];
36
+ rulesCheckParts = input.rulesChecks ? flattenMarkdownList(input.rulesChecks) : [];
29
37
  oath = input.oath;
30
38
  scoreLine = `Scores: precise=${input.score_precise ?? "MISSING"} minimal=${input.score_minimal ?? "MISSING"} isolated=${input.score_isolated ?? "MISSING"} idiomatic=${input.score_idiomatic ?? "MISSING"} cohesive=${input.score_cohesive ?? "MISSING"}`;
31
39
  }
32
- appendDebugLog(`tool close start petition=${petition} cwd=${workingDir} criteriaChecks=${criteriaCheckParts.length} constraintsChecks=${constraintsCheckParts.length}`, {
40
+ appendDebugLog(`tool close start petition=${petition} cwd=${workingDir} criteriaChecks=${criteriaCheckParts.length} rulesChecks=${rulesCheckParts.length}`, {
33
41
  cwd: workingDir,
34
42
  section: "script",
35
43
  });
36
44
  let closeInput;
37
45
  let claimInput;
38
46
  if (input.petition === "CLAIM") {
47
+ const rawCriteriaChecks = requireClaimField(input.criteriaChecks, "criteriaChecks");
48
+ const rawRulesChecks = requireClaimField(input.rulesChecks, "rulesChecks");
49
+ const criteriaChecks = parseRequiredCheckField(rawCriteriaChecks, "criteriaChecks");
50
+ const rulesChecks = parseRequiredCheckField(rawRulesChecks, "rulesChecks");
51
+ criteriaCheckParts = criteriaChecks;
52
+ rulesCheckParts = rulesChecks;
39
53
  claimInput = {
40
54
  petition: "CLAIM",
41
- criteriaChecks: requireClaimField(input.criteriaChecks, "criteriaChecks"),
42
- constraintsChecks: requireClaimField(input.constraintsChecks, "constraintsChecks"),
55
+ criteriaChecks,
56
+ rulesChecks,
43
57
  score_precise: requireClaimField(input.score_precise, "score_precise"),
44
58
  score_minimal: requireClaimField(input.score_minimal, "score_minimal"),
45
59
  score_isolated: requireClaimField(input.score_isolated, "score_isolated"),
@@ -73,7 +87,7 @@ export function createCloseHandler() {
73
87
  });
74
88
  return buildCloseDoneResponse(finalOutcome, {
75
89
  criteriaChecks: criteriaCheckParts,
76
- constraintsChecks: constraintsCheckParts,
90
+ rulesChecks: rulesCheckParts,
77
91
  score_precise: claimInput.score_precise,
78
92
  score_minimal: claimInput.score_minimal,
79
93
  score_isolated: claimInput.score_isolated,
@@ -105,7 +119,7 @@ export function createCloseHandler() {
105
119
  inputEcho: [
106
120
  `Petition: ${petition}`,
107
121
  `Criteria checks (${criteriaCheckParts.length}): ${criteriaCheckParts.join("; ")}`,
108
- `Constraints checks (${constraintsCheckParts.length}): ${constraintsCheckParts.join("; ")}`,
122
+ `Rules checks (${rulesCheckParts.length}): ${rulesCheckParts.join("; ")}`,
109
123
  ...(scoreLine ? [scoreLine] : []),
110
124
  ...(petition === "CLAIM" && oath ? [`Oath: ${oath}`] : []),
111
125
  `Path: ${workingDir}`,
@@ -7,11 +7,11 @@ export function createHelpHandler(preset) {
7
7
  "## Core Files (.keiyaku/)",
8
8
  "These files define the 'Law' and configuration of the project.",
9
9
  "",
10
- "- **base-constraints.md**: Mandatory architectural boundaries and coding standards.",
11
- " - **Purpose**: Global constraints injected into every mission.",
12
- " - **Logic**: The system prioritize extracting all list items (`-` or `*`) as individual constraints.",
13
- " - **Formatting**: Use lists for constraints. You can use H3+ headers (`###`) for organization, but they are stripped if the section contains lists.",
14
- " - **Strict Rule**: DO NOT use H1 (`#`) or H2 (`##`) within constraints or as content if they aren't meant to be item separators, as they will trigger validation errors.",
10
+ "- **base-rules.md**: Mandatory architectural boundaries and coding standards.",
11
+ " - **Purpose**: Global rules injected into every mission.",
12
+ " - **Logic**: The system prioritize extracting all list items (`-` or `*`) as individual rules.",
13
+ " - **Formatting**: Use lists for rules. You can use H3+ headers (`###`) for organization, but they are stripped if the section contains lists.",
14
+ " - **Strict Rule**: DO NOT use H1 (`#`) or H2 (`##`) within rules or as content if they aren't meant to be item separators, as they will trigger validation errors.",
15
15
  "- **settings.json**: Local configuration for the Keiyaku environment.",
16
16
  "",
17
17
  preset.usageGuide,
@@ -1,4 +1,5 @@
1
1
  import { appendDebugLog } from "../utils/debug-log.js";
2
+ import { flattenMarkdownList } from "../utils/text-utils.js";
2
3
  import { startKeiyaku } from "../workflow/start.js";
3
4
  import { buildKeiyakuSuccessResponse, } from "../workflow/response-builders.js";
4
5
  import { resolveTermPreset } from "../config/term-presets.js";
@@ -9,9 +10,11 @@ function normalizeOptionalArg(value) {
9
10
  return value.trim().length > 0 ? value : undefined;
10
11
  }
11
12
  export function createStartHandler() {
12
- return async ({ from_file, title, goal, directive, context, constraints, criteria, name, cwd }, extra) => {
13
+ return async ({ from_file, title, goal, directive, context, rules, criteria, name, cwd }, extra) => {
13
14
  const workingDir = cwd || process.cwd();
14
15
  const normalizedFromFile = normalizeOptionalArg(from_file);
16
+ const parsedCriteria = criteria === undefined ? undefined : flattenMarkdownList(criteria);
17
+ const parsedRules = rules === undefined ? undefined : flattenMarkdownList(rules);
15
18
  try {
16
19
  appendDebugLog(`tool start cwd=${workingDir}`, { cwd: workingDir, section: "script" });
17
20
  const result = normalizedFromFile
@@ -22,8 +25,8 @@ export function createStartHandler() {
22
25
  goal: normalizeOptionalArg(goal),
23
26
  directive: normalizeOptionalArg(directive),
24
27
  context: normalizeOptionalArg(context),
25
- constraints,
26
- criteria,
28
+ rules: parsedRules,
29
+ criteria: parsedCriteria,
27
30
  name,
28
31
  signal: extra.signal,
29
32
  })
@@ -33,8 +36,8 @@ export function createStartHandler() {
33
36
  goal: goal ?? "",
34
37
  directive: normalizeOptionalArg(directive),
35
38
  context: context ?? "",
36
- constraints,
37
- criteria: criteria ?? [],
39
+ rules: parsedRules,
40
+ criteria: parsedCriteria ?? [],
38
41
  name,
39
42
  signal: extra.signal,
40
43
  });
@@ -59,9 +62,9 @@ export function createStartHandler() {
59
62
  ...(title ? [`Title: ${title}`] : []),
60
63
  ...(goal ? [`Goal: ${goal}`] : []),
61
64
  ...(directive ? [`Directive: ${directive}`] : []),
62
- ...(criteria ? [`Criteria (${criteria.length}): ${criteria.join("; ")}`] : []),
65
+ ...(parsedCriteria ? [`Criteria (${parsedCriteria.length}): ${parsedCriteria.join("; ")}`] : []),
63
66
  ...(context ? [`Context: ${context}`] : []),
64
- ...(constraints ? [`Constraints (${constraints.length}): ${constraints.join("; ")}`] : []),
67
+ ...(parsedRules ? [`Rules (${parsedRules.length}): ${parsedRules.join("; ")}`] : []),
65
68
  ...(name ? [`${preset.identity}: ${name}`] : []),
66
69
  `Path: ${workingDir}`,
67
70
  ],
@@ -5,8 +5,8 @@ export const startToolSchema = z.object({
5
5
  goal: z.string().optional(),
6
6
  directive: z.string().optional(),
7
7
  context: z.string().optional(),
8
- constraints: z.array(z.string().trim().min(1)).min(1).optional(),
9
- criteria: z.array(z.string().trim().min(1)).min(1).optional(),
8
+ rules: z.string().optional(),
9
+ criteria: z.string().optional(),
10
10
  name: z.string().optional(),
11
11
  cwd: z.string().optional(),
12
12
  });
@@ -25,13 +25,11 @@ export const askToolSchema = z.object({
25
25
  export const closeToolSchema = z.object({
26
26
  petition: z.enum(["CLAIM", "FORFEIT"]).describe("REQUIRED. CLAIM to finalize, FORFEIT to abandon."),
27
27
  criteriaChecks: z
28
- .array(z.string().trim().min(1))
29
- .min(1)
28
+ .string()
30
29
  .optional()
31
30
  .describe("Required when petition=CLAIM. For FORFEIT, this field is optional and can be omitted."),
32
- constraintsChecks: z
33
- .array(z.string().trim().min(1))
34
- .min(1)
31
+ rulesChecks: z
32
+ .string()
35
33
  .optional()
36
34
  .describe("Required when petition=CLAIM. For FORFEIT, this field is optional and can be omitted."),
37
35
  score_precise: z
@@ -263,6 +263,22 @@ export async function discardAllWorkingTreeChanges(cwd) {
263
263
  throw wrapGitError("clean -fd", err, cwd);
264
264
  }
265
265
  }
266
+ export async function discardWorkingTreeChangesExcluding(cwd, excludeFiles) {
267
+ const git = createGit(cwd);
268
+ try {
269
+ await git.raw(["reset", "--hard", "HEAD"]);
270
+ }
271
+ catch (err) {
272
+ throw wrapGitError("reset --hard HEAD", err, cwd);
273
+ }
274
+ const excludeArgs = excludeFiles.flatMap((filePath) => ["--exclude", filePath]);
275
+ try {
276
+ await git.raw(["clean", "-fd", ...excludeArgs]);
277
+ }
278
+ catch (err) {
279
+ throw wrapGitError(`clean -fd ${excludeArgs.join(" ")}`.trim(), err, cwd);
280
+ }
281
+ }
266
282
  export async function assertValidBranchName(cwd, branchName) {
267
283
  const git = createGit(cwd);
268
284
  try {
@@ -456,21 +456,6 @@ function splitByHeadingBlocks(sectionNode) {
456
456
  commitGroup();
457
457
  return groups;
458
458
  }
459
- export function renderMarkdownSections(items) {
460
- return items
461
- .map((item) => {
462
- // Contract-level H2 headers (## ...) are used as section delimiters in KEIYAKU.md.
463
- // To keep section parsing stable without destroying relative structure, shift all headings
464
- // in user-provided content so the shallowest heading is at least H3.
465
- const normalized = demoteMarkdownHeadings(item, 3);
466
- const trimmed = normalized.trimStart();
467
- if (/^[-*+]\s+/.test(trimmed) || /^\d+\.\s+/.test(trimmed)) {
468
- return normalized;
469
- }
470
- return `- ${normalized}`;
471
- })
472
- .join("\n\n");
473
- }
474
459
  export function validateMarkdownHeaders(text) {
475
460
  const ast = parseToAST(text, { allowSections: false });
476
461
  const hasH1H2 = (() => {
@@ -497,55 +482,6 @@ export function validateMarkdownHeaders(text) {
497
482
  throw new FlowError("INVALID_KEIYAKU_DRAFT", "Markdown content cannot contain H1/H2 headers (#/##). Use H3 (###) or list items.");
498
483
  }
499
484
  }
500
- export function demoteMarkdownHeadings(text, minLevel = 3) {
501
- const ast = parseToAST(text, { allowSections: false });
502
- // Preserve relative heading hierarchy by shifting all headings together.
503
- // This avoids collapsing something like `# A` and `### B` into the same level.
504
- let shallowest = null;
505
- {
506
- const stack = [ast];
507
- while (stack.length > 0) {
508
- const node = stack.pop();
509
- if (!node)
510
- continue;
511
- if (node.type === "heading") {
512
- shallowest = shallowest === null ? node.level : Math.min(shallowest, node.level);
513
- }
514
- if (node.type === "code_block" || node.type === "text")
515
- continue;
516
- if (node.type === "document" || node.type === "section" || node.type === "list_item") {
517
- stack.push(...node.children);
518
- }
519
- else if (node.type === "list") {
520
- stack.push(...node.items);
521
- }
522
- }
523
- }
524
- if (shallowest === null || shallowest >= minLevel) {
525
- // Return a normalized render (trimEnd) to avoid leaking extra trailing newlines.
526
- return renderNodeContent(ast).trimEnd();
527
- }
528
- const delta = minLevel - shallowest;
529
- const shift = (node) => {
530
- switch (node.type) {
531
- case "heading": {
532
- const nextLevel = Math.min(6, node.level + delta);
533
- return nextLevel === node.level ? node : { ...node, level: nextLevel };
534
- }
535
- case "code_block":
536
- case "text":
537
- return node;
538
- case "document":
539
- case "section":
540
- return { ...node, children: node.children.map((child) => shift(child)) };
541
- case "list":
542
- return { ...node, items: node.items.map((item) => shift(item)) };
543
- case "list_item":
544
- return { ...node, children: node.children.map((child) => shift(child)) };
545
- }
546
- };
547
- return renderNodeContent(shift(ast)).trimEnd();
548
- }
549
485
  export function parseMarkdownListSection(text) {
550
486
  const ast = parseToAST(text, { allowSections: false });
551
487
  const pseudoSection = {
@@ -1,7 +1,8 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { FlowError } from "../common/errors.js";
4
- import { extractListItems, parseToAST, parseMarkdownStructure, parseMarkdownListSection, demoteMarkdownHeadings, renderSectionContent, renderMarkdownSections, validateMarkdownHeaders, } from "./keiyaku-document.js";
4
+ import { extractListItems, parseToAST, parseMarkdownStructure, parseMarkdownListSection, renderSectionContent, validateMarkdownHeaders, } from "./keiyaku-document.js";
5
+ import { computeHeadingDelta, demoteMarkdownHeadings, renderMarkdownSections, } from "../workflow/markdown-normalization.js";
5
6
  test("parseToAST models H1/H2 sections and keeps H3+ headers as heading nodes", () => {
6
7
  const markdown = [
7
8
  "# Feature Alpha",
@@ -35,7 +36,7 @@ test("parseToAST models H1/H2 sections and keeps H3+ headers as heading nodes",
35
36
  assert.equal(goalList.items[0]?.children[1]?.type === "heading" ? `### ${goalList.items[0].children[1].text}` : "", "### Keep this as content");
36
37
  });
37
38
  test("renderSectionContent and extractListItems keep H3+ content inside list items", () => {
38
- const ast = parseToAST(["## Constraints", "- Keep parser", " ### preserve heading", "- Keep tests"].join("\n"));
39
+ const ast = parseToAST(["## Rules", "- Keep parser", " ### preserve heading", "- Keep tests"].join("\n"));
39
40
  const section = ast.children[0];
40
41
  assert.equal(section?.type, "section");
41
42
  if (section?.type !== "section")
@@ -114,6 +115,11 @@ test("parseMarkdownListSection handles empty lines between list items", () => {
114
115
  test("renderMarkdownSections renders plain markdown bullets", () => {
115
116
  assert.equal(renderMarkdownSections(["Constraint A", "Constraint B"]), "- Constraint A\n\n- Constraint B");
116
117
  });
118
+ test("computeHeadingDelta returns shift only when needed", () => {
119
+ assert.equal(computeHeadingDelta(null, 3), 0);
120
+ assert.equal(computeHeadingDelta(3, 3), 0);
121
+ assert.equal(computeHeadingDelta(1, 3), 2);
122
+ });
117
123
  test("demoteMarkdownHeadings shifts headings to keep minimum level at h3 while preserving relative hierarchy", () => {
118
124
  assert.equal(demoteMarkdownHeadings("# Title\n## Sub\n### Already", 3), "### Title\n#### Sub\n##### Already");
119
125
  assert.equal(demoteMarkdownHeadings(["```md", "## Not a section", "```", "## Real heading"].join("\n"), 3), ["```md", "## Not a section", "```", "### Real heading"].join("\n"));
@@ -5,9 +5,9 @@ import { runSubagent } from "../agents/round-runner.js";
5
5
  import * as git from "../utils/git.js";
6
6
  import { buildAskPrompt } from "./prompts.js";
7
7
  import { FlowError } from "../common/errors.js";
8
- import { renderMarkdownSections } from "../utils/keiyaku-document.js";
8
+ import { renderMarkdownSections } from "./markdown-normalization.js";
9
9
  import { flattenMarkdownList } from "../utils/text-utils.js";
10
- const BASE_CONSTRAINTS_FILE = path.join(".keiyaku", "base-constraints.md");
10
+ const BASE_RULES_FILE = path.join(".keiyaku", "base-rules.md");
11
11
  function requireText(name, value) {
12
12
  const normalized = value.trim();
13
13
  if (!normalized) {
@@ -19,12 +19,12 @@ export async function askServant(input) {
19
19
  const { cwd, signal, name } = input;
20
20
  const request = requireText("request", input.request);
21
21
  const context = requireText("context", input.context);
22
- let referenceConstraints;
22
+ let referenceRules;
23
23
  try {
24
- const baseConstraintsRaw = await fs.readFile(path.join(cwd, BASE_CONSTRAINTS_FILE), "utf-8");
25
- const baseConstraints = flattenMarkdownList(baseConstraintsRaw);
26
- if (baseConstraints.length > 0) {
27
- referenceConstraints = renderMarkdownSections(baseConstraints);
24
+ const baseRulesRaw = await fs.readFile(path.join(cwd, BASE_RULES_FILE), "utf-8");
25
+ const baseRules = flattenMarkdownList(baseRulesRaw);
26
+ if (baseRules.length > 0) {
27
+ referenceRules = renderMarkdownSections(baseRules);
28
28
  }
29
29
  }
30
30
  catch (error) {
@@ -32,7 +32,7 @@ export async function askServant(input) {
32
32
  throw error;
33
33
  }
34
34
  }
35
- const prompt = buildAskPrompt(request, context, referenceConstraints);
35
+ const prompt = buildAskPrompt(request, context, referenceRules);
36
36
  // TODO: enforce read-only access and persist summary to .keiyaku/notes/.
37
37
  const summary = await runSubagent(selectSubagent(name), prompt, cwd, 0, signal);
38
38
  let currentBranch;
@@ -54,7 +54,7 @@ export async function driveServant(input) {
54
54
  const traceState = computeTraceState(traceContent);
55
55
  const title = keiyakuBranch.slice("keiyaku/".length);
56
56
  const goal = readGoalFromKeiyaku(parsedKeiyaku);
57
- const constraints = readKeiyakuSection(parsedKeiyaku, "Constraints");
57
+ const rules = readKeiyakuSection(parsedKeiyaku, "Rules");
58
58
  const criteria = readKeiyakuSection(parsedKeiyaku, "Acceptance Criteria");
59
59
  const normalizedDirective = requireText("directive", directive);
60
60
  try {
@@ -82,7 +82,7 @@ export async function driveServant(input) {
82
82
  diff,
83
83
  summary,
84
84
  goal,
85
- constraints,
85
+ rules,
86
86
  criteria,
87
87
  currentBranch: keiyakuBranch,
88
88
  baseBranch,
@@ -1,14 +1,14 @@
1
- import { demoteMarkdownHeadings, renderMarkdownSections } from "../utils/keiyaku-document.js";
2
- const BASE_CONSTRAINTS_SOURCE = ".keiyaku/base-constraints.md";
3
- export function renderKeiyaku(title, goal, context, baseConstraints, taskConstraints, taskCriteria) {
1
+ import { demoteMarkdownHeadings, renderMarkdownSections } from "./markdown-normalization.js";
2
+ const BASE_RULES_SOURCE = ".keiyaku/base-rules.md";
3
+ export function renderKeiyaku(title, goal, context, baseRules, taskRules, taskCriteria) {
4
4
  const normalizedContext = demoteMarkdownHeadings(context, 3);
5
5
  const normalizedGoal = demoteMarkdownHeadings(goal, 3);
6
6
  let content = `# ${title}\n\n## Context\n${normalizedContext}\n\n## Goal\n${normalizedGoal}`;
7
- content += "\n\n## Constraints";
8
- if (baseConstraints.length > 0) {
9
- content += `\n\n### Project Constraints\nLoaded from: \`${BASE_CONSTRAINTS_SOURCE}\`\n\n${renderMarkdownSections(baseConstraints)}`;
7
+ content += "\n\n## Rules";
8
+ if (baseRules.length > 0) {
9
+ content += `\n\n### Project Rules\nLoaded from: \`${BASE_RULES_SOURCE}\`\n\n${renderMarkdownSections(baseRules)}`;
10
10
  }
11
- content += `\n\n### Task Constraints\n${renderMarkdownSections(taskConstraints)}`;
11
+ content += `\n\n### Task Rules\n${renderMarkdownSections(taskRules)}`;
12
12
  content += "\n\n## Acceptance Criteria";
13
13
  content += `\n\n### Task Criteria\n${renderMarkdownSections(taskCriteria)}`;
14
14
  return content;
@@ -1,10 +1,11 @@
1
1
  import { FlowError } from "../common/errors.js";
2
2
  import { extractListItems, parseToAST, renderSectionContent, } from "../utils/keiyaku-document.js";
3
+ import { computeHeadingDelta } from "./markdown-normalization.js";
3
4
  const KNOWN_DRAFT_SECTIONS = new Set([
4
5
  "goal",
5
6
  "directive",
6
7
  "context",
7
- "constraints",
8
+ "rules",
8
9
  "criteria",
9
10
  "acceptance criteria",
10
11
  ]);
@@ -131,10 +132,10 @@ function normalizeDraftHeadings(content) {
131
132
  }
132
133
  shallowestNonStructural = shallowestNonStructural === null ? level : Math.min(shallowestNonStructural, level);
133
134
  }
134
- if (shallowestNonStructural === null || shallowestNonStructural >= MIN_HEADING_LEVEL) {
135
+ const delta = computeHeadingDelta(shallowestNonStructural, MIN_HEADING_LEVEL);
136
+ if (delta === 0) {
135
137
  return lines.join("\n");
136
138
  }
137
- const delta = MIN_HEADING_LEVEL - shallowestNonStructural;
138
139
  const out = [];
139
140
  sawTitleH1 = false;
140
141
  fence = null;
@@ -238,7 +239,7 @@ export function parseAndValidateKeiyakuDraft(content) {
238
239
  const goal = sections.get("goal")?.trim();
239
240
  const directive = sections.get("directive")?.trim();
240
241
  const context = sections.get("context")?.trim();
241
- const constraints = sections.has("constraints") ? collectSectionItems(ast, "constraints") : undefined;
242
+ const rules = sections.has("rules") ? collectSectionItems(ast, "rules") : undefined;
242
243
  const criteria = sections.has("acceptance criteria")
243
244
  ? collectSectionItems(ast, "acceptance criteria")
244
245
  : sections.has("criteria")
@@ -249,7 +250,7 @@ export function parseAndValidateKeiyakuDraft(content) {
249
250
  goal: goal || undefined,
250
251
  directive: directive || undefined,
251
252
  context: context || undefined,
252
- constraints: constraints && constraints.length > 0 ? constraints : undefined,
253
+ rules: rules && rules.length > 0 ? rules : undefined,
253
254
  criteria: criteria && criteria.length > 0 ? criteria : undefined,
254
255
  };
255
256
  }