@astrosheep/keiyaku 0.1.19 → 0.1.21

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.
@@ -1,24 +1,22 @@
1
1
  import * as fs from "fs/promises";
2
2
  import * as path from "path";
3
3
  import { KEIYAKU_DRAFT_FILE, KEIYAKU_FILE, TRACE_FILE } from "../common/constants.js";
4
- import { appendDebugLog } from "../utils/debug-log.js";
4
+ import { appendDebugLog, shouldEmitProgressLogs } from "../utils/debug-log.js";
5
5
  import { FlowError, isFlowError, wrapFlowError } from "../common/errors.js";
6
6
  import * as git from "../utils/git.js";
7
7
  import { computeTraceState, readTraceContent } from "../utils/trace.js";
8
8
  import { buildStartPrompt } from "./prompts.js";
9
- import { parseToAST, parseMarkdownStructure, renderSectionContent, extractListItems, renderKeiyaku, } from "../utils/keiyaku-document.js";
9
+ import { parseMarkdownStructure } from "../utils/keiyaku-document.js";
10
10
  import { resolveTermPreset } from "../config/term-presets.js";
11
11
  import { renderRoundSummary, TOOL_DEFAULT_POLICY } from "./round-summary.js";
12
12
  import { flattenMarkdownList } from "../utils/text-utils.js";
13
13
  import { assertCleanWorkingTree } from "./contract.js";
14
14
  import { runAndRecordRound } from "./round.js";
15
+ import { renderKeiyaku } from "./keiyaku-document-builder.js";
16
+ import { normalizeDraftMarkdownListItems, parseAndValidateKeiyakuDraft, } from "./keiyaku-draft.js";
15
17
  const BASE_CONSTRAINTS_FILE = path.join(".keiyaku", "base-constraints.md");
16
18
  const ACTIVE_KEIYAKU_PREVIEW_MAX_CHARS = 8000;
17
- const KNOWN_SECTIONS = new Set(["goal", "directive", "context", "constraints", "criteria", "acceptance criteria"]);
18
19
  const INTERNAL_START_DIRTY_ALLOWLIST = [];
19
- function normalizeSectionTitle(title) {
20
- return title.trim().toLowerCase().replace(/\s+/g, " ");
21
- }
22
20
  function requireText(name, value) {
23
21
  const normalized = value.trim();
24
22
  if (!normalized) {
@@ -47,130 +45,6 @@ function normalizeOptionalStringList(name, value) {
47
45
  return requireText(`${name}[${index}]`, item);
48
46
  });
49
47
  }
