@astrosheep/keiyaku 2.9.5 → 2.9.6

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.
@@ -3,13 +3,17 @@ import { SUBAGENT_SESSION_VERSION, } from "../session.js";
3
3
  import { OUTCOME_ERROR_VERSION, SubagentFailure, } from "../types.js";
4
4
  import { isProviderTerminalError, } from "./provider-adapter.js";
5
5
  import { renderCapturedText } from "./runtime.js";
6
- export function createOutcomeState(maxCaptureChars) {
6
+ export function createOutcomeState(maxCaptureChars, admittedSession) {
7
7
  return {
8
8
  outputBuffer: new StringTailBuffer(maxCaptureChars),
9
9
  diagnosticsBuffer: new StringTailBuffer(maxCaptureChars),
10
10
  warnings: [],
11
+ ...(admittedSession ? { session: admittedSession } : {}),
11
12
  };
12
13
  }
14
+ export function recordSessionAdmission(state, session) {
15
+ state.session = session;
16
+ }
13
17
  function mintOutcome(draft) {
14
18
  return draft;
15
19
  }
@@ -29,7 +33,6 @@ export function processEvent(state, event) {
29
33
  return;
30
34
  case "done":
31
35
  state.doneEvent = event;
32
- state.session = event.session;
33
36
  if (event.finalMessage !== undefined) {
34
37
  state.finalMessageCandidate = event.finalMessage;
35
38
  }
@@ -173,13 +176,16 @@ export function failWithError(shared, state, error, finalMessage, diagnostics =
173
176
  warnings: [...state.warnings],
174
177
  });
175
178
  }
