@astrosheep/keiyaku 0.1.76 → 0.1.78

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 (53) hide show
  1. package/README.md +88 -96
  2. package/build/.tsbuildinfo +1 -1
  3. package/build/config/apply-argument-descriptions.js +1 -1
  4. package/build/config/base-rules.js +14 -7
  5. package/build/config/dotenv.js +17 -11
  6. package/build/config/keiyaku-home.js +9 -0
  7. package/build/config/settings.js +41 -24
  8. package/build/config/term-presets/resolver.js +0 -3
  9. package/build/errno.js +3 -0
  10. package/build/flow-error.js +2 -0
  11. package/build/generated/version.js +1 -1
  12. package/build/git/diff/constants.js +1 -0
  13. package/build/git/diff/filter.js +3 -18
  14. package/build/git/diff/parsers.js +149 -61
  15. package/build/git/diff/preview.js +16 -2
  16. package/build/git/diff/read.js +32 -20
  17. package/build/git/snapshot.js +5 -24
  18. package/build/git/worktree.js +5 -4
  19. package/build/mcp/responses.js +3 -2
  20. package/build/mcp/server.js +61 -69
  21. package/build/protocol/draft-artifacts.js +2 -1
  22. package/build/protocol/file-guards.js +2 -1
  23. package/build/protocol/markdown/lex.js +52 -14
  24. package/build/protocol/markdown/normalization.js +3 -2
  25. package/build/protocol/markdown/parser.js +2 -2
  26. package/build/protocol/response-history.js +44 -5
  27. package/build/protocol/status-previews.js +20 -8
  28. package/build/protocol/summon-draft.js +3 -2
  29. package/build/protocol/summon-input.js +1 -0
  30. package/build/protocol/trace.js +1 -1
  31. package/build/tools/amend/index.js +11 -21
  32. package/build/tools/ask/index.js +11 -18
  33. package/build/tools/ask/persist.js +60 -37
  34. package/build/tools/ask/run.js +17 -7
  35. package/build/tools/create-handler.js +31 -0
  36. package/build/tools/drive/index.js +11 -24
  37. package/build/tools/drive/run.js +9 -5
  38. package/build/tools/petition/claim-gates.js +38 -9
  39. package/build/tools/petition/claim.js +20 -2
  40. package/build/tools/petition/forfeit.js +4 -1
  41. package/build/tools/petition/index.js +43 -58
  42. package/build/tools/petition/run.js +12 -0
  43. package/build/tools/round/head-guard.js +10 -0
  44. package/build/tools/round/incremental-diff.js +6 -2
  45. package/build/tools/round/report.js +24 -2
  46. package/build/tools/round/run.js +6 -0
  47. package/build/tools/round/worktree.js +6 -2
  48. package/build/tools/status/index.js +11 -24
  49. package/build/tools/status/read.js +6 -4
  50. package/build/tools/summon/index.js +17 -27
  51. package/build/tools/summon/run.js +21 -18
  52. package/package.json +6 -6
  53. package/build/git/diff/stat.js +0 -9
@@ -7,8 +7,10 @@ import { runAsk } from "./run.js";
7
7
  import { assertKeiyakuProtocolFiles } from "../../protocol/file-guards.js";
8
8
  import { persistResponseHistory } from "../../protocol/response-history.js";
9
9
  import { getCurrentBranch } from "../../git/branches.js";
10
+ import { MISSING_HEAD_PATTERNS, NOT_GIT_REPOSITORY_PATTERNS, errorContainsAnyPattern } from "../../git/core.js";
10
11
  import { getActiveKeiyakuGitState } from "../../git/keiyaku-state.js";
11
12
  import { addFiles, commit, getLatestCommitHash } from "../../git/staging.js";
13
+ import { isErrnoException } from "../../errno.js";
12
14
  const ASK_TRACE_COMMIT_PREFIX = "ask: ";
13
15
  const ASK_TRACE_COMMIT_SUMMARY_MAX_CHARS = 50;
