@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.
@@ -0,0 +1,255 @@
1
+ import { FlowError } from "../common/errors.js";
2
+ import { extractListItems, parseToAST, renderSectionContent, } from "../utils/keiyaku-document.js";
3
+ const KNOWN_DRAFT_SECTIONS = new Set([
4
+ "goal",
5
+ "directive",
6
+ "context",
7
+ "constraints",
8
+ "criteria",
9
+ "acceptance criteria",
10
+ ]);
11
+ function normalizeSectionTitle(title) {
12
+ return title.trim().toLowerCase().replace(/\s+/g, " ");
13
+ }
14
+ function requireDraftText(name, value) {
15
+ const normalized = value.trim();
16
+ if (!normalized) {
17
+ throw new FlowError("EMPTY_PARAM", `parameter '${name}' cannot be empty`);
18
+ }
19
+ return normalized;
20
+ }
21
+ function parseFenceLine(trimmedLine) {
22
+ if (!trimmedLine.startsWith("```"))
23
+ return null;
24
+ let idx = 0;
25
+ while (idx < trimmedLine.length && trimmedLine[idx] === "`")
26
+ idx += 1;
27
+ if (idx < 3)
28
+ return null;
29
+ return idx;
30
+ }
31
+ function splitHeadingGroups(sectionNode) {
32
+ const firstMeaningful = sectionNode.children.find((child) => {
33
+ if (child.type !== "text")
34
+ return true;
35
+ return child.value.trim().length > 0;
36
+ });
37
+ if (!firstMeaningful || firstMeaningful.type !== "heading" || firstMeaningful.level < 2) {
38
+ return [];
39
+ }
40
+ const groups = [];
41
+ let currentChildren = [];
42
+ const commit = () => {
43
+ if (currentChildren.length === 0)
44
+ return;
45
+ groups.push({
46
+ type: "section",
47
+ level: 2,
48
+ title: "",
49
+ children: currentChildren,
50
+ });
51
+ currentChildren = [];
52
+ };
53
+ for (const child of sectionNode.children) {
54
+ if (child.type === "heading" && child.level >= 2) {
55
+ commit();
56
+ currentChildren = [child];
57
+ continue;
58
+ }
59
+ currentChildren.push(child);
60
+ }
61
+ commit();
62
+ return groups
63
+ .map((group) => renderSectionContent(group).trim())
64
+ .filter((item) => item.length > 0);
65
+ }
66
+ function collectSectionItems(ast, sectionTitle) {
67
+ const normalizedSectionTitle = normalizeSectionTitle(sectionTitle);
68
+ const targetSection = ast.children.find((node) => {
69
+ if (node.type !== "section")
70
+ return false;
71
+ if (node.level !== 2)
72
+ return false;
73
+ return normalizeSectionTitle(node.title) === normalizedSectionTitle;
74
+ });
75
+ if (!targetSection || targetSection.type !== "section") {
76
+ return [];
77
+ }
78
+ const listItems = extractListItems(targetSection);
79
+ if (listItems.length > 0) {
80
+ return listItems;
81
+ }
82
+ const grouped = splitHeadingGroups(targetSection);
83
+ if (grouped.length > 0) {
84
+ return grouped;
85
+ }
86
+ const content = renderSectionContent(targetSection).trim();
87
+ return content ? [content] : [];
88
+ }
89
+ function normalizeDraftHeadings(content) {
90
+ const lines = content.split(/\r?\n/);
91
+ if (lines.length > 0) {
92
+ lines[0] = lines[0].replace(/^\uFEFF/, "");
93
+ }
94
+ // Keep structural title/known section headings stable, and shift payload headings together.
95
+ const MIN_HEADING_LEVEL = 3;
96
+ let sawTitleH1 = false;
97
+ let fence = null;
98
+ let shallowestNonStructural = null;
99
+ for (const line of lines) {
100
+ let leadingSpaces = 0;
101
+ while (leadingSpaces < line.length && line[leadingSpaces] === " ")
102
+ leadingSpaces += 1;
103
+ const trimmed = leadingSpaces <= 3 ? line.trimStart() : line;
104
+ if (leadingSpaces <= 3) {
105
+ const fenceLen = parseFenceLine(trimmed);
106
+ if (fenceLen !== null) {
107
+ if (!fence)
108
+ fence = { length: fenceLen };
109
+ else if (fenceLen >= fence.length)
110
+ fence = null;
111
+ continue;
112
+ }
113
+ }
114
+ if (fence)
115
+ continue;
116
+ if (leadingSpaces > 3)
117
+ continue;
118
+ const headerMatch = trimmed.match(/^(#{1,6})[ \t]+(.+?)\s*$/);
119
+ if (!headerMatch)
120
+ continue;
121
+ const level = (headerMatch[1] ?? "").length;
122
+ const text = (headerMatch[2] ?? "").trim();
123
+ if (level === 1 && !sawTitleH1) {
124
+ sawTitleH1 = true;
125
+ continue;
126
+ }
127
+ if (level === 2) {
128
+ // Keep ALL H2 section headers structural in from_file drafts. Unknown headers
129
+ // should remain visible to validation instead of being silently demoted.
130
+ continue;
131
+ }
132
+ shallowestNonStructural = shallowestNonStructural === null ? level : Math.min(shallowestNonStructural, level);
133
+ }
134
+ if (shallowestNonStructural === null || shallowestNonStructural >= MIN_HEADING_LEVEL) {
135
+ return lines.join("\n");
136
+ }
137
+ const delta = MIN_HEADING_LEVEL - shallowestNonStructural;
138
+ const out = [];
139
+ sawTitleH1 = false;
140
+ fence = null;
141
+ for (const line of lines) {
142
+ let leadingSpaces = 0;
143
+ while (leadingSpaces < line.length && line[leadingSpaces] === " ")
144
+ leadingSpaces += 1;
145
+ const trimmed = leadingSpaces <= 3 ? line.trimStart() : line;
146
+ const prefix = line.slice(0, leadingSpaces);
147
+ if (leadingSpaces <= 3) {
148
+ const fenceLen = parseFenceLine(trimmed);
149
+ if (fenceLen !== null) {
150
+ if (!fence)
151
+ fence = { length: fenceLen };
152
+ else if (fenceLen >= fence.length)
153
+ fence = null;
154
+ out.push(line);
155
+ continue;
156
+ }
157
+ }
158
+ if (fence) {
159
+ out.push(line);
160
+ continue;
161
+ }
162
+ if (leadingSpaces <= 3) {
163
+ const headerMatch = trimmed.match(/^(#{1,6})[ \t]+(.+?)\s*$/);
164
+ if (headerMatch) {
165
+ const hashes = headerMatch[1] ?? "";
166
+ const text = (headerMatch[2] ?? "").trim();
167
+ const level = hashes.length;
168
+ if (level === 1) {
169
+ if (!sawTitleH1) {
170
+ sawTitleH1 = true;
171
+ out.push(line);
172
+ continue;
173
+ }
174
+ out.push(`${prefix}${"#".repeat(Math.min(6, level + delta))} ${text}`);
175
+ continue;
176
+ }
177
+ if (level === 2) {
178
+ out.push(line);
179
+ continue;
180
+ }
181
+ out.push(`${prefix}${"#".repeat(Math.min(6, level + delta))} ${text}`);
182
+ continue;
183
+ }
184
+ }
185
+ out.push(line);
186
+ }
187
+ return out.join("\n");
188
+ }
189
+ export function normalizeDraftMarkdownListItems(name, values) {
190
+ const normalized = [];
191
+ for (const [index, value] of values.entries()) {
192
+ const normalizedValue = requireDraftText(`${name}[${index}]`, value);
193
+ const ast = parseToAST(normalizedValue, { allowSections: false });
194
+ const sectionNode = { type: "section", level: 2, title: "", children: ast.children };
195
+ const parsedItems = extractListItems(sectionNode);
196
+ if (parsedItems.length > 0) {
197
+ normalized.push(...parsedItems);
198
+ continue;
199
+ }
200
+ const grouped = splitHeadingGroups(sectionNode);
201
+ if (grouped.length > 0) {
202
+ normalized.push(...grouped);
203
+ continue;
204
+ }
205
+ const rendered = renderSectionContent(sectionNode).trim();
206
+ if (rendered.length > 0) {
207
+ normalized.push(rendered);
208
+ continue;
209
+ }
210
+ normalized.push(normalizedValue);
211
+ }
212
+ return normalized;
213
+ }
214
+ export function parseAndValidateKeiyakuDraft(content) {
215
+ const normalized = normalizeDraftHeadings(content);
216
+ const ast = parseToAST(normalized);
217
+ const sections = new Map();
218
+ let title;
219
+ for (const node of ast.children) {
220
+ if (node.type !== "section")
221
+ continue;
222
+ if (node.level === 1 && !title) {
223
+ title = node.title.trim() || undefined;
224
+ continue;
225
+ }
226
+ if (node.level !== 2)
227
+ continue;
228
+ const normalizedSection = normalizeSectionTitle(node.title);
229
+ if (sections.has(normalizedSection)) {
230
+ throw new FlowError("INVALID_KEIYAKU_DRAFT", `invalid keiyaku draft: duplicate section header '${normalizedSection}'`);
231
+ }
232
+ sections.set(normalizedSection, renderSectionContent(node).trim());
233
+ }
234
+ const unknownSections = [...sections.keys()].filter((section) => !KNOWN_DRAFT_SECTIONS.has(section));
235
+ if (unknownSections.length > 0) {
236
+ throw new FlowError("INVALID_KEIYAKU_DRAFT", `invalid keiyaku draft: unknown section header(s): ${unknownSections.map((section) => `'${section}'`).join(", ")}`);
237
+ }
238
+ const goal = sections.get("goal")?.trim();
239
+ const directive = sections.get("directive")?.trim();
240
+ const context = sections.get("context")?.trim();
241
+ const constraints = sections.has("constraints") ? collectSectionItems(ast, "constraints") : undefined;
242
+ const criteria = sections.has("acceptance criteria")
243
+ ? collectSectionItems(ast, "acceptance criteria")
244
+ : sections.has("criteria")
245
+ ? collectSectionItems(ast, "criteria")
246
+ : undefined;
247
+ return {
248
+ title: title?.trim() || undefined,
249
+ goal: goal || undefined,
250
+ directive: directive || undefined,
251
+ context: context || undefined,
252
+ constraints: constraints && constraints.length > 0 ? constraints : undefined,
253
+ criteria: criteria && criteria.length > 0 ? criteria : undefined,
254
+ };
255
+ }
@@ -1,8 +1,8 @@
1
1
  import * as fs from "fs/promises";
2
2
  import * as path from "path";
3
- import { KEIYAKU_DRAFT_FILE, KEIYAKU_DRAFT_NEW_FILE, KEIYAKU_FILE, TRACE_FILE } from "../common/constants.js";
3
+ import { KEIYAKU_DRAFT_FILE, KEIYAKU_FILE, TRACE_FILE } from "../common/constants.js";
4
4
  import { resolveTermPreset } from "../config/term-presets.js";
5
- import { appendDebugLog } from "../utils/debug-log.js";
5
+ import { appendDebugLog, shouldEmitProgressLogs } from "../utils/debug-log.js";
6
6
  import { FlowError, wrapFlowError } from "../common/errors.js";
7
7
  import * as git from "../utils/git.js";
8
8
  import { computeTraceState, readTraceContent } from "../utils/trace.js";
@@ -98,20 +98,6 @@ function requireChecks(name, values) {
98
98
  async function removeClaimProtocolFiles(cwd) {
99
99
  await fs.unlink(path.join(cwd, KEIYAKU_FILE));
100
100
  await fs.unlink(path.join(cwd, TRACE_FILE));
101
- const draftCandidates = [KEIYAKU_DRAFT_FILE, KEIYAKU_DRAFT_NEW_FILE];
102
- for (const draftPath of draftCandidates) {
103
- const tracked = await git.isPathTracked(cwd, draftPath);
104
- if (tracked)
105
- continue;
106
- try {
107
- await fs.unlink(path.join(cwd, draftPath));
108
- }
109
- catch (error) {
110
- if (error?.code !== "ENOENT") {
111
- throw error;
112
- }
113
- }
114
- }
115
101
  }
116
102
  function formatCloseDiffSummary(stats, baseBranch) {
117
103
  return `Range ${baseBranch}...HEAD | Files ${stats.filesChanged} | +${stats.insertions} / -${stats.deletions}`;
@@ -134,6 +120,7 @@ export async function presentWork(input) {
134
120
  const petition = input.petition;
135
121
  if (petition === "FORFEIT") {
136
122
  let round = 0;
123
+ let preservedDraftContent;
137
124
  try {
138
125
  const trace = await fs.readFile(path.join(cwd, TRACE_FILE), "utf-8");
139
126
  round = computeTraceState(trace).maxRound;
@@ -141,6 +128,14 @@ export async function presentWork(input) {
141
128
  catch {
142
129
  round = 0;
143
130
  }
131
+ try {
132
+ preservedDraftContent = await fs.readFile(path.join(cwd, KEIYAKU_DRAFT_FILE), "utf-8");
133
+ }
134
+ catch (error) {
135
+ if (error?.code !== "ENOENT") {
136
+ throw error;
137
+ }
138
+ }
144
139
  const dirtyFiles = await git.getDirtyFiles(cwd);
145
140
  const droppedChanges = dirtyFiles.map((file) => `${file.index}${file.working_dir} ${file.path}`);
146
141
  try {
@@ -150,6 +145,22 @@ export async function presentWork(input) {
150
145
  await git.checkoutBranch(cwd, baseBranch);
151
146
  await git.deleteBranch(cwd, keiyakuBranch, true);
152
147
  await git.clearKeiyakuBase(cwd, keiyakuBranch);
148
+ if (preservedDraftContent !== undefined) {
149
+ const canonicalDraftPath = path.join(cwd, KEIYAKU_DRAFT_FILE);
150
+ try {
151
+ // Restore as untracked recovery artifact on the base branch.
152
+ await fs.writeFile(canonicalDraftPath, preservedDraftContent, { flag: "wx" });
153
+ }
154
+ catch (error) {
155
+ if (error?.code === "EEXIST") {
156
+ const fallbackDraftFile = "KEIYAKU.draft.forfeit.md";
157
+ await fs.writeFile(path.join(cwd, fallbackDraftFile), preservedDraftContent, { flag: "wx" });
158
+ }
159
+ else {
160
+ throw error;
161
+ }
162
+ }
163
+ }
153
164
  }
154
165
  catch (err) {
155
166
  throw wrapFlowError(`execute FORFEIT (${keiyakuBranch} -> ${baseBranch})`, err);
@@ -197,35 +208,41 @@ export async function presentWork(input) {
197
208
  if (!oathMatches(input.oath, expectedOath)) {
198
209
  throw new FlowError("OATH_MISMATCH", `Oath mismatch. Correct oath: ${expectedOath}`);
199
210
  }
200
- await assertCleanWorkingTree(cwd, [KEIYAKU_DRAFT_FILE, KEIYAKU_DRAFT_NEW_FILE]);
211
+ await assertCleanWorkingTree(cwd, [KEIYAKU_DRAFT_FILE]);
201
212
  try {
202
213
  const invokeDiffLog = `[CLAIM] Collecting diff preview against base '${baseBranch}'`;
203
- console.error(invokeDiffLog);
214
+ if (shouldEmitProgressLogs())
215
+ console.error(invokeDiffLog);
204
216
  appendDebugLog(invokeDiffLog, { cwd, section: "script" });
205
217
  const invokeReadLog = "[CLAIM] Reading keiyaku protocol files";
206
- console.error(invokeReadLog);
218
+ if (shouldEmitProgressLogs())
219
+ console.error(invokeReadLog);
207
220
  appendDebugLog(invokeReadLog, { cwd, section: "script" });
208
221
  const keiyakuContent = await fs.readFile(path.join(cwd, KEIYAKU_FILE), "utf-8");
209
222
  const message = buildMergeMessage(title, keiyakuContent, traceContent);
210
223
  const diffStats = await git.getDiffStats(cwd, baseBranch);
211
224
  const diff = formatCloseDiffSummary(diffStats, baseBranch);
212
225
  const invokeCleanupLog = "[CLAIM] Removing protocol files and creating cleanup commit";
213
- console.error(invokeCleanupLog);
226
+ if (shouldEmitProgressLogs())
227
+ console.error(invokeCleanupLog);
214
228
  appendDebugLog(invokeCleanupLog, { cwd, section: "script" });
215
229
  await removeClaimProtocolFiles(cwd);
216
- await git.addFiles(cwd, "-A");
230
+ await git.addFiles(cwd, [KEIYAKU_FILE, TRACE_FILE]);
217
231
  await git.commit(cwd, `keiyaku(${title}): cleanup`);
218
232
  const invokeCheckoutLog = `[CLAIM] Checking out base branch '${baseBranch}'`;
219
- console.error(invokeCheckoutLog);
233
+ if (shouldEmitProgressLogs())
234
+ console.error(invokeCheckoutLog);
220
235
  appendDebugLog(invokeCheckoutLog, { cwd, section: "script" });
221
236
  await git.checkoutBranch(cwd, baseBranch);
222
237
  const invokeMergeLog = `[CLAIM] Merging '${keiyakuBranch}' into '${baseBranch}'`;
223
- console.error(invokeMergeLog);
238
+ if (shouldEmitProgressLogs())
239
+ console.error(invokeMergeLog);
224
240
  appendDebugLog(invokeMergeLog, { cwd, section: "script" });
225
241
  await git.merge(cwd, keiyakuBranch, message);
226
242
  const commit = await git.getLatestCommitHash(cwd);
227
243
  const invokeFinalizeLog = `[CLAIM] Deleting merged branch '${keiyakuBranch}' and clearing metadata`;
228
- console.error(invokeFinalizeLog);
244
+ if (shouldEmitProgressLogs())
245
+ console.error(invokeFinalizeLog);
229
246
  appendDebugLog(invokeFinalizeLog, { cwd, section: "script" });
230
247
  await git.deleteBranch(cwd, keiyakuBranch, true);
231
248
  await git.clearKeiyakuBase(cwd, keiyakuBranch);
@@ -200,8 +200,8 @@ export function buildCloseDoneResponse(result, input) {
200
200
  ...formatMaybe("Oath", input.oath, 220),
201
201
  ];
202
202
  const text = assembleResponse("✓ Keiyaku Fulfilled (CLAIM)", result.commit
203
- ? `Merged '${result.deletedBranch}' into '${result.mergedInto}' [${result.commit}]. Deleted feature branch.`
204
- : `Merged '${result.deletedBranch}' into '${result.mergedInto}'. Deleted feature branch.`, [typeof diffSection === "string" ? null : diffSection].filter((section) => section !== null), infoLines);
203
+ ? `Merged '${result.deletedBranch}' into '${result.mergedInto}' [${result.commit}]. Deleted feature branch. Note: KEIYAKU.draft.md preserved (if present).`
204
+ : `Merged '${result.deletedBranch}' into '${result.mergedInto}'. Deleted feature branch. Note: KEIYAKU.draft.md preserved (if present).`, [typeof diffSection === "string" ? null : diffSection].filter((section) => section !== null), infoLines);
205
205
  return {
206
206
  content: [{ type: "text", text }],
207
207
  structuredContent: buildSuccessStructuredContent(closeToolName, resultData),
@@ -219,8 +219,8 @@ export function buildCloseDropResponse(result, input) {
219
219
  ...formatMaybe("Base Branch", result.baseBranch, 200),
220
220
  ];
221
221
  const text = assembleResponse("✗ Keiyaku Forfeited (FORFEIT)", droppedChanges.length > 0
222
- ? `Deleted '${result.deletedBranch}'. Switched back to '${result.baseBranch}'. Dropped ${droppedChanges.length} local change(s).`
223
- : `Deleted '${result.deletedBranch}'. Switched back to '${result.baseBranch}'.`, [warningSection].filter((section) => section !== null), infoLines);
222
+ ? `Deleted '${result.deletedBranch}'. Switched back to '${result.baseBranch}'. Dropped ${droppedChanges.length} local change(s). Note: KEIYAKU.draft.md preserved (if present).`
223
+ : `Deleted '${result.deletedBranch}'. Switched back to '${result.baseBranch}'. Note: KEIYAKU.draft.md preserved (if present).`, [warningSection].filter((section) => section !== null), infoLines);
224
224
  return {
225
225
  content: [{ type: "text", text }],
226
226
  structuredContent: buildSuccessStructuredContent(closeToolName, resultData),
@@ -1,8 +1,8 @@
1
1
  import { selectSubagent } from "../agents/selector.js";
2
2
  import { describeSubagentFailure, runSubagent } from "../agents/round-runner.js";
3
- import { TRACE_FILE } from "../common/constants.js";
3
+ import { KEIYAKU_DRAFT_FILE, TRACE_FILE } from "../common/constants.js";
4
4
  import { FlowError } from "../common/errors.js";
5
- import { appendDebugBlock, appendDebugLog } from "../utils/debug-log.js";
5
+ import { appendDebugBlock, appendDebugLog, shouldEmitProgressLogs } from "../utils/debug-log.js";
6
6
  import * as git from "../utils/git.js";
7
7
  import { appendRoundReport } from "../utils/trace.js";
8
8
  import { parseRoundSummary } from "./round-summary.js";
@@ -46,6 +46,10 @@ async function appendRoundResult(cwd, round, summary, failureMessage) {
46
46
  filesModified,
47
47
  });
48
48
  }
49
+ async function stageRoundCommit(cwd) {
50
+ // Never stage draft recovery artifacts; they should remain local unless explicitly committed by the user.
51
+ await git.addFiles(cwd, ["-A", "--", ".", `:(exclude)${KEIYAKU_DRAFT_FILE}`]);
52
+ }
49
53
  export async function runAndRecordRound(cwd, titleToken, round, prompt, options) {
50
54
  const roundSubagent = selectSubagent(options.name);
51
55
  let rawSummary;
@@ -62,11 +66,13 @@ export async function runAndRecordRound(cwd, titleToken, round, prompt, options)
62
66
  }
63
67
  const failure = describeSubagentFailure(err, roundSubagent.displayName, round);
64
68
  const failureLog = `[Subagent failure] name=${failure.name} round=${failure.round} code=${failure.errorCode} timeoutMs=${failure.timeoutMs ?? "unknown"} exitCode=${failure.exitCode ?? "none"}`;
65
- console.error(failureLog);
69
+ if (shouldEmitProgressLogs())
70
+ console.error(failureLog);
66
71
  appendDebugLog(failureLog, { cwd, section: "script" });
67
72
  if (failure.stderrSnippet) {
68
73
  const failureStderrLog = `[Subagent failure stderr]\n${failure.stderrSnippet}`;
69
- console.error(failureStderrLog);
74
+ if (shouldEmitProgressLogs())
75
+ console.error(failureStderrLog);
70
76
  appendDebugBlock("subagent failure stderr", failure.stderrSnippet, { cwd, section: "codex-stderr" });
71
77
  }
72
78
  failureMessage = failure.message.trim() || "Unknown error.";
@@ -74,12 +80,12 @@ export async function runAndRecordRound(cwd, titleToken, round, prompt, options)
74
80
  ? `Round ${round} completed with subagent execution failure recorded in trace.`
75
81
  : "Round 1 completed with subagent execution failure recorded in trace.";
76
82
  await appendRoundResult(cwd, round, summary, failureMessage);
77
- await git.addFiles(cwd, "-A");
83
+ await stageRoundCommit(cwd);
78
84
  await git.commit(cwd, `keiyaku(${titleToken}): round ${round}`);
79
85
  throw new FlowError("ROUND_SUBAGENT_FAILED", formatSubagentFailureMessage(failure), err);
80
86
  }
81
87
  await appendRoundResult(cwd, round, rawSummary);
82
- await git.addFiles(cwd, "-A");
88
+ await stageRoundCommit(cwd);
83
89
  await git.commit(cwd, `keiyaku(${titleToken}): round ${round}`);
84
90
  return { roundSummary: parseRoundSummary(rawSummary) };
85
91
  }