176
- export function completeOutcome(shared, state, finalMessage, session) {
179
+ export function completeOutcome(state, finalMessage) {
180
+ if (!state.session) {
181
+ throw new Error("Cannot complete an outcome before session admission");
182
+ }
177
183
  return mintOutcome({
178
184
  status: "completed",
179
185
  finalMessage,
180
186
  output: renderCapturedText(state.outputBuffer),
181
187
  diagnostics: renderCapturedText(state.diagnosticsBuffer),
182
- session,
188
+ session: state.session,
183
189
  warnings: [...state.warnings],
184
190
  });
185
191
  }
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { FlowError } from "../../flow-error.js";
3
3
  import { createEventChannel } from "./event-channel.js";
4
4
  import { overflowMarkerFromDropped, prepareAgentEventForPersistence, } from "./execution-handle.js";
5
- import { classifyFailure, classifySettlementFailure, completeOutcome, createOutcomeState, failFromFailEvent, failWithError, processEvent, renderOutcomeCapturedText, } from "./outcome.js";
5
+ import { classifyFailure, classifySettlementFailure, completeOutcome, createOutcomeState, failFromFailEvent, failWithError, processEvent, recordSessionAdmission, renderOutcomeCapturedText, } from "./outcome.js";
6
6
  import { startLiveProjection } from "./router.js";
7
7
  import { createAbortError } from "./runtime.js";
8
8
  const POLICY_BUDGET_CLEANUP_GRACE_MS = 5_000;
@@ -198,7 +198,7 @@ function deliverDriverEvent(shared, state, live, event, refreshIdleTimeout, know
198
198
  processEvent(state, event);
199
199
  }
200
200
  async function settleProjection(shared) {
201
- const state = createOutcomeState(shared.terms.maxCaptureChars);
201
+ const state = createOutcomeState(shared.terms.maxCaptureChars, shared.reviveTerms?.session);
202
202
  let idleTimedOut = false;
203
203
  let transportFailure;
204
204
  let policyBudgetExceeded = false;
@@ -245,7 +245,6 @@ async function settleProjection(shared) {
245
245
  resolvePolicyBudget();
246
246
  }, shared.terms.executionTimeoutMs);
247
247
  executionTimeout.unref?.();
248
- refreshIdleTimeout();
249
248
  const requestLiveAbort = () => {
250
249
  if (shared.explicitlyAborted)
251
250
  return;
@@ -257,7 +256,10 @@ async function settleProjection(shared) {
257
256
  shared.lifecycleAbort.signal.addEventListener("abort", requestLiveAbort, { once: true });
258
257
  const providerSettlement = (async () => {
259
258
  try {
260
- const live = await startLiveProjection(shared.terms, shared.lifecycleAbort.signal, shared.reviveTerms, refreshIdleTimeout, shared.observer);
259
+ const live = await startLiveProjection(shared.terms, shared.lifecycleAbort.signal, shared.reviveTerms, refreshIdleTimeout, shared.observer, (admission) => {
260
+ recordSessionAdmission(state, admission.session);
261
+ refreshIdleTimeout();
262
+ });
261
263
  liveDriver = live;
262
264
  shared.onLive(live);
263
265
  resolveForceWaiters(shared, live);
@@ -324,8 +326,7 @@ async function settleProjection(shared) {
324
326
  completionOutcome?.finalMessage ??
325
327
  state.finalMessageCandidate ??
326
328
  renderOutcomeCapturedText(state.outputBuffer).trim();
327
- const session = state.doneEvent?.session ?? completionOutcome?.session;
328
- if (!session) {
329
+ if (!state.session) {
329
330
  return failWithError(shared, state, {
330
331
  code: "SUBAGENT_EXEC_ERROR",
331
332
  message: `${shared.terms.provider} exec ended without a session handle`,
@@ -333,7 +334,7 @@ async function settleProjection(shared) {
333
334
  exitCode: null,
334
335
  }, finalMessage);
335
336
  }
336
- return completeOutcome(shared, state, finalMessage, session);
337
+ return completeOutcome(state, finalMessage);
337
338
  }
338
339
  catch (error) {
339
340
  if (error instanceof FlowError) {
@@ -1,5 +1,5 @@
1
1
  import { getProviderRegistration } from "../providers/registry.js";
2
- export async function startLiveProjection(terms, signal, reviveTerms, onIngress, observer) {
2
+ export async function startLiveProjection(terms, signal, reviveTerms, onIngress, observer, onSessionAdmission) {
3
3
  const onSyncCheckpoint = observer
4
4
  ? (control, checkpoint) => {
5
5
  observer.onCheckpoint(control, checkpoint);
@@ -19,6 +19,7 @@ export async function startLiveProjection(terms, signal, reviveTerms, onIngress,
19
19
  reviveTerms,
20
20
  onSyncCheckpoint,
21
21
  onDeliveryOpportunity,
22
+ onSessionAdmission,
22
23
  onIngress,
23
24
  observer,
24
25
  });
@@ -198,6 +198,7 @@ export async function startClaudeAgentSdk(prompt, cwd, options) {
198
198
  queryActive = true;
199
199
  notifyActive();
200
200
  let resumedSessionId = sessionId;
201
+ let admittedSessionId;
201
202
  let finalMessage;
202
203
  let latestAssistantMessageModel;
203
204
  // Provider acceptance / pre-terminal retry evidence for no-turn classification.
@@ -207,7 +208,18 @@ export async function startClaudeAgentSdk(prompt, cwd, options) {
207
208
  const seenToolUses = new Map();
208
209
  for await (const event of query) {
209
210
  builder.ingress(CLAUDE_AGENT_SDK_PROVIDER, event.type, event, claudeIngressDisposition(event.type), () => {
210
- resumedSessionId = extractResumeToken(event) ?? resumedSessionId;
211
+ const providerSessionId = extractResumeToken(event);
212
+ resumedSessionId = providerSessionId ?? resumedSessionId;
213
+ if (providerSessionId && providerSessionId !== admittedSessionId) {
214
+ admittedSessionId = providerSessionId;
215
+ options.onSessionAdmission?.({
216
+ session: {
217
+ provider: CLAUDE_AGENT_SDK_PROVIDER,
218
+ version: SUBAGENT_SESSION_VERSION,
219
+ handle: { sessionId: providerSessionId },
220
+ },
221
+ });
222
+ }
211
223
  if (isClaudeApiRetryEvent(event)) {
212
224
  lastPreTerminalEvidenceWasRetry = true;
213
225
  builder.emit({ type: "warning", warning: mapClaudeApiRetryWarning(event) });
@@ -179,7 +179,7 @@ export const claudeAgentSdkRegistration = {
179
179
  return loader ? { ...terms, loader } : terms;
180
180
  },
181
181
  nativePolicyOwnership: () => null,
182
- startLive: async ({ terms, signal, reviveTerms, onSyncCheckpoint, onIngress }) => {
182
+ startLive: async ({ terms, signal, reviveTerms, onSyncCheckpoint, onSessionAdmission, onIngress }) => {
183
183
  if (terms.provider !== CLAUDE_AGENT_SDK_PROVIDER) {
184
184
  throw new Error(`startLive expected ${CLAUDE_AGENT_SDK_PROVIDER}`);
185
185
  }
@@ -197,6 +197,7 @@ export const claudeAgentSdkRegistration = {
197
197
  systemPrompt: claudeTerms.systemPrompt,
198
198
  onSyncCheckpoint,
199
199
  onEvidence: claudeTerms.onEvidence,
200
+ onSessionAdmission,
200
201
  onIngress,
201
202
  initialTurn: claudeTerms.initialTurn,
202
203
  loader: claudeTerms.loader,
@@ -304,6 +304,15 @@ export async function startCodexAppServer(prompt, cwd, options = {}) {
304
304
  builder.emit({ type: "fail", message });
305
305
  throw createProviderFailure(message);
306
306
  }
307
+ const admittedSession = {
308
+ provider: "codex-app-server",
309
+ version: SUBAGENT_SESSION_VERSION,
310
+ handle: {
311
+ threadId: activeThreadId,
312
+ ...(serverCodexHome ? { codexHome: serverCodexHome } : {}),
313
+ },
314
+ };
315
+ options.onSessionAdmission?.({ session: admittedSession });
307
316
  if (options.onEvidence)
308
317
  emitProviderEvidence(options.onEvidence, { type: "session", provider: CODEX_APP_SERVER_PROVIDER, threadId: activeThreadId }, builderDiagnostic);
309
318
  // Policy A defaults, but omit when config.* already owns that surface (thread/start config wins).
@@ -351,17 +360,9 @@ export async function startCodexAppServer(prompt, cwd, options = {}) {
351
360
  throw createProviderFailure(message);
352
361
  }
353
362
  // Successful turn/completed is consumption evidence; interrupt ack alone is not terminal proof.
354
- const session = {
355
- provider: "codex-app-server",
356
- version: SUBAGENT_SESSION_VERSION,
357
- handle: {
358
- threadId: activeThreadId,
359
- ...(serverCodexHome ? { codexHome: serverCodexHome } : {}),
360
- },
361
- };
362
363
  const finalMessage = turnState.lastAgentMessage;
363
- builder.emit({ type: "done", finalMessage, session });
364
- return { finalMessage, session };
364
+ builder.emit({ type: "done", finalMessage, session: admittedSession });
365
+ return { finalMessage, session: admittedSession };
365
366
  }
366
367
  finally {
367
368
  activeTurnCompletion = undefined;
@@ -212,7 +212,7 @@ export const codexAppServerRegistration = {
212
212
  webSearch: Object.freeze(webSearch),
213
213
  });
214
214
  },
215
- startLive: async ({ terms, signal, reviveTerms, onSyncCheckpoint, onDeliveryOpportunity, onIngress, }) => {
215
+ startLive: async ({ terms, signal, reviveTerms, onSyncCheckpoint, onDeliveryOpportunity, onSessionAdmission, onIngress, }) => {
216
216
  if (terms.provider !== CODEX_APP_SERVER_PROVIDER) {
217
217
  throw new Error(`startLive expected ${CODEX_APP_SERVER_PROVIDER}`);
218
218
  }
@@ -231,6 +231,7 @@ export const codexAppServerRegistration = {
231
231
  onSyncCheckpoint,
232
232
  onDeliveryOpportunity,
233
233
  onEvidence: codexTerms.onEvidence,
234
+ onSessionAdmission,
234
235
  onIngress,
235
236
  initialTurn: codexTerms.initialTurn,
236
237
  };
@@ -264,6 +264,13 @@ export async function startOpencode(prompt, cwd, options = {}) {
264
264
  builder.emit({ type: "fail", message });
265
265
  throw createProviderFailure(message);
266
266
  }
267
+ options.onSessionAdmission?.({
268
+ session: {
269
+ provider: OPENCODE_SDK_PROVIDER,
270
+ version: SUBAGENT_SESSION_VERSION,
271
+ handle: { sessionId: activeSessionId },
272
+ },
273
+ });
267
274
  idleMonitor = new OpencodeIdleMonitor(sdk.client.v2.session, activeSessionId, combined.signal);
268
275
  eventController = createOpencodeEventController({
269
276
  builder,
@@ -104,7 +104,7 @@ export const opencodeSdkRegistration = {
104
104
  return loader ? { ...terms, loader } : terms;
105
105
  },
106
106
  nativePolicyOwnership: () => null,
107
- startLive: async ({ terms, signal, reviveTerms, onSyncCheckpoint, onDeliveryOpportunity, onIngress, }) => {
107
+ startLive: async ({ terms, signal, reviveTerms, onSyncCheckpoint, onDeliveryOpportunity, onSessionAdmission, onIngress, }) => {
108
108
  if (terms.provider !== OPENCODE_SDK_PROVIDER) {
109
109
  throw new Error(`startLive expected ${OPENCODE_SDK_PROVIDER}`);
110
110
  }
@@ -118,6 +118,7 @@ export const opencodeSdkRegistration = {
118
118
  onSyncCheckpoint,
119
119
  onDeliveryOpportunity,
120
120
  onEvidence: opencodeTerms.onEvidence,
121
+ onSessionAdmission,
121
122
  onIngress,
122
123
  initialTurn: opencodeTerms.initialTurn,
123
124
  loader: opencodeTerms.loader,
@@ -1,5 +1,6 @@
1
1
  import { FlowError } from "../../../flow-error.js";
2
2
  import { appendDebugLog } from "../../../telemetry/debug-log.js";
3
+ import { SUBAGENT_SESSION_VERSION, } from "../../session.js";
3
4
  import { createProjectionDriver } from "../../harness/execution-handle.js";
4
5
  import { createProviderFailure, toProviderTerminalError, } from "../../harness/provider-adapter.js";
5
6
  import { createAbortError, emitProviderEvidence, sha256Utf8 } from "../../harness/runtime.js";
@@ -103,6 +104,15 @@ async function openPiProjectionDriver(prompt, cwd, options) {
103
104
  ...(resourceLoader ? { resourceLoader } : {}),
104
105
  });
105
106
  const session = created.session;
107
+ if (session.sessionFile) {
108
+ options.onSessionAdmission?.({
109
+ session: {
110
+ provider: PI_PROVIDER,
111
+ version: SUBAGENT_SESSION_VERSION,
112
+ handle: { sessionId: session.sessionId, sessionFile: session.sessionFile },
113
+ },
114
+ });
115
+ }
106
116
  if (options.onEvidence)
107
117
  emitProviderEvidence(options.onEvidence, { type: "session", provider: PI_PROVIDER, sessionId: session.sessionId, ...(session.sessionFile ? { sessionFile: session.sessionFile } : {}) }, reportEvidenceDiagnostic);
108
118
  session.setSteeringMode("one-at-a-time");
@@ -109,7 +109,7 @@ export const piRegistration = {
109
109
  return loader ? { ...terms, loader } : terms;
110
110
  },
111
111
  nativePolicyOwnership: () => null,
112
- startLive: async ({ terms, signal, reviveTerms, onSyncCheckpoint, onIngress }) => {
112
+ startLive: async ({ terms, signal, reviveTerms, onSyncCheckpoint, onSessionAdmission, onIngress }) => {
113
113
  if (terms.provider !== PI_PROVIDER) {
114
114
  throw new Error(`startLive expected ${PI_PROVIDER}`);
115
115
  }
@@ -125,6 +125,7 @@ export const piRegistration = {
125
125
  appendSystemPromptOverride: piTerms.appendSystemPromptOverride,
126
126
  onSyncCheckpoint,
127
127
  onEvidence: piTerms.onEvidence,
128
+ onSessionAdmission,
128
129
  onIngress,
129
130
  initialTurn: piTerms.initialTurn,
130
131
  loader: piTerms.loader,
@@ -137,6 +138,7 @@ export const piRegistration = {
137
138
  appendSystemPromptOverride: piTerms.appendSystemPromptOverride,
138
139
  onSyncCheckpoint,
139
140
  onEvidence: piTerms.onEvidence,
141
+ onSessionAdmission,
140
142
  onIngress,
141
143
  initialTurn: piTerms.initialTurn,
142
144
  loader: piTerms.loader,
@@ -2,6 +2,7 @@ import { assertSettingsKnobUsable } from "../config/settings/disease.js";
2
2
  import { loadKeiyakuSettings } from "../config/settings/loader.js";
3
3
  import { loadAkumaCatalog } from "../config/akuma-loader.js";
4
4
  import { listContractIds } from "../core/ledger.js";
5
+ import { TASK_FILTER_COMPLETION } from "../core/task/query.js";
5
6
  import { allCommandMetadata, getCommandMetadata } from "./commands/metadata.js";
6
7
  function topLevelCommands() {
7
8
  return allCommandMetadata()
@@ -46,6 +47,9 @@ export async function renderCompletionCandidates(input) {
46
47
  if (input.previous === "task") {
47
48
  return filterPrefix(subcommandsFor("task"), word).join("\n");
48
49
  }
50
+ if (input.previous === "--filter") {
51
+ return filterPrefix([TASK_FILTER_COMPLETION], word).join("\n");
52
+ }
49
53
  return filterPrefix(topLevelCommands(), word).join("\n");
50
54
  }
51
55
  export function renderCompletionScript(shell = "bash") {
@@ -54,7 +58,7 @@ export function renderCompletionScript(shell = "bash") {
54
58
  "#compdef keiyaku",
55
59
  "_keiyaku() {",
56
60
  " local -a candidates",
57
- " candidates=(${(f)\"$(keiyaku completion --complete --word \"${words[CURRENT]}\" --previous \"${words[CURRENT-1]}\" 2>/dev/null)\"})",
61
+ " candidates=(${(f)\"$(keiyaku completion --complete --word=\"${words[CURRENT]}\" --previous=\"${words[CURRENT-1]}\" 2>/dev/null)\"})",
58
62
  " compadd -- $candidates",
59
63
  "}",
60
64
  "_keiyaku \"$@\"",
@@ -65,7 +69,10 @@ export function renderCompletionScript(shell = "bash") {
65
69
  " local cur prev",
66
70
  " cur=\"${COMP_WORDS[COMP_CWORD]}\"",
67
71
  " prev=\"${COMP_WORDS[COMP_CWORD-1]}\"",
68
- " COMPREPLY=( $(compgen -W \"$(keiyaku completion --complete --word \"$cur\" --previous \"$prev\" 2>/dev/null)\" -- \"$cur\") )",
72
+ " COMPREPLY=()",
73
+ " while IFS= read -r candidate; do",
74
+ " COMPREPLY+=(\"$candidate\")",
75
+ " done < <(keiyaku completion --complete --word=\"$cur\" --previous=\"$prev\" 2>/dev/null)",
69
76
  "}",
70
77
  "complete -F _keiyaku_completion keiyaku",
71
78
  ].join("\n");
@@ -1,4 +1,5 @@
1
1
  import { ACTOR_IDENTITY } from "../keiyaku.js";
2
+ import { TASK_FILTER_GRAMMAR } from "../core/task/query.js";
2
3
  export const HELP_FLAGS = new Set(["--help", "-h"]);
3
4
  export const FLAG_SPECS = {
4
5
  cwd: {
@@ -178,8 +179,8 @@ export const FLAG_SPECS = {
178
179
  },
179
180
  filter: {
180
181
  kind: "value",
181
- placeholder: "FIELD OP VALUE",
182
- description: "Filter structured task facts. Repeat for AND; comma-separated values are OR.",
182
+ placeholder: TASK_FILTER_GRAMMAR,
183
+ description: "Filter structured task facts. Repeat --filter for AND; comma values or | terms are OR.",
183
184
  },
184
185
  sort: {
185
186
  kind: "value",
@@ -1,4 +1,4 @@
1
- import { asMessage, isFlowError, pickHintsFromError } from "../../flow-error.js";
1
+ import { asMessage, isFlowError, pickHintsFromError, } from "../../flow-error.js";
2
2
  import { assertSettingsKnobUsable } from "../../config/settings/disease.js";
3
3
  import { loadKeiyakuSettings } from "../../config/settings/loader.js";
4
4
  import { loadAkumaCatalog } from "../../config/akuma-loader.js";
@@ -6,6 +6,20 @@ import { appendDebugLog } from "../../telemetry/debug-log.js";
6
6
  import { ACTOR_IDENTITY, TOOL_NAMES, BUILTIN_AGENT_NAMES } from "../../keiyaku.js";
7
7
  import { buildErrorResponse } from "./misc.js";
8
8
  import { addressCarrierFromError } from "../../core/addressing.js";
9
+ import { shellQuote } from "./format.js";
10
+ const TASK_FILTER_GRAMMAR_EXAMPLE = "title~audit,scope";
11
+ function formatTaskFilterExpression(terms) {
12
+ return terms.map((term) => `${term.field}${term.operator}${term.values.join(",")}`).join("|");
13
+ }
14
+ function renderInvalidTaskFilterMessage(facts) {
15
+ const guidance = facts.recoveredTerms.length === 0
16
+ ? `Grammar example: keiyaku task ls --filter ${shellQuote(TASK_FILTER_GRAMMAR_EXAMPLE)}`
17
+ : `Paste-ready equivalent for parsed terms: keiyaku task ls --filter ${shellQuote(formatTaskFilterExpression(facts.recoveredTerms))}`;
18
+ return `invalid task filter: ${facts.detail}. Valid fields: ${facts.fields.join(", ")}. `
19
+ + `Valid operators: ${facts.operators.join(", ")}. `
20
+ + `Repeat --filter for AND; use commas for same-field OR or | between complete filters. `
21
+ + guidance;
22
+ }
9
23
  function renderScopeViolation(evidence) {
10
24
  if (evidence.outcome === "no-positive-match") {
11
25
  return ` ${evidence.path}\n no positive pattern matched`;
@@ -35,6 +49,9 @@ export function renderScopeAuditMessage(scope, evidence) {
35
49
  ].join("\n");
36
50
  }
37
51
  function renderErrorMessage(error) {
52
+ if (isFlowError(error) && error.facts?.kind === "invalid_task_filter") {
53
+ return renderInvalidTaskFilterMessage(error.facts);
54
+ }
38
55
  if (isFlowError(error) && error.facts?.kind === "scope_violation") {
39
56
  return renderScopeAuditMessage(error.facts.scope, error.facts.violations);
40
57
  }
@@ -1,10 +1,13 @@
1
1
  import { getConfig } from "../../config/env.js";
2
2
  import { SECTION_ICONS } from "./response-style.js";
3
- /** Quote a shell argument only when the token is not already a single safe word. */
3
+ // Command-copy output is POSIX shell syntax: close the quote, emit a literal
4
+ // apostrophe, then reopen the quote. Other shell dialects are not supported.
5
+ const POSIX_SINGLE_QUOTE_ESCAPE = "'\\''";
6
+ /** Quote a POSIX shell argument only when the token is not already a single safe word. */
4
7
  export function shellQuote(value) {
5
8
  if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value))
6
9
  return value;
7
- return `'${value.replace(/'/g, "'\\''")}'`;
10
+ return `'${value.replace(/'/g, POSIX_SINGLE_QUOTE_ESCAPE)}'`;
8
11
  }
9
12
  export function formatAgeMs(ms) {
10
13
  if (ms === undefined)
@@ -0,0 +1,48 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { FlowError, asMessage } from "../../flow-error.js";
5
+ import { createGit, wrapGitError } from "../../git/core.js";
6
+ import { readStructuredDiff } from "../../git/diff/structured.js";
7
+ import { rewriteRenewBranch } from "../renew.js";
8
+ async function resolveObject(cwd, revision, kind) {
9
+ try {
10
+ return (await createGit(cwd).raw(["rev-parse", "--verify", `${revision}^{${kind}}`])).trim();
11
+ }
12
+ catch (error) {
13
+ throw wrapGitError(`rev-parse ${revision}^{${kind}}`, error, cwd);
14
+ }
15
+ }
16
+ export async function createAuditCandidate(input) {
17
+ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "keiyaku-audit-"));
18
+ const git = createGit(input.root);
19
+ try {
20
+ await git.raw(["clone", "--quiet", "--no-hardlinks", "--no-checkout", input.root, cwd]);
21
+ const candidateGit = createGit(cwd);
22
+ await candidateGit.raw(["config", "user.name", "Keiyaku Audit"]);
23
+ await candidateGit.raw(["config", "user.email", "audit@keiyaku.invalid"]);
24
+ await candidateGit.raw(["checkout", "--quiet", "-B", `keiyaku/${input.contractId}`, input.frozen.coordinates.delivery.head]);
25
+ if (input.frozen.base === input.frozen.coordinates.target.head)
26
+ return { cwd, rehearsal: { status: "fresh" }, candidate: { base: input.frozen.coordinates.target.head, head: input.frozen.coordinates.delivery.head, tree: input.frozen.coordinates.delivery.tree, rewritten: false } };
27
+ try {
28
+ await rewriteRenewBranch({ cwd, contractId: input.contractId, oldHead: input.frozen.coordinates.delivery.head, recoveryRef: "refs/keiyaku/audit/disposable", newBase: input.frozen.coordinates.target.head, oldBase: input.frozen.base });
29
+ }
30
+ catch (error) {
31
+ if (error instanceof FlowError && error.code === "CLOSE_MERGE_CONFLICT" && error.facts?.kind === "renew_conflict")
32
+ return { cwd, rehearsal: { status: "conflict", paths: [...error.facts.conflictPaths], pathCapture: error.facts.conflictPathCapture.status === "captured" ? "captured" : "failed", ...(error.facts.conflictPathCapture.status === "failed" ? { diagnostic: error.facts.conflictPathCapture.diagnostic } : {}) }, candidate: null };
33
+ return { cwd, rehearsal: { status: "unknown", cause: { code: error instanceof FlowError ? error.code : "INTERNAL_STATE", message: asMessage(error) } }, candidate: null };
34
+ }
35
+ const [head, tree] = await Promise.all([resolveObject(cwd, "HEAD", "commit"), resolveObject(cwd, "HEAD", "tree")]);
36
+ return { cwd, rehearsal: { status: "clean", oldBase: input.frozen.base, newBase: input.frozen.coordinates.target.head, oldHead: input.frozen.coordinates.delivery.head }, candidate: { base: input.frozen.coordinates.target.head, head, tree, rewritten: true } };
37
+ }
38
+ catch (error) {
39
+ return { cwd, rehearsal: { status: "unknown", cause: { code: error instanceof FlowError ? error.code : "INTERNAL_STATE", message: asMessage(error) } }, candidate: null };
40
+ }
41
+ }
42
+ export async function disposeAuditCandidate(candidate) { await fs.rm(candidate.cwd, { recursive: true, force: true }); }
43
+ export async function readAuditCandidateDiff(candidateResult) {
44
+ const candidate = candidateResult.candidate;
45
+ if (!candidate)
46
+ return null;
47
+ return readStructuredDiff(candidateResult.cwd, candidate.base, candidate.head, { excludePaths: [".keiyaku/KEIYAKU.md"] });
48
+ }
@@ -0,0 +1,180 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { FlowError, asMessage } from "../../flow-error.js";
5
+ import { createGit, wrapGitError } from "../../git/core.js";
6
+ import { readLedger } from "../ledger.js";
7
+ import { hasStrandableProjectionGeneration } from "../projection/index.js";
8
+ import { readRenewRecoverySession } from "../renew-session.js";
9
+ import { effectiveScope, findOpenArc, getFence, getMaxArc, receiptsAfterFence } from "../seal.js";
10
+ import { queueMembership, readQueueReadModel } from "../settlement/queue.js";
11
+ import { markdownSourceSections } from "../scope.js";
12
+ import { normalizeSectionTitle } from "../markdown/titles.js";
13
+ import { commissionTarget, latestBase } from "../status/drift.js";
14
+ import { deriveContractState, isTerminalState } from "../status/lifecycle.js";
15
+ import { resolveContractDeliveryCwd } from "../worktree-path.js";
16
+ import { KEIYAKU_FILE } from "../../keiyaku.js";
17
+ async function resolveObject(cwd, revision, kind) {
18
+ const git = createGit(cwd);
19
+ try {
20
+ return (await git.raw(["rev-parse", "--verify", `${revision}^{${kind}}`])).trim();
21
+ }
22
+ catch (error) {
23
+ throw wrapGitError(`rev-parse ${revision}^{${kind}}`, error, cwd);
24
+ }
25
+ }
26
+ export function appendedScopeTerms(entries) {
27
+ return entries.flatMap((entry) => entry.kind === "amend" && entry.data.scopeDelta ? entry.data.scopeDelta.add : []);
28
+ }
29
+ export function effectiveCriteria(entries) {
30
+ const bind = entries.find((entry) => entry.kind === "bind");
31
+ if (!bind)
32
+ return [];
33
+ const criteria = [...bind.data.checks];
34
+ for (const entry of entries) {
35
+ if (entry.kind !== "amend")
36
+ continue;
37
+ for (const section of markdownSourceSections(entry.data.amendment)) {
38
+ if (section.level !== 2 || normalizeSectionTitle(section.title) !== "checks")
39
+ continue;
40
+ for (const line of entry.data.amendment.slice(section.contentStart, section.end).split(/\r?\n/)) {
41
+ const match = line.match(/^\s*-\s+(.+)$/);
42
+ if (match)
43
+ criteria.push(match[1].trim());
44
+ }
45
+ }
46
+ }
47
+ return criteria;
48
+ }
49
+ async function pinLedgerCoordinates(input) {
50
+ const ledger = await readLedger(input.root, input.contractId);
51
+ if (!ledger)
52
+ throw new FlowError("INTERNAL_STATE", `contract ${input.contractId} not found or not active`);
53
+ const bind = ledger.entries.find((entry) => entry.kind === "bind");
54
+ const target = commissionTarget(ledger.entries);
55
+ const base = latestBase(ledger.entries);
56
+ if (!bind || !target || !base)
57
+ throw new FlowError("INTERNAL_STATE", `contract ${input.contractId} is missing pinned audit coordinates`);
58
+ const [deliveryHead, deliveryTree, targetHead, targetTree] = await Promise.all([
59
+ resolveObject(input.root, input.deliveryRef, "commit"), resolveObject(input.root, input.deliveryRef, "tree"),
60
+ resolveObject(input.root, target.ref, "commit"), resolveObject(input.root, target.ref, "tree"),
61
+ ]);
62
+ return {
63
+ criteria: effectiveCriteria(ledger.entries), scope: effectiveScope(ledger.entries), appendScope: appendedScopeTerms(ledger.entries), base, entries: ledger.entries,
64
+ coordinates: { ledger: { head: ledger.head }, delivery: { ref: input.deliveryRef, head: deliveryHead, tree: deliveryTree }, target: { ref: target.ref, head: targetHead, tree: targetTree } },
65
+ };
66
+ }
67
+ async function deliveryResidue(cwd) {
68
+ const fields = (await createGit(cwd).raw(["status", "--porcelain=v1", "-z", "--untracked-files=all"])).split("\0");
69
+ const staged = new Set();
70
+ const unstaged = new Set();
71
+ const untracked = new Set();
72
+ for (let index = 0; index < fields.length; index += 1) {
73
+ const field = fields[index];
74
+ if (!field)
75
+ continue;
76
+ const xy = field.slice(0, 2);
77
+ const filePath = field.slice(3);
78
+ if (filePath === KEIYAKU_FILE || filePath.startsWith(".keiyaku/recovery/")) {
79
+ if (xy[0] === "R" || xy[0] === "C")
80
+ index += 1;
81
+ continue;
82
+ }
83
+ if (xy === "??")
84
+ untracked.add(filePath);
85
+ else {
86
+ if (xy[0] !== " ")
87
+ staged.add(filePath);
88
+ if (xy[1] !== " ")
89
+ unstaged.add(filePath);
90
+ if (xy[0] === "R" || xy[0] === "C")
91
+ index += 1;
92
+ }
93
+ }
94
+ const tracked = new Set([...staged, ...unstaged]);
95
+ return { tracked: [...tracked], staged: [...staged], unstaged: [...unstaged], untracked: [...untracked] };
96
+ }
97
+ function residueKey(residue) {
98
+ return JSON.stringify({ tracked: [...residue.tracked].sort(), staged: [...residue.staged].sort(), unstaged: [...residue.unstaged].sort(), untracked: [...residue.untracked].sort() });
99
+ }
100
+ async function residueFingerprint(cwd, residue) {
101
+ const git = createGit(cwd);
102
+ const digest = createHash("sha256").update(residueKey(residue));
103
+ const tracked = [...residue.tracked].sort();
104
+ if (tracked.length > 0)
105
+ digest.update(await git.raw(["diff", "--no-color", "--no-ext-diff", "--binary", "HEAD", "--", ...tracked]));
106
+ for (const pathname of [...residue.untracked].sort()) {
107
+ const absolute = path.join(cwd, pathname);
108
+ const stat = await fs.lstat(absolute);
109
+ digest.update(pathname).update("\0").update(String(stat.mode)).update("\0");
110
+ if (stat.isSymbolicLink())
111
+ digest.update(await fs.readlink(absolute));
112
+ else
113
+ digest.update(await fs.readFile(absolute));
114
+ digest.update("\0");
115
+ }
116
+ return digest.digest("hex");
117
+ }
118
+ async function deliveryCoordinate(cwd) {
119
+ const [head, tree, residue] = await Promise.all([resolveObject(cwd, "HEAD", "commit"), resolveObject(cwd, "HEAD", "tree"), deliveryResidue(cwd)]);
120
+ return { head, tree, residue, residueFingerprint: await residueFingerprint(cwd, residue) };
121
+ }
122
+ async function readReadiness(input) {
123
+ const bind = input.frozen.entries.find((entry) => entry.kind === "bind");
124
+ const lifecycle = deriveContractState(input.frozen.entries);
125
+ const [residue, looseCommits, session, queue, liveProjection, dependencies] = await Promise.all([
126
+ deliveryResidue(input.deliveryCwd), receiptsAfterFence(input.root, input.frozen.entries, input.frozen.coordinates.delivery.head), readRenewRecoverySession(input.root, input.contractId), readQueueReadModel(input.root),
127
+ hasStrandableProjectionGeneration({ cwd: input.root, commissionId: input.contractId, deliveryWorktree: input.deliveryCwd, nowMs: Date.now() }),
128
+ Promise.all((bind.data.after ?? []).map(async (id) => { const ledger = await readLedger(input.root, id); const state = ledger ? deriveContractState(ledger.entries) : { state: "missing" }; return { id, satisfied: ledger !== null && isTerminalState(state), state: state.state }; })),
129
+ ]);
130
+ const membership = queueMembership(queue, input.contractId);
131
+ return { deliveryResidue: residue, arc: { open: findOpenArc(input.frozen.entries)?.data.arc ?? (getMaxArc(input.frozen.entries) === 0 ? 0 : null), max: getMaxArc(input.frozen.entries), fence: getFence(input.frozen.entries), looseCommits }, renewSession: { open: session !== null }, rewriteGuard: { liveProjection }, dependencies, petition: { state: lifecycle.state, queue: membership ? { position: membership.position, length: membership.queueLength, head: membership.queueHead, diagnostics: membership.diagnostics.map((item) => item.kind) } : null } };
132
+ }
133
+ export async function pinAuditSnapshot(input) {
134
+ const contractId = input.address.binding.commissionId;
135
+ const root = input.address.repo.stableRoot;
136
+ const deliveryRef = `refs/heads/keiyaku/${contractId}`;
137
+ return pinLedgerCoordinates({ root, contractId, deliveryRef });
138
+ }
139
+ export function initialCoordinateRace(input) {
140
+ return { which: "delivery-worktree", pinned: input.frozen.coordinates.delivery, observed: input.observed };
141
+ }
142
+ export async function pinAuditCoordinates(input) {
143
+ const contractId = input.address.binding.commissionId;
144
+ const root = input.address.repo.stableRoot;
145
+ const frozen = await pinAuditSnapshot({ address: input.address });
146
+ const deliveryCwd = await resolveContractDeliveryCwd(input.cwd, contractId);
147
+ const coordinate = await deliveryCoordinate(deliveryCwd);
148
+ if (coordinate.head !== frozen.coordinates.delivery.head || coordinate.tree !== frozen.coordinates.delivery.tree)
149
+ throw new Error(`registered delivery worktree ${coordinate.head}/${coordinate.tree} does not match pinned delivery ref ${frozen.coordinates.delivery.head}/${frozen.coordinates.delivery.tree}`);
150
+ const readiness = await readReadiness({ root, deliveryCwd, contractId, frozen });
151
+ return { ...frozen, deliveryCwd, deliveryCoordinate: coordinate, readiness };
152
+ }
153
+ export async function rereadCoordinateRace(input) {
154
+ const contractId = input.address.binding.commissionId;
155
+ const root = input.address.repo.stableRoot;
156
+ const deliveryRef = `refs/heads/keiyaku/${contractId}`;
157
+ let after;
158
+ try {
159
+ after = await pinLedgerCoordinates({ root, contractId, deliveryRef });
160
+ }
161
+ catch (error) {
162
+ return { which: "ledger", pinned: input.frozen.coordinates.ledger, observed: asMessage(error) };
163
+ }
164
+ if (after.coordinates.ledger.head !== input.frozen.coordinates.ledger.head)
165
+ return { which: "ledger", pinned: input.frozen.coordinates.ledger, observed: after.coordinates.ledger };
166
+ if (after.coordinates.delivery.head !== input.frozen.coordinates.delivery.head || after.coordinates.delivery.tree !== input.frozen.coordinates.delivery.tree)
167
+ return { which: "delivery", pinned: input.frozen.coordinates.delivery, observed: after.coordinates.delivery };
168
+ if (after.coordinates.target.head !== input.frozen.coordinates.target.head || after.coordinates.target.tree !== input.frozen.coordinates.target.tree)
169
+ return { which: "target", pinned: input.frozen.coordinates.target, observed: after.coordinates.target };
170
+ try {
171
+ const deliveryCwd = await resolveContractDeliveryCwd(input.cwd, contractId);
172
+ const observed = await deliveryCoordinate(deliveryCwd);
173
+ if (path.resolve(deliveryCwd) !== path.resolve(input.frozen.deliveryCwd) || observed.head !== input.frozen.deliveryCoordinate.head || observed.tree !== input.frozen.deliveryCoordinate.tree || observed.residueFingerprint !== input.frozen.deliveryCoordinate.residueFingerprint)
174
+ return { which: "delivery-worktree", pinned: { cwd: input.frozen.deliveryCwd, ...input.frozen.deliveryCoordinate }, observed: { cwd: deliveryCwd, ...observed } };
175
+ }
176
+ catch (error) {
177
+ return { which: "delivery-worktree", pinned: { cwd: input.frozen.deliveryCwd, ...input.frozen.deliveryCoordinate }, observed: asMessage(error) };
178
+ }
179
+ return null;
180
+ }