@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,403 @@
1
+ /**
2
+ * `gjc gc` runtime — a global, liveness-only, dry-run-by-default garbage
3
+ * collector for stale GJC session/PID records.
4
+ *
5
+ * Design (see .gjc/plans/ralplan/2026-06-13-1347-954f/pending-approval.md):
6
+ * - This module is an ORCHESTRATOR only. It owns the shared PID probe, the
7
+ * report/exit-code policy, and text/JSON rendering. It must NOT parse private
8
+ * store layouts directly; every store is reached through an injectable
9
+ * `GcStoreAdapter` that lives next to its store owner.
10
+ * - Liveness-only and fail-closed: only `ESRCH` (no such process) is `dead`
11
+ * (removable). `process.kill(pid, 0)` success, `EPERM`, and any unknown probe
12
+ * error all mean KEEP — a live process is never signalled or killed.
13
+ * - Dry-run by default: nothing is deleted unless `--prune`/`--force`.
14
+ */
15
+
16
+ import { buildGcReportText } from "./gc-render";
17
+
18
+ export type GcStore = "harness_leases" | "team_workers" | "file_locks" | "tmux_sessions" | "registry_entries";
19
+
20
+ export const GC_STORES: readonly GcStore[] = [
21
+ "harness_leases",
22
+ "team_workers",
23
+ "file_locks",
24
+ "tmux_sessions",
25
+ "registry_entries",
26
+ ] as const;
27
+
28
+ /** Why a probed pid is kept instead of treated as dead. */
29
+ export type GcPidKeepReason = "alive" | "eperm" | "unknown";
30
+
31
+ export interface GcPidProbeResult {
32
+ /** `dead` only on ESRCH; `keep` for alive/eperm/unknown (fail-closed). */
33
+ status: "dead" | "keep";
34
+ reason?: GcPidKeepReason;
35
+ error?: string;
36
+ }
37
+
38
+ /** Single shared liveness contract threaded through every classifier + prune path. */
39
+ export type GcPidProbe = (pid: number) => GcPidProbeResult;
40
+
41
+ export type GcPidStatus = "dead" | "alive" | "eperm" | "unknown" | "none";
42
+
43
+ export type GcAction = "none" | "would_remove" | "removed" | "remove_failed" | "skipped";
44
+
45
+ export interface GcRecord {
46
+ store: GcStore;
47
+ /** Stable identifier: session id, lock dir path, worker id, tmux name, registry session id. */
48
+ id: string;
49
+ path?: string;
50
+ root?: string;
51
+ pid?: number;
52
+ pid_status?: GcPidStatus;
53
+ /** Store-specific classification label (e.g. "dead", "live", "unclassified", "terminal_lifecycle"). */
54
+ status: string;
55
+ stale: boolean;
56
+ removable: boolean;
57
+ action: GcAction;
58
+ reason: string;
59
+ detail?: string;
60
+ error?: string;
61
+ removed?: boolean;
62
+ }
63
+
64
+ export interface GcError {
65
+ store: GcStore;
66
+ scope: string;
67
+ message: string;
68
+ }
69
+
70
+ export interface GcCollectResult {
71
+ records: GcRecord[];
72
+ errors: GcError[];
73
+ }
74
+
75
+ export interface GcPruneOutcome {
76
+ removed: boolean;
77
+ error?: string;
78
+ /** Set when a removable record was skipped at prune time (e.g. TOCTOU became live). */
79
+ skipped?: string;
80
+ }
81
+
82
+ export interface GcContext {
83
+ probe: GcPidProbe;
84
+ force: boolean;
85
+ env: NodeJS.ProcessEnv;
86
+ cwd: string;
87
+ }
88
+
89
+ /**
90
+ * A store-owned GC adapter. `collect` discovers + classifies (using the shared
91
+ * probe) without mutating anything. `prune` removes a single record, and MUST
92
+ * re-validate / re-probe immediately before any destructive action.
93
+ */
94
+ export interface GcStoreAdapter {
95
+ store: GcStore;
96
+ collect(ctx: GcContext): Promise<GcCollectResult>;
97
+ prune(record: GcRecord, ctx: GcContext): Promise<GcPruneOutcome>;
98
+ }
99
+
100
+ export interface GcCounts {
101
+ discovered: number;
102
+ stale: number;
103
+ alive: number;
104
+ eperm: number;
105
+ unknown: number;
106
+ terminal_lifecycle: number;
107
+ unclassified: number;
108
+ would_remove: number;
109
+ removed: number;
110
+ failed: number;
111
+ errors: number;
112
+ by_store: Record<
113
+ GcStore,
114
+ { discovered: number; stale: number; would_remove: number; removed: number; failed: number }
115
+ >;
116
+ }
117
+
118
+ export interface GcReport {
119
+ dry_run: boolean;
120
+ stores: Record<GcStore, GcRecord[]>;
121
+ counts: GcCounts;
122
+ errors: GcError[];
123
+ }
124
+
125
+ export interface GcRunResult {
126
+ stdout: string;
127
+ stderr: string;
128
+ status: number;
129
+ }
130
+
131
+ /**
132
+ * The shared, fail-closed PID probe. ESRCH => dead/removable; success => alive;
133
+ * EPERM => kept (owned by another user); any other error => kept as unknown.
134
+ */
135
+ export const gcPidProbe: GcPidProbe = (pid: number): GcPidProbeResult => {
136
+ if (!Number.isInteger(pid) || pid <= 0) {
137
+ return { status: "keep", reason: "unknown", error: `invalid_pid:${pid}` };
138
+ }
139
+ try {
140
+ process.kill(pid, 0);
141
+ return { status: "keep", reason: "alive" };
142
+ } catch (error) {
143
+ const code = (error as NodeJS.ErrnoException).code;
144
+ if (code === "ESRCH") return { status: "dead" };
145
+ if (code === "EPERM") return { status: "keep", reason: "eperm" };
146
+ return { status: "keep", reason: "unknown", error: code ?? String(error) };
147
+ }
148
+ };
149
+
150
+ /** Map a `GcPidProbe` onto the harness lease probe shape (`"alive"|"dead"|"eperm"`). */
151
+ export function gcProbeToLeasePidStatus(probe: GcPidProbe): (pid: number) => "alive" | "dead" | "eperm" {
152
+ return (pid: number) => {
153
+ const result = probe(pid);
154
+ if (result.status === "dead") return "dead";
155
+ // EPERM stays eperm; unknown maps to alive so classifyLeaseStatus keeps it.
156
+ return result.reason === "eperm" ? "eperm" : "alive";
157
+ };
158
+ }
159
+
160
+ /** Translate a probe result into a record-friendly pid status label. */
161
+ export function gcPidStatusLabel(result: GcPidProbeResult): Exclude<GcPidStatus, "none"> {
162
+ if (result.status === "dead") return "dead";
163
+ return result.reason ?? "alive";
164
+ }
165
+
166
+ function emptyByStore(): GcCounts["by_store"] {
167
+ const by = {} as GcCounts["by_store"];
168
+ for (const store of GC_STORES) {
169
+ by[store] = { discovered: 0, stale: 0, would_remove: 0, removed: 0, failed: 0 };
170
+ }
171
+ return by;
172
+ }
173
+
174
+ function emptyStores(): Record<GcStore, GcRecord[]> {
175
+ const stores = {} as Record<GcStore, GcRecord[]>;
176
+ for (const store of GC_STORES) stores[store] = [];
177
+ return stores;
178
+ }
179
+
180
+ function computeCounts(stores: Record<GcStore, GcRecord[]>, errors: GcError[]): GcCounts {
181
+ const counts: GcCounts = {
182
+ discovered: 0,
183
+ stale: 0,
184
+ alive: 0,
185
+ eperm: 0,
186
+ unknown: 0,
187
+ terminal_lifecycle: 0,
188
+ unclassified: 0,
189
+ would_remove: 0,
190
+ removed: 0,
191
+ failed: 0,
192
+ errors: errors.length,
193
+ by_store: emptyByStore(),
194
+ };
195
+ for (const store of GC_STORES) {
196
+ for (const record of stores[store]) {
197
+ counts.discovered++;
198
+ counts.by_store[store].discovered++;
199
+ if (record.stale) {
200
+ counts.stale++;
201
+ counts.by_store[store].stale++;
202
+ }
203
+ if (record.pid_status === "alive") counts.alive++;
204
+ else if (record.pid_status === "eperm") counts.eperm++;
205
+ else if (record.pid_status === "unknown") counts.unknown++;
206
+ if (record.status === "terminal_lifecycle") counts.terminal_lifecycle++;
207
+ if (record.status === "unclassified") counts.unclassified++;
208
+ if (record.action === "would_remove") {
209
+ counts.would_remove++;
210
+ counts.by_store[store].would_remove++;
211
+ }
212
+ if (record.action === "removed") {
213
+ counts.removed++;
214
+ counts.by_store[store].removed++;
215
+ }
216
+ if (record.action === "remove_failed") {
217
+ counts.failed++;
218
+ counts.by_store[store].failed++;
219
+ }
220
+ }
221
+ }
222
+ return counts;
223
+ }
224
+
225
+ interface ParsedGcArgs {
226
+ json: boolean;
227
+ prune: boolean;
228
+ help: boolean;
229
+ }
230
+
231
+ class GcUsageError extends Error {}
232
+
233
+ function parseGcArgs(argv: string[]): ParsedGcArgs {
234
+ let json = false;
235
+ let prune = false;
236
+ let dryRun = false;
237
+ let help = false;
238
+ for (const arg of argv) {
239
+ switch (arg) {
240
+ case "--json":
241
+ case "-j":
242
+ json = true;
243
+ break;
244
+ case "--prune":
245
+ case "--force":
246
+ prune = true;
247
+ break;
248
+ case "--dry-run":
249
+ dryRun = true;
250
+ break;
251
+ case "--help":
252
+ case "-h":
253
+ help = true;
254
+ break;
255
+ default:
256
+ throw new GcUsageError(`unknown_flag:${arg}`);
257
+ }
258
+ }
259
+ // Explicit --dry-run always wins over --prune/--force.
260
+ if (dryRun) prune = false;
261
+ return { json, prune, help };
262
+ }
263
+
264
+ /**
265
+ * Collect every store's records (catching hard discovery errors per adapter),
266
+ * then optionally prune removable records with per-record revalidation.
267
+ */
268
+ export async function collectGcReport(adapters: GcStoreAdapter[], ctx: GcContext, prune: boolean): Promise<GcReport> {
269
+ const stores = emptyStores();
270
+ const errors: GcError[] = [];
271
+
272
+ for (const adapter of adapters) {
273
+ try {
274
+ const result = await adapter.collect(ctx);
275
+ stores[adapter.store].push(...result.records);
276
+ errors.push(...result.errors);
277
+ } catch (error) {
278
+ errors.push({
279
+ store: adapter.store,
280
+ scope: "collect",
281
+ message: error instanceof Error ? error.message : String(error),
282
+ });
283
+ }
284
+ }
285
+
286
+ // Mark dry-run intent on every removable record before pruning.
287
+ for (const store of GC_STORES) {
288
+ for (const record of stores[store]) {
289
+ if (record.removable) record.action = "would_remove";
290
+ }
291
+ }
292
+
293
+ if (prune) {
294
+ const adapterByStore = new Map(adapters.map(a => [a.store, a] as const));
295
+ for (const store of GC_STORES) {
296
+ const adapter = adapterByStore.get(store);
297
+ if (!adapter) continue;
298
+ for (const record of stores[store]) {
299
+ if (!record.removable) continue;
300
+ try {
301
+ const outcome = await adapter.prune(record, ctx);
302
+ if (outcome.removed) {
303
+ record.action = "removed";
304
+ record.removed = true;
305
+ } else if (outcome.skipped) {
306
+ record.action = "skipped";
307
+ record.reason = outcome.skipped;
308
+ record.removed = false;
309
+ } else {
310
+ record.action = "remove_failed";
311
+ record.removed = false;
312
+ record.error = outcome.error ?? "remove_failed";
313
+ }
314
+ } catch (error) {
315
+ record.action = "remove_failed";
316
+ record.removed = false;
317
+ record.error = error instanceof Error ? error.message : String(error);
318
+ }
319
+ }
320
+ }
321
+ }
322
+
323
+ return { dry_run: !prune, stores, counts: computeCounts(stores, errors), errors };
324
+ }
325
+
326
+ /**
327
+ * Exit-code policy:
328
+ * - usage/parse error => 2
329
+ * - hard discovery errors => 1 (both modes)
330
+ * - prune mode with a failed intended removal => 1
331
+ * - otherwise => 0
332
+ */
333
+ export function computeExitCode(report: GcReport): number {
334
+ if (report.errors.length > 0) return 1;
335
+ if (!report.dry_run && report.counts.failed > 0) return 1;
336
+ return 0;
337
+ }
338
+
339
+ export async function runGjcGcCommand(
340
+ argv: string[],
341
+ cwd: string = process.cwd(),
342
+ env: NodeJS.ProcessEnv = process.env,
343
+ adapters?: GcStoreAdapter[],
344
+ ): Promise<GcRunResult> {
345
+ let parsed: ParsedGcArgs;
346
+ try {
347
+ parsed = parseGcArgs(argv);
348
+ } catch (error) {
349
+ const message = error instanceof GcUsageError ? error.message : String(error);
350
+ return { stdout: "", stderr: `gjc gc: ${message}\n`, status: 2 };
351
+ }
352
+
353
+ if (parsed.help) {
354
+ return { stdout: gcHelpText(), stderr: "", status: 0 };
355
+ }
356
+
357
+ const resolvedAdapters = adapters ?? (await defaultGcAdapters());
358
+ const ctx: GcContext = { probe: gcPidProbe, force: parsed.prune, env, cwd };
359
+ const report = await collectGcReport(resolvedAdapters, ctx, parsed.prune);
360
+ const status = computeExitCode(report);
361
+ const stdout = parsed.json ? `${JSON.stringify(report, null, 2)}\n` : buildGcReportText(report);
362
+ return { stdout, stderr: "", status };
363
+ }
364
+
365
+ export function gcHelpText(): string {
366
+ return [
367
+ "gjc gc - garbage-collect stale GJC session/PID records",
368
+ "",
369
+ "USAGE",
370
+ " $ gjc gc [--prune|--force] [--json]",
371
+ "",
372
+ "FLAGS",
373
+ " --prune, --force Actually remove stale records (default: dry-run report only)",
374
+ " --dry-run Force report-only mode (overrides --prune/--force)",
375
+ " -j, --json Emit machine-readable JSON",
376
+ "",
377
+ "Liveness-only: a record is removed only when its owning process is dead",
378
+ "(ESRCH). Live / permission-denied / unknown processes are always kept.",
379
+ "",
380
+ ].join("\n");
381
+ }
382
+
383
+ /** Lazily assemble the real store adapters (kept lazy to avoid import cycles). */
384
+ export async function defaultGcAdapters(): Promise<GcStoreAdapter[]> {
385
+ const [
386
+ { harnessLeasesGcAdapter, registryEntriesGcAdapter },
387
+ { fileLocksGcAdapter },
388
+ { teamWorkersGcAdapter },
389
+ { tmuxSessionsGcAdapter },
390
+ ] = await Promise.all([
391
+ import("../harness-control-plane/gc-adapter"),
392
+ import("../config/file-lock-gc"),
393
+ import("./team-gc"),
394
+ import("./tmux-gc"),
395
+ ]);
396
+ return [
397
+ harnessLeasesGcAdapter,
398
+ teamWorkersGcAdapter,
399
+ fileLocksGcAdapter,
400
+ tmuxSessionsGcAdapter,
401
+ registryEntriesGcAdapter,
402
+ ];
403
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Pure parse + summarize for ledger-backed skill observability.
3
+ *
4
+ * Workflow progress for ultragoal/ralplan cannot be observed via subagent tool
5
+ * events (those skills persist through `bash`-backed `gjc` CLI calls whose tool
6
+ * `details` carry no structured payload). The durable source of truth is the
7
+ * append-only ledgers:
8
+ * - ultragoal: `.gjc/ultragoal/ledger.jsonl`
9
+ * - ralplan: `.gjc/plans/ralplan/<run-id>/index.jsonl`
10
+ *
11
+ * This module is I/O-free: callers read the files and pass lines or already-parsed
12
+ * rows. It feeds the compact HUD chip builders in `skill-state/workflow-hud.ts`
13
+ * via the runtime sync paths. Display-string helpers stay theme-free.
14
+ */
15
+
16
+ /* ------------------------------- ultragoal ------------------------------- */
17
+
18
+ /** Minimal projection of an ultragoal ledger row used for the HUD chip. */
19
+ export interface UltragoalLedgerEventLite {
20
+ /** Normalized from the row's `event` field, or `type` for reconcile rows. */
21
+ event: string;
22
+ goalId?: string;
23
+ status?: string;
24
+ timestamp?: string;
25
+ }
26
+
27
+ /**
28
+ * Coerce an already-parsed ledger row into the lite shape. Accepts both the
29
+ * `event`-keyed vocabulary (plan_created, goal_started, goal_checkpointed,
30
+ * steering_accepted/rejected, review_blockers_recorded) and the `type`-keyed
31
+ * reconcile-failure row (`type: "reconcile_failed"`). Returns undefined when no
32
+ * event/type discriminator is present.
33
+ */
34
+ export function coerceUltragoalLedgerEvent(row: Record<string, unknown>): UltragoalLedgerEventLite | undefined {
35
+ const event = typeof row.event === "string" ? row.event : typeof row.type === "string" ? row.type : undefined;
36
+ if (!event) return undefined;
37
+ const lite: UltragoalLedgerEventLite = { event };
38
+ if (typeof row.goalId === "string") lite.goalId = row.goalId;
39
+ if (typeof row.status === "string") lite.status = row.status;
40
+ if (typeof row.timestamp === "string") lite.timestamp = row.timestamp;
41
+ return lite;
42
+ }
43
+
44
+ /** Parse a single ultragoal ledger JSONL line; undefined for blank/malformed lines. */
45
+ export function parseUltragoalLedgerLine(line: string): UltragoalLedgerEventLite | undefined {
46
+ const trimmed = line.trim();
47
+ if (!trimmed) return undefined;
48
+ let row: unknown;
49
+ try {
50
+ row = JSON.parse(trimmed);
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ if (!row || typeof row !== "object" || Array.isArray(row)) return undefined;
55
+ return coerceUltragoalLedgerEvent(row as Record<string, unknown>);
56
+ }
57
+
58
+ /** The most recent event, or undefined when the ledger is empty. */
59
+ export function latestUltragoalLedgerEvent(
60
+ events: readonly UltragoalLedgerEventLite[],
61
+ ): UltragoalLedgerEventLite | undefined {
62
+ return events.length > 0 ? events[events.length - 1] : undefined;
63
+ }
64
+
65
+ /**
66
+ * Best-effort latest event from raw ledger text: parses line-by-line and skips
67
+ * blank/malformed rows so a torn or hand-edited ledger never throws on the HUD
68
+ * path. Strict receipt consumers should keep using the validating reader.
69
+ */
70
+ export function latestUltragoalLedgerEventFromText(text: string): UltragoalLedgerEventLite | undefined {
71
+ const events: UltragoalLedgerEventLite[] = [];
72
+ for (const line of text.split(/\r?\n/)) {
73
+ const event = parseUltragoalLedgerLine(line);
74
+ if (event) events.push(event);
75
+ }
76
+ return latestUltragoalLedgerEvent(events);
77
+ }
78
+
79
+ /* -------------------------------- ralplan -------------------------------- */
80
+
81
+ /** Minimal projection of a ralplan `index.jsonl` row. */
82
+ export interface RalplanIndexRow {
83
+ stage: string;
84
+ stageN?: number;
85
+ }
86
+
87
+ /** Stages that open a new consensus iteration when they appear in append order. */
88
+ const RALPLAN_ITERATION_OPENERS = new Set(["planner", "revision"]);
89
+
90
+ const RALPLAN_STAGE_CODES: Record<string, string> = {
91
+ planner: "P",
92
+ revision: "R",
93
+ architect: "A",
94
+ critic: "C",
95
+ adr: "D",
96
+ final: "F",
97
+ };
98
+
99
+ const DEFAULT_STAGE_PRESENCE_CAP = 6;
100
+
101
+ /** Parse a single ralplan index JSONL line; undefined for blank/malformed lines. */
102
+ export function parseRalplanIndexLine(line: string): RalplanIndexRow | undefined {
103
+ const trimmed = line.trim();
104
+ if (!trimmed) return undefined;
105
+ let row: unknown;
106
+ try {
107
+ row = JSON.parse(trimmed);
108
+ } catch {
109
+ return undefined;
110
+ }
111
+ if (!row || typeof row !== "object" || Array.isArray(row)) return undefined;
112
+ const record = row as Record<string, unknown>;
113
+ if (typeof record.stage !== "string") return undefined;
114
+ const out: RalplanIndexRow = { stage: record.stage };
115
+ if (typeof record.stage_n === "number") out.stageN = record.stage_n;
116
+ return out;
117
+ }
118
+
119
+ export interface RalplanIndexSummary {
120
+ /** Number of consensus iterations (planner/revision boundaries), >= 0. */
121
+ iteration: number;
122
+ /** Stage names present in the current (latest) iteration, in append order. */
123
+ currentStages: string[];
124
+ }
125
+
126
+ /**
127
+ * Derive iteration count and current-iteration stage presence from index rows.
128
+ *
129
+ * `stage_n` is NOT used as the iteration key: it is stored verbatim per row and a
130
+ * single planner/architect/critic pass can span multiple stage_n values. Instead,
131
+ * a `planner` or `revision` row opens a new iteration and subsequent rows attach
132
+ * to it. No verdict is derived here (index rows carry none).
133
+ */
134
+ export function summarizeRalplanIndex(rows: readonly RalplanIndexRow[]): RalplanIndexSummary {
135
+ let iteration = 0;
136
+ let currentStages: string[] = [];
137
+ for (const row of rows) {
138
+ if (RALPLAN_ITERATION_OPENERS.has(row.stage)) {
139
+ iteration += 1;
140
+ currentStages = [row.stage];
141
+ } else {
142
+ if (iteration === 0) iteration = 1;
143
+ currentStages.push(row.stage);
144
+ }
145
+ }
146
+ return { iteration, currentStages };
147
+ }
148
+
149
+ /**
150
+ * Compact, theme-free presence string for the ralplan `stages` chip, e.g.
151
+ * `P·A·C`. Collapses past `cap` with a "… N more" suffix. Returns undefined when
152
+ * there are no stages.
153
+ */
154
+ export function formatRalplanStagePresence(
155
+ stages: readonly string[],
156
+ cap = DEFAULT_STAGE_PRESENCE_CAP,
157
+ ): string | undefined {
158
+ if (stages.length === 0) return undefined;
159
+ const codes = stages.map(stage => RALPLAN_STAGE_CODES[stage] ?? stage.charAt(0).toUpperCase());
160
+ if (codes.length <= cap) return codes.join("·");
161
+ const shown = codes.slice(0, cap).join("·");
162
+ const remaining = codes.length - cap;
163
+ return `${shown} … ${remaining} more ${remaining === 1 ? "stage" : "stages"}`;
164
+ }
@@ -5,6 +5,12 @@ import { syncSkillActiveState } from "../skill-state/active-state";
5
5
  import { buildRalplanHudSummary } from "../skill-state/workflow-hud";
6
6
  import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
7
7
  import { renderCliWriteReceipt } from "./cli-write-receipt";
8
+ import {
9
+ formatRalplanStagePresence,
10
+ parseRalplanIndexLine,
11
+ type RalplanIndexRow,
12
+ summarizeRalplanIndex,
13
+ } from "./ledger-event-renderer";
8
14
  import { isRestrictedRoleAgentBash } from "./restricted-role-agent-bash";
9
15
  import { migrateWorkflowState } from "./state-migrations";
10
16
  import { appendJsonl, readExistingStateForMutation, writeArtifact, writeWorkflowEnvelopeAtomic } from "./state-writer";
@@ -437,12 +443,32 @@ async function persistArtifact(resolved: ResolvedArtifactArgs, cwd: string): Pro
437
443
  };
438
444
  }
