@astrosheep/keiyaku 2.9.8 → 2.9.10

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 (230) hide show
  1. package/README.md +50 -182
  2. package/build/.tsbuildinfo +1 -1
  3. package/build/agents/harness/control-types.js +18 -1
  4. package/build/agents/harness/index.js +2 -0
  5. package/build/agents/harness/outcome-error.js +81 -0
  6. package/build/agents/harness/projection.js +10 -7
  7. package/build/agents/harness/router.js +5 -1
  8. package/build/agents/providers/claude-agent-sdk/adapter.js +18 -4
  9. package/build/agents/providers/claude-agent-sdk/registration.js +11 -1
  10. package/build/agents/providers/claude-agent-sdk/session.js +19 -5
  11. package/build/agents/providers/codex-app-server/adapter.js +11 -6
  12. package/build/agents/providers/codex-app-server/events.js +67 -6
  13. package/build/agents/providers/codex-app-server/registration.js +11 -1
  14. package/build/agents/providers/opencode-sdk/adapter.js +8 -1
  15. package/build/agents/providers/opencode-sdk/events.js +24 -2
  16. package/build/agents/providers/opencode-sdk/registration.js +11 -1
  17. package/build/agents/providers/pi/checkpoint.js +9 -1
  18. package/build/agents/providers/pi/events.js +2 -0
  19. package/build/agents/providers/pi/registration.js +14 -1
  20. package/build/cli/commands/akuma/akuma/handler.js +1 -1
  21. package/build/cli/commands/akuma/akuma/meta.js +3 -3
  22. package/build/cli/commands/akuma/catalog.js +2 -2
  23. package/build/cli/commands/akuma/{view → show}/handler.js +5 -5
  24. package/build/cli/commands/akuma/{view → show}/meta.js +3 -3
  25. package/build/cli/commands/akuma.js +1 -1
  26. package/build/cli/commands/contract/amend/handler.js +5 -4
  27. package/build/cli/commands/contract/arc/handler.js +5 -4
  28. package/build/cli/commands/contract/audit/handler.js +2 -2
  29. package/build/cli/commands/contract/bind/handler.js +12 -3
  30. package/build/cli/commands/contract/bind/meta.js +45 -15
  31. package/build/cli/commands/contract/forfeit/handler.js +9 -5
  32. package/build/cli/commands/contract/log/handler.js +2 -2
  33. package/build/cli/commands/contract/petition/handler.js +34 -7
  34. package/build/cli/commands/contract/renew/handler.js +7 -7
  35. package/build/cli/commands/contract/renew/meta.js +3 -3
  36. package/build/cli/commands/metadata.js +10 -6
  37. package/build/cli/commands/projection/call/meta.js +1 -1
  38. package/build/cli/commands/projection/catalog.js +2 -0
  39. package/build/cli/commands/projection/history/handler.js +21 -0
  40. package/build/cli/commands/projection/history/meta.js +9 -0
  41. package/build/cli/commands/projection/shared.js +3 -4
  42. package/build/cli/commands/projection/status/handler.js +3 -3
  43. package/build/cli/commands/projection/wait/handler.js +20 -11
  44. package/build/cli/commands/task/add/handler.js +14 -5
  45. package/build/cli/commands/task/add/meta.js +2 -2
  46. package/build/cli/commands/task/catalog.js +4 -0
  47. package/build/cli/commands/task/done/handler.js +1 -4
  48. package/build/cli/commands/task/done/meta.js +1 -1
  49. package/build/cli/commands/task/drop/meta.js +1 -1
  50. package/build/cli/commands/task/hold/handler.js +3 -0
  51. package/build/cli/commands/task/hold/meta.js +8 -0
  52. package/build/cli/commands/task/log/handler.js +1 -1
  53. package/build/cli/commands/task/log/meta.js +2 -2
  54. package/build/cli/commands/task/resume/handler.js +3 -0
  55. package/build/cli/commands/task/resume/meta.js +8 -0
  56. package/build/cli/commands/task/shared.js +39 -14
  57. package/build/cli/commands/task/show/handler.js +1 -1
  58. package/build/cli/commands/task/show/meta.js +1 -1
  59. package/build/cli/commands/task/start/meta.js +1 -1
  60. package/build/cli/commands/task/stop/handler.js +1 -4
  61. package/build/cli/commands/task/stop/meta.js +1 -1
  62. package/build/cli/commands/task/update/handler.js +11 -3
  63. package/build/cli/commands/task/update/meta.js +3 -3
  64. package/build/cli/completion.js +3 -2
  65. package/build/cli/flags.js +51 -14
  66. package/build/cli/help.js +4 -1
  67. package/build/cli/index.js +40 -25
  68. package/build/cli/parse-flags.js +40 -7
  69. package/build/cli/parse-metadata.js +5 -2
  70. package/build/cli/parse-selectors.js +2 -3
  71. package/build/cli/parse.js +40 -6
  72. package/build/cli/projection-address.js +12 -10
  73. package/build/cli/render/amendment-warning.js +8 -0
  74. package/build/cli/render/arc.js +6 -2
  75. package/build/cli/render/audit.js +400 -81
  76. package/build/cli/render/kanshi.js +242 -79
  77. package/build/cli/render/lifecycle.js +56 -0
  78. package/build/cli/render/misc.js +24 -28
  79. package/build/cli/render/petition.js +5 -0
  80. package/build/cli/render/projection-activity.js +65 -29
  81. package/build/cli/render/projection-history.js +64 -0
  82. package/build/cli/render/shared.js +23 -9
  83. package/build/cli/render/status.js +13 -35
  84. package/build/cli/render/task.js +51 -26
  85. package/build/cli/render/terminal-failure.js +3 -1
  86. package/build/cli/render/wait.js +45 -69
  87. package/build/cli/subagent-guard.js +1 -1
  88. package/build/cli/types.js +1 -1
  89. package/build/config/env-keys.js +3 -1
  90. package/build/config/env.js +24 -1
  91. package/build/config/settings/schema.js +3 -1
  92. package/build/core/address-carrier.js +89 -0
  93. package/build/core/addressing.js +255 -196
  94. package/build/core/amend.js +115 -38
  95. package/build/core/amendment-criteria.js +17 -0
  96. package/build/core/amendment-warning.js +10 -0
  97. package/build/core/arc.js +184 -83
  98. package/build/core/audit/candidate.js +110 -9
  99. package/build/core/audit/coordinates.js +120 -65
  100. package/build/core/audit/evidence.js +19 -23
  101. package/build/core/audit/facade.js +34 -11
  102. package/build/core/audit/report.js +5 -5
  103. package/build/core/bind-reconciliation.js +390 -0
  104. package/build/core/bind-workspace.js +12 -20
  105. package/build/core/bind.js +242 -273
  106. package/build/core/call/call.js +1 -0
  107. package/build/core/call/context.js +46 -71
  108. package/build/core/call/prompt.js +6 -3
  109. package/build/core/context.js +49 -35
  110. package/build/core/contract-carrier-runtime.js +408 -0
  111. package/build/core/contract-carrier.js +270 -0
  112. package/build/core/contract-view.js +33 -27
  113. package/build/core/contract.js +5 -1
  114. package/build/core/derived-replay.js +207 -0
  115. package/build/core/draft.js +125 -57
  116. package/build/core/forfeit.js +113 -56
  117. package/build/core/identifier-slug.js +63 -13
  118. package/build/core/ids.js +4 -4
  119. package/build/core/lifecycle-history.js +35 -0
  120. package/build/core/lifecycle-recovery.js +117 -0
  121. package/build/core/lifecycle-runner.js +377 -0
  122. package/build/core/log.js +37 -11
  123. package/build/core/outcome-base.js +181 -0
  124. package/build/core/process-group.js +139 -0
  125. package/build/core/projection/generation/database.js +82 -7
  126. package/build/core/projection/generation/model.js +9 -36
  127. package/build/core/projection/generation/projection-generation-continuation.js +10 -22
  128. package/build/core/projection/generation/projection-generation-execution.js +13 -6
  129. package/build/core/projection/index.js +5 -4
  130. package/build/core/projection/projection-activity.js +29 -8
  131. package/build/core/projection/projection-alias.js +23 -10
  132. package/build/core/projection/projection-core.js +21 -7
  133. package/build/core/projection/projection-execution-observer.js +91 -39
  134. package/build/core/projection/projection-history.js +474 -0
  135. package/build/core/projection/projection-runner-lock.js +12 -0
  136. package/build/core/projection/projection-status-observation.js +365 -0
  137. package/build/core/projection/projection-status.js +35 -337
  138. package/build/core/projection/projection-terminal-failure.js +13 -8
  139. package/build/core/projection/projection-wait.js +7 -2
  140. package/build/core/projection/tell/launch-store.js +244 -0
  141. package/build/core/projection/tell/model.js +3 -3
  142. package/build/core/projection/tell/store.js +70 -118
  143. package/build/core/registry.js +22 -87
  144. package/build/core/renew-build.js +424 -0
  145. package/build/core/renew-plan.js +239 -0
  146. package/build/core/renew-rewrite.js +77 -0
  147. package/build/core/renew.js +295 -520
  148. package/build/core/repository-ledger/accepted-fold-read.js +19 -0
  149. package/build/core/repository-ledger/accepted-tail.js +116 -0
  150. package/build/core/repository-ledger/admission.js +38 -0
  151. package/build/core/repository-ledger/authoritative-transaction.js +1 -0
  152. package/build/core/repository-ledger/claim-evidence.js +147 -0
  153. package/build/core/repository-ledger/codec.js +297 -0
  154. package/build/core/repository-ledger/current-state-store.js +853 -0
  155. package/build/core/repository-ledger/fold-repository.js +375 -0
  156. package/build/core/repository-ledger/fold.js +248 -0
  157. package/build/core/repository-ledger/git-object-store.js +243 -0
  158. package/build/core/repository-ledger/history-read.js +15 -0
  159. package/build/core/repository-ledger/incremental-read.js +153 -0
  160. package/build/core/repository-ledger/index.js +15 -0
  161. package/build/core/repository-ledger/inventory-plan.js +75 -0
  162. package/build/core/repository-ledger/inventory.js +310 -0
  163. package/build/core/repository-ledger/publication-admission.js +125 -0
  164. package/build/core/repository-ledger/publication.js +353 -0
  165. package/build/core/repository-ledger/read-model.js +469 -0
  166. package/build/core/repository-ledger/transaction-membership.js +78 -0
  167. package/build/core/repository-ledger/traversal.js +404 -0
  168. package/build/core/repository-ledger/write-transaction.js +339 -0
  169. package/build/core/seal.js +168 -161
  170. package/build/core/settlement/claim-delivery.js +42 -215
  171. package/build/core/settlement/claim.js +235 -153
  172. package/build/core/settlement/index.js +4 -4
  173. package/build/core/settlement/petition-claim-gates.js +52 -33
  174. package/build/core/settlement/petition-forfeit.js +12 -5
  175. package/build/core/settlement/petition-head-guard.js +8 -5
  176. package/build/core/settlement/petition-preview.js +68 -56
  177. package/build/core/settlement/petition-window.js +50 -0
  178. package/build/core/settlement/petition.js +252 -131
  179. package/build/core/settlement/queue-read-model.js +72 -95
  180. package/build/core/settlement/queue-seat-allocation.js +35 -126
  181. package/build/core/settlement/queue.js +1 -1
  182. package/build/core/settlement/settlement.js +172 -80
  183. package/build/core/settlement/verdict.js +112 -66
  184. package/build/core/settlement/verification-coordination.js +305 -0
  185. package/build/core/settlement/verification-supervisor.js +275 -0
  186. package/build/core/settlement/verification.js +398 -31
  187. package/build/core/status/board.js +125 -127
  188. package/build/core/status/lifecycle.js +7 -80
  189. package/build/core/structured-query.js +23 -0
  190. package/build/core/target-ref.js +1 -1
  191. package/build/core/task/board.js +59 -24
  192. package/build/core/task/commands.js +181 -33
  193. package/build/core/task/coordinate.js +56 -0
  194. package/build/core/task/document.js +25 -9
  195. package/build/core/task/index.js +7 -4
  196. package/build/core/task/query.js +17 -27
  197. package/build/core/task/settlement-git.js +164 -179
  198. package/build/core/task/settlement-policy.js +15 -11
  199. package/build/core/task/source-board.js +31 -13
  200. package/build/core/task/task-bind-preparation.js +193 -0
  201. package/build/core/task/task-contract.js +102 -193
  202. package/build/core/task/task-file-transaction.js +171 -0
  203. package/build/core/task/task-git-runtime.js +76 -14
  204. package/build/core/task/task-git-store.js +68 -169
  205. package/build/core/task/task-store-repository.js +13 -6
  206. package/build/core/task/task-worktree.js +6 -33
  207. package/build/core/task/task.js +1 -1
  208. package/build/core/task/tree.js +3 -3
  209. package/build/core/task/validation.js +4 -3
  210. package/build/core/transcripts.js +93 -1
  211. package/build/core/worktree-bootstrap.js +44 -36
  212. package/build/core/worktree-path.js +210 -77
  213. package/build/flow-error.js +7 -7
  214. package/build/generated/version.js +2 -2
  215. package/build/git/core.js +76 -13
  216. package/build/git/refs.js +168 -46
  217. package/build/git/streaming-batch.js +73 -13
  218. package/package.json +1 -1
  219. package/skills/keiyaku/SKILL.md +1 -1
  220. package/skills/keiyaku-akuma/SKILL.md +3 -3
  221. package/skills/keiyaku-task/SKILL.md +4 -2
  222. package/skills/keiyaku-workflow/SKILL.md +88 -203
  223. package/build/core/entry.js +0 -305
  224. package/build/core/ledger-batch.js +0 -194
  225. package/build/core/ledger.js +0 -84
  226. package/build/core/projection/leash.js +0 -87
  227. package/build/core/ref-log.js +0 -119
  228. package/build/core/renew-session.js +0 -128
  229. package/build/core/status/ledger-batch.js +0 -1
  230. package/build/core/status/reconciliation.js +0 -137
