@astrosheep/keiyaku 0.1.47 → 0.1.49

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.
Files changed (58) hide show
  1. package/build/.tsbuildinfo +1 -1
  2. package/build/agents/round-runner.js +1 -1
  3. package/build/agents/selector.js +1 -1
  4. package/build/common/constants.js +6 -1
  5. package/build/common/errors.js +1 -1
  6. package/build/common/response-style.js +4 -1
  7. package/build/config/term-presets/constants.js +24 -0
  8. package/build/config/term-presets/default-preset.js +119 -0
  9. package/build/config/term-presets/index.js +5 -0
  10. package/build/config/term-presets/mischief-preset.js +105 -0
  11. package/build/config/term-presets/pocket-preset.js +105 -0
  12. package/build/config/term-presets/resolver.js +52 -0
  13. package/build/config/term-presets/types.js +1 -0
  14. package/build/handlers/ask.js +10 -120
  15. package/build/handlers/close.js +2 -9
  16. package/build/handlers/drive.js +1 -15
  17. package/build/handlers/shared.js +1 -2
  18. package/build/handlers/start.js +0 -17
  19. package/build/handlers/status.js +2 -6
  20. package/build/index.js +2 -2
  21. package/build/types/git-diff.js +1 -0
  22. package/build/utils/ask-history.js +75 -0
  23. package/build/utils/draft.js +22 -0
  24. package/build/utils/git-diff/constants.js +9 -0
  25. package/build/utils/git-diff/filter.js +70 -0
  26. package/build/utils/git-diff/incremental.js +111 -0
  27. package/build/utils/git-diff/index.js +3 -0
  28. package/build/utils/git-diff/parsers.js +160 -0
  29. package/build/utils/git-diff/preview.js +157 -0
  30. package/build/utils/git-diff/stat.js +27 -0
  31. package/build/utils/git-diff/types.js +1 -0
  32. package/build/utils/git-ops.js +9 -0
  33. package/build/utils/git.js +1 -1
  34. package/build/utils/keiyaku-document/index.js +13 -0
  35. package/build/utils/keiyaku-document/lex.js +178 -0
  36. package/build/utils/keiyaku-document/parser.js +242 -0
  37. package/build/utils/keiyaku-document/render.js +68 -0
  38. package/build/utils/keiyaku-document/sections.js +105 -0
  39. package/build/utils/keiyaku-document/types.js +6 -0
  40. package/build/utils/keiyaku-document.test.js +12 -1
  41. package/build/utils/trace.js +6 -7
  42. package/build/workflow/ask-execution.js +81 -0
  43. package/build/workflow/drive.js +10 -5
  44. package/build/workflow/iterate-plan.js +1 -1
  45. package/build/workflow/keiyaku-draft.js +1 -1
  46. package/build/workflow/{contract.js → keiyaku.js} +2 -2
  47. package/build/workflow/markdown-normalization.js +3 -2
  48. package/build/workflow/present.js +68 -13
  49. package/build/workflow/response-builders.js +75 -44
  50. package/build/workflow/response-meta.js +15 -0
  51. package/build/workflow/response-renderer.js +2 -13
  52. package/build/workflow/round-summary.js +1 -1
  53. package/build/workflow/start.js +8 -3
  54. package/build/workflow/status.js +129 -62
  55. package/package.json +1 -1
  56. package/build/config/term-presets.js +0 -398
  57. package/build/utils/git-diff.js +0 -519
  58. package/build/utils/keiyaku-document.js +0 -539