50
- function normalizeMarkdownListItems(name, values) {
51
- const normalized = [];
52
- for (const [index, value] of values.entries()) {
53
- const normalizedValue = requireText(`${name}[${index}]`, value);
54
- const ast = parseToAST(normalizedValue, { allowSections: false });
55
- const sectionNode = { type: "section", level: 2, title: "", children: ast.children };
56
- const parsedItems = extractListItems(sectionNode);
57
- if (parsedItems.length > 0) {
58
- normalized.push(...parsedItems);
59
- continue;
60
- }
61
- const grouped = splitHeadingGroups(sectionNode);
62
- if (grouped.length > 0) {
63
- normalized.push(...grouped);
64
- continue;
65
- }
66
- const rendered = renderSectionContent(sectionNode).trim();
67
- if (rendered.length > 0) {
68
- normalized.push(rendered);
69
- continue;
70
- }
71
- normalized.push(normalizedValue);
72
- }
73
- return normalized;
74
- }
75
- function splitHeadingGroups(sectionNode) {
76
- const firstMeaningful = sectionNode.children.find((child) => {
77
- if (child.type !== "text")
78
- return true;
79
- return child.value.trim().length > 0;
80
- });
81
- if (!firstMeaningful || firstMeaningful.type !== "heading" || firstMeaningful.level < 2) {
82
- return [];
83
- }
84
- const groups = [];
85
- let currentChildren = [];
86
- const commit = () => {
87
- if (currentChildren.length === 0)
88
- return;
89
- groups.push({
90
- type: "section",
91
- level: 2,
92
- title: "",
93
- children: currentChildren,
94
- });
95
- currentChildren = [];
96
- };
97
- for (const child of sectionNode.children) {
98
- if (child.type === "heading" && child.level >= 2) {
99
- commit();
100
- currentChildren = [child];
101
- continue;
102
- }
103
- currentChildren.push(child);
104
- }
105
- commit();
106
- return groups
107
- .map((group) => renderSectionContent(group).trim())
108
- .filter((item) => item.length > 0);
109
- }
110
- function collectSectionItems(ast, sectionTitle) {
111
- const normalizedSectionTitle = normalizeSectionTitle(sectionTitle);
112
- const targetSection = ast.children.find((node) => {
113
- if (node.type !== "section")
114
- return false;
115
- if (node.level !== 2)
116
- return false;
117
- return normalizeSectionTitle(node.title) === normalizedSectionTitle;
118
- });
119
- if (!targetSection || targetSection.type !== "section") {
120
- return [];
121
- }
122
- const listItems = extractListItems(targetSection);
123
- if (listItems.length > 0) {
124
- return listItems;
125
- }
126
- const grouped = splitHeadingGroups(targetSection);
127
- if (grouped.length > 0) {
128
- return grouped;
129
- }
130
- const content = renderSectionContent(targetSection).trim();
131
- return content ? [content] : [];
132
- }
133
- function parseAndValidateKeiyakuDraft(content) {
134
- const ast = parseToAST(content);
135
- const sections = new Map();
136
- let title;
137
- for (const node of ast.children) {
138
- if (node.type !== "section")
139
- continue;
140
- if (node.level === 1 && !title) {
141
- title = node.title.trim() || undefined;
142
- continue;
143
- }
144
- if (node.level !== 2)
145
- continue;
146
- const normalizedSection = normalizeSectionTitle(node.title);
147
- if (sections.has(normalizedSection)) {
148
- throw new FlowError("INVALID_KEIYAKU_DRAFT", `invalid keiyaku draft: duplicate section header '${normalizedSection}'`);
149
- }
150
- sections.set(normalizedSection, renderSectionContent(node).trim());
151
- }
152
- const unknownSections = [...sections.keys()].filter((section) => !KNOWN_SECTIONS.has(section));
153
- if (unknownSections.length > 0) {
154
- throw new FlowError("INVALID_KEIYAKU_DRAFT", `invalid keiyaku draft: unknown section header(s): ${unknownSections.map((section) => `'${section}'`).join(", ")}`);
155
- }
156
- const goal = sections.get("goal")?.trim();
157
- const directive = sections.get("directive")?.trim();
158
- const context = sections.get("context")?.trim();
159
- const constraints = sections.has("constraints") ? collectSectionItems(ast, "constraints") : undefined;
160
- const criteria = sections.has("acceptance criteria")
161
- ? collectSectionItems(ast, "acceptance criteria")
162
- : sections.has("criteria")
163
- ? collectSectionItems(ast, "criteria")
164
- : undefined;
165
- return {
166
- title: title?.trim() || undefined,
167
- goal: goal || undefined,
168
- directive: directive || undefined,
169
- context: context || undefined,
170
- constraints: constraints && constraints.length > 0 ? constraints : undefined,
171
- criteria: criteria && criteria.length > 0 ? criteria : undefined,
172
- };
173
- }
174
48
  function truncateForMessage(text, maxChars) {
175
49
  if (text.length <= maxChars)
176
50
  return text;
@@ -235,24 +109,11 @@ async function fileExists(filePath) {
235
109
  throw error;
236
110
  }
237
111
  }
