@gajae-code/coding-agent 0.4.4 → 0.4.5

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 (68) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/types/cli/fast-help.d.ts +1 -0
  3. package/dist/types/cli/setup-cli.d.ts +2 -0
  4. package/dist/types/commands/harness.d.ts +3 -0
  5. package/dist/types/commands/setup.d.ts +6 -0
  6. package/dist/types/config/model-registry.d.ts +3 -0
  7. package/dist/types/config/models-config-schema.d.ts +5 -0
  8. package/dist/types/coordinator/contract.d.ts +1 -1
  9. package/dist/types/coordinator-mcp/server.d.ts +8 -2
  10. package/dist/types/harness-control-plane/finalize.d.ts +5 -0
  11. package/dist/types/harness-control-plane/phase-rollup.d.ts +23 -0
  12. package/dist/types/harness-control-plane/receipt-ingest.d.ts +19 -0
  13. package/dist/types/harness-control-plane/receipts.d.ts +46 -0
  14. package/dist/types/harness-control-plane/rpc-adapter.d.ts +3 -0
  15. package/dist/types/harness-control-plane/types.d.ts +9 -1
  16. package/dist/types/main.d.ts +2 -2
  17. package/dist/types/modes/utils/abort-message.d.ts +4 -0
  18. package/dist/types/session/session-manager.d.ts +8 -0
  19. package/dist/types/setup/hermes-setup.d.ts +7 -0
  20. package/dist/types/task/fork-context-advisory.d.ts +13 -0
  21. package/dist/types/task/receipt.d.ts +1 -0
  22. package/dist/types/task/roi-reconciliation.d.ts +27 -0
  23. package/dist/types/task/types.d.ts +10 -0
  24. package/package.json +8 -7
  25. package/scripts/build-binary.ts +4 -0
  26. package/src/cli/fast-help.ts +80 -0
  27. package/src/cli/setup-cli.ts +12 -3
  28. package/src/cli.ts +107 -16
  29. package/src/commands/coordinator.ts +44 -1
  30. package/src/commands/harness.ts +92 -9
  31. package/src/commands/mcp-serve.ts +3 -2
  32. package/src/commands/setup.ts +4 -0
  33. package/src/config/models-config-schema.ts +1 -0
  34. package/src/coordinator/contract.ts +1 -0
  35. package/src/coordinator-mcp/server.ts +385 -182
  36. package/src/cursor.ts +30 -2
  37. package/src/gjc-runtime/launch-worktree.ts +12 -1
  38. package/src/gjc-runtime/session-state-sidecar.ts +38 -0
  39. package/src/harness-control-plane/finalize.ts +39 -5
  40. package/src/harness-control-plane/owner.ts +9 -1
  41. package/src/harness-control-plane/phase-rollup.ts +96 -0
  42. package/src/harness-control-plane/receipt-ingest.ts +127 -0
  43. package/src/harness-control-plane/receipts.ts +229 -1
  44. package/src/harness-control-plane/rpc-adapter.ts +8 -0
  45. package/src/harness-control-plane/types.ts +29 -1
  46. package/src/internal-urls/docs-index.generated.ts +6 -5
  47. package/src/main.ts +7 -3
  48. package/src/modes/components/status-line.ts +6 -6
  49. package/src/modes/controllers/event-controller.ts +5 -4
  50. package/src/modes/interactive-mode.ts +4 -5
  51. package/src/modes/print-mode.ts +1 -1
  52. package/src/modes/theme/theme.ts +2 -2
  53. package/src/modes/utils/abort-message.ts +41 -0
  54. package/src/modes/utils/context-usage.ts +15 -8
  55. package/src/modes/utils/ui-helpers.ts +5 -6
  56. package/src/sdk.ts +9 -4
  57. package/src/session/agent-session.ts +16 -5
  58. package/src/session/session-manager.ts +20 -0
  59. package/src/setup/hermes/templates/operator-instructions.v1.md +3 -2
  60. package/src/setup/hermes-setup.ts +63 -8
  61. package/src/task/fork-context-advisory.ts +99 -0
  62. package/src/task/index.ts +31 -2
  63. package/src/task/receipt.ts +2 -0
  64. package/src/task/roi-reconciliation.ts +90 -0
  65. package/src/task/types.ts +7 -0
  66. package/src/tools/index.ts +2 -2
  67. package/src/tools/subagent-render.ts +10 -1
  68. package/src/utils/title-generator.ts +16 -2