@@ -64,6 +64,8 @@ export function resolveFlowErrorHints(code, context, facts) {
64
64
  return [`Next: open an arc with \`${context.arcToolName}\`, then use \`${context.renewToolName}\` when delivery must follow the current base, or \`${context.closeToolName}\` to settle it.`];
65
65
  case "CONTRACT_AMBIGUOUS":
66
66
  return ["Address a specific contract workspace, or use `call --bare` to run without contract attachment."];
67
+ case "TASK_AMBIGUOUS":
68
+ return ["Pass one complete task coordinate from the listed candidates."];
67
69
  case "UNKNOWN_COMMAND":
68
70
  return ["Run `keiyaku --help` to inspect available commands."];
69
71
  case "EMPTY_PARAM":
@@ -77,13 +79,11 @@ export function resolveFlowErrorHints(code, context, facts) {
77
79
  case "MISSING_PROTOCOL_FILES":
78
80
  return ["Required protocol file is missing (`KEIYAKU.md`)."];
79
81
  case "CLOSE_MERGE_CONFLICT":
80
- return [`Open a Keiyaku recovery session with \`keiyaku ${context.renewToolName} --resolve\`.`];
82
+ return [];
81
83
  case "RENEW_COORDINATE_DRIFT":
82
84
  return [`Restore the delivery to its ledger-recorded history with Git reflog, then retry \`keiyaku ${context.renewToolName}\`.`];
83
- case "RENEW_SESSION_OPEN":
84
- return [`Continue the owned rewrite with \`keiyaku ${context.renewToolName} --continue\`, or restore it with \`keiyaku ${context.renewToolName} --abort\`.`];
85
- case "RENEW_SESSION_NOT_FOUND":
86
- return [`No recovery session is open; run \`keiyaku ${context.renewToolName}\` normally.`];
85
+ case "RENEW_SCRATCH_NOT_FOUND":
86
+ return [`No renew conflict scratch is open; run \`keiyaku ${context.renewToolName}\` normally.`];
87
87
  case "CLOSE_QUALITY_GATE_FAILED":
88
88
  return [`CLAIM denied: a configured verification command failed. Use \`${context.arcToolName}\` after addressing the failure before petitioning again.`];
89
89
  case "INVALID_BRANCH_TITLE":
@@ -95,9 +95,9 @@ export function resolveFlowErrorHints(code, context, facts) {
95
95
  case "FROM_DRAFT_NOT_FOUND":
96
96
  return ["The path in `from_draft` does not exist. `from_draft` must reference an existing file path (absolute or relative to `cwd`)."];
97
97
  case "INVALID_KEIYAKU_DRAFT":
98
- return ["Invalid draft/input. Use a `# <title>` heading with non-empty `## Objective` and `## Scope` sections (or the equivalent shape in `from_draft`)."];
98
+ return ["Invalid draft/input. Use a `# <title>` heading with non-empty `## Context`, `## Objective`, `## Design`, `## Scope`, `## Criteria`, and `## Verification` sections (or the equivalent shape in `from_draft`)."];
99
99
  case "BIND_TASK_INCOMPLETE":
100
- return ["Task supplies name/objective defaults; pipe only the missing sections as partial contract Markdown on stdin."];
100
+ return ["Task supplies title/objective defaults; pipe only the missing sections as partial contract Markdown on stdin."];
101
101
  case "KEIYAKU_FILE_EXISTS":
102
102
  return [`\`${context.bindToolName}\` requires a clean slate. \`KEIYAKU.md\` must be removed before starting.`];
103
103
  case "DETACHED_HEAD":
@@ -1,3 +1,3 @@
1
1
  // Generated into build output by scripts/build.mjs
2
- export const VERSION = "2.9.8";
3
- export const GIT_HASH = "c9cfce1";
2
+ export const VERSION = "2.9.10";
3
+ export const GIT_HASH = "b4c38cc";
package/build/git/core.js CHANGED
@@ -4,6 +4,28 @@ import { normalizeFilesystemCoordinate } from "../fs/path-coordinate.js";
4
4
  import { getConfig } from "../config/env.js";
5
5
  import { appendDebugBlock } from "../telemetry/debug-log.js";
6
6
  const GIT_ERROR_COMPACT_MAX_CHARS = 4000;
7
+ export const GIT_ABORT_FORCE_KILL_MS = 250;
8
+ /** Terminate one non-detached Git child and wait until Node drains its stdio close. */
9
+ export async function terminateGitChild(child, closed, graceMs = GIT_ABORT_FORCE_KILL_MS) {
10
+ if (child.exitCode === null && child.signalCode === null)
11
+ child.kill("SIGTERM");
12
+ let grace;
13
+ const exited = await Promise.race([
14
+ closed.then(() => true),
15
+ new Promise((resolve) => {
16
+ grace = setTimeout(() => resolve(false), graceMs);
17
+ grace.unref();
18
+ }),
19
+ ]);
20
+ if (grace)
21
+ clearTimeout(grace);
22
+ // Only the close event proves process exit and stdio drain. `killed` and
23
+ // `signalCode` merely report signal delivery and can be set while a handler
24
+ // keeps the child alive.
25
+ if (!exited)
26
+ child.kill("SIGKILL");
27
+ await closed;
28
+ }
7
29
  export const MISSING_GIT_CONFIG_PATTERNS = ["No such section or key"];
8
30
  export const NOT_GIT_REPOSITORY_PATTERNS = ["not a git repository"];
9
31
  export const MISSING_HEAD_PATTERNS = [
@@ -12,6 +34,10 @@ export const MISSING_HEAD_PATTERNS = [
12
34
  "unknown revision or path not in the working tree",
13
35
  "bad revision 'HEAD'",
14
36
  ];
37
+ /** Accept only a complete object id from either supported Git object format. */
38
+ export function isFullGitObjectId(value) {
39
+ return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value);
40
+ }
15
41
  /** Git is an optional adapter for directory-local product surfaces. */
16
42
  export function isGitExecutableUnavailable(error) {
17
43
  const source = (error ?? {});
@@ -94,11 +120,18 @@ export function createGit(cwd) {
94
120
  const git = simpleGit(normalizeFilesystemCoordinate(cwd), {
95
121
  timeout: { block: timeoutMs, stdErr: true, stdOut: true },
96
122
  });
97
- git.env("GIT_TERMINAL_PROMPT", "0");
98
- git.env("GCM_INTERACTIVE", "Never");
99
- git.env("GIT_MERGE_AUTOEDIT", "no");
100
- git.env("LC_ALL", "C");
101
- git.env("PATH", process.env.PATH ?? "");
123
+ // simple-git replaces its child environment when configured through env().
124
+ // Preserve trace controls explicitly without admitting unrelated ambient Git
125
+ // behavior such as a pager into command execution.
126
+ const traceEnvironment = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith("GIT_TRACE2")));
127
+ git.env({
128
+ ...traceEnvironment,
129
+ GIT_TERMINAL_PROMPT: "0",
130
+ GCM_INTERACTIVE: "Never",
131
+ GIT_MERGE_AUTOEDIT: "no",
132
+ LC_ALL: "C",
133
+ PATH: process.env.PATH ?? "",
134
+ });
102
135
  return git;
103
136
  }
104
137
  /**
@@ -107,7 +140,8 @@ export function createGit(cwd) {
107
140
  * soft status codes or stdin (simple-git drops empty-stderr non-zero exits).
108
141
  * Long-lived multi-request stdin/stdout sessions use `openGitStreamingBatch`.
109
142
  */
110
- export async function runGitProcess(cwd, args, options) {
143
+ export async function runGitProcessBytes(cwd, args, options) {
144
+ options?.signal?.throwIfAborted();
111
145
  const timeoutMs = resolveGitTimeoutMs();
112
146
  const env = gitProcessEnv(options?.env);
113
147
  return await new Promise((resolve) => {
@@ -121,19 +155,35 @@ export async function runGitProcess(cwd, args, options) {
121
155
  const stderrChunks = [];
122
156
  let settled = false;
123
157
  let timedOut = false;
158
+ let aborting = false;
159
+ let terminationPromise;
160
+ const closed = new Promise((resolveClosed) => child.once("close", () => resolveClosed()));
124
161
  const finish = (result) => {
125
162
  if (settled)
126
163
  return;
127
164
  settled = true;
128
165
  clearTimeout(timeout);
166
+ options?.signal?.removeEventListener("abort", abort);
129
167
  resolve(result);
130
168
  };
169
+ const abort = () => {
170
+ if (aborting)
171
+ return;
172
+ aborting = true;
173
+ terminationPromise = terminateGitChild(child, closed);
174
+ };
175
+ options?.signal?.addEventListener("abort", abort, { once: true });
176
+ if (options?.signal?.aborted)
177
+ abort();
131
178
  const timeout = setTimeout(() => {
132
179
  timedOut = true;
133
180
  child.kill("SIGKILL");
134
181
  }, timeoutMs);
135
182
  if (options?.input !== undefined) {
136
- child.stdin.end(options.input, "utf8");
183
+ if (typeof options.input === "string")
184
+ child.stdin.end(options.input, "utf8");
185
+ else
186
+ child.stdin.end(options.input);
137
187
  }
138
188
  else {
139
189
  child.stdin.end();
@@ -142,15 +192,17 @@ export async function runGitProcess(cwd, args, options) {
142
192
  child.stderr.on("data", (chunk) => stderrChunks.push(chunk));
143
193
  child.on("error", (error) => {
144
194
  finish({
145
- stdout: Buffer.concat(stdoutChunks).toString("utf8"),
146
- stderr: Buffer.concat(stderrChunks).toString("utf8"),
195
+ stdout: Buffer.concat(stdoutChunks),
196
+ stderr: Buffer.concat(stderrChunks),
147
197
  status: null,
148
198
  error,
149
199
  });
150
200
  });
151
- child.on("close", (code, signal) => {
152
- const stdout = Buffer.concat(stdoutChunks).toString("utf8");
153
- const stderr = Buffer.concat(stderrChunks).toString("utf8");
201
+ child.on("close", async (code, signal) => {
202
+ if (terminationPromise)
203
+ await terminationPromise;
204
+ const stdout = Buffer.concat(stdoutChunks);
205
+ const stderr = Buffer.concat(stderrChunks);
154
206
  if (timedOut) {
155
207
  finish({
156
208
  stdout,
@@ -165,7 +217,9 @@ export async function runGitProcess(cwd, args, options) {
165
217
  stdout,
166
218
  stderr,
167
219
  status: null,
168
- error: new Error(`git exited from signal ${signal}`),
220
+ error: options?.signal?.aborted
221
+ ? options.signal.reason instanceof Error ? options.signal.reason : new DOMException("The operation was aborted", "AbortError")
222
+ : new Error(`git exited from signal ${signal}`),
169
223
  });
170
224
  return;
171
225
  }
@@ -173,3 +227,12 @@ export async function runGitProcess(cwd, args, options) {
173
227
  });
174
228
  });
175
229
  }
230
+ export async function runGitProcess(cwd, args, options) {
231
+ const result = await runGitProcessBytes(cwd, args, options);
232
+ return {
233
+ stdout: result.stdout.toString("utf8"),
234
+ stderr: result.stderr.toString("utf8"),
235
+ status: result.status,
236
+ ...(result.error ? { error: result.error } : {}),
237
+ };
238
+ }
package/build/git/refs.js CHANGED
@@ -1,11 +1,22 @@
1
- import { createGit, errorContainsAnyPattern, MISSING_HEAD_PATTERNS, runGitProcess, wrapGitError, } from "./core.js";
2
- const ZERO_OBJECT_ID = "0000000000000000000000000000000000000000";
1
+ import { isFullGitObjectId, runGitProcess, wrapGitError, } from "./core.js";
3
2
  /** Normalize a configured branch to the sole local-branch ref spelling. */
4
3
  export function normalizeLocalBranchRef(branch) {
5
4
  return branch.startsWith("refs/heads/") ? branch : `refs/heads/${branch}`;
6
5
  }
6
+ export const REF_TRANSACTION_CAS_MISMATCH = "REF_TRANSACTION_CAS_MISMATCH";
7
+ export const REF_TRANSACTION_NOT_COMMITTED = "REF_TRANSACTION_NOT_COMMITTED";
8
+ export const REF_TRANSACTION_INDETERMINATE = "REF_TRANSACTION_INDETERMINATE";
9
+ export class RefTransactionError extends Error {
10
+ code = "REF_TRANSACTION_FAILED";
11
+ constructor(message, options) {
12
+ super(message, options);
13
+ this.name = "RefTransactionError";
14
+ }
15
+ }
7
16
  function isMissingExactRef(result) {
8
- return result.status === 1 && result.stdout === "";
17
+ return !result.error
18
+ && result.stdout === ""
19
+ && result.status === 1;
9
20
  }
10
21
  function parseShowRefRows(output) {
11
22
  const lines = output.split(/\r?\n/);
@@ -15,14 +26,18 @@ function parseShowRefRows(output) {
15
26
  return null;
16
27
  const rows = [];
17
28
  for (const line of lines) {
18
- const match = /^(?<objectId>[0-9a-fA-F]{40}|[0-9a-fA-F]{64}) (?<ref>refs\/\S+)$/.exec(line);
19
- if (!match?.groups)
29
+ const separator = line.indexOf(" ");
30
+ const objectId = separator < 0 ? "" : line.slice(0, separator);
31
+ const ref = separator < 0 ? "" : line.slice(separator + 1);
32
+ if (!isFullGitObjectId(objectId) || !/^refs\/\S+$/.test(ref))
20
33
  return null;
21
- rows.push({ objectId: match.groups.objectId, ref: match.groups.ref });
34
+ rows.push({ objectId, ref });
22
35
  }
23
36
  return rows;
24
37
  }
25
38
  function throwGitProcessFailure(commandLabel, result, cwd) {
39
+ if (result.error?.name === "AbortError")
40
+ throw result.error;
26
41
  const failure = result.error ?? Object.assign(new Error(result.stderr || `git ${commandLabel} exited ${result.status}`), {
27
42
  stderr: result.stderr,
28
43
  stdout: result.stdout,
@@ -46,58 +61,165 @@ export async function resolveLocalBranchHead(cwd, branch, run = runGitProcess) {
46
61
  }
47
62
  return throwGitProcessFailure(`show-ref ${ref}`, result, cwd);
48
63
  }
49
- async function resolveRefOrNull(cwd, ref) {
50
- const git = createGit(cwd);
51
- try {
52
- return (await git.raw(["rev-parse", "--verify", ref])).trim();
64
+ async function validateTransactionOperation(cwd, operation, objectIdLength) {
65
+ const refCheck = await runGitProcess(cwd, ["check-ref-format", operation.ref]);
66
+ if (refCheck.status !== 0 || refCheck.error) {
67
+ throw new RefTransactionError(`invalid transaction ref ${JSON.stringify(operation.ref)}`, { cause: refCheck.error });
53
68
  }
54
- catch (error) {
55
- if (errorContainsAnyPattern(error, MISSING_HEAD_PATTERNS))
56
- return null;
57
- throw wrapGitError(`rev-parse ${ref}`, error, cwd);
69
+ const objectIds = operation.kind === "update"
70
+ ? [["new", operation.newOid], ["old", operation.expectedOldOid]]
71
+ : [["old", operation.expectedOldOid]];
72
+ for (const [label, oid] of objectIds) {
73
+ if (oid !== null && (oid.length !== objectIdLength || !isFullGitObjectId(oid))) {
74
+ throw new RefTransactionError(`invalid ${label} object ID for ${operation.ref}`);
75
+ }
58
76
  }
59
77
  }
60
- /** Create an immutable recovery ref, accepting an earlier identical attempt. */
61
- export async function createRecoveryRef(cwd, ref, objectId) {
62
- const existing = await resolveRefOrNull(cwd, ref);
63
- if (existing === objectId)
64
- return;
65
- if (existing !== null) {
66
- throw new Error(`recovery ref ${ref} already resolves to ${existing}, not ${objectId}`);
67
- }
68
- const git = createGit(cwd);
78
+ function formatTransactionOperation(operation, zero) {
79
+ if (operation.kind === "delete")
80
+ return `delete ${operation.ref} ${operation.expectedOldOid}`;
81
+ if (operation.kind === "verify")
82
+ return `verify ${operation.ref} ${operation.expectedOldOid ?? zero}`;
83
+ return `update ${operation.ref} ${operation.newOid} ${operation.expectedOldOid ?? zero}`;
84
+ }
85
+ function isUnambiguousCasRefusal(result) {
86
+ if (result.status === null || result.status === 0 || result.error)
87
+ return false;
88
+ return /cannot lock ref '[^']+': (?:is at [0-9a-f]+ but expected [0-9a-f]+|reference already exists|reference is missing but expected [0-9a-f]+)/i.test(result.stderr);
89
+ }
90
+ function isUnableToResolveReference(result) {
91
+ return result.status !== null
92
+ && result.status !== 0
93
+ && !result.error
94
+ && /cannot lock ref '[^']+': unable to resolve reference/i.test(result.stderr);
95
+ }
96
+ function hasConclusiveMissingExpectedReference(observations) {
97
+ return observations.some((observation) => (observation.expectedOldOid !== null
98
+ && observation.observed.status === "absent"));
99
+ }
100
+ function transactionFailureCause(result) {
101
+ return result.stderr.trim() || result.error?.message || `exit ${String(result.status)}`;
102
+ }
103
+ function requestedNewOid(operation) {
104
+ if (operation.kind === "update")
105
+ return operation.newOid;
106
+ if (operation.kind === "delete")
107
+ return null;
108
+ return operation.expectedOldOid;
109
+ }
110
+ function observationMatches(observation, expectedOid) {
111
+ if (observation.observed.status === "unknown")
112
+ return false;
113
+ if (expectedOid === null)
114
+ return observation.observed.status === "absent";
115
+ return observation.observed.status === "present" && observation.observed.objectId === expectedOid;
116
+ }
117
+ async function observeExactRef(cwd, ref) {
69
118
  try {
70
- await git.raw(["update-ref", ref, objectId, ZERO_OBJECT_ID]);
119
+ const symbolic = await runGitProcess(cwd, ["symbolic-ref", "--quiet", ref]);
120
+ if (symbolic.status === 0 && !symbolic.error) {
121
+ const target = symbolic.stdout.trim();
122
+ return { status: "unknown", cause: `symbolic ref targets ${target || "an unreported ref"}` };
123
+ }
124
+ if (symbolic.error || (symbolic.status !== 1 && symbolic.status !== 0)) {
125
+ const cause = symbolic.stderr.trim() || symbolic.error?.message || `symbolic-ref exited ${String(symbolic.status)}`;
126
+ return { status: "unknown", cause };
127
+ }
128
+ const exact = await runGitProcess(cwd, ["show-ref", ref]);
129
+ if (exact.status === 0 && !exact.error) {
130
+ const exactRows = parseShowRefRows(exact.stdout)?.filter((row) => row.ref === ref);
131
+ if (exactRows?.length === 1) {
132
+ return { status: "present", objectId: exactRows[0].objectId };
133
+ }
134
+ return { status: "unknown", cause: `show-ref returned an invalid exact value for ${ref}` };
135
+ }
136
+ if (!exact.error && exact.status === 1 && exact.stdout === "")
137
+ return { status: "absent" };
138
+ const cause = exact.stderr.trim() || exact.error?.message || `show-ref exited ${String(exact.status)}`;
139
+ return { status: "unknown", cause };
71
140
  }
72
141
  catch (error) {
73
- // Another identical attempt may have won the create race.
74
- if (await resolveRefOrNull(cwd, ref) === objectId)
75
- return;
76
- throw wrapGitError(`update-ref ${ref}`, error, cwd);
142
+ return { status: "unknown", cause: error instanceof Error ? error.message : String(error) };
77
143
  }
78
144
  }
79
- /** Remove only the exact attempt ref, returning truthful non-fatal cleanup facts. */
80
- export async function deleteRecoveryRef(cwd, ref, objectId) {
145
+ async function observeTransactionRefs(cwd, operations) {
146
+ return await Promise.all(operations.map(async (operation) => ({
147
+ ref: operation.ref,
148
+ expectedOldOid: operation.expectedOldOid,
149
+ requestedNewOid: requestedNewOid(operation),
150
+ observed: await observeExactRef(cwd, operation.ref),
151
+ })));
152
+ }
153
+ /**
154
+ * Atomically apply a closed set of ref operations. Every old-OID assertion is
155
+ * submitted inside the same update-ref transaction as its operation; no
156
+ * preflight read is used as authority.
157
+ */
158
+ export async function commitRefTransaction(cwd, operations, objectIdLength, options = {}) {
159
+ if (operations.length === 0)
160
+ throw new RefTransactionError("a ref transaction requires at least one operation");
161
+ const seen = new Set();
162
+ for (const operation of operations) {
163
+ await validateTransactionOperation(cwd, operation, objectIdLength);
164
+ if (seen.has(operation.ref))
165
+ throw new RefTransactionError(`duplicate transaction ref ${operation.ref}`);
166
+ seen.add(operation.ref);
167
+ }
168
+ const zero = "0".repeat(objectIdLength);
169
+ const input = [
170
+ "start",
171
+ ...operations.map((operation) => formatTransactionOperation(operation, zero)),
172
+ "prepare",
173
+ "commit",
174
+ "",
175
+ ].join("\n");
176
+ let result;
81
177
  try {
82
- const existing = await resolveRefOrNull(cwd, ref);
83
- if (existing === null)
84
- return { ref, status: "already-absent" };
85
- if (existing !== objectId) {
86
- return {
87
- ref,
88
- status: "retained",
89
- diagnostic: `ref resolves to ${existing}, not expected object ${objectId}`,
90
- };
91
- }
92
- const git = createGit(cwd);
93
- await git.raw(["update-ref", "-d", ref, objectId]);
94
- return { ref, status: "removed" };
178
+ result = await (options.run ?? runGitProcess)(cwd, ["update-ref", "--no-deref", "--stdin"], { input });
95
179
  }
96
180
  catch (error) {
181
+ result = {
182
+ stdout: "",
183
+ stderr: "",
184
+ status: null,
185
+ error: error instanceof Error ? error : new Error(String(error)),
186
+ };
187
+ }
188
+ if (result.status === 0 && !result.error) {
189
+ return { status: "committed", proof: "process-success", operations, observations: [] };
190
+ }
191
+ const observations = await observeTransactionRefs(cwd, operations);
192
+ const allPre = observations.every((observation) => observationMatches(observation, observation.expectedOldOid));
193
+ const allPost = observations.every((observation) => observationMatches(observation, observation.requestedNewOid));
194
+ const changesAuthority = operations.some((operation) => requestedNewOid(operation) !== operation.expectedOldOid);
195
+ const cause = transactionFailureCause(result);
196
+ if (allPost && changesAuthority) {
197
+ return { status: "committed", proof: "observed-post-state", operations, observations };
198
+ }
199
+ if (isUnambiguousCasRefusal(result)
200
+ || (isUnableToResolveReference(result) && hasConclusiveMissingExpectedReference(observations))) {
201
+ return {
202
+ status: "refused",
203
+ code: REF_TRANSACTION_CAS_MISMATCH,
204
+ operations,
205
+ observations,
206
+ cause,
207
+ };
208
+ }
209
+ if (allPre && changesAuthority) {
97
210
  return {
98
- ref,
99
- status: "retained",
100
- diagnostic: wrapGitError(`update-ref -d ${ref}`, error, cwd).message,
211
+ status: "refused",
212
+ code: REF_TRANSACTION_NOT_COMMITTED,
213
+ operations,
214
+ observations,
215
+ cause,
101
216
  };
102
217
  }
218
+ return {
219
+ status: "indeterminate",
220
+ code: REF_TRANSACTION_INDETERMINATE,
221
+ operations,
222
+ observations,
223
+ cause,
224
+ };
103
225
  }
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { isFullGitObjectId } from "./core.js";
2
3
  import { normalizeFilesystemCoordinate } from "../fs/path-coordinate.js";
3
- import { gitProcessEnv, resolveGitTimeoutMs, } from "./core.js";
4
+ import { gitProcessEnv, resolveGitTimeoutMs, terminateGitChild, } from "./core.js";
4
5
  /** Default long-lived batch argv. One-shot calls must not use this gate. */
5
6
  export const GIT_STREAMING_BATCH_DEFAULT_ARGS = ["cat-file", "--batch"];
6
7
  /** Safety cap for a single response header line before the body frame. */
@@ -15,10 +16,12 @@ export class GitStreamingBatchResponseEndedError extends Error {
15
16
  }
16
17
  class BufferedReader {
17
18
  #iterator;
19
+ #onProgress;
18
20
  #buffer = Buffer.alloc(0);
19
21
  #ended = false;
20
- constructor(stream) {
22
+ constructor(stream, onProgress) {
21
23
  this.#iterator = stream[Symbol.asyncIterator]();
24
+ this.#onProgress = onProgress;
22
25
  }
23
26
  async #fill() {
24
27
  if (this.#ended) {
@@ -30,6 +33,7 @@ class BufferedReader {
30
33
  throw new GitStreamingBatchResponseEndedError("git streaming batch response ended unexpectedly");
31
34
  }
32
35
  const chunk = Buffer.isBuffer(next.value) ? next.value : Buffer.from(next.value);
36
+ this.#onProgress();
33
37
  this.#buffer = this.#buffer.length === 0 ? chunk : Buffer.concat([this.#buffer, chunk]);
34
38
  }
35
39
  async readLine(maxBytes) {
@@ -84,20 +88,36 @@ export class GitStreamingBatchSession {
84
88
  #child;
85
89
  #reader;
86
90
  #stderr = [];
87
- #timeout;
91
+ #timeoutMs;
88
92
  #closed;
89
93
  #maxHeaderBytes;
94
+ #signal;
95
+ #abortListener;
96
+ #timeout = null;
90
97
  #state = { kind: "open" };
91
98
  #timedOut = false;
92
99
  #aborted = false;
93
100
  #inputError = null;
94
101
  #requestInFlight = false;
102
+ #requestDone = null;
103
+ #finishRequest = null;
104
+ #requestTimeoutOwner = null;
95
105
  #closePromise = null;
96
- constructor(cwd, child, timeoutMs, maxHeaderBytes) {
106
+ #terminationPromise = null;
107
+ constructor(cwd, child, timeoutMs, maxHeaderBytes, signal) {
97
108
  this.cwd = cwd;
98
109
  this.#child = child;
99
- this.#reader = new BufferedReader(this.#child.stdout);
110
+ this.#timeoutMs = timeoutMs;
111
+ this.#reader = new BufferedReader(this.#child.stdout, () => {
112
+ if (this.#requestTimeoutOwner)
113
+ this.#armTimeout(this.#requestTimeoutOwner);
114
+ });
100
115
  this.#maxHeaderBytes = maxHeaderBytes;
116
+ this.#signal = signal;
117
+ this.#abortListener = () => this.abort();
118
+ signal?.addEventListener("abort", this.#abortListener, { once: true });
119
+ if (signal?.aborted)
120
+ this.abort();
101
121
  this.#child.stderr.on("data", (chunk) => this.#stderr.push(chunk));
102
122
  this.#child.stdin.on("error", (error) => {
103
123
  this.#inputError = error;
@@ -110,10 +130,27 @@ export class GitStreamingBatchSession {
110
130
  resolve({ code, signal });
111
131
  });
112
132
  });
113
- this.#timeout = setTimeout(() => {
133
+ }
134
+ #armTimeout(owner) {
135
+ this.#clearTimeout();
136
+ const handle = setTimeout(() => {
137
+ if (this.#timeout?.owner !== owner)
138
+ return;
139
+ this.#timeout = null;
114
140
  this.#timedOut = true;
141
+ if (this.#state.kind === "open")
142
+ this.#state = { kind: "closing" };
115
143
  this.#child.kill("SIGKILL");
116
- }, timeoutMs);
144
+ }, this.#timeoutMs);
145
+ this.#timeout = { owner, handle };
146
+ }
147
+ #clearTimeout(owner) {
148
+ if (this.#timeout === null)
149
+ return;
150
+ if (owner !== undefined && this.#timeout.owner !== owner)
151
+ return;
152
+ clearTimeout(this.#timeout.handle);
153
+ this.#timeout = null;
117
154
  }
118
155
  /**
119
156
  * Open the sole long-lived Git batch process for `cwd`.
@@ -125,11 +162,11 @@ export class GitStreamingBatchSession {
125
162
  const maxHeaderBytes = options?.maxHeaderBytes ?? GIT_STREAMING_BATCH_MAX_HEADER_BYTES;
126
163
  const child = spawn("git", [...args], {
127
164
  cwd: normalizeFilesystemCoordinate(cwd),
128
- env: gitProcessEnv(),
165
+ env: gitProcessEnv(options?.env),
129
166
  shell: false,
130
167
  stdio: ["pipe", "pipe", "pipe"],
131
168
  });
132
- return new GitStreamingBatchSession(cwd, child, timeoutMs, maxHeaderBytes);
169
+ return new GitStreamingBatchSession(cwd, child, timeoutMs, maxHeaderBytes, options?.signal);
133
170
  }
134
171
  /**
135
172
  * Write one request line and read one framed response record.
@@ -142,7 +179,13 @@ export class GitStreamingBatchSession {
142
179
  if (this.#requestInFlight) {
143
180
  throw new Error("git streaming batch rejects concurrent requests");
144
181
  }
182
+ const timeoutOwner = Symbol("git-streaming-batch-request");
145
183
  this.#requestInFlight = true;
184
+ this.#requestTimeoutOwner = timeoutOwner;
185
+ this.#requestDone = new Promise((resolve) => {
186
+ this.#finishRequest = resolve;
187
+ });
188
+ this.#armTimeout(timeoutOwner);
146
189
  try {
147
190
  if (this.#inputError)
148
191
  throw this.#inputError;
@@ -168,7 +211,8 @@ export class GitStreamingBatchSession {
168
211
  catch (error) {
169
212
  if (error instanceof GitStreamingBatchFramingError
170
213
  || error instanceof GitStreamingBatchResponseEndedError) {
171
- this.abort();
214
+ if (!this.#timedOut)
215
+ this.abort();
172
216
  }
173
217
  throw error;
174
218
  }
@@ -183,7 +227,7 @@ export class GitStreamingBatchSession {
183
227
  const size = Number(sizeText);
184
228
  if (unexpected.length > 0
185
229
  || !oid
186
- || !/^[0-9a-f]{40,64}$/.test(oid)
230
+ || !isFullGitObjectId(oid)
187
231
  || !type
188
232
  || !Number.isSafeInteger(size)
189
233
  || size < 0) {
@@ -221,7 +265,13 @@ export class GitStreamingBatchSession {
221
265
  };
222
266
  }
223
267
  finally {
268
+ this.#clearTimeout(timeoutOwner);
269
+ this.#requestTimeoutOwner = null;
224
270
  this.#requestInFlight = false;
271
+ const finishRequest = this.#finishRequest;
272
+ this.#finishRequest = null;
273
+ this.#requestDone = null;
274
+ finishRequest?.();
225
275
  }
226
276
  }
227
277
  /**
@@ -232,11 +282,12 @@ export class GitStreamingBatchSession {
232
282
  if (this.#aborted)
233
283
  return;
234
284
  this.#aborted = true;
285
+ this.#clearTimeout();
235
286
  if (this.#state.kind === "open") {
236
287
  this.#state = { kind: "closing" };
237
288
  }
238
289
  this.#child.stdin.destroy();
239
- this.#child.kill("SIGKILL");
290
+ this.#terminationPromise ??= terminateGitChild(this.#child, this.#closed);
240
291
  }
241
292
  /**
242
293
  * End stdin, await process exit, and return structured close facts.
@@ -263,7 +314,15 @@ export class GitStreamingBatchSession {
263
314
  return this.#closePromise;
264
315
  }
265
316
  async #closeOnce() {
317
+ const pendingRequest = this.#requestDone;
318
+ if (pendingRequest)
319
+ await pendingRequest;
320
+ if (this.#terminationPromise)
321
+ await this.#terminationPromise;
322
+ const timeoutOwner = Symbol("git-streaming-batch-close");
266
323
  if (!this.#aborted && this.#child.stdin?.writable) {
324
+ if (!this.#timedOut)
325
+ this.#armTimeout(timeoutOwner);
267
326
  this.#child.stdin.end();
268
327
  }
269
328
  let exit;
@@ -271,8 +330,9 @@ export class GitStreamingBatchSession {
271
330
  exit = await this.#closed;
272
331
  }
273
332
  finally {
274
- clearTimeout(this.#timeout);
333
+ this.#clearTimeout(timeoutOwner);
275
334
  }
335
+ this.#signal?.removeEventListener("abort", this.#abortListener);
276
336
  if (exit.processError && !this.#aborted) {
277
337
  const fact = {
278
338
  code: null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/keiyaku",
3
- "version": "2.9.8",
3
+ "version": "2.9.10",
4
4
  "description": "CLI for running iterative keiyaku workflows with Codex subagents.",
5
5
  "license": "MIT",
6
6
  "type": "module",