@gajae-code/coding-agent 0.4.5 → 0.5.1

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 (185) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/types/async/job-manager.d.ts +26 -0
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/list-models.d.ts +6 -0
  5. package/dist/types/commands/gc.d.ts +26 -0
  6. package/dist/types/commands/harness.d.ts +3 -0
  7. package/dist/types/config/file-lock-gc.d.ts +5 -0
  8. package/dist/types/config/file-lock.d.ts +7 -0
  9. package/dist/types/config/model-profile-activation.d.ts +11 -2
  10. package/dist/types/config/model-profiles.d.ts +7 -0
  11. package/dist/types/config/model-registry.d.ts +3 -0
  12. package/dist/types/config/model-resolver.d.ts +2 -0
  13. package/dist/types/config/models-config-schema.d.ts +30 -0
  14. package/dist/types/config/settings-schema.d.ts +4 -3
  15. package/dist/types/coordinator/contract.d.ts +1 -1
  16. package/dist/types/defaults/gjc/extensions/grok-build/index.d.ts +1 -0
  17. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/index.d.ts +1 -0
  18. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +25 -0
  19. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.d.ts +27 -0
  20. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.d.ts +8 -0
  21. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.d.ts +5 -0
  22. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +10 -0
  23. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.d.ts +2 -0
  24. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.d.ts +2 -0
  25. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.d.ts +38 -0
  26. package/dist/types/defaults/gjc-grok-cli.d.ts +5 -0
  27. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  28. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +35 -0
  29. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +103 -0
  30. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -0
  31. package/dist/types/gjc-runtime/deep-interview-state.d.ts +112 -0
  32. package/dist/types/gjc-runtime/gc-render.d.ts +6 -0
  33. package/dist/types/gjc-runtime/gc-runtime.d.ts +134 -0
  34. package/dist/types/gjc-runtime/ledger-event-renderer.d.ts +68 -0
  35. package/dist/types/gjc-runtime/team-gc.d.ts +7 -0
  36. package/dist/types/gjc-runtime/team-runtime.d.ts +5 -1
  37. package/dist/types/gjc-runtime/tmux-common.d.ts +14 -0
  38. package/dist/types/gjc-runtime/tmux-gc.d.ts +7 -0
  39. package/dist/types/gjc-runtime/tmux-sessions.d.ts +13 -0
  40. package/dist/types/harness-control-plane/gc-adapter.d.ts +3 -0
  41. package/dist/types/harness-control-plane/owner.d.ts +8 -1
  42. package/dist/types/harness-control-plane/receipt-spool.d.ts +19 -0
  43. package/dist/types/harness-control-plane/state-machine.d.ts +6 -1
  44. package/dist/types/harness-control-plane/storage.d.ts +20 -0
  45. package/dist/types/harness-control-plane/types.d.ts +4 -0
  46. package/dist/types/hindsight/mental-models.d.ts +5 -5
  47. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  48. package/dist/types/modes/components/model-selector.d.ts +1 -12
  49. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  50. package/dist/types/modes/rpc/rpc-client.d.ts +2 -2
  51. package/dist/types/modes/rpc/rpc-mode.d.ts +16 -1
  52. package/dist/types/modes/rpc/rpc-types.d.ts +4 -1
  53. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +13 -0
  54. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +25 -0
  55. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +2 -0
  56. package/dist/types/sdk.d.ts +5 -0
  57. package/dist/types/session/agent-session.d.ts +3 -1
  58. package/dist/types/session/blob-store.d.ts +59 -4
  59. package/dist/types/session/session-manager.d.ts +24 -6
  60. package/dist/types/session/streaming-output.d.ts +3 -2
  61. package/dist/types/session/tool-choice-queue.d.ts +6 -0
  62. package/dist/types/skill-state/workflow-hud.d.ts +14 -0
  63. package/dist/types/task/receipt.d.ts +1 -0
  64. package/dist/types/task/types.d.ts +7 -0
  65. package/dist/types/thinking-metadata.d.ts +16 -0
  66. package/dist/types/thinking.d.ts +3 -12
  67. package/dist/types/tools/ask.d.ts +15 -1
  68. package/dist/types/tools/index.d.ts +2 -0
  69. package/dist/types/tools/resolve.d.ts +0 -10
  70. package/dist/types/tools/subagent.d.ts +6 -0
  71. package/dist/types/utils/tool-choice.d.ts +14 -1
  72. package/package.json +7 -7
  73. package/src/async/job-manager.ts +52 -0
  74. package/src/cli/args.ts +3 -0
  75. package/src/cli/auth-broker-cli.ts +1 -0
  76. package/src/cli/list-models.ts +13 -1
  77. package/src/cli.ts +9 -4
  78. package/src/commands/gc.ts +22 -0
  79. package/src/commands/harness.ts +43 -5
  80. package/src/commands/launch.ts +2 -2
  81. package/src/commands/session.ts +3 -1
  82. package/src/config/file-lock-gc.ts +181 -0
  83. package/src/config/file-lock.ts +14 -0
  84. package/src/config/model-profile-activation.ts +15 -3
  85. package/src/config/model-profiles.ts +264 -56
  86. package/src/config/model-resolver.ts +9 -6
  87. package/src/config/models-config-schema.ts +1 -0
  88. package/src/config/settings-schema.ts +6 -3
  89. package/src/coordinator/contract.ts +1 -0
  90. package/src/coordinator-mcp/server.ts +513 -26
  91. package/src/cursor.ts +16 -2
  92. package/src/defaults/gjc/agent.models.grok-cli.yml +36 -0
  93. package/src/defaults/gjc/extensions/grok-build/index.ts +1 -0
  94. package/src/defaults/gjc/extensions/grok-build/package.json +7 -0
  95. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +39 -0
  96. package/src/defaults/gjc/extensions/grok-cli-vendor/package.json +8 -0
  97. package/src/defaults/gjc/extensions/grok-cli-vendor/src/index.ts +1 -0
  98. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +155 -0
  99. package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +361 -0
  100. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.ts +57 -0
  101. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +99 -0
  102. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +50 -0
  103. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.ts +56 -0
  104. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +36 -0
  105. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.ts +44 -0
  106. package/src/defaults/gjc/skills/deep-interview/SKILL.md +131 -113
  107. package/src/defaults/gjc/skills/deep-interview/lateral-review-panel.md +49 -0
  108. package/src/defaults/gjc/skills/team/SKILL.md +3 -2
  109. package/src/defaults/gjc/skills/ultragoal/SKILL.md +8 -2
  110. package/src/defaults/gjc-defaults.ts +7 -0
  111. package/src/defaults/gjc-grok-cli.ts +22 -0
  112. package/src/export/html/index.ts +13 -9
  113. package/src/extensibility/extensions/index.ts +1 -0
  114. package/src/extensibility/extensions/prefix-command-bridge.ts +128 -0
  115. package/src/gjc-runtime/deep-interview-recorder.ts +417 -0
  116. package/src/gjc-runtime/deep-interview-runtime.ts +18 -26
  117. package/src/gjc-runtime/deep-interview-state.ts +324 -0
  118. package/src/gjc-runtime/gc-render.ts +70 -0
  119. package/src/gjc-runtime/gc-runtime.ts +403 -0
  120. package/src/gjc-runtime/ledger-event-renderer.ts +164 -0
  121. package/src/gjc-runtime/ralplan-runtime.ts +58 -7
  122. package/src/gjc-runtime/state-renderer.ts +12 -3
  123. package/src/gjc-runtime/state-runtime.ts +46 -29
  124. package/src/gjc-runtime/team-gc.ts +49 -0
  125. package/src/gjc-runtime/team-runtime.ts +211 -8
  126. package/src/gjc-runtime/tmux-common.ts +29 -0
  127. package/src/gjc-runtime/tmux-gc.ts +176 -0
  128. package/src/gjc-runtime/tmux-sessions.ts +68 -12
  129. package/src/gjc-runtime/ultragoal-runtime.ts +517 -41
  130. package/src/gjc-runtime/workflow-manifest.generated.json +27 -1
  131. package/src/gjc-runtime/workflow-manifest.ts +16 -1
  132. package/src/harness-control-plane/gc-adapter.ts +184 -0
  133. package/src/harness-control-plane/owner.ts +89 -27
  134. package/src/harness-control-plane/receipt-spool.ts +128 -0
  135. package/src/harness-control-plane/state-machine.ts +27 -6
  136. package/src/harness-control-plane/storage.ts +93 -0
  137. package/src/harness-control-plane/types.ts +4 -0
  138. package/src/hindsight/mental-models.ts +17 -16
  139. package/src/internal-urls/docs-index.generated.ts +14 -8
  140. package/src/main.ts +7 -2
  141. package/src/modes/components/assistant-message.ts +26 -14
  142. package/src/modes/components/diff.ts +97 -0
  143. package/src/modes/components/hook-selector.ts +19 -0
  144. package/src/modes/components/model-selector.ts +370 -181
  145. package/src/modes/components/status-line/segments.ts +1 -1
  146. package/src/modes/components/tool-execution.ts +30 -13
  147. package/src/modes/controllers/command-controller.ts +25 -6
  148. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  149. package/src/modes/controllers/selector-controller.ts +34 -42
  150. package/src/modes/rpc/rpc-client.ts +3 -2
  151. package/src/modes/rpc/rpc-mode.ts +187 -39
  152. package/src/modes/rpc/rpc-types.ts +5 -2
  153. package/src/modes/shared/agent-wire/command-dispatch.ts +279 -257
  154. package/src/modes/shared/agent-wire/command-validation.ts +11 -0
  155. package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
  156. package/src/modes/shared/agent-wire/session-registry.ts +109 -0
  157. package/src/modes/shared/agent-wire/unattended-action-policy.ts +24 -0
  158. package/src/modes/shared/agent-wire/unattended-run-controller.ts +23 -3
  159. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  160. package/src/sdk.ts +46 -5
  161. package/src/secrets/obfuscator.ts +102 -27
  162. package/src/session/agent-session.ts +179 -25
  163. package/src/session/blob-store.ts +148 -6
  164. package/src/session/session-manager.ts +311 -60
  165. package/src/session/streaming-output.ts +185 -122
  166. package/src/session/tool-choice-queue.ts +23 -0
  167. package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
  168. package/src/skill-state/workflow-hud.ts +106 -10
  169. package/src/slash-commands/builtin-registry.ts +3 -2
  170. package/src/task/executor.ts +78 -6
  171. package/src/task/receipt.ts +5 -0
  172. package/src/task/render.ts +21 -1
  173. package/src/task/types.ts +8 -0
  174. package/src/thinking-metadata.ts +51 -0
  175. package/src/thinking.ts +26 -46
  176. package/src/tools/ask.ts +56 -1
  177. package/src/tools/bash.ts +1 -1
  178. package/src/tools/index.ts +2 -0
  179. package/src/tools/job.ts +3 -2
  180. package/src/tools/monitor.ts +36 -1
  181. package/src/tools/resolve.ts +93 -18
  182. package/src/tools/subagent-render.ts +9 -0
  183. package/src/tools/subagent.ts +26 -2
  184. package/src/utils/edit-mode.ts +1 -1
  185. package/src/utils/tool-choice.ts +45 -16
