@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
@@ -0,0 +1,128 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { withFileLock } from "../config/file-lock";
5
+ import type { ReceiptEnvelope } from "./receipts";
6
+
7
+ export const RECEIPT_SPOOL_DIR_ENV = "GJC_RECEIPT_SPOOL_DIR";
8
+ export const RECEIPT_SPOOL_FILENAME = "spool.jsonl";
9
+ export const RECEIPT_SPOOL_CURSOR_WIDTH = 12;
10
+
11
+ export interface ReceiptSpoolRecord {
12
+ cursor: string;
13
+ envelope: ReceiptEnvelope<unknown>;
14
+ }
15
+
16
+ export interface ReceiptSpoolAppendResult {
17
+ cursor: string;
18
+ path: string;
19
+ }
20
+
21
+ const receiptSpoolDirStorage = new AsyncLocalStorage<string | undefined>();
22
+ const spoolQueues = new Map<string, Promise<void>>();
23
+ const noop = (): void => undefined;
24
+ export async function withReceiptSpoolDir<T>(spoolDir: string, fn: () => Promise<T>): Promise<T> {
25
+ const trimmed = spoolDir.trim();
26
+ if (!trimmed) throw new Error("receipt_spool_dir_empty");
27
+ const resolved = path.resolve(trimmed);
28
+ return receiptSpoolDirStorage.run(resolved, fn);
29
+ }
30
+
31
+ export function resolveReceiptSpoolDir(env: NodeJS.ProcessEnv = process.env): string | undefined {
32
+ const active = receiptSpoolDirStorage.getStore();
33
+ if (active !== undefined) return active;
34
+ const raw = env[RECEIPT_SPOOL_DIR_ENV]?.trim();
35
+ return raw ? path.resolve(raw) : undefined;
36
+ }
37
+
38
+ export function receiptSpoolPath(spoolDir: string): string {
39
+ return path.join(path.resolve(spoolDir), RECEIPT_SPOOL_FILENAME);
40
+ }
41
+
42
+ function parseCursor(value: unknown): bigint | undefined {
43
+ if (typeof value !== "string" || !/^\d+$/.test(value)) return undefined;
44
+ try {
45
+ return BigInt(value);
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ }
50
+
51
+ export function formatReceiptSpoolCursor(cursor: bigint): string {
52
+ const raw = cursor.toString();
53
+ return raw.length >= RECEIPT_SPOOL_CURSOR_WIDTH ? raw : raw.padStart(RECEIPT_SPOOL_CURSOR_WIDTH, "0");
54
+ }
55
+
56
+ export async function readHighestReceiptSpoolCursor(spoolDir: string): Promise<bigint> {
57
+ const spoolFile = receiptSpoolPath(spoolDir);
58
+ let raw: string;
59
+ try {
60
+ raw = await fs.readFile(spoolFile, "utf8");
61
+ } catch (error) {
62
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return 0n;
63
+ throw error;
64
+ }
65
+
66
+ let highest = 0n;
67
+ for (const line of raw.split("\n")) {
68
+ const trimmed = line.trim();
69
+ if (!trimmed) continue;
70
+ try {
71
+ const parsed = JSON.parse(trimmed) as { cursor?: unknown };
72
+ const cursor = parseCursor(parsed.cursor);
73
+ if (cursor !== undefined && cursor > highest) highest = cursor;
74
+ } catch {
75
+ // A crash may leave a torn tail; consumers skip malformed lines and so do we.
76
+ }
77
+ }
78
+ return highest;
79
+ }
80
+
81
+ async function enqueueSpoolAppend<T>(spoolFile: string, task: () => Promise<T>): Promise<T> {
82
+ const previous = spoolQueues.get(spoolFile) ?? Promise.resolve();
83
+ const running = previous.catch(noop).then(task);
84
+ const normalized = running.then(noop, noop);
85
+ spoolQueues.set(spoolFile, normalized);
86
+ normalized
87
+ .finally(() => {
88
+ if (spoolQueues.get(spoolFile) === normalized) spoolQueues.delete(spoolFile);
89
+ })
90
+ .catch(noop);
91
+ return running;
92
+ }
93
+
94
+ export async function appendReceiptToSpool(
95
+ spoolDir: string,
96
+ envelope: ReceiptEnvelope<unknown>,
97
+ ): Promise<ReceiptSpoolAppendResult> {
98
+ const resolvedDir = path.resolve(spoolDir);
99
+ const spoolFile = receiptSpoolPath(resolvedDir);
100
+ return enqueueSpoolAppend(spoolFile, async () => {
101
+ await fs.mkdir(resolvedDir, { recursive: true, mode: 0o700 });
102
+ return withFileLock(
103
+ spoolFile,
104
+ async () => {
105
+ const cursor = formatReceiptSpoolCursor((await readHighestReceiptSpoolCursor(resolvedDir)) + 1n);
106
+ const record: ReceiptSpoolRecord = { cursor, envelope };
107
+ const handle = await fs.open(spoolFile, "a", 0o600);
108
+ try {
109
+ await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8");
110
+ await handle.sync();
111
+ } finally {
112
+ await handle.close();
113
+ }
114
+ return { cursor, path: spoolFile };
115
+ },
116
+ { staleMs: 30_000, retries: 100, retryDelayMs: 25 },
117
+ );
118
+ });
119
+ }
120
+
121
+ export async function appendReceiptToConfiguredSpool(
122
+ envelope: ReceiptEnvelope<unknown>,
123
+ env: NodeJS.ProcessEnv = process.env,
124
+ ): Promise<ReceiptSpoolAppendResult | undefined> {
125
+ const spoolDir = resolveReceiptSpoolDir(env);
126
+ if (!spoolDir) return undefined;
127
+ return appendReceiptToSpool(spoolDir, envelope);
128
+ }
@@ -46,7 +46,7 @@ export interface ReceiptEnvelope<E = Record<string, unknown>> {
46
46
  export const RECEIPT_SCHEMA_VERSION = 1 as const;
47
47
 
48
48
  /** Deterministic stringify with sorted keys (stable hash basis). */
49
- function canonicalJson(value: unknown): string {
49
+ export function canonicalJson(value: unknown): string {
50
50
  if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
51
51
  if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
52
52
  const obj = value as Record<string, unknown>;
@@ -96,9 +96,50 @@ export interface ValidationOutcome {
96
96
  reasons: string[];
97
97
  }
98
98
 
99
+ /** Reusable non-empty string guard for structural envelope checks. */
100
+ function isNonEmptyString(value: unknown): value is string {
101
+ return typeof value === "string" && value.length > 0;
102
+ }
103
+
104
+ /**
105
+ * Validate the structural envelope fields independently of the hash. A receipt
106
+ * can be hash-self-consistent while carrying empty/missing identity fields (an
107
+ * attacker controls the bytes the hash is computed over), so these checks must
108
+ * run BEFORE any lifecycle transition is allowed. Fail-closed.
109
+ */
110
+ function validateStructure(receipt: ReceiptEnvelope<unknown>): string[] {
111
+ const reasons: string[] = [];
112
+ if (!isNonEmptyString(receipt.receiptId)) reasons.push("envelope-missing-receiptId");
113
+ if (!isNonEmptyString(receipt.sessionId)) reasons.push("envelope-missing-sessionId");
114
+ if (!isNonEmptyString(receipt.source)) reasons.push("envelope-missing-source");
115
+ if (!isNonEmptyString(receipt.createdAt)) reasons.push("envelope-missing-createdAt");
116
+ // Family vocabulary itself is enforced by `validateFamily`; here we only
117
+ // require a non-empty family token so the envelope is well-formed.
118
+ if (!isNonEmptyString(receipt.family)) reasons.push("envelope-missing-family");
119
+ if (typeof receipt.valid !== "boolean") reasons.push("envelope-bad-valid");
120
+ if (typeof receipt.sha256 !== "string") reasons.push("envelope-missing-sha256");
121
+ const subject = receipt.subject as ReceiptSubject | undefined;
122
+ if (!subject || typeof subject !== "object" || Array.isArray(subject) || !isNonEmptyString(subject.workspace)) {
123
+ reasons.push("envelope-bad-subject");
124
+ }
125
+ if (receipt.evidence === null || typeof receipt.evidence !== "object" || Array.isArray(receipt.evidence)) {
126
+ reasons.push("envelope-bad-evidence");
127
+ }
128
+ if (!receipt.artifactHashes || typeof receipt.artifactHashes !== "object" || Array.isArray(receipt.artifactHashes)) {
129
+ reasons.push("envelope-bad-artifactHashes");
130
+ }
131
+ return reasons;
132
+ }
133
+
99
134
  /** Recompute the hash and run structural family checks. Fail-closed. */
100
135
  export function validateReceipt(receipt: ReceiptEnvelope<unknown>): ValidationOutcome {
136
+ // Fail closed on malformed/non-object envelopes (null, undefined, arrays,
137
+ // primitives) instead of throwing while destructuring below.
138
+ if (receipt === null || typeof receipt !== "object" || Array.isArray(receipt)) {
139
+ return { valid: false, reasons: ["malformed-envelope"] };
140
+ }
101
141
  const reasons: string[] = [];
142
+ reasons.push(...validateStructure(receipt));
102
143
  const { sha256, ...rest } = receipt;
103
144
  if (sha256Hex(hashBasis(rest)) !== sha256) reasons.push("hash-mismatch");
104
145
  if (receipt.schemaVersion !== RECEIPT_SCHEMA_VERSION) reasons.push("schema-version-mismatch");
@@ -156,6 +197,10 @@ export interface ReviewVerdictEvidence {
156
197
  finalizedAt: string;
157
198
  /** Bounded summary code/reference for the verdict; never raw assistant text. */
158
199
  summaryRef: string | null;
200
+ /** Where the verdict came from: explicit operator input or extracted from final assistant text. */
201
+ verdictSource?: "input" | "assistant";
202
+ /** sha256 of the assistant text the verdict was extracted from, when sourced from the agent. */
203
+ assistantDigest?: string | null;
159
204
  }
160
205
 
161
206
  export interface ReviewFailureEvidence {
@@ -165,6 +210,10 @@ export interface ReviewFailureEvidence {
165
210
  failedAt: string;
166
211
  /** Routing hint for the operator/fallback path. */
167
212
  fallback: string;
213
+ /** sha256 of the assistant text examined for a verdict, when one was available. */
214
+ assistantDigest?: string | null;
215
+ /** Bounded, whitespace-collapsed assistant summary (never an unbounded transcript dump). */
216
+ assistantSummary?: string | null;
168
217
  }
169
218
 
170
219
  function validateFamily(receipt: ReceiptEnvelope<unknown>): string[] {
@@ -181,11 +230,187 @@ function validateFamily(receipt: ReceiptEnvelope<unknown>): string[] {
181
230
  return validateReviewVerdict(receipt.evidence as ReviewVerdictEvidence);
182
231
  case "review-failure":
183
232
  return validateReviewFailure(receipt.evidence as ReviewFailureEvidence);
233
+ case "phase-rollup":
234
+ return validatePhaseRollup(receipt.evidence as PhaseRollupEvidence);
184
235
  default:
185
236
  return [`unknown-family:${receipt.family}`];
186
237
  }
187
238
  }
188
239
 
240
+ // ---- Phase rollup (receipt-of-receipts) ----------------------------------------
241
+
242
+ /** Pointer back to one superseded child task receipt. */
243
+ export interface PhaseRollupChildPointer {
244
+ id: string;
245
+ status: "completed" | "failed" | "aborted" | "merge_failed" | "paused";
246
+ /** Artifact URI holding the child's full output, when available. */
247
+ outputUri: string | null;
248
+ /** Content hash of the child's output artifact, when available. */
249
+ outputSha256: string | null;
250
+ /** Hash of the child receipt itself (canonical JSON), for staleness checks. */
251
+ receiptSha256: string;
252
+ /**
253
+ * Per-child ROI accounting carried into the rollup so the aggregate totals
254
+ * below are recomputable/verifiable from child evidence (not self-reported).
255
+ * `tokens` is the child's effective token count; cost/cloned are null when
256
+ * the child reported no such accounting.
257
+ */
258
+ tokens: number;
259
+ costTotal: number | null;
260
+ clonedTokens: number | null;
261
+ lowRoi: boolean;
262
+ }
263
+
264
+ export interface PhaseRollupEvidence {
265
+ /** Harness lifecycle boundary this rollup was emitted at. */
266
+ phase: string;
267
+ children: PhaseRollupChildPointer[];
268
+ aggregate: {
269
+ childCount: number;
270
+ completed: number;
271
+ failed: number;
272
+ totalTokens: number;
273
+ totalCostTotal: number | null;
274
+ totalClonedTokens: number | null;
275
+ lowRoiChildIds: string[];
276
+ };
277
+ }
278
+
279
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
280
+
281
+ const PHASE_ROLLUP_CHILD_STATUSES = new Set(["completed", "failed", "aborted", "merge_failed", "paused"]);
282
+
283
+ /** Reconcile two recomputed-vs-reported numeric totals (null == "not reported"). */
284
+ function numbersReconcile(actual: number | null, expected: number | null): boolean {
285
+ if (actual === null || expected === null) return actual === expected;
286
+ return Math.abs(actual - expected) <= 1e-9;
287
+ }
288
+
289
+ /** True when two id lists describe the same set (order-independent). */
290
+ function sameIdSet(actual: readonly string[], expected: readonly string[]): boolean {
291
+ if (actual.length !== expected.length) return false;
292
+ const expectedSet = new Set(expected);
293
+ for (const id of actual) {
294
+ if (!expectedSet.has(id)) return false;
295
+ }
296
+ return new Set(actual).size === expectedSet.size;
297
+ }
298
+
299
+ export function validatePhaseRollup(e: PhaseRollupEvidence): string[] {
300
+ const reasons: string[] = [];
301
+ if (!e || typeof e.phase !== "string" || e.phase.length === 0) return ["phase-rollup-missing-phase"];
302
+ if (!Array.isArray(e.children) || e.children.length === 0) {
303
+ reasons.push("phase-rollup-empty-children");
304
+ return reasons;
305
+ }
306
+ const seenIds = new Set<string>();
307
+ let completedFromChildren = 0;
308
+ let failedFromChildren = 0;
309
+ let tokensFromChildren = 0;
310
+ let costFromChildren = 0;
311
+ let clonedFromChildren = 0;
312
+ let anyCost = false;
313
+ let anyCloned = false;
314
+ const lowRoiFromChildren: string[] = [];
315
+ for (const child of e.children) {
316
+ if (!child || typeof child.id !== "string" || child.id.length === 0) {
317
+ reasons.push("phase-rollup-child-missing-id");
318
+ continue;
319
+ }
320
+ if (seenIds.has(child.id)) reasons.push(`phase-rollup-duplicate-child-id:${child.id}`);
321
+ seenIds.add(child.id);
322
+ if (!PHASE_ROLLUP_CHILD_STATUSES.has(child.status)) {
323
+ reasons.push(`phase-rollup-child-bad-status:${child.id}`);
324
+ }
325
+ if (child.status === "completed") completedFromChildren++;
326
+ if (child.status === "failed" || child.status === "merge_failed") failedFromChildren++;
327
+ if (typeof child.receiptSha256 !== "string" || !SHA256_HEX.test(child.receiptSha256)) {
328
+ reasons.push(`phase-rollup-child-bad-receipt-hash:${child.id}`);
329
+ }
330
+ if (child.outputUri !== null && (typeof child.outputUri !== "string" || child.outputUri.length === 0)) {
331
+ reasons.push(`phase-rollup-child-bad-output-uri:${child.id}`);
332
+ }
333
+ if (child.outputSha256 !== null && !SHA256_HEX.test(child.outputSha256)) {
334
+ reasons.push(`phase-rollup-child-bad-output-hash:${child.id}`);
335
+ }
336
+ // Receipt-of-receipts integrity requires BOTH an output URI and its
337
+ // content hash. Reject either one-sided pairing fail-closed: a hash
338
+ // without a URI is unanchored, and a URI without a hash is unverifiable.
339
+ if (child.outputSha256 !== null && child.outputUri === null) {
340
+ reasons.push(`phase-rollup-child-orphan-output-hash:${child.id}`);
341
+ }
342
+ if (child.outputUri !== null && child.outputSha256 === null) {
343
+ reasons.push(`phase-rollup-child-orphan-output-uri:${child.id}`);
344
+ }
345
+ // Per-child ROI accounting must be well-formed before it can be summed
346
+ // for the recomputed aggregate reconciliation below.
347
+ if (typeof child.tokens !== "number" || !Number.isFinite(child.tokens) || child.tokens < 0) {
348
+ reasons.push(`phase-rollup-child-bad-tokens:${child.id}`);
349
+ } else {
350
+ tokensFromChildren += child.tokens;
351
+ }
352
+ if (child.costTotal !== null) {
353
+ if (typeof child.costTotal !== "number" || !Number.isFinite(child.costTotal) || child.costTotal < 0) {
354
+ reasons.push(`phase-rollup-child-bad-cost:${child.id}`);
355
+ } else {
356
+ anyCost = true;
357
+ costFromChildren += child.costTotal;
358
+ }
359
+ }
360
+ if (child.clonedTokens !== null) {
361
+ if (typeof child.clonedTokens !== "number" || !Number.isFinite(child.clonedTokens) || child.clonedTokens < 0) {
362
+ reasons.push(`phase-rollup-child-bad-cloned-tokens:${child.id}`);
363
+ } else {
364
+ anyCloned = true;
365
+ clonedFromChildren += child.clonedTokens;
366
+ }
367
+ }
368
+ if (typeof child.lowRoi !== "boolean") {
369
+ reasons.push(`phase-rollup-child-bad-low-roi:${child.id}`);
370
+ } else if (child.lowRoi) {
371
+ lowRoiFromChildren.push(child.id);
372
+ }
373
+ }
374
+ const aggregate = e.aggregate;
375
+ if (!aggregate || typeof aggregate.childCount !== "number") {
376
+ reasons.push("phase-rollup-missing-aggregate");
377
+ return reasons;
378
+ }
379
+ if (aggregate.childCount !== e.children.length) reasons.push("phase-rollup-child-count-mismatch");
380
+ if (aggregate.completed !== completedFromChildren) reasons.push("phase-rollup-aggregate-completed-mismatch");
381
+ if (aggregate.failed !== failedFromChildren) reasons.push("phase-rollup-aggregate-failed-mismatch");
382
+ for (const field of ["totalTokens", "completed", "failed"] as const) {
383
+ const value = aggregate[field];
384
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
385
+ reasons.push(`phase-rollup-aggregate-bad-${field}`);
386
+ }
387
+ }
388
+ for (const field of ["totalCostTotal", "totalClonedTokens"] as const) {
389
+ const value = aggregate[field];
390
+ if (value !== null && (typeof value !== "number" || !Number.isFinite(value) || value < 0)) {
391
+ reasons.push(`phase-rollup-aggregate-bad-${field}`);
392
+ }
393
+ }
394
+ // Recompute the ROI aggregates from child evidence and fail closed on any
395
+ // self-reported total that does not reconcile. `null` is the canonical
396
+ // "no child reported this metric" value, mirroring the builder.
397
+ if (aggregate.totalTokens !== tokensFromChildren) {
398
+ reasons.push("phase-rollup-aggregate-tokens-mismatch");
399
+ }
400
+ if (!numbersReconcile(aggregate.totalCostTotal, anyCost ? costFromChildren : null)) {
401
+ reasons.push("phase-rollup-aggregate-cost-mismatch");
402
+ }
403
+ if (!numbersReconcile(aggregate.totalClonedTokens, anyCloned ? clonedFromChildren : null)) {
404
+ reasons.push("phase-rollup-aggregate-cloned-tokens-mismatch");
405
+ }
406
+ if (!Array.isArray(aggregate.lowRoiChildIds)) {
407
+ reasons.push("phase-rollup-aggregate-bad-lowRoiChildIds");
408
+ } else if (!sameIdSet(aggregate.lowRoiChildIds, lowRoiFromChildren)) {
409
+ reasons.push("phase-rollup-aggregate-low-roi-mismatch");
410
+ }
411
+ return reasons;
412
+ }
413
+
189
414
  function validateVanish(e: VanishEvidence): string[] {
190
415
  const reasons: string[] = [];
191
416
  if (!e || typeof e.gitDelta !== "string") return ["vanish-missing-evidence"];
@@ -230,6 +455,9 @@ function validateCompletion(e: CompletionEvidence): string[] {
230
455
  reasons.push("completion-missing-validation-receipts");
231
456
  }
232
457
  if (Array.isArray(e.blockers) && e.blockers.length > 0) reasons.push("completion-has-blockers");
458
+ // NOTE: evidence.finalLifecycle vs the lifecycle target is reconciled
459
+ // fail-closed at the ingest layer (`evidenceContradiction` ->
460
+ // `evidence-lifecycle-mismatch`), where the actual transition is gated.
233
461
  return reasons;
234
462
  }
235
463
 
@@ -36,6 +36,8 @@ export interface HarnessRpc {
36
36
  isLive?(): boolean;
37
37
  /** ISO timestamp of the last observed event frame, or null. */
38
38
  lastFrameAt?(): string | null;
39
+ /** Final assistant text from the live session (for review-verdict extraction); null when unavailable. */
40
+ getLastAssistantText?(): Promise<string | null>;
39
41
  }
40
42
 
41
43
  export interface AcceptanceResult {
@@ -240,6 +242,12 @@ export class GajaeCodeRpc implements HarnessRpc {
240
242
  };
241
243
  }
242
244
 
245
+ async getLastAssistantText(): Promise<string | null> {
246
+ const res = await this.#send({ type: "get_last_assistant_text" });
247
+ const data = (res.data ?? {}) as Record<string, unknown>;
248
+ return typeof data.text === "string" ? data.text : null;
249
+ }
250
+
243
251
  async sendPrompt(prompt: string): Promise<{ commandId: string; ack: boolean }> {
244
252
  const id = randomUUID();
245
253
  const ackPromise = new Promise<Record<string, unknown>>((resolve, reject) => {
@@ -8,6 +8,7 @@
8
8
  import type { HarnessLifecycle, NextAllowedAction, PrimitiveResponse, SessionState, SessionStateView } from "./types";
9
9
 
10
10
  const TERMINAL_LIFECYCLES: ReadonlySet<HarnessLifecycle> = new Set(["completed", "retired"]);
11
+ const SUBMIT_READY_LIFECYCLES: ReadonlySet<HarnessLifecycle> = new Set(["started", "observing"]);
11
12
 
12
13
  const TRANSITIONS: Record<HarnessLifecycle, readonly HarnessLifecycle[]> = {
13
14
  new: ["started", "blocked", "retired"],
@@ -37,11 +38,32 @@ export function assertTransition(from: HarnessLifecycle, to: HarnessLifecycle):
37
38
  }
38
39
  }
39
40
 
41
+ export interface NextAllowedActionsOptions {
42
+ /** Additional live-owner/RPC readiness gate for submit, e.g. rpc-not-idle. */
43
+ submitUnavailableReason?: string | null;
44
+ }
45
+
46
+ export function submitUnavailableReason(
47
+ lifecycle: HarnessLifecycle,
48
+ ownerLive: boolean,
49
+ gateReason: string | null = null,
50
+ ): string | null {
51
+ if (isTerminal(lifecycle)) return `lifecycle-terminal:${lifecycle}`;
52
+ if (lifecycle === "blocked") return "lifecycle-blocked";
53
+ if (!SUBMIT_READY_LIFECYCLES.has(lifecycle)) return `lifecycle-not-idle:${lifecycle}`;
54
+ if (!ownerLive) return "owner-not-live";
55
+ return gateReason;
56
+ }
57
+
40
58
  /**
41
59
  * Derive the permitted next actions for a session given its lifecycle and whether
42
60
  * a live owner currently holds the lease.
43
61
  */
44
- export function nextAllowedActions(lifecycle: HarnessLifecycle, ownerLive: boolean): NextAllowedAction[] {
62
+ export function nextAllowedActions(
63
+ lifecycle: HarnessLifecycle,
64
+ ownerLive: boolean,
65
+ options: NextAllowedActionsOptions = {},
66
+ ): NextAllowedAction[] {
45
67
  const terminal = isTerminal(lifecycle);
46
68
  const actions: NextAllowedAction[] = [];
47
69
  const add = (verb: NextAllowedAction["verb"], available: boolean, reason?: string): void => {
@@ -57,11 +79,10 @@ export function nextAllowedActions(lifecycle: HarnessLifecycle, ownerLive: boole
57
79
  // `start` creates a new session; never re-applicable to an existing record.
58
80
  add("start", false, "session-already-exists");
59
81
 
60
- // `submit` is owner-routed: it requires a live owner and a non-blocked, non-terminal lifecycle.
61
- if (terminal) add("submit", false, `lifecycle-terminal:${lifecycle}`);
62
- else if (lifecycle === "blocked") add("submit", false, "lifecycle-blocked");
63
- else if (!ownerLive) add("submit", false, "owner-not-live");
64
- else add("submit", true);
82
+ // `submit` is owner-routed: it requires a live owner, a submit-ready lifecycle,
83
+ // and (for owner-observed responses) an idle/routable RPC backend.
84
+ const submitReason = submitUnavailableReason(lifecycle, ownerLive, options.submitUnavailableReason ?? null);
85
+ add("submit", submitReason === null, submitReason ?? undefined);
65
86
 
66
87
  // `recover` handles a dead/failed owner, so it is available without a live owner.
67
88
  add("recover", !terminal, terminal ? `lifecycle-terminal:${lifecycle}` : undefined);
@@ -18,6 +18,8 @@ import * as fsSync from "node:fs";
18
18
  import * as fs from "node:fs/promises";
19
19
  import * as os from "node:os";
20
20
  import * as path from "node:path";
21
+ import { appendReceiptToConfiguredSpool } from "./receipt-spool";
22
+ import type { ReceiptEnvelope } from "./receipts";
21
23
  import type { EventEnvelope, ReceiptFamily, SessionState } from "./types";
22
24
 
23
25
  interface HarnessRootRegistryEntry {
@@ -227,6 +229,26 @@ async function readJson<T>(file: string): Promise<T | null> {
227
229
  throw error;
228
230
  }
229
231
  }
232
+ function isReceiptEnvelope(value: unknown): value is ReceiptEnvelope<unknown> {
233
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
234
+ const envelope = value as Record<string, unknown>;
235
+ return (
236
+ typeof envelope.receiptId === "string" &&
237
+ typeof envelope.schemaVersion === "number" &&
238
+ typeof envelope.sessionId === "string" &&
239
+ typeof envelope.family === "string" &&
240
+ typeof envelope.valid === "boolean" &&
241
+ typeof envelope.createdAt === "string" &&
242
+ typeof envelope.source === "string" &&
243
+ envelope.subject !== null &&
244
+ typeof envelope.subject === "object" &&
245
+ envelope.evidence !== null &&
246
+ typeof envelope.evidence === "object" &&
247
+ envelope.artifactHashes !== null &&
248
+ typeof envelope.artifactHashes === "object" &&
249
+ typeof envelope.sha256 === "string"
250
+ );
251
+ }
230
252
 
231
253
  export async function readSessionState(root: string, sessionId: string): Promise<SessionState | null> {
232
254
  return readJson<SessionState>(sessionPaths(root, sessionId).state);
@@ -372,6 +394,7 @@ export async function writeReceiptImmutable(
372
394
  path: file,
373
395
  };
374
396
  await fs.appendFile(paths.receiptsIndex, `${JSON.stringify(entry)}\n`, "utf8");
397
+ if (isReceiptEnvelope(value)) await appendReceiptToConfiguredSpool(value);
375
398
  return entry;
376
399
  }
377
400
 
@@ -29,6 +29,33 @@ export function isReviewVerdict(value: unknown): value is ReviewVerdict {
29
29
  return typeof value === "string" && (REVIEW_VERDICTS as readonly string[]).includes(value);
30
30
  }
31
31
 
32
+ /**
33
+ * Alias verdict tokens accepted from free-form assistant text, mapped to their canonical verdict.
34
+ * `MERGE_READY` is treated as `APPROVE_MERGE_READY`.
35
+ */
36
+ const VERDICT_ALIASES: Readonly<Record<string, ReviewVerdict>> = {
37
+ MERGE_READY: "APPROVE_MERGE_READY",
38
+ };
39
+
40
+ /**
41
+ * Extract a single closed-vocabulary review verdict from free-form assistant text.
42
+ *
43
+ * Scans for canonical verdict tokens (and accepted aliases) as whole words and returns the
44
+ * LAST occurrence — the agent's final stated decision wins over any earlier mention. Returns
45
+ * null when no allowed token is present, so the finalizer fails closed on a missing verdict.
46
+ */
47
+ export function extractReviewVerdict(text: string | null | undefined): ReviewVerdict | null {
48
+ if (typeof text !== "string" || text.length === 0) return null;
49
+ const tokens = [...REVIEW_VERDICTS, ...Object.keys(VERDICT_ALIASES)];
50
+ const pattern = new RegExp(`\\b(${tokens.join("|")})\\b`, "g");
51
+ let last: ReviewVerdict | null = null;
52
+ for (const match of text.matchAll(pattern)) {
53
+ const token = match[1];
54
+ last = VERDICT_ALIASES[token] ?? (token as ReviewVerdict);
55
+ }
56
+ return last;
57
+ }
58
+
32
59
  /** Lifecycle states of an operated session. */
33
60
  export type HarnessLifecycle =
34
61
  | "new"
@@ -68,7 +95,8 @@ export type ReceiptFamily =
68
95
  | "validation"
69
96
  | "completion"
70
97
  | "review-verdict"
71
- | "review-failure";
98
+ | "review-failure"
99
+ | "phase-rollup";
72
100
 
73
101
  /** The CLI verbs / primitives exposed by `gjc harness <verb>`. */
74
102
  export type HarnessVerb =
@@ -182,6 +210,10 @@ export interface Observation {
182
210
  rpcLive?: boolean;
183
211
  /** ISO timestamp of the most recent RPC frame the owner observed, if any. */
184
212
  rpcLastFrameAt?: string | null;
213
+ /** True only when owner/rpc/lifecycle gates indicate a prompt can be submitted now. */
214
+ readyForSubmit?: boolean;
215
+ /** Present when readyForSubmit is false; mirrors submit's nextAllowedActions reason. */
216
+ submitUnavailableReason?: string | null;
185
217
  }
186
218
 
187
219
  /** Input to the deterministic recovery classifier. */
@@ -295,12 +295,12 @@ export function summarizeMentalModel(model: MentalModelSummary): string {
295
295
  * snapshot only; the diff is computed locally for display purposes.
296
296
  *
297
297
  * This is intentionally minimal — for "what changed" at a glance, not for a
298
- * full structural diff. Each side is capped at `MAX_LCS_LINES` lines BEFORE
299
- * the O(n*m) LCS table is built so a long curated model can never hang the
300
- * TUI; output is then capped at `maxLines` so the rendered diff stays
301
- * readable. The cap is signalled inline.
298
+ * full structural diff. Each side is capped at `MAX_LCS_LINES` lines before
299
+ * the Hunt-Szymanski LCS pass so a long curated model can never hang the TUI;
300
+ * output is then capped at `maxLines` so the rendered diff stays readable. The
301
+ * cap is signalled inline.
302
302
  */
303
- /** Hard cap on input line count per side before LCS. Keeps the O(n*m) table tractable. */
303
+ /** Hard cap on input line count per side before LCS. Keeps worst-case repeated-line matching bounded. */
304
304
  export const MAX_LCS_LINES = 1_000;
305
305
 
306
306
  export function diffMentalModelContent(previous: string | null, current: string, maxLines = 200): string {
@@ -346,24 +346,25 @@ export function diffMentalModelContent(previous: string | null, current: string,
346
346
  }
347
347
 
348
348
  function longestCommonSubsequence(a: string[], b: string[]): string[] {
349
- const n = a.length;
350
- const m = b.length;
351
- if (n === 0 || m === 0) return [];
352
- const table: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
353
- for (let i = 0; i < n; i++) {
354
- for (let j = 0; j < m; j++) {
355
- table[i + 1][j + 1] = a[i] === b[j] ? table[i][j] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
349
+ return longestCommonSubsequenceDense(a, b);
350
+ }
351
+
352
+ function longestCommonSubsequenceDense(a: string[], b: string[]): string[] {
353
+ const table: number[][] = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
354
+ for (let i = 0; i < a.length; i++) {
355
+ for (let j = 0; j < b.length; j++) {
356
+ table[i + 1]![j + 1] = a[i] === b[j] ? table[i]![j]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!);
356
357
  }
357
358
  }
358
359
  const out: string[] = [];
359
- let i = n;
360
- let j = m;
360
+ let i = a.length;
361
+ let j = b.length;
361
362
  while (i > 0 && j > 0) {
362
363
  if (a[i - 1] === b[j - 1]) {
363
- out.push(a[i - 1]);
364
+ out.push(a[i - 1]!);
364
365
  i--;
365
366
  j--;
366
- } else if (table[i - 1][j] >= table[i][j - 1]) {
367
+ } else if (table[i - 1]![j]! >= table[i]![j - 1]!) {
367
368
  i--;
368
369
  } else {
369
370
  j--;