@@ -0,0 +1,68 @@
1
+ function renderListItemFirstLine(item) {
2
+ const content = renderNodeContent(item);
3
+ if (!content) {
4
+ return `${" ".repeat(item.indent)}${item.marker}`;
5
+ }
6
+ const [firstLine, ...rest] = content.split("\n");
7
+ const prefix = `${" ".repeat(item.indent)}${item.marker}${firstLine.length > 0 ? " " : ""}${firstLine}`;
8
+ if (rest.length === 0) {
9
+ return prefix;
10
+ }
11
+ return [prefix, ...rest].join("\n");
12
+ }
13
+ export function renderBlock(node) {
14
+ switch (node.type) {
15
+ case "section": {
16
+ const header = `${"#".repeat(node.level)} ${node.title}`;
17
+ const body = renderNodeContent(node);
18
+ return body ? `${header}\n${body}` : header;
19
+ }
20
+ case "list":
21
+ return node.items.map((item) => renderListItemFirstLine(item)).join("\n");
22
+ case "code_block":
23
+ return node.lines.join("\n");
24
+ case "blockquote":
25
+ return node.lines.map((line) => `${node.marker}${line}`).join("\n");
26
+ case "text":
27
+ return node.value;
28
+ case "heading":
29
+ return `${"#".repeat(node.level)} ${node.text}`;
30
+ }
31
+ }
32
+ export function renderNodeContent(node) {
33
+ switch (node.type) {
34
+ case "document":
35
+ return node.children.map((child) => renderBlock(child)).join("\n");
36
+ case "section":
37
+ return node.children.map((child) => renderBlock(child)).join("\n");
38
+ case "list":
39
+ return renderBlock(node);
40
+ case "list_item":
41
+ return node.children.map((child) => renderBlock(child)).join("\n");
42
+ case "code_block":
43
+ return node.lines.join("\n");
44
+ case "blockquote":
45
+ return node.lines.map((line) => `${node.marker}${line}`).join("\n");
46
+ case "text":
47
+ return node.value;
48
+ case "heading":
49
+ return `${"#".repeat(node.level)} ${node.text}`;
50
+ }
51
+ }
52
+ export function renderSectionContent(sectionNode) {
53
+ return sectionNode.children.map((child) => renderBlock(child)).join("\n");
54
+ }
55
+ export function extractListItems(sectionNode) {
56
+ const items = [];
57
+ for (const child of sectionNode.children) {
58
+ if (child.type !== "list")
59
+ continue;
60
+ for (const item of child.items) {
61
+ const rendered = renderNodeContent(item).trimEnd();
62
+ if (rendered.trim().length > 0) {
63
+ items.push(rendered);
64
+ }
65
+ }
66
+ }
67
+ return items;
68
+ }
@@ -0,0 +1,105 @@
1
+ import { parseToAST } from "./parser.js";
2
+ import { extractListItems, renderBlock, renderSectionContent } from "./render.js";
3
+ import { KeiyakuParseError } from "./types.js";
4
+ function normalizeSectionTitle(title) {
5
+ return title.trim().toLowerCase().replace(/\s+/g, " ");
6
+ }
7
+ function splitByHeadingBlocks(sectionNode) {
8
+ const groups = [];
9
+ let currentGroup = [];
10
+ const commitGroup = () => {
11
+ if (currentGroup.length === 0)
12
+ return;
13
+ const rendered = currentGroup.map((node) => renderBlock(node)).join("\n").trim();
14
+ if (rendered.length > 0) {
15
+ groups.push(rendered);
16
+ }
17
+ currentGroup = [];
18
+ };
19
+ for (const child of sectionNode.children) {
20
+ if (child.type === "heading" && child.level >= 2) {
21
+ commitGroup();
22
+ currentGroup = [child];
23
+ continue;
24
+ }
25
+ currentGroup.push(child);
26
+ }
27
+ commitGroup();
28
+ return groups;
29
+ }
30
+ export function hasTopLevelHeaders(text) {
31
+ const ast = parseToAST(text, { allowSections: false });
32
+ return (() => {
33
+ const stack = [ast];
34
+ while (stack.length > 0) {
35
+ const node = stack.pop();
36
+ if (!node)
37
+ continue;
38
+ if (node.type === "heading" && node.level <= 2)
39
+ return true;
40
+ // We intentionally do not inspect code block lines for headings.
41
+ if (node.type === "code_block" || node.type === "text")
42
+ continue;
43
+ if (node.type === "document" || node.type === "section" || node.type === "list_item") {
44
+ stack.push(...node.children);
45
+ }
46
+ else if (node.type === "list") {
47
+ stack.push(...node.items);
48
+ }
49
+ }
50
+ return false;
51
+ })();
52
+ }
53
+ export function parseMarkdownListSection(text) {
54
+ const ast = parseToAST(text, { allowSections: false });
55
+ const pseudoSection = {
56
+ type: "section",
57
+ level: 2,
58
+ title: "",
59
+ children: ast.children,
60
+ };
61
+ const listItems = extractListItems(pseudoSection);
62
+ if (listItems.length > 0) {
63
+ return listItems;
64
+ }
65
+ const nonHeadingSection = {
66
+ type: "section",
67
+ level: 2,
68
+ title: "",
69
+ children: pseudoSection.children.filter((child) => child.type !== "heading"),
70
+ };
71
+ const fallback = renderSectionContent(nonHeadingSection).trim();
72
+ return fallback ? [fallback] : [];
73
+ }
74
+ export function parseMarkdownSections(text) {
75
+ const ast = parseToAST(text, { allowSections: false });
76
+ const pseudoSection = {
77
+ type: "section",
78
+ level: 2,
79
+ title: "",
80
+ children: ast.children,
81
+ };
82
+ return splitByHeadingBlocks(pseudoSection);
83
+ }
84
+ export function parseMarkdownStructure(content) {
85
+ const ast = parseToAST(content);
86
+ const sections = new Map();
87
+ let title;
88
+ for (const node of ast.children) {
89
+ if (node.type !== "section")
90
+ continue;
91
+ if (node.level === 1 && !title) {
92
+ title = node.title.trim() || undefined;
93
+ continue;
94
+ }
95
+ if (node.level !== 2)
96
+ continue;
97
+ const normalizedSection = normalizeSectionTitle(node.title);
98
+ if (sections.has(normalizedSection)) {
99
+ throw new KeiyakuParseError(`invalid keiyaku draft: duplicate section header '${normalizedSection}'`);
100
+ }
101
+ const body = renderSectionContent(node).trim();
102
+ sections.set(normalizedSection, body ? body.split("\n") : []);
103
+ }
104
+ return { title, sections };
105
+ }
@@ -0,0 +1,6 @@
1
+ export class KeiyakuParseError extends Error {
2
+ constructor(message, cause) {
3
+ super(message, cause === undefined ? undefined : { cause });
4
+ this.name = "KeiyakuParseError";
5
+ }
6
+ }
@@ -1,6 +1,6 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { extractListItems, hasTopLevelHeaders, KeiyakuParseError, parseToAST, parseMarkdownStructure, parseMarkdownListSection, renderSectionContent, } from "./keiyaku-document.js";
3
+ import { extractListItems, hasTopLevelHeaders, KeiyakuParseError, parseToAST, parseMarkdownStructure, parseMarkdownListSection, renderSectionContent, } from "./keiyaku-document/index.js";
4
4
  import { computeHeadingDelta, demoteMarkdownHeadings, renderMarkdownSections, } from "../workflow/markdown-normalization.js";
