@gajae-code/coding-agent 0.4.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/dist/types/cli/fast-help.d.ts +1 -0
- package/dist/types/cli/setup-cli.d.ts +2 -0
- package/dist/types/commands/harness.d.ts +6 -0
- package/dist/types/commands/setup.d.ts +6 -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 +6 -0
- package/dist/types/config/model-resolver.d.ts +2 -0
- package/dist/types/config/models-config-schema.d.ts +35 -0
- package/dist/types/config/settings-schema.d.ts +4 -3
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/coordinator-mcp/server.d.ts +8 -2
- package/dist/types/gjc-runtime/team-runtime.d.ts +0 -1
- package/dist/types/gjc-runtime/tmux-common.d.ts +3 -0
- package/dist/types/harness-control-plane/finalize.d.ts +5 -0
- package/dist/types/harness-control-plane/owner.d.ts +1 -1
- package/dist/types/harness-control-plane/phase-rollup.d.ts +23 -0
- package/dist/types/harness-control-plane/receipt-ingest.d.ts +19 -0
- package/dist/types/harness-control-plane/receipt-spool.d.ts +19 -0
- package/dist/types/harness-control-plane/receipts.d.ts +46 -0
- package/dist/types/harness-control-plane/rpc-adapter.d.ts +3 -0
- package/dist/types/harness-control-plane/state-machine.d.ts +6 -1
- package/dist/types/harness-control-plane/types.d.ts +13 -1
- package/dist/types/hindsight/mental-models.d.ts +5 -5
- package/dist/types/main.d.ts +2 -2
- package/dist/types/modes/components/model-selector.d.ts +1 -12
- package/dist/types/modes/rpc/rpc-client.d.ts +2 -2
- package/dist/types/modes/rpc/rpc-types.d.ts +4 -1
- package/dist/types/modes/utils/abort-message.d.ts +4 -0
- package/dist/types/sdk.d.ts +5 -0
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/blob-store.d.ts +20 -1
- package/dist/types/session/session-manager.d.ts +32 -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/setup/hermes-setup.d.ts +7 -0
- package/dist/types/task/fork-context-advisory.d.ts +13 -0
- package/dist/types/task/receipt.d.ts +2 -0
- package/dist/types/task/roi-reconciliation.d.ts +27 -0
- package/dist/types/task/types.d.ts +17 -0
- package/dist/types/thinking-metadata.d.ts +16 -0
- package/dist/types/thinking.d.ts +3 -12
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tools/resolve.d.ts +0 -10
- package/dist/types/utils/tool-choice.d.ts +14 -1
- package/package.json +8 -7
- package/scripts/build-binary.ts +4 -0
- package/src/cli/fast-help.ts +80 -0
- package/src/cli/setup-cli.ts +12 -3
- package/src/cli.ts +112 -17
- package/src/commands/coordinator.ts +44 -1
- package/src/commands/harness.ts +128 -11
- package/src/commands/launch.ts +2 -2
- package/src/commands/mcp-serve.ts +3 -2
- package/src/commands/session.ts +3 -1
- package/src/commands/setup.ts +4 -0
- package/src/config/model-profile-activation.ts +15 -3
- package/src/config/model-profiles.ts +255 -56
- package/src/config/model-resolver.ts +9 -6
- package/src/config/models-config-schema.ts +2 -0
- package/src/config/settings-schema.ts +6 -3
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/server.ts +427 -193
- package/src/cursor.ts +46 -4
- package/src/defaults/gjc/skills/team/SKILL.md +3 -2
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +8 -2
- package/src/export/html/index.ts +13 -9
- package/src/gjc-runtime/launch-worktree.ts +12 -1
- package/src/gjc-runtime/session-state-sidecar.ts +38 -0
- package/src/gjc-runtime/team-runtime.ts +33 -7
- package/src/gjc-runtime/tmux-common.ts +15 -0
- package/src/gjc-runtime/tmux-sessions.ts +19 -11
- package/src/gjc-runtime/ultragoal-runtime.ts +505 -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/finalize.ts +39 -5
- package/src/harness-control-plane/owner.ts +87 -28
- package/src/harness-control-plane/phase-rollup.ts +96 -0
- package/src/harness-control-plane/receipt-ingest.ts +127 -0
- package/src/harness-control-plane/receipt-spool.ts +128 -0
- package/src/harness-control-plane/receipts.ts +229 -1
- package/src/harness-control-plane/rpc-adapter.ts +8 -0
- package/src/harness-control-plane/state-machine.ts +27 -6
- package/src/harness-control-plane/storage.ts +23 -0
- package/src/harness-control-plane/types.ts +33 -1
- package/src/hindsight/mental-models.ts +17 -16
- package/src/internal-urls/docs-index.generated.ts +8 -7
- package/src/main.ts +7 -3
- package/src/modes/components/assistant-message.ts +26 -14
- package/src/modes/components/diff.ts +97 -0
- package/src/modes/components/model-selector.ts +353 -181
- package/src/modes/components/status-line.ts +6 -6
- package/src/modes/components/tool-execution.ts +30 -13
- package/src/modes/controllers/event-controller.ts +5 -4
- package/src/modes/controllers/selector-controller.ts +33 -42
- package/src/modes/interactive-mode.ts +4 -5
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/rpc/rpc-client.ts +3 -2
- package/src/modes/rpc/rpc-mode.ts +44 -14
- package/src/modes/rpc/rpc-types.ts +5 -2
- package/src/modes/shared/agent-wire/command-dispatch.ts +10 -5
- package/src/modes/shared/agent-wire/command-validation.ts +11 -0
- package/src/modes/theme/theme.ts +2 -2
- package/src/modes/utils/abort-message.ts +41 -0
- package/src/modes/utils/context-usage.ts +15 -8
- package/src/modes/utils/ui-helpers.ts +5 -6
- package/src/sdk.ts +38 -6
- package/src/secrets/obfuscator.ts +102 -27
- package/src/session/agent-session.ts +121 -25
- package/src/session/blob-store.ts +89 -3
- package/src/session/session-manager.ts +328 -57
- 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 +3 -2
- package/src/setup/hermes-setup.ts +63 -8
- package/src/task/executor.ts +69 -6
- package/src/task/fork-context-advisory.ts +99 -0
- package/src/task/index.ts +31 -2
- package/src/task/receipt.ts +7 -0
- package/src/task/render.ts +21 -1
- package/src/task/roi-reconciliation.ts +90 -0
- package/src/task/types.ts +15 -0
- package/src/thinking-metadata.ts +51 -0
- package/src/thinking.ts +26 -46
- package/src/tools/bash.ts +1 -1
- package/src/tools/index.ts +4 -2
- package/src/tools/resolve.ts +93 -18
- package/src/tools/subagent-render.ts +10 -1
- package/src/utils/edit-mode.ts +1 -1
- package/src/utils/title-generator.ts +16 -2
- package/src/utils/tool-choice.ts +45 -16
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { withFileLock } from "../config/file-lock";
|
|
5
|
+
import type { ReceiptEnvelope } from "./receipts";
|
|
6
|
+
|
|
7
|
+
export const RECEIPT_SPOOL_DIR_ENV = "GJC_RECEIPT_SPOOL_DIR";
|
|
8
|
+
export const RECEIPT_SPOOL_FILENAME = "spool.jsonl";
|
|
9
|
+
export const RECEIPT_SPOOL_CURSOR_WIDTH = 12;
|
|
10
|
+
|
|
11
|
+
export interface ReceiptSpoolRecord {
|
|
12
|
+
cursor: string;
|
|
13
|
+
envelope: ReceiptEnvelope<unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ReceiptSpoolAppendResult {
|
|
17
|
+
cursor: string;
|
|
18
|
+
path: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const receiptSpoolDirStorage = new AsyncLocalStorage<string | undefined>();
|
|
22
|
+
const spoolQueues = new Map<string, Promise<void>>();
|
|
23
|
+
const noop = (): void => undefined;
|
|
24
|
+
export async function withReceiptSpoolDir<T>(spoolDir: string, fn: () => Promise<T>): Promise<T> {
|
|
25
|
+
const trimmed = spoolDir.trim();
|
|
26
|
+
if (!trimmed) throw new Error("receipt_spool_dir_empty");
|
|
27
|
+
const resolved = path.resolve(trimmed);
|
|
28
|
+
return receiptSpoolDirStorage.run(resolved, fn);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveReceiptSpoolDir(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
32
|
+
const active = receiptSpoolDirStorage.getStore();
|
|
33
|
+
if (active !== undefined) return active;
|
|
34
|
+
const raw = env[RECEIPT_SPOOL_DIR_ENV]?.trim();
|
|
35
|
+
return raw ? path.resolve(raw) : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function receiptSpoolPath(spoolDir: string): string {
|
|
39
|
+
return path.join(path.resolve(spoolDir), RECEIPT_SPOOL_FILENAME);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseCursor(value: unknown): bigint | undefined {
|
|
43
|
+
if (typeof value !== "string" || !/^\d+$/.test(value)) return undefined;
|
|
44
|
+
try {
|
|
45
|
+
return BigInt(value);
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatReceiptSpoolCursor(cursor: bigint): string {
|
|
52
|
+
const raw = cursor.toString();
|
|
53
|
+
return raw.length >= RECEIPT_SPOOL_CURSOR_WIDTH ? raw : raw.padStart(RECEIPT_SPOOL_CURSOR_WIDTH, "0");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function readHighestReceiptSpoolCursor(spoolDir: string): Promise<bigint> {
|
|
57
|
+
const spoolFile = receiptSpoolPath(spoolDir);
|
|
58
|
+
let raw: string;
|
|
59
|
+
try {
|
|
60
|
+
raw = await fs.readFile(spoolFile, "utf8");
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return 0n;
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let highest = 0n;
|
|
67
|
+
for (const line of raw.split("\n")) {
|
|
68
|
+
const trimmed = line.trim();
|
|
69
|
+
if (!trimmed) continue;
|
|
70
|
+
try {
|
|
71
|
+
const parsed = JSON.parse(trimmed) as { cursor?: unknown };
|
|
72
|
+
const cursor = parseCursor(parsed.cursor);
|
|
73
|
+
if (cursor !== undefined && cursor > highest) highest = cursor;
|
|
74
|
+
} catch {
|
|
75
|
+
// A crash may leave a torn tail; consumers skip malformed lines and so do we.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return highest;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function enqueueSpoolAppend<T>(spoolFile: string, task: () => Promise<T>): Promise<T> {
|
|
82
|
+
const previous = spoolQueues.get(spoolFile) ?? Promise.resolve();
|
|
83
|
+
const running = previous.catch(noop).then(task);
|
|
84
|
+
const normalized = running.then(noop, noop);
|
|
85
|
+
spoolQueues.set(spoolFile, normalized);
|
|
86
|
+
normalized
|
|
87
|
+
.finally(() => {
|
|
88
|
+
if (spoolQueues.get(spoolFile) === normalized) spoolQueues.delete(spoolFile);
|
|
89
|
+
})
|
|
90
|
+
.catch(noop);
|
|
91
|
+
return running;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function appendReceiptToSpool(
|
|
95
|
+
spoolDir: string,
|
|
96
|
+
envelope: ReceiptEnvelope<unknown>,
|
|
97
|
+
): Promise<ReceiptSpoolAppendResult> {
|
|
98
|
+
const resolvedDir = path.resolve(spoolDir);
|
|
99
|
+
const spoolFile = receiptSpoolPath(resolvedDir);
|
|
100
|
+
return enqueueSpoolAppend(spoolFile, async () => {
|
|
101
|
+
await fs.mkdir(resolvedDir, { recursive: true, mode: 0o700 });
|
|
102
|
+
return withFileLock(
|
|
103
|
+
spoolFile,
|
|
104
|
+
async () => {
|
|
105
|
+
const cursor = formatReceiptSpoolCursor((await readHighestReceiptSpoolCursor(resolvedDir)) + 1n);
|
|
106
|
+
const record: ReceiptSpoolRecord = { cursor, envelope };
|
|
107
|
+
const handle = await fs.open(spoolFile, "a", 0o600);
|
|
108
|
+
try {
|
|
109
|
+
await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8");
|
|
110
|
+
await handle.sync();
|
|
111
|
+
} finally {
|
|
112
|
+
await handle.close();
|
|
113
|
+
}
|
|
114
|
+
return { cursor, path: spoolFile };
|
|
115
|
+
},
|
|
116
|
+
{ staleMs: 30_000, retries: 100, retryDelayMs: 25 },
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function appendReceiptToConfiguredSpool(
|
|
122
|
+
envelope: ReceiptEnvelope<unknown>,
|
|
123
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
124
|
+
): Promise<ReceiptSpoolAppendResult | undefined> {
|
|
125
|
+
const spoolDir = resolveReceiptSpoolDir(env);
|
|
126
|
+
if (!spoolDir) return undefined;
|
|
127
|
+
return appendReceiptToSpool(spoolDir, envelope);
|
|
128
|
+
}
|
|
@@ -46,7 +46,7 @@ export interface ReceiptEnvelope<E = Record<string, unknown>> {
|
|
|
46
46
|
export const RECEIPT_SCHEMA_VERSION = 1 as const;
|
|
47
47
|
|
|
48
48
|
/** Deterministic stringify with sorted keys (stable hash basis). */
|
|
49
|
-
function canonicalJson(value: unknown): string {
|
|
49
|
+
export function canonicalJson(value: unknown): string {
|
|
50
50
|
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
51
51
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
52
52
|
const obj = value as Record<string, unknown>;
|
|
@@ -96,9 +96,50 @@ export interface ValidationOutcome {
|
|
|
96
96
|
reasons: string[];
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/** Reusable non-empty string guard for structural envelope checks. */
|
|
100
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
101
|
+
return typeof value === "string" && value.length > 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Validate the structural envelope fields independently of the hash. A receipt
|
|
106
|
+
* can be hash-self-consistent while carrying empty/missing identity fields (an
|
|
107
|
+
* attacker controls the bytes the hash is computed over), so these checks must
|
|
108
|
+
* run BEFORE any lifecycle transition is allowed. Fail-closed.
|
|
109
|
+
*/
|
|
110
|
+
function validateStructure(receipt: ReceiptEnvelope<unknown>): string[] {
|
|
111
|
+
const reasons: string[] = [];
|
|
112
|
+
if (!isNonEmptyString(receipt.receiptId)) reasons.push("envelope-missing-receiptId");
|
|
113
|
+
if (!isNonEmptyString(receipt.sessionId)) reasons.push("envelope-missing-sessionId");
|
|
114
|
+
if (!isNonEmptyString(receipt.source)) reasons.push("envelope-missing-source");
|
|
115
|
+
if (!isNonEmptyString(receipt.createdAt)) reasons.push("envelope-missing-createdAt");
|
|
116
|
+
// Family vocabulary itself is enforced by `validateFamily`; here we only
|
|
117
|
+
// require a non-empty family token so the envelope is well-formed.
|
|
118
|
+
if (!isNonEmptyString(receipt.family)) reasons.push("envelope-missing-family");
|
|
119
|
+
if (typeof receipt.valid !== "boolean") reasons.push("envelope-bad-valid");
|
|
120
|
+
if (typeof receipt.sha256 !== "string") reasons.push("envelope-missing-sha256");
|
|
121
|
+
const subject = receipt.subject as ReceiptSubject | undefined;
|
|
122
|
+
if (!subject || typeof subject !== "object" || Array.isArray(subject) || !isNonEmptyString(subject.workspace)) {
|
|
123
|
+
reasons.push("envelope-bad-subject");
|
|
124
|
+
}
|
|
125
|
+
if (receipt.evidence === null || typeof receipt.evidence !== "object" || Array.isArray(receipt.evidence)) {
|
|
126
|
+
reasons.push("envelope-bad-evidence");
|
|
127
|
+
}
|
|
128
|
+
if (!receipt.artifactHashes || typeof receipt.artifactHashes !== "object" || Array.isArray(receipt.artifactHashes)) {
|
|
129
|
+
reasons.push("envelope-bad-artifactHashes");
|
|
130
|
+
}
|
|
131
|
+
return reasons;
|
|
132
|
+
}
|
|
133
|
+
|
|
99
134
|
/** Recompute the hash and run structural family checks. Fail-closed. */
|
|
100
135
|
export function validateReceipt(receipt: ReceiptEnvelope<unknown>): ValidationOutcome {
|
|
136
|
+
// Fail closed on malformed/non-object envelopes (null, undefined, arrays,
|
|
137
|
+
// primitives) instead of throwing while destructuring below.
|
|
138
|
+
if (receipt === null || typeof receipt !== "object" || Array.isArray(receipt)) {
|
|
139
|
+
return { valid: false, reasons: ["malformed-envelope"] };
|
|
140
|
+
}
|
|
101
141
|
const reasons: string[] = [];
|
|
142
|
+
reasons.push(...validateStructure(receipt));
|
|
102
143
|
const { sha256, ...rest } = receipt;
|
|
103
144
|
if (sha256Hex(hashBasis(rest)) !== sha256) reasons.push("hash-mismatch");
|
|
104
145
|
if (receipt.schemaVersion !== RECEIPT_SCHEMA_VERSION) reasons.push("schema-version-mismatch");
|
|
@@ -156,6 +197,10 @@ export interface ReviewVerdictEvidence {
|
|
|
156
197
|
finalizedAt: string;
|
|
157
198
|
/** Bounded summary code/reference for the verdict; never raw assistant text. */
|
|
158
199
|
summaryRef: string | null;
|
|
200
|
+
/** Where the verdict came from: explicit operator input or extracted from final assistant text. */
|
|
201
|
+
verdictSource?: "input" | "assistant";
|
|
202
|
+
/** sha256 of the assistant text the verdict was extracted from, when sourced from the agent. */
|
|
203
|
+
assistantDigest?: string | null;
|
|
159
204
|
}
|
|
160
205
|
|
|
161
206
|
export interface ReviewFailureEvidence {
|
|
@@ -165,6 +210,10 @@ export interface ReviewFailureEvidence {
|
|
|
165
210
|
failedAt: string;
|
|
166
211
|
/** Routing hint for the operator/fallback path. */
|
|
167
212
|
fallback: string;
|
|
213
|
+
/** sha256 of the assistant text examined for a verdict, when one was available. */
|
|
214
|
+
assistantDigest?: string | null;
|
|
215
|
+
/** Bounded, whitespace-collapsed assistant summary (never an unbounded transcript dump). */
|
|
216
|
+
assistantSummary?: string | null;
|
|
168
217
|
}
|
|
169
218
|
|
|
170
219
|
function validateFamily(receipt: ReceiptEnvelope<unknown>): string[] {
|
|
@@ -181,11 +230,187 @@ function validateFamily(receipt: ReceiptEnvelope<unknown>): string[] {
|
|
|
181
230
|
return validateReviewVerdict(receipt.evidence as ReviewVerdictEvidence);
|
|
182
231
|
case "review-failure":
|
|
183
232
|
return validateReviewFailure(receipt.evidence as ReviewFailureEvidence);
|
|
233
|
+
case "phase-rollup":
|
|
234
|
+
return validatePhaseRollup(receipt.evidence as PhaseRollupEvidence);
|
|
184
235
|
default:
|
|
185
236
|
return [`unknown-family:${receipt.family}`];
|
|
186
237
|
}
|
|
187
238
|
}
|
|
188
239
|
|
|
240
|
+
// ---- Phase rollup (receipt-of-receipts) ----------------------------------------
|
|
241
|
+
|
|
242
|
+
/** Pointer back to one superseded child task receipt. */
|
|
243
|
+
export interface PhaseRollupChildPointer {
|
|
244
|
+
id: string;
|
|
245
|
+
status: "completed" | "failed" | "aborted" | "merge_failed" | "paused";
|
|
246
|
+
/** Artifact URI holding the child's full output, when available. */
|
|
247
|
+
outputUri: string | null;
|
|
248
|
+
/** Content hash of the child's output artifact, when available. */
|
|
249
|
+
outputSha256: string | null;
|
|
250
|
+
/** Hash of the child receipt itself (canonical JSON), for staleness checks. */
|
|
251
|
+
receiptSha256: string;
|
|
252
|
+
/**
|
|
253
|
+
* Per-child ROI accounting carried into the rollup so the aggregate totals
|
|
254
|
+
* below are recomputable/verifiable from child evidence (not self-reported).
|
|
255
|
+
* `tokens` is the child's effective token count; cost/cloned are null when
|
|
256
|
+
* the child reported no such accounting.
|
|
257
|
+
*/
|
|
258
|
+
tokens: number;
|
|
259
|
+
costTotal: number | null;
|
|
260
|
+
clonedTokens: number | null;
|
|
261
|
+
lowRoi: boolean;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface PhaseRollupEvidence {
|
|
265
|
+
/** Harness lifecycle boundary this rollup was emitted at. */
|
|
266
|
+
phase: string;
|
|
267
|
+
children: PhaseRollupChildPointer[];
|
|
268
|
+
aggregate: {
|
|
269
|
+
childCount: number;
|
|
270
|
+
completed: number;
|
|
271
|
+
failed: number;
|
|
272
|
+
totalTokens: number;
|
|
273
|
+
totalCostTotal: number | null;
|
|
274
|
+
totalClonedTokens: number | null;
|
|
275
|
+
lowRoiChildIds: string[];
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
280
|
+
|
|
281
|
+
const PHASE_ROLLUP_CHILD_STATUSES = new Set(["completed", "failed", "aborted", "merge_failed", "paused"]);
|
|
282
|
+
|
|
283
|
+
/** Reconcile two recomputed-vs-reported numeric totals (null == "not reported"). */
|
|
284
|
+
function numbersReconcile(actual: number | null, expected: number | null): boolean {
|
|
285
|
+
if (actual === null || expected === null) return actual === expected;
|
|
286
|
+
return Math.abs(actual - expected) <= 1e-9;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** True when two id lists describe the same set (order-independent). */
|
|
290
|
+
function sameIdSet(actual: readonly string[], expected: readonly string[]): boolean {
|
|
291
|
+
if (actual.length !== expected.length) return false;
|
|
292
|
+
const expectedSet = new Set(expected);
|
|
293
|
+
for (const id of actual) {
|
|
294
|
+
if (!expectedSet.has(id)) return false;
|
|
295
|
+
}
|
|
296
|
+
return new Set(actual).size === expectedSet.size;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function validatePhaseRollup(e: PhaseRollupEvidence): string[] {
|
|
300
|
+
const reasons: string[] = [];
|
|
301
|
+
if (!e || typeof e.phase !== "string" || e.phase.length === 0) return ["phase-rollup-missing-phase"];
|
|
302
|
+
if (!Array.isArray(e.children) || e.children.length === 0) {
|
|
303
|
+
reasons.push("phase-rollup-empty-children");
|
|
304
|
+
return reasons;
|
|
305
|
+
}
|
|
306
|
+
const seenIds = new Set<string>();
|
|
307
|
+
let completedFromChildren = 0;
|
|
308
|
+
let failedFromChildren = 0;
|
|
309
|
+
let tokensFromChildren = 0;
|
|
310
|
+
let costFromChildren = 0;
|
|
311
|
+
let clonedFromChildren = 0;
|
|
312
|
+
let anyCost = false;
|
|
313
|
+
let anyCloned = false;
|
|
314
|
+
const lowRoiFromChildren: string[] = [];
|
|
315
|
+
for (const child of e.children) {
|
|
316
|
+
if (!child || typeof child.id !== "string" || child.id.length === 0) {
|
|
317
|
+
reasons.push("phase-rollup-child-missing-id");
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (seenIds.has(child.id)) reasons.push(`phase-rollup-duplicate-child-id:${child.id}`);
|
|
321
|
+
seenIds.add(child.id);
|
|
322
|
+
if (!PHASE_ROLLUP_CHILD_STATUSES.has(child.status)) {
|
|
323
|
+
reasons.push(`phase-rollup-child-bad-status:${child.id}`);
|
|
324
|
+
}
|
|
325
|
+
if (child.status === "completed") completedFromChildren++;
|
|
326
|
+
if (child.status === "failed" || child.status === "merge_failed") failedFromChildren++;
|
|
327
|
+
if (typeof child.receiptSha256 !== "string" || !SHA256_HEX.test(child.receiptSha256)) {
|
|
328
|
+
reasons.push(`phase-rollup-child-bad-receipt-hash:${child.id}`);
|
|
329
|
+
}
|
|
330
|
+
if (child.outputUri !== null && (typeof child.outputUri !== "string" || child.outputUri.length === 0)) {
|
|
331
|
+
reasons.push(`phase-rollup-child-bad-output-uri:${child.id}`);
|
|
332
|
+
}
|
|
333
|
+
if (child.outputSha256 !== null && !SHA256_HEX.test(child.outputSha256)) {
|
|
334
|
+
reasons.push(`phase-rollup-child-bad-output-hash:${child.id}`);
|
|
335
|
+
}
|
|
336
|
+
// Receipt-of-receipts integrity requires BOTH an output URI and its
|
|
337
|
+
// content hash. Reject either one-sided pairing fail-closed: a hash
|
|
338
|
+
// without a URI is unanchored, and a URI without a hash is unverifiable.
|
|
339
|
+
if (child.outputSha256 !== null && child.outputUri === null) {
|
|
340
|
+
reasons.push(`phase-rollup-child-orphan-output-hash:${child.id}`);
|
|
341
|
+
}
|
|
342
|
+
if (child.outputUri !== null && child.outputSha256 === null) {
|
|
343
|
+
reasons.push(`phase-rollup-child-orphan-output-uri:${child.id}`);
|
|
344
|
+
}
|
|
345
|
+
// Per-child ROI accounting must be well-formed before it can be summed
|
|
346
|
+
// for the recomputed aggregate reconciliation below.
|
|
347
|
+
if (typeof child.tokens !== "number" || !Number.isFinite(child.tokens) || child.tokens < 0) {
|
|
348
|
+
reasons.push(`phase-rollup-child-bad-tokens:${child.id}`);
|
|
349
|
+
} else {
|
|
350
|
+
tokensFromChildren += child.tokens;
|
|
351
|
+
}
|
|
352
|
+
if (child.costTotal !== null) {
|
|
353
|
+
if (typeof child.costTotal !== "number" || !Number.isFinite(child.costTotal) || child.costTotal < 0) {
|
|
354
|
+
reasons.push(`phase-rollup-child-bad-cost:${child.id}`);
|
|
355
|
+
} else {
|
|
356
|
+
anyCost = true;
|
|
357
|
+
costFromChildren += child.costTotal;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (child.clonedTokens !== null) {
|
|
361
|
+
if (typeof child.clonedTokens !== "number" || !Number.isFinite(child.clonedTokens) || child.clonedTokens < 0) {
|
|
362
|
+
reasons.push(`phase-rollup-child-bad-cloned-tokens:${child.id}`);
|
|
363
|
+
} else {
|
|
364
|
+
anyCloned = true;
|
|
365
|
+
clonedFromChildren += child.clonedTokens;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (typeof child.lowRoi !== "boolean") {
|
|
369
|
+
reasons.push(`phase-rollup-child-bad-low-roi:${child.id}`);
|
|
370
|
+
} else if (child.lowRoi) {
|
|
371
|
+
lowRoiFromChildren.push(child.id);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const aggregate = e.aggregate;
|
|
375
|
+
if (!aggregate || typeof aggregate.childCount !== "number") {
|
|
376
|
+
reasons.push("phase-rollup-missing-aggregate");
|
|
377
|
+
return reasons;
|
|
378
|
+
}
|
|
379
|
+
if (aggregate.childCount !== e.children.length) reasons.push("phase-rollup-child-count-mismatch");
|
|
380
|
+
if (aggregate.completed !== completedFromChildren) reasons.push("phase-rollup-aggregate-completed-mismatch");
|
|
381
|
+
if (aggregate.failed !== failedFromChildren) reasons.push("phase-rollup-aggregate-failed-mismatch");
|
|
382
|
+
for (const field of ["totalTokens", "completed", "failed"] as const) {
|
|
383
|
+
const value = aggregate[field];
|
|
384
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
385
|
+
reasons.push(`phase-rollup-aggregate-bad-${field}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
for (const field of ["totalCostTotal", "totalClonedTokens"] as const) {
|
|
389
|
+
const value = aggregate[field];
|
|
390
|
+
if (value !== null && (typeof value !== "number" || !Number.isFinite(value) || value < 0)) {
|
|
391
|
+
reasons.push(`phase-rollup-aggregate-bad-${field}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// Recompute the ROI aggregates from child evidence and fail closed on any
|
|
395
|
+
// self-reported total that does not reconcile. `null` is the canonical
|
|
396
|
+
// "no child reported this metric" value, mirroring the builder.
|
|
397
|
+
if (aggregate.totalTokens !== tokensFromChildren) {
|
|
398
|
+
reasons.push("phase-rollup-aggregate-tokens-mismatch");
|
|
399
|
+
}
|
|
400
|
+
if (!numbersReconcile(aggregate.totalCostTotal, anyCost ? costFromChildren : null)) {
|
|
401
|
+
reasons.push("phase-rollup-aggregate-cost-mismatch");
|
|
402
|
+
}
|
|
403
|
+
if (!numbersReconcile(aggregate.totalClonedTokens, anyCloned ? clonedFromChildren : null)) {
|
|
404
|
+
reasons.push("phase-rollup-aggregate-cloned-tokens-mismatch");
|
|
405
|
+
}
|
|
406
|
+
if (!Array.isArray(aggregate.lowRoiChildIds)) {
|
|
407
|
+
reasons.push("phase-rollup-aggregate-bad-lowRoiChildIds");
|
|
408
|
+
} else if (!sameIdSet(aggregate.lowRoiChildIds, lowRoiFromChildren)) {
|
|
409
|
+
reasons.push("phase-rollup-aggregate-low-roi-mismatch");
|
|
410
|
+
}
|
|
411
|
+
return reasons;
|
|
412
|
+
}
|
|
413
|
+
|
|
189
414
|
function validateVanish(e: VanishEvidence): string[] {
|
|
190
415
|
const reasons: string[] = [];
|
|
191
416
|
if (!e || typeof e.gitDelta !== "string") return ["vanish-missing-evidence"];
|
|
@@ -230,6 +455,9 @@ function validateCompletion(e: CompletionEvidence): string[] {
|
|
|
230
455
|
reasons.push("completion-missing-validation-receipts");
|
|
231
456
|
}
|
|
232
457
|
if (Array.isArray(e.blockers) && e.blockers.length > 0) reasons.push("completion-has-blockers");
|
|
458
|
+
// NOTE: evidence.finalLifecycle vs the lifecycle target is reconciled
|
|
459
|
+
// fail-closed at the ingest layer (`evidenceContradiction` ->
|
|
460
|
+
// `evidence-lifecycle-mismatch`), where the actual transition is gated.
|
|
233
461
|
return reasons;
|
|
234
462
|
}
|
|
235
463
|
|
|
@@ -36,6 +36,8 @@ export interface HarnessRpc {
|
|
|
36
36
|
isLive?(): boolean;
|
|
37
37
|
/** ISO timestamp of the last observed event frame, or null. */
|
|
38
38
|
lastFrameAt?(): string | null;
|
|
39
|
+
/** Final assistant text from the live session (for review-verdict extraction); null when unavailable. */
|
|
40
|
+
getLastAssistantText?(): Promise<string | null>;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export interface AcceptanceResult {
|
|
@@ -240,6 +242,12 @@ export class GajaeCodeRpc implements HarnessRpc {
|
|
|
240
242
|
};
|
|
241
243
|
}
|
|
242
244
|
|
|
245
|
+
async getLastAssistantText(): Promise<string | null> {
|
|
246
|
+
const res = await this.#send({ type: "get_last_assistant_text" });
|
|
247
|
+
const data = (res.data ?? {}) as Record<string, unknown>;
|
|
248
|
+
return typeof data.text === "string" ? data.text : null;
|
|
249
|
+
}
|
|
250
|
+
|
|
243
251
|
async sendPrompt(prompt: string): Promise<{ commandId: string; ack: boolean }> {
|
|
244
252
|
const id = randomUUID();
|
|
245
253
|
const ackPromise = new Promise<Record<string, unknown>>((resolve, reject) => {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { HarnessLifecycle, NextAllowedAction, PrimitiveResponse, SessionState, SessionStateView } from "./types";
|
|
9
9
|
|
|
10
10
|
const TERMINAL_LIFECYCLES: ReadonlySet<HarnessLifecycle> = new Set(["completed", "retired"]);
|
|
11
|
+
const SUBMIT_READY_LIFECYCLES: ReadonlySet<HarnessLifecycle> = new Set(["started", "observing"]);
|
|
11
12
|
|
|
12
13
|
const TRANSITIONS: Record<HarnessLifecycle, readonly HarnessLifecycle[]> = {
|
|
13
14
|
new: ["started", "blocked", "retired"],
|
|
@@ -37,11 +38,32 @@ export function assertTransition(from: HarnessLifecycle, to: HarnessLifecycle):
|
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
export interface NextAllowedActionsOptions {
|
|
42
|
+
/** Additional live-owner/RPC readiness gate for submit, e.g. rpc-not-idle. */
|
|
43
|
+
submitUnavailableReason?: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function submitUnavailableReason(
|
|
47
|
+
lifecycle: HarnessLifecycle,
|
|
48
|
+
ownerLive: boolean,
|
|
49
|
+
gateReason: string | null = null,
|
|
50
|
+
): string | null {
|
|
51
|
+
if (isTerminal(lifecycle)) return `lifecycle-terminal:${lifecycle}`;
|
|
52
|
+
if (lifecycle === "blocked") return "lifecycle-blocked";
|
|
53
|
+
if (!SUBMIT_READY_LIFECYCLES.has(lifecycle)) return `lifecycle-not-idle:${lifecycle}`;
|
|
54
|
+
if (!ownerLive) return "owner-not-live";
|
|
55
|
+
return gateReason;
|
|
56
|
+
}
|
|
57
|
+
|
|
40
58
|
/**
|
|
41
59
|
* Derive the permitted next actions for a session given its lifecycle and whether
|
|
42
60
|
* a live owner currently holds the lease.
|
|
43
61
|
*/
|
|
44
|
-
export function nextAllowedActions(
|
|
62
|
+
export function nextAllowedActions(
|
|
63
|
+
lifecycle: HarnessLifecycle,
|
|
64
|
+
ownerLive: boolean,
|
|
65
|
+
options: NextAllowedActionsOptions = {},
|
|
66
|
+
): NextAllowedAction[] {
|
|
45
67
|
const terminal = isTerminal(lifecycle);
|
|
46
68
|
const actions: NextAllowedAction[] = [];
|
|
47
69
|
const add = (verb: NextAllowedAction["verb"], available: boolean, reason?: string): void => {
|
|
@@ -57,11 +79,10 @@ export function nextAllowedActions(lifecycle: HarnessLifecycle, ownerLive: boole
|
|
|
57
79
|
// `start` creates a new session; never re-applicable to an existing record.
|
|
58
80
|
add("start", false, "session-already-exists");
|
|
59
81
|
|
|
60
|
-
// `submit` is owner-routed: it requires a live owner
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
else add("submit", true);
|
|
82
|
+
// `submit` is owner-routed: it requires a live owner, a submit-ready lifecycle,
|
|
83
|
+
// and (for owner-observed responses) an idle/routable RPC backend.
|
|
84
|
+
const submitReason = submitUnavailableReason(lifecycle, ownerLive, options.submitUnavailableReason ?? null);
|
|
85
|
+
add("submit", submitReason === null, submitReason ?? undefined);
|
|
65
86
|
|
|
66
87
|
// `recover` handles a dead/failed owner, so it is available without a live owner.
|
|
67
88
|
add("recover", !terminal, terminal ? `lifecycle-terminal:${lifecycle}` : undefined);
|
|
@@ -18,6 +18,8 @@ import * as fsSync from "node:fs";
|
|
|
18
18
|
import * as fs from "node:fs/promises";
|
|
19
19
|
import * as os from "node:os";
|
|
20
20
|
import * as path from "node:path";
|
|
21
|
+
import { appendReceiptToConfiguredSpool } from "./receipt-spool";
|
|
22
|
+
import type { ReceiptEnvelope } from "./receipts";
|
|
21
23
|
import type { EventEnvelope, ReceiptFamily, SessionState } from "./types";
|
|
22
24
|
|
|
23
25
|
interface HarnessRootRegistryEntry {
|
|
@@ -227,6 +229,26 @@ async function readJson<T>(file: string): Promise<T | null> {
|
|
|
227
229
|
throw error;
|
|
228
230
|
}
|
|
229
231
|
}
|
|
232
|
+
function isReceiptEnvelope(value: unknown): value is ReceiptEnvelope<unknown> {
|
|
233
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
234
|
+
const envelope = value as Record<string, unknown>;
|
|
235
|
+
return (
|
|
236
|
+
typeof envelope.receiptId === "string" &&
|
|
237
|
+
typeof envelope.schemaVersion === "number" &&
|
|
238
|
+
typeof envelope.sessionId === "string" &&
|
|
239
|
+
typeof envelope.family === "string" &&
|
|
240
|
+
typeof envelope.valid === "boolean" &&
|
|
241
|
+
typeof envelope.createdAt === "string" &&
|
|
242
|
+
typeof envelope.source === "string" &&
|
|
243
|
+
envelope.subject !== null &&
|
|
244
|
+
typeof envelope.subject === "object" &&
|
|
245
|
+
envelope.evidence !== null &&
|
|
246
|
+
typeof envelope.evidence === "object" &&
|
|
247
|
+
envelope.artifactHashes !== null &&
|
|
248
|
+
typeof envelope.artifactHashes === "object" &&
|
|
249
|
+
typeof envelope.sha256 === "string"
|
|
250
|
+
);
|
|
251
|
+
}
|
|
230
252
|
|
|
231
253
|
export async function readSessionState(root: string, sessionId: string): Promise<SessionState | null> {
|
|
232
254
|
return readJson<SessionState>(sessionPaths(root, sessionId).state);
|
|
@@ -372,6 +394,7 @@ export async function writeReceiptImmutable(
|
|
|
372
394
|
path: file,
|
|
373
395
|
};
|
|
374
396
|
await fs.appendFile(paths.receiptsIndex, `${JSON.stringify(entry)}\n`, "utf8");
|
|
397
|
+
if (isReceiptEnvelope(value)) await appendReceiptToConfiguredSpool(value);
|
|
375
398
|
return entry;
|
|
376
399
|
}
|
|
377
400
|
|
|
@@ -29,6 +29,33 @@ export function isReviewVerdict(value: unknown): value is ReviewVerdict {
|
|
|
29
29
|
return typeof value === "string" && (REVIEW_VERDICTS as readonly string[]).includes(value);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Alias verdict tokens accepted from free-form assistant text, mapped to their canonical verdict.
|
|
34
|
+
* `MERGE_READY` is treated as `APPROVE_MERGE_READY`.
|
|
35
|
+
*/
|
|
36
|
+
const VERDICT_ALIASES: Readonly<Record<string, ReviewVerdict>> = {
|
|
37
|
+
MERGE_READY: "APPROVE_MERGE_READY",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Extract a single closed-vocabulary review verdict from free-form assistant text.
|
|
42
|
+
*
|
|
43
|
+
* Scans for canonical verdict tokens (and accepted aliases) as whole words and returns the
|
|
44
|
+
* LAST occurrence — the agent's final stated decision wins over any earlier mention. Returns
|
|
45
|
+
* null when no allowed token is present, so the finalizer fails closed on a missing verdict.
|
|
46
|
+
*/
|
|
47
|
+
export function extractReviewVerdict(text: string | null | undefined): ReviewVerdict | null {
|
|
48
|
+
if (typeof text !== "string" || text.length === 0) return null;
|
|
49
|
+
const tokens = [...REVIEW_VERDICTS, ...Object.keys(VERDICT_ALIASES)];
|
|
50
|
+
const pattern = new RegExp(`\\b(${tokens.join("|")})\\b`, "g");
|
|
51
|
+
let last: ReviewVerdict | null = null;
|
|
52
|
+
for (const match of text.matchAll(pattern)) {
|
|
53
|
+
const token = match[1];
|
|
54
|
+
last = VERDICT_ALIASES[token] ?? (token as ReviewVerdict);
|
|
55
|
+
}
|
|
56
|
+
return last;
|
|
57
|
+
}
|
|
58
|
+
|
|
32
59
|
/** Lifecycle states of an operated session. */
|
|
33
60
|
export type HarnessLifecycle =
|
|
34
61
|
| "new"
|
|
@@ -68,7 +95,8 @@ export type ReceiptFamily =
|
|
|
68
95
|
| "validation"
|
|
69
96
|
| "completion"
|
|
70
97
|
| "review-verdict"
|
|
71
|
-
| "review-failure"
|
|
98
|
+
| "review-failure"
|
|
99
|
+
| "phase-rollup";
|
|
72
100
|
|
|
73
101
|
/** The CLI verbs / primitives exposed by `gjc harness <verb>`. */
|
|
74
102
|
export type HarnessVerb =
|
|
@@ -182,6 +210,10 @@ export interface Observation {
|
|
|
182
210
|
rpcLive?: boolean;
|
|
183
211
|
/** ISO timestamp of the most recent RPC frame the owner observed, if any. */
|
|
184
212
|
rpcLastFrameAt?: string | null;
|
|
213
|
+
/** True only when owner/rpc/lifecycle gates indicate a prompt can be submitted now. */
|
|
214
|
+
readyForSubmit?: boolean;
|
|
215
|
+
/** Present when readyForSubmit is false; mirrors submit's nextAllowedActions reason. */
|
|
216
|
+
submitUnavailableReason?: string | null;
|
|
185
217
|
}
|
|
186
218
|
|
|
187
219
|
/** Input to the deterministic recovery classifier. */
|
|
@@ -295,12 +295,12 @@ export function summarizeMentalModel(model: MentalModelSummary): string {
|
|
|
295
295
|
* snapshot only; the diff is computed locally for display purposes.
|
|
296
296
|
*
|
|
297
297
|
* This is intentionally minimal — for "what changed" at a glance, not for a
|
|
298
|
-
* full structural diff. Each side is capped at `MAX_LCS_LINES` lines
|
|
299
|
-
* the
|
|
300
|
-
*
|
|
301
|
-
*
|
|
298
|
+
* full structural diff. Each side is capped at `MAX_LCS_LINES` lines before
|
|
299
|
+
* the Hunt-Szymanski LCS pass so a long curated model can never hang the TUI;
|
|
300
|
+
* output is then capped at `maxLines` so the rendered diff stays readable. The
|
|
301
|
+
* cap is signalled inline.
|
|
302
302
|
*/
|
|
303
|
-
/** Hard cap on input line count per side before LCS. Keeps
|
|
303
|
+
/** Hard cap on input line count per side before LCS. Keeps worst-case repeated-line matching bounded. */
|
|
304
304
|
export const MAX_LCS_LINES = 1_000;
|
|
305
305
|
|
|
306
306
|
export function diffMentalModelContent(previous: string | null, current: string, maxLines = 200): string {
|
|
@@ -346,24 +346,25 @@ export function diffMentalModelContent(previous: string | null, current: string,
|
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
function longestCommonSubsequence(a: string[], b: string[]): string[] {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
349
|
+
return longestCommonSubsequenceDense(a, b);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function longestCommonSubsequenceDense(a: string[], b: string[]): string[] {
|
|
353
|
+
const table: number[][] = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
354
|
+
for (let i = 0; i < a.length; i++) {
|
|
355
|
+
for (let j = 0; j < b.length; j++) {
|
|
356
|
+
table[i + 1]![j + 1] = a[i] === b[j] ? table[i]![j]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!);
|
|
356
357
|
}
|
|
357
358
|
}
|
|
358
359
|
const out: string[] = [];
|
|
359
|
-
let i =
|
|
360
|
-
let j =
|
|
360
|
+
let i = a.length;
|
|
361
|
+
let j = b.length;
|
|
361
362
|
while (i > 0 && j > 0) {
|
|
362
363
|
if (a[i - 1] === b[j - 1]) {
|
|
363
|
-
out.push(a[i - 1]);
|
|
364
|
+
out.push(a[i - 1]!);
|
|
364
365
|
i--;
|
|
365
366
|
j--;
|
|
366
|
-
} else if (table[i - 1][j] >= table[i][j - 1]) {
|
|
367
|
+
} else if (table[i - 1]![j]! >= table[i]![j - 1]!) {
|
|
367
368
|
i--;
|
|
368
369
|
} else {
|
|
369
370
|
j--;
|