@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.
- package/build/.tsbuildinfo +1 -0
- package/build/agents/round-runner.js +22 -2
- package/build/common/constants.js +0 -1
- package/build/config/term-presets.js +3 -3
- package/build/utils/debug-log.js +3 -0
- package/build/utils/keiyaku-document.js +79 -20
- package/build/utils/keiyaku-document.test.js +7 -7
- package/build/workflow/drive.js +17 -10
- package/build/workflow/keiyaku-document-builder.js +15 -0
- package/build/workflow/keiyaku-draft.js +255 -0
- package/build/workflow/present.js +41 -24
- package/build/workflow/response-builders.js +4 -4
- package/build/workflow/round.js +12 -6
- package/build/workflow/start.js +24 -166
- package/package.json +3 -3
package/build/workflow/start.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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:
|
|
300
|
-
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:
|
|
341
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
+
if (shouldEmitProgressLogs())
|
|
299
|
+
console.error(branchWarning);
|
|
435
300
|
appendDebugLog(branchWarning, { cwd, section: "script" });
|
|
436
301
|
}
|
|
437
|
-
await assertStartPreFlight(cwd
|
|
438
|
-
|
|
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
|
-
|
|
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.
|
|
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=
|
|
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=
|
|
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": "*",
|