create-principles-disciple 1.145.41 → 1.145.43
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/_release/product-identity.json +1 -1
- package/codex-adapter/package.json +1 -1
- package/console/dist/server/index.js +8 -0
- package/console/dist/server/routes/examiner-executor.d.ts +254 -0
- package/console/dist/server/routes/examiner-executor.js +622 -0
- package/console/dist/server/routes/examiner.d.ts +46 -0
- package/console/dist/server/routes/examiner.js +296 -0
- package/console/dist/ui/App.js +6 -3
- package/console/dist/ui/api.d.ts +138 -0
- package/console/dist/ui/api.js +56 -0
- package/console/dist/ui/components/layout/app-sidebar.d.ts +11 -0
- package/console/dist/ui/components/layout/app-sidebar.js +12 -2
- package/console/dist/ui/i18n/en.json +63 -0
- package/console/dist/ui/i18n/zh-CN.json +63 -0
- package/console/dist/ui/pages/examiner/ExaminerPage.d.ts +15 -0
- package/console/dist/ui/pages/examiner/ExaminerPage.js +158 -0
- package/console/dist/ui/pages/principles/PrincipleDetailPage.js +2 -1
- package/console/dist/ui/utils/runtime-mode.d.ts +10 -0
- package/console/dist/ui/utils/runtime-mode.js +10 -0
- package/console/dist/web/assets/app.css +44 -0
- package/console/dist/web/assets/app.js +33 -31
- package/core/dist/host/host-installer.d.ts +13 -0
- package/core/dist/host/host-installer.d.ts.map +1 -1
- package/core/dist/host/host-installer.js.map +1 -1
- package/core/package.json +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -1
- package/dist/installer.js +1 -1
- package/dist/installer.js.map +1 -1
- package/dist/installers/workbuddy-host-installer.d.ts +39 -0
- package/dist/installers/workbuddy-host-installer.d.ts.map +1 -1
- package/dist/installers/workbuddy-host-installer.js +76 -27
- package/dist/installers/workbuddy-host-installer.js.map +1 -1
- package/package.json +2 -2
- package/pd-cli/dist/services/pd-config-loader.d.ts +1 -1
- package/pd-cli/dist/services/pd-config-loader.d.ts.map +1 -1
- package/pd-cli/dist/services/pd-config-loader.js +9 -0
- package/pd-cli/dist/services/pd-config-loader.js.map +1 -1
- package/pd-cli/package.json +1 -1
- package/plugin/openclaw.plugin.json +1 -1
- package/plugin/package.json +1 -1
- package/release-manager/dist/index.d.ts.map +1 -1
- package/release-manager/dist/index.js +29 -0
- package/release-manager/dist/index.js.map +1 -1
- package/release-manager/dist/installer.js +1 -1
- package/release-manager/dist/installer.js.map +1 -1
- package/release-manager/dist/installers/workbuddy-host-installer.d.ts +39 -0
- package/release-manager/dist/installers/workbuddy-host-installer.d.ts.map +1 -1
- package/release-manager/dist/installers/workbuddy-host-installer.js +76 -27
- package/release-manager/dist/installers/workbuddy-host-installer.js.map +1 -1
- package/release-manager/package.json +2 -2
- package/workbuddy-adapter/package.json +1 -1
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPEC-PD-EXAMINER-001 v0.9 §8 — Examiner Console executor (offline-stub).
|
|
3
|
+
*
|
|
4
|
+
* Receives an ALREADY-RESOLVED study directory (selected by exact membership
|
|
5
|
+
* in the server's own enumeration — see examiner.ts) and performs the offline
|
|
6
|
+
* preflight / execution / report actions through the principle-learning-lab
|
|
7
|
+
* modules. This module never touches request objects and never transports a
|
|
8
|
+
* caller-supplied execution mode: the only reachable mode is offline-stub.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { randomUUID } from 'node:crypto';
|
|
13
|
+
import { pathToFileURL } from 'node:url';
|
|
14
|
+
export const CONSOLE_OWNERSHIP_MARKER = '.console-study-owner.json';
|
|
15
|
+
const OWNERSHIP_SCHEMA = 'examiner-console-ownership.v1';
|
|
16
|
+
const HANDLE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]*$/;
|
|
17
|
+
/** Stable, Owner-facing boundary reasons — never paths or exception text. */
|
|
18
|
+
export const STUDY_PATH_PROBLEM = {
|
|
19
|
+
unresolved: 'the study path cannot be resolved to a canonical location (fail-closed)',
|
|
20
|
+
notInside: 'the study directory resolves outside the writable console root',
|
|
21
|
+
historical: 'the study directory resolves inside the historical research area',
|
|
22
|
+
rootNotIsolated: 'the writable study root must be separate from the historical examiner-studies area',
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* P1-1 canonical resolution. An existing path goes through `realpath`, which
|
|
26
|
+
* follows symlinks AND Windows junctions. A path that does not exist yet is
|
|
27
|
+
* resolved through its deepest EXISTING ancestor (so a symlinked parent can
|
|
28
|
+
* never hide an escape) and the remaining lexically-safe segments are
|
|
29
|
+
* appended. Any resolution failure is fail-closed (null).
|
|
30
|
+
*/
|
|
31
|
+
export function canonicalPathSync(target) {
|
|
32
|
+
let current = path.resolve(target);
|
|
33
|
+
const remainder = [];
|
|
34
|
+
for (;;) {
|
|
35
|
+
let exists = false;
|
|
36
|
+
try {
|
|
37
|
+
fs.lstatSync(current);
|
|
38
|
+
exists = true;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
const { code } = error;
|
|
42
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR')
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
if (exists) {
|
|
46
|
+
try {
|
|
47
|
+
const real = fs.realpathSync.native(current);
|
|
48
|
+
return remainder.length > 0 ? path.join(real, ...remainder.reverse()) : real;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const parent = path.dirname(current);
|
|
55
|
+
if (parent === current)
|
|
56
|
+
return null;
|
|
57
|
+
remainder.push(path.basename(current));
|
|
58
|
+
current = parent;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const foldPath = (value) => (process.platform === 'win32' ? value.toLowerCase() : value);
|
|
62
|
+
export function isSamePath(a, b) {
|
|
63
|
+
return foldPath(path.resolve(a)) === foldPath(path.resolve(b));
|
|
64
|
+
}
|
|
65
|
+
/** Strict containment: equal paths are NOT inside each other. */
|
|
66
|
+
export function isPathInside(root, candidate) {
|
|
67
|
+
const r = foldPath(path.resolve(root));
|
|
68
|
+
const c = foldPath(path.resolve(candidate));
|
|
69
|
+
const prefix = r.endsWith(path.sep) ? r : `${r}${path.sep}`;
|
|
70
|
+
return c !== r && c.startsWith(prefix);
|
|
71
|
+
}
|
|
72
|
+
/** Resolve the two roots once. `PD_EXAMINER_STUDIES_ROOT` overrides the WRITABLE root only. */
|
|
73
|
+
export function resolveStudyRoots(labRoot) {
|
|
74
|
+
const override = process.env.PD_EXAMINER_STUDIES_ROOT;
|
|
75
|
+
const writable = override ? path.resolve(override) : path.join(path.resolve(labRoot), 'examiner-console-studies');
|
|
76
|
+
const historical = path.join(path.resolve(labRoot), 'examiner-studies');
|
|
77
|
+
// Canonical forms when resolvable; the unresolved form is kept so the
|
|
78
|
+
// boundary gates below can fail closed with a stable reason.
|
|
79
|
+
return {
|
|
80
|
+
writable: canonicalPathSync(writable) ?? writable,
|
|
81
|
+
historical: canonicalPathSync(historical) ?? historical,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export function isJsonObject(value) {
|
|
85
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
86
|
+
}
|
|
87
|
+
/** Read an optional string field off an untrusted object (never throws). */
|
|
88
|
+
function stringField(value, key) {
|
|
89
|
+
if (!isJsonObject(value))
|
|
90
|
+
return null;
|
|
91
|
+
const field = value[key];
|
|
92
|
+
return typeof field === 'string' ? field : null;
|
|
93
|
+
}
|
|
94
|
+
function isPrincipleFact(value) {
|
|
95
|
+
return isJsonObject(value) && typeof value.id === 'string' && typeof value.text === 'string';
|
|
96
|
+
}
|
|
97
|
+
function asJsonObject(value, context) {
|
|
98
|
+
if (!isJsonObject(value))
|
|
99
|
+
throw new Error(`${context}: expected a JSON object`);
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
function readJsonObjectFile(file) {
|
|
103
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
104
|
+
return asJsonObject(parsed, file);
|
|
105
|
+
}
|
|
106
|
+
/* eslint-disable @typescript-eslint/max-params -- lab delegation calls carry (modules, labRoot/studiesRoot, dir, data) by design */
|
|
107
|
+
/** Boundary-checked study file reader (fails closed on missing/malformed). */
|
|
108
|
+
export function readStudyJson(studyDir, rel) {
|
|
109
|
+
const root = path.resolve(studyDir);
|
|
110
|
+
const file = path.resolve(root, rel);
|
|
111
|
+
if (!isPathInside(root, file))
|
|
112
|
+
throw new Error('invalid study file path');
|
|
113
|
+
return readJsonObjectFile(file);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* List the study directory names the server itself enumerated under the
|
|
117
|
+
* studies root (sorted). The route layer treats this list as the allowlist.
|
|
118
|
+
*/
|
|
119
|
+
export function listStudyNames(studiesRoot) {
|
|
120
|
+
if (!fs.existsSync(studiesRoot))
|
|
121
|
+
return [];
|
|
122
|
+
return fs.readdirSync(studiesRoot, { withFileTypes: true })
|
|
123
|
+
.filter((entry) => entry.isDirectory())
|
|
124
|
+
.map((entry) => entry.name)
|
|
125
|
+
.sort();
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* P1-1: verify that a directory under the WRITABLE root was really created by
|
|
129
|
+
* this server. Name membership is not enough and file existence is not enough:
|
|
130
|
+
* the directory must
|
|
131
|
+
* 1. resolve (realpath) strictly INSIDE the writable root,
|
|
132
|
+
* 2. be a real directory (not a symlink/junction),
|
|
133
|
+
* 3. carry the server ownership marker whose studyId matches BOTH the
|
|
134
|
+
* directory name and the frozen examiner manifest.
|
|
135
|
+
* A historical research asset can therefore never be written, even if a file
|
|
136
|
+
* named like the marker is dropped beside it.
|
|
137
|
+
*/
|
|
138
|
+
export function verifyConsoleOwnership(writableRoot, studyDir) {
|
|
139
|
+
const rootReal = canonicalPathSync(writableRoot);
|
|
140
|
+
const dirReal = canonicalPathSync(studyDir);
|
|
141
|
+
if (!rootReal || !dirReal)
|
|
142
|
+
return { ok: false, reason: STUDY_PATH_PROBLEM.unresolved };
|
|
143
|
+
if (!isPathInside(rootReal, dirReal))
|
|
144
|
+
return { ok: false, reason: STUDY_PATH_PROBLEM.notInside };
|
|
145
|
+
let stat;
|
|
146
|
+
try {
|
|
147
|
+
stat = fs.lstatSync(studyDir);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return { ok: false, reason: 'study directory is unreadable' };
|
|
151
|
+
}
|
|
152
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
153
|
+
return { ok: false, reason: 'study directory is not a real directory' };
|
|
154
|
+
const markerPath = path.join(studyDir, CONSOLE_OWNERSHIP_MARKER);
|
|
155
|
+
// The marker is the trust anchor: a symlink planted here would redirect the
|
|
156
|
+
// read outside the study, so it must be a regular file.
|
|
157
|
+
let markerStat;
|
|
158
|
+
try {
|
|
159
|
+
markerStat = fs.lstatSync(markerPath);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return { ok: false, reason: 'console ownership marker missing — the study was not created by this server' };
|
|
163
|
+
}
|
|
164
|
+
if (markerStat.isSymbolicLink() || !markerStat.isFile()) {
|
|
165
|
+
return { ok: false, reason: 'console ownership marker is not a regular file' };
|
|
166
|
+
}
|
|
167
|
+
let marker;
|
|
168
|
+
let manifest;
|
|
169
|
+
try {
|
|
170
|
+
marker = readJsonObjectFile(markerPath);
|
|
171
|
+
manifest = readStudyJson(studyDir, 'examiner-manifest.json');
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
// The reason travels into HTTP payloads, so it must not carry raw
|
|
175
|
+
// exception text (paths/internals); the detail stays in the server log.
|
|
176
|
+
console.error('[pd-console] examiner ownership check failed:', error);
|
|
177
|
+
return { ok: false, reason: 'ownership marker or manifest is unreadable (details in the console server log)' };
|
|
178
|
+
}
|
|
179
|
+
const name = path.basename(studyDir);
|
|
180
|
+
if (marker.schema !== OWNERSHIP_SCHEMA || marker.origin !== 'pd-console') {
|
|
181
|
+
return { ok: false, reason: 'console ownership marker is not a valid pd-console record' };
|
|
182
|
+
}
|
|
183
|
+
if (marker.studyId !== name || manifest.studyId !== name) {
|
|
184
|
+
return { ok: false, reason: 'ownership marker / manifest studyId does not match the directory identity' };
|
|
185
|
+
}
|
|
186
|
+
// The marker names the root it was issued for; compare CANONICAL forms so a
|
|
187
|
+
// symlink/junction alias of the same root cannot pass (or be forged).
|
|
188
|
+
const markerRoot = typeof marker.writableRoot === 'string' ? canonicalPathSync(marker.writableRoot) : null;
|
|
189
|
+
if (!markerRoot || !isSamePath(markerRoot, rootReal)) {
|
|
190
|
+
return { ok: false, reason: 'ownership marker was issued for a different writable root' };
|
|
191
|
+
}
|
|
192
|
+
return { ok: true, reason: 'server-owned console study' };
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Resolve a requested study by EXACT membership in the server's own
|
|
196
|
+
* enumeration of BOTH roots. The returned path is always built from an
|
|
197
|
+
* enumerated entry name; callers only select, never construct path segments.
|
|
198
|
+
* A writable entry that resolves outside the writable root (or into the
|
|
199
|
+
* historical area) is returned READ-ONLY with a stable boundary reason.
|
|
200
|
+
*/
|
|
201
|
+
export function resolveStudyAccess(roots, requested) {
|
|
202
|
+
if (!HANDLE_PATTERN.test(requested))
|
|
203
|
+
return null;
|
|
204
|
+
const writableRoot = canonicalPathSync(roots.writable) ?? path.resolve(roots.writable);
|
|
205
|
+
const writableName = listStudyNames(writableRoot).find((entry) => entry === requested);
|
|
206
|
+
if (writableName) {
|
|
207
|
+
const dir = path.join(writableRoot, writableName);
|
|
208
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define -- hoisted function declaration
|
|
209
|
+
const boundary = writableStudyDirProblem(roots, dir);
|
|
210
|
+
if (boundary)
|
|
211
|
+
return { dir, studyId: writableName, readOnly: true, reason: boundary };
|
|
212
|
+
const ownership = verifyConsoleOwnership(roots.writable, dir);
|
|
213
|
+
return { dir, studyId: writableName, readOnly: !ownership.ok, reason: ownership.reason };
|
|
214
|
+
}
|
|
215
|
+
const historicalRoot = canonicalPathSync(roots.historical) ?? path.resolve(roots.historical);
|
|
216
|
+
const historicalName = listStudyNames(historicalRoot).find((entry) => entry === requested);
|
|
217
|
+
if (historicalName) {
|
|
218
|
+
const dir = path.join(historicalRoot, historicalName);
|
|
219
|
+
const dirReal = canonicalPathSync(dir);
|
|
220
|
+
const historicalReal = canonicalPathSync(roots.historical);
|
|
221
|
+
// An entry that resolves outside the historical root is not this study.
|
|
222
|
+
if (!dirReal || !historicalReal || !isPathInside(historicalReal, dirReal))
|
|
223
|
+
return null;
|
|
224
|
+
return {
|
|
225
|
+
dir: dirReal,
|
|
226
|
+
studyId: historicalName,
|
|
227
|
+
readOnly: true,
|
|
228
|
+
reason: 'historical research asset — readable, never writable from the console',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The writable root must never be, contain, or be contained by the historical
|
|
235
|
+
* research area. Both the LEXICAL pair and the CANONICAL pair are checked:
|
|
236
|
+
* lexical catches obvious aliases, canonical catches symlink/junction aliases
|
|
237
|
+
* and their not-yet-existing descendants. Unresolvable roots fail closed.
|
|
238
|
+
*/
|
|
239
|
+
export function writableRootProblem(roots) {
|
|
240
|
+
const writableLexical = path.resolve(roots.writable);
|
|
241
|
+
const historicalLexical = path.resolve(roots.historical);
|
|
242
|
+
const writable = canonicalPathSync(writableLexical);
|
|
243
|
+
const historical = canonicalPathSync(historicalLexical);
|
|
244
|
+
if (!writable || !historical)
|
|
245
|
+
return STUDY_PATH_PROBLEM.unresolved;
|
|
246
|
+
const pairs = [[writableLexical, historicalLexical], [writable, historical]];
|
|
247
|
+
for (const [w, h] of pairs) {
|
|
248
|
+
if (isSamePath(w, h) || isPathInside(h, w) || isPathInside(w, h))
|
|
249
|
+
return STUDY_PATH_PROBLEM.rootNotIsolated;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Unified write-boundary check for ONE study directory: inside the canonical
|
|
255
|
+
* writable root AND outside the canonical historical area. Used by every
|
|
256
|
+
* write path (creation, ownership marker, run, report) immediately before the
|
|
257
|
+
* mutation — a check done only at startup is not evidence about now.
|
|
258
|
+
*/
|
|
259
|
+
export function writableStudyDirProblem(roots, studyDir) {
|
|
260
|
+
const rootProblem = writableRootProblem(roots);
|
|
261
|
+
if (rootProblem)
|
|
262
|
+
return rootProblem;
|
|
263
|
+
const writable = canonicalPathSync(roots.writable);
|
|
264
|
+
const historical = canonicalPathSync(roots.historical);
|
|
265
|
+
const dir = canonicalPathSync(studyDir);
|
|
266
|
+
if (!writable || !historical || !dir)
|
|
267
|
+
return STUDY_PATH_PROBLEM.unresolved;
|
|
268
|
+
if (isSamePath(historical, dir) || isPathInside(historical, dir))
|
|
269
|
+
return STUDY_PATH_PROBLEM.historical;
|
|
270
|
+
if (!isPathInside(writable, dir))
|
|
271
|
+
return STUDY_PATH_PROBLEM.notInside;
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
/** Study status projection (manifest + ledger settlement counts + report status). */
|
|
275
|
+
export function studyStatusPayload(studyDir) {
|
|
276
|
+
const manifest = readStudyJson(studyDir, 'examiner-manifest.json');
|
|
277
|
+
const eventsFile = path.join(studyDir, 'events.jsonl');
|
|
278
|
+
const events = fs.existsSync(eventsFile)
|
|
279
|
+
? fs.readFileSync(eventsFile, 'utf8').trim().split('\n').filter(Boolean).map((line) => asJsonObject(JSON.parse(line), eventsFile))
|
|
280
|
+
: [];
|
|
281
|
+
const counts = {};
|
|
282
|
+
for (const event of events) {
|
|
283
|
+
if (typeof event.type !== 'string' || !event.type.startsWith('UNIT_FINISHED'))
|
|
284
|
+
continue;
|
|
285
|
+
const result = String(event.result ?? 'UNKNOWN');
|
|
286
|
+
counts[result] = (counts[result] ?? 0) + 1;
|
|
287
|
+
}
|
|
288
|
+
const reportExists = fs.existsSync(path.join(studyDir, 'behavior-report-v2.json'));
|
|
289
|
+
const report = reportExists ? readStudyJson(studyDir, 'behavior-report-v2.json') : null;
|
|
290
|
+
return {
|
|
291
|
+
studyId: path.basename(studyDir),
|
|
292
|
+
experimentMode: manifest.experimentMode,
|
|
293
|
+
interventionType: manifest.interventionType,
|
|
294
|
+
targetPrincipleId: stringField(manifest.targetPrinciple, 'id'),
|
|
295
|
+
plannedPairs: manifest.plannedPairs,
|
|
296
|
+
runStarted: events.some((event) => event.type === 'RUN_STARTED'),
|
|
297
|
+
runFinished: events.some((event) => event.type === 'RUN_FINISHED'),
|
|
298
|
+
unitStates: counts,
|
|
299
|
+
reportStatus: report ? (report.status) : null,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/** Read-only Report V2 payload + replay verification. */
|
|
303
|
+
export function studyReportPayload(modules, studyDir) {
|
|
304
|
+
if (!fs.existsSync(path.join(studyDir, 'behavior-report-v2.json')))
|
|
305
|
+
return null;
|
|
306
|
+
return {
|
|
307
|
+
studyId: path.basename(studyDir),
|
|
308
|
+
report: readStudyJson(studyDir, 'behavior-report-v2.json'),
|
|
309
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define -- hoisted function declaration
|
|
310
|
+
verification: studyReportVerify(modules, studyDir),
|
|
311
|
+
note: 'historical reports are read-only — REPORT_READY artifacts can never be overwritten',
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/** The didactic offline actor pair — SAME scripted contrast as the committed acceptance artifacts. */
|
|
315
|
+
function didacticActions(armId) {
|
|
316
|
+
return armId === 'A'
|
|
317
|
+
? [
|
|
318
|
+
{ type: 'observe', args: { obsId: 'OBS-WRONG' } },
|
|
319
|
+
{ type: 'report', args: { claims: [{ factId: 'F1', verdict: 'verified_true', evidenceRefs: ['OBS-WRONG'] }] } },
|
|
320
|
+
]
|
|
321
|
+
: [
|
|
322
|
+
{ type: 'observe', args: { obsId: 'OBS-PRIMARY' } },
|
|
323
|
+
{ type: 'report', args: { claims: [{ factId: 'F1', verdict: 'verified_true', evidenceRefs: ['OBS-PRIMARY'] }] } },
|
|
324
|
+
];
|
|
325
|
+
}
|
|
326
|
+
export class ExaminerPreflightBlockedError extends Error {
|
|
327
|
+
blockers;
|
|
328
|
+
constructor(blockers) {
|
|
329
|
+
super(`preflight blocked (${blockers.length} blocker(s)) — no run started, no unit reserved, no actor created`);
|
|
330
|
+
this.name = 'ExaminerPreflightBlockedError';
|
|
331
|
+
this.blockers = blockers;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
export class ExaminerWriteRefusedError extends Error {
|
|
335
|
+
constructor(message) {
|
|
336
|
+
super(message);
|
|
337
|
+
this.name = 'ExaminerWriteRefusedError';
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function studyDirStatus(studyDir, context) {
|
|
341
|
+
if (!context)
|
|
342
|
+
return { insideFrozenArea: false, collidesWithHistorical: false };
|
|
343
|
+
const dirReal = canonicalPathSync(studyDir);
|
|
344
|
+
// Unresolvable is not "probably fine": report it as colliding so the
|
|
345
|
+
// fail-closed preflight refuses instead of running.
|
|
346
|
+
if (!dirReal)
|
|
347
|
+
return { insideFrozenArea: true, collidesWithHistorical: true };
|
|
348
|
+
const insideRoot = (root) => {
|
|
349
|
+
const real = canonicalPathSync(root);
|
|
350
|
+
if (!real)
|
|
351
|
+
return true;
|
|
352
|
+
return isSamePath(real, dirReal) || isPathInside(real, dirReal);
|
|
353
|
+
};
|
|
354
|
+
return {
|
|
355
|
+
insideFrozenArea: insideRoot(path.join(context.labRoot, 'challenge-studies')),
|
|
356
|
+
collidesWithHistorical: insideRoot(context.roots.historical),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
export function preflightCheck(modules, studyDir, context) {
|
|
360
|
+
const manifest = readStudyJson(studyDir, 'examiner-manifest.json');
|
|
361
|
+
const hepsInput = readStudyJson(studyDir, 'heps-principles.json');
|
|
362
|
+
const armConfigs = readStudyJson(studyDir, 'examiner-arm-configs.json');
|
|
363
|
+
const rulecode = readStudyJson(studyDir, 'rulecode-baseline.json');
|
|
364
|
+
const pairAssignment = readStudyJson(studyDir, 'pair-assignment.json');
|
|
365
|
+
const arms = asJsonObject(armConfigs.arms, 'examiner-arm-configs.arms');
|
|
366
|
+
const armPayloadText = (side) => {
|
|
367
|
+
const entry = arms[side];
|
|
368
|
+
if (!isJsonObject(entry))
|
|
369
|
+
return '';
|
|
370
|
+
return typeof entry.payloadText === 'string' ? entry.payloadText : '';
|
|
371
|
+
};
|
|
372
|
+
const principles = Array.isArray(hepsInput.principles) ? hepsInput.principles.filter(isPrincipleFact) : [];
|
|
373
|
+
// P1-3/P1-4: the frozen files must agree with each other AND the immutable
|
|
374
|
+
// anchor. This verdict is a hard blocker; a study whose files were rewritten
|
|
375
|
+
// in a mutually consistent way can never pass the run gate.
|
|
376
|
+
const bindings = modules.study.verifyExaminerStudyBindings(studyDir);
|
|
377
|
+
return modules.study.runExaminerPreflight({
|
|
378
|
+
manifest,
|
|
379
|
+
heps: modules.study.buildHostEffectivePrincipleSet({ principles: hepsInput.principles, targetPrincipleId: hepsInput.targetPrincipleId, host: hepsInput.host }),
|
|
380
|
+
ruleCodePolicy: modules.study.buildRuleCodePolicy({
|
|
381
|
+
mode: manifest.experimentMode,
|
|
382
|
+
ruleCodes: rulecode.ruleCodes,
|
|
383
|
+
targetPrincipleId: hepsInput.targetPrincipleId,
|
|
384
|
+
principleRuleCodeIndependence: rulecode.principleRuleCodeIndependence,
|
|
385
|
+
targetRuleCodeCount: rulecode.targetRuleCodeCount ?? null,
|
|
386
|
+
}),
|
|
387
|
+
pairAssignment,
|
|
388
|
+
primaryEndpoint: modules.study.definePrimaryEndpoint({ id: stringField(manifest.primaryEndpoint, 'id') ?? 'target_unsupported_completion_assertion_rate' }),
|
|
389
|
+
armConfigs: { arms: { A: { delivery: { payloadText: armPayloadText('control') } }, B: { delivery: { payloadText: armPayloadText('treatment') } } } },
|
|
390
|
+
targetPrincipleText: principles.find((p) => p.id === hepsInput.targetPrincipleId)?.text,
|
|
391
|
+
isolation: { sessionPerArm: true, workspacePerArm: true },
|
|
392
|
+
goldStatus: 'SYNTHETIC_OFFLINE',
|
|
393
|
+
injectionCapability: { host: 'offline-stub', maxProvableLevel: 'RUNTIME_LOADED', minimumRequiredLevel: 'CONTEXT_PRESENT' },
|
|
394
|
+
authorization: { mode: 'offline-stub' },
|
|
395
|
+
studyDirStatus: studyDirStatus(studyDir, context),
|
|
396
|
+
bindings,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* P1-4: POST /run can never reach the runner without passing the server gate.
|
|
401
|
+
* The readiness check below is evaluated first (a convenience projection), then
|
|
402
|
+
* the runner re-evaluates the same frozen facts UNDER the study write lock
|
|
403
|
+
* before RUN_STARTED — but a blocker here means the runner is not called at
|
|
404
|
+
* all, so no attempt is reserved and no actor is created.
|
|
405
|
+
*/
|
|
406
|
+
export async function offlineStudyPass(modules, studyDir, context) {
|
|
407
|
+
// The write boundary is re-evaluated here (not only at route start): a
|
|
408
|
+
// directory swapped for a symlink/junction after access resolution must not
|
|
409
|
+
// reach the runner.
|
|
410
|
+
if (context) {
|
|
411
|
+
const boundary = writableStudyDirProblem(context.roots, studyDir);
|
|
412
|
+
if (boundary)
|
|
413
|
+
throw new ExaminerPreflightBlockedError([{ id: 'study_path_boundary', reason: boundary }]);
|
|
414
|
+
}
|
|
415
|
+
const preflight = preflightCheck(modules, studyDir, context);
|
|
416
|
+
if (!preflight.ok)
|
|
417
|
+
throw new ExaminerPreflightBlockedError(preflight.blockers);
|
|
418
|
+
const manifest = readStudyJson(studyDir, 'examiner-manifest.json');
|
|
419
|
+
const pairAssignment = readStudyJson(studyDir, 'pair-assignment.json');
|
|
420
|
+
const suite = readStudyJson(studyDir, 'suite-manifest.json');
|
|
421
|
+
const repetitions = Array.from({ length: Number(manifest.repetitionsPerCase ?? 1) }, (_, i) => String(i + 1));
|
|
422
|
+
return modules.runner.runChallengeStudy({
|
|
423
|
+
studyDir,
|
|
424
|
+
plan: {
|
|
425
|
+
arms: ['A', 'B'],
|
|
426
|
+
cases: suite.cases.map((c) => c.caseId),
|
|
427
|
+
repetitions,
|
|
428
|
+
unitOrder: pairAssignment.unitOrder,
|
|
429
|
+
},
|
|
430
|
+
actorFactory: ({ armId }) => modules.actor.createScriptedStub({ actions: didacticActions(armId) }),
|
|
431
|
+
execution: { mode: 'offline-stub' },
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
export function reportMaterialize(modules, studyDir, now, context, canWrite) {
|
|
435
|
+
if (context) {
|
|
436
|
+
const boundary = writableStudyDirProblem(context.roots, studyDir);
|
|
437
|
+
if (boundary)
|
|
438
|
+
throw new ExaminerWriteRefusedError(boundary);
|
|
439
|
+
}
|
|
440
|
+
// The lab calls canWrite() immediately before writing; re-apply the boundary
|
|
441
|
+
// check there so a directory swapped AFTER this function returned still
|
|
442
|
+
// fails closed instead of writing into an alias.
|
|
443
|
+
const guardedCanWrite = () => {
|
|
444
|
+
if (context) {
|
|
445
|
+
const boundary = writableStudyDirProblem(context.roots, studyDir);
|
|
446
|
+
if (boundary)
|
|
447
|
+
return { ok: false, reason: boundary };
|
|
448
|
+
}
|
|
449
|
+
return canWrite();
|
|
450
|
+
};
|
|
451
|
+
return modules.report.writeBehaviorReportV2(studyDir, { now, canWrite: guardedCanWrite });
|
|
452
|
+
}
|
|
453
|
+
export function studyReportVerify(modules, studyDir) {
|
|
454
|
+
return modules.report.verifyBehaviorReportV2(studyDir);
|
|
455
|
+
}
|
|
456
|
+
export async function loadLabModules(labRoot) {
|
|
457
|
+
const url = (rel) => pathToFileURL(path.join(labRoot, rel)).href;
|
|
458
|
+
const study = (await import(url('lib/examiner-study.mjs')));
|
|
459
|
+
const runner = (await import(url('lib/challenge-runner.mjs')));
|
|
460
|
+
const report = (await import(url('lib/challenge-behavior-report.mjs')));
|
|
461
|
+
const actor = (await import(url('lib/challenge-actor.mjs')));
|
|
462
|
+
return { study, runner, report, actor };
|
|
463
|
+
}
|
|
464
|
+
/** Host injection-capability ceiling table (from examiner-injection-evidence.mjs). */
|
|
465
|
+
export async function loadHostCapability(labRoot) {
|
|
466
|
+
const url = pathToFileURL(path.join(labRoot, 'lib', 'examiner-injection-evidence.mjs')).href;
|
|
467
|
+
const mod = (await import(url));
|
|
468
|
+
if (!mod || typeof mod.HOST_MAX_PROVABLE_LEVEL !== 'object')
|
|
469
|
+
throw new Error('examiner-injection-evidence.mjs exports missing');
|
|
470
|
+
return mod.HOST_MAX_PROVABLE_LEVEL;
|
|
471
|
+
}
|
|
472
|
+
/** Server-minted study handle + directory (crypto-random; never request-derived). */
|
|
473
|
+
export function mintStudyHandle(roots) {
|
|
474
|
+
const problem = writableRootProblem(roots);
|
|
475
|
+
if (problem)
|
|
476
|
+
throw new ExaminerWriteRefusedError(problem);
|
|
477
|
+
fs.mkdirSync(roots.writable, { recursive: true });
|
|
478
|
+
// Re-resolve AFTER mkdir: the pre-mkdir check is not evidence about the
|
|
479
|
+
// directory that now exists (a parent could have become a link meanwhile).
|
|
480
|
+
const afterMkdir = writableRootProblem(roots);
|
|
481
|
+
if (afterMkdir)
|
|
482
|
+
throw new ExaminerWriteRefusedError(afterMkdir);
|
|
483
|
+
const writableReal = canonicalPathSync(roots.writable);
|
|
484
|
+
if (!writableReal)
|
|
485
|
+
throw new ExaminerWriteRefusedError(STUDY_PATH_PROBLEM.unresolved);
|
|
486
|
+
const studyId = `EXM-C-${randomUUID().toUpperCase()}`;
|
|
487
|
+
// A historical identity must never be reused for a new write target.
|
|
488
|
+
if (listStudyNames(roots.historical).includes(studyId))
|
|
489
|
+
return null;
|
|
490
|
+
const candidate = path.join(writableReal, studyId);
|
|
491
|
+
if (fs.existsSync(candidate))
|
|
492
|
+
return null;
|
|
493
|
+
return { studyId, studyDir: candidate };
|
|
494
|
+
}
|
|
495
|
+
function writeOwnershipMarker(studyId, studyDir, roots) {
|
|
496
|
+
// Write-time boundary re-check (creation may have taken a while).
|
|
497
|
+
const boundary = writableStudyDirProblem(roots, studyDir);
|
|
498
|
+
if (boundary)
|
|
499
|
+
throw new ExaminerWriteRefusedError(boundary);
|
|
500
|
+
const rootReal = canonicalPathSync(roots.writable);
|
|
501
|
+
if (!rootReal)
|
|
502
|
+
throw new ExaminerWriteRefusedError(STUDY_PATH_PROBLEM.unresolved);
|
|
503
|
+
const marker = {
|
|
504
|
+
schema: OWNERSHIP_SCHEMA,
|
|
505
|
+
origin: 'pd-console',
|
|
506
|
+
studyId,
|
|
507
|
+
writableRoot: rootReal,
|
|
508
|
+
createdAt: new Date().toISOString(),
|
|
509
|
+
};
|
|
510
|
+
// Exclusive create: never follow (or overwrite) a file/symlink that was
|
|
511
|
+
// planted inside the freshly minted directory.
|
|
512
|
+
const markerPath = path.join(studyDir, CONSOLE_OWNERSHIP_MARKER);
|
|
513
|
+
const fd = fs.openSync(markerPath, 'wx');
|
|
514
|
+
try {
|
|
515
|
+
fs.writeSync(fd, `${JSON.stringify(marker, null, 2)}\n`);
|
|
516
|
+
}
|
|
517
|
+
finally {
|
|
518
|
+
fs.closeSync(fd);
|
|
519
|
+
}
|
|
520
|
+
const check = verifyConsoleOwnership(roots.writable, studyDir);
|
|
521
|
+
if (!check.ok)
|
|
522
|
+
throw new ExaminerWriteRefusedError(`console ownership marker was not accepted: ${check.reason}`);
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Create one console-originated offline examiner study: mint the server-side
|
|
526
|
+
* identity, resolve the target from the SERVER-provided ledger list (the id
|
|
527
|
+
* is charset-validated by the caller and must match a ledger entry exactly),
|
|
528
|
+
* then materialize the study through the lab contract. Synthetic calibration
|
|
529
|
+
* cases with SYNTHETIC_OFFLINE gold provenance — offline contract validation,
|
|
530
|
+
* never real-host behavior.
|
|
531
|
+
*/
|
|
532
|
+
export async function materializeConsoleStudy(modules, labRoot, roots, selection) {
|
|
533
|
+
const target = selection.ledgerPrinciples[selection.targetIndex];
|
|
534
|
+
if (!target)
|
|
535
|
+
throw new Error('the target principle was not found in the workspace ledger');
|
|
536
|
+
const rootProblem = writableRootProblem(roots);
|
|
537
|
+
if (rootProblem)
|
|
538
|
+
throw new ExaminerWriteRefusedError(rootProblem);
|
|
539
|
+
const experimentMode = selection.isolatedMode ? 'isolated_principle' : 'full_library_incremental';
|
|
540
|
+
const minted = mintStudyHandle(roots);
|
|
541
|
+
if (!minted)
|
|
542
|
+
throw new Error('server-minted study handle collided — retry');
|
|
543
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define -- hoisted function declaration
|
|
544
|
+
const created = await materializeOfflineStudy(modules, labRoot, {
|
|
545
|
+
studyId: minted.studyId,
|
|
546
|
+
studyDir: minted.studyDir,
|
|
547
|
+
experimentMode,
|
|
548
|
+
target,
|
|
549
|
+
ledgerPrinciples: selection.ledgerPrinciples,
|
|
550
|
+
repetitionsPerCase: selection.repetitionsPerCase,
|
|
551
|
+
});
|
|
552
|
+
// The lab created the directory tree; verify the REAL location before the
|
|
553
|
+
// ownership marker (the trust anchor) is written into it.
|
|
554
|
+
const boundary = writableStudyDirProblem(roots, minted.studyDir);
|
|
555
|
+
if (boundary)
|
|
556
|
+
throw new ExaminerWriteRefusedError(boundary);
|
|
557
|
+
writeOwnershipMarker(minted.studyId, minted.studyDir, roots);
|
|
558
|
+
const { pairs } = created.pairAssignment;
|
|
559
|
+
return {
|
|
560
|
+
studyId: minted.studyId,
|
|
561
|
+
targetPrincipleId: target.id,
|
|
562
|
+
manifestHash: created.manifest.manifestHash,
|
|
563
|
+
plannedPairs: Array.isArray(pairs) ? pairs.length : null,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Create one offline examiner study. The study identity and directory are
|
|
568
|
+
* ALWAYS server-minted by the caller (crypto-random handle); this function
|
|
569
|
+
* receives already-validated server data only. Synthetic calibration cases
|
|
570
|
+
* with SYNTHETIC_OFFLINE gold provenance — offline contract validation,
|
|
571
|
+
* never real-host behavior.
|
|
572
|
+
*/
|
|
573
|
+
export async function materializeOfflineStudy(modules, labRoot, inputs) {
|
|
574
|
+
const fixtureUrl = pathToFileURL(path.join(labRoot, '..', '..', '__tests__', 'fixtures', 'examiner-v07.mjs')).href;
|
|
575
|
+
const fixture = (await import(fixtureUrl));
|
|
576
|
+
const freezeUrl = pathToFileURL(path.join(labRoot, 'lib', 'challenge-freeze.mjs')).href;
|
|
577
|
+
const freeze = (await import(freezeUrl));
|
|
578
|
+
const caseDefs = [
|
|
579
|
+
fixture.makeExaminerV3Case({ caseId: `${inputs.studyId}-CASE-01` }),
|
|
580
|
+
fixture.makeExaminerV3Case({ caseId: `${inputs.studyId}-CASE-02`, boundary: true }),
|
|
581
|
+
];
|
|
582
|
+
const principleFrozen = { contentJson: { principleDraft: { statement: inputs.target.text } } };
|
|
583
|
+
const mapping = {
|
|
584
|
+
schema: 'PainMechanismMappingV1',
|
|
585
|
+
mappingId: `PMM-${inputs.studyId}`,
|
|
586
|
+
principleContentHash: freeze.hashOfCanonical(principleFrozen.contentJson),
|
|
587
|
+
observedFailure: 'console-originated offline calibration (synthetic)',
|
|
588
|
+
failureMechanism: 'assertion_without_observation',
|
|
589
|
+
ownerGroundedGoal: 'offline contract validation for the console entry',
|
|
590
|
+
};
|
|
591
|
+
mapping.mappingHash = freeze.freezeMapping(mapping).mappingHash;
|
|
592
|
+
return modules.study.writeExaminerStudyArtifacts({
|
|
593
|
+
studyId: inputs.studyId,
|
|
594
|
+
experimentMode: inputs.experimentMode,
|
|
595
|
+
hepsPrinciples: { principles: inputs.ledgerPrinciples, targetPrincipleId: inputs.target.id, host: 'offline-stub' },
|
|
596
|
+
// P1-5: RuleCode facts are EVIDENCE, never a default. The ledger projects
|
|
597
|
+
// only the count of RuleCodes bound to the principle; absent individual
|
|
598
|
+
// RuleCode ids that count is passed as unresolved evidence, which makes the
|
|
599
|
+
// policy INTERVENTION_NOT_IDENTIFIABLE and the creation refuse (422).
|
|
600
|
+
// A zero count is a real, evidenced fact: no RuleCode exists to couple.
|
|
601
|
+
ruleCodeFacts: {
|
|
602
|
+
ruleCodes: [],
|
|
603
|
+
principleRuleCodeIndependence: (inputs.target.ruleCount ?? 0) === 0 ? true : null,
|
|
604
|
+
targetRuleCodeCount: inputs.target.ruleCount ?? 0,
|
|
605
|
+
},
|
|
606
|
+
caseDefs,
|
|
607
|
+
principleFrozen,
|
|
608
|
+
mapping,
|
|
609
|
+
primaryEndpointId: 'target_unsupported_completion_assertion_rate',
|
|
610
|
+
commonContextText: 'Offline examiner study created from the PD console (target principle read from the workspace ledger). Synthetic calibration cases — offline contract validation only, never real-host behavior.',
|
|
611
|
+
budgetChars: 2000,
|
|
612
|
+
seed: `${inputs.studyId}-seed`,
|
|
613
|
+
repetitionsPerCase: inputs.repetitionsPerCase,
|
|
614
|
+
hostManifest: { host: 'offline-stub', hostKind: 'offline-stub', adapter: 'lab-offline', maxProvableInjectionLevel: 'RUNTIME_LOADED' },
|
|
615
|
+
modelManifest: { model: 'offline-deterministic-stub', provider: 'local', version: 'stub-1' },
|
|
616
|
+
scorerVersion: 'mechanical-claim-evidence-v3',
|
|
617
|
+
analysisPlan: { primary: 'ITT over preregistered applicable pairs with UNKNOWN bounds', missingHandling: 'bounds only', inference: 'paired descriptive statistics — no significance claims (DESCRIPTIVE_ONLY)' },
|
|
618
|
+
invalidationPolicy: { hard: ['preregistration tampered', 'gold leak', 'shared memory across arms'], infrastructure: 'single failures stay UNKNOWN' },
|
|
619
|
+
goldProvenance: 'SYNTHETIC_OFFLINE',
|
|
620
|
+
studyDir: inputs.studyDir,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPEC-PD-EXAMINER-001 v0.9 §8 — Examiner Console API (offline-first).
|
|
3
|
+
*
|
|
4
|
+
* Product entry for the Examiner research pipeline on top of the existing
|
|
5
|
+
* principle-learning-lab (single research engine — no parallel Runner/Ledger/
|
|
6
|
+
* Scorer/Report). This round is authorized for the LOCAL DEV environment
|
|
7
|
+
* only: the lab lives in the repository under scripts/dev/, so in an
|
|
8
|
+
* installed runtime the routes answer 503 `examiner_dev_tooling_unavailable`
|
|
9
|
+
* instead of pretending.
|
|
10
|
+
*
|
|
11
|
+
* Surface (all offline, zero real model/host calls):
|
|
12
|
+
* GET /api/v1/examiner/overview — gates, host capability, study list
|
|
13
|
+
* GET /api/v1/examiner/principles — workspace principles as study inputs
|
|
14
|
+
* POST /api/v1/examiner/studies — create an offline examiner study
|
|
15
|
+
* GET /api/v1/examiner/studies/:studyId — manifest + execution + report status
|
|
16
|
+
* POST /api/v1/examiner/studies/:studyId/preflight — fail-closed preflight
|
|
17
|
+
* POST /api/v1/examiner/studies/:studyId/run — offline-stub execution (real modes refused)
|
|
18
|
+
* GET /api/v1/examiner/studies/:studyId/report — Report V2 (read-only)
|
|
19
|
+
* POST /api/v1/examiner/studies/:studyId/report — generate Report V2 (idempotent)
|
|
20
|
+
*
|
|
21
|
+
* Safety contract: this route layer contains NO filesystem access — every
|
|
22
|
+
* study path is resolved inside examiner-executor by exact membership in the
|
|
23
|
+
* server's own directory enumeration, and study identities are always
|
|
24
|
+
* server-minted crypto-random handles. A request only SELECTS: the target
|
|
25
|
+
* principle travels onward as an index into the server's own ledger list,
|
|
26
|
+
* the experiment mode as a boolean. No request string crosses into the
|
|
27
|
+
* research pipeline, and no request value constructs a path.
|
|
28
|
+
*
|
|
29
|
+
* Two roots (P1-1): NEW console studies are written under
|
|
30
|
+
* `examiner-console-studies/` and require the server's own ownership marker;
|
|
31
|
+
* the committed `examiner-studies/` research assets are visible but ALWAYS
|
|
32
|
+
* read-only here (POST /run and POST /report answer 403). The frozen
|
|
33
|
+
* `challenge-studies/` area is never a write target. Real-host execution is
|
|
34
|
+
* refused by this route class itself.
|
|
35
|
+
*/
|
|
36
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
37
|
+
/**
|
|
38
|
+
* P2-1: the Examiner is a DEV-ONLY capability. The gate is SERVER-SIDE and
|
|
39
|
+
* cannot be set by a client — it reads only the running module's own URL and
|
|
40
|
+
* the operator's environment: a source run (tsx/vitest, import.meta.url ends
|
|
41
|
+
* with .ts) or an explicit `PD_EXAMINER_DEV=1`. `NODE_ENV=production` is an
|
|
42
|
+
* UNCONDITIONAL hard gate: not even the DEV override can enable the surface
|
|
43
|
+
* there, so a production build (`dist/server.js`) answers 404 — never 503.
|
|
44
|
+
*/
|
|
45
|
+
export declare function isExaminerDevRuntime(moduleUrl: string, env?: Record<string, string | undefined>): boolean;
|
|
46
|
+
export declare function handleExaminerRoute(req: IncomingMessage, res: ServerResponse, workspaceDir: string, subPath: string): Promise<void>;
|