@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.
Files changed (161) hide show
  1. package/README.md +90 -106
  2. package/dist/discovery-metadata.d.ts +1 -1
  3. package/dist/discovery-metadata.js +1 -1
  4. package/dist/resources.d.ts +1 -2
  5. package/dist/resources.js +190 -238
  6. package/dist/server-validation.d.ts +1 -1
  7. package/dist/server-validation.js +16 -11
  8. package/dist/server.d.ts +23 -0
  9. package/dist/server.js +152 -7
  10. package/dist/tools/run-dossier.d.ts +3 -1
  11. package/dist/tools/run-dossier.js +40 -2
  12. package/dist/tools/run-loop.d.ts +1 -4
  13. package/dist/tools/run-loop.js +6 -15
  14. package/dist/tools/tool-support.d.ts +2 -1
  15. package/dist/tools/tool-support.js +1 -12
  16. package/dist/vendor/adapters/claude-cli.js +38 -35
  17. package/dist/vendor/adapters/cli-bridge.d.ts +9 -1
  18. package/dist/vendor/adapters/cli-bridge.js +54 -13
  19. package/dist/vendor/adapters/codex-launcher.d.ts +61 -1
  20. package/dist/vendor/adapters/codex-launcher.js +118 -19
  21. package/dist/vendor/adapters/index.d.ts +1 -3
  22. package/dist/vendor/adapters/index.js +1 -3
  23. package/dist/vendor/adapters/openai-compatible.js +57 -18
  24. package/dist/vendor/contracts/adapter-capabilities.d.ts +1 -1
  25. package/dist/vendor/contracts/context-handoff.d.ts +100 -0
  26. package/dist/vendor/contracts/context-handoff.js +11 -0
  27. package/dist/vendor/contracts/context-manifest.d.ts +195 -0
  28. package/dist/vendor/contracts/context-manifest.js +17 -0
  29. package/dist/vendor/contracts/context-shadow.d.ts +75 -0
  30. package/dist/vendor/contracts/context-shadow.js +8 -0
  31. package/dist/vendor/contracts/exits.d.ts +126 -0
  32. package/dist/vendor/contracts/exits.js +32 -0
  33. package/dist/vendor/contracts/index.d.ts +39 -2
  34. package/dist/vendor/contracts/index.js +22 -0
  35. package/dist/vendor/contracts/mission.d.ts +102 -0
  36. package/dist/vendor/contracts/mission.js +64 -0
  37. package/dist/vendor/contracts/operator.d.ts +3 -1
  38. package/dist/vendor/contracts/operator.js +2 -1
  39. package/dist/vendor/contracts/verified-handoff.d.ts +100 -0
  40. package/dist/vendor/contracts/verified-handoff.js +35 -0
  41. package/dist/vendor/core/context-chain-gate.d.ts +89 -0
  42. package/dist/vendor/core/context-chain-gate.js +173 -0
  43. package/dist/vendor/core/context-compiler.d.ts +53 -0
  44. package/dist/vendor/core/context-compiler.js +261 -0
  45. package/dist/vendor/core/context-handoff.d.ts +42 -0
  46. package/dist/vendor/core/context-handoff.js +167 -0
  47. package/dist/vendor/core/context-shadow.d.ts +39 -0
  48. package/dist/vendor/core/context-shadow.js +200 -0
  49. package/dist/vendor/core/exit-signal.d.ts +88 -0
  50. package/dist/vendor/core/exit-signal.js +339 -0
  51. package/dist/vendor/core/exits.d.ts +41 -0
  52. package/dist/vendor/core/exits.js +258 -0
  53. package/dist/vendor/core/index.d.ts +42 -1
  54. package/dist/vendor/core/index.js +1086 -662
  55. package/dist/vendor/core/mission/aggregate.d.ts +27 -0
  56. package/dist/vendor/core/mission/aggregate.js +42 -0
  57. package/dist/vendor/core/mission/index.d.ts +2 -0
  58. package/dist/vendor/core/mission/index.js +1 -0
  59. package/dist/vendor/core/persistence/compiler.d.ts +8 -0
  60. package/dist/vendor/core/persistence/compiler.js +156 -0
  61. package/dist/vendor/core/persistence/context-handoff-store.d.ts +68 -0
  62. package/dist/vendor/core/persistence/context-handoff-store.js +112 -0
  63. package/dist/vendor/core/persistence/index.d.ts +4 -0
  64. package/dist/vendor/core/persistence/index.js +2 -0
  65. package/dist/vendor/core/persistence/ledger.d.ts +1 -1
  66. package/dist/vendor/core/persistence/mission-store.d.ts +56 -0
  67. package/dist/vendor/core/persistence/mission-store.js +281 -0
  68. package/dist/vendor/core/policy.d.ts +2 -0
  69. package/dist/vendor/core/policy.js +5 -2
  70. package/dist/vendor/core/rollback.js +15 -1
  71. package/dist/vendor/core/savings.d.ts +13 -0
  72. package/dist/vendor/core/savings.js +22 -0
  73. package/dist/vendor/core/termination-store.d.ts +15 -0
  74. package/dist/vendor/core/termination-store.js +44 -0
  75. package/dist/vendor/core/verified-handoff.d.ts +67 -0
  76. package/dist/vendor/core/verified-handoff.js +210 -0
  77. package/package.json +76 -83
  78. package/server.json +21 -21
  79. package/LICENSE +0 -216
  80. package/dist/vendor/adapters/adapters/src/claude-cli.d.ts +0 -146
  81. package/dist/vendor/adapters/adapters/src/claude-cli.js +0 -1100
  82. package/dist/vendor/adapters/adapters/src/cli-bridge.d.ts +0 -103
  83. package/dist/vendor/adapters/adapters/src/cli-bridge.js +0 -569
  84. package/dist/vendor/adapters/adapters/src/codex-launcher.d.ts +0 -78
  85. package/dist/vendor/adapters/adapters/src/codex-launcher.js +0 -663
  86. package/dist/vendor/adapters/adapters/src/direct-provider.d.ts +0 -10
  87. package/dist/vendor/adapters/adapters/src/direct-provider.js +0 -40
  88. package/dist/vendor/adapters/adapters/src/index.d.ts +0 -8
  89. package/dist/vendor/adapters/adapters/src/index.js +0 -8
  90. package/dist/vendor/adapters/adapters/src/openai-compatible.d.ts +0 -62
  91. package/dist/vendor/adapters/adapters/src/openai-compatible.js +0 -306
  92. package/dist/vendor/adapters/adapters/src/runtime-support.d.ts +0 -17
  93. package/dist/vendor/adapters/adapters/src/runtime-support.js +0 -59
  94. package/dist/vendor/adapters/adapters/src/stub-agent-cli.d.ts +0 -8
  95. package/dist/vendor/adapters/adapters/src/stub-agent-cli.js +0 -40
  96. package/dist/vendor/adapters/adapters/src/stub-direct-provider.d.ts +0 -8
  97. package/dist/vendor/adapters/adapters/src/stub-direct-provider.js +0 -12
  98. package/dist/vendor/adapters/adapters/src/verifier-only.d.ts +0 -9
  99. package/dist/vendor/adapters/adapters/src/verifier-only.js +0 -63
  100. package/dist/vendor/adapters/contracts/src/context-graph.d.ts +0 -48
  101. package/dist/vendor/adapters/contracts/src/context-graph.js +0 -26
  102. package/dist/vendor/adapters/contracts/src/execution-policy.d.ts +0 -78
  103. package/dist/vendor/adapters/contracts/src/execution-policy.js +0 -43
  104. package/dist/vendor/adapters/contracts/src/governance.d.ts +0 -24
  105. package/dist/vendor/adapters/contracts/src/governance.js +0 -11
  106. package/dist/vendor/adapters/contracts/src/identity.d.ts +0 -29
  107. package/dist/vendor/adapters/contracts/src/identity.js +0 -30
  108. package/dist/vendor/adapters/contracts/src/index.d.ts +0 -412
  109. package/dist/vendor/adapters/contracts/src/index.js +0 -225
  110. package/dist/vendor/adapters/contracts/src/operator.d.ts +0 -19
  111. package/dist/vendor/adapters/contracts/src/operator.js +0 -11
  112. package/dist/vendor/adapters/contracts/src/trajectory.d.ts +0 -26
  113. package/dist/vendor/adapters/contracts/src/trajectory.js +0 -27
  114. package/dist/vendor/adapters/core/src/compiler.d.ts +0 -52
  115. package/dist/vendor/adapters/core/src/compiler.js +0 -52
  116. package/dist/vendor/adapters/core/src/context-graph.d.ts +0 -3
  117. package/dist/vendor/adapters/core/src/context-graph.js +0 -371
  118. package/dist/vendor/adapters/core/src/context-integrity.d.ts +0 -26
  119. package/dist/vendor/adapters/core/src/context-integrity.js +0 -80
  120. package/dist/vendor/adapters/core/src/grounding.d.ts +0 -38
  121. package/dist/vendor/adapters/core/src/grounding.js +0 -273
  122. package/dist/vendor/adapters/core/src/identity.d.ts +0 -20
  123. package/dist/vendor/adapters/core/src/identity.js +0 -86
  124. package/dist/vendor/adapters/core/src/index.d.ts +0 -186
  125. package/dist/vendor/adapters/core/src/index.js +0 -1495
  126. package/dist/vendor/adapters/core/src/leash.d.ts +0 -48
  127. package/dist/vendor/adapters/core/src/leash.js +0 -497
  128. package/dist/vendor/adapters/core/src/persistence/compiler.d.ts +0 -18
  129. package/dist/vendor/adapters/core/src/persistence/compiler.js +0 -34
  130. package/dist/vendor/adapters/core/src/persistence/index.d.ts +0 -10
  131. package/dist/vendor/adapters/core/src/persistence/index.js +0 -5
  132. package/dist/vendor/adapters/core/src/persistence/integrity.d.ts +0 -39
  133. package/dist/vendor/adapters/core/src/persistence/integrity.js +0 -252
  134. package/dist/vendor/adapters/core/src/persistence/ledger.d.ts +0 -23
  135. package/dist/vendor/adapters/core/src/persistence/ledger.js +0 -9
  136. package/dist/vendor/adapters/core/src/persistence/memory-store.d.ts +0 -63
  137. package/dist/vendor/adapters/core/src/persistence/memory-store.js +0 -115
  138. package/dist/vendor/adapters/core/src/persistence/runs-reader.d.ts +0 -66
  139. package/dist/vendor/adapters/core/src/persistence/runs-reader.js +0 -140
  140. package/dist/vendor/adapters/core/src/persistence/store.d.ts +0 -89
  141. package/dist/vendor/adapters/core/src/persistence/store.js +0 -125
  142. package/dist/vendor/adapters/core/src/persistence/trace-store.d.ts +0 -85
  143. package/dist/vendor/adapters/core/src/persistence/trace-store.js +0 -125
  144. package/dist/vendor/adapters/core/src/policy-compiler.d.ts +0 -2
  145. package/dist/vendor/adapters/core/src/policy-compiler.js +0 -195
  146. package/dist/vendor/adapters/core/src/policy.d.ts +0 -156
  147. package/dist/vendor/adapters/core/src/policy.js +0 -704
  148. package/dist/vendor/adapters/core/src/repo-analyzer.d.ts +0 -53
  149. package/dist/vendor/adapters/core/src/repo-analyzer.js +0 -263
  150. package/dist/vendor/adapters/core/src/rollback.d.ts +0 -15
  151. package/dist/vendor/adapters/core/src/rollback.js +0 -253
  152. package/dist/vendor/adapters/core/src/routing.d.ts +0 -82
  153. package/dist/vendor/adapters/core/src/routing.js +0 -336
  154. package/dist/vendor/adapters/core/src/test-integrity.d.ts +0 -64
  155. package/dist/vendor/adapters/core/src/test-integrity.js +0 -197
  156. package/dist/vendor/adapters/core/src/trajectory.d.ts +0 -18
  157. package/dist/vendor/adapters/core/src/trajectory.js +0 -295
  158. package/dist/vendor/adapters/stub-agent-cli.d.ts +0 -8
  159. package/dist/vendor/adapters/stub-agent-cli.js +0 -40
  160. package/dist/vendor/adapters/verifier-only.d.ts +0 -9
  161. 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;