14
16
  const ASK_TRACE_IGNORE_FLOW_ERRORS = new Set([
@@ -17,6 +19,24 @@ const ASK_TRACE_IGNORE_FLOW_ERRORS = new Set([
17
19
  "MISSING_PROTOCOL_FILES",
18
20
  "NOT_ACTIVE_KEIYAKU_BRANCH",
19
21
  ]);
22
+ function isMissingPathError(error) {
23
+ return isErrnoException(error) && error.code === "ENOENT";
24
+ }
25
+ function isBenignHistoryBranchLookupError(error) {
26
+ return ((isFlowError(error) && error.code === "DETACHED_HEAD") ||
27
+ isMissingPathError(error) ||
28
+ errorContainsAnyPattern(error, NOT_GIT_REPOSITORY_PATTERNS));
29
+ }
30
+ function isBenignHistoryCommitLookupError(error) {
31
+ return (isMissingPathError(error) ||
32
+ errorContainsAnyPattern(error, NOT_GIT_REPOSITORY_PATTERNS) ||
33
+ errorContainsAnyPattern(error, MISSING_HEAD_PATTERNS));
34
+ }
35
+ function isBenignAskTracePersistenceError(error) {
36
+ return ((isFlowError(error) && ASK_TRACE_IGNORE_FLOW_ERRORS.has(error.code)) ||
37
+ isMissingPathError(error) ||
38
+ errorContainsAnyPattern(error, NOT_GIT_REPOSITORY_PATTERNS));
39
+ }
20
40
  function buildAskTraceCommitMessage(request) {
21
41
  const summary = request.replaceAll(/\s+/g, " ").trim().slice(0, ASK_TRACE_COMMIT_SUMMARY_MAX_CHARS);
22
42
  return `${ASK_TRACE_COMMIT_PREFIX}${summary}`;
@@ -24,22 +44,28 @@ function buildAskTraceCommitMessage(request) {
24
44
  export async function runAskAndPersist(input) {
25
45
  const result = await runAsk(input);
26
46
  let responsePath;
47
+ let historyBranch;
27
48
  try {
28
- let historyBranch;
29
- try {
30
- historyBranch = await getCurrentBranch(input.cwd);
31
- }
32
- catch {
33
- historyBranch = undefined;
34
- }
35
- let commit;
36
- try {
37
- commit = await getLatestCommitHash(input.cwd);
49
+ historyBranch = await getCurrentBranch(input.cwd);
50
+ }
51
+ catch (error) {
52
+ if (!isBenignHistoryBranchLookupError(error)) {
53
+ throw error;
38
54
  }
39
- catch {
40
- // Ask can run outside git repos; history should still be persisted.
41
- commit = undefined;
55
+ historyBranch = undefined;
56
+ }
57
+ let commitHash;
58
+ try {
59
+ commitHash = await getLatestCommitHash(input.cwd);
60
+ }
61
+ catch (error) {
62
+ if (!isBenignHistoryCommitLookupError(error)) {
63
+ throw error;
42
64
  }
65
+ // Ask can run outside git repos; history should still be persisted.
66
+ commitHash = undefined;
67
+ }
68
+ try {
43
69
  responsePath = await persistResponseHistory({
44
70
  cwd: input.cwd,
45
71
  tool: "ask",
@@ -51,7 +77,7 @@ export async function runAskAndPersist(input) {
51
77
  sessionId: result.sessionId,
52
78
  subagentName: result.identity,
53
79
  branch: historyBranch,
54
- commit,
80
+ commit: commitHash,
55
81
  });
56
82
  }
57
83
  catch (historyError) {
@@ -69,29 +95,6 @@ export async function runAskAndPersist(input) {
69
95
  summary: result.summary,
70
96
  historyPath: responsePath,
71
97
  });
72
- try {
73
- const activeState = await getActiveKeiyakuGitState(input.cwd);
74
- if (activeState) {
75
- await assertKeiyakuProtocolFiles(input.cwd);
76
- try {
77
- await addFiles(input.cwd, TRACE_FILE);
78
- await commit(input.cwd, buildAskTraceCommitMessage(input.request));
79
- }
80
- catch (error) {
81
- const message = error instanceof Error ? error.message : String(error);
82
- logWarn(`Failed to commit ask trace update: ${message}`, { cwd: input.cwd, section: "script" });
83
- }
84
- }
85
- }
86
- catch (error) {
87
- if (isFlowError(error) && ASK_TRACE_IGNORE_FLOW_ERRORS.has(error.code)) {
88
- // Non-active keiyaku or missing protocol files should not block ask.
89
- }
90
- else {
91
- const message = error instanceof Error ? error.message : String(error);
92
- logWarn(`Unexpected ask trace path error: ${message}`, { cwd: input.cwd, section: "script" });
93
- }
94
- }
95
98
  }
96
99
  catch (askTraceError) {
97
100
  const askTraceErrorMessage = askTraceError instanceof Error ? askTraceError.message : String(askTraceError);
@@ -99,6 +102,26 @@ export async function runAskAndPersist(input) {
99
102
  cwd: input.cwd,
100
103
  section: "script",
101
104
  });
105
+ if (!isBenignAskTracePersistenceError(askTraceError)) {
106
+ logWarn(`Failed to append ask trace entry: ${askTraceErrorMessage}`, {
107
+ cwd: input.cwd,
108
+ section: "script",
109
+ });
110
+ throw askTraceError;
111
+ }
112
+ }
113
+ try {
114
+ const activeState = await getActiveKeiyakuGitState(input.cwd);
115
+ if (activeState) {
116
+ await assertKeiyakuProtocolFiles(input.cwd);
117
+ await addFiles(input.cwd, TRACE_FILE);
118
+ await commit(input.cwd, buildAskTraceCommitMessage(input.request));
119
+ }
120
+ }
121
+ catch (error) {
122
+ if (!isBenignAskTracePersistenceError(error)) {
123
+ throw error;
124
+ }
102
125
  }
103
126
  return { result, responsePath };
104
127
  }
@@ -1,9 +1,11 @@
1
1
  import * as fs from "fs/promises";
2
2
  import * as path from "path";
3
3
  import { KEIYAKU_FILE, TRACE_FILE } from "../../keiyaku.js";
4
+ import { isErrnoException } from "../../errno.js";
4
5
  import { requireText } from "../../flow-error.js";
5
6
  import { selectSubagent } from "../../agents/selector.js";
6
7
  import { runSubagentWithResult } from "../../agents/round-runner.js";
8
+ import { NOT_GIT_REPOSITORY_PATTERNS, errorContainsAnyPattern } from "../../git/core.js";
7
9
  import { getActiveKeiyakuGitState } from "../../git/keiyaku-state.js";
8
10
  import { createWorkspaceSnapshot, getChangedFilesBetweenSnapshots } from "../../git/snapshot.js";
9
11
  import { MAX_DIFF_LINES_PER_FILE } from "../../git/diff/constants.js";
@@ -14,13 +16,16 @@ import { readBaseRules } from "../../config/base-rules.js";
14
16
  import { resolveIncrementalDiffMode } from "../../config/incremental-diff-mode.js";
15
17
  import { resolveAskResponsePath } from "../../protocol/response-history.js";
16
18
  import { INCREMENTAL_DIFF_MAX_CHARS } from "../../keiyaku.js";
17
- async function renderSnapshotDiff(cwd, beforeSha, afterSha, mode) {
18
- const range = `${beforeSha}..${afterSha}`;
19
- if (mode === "stat" || mode === "targeted") {
20
- const statDiff = await readDiff(cwd, range, "stat", { maxChars: INCREMENTAL_DIFF_MAX_CHARS.stat });
19
+ function isBenignAskGitStateError(error) {
20
+ return (isErrnoException(error) && error.code === "ENOENT") || errorContainsAnyPattern(error, NOT_GIT_REPOSITORY_PATTERNS);
21
+ }
22
+ export async function renderSnapshotDiff(cwd, beforeSha, afterSha, mode) {
23
+ const snapshotTarget = [beforeSha, afterSha];
24
+ if (mode === "stat") {
25
+ const statDiff = await readDiff(cwd, snapshotTarget, "stat", { maxChars: INCREMENTAL_DIFF_MAX_CHARS.stat });
21
26
  return statDiff ?? "No incremental diff available.";
22
27
  }
23
- const patchDiff = await readDiff(cwd, range, "patch", {
28
+ const patchDiff = await readDiff(cwd, snapshotTarget, "patch", {
24
29
  maxChars: INCREMENTAL_DIFF_MAX_CHARS.targeted,
25
30
  maxLinesPerFile: MAX_DIFF_LINES_PER_FILE,
26
31
  });
@@ -35,7 +40,7 @@ async function readAskProtocolReferences(cwd) {
35
40
  return { keiyaku, trace };
36
41
  }
37
42
  catch (error) {
38
- if (error?.code === "ENOENT") {
43
+ if (isErrnoException(error) && error.code === "ENOENT") {
39
44
  return undefined;
40
45
  }
41
46
  throw error;
@@ -46,7 +51,12 @@ export async function runAsk(input) {
46
51
  requireText("title", input.title);
47
52
  const request = requireText("request", input.request);
48
53
  const referenceRules = await readBaseRules(cwd);
49
- const activeState = await getActiveKeiyakuGitState(cwd).catch(() => null);
54
+ const activeState = await getActiveKeiyakuGitState(cwd).catch((error) => {
55
+ if (isBenignAskGitStateError(error)) {
56
+ return null;
57
+ }
58
+ throw error;
59
+ });
50
60
  const protocolReferences = activeState ? await readAskProtocolReferences(cwd) : undefined;
51
61
  const prompt = buildAskPrompt(request, input.context, referenceRules, protocolReferences);
52
62
  const beforeSnapshot = await createWorkspaceSnapshot(cwd);
@@ -0,0 +1,31 @@
1
+ import { appendDebugLog } from "../telemetry/debug-log.js";
2
+ import { handleToolError } from "../mcp/tool-errors.js";
3
+ const TOOL_LOG_SECTION = "script";
4
+ function resolveWorkingDirectory(cwd) {
5
+ return typeof cwd === "string" && cwd.length > 0 ? cwd : process.cwd();
6
+ }
7
+ export function createToolHandler(config) {
8
+ return async (input, extra) => {
9
+ const normalizedInput = { ...input, cwd: resolveWorkingDirectory(input.cwd) };
10
+ try {
11
+ appendDebugLog(`${config.logLabel} cwd=${normalizedInput.cwd}`, {
12
+ cwd: normalizedInput.cwd,
13
+ section: TOOL_LOG_SECTION,
14
+ });
15
+ const result = await config.run(normalizedInput, extra);
16
+ const doneLog = typeof config.doneLog === "function" ? config.doneLog(result) : config.doneLog;
17
+ appendDebugLog(doneLog, {
18
+ cwd: normalizedInput.cwd,
19
+ section: TOOL_LOG_SECTION,
20
+ });
21
+ return config.buildResponse(result, normalizedInput);
22
+ }
23
+ catch (error) {
24
+ return handleToolError({
25
+ error,
26
+ cwd: normalizedInput.cwd,
27
+ logLabel: config.logLabel,
28
+ });
29
+ }
30
+ };
31
+ }
@@ -1,35 +1,22 @@
1
- import { appendDebugLog } from "../../telemetry/debug-log.js";
2
1
  import { driveServant } from "./run.js";
3
2
  import { buildDriveResponse, } from "../../mcp/responses.js";
4
- import { handleToolError } from "../../mcp/tool-errors.js";
3
+ import { createToolHandler } from "../create-handler.js";
5
4
  export function createDriveHandler() {
6
- return async ({ title, directive, context, tier, cwd }, extra) => {
7
- const workingDir = cwd || process.cwd();
8
- try {
9
- appendDebugLog(`tool drive start cwd=${workingDir}`, {
10
- cwd: workingDir,
11
- section: "script",
12
- });
13
- const outcome = await driveServant({
14
- cwd: workingDir,
5
+ return createToolHandler({
6
+ logLabel: "tool drive",
7
+ doneLog: (outcome) => `tool drive success round=${outcome.round}`,
8
+ async run({ title, directive, context, tier, cwd }, extra) {
9
+ return driveServant({
10
+ cwd,
15
11
  title,
16
12
  directive,
17
13
  context,
18
14
  tier,
19
15
  signal: extra.signal,
20
16
  });
21
- appendDebugLog(`tool drive success round=${outcome.round}`, {
22
- cwd: workingDir,
23
- section: "script",
24
- });
17
+ },
18
+ buildResponse(outcome, { tier }) {
25
19
  return buildDriveResponse(outcome, { tier });
26
- }
27
- catch (err) {
28
- return handleToolError({
29
- error: err,
30
- cwd: workingDir,
31
- logLabel: "tool drive",
32
- });
33
- }
34
- };
20
+ },
21
+ });
35
22
  }
@@ -23,6 +23,7 @@ import { appendDebugLog } from "../../telemetry/debug-log.js";
23
23
  import { isDraftArtifactPath } from "../../protocol/draft-artifacts.js";
24
24
  import { getUntrackedFiles } from "../../git/worktree.js";
25
25
  import { renderIncrementalDiff } from "../round/incremental-diff.js";
26
+ import { assertHeadUnchanged } from "../round/head-guard.js";
26
27
  const DRIVE_CANCELLATION_NOTE = "Round execution canceled.";
27
28
  const DRIVE_CANCELLATION_REASON = "canceled";
28
29
  function normalizeSectionTitle(title) {
@@ -67,9 +68,10 @@ function buildCancelledRevertCommitMessage(titleToken, round, attemptCommit) {
67
68
  function buildCancelledTraceCommitMessage(titleToken, round) {
68
69
  return buildRoundCommitMessage(titleToken, round, DRIVE_CANCELLATION_REASON);
69
70
  }
70
- async function stageAbortAttemptFiles(cwd) {
71
+ async function stageAbortAttemptFiles(cwd, preRunUntracked) {
71
72
  await addUpdatedFiles(cwd);
72
- const untracked = (await getUntrackedFiles(cwd)).filter((filePath) => !isDraftArtifactPath(filePath));
73
+ const before = new Set(preRunUntracked);
74
+ const untracked = (await getUntrackedFiles(cwd)).filter((filePath) => !isDraftArtifactPath(filePath) && !before.has(filePath));
73
75
  if (untracked.length > 0) {
74
76
  await addFiles(cwd, untracked);
75
77
  }
@@ -80,11 +82,11 @@ async function commitCancellationTraceOnly(cwd, title, round) {
80
82
  await commitChanges(cwd, buildCancelledTraceCommitMessage(title, round));
81
83
  await getLatestCommitHash(cwd);
82
84
  }
83
- async function commitAndRevertCancelledRoundAttempt(cwd, title, round) {
85
+ export async function commitAndRevertCancelledRoundAttempt(cwd, title, round, preRunUntracked) {
84
86
  await appendRoundSystemNote(cwd, round, DRIVE_CANCELLATION_NOTE);
85
87
  let attemptCommit;
86
88
  try {
87
- await stageAbortAttemptFiles(cwd);
89
+ await stageAbortAttemptFiles(cwd, preRunUntracked);
88
90
  await commitChanges(cwd, buildCancelledAttemptCommitMessage(title, round));
89
91
  attemptCommit = await getLatestCommitHash(cwd);
90
92
  }
@@ -142,15 +144,17 @@ export async function driveServant(input) {
142
144
  traceState = computeTraceState(traceContent);
143
145
  }
144
146
  const plan = buildIteratePlan(title, traceState, traceContent, keiyakuContent, goal, normalizedDirective, context, baseRules);
147
+ const headBeforeRound = await getLatestCommitHash(cwd);
145
148
  const preRunUntracked = await getUntrackedFiles(cwd);
146
149
  const { roundSummary, rawSummary, commit, identity, sessionId } = await runAndRecordRound(cwd, title, plan.targetRound, plan.prompt, {
147
150
  signal,
148
151
  tier,
149
152
  failureMode: "round_specific",
150
153
  preRunUntracked,
154
+ beforePersist: () => assertHeadUnchanged(cwd, headBeforeRound, "drive"),
151
155
  beforeRoundWrite: () => appendDirective(cwd, plan.targetRound, plan.directiveReason),
152
156
  onAbort: async () => {
153
- await commitAndRevertCancelledRoundAttempt(cwd, title, plan.targetRound);
157
+ await commitAndRevertCancelledRoundAttempt(cwd, title, plan.targetRound, preRunUntracked);
154
158
  },
155
159
  });
156
160
  const summary = renderToolRoundSummary(roundSummary);
@@ -33,16 +33,33 @@ const REVIEW_REJECTION_MESSAGE = "CLAIM rejected by independent reviewer.";
33
33
  const REVIEW_REJECTION_AUDIT_PREFIX = "Independent reviewer rejected CLAIM.";
34
34
  const REVIEW_RESPONSE_FALLBACK = "(no output)";
35
35
  const REVIEW_SKIPPED_RESULT = { pass: true };
36
+ const REVIEW_ATTEMPT_SECTION_TITLE = "## Review Attempt";
36
37
  const REVIEW_PROMPT_LINES = [
37
38
  "You are an independent reviewer.",
38
- "Evaluate whether the submitted work satisfies the keiyaku criteria.",
39
- "You have full access to the repository. Read any file you need.",
39
+ "Evaluate whether the submitted work fulfills the keiyaku.",
40
+ "You have full access to the repository. Read source files as needed to verify the changes in context.",
41
+ "Do not run tests or commands - a separate verification step handles that.",
42
+ "",
43
+ "## Workflow Context",
44
+ "- KEIYAKU.md and KEIYAKU_TRACE.md are workflow protocol files that will be removed before merge. Ignore them.",
45
+ "- Commit messages in this branch are intermediate; the branch will be squash-merged. Do not evaluate commit hygiene.",
40
46
  "",
41
47
  "## Output Rules",
42
- "- If the work meets all criteria: output exactly `PASS` and nothing else.",
43
- "- If not: output your findings. Be specific. Reference files and lines.",
44
- " Do not suggest fixes. State what is wrong and why it fails the criteria.",
48
+ "- If the work fulfills the keiyaku: begin with `PASS`, optionally followed by observations.",
49
+ "- If not: begin with `REJECTED`, then state what is wrong and why.",
50
+ " Be specific. Reference files and lines. Do not suggest fixes.",
45
51
  ];
52
+ function buildReviewAttemptBlock(reviewNumber) {
53
+ if (reviewNumber === 1) {
54
+ return [REVIEW_ATTEMPT_SECTION_TITLE, "This is review attempt 1."];
55
+ }
56
+ return [
57
+ REVIEW_ATTEMPT_SECTION_TITLE,
58
+ `This is review attempt ${reviewNumber}.`,
59
+ "The prior review was rejected.",
60
+ "Focus on whether the previous concerns have been addressed.",
61
+ ];
62
+ }
46
63
  function buildCommandmentFailureMessage(violationTitle, score, threshold, definition, driveCommandName) {
47
64
  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.`;
48
65
  }
@@ -83,6 +100,8 @@ function buildReviewerPrompt(input) {
83
100
  const lines = [
84
101
  ...REVIEW_PROMPT_LINES,
85
102
  "",
103
+ ...buildReviewAttemptBlock(input.reviewNumber),
104
+ "",
86
105
  "## Keiyaku",
87
106
  input.keiyakuContent,
88
107
  "",
@@ -223,18 +242,26 @@ export function createAgentReviewGate() {
223
242
  claimPlea: input.plea,
224
243
  baseRef: baseBranch,
225
244
  headRef,
245
+ reviewNumber,
226
246
  diffStat: diffStat ?? "No diff.",
227
247
  fullDiff: fullDiff ?? "No diff.",
228
248
  priorFindings: priorReview?.result === "rejected" ? priorReview.findings : undefined,
229
249
  });
230
250
  const responsePathResolution = getConfig().review.resumeSession && priorReview?.historyPath
231
- ? await resolveAskResponsePath({ cwd, responsePath: priorReview.historyPath })
251
+ ? await resolveAskResponsePath({
252
+ cwd,
253
+ responsePath: priorReview.historyPath,
254
+ allowedTools: ["review"],
255
+ onArtifactTypeMismatch: "return-null",
256
+ })
232
257
  : undefined;
233
258
  const subagentResult = await runSubagentExec(REVIEWER_ROLE, prompt, cwd, {
234
259
  signal: input.signal,
235
260
  responsePath: responsePathResolution?.sessionId,
236
261
  });
237
- const findings = subagentResult.finalMessage.trim();
262
+ const raw = subagentResult.finalMessage.trim();
263
+ const passed = raw.startsWith(REVIEW_PASS_TOKEN);
264
+ const observations = passed ? raw.slice(REVIEW_PASS_TOKEN.length).trim() || undefined : undefined;
238
265
  const responsePath = await persistResponseHistory({
239
266
  cwd,
240
267
  tool: "review",
@@ -248,14 +275,15 @@ export function createAgentReviewGate() {
248
275
  });
249
276
  const review = {
250
277
  review: reviewNumber,
251
- result: findings === REVIEW_PASS_TOKEN ? "passed" : "rejected",
278
+ result: passed ? "passed" : "rejected",
252
279
  historyPath: responsePath,
253
- findings: findings === REVIEW_PASS_TOKEN ? undefined : findings || REVIEW_RESPONSE_FALLBACK,
280
+ findings: passed ? observations : raw || REVIEW_RESPONSE_FALLBACK,
254
281
  };
255
282
  if (review.result === "passed") {
256
283
  return {
257
284
  pass: true,
258
285
  review,
286
+ reviewHeadBeforeWrite: headRef,
259
287
  };
260
288
  }
261
289
  return {
@@ -264,6 +292,7 @@ export function createAgentReviewGate() {
264
292
  message: REVIEW_REJECTION_MESSAGE,
265
293
  auditFailures: [`${REVIEW_REJECTION_AUDIT_PREFIX} See Review ${review.review}.`],
266
294
  review,
295
+ reviewHeadBeforeWrite: headRef,
267
296
  };
268
297
  },
269
298
  };
@@ -13,6 +13,7 @@ import { appendReview, computeTraceState, readTraceContent, renderReviewSection,
13
13
  import { assertKeiyakuProtocolFiles } from "../../protocol/file-guards.js";
14
14
  import { assertCleanWorkingTree } from "../round/worktree.js";
15
15
  import { buildClaimGatePipeline, collectClaimScores, } from "./claim-gates.js";
16
+ import { assertHeadUnchanged } from "../round/head-guard.js";
16
17
  function buildMergeMessage(title, plea, keiyakuContent, reportContent) {
17
18
  return `keiyaku(${title}): done\n\nPlea: ${plea}\n\n---\n${keiyakuContent}\n---\n${reportContent}\n---\n`;
18
19
  }
@@ -23,6 +24,9 @@ async function throwClaimGateFailure(failure, input, context, dependencies) {
23
24
  const { cwd, titleToken } = context;
24
25
  const scores = "dangerouslyBypassGates" in input && input.dangerouslyBypassGates ? undefined : collectClaimScores(input);
25
26
  if (failure.review) {
27
+ if (failure.reviewHeadBeforeWrite) {
28
+ await assertHeadUnchanged(cwd, failure.reviewHeadBeforeWrite, "petition claim review");
29
+ }
26
30
  await appendReview(cwd, failure.review);
27
31
  }
28
32
  if (failure.auditFailures && scores) {
@@ -38,6 +42,7 @@ async function runClaimGatePipelineOrThrow(input, context, dependencies) {
38
42
  const driveCommandName = resolveTermPreset().tools.drive.name;
39
43
  const { verdict, verificationCommands } = await dependencies.resolveClaimGateConfig(context.cwd);
40
44
  let review;
45
+ let reviewHeadBeforeWrite;
41
46
  for (const gate of buildClaimGatePipeline()) {
42
47
  const result = await gate.evaluate({
43
48
  cwd: context.cwd,
@@ -51,12 +56,13 @@ async function runClaimGatePipelineOrThrow(input, context, dependencies) {
51
56
  });
52
57
  if (result.review) {
53
58
  review = result.review;
59
+ reviewHeadBeforeWrite = result.reviewHeadBeforeWrite;
54
60
  }
55
61
  if (!result.pass) {
56
62
  await throwClaimGateFailure(result, input, context, dependencies);
57
63
  }
58
64
  }
59
- return { scores, review };
65
+ return { scores, review, reviewHeadBeforeWrite };
60
66
  }
61
67
  export async function claimKeiyaku(input, context, dependencies) {
62
68
  const { cwd, baseBranch, keiyakuBranch, titleToken } = context;
@@ -66,7 +72,7 @@ export async function claimKeiyaku(input, context, dependencies) {
66
72
  const claimTitle = input.title;
67
73
  const claimPlea = input.plea;
68
74
  const claimResult = "dangerouslyBypassGates" in input && input.dangerouslyBypassGates
69
- ? { review: undefined, scores: undefined, bypassedByArchitect: true }
75
+ ? { review: undefined, reviewHeadBeforeWrite: undefined, scores: undefined, bypassedByArchitect: true }
70
76
  : {
71
77
  ...(await runClaimGatePipelineOrThrow(input, { cwd, titleToken }, {
72
78
  ...dependencies,
@@ -80,6 +86,17 @@ export async function claimKeiyaku(input, context, dependencies) {
80
86
  logInfo(`[CLAIM] Collecting diff preview against base '${baseBranch}'`, { cwd, section: "script", progressOnly: true });
81
87
  logInfo("[CLAIM] Reading keiyaku protocol files", { cwd, section: "script", progressOnly: true });
82
88
  const keiyakuContent = await fs.readFile(path.join(cwd, KEIYAKU_FILE), "utf-8");
89
+ if (claimResult.review && claimResult.reviewHeadBeforeWrite) {
90
+ await assertHeadUnchanged(cwd, claimResult.reviewHeadBeforeWrite, "petition claim review");
91
+ await appendReview(cwd, claimResult.review);
92
+ try {
93
+ await dependencies.commitPassedReviewAuditTrail(cwd);
94
+ }
95
+ catch (error) {
96
+ const warning = `[CLAIM] Failed to persist passed review audit trail: ${error instanceof Error ? error.message : String(error)}`;
97
+ logWarn(warning, { cwd, section: "script" });
98
+ }
99
+ }
83
100
  const reportTraceContent = claimResult.review ? `${traceContent}${renderReviewSection(claimResult.review)}` : traceContent;
84
101
  const reportContent = `${reportTraceContent}${renderVerdictSection("ACCEPTED", {
85
102
  scores: claimResult.scores,
@@ -114,6 +131,7 @@ export async function claimKeiyaku(input, context, dependencies) {
114
131
  return {
115
132
  status: "success",
116
133
  result: "merged",
134
+ review: claimResult.review,
117
135
  round,
118
136
  currentBranch: baseBranch,
119
137
  baseBranch,
@@ -16,6 +16,9 @@ function buildForfeitSafetyStashMessage(titleToken) {
16
16
  function buildForfeitRecoveryHint(stashRef) {
17
17
  return `${FORFEIT_SAFETY_STASH_HINT_PREFIX}: ${stashRef}`;
18
18
  }
19
+ function buildArchiveTag(titleToken, now = Date.now()) {
20
+ return `${KEIYAKU_ARCHIVE_TAG_PREFIX}${titleToken}-${now}`;
21
+ }
19
22
  async function restoreForfeitSafetySnapshot(cwd, keiyakuBranch, stashRef) {
20
23
  const hints = [];
21
24
  try {
@@ -38,7 +41,7 @@ async function restoreForfeitSafetySnapshot(cwd, keiyakuBranch, stashRef) {
38
41
  }
39
42
  export async function forfeitKeiyaku(context) {
40
43
  const { cwd, baseBranch, keiyakuBranch, titleToken, draftSnapshot } = context;
41
- const archiveTag = `${KEIYAKU_ARCHIVE_TAG_PREFIX}${titleToken}`;
44
+ const archiveTag = buildArchiveTag(titleToken);
42
45
  let round = 0;
43
46
  try {
44
47
  const trace = await fs.readFile(path.join(cwd, TRACE_FILE), "utf-8");