@gajae-code/coding-agent 0.4.5 → 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 (87) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/types/commands/harness.d.ts +3 -0
  3. package/dist/types/config/model-profile-activation.d.ts +11 -2
  4. package/dist/types/config/model-profiles.d.ts +7 -0
  5. package/dist/types/config/model-registry.d.ts +3 -0
  6. package/dist/types/config/model-resolver.d.ts +2 -0
  7. package/dist/types/config/models-config-schema.d.ts +30 -0
  8. package/dist/types/config/settings-schema.d.ts +4 -3
  9. package/dist/types/gjc-runtime/team-runtime.d.ts +0 -1
  10. package/dist/types/gjc-runtime/tmux-common.d.ts +3 -0
  11. package/dist/types/harness-control-plane/owner.d.ts +1 -1
  12. package/dist/types/harness-control-plane/receipt-spool.d.ts +19 -0
  13. package/dist/types/harness-control-plane/state-machine.d.ts +6 -1
  14. package/dist/types/harness-control-plane/types.d.ts +4 -0
  15. package/dist/types/hindsight/mental-models.d.ts +5 -5
  16. package/dist/types/modes/components/model-selector.d.ts +1 -12
  17. package/dist/types/modes/rpc/rpc-client.d.ts +2 -2
  18. package/dist/types/modes/rpc/rpc-types.d.ts +4 -1
  19. package/dist/types/sdk.d.ts +5 -0
  20. package/dist/types/session/agent-session.d.ts +2 -0
  21. package/dist/types/session/blob-store.d.ts +20 -1
  22. package/dist/types/session/session-manager.d.ts +24 -6
  23. package/dist/types/session/streaming-output.d.ts +3 -2
  24. package/dist/types/session/tool-choice-queue.d.ts +6 -0
  25. package/dist/types/task/receipt.d.ts +1 -0
  26. package/dist/types/task/types.d.ts +7 -0
  27. package/dist/types/thinking-metadata.d.ts +16 -0
  28. package/dist/types/thinking.d.ts +3 -12
  29. package/dist/types/tools/index.d.ts +2 -0
  30. package/dist/types/tools/resolve.d.ts +0 -10
  31. package/dist/types/utils/tool-choice.d.ts +14 -1
  32. package/package.json +7 -7
  33. package/src/cli.ts +8 -4
  34. package/src/commands/harness.ts +36 -2
  35. package/src/commands/launch.ts +2 -2
  36. package/src/commands/session.ts +3 -1
  37. package/src/config/model-profile-activation.ts +15 -3
  38. package/src/config/model-profiles.ts +255 -56
  39. package/src/config/model-resolver.ts +9 -6
  40. package/src/config/models-config-schema.ts +1 -0
  41. package/src/config/settings-schema.ts +6 -3
  42. package/src/coordinator-mcp/server.ts +54 -23
  43. package/src/cursor.ts +16 -2
  44. package/src/defaults/gjc/skills/team/SKILL.md +3 -2
  45. package/src/defaults/gjc/skills/ultragoal/SKILL.md +8 -2
  46. package/src/export/html/index.ts +13 -9
  47. package/src/gjc-runtime/team-runtime.ts +33 -7
  48. package/src/gjc-runtime/tmux-common.ts +15 -0
  49. package/src/gjc-runtime/tmux-sessions.ts +19 -11
  50. package/src/gjc-runtime/ultragoal-runtime.ts +505 -41
  51. package/src/gjc-runtime/workflow-manifest.generated.json +27 -1
  52. package/src/gjc-runtime/workflow-manifest.ts +16 -1
  53. package/src/harness-control-plane/owner.ts +78 -27
  54. package/src/harness-control-plane/receipt-spool.ts +128 -0
  55. package/src/harness-control-plane/state-machine.ts +27 -6
  56. package/src/harness-control-plane/storage.ts +23 -0
  57. package/src/harness-control-plane/types.ts +4 -0
  58. package/src/hindsight/mental-models.ts +17 -16
  59. package/src/internal-urls/docs-index.generated.ts +2 -2
  60. package/src/modes/components/assistant-message.ts +26 -14
  61. package/src/modes/components/diff.ts +97 -0
  62. package/src/modes/components/model-selector.ts +353 -181
  63. package/src/modes/components/tool-execution.ts +30 -13
  64. package/src/modes/controllers/selector-controller.ts +33 -42
  65. package/src/modes/rpc/rpc-client.ts +3 -2
  66. package/src/modes/rpc/rpc-mode.ts +44 -14
  67. package/src/modes/rpc/rpc-types.ts +5 -2
  68. package/src/modes/shared/agent-wire/command-dispatch.ts +10 -5
  69. package/src/modes/shared/agent-wire/command-validation.ts +11 -0
  70. package/src/sdk.ts +29 -2
  71. package/src/secrets/obfuscator.ts +102 -27
  72. package/src/session/agent-session.ts +105 -20
  73. package/src/session/blob-store.ts +89 -3
  74. package/src/session/session-manager.ts +309 -58
  75. package/src/session/streaming-output.ts +185 -122
  76. package/src/session/tool-choice-queue.ts +23 -0
  77. package/src/task/executor.ts +69 -6
  78. package/src/task/receipt.ts +5 -0
  79. package/src/task/render.ts +21 -1
  80. package/src/task/types.ts +8 -0
  81. package/src/thinking-metadata.ts +51 -0
  82. package/src/thinking.ts +26 -46
  83. package/src/tools/bash.ts +1 -1
  84. package/src/tools/index.ts +2 -0
  85. package/src/tools/resolve.ts +93 -18
  86. package/src/utils/edit-mode.ts +1 -1
  87. package/src/utils/tool-choice.ts +45 -16
@@ -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
 
@@ -543,10 +578,21 @@ export class RuntimeOwner {
543
578
  const prompt = typeof input.prompt === "string" ? input.prompt : "";
544
579
  const state = await this.#loadState();
545
580
  if (!prompt) {
546
- 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
+ );
547
587
  }
548
- if (state.lifecycle === "blocked") {
549
- 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
+ );
550
596
  }
551
597
  const result = await singleFlightAccept(this.#opts.rpc, prompt, this.#opts.acceptanceTimeoutMs);
552
598
  if (result.accepted) {
@@ -560,11 +606,12 @@ export class RuntimeOwner {
560
606
  } else {
561
607
  await this.#emit("warn", "prompt_not_accepted", { reason: result.reason });
562
608
  }
609
+ const submitGateReason = result.accepted ? null : result.reason === "pre-state-not-idle" ? "rpc-not-idle" : null;
563
610
  return this.#response(
564
611
  state,
565
612
  {
566
613
  accepted: result.accepted,
567
- submitted: true,
614
+ submitted: result.commandId !== null,
568
615
  reason: result.reason,
569
616
  commandId: result.commandId,
570
617
  preSubmitCursor: result.preSubmitCursor,
@@ -572,12 +619,16 @@ export class RuntimeOwner {
572
619
  acceptanceEvidence: result.preSubmitState,
573
620
  },
574
621
  result.accepted,
622
+ submitGateReason,
575
623
  );
576
624
  }
577
625
 
578
626
  async #observe(): Promise<PrimitiveResponse> {
579
627
  const state = await this.#loadState();
580
- 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);
581
632
  }
582
633
 
583
634
  async #retire(): Promise<PrimitiveResponse> {
@@ -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
+ }
@@ -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
 
@@ -210,6 +210,10 @@ export interface Observation {
210
210
  rpcLive?: boolean;
211
211
  /** ISO timestamp of the most recent RPC frame the owner observed, if any. */
212
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;
213
217
  }
214
218
 
215
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--;