@martinloop/mcp 0.3.8 → 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/README.md +90 -106
- package/dist/discovery-metadata.d.ts +1 -1
- package/dist/discovery-metadata.js +1 -1
- package/dist/resources.d.ts +1 -2
- package/dist/resources.js +190 -238
- package/dist/server-validation.d.ts +1 -1
- package/dist/server-validation.js +16 -11
- package/dist/server.d.ts +23 -0
- package/dist/server.js +152 -7
- package/dist/tools/run-dossier.d.ts +3 -1
- package/dist/tools/run-dossier.js +40 -2
- package/dist/tools/run-loop.d.ts +1 -4
- package/dist/tools/run-loop.js +6 -15
- package/dist/tools/tool-support.d.ts +2 -1
- package/dist/tools/tool-support.js +1 -12
- package/dist/vendor/adapters/claude-cli.js +38 -35
- package/dist/vendor/adapters/cli-bridge.d.ts +9 -1
- package/dist/vendor/adapters/cli-bridge.js +54 -13
- package/dist/vendor/adapters/codex-launcher.d.ts +61 -1
- package/dist/vendor/adapters/codex-launcher.js +118 -19
- package/dist/vendor/adapters/index.d.ts +1 -3
- package/dist/vendor/adapters/index.js +1 -3
- package/dist/vendor/adapters/openai-compatible.js +57 -18
- package/dist/vendor/contracts/adapter-capabilities.d.ts +1 -1
- package/dist/vendor/contracts/context-handoff.d.ts +100 -0
- package/dist/vendor/contracts/context-handoff.js +11 -0
- package/dist/vendor/contracts/context-manifest.d.ts +195 -0
- package/dist/vendor/contracts/context-manifest.js +17 -0
- package/dist/vendor/contracts/context-shadow.d.ts +75 -0
- package/dist/vendor/contracts/context-shadow.js +8 -0
- package/dist/vendor/contracts/exits.d.ts +126 -0
- package/dist/vendor/contracts/exits.js +32 -0
- package/dist/vendor/contracts/index.d.ts +39 -2
- package/dist/vendor/contracts/index.js +22 -0
- package/dist/vendor/contracts/mission.d.ts +102 -0
- package/dist/vendor/contracts/mission.js +64 -0
- package/dist/vendor/contracts/operator.d.ts +3 -1
- package/dist/vendor/contracts/operator.js +2 -1
- package/dist/vendor/contracts/verified-handoff.d.ts +100 -0
- package/dist/vendor/contracts/verified-handoff.js +35 -0
- package/dist/vendor/core/context-chain-gate.d.ts +89 -0
- package/dist/vendor/core/context-chain-gate.js +173 -0
- package/dist/vendor/core/context-compiler.d.ts +53 -0
- package/dist/vendor/core/context-compiler.js +261 -0
- package/dist/vendor/core/context-handoff.d.ts +42 -0
- package/dist/vendor/core/context-handoff.js +167 -0
- package/dist/vendor/core/context-shadow.d.ts +39 -0
- package/dist/vendor/core/context-shadow.js +200 -0
- package/dist/vendor/core/exit-signal.d.ts +88 -0
- package/dist/vendor/core/exit-signal.js +339 -0
- package/dist/vendor/core/exits.d.ts +41 -0
- package/dist/vendor/core/exits.js +258 -0
- package/dist/vendor/core/index.d.ts +42 -1
- package/dist/vendor/core/index.js +1086 -662
- package/dist/vendor/core/mission/aggregate.d.ts +27 -0
- package/dist/vendor/core/mission/aggregate.js +42 -0
- package/dist/vendor/core/mission/index.d.ts +2 -0
- package/dist/vendor/core/mission/index.js +1 -0
- package/dist/vendor/core/persistence/compiler.d.ts +8 -0
- package/dist/vendor/core/persistence/compiler.js +156 -0
- package/dist/vendor/core/persistence/context-handoff-store.d.ts +68 -0
- package/dist/vendor/core/persistence/context-handoff-store.js +112 -0
- package/dist/vendor/core/persistence/index.d.ts +4 -0
- package/dist/vendor/core/persistence/index.js +2 -0
- package/dist/vendor/core/persistence/ledger.d.ts +1 -1
- package/dist/vendor/core/persistence/mission-store.d.ts +56 -0
- package/dist/vendor/core/persistence/mission-store.js +281 -0
- package/dist/vendor/core/policy.d.ts +2 -0
- package/dist/vendor/core/policy.js +5 -2
- package/dist/vendor/core/rollback.js +15 -1
- package/dist/vendor/core/savings.d.ts +13 -0
- package/dist/vendor/core/savings.js +22 -0
- package/dist/vendor/core/termination-store.d.ts +15 -0
- package/dist/vendor/core/termination-store.js +44 -0
- package/dist/vendor/core/verified-handoff.d.ts +67 -0
- package/dist/vendor/core/verified-handoff.js +210 -0
- package/package.json +76 -83
- package/server.json +21 -21
- package/LICENSE +0 -216
- package/dist/vendor/adapters/adapters/src/claude-cli.d.ts +0 -146
- package/dist/vendor/adapters/adapters/src/claude-cli.js +0 -1100
- package/dist/vendor/adapters/adapters/src/cli-bridge.d.ts +0 -103
- package/dist/vendor/adapters/adapters/src/cli-bridge.js +0 -569
- package/dist/vendor/adapters/adapters/src/codex-launcher.d.ts +0 -78
- package/dist/vendor/adapters/adapters/src/codex-launcher.js +0 -663
- package/dist/vendor/adapters/adapters/src/direct-provider.d.ts +0 -10
- package/dist/vendor/adapters/adapters/src/direct-provider.js +0 -40
- package/dist/vendor/adapters/adapters/src/index.d.ts +0 -8
- package/dist/vendor/adapters/adapters/src/index.js +0 -8
- package/dist/vendor/adapters/adapters/src/openai-compatible.d.ts +0 -62
- package/dist/vendor/adapters/adapters/src/openai-compatible.js +0 -306
- package/dist/vendor/adapters/adapters/src/runtime-support.d.ts +0 -17
- package/dist/vendor/adapters/adapters/src/runtime-support.js +0 -59
- package/dist/vendor/adapters/adapters/src/stub-agent-cli.d.ts +0 -8
- package/dist/vendor/adapters/adapters/src/stub-agent-cli.js +0 -40
- package/dist/vendor/adapters/adapters/src/stub-direct-provider.d.ts +0 -8
- package/dist/vendor/adapters/adapters/src/stub-direct-provider.js +0 -12
- package/dist/vendor/adapters/adapters/src/verifier-only.d.ts +0 -9
- package/dist/vendor/adapters/adapters/src/verifier-only.js +0 -63
- package/dist/vendor/adapters/contracts/src/context-graph.d.ts +0 -48
- package/dist/vendor/adapters/contracts/src/context-graph.js +0 -26
- package/dist/vendor/adapters/contracts/src/execution-policy.d.ts +0 -78
- package/dist/vendor/adapters/contracts/src/execution-policy.js +0 -43
- package/dist/vendor/adapters/contracts/src/governance.d.ts +0 -24
- package/dist/vendor/adapters/contracts/src/governance.js +0 -11
- package/dist/vendor/adapters/contracts/src/identity.d.ts +0 -29
- package/dist/vendor/adapters/contracts/src/identity.js +0 -30
- package/dist/vendor/adapters/contracts/src/index.d.ts +0 -412
- package/dist/vendor/adapters/contracts/src/index.js +0 -225
- package/dist/vendor/adapters/contracts/src/operator.d.ts +0 -19
- package/dist/vendor/adapters/contracts/src/operator.js +0 -11
- package/dist/vendor/adapters/contracts/src/trajectory.d.ts +0 -26
- package/dist/vendor/adapters/contracts/src/trajectory.js +0 -27
- package/dist/vendor/adapters/core/src/compiler.d.ts +0 -52
- package/dist/vendor/adapters/core/src/compiler.js +0 -52
- package/dist/vendor/adapters/core/src/context-graph.d.ts +0 -3
- package/dist/vendor/adapters/core/src/context-graph.js +0 -371
- package/dist/vendor/adapters/core/src/context-integrity.d.ts +0 -26
- package/dist/vendor/adapters/core/src/context-integrity.js +0 -80
- package/dist/vendor/adapters/core/src/grounding.d.ts +0 -38
- package/dist/vendor/adapters/core/src/grounding.js +0 -273
- package/dist/vendor/adapters/core/src/identity.d.ts +0 -20
- package/dist/vendor/adapters/core/src/identity.js +0 -86
- package/dist/vendor/adapters/core/src/index.d.ts +0 -186
- package/dist/vendor/adapters/core/src/index.js +0 -1495
- package/dist/vendor/adapters/core/src/leash.d.ts +0 -48
- package/dist/vendor/adapters/core/src/leash.js +0 -497
- package/dist/vendor/adapters/core/src/persistence/compiler.d.ts +0 -18
- package/dist/vendor/adapters/core/src/persistence/compiler.js +0 -34
- package/dist/vendor/adapters/core/src/persistence/index.d.ts +0 -10
- package/dist/vendor/adapters/core/src/persistence/index.js +0 -5
- package/dist/vendor/adapters/core/src/persistence/integrity.d.ts +0 -39
- package/dist/vendor/adapters/core/src/persistence/integrity.js +0 -252
- package/dist/vendor/adapters/core/src/persistence/ledger.d.ts +0 -23
- package/dist/vendor/adapters/core/src/persistence/ledger.js +0 -9
- package/dist/vendor/adapters/core/src/persistence/memory-store.d.ts +0 -63
- package/dist/vendor/adapters/core/src/persistence/memory-store.js +0 -115
- package/dist/vendor/adapters/core/src/persistence/runs-reader.d.ts +0 -66
- package/dist/vendor/adapters/core/src/persistence/runs-reader.js +0 -140
- package/dist/vendor/adapters/core/src/persistence/store.d.ts +0 -89
- package/dist/vendor/adapters/core/src/persistence/store.js +0 -125
- package/dist/vendor/adapters/core/src/persistence/trace-store.d.ts +0 -85
- package/dist/vendor/adapters/core/src/persistence/trace-store.js +0 -125
- package/dist/vendor/adapters/core/src/policy-compiler.d.ts +0 -2
- package/dist/vendor/adapters/core/src/policy-compiler.js +0 -195
- package/dist/vendor/adapters/core/src/policy.d.ts +0 -156
- package/dist/vendor/adapters/core/src/policy.js +0 -704
- package/dist/vendor/adapters/core/src/repo-analyzer.d.ts +0 -53
- package/dist/vendor/adapters/core/src/repo-analyzer.js +0 -263
- package/dist/vendor/adapters/core/src/rollback.d.ts +0 -15
- package/dist/vendor/adapters/core/src/rollback.js +0 -253
- package/dist/vendor/adapters/core/src/routing.d.ts +0 -82
- package/dist/vendor/adapters/core/src/routing.js +0 -336
- package/dist/vendor/adapters/core/src/test-integrity.d.ts +0 -64
- package/dist/vendor/adapters/core/src/test-integrity.js +0 -197
- package/dist/vendor/adapters/core/src/trajectory.d.ts +0 -18
- package/dist/vendor/adapters/core/src/trajectory.js +0 -295
- package/dist/vendor/adapters/stub-agent-cli.d.ts +0 -8
- package/dist/vendor/adapters/stub-agent-cli.js +0 -40
- package/dist/vendor/adapters/verifier-only.d.ts +0 -9
- package/dist/vendor/adapters/verifier-only.js +0 -63
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Shadow Compiler — A-CTX-0
|
|
3
|
+
*
|
|
4
|
+
* Deterministic shadow manifest emitter. Observes the compiled prompt and
|
|
5
|
+
* records what a context-aware selector would have chosen, without altering
|
|
6
|
+
* the actual provider input in any way.
|
|
7
|
+
*
|
|
8
|
+
* Invariants:
|
|
9
|
+
* - nowMs is always a captured input; Date.now() is never called internally
|
|
10
|
+
* - No source text appears in any output type
|
|
11
|
+
* - Required segments are always preferred first
|
|
12
|
+
* - required-over-budget is recorded, never thrown
|
|
13
|
+
* - Manifest hash covers metadata only (no content)
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { CONTEXT_SHADOW_MANIFEST_VERSION, CONTEXT_C5_VERSION } from "../contracts/index.js";
|
|
17
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
18
|
+
function sha256(value) {
|
|
19
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
20
|
+
}
|
|
21
|
+
function canonicalize(value) {
|
|
22
|
+
if (Array.isArray(value)) {
|
|
23
|
+
return value.map(canonicalize);
|
|
24
|
+
}
|
|
25
|
+
if (value !== null && typeof value === "object") {
|
|
26
|
+
return Object.fromEntries(Object.entries(value)
|
|
27
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
28
|
+
.map(([k, v]) => [k, canonicalize(v)]));
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function canonicalJson(value) {
|
|
33
|
+
return JSON.stringify(canonicalize(value));
|
|
34
|
+
}
|
|
35
|
+
export function estimateContextTokens(text) {
|
|
36
|
+
if (text.length === 0)
|
|
37
|
+
return 0;
|
|
38
|
+
return Math.max(1, Math.ceil(Buffer.byteLength(text, "utf8") / 4));
|
|
39
|
+
}
|
|
40
|
+
// ─── Compiler ────────────────────────────────────────────────────────────────
|
|
41
|
+
export function compileContextShadow(input) {
|
|
42
|
+
const { runId, adapter, nowMs, shadowBudgetTokens, segments } = input;
|
|
43
|
+
// Validate: all segmentIds must be non-empty and unique
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
for (const seg of segments) {
|
|
46
|
+
if (!seg.segmentId || seg.segmentId.trim() === "") {
|
|
47
|
+
throw new Error("compileContextShadow: segmentId must be non-empty");
|
|
48
|
+
}
|
|
49
|
+
if (seen.has(seg.segmentId)) {
|
|
50
|
+
throw new Error(`compileContextShadow: duplicate segmentId "${seg.segmentId}"`);
|
|
51
|
+
}
|
|
52
|
+
seen.add(seg.segmentId);
|
|
53
|
+
}
|
|
54
|
+
// Hash each segment's content — never expose the text itself
|
|
55
|
+
const hashed = segments.map((seg) => ({
|
|
56
|
+
segmentId: seg.segmentId,
|
|
57
|
+
kind: seg.kind,
|
|
58
|
+
required: seg.required,
|
|
59
|
+
contentHash: sha256(seg.text),
|
|
60
|
+
estimatedTokens: estimateContextTokens(seg.text)
|
|
61
|
+
}));
|
|
62
|
+
// Compute actualPromptHash from all segments in original order
|
|
63
|
+
const actualPromptHash = sha256(canonicalJson(hashed.map((h) => ({ segmentId: h.segmentId, contentHash: h.contentHash }))));
|
|
64
|
+
const actualEstimatedTokens = hashed.reduce((sum, h) => sum + h.estimatedTokens, 0);
|
|
65
|
+
// Compute compilerInputHash — includes nowMs for cross-run determinism
|
|
66
|
+
const compilerInputHash = sha256(canonicalJson({
|
|
67
|
+
adapter,
|
|
68
|
+
nowMs,
|
|
69
|
+
segments: hashed.map((h) => ({
|
|
70
|
+
segmentId: h.segmentId,
|
|
71
|
+
kind: h.kind,
|
|
72
|
+
required: h.required,
|
|
73
|
+
contentHash: h.contentHash,
|
|
74
|
+
estimatedTokens: h.estimatedTokens
|
|
75
|
+
})),
|
|
76
|
+
shadowBudgetTokens,
|
|
77
|
+
...(input.taskId === undefined ? {} : { taskId: input.taskId }),
|
|
78
|
+
...(input.model === undefined ? {} : { model: input.model })
|
|
79
|
+
}));
|
|
80
|
+
// Shadow selection: required first (in original order), then optional
|
|
81
|
+
const required = hashed.filter((h) => h.required);
|
|
82
|
+
const optional = hashed.filter((h) => !h.required);
|
|
83
|
+
let remaining = shadowBudgetTokens;
|
|
84
|
+
const decisions = [];
|
|
85
|
+
// Required segments — always included even if over budget
|
|
86
|
+
let requiredOverBudget = false;
|
|
87
|
+
const requiredTokens = required.reduce((s, h) => s + h.estimatedTokens, 0);
|
|
88
|
+
if (requiredTokens > shadowBudgetTokens) {
|
|
89
|
+
requiredOverBudget = true;
|
|
90
|
+
}
|
|
91
|
+
for (const h of required) {
|
|
92
|
+
const included = !requiredOverBudget;
|
|
93
|
+
decisions.push({
|
|
94
|
+
segmentId: h.segmentId,
|
|
95
|
+
kind: h.kind,
|
|
96
|
+
contentHash: h.contentHash,
|
|
97
|
+
estimatedTokens: h.estimatedTokens,
|
|
98
|
+
actuallyIncluded: true,
|
|
99
|
+
proposedDecision: included ? "included" : "excluded",
|
|
100
|
+
reason: requiredOverBudget ? "required_over_budget" : "required"
|
|
101
|
+
});
|
|
102
|
+
if (!requiredOverBudget) {
|
|
103
|
+
remaining -= h.estimatedTokens;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Optional segments — include while budget remains
|
|
107
|
+
for (const h of optional) {
|
|
108
|
+
if (h.estimatedTokens <= remaining) {
|
|
109
|
+
decisions.push({
|
|
110
|
+
segmentId: h.segmentId,
|
|
111
|
+
kind: h.kind,
|
|
112
|
+
contentHash: h.contentHash,
|
|
113
|
+
estimatedTokens: h.estimatedTokens,
|
|
114
|
+
actuallyIncluded: true,
|
|
115
|
+
proposedDecision: "included",
|
|
116
|
+
reason: "within_shadow_budget"
|
|
117
|
+
});
|
|
118
|
+
remaining -= h.estimatedTokens;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
decisions.push({
|
|
122
|
+
segmentId: h.segmentId,
|
|
123
|
+
kind: h.kind,
|
|
124
|
+
contentHash: h.contentHash,
|
|
125
|
+
estimatedTokens: h.estimatedTokens,
|
|
126
|
+
actuallyIncluded: true,
|
|
127
|
+
proposedDecision: "excluded",
|
|
128
|
+
reason: "shadow_budget_exhausted"
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Restore original segment order in decisions list
|
|
133
|
+
const orderMap = new Map(segments.map((s, i) => [s.segmentId, i]));
|
|
134
|
+
decisions.sort((a, b) => (orderMap.get(a.segmentId) ?? 0) - (orderMap.get(b.segmentId) ?? 0));
|
|
135
|
+
const proposedEstimatedTokens = decisions
|
|
136
|
+
.filter((d) => d.proposedDecision === "included")
|
|
137
|
+
.reduce((s, d) => s + d.estimatedTokens, 0);
|
|
138
|
+
const capturedAt = new Date(nowMs).toISOString();
|
|
139
|
+
const manifestId = sha256(`${runId}:${compilerInputHash}:${capturedAt}`);
|
|
140
|
+
// Manifest hash covers metadata only — no content
|
|
141
|
+
const manifestHashInput = canonicalJson({
|
|
142
|
+
adapter,
|
|
143
|
+
capturedAt,
|
|
144
|
+
compilerInputHash,
|
|
145
|
+
manifestId,
|
|
146
|
+
mode: "shadow",
|
|
147
|
+
modelWindowEvidence: input.modelWindowEvidence ?? "unknown",
|
|
148
|
+
modelWindowTokens: input.modelWindowTokens ?? null,
|
|
149
|
+
requiredOverBudget,
|
|
150
|
+
runId,
|
|
151
|
+
shadowBudgetTokens,
|
|
152
|
+
...(input.taskId === undefined ? {} : { taskId: input.taskId }),
|
|
153
|
+
...(input.model === undefined ? {} : { model: input.model })
|
|
154
|
+
});
|
|
155
|
+
const manifestHash = sha256(manifestHashInput);
|
|
156
|
+
const includedCount = decisions.filter((d) => d.proposedDecision === "included").length;
|
|
157
|
+
const excludedCount = decisions.filter((d) => d.proposedDecision === "excluded").length;
|
|
158
|
+
const manifest = {
|
|
159
|
+
schemaVersion: CONTEXT_SHADOW_MANIFEST_VERSION,
|
|
160
|
+
manifestId,
|
|
161
|
+
manifestHash,
|
|
162
|
+
compilerInputHash,
|
|
163
|
+
runId,
|
|
164
|
+
adapter,
|
|
165
|
+
capturedAt,
|
|
166
|
+
mode: "shadow",
|
|
167
|
+
modelWindowTokens: input.modelWindowTokens ?? null,
|
|
168
|
+
modelWindowEvidence: input.modelWindowEvidence ?? "unknown",
|
|
169
|
+
shadowBudgetTokens,
|
|
170
|
+
actualPromptHash,
|
|
171
|
+
actualEstimatedTokens,
|
|
172
|
+
proposedEstimatedTokens,
|
|
173
|
+
requiredOverBudget,
|
|
174
|
+
decisions,
|
|
175
|
+
...(input.taskId === undefined ? {} : { taskId: input.taskId }),
|
|
176
|
+
...(input.model === undefined ? {} : { model: input.model })
|
|
177
|
+
};
|
|
178
|
+
const receipt = {
|
|
179
|
+
schemaVersion: CONTEXT_C5_VERSION,
|
|
180
|
+
resource: "context",
|
|
181
|
+
event: "shadow_compiled",
|
|
182
|
+
runId,
|
|
183
|
+
manifestId,
|
|
184
|
+
manifestHash,
|
|
185
|
+
mode: "shadow",
|
|
186
|
+
adapter,
|
|
187
|
+
actualEstimatedTokens,
|
|
188
|
+
proposedEstimatedTokens,
|
|
189
|
+
candidateCount: decisions.length,
|
|
190
|
+
includedCount,
|
|
191
|
+
excludedCount,
|
|
192
|
+
requiredOverBudget,
|
|
193
|
+
modelWindowTokens: input.modelWindowTokens ?? null,
|
|
194
|
+
modelWindowEvidence: input.modelWindowEvidence ?? "unknown",
|
|
195
|
+
createdAt: capturedAt,
|
|
196
|
+
...(input.taskId === undefined ? {} : { taskId: input.taskId }),
|
|
197
|
+
...(input.model === undefined ? {} : { model: input.model })
|
|
198
|
+
};
|
|
199
|
+
return { manifest, receipt };
|
|
200
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable exit signals — per-kind, exclusive-create, precedence-safe.
|
|
3
|
+
*
|
|
4
|
+
* Each signal kind occupies its own immutable slot:
|
|
5
|
+
* <runDir>/signals/human_interrupt.json
|
|
6
|
+
* <runDir>/signals/external_event.json
|
|
7
|
+
*
|
|
8
|
+
* Atomic publication (crash-safe):
|
|
9
|
+
* 1. Write complete JSON to a unique tmp file (wx, 0o600) in the signals dir
|
|
10
|
+
* 2. Sync and close the tmp file
|
|
11
|
+
* 3. Hard-link tmp to the final per-kind slot (EEXIST → already_exists)
|
|
12
|
+
* 4. Unlink tmp in finally; best-effort dir sync on POSIX
|
|
13
|
+
*
|
|
14
|
+
* The final slot is only visible after a fully written tmp — a crash cannot
|
|
15
|
+
* leave a permanently malformed final slot.
|
|
16
|
+
*
|
|
17
|
+
* Per-kind files let different kinds coexist — an external event filed first
|
|
18
|
+
* does NOT prevent a later human interrupt. EXIT_PRECEDENCE is always honoured.
|
|
19
|
+
*
|
|
20
|
+
* Real-path containment guards against symlink/junction traversal out of the
|
|
21
|
+
* run store. Diagnostics from malformed signals are propagated — never silently
|
|
22
|
+
* discarded as "no signal".
|
|
23
|
+
*/
|
|
24
|
+
import type { ExitSignalV1 } from "../contracts/index.js";
|
|
25
|
+
export interface ExitSignalSource {
|
|
26
|
+
poll(runId: string): Promise<SignalReadResult>;
|
|
27
|
+
}
|
|
28
|
+
export interface SignalReadResult {
|
|
29
|
+
signals: readonly ExitSignalV1[];
|
|
30
|
+
diagnostics: readonly SignalDiagnostic[];
|
|
31
|
+
}
|
|
32
|
+
export interface SignalDiagnostic {
|
|
33
|
+
kind: "human_interrupt" | "external_event";
|
|
34
|
+
error: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Typed error surfaced by the monitor when diagnostics are detected and no
|
|
38
|
+
* onDiagnostic handler is registered. Contains safe diagnostic codes and
|
|
39
|
+
* signal kinds — no absolute paths or payload secrets.
|
|
40
|
+
*/
|
|
41
|
+
export declare class SignalDiagnosticError extends Error {
|
|
42
|
+
readonly diagnostics: readonly SignalDiagnostic[];
|
|
43
|
+
constructor(diagnostics: readonly SignalDiagnostic[]);
|
|
44
|
+
}
|
|
45
|
+
/** Path to a per-kind signal file — safe against lexical traversal. */
|
|
46
|
+
export declare function exitSignalPath(runsRoot: string, runId: string, kind: "human_interrupt" | "external_event"): string;
|
|
47
|
+
/**
|
|
48
|
+
* Write one signal of its kind atomically. Returns "created" on success or
|
|
49
|
+
* "already_exists" when that kind was already filed (first writer wins).
|
|
50
|
+
* Throws on IO errors other than EEXIST on the final slot.
|
|
51
|
+
*
|
|
52
|
+
* Publication sequence (crash-safe):
|
|
53
|
+
* 1. Validate signal and size-check the payload
|
|
54
|
+
* 2. mkdir the signals directory
|
|
55
|
+
* 3. realpath-check: the real signals dir must stay inside the real root
|
|
56
|
+
* 4. Write complete JSON to a unique tmp file (wx, 0o600)
|
|
57
|
+
* 5. Sync and close the tmp file
|
|
58
|
+
* 6. Hard-link tmp to the final per-kind slot (EEXIST → already_exists)
|
|
59
|
+
* 7. Unlink tmp in finally; best-effort dir sync on POSIX
|
|
60
|
+
*/
|
|
61
|
+
export declare function writeExitSignal(runsRoot: string, signal: ExitSignalV1): Promise<"created" | "already_exists">;
|
|
62
|
+
/**
|
|
63
|
+
* Read all present signals for a run (both kinds). Returns a structured
|
|
64
|
+
* result with any parse diagnostics rather than throwing on malformed files.
|
|
65
|
+
*/
|
|
66
|
+
export declare function readAllExitSignals(runsRoot: string, runId: string): Promise<SignalReadResult>;
|
|
67
|
+
/** Read a single signal kind; returns undefined when absent. */
|
|
68
|
+
export declare function readExitSignal(runsRoot: string, runId: string, kind: "human_interrupt" | "external_event"): Promise<ExitSignalV1 | undefined>;
|
|
69
|
+
export declare function createFileExitSignalSource(runsRoot: string): ExitSignalSource;
|
|
70
|
+
/**
|
|
71
|
+
* Polls for any exit signal and calls onSignal with the full set when any
|
|
72
|
+
* new signal appears. Returns a dispose function — MUST be called on every
|
|
73
|
+
* return/throw path in the run harness (invariant: one interval per run).
|
|
74
|
+
*
|
|
75
|
+
* Diagnostics (malformed signals, containment failures) are routed to
|
|
76
|
+
* onDiagnostic when provided; otherwise surfaced as a SignalDiagnosticError
|
|
77
|
+
* through onError so callers always learn about corruption rather than
|
|
78
|
+
* silently treating it as "no signal".
|
|
79
|
+
*/
|
|
80
|
+
export declare function startExitSignalMonitor(input: {
|
|
81
|
+
source?: ExitSignalSource;
|
|
82
|
+
runId: string;
|
|
83
|
+
controller: AbortController;
|
|
84
|
+
pollIntervalMs?: number;
|
|
85
|
+
onSignal: (signals: readonly ExitSignalV1[]) => void;
|
|
86
|
+
onDiagnostic?: (diagnostics: readonly SignalDiagnostic[]) => void;
|
|
87
|
+
onError: (error: Error) => void;
|
|
88
|
+
}): () => void;
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable exit signals — per-kind, exclusive-create, precedence-safe.
|
|
3
|
+
*
|
|
4
|
+
* Each signal kind occupies its own immutable slot:
|
|
5
|
+
* <runDir>/signals/human_interrupt.json
|
|
6
|
+
* <runDir>/signals/external_event.json
|
|
7
|
+
*
|
|
8
|
+
* Atomic publication (crash-safe):
|
|
9
|
+
* 1. Write complete JSON to a unique tmp file (wx, 0o600) in the signals dir
|
|
10
|
+
* 2. Sync and close the tmp file
|
|
11
|
+
* 3. Hard-link tmp to the final per-kind slot (EEXIST → already_exists)
|
|
12
|
+
* 4. Unlink tmp in finally; best-effort dir sync on POSIX
|
|
13
|
+
*
|
|
14
|
+
* The final slot is only visible after a fully written tmp — a crash cannot
|
|
15
|
+
* leave a permanently malformed final slot.
|
|
16
|
+
*
|
|
17
|
+
* Per-kind files let different kinds coexist — an external event filed first
|
|
18
|
+
* does NOT prevent a later human interrupt. EXIT_PRECEDENCE is always honoured.
|
|
19
|
+
*
|
|
20
|
+
* Real-path containment guards against symlink/junction traversal out of the
|
|
21
|
+
* run store. Diagnostics from malformed signals are propagated — never silently
|
|
22
|
+
* discarded as "no signal".
|
|
23
|
+
*/
|
|
24
|
+
import { link, lstat, mkdir, open, readFile, realpath, unlink } from "node:fs/promises";
|
|
25
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
26
|
+
import { randomUUID } from "node:crypto";
|
|
27
|
+
const MAX_SIGNAL_BYTES = 64 * 1024;
|
|
28
|
+
const RUN_ID_RE = /^[A-Za-z0-9._-]{1,128}$/u;
|
|
29
|
+
/**
|
|
30
|
+
* Typed error surfaced by the monitor when diagnostics are detected and no
|
|
31
|
+
* onDiagnostic handler is registered. Contains safe diagnostic codes and
|
|
32
|
+
* signal kinds — no absolute paths or payload secrets.
|
|
33
|
+
*/
|
|
34
|
+
export class SignalDiagnosticError extends Error {
|
|
35
|
+
diagnostics;
|
|
36
|
+
constructor(diagnostics) {
|
|
37
|
+
const summary = diagnostics
|
|
38
|
+
.map((d) => `${d.kind}: ${d.error}`)
|
|
39
|
+
.join("; ");
|
|
40
|
+
super(`Signal diagnostics: ${summary}`);
|
|
41
|
+
this.name = "SignalDiagnosticError";
|
|
42
|
+
this.diagnostics = diagnostics;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Path to a per-kind signal file — safe against lexical traversal. */
|
|
46
|
+
export function exitSignalPath(runsRoot, runId, kind) {
|
|
47
|
+
assertRunId(runId);
|
|
48
|
+
const root = resolve(runsRoot);
|
|
49
|
+
const runDir = resolve(root, runId);
|
|
50
|
+
const sep = process.platform === "win32" ? "\\" : "/";
|
|
51
|
+
if (runDir !== root && !runDir.startsWith(`${root}${sep}`)) {
|
|
52
|
+
throw new Error("Resolved run path escapes runs root");
|
|
53
|
+
}
|
|
54
|
+
const filename = kind === "human_interrupt"
|
|
55
|
+
? "human_interrupt.json"
|
|
56
|
+
: "external_event.json";
|
|
57
|
+
return join(runDir, "signals", filename);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Write one signal of its kind atomically. Returns "created" on success or
|
|
61
|
+
* "already_exists" when that kind was already filed (first writer wins).
|
|
62
|
+
* Throws on IO errors other than EEXIST on the final slot.
|
|
63
|
+
*
|
|
64
|
+
* Publication sequence (crash-safe):
|
|
65
|
+
* 1. Validate signal and size-check the payload
|
|
66
|
+
* 2. mkdir the signals directory
|
|
67
|
+
* 3. realpath-check: the real signals dir must stay inside the real root
|
|
68
|
+
* 4. Write complete JSON to a unique tmp file (wx, 0o600)
|
|
69
|
+
* 5. Sync and close the tmp file
|
|
70
|
+
* 6. Hard-link tmp to the final per-kind slot (EEXIST → already_exists)
|
|
71
|
+
* 7. Unlink tmp in finally; best-effort dir sync on POSIX
|
|
72
|
+
*/
|
|
73
|
+
export async function writeExitSignal(runsRoot, signal) {
|
|
74
|
+
validateExitSignal(signal);
|
|
75
|
+
const payload = `${JSON.stringify(signal)}\n`;
|
|
76
|
+
if (Buffer.byteLength(payload, "utf8") > MAX_SIGNAL_BYTES) {
|
|
77
|
+
throw new Error("Exit signal payload exceeds 64 KiB");
|
|
78
|
+
}
|
|
79
|
+
const finalPath = exitSignalPath(runsRoot, signal.runId, signal.kind);
|
|
80
|
+
const sigDir = dirname(finalPath);
|
|
81
|
+
await mkdir(sigDir, { recursive: true });
|
|
82
|
+
// Real-path containment: the real signals directory must stay inside the
|
|
83
|
+
// real run store root. This catches symlink/junction escapes that lexical
|
|
84
|
+
// resolve() cannot detect.
|
|
85
|
+
const realRoot = await realpath(resolve(runsRoot));
|
|
86
|
+
const realSigDir = await realpath(sigDir);
|
|
87
|
+
if (!isContained(realRoot, realSigDir)) {
|
|
88
|
+
throw new Error("Signal directory escapes the run store");
|
|
89
|
+
}
|
|
90
|
+
// Atomic publication via unique tmp file + hard-link.
|
|
91
|
+
// Both tmp and final are in the same directory, so they are on the same
|
|
92
|
+
// filesystem — hard-link is always available.
|
|
93
|
+
const tmpPath = join(sigDir, `.tmp-${randomUUID()}`);
|
|
94
|
+
let handle;
|
|
95
|
+
try {
|
|
96
|
+
handle = await open(tmpPath, "wx", 0o600);
|
|
97
|
+
await handle.writeFile(payload, "utf8");
|
|
98
|
+
await handle.sync();
|
|
99
|
+
await handle.close();
|
|
100
|
+
handle = undefined;
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (handle !== undefined) {
|
|
104
|
+
await handle.close().catch(() => undefined);
|
|
105
|
+
handle = undefined;
|
|
106
|
+
}
|
|
107
|
+
await unlink(tmpPath).catch(() => undefined);
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
// Claim the final slot atomically — link() fails with EEXIST if slot is
|
|
111
|
+
// already occupied, preserving first-signal-wins without replacing the file.
|
|
112
|
+
let outcome;
|
|
113
|
+
try {
|
|
114
|
+
await link(tmpPath, finalPath);
|
|
115
|
+
outcome = "created";
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (err.code === "EEXIST") {
|
|
119
|
+
outcome = "already_exists";
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
await unlink(tmpPath).catch(() => undefined);
|
|
127
|
+
// Best-effort directory sync so the new entry is durable on POSIX.
|
|
128
|
+
// Windows directories cannot be opened with O_RDONLY — skip.
|
|
129
|
+
if (process.platform !== "win32") {
|
|
130
|
+
const dh = await open(sigDir, "r").catch(() => undefined);
|
|
131
|
+
if (dh !== undefined) {
|
|
132
|
+
await dh.sync().catch(() => undefined);
|
|
133
|
+
await dh.close().catch(() => undefined);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return outcome;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Read all present signals for a run (both kinds). Returns a structured
|
|
141
|
+
* result with any parse diagnostics rather than throwing on malformed files.
|
|
142
|
+
*/
|
|
143
|
+
export async function readAllExitSignals(runsRoot, runId) {
|
|
144
|
+
const kinds = [
|
|
145
|
+
"human_interrupt",
|
|
146
|
+
"external_event"
|
|
147
|
+
];
|
|
148
|
+
const signals = [];
|
|
149
|
+
const diagnostics = [];
|
|
150
|
+
for (const kind of kinds) {
|
|
151
|
+
const result = await readOneSignal(runsRoot, runId, kind);
|
|
152
|
+
if (result.signal !== undefined)
|
|
153
|
+
signals.push(result.signal);
|
|
154
|
+
if (result.diagnostic !== undefined)
|
|
155
|
+
diagnostics.push(result.diagnostic);
|
|
156
|
+
}
|
|
157
|
+
return { signals, diagnostics };
|
|
158
|
+
}
|
|
159
|
+
/** Read a single signal kind; returns undefined when absent. */
|
|
160
|
+
export async function readExitSignal(runsRoot, runId, kind) {
|
|
161
|
+
const { signal } = await readOneSignal(runsRoot, runId, kind);
|
|
162
|
+
return signal;
|
|
163
|
+
}
|
|
164
|
+
export function createFileExitSignalSource(runsRoot) {
|
|
165
|
+
return {
|
|
166
|
+
poll: async (runId) => readAllExitSignals(runsRoot, runId)
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Polls for any exit signal and calls onSignal with the full set when any
|
|
171
|
+
* new signal appears. Returns a dispose function — MUST be called on every
|
|
172
|
+
* return/throw path in the run harness (invariant: one interval per run).
|
|
173
|
+
*
|
|
174
|
+
* Diagnostics (malformed signals, containment failures) are routed to
|
|
175
|
+
* onDiagnostic when provided; otherwise surfaced as a SignalDiagnosticError
|
|
176
|
+
* through onError so callers always learn about corruption rather than
|
|
177
|
+
* silently treating it as "no signal".
|
|
178
|
+
*/
|
|
179
|
+
export function startExitSignalMonitor(input) {
|
|
180
|
+
if (input.source === undefined)
|
|
181
|
+
return () => undefined;
|
|
182
|
+
const intervalMs = input.pollIntervalMs ?? 250;
|
|
183
|
+
let polling = false;
|
|
184
|
+
let lastCount = 0;
|
|
185
|
+
const timer = setInterval(() => {
|
|
186
|
+
if (polling || input.controller.signal.aborted)
|
|
187
|
+
return;
|
|
188
|
+
polling = true;
|
|
189
|
+
void input.source
|
|
190
|
+
.poll(input.runId)
|
|
191
|
+
.then(({ signals, diagnostics }) => {
|
|
192
|
+
if (input.controller.signal.aborted)
|
|
193
|
+
return;
|
|
194
|
+
// Surface diagnostics without aborting the run
|
|
195
|
+
if (diagnostics.length > 0) {
|
|
196
|
+
if (input.onDiagnostic !== undefined) {
|
|
197
|
+
input.onDiagnostic(diagnostics);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
input.onError(new SignalDiagnosticError(diagnostics));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Only fire onSignal when new signals appear
|
|
204
|
+
if (signals.length > lastCount) {
|
|
205
|
+
lastCount = signals.length;
|
|
206
|
+
input.onSignal(signals);
|
|
207
|
+
input.controller.abort(signals);
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
.catch((err) => {
|
|
211
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
212
|
+
input.onError(e);
|
|
213
|
+
input.controller.abort(e);
|
|
214
|
+
})
|
|
215
|
+
.finally(() => { polling = false; });
|
|
216
|
+
}, intervalMs);
|
|
217
|
+
timer.unref?.();
|
|
218
|
+
return () => clearInterval(timer);
|
|
219
|
+
}
|
|
220
|
+
// ─── Internal helpers ────────────────────────────────────────────────────────
|
|
221
|
+
async function readOneSignal(runsRoot, runId, kind) {
|
|
222
|
+
assertRunId(runId);
|
|
223
|
+
const finalPath = exitSignalPath(runsRoot, runId, kind);
|
|
224
|
+
const sigDir = dirname(finalPath);
|
|
225
|
+
// Real-path containment for reads: signals dir must stay inside the run store.
|
|
226
|
+
// ENOENT on the signals dir means no signals have been written — return absent.
|
|
227
|
+
let realSigDir;
|
|
228
|
+
try {
|
|
229
|
+
realSigDir = await realpath(sigDir);
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
if (err.code === "ENOENT")
|
|
233
|
+
return {};
|
|
234
|
+
return {
|
|
235
|
+
diagnostic: {
|
|
236
|
+
kind,
|
|
237
|
+
error: `containment check failed [${err.code ?? "ERR"}]`
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const realRoot = await realpath(resolve(runsRoot));
|
|
242
|
+
if (!isContained(realRoot, realSigDir)) {
|
|
243
|
+
return { diagnostic: { kind, error: "signal directory escapes the run store" } };
|
|
244
|
+
}
|
|
245
|
+
// lstat: reject symlinks and surface size violations before reading.
|
|
246
|
+
// With atomic publication, the final file is always a complete hardlinked
|
|
247
|
+
// inode — a size=0 or a symlink indicates tampering.
|
|
248
|
+
let size;
|
|
249
|
+
try {
|
|
250
|
+
const lstats = await lstat(finalPath);
|
|
251
|
+
if (lstats.isSymbolicLink()) {
|
|
252
|
+
return { diagnostic: { kind, error: "signal file is a symbolic link" } };
|
|
253
|
+
}
|
|
254
|
+
size = lstats.size;
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
if (err.code === "ENOENT")
|
|
258
|
+
return {};
|
|
259
|
+
return {
|
|
260
|
+
diagnostic: {
|
|
261
|
+
kind,
|
|
262
|
+
error: `stat error [${err.code ?? "ERR"}]`
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
if (size > MAX_SIGNAL_BYTES) {
|
|
267
|
+
return { diagnostic: { kind, error: "signal file exceeds 64 KiB" } };
|
|
268
|
+
}
|
|
269
|
+
let text;
|
|
270
|
+
try {
|
|
271
|
+
text = await readFile(finalPath, "utf8");
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
if (err.code === "ENOENT")
|
|
275
|
+
return {};
|
|
276
|
+
return {
|
|
277
|
+
diagnostic: {
|
|
278
|
+
kind,
|
|
279
|
+
error: `read error [${err.code ?? "ERR"}]`
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
// With atomic publication the final file should always contain valid JSON.
|
|
284
|
+
// A parse failure indicates file corruption — surface as diagnostic.
|
|
285
|
+
let parsed;
|
|
286
|
+
try {
|
|
287
|
+
parsed = JSON.parse(text);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return { diagnostic: { kind, error: "signal file contains invalid JSON" } };
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
validateExitSignal(parsed);
|
|
294
|
+
return { signal: parsed };
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
return { diagnostic: { kind, error: String(err) } };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function validateExitSignal(signal) {
|
|
301
|
+
if (signal.schemaVersion !== "exit-signal/1") {
|
|
302
|
+
throw new Error(`Unsupported exit signal schema: ${String(signal.schemaVersion)}`);
|
|
303
|
+
}
|
|
304
|
+
assertRunId(signal.runId);
|
|
305
|
+
if (signal.kind !== "human_interrupt" && signal.kind !== "external_event") {
|
|
306
|
+
throw new Error(`Invalid exit signal kind: ${String(signal.kind)}`);
|
|
307
|
+
}
|
|
308
|
+
if (!signal.requestedBy?.trim() || !Number.isFinite(Date.parse(signal.requestedAt))) {
|
|
309
|
+
throw new Error("Exit signal requester and timestamp are required");
|
|
310
|
+
}
|
|
311
|
+
if (signal.kind === "external_event" && signal.externalEvent === undefined) {
|
|
312
|
+
throw new Error("external_event signal requires externalEvent evidence");
|
|
313
|
+
}
|
|
314
|
+
if (signal.externalEvent !== undefined) {
|
|
315
|
+
const ev = signal.externalEvent;
|
|
316
|
+
if (!ev.source?.trim() || !ev.event?.trim()) {
|
|
317
|
+
throw new Error("External event source and event are required");
|
|
318
|
+
}
|
|
319
|
+
if (!["satisfied", "superseded", "cancelled"].includes(ev.disposition)) {
|
|
320
|
+
throw new Error(`Invalid external event disposition: ${String(ev.disposition)}`);
|
|
321
|
+
}
|
|
322
|
+
if (!Number.isFinite(Date.parse(ev.observedAt))) {
|
|
323
|
+
throw new Error("External event observedAt must be an ISO timestamp");
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function assertRunId(runId) {
|
|
328
|
+
if (runId === "." ||
|
|
329
|
+
runId === ".." ||
|
|
330
|
+
!RUN_ID_RE.test(runId) ||
|
|
331
|
+
basename(runId) !== runId) {
|
|
332
|
+
throw new Error(`Invalid run id: ${JSON.stringify(runId)}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/** True when candidate equals root or is a strict descendant under root. */
|
|
336
|
+
function isContained(root, candidate) {
|
|
337
|
+
const sep = process.platform === "win32" ? "\\" : "/";
|
|
338
|
+
return candidate === root || candidate.startsWith(`${root}${sep}`);
|
|
339
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eight-Exit Runtime — pure deterministic evaluator.
|
|
3
|
+
*
|
|
4
|
+
* EXIT_PRECEDENCE rationale (product decision — see contracts/exits.ts for rationale;
|
|
5
|
+
* any change to this array requires updating that paragraph in the same commit):
|
|
6
|
+
* human_interrupt > external_event > wall_clock > budget_cap > turn_cap >
|
|
7
|
+
* goal_met > error_threshold > no_progress
|
|
8
|
+
*/
|
|
9
|
+
import type { ExitEvaluationV1, ExitPolicyV1, ExitSnapshotV1, LoopBudget, LoopLifecycleState, LoopStatus } from "../contracts/index.js";
|
|
10
|
+
export interface LegacyExitDecision {
|
|
11
|
+
shouldExit: boolean;
|
|
12
|
+
lifecycleState: LoopLifecycleState;
|
|
13
|
+
status: LoopStatus;
|
|
14
|
+
reason: string;
|
|
15
|
+
reasonCode?: string;
|
|
16
|
+
failureClass?: string;
|
|
17
|
+
safetySurface?: string;
|
|
18
|
+
exitEvaluation: ExitEvaluationV1;
|
|
19
|
+
}
|
|
20
|
+
export interface ExitPolicyOverrides {
|
|
21
|
+
goal?: Partial<ExitPolicyV1["goal"]>;
|
|
22
|
+
turns?: Partial<ExitPolicyV1["turns"]>;
|
|
23
|
+
budget?: Partial<ExitPolicyV1["budget"]>;
|
|
24
|
+
wallClock?: Partial<ExitPolicyV1["wallClock"]>;
|
|
25
|
+
progress?: Partial<ExitPolicyV1["progress"]>;
|
|
26
|
+
errors?: Partial<ExitPolicyV1["errors"]>;
|
|
27
|
+
humanInterrupt?: Partial<ExitPolicyV1["humanInterrupt"]>;
|
|
28
|
+
externalEvent?: Partial<ExitPolicyV1["externalEvent"]>;
|
|
29
|
+
}
|
|
30
|
+
export declare function createDefaultExitPolicy(budget: LoopBudget, overrides?: ExitPolicyOverrides): ExitPolicyV1;
|
|
31
|
+
export declare function validateExitPolicy(policy: ExitPolicyV1): void;
|
|
32
|
+
export declare function evaluateExitPolicy(policy: ExitPolicyV1, snapshot: ExitSnapshotV1): ExitEvaluationV1;
|
|
33
|
+
export declare function toLegacyExitDecision(evaluation: ExitEvaluationV1, externalDisposition?: "satisfied" | "superseded" | "cancelled"): LegacyExitDecision;
|
|
34
|
+
/**
|
|
35
|
+
* Hash the meaningful workspace/verification state for no-progress detection.
|
|
36
|
+
* Excludes timestamps, cost counters, prose summaries — only structural changes count.
|
|
37
|
+
*/
|
|
38
|
+
export declare function hashProgressState(value: unknown): string;
|
|
39
|
+
/** @deprecated Use evaluateExitPolicy instead. Retained for downstream compatibility. */
|
|
40
|
+
export { inferExitCompat as inferExitCompat };
|
|
41
|
+
declare function inferExitCompat(_unused: unknown): never;
|