@@ -3,12 +3,15 @@ import * as fs from "node:fs/promises";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { syncSkillActiveState } from "../skill-state/active-state";
6
- import { buildDeepInterviewHudSummary } from "../skill-state/workflow-hud";
6
+ import { deriveDeepInterviewHud } from "../skill-state/workflow-hud";
7
7
  import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
8
+ import { normalizeDeepInterviewEnvelope } from "./deep-interview-state";
8
9
  import { runNativeRalplanCommand } from "./ralplan-runtime";
9
10
  import { runNativeStateCommand } from "./state-runtime";
10
11
  import { appendJsonl, readExistingStateForMutation, writeArtifact, writeWorkflowEnvelopeAtomic } from "./state-writer";
11
12
 
13
+ export * from "./deep-interview-recorder";
14
+
12
15
  /**
13
16
  * Native implementation of `gjc deep-interview`.
14
17
  *
@@ -92,7 +95,7 @@ function stateDirFor(cwd: string, sessionId: string | undefined): string {
92
95
  : path.join(cwd, ".gjc", "state");
93
96
  }
94
97
 
95
- function deepInterviewStatePath(cwd: string, sessionId: string | undefined): string {
98
+ export function deepInterviewStatePath(cwd: string, sessionId: string | undefined): string {
96
99
  return path.join(stateDirFor(cwd, sessionId), "deep-interview-state.json");
97
100
  }
98
101
 
@@ -413,7 +416,7 @@ export async function persistDeepInterviewSpec(
413
416
  { cwd, audit: { category: "ledger", verb: "append", owner: "gjc-runtime", skill: "deep-interview" } },
414
417
  );
415
418
 
416
- const payload: Record<string, unknown> = {
419
+ const payload = normalizeDeepInterviewEnvelope({
417
420
  ...existing,
418
421
  active: true,
419
422
  current_phase: "handoff",
@@ -425,7 +428,7 @@ export async function persistDeepInterviewSpec(
425
428
  spec_stage: resolved.stage,
426
429
  spec_persisted_at: createdAt,
427
430
  updated_at: createdAt,
428
- };
431
+ }) as Record<string, unknown>;
429
432
  if (resolved.sessionId) payload.session_id = resolved.sessionId;
430
433
  await writeWorkflowEnvelopeAtomic(statePath, payload, {
431
434
  cwd,
@@ -448,6 +451,7 @@ export async function persistDeepInterviewSpec(
448
451
  await syncDeepInterviewHud({
449
452
  cwd,
450
453
  sessionId: resolved.sessionId,
454
+ payload,
451
455
  phase: "handoff",
452
456
  specStatus: "persisted",
453
457
  });
@@ -476,6 +480,7 @@ async function seedDeepInterviewState(cwd: string, resolved: ResolvedDeepIntervi
476
480
  state: {
477
481
  initial_idea: resolved.idea,
478
482
  rounds: [],
483
+ established_facts: [],
479
484
  current_ambiguity: 1.0,
480
485
  threshold: resolved.threshold,
481
486
  threshold_source: resolved.thresholdSource,
@@ -499,34 +504,29 @@ async function seedDeepInterviewState(cwd: string, resolved: ResolvedDeepIntervi
499
504
  },
500
505
  audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "deep-interview" },
501
506
  });
507
+ await syncDeepInterviewHud({ cwd, sessionId: resolved.sessionId, payload, phase: "interviewing" });
502
508
  return statePath;
503
509
  }
504
510
 
505
511
  async function syncDeepInterviewHud(options: {
506
512
  cwd: string;
507
513
  sessionId?: string;
508
- phase: string;
509
- ambiguity?: number;
510
- threshold?: number;
511
- roundCount?: number;
514
+ payload: Record<string, unknown>;
515
+ phase?: string;
512
516
  specStatus?: string;
513
517
  }): Promise<void> {
514
518
  try {
519
+ const phase =
520
+ options.phase ??
521
+ (typeof options.payload.current_phase === "string" ? options.payload.current_phase : "interviewing");
515
522
  await syncSkillActiveState({
516
523
  cwd: options.cwd,
517
524
  skill: "deep-interview",
518
- active: options.phase !== "complete",
519
- phase: options.phase,
525
+ active: phase !== "complete",
526
+ phase,
520
527
  sessionId: options.sessionId,
521
528
  source: "gjc-deep-interview-native",
522
- hud: buildDeepInterviewHudSummary({
523
- phase: options.phase,
524
- ambiguity: options.ambiguity,
525
- threshold: options.threshold,
526
- roundCount: options.roundCount,
527
- specStatus: options.specStatus,
528
- updatedAt: new Date().toISOString(),
529
- }),
529
+ hud: deriveDeepInterviewHud(options.payload, { phase, specStatus: options.specStatus }),
530
530
  });
531
531
  } catch {
532
532
  // HUD sync is best-effort and must not change command semantics.
@@ -611,14 +611,6 @@ export async function runNativeDeepInterviewCommand(
611
611
  );
612
612
  }
613
613
  const statePath = await seedDeepInterviewState(cwd, resolved);
614
- await syncDeepInterviewHud({
615
- cwd,
616
- sessionId: resolved.sessionId,
617
- phase: "interviewing",
618
- ambiguity: 1,
619
- threshold: resolved.threshold,
620
- roundCount: 0,
621
- });
622
614
 
623
615
  const summary = {
624
616
  skill: "deep-interview",
@@ -0,0 +1,324 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /**
4
+ * Pure, dependency-free foundation for deep-interview state shape.
5
+ *
6
+ * Ownership boundary (per the approved consensus plan): this leaf module owns the
7
+ * canonical persisted shape (interview data nested under `state`), durable round
8
+ * identity/hashing, lossless legacy normalization, and the deep-interview-specific
9
+ * envelope/round merge used by every writer (`deep-interview-recorder`,
10
+ * `state-runtime` write/reconcile, seed, and handoff). It MUST NOT import the
11
+ * active-state, state-writer, CLI runtime, or filesystem so it stays cycle-free and
12
+ * trivially testable.
13
+ */
14
+
15
+ // =============================================================================
16
+ // Domain types
17
+ // =============================================================================
18
+
19
+ export type DeepInterviewRoundLifecycle = "answered" | "pending_scoring" | "scored";
20
+
21
+ export type DeepInterviewTriggerKind = "A" | "B" | "C" | "D";
22
+
23
+ /** `active` triggers must satisfy the bidirectional invariant; disputed/unresolved are exempt with rationale. */
24
+ export type DeepInterviewTriggerStatus = "active" | "disputed" | "unresolved";
25
+
26
+ export interface DeepInterviewEstablishedFact {
27
+ id: string;
28
+ statement: string;
29
+ round: number;
30
+ component?: string;
31
+ dimension?: string;
32
+ evidence?: string;
33
+ disputed: boolean;
34
+ }
35
+
36
+ export interface DeepInterviewTriggerMetadata {
37
+ kind: DeepInterviewTriggerKind;
38
+ name: string;
39
+ status: DeepInterviewTriggerStatus;
40
+ component: string;
41
+ dimension: string;
42
+ priorDimensionScore?: number;
43
+ newDimensionScore?: number;
44
+ priorAmbiguity?: number;
45
+ newAmbiguity?: number;
46
+ evidence?: string;
47
+ contradictedFactId?: string;
48
+ /** Required when status is `disputed` or `unresolved` to exempt the invariant. */
49
+ rationale?: string;
50
+ }
51
+
52
+ export interface DeepInterviewRoundRecord {
53
+ round_key: string;
54
+ round_id?: string;
55
+ round: number;
56
+ question_id?: string;
57
+ question_text?: string;
58
+ question_hash: string;
59
+ answer_hash: string;
60
+ selected_options?: string[];
61
+ custom_input?: string;
62
+ component?: string;
63
+ dimension?: string;
64
+ ambiguity_at_ask?: number;
65
+ lifecycle: DeepInterviewRoundLifecycle;
66
+ answered_at: string;
67
+ scored_at?: string;
68
+ scores?: Record<string, number>;
69
+ ambiguity?: number;
70
+ triggers?: DeepInterviewTriggerMetadata[];
71
+ }
72
+
73
+ export interface DeepInterviewStateEnvelope {
74
+ threshold?: number;
75
+ threshold_source?: string;
76
+ state?: Record<string, unknown>;
77
+ [key: string]: unknown;
78
+ }
79
+
80
+ // =============================================================================
81
+ // Pure helpers: identity + hashing
82
+ // =============================================================================
83
+
84
+ export function hashContent(value: string): string {
85
+ return createHash("sha256").update(value).digest("hex").slice(0, 32);
86
+ }
87
+
88
+ export function questionHash(questionText: string): string {
89
+ return hashContent(questionText);
90
+ }
91
+
92
+ export function answerHash(selectedOptions: string[] | undefined, customInput: string | undefined): string {
93
+ return hashContent(JSON.stringify({ selected: selectedOptions ?? [], custom: customInput ?? null }));
94
+ }
95
+
96
+ /**
97
+ * Durable round identity. Prefer `interview_id + round_id`; fall back to
98
+ * `interview_id + round + question.id` when no caller-supplied `round_id` exists.
99
+ */
100
+ export function deriveRoundKey(
101
+ interviewId: string | undefined,
102
+ input: { round_id?: string; round: number; questionId?: string },
103
+ ): string {
104
+ const interview = interviewId && interviewId.trim() !== "" ? interviewId : "nointerview";
105
+ if (input.round_id && input.round_id.trim() !== "") {
106
+ return `${interview}::rid:${input.round_id}`;
107
+ }
108
+ return `${interview}::r:${input.round}::q:${input.questionId ?? "noqid"}`;
109
+ }
110
+
111
+ // =============================================================================
112
+ // Pure helpers: canonical shape normalization
113
+ // =============================================================================
114
+
115
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
116
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
117
+ }
118
+
119
+ /**
120
+ * Interview transcript/scoring fields that are canonical under `state`. When a
121
+ * legacy flattened envelope carries them at the top level they are hoisted into
122
+ * `state` and removed from the top level so exactly one canonical copy survives.
123
+ */
124
+ const TRANSCRIPT_STATE_FIELDS = [
125
+ "rounds",
126
+ "established_facts",
127
+ "current_ambiguity",
128
+ "topology",
129
+ "ontology_snapshots",
130
+ "auto_researched_rounds",
131
+ "auto_answered_rounds",
132
+ "architect_failures",
133
+ ] as const;
134
+
135
+ /**
136
+ * Interview context fields that belong under `state` but are also legitimately
137
+ * mirrored at the envelope level by the seed/spec writers (e.g. `threshold`,
138
+ * `language`). They are hoisted into `state` when missing there but never stripped
139
+ * from the top level, preserving existing dual-write behavior.
140
+ */
141
+ const HOISTED_STATE_FIELDS = [
142
+ "initial_idea",
143
+ "initial_context_summary",
144
+ "codebase_context",
145
+ "challenge_modes_used",
146
+ "interview_id",
147
+ "type",
148
+ "language",
149
+ "threshold",
150
+ "threshold_source",
151
+ ] as const;
152
+
153
+ /**
154
+ * Canonicalize a deep-interview envelope: interview data nested under `state`,
155
+ * legacy flattened fields hoisted in losslessly, transcript duplicates removed
156
+ * from the top level, and `rounds`/`established_facts` guaranteed to be arrays.
157
+ *
158
+ * Idempotent: a canonical envelope is returned unchanged in shape. Never deletes
159
+ * unknown envelope or nested fields, and never mutates the input.
160
+ */
161
+ export function normalizeDeepInterviewEnvelope(value: unknown): DeepInterviewStateEnvelope {
162
+ const envelope: DeepInterviewStateEnvelope = isPlainObject(value) ? { ...value } : {};
163
+ const inner: Record<string, unknown> = isPlainObject(envelope.state) ? { ...envelope.state } : {};
164
+
165
+ for (const field of TRANSCRIPT_STATE_FIELDS) {
166
+ if (inner[field] === undefined && envelope[field] !== undefined) inner[field] = envelope[field];
167
+ if (field in envelope) delete envelope[field];
168
+ }
169
+ for (const field of HOISTED_STATE_FIELDS) {
170
+ if (inner[field] === undefined && envelope[field] !== undefined) inner[field] = envelope[field];
171
+ }
172
+
173
+ if (!Array.isArray(inner.rounds)) inner.rounds = [];
174
+ if (!Array.isArray(inner.established_facts)) inner.established_facts = [];
175
+ envelope.state = inner;
176
+ return envelope;
177
+ }
178
+
179
+ // =============================================================================
180
+ // Pure helpers: lossless round + envelope merge
181
+ // =============================================================================
182
+
183
+ function nonEmptyString(value: unknown): value is string {
184
+ return typeof value === "string" && value.trim() !== "";
185
+ }
186
+
187
+ /** Durable merge key for a round, or `undefined` when the record is not addressable. */
188
+ function durableRoundKey(record: Record<string, unknown>): string | undefined {
189
+ if (nonEmptyString(record.round_key)) return record.round_key;
190
+ const hasId = nonEmptyString(record.round_id) || nonEmptyString(record.question_id);
191
+ if (!hasId) return undefined;
192
+ return deriveRoundKey(undefined, {
193
+ round_id: nonEmptyString(record.round_id) ? record.round_id : undefined,
194
+ round: typeof record.round === "number" ? record.round : 0,
195
+ questionId: nonEmptyString(record.question_id) ? record.question_id : undefined,
196
+ });
197
+ }
198
+
199
+ function deepEqual(a: unknown, b: unknown): boolean {
200
+ if (a === b) return true;
201
+ if (typeof a !== typeof b) return false;
202
+ if (Array.isArray(a) && Array.isArray(b)) {
203
+ return a.length === b.length && a.every((item, index) => deepEqual(item, b[index]));
204
+ }
205
+ if (isPlainObject(a) && isPlainObject(b)) {
206
+ const aKeys = Object.keys(a);
207
+ const bKeys = Object.keys(b);
208
+ if (aKeys.length !== bKeys.length) return false;
209
+ return aKeys.every(key => deepEqual(a[key], b[key]));
210
+ }
211
+ return false;
212
+ }
213
+
214
+ /** Merge a later round record into an earlier one for the same durable key. */
215
+ function mergeRoundPair(existing: Record<string, unknown>, incoming: Record<string, unknown>): Record<string, unknown> {
216
+ const merged: Record<string, unknown> = { ...existing };
217
+ for (const [key, value] of Object.entries(incoming)) {
218
+ if (value === undefined) continue;
219
+ merged[key] = value;
220
+ }
221
+ // Never downgrade a scored lifecycle back to answered.
222
+ if (existing.lifecycle === "scored" && incoming.lifecycle !== "scored") merged.lifecycle = "scored";
223
+ // Preserve shell identity fields when the incoming (scoring) record blanked them.
224
+ for (const field of ["question_hash", "answer_hash", "question_text"]) {
225
+ if (!nonEmptyString(incoming[field]) && nonEmptyString(existing[field])) merged[field] = existing[field];
226
+ }
227
+ return merged;
228
+ }
229
+
230
+ /**
231
+ * Lossless, idempotent merge of two round arrays.
232
+ *
233
+ * - Records sharing a durable key (`round_key`, or synthesized from
234
+ * `round_id`/`question_id`) merge into one, preferring scored over answered.
235
+ * - Records without any durable identity are preserved verbatim; an exact
236
+ * duplicate is skipped so repeated writes stay idempotent, but distinct records
237
+ * are never collapsed.
238
+ *
239
+ * Deliberate refinement of the approved plan: rather than mutating opaque legacy
240
+ * records with synthetic `legacy:<index>` keys, they are preserved verbatim with
241
+ * exact-duplicate dedupe. This satisfies the plan's intent (lossless, idempotent,
242
+ * never collapse distinct rounds) without rewriting user-supplied round objects,
243
+ * and keeps free-form extension preservation intact. Recorder-produced records
244
+ * always carry a `round_key`, so the synthetic path is unnecessary in practice.
245
+ */
246
+ export function mergeDeepInterviewRounds(
247
+ existing: readonly Record<string, unknown>[],
248
+ incoming: readonly Record<string, unknown>[],
249
+ ): Record<string, unknown>[] {
250
+ const result: Record<string, unknown>[] = [];
251
+ const indexByKey = new Map<string, number>();
252
+
253
+ const add = (record: Record<string, unknown>): void => {
254
+ const key = durableRoundKey(record);
255
+ if (key !== undefined) {
256
+ const existingIndex = indexByKey.get(key);
257
+ if (existingIndex === undefined) {
258
+ const stored = nonEmptyString(record.round_key) ? { ...record } : { ...record, round_key: key };
259
+ indexByKey.set(key, result.length);
260
+ result.push(stored);
261
+ } else {
262
+ result[existingIndex] = mergeRoundPair(result[existingIndex], record);
263
+ }
264
+ return;
265
+ }
266
+ // Opaque/legacy record without durable identity: preserve verbatim, dedupe exact copies only.
267
+ if (result.some(item => deepEqual(item, record))) return;
268
+ result.push({ ...record });
269
+ };
270
+
271
+ for (const record of existing) if (isPlainObject(record)) add(record);
272
+ for (const record of incoming) if (isPlainObject(record)) add(record);
273
+ return result;
274
+ }
275
+
276
+ function asRecordArray(value: unknown): Record<string, unknown>[] {
277
+ return Array.isArray(value) ? value.filter(isPlainObject) : [];
278
+ }
279
+
280
+ /**
281
+ * Deep-interview-specific envelope merge. Unlike the generic shallow null-delete
282
+ * merge, this keeps interview data nested under `state`, never deletes `state`,
283
+ * and merges `rounds` losslessly by durable key so a partial write (e.g. a
284
+ * scoring update) cannot drop recorder-written transcript history.
285
+ */
286
+ export function mergeDeepInterviewEnvelope(
287
+ existing: unknown,
288
+ incoming: unknown,
289
+ options: { replace?: boolean } = {},
290
+ ): DeepInterviewStateEnvelope {
291
+ const incomingEnvelope = isPlainObject(incoming) ? incoming : {};
292
+ const incomingNestedState = isPlainObject(incomingEnvelope.state) ? incomingEnvelope.state : {};
293
+ const incomingHasEstablishedFacts =
294
+ Object.hasOwn(incomingNestedState, "established_facts") || Object.hasOwn(incomingEnvelope, "established_facts");
295
+ const normalizedIncoming = normalizeDeepInterviewEnvelope(incoming);
296
+ if (options.replace) return normalizedIncoming;
297
+
298
+ const normalizedExisting = normalizeDeepInterviewEnvelope(existing);
299
+ const merged: Record<string, unknown> = {};
300
+ for (const [key, value] of Object.entries(normalizedExisting)) {
301
+ if (key !== "state") merged[key] = value;
302
+ }
303
+ for (const [key, value] of Object.entries(normalizedIncoming)) {
304
+ if (key === "state") continue;
305
+ if (value === null) delete merged[key];
306
+ else merged[key] = value;
307
+ }
308
+
309
+ const existingState = isPlainObject(normalizedExisting.state) ? normalizedExisting.state : {};
310
+ const incomingState = isPlainObject(normalizedIncoming.state) ? normalizedIncoming.state : {};
311
+ const mergedState: Record<string, unknown> = { ...existingState };
312
+ for (const [key, value] of Object.entries(incomingState)) {
313
+ if (key === "rounds") continue;
314
+ if (key === "established_facts" && !incomingHasEstablishedFacts) continue;
315
+ if (value === null) delete mergedState[key];
316
+ else mergedState[key] = value;
317
+ }
318
+ mergedState.rounds = mergeDeepInterviewRounds(
319
+ asRecordArray(existingState.rounds),
320
+ asRecordArray(incomingState.rounds),
321
+ );
322
+ merged.state = mergedState;
323
+ return merged as DeepInterviewStateEnvelope;
324
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Text rendering for `gjc gc` reports. JSON output is produced directly in
3
+ * `gc-runtime.ts`; this module owns the human-readable grouped report.
4
+ */
5
+
6
+ import type { GcRecord, GcReport, GcStore } from "./gc-runtime";
7
+ import { GC_STORES } from "./gc-runtime";
8
+
9
+ const STORE_HEADINGS: Record<GcStore, string> = {
10
+ harness_leases: "Harness owner leases",
11
+ team_workers: "Team workers",
12
+ file_locks: "Config file-locks",
13
+ tmux_sessions: "Tmux sessions",
14
+ registry_entries: "Harness-root registry entries",
15
+ };
16
+
17
+ function actionLabel(record: GcRecord): string {
18
+ switch (record.action) {
19
+ case "would_remove":
20
+ return "would remove";
21
+ case "removed":
22
+ return "removed";
23
+ case "remove_failed":
24
+ return `remove failed${record.error ? `: ${record.error}` : ""}`;
25
+ case "skipped":
26
+ return `skipped: ${record.reason}`;
27
+ default:
28
+ return "keep";
29
+ }
30
+ }
31
+
32
+ function renderRecord(record: GcRecord): string {
33
+ const target = record.path ?? record.id;
34
+ const pid = record.pid !== undefined ? ` pid=${record.pid}` : "";
35
+ const pidStatus = record.pid_status ? ` (${record.pid_status})` : "";
36
+ const note = record.detail ? ` — ${record.detail}` : "";
37
+ return ` [${actionLabel(record)}] ${target}${pid}${pidStatus} :: ${record.status} — ${record.reason}${note}`;
38
+ }
39
+
40
+ export function buildGcReportText(report: GcReport): string {
41
+ const lines: string[] = [];
42
+ lines.push(report.dry_run ? "gjc gc — dry run (no changes made; pass --prune to remove)" : "gjc gc — prune");
43
+ lines.push("");
44
+
45
+ for (const store of GC_STORES) {
46
+ const records = report.stores[store];
47
+ lines.push(`${STORE_HEADINGS[store]} (${records.length})`);
48
+ if (records.length === 0) {
49
+ lines.push(" (none)");
50
+ } else {
51
+ for (const record of records) lines.push(renderRecord(record));
52
+ }
53
+ lines.push("");
54
+ }
55
+
56
+ if (report.errors.length > 0) {
57
+ lines.push(`Errors (${report.errors.length})`);
58
+ for (const err of report.errors) lines.push(` [${err.store}/${err.scope}] ${err.message}`);
59
+ lines.push("");
60
+ }
61
+
62
+ const c = report.counts;
63
+ lines.push(
64
+ `Summary: discovered=${c.discovered} stale=${c.stale} alive=${c.alive} eperm=${c.eperm} unknown=${c.unknown} ` +
65
+ `terminal_lifecycle=${c.terminal_lifecycle} unclassified=${c.unclassified} ` +
66
+ `${report.dry_run ? `would_remove=${c.would_remove}` : `removed=${c.removed} failed=${c.failed}`} errors=${c.errors}`,
67
+ );
68
+ lines.push("");
69
+ return `${lines.join("\n")}`;
70
+ }