package/src/cursor.ts CHANGED
@@ -161,7 +161,20 @@ function formatMcpToolErrorMessage(toolName: string, availableTools: string[]):
161
161
  }
162
162
 
163
163
  export class CursorExecHandlers implements ICursorExecHandlers {
164
- constructor(private options: CursorExecBridgeOptions) {}
164
+ constructor(private options: CursorExecBridgeOptions) {
165
+ // Bind every native handler so methods stay instance-safe when invoked
166
+ // detached/unbound by the Cursor provider (e.g. `const read = handlers.read`).
167
+ // Without this, `this.#optionsForCall()` throws "undefined is not an object".
168
+ this.read = this.read.bind(this);
169
+ this.ls = this.ls.bind(this);
170
+ this.grep = this.grep.bind(this);
171
+ this.write = this.write.bind(this);
172
+ this.delete = this.delete.bind(this);
173
+ this.shell = this.shell.bind(this);
174
+ this.shellStream = this.shellStream.bind(this);
175
+ this.diagnostics = this.diagnostics.bind(this);
176
+ this.mcp = this.mcp.bind(this);
177
+ }
165
178
 
166
179
  #optionsForCall(): CursorExecBridgeOptions {
167
180
  return {
@@ -185,9 +198,24 @@ export class CursorExecHandlers implements ICursorExecHandlers {
185
198
 
186
199
  async grep(args: Parameters<NonNullable<ICursorExecHandlers["grep"]>>[0]) {
187
200
  const toolCallId = decodeToolCallId(args.toolCallId);
201
+ // Cursor's native Glob tool arrives as a grep exec with a glob but no content
202
+ // pattern. The search tool requires a non-empty pattern, so an empty pattern
203
+ // means "list files matching this glob" — route that to find instead of
204
+ // throwing "Pattern must not be empty".
205
+ const pattern = typeof args.pattern === "string" ? args.pattern : "";
206
+ if (pattern.trim().length === 0) {
207
+ if (args.glob) {
208
+ const globPath = `${args.path || "."}/${args.glob}`;
209
+ return executeTool(this.#optionsForCall(), "find", toolCallId, { paths: [globPath] });
210
+ }
211
+ const result = buildToolErrorResult(
212
+ "Cursor grep request rejected: pattern must not be empty. Provide a non-empty search pattern.",
213
+ );
214
+ return createToolResultMessage(toolCallId, "search", result, true);
215
+ }
188
216
  const searchPath = args.glob ? `${args.path || "."}/${args.glob}` : args.path || ".";
189
217
  const toolResultMessage = await executeTool(this.#optionsForCall(), "search", toolCallId, {
190
- pattern: args.pattern,
218
+ pattern,
191
219
  paths: [searchPath],
192
220
  i: args.caseInsensitive || undefined,
193
221
  });
@@ -140,6 +140,17 @@ function readWorktreeEntryFromPath(repoRoot: string, worktreePath: string): GitW
140
140
  return { path: path.resolve(worktreePath), head, branchRef, detached: !branchRef };
141
141
  }
142
142
 
143
+ function resolveCanonicalRepoRoot(cwd: string): string {
144
+ const repoRoot = runGit(cwd, ["rev-parse", "--show-toplevel"]);
145
+ const commonDir = tryRunGit(repoRoot, ["rev-parse", "--git-common-dir"]);
146
+ if (!commonDir) return repoRoot;
147
+ const resolvedCommonDir = path.resolve(repoRoot, commonDir);
148
+ if (path.basename(resolvedCommonDir) !== ".git") return repoRoot;
149
+ const ownerRoot = path.dirname(resolvedCommonDir);
150
+ if (tryRunGit(ownerRoot, ["rev-parse", "--is-inside-work-tree"]) !== "true") return repoRoot;
151
+ return ownerRoot;
152
+ }
153
+
143
154
  function isWorktreeDirty(worktreePath: string): boolean {
144
155
  return runGit(worktreePath, ["status", "--porcelain"]).length > 0;
145
156
  }
@@ -187,7 +198,7 @@ export function planLaunchWorktree(
187
198
  mode: GjcLaunchWorktreeMode,
188
199
  ): GjcLaunchWorktreePlan | { enabled: false } {
189
200
  if (!mode.enabled) return { enabled: false };
190
- const repoRoot = runGit(cwd, ["rev-parse", "--show-toplevel"]);
201
+ const repoRoot = resolveCanonicalRepoRoot(cwd);
191
202
  const baseRef = runGit(repoRoot, ["rev-parse", "HEAD"]);
192
203
  const branchName = mode.detached ? null : mode.name;
193
204
  if (branchName) validateBranchName(repoRoot, branchName);
@@ -30,6 +30,33 @@ function lastAssistant(messages: unknown[] | undefined): AssistantMessage | unde
30
30
  return undefined;
31
31
  }
32
32
 
33
+ function assistantText(assistant: AssistantMessage | undefined): string | null {
34
+ if (!assistant) return null;
35
+ const text = assistant.content
36
+ .filter(part => part.type === "text")
37
+ .map(part => part.text)
38
+ .join("\n")
39
+ .trim();
40
+ return text.length > 0 ? text : null;
41
+ }
42
+
43
+ function finalResponseForEvent(event: RuntimeStateEvent): {
44
+ text: string | null;
45
+ format: "markdown";
46
+ source: "agent_end";
47
+ artifact_path: null;
48
+ truncated: false;
49
+ } | null {
50
+ if (event.type !== "agent_end") return null;
51
+ return {
52
+ text: assistantText(lastAssistant(event.messages)),
53
+ format: "markdown",
54
+ source: "agent_end",
55
+ artifact_path: null,
56
+ truncated: false,
57
+ };
58
+ }
59
+
33
60
  function stateForEvent(event: RuntimeStateEvent): RuntimeState | null {
34
61
  if (event.type === "agent_start" || event.type === "turn_start") return "running";
35
62
  if (event.type === "agent_end") {
@@ -55,6 +82,7 @@ export async function persistCoordinatorRuntimeStateFromEvent(
55
82
  } catch {
56
83
  previous = {};
57
84
  }
85
+ const finalResponse = finalResponseForEvent(event);
58
86
  const payload = {
59
87
  schema_version: 1,
60
88
  session_id: process.env[GJC_COORDINATOR_SESSION_ID_ENV]?.trim() || context.sessionId,
@@ -69,6 +97,16 @@ export async function persistCoordinatorRuntimeStateFromEvent(
69
97
  event: event.type,
70
98
  cwd: context.cwd,
71
99
  session_file: context.sessionFile ?? null,
100
+ ...(finalResponse ? { final_response: finalResponse } : {}),
101
+ ...(state === "errored"
102
+ ? {
103
+ error: {
104
+ code: "agent_error",
105
+ message: lastAssistant(event.messages)?.errorMessage ?? "agent_error",
106
+ recoverable: true,
107
+ },
108
+ }
109
+ : {}),
72
110
  };
73
111
  try {
74
112
  await fs.mkdir(path.dirname(stateFile), { recursive: true });
@@ -19,11 +19,12 @@ import {
19
19
  type ReceiptSubject,
20
20
  type ReviewFailureEvidence,
21
21
  type ReviewVerdictEvidence,
22
+ sha256Hex,
22
23
  type ValidationEvidence,
23
24
  validateReceipt,
24
25
  } from "./receipts";
25
26
  import { readReceiptIndex, writeReceiptImmutable } from "./storage";
26
- import { isReviewVerdict, type ReviewVerdict } from "./types";
27
+ import { extractReviewVerdict, isReviewVerdict, type ReviewVerdict } from "./types";
27
28
 
28
29
  export interface ValidationCommandSpec {
29
30
  name: string;
@@ -56,6 +57,11 @@ export interface FinalizeOptions {
56
57
  reviewOnly?: boolean;
57
58
  /** Operator/loop-supplied terminal review verdict (closed vocabulary). */
58
59
  verdict?: string | null;
60
+ /**
61
+ * Final assistant text from the live RPC owner, used to extract a closed-vocabulary verdict
62
+ * for review-only sessions when no explicit {@link verdict} is supplied. Never persisted raw.
63
+ */
64
+ assistantText?: string | null;
59
65
  /** Bounded PR/issue reference for the review target (e.g. "PR-414"). Never resolved from the live repo. */
60
66
  prTarget?: string | null;
61
67
  validationCommands?: ValidationCommandSpec[];
@@ -78,6 +84,14 @@ function receiptId(prefix: string): string {
78
84
  return `${prefix}-${Date.now()}-${randomBytes(4).toString("hex")}`;
79
85
  }
80
86
 
87
+ /** Bound + whitespace-collapse assistant text into a redaction-safe digest summary (never a raw dump). */
88
+ function boundedAssistantSummary(text: string | null): string | null {
89
+ if (!text) return null;
90
+ const collapsed = text.replace(/\s+/g, " ").trim();
91
+ if (collapsed.length === 0) return null;
92
+ return collapsed.length > 280 ? `${collapsed.slice(0, 280)}…` : collapsed;
93
+ }
94
+
81
95
  export async function runFinalize(opts: FinalizeOptions): Promise<FinalizeResult> {
82
96
  if (opts.reviewOnly) return runReviewFinalize(opts);
83
97
 
@@ -214,9 +228,27 @@ async function runReviewFinalize(opts: FinalizeOptions): Promise<FinalizeResult>
214
228
  issueArtifact: null,
215
229
  };
216
230
 
217
- if (!isReviewVerdict(opts.verdict)) {
218
- const reason = opts.verdict == null ? "review-verdict-missing" : "review-verdict-invalid";
219
- const failure: ReviewFailureEvidence = { reason, prTarget, failedAt: now(), fallback: "operator-or-omx-review" };
231
+ // Explicit operator/loop verdict always wins. Only when none is supplied do we fall back to
232
+ // extracting a closed-vocabulary verdict from the live RPC owner's final assistant text.
233
+ const explicitProvided = opts.verdict != null;
234
+ const explicitValid = isReviewVerdict(opts.verdict);
235
+ const assistantText = typeof opts.assistantText === "string" ? opts.assistantText : null;
236
+ const extracted = explicitProvided ? null : extractReviewVerdict(assistantText);
237
+ const verdict: ReviewVerdict | null = explicitValid ? (opts.verdict as ReviewVerdict) : extracted;
238
+ const verdictSource: "input" | "assistant" = explicitValid ? "input" : "assistant";
239
+
240
+ if (!verdict) {
241
+ const reason = explicitProvided ? "review-verdict-invalid" : "review-verdict-missing";
242
+ const assistantDigest = assistantText ? sha256Hex(assistantText) : null;
243
+ const assistantSummary = boundedAssistantSummary(assistantText);
244
+ const failure: ReviewFailureEvidence = {
245
+ reason,
246
+ prTarget,
247
+ failedAt: now(),
248
+ fallback: "operator-or-omx-review",
249
+ ...(assistantDigest ? { assistantDigest } : {}),
250
+ ...(assistantSummary ? { assistantSummary } : {}),
251
+ };
220
252
  const receipt = buildReceipt<ReviewFailureEvidence>({
221
253
  receiptId: receiptId("revfail"),
222
254
  sessionId: opts.sessionId,
@@ -238,12 +270,14 @@ async function runReviewFinalize(opts: FinalizeOptions): Promise<FinalizeResult>
238
270
  return { ...baseResult, completed: false, receiptPath: entry.path, verdict: null, blockers };
239
271
  }
240
272
 
241
- const verdict = opts.verdict as ReviewVerdict;
273
+ const assistantDigest = verdictSource === "assistant" && assistantText ? sha256Hex(assistantText) : null;
242
274
  const evidence: ReviewVerdictEvidence = {
243
275
  verdict,
244
276
  prTarget,
245
277
  finalizedAt: now(),
246
278
  summaryRef: typeof opts.prTarget === "string" ? `verdict:${verdict}@${opts.prTarget}` : `verdict:${verdict}`,
279
+ verdictSource,
280
+ ...(assistantDigest ? { assistantDigest } : {}),
247
281
  };
248
282
  const receipt = buildReceipt<ReviewVerdictEvidence>({
249
283
  receiptId: receiptId("verdict"),
@@ -504,13 +504,21 @@ export class RuntimeOwner {
504
504
  const workspace = state.handle.workspace;
505
505
  const checks = this.#finalizeChecks ?? defaultFinalizeChecks(workspace);
506
506
  const reviewOnly = state.handle.mode === "review";
507
+ const inputVerdict = reviewOnly ? (typeof input.verdict === "string" ? input.verdict : null) : undefined;
508
+ // Review-only finalize with no explicit verdict pulls the final assistant text from the live
509
+ // RPC owner so the verdict can be extracted deterministically instead of demanded from the operator.
510
+ let assistantText: string | null = null;
511
+ if (reviewOnly && inputVerdict == null && this.#opts.rpc.getLastAssistantText) {
512
+ assistantText = await this.#opts.rpc.getLastAssistantText().catch(() => null);
513
+ }
507
514
  const fin = await runFinalize({
508
515
  root: this.#opts.root,
509
516
  sessionId: this.#opts.sessionId,
510
517
  workspace,
511
518
  branch: state.handle.branch ?? "",
512
519
  reviewOnly,
513
- verdict: reviewOnly ? (typeof input.verdict === "string" ? input.verdict : null) : undefined,
520
+ verdict: inputVerdict,
521
+ assistantText: reviewOnly ? assistantText : undefined,
514
522
  prTarget: reviewOnly ? state.handle.issueOrPr : undefined,
515
523
  requireTests: input.requireTests !== false,
516
524
  requireCommit: input.requireCommit !== false,
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Phase-boundary receipt rollup builder (receipt-of-receipts).
3
+ *
4
+ * At a harness lifecycle boundary, N child task receipts can be superseded by a
5
+ * single `phase-rollup` receipt that preserves per-child pointers (id, status,
6
+ * outputRef, sha256) plus aggregate ROI totals. The rollup is hash-sealed via
7
+ * the standard receipt envelope and validated fail-closed like every other
8
+ * family (see `validatePhaseRollup` in receipts.ts). Pure builder — no runtime
9
+ * injection behavior is changed here.
10
+ */
11
+ import type { TaskResultReceipt } from "../task/receipt";
12
+ import {
13
+ type BuildReceiptInput,
14
+ buildReceipt,
15
+ canonicalJson,
16
+ type PhaseRollupChildPointer,
17
+ type PhaseRollupEvidence,
18
+ type ReceiptEnvelope,
19
+ sha256Hex,
20
+ } from "./receipts";
21
+
22
+ function childPointer(receipt: TaskResultReceipt): PhaseRollupChildPointer {
23
+ const ref = receipt.outputRef;
24
+ // Receipt-of-receipts integrity requires BOTH a pointer URI and its content
25
+ // hash. A URI without a verifiable hash cannot be integrity-checked, so we
26
+ // drop the (one-sided) pointer entirely rather than emit an unverifiable ref
27
+ // that the fail-closed validator would reject.
28
+ const hasVerifiableRef = Boolean(ref?.uri) && Boolean(ref?.sha256);
29
+ return {
30
+ id: receipt.id,
31
+ status: receipt.status,
32
+ outputUri: hasVerifiableRef ? (ref?.uri ?? null) : null,
33
+ outputSha256: hasVerifiableRef ? (ref?.sha256 ?? null) : null,
34
+ // Normalize through JSON first: in-memory task receipts carry optional
35
+ // fields with value `undefined`, which canonicalJson would hash as
36
+ // `null` while persisted/parsed receipts omit those keys entirely.
37
+ // JSON round-tripping drops undefined-valued keys so the hash is
38
+ // identical for in-memory and rehydrated copies of the same receipt.
39
+ receiptSha256: sha256Hex(canonicalJson(JSON.parse(JSON.stringify(receipt)))),
40
+ // Per-child ROI accounting so the rollup aggregate is recomputable from
41
+ // child evidence (see validatePhaseRollup). `tokens` falls back to the
42
+ // receipt's raw token count when no ROI proxy is present.
43
+ tokens: receipt.roi?.tokens ?? receipt.tokens,
44
+ costTotal: receipt.roi?.costTotal ?? null,
45
+ clonedTokens: receipt.roi?.clonedTokens ?? null,
46
+ lowRoi: receipt.roi?.lowRoi ?? false,
47
+ };
48
+ }
49
+
50
+ export interface BuildPhaseRollupInput {
51
+ receiptId: string;
52
+ sessionId: string;
53
+ source: string;
54
+ subject: BuildReceiptInput<PhaseRollupEvidence>["subject"];
55
+ phase: string;
56
+ children: readonly TaskResultReceipt[];
57
+ /** Supply for deterministic output; defaults to now. */
58
+ createdAt?: string;
59
+ }
60
+
61
+ export function buildPhaseRollupReceipt(input: BuildPhaseRollupInput): ReceiptEnvelope<PhaseRollupEvidence> {
62
+ // `null` means "no child reported this metric" — the canonical value the
63
+ // fail-closed validator reconciles against. Decide presence from whether a
64
+ // child carried the field at all (not from a >0 sum), so a legitimate
65
+ // all-zero total reconciles instead of collapsing to null and mismatching.
66
+ const anyCost = input.children.some(child => (child.roi?.costTotal ?? null) !== null);
67
+ const anyCloned = input.children.some(child => (child.roi?.clonedTokens ?? null) !== null);
68
+ const totalCostTotal = anyCost
69
+ ? input.children.reduce((total, child) => total + (child.roi?.costTotal ?? 0), 0)
70
+ : null;
71
+ const totalClonedTokens = anyCloned
72
+ ? input.children.reduce((total, child) => total + (child.roi?.clonedTokens ?? 0), 0)
73
+ : null;
74
+ const evidence: PhaseRollupEvidence = {
75
+ phase: input.phase,
76
+ children: input.children.map(childPointer),
77
+ aggregate: {
78
+ childCount: input.children.length,
79
+ completed: input.children.filter(child => child.status === "completed").length,
80
+ failed: input.children.filter(child => child.status === "failed" || child.status === "merge_failed").length,
81
+ totalTokens: input.children.reduce((total, child) => total + (child.roi?.tokens ?? child.tokens), 0),
82
+ totalCostTotal,
83
+ totalClonedTokens,
84
+ lowRoiChildIds: input.children.filter(child => child.roi?.lowRoi).map(child => child.id),
85
+ },
86
+ };
87
+ return buildReceipt({
88
+ receiptId: input.receiptId,
89
+ sessionId: input.sessionId,
90
+ family: "phase-rollup",
91
+ source: input.source,
92
+ subject: input.subject,
93
+ evidence,
94
+ createdAt: input.createdAt,
95
+ });
96
+ }
@@ -0,0 +1,127 @@
1
+ import type { CompletionEvidence, ReceiptEnvelope, ReviewVerdictEvidence } from "./receipts";
2
+ import { validateReceipt } from "./receipts";
3
+ import { canTransition } from "./state-machine";
4
+ import type { HarnessLifecycle, ReceiptFamily, SessionState } from "./types";
5
+
6
+ export const RECEIPT_DIGEST_MAX_CHARS = 280;
7
+
8
+ export const RECEIPT_FAMILY_LIFECYCLE_TARGETS: Partial<Record<ReceiptFamily, HarnessLifecycle>> = {
9
+ completion: "completed",
10
+ // Review-only sessions terminate on a valid review verdict rather than a
11
+ // completion receipt; the finalizer treats valid verdicts as terminal.
12
+ "review-verdict": "completed",
13
+ };
14
+
15
+ /**
16
+ * Family-specific evidence consistency: the lifecycle target must agree with
17
+ * what the evidence itself claims. Hash validity alone is not enough — a
18
+ * semantically contradictory receipt must not drive the lifecycle.
19
+ */
20
+ function evidenceContradiction(receipt: ReceiptEnvelope<unknown>, target: HarnessLifecycle): string | undefined {
21
+ if (receipt.family === "completion") {
22
+ const evidence = receipt.evidence as CompletionEvidence;
23
+ if (evidence.finalLifecycle !== target) {
24
+ return `evidence-lifecycle-mismatch:${evidence.finalLifecycle}`;
25
+ }
26
+ }
27
+ if (receipt.family === "review-verdict") {
28
+ const evidence = receipt.evidence as ReviewVerdictEvidence;
29
+ // Owner confirmation is not a terminal success verdict.
30
+ if (evidence.verdict === "OWNER_CONFIRMATION_REQUIRED") {
31
+ return "review-verdict-not-terminal";
32
+ }
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ export interface ReceiptIngestResult {
38
+ accepted: ReceiptEnvelope<unknown>[];
39
+ rejected: { receipt: ReceiptEnvelope<unknown>; reasons: string[] }[];
40
+ transitions: { from: HarnessLifecycle; to: HarnessLifecycle; receiptId: string }[];
41
+ finalLifecycle: HarnessLifecycle;
42
+ digest: string;
43
+ }
44
+
45
+ export function ingestReceipts(
46
+ state: SessionState,
47
+ receipts: readonly ReceiptEnvelope<unknown>[],
48
+ ): ReceiptIngestResult {
49
+ let lifecycle = state.lifecycle;
50
+ const accepted: ReceiptEnvelope<unknown>[] = [];
51
+ const rejected: { receipt: ReceiptEnvelope<unknown>; reasons: string[] }[] = [];
52
+ const transitions: { from: HarnessLifecycle; to: HarnessLifecycle; receiptId: string }[] = [];
53
+
54
+ for (const receipt of receipts) {
55
+ const validation = validateReceipt(receipt);
56
+ if (!validation.valid) {
57
+ rejected.push({ receipt, reasons: validation.reasons });
58
+ continue;
59
+ }
60
+
61
+ // Fail closed on receipts the envelope itself marks invalid: the hash
62
+ // can be self-consistent while the issuer recorded the receipt as not
63
+ // proving its claim.
64
+ if (receipt.valid !== true) {
65
+ rejected.push({ receipt, reasons: ["receipt-marked-invalid"] });
66
+ continue;
67
+ }
68
+
69
+ // Fail closed on cross-session receipts: a self-consistent receipt from
70
+ // another session must never drive this session's lifecycle.
71
+ if (receipt.sessionId !== state.sessionId) {
72
+ rejected.push({ receipt, reasons: [`session-mismatch:${receipt.sessionId}`] });
73
+ continue;
74
+ }
75
+
76
+ const target = RECEIPT_FAMILY_LIFECYCLE_TARGETS[receipt.family];
77
+ if (target) {
78
+ // Non-terminal review verdicts (OWNER_CONFIRMATION_REQUIRED) are
79
+ // valid receipts but do not complete the session: accept, no move.
80
+ if (receipt.family === "review-verdict" && evidenceContradiction(receipt, target) !== undefined) {
81
+ accepted.push(receipt);
82
+ continue;
83
+ }
84
+ // Other contradictions (e.g. completion evidence whose
85
+ // finalLifecycle disagrees with the target) reject fail-closed.
86
+ const contradiction = evidenceContradiction(receipt, target);
87
+ if (contradiction !== undefined) {
88
+ rejected.push({ receipt, reasons: [contradiction] });
89
+ continue;
90
+ }
91
+ if (!canTransition(lifecycle, target)) {
92
+ rejected.push({ receipt, reasons: [`illegal-transition:${lifecycle}->${target}`] });
93
+ continue;
94
+ }
95
+
96
+ transitions.push({ from: lifecycle, to: target, receiptId: receipt.receiptId });
97
+ lifecycle = target;
98
+ }
99
+
100
+ accepted.push(receipt);
101
+ }
102
+
103
+ return {
104
+ accepted,
105
+ rejected,
106
+ transitions,
107
+ finalLifecycle: lifecycle,
108
+ digest: buildReceiptIngestDigest(receipts.length, accepted.length, rejected, state.lifecycle, lifecycle),
109
+ };
110
+ }
111
+
112
+ function buildReceiptIngestDigest(
113
+ total: number,
114
+ acceptedCount: number,
115
+ rejected: readonly { receipt: ReceiptEnvelope<unknown>; reasons: readonly string[] }[],
116
+ initialLifecycle: HarnessLifecycle,
117
+ finalLifecycle: HarnessLifecycle,
118
+ ): string {
119
+ let digest = `ingested ${total} receipts: ${acceptedCount} accepted, ${rejected.length} rejected; lifecycle ${initialLifecycle}->${finalLifecycle}`;
120
+ if (rejected.length > 0) {
121
+ const rejectedSummary = rejected
122
+ .map(item => `${item.receipt?.receiptId ?? "<malformed>"}(${item.reasons.join("|")})`)
123
+ .join(",");
124
+ digest += `; rejected: ${rejectedSummary}`;
125
+ }
126
+ return digest.slice(0, RECEIPT_DIGEST_MAX_CHARS);
127
+ }