238
- function resolveFromFilePath(cwd, fromFile) {
239
- return path.isAbsolute(fromFile) ? path.resolve(fromFile) : path.resolve(cwd, fromFile);
240
- }
241
- function isCanonicalDraftFromFile(cwd, fromFile) {
242
- if (!fromFile)
243
- return false;
244
- return resolveFromFilePath(cwd, fromFile) === path.resolve(cwd, KEIYAKU_DRAFT_FILE);
245
- }
246
- async function assertStartPreFlight(cwd, fromFile) {
112
+ async function assertStartPreFlight(cwd) {
247
113
  const keiyakuPath = path.join(cwd, KEIYAKU_FILE);
248
114
  if (await fileExists(keiyakuPath)) {
249
115
  throw new FlowError("KEIYAKU_FILE_EXISTS", `pre-flight failed: ${KEIYAKU_FILE} already exists in cwd`);
250
116
  }
251
- const draftPath = path.join(cwd, KEIYAKU_DRAFT_FILE);
252
- const draftExists = await fileExists(draftPath);
253
- if (draftExists && !isCanonicalDraftFromFile(cwd, fromFile)) {
254
- throw new FlowError("DRAFT_FILE_EXISTS", `pre-flight failed: ${KEIYAKU_DRAFT_FILE} exists but from_file does not target it`);
255
- }
256
117
  }
257
118
  function normalizeTitleForBranch(title) {
258
119
  const normalized = title
@@ -296,8 +157,8 @@ async function resolveStartInput(cwd, input) {
296
157
  goal: provided.goal,
297
158
  directive: provided.directive,
298
159
  context: provided.context,
299
- criteria: normalizeMarkdownListItems("criteria", provided.criteria),
300
- constraints: normalizeMarkdownListItems("constraints", provided.constraints ?? []),
160
+ criteria: normalizeDraftMarkdownListItems("criteria", provided.criteria),
161
+ constraints: normalizeDraftMarkdownListItems("constraints", provided.constraints ?? []),
301
162
  fromFile: undefined,
302
163
  dirtyAllowlist,
303
164
  };
@@ -337,8 +198,8 @@ async function resolveStartInput(cwd, input) {
337
198
  goal,
338
199
  directive,
339
200
  context,
340
- criteria: normalizeMarkdownListItems("criteria", criteria),
341
- constraints: normalizeMarkdownListItems("constraints", constraints),
201
+ criteria: normalizeDraftMarkdownListItems("criteria", criteria),
202
+ constraints: normalizeDraftMarkdownListItems("constraints", constraints),
342
203
  fromFile,
343
204
  dirtyAllowlist,
344
205
  };
@@ -383,18 +244,22 @@ async function handleStartFailure(cwd, error, keiyakuContent, fromFile) {
383
244
  try {
384
245
  const draftPath = path.join(cwd, KEIYAKU_DRAFT_FILE);
385
246
  if (await fileExists(draftPath)) {
386
- console.warn("[keiyaku] Race condition: Draft file exists. Content not saved.");
247
+ if (shouldEmitProgressLogs())
248
+ console.warn("[keiyaku] Race condition: Draft file exists. Content not saved.");
387
249
  }
388
250
  else {
389
251
  await fs.writeFile(draftPath, keiyakuContent, { flag: "wx" });
390
252
  const startToolName = resolveTermPreset().tools.start.name;
391
- console.error(`[keiyaku] Start failed. Draft saved to ${KEIYAKU_DRAFT_FILE}. Retry with: ${startToolName} --from_file ${KEIYAKU_DRAFT_FILE}`);
253
+ if (shouldEmitProgressLogs()) {
254
+ console.error(`[keiyaku] Start failed. Draft saved to ${KEIYAKU_DRAFT_FILE}. Retry with: ${startToolName} --from_file ${KEIYAKU_DRAFT_FILE}`);
255
+ }
392
256
  savedDraft = true;
393
257
  }
394
258
  }
395
259
  catch (draftSaveError) {
396
260
  const message = draftSaveError instanceof Error ? draftSaveError.message : String(draftSaveError);
397
- console.error(`[keiyaku] Failed to save draft file: ${message}`);
261
+ if (shouldEmitProgressLogs())
262
+ console.error(`[keiyaku] Failed to save draft file: ${message}`);
398
263
  }
399
264
  }
400
265
  if (savedDraft) {
@@ -412,7 +277,6 @@ async function handleStartFailure(cwd, error, keiyakuContent, fromFile) {
412
277
  export async function startKeiyaku(input) {
413
278
  const { cwd, signal, name } = input;
414
279
  const resolved = await resolveStartInput(cwd, input);
415
- const startedFromCanonicalDraft = isCanonicalDraftFromFile(cwd, resolved.fromFile);
416
280
  let keiyakuContent;
417
281
  let createdBranch = false;
418
282
  let keiyakuBranch;
@@ -431,11 +295,14 @@ export async function startKeiyaku(input) {
431
295
  const existingBranches = await git.listLocalKeiyakuBranches(cwd);
432
296
  if (existingBranches.length > 0) {
433
297
  const branchWarning = `[keiyaku] existing local keiyaku branches detected (non-blocking): ${existingBranches.join(", ")}`;
434
- console.error(branchWarning);
298
+ if (shouldEmitProgressLogs())
299
+ console.error(branchWarning);
435
300
  appendDebugLog(branchWarning, { cwd, section: "script" });
436
301
  }
437
- await assertStartPreFlight(cwd, resolved.fromFile);
438
- const dirtyAllowlist = Array.from(new Set([...(resolved.fromFile ? [resolved.fromFile] : []), ...resolved.dirtyAllowlist]));
302
+ await assertStartPreFlight(cwd);
303
+ // `KEIYAKU.draft.md` is a recovery artifact and should not block starting a new session
304
+ // (unless the user explicitly chooses to start from it).
305
+ const dirtyAllowlist = Array.from(new Set([KEIYAKU_DRAFT_FILE, ...(resolved.fromFile ? [resolved.fromFile] : []), ...resolved.dirtyAllowlist]));
439
306
  await assertCleanWorkingTree(cwd, dirtyAllowlist.length > 0 ? dirtyAllowlist : undefined);
440
307
  baseBranch = await git.getCurrentBranch(cwd);
441
308
  const branchToken = normalizeTitleForBranch(resolved.title);
@@ -445,7 +312,8 @@ export async function startKeiyaku(input) {
445
312
  createdBranch = true;
446
313
  await git.setKeiyakuBase(cwd, keiyakuBranch, baseBranch);
447
314
  const switchedLog = `Switched to branch: ${keiyakuBranch} (base: ${baseBranch})`;
448
- console.error(switchedLog);
315
+ if (shouldEmitProgressLogs())
316
+ console.error(switchedLog);
449
317
  appendDebugLog(switchedLog, { cwd, section: "script" });
450
318
  // Ensure we write the content we prepared.
451
319
  if (!keiyakuContent) {
@@ -468,16 +336,6 @@ export async function startKeiyaku(input) {
468
336
  });
469
337
  const summary = renderRoundSummary(roundSummary, TOOL_DEFAULT_POLICY);
470
338
  const diff = await git.getIncrementalDiff(cwd);
471
- if (startedFromCanonicalDraft) {
472
- try {
473
- await fs.unlink(path.join(cwd, KEIYAKU_DRAFT_FILE));
474
- }
475
- catch (error) {
476
- if (error?.code !== "ENOENT") {
477
- throw error;
478
- }
479
- }
480
- }
481
339
  return {
482
340
  status: "success",
483
341
  round: 1,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/keiyaku",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "MCP server for running iterative keiyaku workflows with Codex subagents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,8 +24,8 @@
24
24
  "prepublishOnly": "npm run build",
25
25
  "start": "node build/index.js",
26
26
  "test:unit": "node --import tsx --test --test-concurrency=1 tests/unit/unit.test.ts src/utils/keiyaku-document.test.ts",
27
- "test:integration": "npx tsc && node --import tsx --test --test-concurrency=1 tests/integration/integration.test.ts",
28
- "test": "node --import tsx --test --test-concurrency=1 tests/unit/unit.test.ts src/utils/keiyaku-document.test.ts && npx tsc && node --import tsx --test --test-concurrency=1 tests/integration/integration.test.ts"
27
+ "test:integration": "npx tsc && node --import tsx --test --test-concurrency=3 tests/integration/integration.test.ts",
28
+ "test": "node --import tsx --test --test-concurrency=1 tests/unit/unit.test.ts src/utils/keiyaku-document.test.ts && npx tsc && node --import tsx --test --test-concurrency=3 tests/integration/integration.test.ts"
29
29
  },
30
30
  "dependencies": {
31
31
  "@modelcontextprotocol/sdk": "*",