create-pmos 0.5.0 → 0.6.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 +114 -0
- package/bin/create-pmos.js +93 -7
- package/package.json +7 -3
- package/payload/AGENTS-HUMAN.md +6 -0
- package/payload/AGENTS.md +35 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/README.md +6 -0
- package/payload/SECURITY.md +6 -0
- package/payload/okf/core/concepts/frontmatter-format.md +75 -0
- package/payload/okf/core/concepts/index.md +3 -0
- package/payload/okf/core/concepts/log.md +13 -0
- package/payload/okf/core/templates/eval-rubric-template.md +12 -10
- package/payload/okf/core/templates/prd-template.md +6 -0
- package/payload/okf/core/templates/prototype-brief-template.md +45 -21
- package/payload/planning/evals/agent-run-eval-rubric.md +10 -7
- package/payload/scripts/metrics.py +142 -121
- package/payload/skills/SKILL-FORMAT.md +6 -0
- package/payload/skills/discovery.skill +4 -0
- package/payload/skills/eval-rubric.skill +1 -1
- package/payload/skills/explain-surface.skill +156 -0
- package/payload/skills/pmos-router.skill +25 -3
- package/payload/skills/prd.skill +8 -0
- package/payload/skills/prototype.skill +106 -41
- package/payload/skills-native/discovery/SKILL.md +4 -0
- package/payload/skills-native/eval-rubric/SKILL.md +1 -1
- package/payload/skills-native/explain-surface/SKILL.md +158 -0
- package/payload/skills-native/pmos-router/SKILL.md +25 -3
- package/payload/skills-native/prd/SKILL.md +8 -0
- package/payload/skills-native/prototype/SKILL.md +106 -41
- package/payload/templates/agent-run-eval-rubric.md +10 -7
- package/payload/templates/ticket-template.md +2 -0
- package/workspace/README.md +112 -0
- package/workspace/REMOTE.md +131 -0
- package/workspace/bin/pmos.js +206 -0
- package/workspace/doctor.js +99 -0
- package/workspace/lib/console-state.js +117 -0
- package/workspace/lib/file-mode-parse.js +58 -0
- package/workspace/lib/harness-parse.js +222 -0
- package/workspace/lib/preview-kind.js +98 -0
- package/workspace/lib/rate-limit.js +26 -0
- package/workspace/lib/remote-access.js +235 -0
- package/workspace/lib/review-gate.js +39 -0
- package/workspace/lib/schedule-logic.js +132 -0
- package/workspace/lib/skill-store.js +143 -0
- package/workspace/lib/surface-contract.js +79 -0
- package/workspace/lib/transcript-read.js +247 -0
- package/workspace/package-lock.json +94 -0
- package/workspace/package.json +23 -0
- package/workspace/public/app.js +1710 -0
- package/workspace/public/index.html +175 -0
- package/workspace/public/styles.css +766 -0
- package/workspace/server.js +1753 -0
- package/workspace/skills/backlog-triage.skill +13 -0
- package/workspace/skills/weekly-report.skill +19 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* harness-parse — the five scrollback-derived signals for the harness tower.
|
|
4
|
+
* PRD: planning/prd/workspace-harness-tower-prd.md (R3). Rubric dimension 1 is a VETO.
|
|
5
|
+
*
|
|
6
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
* VENDOR COUPLING — READ THIS BEFORE CHANGING ANYTHING HERE.
|
|
8
|
+
*
|
|
9
|
+
* Owner: the PM (wawan). There is no upstream contract behind any of this.
|
|
10
|
+
*
|
|
11
|
+
* Every signal below is derived by reading the raw bytes ONE VENDOR'S CLI (Claude Code)
|
|
12
|
+
* happened to print into a PTY. Claude Code renders its TUI in place with cursor motion; we
|
|
13
|
+
* are reading a byte ring, not a screen. The markers we anchor on (`⏺` for a block, `⎿` for
|
|
14
|
+
* tool output) are that CLI's presentation, not an API. When Anthropic restyles the terminal
|
|
15
|
+
* UI, these parsers degrade — and because the PM ratified FIVE of them over the recommended
|
|
16
|
+
* one (pm-decisions.md, 2026-07-25), a single restyle can degrade five surfaces at once.
|
|
17
|
+
*
|
|
18
|
+
* What to check when output looks wrong:
|
|
19
|
+
* 1. Do `⏺` / `⎿` still prefix blocks and tool results? (BLOCK / RESULT below)
|
|
20
|
+
* 2. Are tool calls still `Name(args)` and MCP still `server - tool (MCP)`? (TOOL_CALL / MCP_CALL)
|
|
21
|
+
* 3. Does the CLI still print a context percentage at all? (CONTEXT_PCT)
|
|
22
|
+
* If (1) breaks, everything here returns its failure state — which is the correct behaviour,
|
|
23
|
+
* not a bug to paper over.
|
|
24
|
+
*
|
|
25
|
+
* THE RULE THAT MAKES THIS SAFE TO SHIP: every signal has a FAILURE STATE that the UI draws.
|
|
26
|
+
* A parser that cannot read must say so. It must never return an empty string, a zero, or a
|
|
27
|
+
* plausible-looking value — a monitoring surface that guesses is worse than one that is absent.
|
|
28
|
+
* `missing[signal]` carries the reason, and the client renders it verbatim.
|
|
29
|
+
*
|
|
30
|
+
* REVERSAL CONDITION (pre-agreed, pm-decisions.md): if two or more of these break in one CLI
|
|
31
|
+
* update, or any one is found rendering a value where it should have rendered a failure, scope
|
|
32
|
+
* reverts to parser 1 alone and the rest are deleted.
|
|
33
|
+
*
|
|
34
|
+
* INPUT MUST ALREADY BE SCRUBBED. Callers pass text through server.js's `scrub()` first —
|
|
35
|
+
* ANSI stripped and secrets redacted (D08). Scrollback holds whatever the agent printed, so a
|
|
36
|
+
* verbatim "last words" line could otherwise put a token on screen.
|
|
37
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
// Claude Code's block markers. Everything in this file anchors on these two glyphs.
|
|
41
|
+
const BLOCK = /^\s*⏺\s+(.+)$/; // an assistant block: prose OR a tool call
|
|
42
|
+
const RESULT = /^\s*⎿/; // tool output under a call
|
|
43
|
+
const TOOL_CALL = /^([A-Z][A-Za-z0-9_]*)\(/; // Read(…), Bash(…), Write(…)
|
|
44
|
+
const MCP_CALL = /^(\S+)\s+-\s+(\S+)/; // pmos - save_spec (MCP)
|
|
45
|
+
const CONTEXT_PCT = /context[^\n%]{0,24}?(\d{1,3})\s*%|(\d{1,3})\s*%\s+context/i;
|
|
46
|
+
const MODEL = /\bclaude-(?:opus|sonnet|haiku|fable)[\w.-]*/i;
|
|
47
|
+
const EFFORT = /\beffort[:\s]+(low|medium|high|xhigh|max)\b/i;
|
|
48
|
+
|
|
49
|
+
// Only the tail is parsed. The ring holds 200 KB; re-reading all of it for every session on
|
|
50
|
+
// every broadcast is waste. The consequence is stated honestly in the UI: tool counts are
|
|
51
|
+
// "recent", not "for the whole run", because a ring cannot promise the whole run anyway.
|
|
52
|
+
const TAIL_BYTES = 32_768;
|
|
53
|
+
|
|
54
|
+
const NOT_CLAUDE = 'only claude sessions are parsed';
|
|
55
|
+
const UNREADABLE = "can't read its output — no recognisable Claude Code blocks";
|
|
56
|
+
|
|
57
|
+
/** Split the scrubbed tail into lines, dropping a partial first line. */
|
|
58
|
+
function tailLines(scrubbed) {
|
|
59
|
+
const tail = scrubbed.length > TAIL_BYTES ? scrubbed.slice(scrubbed.length - TAIL_BYTES) : scrubbed;
|
|
60
|
+
const lines = tail.split(/\r?\n/);
|
|
61
|
+
if (scrubbed.length > TAIL_BYTES) lines.shift(); // first line is mid-sentence; never quote it
|
|
62
|
+
return lines;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Is this block line a tool call rather than prose? */
|
|
66
|
+
function isToolCall(body) {
|
|
67
|
+
return TOOL_CALL.test(body) || /\(MCP\)/.test(body);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Name a tool call for counting. Returns null when the body is prose. */
|
|
71
|
+
function toolName(body) {
|
|
72
|
+
const direct = body.match(TOOL_CALL);
|
|
73
|
+
if (direct) return direct[1];
|
|
74
|
+
if (/\(MCP\)/.test(body)) {
|
|
75
|
+
const mcp = body.match(MCP_CALL);
|
|
76
|
+
if (mcp) return `${mcp[1]}/${mcp[2]}`;
|
|
77
|
+
return 'mcp';
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parser 1 — the agent's own last words, verbatim.
|
|
84
|
+
* No paraphrase: a summary would be another model call per session, and a paraphrase that
|
|
85
|
+
* drifts from what the agent actually said is the defect the leg-A grilling caught.
|
|
86
|
+
*/
|
|
87
|
+
function lastWords(lines) {
|
|
88
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
89
|
+
const m = lines[i].match(BLOCK);
|
|
90
|
+
if (!m || isToolCall(m[1])) continue;
|
|
91
|
+
const parts = [m[1].trim()];
|
|
92
|
+
// prose wraps onto indented continuation lines; stop at the next marker or a blank line
|
|
93
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
94
|
+
const nxt = lines[j];
|
|
95
|
+
if (!nxt.trim() || BLOCK.test(nxt) || RESULT.test(nxt) || /^\s*[❯>]\s/.test(nxt)) break;
|
|
96
|
+
if (!/^\s{1,6}\S/.test(nxt)) break;
|
|
97
|
+
parts.push(nxt.trim());
|
|
98
|
+
}
|
|
99
|
+
const text = parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
100
|
+
if (text) return text;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Parser 3 — tool counts across the retained tail. */
|
|
106
|
+
function toolCounts(lines) {
|
|
107
|
+
const counts = {};
|
|
108
|
+
let total = 0;
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
const m = line.match(BLOCK);
|
|
111
|
+
if (!m) continue;
|
|
112
|
+
const name = toolName(m[1]);
|
|
113
|
+
if (!name) continue;
|
|
114
|
+
counts[name] = (counts[name] || 0) + 1;
|
|
115
|
+
total++;
|
|
116
|
+
}
|
|
117
|
+
return total ? { counts, total } : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Parser 4 — model + effort, only if the CLI printed them. Never guessed. */
|
|
121
|
+
function modelInfo(text) {
|
|
122
|
+
const m = text.match(MODEL);
|
|
123
|
+
if (!m) return null;
|
|
124
|
+
const e = text.match(EFFORT);
|
|
125
|
+
return { model: m[0], effort: e ? e[1].toLowerCase() : null };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Parser 5 — context utilisation.
|
|
130
|
+
* Reported ONLY as a percentage the CLI itself printed. We never compute one: the window size
|
|
131
|
+
* is not in the scrollback, and a percentage of an unknown denominator is not a quantity
|
|
132
|
+
* (PRD R3). If the CLI never printed one, this signal is absent — which is the common case.
|
|
133
|
+
*/
|
|
134
|
+
function contextPct(text) {
|
|
135
|
+
const m = text.match(CONTEXT_PCT);
|
|
136
|
+
if (!m) return null;
|
|
137
|
+
const raw = Number(m[1] != null ? m[1] : m[2]);
|
|
138
|
+
if (!Number.isFinite(raw) || raw < 0 || raw > 100) return null;
|
|
139
|
+
return { pct: raw, source: 'printed by the CLI' };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse the four scrollback signals for one session.
|
|
144
|
+
* Parser 2 (quiet timing) is NOT here — it is a server-side timestamp (`lastOutputAt`), not a
|
|
145
|
+
* parse, which is why it is the only one of the five that cannot lie about what it read.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} scrubbed buffer already through scrub() — ANSI stripped, secrets redacted
|
|
148
|
+
* @param {string} agent 'claude' | 'codex' | 'shell'
|
|
149
|
+
* @returns {{lastWords:string|null, tools:object|null, model:object|null, context:object|null,
|
|
150
|
+
* missing:Record<string,string>}}
|
|
151
|
+
*/
|
|
152
|
+
function parseHarness(scrubbed, agent) {
|
|
153
|
+
const out = { lastWords: null, tools: null, model: null, context: null, missing: {} };
|
|
154
|
+
|
|
155
|
+
if (agent !== 'claude') {
|
|
156
|
+
out.missing.lastWords = `can't read its last words — ${NOT_CLAUDE}`;
|
|
157
|
+
out.missing.tools = NOT_CLAUDE;
|
|
158
|
+
out.missing.model = NOT_CLAUDE;
|
|
159
|
+
out.missing.context = NOT_CLAUDE;
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const text = String(scrubbed || '');
|
|
164
|
+
const lines = tailLines(text);
|
|
165
|
+
const anyBlock = lines.some((l) => BLOCK.test(l));
|
|
166
|
+
|
|
167
|
+
if (!anyBlock) {
|
|
168
|
+
// The single most important branch in this file: nothing recognisable was found, so every
|
|
169
|
+
// signal says so. This is what the forced-failure test exercises.
|
|
170
|
+
out.missing.lastWords = UNREADABLE;
|
|
171
|
+
out.missing.tools = 'no recognisable tool calls in the retained scrollback';
|
|
172
|
+
out.missing.model = 'the CLI has not printed a model name';
|
|
173
|
+
out.missing.context = 'the CLI has not printed a context percentage';
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
out.lastWords = lastWords(lines);
|
|
178
|
+
if (!out.lastWords) out.missing.lastWords = "can't read its last words — no prose block yet";
|
|
179
|
+
|
|
180
|
+
out.tools = toolCounts(lines);
|
|
181
|
+
if (!out.tools) out.missing.tools = 'no tool calls in the retained scrollback';
|
|
182
|
+
|
|
183
|
+
const tailText = lines.join('\n');
|
|
184
|
+
out.model = modelInfo(tailText);
|
|
185
|
+
if (!out.model) out.missing.model = 'the CLI has not printed a model name';
|
|
186
|
+
|
|
187
|
+
out.context = contextPct(tailText);
|
|
188
|
+
if (!out.context) out.missing.context = 'the CLI has not printed a context percentage';
|
|
189
|
+
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Parser 2 — quiet timing, derived from a timestamp rather than a parse.
|
|
195
|
+
* `quiet` means EXACTLY "no bytes for >= QUIET_AFTER_MS". It does NOT mean "waiting on you":
|
|
196
|
+
* quiescence proves quiet, not waiting (PRD R1). The word in the UI must stay "quiet".
|
|
197
|
+
*
|
|
198
|
+
* `failed` deliberately does not exist. The server knows only working|exited; deriving failure
|
|
199
|
+
* by grepping scrollback for "error" would install a watermelon generator in the product whose
|
|
200
|
+
* thesis is anti-watermelon.
|
|
201
|
+
*/
|
|
202
|
+
function deriveState(state, lastOutputAt, now) {
|
|
203
|
+
if (state === 'exited') return { state: 'exited', quietMs: null };
|
|
204
|
+
if (!lastOutputAt) return { state: 'working', quietMs: null }; // no timestamp ⇒ no claim
|
|
205
|
+
const quietMs = Math.max(0, now - lastOutputAt);
|
|
206
|
+
return quietMs >= module.exports.QUIET_AFTER_MS
|
|
207
|
+
? { state: 'quiet', quietMs }
|
|
208
|
+
: { state: 'working', quietMs };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = {
|
|
212
|
+
parseHarness,
|
|
213
|
+
deriveState,
|
|
214
|
+
// Provisional, and knowingly so. It must exceed the longest gap a WORKING claude session can
|
|
215
|
+
// go without emitting a byte; the spinner redraws continuously, so real gaps are sub-second.
|
|
216
|
+
// 30s is a deliberate over-estimate: a false "quiet" is the failure that matters, because the
|
|
217
|
+
// whole feature exists to stop the PM mistaking blocked for busy. Needs one field measurement
|
|
218
|
+
// (the byte rate of a working session) to tighten — carried as an open question in the PRD.
|
|
219
|
+
QUIET_AFTER_MS: 30_000,
|
|
220
|
+
TAIL_BYTES,
|
|
221
|
+
_internal: { lastWords, toolCounts, modelInfo, contextPct, tailLines, isToolCall, toolName },
|
|
222
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/* preview-kind — the SINGLE classifier the reader trusts (workspace-reader-preview).
|
|
2
|
+
*
|
|
3
|
+
* One source of truth for "what can the reader do with this file", used on BOTH sides:
|
|
4
|
+
* - server.js requires it (readTextDoc allowlist, /api/asset MIME, tree() kind flag)
|
|
5
|
+
* - the browser loads it as /lib/preview-kind.js before app.js (window.PreviewKind)
|
|
6
|
+
* so the tree flag, the read endpoints, and the renderer can never disagree (R1/R2/R8).
|
|
7
|
+
*
|
|
8
|
+
* The one security-critical function is isRepoRelativeSrc(): it decides whether a markdown
|
|
9
|
+
* image renders inline (repo-relative, served through the guarded /api/asset) or stays
|
|
10
|
+
* downgraded to alt-text (remote / data: / any scheme). That single boolean is the
|
|
11
|
+
* no-remote-image-beacon invariant (R4) — a poisoned `` must never
|
|
12
|
+
* cause a fetch on view. It is pure, so it is unit-tested directly.
|
|
13
|
+
*/
|
|
14
|
+
'use strict';
|
|
15
|
+
(function (root, factory) {
|
|
16
|
+
const api = factory();
|
|
17
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
18
|
+
else root.PreviewKind = api;
|
|
19
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
20
|
+
// Textual formats that ARE markdown — rendered through the markdown pipeline. `.skill` is
|
|
21
|
+
// markdown + YAML frontmatter, so it renders like any concept doc (R1).
|
|
22
|
+
const MARKDOWN_EXT = new Set(['md', 'markdown', 'mdown', 'mkd', 'mdx', 'skill']);
|
|
23
|
+
|
|
24
|
+
// Textual formats shown as ESCAPED source (never executed, R2). Broad on purpose — every entry
|
|
25
|
+
// is escaped text, so breadth adds no risk (PRD open-question resolved: broad allowlist).
|
|
26
|
+
const TEXT_EXT = new Set([
|
|
27
|
+
'txt', 'text', 'log', 'csv', 'tsv', 'json', 'jsonc', 'json5', 'ndjson',
|
|
28
|
+
'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'config', 'properties', 'env', 'example',
|
|
29
|
+
'xml', 'rss', 'atom', // NB: .svg is classified 'image' (IMAGE_MIME, checked first) — it renders, not source
|
|
30
|
+
'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat',
|
|
31
|
+
'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'css', 'scss', 'sass', 'less',
|
|
32
|
+
'sql', 'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'c', 'h', 'cc', 'cpp', 'hpp',
|
|
33
|
+
'cs', 'php', 'pl', 'pm', 'lua', 'r', 'swift', 'scala', 'clj', 'ex', 'exs', 'erl',
|
|
34
|
+
'diff', 'patch', 'lock', 'gitignore', 'gitattributes', 'editorconfig', 'npmrc', 'nvmrc',
|
|
35
|
+
'dockerfile', 'makefile', 'gradle', 'graphql', 'gql', 'proto', 'tf', 'hcl',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const HTML_EXT = new Set(['html', 'htm']);
|
|
39
|
+
|
|
40
|
+
// Raster/vector formats servable through /api/asset as an <img>. Loaded via <img>, SVG scripts
|
|
41
|
+
// do not execute; /api/asset additionally sends nosniff + a script-blocking CSP for direct hits.
|
|
42
|
+
const IMAGE_MIME = {
|
|
43
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
44
|
+
webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif', bmp: 'image/bmp',
|
|
45
|
+
ico: 'image/x-icon', apng: 'image/apng',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Extension-less files that are really text (open as source).
|
|
49
|
+
const TEXT_BASENAMES = new Set(['dockerfile', 'makefile', 'license', 'licence', 'authors',
|
|
50
|
+
'notice', 'copying', 'changelog', 'procfile', 'brewfile', 'gemfile', 'rakefile', 'vagrantfile']);
|
|
51
|
+
|
|
52
|
+
/** lowercase extension without the dot; '' when there is none. Dotfiles (.gitignore) → 'gitignore'. */
|
|
53
|
+
function extOf(name) {
|
|
54
|
+
const base = String(name || '').split(/[\\/]/).pop().toLowerCase();
|
|
55
|
+
if (!base) return '';
|
|
56
|
+
const dot = base.lastIndexOf('.');
|
|
57
|
+
if (dot < 0) return ''; // no dot at all
|
|
58
|
+
if (dot === 0) return base.slice(1); // dotfile: whole thing after the leading dot
|
|
59
|
+
return base.slice(dot + 1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** name/path → 'markdown' | 'text' | 'html' | 'image' | 'inert'. */
|
|
63
|
+
function previewKind(name) {
|
|
64
|
+
const base = String(name || '').split(/[\\/]/).pop().toLowerCase();
|
|
65
|
+
const ext = extOf(base);
|
|
66
|
+
if (MARKDOWN_EXT.has(ext)) return 'markdown';
|
|
67
|
+
if (HTML_EXT.has(ext)) return 'html';
|
|
68
|
+
// an image extension wins over its text twin (.svg) only when there is a real extension;
|
|
69
|
+
// opening an .svg as a DOC still shows source (TEXT_EXT), inline ![]() treats it as an image.
|
|
70
|
+
if (Object.prototype.hasOwnProperty.call(IMAGE_MIME, ext)) return 'image';
|
|
71
|
+
if (TEXT_EXT.has(ext)) return 'text';
|
|
72
|
+
if (!ext && TEXT_BASENAMES.has(base)) return 'text';
|
|
73
|
+
return 'inert';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** image MIME for /api/asset, or null when the extension is not an allowlisted image. */
|
|
77
|
+
function imageMime(name) {
|
|
78
|
+
return IMAGE_MIME[extOf(name)] || null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** a markdown-body kind served as escaped source (vs. rendered markdown). */
|
|
82
|
+
function isTextSource(kind) { return kind === 'text'; }
|
|
83
|
+
|
|
84
|
+
/** THE no-beacon invariant (R4). true ⇒ inline via the guarded /api/asset; false ⇒ downgrade
|
|
85
|
+
* to alt-text and never fetch. Anything with a scheme (http:, https:, data:, blob:, mailto:),
|
|
86
|
+
* protocol-relative //host, or a bare #anchor is treated as NON-repo-relative. */
|
|
87
|
+
function isRepoRelativeSrc(src) {
|
|
88
|
+
const s = String(src == null ? '' : src).trim();
|
|
89
|
+
if (!s) return false;
|
|
90
|
+
if (s.startsWith('#')) return false; // an in-page anchor is not an image file
|
|
91
|
+
if (/^\/\//.test(s)) return false; // protocol-relative → remote
|
|
92
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(s)) return false; // any URI scheme (http:, data:, blob:, mailto:)
|
|
93
|
+
return true; // a plain relative / root-relative repo path
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { previewKind, imageMime, isTextSource, isRepoRelativeSrc, extOf,
|
|
97
|
+
MARKDOWN_EXT, TEXT_EXT, HTML_EXT, IMAGE_MIME };
|
|
98
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* A tiny, pure token-bucket rate limiter (D64 / pivot-workspace-first, Track A3).
|
|
4
|
+
*
|
|
5
|
+
* The WS control messages that SPAWN shells ({t:'new'}, {t:'respawn'}) and FEED a PTY ({t:'input'})
|
|
6
|
+
* are the abuse surface if the socket is ever reached by something hostile. A per-connection bucket
|
|
7
|
+
* bounds that abuse while staying invisible to a human at an interactive terminal: the input bucket
|
|
8
|
+
* is sized so ordinary typing (and normal pastes) never trip it, and the spawn bucket allows a small
|
|
9
|
+
* burst then drips.
|
|
10
|
+
*
|
|
11
|
+
* Pure + time-injectable so the limiter is unit-testable without sleeping. No I/O, no globals.
|
|
12
|
+
*/
|
|
13
|
+
function createBucket(capacity, refillPerSec, now = Date.now()) {
|
|
14
|
+
return { capacity, tokens: capacity, refillPerSec, last: now };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Try to spend `n` tokens. Refills continuously from elapsed wall-time. Returns true if allowed.
|
|
18
|
+
function take(bucket, n = 1, now = Date.now()) {
|
|
19
|
+
const elapsed = Math.max(0, now - bucket.last) / 1000;
|
|
20
|
+
bucket.tokens = Math.min(bucket.capacity, bucket.tokens + elapsed * bucket.refillPerSec);
|
|
21
|
+
bucket.last = now;
|
|
22
|
+
if (bucket.tokens >= n) { bucket.tokens -= n; return true; }
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { createBucket, take };
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* Remote (tailnet) access — the transport half of workspace-remote-tailnet.
|
|
4
|
+
*
|
|
5
|
+
* D62 is the locked security boundary: the workspace server owns real PTYs running the PM's
|
|
6
|
+
* Claude subscription, so it must NEVER be reachable from a public interface. This module makes
|
|
7
|
+
* "reach the SAME local server from my phone" possible over a PRIVATE Tailscale tailnet only:
|
|
8
|
+
*
|
|
9
|
+
* 1. Bind resolution — default is loopback ONLY (unchanged). With the opt-in remote flag on, the
|
|
10
|
+
* server binds loopback PLUS the machine's own tailnet IPv4 (Tailscale CGNAT 100.64.0.0/10)
|
|
11
|
+
* and NOTHING else. Any requested bind that is not loopback and not in 100.64.0.0/10 (e.g.
|
|
12
|
+
* 0.0.0.0, ::, a LAN or public IP) makes resolveBinds REFUSE — the server won't start. There
|
|
13
|
+
* is no code path that yields a public bind address.
|
|
14
|
+
* 2. Token guard — when bound beyond loopback a bearer token is REQUIRED. Every non-loopback peer
|
|
15
|
+
* (HTTP request or WS upgrade) must present it; loopback peers stay token-free for local dev.
|
|
16
|
+
* Comparison is constant-time. The token is generated if not supplied and printed ONCE at
|
|
17
|
+
* startup — never logged thereafter, and request headers are never logged (D08).
|
|
18
|
+
*
|
|
19
|
+
* Pure and side-effect-light so the guard logic is unit-testable without a live tailnet peer.
|
|
20
|
+
*/
|
|
21
|
+
const crypto = require('crypto');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
const { spawnSync } = require('child_process');
|
|
24
|
+
|
|
25
|
+
const LOOPBACK = '127.0.0.1';
|
|
26
|
+
|
|
27
|
+
// Strip an IPv4-mapped IPv6 prefix (::ffff:100.64.0.1) so one matcher handles both stacks.
|
|
28
|
+
function unmap(addr) { return String(addr || '').replace(/^::ffff:/i, ''); }
|
|
29
|
+
|
|
30
|
+
function parseIPv4(addr) {
|
|
31
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(unmap(addr));
|
|
32
|
+
if (!m) return null;
|
|
33
|
+
const o = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
|
|
34
|
+
return o.some((n) => n > 255) ? null : o;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Loopback = 127.0.0.0/8 (IPv4) or ::1 (IPv6). These peers are token-exempt.
|
|
38
|
+
function isLoopbackAddr(addr) {
|
|
39
|
+
if (!addr) return false;
|
|
40
|
+
if (unmap(addr) === '::1') return true;
|
|
41
|
+
const o = parseIPv4(addr);
|
|
42
|
+
return !!o && o[0] === 127;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The Tailscale tailnet lives in the CGNAT block 100.64.0.0/10 → first octet 100, second 64..127.
|
|
46
|
+
// This is the ONLY non-loopback range the server is ever permitted to bind or trust.
|
|
47
|
+
function isTailnetIp(addr) {
|
|
48
|
+
const o = parseIPv4(addr);
|
|
49
|
+
return !!o && o[0] === 100 && o[1] >= 64 && o[1] <= 127;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Discover this machine's own tailnet IPv4. Prefers `tailscale ip -4`; falls back to scanning the
|
|
53
|
+
// network interfaces for a 100.64.0.0/10 address; honours an explicit PMOS_TAILNET_IP override.
|
|
54
|
+
// Injectable deps keep it testable and keep the spawn out of the non-remote (default/test) path.
|
|
55
|
+
function detectTailnetIp({ env = process.env, netInterfaces = os.networkInterfaces, exec = spawnSync } = {}) {
|
|
56
|
+
const override = (env.PMOS_TAILNET_IP || '').trim();
|
|
57
|
+
if (override) return isTailnetIp(override) ? override : null; // an explicit non-tailnet override is a mistake, not a fallback
|
|
58
|
+
try {
|
|
59
|
+
const r = exec('tailscale', ['ip', '-4'], { encoding: 'utf8', timeout: 4000 });
|
|
60
|
+
if (r && r.status === 0 && r.stdout) {
|
|
61
|
+
const ip = String(r.stdout).trim().split(/\s+/).find((x) => isTailnetIp(x));
|
|
62
|
+
if (ip) return ip;
|
|
63
|
+
}
|
|
64
|
+
} catch { /* tailscale not installed — fall through to the interface scan */ }
|
|
65
|
+
let ifaces = {};
|
|
66
|
+
try { ifaces = netInterfaces() || {}; } catch { ifaces = {}; }
|
|
67
|
+
for (const list of Object.values(ifaces)) {
|
|
68
|
+
for (const ni of (list || [])) {
|
|
69
|
+
const fam = ni && ni.family;
|
|
70
|
+
if ((fam === 'IPv4' || fam === 4) && isTailnetIp(ni.address)) return ni.address;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function remoteRequested(env) {
|
|
77
|
+
return env.PMOS_REMOTE === 'tailnet'
|
|
78
|
+
|| env.PMOS_BIND_TAILNET === '1' || env.PMOS_BIND_TAILNET === 'true' || env.PMOS_BIND_TAILNET === 'yes';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The one function that decides what the server may bind. Fail-closed: anything that is not
|
|
82
|
+
// loopback or a tailnet address REFUSES (the server exits), so a public bind is unreachable by
|
|
83
|
+
// construction. Returns {remote, binds, token, tailnetIp, generated} or {remote, refused:<msg>}.
|
|
84
|
+
function resolveBinds({ env = process.env, detect = detectTailnetIp } = {}) {
|
|
85
|
+
if (!remoteRequested(env)) {
|
|
86
|
+
return { remote: false, binds: [LOOPBACK], token: null, tailnetIp: null, generated: false };
|
|
87
|
+
}
|
|
88
|
+
// An explicit bind address, when given, must itself be loopback or tailnet — never public.
|
|
89
|
+
const requested = (env.PMOS_BIND_ADDR || '').trim();
|
|
90
|
+
if (requested && !isLoopbackAddr(requested) && !isTailnetIp(requested)) {
|
|
91
|
+
return { remote: true, refused:
|
|
92
|
+
`refusing to bind "${requested}": only loopback or a Tailscale tailnet address (100.64.0.0/10) is permitted — never a public/default-route interface (D62).` };
|
|
93
|
+
}
|
|
94
|
+
const ip = (requested && isTailnetIp(requested)) ? requested : detect({ env });
|
|
95
|
+
if (!ip) {
|
|
96
|
+
return { remote: true, refused:
|
|
97
|
+
'PMOS_REMOTE=tailnet is set but no Tailscale tailnet address (100.64.0.0/10) was found. Start Tailscale (`tailscale up`) or set PMOS_TAILNET_IP to your tailnet IP. Refusing to bind any non-tailnet interface (D62).' };
|
|
98
|
+
}
|
|
99
|
+
if (!isTailnetIp(ip)) { // belt-and-suspenders: detect must only ever yield a tailnet IP
|
|
100
|
+
return { remote: true, refused:
|
|
101
|
+
`refusing to bind "${ip}": not within the tailnet range 100.64.0.0/10 — never a public interface (D62).` };
|
|
102
|
+
}
|
|
103
|
+
const supplied = (env.PMOS_TOKEN || '').trim();
|
|
104
|
+
const token = supplied || crypto.randomBytes(32).toString('base64url');
|
|
105
|
+
return { remote: true, binds: [LOOPBACK, ip], token, tailnetIp: ip, generated: !supplied };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Token extraction ──────────────────────────────────────────────────────────────────
|
|
109
|
+
// HTTP: Authorization: Bearer <t>. (Node lowercases header names.)
|
|
110
|
+
function extractHttpToken(headers = {}) {
|
|
111
|
+
const h = headers.authorization || headers.Authorization || '';
|
|
112
|
+
const m = /^Bearer\s+(.+)$/i.exec(String(h));
|
|
113
|
+
return m ? m[1].trim() : null;
|
|
114
|
+
}
|
|
115
|
+
// WS upgrade: a browser cannot set headers on a WebSocket, so accept, in order, an Authorization
|
|
116
|
+
// header (non-browser clients), a ?token=/?access_token= query param, or a `bearer,<t>` subprotocol.
|
|
117
|
+
function extractWsToken(req = {}) {
|
|
118
|
+
const headers = req.headers || {};
|
|
119
|
+
const fromHeader = extractHttpToken(headers);
|
|
120
|
+
if (fromHeader) return fromHeader;
|
|
121
|
+
try {
|
|
122
|
+
const u = new URL(req.url || '', 'http://localhost');
|
|
123
|
+
const q = u.searchParams.get('token') || u.searchParams.get('access_token');
|
|
124
|
+
if (q) return q.trim();
|
|
125
|
+
} catch { /* malformed URL → no token */ }
|
|
126
|
+
const proto = headers['sec-websocket-protocol'];
|
|
127
|
+
if (proto) {
|
|
128
|
+
const parts = String(proto).split(',').map((s) => s.trim()).filter(Boolean);
|
|
129
|
+
const i = parts.findIndex((p) => /^bearer$/i.test(p));
|
|
130
|
+
if (i >= 0 && parts[i + 1]) return parts[i + 1];
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Origin / Host allowlist (D64 / pivot-workspace-first, Track A1) ─────────────────────
|
|
136
|
+
// The threat: the server owns real PTYs and, for loopback peers, is token-EXEMPT. Without an
|
|
137
|
+
// Origin/Host check, any website the PM has open in their browser can open a WebSocket to
|
|
138
|
+
// ws://127.0.0.1:<port> and send {t:'new'}+{t:'input'} to spawn a shell and type into it — a
|
|
139
|
+
// cross-origin-localhost / DNS-rebinding remote-code-execution vector. These predicates close it:
|
|
140
|
+
// • a same-origin browser fetch/WS (served BY this server) sends an Origin equal to the Host it
|
|
141
|
+
// arrived on — allowed;
|
|
142
|
+
// • a cross-site page sends its OWN (foreign) Origin — rejected;
|
|
143
|
+
// • a DNS-rebinding attack forces a foreign Host header — rejected (the Host hostname must be
|
|
144
|
+
// loopback/localhost or a tailnet address, never an attacker's domain);
|
|
145
|
+
// • a native client / curl sends no Origin — allowed here, then still gated by the token guard.
|
|
146
|
+
// Port-agnostic on purpose: the server may listen on any port, so an Origin is compared to the Host
|
|
147
|
+
// it arrived on (true same-origin) rather than to a hard-coded port. Header VALUES are never logged.
|
|
148
|
+
function hostnameOf(authority) {
|
|
149
|
+
let h = String(authority == null ? '' : authority).trim().toLowerCase();
|
|
150
|
+
if (!h) return '';
|
|
151
|
+
const scheme = h.indexOf('://');
|
|
152
|
+
if (scheme !== -1) h = h.slice(scheme + 3);
|
|
153
|
+
if (h[0] === '[') { const i = h.indexOf(']'); return i === -1 ? h.slice(1) : h.slice(1, i); }
|
|
154
|
+
return h.split('/')[0].split(':')[0];
|
|
155
|
+
}
|
|
156
|
+
function authorityOf(value) {
|
|
157
|
+
let h = String(value == null ? '' : value).trim().toLowerCase();
|
|
158
|
+
if (!h || h === 'null') return '';
|
|
159
|
+
const scheme = h.indexOf('://');
|
|
160
|
+
if (scheme !== -1) h = h.slice(scheme + 3);
|
|
161
|
+
return h.split('/')[0]; // host[:port]
|
|
162
|
+
}
|
|
163
|
+
function hostnameAllowed(authority, binds) {
|
|
164
|
+
const h = hostnameOf(authority);
|
|
165
|
+
if (!h) return false;
|
|
166
|
+
if (h === 'localhost') return true;
|
|
167
|
+
if (isLoopbackAddr(h)) return true;
|
|
168
|
+
for (const b of (binds || [])) if (isTailnetIp(b) && unmap(h) === unmap(b)) return true;
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
// The single decision, called from BOTH the HTTP guard and the WS-upgrade guard. Returns true when
|
|
172
|
+
// the request is allowed to proceed (it may still be rejected by the token guard afterwards).
|
|
173
|
+
function checkHostOrigin({ origin, host, binds }) {
|
|
174
|
+
// 1. If a Host header is present, its hostname must be loopback/localhost/tailnet (anti-rebinding).
|
|
175
|
+
if (host != null && host !== '') {
|
|
176
|
+
if (!hostnameAllowed(host, binds)) return false;
|
|
177
|
+
}
|
|
178
|
+
// 2. If an Origin header is present, it must be same-origin with the Host it arrived on, and its
|
|
179
|
+
// hostname must itself be in the allowlist. "null"/opaque/garbage Origins are rejected.
|
|
180
|
+
if (origin != null && origin !== '') {
|
|
181
|
+
const oa = authorityOf(origin);
|
|
182
|
+
if (!oa) return false;
|
|
183
|
+
const ha = authorityOf(host);
|
|
184
|
+
if (ha && oa !== ha) return false; // cross-origin → reject
|
|
185
|
+
if (!hostnameAllowed(oa, binds)) return false; // Host absent but Origin foreign → reject
|
|
186
|
+
}
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
function isAllowedOrigin(origin, { host, binds } = {}) { return checkHostOrigin({ origin, host, binds }); }
|
|
190
|
+
|
|
191
|
+
// Constant-time token comparison. Length is checked first (timingSafeEqual throws on a length
|
|
192
|
+
// mismatch); a differing length is already a non-match, so this leaks nothing useful.
|
|
193
|
+
function tokenMatches(presented, expected) {
|
|
194
|
+
if (!expected || !presented) return false;
|
|
195
|
+
const a = Buffer.from(String(presented));
|
|
196
|
+
const b = Buffer.from(String(expected));
|
|
197
|
+
if (a.length !== b.length) return false;
|
|
198
|
+
try { return crypto.timingSafeEqual(a, b); } catch { return false; }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The single authorization decision, PEER-based (what matters is where the request came FROM, not
|
|
202
|
+
// which listener it hit). Loopback is always exempt; every non-loopback peer must present a valid
|
|
203
|
+
// bearer token. Fail-closed: with no token configured, a non-loopback peer is rejected.
|
|
204
|
+
function authorize({ remoteAddress, presentedToken, expectedToken }) {
|
|
205
|
+
if (isLoopbackAddr(remoteAddress)) return true;
|
|
206
|
+
return tokenMatches(presentedToken, expectedToken);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The startup banner lines. This is the ONE place a token may be printed, and ONLY a freshly
|
|
210
|
+
// GENERATED token — a token supplied via PMOS_TOKEN is the PM's own secret and is never echoed.
|
|
211
|
+
// Nothing here (or anywhere in this module) logs request headers (D08).
|
|
212
|
+
function startupLines(bind, port) {
|
|
213
|
+
if (!bind || !bind.remote) {
|
|
214
|
+
return [`pmos workspace server — http://${LOOPBACK}:${port} (loopback only, D62)`];
|
|
215
|
+
}
|
|
216
|
+
const lines = [
|
|
217
|
+
'pmos workspace server — REMOTE (tailnet) mode',
|
|
218
|
+
` local: http://${LOOPBACK}:${port}`,
|
|
219
|
+
` tailnet: http://${bind.tailnetIp}:${port} (private WireGuard net, device-authenticated, never public — D62)`,
|
|
220
|
+
];
|
|
221
|
+
if (bind.generated) {
|
|
222
|
+
lines.push(` token (generated — shown ONCE, copy it now): ${bind.token}`);
|
|
223
|
+
lines.push(' attach with header Authorization: Bearer <token> or query ?token=<token>');
|
|
224
|
+
} else {
|
|
225
|
+
lines.push(' token: using PMOS_TOKEN from the environment (not shown)');
|
|
226
|
+
}
|
|
227
|
+
lines.push(' loopback requests are token-free; every tailnet request must present the token.');
|
|
228
|
+
return lines;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = {
|
|
232
|
+
LOOPBACK, isLoopbackAddr, isTailnetIp, detectTailnetIp, remoteRequested,
|
|
233
|
+
resolveBinds, extractHttpToken, extractWsToken, tokenMatches, authorize, startupLines,
|
|
234
|
+
checkHostOrigin, isAllowedOrigin, hostnameAllowed,
|
|
235
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* CANONICAL Review-Gate pass rule — the single source of truth (D64 / pivot-workspace-first, Track D2).
|
|
4
|
+
*
|
|
5
|
+
* The rule is: an explicit verdict DECIDES (pass iff it starts with "pass"); the score is a
|
|
6
|
+
* fallback used ONLY when no verdict was recorded (score >= 0.85).
|
|
7
|
+
*
|
|
8
|
+
* AMENDED 2026-07-26 (blind-plant round 2 finding). The rule was previously
|
|
9
|
+
* `verdict ILIKE 'pass%' OR score >= 0.85`, and that disjunction was a real leniency hole: a run the
|
|
10
|
+
* evaluator explicitly FAILED still passed the gate if its weighted score happened to clear 0.85.
|
|
11
|
+
* Round 2 hit it live — specimen `be58` came back verdict=FAIL score=0.93 and the old rule counted it
|
|
12
|
+
* pass-side, overriding a FAIL issued under the two-axis rule ("a FAIL on either axis cannot become a
|
|
13
|
+
* PASS"). A gate whose plumbing can overturn its own judge is not a gate. See
|
|
14
|
+
* planning/evals/blind-plants/2026-07-26-round2/RESULTS.md.
|
|
15
|
+
*
|
|
16
|
+
* Note the fix is NOT a blunt OR->AND: that would wrongly fail a legitimate `PASS` verdict carrying no
|
|
17
|
+
* recorded score. Verdict-authoritative-with-score-fallback preserves every other case and changes
|
|
18
|
+
* exactly the one that was broken.
|
|
19
|
+
*
|
|
20
|
+
* It was previously duplicated verbatim across SQL views, TS, the workspace JS, and metrics.py, so a
|
|
21
|
+
* change in one place silently drifted the others. It now lives HERE and is mirrored in exactly one
|
|
22
|
+
* other place — kit/scripts/metrics.py's `review_gate_pass()` — with a comment pointing back here.
|
|
23
|
+
* kit/fixtures/test_parsers.py runs BOTH implementations against kit/fixtures/review-gate-pass-rule.json
|
|
24
|
+
* and fails CI if they ever disagree, so the mirror can never drift unnoticed.
|
|
25
|
+
*/
|
|
26
|
+
const PASS_VERDICT_PREFIX = 'pass'; // ILIKE 'pass%' → lowercased startsWith('pass')
|
|
27
|
+
const SCORE_THRESHOLD = 0.85;
|
|
28
|
+
|
|
29
|
+
// A recorded verdict is AUTHORITATIVE: the run passes iff that verdict starts with "pass"
|
|
30
|
+
// (PASS / PASS-WITH-FINDINGS / …). An explicit FAIL is never overridden by a high score.
|
|
31
|
+
// Only when NO verdict was recorded (null / "" / whitespace) does the numeric score decide.
|
|
32
|
+
function isReviewPass(verdict, score) {
|
|
33
|
+
const v = String(verdict == null ? '' : verdict).trim().toLowerCase();
|
|
34
|
+
if (v) return v.startsWith(PASS_VERDICT_PREFIX);
|
|
35
|
+
const s = Number(score);
|
|
36
|
+
return Number.isFinite(s) && s >= SCORE_THRESHOLD;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { isReviewPass, PASS_VERDICT_PREFIX, SCORE_THRESHOLD };
|