@gajae-code/coding-agent 0.5.0 → 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 (125) hide show
  1. package/CHANGELOG.md +19 -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/config/file-lock-gc.d.ts +5 -0
  7. package/dist/types/config/file-lock.d.ts +7 -0
  8. package/dist/types/coordinator/contract.d.ts +1 -1
  9. package/dist/types/defaults/gjc/extensions/grok-build/index.d.ts +1 -0
  10. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/index.d.ts +1 -0
  11. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +25 -0
  12. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.d.ts +27 -0
  13. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.d.ts +8 -0
  14. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.d.ts +5 -0
  15. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +10 -0
  16. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.d.ts +2 -0
  17. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.d.ts +2 -0
  18. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.d.ts +38 -0
  19. package/dist/types/defaults/gjc-grok-cli.d.ts +5 -0
  20. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  21. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +35 -0
  22. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +103 -0
  23. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -0
  24. package/dist/types/gjc-runtime/deep-interview-state.d.ts +112 -0
  25. package/dist/types/gjc-runtime/gc-render.d.ts +6 -0
  26. package/dist/types/gjc-runtime/gc-runtime.d.ts +134 -0
  27. package/dist/types/gjc-runtime/ledger-event-renderer.d.ts +68 -0
  28. package/dist/types/gjc-runtime/team-gc.d.ts +7 -0
  29. package/dist/types/gjc-runtime/team-runtime.d.ts +5 -0
  30. package/dist/types/gjc-runtime/tmux-common.d.ts +11 -0
  31. package/dist/types/gjc-runtime/tmux-gc.d.ts +7 -0
  32. package/dist/types/gjc-runtime/tmux-sessions.d.ts +13 -0
  33. package/dist/types/harness-control-plane/gc-adapter.d.ts +3 -0
  34. package/dist/types/harness-control-plane/owner.d.ts +7 -0
  35. package/dist/types/harness-control-plane/storage.d.ts +20 -0
  36. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  37. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  38. package/dist/types/modes/rpc/rpc-mode.d.ts +16 -1
  39. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +13 -0
  40. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +25 -0
  41. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +2 -0
  42. package/dist/types/session/agent-session.d.ts +1 -1
  43. package/dist/types/session/blob-store.d.ts +39 -3
  44. package/dist/types/skill-state/workflow-hud.d.ts +14 -0
  45. package/dist/types/tools/ask.d.ts +15 -1
  46. package/dist/types/tools/subagent.d.ts +6 -0
  47. package/package.json +7 -7
  48. package/src/async/job-manager.ts +52 -0
  49. package/src/cli/args.ts +3 -0
  50. package/src/cli/auth-broker-cli.ts +1 -0
  51. package/src/cli/list-models.ts +13 -1
  52. package/src/cli.ts +1 -0
  53. package/src/commands/gc.ts +22 -0
  54. package/src/commands/harness.ts +7 -3
  55. package/src/config/file-lock-gc.ts +181 -0
  56. package/src/config/file-lock.ts +14 -0
  57. package/src/config/model-profiles.ts +24 -15
  58. package/src/coordinator/contract.ts +1 -0
  59. package/src/coordinator-mcp/server.ts +459 -3
  60. package/src/defaults/gjc/agent.models.grok-cli.yml +36 -0
  61. package/src/defaults/gjc/extensions/grok-build/index.ts +1 -0
  62. package/src/defaults/gjc/extensions/grok-build/package.json +7 -0
  63. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +39 -0
  64. package/src/defaults/gjc/extensions/grok-cli-vendor/package.json +8 -0
  65. package/src/defaults/gjc/extensions/grok-cli-vendor/src/index.ts +1 -0
  66. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +155 -0
  67. package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +361 -0
  68. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.ts +57 -0
  69. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +99 -0
  70. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +50 -0
  71. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.ts +56 -0
  72. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +36 -0
  73. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.ts +44 -0
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +131 -113
  75. package/src/defaults/gjc/skills/deep-interview/lateral-review-panel.md +49 -0
  76. package/src/defaults/gjc-defaults.ts +7 -0
  77. package/src/defaults/gjc-grok-cli.ts +22 -0
  78. package/src/extensibility/extensions/index.ts +1 -0
  79. package/src/extensibility/extensions/prefix-command-bridge.ts +128 -0
  80. package/src/gjc-runtime/deep-interview-recorder.ts +417 -0
  81. package/src/gjc-runtime/deep-interview-runtime.ts +18 -26
  82. package/src/gjc-runtime/deep-interview-state.ts +324 -0
  83. package/src/gjc-runtime/gc-render.ts +70 -0
  84. package/src/gjc-runtime/gc-runtime.ts +403 -0
  85. package/src/gjc-runtime/ledger-event-renderer.ts +164 -0
  86. package/src/gjc-runtime/ralplan-runtime.ts +58 -7
  87. package/src/gjc-runtime/state-renderer.ts +12 -3
  88. package/src/gjc-runtime/state-runtime.ts +46 -29
  89. package/src/gjc-runtime/team-gc.ts +49 -0
  90. package/src/gjc-runtime/team-runtime.ts +179 -2
  91. package/src/gjc-runtime/tmux-common.ts +14 -0
  92. package/src/gjc-runtime/tmux-gc.ts +176 -0
  93. package/src/gjc-runtime/tmux-sessions.ts +49 -1
  94. package/src/gjc-runtime/ultragoal-runtime.ts +12 -0
  95. package/src/harness-control-plane/gc-adapter.ts +184 -0
  96. package/src/harness-control-plane/owner.ts +11 -0
  97. package/src/harness-control-plane/storage.ts +70 -0
  98. package/src/internal-urls/docs-index.generated.ts +14 -8
  99. package/src/main.ts +7 -2
  100. package/src/modes/components/hook-selector.ts +19 -0
  101. package/src/modes/components/model-selector.ts +25 -8
  102. package/src/modes/components/status-line/segments.ts +1 -1
  103. package/src/modes/controllers/command-controller.ts +25 -6
  104. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  105. package/src/modes/controllers/selector-controller.ts +1 -0
  106. package/src/modes/rpc/rpc-mode.ts +151 -33
  107. package/src/modes/shared/agent-wire/command-dispatch.ts +278 -261
  108. package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
  109. package/src/modes/shared/agent-wire/session-registry.ts +109 -0
  110. package/src/modes/shared/agent-wire/unattended-action-policy.ts +24 -0
  111. package/src/modes/shared/agent-wire/unattended-run-controller.ts +23 -3
  112. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  113. package/src/sdk.ts +17 -3
  114. package/src/session/agent-session.ts +77 -8
  115. package/src/session/blob-store.ts +59 -3
  116. package/src/session/session-manager.ts +4 -4
  117. package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
  118. package/src/skill-state/workflow-hud.ts +106 -10
  119. package/src/slash-commands/builtin-registry.ts +3 -2
  120. package/src/task/executor.ts +9 -0
  121. package/src/tools/ask.ts +56 -1
  122. package/src/tools/job.ts +3 -2
  123. package/src/tools/monitor.ts +36 -1
  124. package/src/tools/subagent-render.ts +9 -0
  125. package/src/tools/subagent.ts +26 -2
@@ -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
+ }