@visual-z/xeno 0.7.2 → 0.8.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 +43 -0
- package/README.md +36 -9
- package/docs/foundations.md +1 -1
- package/package.json +3 -1
- package/src/apps/cli.ts +4 -3
- package/src/apps/commands.ts +8 -0
- package/src/apps/goal-flow.ts +70 -0
- package/src/apps/render.ts +0 -2
- package/src/apps/session-hub.ts +6 -5
- package/src/apps/session-lifecycle.ts +64 -0
- package/src/apps/shell.ts +89 -34
- package/src/apps/startup.ts +32 -31
- package/src/apps/ui/app.ts +38 -6
- package/src/apps/ui/control-model.ts +18 -1
- package/src/apps/ui/surface.ts +2 -0
- package/src/apps/update-check.ts +1 -1
- package/src/core/acceptance.ts +41 -9
- package/src/core/blind-checks.ts +72 -0
- package/src/core/branch-receipt.ts +3 -1
- package/src/core/checkpoints.ts +21 -0
- package/src/core/churn.ts +2 -2
- package/src/core/completion-review.ts +18 -11
- package/src/core/continuation.ts +11 -10
- package/src/core/control-view.ts +5 -8
- package/src/core/deadline.ts +2 -1
- package/src/core/derive.ts +0 -7
- package/src/core/environment.ts +2 -2
- package/src/core/event-validation.ts +19 -6
- package/src/core/events.ts +41 -32
- package/src/core/evidence-ladder.ts +62 -0
- package/src/core/evidence-links.ts +0 -10
- package/src/core/evidence.ts +1 -2
- package/src/core/export.ts +6 -11
- package/src/core/goal.ts +200 -0
- package/src/core/history.ts +1 -11
- package/src/core/json-event.ts +0 -6
- package/src/core/outcome.ts +0 -1
- package/src/core/program-status.ts +11 -2
- package/src/core/progress.ts +14 -8
- package/src/core/project.ts +6 -16
- package/src/core/read-bounds.ts +11 -2
- package/src/core/read-observation.ts +24 -0
- package/src/core/request-budget.ts +13 -3
- package/src/core/request-state.ts +178 -19
- package/src/core/response-recovery.ts +2 -1
- package/src/core/session-entry.ts +8 -3
- package/src/core/session-exit.ts +19 -3
- package/src/core/session-format.ts +169 -0
- package/src/core/settings.ts +7 -27
- package/src/core/stall.ts +30 -7
- package/src/core/stop-report.ts +4 -3
- package/src/core/transcript.ts +22 -2
- package/src/runtime/blind-check.ts +36 -0
- package/src/runtime/counter-check.ts +74 -0
- package/src/runtime/exec-sessions.ts +11 -0
- package/src/runtime/foundation.ts +46 -8
- package/src/runtime/loop.ts +239 -430
- package/src/runtime/oauth.ts +1 -3
- package/src/runtime/provider-request.ts +2 -1
- package/src/runtime/runtime-target.ts +2 -2
- package/src/runtime/session-control.ts +4 -4
- package/src/runtime/tools.ts +44 -42
- package/src/runtime/version.ts +12 -0
- package/src/runtime/workspace-lifecycle.ts +31 -0
- package/src/store/blind-checks.ts +32 -0
- package/src/store/checkpoint-history.ts +1 -12
- package/src/store/file-index.ts +88 -7
- package/src/store/log.ts +25 -27
- package/src/store/recovery.ts +1 -3
- package/src/store/replay.ts +2 -2
- package/src/store/restore.ts +10 -202
- package/src/store/search.ts +7 -5
- package/src/store/session-archive.ts +0 -1
- package/src/store/sessions.ts +31 -33
- package/src/store/workspace-clone.ts +42 -0
- package/src/store/workspace.ts +10 -157
- package/src/tui/ui/status.ts +6 -2
- package/src/core/benchmark.ts +0 -195
- package/src/core/evaluation-coverage.ts +0 -74
- package/src/core/experiment.ts +0 -173
- package/src/core/harness-comparison.ts +0 -90
- package/src/core/harness-diagnostics.ts +0 -206
- package/src/core/native-eval.ts +0 -25
- package/src/core/policy-evaluation.ts +0 -55
- package/src/core/query-eval-contract.ts +0 -14
- package/src/core/recall-analysis.ts +0 -90
- package/src/core/recall-trial.ts +0 -153
- package/src/core/recovery-corpus.ts +0 -17
- package/src/core/recovery-evaluation.ts +0 -60
- package/src/core/tool-mode-protocol.ts +0 -25
- package/src/core/trial-lifecycle.ts +0 -14
- package/src/core/window-trial.ts +0 -65
- package/src/index.ts +0 -10
- package/src/runtime/benchmark.ts +0 -187
- package/src/runtime/harness-comparison.ts +0 -376
- package/src/runtime/harness-eval.ts +0 -462
- package/src/runtime/native-eval-fixtures.ts +0 -33
- package/src/runtime/native-eval.ts +0 -146
- package/src/runtime/query-eval-verifier.ts +0 -28
- package/src/runtime/recall-trial.ts +0 -226
- package/src/runtime/recovery-coverage.ts +0 -71
- package/src/runtime/recovery-evidence.ts +0 -76
- package/src/runtime/recovery-fixtures.ts +0 -116
- package/src/runtime/tool-mode-eval.ts +0 -146
- package/src/runtime/trial-control.ts +0 -45
- package/src/runtime/window-trial.ts +0 -141
- package/src/store/git-branch.ts +0 -35
- package/src/tui/ui/testing.ts +0 -5
package/src/core/project.ts
CHANGED
|
@@ -72,7 +72,7 @@ export interface ProjectInput {
|
|
|
72
72
|
handoff: Handoff | null;
|
|
73
73
|
events: readonly LogEvent[];
|
|
74
74
|
/** Source locations and versioned recovery guidance; workspace search does not index the log. */
|
|
75
|
-
logHint?: { manifestPath: string; segmentPath: string
|
|
75
|
+
logHint?: { manifestPath: string; segmentPath: string };
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
export interface ProjectResult {
|
|
@@ -138,7 +138,7 @@ export function toolCallIdentityViolations(blocks: readonly AssistantBlock[]): P
|
|
|
138
138
|
* two things". It is a following message rather than a mutation of the user
|
|
139
139
|
* turn so appending the event only appends to the projection (P7).
|
|
140
140
|
*/
|
|
141
|
-
type ReplayEvent = Extract<LogEvent, { t: "acceptance_contract" | "acceptance_decision" | "user" | "user_bash" | "guidance" | "interrupt" | "assistant" | "tool_result" | "rewind" | "
|
|
141
|
+
type ReplayEvent = Extract<LogEvent, { t: "acceptance_contract" | "acceptance_decision" | "user" | "user_bash" | "guidance" | "interrupt" | "assistant" | "tool_result" | "rewind" | "extension" }>;
|
|
142
142
|
|
|
143
143
|
function replayable(e: LogEvent): e is ReplayEvent {
|
|
144
144
|
if (e.t === "tool_result" && (e.parent_call_id !== undefined || e.origin === "extension")) return false;
|
|
@@ -146,7 +146,6 @@ function replayable(e: LogEvent): e is ReplayEvent {
|
|
|
146
146
|
if (e.t === "extension") return e.inContext;
|
|
147
147
|
if (e.t === "user_bash") return !e.excludeFromContext;
|
|
148
148
|
if (e.t === "rewind") return e.mode !== "both";
|
|
149
|
-
if (e.t === "snapshot") return e.undeclared.length > 0;
|
|
150
149
|
return e.t === "user" || e.t === "guidance" || e.t === "interrupt" || e.t === "assistant" || e.t === "tool_result";
|
|
151
150
|
}
|
|
152
151
|
|
|
@@ -228,7 +227,7 @@ export function renderRewindMessage(e: Extract<LogEvent, { t: "rewind" }>): Mess
|
|
|
228
227
|
*/
|
|
229
228
|
export function renderHandoffMessage(
|
|
230
229
|
handoff: Handoff,
|
|
231
|
-
logHint?: { manifestPath: string; segmentPath: string
|
|
230
|
+
logHint?: { manifestPath: string; segmentPath: string },
|
|
232
231
|
): Message {
|
|
233
232
|
const yaml = emitHandoff(handoff);
|
|
234
233
|
|
|
@@ -251,10 +250,7 @@ export function renderHandoffMessage(
|
|
|
251
250
|
"detail this state does not carry, read it rather than guessing:",
|
|
252
251
|
` - segment index: ${logHint.manifestPath}`,
|
|
253
252
|
` - previous segment: ${logHint.segmentPath}`,
|
|
254
|
-
|
|
255
|
-
"Each line is one JSON event. Search it with history; read a line range with read,",
|
|
256
|
-
"or filter with bash and jq. The grep tool skips the log directory.",
|
|
257
|
-
] : ["Each line is one JSON event. Recover originals using the declared tools' contracts; if evidence is inaccessible, state the gap instead of guessing."]),
|
|
253
|
+
"Each line is one JSON event. Recover originals using the declared tools' contracts; if evidence is inaccessible, state the gap instead of guessing.",
|
|
258
254
|
);
|
|
259
255
|
}
|
|
260
256
|
|
|
@@ -329,13 +325,6 @@ export function projectContext(input: ProjectInput): ProjectResult {
|
|
|
329
325
|
append({ role: "user", content: [{ type: "text", text: `User ran: ${e.command}\n${e.content}` }] }, [e.id]);
|
|
330
326
|
continue;
|
|
331
327
|
}
|
|
332
|
-
if (e.t === "snapshot") {
|
|
333
|
-
flushPending();
|
|
334
|
-
append({ role: "user", content: [{ type: "text",
|
|
335
|
-
text: `Workspace observation: changes outside declared file edits: ${e.undeclared.join(", ")}. ${REREAD_INSTRUCTION}`,
|
|
336
|
-
}] }, [e.id]);
|
|
337
|
-
continue;
|
|
338
|
-
}
|
|
339
328
|
if (e.t === "rewind") {
|
|
340
329
|
// Flushed first for the same reason a user turn is: it opens a user
|
|
341
330
|
// message, and a pending tool_result batch has to close before it.
|
|
@@ -396,7 +385,8 @@ export function projectContext(input: ProjectInput): ProjectResult {
|
|
|
396
385
|
pending.push({
|
|
397
386
|
type: "tool_result",
|
|
398
387
|
tool_use_id: e.call_id,
|
|
399
|
-
content: diagnosticContent(e.content, e.diagnostic) + (e.warnings?.length ? "\nObserver warnings (operation result retained): " + JSON.stringify(e.warnings) : "")
|
|
388
|
+
content: diagnosticContent(e.content, e.diagnostic) + (e.warnings?.length ? "\nObserver warnings (operation result retained): " + JSON.stringify(e.warnings) : "") +
|
|
389
|
+
(e.instructions ? "\n\n" + e.instructions.text : ""),
|
|
400
390
|
...(e.ok ? {} : { is_error: true }),
|
|
401
391
|
});
|
|
402
392
|
// Images follow the paired text result, so APIs without image tool outputs receive a user image.
|
package/src/core/read-bounds.ts
CHANGED
|
@@ -10,6 +10,15 @@ export function readBounds(total: number, first: unknown, last: unknown):
|
|
|
10
10
|
if (start > total) return { ok: false, message: `read start ${start} is beyond end of file (${bounds}); choose a start in that range` };
|
|
11
11
|
if (end < start) return { ok: false, message: `read end ${end} precedes start ${start} (${bounds})` };
|
|
12
12
|
const actualEnd = Math.min(end, total);
|
|
13
|
-
return { ok: true, start, end: actualEnd,
|
|
14
|
-
|
|
13
|
+
return { ok: true, start, end: actualEnd, notice: readNotice(start, actualEnd, total) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The selection header. `budget` names the byte limit when it, rather than the
|
|
18
|
+
* requested range, ended the selection, so "next start" is never mistaken for
|
|
19
|
+
* the end of what was asked.
|
|
20
|
+
*/
|
|
21
|
+
export function readNotice(start: number, end: number, total: number, budget?: number): string {
|
|
22
|
+
const rest = end < total ? `next start: ${end + 1}${budget === undefined ? "" : `; stopped at the ${budget}-byte output budget`}` : "end of file";
|
|
23
|
+
return `[Read selection: lines ${start}-${end}; total lines: ${total}; ${rest}.]`;
|
|
15
24
|
}
|
|
@@ -65,6 +65,30 @@ export function previewReadObservation(header: string, lines: readonly string[],
|
|
|
65
65
|
visible, next_offset: next === source ? null : next } };
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* How many of `lines` a read shows whole within `maxBytes`. OpenCode and Pi cut
|
|
70
|
+
* a file read at a line boundary and name the next offset; eliding the middle
|
|
71
|
+
* lets a receipt claim "end of file" while hiding the lines between, so the
|
|
72
|
+
* model re-reads them and the stall ledger, trusting the claimed range, counts
|
|
73
|
+
* that as no progress. At least one line is kept: a single line over budget
|
|
74
|
+
* is still cut inside itself, with its recovery reference.
|
|
75
|
+
*/
|
|
76
|
+
export function linesWithin(header: (count: number) => string, lines: readonly string[], start: number,
|
|
77
|
+
maxBytes: number, maxLineChars = READ_MAX_LINE_CHARS, tools?: readonly string[]): number {
|
|
78
|
+
// Rendered without a byte cut, so only the budget decides; a line cut at
|
|
79
|
+
// maxLineChars is already part of the rendering. Any 64-hex artifact name
|
|
80
|
+
// renders recovery references at their final length.
|
|
81
|
+
const fits = (count: number) => size(previewReadObservation(header(count), lines.slice(0, count), start, "0".repeat(64),
|
|
82
|
+
Number.MAX_SAFE_INTEGER, maxLineChars, tools).text) <= maxBytes;
|
|
83
|
+
if (lines.length === 0 || fits(lines.length)) return lines.length;
|
|
84
|
+
let low = 1, high = lines.length - 1;
|
|
85
|
+
while (low < high) {
|
|
86
|
+
const mid = Math.ceil((low + high) / 2);
|
|
87
|
+
if (fits(mid)) low = mid; else high = mid - 1;
|
|
88
|
+
}
|
|
89
|
+
return low;
|
|
90
|
+
}
|
|
91
|
+
|
|
68
92
|
const record = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
69
93
|
const natural = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
70
94
|
const digest = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* D6 / Q.26: finalization spends the existing budget and cannot admit new work.
|
|
3
|
+
* `Infinity` is no cap (S.6 item 1): there is no final request to reserve, and
|
|
4
|
+
* the stall watchdog is what ends a run that stops producing evidence.
|
|
5
|
+
*/
|
|
2
6
|
export function requestPhase(index: number, limit: number): "work" | "finalize" | "exhausted" {
|
|
3
|
-
if (!Number.isSafeInteger(limit) || limit < 1 || !Number.isSafeInteger(index) || index < 0)
|
|
4
|
-
throw new RangeError("request budget requires positive integer limit and nonnegative index");
|
|
7
|
+
if (!(Number.isSafeInteger(limit) || limit === Infinity) || limit < 1 || !Number.isSafeInteger(index) || index < 0)
|
|
8
|
+
throw new RangeError("request budget requires a positive integer or unbounded limit and a nonnegative index");
|
|
5
9
|
if (index >= limit) return "exhausted";
|
|
6
10
|
// One request cannot both investigate and close. Preserve that work opportunity;
|
|
7
11
|
// its deterministic outcome is the exit, without a second paid request.
|
|
@@ -11,3 +15,9 @@ export function requestPhase(index: number, limit: number): "work" | "finalize"
|
|
|
11
15
|
export function finalizationNotice(): string {
|
|
12
16
|
return "Harness: this is the final request in the existing budget. Tools are disabled by the host. Give the user a concise answer from the observations already recorded: what is established, what remains unresolved, and the next useful action. Do not claim unobserved execution or verified completion.";
|
|
13
17
|
}
|
|
18
|
+
|
|
19
|
+
/** Without a request cap the stall watchdog is the only backstop, so it cannot be off (S.6 item 1). */
|
|
20
|
+
export function validateRequestBudget(limit: number, stallLimit: number): void {
|
|
21
|
+
requestPhase(0, limit);
|
|
22
|
+
if (limit === Infinity && stallLimit === 0) throw new RangeError("an unbounded request budget requires the stall watchdog");
|
|
23
|
+
}
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* The request ledger (S.1, Q23 / D8 / D11): what the current request has
|
|
3
|
+
* evidence for, derived only from the selected log ancestry.
|
|
4
|
+
*
|
|
5
|
+
* Every view of "is this request done" -- the completion check, the goal
|
|
6
|
+
* panel, terminal status, branch receipts -- maps from this one derivation,
|
|
7
|
+
* so they cannot disagree. Only harness-observed facts move it: command
|
|
8
|
+
* receipts with their workspace fingerprints, file versions, and the user's
|
|
9
|
+
* acceptance decisions. A model note can describe a blocker; it never changes
|
|
10
|
+
* the status.
|
|
11
|
+
*/
|
|
2
12
|
import type { LogEvent } from "./events.ts";
|
|
3
|
-
import { deriveAcceptance, type AcceptanceState } from "./acceptance.ts";
|
|
4
|
-
import { changedPaths } from "./checkpoints.ts";
|
|
13
|
+
import { counterVerdict, deriveAcceptance, type AcceptanceState, type CounterVerdict } from "./acceptance.ts";
|
|
14
|
+
import { changedPaths, workspaceChanged } from "./checkpoints.ts";
|
|
5
15
|
import { evidenceStale, isCommandResult, resolveEvidence } from "./evidence.ts";
|
|
6
16
|
import { executionOutcome, modelOutcome, type ExecutionOutcome } from "./outcome.ts";
|
|
7
17
|
import { deriveWorkItems, type WorkItem } from "./work-state.ts";
|
|
@@ -14,18 +24,74 @@ export type RequestCommand = {
|
|
|
14
24
|
};
|
|
15
25
|
export type RequestArtifact = { source: RequestSource; path: string; sha256: string | null;
|
|
16
26
|
kind: "read" | "edit" | "checkpoint" | "command_output" };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One requirement this request must meet. With a declared contract these are
|
|
30
|
+
* its criteria. Without one, a request that changed the workspace still has
|
|
31
|
+
* one requirement: a successful check after its last change.
|
|
32
|
+
*/
|
|
33
|
+
export type RequestCriterion = {
|
|
34
|
+
id: string; kind: "command" | "human" | "fresh_check"; description: string;
|
|
35
|
+
status: "pass" | "fail" | "stale" | "not_run" | "awaiting_human" | "accepted";
|
|
36
|
+
command?: string; evidence?: string;
|
|
37
|
+
/** A declared guard: it must pass before and after the work, rather than fail before it (S.7). */
|
|
38
|
+
guard?: true;
|
|
39
|
+
/** The latest counter-check of this criterion in the request, when one ran, read against its role. */
|
|
40
|
+
counter?: CounterVerdict;
|
|
41
|
+
/**
|
|
42
|
+
* Consecutive runs of this exact command that failed with the same exit code
|
|
43
|
+
* against the same workspace. Two or more mean rerunning it unchanged will
|
|
44
|
+
* not change the answer: the work, or the check itself, must change.
|
|
45
|
+
*/
|
|
46
|
+
repeated_failures?: number;
|
|
47
|
+
/** A fresh check under strict verification: only a configured gate counts (O.11). */
|
|
48
|
+
gate_only?: true;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The rungs of the evidence ladder this derivation can establish. */
|
|
52
|
+
export type RequestEvidence = {
|
|
53
|
+
/** Every required check ran successfully against the current workspace. */
|
|
54
|
+
ran: "pass" | "fail" | "stale" | "missing" | "not_applicable";
|
|
55
|
+
/** Every required human criterion was accepted by the user. */
|
|
56
|
+
human: "accepted" | "pending" | "not_required";
|
|
57
|
+
/**
|
|
58
|
+
* Every passing check meant to test the change failed once this request's
|
|
59
|
+
* changes were undone (feat-counter-check). `missing`: every check is a guard
|
|
60
|
+
* that held, so nothing shows the work changed anything (S.7).
|
|
61
|
+
*/
|
|
62
|
+
discriminating: "pass" | "vacuous" | "unavailable" | "not_run" | "missing" | "not_applicable";
|
|
63
|
+
/** Blind checks the model never touched passed on the final result (feat-blind-checks). */
|
|
64
|
+
independent: "pass" | "fail" | "exposed" | "unavailable" | "not_run" | "not_applicable";
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type RequestStatus = "not_applicable" | "unchecked" | "checked" | "needs_work" | "awaiting_human" | "verified";
|
|
68
|
+
|
|
17
69
|
export type RequestState = {
|
|
18
70
|
request: RequestSource; inputs: RequestSource[];
|
|
19
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Without a contract: not_applicable (nothing changed), unchecked, or
|
|
73
|
+
* checked. With one: needs_work, awaiting_human, verified, or
|
|
74
|
+
* not_applicable for a changes-scoped contract over an unchanged workspace.
|
|
75
|
+
*/
|
|
76
|
+
status: RequestStatus;
|
|
77
|
+
criteria: RequestCriterion[]; evidence: RequestEvidence; unmet: string[];
|
|
20
78
|
execution?: ExecutionOutcome; acceptance?: AcceptanceState;
|
|
21
79
|
commands: RequestCommand[]; artifacts: RequestArtifact[]; work: WorkItem[];
|
|
80
|
+
/** What the model said blocks it. Shown to the user; never a verdict (D11). */
|
|
22
81
|
reported_blockers: { source: RequestSource; reason: string }[];
|
|
23
82
|
progress: RequestProgress;
|
|
24
83
|
changed: boolean; used_tools: boolean; reviewed: boolean;
|
|
25
84
|
final?: { source: RequestSource; text: string; has_tools: boolean };
|
|
26
85
|
};
|
|
27
86
|
|
|
28
|
-
|
|
87
|
+
export type LedgerOptions = {
|
|
88
|
+
/** The CLI's strict verification: only a configured gate command counts as the fresh check. */
|
|
89
|
+
strict?: boolean;
|
|
90
|
+
/** The session declares blind checks, so their absence means "not run" rather than "not applicable". */
|
|
91
|
+
blind?: boolean;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** New steering events refer to their original user event. A user event without that link opens a request. */
|
|
29
95
|
export function currentRequestRecords(parts: readonly RequestPart[]): RequestRecord[] {
|
|
30
96
|
const records = parts.flatMap(part => part.events.map(event => ({ node: part.node, event })));
|
|
31
97
|
const last = records.findLastIndex(({ event }) => event.t === "user");
|
|
@@ -38,7 +104,18 @@ export function currentRequestRecords(parts: readonly RequestPart[]): RequestRec
|
|
|
38
104
|
return records.slice(start);
|
|
39
105
|
}
|
|
40
106
|
|
|
41
|
-
|
|
107
|
+
/**
|
|
108
|
+
* The current request's events in one flat stream: the one slicer every
|
|
109
|
+
* per-request notice uses, so steering never restarts a request's budget,
|
|
110
|
+
* notices or failure counts. Empty when the stream holds no user event.
|
|
111
|
+
*/
|
|
112
|
+
export function currentRequestEvents(events: readonly LogEvent[]): LogEvent[] {
|
|
113
|
+
return currentRequestRecords([{ node: 0, events }]).map(record => record.event);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const FRESH_CHECK = "a successful check after the last change";
|
|
117
|
+
|
|
118
|
+
export function deriveRequestState(parts: readonly RequestPart[], options: LedgerOptions = {}): RequestState | undefined {
|
|
42
119
|
const records = currentRequestRecords(parts);
|
|
43
120
|
const first = records[0];
|
|
44
121
|
if (!first) return undefined;
|
|
@@ -46,7 +123,7 @@ export function deriveRequestState(parts: readonly RequestPart[]): RequestState
|
|
|
46
123
|
const source = ({ node, event }: RequestRecord): RequestSource => ({ node, event: event.id });
|
|
47
124
|
const request = source(first);
|
|
48
125
|
const acceptance = deriveAcceptance(undefined, events.filter(event => event.t !== "acceptance_contract" || event.request === request.event));
|
|
49
|
-
const commands: RequestCommand[] = [], artifacts: RequestArtifact[] = [];
|
|
126
|
+
const commands: (RequestCommand & { index: number })[] = [], artifacts: RequestArtifact[] = [];
|
|
50
127
|
const lastCommands = new Map<string, string>();
|
|
51
128
|
for (const event of events) if (isCommandResult(event)) lastCommands.set(event.effect.cmd, event.id);
|
|
52
129
|
let work: WorkItem[] = [];
|
|
@@ -56,7 +133,7 @@ export function deriveRequestState(parts: readonly RequestPart[]): RequestState
|
|
|
56
133
|
work = deriveWorkItems(work, part.events);
|
|
57
134
|
for (let index = 0; index < part.events.length; index++) {
|
|
58
135
|
const event = part.events[index]!;
|
|
59
|
-
//
|
|
136
|
+
// Untyped completed claims supersede a same-named blocker only when the
|
|
60
137
|
// original same-segment evidence resolves, just as in deriveHandoff.
|
|
61
138
|
if (event.t === "claim" && event.work_id === undefined && ids.has(event.id) &&
|
|
62
139
|
resolveEvidence(part.events.slice(0, index), event.evidence).ok) completed.add(event.what);
|
|
@@ -64,8 +141,8 @@ export function deriveRequestState(parts: readonly RequestPart[]): RequestState
|
|
|
64
141
|
}
|
|
65
142
|
work = work.filter(item => ids.has(item.opened_at) || ids.has(item.updated_at));
|
|
66
143
|
const reported_blockers: RequestState["reported_blockers"] = [];
|
|
67
|
-
let execution: ExecutionOutcome | undefined, final: RequestState["final"], changed = false;
|
|
68
|
-
|
|
144
|
+
let execution: ExecutionOutcome | undefined, final: RequestState["final"], changed = false, lastChange = -1;
|
|
145
|
+
records.forEach((record, index) => {
|
|
69
146
|
const event = record.event, ref = source(record);
|
|
70
147
|
if (event.t === "user") { execution = undefined; final = undefined; }
|
|
71
148
|
else if (event.t === "assistant") {
|
|
@@ -78,12 +155,13 @@ export function deriveRequestState(parts: readonly RequestPart[]): RequestState
|
|
|
78
155
|
if (event.t === "blocked" && (event.work_id ? work.some(item => item.id === event.work_id && item.status === "blocked") : !completed.has(event.what))) {
|
|
79
156
|
reported_blockers.push({ source: ref, reason: event.reason });
|
|
80
157
|
}
|
|
158
|
+
if (workspaceChanged(event)) { changed = true; lastChange = index; }
|
|
81
159
|
if (isCommandResult(event)) {
|
|
82
160
|
const effect = event.effect;
|
|
83
161
|
const evidence = { command: effect.cmd, exit: effect.exit, event_id: event.id, at: event.at,
|
|
84
162
|
...(effect.workspace_sha256 === undefined ? {} : { workspace_sha256: effect.workspace_sha256 }),
|
|
85
163
|
...(effect.workspace_scope === undefined ? {} : { workspace_scope: effect.workspace_scope }) };
|
|
86
|
-
commands.push({ source: ref, command: effect.cmd, exit: effect.exit,
|
|
164
|
+
commands.push({ index, source: ref, command: effect.cmd, exit: effect.exit,
|
|
87
165
|
status: !event.ok || effect.exit !== 0 ? "fail" : effect.workspace_observed === false || evidenceStale(events, evidence) ? "stale"
|
|
88
166
|
: lastCommands.get(effect.cmd) !== event.id ? "superseded" : "pass",
|
|
89
167
|
...(effect.gate === undefined ? {} : { gate: effect.gate }),
|
|
@@ -95,20 +173,101 @@ export function deriveRequestState(parts: readonly RequestPart[]): RequestState
|
|
|
95
173
|
} else if (event.t === "tool_result" && (event.effect?.kind === "read" || event.effect?.kind === "edit")) {
|
|
96
174
|
const effect = event.effect;
|
|
97
175
|
artifacts.push({ source: ref, path: effect.path, sha256: effect.kind === "read" ? effect.sha256 : effect.sha256_after, kind: effect.kind });
|
|
98
|
-
if (effect.kind === "edit" && effect.sha256_before !== effect.sha256_after) changed = true;
|
|
99
176
|
} else if (event.t === "checkpoint" && event.phase === "applied") {
|
|
100
177
|
for (const path of changedPaths(event.changes)) artifacts.push({ source: ref, path, sha256: event.changes[path]!.after?.sha256 ?? null, kind: "checkpoint" });
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const ledger = acceptance ? contractLedger(acceptance) : freshCheckLedger(commands, changed, lastChange, options);
|
|
182
|
+
const { criteria, status } = ledger;
|
|
183
|
+
for (const event of events) if (event.t === "counter_check" && event.request === request.event) {
|
|
184
|
+
const criterion = criteria.find(item => item.id === event.criterion && item.kind === "command");
|
|
185
|
+
if (criterion) criterion.counter = counterVerdict(event);
|
|
186
|
+
}
|
|
187
|
+
for (const criterion of criteria) if (criterion.kind === "command" && criterion.command && criterion.status === "fail") {
|
|
188
|
+
const runs = commands.filter(command => command.command === criterion.command);
|
|
189
|
+
const last = runs.at(-1);
|
|
190
|
+
let streak = 0;
|
|
191
|
+
for (let i = runs.length - 1; i >= 0; i--) {
|
|
192
|
+
const run = runs[i]!;
|
|
193
|
+
if (run.status !== "fail" || run.exit !== last!.exit || run.workspace_sha256 === undefined || run.workspace_sha256 !== last!.workspace_sha256) break;
|
|
194
|
+
streak++;
|
|
195
|
+
}
|
|
196
|
+
if (streak >= 2) criterion.repeated_failures = streak;
|
|
104
197
|
}
|
|
198
|
+
const blind = new Map<string, Extract<LogEvent, { t: "blind_check" }>>();
|
|
199
|
+
for (const event of events) if (event.t === "blind_check" && event.request === request.event) blind.set(event.check, event);
|
|
200
|
+
const blindRuns = [...blind.values()];
|
|
201
|
+
const independent: RequestEvidence["independent"] = blindRuns.length === 0 ? (options.blind ? "not_run" : "not_applicable")
|
|
202
|
+
: blindRuns.some(run => "exit" in run && run.exit !== 0) ? "fail" : blindRuns.some(run => "reason" in run) ? "unavailable"
|
|
203
|
+
: blindRuns.some(run => run.exposed) ? "exposed" : "pass";
|
|
204
|
+
const evidence: RequestEvidence = { ...ledger.evidence, independent, discriminating: testsTheChange(criteria) };
|
|
205
|
+
const unmet = criteria.filter(item => item.status !== "pass" && item.status !== "accepted").map(item => `${item.description}: ${item.status}`);
|
|
105
206
|
return { request, inputs: records.filter(record => record.event.t === "user").map(source),
|
|
106
|
-
|
|
107
|
-
// Only the declared request contract defines what must finish in this request.
|
|
108
|
-
status: reported_blockers.length ? "blocked" : acceptance?.status ?? "unverified",
|
|
207
|
+
status, criteria, evidence, unmet,
|
|
109
208
|
...(execution ? { execution } : {}), ...(acceptance ? { acceptance } : {}),
|
|
110
|
-
commands, artifacts, work, reported_blockers,
|
|
209
|
+
commands: commands.map(({ index: _index, ...command }) => command), artifacts, work, reported_blockers,
|
|
210
|
+
progress: deriveRequestProgress(records), changed,
|
|
111
211
|
used_tools: events.some(event => event.t === "tool_result"),
|
|
112
212
|
reviewed: events.some(event => event.t === "guidance" && event.reason === "completion_review"),
|
|
113
213
|
...(final ? { final } : {}) };
|
|
114
214
|
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The discriminating rung (S.1, S.7), decided by the checks meant to test the
|
|
218
|
+
* change: each must fail once the request's changes are undone. A guard that
|
|
219
|
+
* held is what a guard should do, so it neither fails the rung nor holds it
|
|
220
|
+
* back. A guard that did not hold does test the change -- the observation
|
|
221
|
+
* outranks the declaration (D11) -- so it counts for the rung. With only
|
|
222
|
+
* guards, all held, nothing shows the work changed anything: missing. A
|
|
223
|
+
* passing fresh check is a check like any declared command and must test the
|
|
224
|
+
* change too.
|
|
225
|
+
*/
|
|
226
|
+
function testsTheChange(criteria: readonly RequestCriterion[]): RequestEvidence["discriminating"] {
|
|
227
|
+
const tests = criteria.filter(item => item.kind === "command" && !item.guard || item.kind === "fresh_check" && item.status === "pass");
|
|
228
|
+
const guards = criteria.filter(item => item.kind === "command" && item.guard);
|
|
229
|
+
if (tests.length === 0 && guards.length === 0) return "not_applicable";
|
|
230
|
+
if (tests.some(item => item.counter === "vacuous")) return "vacuous";
|
|
231
|
+
if (tests.some(item => item.counter === "unavailable")) return "unavailable";
|
|
232
|
+
if (tests.some(item => item.counter !== "discriminating")) return "not_run";
|
|
233
|
+
if (tests.length > 0 || guards.some(item => item.counter === "not_held")) return "pass";
|
|
234
|
+
if (guards.some(item => item.counter === "unavailable")) return "unavailable";
|
|
235
|
+
return guards.every(item => item.counter === "held") ? "missing" : "not_run";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A declared contract defines what this request must finish (O.11); nothing is inferred beyond it. */
|
|
239
|
+
type LedgerCore = Pick<RequestState, "criteria" | "status"> & { evidence: Omit<RequestEvidence, "discriminating" | "independent"> };
|
|
240
|
+
|
|
241
|
+
function contractLedger(acceptance: AcceptanceState): LedgerCore {
|
|
242
|
+
const criteria: RequestCriterion[] = acceptance.criteria.map(item => ({ id: item.id, kind: item.kind, description: item.description,
|
|
243
|
+
status: item.status, ...(item.kind === "command" ? { command: item.command, ...(item.guard ? { guard: true as const } : {}) } : {}),
|
|
244
|
+
...(item.evidence ? { evidence: item.evidence.event_id } : item.accepted_event ? { evidence: item.accepted_event } : {}) }));
|
|
245
|
+
const checks = criteria.filter(item => item.kind === "command"), humans = criteria.filter(item => item.kind === "human");
|
|
246
|
+
const targetsReady = (acceptance.targets ?? []).every(target => target.status === "ready");
|
|
247
|
+
const ran: RequestEvidence["ran"] = checks.length === 0 ? "not_applicable"
|
|
248
|
+
: checks.every(item => item.status === "pass") && targetsReady ? "pass"
|
|
249
|
+
: checks.some(item => item.status === "fail") ? "fail" : checks.some(item => item.status === "stale") ? "stale" : "missing";
|
|
250
|
+
return { criteria, status: acceptance.status,
|
|
251
|
+
evidence: { ran, human: humans.length === 0 ? "not_required" : humans.every(item => item.status === "accepted") ? "accepted" : "pending" } };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Without a contract, a request that changed the workspace still owes one
|
|
256
|
+
* thing: a successful check against the changed workspace. That is all an
|
|
257
|
+
* exit code can say -- not that the request's words were met -- so the status
|
|
258
|
+
* is "checked", never "verified".
|
|
259
|
+
*/
|
|
260
|
+
function freshCheckLedger(commands: readonly (RequestCommand & { index: number })[], changed: boolean, lastChange: number,
|
|
261
|
+
options: LedgerOptions): LedgerCore {
|
|
262
|
+
const none = { human: "not_required" as const };
|
|
263
|
+
if (!changed) return { criteria: [], status: "not_applicable", evidence: { ran: "not_applicable", ...none } };
|
|
264
|
+
const after = commands.filter(command => command.index > lastChange && (!options.strict || command.gate !== undefined));
|
|
265
|
+
const pass = after.find(command => command.status === "pass");
|
|
266
|
+
const status: RequestCriterion["status"] = pass ? "pass" : after.some(command => command.status === "fail") ? "fail"
|
|
267
|
+
: after.some(command => command.status === "stale") ? "stale" : "not_run";
|
|
268
|
+
const criterion: RequestCriterion = { id: "fresh-check", kind: "fresh_check",
|
|
269
|
+
description: options.strict ? `${FRESH_CHECK} (a configured gate)` : FRESH_CHECK, status, ...(options.strict ? { gate_only: true as const } : {}),
|
|
270
|
+
...(pass ? { command: pass.command, evidence: pass.source.event } : {}) };
|
|
271
|
+
return { criteria: [criterion], status: pass ? "checked" : "unchecked",
|
|
272
|
+
evidence: { ran: status === "pass" ? "pass" : status === "not_run" ? "missing" : status, ...none } };
|
|
273
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** N.12: a returned response is durable evidence, not necessarily a completed action. */
|
|
2
2
|
import type { GuidanceEvent, LogEvent } from "./events.ts";
|
|
3
|
+
import { currentRequestEvents } from "./request-state.ts";
|
|
3
4
|
|
|
4
5
|
// One appended attempt is an operational assumption, not a measured optimum.
|
|
5
6
|
// It shares the existing request ceiling and never replays a tool automatically.
|
|
@@ -34,7 +35,7 @@ export function knownUnexecutedTool(events: readonly LogEvent[], callId: string)
|
|
|
34
35
|
export function outputRecoveryNotice(events: readonly LogEvent[], limit: number, remainingRequests: number):
|
|
35
36
|
Pick<GuidanceEvent, "reason" | "sources" | "text"> | null {
|
|
36
37
|
validateOutputRecoveryLimit(limit);
|
|
37
|
-
const current = events.slice(
|
|
38
|
+
const current = currentRequestEvents(events).slice(1);
|
|
38
39
|
const assistant = current.findLast(event => event.t === "assistant");
|
|
39
40
|
const notices = current.filter(event => event.t === "guidance" && event.reason === "output_recovery");
|
|
40
41
|
if (assistant?.t !== "assistant" || !isOutputLimit(assistant.stop_reason) || notices.length >= limit || remainingRequests < 1 ||
|
|
@@ -43,13 +43,18 @@ export interface SavedModel {
|
|
|
43
43
|
provider?: string;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* S.3: every cursor of this session format names its provider, so a resume
|
|
48
|
+
* restores exactly that provider/model and never guesses one from a model ID
|
|
49
|
+
* that several providers serve.
|
|
50
|
+
*/
|
|
47
51
|
export function restoreSessionModel<T extends { id: string; provider: string }>(
|
|
48
52
|
saved: SavedModel,
|
|
49
53
|
catalog: readonly T[],
|
|
50
54
|
): T {
|
|
51
|
-
|
|
52
|
-
const
|
|
55
|
+
if (saved.provider === undefined) throw new Error(`saved model ${saved.model} names no provider, which this session format always records. Start a new session`);
|
|
56
|
+
const matches = catalog.filter((m) => m.id === saved.model && m.provider === saved.provider);
|
|
57
|
+
const label = `${saved.provider}/${saved.model}`;
|
|
53
58
|
if (matches.length === 0) throw new Error(`saved model ${label} is unavailable. Restore its catalog entry, or start a new session`);
|
|
54
59
|
if (matches.length !== 1) throw new Error(`saved model ${label} is ambiguous across providers. Restore an unambiguous catalog before resuming`);
|
|
55
60
|
return matches[0]!;
|
package/src/core/session-exit.ts
CHANGED
|
@@ -2,17 +2,33 @@
|
|
|
2
2
|
import type { LogEvent } from "./events.ts";
|
|
3
3
|
import { pendingToolCalls } from "./execution.ts";
|
|
4
4
|
import { modelOutcome } from "./outcome.ts";
|
|
5
|
+
import { currentRequestEvents } from "./request-state.ts";
|
|
5
6
|
|
|
6
7
|
export const DEFAULT_FULLSCREEN_EXIT_OUTPUT = "resume-hint";
|
|
7
8
|
|
|
9
|
+
/** The identifiers `--session-id` accepts; anything else is printed as a path. */
|
|
10
|
+
const PLAIN_ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The command a person retypes to come back. Claude Code prints
|
|
14
|
+
* `claude --resume <id>` and Codex `codex resume <id>`: an id, not a path.
|
|
15
|
+
* `--resume <id>` resolves only inside the sessions directory of the shell's
|
|
16
|
+
* cwd, so a session stored anywhere else (`--session-dir`, `--state-dir`,
|
|
17
|
+
* `-w` elsewhere) keeps the explicit path rather than a command that cannot
|
|
18
|
+
* find it.
|
|
19
|
+
*/
|
|
20
|
+
export function resumeCommand(session: { id: string; directory: string; path: string }, cwdSessions: string): string {
|
|
21
|
+
return session.directory === cwdSessions && PLAIN_ID.test(session.id)
|
|
22
|
+
? `xeno --resume ${session.id}` : `xeno --session ${JSON.stringify(session.path)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
8
25
|
/** Terminal controls and arbitrarily large reason text do not own the shell surface. */
|
|
9
26
|
export function exitLine(text: string, limit = 180): string {
|
|
10
27
|
return text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x1f\x7f-\x9f]/g, " ").replace(/\s+/g, " ").trim().slice(0, limit);
|
|
11
28
|
}
|
|
12
29
|
|
|
13
30
|
export function sessionExitStatus(events: readonly LogEvent[]): string {
|
|
14
|
-
const
|
|
15
|
-
const current = events.slice(Math.max(0, start));
|
|
31
|
+
const current = currentRequestEvents(events);
|
|
16
32
|
const outcome = current.findLast(event => event.t === "outcome" || event.t === "user_bash");
|
|
17
33
|
if (outcome?.t === "outcome") return exitLine(`${outcome.outcome.status} · ${outcome.outcome.reason} · ${outcome.reason}`);
|
|
18
34
|
if (outcome?.t === "user_bash") return `${outcome.outcome.status} · ${outcome.outcome.reason} · command exit ${outcome.exit}`;
|
|
@@ -22,5 +38,5 @@ export function sessionExitStatus(events: readonly LogEvent[]): string {
|
|
|
22
38
|
const result = modelOutcome(assistant.stop_reason ?? null);
|
|
23
39
|
return result.reason === "model_end" ? "model finished · verification remains in session evidence" : `${result.status} · ${result.reason}`;
|
|
24
40
|
}
|
|
25
|
-
return
|
|
41
|
+
return current.length === 0 ? "session saved" : "unfinished · inspect recorded receipts before continuing";
|
|
26
42
|
}
|