@gajae-code/coding-agent 0.4.4 → 0.5.0

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 (132) hide show
  1. package/CHANGELOG.md +83 -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 +6 -0
  5. package/dist/types/commands/setup.d.ts +6 -0
  6. package/dist/types/config/model-profile-activation.d.ts +11 -2
  7. package/dist/types/config/model-profiles.d.ts +7 -0
  8. package/dist/types/config/model-registry.d.ts +6 -0
  9. package/dist/types/config/model-resolver.d.ts +2 -0
  10. package/dist/types/config/models-config-schema.d.ts +35 -0
  11. package/dist/types/config/settings-schema.d.ts +4 -3
  12. package/dist/types/coordinator/contract.d.ts +1 -1
  13. package/dist/types/coordinator-mcp/server.d.ts +8 -2
  14. package/dist/types/gjc-runtime/team-runtime.d.ts +0 -1
  15. package/dist/types/gjc-runtime/tmux-common.d.ts +3 -0
  16. package/dist/types/harness-control-plane/finalize.d.ts +5 -0
  17. package/dist/types/harness-control-plane/owner.d.ts +1 -1
  18. package/dist/types/harness-control-plane/phase-rollup.d.ts +23 -0
  19. package/dist/types/harness-control-plane/receipt-ingest.d.ts +19 -0
  20. package/dist/types/harness-control-plane/receipt-spool.d.ts +19 -0
  21. package/dist/types/harness-control-plane/receipts.d.ts +46 -0
  22. package/dist/types/harness-control-plane/rpc-adapter.d.ts +3 -0
  23. package/dist/types/harness-control-plane/state-machine.d.ts +6 -1
  24. package/dist/types/harness-control-plane/types.d.ts +13 -1
  25. package/dist/types/hindsight/mental-models.d.ts +5 -5
  26. package/dist/types/main.d.ts +2 -2
  27. package/dist/types/modes/components/model-selector.d.ts +1 -12
  28. package/dist/types/modes/rpc/rpc-client.d.ts +2 -2
  29. package/dist/types/modes/rpc/rpc-types.d.ts +4 -1
  30. package/dist/types/modes/utils/abort-message.d.ts +4 -0
  31. package/dist/types/sdk.d.ts +5 -0
  32. package/dist/types/session/agent-session.d.ts +2 -0
  33. package/dist/types/session/blob-store.d.ts +20 -1
  34. package/dist/types/session/session-manager.d.ts +32 -6
  35. package/dist/types/session/streaming-output.d.ts +3 -2
  36. package/dist/types/session/tool-choice-queue.d.ts +6 -0
  37. package/dist/types/setup/hermes-setup.d.ts +7 -0
  38. package/dist/types/task/fork-context-advisory.d.ts +13 -0
  39. package/dist/types/task/receipt.d.ts +2 -0
  40. package/dist/types/task/roi-reconciliation.d.ts +27 -0
  41. package/dist/types/task/types.d.ts +17 -0
  42. package/dist/types/thinking-metadata.d.ts +16 -0
  43. package/dist/types/thinking.d.ts +3 -12
  44. package/dist/types/tools/index.d.ts +2 -0
  45. package/dist/types/tools/resolve.d.ts +0 -10
  46. package/dist/types/utils/tool-choice.d.ts +14 -1
  47. package/package.json +8 -7
  48. package/scripts/build-binary.ts +4 -0
  49. package/src/cli/fast-help.ts +80 -0
  50. package/src/cli/setup-cli.ts +12 -3
  51. package/src/cli.ts +112 -17
  52. package/src/commands/coordinator.ts +44 -1
  53. package/src/commands/harness.ts +128 -11
  54. package/src/commands/launch.ts +2 -2
  55. package/src/commands/mcp-serve.ts +3 -2
  56. package/src/commands/session.ts +3 -1
  57. package/src/commands/setup.ts +4 -0
  58. package/src/config/model-profile-activation.ts +15 -3
  59. package/src/config/model-profiles.ts +255 -56
  60. package/src/config/model-resolver.ts +9 -6
  61. package/src/config/models-config-schema.ts +2 -0
  62. package/src/config/settings-schema.ts +6 -3
  63. package/src/coordinator/contract.ts +1 -0
  64. package/src/coordinator-mcp/server.ts +427 -193
  65. package/src/cursor.ts +46 -4
  66. package/src/defaults/gjc/skills/team/SKILL.md +3 -2
  67. package/src/defaults/gjc/skills/ultragoal/SKILL.md +8 -2
  68. package/src/export/html/index.ts +13 -9
  69. package/src/gjc-runtime/launch-worktree.ts +12 -1
  70. package/src/gjc-runtime/session-state-sidecar.ts +38 -0
  71. package/src/gjc-runtime/team-runtime.ts +33 -7
  72. package/src/gjc-runtime/tmux-common.ts +15 -0
  73. package/src/gjc-runtime/tmux-sessions.ts +19 -11
  74. package/src/gjc-runtime/ultragoal-runtime.ts +505 -41
  75. package/src/gjc-runtime/workflow-manifest.generated.json +27 -1
  76. package/src/gjc-runtime/workflow-manifest.ts +16 -1
  77. package/src/harness-control-plane/finalize.ts +39 -5
  78. package/src/harness-control-plane/owner.ts +87 -28
  79. package/src/harness-control-plane/phase-rollup.ts +96 -0
  80. package/src/harness-control-plane/receipt-ingest.ts +127 -0
  81. package/src/harness-control-plane/receipt-spool.ts +128 -0
  82. package/src/harness-control-plane/receipts.ts +229 -1
  83. package/src/harness-control-plane/rpc-adapter.ts +8 -0
  84. package/src/harness-control-plane/state-machine.ts +27 -6
  85. package/src/harness-control-plane/storage.ts +23 -0
  86. package/src/harness-control-plane/types.ts +33 -1
  87. package/src/hindsight/mental-models.ts +17 -16
  88. package/src/internal-urls/docs-index.generated.ts +8 -7
  89. package/src/main.ts +7 -3
  90. package/src/modes/components/assistant-message.ts +26 -14
  91. package/src/modes/components/diff.ts +97 -0
  92. package/src/modes/components/model-selector.ts +353 -181
  93. package/src/modes/components/status-line.ts +6 -6
  94. package/src/modes/components/tool-execution.ts +30 -13
  95. package/src/modes/controllers/event-controller.ts +5 -4
  96. package/src/modes/controllers/selector-controller.ts +33 -42
  97. package/src/modes/interactive-mode.ts +4 -5
  98. package/src/modes/print-mode.ts +1 -1
  99. package/src/modes/rpc/rpc-client.ts +3 -2
  100. package/src/modes/rpc/rpc-mode.ts +44 -14
  101. package/src/modes/rpc/rpc-types.ts +5 -2
  102. package/src/modes/shared/agent-wire/command-dispatch.ts +10 -5
  103. package/src/modes/shared/agent-wire/command-validation.ts +11 -0
  104. package/src/modes/theme/theme.ts +2 -2
  105. package/src/modes/utils/abort-message.ts +41 -0
  106. package/src/modes/utils/context-usage.ts +15 -8
  107. package/src/modes/utils/ui-helpers.ts +5 -6
  108. package/src/sdk.ts +38 -6
  109. package/src/secrets/obfuscator.ts +102 -27
  110. package/src/session/agent-session.ts +121 -25
  111. package/src/session/blob-store.ts +89 -3
  112. package/src/session/session-manager.ts +328 -57
  113. package/src/session/streaming-output.ts +185 -122
  114. package/src/session/tool-choice-queue.ts +23 -0
  115. package/src/setup/hermes/templates/operator-instructions.v1.md +3 -2
  116. package/src/setup/hermes-setup.ts +63 -8
  117. package/src/task/executor.ts +69 -6
  118. package/src/task/fork-context-advisory.ts +99 -0
  119. package/src/task/index.ts +31 -2
  120. package/src/task/receipt.ts +7 -0
  121. package/src/task/render.ts +21 -1
  122. package/src/task/roi-reconciliation.ts +90 -0
  123. package/src/task/types.ts +15 -0
  124. package/src/thinking-metadata.ts +51 -0
  125. package/src/thinking.ts +26 -46
  126. package/src/tools/bash.ts +1 -1
  127. package/src/tools/index.ts +4 -2
  128. package/src/tools/resolve.ts +93 -18
  129. package/src/tools/subagent-render.ts +10 -1
  130. package/src/utils/edit-mode.ts +1 -1
  131. package/src/utils/title-generator.ts +16 -2
  132. package/src/utils/tool-choice.ts +45 -16