5
5
  test("parseToAST models H1/H2 sections and keeps H3+ headers as heading nodes", () => {
6
6
  const markdown = [
@@ -55,6 +55,17 @@ test("parseToAST keeps fenced code blocks as structured nodes", () => {
55
55
  assert.equal(section.children[0]?.type === "code_block" ? section.children[0].lines.join("\n") : "", "```md\n## Not A Section\n```");
56
56
  assert.equal(section.children[1]?.type === "text" ? section.children[1].value : "", "After fence.");
57
57
  });
58
+ test("parseToAST keeps blockquote blocks as structured nodes", () => {
59
+ const markdown = ["## Summary", "> ## Outcome", "> Implemented parser.", ">", "> ## Changes", "> - Added tests."].join("\n");
60
+ const ast = parseToAST(markdown);
61
+ const section = ast.children[0];
62
+ assert.equal(section?.type, "section");
63
+ if (section?.type !== "section")
64
+ return;
65
+ assert.equal(section.children[0]?.type, "blockquote");
66
+ assert.equal(section.children[0]?.type === "blockquote" ? section.children[0].value : "", "## Outcome\nImplemented parser.\n\n## Changes\n- Added tests.");
67
+ assert.equal(section.children[0]?.type === "blockquote" ? renderSectionContent(section) : "", "> ## Outcome\n> Implemented parser.\n> \n> ## Changes\n> - Added tests.");
68
+ });
58
69
  test("parseMarkdownListSection flattens list items and ignores source headers", () => {
59
70
  const markdown = [
60
71
  "## Group A",
@@ -2,10 +2,12 @@ import * as fs from "fs/promises";
2
2
  import * as path from "path";
3
3
  import { TRACE_FILE } from "../common/constants.js";
4
4
  import { RESPONSE_MARKERS } from "../common/response-style.js";
5
- import { parseToAST } from "./keiyaku-document.js";
5
+ import { parseToAST } from "./keiyaku-document/index.js";
6
6
  const DIFF_COORDINATES_SECTION_PREFIX = "Diff Coordinates";
7
7
  const DIFF_COORDINATES_FENCE = "```keiyaku-coords";
8
8
  const DIFF_COORDINATE_LINE_RE = /^(.*):(\d+)(?:-(\d+))?$/;
9
+ const BLOCKQUOTE_PREFIX = "> ";
10
+ const EMPTY_BLOCKQUOTE_LINE = ">";
9
11
  async function fileExists(cwd, filepath) {
10
12
  try {
11
13
  await fs.access(path.join(cwd, filepath));
@@ -33,6 +35,7 @@ export async function appendReview(cwd, round, reason) {
33
35
  export async function appendRoundReport(cwd, report) {
34
36
  const summaryText = report.summary.trim().length > 0 ? report.summary : "Unknown error.";
35
37
  const tracePath = path.join(cwd, TRACE_FILE);
38
+ const quotedSummary = summaryText.split(/\r?\n/).map((line) => (line.length > 0 ? `${BLOCKQUOTE_PREFIX}${line}` : EMPTY_BLOCKQUOTE_LINE));
36
39
  const lines = [
37
40
  "",
38
41
  "---",
@@ -41,9 +44,7 @@ export async function appendRoundReport(cwd, report) {
41
44
  "### Status",
42
45
  report.status,
43
46
  "### Summary",
44
- "```markdown",
45
- summaryText,
46
- "```",
47
+ ...quotedSummary,
47
48
  ];
48
49
  if (report.errorMessage) {
49
50
  lines.push("### Error", report.errorMessage);
@@ -62,9 +63,7 @@ export async function appendRoundSystemNote(cwd, round, note) {
62
63
  "### Status",
63
64
  "FAILED",
64
65
  "### Summary",
65
- "```markdown",
66
- "Subagent execution cancelled by user/client.",
67
- "```",
66
+ `${BLOCKQUOTE_PREFIX}Subagent execution cancelled by user/client.`,
68
67
  "### Error",
69
68
  note,
70
69
  "",
@@ -0,0 +1,81 @@
1
+ import { TRACE_FILE } from "../common/constants.js";
2
+ import { appendAsk } from "../utils/trace.js";
3
+ import { appendDebugLog } from "../utils/debug-log.js";
4
+ import { logWarn } from "../utils/logger.js";
5
+ import { runAsk } from "./ask.js";
6
+ import { assertKeiyakuProtocolFiles } from "./keiyaku.js";
7
+ import { persistAskHistory } from "../utils/ask-history.js";
8
+ import * as git from "../utils/git.js";
9
+ const ASK_TRACE_COMMIT_PREFIX = "ask: ";
10
+ const ASK_TRACE_COMMIT_SUMMARY_MAX_CHARS = 50;
11
+ function buildAskTraceCommitMessage(request) {
12
+ const summary = request.replaceAll(/\s+/g, " ").trim().slice(0, ASK_TRACE_COMMIT_SUMMARY_MAX_CHARS);
13
+ return `${ASK_TRACE_COMMIT_PREFIX}${summary}`;
14
+ }
15
+ export async function runAskAndPersist(input) {
16
+ const result = await runAsk(input);
17
+ let warning;
18
+ let savedTo;
19
+ try {
20
+ let commit;
21
+ try {
22
+ commit = await git.getLatestCommitHash(input.cwd);
23
+ }
24
+ catch {
25
+ // Ask can run outside git repos; history should still be persisted.
26
+ commit = undefined;
27
+ }
28
+ savedTo = await persistAskHistory({
29
+ cwd: input.cwd,
30
+ request: input.request,
31
+ context: input.context,
32
+ summary: result.summary,
33
+ sessionId: input.sessionId,
34
+ branch: result.currentBranch,
35
+ commit,
36
+ });
37
+ }
38
+ catch (historyError) {
39
+ const historyErrorMessage = historyError instanceof Error ? historyError.message : String(historyError);
40
+ appendDebugLog(`tool ask history logging failed: ${historyErrorMessage}`, {
41
+ cwd: input.cwd,
42
+ section: "script",
43
+ });
44
+ warning = `Failed to persist ask history: ${historyErrorMessage}`;
45
+ }
46
+ try {
47
+ await appendAsk(input.cwd, {
48
+ request: input.request,
49
+ context: input.context,
50
+ summary: result.summary,
51
+ diffStats: result.diffStats,
52
+ });
53
+ try {
54
+ const activeState = await git.getActiveKeiyakuGitState(input.cwd);
55
+ if (activeState) {
56
+ await assertKeiyakuProtocolFiles(input.cwd);
57
+ try {
58
+ await git.addFiles(input.cwd, TRACE_FILE);
59
+ await git.commit(input.cwd, buildAskTraceCommitMessage(input.request));
60
+ }
61
+ catch (error) {
62
+ const message = error instanceof Error ? error.message : String(error);
63
+ const commitWarning = `Failed to commit ask trace update: ${message}`;
64
+ logWarn(commitWarning, { cwd: input.cwd, section: "script" });
65
+ warning = warning ? `${warning}\n${commitWarning}` : commitWarning;
66
+ }
67
+ }
68
+ }
69
+ catch {
70
+ // Non-active keiyaku or missing protocol files should not block ask.
71
+ }
72
+ }
73
+ catch (askTraceError) {
74
+ const askTraceErrorMessage = askTraceError instanceof Error ? askTraceError.message : String(askTraceError);
75
+ appendDebugLog(`tool ask trace logging failed: ${askTraceErrorMessage}`, {
76
+ cwd: input.cwd,
77
+ section: "script",
78
+ });
79
+ }
80
+ return { result, warning, savedTo };
81
+ }
@@ -1,17 +1,18 @@
1
1
  import * as fs from "fs/promises";
2
2
  import * as path from "path";
3
- import { KEIYAKU_BRANCH_PREFIX, KEIYAKU_DRAFT_FILE, KEIYAKU_FILE, TRACE_FILE } from "../common/constants.js";
3
+ import { INCREMENTAL_DIFF_MAX_CHARS, KEIYAKU_BRANCH_PREFIX, KEIYAKU_DRAFT_FILE, KEIYAKU_FILE, TRACE_FILE, } from "../common/constants.js";
4
4
  import { FlowError, requireText, wrapFlowError } from "../common/errors.js";
5
5
  import { appendRoundSystemNote, appendReview, computeTraceState, parseDiffCoordinates, readTraceContent, } from "../utils/trace.js";
6
6
  import * as git from "../utils/git.js";
7
- import { KeiyakuParseError, parseMarkdownStructure } from "../utils/keiyaku-document.js";
7
+ import { KeiyakuParseError, parseMarkdownStructure } from "../utils/keiyaku-document/index.js";
8
8
  import { logWarn } from "../utils/logger.js";
9
9
  import { renderToolRoundSummary } from "./round-summary.js";
10
- import { assertCleanWorkingTree, buildNoActiveKeiyakuGuidance, ensureKeiyakuFiles } from "./contract.js";
10
+ import { assertCleanWorkingTree, assertKeiyakuProtocolFiles, buildNoActiveKeiyakuGuidance } from "./keiyaku.js";
11
11
  import { buildRoundCommitMessage, runAndRecordRound } from "./round.js";
12
12
  import { resolveIncrementalDiffMode } from "../config/incremental-diff-mode.js";
13
13
  import { buildIteratePlan } from "./iterate-plan.js";
14
14
  import { readBaseRules } from "./base-rules.js";
15
+ import { resolveWorkflowHints } from "./response-meta.js";
15
16
  function normalizeSectionTitle(title) {
16
17
  return title.trim().toLowerCase().replace(/\s+/g, " ");
17
18
  }
@@ -57,7 +58,7 @@ export async function driveServant(input) {
57
58
  throw new FlowError("MISSING_KEIYAKU_BASE", `branch ${keiyakuBranch} is missing base metadata; cannot continue drive`);
58
59
  }
59
60
  await assertCleanWorkingTree(cwd, [KEIYAKU_DRAFT_FILE]);
60
- await ensureKeiyakuFiles(cwd);
61
+ await assertKeiyakuProtocolFiles(cwd);
61
62
  const keiyakuContent = await fs.readFile(path.join(cwd, KEIYAKU_FILE), "utf-8");
62
63
  const parsedKeiyaku = parseKeiyakuStructure(keiyakuContent);
63
64
  const traceContent = await readTraceContent(cwd);
@@ -94,7 +95,10 @@ export async function driveServant(input) {
94
95
  coordinates = undefined;
95
96
  }
96
97
  }
97
- const diff = await git.getIncrementalDiff(cwd, incrementalDiffMode, coordinates);
98
+ const diff = await git.getIncrementalDiff(cwd, incrementalDiffMode, {
99
+ coordinates,
100
+ maxChars: INCREMENTAL_DIFF_MAX_CHARS,
101
+ });
98
102
  return {
99
103
  status: "success",
100
104
  round: plan.targetRound,
@@ -103,6 +107,7 @@ export async function driveServant(input) {
103
107
  currentBranch: keiyakuBranch,
104
108
  baseBranch,
105
109
  commit,
110
+ meta: { hints: resolveWorkflowHints("drive") },
106
111
  };
107
112
  }
108
113
  catch (err) {
@@ -1,6 +1,6 @@
1
1
  import { ITERATE_PRIOR_ROUND_SUMMARY_COUNT } from "../common/constants.js";
2
2
  import { FlowError } from "../common/errors.js";
3
- import { parseToAST, renderSectionContent } from "../utils/keiyaku-document.js";
3
+ import { parseToAST, renderSectionContent } from "../utils/keiyaku-document/index.js";
4
4
  import { buildIteratePrompt } from "./prompts.js";
5
5
  function readRoundSectionNumber(title) {
6
6
  const match = /^round\s+(\d+)$/i.exec(title.trim());
@@ -1,5 +1,5 @@
1
1
  import { FlowError } from "../common/errors.js";
2
- import { KeiyakuParseError, parseToAST, renderSectionContent, } from "../utils/keiyaku-document.js";
2
+ import { KeiyakuParseError, parseToAST, renderSectionContent, } from "../utils/keiyaku-document/index.js";
3
3
  import { computeHeadingDelta } from "./markdown-normalization.js";
4
4
  const KNOWN_DRAFT_SECTIONS = new Set([
5
5
  "goal",
@@ -1,13 +1,13 @@
1
1
  import * as fs from "fs/promises";
2
2
  import * as path from "path";
3
- import { resolveTermPreset } from "../config/term-presets.js";
3
+ import { resolveTermPreset } from "../config/term-presets/index.js";
4
4
  import { KEIYAKU_FILE, TRACE_FILE } from "../common/constants.js";
5
5
  import { FlowError } from "../common/errors.js";
6
6
  import * as git from "../utils/git.js";
7
7
  import { resolveOath } from "./oath.js";
8
8
  export { resolveOath };
9
9
  const DIRTY_WORKTREE_LIST_LIMIT = 10;
10
- export async function ensureKeiyakuFiles(cwd) {
10
+ export async function assertKeiyakuProtocolFiles(cwd) {
11
11
  const keiyakuPath = path.join(cwd, KEIYAKU_FILE);
12
12
  const tracePath = path.join(cwd, TRACE_FILE);
13
13
  try {
@@ -1,4 +1,4 @@
1
- import { parseToAST, renderNodeContent, } from "../utils/keiyaku-document.js";
1
+ import { parseToAST, renderNodeContent, } from "../utils/keiyaku-document/index.js";
2
2
  export function computeHeadingDelta(shallowest, minLevel) {
3
3
  if (shallowest === null || shallowest >= minLevel) {
4
4
  return 0;
@@ -19,7 +19,7 @@ export function demoteMarkdownHeadings(text, minLevel = 3) {
19
19
  if (node.type === "heading") {
20
20
  shallowest = shallowest === null ? node.level : Math.min(shallowest, node.level);
21
21
  }
22
- if (node.type === "code_block" || node.type === "text")
22
+ if (node.type === "code_block" || node.type === "blockquote" || node.type === "text")
23
23
  continue;
24
24
  if (node.type === "document" || node.type === "section" || node.type === "list_item") {
25
25
  stack.push(...node.children);
@@ -41,6 +41,7 @@ export function demoteMarkdownHeadings(text, minLevel = 3) {
41
41
  return nextLevel === node.level ? node : { ...node, level: nextLevel };
42
42
  }
43
43
  case "code_block":
44
+ case "blockquote":
44
45
  case "text":
45
46
  return node;
46
47
  case "document":
@@ -1,13 +1,15 @@
1
1
  import * as fs from "fs/promises";
2
2
  import * as path from "path";
3
+ import { spawn } from "node:child_process";
3
4
  import { KEIYAKU_ARCHIVE_TAG_PREFIX, KEIYAKU_BRANCH_PREFIX, KEIYAKU_DRAFT_FILE, KEIYAKU_FILE, TRACE_FILE, } from "../common/constants.js";
4
- import { resolveTermPreset } from "../config/term-presets.js";
5
+ import { resolveTermPreset } from "../config/term-presets/index.js";
5
6
  import { logInfo, logWarn } from "../utils/logger.js";
6
7
  import { FlowError, wrapFlowError } from "../common/errors.js";
8
+ import { readDraftSnapshot, restoreDraftSnapshot } from "../utils/draft.js";
7
9
  import * as git from "../utils/git.js";
8
10
  import { appendVerdict, computeTraceState, readTraceContent } from "../utils/trace.js";
9
11
  import { oathMatches } from "./oath.js";
10
- import { assertCleanWorkingTree, buildNoActiveKeiyakuGuidance, ensureKeiyakuFiles, resolveOath } from "./contract.js";
12
+ import { assertCleanWorkingTree, assertKeiyakuProtocolFiles, buildNoActiveKeiyakuGuidance, resolveOath } from "./keiyaku.js";
11
13
  const DIMENSIONS = [
12
14
  "placement",
13
15
  "exactness",
@@ -32,6 +34,10 @@ const DIMENSION_STANDARDS = {
32
34
  const VERDICT_DENIED_CODE = "VERDICT_DENIED";
33
35
  const VERDICT_SETTINGS_FILE = path.join(".keiyaku", "settings.json");
34
36
  const DIMENSION_KEY_SET = new Set(DIMENSIONS);
37
+ const CLAIM_VERIFICATION_COMMANDS = [
38
+ { command: "npm", args: ["test"], label: "npm test" },
39
+ { command: "npm", args: ["run", "test:typecheck"], label: "npm run test:typecheck" },
40
+ ];
35
41
  function clampThreshold(value, fallback) {
36
42
  if (typeof value !== "number" || !Number.isFinite(value))
37
43
  return fallback;
@@ -118,6 +124,58 @@ function collectScores(input) {
118
124
  cohesive: input.cohesive,
119
125
  };
120
126
  }
127
+ async function runCommand(options) {
128
+ return await new Promise((resolve, reject) => {
129
+ const child = spawn(options.command, options.args, {
130
+ cwd: options.cwd,
131
+ stdio: ["ignore", "pipe", "pipe"],
132
+ env: process.env,
133
+ });
134
+ let stdout = "";
135
+ let stderr = "";
136
+ child.stdout.on("data", (chunk) => {
137
+ stdout += String(chunk);
138
+ });
139
+ child.stderr.on("data", (chunk) => {
140
+ stderr += String(chunk);
141
+ });
142
+ child.on("error", reject);
143
+ child.on("close", (code) => {
144
+ resolve({
145
+ code: typeof code === "number" ? code : -1,
146
+ stdout,
147
+ stderr,
148
+ });
149
+ });
150
+ });
151
+ }
152
+ function clipCommandOutput(value, maxChars = 1200) {
153
+ const trimmed = value.trim();
154
+ if (!trimmed)
155
+ return "(no output)";
156
+ if (trimmed.length <= maxChars)
157
+ return trimmed;
158
+ return `${trimmed.slice(0, maxChars)}\n… (+${trimmed.length - maxChars} chars)`;
159
+ }
160
+ async function runClaimVerificationGate(cwd) {
161
+ for (const command of CLAIM_VERIFICATION_COMMANDS) {
162
+ logInfo(`[CLAIM] Running verification: ${command.label}`, { cwd, section: "script", progressOnly: true });
163
+ const result = await runCommand({
164
+ cwd,
165
+ command: command.command,
166
+ args: command.args,
167
+ });
168
+ if (result.code !== 0) {
169
+ throw new FlowError("CLOSE_QUALITY_GATE_FAILED", `CLAIM blocked: verification failed at '${command.label}' (exit ${result.code}).`, {
170
+ suggestion: [
171
+ `Fix failures from '${command.label}', then retry CLAIM.`,
172
+ `stdout:\n${clipCommandOutput(result.stdout)}`,
173
+ `stderr:\n${clipCommandOutput(result.stderr)}`,
174
+ ].join("\n\n"),
175
+ });
176
+ }
177
+ }
178
+ }
121
179
  export function buildCommandmentFailureMessage(violationTitle, score, threshold, definition, driveCommandName) {
122
180
  return `${violationTitle} score too low (${score} < ${threshold})\n${definition}\nIdentify what needs to change to meet this standard. Then use \`${driveCommandName}\` to implement those improvements in the code. Score edits without new implementation evidence are treated as oath violation.`;
123
181
  }
@@ -155,14 +213,7 @@ export async function presentWork(input) {
155
213
  }
156
214
  const title = keiyakuBranch.slice(KEIYAKU_BRANCH_PREFIX.length);
157
215
  const petition = input.petition;
158
- let draftKept = false;
159
- try {
160
- const stat = await fs.stat(path.join(cwd, KEIYAKU_DRAFT_FILE));
161
- draftKept = stat.isFile();
162
- }
163
- catch {
164
- // ignored
165
- }
216
+ const draftSnapshot = await readDraftSnapshot(cwd);
166
217
  if (petition === "FORFEIT") {
167
218
  const archiveTag = `${KEIYAKU_ARCHIVE_TAG_PREFIX}${title}`;
168
219
  let round = 0;
@@ -181,6 +232,9 @@ export async function presentWork(input) {
181
232
  await git.discardAllWorkingTreeChanges(cwd);
182
233
  }
183
234
  await git.checkoutBranch(cwd, baseBranch);
235
+ if (draftSnapshot) {
236
+ await restoreDraftSnapshot(cwd, draftSnapshot);
237
+ }
184
238
  await git.deleteBranch(cwd, keiyakuBranch, true);
185
239
  await git.clearKeiyakuBase(cwd, keiyakuBranch);
186
240
  }
@@ -199,10 +253,10 @@ export async function presentWork(input) {
199
253
  ? `Forfeited without merge. Dropped ${droppedChanges.length} local change(s).`
200
254
  : "Forfeited without merge.",
201
255
  droppedChanges,
202
- draftKept,
256
+ draftPath: draftSnapshot?.path ?? null,
203
257
  };
204
258
  }
205
- await ensureKeiyakuFiles(cwd);
259
+ await assertKeiyakuProtocolFiles(cwd);
206
260
  let traceContent = await readTraceContent(cwd);
207
261
  if (petition === "CLAIM") {
208
262
  const driveCommandName = resolveTermPreset().tools.drive.name;
@@ -239,6 +293,7 @@ export async function presentWork(input) {
239
293
  await appendDeniedVerdictWithAuditCommit(cwd, title, scores, ["Oath mismatch."]);
240
294
  throw oathMismatchError;
241
295
  }
296
+ await runClaimVerificationGate(cwd);
242
297
  await assertCleanWorkingTree(cwd, [KEIYAKU_DRAFT_FILE]);
243
298
  try {
244
299
  const invokeDiffLog = `[CLAIM] Collecting diff preview against base '${baseBranch}'`;
@@ -282,7 +337,7 @@ export async function presentWork(input) {
282
337
  mergedInto: baseBranch,
283
338
  deletedBranch: keiyakuBranch,
284
339
  diff,
285
- draftKept,
340
+ draftPath: draftSnapshot?.path ?? null,
286
341
  };
287
342
  }
288
343
  catch (err) {