439
445
 
446
+ /**
447
+ * Read and parse the run's `index.jsonl` rows. Best-effort: returns [] when the
448
+ * file is absent or unreadable so HUD sync never fails on a missing index.
449
+ */
450
+ async function readRalplanIndexRows(cwd: string, runId: string): Promise<RalplanIndexRow[]> {
451
+ try {
452
+ const indexPath = path.join(cwd, ".gjc", "plans", "ralplan", runId, "index.jsonl");
453
+ const text = await fs.readFile(indexPath, "utf8");
454
+ const rows: RalplanIndexRow[] = [];
455
+ for (const line of text.split(/\r?\n/)) {
456
+ const row = parseRalplanIndexLine(line);
457
+ if (row) rows.push(row);
458
+ }
459
+ return rows;
460
+ } catch {
461
+ return [];
462
+ }
463
+ }
464
+
440
465
  async function syncRalplanHud(options: {
441
466
  cwd: string;
442
467
  sessionId?: string;
443
468
  stage: string;
444
469
  pendingApproval: boolean;
445
470
  iteration?: number;
471
+ runId?: string;
446
472
  latestSummary?: string;
447
473
  }): Promise<void> {
448
474
  try {
@@ -453,19 +479,42 @@ async function syncRalplanHud(options: {
453
479
  phase: options.stage,
454
480
  sessionId: options.sessionId,
455
481
  source: "gjc-ralplan-native",
456
- hud: buildRalplanHudSummary({
457
- stage: options.stage,
458
- iteration: options.iteration,
459
- pendingApproval: options.pendingApproval,
460
- latestSummary: options.latestSummary,
461
- updatedAt: new Date().toISOString(),
462
- }),
482
+ hud: await buildRalplanHud(options),
463
483
  });
464
484
  } catch {
465
485
  // HUD sync is best-effort and must not change command semantics.
466
486
  }
467
487
  }