@@ -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"),
@@ -20,6 +20,7 @@ import { ControlServer, type EndpointRequest } from "./control-endpoint";
20
20
  import { defaultFinalizeChecks, type FinalizeChecks, runFinalize, type ValidationCommandSpec } from "./finalize";
21
21
  import { type OperateResult, operate } from "./operate";
22
22
  import { preserveDirtyWorktree } from "./preserve";
23
+ import { RECEIPT_SPOOL_DIR_ENV, withReceiptSpoolDir } from "./receipt-spool";
23
24
  import {
24
25
  buildReceipt,
25
26
  type ReceiptSubject,
@@ -28,8 +29,7 @@ import {
28
29
  type VanishEvidence,
29
30
  validateReceipt,
30
31
  } from "./receipts";
31
- import type { HarnessRpc } from "./rpc-adapter";
32
- import { singleFlightAccept } from "./rpc-adapter";
32
+ import { type HarnessRpc, type RpcStateSnapshot, singleFlightAccept } from "./rpc-adapter";
33
33
  import {
34
34
  acquireLease,
35
35
  canWriteEvents,
@@ -39,7 +39,7 @@ import {
39
39
  releaseLease,
40
40
  type SessionLease,
41
41
  } from "./session-lease";
42
- import { buildStateView, nextAllowedActions } from "./state-machine";
42
+ import { buildStateView, nextAllowedActions, submitUnavailableReason } from "./state-machine";
43
43
  import {
44
44
  appendEvent,
45
45
  controlSocketPath,
@@ -200,11 +200,6 @@ export class RuntimeOwner {
200
200
  }
201
201
 
202
202
  async #emitMapped(mapped: NonNullable<ReturnType<typeof observeRpcOutboundFrame>>): Promise<void> {
203
- await this.#emit(
204
- mapped.severity,
205
- mapped.kind,
206
- mapped.signal ? { ...mapped.evidence, signal: mapped.signal } : mapped.evidence,
207
- );
208
203
  if (mapped.kind === "rpc_agent_completed") {
209
204
  const state = await readSessionState(this.#opts.root, this.#opts.sessionId);
210
205
  if (
@@ -218,6 +213,11 @@ export class RuntimeOwner {
218
213
  await writeSessionState(this.#opts.root, state);
219
214
  }
220
215
  }
216
+ await this.#emit(
217
+ mapped.severity,
218
+ mapped.kind,
219
+ mapped.signal ? { ...mapped.evidence, signal: mapped.signal } : mapped.evidence,
220
+ );
221
221
  }
222
222
 
223
223
  #aggregateSignals(events: EventEnvelope[]): string[] {
@@ -233,6 +233,20 @@ export class RuntimeOwner {
233
233
  return out;
234
234
  }
235
235
 
236
+ #eventSubmitGateReason(kind: string, evidence: Record<string, unknown>): string | null {
237
+ const reason = typeof evidence.reason === "string" ? evidence.reason : null;
238
+ const signal = typeof evidence.signal === "string" ? evidence.signal : null;
239
+ const rpcActive =
240
+ kind === "prompt_accepted" ||
241
+ reason === "pre-state-not-idle" ||
242
+ kind.startsWith("rpc_") ||
243
+ signal === "prompt-accepted" ||
244
+ signal === "streaming" ||
245
+ signal === "tool-call" ||
246
+ signal === "test-running";
247
+ return rpcActive ? "rpc-not-idle" : null;
248
+ }
249
+
236
250
  async #emit(severity: Severity, kind: string, evidence: Record<string, unknown>): Promise<void> {
237
251
  const lease = await readLease(this.#opts.root, this.#opts.sessionId);
238
252
  // Single-writer guard: only emit while we still hold a live lease.
@@ -247,6 +261,7 @@ export class RuntimeOwner {
247
261
  ownerLive: true,
248
262
  blockers: [],
249
263
  };
264
+ const submitGateReason = this.#eventSubmitGateReason(kind, evidence);
250
265
  const envelope: EventEnvelope = {
251
266
  eventId: randomUUID(),
252
267
  cursor: ++this.#cursor,
@@ -255,21 +270,41 @@ export class RuntimeOwner {
255
270
  kind,
256
271
  state: view,
257
272
  evidence,
258
- nextAllowedActions: nextAllowedActions(view.lifecycle, true),
273
+ nextAllowedActions: nextAllowedActions(view.lifecycle, true, { submitUnavailableReason: submitGateReason }),
259
274
  writer: { ownerId: this.ownerId, leaseEpoch: this.#leaseEpoch },
260
275
  };
261
276
  await appendEvent(this.#opts.root, this.#opts.sessionId, envelope);
262
277
  }
263
278
 
264
- #response(state: SessionState, evidence: Record<string, unknown>, ok = true): PrimitiveResponse {
279
+ #response(
280
+ state: SessionState,
281
+ evidence: Record<string, unknown>,
282
+ ok = true,
283
+ submitGateReason: string | null = null,
284
+ ): PrimitiveResponse {
265
285
  return {
266
286
  ok,
267
287
  state: buildStateView(state, true),
268
288
  evidence,
269
- nextAllowedActions: nextAllowedActions(state.lifecycle, true),
289
+ nextAllowedActions: nextAllowedActions(state.lifecycle, true, { submitUnavailableReason: submitGateReason }),
270
290
  };
271
291
  }
272
292
 
293
+ #submitGateReason(state: SessionState, rpcState: RpcStateSnapshot | null): string | null {
294
+ const rpcReason = rpcState
295
+ ? rpcState.isStreaming || rpcState.steeringQueueDepth > 0 || rpcState.followupQueueDepth > 0
296
+ ? "rpc-not-idle"
297
+ : null
298
+ : "rpc-not-live";
299
+ return submitUnavailableReason(state.lifecycle, true, rpcReason);
300
+ }
301
+
302
+ async #withReceiptSpoolFromInput<T>(input: Record<string, unknown>, fn: () => Promise<T>): Promise<T> {
303
+ const requested = input[RECEIPT_SPOOL_DIR_ENV];
304
+ if (typeof requested === "string" && requested.trim()) return withReceiptSpoolDir(requested, fn);
305
+ return fn();
306
+ }
307
+
273
308
  async #handle(req: EndpointRequest): Promise<unknown> {
274
309
  switch (req.verb) {
275
310
  case "ping":
@@ -281,13 +316,13 @@ export class RuntimeOwner {
281
316
  case "retire":
282
317
  return this.#retire();
283
318
  case "finalize":
284
- return this.#finalize(req.input);
319
+ return this.#withReceiptSpoolFromInput(req.input, () => this.#finalize(req.input));
285
320
  case "recover":
286
- return this.#recover();
321
+ return this.#withReceiptSpoolFromInput(req.input, () => this.#recover());
287
322
  case "validate":
288
- return this.#validate();
323
+ return this.#withReceiptSpoolFromInput(req.input, () => this.#validate());
289
324
  case "operate":
290
- return this.#operate(req.input);
325
+ return this.#withReceiptSpoolFromInput(req.input, () => this.#operate(req.input));
291
326
  default:
292
327
  return { ok: false, error: `owner_unsupported_verb:${req.verb}` };
293
328
  }
@@ -297,8 +332,10 @@ export class RuntimeOwner {
297
332
  const state = await this.#loadState();
298
333
  const workspace = state.handle.workspace;
299
334
  let streaming = false;
335
+ let rpcState: RpcStateSnapshot | null = null;
300
336
  try {
301
- streaming = (await this.#opts.rpc.getState()).isStreaming;
337
+ rpcState = await this.#opts.rpc.getState();
338
+ streaming = rpcState.isStreaming;
302
339
  } catch {
303
340
  streaming = false;
304
341
  }
@@ -328,12 +365,7 @@ export class RuntimeOwner {
328
365
  gitDelta = "unknown";
329
366
  }
330
367
  }
331
- const rpcLive = this.#opts.rpc.isLive
332
- ? this.#opts.rpc.isLive()
333
- : await this.#opts.rpc
334
- .getState()
335
- .then(() => true)
336
- .catch(() => false);
368
+ const rpcLive = this.#opts.rpc.isLive ? this.#opts.rpc.isLive() : rpcState !== null;
337
369
  const rpcLastFrameAt = this.#opts.rpc.lastFrameAt ? this.#opts.rpc.lastFrameAt() : null;
338
370
  // Sticky semantic signals come from the persisted owner event log -> survive polling gaps.
339
371
  const recent = (await readEvents(this.#opts.root, this.#opts.sessionId, 0)).slice(-200);
@@ -343,6 +375,7 @@ export class RuntimeOwner {
343
375
  (t): t is string => typeof t === "string",
344
376
  );
345
377
  const lastActivityAt = stamps.length > 0 ? (stamps.sort().at(-1) ?? state.updatedAt) : state.updatedAt;
378
+ const submitGateReason = this.#submitGateReason(state, rpcState);
346
379
  return {
347
380
  lifecycle: state.lifecycle,
348
381
  ownerLive: true,
@@ -354,6 +387,8 @@ export class RuntimeOwner {
354
387
  risk: deleted ? "deleted-worktree" : "normal",
355
388
  rpcLive,
356
389
  rpcLastFrameAt,
390
+ readyForSubmit: submitGateReason === null,
391
+ submitUnavailableReason: submitGateReason,
357
392
  };
358
393
  }
359
394
 
@@ -504,13 +539,21 @@ export class RuntimeOwner {
504
539
  const workspace = state.handle.workspace;
505
540
  const checks = this.#finalizeChecks ?? defaultFinalizeChecks(workspace);
506
541
  const reviewOnly = state.handle.mode === "review";
542
+ const inputVerdict = reviewOnly ? (typeof input.verdict === "string" ? input.verdict : null) : undefined;
543
+ // Review-only finalize with no explicit verdict pulls the final assistant text from the live
544
+ // RPC owner so the verdict can be extracted deterministically instead of demanded from the operator.
545
+ let assistantText: string | null = null;
546
+ if (reviewOnly && inputVerdict == null && this.#opts.rpc.getLastAssistantText) {
547
+ assistantText = await this.#opts.rpc.getLastAssistantText().catch(() => null);
548
+ }
507
549
  const fin = await runFinalize({
508
550
  root: this.#opts.root,
509
551
  sessionId: this.#opts.sessionId,
510
552
  workspace,
511
553
  branch: state.handle.branch ?? "",
512
554
  reviewOnly,
513
- verdict: reviewOnly ? (typeof input.verdict === "string" ? input.verdict : null) : undefined,
555
+ verdict: inputVerdict,
556
+ assistantText: reviewOnly ? assistantText : undefined,
514
557
  prTarget: reviewOnly ? state.handle.issueOrPr : undefined,
515
558
  requireTests: input.requireTests !== false,
516
559
  requireCommit: input.requireCommit !== false,
@@ -535,10 +578,21 @@ export class RuntimeOwner {
535
578
  const prompt = typeof input.prompt === "string" ? input.prompt : "";
536
579
  const state = await this.#loadState();
537
580
  if (!prompt) {
538
- return this.#response(state, { accepted: false, reason: "empty-prompt" }, false);
581
+ return this.#response(
582
+ state,
583
+ { accepted: false, submitted: false, reason: "empty-prompt" },
584
+ false,
585
+ "empty-prompt",
586
+ );
539
587
  }
540
- if (state.lifecycle === "blocked") {
541
- return this.#response(state, { accepted: false, reason: "lifecycle-blocked" }, false);
588
+ const lifecycleGate = submitUnavailableReason(state.lifecycle, true);
589
+ if (lifecycleGate) {
590
+ return this.#response(
591
+ state,
592
+ { accepted: false, submitted: false, reason: lifecycleGate },
593
+ false,
594
+ lifecycleGate,
595
+ );
542
596
  }
543
597
  const result = await singleFlightAccept(this.#opts.rpc, prompt, this.#opts.acceptanceTimeoutMs);
544
598
  if (result.accepted) {
@@ -552,11 +606,12 @@ export class RuntimeOwner {
552
606
  } else {
553
607
  await this.#emit("warn", "prompt_not_accepted", { reason: result.reason });
554
608
  }
609
+ const submitGateReason = result.accepted ? null : result.reason === "pre-state-not-idle" ? "rpc-not-idle" : null;
555
610
  return this.#response(
556
611
  state,
557
612
  {
558
613
  accepted: result.accepted,
559
- submitted: true,
614
+ submitted: result.commandId !== null,
560
615
  reason: result.reason,
561
616
  commandId: result.commandId,
562
617
  preSubmitCursor: result.preSubmitCursor,
@@ -564,12 +619,16 @@ export class RuntimeOwner {
564
619
  acceptanceEvidence: result.preSubmitState,
565
620
  },
566
621
  result.accepted,
622
+ submitGateReason,
567
623
  );
568
624
  }
569
625
 
570
626
  async #observe(): Promise<PrimitiveResponse> {
571
627
  const state = await this.#loadState();
572
- return this.#response(state, { observation: await this.#observeGit(), ownerRouted: true });
628
+ const observation = await this.#observeGit();
629
+ const submitGateReason =
630
+ typeof observation.submitUnavailableReason === "string" ? observation.submitUnavailableReason : null;
631
+ return this.#response(state, { observation, ownerRouted: true }, true, submitGateReason);
573
632
  }
574
633
 
575
634
  async #retire(): Promise<PrimitiveResponse> {
@@ -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
+ }