@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.
- package/CHANGELOG.md +62 -0
- package/dist/types/async/job-manager.d.ts +26 -0
- package/dist/types/cli/args.d.ts +1 -0
- package/dist/types/cli/list-models.d.ts +6 -0
- package/dist/types/commands/gc.d.ts +26 -0
- package/dist/types/commands/harness.d.ts +3 -0
- package/dist/types/config/file-lock-gc.d.ts +5 -0
- package/dist/types/config/file-lock.d.ts +7 -0
- package/dist/types/config/model-profile-activation.d.ts +11 -2
- package/dist/types/config/model-profiles.d.ts +7 -0
- package/dist/types/config/model-registry.d.ts +3 -0
- package/dist/types/config/model-resolver.d.ts +2 -0
- package/dist/types/config/models-config-schema.d.ts +30 -0
- package/dist/types/config/settings-schema.d.ts +4 -3
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/defaults/gjc/extensions/grok-build/index.d.ts +1 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/index.d.ts +1 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +25 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.d.ts +27 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.d.ts +8 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.d.ts +5 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +10 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.d.ts +2 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.d.ts +2 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.d.ts +38 -0
- package/dist/types/defaults/gjc-grok-cli.d.ts +5 -0
- package/dist/types/extensibility/extensions/index.d.ts +1 -0
- package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +35 -0
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +103 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +112 -0
- package/dist/types/gjc-runtime/gc-render.d.ts +6 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +134 -0
- package/dist/types/gjc-runtime/ledger-event-renderer.d.ts +68 -0
- package/dist/types/gjc-runtime/team-gc.d.ts +7 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +5 -1
- package/dist/types/gjc-runtime/tmux-common.d.ts +14 -0
- package/dist/types/gjc-runtime/tmux-gc.d.ts +7 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +13 -0
- package/dist/types/harness-control-plane/gc-adapter.d.ts +3 -0
- package/dist/types/harness-control-plane/owner.d.ts +8 -1
- package/dist/types/harness-control-plane/receipt-spool.d.ts +19 -0
- package/dist/types/harness-control-plane/state-machine.d.ts +6 -1
- package/dist/types/harness-control-plane/storage.d.ts +20 -0
- package/dist/types/harness-control-plane/types.d.ts +4 -0
- package/dist/types/hindsight/mental-models.d.ts +5 -5
- package/dist/types/modes/components/hook-selector.d.ts +7 -1
- package/dist/types/modes/components/model-selector.d.ts +1 -12
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +2 -2
- package/dist/types/modes/rpc/rpc-mode.d.ts +16 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +4 -1
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +13 -0
- package/dist/types/modes/shared/agent-wire/session-registry.d.ts +25 -0
- package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +2 -0
- package/dist/types/sdk.d.ts +5 -0
- package/dist/types/session/agent-session.d.ts +3 -1
- package/dist/types/session/blob-store.d.ts +59 -4
- package/dist/types/session/session-manager.d.ts +24 -6
- package/dist/types/session/streaming-output.d.ts +3 -2
- package/dist/types/session/tool-choice-queue.d.ts +6 -0
- package/dist/types/skill-state/workflow-hud.d.ts +14 -0
- package/dist/types/task/receipt.d.ts +1 -0
- package/dist/types/task/types.d.ts +7 -0
- package/dist/types/thinking-metadata.d.ts +16 -0
- package/dist/types/thinking.d.ts +3 -12
- package/dist/types/tools/ask.d.ts +15 -1
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tools/resolve.d.ts +0 -10
- package/dist/types/tools/subagent.d.ts +6 -0
- package/dist/types/utils/tool-choice.d.ts +14 -1
- package/package.json +7 -7
- package/src/async/job-manager.ts +52 -0
- package/src/cli/args.ts +3 -0
- package/src/cli/auth-broker-cli.ts +1 -0
- package/src/cli/list-models.ts +13 -1
- package/src/cli.ts +9 -4
- package/src/commands/gc.ts +22 -0
- package/src/commands/harness.ts +43 -5
- package/src/commands/launch.ts +2 -2
- package/src/commands/session.ts +3 -1
- package/src/config/file-lock-gc.ts +181 -0
- package/src/config/file-lock.ts +14 -0
- package/src/config/model-profile-activation.ts +15 -3
- package/src/config/model-profiles.ts +264 -56
- package/src/config/model-resolver.ts +9 -6
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +6 -3
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/server.ts +513 -26
- package/src/cursor.ts +16 -2
- package/src/defaults/gjc/agent.models.grok-cli.yml +36 -0
- package/src/defaults/gjc/extensions/grok-build/index.ts +1 -0
- package/src/defaults/gjc/extensions/grok-build/package.json +7 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +39 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/package.json +8 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/index.ts +1 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +155 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +361 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.ts +57 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +99 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +50 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.ts +56 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +36 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.ts +44 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +131 -113
- package/src/defaults/gjc/skills/deep-interview/lateral-review-panel.md +49 -0
- package/src/defaults/gjc/skills/team/SKILL.md +3 -2
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +8 -2
- package/src/defaults/gjc-defaults.ts +7 -0
- package/src/defaults/gjc-grok-cli.ts +22 -0
- package/src/export/html/index.ts +13 -9
- package/src/extensibility/extensions/index.ts +1 -0
- package/src/extensibility/extensions/prefix-command-bridge.ts +128 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +417 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +18 -26
- package/src/gjc-runtime/deep-interview-state.ts +324 -0
- package/src/gjc-runtime/gc-render.ts +70 -0
- package/src/gjc-runtime/gc-runtime.ts +403 -0
- package/src/gjc-runtime/ledger-event-renderer.ts +164 -0
- package/src/gjc-runtime/ralplan-runtime.ts +58 -7
- package/src/gjc-runtime/state-renderer.ts +12 -3
- package/src/gjc-runtime/state-runtime.ts +46 -29
- package/src/gjc-runtime/team-gc.ts +49 -0
- package/src/gjc-runtime/team-runtime.ts +211 -8
- package/src/gjc-runtime/tmux-common.ts +29 -0
- package/src/gjc-runtime/tmux-gc.ts +176 -0
- package/src/gjc-runtime/tmux-sessions.ts +68 -12
- package/src/gjc-runtime/ultragoal-runtime.ts +517 -41
- package/src/gjc-runtime/workflow-manifest.generated.json +27 -1
- package/src/gjc-runtime/workflow-manifest.ts +16 -1
- package/src/harness-control-plane/gc-adapter.ts +184 -0
- package/src/harness-control-plane/owner.ts +89 -27
- package/src/harness-control-plane/receipt-spool.ts +128 -0
- package/src/harness-control-plane/state-machine.ts +27 -6
- package/src/harness-control-plane/storage.ts +93 -0
- package/src/harness-control-plane/types.ts +4 -0
- package/src/hindsight/mental-models.ts +17 -16
- package/src/internal-urls/docs-index.generated.ts +14 -8
- package/src/main.ts +7 -2
- package/src/modes/components/assistant-message.ts +26 -14
- package/src/modes/components/diff.ts +97 -0
- package/src/modes/components/hook-selector.ts +19 -0
- package/src/modes/components/model-selector.ts +370 -181
- package/src/modes/components/status-line/segments.ts +1 -1
- package/src/modes/components/tool-execution.ts +30 -13
- package/src/modes/controllers/command-controller.ts +25 -6
- package/src/modes/controllers/extension-ui-controller.ts +3 -0
- package/src/modes/controllers/selector-controller.ts +34 -42
- package/src/modes/rpc/rpc-client.ts +3 -2
- package/src/modes/rpc/rpc-mode.ts +187 -39
- package/src/modes/rpc/rpc-types.ts +5 -2
- package/src/modes/shared/agent-wire/command-dispatch.ts +279 -257
- package/src/modes/shared/agent-wire/command-validation.ts +11 -0
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
- package/src/modes/shared/agent-wire/session-registry.ts +109 -0
- package/src/modes/shared/agent-wire/unattended-action-policy.ts +24 -0
- package/src/modes/shared/agent-wire/unattended-run-controller.ts +23 -3
- package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
- package/src/sdk.ts +46 -5
- package/src/secrets/obfuscator.ts +102 -27
- package/src/session/agent-session.ts +179 -25
- package/src/session/blob-store.ts +148 -6
- package/src/session/session-manager.ts +311 -60
- package/src/session/streaming-output.ts +185 -122
- package/src/session/tool-choice-queue.ts +23 -0
- package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
- package/src/skill-state/workflow-hud.ts +106 -10
- package/src/slash-commands/builtin-registry.ts +3 -2
- package/src/task/executor.ts +78 -6
- package/src/task/receipt.ts +5 -0
- package/src/task/render.ts +21 -1
- package/src/task/types.ts +8 -0
- package/src/thinking-metadata.ts +51 -0
- package/src/thinking.ts +26 -46
- package/src/tools/ask.ts +56 -1
- package/src/tools/bash.ts +1 -1
- package/src/tools/index.ts +2 -0
- package/src/tools/job.ts +3 -2
- package/src/tools/monitor.ts +36 -1
- package/src/tools/resolve.ts +93 -18
- package/src/tools/subagent-render.ts +9 -0
- package/src/tools/subagent.ts +26 -2
- package/src/utils/edit-mode.ts +1 -1
- 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 {
|
|
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
|
|
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
|
-
|
|
509
|
-
|
|
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:
|
|
519
|
-
phase
|
|
525
|
+
active: phase !== "complete",
|
|
526
|
+
phase,
|
|
520
527
|
sessionId: options.sessionId,
|
|
521
528
|
source: "gjc-deep-interview-native",
|
|
522
|
-
hud:
|
|
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
|
+
}
|