@astrosheep/keiyaku 0.1.21 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/build/.tsbuildinfo +1 -1
- package/build/common/errors.js +16 -5
- package/build/config/term-presets.js +35 -35
- package/build/handlers/close.js +22 -8
- package/build/handlers/help.js +5 -5
- package/build/handlers/start.js +10 -7
- package/build/types/tooling.js +5 -7
- package/build/utils/git-ops.js +16 -0
- package/build/utils/keiyaku-document.js +0 -64
- package/build/utils/keiyaku-document.test.js +8 -2
- package/build/workflow/ask.js +8 -8
- package/build/workflow/drive.js +2 -2
- package/build/workflow/keiyaku-document-builder.js +7 -7
- package/build/workflow/keiyaku-draft.js +6 -5
- package/build/workflow/markdown-normalization.js +69 -0
- package/build/workflow/present.js +18 -7
- package/build/workflow/prompts.js +13 -13
- package/build/workflow/response-builders.js +11 -6
- package/build/workflow/round-summary.js +6 -6
- package/build/workflow/start.js +66 -28
- package/package.json +1 -1
|
@@ -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
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
253
|
+
rules: rules && rules.length > 0 ? rules : undefined,
|
|
253
254
|
criteria: criteria && criteria.length > 0 ? criteria : undefined,
|
|
254
255
|
};
|
|
255
256
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { parseToAST, renderNodeContent, } from "../utils/keiyaku-document.js";
|
|
2
|
+
export function computeHeadingDelta(shallowest, minLevel) {
|
|
3
|
+
if (shallowest === null || shallowest >= minLevel) {
|
|
4
|
+
return 0;
|
|
5
|
+
}
|
|
6
|
+
return minLevel - shallowest;
|
|
7
|
+
}
|
|
8
|
+
export function demoteMarkdownHeadings(text, minLevel = 3) {
|
|
9
|
+
const ast = parseToAST(text, { allowSections: false });
|
|
10
|
+
// Preserve relative heading hierarchy by shifting all headings together.
|
|
11
|
+
// This avoids collapsing something like `# A` and `### B` into the same level.
|
|
12
|
+
let shallowest = null;
|
|
13
|
+
{
|
|
14
|
+
const stack = [ast];
|
|
15
|
+
while (stack.length > 0) {
|
|
16
|
+
const node = stack.pop();
|
|
17
|
+
if (!node)
|
|
18
|
+
continue;
|
|
19
|
+
if (node.type === "heading") {
|
|
20
|
+
shallowest = shallowest === null ? node.level : Math.min(shallowest, node.level);
|
|
21
|
+
}
|
|
22
|
+
if (node.type === "code_block" || node.type === "text")
|
|
23
|
+
continue;
|
|
24
|
+
if (node.type === "document" || node.type === "section" || node.type === "list_item") {
|
|
25
|
+
stack.push(...node.children);
|
|
26
|
+
}
|
|
27
|
+
else if (node.type === "list") {
|
|
28
|
+
stack.push(...node.items);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const delta = computeHeadingDelta(shallowest, minLevel);
|
|
33
|
+
if (delta === 0) {
|
|
34
|
+
// Return a normalized render (trimEnd) to avoid leaking extra trailing newlines.
|
|
35
|
+
return renderNodeContent(ast).trimEnd();
|
|
36
|
+
}
|
|
37
|
+
const shift = (node) => {
|
|
38
|
+
switch (node.type) {
|
|
39
|
+
case "heading": {
|
|
40
|
+
const nextLevel = Math.min(6, node.level + delta);
|
|
41
|
+
return nextLevel === node.level ? node : { ...node, level: nextLevel };
|
|
42
|
+
}
|
|
43
|
+
case "code_block":
|
|
44
|
+
case "text":
|
|
45
|
+
return node;
|
|
46
|
+
case "document":
|
|
47
|
+
case "section":
|
|
48
|
+
return { ...node, children: node.children.map((child) => shift(child)) };
|
|
49
|
+
case "list":
|
|
50
|
+
return { ...node, items: node.items.map((item) => shift(item)) };
|
|
51
|
+
case "list_item":
|
|
52
|
+
return { ...node, children: node.children.map((child) => shift(child)) };
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
return renderNodeContent(shift(ast)).trimEnd();
|
|
56
|
+
}
|
|
57
|
+
export function renderMarkdownSections(items) {
|
|
58
|
+
return items
|
|
59
|
+
.map((item) => {
|
|
60
|
+
// Keiyaku uses H2 as contract section delimiters. Keep payload headings at H3+.
|
|
61
|
+
const normalized = demoteMarkdownHeadings(item, 3);
|
|
62
|
+
const trimmed = normalized.trimStart();
|
|
63
|
+
if (/^[-*+]\s+/.test(trimmed) || /^\d+\.\s+/.test(trimmed)) {
|
|
64
|
+
return normalized;
|
|
65
|
+
}
|
|
66
|
+
return `- ${normalized}`;
|
|
67
|
+
})
|
|
68
|
+
.join("\n\n");
|
|
69
|
+
}
|
|
@@ -29,6 +29,14 @@ const SCORE_FIELD_TO_COMMANDMENT = {
|
|
|
29
29
|
score_idiomatic: "Commandment of Idiom",
|
|
30
30
|
score_cohesive: "Commandment of Cohesion",
|
|
31
31
|
};
|
|
32
|
+
const SCORE_FIELD_TO_DEFINITION = {
|
|
33
|
+
score_precise: "Architectural placement. 10 = exact layer, exact boundary, zero misplacement.",
|
|
34
|
+
score_minimal: "Economy of change. 10 = no avoidable lines, no speculative edits, no hidden bloat.",
|
|
35
|
+
score_isolated: "Surgical containment. 10 = zero unrelated files, zero opportunistic cleanup, zero collateral.",
|
|
36
|
+
score_idiomatic: "Native fluency. 10 = naming, structure, style indistinguishable from the codebase.",
|
|
37
|
+
score_cohesive: "Single responsibility. 10 = each unit does one thing, boundaries intact.",
|
|
38
|
+
};
|
|
39
|
+
const VERDICT_DENIED_CODE = "VERDICT_DENIED";
|
|
32
40
|
const VERDICT_SETTINGS_FILE = path.join(".keiyaku", "settings.json");
|
|
33
41
|
function clampThreshold(value, fallback) {
|
|
34
42
|
if (typeof value !== "number" || !Number.isFinite(value))
|
|
@@ -67,7 +75,7 @@ async function resolveVerdictConfig(cwd) {
|
|
|
67
75
|
parsed = JSON.parse(rawSettings);
|
|
68
76
|
}
|
|
69
77
|
catch (error) {
|
|
70
|
-
throw new FlowError(
|
|
78
|
+
throw new FlowError(VERDICT_DENIED_CODE, "Configuration Error: Invalid settings in .keiyaku/settings.json (must be valid JSON).", error);
|
|
71
79
|
}
|
|
72
80
|
if (!parsed || typeof parsed !== "object") {
|
|
73
81
|
return merged;
|
|
@@ -137,10 +145,12 @@ export async function presentWork(input) {
|
|
|
137
145
|
}
|
|
138
146
|
}
|
|
139
147
|
const dirtyFiles = await git.getDirtyFiles(cwd);
|
|
140
|
-
const droppedChanges = dirtyFiles
|
|
148
|
+
const droppedChanges = dirtyFiles
|
|
149
|
+
.filter((file) => file.path !== KEIYAKU_DRAFT_FILE)
|
|
150
|
+
.map((file) => `${file.index}${file.working_dir} ${file.path}`);
|
|
141
151
|
try {
|
|
142
|
-
if (
|
|
143
|
-
await git.
|
|
152
|
+
if (dirtyFiles.length > 0) {
|
|
153
|
+
await git.discardWorkingTreeChangesExcluding(cwd, [KEIYAKU_DRAFT_FILE]);
|
|
144
154
|
}
|
|
145
155
|
await git.checkoutBranch(cwd, baseBranch);
|
|
146
156
|
await git.deleteBranch(cwd, keiyakuBranch, true);
|
|
@@ -182,7 +192,7 @@ export async function presentWork(input) {
|
|
|
182
192
|
const traceContent = await readTraceContent(cwd);
|
|
183
193
|
if (petition === "CLAIM") {
|
|
184
194
|
requireChecks("criteriaChecks", input.criteriaChecks);
|
|
185
|
-
requireChecks("
|
|
195
|
+
requireChecks("rulesChecks", input.rulesChecks);
|
|
186
196
|
const verdict = await resolveVerdictConfig(cwd);
|
|
187
197
|
const failedCommandments = CLOSE_SCORE_FIELDS.flatMap((field) => {
|
|
188
198
|
const score = input[field];
|
|
@@ -190,7 +200,8 @@ export async function presentWork(input) {
|
|
|
190
200
|
const threshold = verdict.thresholds[dimension];
|
|
191
201
|
if (score >= threshold)
|
|
192
202
|
return [];
|
|
193
|
-
|
|
203
|
+
const definition = SCORE_FIELD_TO_DEFINITION[field];
|
|
204
|
+
return [`${SCORE_FIELD_TO_COMMANDMENT[field]} broke (${field}=${score}). Definition: ${definition}`];
|
|
194
205
|
});
|
|
195
206
|
const totalScore = CLOSE_SCORE_FIELDS.reduce((sum, field) => sum + input[field], 0);
|
|
196
207
|
const totalDeficit = verdict.minTotalScore - totalScore;
|
|
@@ -202,7 +213,7 @@ export async function presentWork(input) {
|
|
|
202
213
|
failures.push(`Total devotion deficit: ${totalScore}/${verdict.minTotalScore} (short by ${totalDeficit})`);
|
|
203
214
|
}
|
|
204
215
|
if (failures.length > 0) {
|
|
205
|
-
throw new FlowError(
|
|
216
|
+
throw new FlowError(VERDICT_DENIED_CODE, `Verdict Denied. ${failures.join(". ")}`);
|
|
206
217
|
}
|
|
207
218
|
const expectedOath = resolveOath();
|
|
208
219
|
if (!oathMatches(input.oath, expectedOath)) {
|
|
@@ -10,14 +10,14 @@ Round: 1 (initial implementation).
|
|
|
10
10
|
|
|
11
11
|
1. Read KEIYAKU.md. Use it as source of truth.
|
|
12
12
|
2. If Directive exists, prioritize it. Otherwise, focus on the Goal and Criteria.
|
|
13
|
-
3. Treat all
|
|
13
|
+
3. Treat all rules/criteria as required mission boundaries.
|
|
14
14
|
4. Read KEIYAKU_TRACE.md if present.
|
|
15
15
|
5. Execute:
|
|
16
16
|
- Stay within the scope defined by the Directive (or Goal if no Directive exists).
|
|
17
|
-
- Do not violate
|
|
17
|
+
- Do not violate rules.
|
|
18
18
|
- Aim to progress toward criteria.
|
|
19
19
|
- Match existing structure.
|
|
20
|
-
6. Final Audit: Ensure the ${auditTarget} is fully addressed and no
|
|
20
|
+
6. Final Audit: Ensure the ${auditTarget} is fully addressed and no rules are violated.
|
|
21
21
|
7. Reporting: End with exactly these 6 sections. Use the template below exactly; replace bracketed placeholders with your content. Mark Criteria status (MET/PARTIAL/TODO) honestly based on this round's progress.
|
|
22
22
|
|
|
23
23
|
## Outcome
|
|
@@ -41,8 +41,8 @@ Self-critique (The Delta). Focus only on what is missing or imperfect relative t
|
|
|
41
41
|
## Criteria Check
|
|
42
42
|
- [List each criterion and its status: MET/PARTIAL/TODO]
|
|
43
43
|
|
|
44
|
-
##
|
|
45
|
-
- [List each
|
|
44
|
+
## Rules Check
|
|
45
|
+
- [List each rule and confirm compliance: COMPLIANT/VIOLATED]
|
|
46
46
|
|
|
47
47
|
No git commands. Do not edit KEIYAKU_TRACE.md.`;
|
|
48
48
|
}
|
|
@@ -53,13 +53,13 @@ export function buildIteratePrompt(title, round, goal, directive, context) {
|
|
|
53
53
|
return `You are working on a keiyaku (${title}).${goalBlock}${directiveBlock}${contextBlock}
|
|
54
54
|
Round: ${round} (iteration after review).
|
|
55
55
|
|
|
56
|
-
1. Read KEIYAKU.md. Keep
|
|
56
|
+
1. Read KEIYAKU.md. Keep rules/criteria unchanged.
|
|
57
57
|
2. If Directive/Context exists, prioritize them.
|
|
58
58
|
3. Read KEIYAKU_TRACE.md. Locate feedback from the previous completed round (typically the latest relevant "## Review" section), or generic feedback when a numbered review is absent.
|
|
59
59
|
4. Execute according to the Directive while addressing previous feedback and incorporating any New Context.
|
|
60
|
-
5. No drift from
|
|
60
|
+
5. No drift from rules/criteria.
|
|
61
61
|
6. Keep diff small.
|
|
62
|
-
7. Final Audit: Ensure the Directive is fully addressed and no
|
|
62
|
+
7. Final Audit: Ensure the Directive is fully addressed and no rules are violated.
|
|
63
63
|
8. Reporting: End with exactly these 6 sections. Use the template below exactly; replace bracketed placeholders with your content. Mark Criteria status (MET/PARTIAL/TODO) honestly based on this round's progress.
|
|
64
64
|
|
|
65
65
|
## Outcome
|
|
@@ -83,14 +83,14 @@ Self-critique (The Delta). Focus only on what is missing or imperfect relative t
|
|
|
83
83
|
## Criteria Check
|
|
84
84
|
- [List each criterion and its status: MET/PARTIAL/TODO]
|
|
85
85
|
|
|
86
|
-
##
|
|
87
|
-
- [List each
|
|
86
|
+
## Rules Check
|
|
87
|
+
- [List each rule and confirm compliance: COMPLIANT/VIOLATED]
|
|
88
88
|
|
|
89
89
|
No git commands. Do not edit KEIYAKU_TRACE.md.`;
|
|
90
90
|
}
|
|
91
|
-
export function buildAskPrompt(request, context,
|
|
92
|
-
const referenceBlock =
|
|
93
|
-
? `\nReference
|
|
91
|
+
export function buildAskPrompt(request, context, referenceRules) {
|
|
92
|
+
const referenceBlock = referenceRules
|
|
93
|
+
? `\nReference Rules (project-level; use only if relevant to this ask):\n${referenceRules}\n`
|
|
94
94
|
: "";
|
|
95
95
|
return `Ask mode. Stateless. No git commands.
|
|
96
96
|
|
|
@@ -53,7 +53,7 @@ function colorizeErrorStatus(status) {
|
|
|
53
53
|
}
|
|
54
54
|
const SECTION_ICONS = {
|
|
55
55
|
goal: "▸",
|
|
56
|
-
|
|
56
|
+
rules: "!",
|
|
57
57
|
criteria: "✓",
|
|
58
58
|
context: "◦",
|
|
59
59
|
summary: "·",
|
|
@@ -128,11 +128,16 @@ function getAskToolName() {
|
|
|
128
128
|
export function buildKeiyakuSuccessResponse(result, input) {
|
|
129
129
|
const { status: _status, ...resultData } = result;
|
|
130
130
|
const summarySection = buildSection("Summary", result.summary);
|
|
131
|
-
const
|
|
131
|
+
const rulesSection = buildSection("Rules", truncateForDisplay(result.rules, 1200));
|
|
132
132
|
const diffSection = buildSection("Diff", result.diff);
|
|
133
133
|
const infoLines = [
|
|
134
134
|
...formatMaybe("Identity", input.name, 120),
|
|
135
135
|
...formatMaybe("Path", input.cwd, 300),
|
|
136
|
+
...formatMaybe("Consumed from_file and deleted", result.consumedFromFile, 300),
|
|
137
|
+
...formatList("Existing local keiyaku branches (non-blocking)", result.existingBranches ?? [], {
|
|
138
|
+
maxItems: 10,
|
|
139
|
+
maxItemChars: 220,
|
|
140
|
+
}),
|
|
136
141
|
...formatMaybe("Current Branch", result.currentBranch, 200),
|
|
137
142
|
...formatMaybe("Base Branch", result.baseBranch, 200),
|
|
138
143
|
...formatMaybe("Commit", result.commit, 100),
|
|
@@ -140,7 +145,7 @@ export function buildKeiyakuSuccessResponse(result, input) {
|
|
|
140
145
|
const summaryLine = result.commit
|
|
141
146
|
? `Created branch '${result.currentBranch}' (base: '${result.baseBranch}') [${result.commit}].`
|
|
142
147
|
: `Created branch '${result.currentBranch}' (base: '${result.baseBranch}').`;
|
|
143
|
-
const text = assembleResponse(`◆ Started (Round ${result.round})`, summaryLine, [summarySection,
|
|
148
|
+
const text = assembleResponse(`◆ Started (Round ${result.round})`, summaryLine, [summarySection, rulesSection, diffSection].filter((section) => section !== null), infoLines);
|
|
144
149
|
return {
|
|
145
150
|
content: [{ type: "text", text }],
|
|
146
151
|
structuredContent: buildSuccessStructuredContent(getStartToolName(), resultData),
|
|
@@ -150,7 +155,7 @@ export function buildDriveResponse(result, input) {
|
|
|
150
155
|
const { status: _status, ...resultData } = result;
|
|
151
156
|
const summarySection = buildSection("Summary", result.summary);
|
|
152
157
|
const goalSection = buildSection("Goal", truncateForDisplay(result.goal, 800));
|
|
153
|
-
const
|
|
158
|
+
const rulesSection = buildSection("Rules", truncateForDisplay(result.rules ?? "", 1200));
|
|
154
159
|
const criteriaSection = buildSection("Criteria", truncateForDisplay(result.criteria ?? "", 1200));
|
|
155
160
|
const diffSection = buildSection("Diff", result.diff);
|
|
156
161
|
const infoLines = [
|
|
@@ -163,7 +168,7 @@ export function buildDriveResponse(result, input) {
|
|
|
163
168
|
const summaryLine = result.commit
|
|
164
169
|
? `Updated branch '${result.currentBranch}' [${result.commit}].`
|
|
165
170
|
: `Updated branch '${result.currentBranch}'.`;
|
|
166
|
-
const text = assembleResponse(`◆ Driven (Round ${result.round})`, summaryLine, [summarySection, goalSection,
|
|
171
|
+
const text = assembleResponse(`◆ Driven (Round ${result.round})`, summaryLine, [summarySection, goalSection, rulesSection, criteriaSection, diffSection].filter((section) => section !== null), infoLines);
|
|
167
172
|
return {
|
|
168
173
|
content: [{ type: "text", text }],
|
|
169
174
|
structuredContent: buildSuccessStructuredContent(getDriveToolName(), resultData),
|
|
@@ -195,7 +200,7 @@ export function buildCloseDoneResponse(result, input) {
|
|
|
195
200
|
...formatMaybe("Current Branch", result.currentBranch, 200),
|
|
196
201
|
...formatMaybe("Base Branch", result.baseBranch, 200),
|
|
197
202
|
...formatList("Criteria Checks", input.criteriaChecks, { maxItems: 10, maxItemChars: 220 }),
|
|
198
|
-
...formatList("
|
|
203
|
+
...formatList("Rules Checks", input.rulesChecks ?? [], { maxItems: 10, maxItemChars: 220 }),
|
|
199
204
|
`Scores: precise=${input.score_precise}/10 minimal=${input.score_minimal}/10 isolated=${input.score_isolated}/10 idiomatic=${input.score_idiomatic}/10 cohesive=${input.score_cohesive}/10`,
|
|
200
205
|
...formatMaybe("Oath", input.oath, 220),
|
|
201
206
|
];
|
|
@@ -5,11 +5,11 @@ export const ROUND_SUMMARY_SECTION_KEYS = [
|
|
|
5
5
|
"aestheticsGap",
|
|
6
6
|
"blindspots",
|
|
7
7
|
"criteriaCheck",
|
|
8
|
-
"
|
|
8
|
+
"rulesCheck",
|
|
9
9
|
];
|
|
10
10
|
export const TOOL_DEFAULT_POLICY = Object.freeze({
|
|
11
11
|
includeCriteriaCheck: false,
|
|
12
|
-
|
|
12
|
+
includeRulesCheck: false,
|
|
13
13
|
});
|
|
14
14
|
const SECTION_TITLE_TO_KEY = {
|
|
15
15
|
outcome: "outcome",
|
|
@@ -17,7 +17,7 @@ const SECTION_TITLE_TO_KEY = {
|
|
|
17
17
|
"aesthetics gap": "aestheticsGap",
|
|
18
18
|
blindspots: "blindspots",
|
|
19
19
|
"criteria check": "criteriaCheck",
|
|
20
|
-
"
|
|
20
|
+
"rules check": "rulesCheck",
|
|
21
21
|
};
|
|
22
22
|
function createEmptySections() {
|
|
23
23
|
return {
|
|
@@ -26,7 +26,7 @@ function createEmptySections() {
|
|
|
26
26
|
aestheticsGap: null,
|
|
27
27
|
blindspots: null,
|
|
28
28
|
criteriaCheck: null,
|
|
29
|
-
|
|
29
|
+
rulesCheck: null,
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
function normalizeTitle(value) {
|
|
@@ -62,8 +62,8 @@ function toKnownSection(sectionMarkdown) {
|
|
|
62
62
|
function shouldRenderSection(key, policy) {
|
|
63
63
|
if (key === "criteriaCheck")
|
|
64
64
|
return policy.includeCriteriaCheck;
|
|
65
|
-
if (key === "
|
|
66
|
-
return policy.
|
|
65
|
+
if (key === "rulesCheck")
|
|
66
|
+
return policy.includeRulesCheck;
|
|
67
67
|
return true;
|
|
68
68
|
}
|
|
69
69
|
export function parseRoundSummary(raw) {
|
package/build/workflow/start.js
CHANGED
|
@@ -14,7 +14,7 @@ import { assertCleanWorkingTree } from "./contract.js";
|
|
|
14
14
|
import { runAndRecordRound } from "./round.js";
|
|
15
15
|
import { renderKeiyaku } from "./keiyaku-document-builder.js";
|
|
16
16
|
import { normalizeDraftMarkdownListItems, parseAndValidateKeiyakuDraft, } from "./keiyaku-draft.js";
|
|
17
|
-
const
|
|
17
|
+
const BASE_RULES_FILE = path.join(".keiyaku", "base-rules.md");
|
|
18
18
|
const ACTIVE_KEIYAKU_PREVIEW_MAX_CHARS = 8000;
|
|
19
19
|
const INTERNAL_START_DIRTY_ALLOWLIST = [];
|
|
20
20
|
function requireText(name, value) {
|
|
@@ -30,6 +30,9 @@ function normalizeOptionalText(value) {
|
|
|
30
30
|
const normalized = value.trim();
|
|
31
31
|
return normalized.length > 0 ? normalized : undefined;
|
|
32
32
|
}
|
|
33
|
+
function resolveFromFilePath(cwd, fromFile) {
|
|
34
|
+
return path.isAbsolute(fromFile) ? fromFile : path.join(cwd, fromFile);
|
|
35
|
+
}
|
|
33
36
|
function normalizeOptionalStringList(name, value) {
|
|
34
37
|
if (value === undefined)
|
|
35
38
|
return undefined;
|
|
@@ -137,7 +140,7 @@ async function resolveStartInput(cwd, input) {
|
|
|
137
140
|
directive: normalizeOptionalText(input.directive),
|
|
138
141
|
context: normalizeOptionalText(input.context),
|
|
139
142
|
criteria: normalizeOptionalStringList("criteria", input.criteria),
|
|
140
|
-
|
|
143
|
+
rules: normalizeOptionalStringList("rules", input.rules),
|
|
141
144
|
};
|
|
142
145
|
if (!fromFile) {
|
|
143
146
|
if (!provided.title) {
|
|
@@ -158,12 +161,13 @@ async function resolveStartInput(cwd, input) {
|
|
|
158
161
|
directive: provided.directive,
|
|
159
162
|
context: provided.context,
|
|
160
163
|
criteria: normalizeDraftMarkdownListItems("criteria", provided.criteria),
|
|
161
|
-
|
|
164
|
+
rules: normalizeDraftMarkdownListItems("rules", provided.rules ?? []),
|
|
162
165
|
fromFile: undefined,
|
|
166
|
+
fromFilePath: undefined,
|
|
163
167
|
dirtyAllowlist,
|
|
164
168
|
};
|
|
165
169
|
}
|
|
166
|
-
const draftPath =
|
|
170
|
+
const draftPath = resolveFromFilePath(cwd, fromFile);
|
|
167
171
|
let content;
|
|
168
172
|
try {
|
|
169
173
|
content = await fs.readFile(draftPath, "utf-8");
|
|
@@ -180,7 +184,7 @@ async function resolveStartInput(cwd, input) {
|
|
|
180
184
|
const directive = provided.directive ?? draft.directive;
|
|
181
185
|
const context = provided.context ?? draft.context;
|
|
182
186
|
const criteria = provided.criteria ?? draft.criteria;
|
|
183
|
-
const
|
|
187
|
+
const rules = provided.rules ?? draft.rules ?? [];
|
|
184
188
|
if (!title) {
|
|
185
189
|
throw new FlowError("INVALID_KEIYAKU_DRAFT", "invalid keiyaku draft: missing required field 'title'");
|
|
186
190
|
}
|
|
@@ -199,15 +203,16 @@ async function resolveStartInput(cwd, input) {
|
|
|
199
203
|
directive,
|
|
200
204
|
context,
|
|
201
205
|
criteria: normalizeDraftMarkdownListItems("criteria", criteria),
|
|
202
|
-
|
|
206
|
+
rules: normalizeDraftMarkdownListItems("rules", rules),
|
|
203
207
|
fromFile,
|
|
208
|
+
fromFilePath: draftPath,
|
|
204
209
|
dirtyAllowlist,
|
|
205
210
|
};
|
|
206
211
|
}
|
|
207
|
-
async function
|
|
212
|
+
async function readBaseRules(cwd) {
|
|
208
213
|
try {
|
|
209
|
-
const
|
|
210
|
-
return flattenMarkdownList(
|
|
214
|
+
const baseRulesRaw = await fs.readFile(path.join(cwd, BASE_RULES_FILE), "utf-8");
|
|
215
|
+
return flattenMarkdownList(baseRulesRaw);
|
|
211
216
|
}
|
|
212
217
|
catch (error) {
|
|
213
218
|
if (error?.code !== "ENOENT") {
|
|
@@ -217,8 +222,8 @@ async function readBaseConstraints(cwd) {
|
|
|
217
222
|
}
|
|
218
223
|
}
|
|
219
224
|
async function renderStartKeiyakuContent(cwd, resolved) {
|
|
220
|
-
const
|
|
221
|
-
return renderKeiyaku(resolved.title, resolved.goal, resolved.context,
|
|
225
|
+
const baseRules = await readBaseRules(cwd);
|
|
226
|
+
return renderKeiyaku(resolved.title, resolved.goal, resolved.context, baseRules, resolved.rules, resolved.criteria);
|
|
222
227
|
}
|
|
223
228
|
async function rollbackBranchCreation(cwd, keiyakuBranch, baseBranch) {
|
|
224
229
|
try {
|
|
@@ -239,39 +244,60 @@ async function rollbackBranchCreation(cwd, keiyakuBranch, baseBranch) {
|
|
|
239
244
|
}
|
|
240
245
|
async function handleStartFailure(cwd, error, keiyakuContent, fromFile) {
|
|
241
246
|
let savedDraft = false;
|
|
247
|
+
let replacedExistingDraft = false;
|
|
248
|
+
let draftSaveFailed;
|
|
242
249
|
// Only attempt to save draft if user didn't start from a file.
|
|
243
250
|
if (keiyakuContent && !fromFile) {
|
|
244
251
|
try {
|
|
245
252
|
const draftPath = path.join(cwd, KEIYAKU_DRAFT_FILE);
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
253
|
+
const draftExists = await fileExists(draftPath);
|
|
254
|
+
await fs.writeFile(draftPath, keiyakuContent);
|
|
255
|
+
replacedExistingDraft = draftExists;
|
|
256
|
+
if (draftExists && shouldEmitProgressLogs()) {
|
|
257
|
+
console.warn(`[keiyaku] Existing ${KEIYAKU_DRAFT_FILE} was replaced with the latest failed start draft.`);
|
|
249
258
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (shouldEmitProgressLogs()) {
|
|
254
|
-
console.error(`[keiyaku] Start failed. Draft saved to ${KEIYAKU_DRAFT_FILE}. Retry with: ${startToolName} --from_file ${KEIYAKU_DRAFT_FILE}`);
|
|
255
|
-
}
|
|
256
|
-
savedDraft = true;
|
|
259
|
+
const startToolName = resolveTermPreset().tools.start.name;
|
|
260
|
+
if (shouldEmitProgressLogs()) {
|
|
261
|
+
console.error(`[keiyaku] Start failed. Draft saved to ${KEIYAKU_DRAFT_FILE}. Retry with: ${startToolName} --from_file ${KEIYAKU_DRAFT_FILE}`);
|
|
257
262
|
}
|
|
263
|
+
savedDraft = true;
|
|
258
264
|
}
|
|
259
265
|
catch (draftSaveError) {
|
|
260
266
|
const message = draftSaveError instanceof Error ? draftSaveError.message : String(draftSaveError);
|
|
267
|
+
draftSaveFailed = message;
|
|
261
268
|
if (shouldEmitProgressLogs())
|
|
262
269
|
console.error(`[keiyaku] Failed to save draft file: ${message}`);
|
|
263
270
|
}
|
|
264
271
|
}
|
|
265
272
|
if (savedDraft) {
|
|
266
|
-
const
|
|
273
|
+
const draftReplacedWarning = `Existing ${KEIYAKU_DRAFT_FILE} was replaced with the latest failed start draft`;
|
|
274
|
+
const retryHint = replacedExistingDraft
|
|
275
|
+
? `${draftReplacedWarning}. Draft saved to ${KEIYAKU_DRAFT_FILE}. Retry with from_file: ${KEIYAKU_DRAFT_FILE}`
|
|
276
|
+
: `Draft saved to ${KEIYAKU_DRAFT_FILE}. Retry with from_file: ${KEIYAKU_DRAFT_FILE}`;
|
|
267
277
|
if (isFlowError(error)) {
|
|
268
|
-
throw wrapFlowError("start keiyaku", new FlowError(error.code, `${error.message}\n\n${retryHint}`,
|
|
278
|
+
throw wrapFlowError("start keiyaku", new FlowError(error.code, `${error.message}\n\n${retryHint}`, {
|
|
279
|
+
cause: error,
|
|
280
|
+
suggestion: retryHint,
|
|
281
|
+
}));
|
|
269
282
|
}
|
|
270
283
|
if (error instanceof Error) {
|
|
271
284
|
throw wrapFlowError("start keiyaku", new Error(`${error.message}\n\n${retryHint}`, { cause: error }));
|
|
272
285
|
}
|
|
273
286
|
throw wrapFlowError("start keiyaku", new Error(`${String(error)}\n\n${retryHint}`));
|
|
274
287
|
}
|
|
288
|
+
if (draftSaveFailed) {
|
|
289
|
+
const draftSaveHint = `Failed to save ${KEIYAKU_DRAFT_FILE}: ${draftSaveFailed}`;
|
|
290
|
+
if (isFlowError(error)) {
|
|
291
|
+
throw wrapFlowError("start keiyaku", new FlowError(error.code, `${error.message}\n\n${draftSaveHint}`, {
|
|
292
|
+
cause: error,
|
|
293
|
+
suggestion: draftSaveHint,
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
296
|
+
if (error instanceof Error) {
|
|
297
|
+
throw wrapFlowError("start keiyaku", new Error(`${error.message}\n\n${draftSaveHint}`, { cause: error }));
|
|
298
|
+
}
|
|
299
|
+
throw wrapFlowError("start keiyaku", new Error(`${String(error)}\n\n${draftSaveHint}`));
|
|
300
|
+
}
|
|
275
301
|
throw wrapFlowError("start keiyaku", error);
|
|
276
302
|
}
|
|
277
303
|
export async function startKeiyaku(input) {
|
|
@@ -281,6 +307,8 @@ export async function startKeiyaku(input) {
|
|
|
281
307
|
let createdBranch = false;
|
|
282
308
|
let keiyakuBranch;
|
|
283
309
|
let baseBranch;
|
|
310
|
+
let consumedFromFile;
|
|
311
|
+
let existingBranches;
|
|
284
312
|
try {
|
|
285
313
|
// Eagerly render content to enable draft recovery on early failures.
|
|
286
314
|
keiyakuContent = await renderStartKeiyakuContent(cwd, resolved);
|
|
@@ -292,9 +320,10 @@ export async function startKeiyaku(input) {
|
|
|
292
320
|
if (existingKeiyaku) {
|
|
293
321
|
throw new FlowError("ACTIVE_KEIYAKU_EXISTS", await buildActiveKeiyakuStartMessage(cwd, existingKeiyaku));
|
|
294
322
|
}
|
|
295
|
-
const
|
|
296
|
-
if (
|
|
297
|
-
|
|
323
|
+
const existingKeiyakuBranches = await git.listLocalKeiyakuBranches(cwd);
|
|
324
|
+
if (existingKeiyakuBranches.length > 0) {
|
|
325
|
+
existingBranches = existingKeiyakuBranches;
|
|
326
|
+
const branchWarning = `[keiyaku] existing local keiyaku branches detected (non-blocking): ${existingKeiyakuBranches.join(", ")}`;
|
|
298
327
|
if (shouldEmitProgressLogs())
|
|
299
328
|
console.error(branchWarning);
|
|
300
329
|
appendDebugLog(branchWarning, { cwd, section: "script" });
|
|
@@ -322,7 +351,7 @@ export async function startKeiyaku(input) {
|
|
|
322
351
|
keiyakuContent = await renderStartKeiyakuContent(cwd, resolved);
|
|
323
352
|
}
|
|
324
353
|
const parsedKeiyaku = parseMarkdownStructure(keiyakuContent);
|
|
325
|
-
const
|
|
354
|
+
const rulesSection = parsedKeiyaku.sections.get("rules")?.join("\n").trim() ?? "";
|
|
326
355
|
await fs.writeFile(path.join(cwd, KEIYAKU_FILE), keiyakuContent);
|
|
327
356
|
await fs.writeFile(path.join(cwd, TRACE_FILE), "# Keiyaku Trace\n");
|
|
328
357
|
await git.addFiles(cwd, [KEIYAKU_FILE, TRACE_FILE]);
|
|
@@ -336,12 +365,21 @@ export async function startKeiyaku(input) {
|
|
|
336
365
|
});
|
|
337
366
|
const summary = renderRoundSummary(roundSummary, TOOL_DEFAULT_POLICY);
|
|
338
367
|
const diff = await git.getIncrementalDiff(cwd);
|
|
368
|
+
if (resolved.fromFile && resolved.fromFilePath) {
|
|
369
|
+
await fs.unlink(resolved.fromFilePath);
|
|
370
|
+
consumedFromFile = resolved.fromFile;
|
|
371
|
+
if (shouldEmitProgressLogs()) {
|
|
372
|
+
console.error(`[keiyaku] Consumed from_file and deleted source draft: ${resolved.fromFile}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
339
375
|
return {
|
|
340
376
|
status: "success",
|
|
341
377
|
round: 1,
|
|
342
378
|
diff,
|
|
343
379
|
summary,
|
|
344
|
-
|
|
380
|
+
rules: rulesSection,
|
|
381
|
+
consumedFromFile,
|
|
382
|
+
existingBranches,
|
|
345
383
|
currentBranch: keiyakuBranch,
|
|
346
384
|
baseBranch,
|
|
347
385
|
commit,
|