@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/events.ts
CHANGED
|
@@ -116,7 +116,7 @@ export interface GuidanceEvent {
|
|
|
116
116
|
t: "guidance";
|
|
117
117
|
id: EventId;
|
|
118
118
|
at: IsoTime;
|
|
119
|
-
reason: "continuation" | "repetition" | "budget" | "boundary" | "stall" | "output_recovery" | "completion_review" | "churn" | "deadline" | "runtime_acceptance";
|
|
119
|
+
reason: "continuation" | "repetition" | "budget" | "boundary" | "stall" | "output_recovery" | "completion_review" | "churn" | "deadline" | "runtime_acceptance" | "goal";
|
|
120
120
|
text: string;
|
|
121
121
|
sources: readonly EventId[];
|
|
122
122
|
}
|
|
@@ -200,6 +200,11 @@ export interface ToolResultEvent {
|
|
|
200
200
|
diagnostic?: ToolDiagnostic;
|
|
201
201
|
/** Post-operation observer failures cannot erase the operation's factual result. */
|
|
202
202
|
warnings?: readonly ToolDiagnostic[];
|
|
203
|
+
/**
|
|
204
|
+
* S.6 item 3: scoped instructions first discovered by this read-only call,
|
|
205
|
+
* shown after its content. Keys identify the accepted versions on resume.
|
|
206
|
+
*/
|
|
207
|
+
instructions?: { keys: readonly string[]; text: string };
|
|
203
208
|
}
|
|
204
209
|
|
|
205
210
|
export type ArtifactRef = { sha256: Sha256; path: string; bytes: number };
|
|
@@ -237,7 +242,8 @@ export interface CutEvent {
|
|
|
237
242
|
/** V2 carried-state budget, fixed in the log for identical replay. */
|
|
238
243
|
handoff_tokens?: number;
|
|
239
244
|
/** O.10: absent preserves the original D20 carried projection. */
|
|
240
|
-
|
|
245
|
+
/** R9: present when the source-priority working set was carried across this cut. */
|
|
246
|
+
working_set_version?: 2;
|
|
241
247
|
/** R10: declared builtin advice capabilities at this cut, not an authorization grant. Absent replays old text. */
|
|
242
248
|
guidance_tools?: readonly string[];
|
|
243
249
|
/** N.8: first retained event on the effective ancestry; no copied transcript. */
|
|
@@ -313,33 +319,6 @@ export interface QuestionEvent {
|
|
|
313
319
|
carried_from?: CarriedFrom;
|
|
314
320
|
}
|
|
315
321
|
|
|
316
|
-
/**
|
|
317
|
-
* What changed in the workspace during one turn, as a delta.
|
|
318
|
-
*
|
|
319
|
-
* A delta rather than a full listing: repeating the whole workspace every
|
|
320
|
-
* turn is the same quadratic growth D8 exists to prevent. The full set at
|
|
321
|
-
* any node is the deltas along its ancestry replayed in order.
|
|
322
|
-
*
|
|
323
|
-
* Recorded from a scan rather than from what the tools reported, because a
|
|
324
|
-
* shell command can write anywhere and its effect carries only an exit code.
|
|
325
|
-
* The two are compared, and a change nobody declared is surfaced.
|
|
326
|
-
*/
|
|
327
|
-
export interface SnapshotEvent {
|
|
328
|
-
t: "snapshot";
|
|
329
|
-
id: EventId;
|
|
330
|
-
at: IsoTime;
|
|
331
|
-
added: Record<string, Sha256>;
|
|
332
|
-
changed: Record<string, Sha256>;
|
|
333
|
-
removed: readonly string[];
|
|
334
|
-
/** Paths that changed with no tool claiming them. */
|
|
335
|
-
undeclared: readonly string[];
|
|
336
|
-
/** Paths a tool claimed to edit that did not change. */
|
|
337
|
-
declared_but_unchanged: readonly string[];
|
|
338
|
-
workspace_sha256?: Sha256;
|
|
339
|
-
/** Idle observations must not redefine a completed user exchange. */
|
|
340
|
-
phase?: "turn" | "idle";
|
|
341
|
-
}
|
|
342
|
-
|
|
343
322
|
/**
|
|
344
323
|
* Which halves of a rewind moved.
|
|
345
324
|
*
|
|
@@ -536,6 +515,34 @@ export interface AcceptanceContractEvent {
|
|
|
536
515
|
t: "acceptance_contract"; id: EventId; at: IsoTime; request: EventId;
|
|
537
516
|
contract: AcceptanceContract;
|
|
538
517
|
}
|
|
518
|
+
/**
|
|
519
|
+
* S.2 / S.5: an explicit goal. `set` records exactly what the user accepted
|
|
520
|
+
* from a model draft -- the draft itself stays in the assistant event it came
|
|
521
|
+
* from -- and `clear` ends it. Only the user sets or clears one by command;
|
|
522
|
+
* the harness clears it when the request ledger is verified.
|
|
523
|
+
*/
|
|
524
|
+
/**
|
|
525
|
+
* The discriminating rung of the evidence ladder: one accepted check re-run in
|
|
526
|
+
* a disposable clone with this request's changes undone. Only what happened is
|
|
527
|
+
* recorded -- the exit code there, or why nothing ran (the revert could not be
|
|
528
|
+
* made exact, the check did not finish) -- with the role the criterion was
|
|
529
|
+
* declared with. What that means is derived by `counterVerdict` (S.7).
|
|
530
|
+
*/
|
|
531
|
+
export type CounterCheckEvent = { t: "counter_check"; id: EventId; at: IsoTime; request: EventId; criterion: string; command: string; guard?: true } &
|
|
532
|
+
({ exit: number } | { reason: string });
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* The independent rung: one blind check run after the goal ended, in a clone of
|
|
536
|
+
* the final workspace. `exposed` reports whether the log shows the model ever
|
|
537
|
+
* touched the blind checks; an exposed pass does not count as independent.
|
|
538
|
+
*/
|
|
539
|
+
export type BlindCheckEvent = { t: "blind_check"; id: EventId; at: IsoTime; request: EventId; check: string; exposed: boolean; exposure?: string } &
|
|
540
|
+
({ exit: number } | { reason: string });
|
|
541
|
+
|
|
542
|
+
export type GoalEvent =
|
|
543
|
+
| { t: "goal"; id: EventId; at: IsoTime; action: "set"; objective: string; contract: AcceptanceContract;
|
|
544
|
+
draft: EventId; accepted: readonly string[]; rejected: readonly string[] }
|
|
545
|
+
| { t: "goal"; id: EventId; at: IsoTime; action: "clear"; reason: "met" | "user" };
|
|
539
546
|
export interface AcceptanceDecisionEvent {
|
|
540
547
|
t: "acceptance_decision"; id: EventId; at: IsoTime; request: EventId;
|
|
541
548
|
contract_id: string; criterion: string; actor: "user";
|
|
@@ -562,6 +569,9 @@ export type LogEvent =
|
|
|
562
569
|
| ProcessExitEvent
|
|
563
570
|
| SkillLoadedEvent
|
|
564
571
|
| AcceptanceContractEvent
|
|
572
|
+
| GoalEvent
|
|
573
|
+
| CounterCheckEvent
|
|
574
|
+
| BlindCheckEvent
|
|
565
575
|
| AcceptanceDecisionEvent
|
|
566
576
|
| TargetObservationEvent
|
|
567
577
|
| ExtensionEvent
|
|
@@ -574,7 +584,6 @@ export type LogEvent =
|
|
|
574
584
|
| ToolResultEvent
|
|
575
585
|
| EvidenceLinkEvent
|
|
576
586
|
| CutEvent
|
|
577
|
-
| SnapshotEvent
|
|
578
587
|
| CheckpointEvent
|
|
579
588
|
| WorkspaceObservationEvent
|
|
580
589
|
| RewindEvent
|
|
@@ -598,8 +607,8 @@ export const HARNESS_EVENTS = [
|
|
|
598
607
|
"target_observation",
|
|
599
608
|
"process_exit",
|
|
600
609
|
"skill_loaded",
|
|
601
|
-
"acceptance_contract", "acceptance_decision",
|
|
602
|
-
"user", "user_bash", "outcome", "guidance", "interrupt", "assistant", "tool_result", "cut", "
|
|
610
|
+
"acceptance_contract", "acceptance_decision", "goal", "counter_check", "blind_check",
|
|
611
|
+
"user", "user_bash", "outcome", "guidance", "interrupt", "assistant", "tool_result", "cut", "rewind", "extension",
|
|
603
612
|
] as const;
|
|
604
613
|
export const MODEL_EVENTS = [
|
|
605
614
|
"work_dependencies", "work_owner",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The evidence ladder (research doc S.1): how far a request's evidence goes,
|
|
3
|
+
* one rung at a time, read only from the request ledger.
|
|
4
|
+
*
|
|
5
|
+
* ran a required check has a fresh passing receipt
|
|
6
|
+
* discriminating the check fails once the request's changes are undone
|
|
7
|
+
* (a guard instead still passes there, and does not count; S.7)
|
|
8
|
+
* independent blind checks the model never touched pass
|
|
9
|
+
* human the user accepted what only a person can judge
|
|
10
|
+
*
|
|
11
|
+
* Each rung is its own fact; a higher rung does not imply the ones below it.
|
|
12
|
+
* The summary names the highest rung reached and the first gap, so the user
|
|
13
|
+
* sees what the evidence shows and what it does not, without a percentage.
|
|
14
|
+
*/
|
|
15
|
+
import type { RequestState } from "./request-state.ts";
|
|
16
|
+
|
|
17
|
+
export type Rung = "met" | "missing" | "failed" | "not_run" | "not_applicable";
|
|
18
|
+
export type EvidenceLadder = { ran: Rung; discriminating: Rung; independent: Rung; human: Rung; summary: string };
|
|
19
|
+
export const RUNGS = ["ran", "discriminating", "independent", "human"] as const;
|
|
20
|
+
|
|
21
|
+
const LABEL: Record<(typeof RUNGS)[number], string> = {
|
|
22
|
+
ran: "ran", discriminating: "tests the change", independent: "independent", human: "human accepted",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Null when the request had nothing to check: a conversation turn shows no ladder. */
|
|
26
|
+
export function evidenceLadder(state: RequestState): EvidenceLadder | null {
|
|
27
|
+
if (state.status === "not_applicable") return null;
|
|
28
|
+
const evidence = state.evidence;
|
|
29
|
+
const ran: Rung = evidence.ran === "pass" ? "met" : evidence.ran === "fail" ? "failed" : evidence.ran === "not_applicable" ? "not_applicable" : "missing";
|
|
30
|
+
const discriminating: Rung = evidence.discriminating === "pass" ? "met" : evidence.discriminating === "vacuous" ? "failed"
|
|
31
|
+
: evidence.discriminating === "missing" ? "missing" : evidence.discriminating === "not_applicable" ? "not_applicable" : "not_run";
|
|
32
|
+
const independent: Rung = evidence.independent === "pass" ? "met" : evidence.independent === "fail" || evidence.independent === "exposed" ? "failed"
|
|
33
|
+
: evidence.independent === "not_applicable" ? "not_applicable" : "not_run";
|
|
34
|
+
const human: Rung = evidence.human === "accepted" ? "met" : evidence.human === "pending" ? "missing" : "not_applicable";
|
|
35
|
+
const rungs = { ran, discriminating, independent, human };
|
|
36
|
+
const reached = RUNGS.filter(rung => rungs[rung] === "met").at(-1);
|
|
37
|
+
const gap = RUNGS.find(rung => rungs[rung] === "failed" || rungs[rung] === "missing");
|
|
38
|
+
const why = !gap ? undefined
|
|
39
|
+
: gap === "ran" ? (ran === "failed" ? "the latest check failed" : state.acceptance ? "declared checks have not passed" : "changes not checked")
|
|
40
|
+
: gap === "discriminating" ? (discriminating === "missing" ? "no check tests the change; every check is a guard" : vacuousGap(state))
|
|
41
|
+
: gap === "independent" ? (evidence.independent === "exposed" ? "the model touched the blind checks" : "a blind check failed")
|
|
42
|
+
: "waiting for your /accept";
|
|
43
|
+
const summary = [reached ? `reached: ${LABEL[reached]}` : "nothing verified yet", ...(why ? [why] : [])].join(" · ");
|
|
44
|
+
return { ...rungs, summary };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Which checks pass without the request's changes, by id. Only line mode
|
|
49
|
+
* prints each check's counter-check; the sidebar shows just this summary, and
|
|
50
|
+
* a guard the user asked for ("nothing else changed") reads differently from
|
|
51
|
+
* a check that was meant to test the work (fix-ladder-names-vacuous-check).
|
|
52
|
+
*/
|
|
53
|
+
function vacuousGap(state: RequestState): string {
|
|
54
|
+
const ids = state.criteria.filter(item => item.counter === "vacuous").map(item => item.id);
|
|
55
|
+
const named = ids.length <= 2 ? ids.join(", ") : `${ids.slice(0, 2).join(", ")} +${ids.length - 2}`;
|
|
56
|
+
return `${named} ${ids.length === 1 ? "passes" : "pass"} without the changes`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One glyph per rung for narrow status chrome: ✓ met, ✗ failed, ○ missing or not run, · not applicable. */
|
|
60
|
+
export function ladderGlyph(rung: Rung): string {
|
|
61
|
+
return rung === "met" ? "✓" : rung === "failed" ? "✗" : rung === "not_applicable" ? "·" : "○";
|
|
62
|
+
}
|
|
@@ -32,10 +32,6 @@ export function observedVersion(events: readonly LogEvent[], resource: string):
|
|
|
32
32
|
if (event.t === "tool_result" && event.effect && "path" in event.effect && resourceKey(event.effect.path) === resource) {
|
|
33
33
|
version = event.effect.kind === "read" ? event.effect.sha256 : event.effect.sha256_after;
|
|
34
34
|
}
|
|
35
|
-
if (event.t === "snapshot") {
|
|
36
|
-
if (event.removed.map(resourceKey).includes(resource)) version = null;
|
|
37
|
-
for (const [path, value] of Object.entries({ ...event.added, ...event.changed })) if (resourceKey(path) === resource) version = value;
|
|
38
|
-
}
|
|
39
35
|
if (event.t === "checkpoint" && event.phase === "applied") for (const [path, change] of Object.entries(event.changes)) {
|
|
40
36
|
if (resourceKey(path) === resource) version = change.after?.sha256 ?? null;
|
|
41
37
|
}
|
|
@@ -62,12 +58,6 @@ export function deriveEvidenceLinks(previous: readonly EvidenceLink[], events: r
|
|
|
62
58
|
const version = event.effect.kind === "read" ? event.effect.sha256 : event.effect.sha256_after;
|
|
63
59
|
link.freshness = version === link.version ? "current" : "stale";
|
|
64
60
|
}
|
|
65
|
-
if (event.t === "snapshot") {
|
|
66
|
-
if (event.removed.map(resourceKey).includes(link.resource)) link.freshness = "stale";
|
|
67
|
-
for (const [path, version] of Object.entries({ ...event.added, ...event.changed })) {
|
|
68
|
-
if (resourceKey(path) === link.resource) link.freshness = version === link.version ? "current" : "stale";
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
61
|
if (event.t === "checkpoint" && event.phase === "applied") for (const [path, change] of Object.entries(event.changes)) {
|
|
72
62
|
if (resourceKey(path) === link.resource) link.freshness = change.after?.sha256 === link.version ? "current" : "stale";
|
|
73
63
|
}
|
package/src/core/evidence.ts
CHANGED
|
@@ -19,12 +19,11 @@ export function evidenceStale(events: readonly LogEvent[], evidence: Evidence, i
|
|
|
19
19
|
if (event.t === "checkpoint" && (event.phase === "unavailable" || event.phase === "applied" && changedPaths(event.changes).length > 0)) stale = true;
|
|
20
20
|
if (event.t === "workspace_observation" && event.fingerprint === undefined ||
|
|
21
21
|
event.t === "tool_result" && event.effect?.kind === "command" && event.effect.workspace_observed === false || event.t === "user_bash") stale = true;
|
|
22
|
-
const sha = event.t === "workspace_observation" ? event.fingerprint
|
|
22
|
+
const sha = event.t === "workspace_observation" ? event.fingerprint
|
|
23
23
|
: event.t === "tool_result" && event.effect?.kind === "command" ? event.effect.workspace_sha256 : undefined;
|
|
24
24
|
const scope = event.t === "workspace_observation" ? event.scope : event.t === "tool_result" && event.effect?.kind === "command" ? event.effect.workspace_scope : undefined;
|
|
25
25
|
if (sha !== undefined && evidence.workspace_sha256 !== undefined) stale = evidence.workspace_scope === "source-v1"
|
|
26
26
|
? stale || scope !== evidence.workspace_scope || sha !== evidence.workspace_sha256 : scope !== undefined || sha !== evidence.workspace_sha256;
|
|
27
|
-
else if (event.t === "snapshot" && (Object.keys(event.added).length + Object.keys(event.changed).length + event.removed.length > 0)) stale = true;
|
|
28
27
|
}
|
|
29
28
|
return stale;
|
|
30
29
|
}
|
package/src/core/export.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { AssistantBlock, LogEvent } from "./events.ts";
|
|
15
|
+
import { counterVerdict } from "./acceptance.ts";
|
|
15
16
|
import { assertNever } from "./exhaustive.ts";
|
|
16
17
|
import { imageDescription } from "./images.ts";
|
|
17
18
|
|
|
@@ -109,17 +110,6 @@ function eventHtml(e: LogEvent): string {
|
|
|
109
110
|
return wrap("tool", `tool_result ${e.ok ? "ok" : "fail"}`, e.at, [e.content, ...(e.images ?? []).map(imageDescription)].join("\n"));
|
|
110
111
|
case "cut":
|
|
111
112
|
return wrap("event", "cut", e.at, `${e.reason} → ${e.handoff_ref}`);
|
|
112
|
-
case "snapshot":
|
|
113
|
-
return wrap(
|
|
114
|
-
"event",
|
|
115
|
-
"snapshot",
|
|
116
|
-
e.at,
|
|
117
|
-
[
|
|
118
|
-
...Object.keys(e.added).map((p) => `+ ${p}`),
|
|
119
|
-
...Object.keys(e.changed).map((p) => `~ ${p}`),
|
|
120
|
-
...e.removed.map((p) => `- ${p}`),
|
|
121
|
-
].join("\n") || "(no file changes)",
|
|
122
|
-
);
|
|
123
113
|
case "rewind":
|
|
124
114
|
return wrap("event", `rewind ${e.mode}`, e.at, e.prompt);
|
|
125
115
|
case "spawn":
|
|
@@ -152,6 +142,11 @@ function eventHtml(e: LogEvent): string {
|
|
|
152
142
|
case "acceptance_contract": return wrap("event", "acceptance contract", e.at, JSON.stringify(e.contract));
|
|
153
143
|
case "target_observation": return wrap("event", "runtime target", e.at, JSON.stringify(e.observation));
|
|
154
144
|
case "acceptance_decision": return wrap("event", "human acceptance", e.at, `${e.contract_id}: ${e.criterion}`);
|
|
145
|
+
case "counter_check": return wrap("event", "counter-check", e.at,
|
|
146
|
+
`${e.criterion}${e.guard ? " (guard)" : ""} · ${counterVerdict(e)}${"reason" in e ? ` · ${e.reason}` : ` · exit ${e.exit}`}`);
|
|
147
|
+
case "blind_check": return wrap("event", "blind check", e.at, `${e.check} · ${"reason" in e ? e.reason : `exit ${e.exit}`}${e.exposed ? ` · exposed: ${e.exposure ?? "yes"}` : ""}`);
|
|
148
|
+
case "goal": return wrap("event", e.action === "set" ? "goal set" : "goal cleared", e.at,
|
|
149
|
+
e.action === "set" ? `${e.objective}\naccepted: ${e.accepted.join(", ")}${e.rejected.length ? `\nrejected: ${e.rejected.join(", ")}` : ""}` : e.reason);
|
|
155
150
|
default:
|
|
156
151
|
return assertNever(e, "eventHtml");
|
|
157
152
|
}
|
package/src/core/goal.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit goals (research doc S.2 / S.5).
|
|
3
|
+
*
|
|
4
|
+
* The model drafts acceptance criteria for a goal; the user accepts them one
|
|
5
|
+
* by one; only the accepted ones become the goal's contract. Whether the goal
|
|
6
|
+
* is met is never the model's call: it is the request ledger's status, which
|
|
7
|
+
* only command receipts and the user's own acceptance decisions move (D11).
|
|
8
|
+
* The draft is itself only a claim -- it parses into candidates, and a
|
|
9
|
+
* candidate that does not validate is reported and sent back to the model to
|
|
10
|
+
* correct, never repaired by guessing.
|
|
11
|
+
*/
|
|
12
|
+
import { ACCEPTANCE_COMMAND_LIMIT, parseAcceptanceContract, type AcceptanceContract, type AcceptanceCriterion } from "./acceptance.ts";
|
|
13
|
+
import type { GoalEvent, LogEvent } from "./events.ts";
|
|
14
|
+
import type { RequestState } from "./request-state.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Assumed, not measured: enough for a goal's distinct checks, few enough that
|
|
18
|
+
* a person reads and decides on each one rather than accepting a wall.
|
|
19
|
+
*/
|
|
20
|
+
export const GOAL_CRITERIA_LIMIT = 8;
|
|
21
|
+
|
|
22
|
+
export function goalDraftRequest(objective: string): string {
|
|
23
|
+
return [
|
|
24
|
+
`Goal: ${objective}`,
|
|
25
|
+
"",
|
|
26
|
+
"Do not start the work yet. Draft the acceptance criteria that would show this goal is met, for the user to accept or reject one by one.",
|
|
27
|
+
"You may read the workspace first (read, find, grep) to learn its real files and commands. You cannot change anything or run commands in this step.",
|
|
28
|
+
`Then reply with only a JSON object: {"criteria":[{"id":"short-id","kind":"command","description":"what it shows","command":"exact shell command"}]}, at most ${GOAL_CRITERIA_LIMIT} items.`,
|
|
29
|
+
"- kind \"command\": a command that exits 0 only when the criterion holds, and fails on the workspace as it is now if the goal is not yet done. Prefer checks that exercise new inputs over checks that only replay an example.",
|
|
30
|
+
"- \"guard\": true on a command for what the goal must not break or change, such as files to leave untouched or behaviour that must keep working. A guard passes now and must still pass when the goal is done; every command without it must fail now.",
|
|
31
|
+
"- kind \"human\" (no command): only for what a person must judge, such as tone or visual quality.",
|
|
32
|
+
"- ids: letters, digits, dot, dash, underscore, colon; unique.",
|
|
33
|
+
"If you cannot tell how to check something, make it a human criterion rather than inventing a command.",
|
|
34
|
+
"If the goal cannot be checked or achieved in this workspace at all, reply {\"criteria\": [], \"blocked\": \"the specific reason\"}.",
|
|
35
|
+
].join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type GoalDraft = { criteria: AcceptanceCriterion[]; errors: string[]; blocked?: string };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Assumed, not measured: one correction. Instructor's reask and Pydantic AI's
|
|
42
|
+
* output retries likewise send the validation errors back for a bounded number
|
|
43
|
+
* of attempts. The slip this exists for -- a real GLM draft missing its
|
|
44
|
+
* closing brackets -- needs one; a model that fails the same schema twice is
|
|
45
|
+
* not helped by a third full request, and the user can still redraft.
|
|
46
|
+
*/
|
|
47
|
+
export const GOAL_DRAFT_RETRIES = 1;
|
|
48
|
+
|
|
49
|
+
/** The correction request: the parser's own reasons, and the form the reply must take. */
|
|
50
|
+
export function goalDraftRetry(errors: readonly string[]): string {
|
|
51
|
+
return [
|
|
52
|
+
"Harness draft check: your draft could not be used as written.",
|
|
53
|
+
...errors.map(error => `- ${error}`),
|
|
54
|
+
"Reply again with only the complete corrected JSON object in the same form, keeping the criteria that were valid. Do not start the work.",
|
|
55
|
+
].join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The draft among the events one draft run recorded: the last assistant reply
|
|
60
|
+
* of the first request they open. A follow-up the user queued meanwhile opens
|
|
61
|
+
* its own request and is never read as the draft; a run that recorded no
|
|
62
|
+
* request has no draft.
|
|
63
|
+
*/
|
|
64
|
+
export function draftReply(recorded: readonly LogEvent[]): { id: string; text: string } | null {
|
|
65
|
+
const opens = (event: LogEvent) => event.t === "user" && event.request === undefined;
|
|
66
|
+
const start = recorded.findIndex(opens);
|
|
67
|
+
if (start < 0) return null;
|
|
68
|
+
const next = recorded.findIndex((event, index) => index > start && opens(event));
|
|
69
|
+
const reply = recorded.slice(start, next < 0 ? undefined : next).findLast(event => event.t === "assistant");
|
|
70
|
+
return reply?.t === "assistant" ? { id: reply.id, text: reply.blocks.flatMap(block => block.type === "text" ? [block.text] : []).join("\n") } : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The first JSON object in a reply: a fenced block when present, else the outermost braces. */
|
|
74
|
+
function jsonObject(text: string): unknown {
|
|
75
|
+
const fenced = /```(?:json)?\s*\n([\s\S]*?)\n```/.exec(text)?.[1];
|
|
76
|
+
const candidate = fenced ?? text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1);
|
|
77
|
+
if (!candidate.trim()) throw Error("the reply holds no JSON object");
|
|
78
|
+
return JSON.parse(candidate);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,79}$/;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Why one drafted item is not a criterion, in words the user can act on; null
|
|
85
|
+
* when it is one. A human item's empty or null `command`, and a `guard` of
|
|
86
|
+
* false or null, are only a model's way of saying "none" and are dropped
|
|
87
|
+
* before this runs.
|
|
88
|
+
*/
|
|
89
|
+
function draftItemError(item: Record<string, unknown>, seen: ReadonlySet<string>): string | null {
|
|
90
|
+
const { id, kind, description, command, guard } = item;
|
|
91
|
+
if (typeof id !== "string" || !id.trim()) return "has no id";
|
|
92
|
+
if (!ID.test(id)) return `id ${JSON.stringify(id)} may use only letters, digits, dot, dash, underscore and colon (at most 80)`;
|
|
93
|
+
if (seen.has(id)) return `repeats id ${id}`;
|
|
94
|
+
if (typeof description !== "string" || !description.trim()) return "has no description";
|
|
95
|
+
if (description.length > 300) return "has a description longer than 300 characters";
|
|
96
|
+
if (kind === "human") return command !== undefined ? "is a human criterion with a command; make it a command criterion or drop the command"
|
|
97
|
+
: guard !== undefined ? "is a human criterion marked as a guard; a guard needs a command" : null;
|
|
98
|
+
if (kind !== "command") return `has kind ${JSON.stringify(kind)}; use "command" or "human"`;
|
|
99
|
+
if (guard !== undefined && guard !== true) return `has guard ${JSON.stringify(guard)}; use true or leave it out`;
|
|
100
|
+
if (typeof command !== "string" || !command.trim()) return "needs a command";
|
|
101
|
+
if (command.length > ACCEPTANCE_COMMAND_LIMIT) return `has a command longer than ${ACCEPTANCE_COMMAND_LIMIT} characters`;
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function parseGoalDraft(text: string): GoalDraft {
|
|
106
|
+
let value: unknown;
|
|
107
|
+
try { value = jsonObject(text); }
|
|
108
|
+
catch (error) { return { criteria: [], errors: [`the draft is not readable JSON: ${error instanceof Error ? error.message : String(error)}`] }; }
|
|
109
|
+
const object = value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
110
|
+
const items = object?.["criteria"];
|
|
111
|
+
if (!Array.isArray(items)) return { criteria: [], errors: ["the draft has no criteria array"] };
|
|
112
|
+
const blockedText = object?.["blocked"];
|
|
113
|
+
const blocked = typeof blockedText === "string" && blockedText.trim() ? blockedText.trim().slice(0, 500) : undefined;
|
|
114
|
+
const criteria: AcceptanceCriterion[] = [], errors: string[] = [], seen = new Set<string>();
|
|
115
|
+
items.forEach((raw, index) => {
|
|
116
|
+
if (criteria.length >= GOAL_CRITERIA_LIMIT) { errors.push(`criterion ${index + 1}: more than ${GOAL_CRITERIA_LIMIT} criteria`); return; }
|
|
117
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { errors.push(`criterion ${index + 1} is not an object`); return; }
|
|
118
|
+
const item = { ...(raw as Record<string, unknown>) };
|
|
119
|
+
if (item["kind"] === "human" && (item["command"] === null || typeof item["command"] === "string" && !item["command"].trim())) delete item["command"];
|
|
120
|
+
if (item["guard"] === false || item["guard"] === null) delete item["guard"];
|
|
121
|
+
const problem = draftItemError(item, seen);
|
|
122
|
+
if (problem) { errors.push(`criterion ${index + 1} ${problem}`); return; }
|
|
123
|
+
// The one validator for criteria: a draft item is accepted exactly when a contract would accept it.
|
|
124
|
+
try {
|
|
125
|
+
const [criterion] = parseAcceptanceContract({ version: 1, id: "goal", criteria: [{ id: item["id"], kind: item["kind"], description: item["description"],
|
|
126
|
+
...(item["kind"] === "command" ? { command: item["command"], ...(item["guard"] === true ? { guard: true } : {}) } : {}) }] }).criteria;
|
|
127
|
+
seen.add(criterion!.id); criteria.push(criterion!);
|
|
128
|
+
} catch (error) { errors.push(`criterion ${index + 1}: ${error instanceof Error ? error.message : String(error)}`); }
|
|
129
|
+
});
|
|
130
|
+
return { criteria, errors, ...(blocked ? { blocked } : {}) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Only accepted criteria, in draft order. Accepting none is not a goal. */
|
|
134
|
+
export function goalContract(criteria: readonly AcceptanceCriterion[], accepted: readonly string[]): AcceptanceContract {
|
|
135
|
+
const chosen = criteria.filter(item => accepted.includes(item.id));
|
|
136
|
+
if (chosen.length === 0) throw Error("a goal needs at least one accepted criterion");
|
|
137
|
+
return parseAcceptanceContract({ version: 1, id: "goal", scope: "request", criteria: chosen });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type ActiveGoal = Extract<GoalEvent, { action: "set" }>;
|
|
141
|
+
|
|
142
|
+
/** The goal in force at the end of an ancestry: the last set, unless a clear followed it. */
|
|
143
|
+
export function activeGoal(events: readonly LogEvent[]): ActiveGoal | null {
|
|
144
|
+
const last = events.findLast(event => event.t === "goal");
|
|
145
|
+
return last?.t === "goal" && last.action === "set" ? last : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Assumed, not measured: two goal checks answered without any tool use means
|
|
150
|
+
* the model is talking past the goal, and more requests will not change that.
|
|
151
|
+
* Claude Code's /goal stops on the same signal ("no tool use for several
|
|
152
|
+
* turns in a row"). Too low would stop a model that needed one more reply to
|
|
153
|
+
* plan; too high spends requests on replies that do nothing.
|
|
154
|
+
*/
|
|
155
|
+
export const GOAL_IDLE_LIMIT = 2;
|
|
156
|
+
|
|
157
|
+
/** Goal checks since the last tool result: each one the model answered without acting. */
|
|
158
|
+
export function idleGoalChecks(events: readonly LogEvent[]): number {
|
|
159
|
+
let count = 0;
|
|
160
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
161
|
+
const event = events[i]!;
|
|
162
|
+
if (event.t === "tool_result" || event.t === "user" && event.request === undefined) break;
|
|
163
|
+
if (event.t === "guidance" && event.reason === "goal") count++;
|
|
164
|
+
}
|
|
165
|
+
return count;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export type GoalDecision =
|
|
169
|
+
| { action: "met" }
|
|
170
|
+
| { action: "await_human" }
|
|
171
|
+
| { action: "stop"; reason: string }
|
|
172
|
+
| { action: "continue"; text: string };
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* What a goal does when the model ends a turn. Reads the request ledger, so
|
|
176
|
+
* the loop and every goal view agree. A reported blocker stops the loop --
|
|
177
|
+
* spending more requests against a stated blocker is the user's call -- but
|
|
178
|
+
* leaves the goal active: the statement is not evidence it cannot be met.
|
|
179
|
+
*/
|
|
180
|
+
export function goalDecision(state: RequestState, remainingRequests: number, idleChecks = 0): GoalDecision {
|
|
181
|
+
if (state.status === "verified") return { action: "met" };
|
|
182
|
+
if (state.status === "awaiting_human") return { action: "await_human" };
|
|
183
|
+
if (state.reported_blockers.length > 0) return { action: "stop", reason: `the model reported a blocker: ${state.reported_blockers.at(-1)!.reason}` };
|
|
184
|
+
if (remainingRequests < 1) return { action: "stop", reason: "the request budget is spent" };
|
|
185
|
+
if (idleChecks >= GOAL_IDLE_LIMIT) return { action: "stop", reason: `the model answered ${idleChecks} goal checks without acting` };
|
|
186
|
+
const missing = state.criteria.filter(item => item.kind === "command" && item.status !== "pass");
|
|
187
|
+
// An unchanged workspace failing the same way twice: rerunning is pointless,
|
|
188
|
+
// and the accepted check may be what is wrong. The model may not rewrite it;
|
|
189
|
+
// only the user can redraft a goal (S.2).
|
|
190
|
+
const stuck = missing.filter(item => (item.repeated_failures ?? 0) >= 2), rerun = missing.filter(item => !stuck.includes(item));
|
|
191
|
+
return { action: "continue", text: [
|
|
192
|
+
`Harness goal check: the goal is not met yet. Unmet: ${state.unmet.join("; ")}.`,
|
|
193
|
+
...(rerun.length ? ["Run each missing check exactly, as a separate invocation:",
|
|
194
|
+
...rerun.map(item => `- ${item.id}${item.guard ? " (a guard: it must keep passing)" : ""}: ${JSON.stringify(item.command)}`)] : []),
|
|
195
|
+
...stuck.map(item => `- ${item.id} failed ${item.repeated_failures} times in a row against an unchanged workspace. Do not rerun it as is: change the work, ` +
|
|
196
|
+
"or, if the check itself may itself be broken (a syntax error, a missing tool, or a test of the wrong thing), record which check is broken and why " +
|
|
197
|
+
"with note(kind='blocked') and stop. Do not rewrite or replace an accepted check; the user can redraft the goal with /goal."),
|
|
198
|
+
"Keep working toward the goal. If something outside your control blocks it, record the specific blocker with note(kind='blocked') and stop.",
|
|
199
|
+
].join("\n") };
|
|
200
|
+
}
|
package/src/core/history.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** D20: bounded carried state, with deterministic pages rebuilt from the log. */
|
|
2
2
|
import { emitHandoff, type Handoff } from "./handoff.ts";
|
|
3
3
|
import { estimateTokens } from "./tokens.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { recoveryWorkingSet } from "./working-set.ts";
|
|
5
5
|
import type { RecoveryNodes } from "./recovery-sources.ts";
|
|
6
6
|
|
|
7
7
|
// A carried handoff must leave room for another segment's work. This budget is
|
|
@@ -117,16 +117,6 @@ export function historyPage(full: Handoff, section: HistorySection, offset: numb
|
|
|
117
117
|
return { total: items.length, offset, next: end < items.length ? end : null, items: items.slice(offset, end) };
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
/** O.10: the optional priority view shares the original total budget. */
|
|
121
|
-
export function boundWorkingHandoff(full: Handoff, budget = DEFAULT_HANDOFF_TOKENS): Handoff {
|
|
122
|
-
const legacy = boundHandoff(full, budget);
|
|
123
|
-
// 40% is a declared allocation hypothesis, not a measured context threshold.
|
|
124
|
-
const view = workingSet(full, budget * .4);
|
|
125
|
-
if (!view) return legacy;
|
|
126
|
-
try { return boundHandoff({ ...full, working_set: view }, budget); }
|
|
127
|
-
catch (error) { if (error instanceof RangeError) return legacy; throw error; }
|
|
128
|
-
}
|
|
129
|
-
|
|
130
120
|
/** R9 uses the same budget; originals are supplied by the ancestry owner. */
|
|
131
121
|
export function boundRecoveryHandoff(full: Handoff, nodes: RecoveryNodes, budget = DEFAULT_HANDOFF_TOKENS, tools?: readonly string[]): Handoff {
|
|
132
122
|
const legacy = boundHandoff(full, budget);
|
package/src/core/json-event.ts
CHANGED
|
@@ -30,8 +30,6 @@ export type JsonLoopKind =
|
|
|
30
30
|
| "stop_and_ask"
|
|
31
31
|
| "branch_start"
|
|
32
32
|
| "branch_done"
|
|
33
|
-
| "snapshot_failed"
|
|
34
|
-
| "undeclared_change"
|
|
35
33
|
| "done";
|
|
36
34
|
|
|
37
35
|
/**
|
|
@@ -127,10 +125,6 @@ export function toJsonEvent(e: JsonLoopEvent, at: string): Record<string, unknow
|
|
|
127
125
|
...(e.text !== undefined ? { text: e.text } : {}),
|
|
128
126
|
at,
|
|
129
127
|
};
|
|
130
|
-
case "snapshot_failed":
|
|
131
|
-
return { type: "snapshot_failed", ...(e.reason !== undefined ? { reason: e.reason } : {}), at };
|
|
132
|
-
case "undeclared_change":
|
|
133
|
-
return { type: "undeclared_change", ...(e.reason !== undefined ? { reason: e.reason } : {}), at };
|
|
134
128
|
default:
|
|
135
129
|
return assertNever(e.kind, "toJsonEvent");
|
|
136
130
|
}
|
package/src/core/outcome.ts
CHANGED
|
@@ -3,6 +3,15 @@ import type { ExecutionOutcome } from "./outcome.ts";
|
|
|
3
3
|
|
|
4
4
|
export type ProgramStatus = { state: "idle" | "working" | "done" | "blocked" | "error" | "clear";
|
|
5
5
|
kind?: "permission" | "question" | "auth"; msg?: string };
|
|
6
|
+
/** S.1: what a finished turn's ledger says, in one line; the status itself stays "done". */
|
|
7
|
+
const FINISHED: Readonly<Record<string, string>> = {
|
|
8
|
+
verified: "Goal verified against its contract",
|
|
9
|
+
checked: "Turn finished; changes passed a fresh check",
|
|
10
|
+
unchecked: "Turn finished; changes not checked",
|
|
11
|
+
needs_work: "Turn finished; declared checks incomplete",
|
|
12
|
+
not_applicable: "Turn finished",
|
|
13
|
+
};
|
|
14
|
+
|
|
6
15
|
export function programStatus(input: { running: boolean; waiting?: "permission" | "question" | "auth" | null;
|
|
7
16
|
outcome?: ExecutionOutcome; goal?: string }): ProgramStatus {
|
|
8
17
|
if (input.waiting) return { state: "blocked", kind: input.waiting, msg: input.waiting === "auth" ? "Sign in to continue" : input.waiting === "permission" ? "Tool approval required" : "User input required" };
|
|
@@ -10,8 +19,8 @@ export function programStatus(input: { running: boolean; waiting?: "permission"
|
|
|
10
19
|
if (input.outcome?.status === "cancelled") return { state: "idle", msg: "Turn cancelled" };
|
|
11
20
|
if (input.outcome?.status === "failed") return { state: "error", msg: "Turn failed; inspect the session" };
|
|
12
21
|
if (input.outcome?.status === "stopped") return { state: "blocked", kind: "question", msg: "Turn stopped; review evidence before continuing" };
|
|
13
|
-
if (input.goal === "awaiting_human"
|
|
14
|
-
if (input.outcome?.reason === "model_end") return { state: "done", msg: input.goal
|
|
22
|
+
if (input.goal === "awaiting_human") return { state: "blocked", kind: "question", msg: "Goal awaits user review" };
|
|
23
|
+
if (input.outcome?.reason === "model_end") return { state: "done", msg: FINISHED[input.goal ?? "not_applicable"] ?? FINISHED["not_applicable"]! };
|
|
15
24
|
return { state: "idle", msg: "Ready for a request" };
|
|
16
25
|
}
|
|
17
26
|
export const PROGRAM_STATUS_QUERY = "\x1b]7501;?\x1b\\";
|
package/src/core/progress.ts
CHANGED
|
@@ -66,9 +66,12 @@ export function deriveProgress(
|
|
|
66
66
|
if (event.t !== "tool_result") continue;
|
|
67
67
|
const name = calls.get(event.call_id);
|
|
68
68
|
calls.delete(event.call_id);
|
|
69
|
-
if (!event.ok) continue;
|
|
70
|
-
const source = { node, event_id: event.id };
|
|
71
69
|
const effect = event.effect;
|
|
70
|
+
// A failed tool that never ran observed nothing. A command that ran and
|
|
71
|
+
// exited nonzero did observe something (S.6 item 2): its output counts
|
|
72
|
+
// like any other, but it is never a check.
|
|
73
|
+
if (!event.ok && effect?.kind !== "command") continue;
|
|
74
|
+
const source = { node, event_id: event.id };
|
|
72
75
|
const observation = event.observation_sha256 ?? event.content_ref?.sha256 ?? event.content_sha256;
|
|
73
76
|
if (effect?.kind === "read" && effect.sha256 !== "") observations.add(versionKey(effect.path, effect.sha256));
|
|
74
77
|
if (effect?.kind === "read" && effect.sha256 !== "") {
|
|
@@ -88,18 +91,21 @@ export function deriveProgress(
|
|
|
88
91
|
if (observation === undefined || !observations.has(observation)) progressed = true;
|
|
89
92
|
if (observation !== undefined) observations.add(observation);
|
|
90
93
|
}
|
|
91
|
-
} else if (effect?.kind === "command"
|
|
92
|
-
// D9
|
|
93
|
-
// new observation, exactly as for read/grep
|
|
94
|
-
// `sed -n` or `grep` in the shell
|
|
95
|
-
//
|
|
94
|
+
} else if (effect?.kind === "command") {
|
|
95
|
+
// D9 revisions (2026-09-29 and S.6 item 2, user-approved): output never
|
|
96
|
+
// seen before is a new observation, exactly as for read/grep, whatever
|
|
97
|
+
// the exit code. A model exploring with `sed -n` or `grep` in the shell
|
|
98
|
+
// is gathering evidence too, and a grep with no match exits 1 after the
|
|
99
|
+
// earlier pipeline stages printed what was asked for. The digest
|
|
100
|
+
// excludes wall time, so an unchanged `git status` or the same failure
|
|
101
|
+
// repeats and stalls.
|
|
96
102
|
if (observation !== undefined && !observations.has(observation)) {
|
|
97
103
|
observations.add(observation);
|
|
98
104
|
evidence.push({ kind: "observation", sha256: observation, source });
|
|
99
105
|
progressed = true;
|
|
100
106
|
}
|
|
101
107
|
const key = checkKey(effect.cmd, effect.gate, effect.workspace_sha256);
|
|
102
|
-
if (!checks.has(key)) {
|
|
108
|
+
if (event.ok && effect.exit === 0 && !checks.has(key)) {
|
|
103
109
|
checks.add(key);
|
|
104
110
|
evidence.push({ kind: "check", command: effect.cmd, source,
|
|
105
111
|
...(effect.gate === undefined ? {} : { gate: effect.gate }),
|