468
488
 
489
+ async function buildRalplanHud(options: {
490
+ cwd: string;
491
+ stage: string;
492
+ pendingApproval: boolean;
493
+ iteration?: number;
494
+ latestSummary?: string;
495
+ runId?: string;
496
+ }) {
497
+ let iterationFromIndex: number | undefined;
498
+ let stages: string | undefined;
499
+ if (options.runId) {
500
+ const rows = await readRalplanIndexRows(options.cwd, options.runId);
501
+ if (rows.length > 0) {
502
+ const summary = summarizeRalplanIndex(rows);
503
+ iterationFromIndex = summary.iteration;
504
+ stages = formatRalplanStagePresence(summary.currentStages);
505
+ }
506
+ }
507
+ return buildRalplanHudSummary({
508
+ stage: options.stage,
509
+ iteration: options.iteration,
510
+ iterationFromIndex,
511
+ stages,
512
+ pendingApproval: options.pendingApproval,
513
+ latestSummary: options.latestSummary,
514
+ updatedAt: new Date().toISOString(),
515
+ });
516
+ }
517
+
469
518
  async function handleArtifactWrite(args: readonly string[], cwd: string): Promise<RalplanCommandResult> {
470
519
  const plannerState = parsePlannerStateArgs(args);
471
520
  const resolved = await resolveArtifactArgs(args, cwd);
@@ -477,6 +526,7 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
477
526
  cwd,
478
527
  sessionId: resolved.sessionId,
479
528
  stage: persisted.stage,
529
+ runId: persisted.runId,
480
530
  pendingApproval: persisted.stage === "final",
481
531
  iteration: persisted.stageN,
482
532
  latestSummary: `persisted ${persisted.stage} stage ${persisted.stageN}`,
@@ -617,6 +667,7 @@ async function handleConsensusHandoff(args: readonly string[], cwd: string): Pro
617
667
  cwd,
618
668
  sessionId: resolved.sessionId,
619
669
  stage: "planner",
670
+ runId,
620
671
  pendingApproval: false,
621
672
  iteration: 1,
622
673
  latestSummary: `${mode} run · ${resolved.interactive ? "interactive" : "automated"}`,
@@ -118,8 +118,13 @@ export interface StateStatusSummary {
118
118
 
119
119
  function compactStateFields(state: Record<string, unknown>): Array<[string, string]> {
120
120
  const fields: Array<[string, string]> = [];
121
+ const nested = isRecord(state.state) ? state.state : undefined;
121
122
  for (const key of COMPACT_ELIDE_KEYS) {
122
- const value = state[key];
123
+ const value = Array.isArray(state[key])
124
+ ? state[key]
125
+ : nested && Array.isArray(nested[key])
126
+ ? nested[key]
127
+ : undefined;
123
128
  if (Array.isArray(value)) fields.push([key, `${value.length} entries (elided)`]);
124
129
  }
125
130
  return fields;
@@ -133,9 +138,13 @@ export function compactProjectStateJson(
133
138
  const state = stateObject(stateJson);
134
139
  const compact = projectStateFields(skill, stateJson, manifest, STATE_FIELD_ALLOWLIST);
135
140
  const elisions: Record<string, unknown> = {};
141
+ const nested = isRecord(state.state) ? state.state : undefined;
136
142
  for (const key of COMPACT_ELIDE_KEYS) {
137
- const value = state[key];
138
- if (Array.isArray(value)) elisions[key] = { type: "array", count: value.length, pointer: `/${key}` };
143
+ if (Array.isArray(state[key])) {
144
+ elisions[key] = { type: "array", count: (state[key] as unknown[]).length, pointer: `/${key}` };
145
+ } else if (nested && Array.isArray(nested[key])) {
146
+ elisions[key] = { type: "array", count: (nested[key] as unknown[]).length, pointer: `/state/${key}` };
147
+ }
139
148
  }
140
149
  if (Object.keys(elisions).length) compact.elided = elisions;
141
150
  return compact;