dsh-vibe-math 2.5.0 → 2.7.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.en.md +175 -369
- package/README.md +175 -370
- package/cordis.patch.yml +78 -22
- package/docs/AUDIT-CHECKLIST.md +3 -3
- package/docs/final-paper.md +172 -0
- package/docs/formal-verification.md +184 -29
- package/docs/math-computation.md +121 -0
- package/docs/release-notes/RELEASE-NOTES-2.6.0.md +268 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.0.en.md +151 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.0.md +115 -0
- package/docs/test-timing.md +25 -9
- package/installer.js +7 -4
- package/package.json +21 -3
- package/prompt-corpus-persona/persona-corpus.json +16 -16
- package/prompt-corpus-persona/persona-corpus.md +164 -52
- package/prompt-corpus-v2/formal-verify-v2.json +128 -92
- package/prompt-corpus-v2/formal-verify-v2.md +1574 -108
- package/prompt-corpus-v3/formal-verify-v3.json +137 -53
- package/prompt-corpus-v3/formal-verify-v3.md +1406 -100
- package/prompt-corpus-v4/formal-verify-v4.json +14 -14
- package/prompt-corpus-v4/formal-verify-v4.md +247 -14
- package/prompt-corpus-v5/prompt-corpus-v5.json +62 -62
- package/prompt-corpus-v5/prompt-corpus-v5.md +570 -3
- package/tests/audit-math-computation-contract.mjs +191 -0
- package/tests/audit-math-computation-parity.mjs +169 -0
- package/tests/audit-math-computation-sensitivity.mjs +231 -0
- package/tests/audit-persona-sensitivity.mjs +64 -11
- package/tests/audit-persona-surface.test.mjs +53 -16
- package/tests/audit-v5-integrity.mjs +14 -0
- package/tests/e2e-v5-round2.test.mjs +923 -3
- package/tests/formal-verify-v2.test.mjs +295 -6
- package/tests/formal-verify-v3.test.mjs +275 -4
- package/tests/formal-verify-v4.test.mjs +295 -6
- package/tests/formal-verify-v5.test.mjs +9 -1
- package/tests/helpers/math-computation-fake-seam.mjs +135 -0
- package/tests/math-computation-shared.test.mjs +504 -0
- package/tests/prompt-v5-integrity.test.mjs +14 -8
- package/tests/selfdrive-v5.mjs +55 -4
- package/vibe-math-v2/agent.cordis.yml +36 -14
- package/vibe-math-v2/math-computation.js +936 -0
- package/vibe-math-v2/math-engines.js +156 -0
- package/vibe-math-v2/vibe-math-v2.js +1653 -48
- package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
- package/vibe-math-v3/agent.cordis.yml +30 -12
- package/vibe-math-v3/math-computation.js +936 -0
- package/vibe-math-v3/math-engines.js +156 -0
- package/vibe-math-v3/vibe-math-v3.js +1623 -53
- package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
- package/vibe-math-v4/agent.cordis.yml +44 -8
- package/vibe-math-v4/math-computation.js +936 -0
- package/vibe-math-v4/math-engines.js +156 -0
- package/vibe-math-v4/vibe-math-v4.js +1592 -37
- package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
- package/vibe-math-v5/agent.cordis.yml +46 -10
- package/vibe-math-v5/math-computation.js +936 -0
- package/vibe-math-v5/math-engines.js +156 -0
- package/vibe-math-v5/vibe-math-v5.js +2233 -42
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +153 -4
- package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +251 -0
|
@@ -52,6 +52,27 @@
|
|
|
52
52
|
// field — so the old projection-based primary made the user's own session unresumable.
|
|
53
53
|
export const inject = ['subagents', 'agents', 'fs', 'tools', 'commands', 'timer']
|
|
54
54
|
|
|
55
|
+
// ── math_computation (docs/math-computation.md) ──────────────────────────────
|
|
56
|
+
// The tool CORE lives in a shared module installed byte-identically into all four presets
|
|
57
|
+
// (`_oneoff/mc-P1-ready/INTERFACE-FREEZE.md` §3/§4): this preset only wires it (import +
|
|
58
|
+
// params + one registration + prompt text). The module imports `./math-engines.js` itself.
|
|
59
|
+
import {
|
|
60
|
+
MATH_PARAM_NAMES,
|
|
61
|
+
MATH_PARAM_DEFAULTS,
|
|
62
|
+
MATH_CAPS,
|
|
63
|
+
MATH_SHELL_FALLBACK_MARK,
|
|
64
|
+
MATH_TOOL_DESCRIPTION,
|
|
65
|
+
MATH_PERSONA_TOOL_LINE,
|
|
66
|
+
MATH_RULE_LINES,
|
|
67
|
+
MATH_RULE_LINES_EN,
|
|
68
|
+
MATH_ARCHIVE_WORKFLOW_LINE,
|
|
69
|
+
MATH_ARCHIVE_WORKFLOW_LINE_EN,
|
|
70
|
+
normalizeMathParams,
|
|
71
|
+
probeMathEngines,
|
|
72
|
+
registerMathComputation,
|
|
73
|
+
mathAvailabilityLine,
|
|
74
|
+
} from './math-computation.js'
|
|
75
|
+
|
|
55
76
|
const PROJECTION_VERSION = 1
|
|
56
77
|
const EV = {
|
|
57
78
|
institute: 'vibe5/institute',
|
|
@@ -68,6 +89,120 @@ const EV = {
|
|
|
68
89
|
formal: 'vibe5/formal',
|
|
69
90
|
}
|
|
70
91
|
|
|
92
|
+
// ── final paper (docs/final-paper.md) ─────────────────────────────────────────
|
|
93
|
+
// The run's LAST deliverable. The permanent staff each write their own part, cross-review
|
|
94
|
+
// each other's part, and the editor named by `paperEditor` finalises — but ONLY after the
|
|
95
|
+
// whole institute has agreed the parts are deliverable, and (when the office edits) only
|
|
96
|
+
// after the office has consulted the institute. The phase runs BEFORE the completion flags
|
|
97
|
+
// are flipped (docs/final-paper.md §3): after `phase='solved'` the machinery refuses to wake
|
|
98
|
+
// members (`wakeIfIdle`), to convene meetings (`startMeeting`) or to dispatch an end.
|
|
99
|
+
const PAPER_MAX_ROUNDS = 3
|
|
100
|
+
// Chinese first (docs/final-paper.md §8): xelatex covers CJK out of the box; English prefers pdflatex.
|
|
101
|
+
const PAPER_ENGINE_ORDER = {
|
|
102
|
+
zh: ['xelatex', 'latexmk', 'pdflatex', 'lualatex', 'tectonic'],
|
|
103
|
+
en: ['pdflatex', 'latexmk', 'lualatex', 'tectonic', 'xelatex'],
|
|
104
|
+
}
|
|
105
|
+
// The repair pass keeps only what the text itself needs; everything else (hyperref,
|
|
106
|
+
// longtable, booktabs, an unknown package) is dropped before the retry.
|
|
107
|
+
const PAPER_REPAIR_CORE = ['amsmath', 'amssymb', 'amsthm', 'geometry', 'ctex']
|
|
108
|
+
const PAPER_SECTIONS = [
|
|
109
|
+
'标题、作者、日期与摘要',
|
|
110
|
+
'引言与问题背景',
|
|
111
|
+
'原问题的完整解法',
|
|
112
|
+
'已检验通过的命题',
|
|
113
|
+
'已解决的子问题 / 中间成果',
|
|
114
|
+
'创造或发现的有价值之物:方法、理论、思想、经验与数学理解',
|
|
115
|
+
'规律总结',
|
|
116
|
+
'讨论、局限与展望',
|
|
117
|
+
'附录:证据与文件索引',
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
// ── Lean async compile queue + search path (docs/formal-verification.md §1/§5/§7) ──────────
|
|
121
|
+
// The queue is a per-session FIFO driven by the existing heartbeat, so the constants and the
|
|
122
|
+
// pure helpers live at module scope. Concurrency is a CONSTANT, not a parameter: compiles and
|
|
123
|
+
// the model compete for the same CPU, and the parameter surface deliberately gains only
|
|
124
|
+
// `leanAsync` (the per-compile budget reuses `leanTimeoutMs`).
|
|
125
|
+
const LEAN_QUEUE_CONCURRENCY = 1
|
|
126
|
+
// ONE place for the flag spelling: a toolchain that does not know `--search-path` only needs
|
|
127
|
+
// this constant changed (never the interface).
|
|
128
|
+
const LEAN_SEARCH_PATH_FLAG = '--search-path'
|
|
129
|
+
const LEAN_READ_MAX_BYTES = 64 * 1024
|
|
130
|
+
// A user-supplied search root wins: never inject a second one (explicit override first).
|
|
131
|
+
function leanHasSearchFlag(args) {
|
|
132
|
+
return (Array.isArray(args) ? args : []).some((a) => {
|
|
133
|
+
const t = String(a)
|
|
134
|
+
return t === LEAN_SEARCH_PATH_FLAG || t.indexOf(LEAN_SEARCH_PATH_FLAG + '=') === 0 || t === '-R' || t === '--root'
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
// Content identity for queue dedupe: CRLF normalised, trailing blank lines dropped.
|
|
138
|
+
function leanHashText(s) {
|
|
139
|
+
return String(s == null ? '' : s).replace(/\r\n?/g, '\n').replace(/[\s\n]+$/, '')
|
|
140
|
+
}
|
|
141
|
+
const SHA256_K = [
|
|
142
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
143
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
144
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
145
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
146
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
147
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
148
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
149
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
150
|
+
]
|
|
151
|
+
const sha256Rotr = (x, n) => ((x >>> n) | (x << (32 - n))) >>> 0
|
|
152
|
+
// SHA-256 (FIPS 180-4), implemented here instead of importing `node:crypto`: this preset is a
|
|
153
|
+
// self-contained file row with ZERO imports, and the digest is only a content identity (queue
|
|
154
|
+
// dedupe + job id). `__testHelpers.sha256Hex` is checked against the published vectors AND
|
|
155
|
+
// against `node:crypto` in the tests, so correctness is proven rather than assumed.
|
|
156
|
+
function sha256Hex(input) {
|
|
157
|
+
const text = String(input == null ? '' : input)
|
|
158
|
+
const bytes = []
|
|
159
|
+
for (let i = 0; i < text.length; i++) {
|
|
160
|
+
const c = text.charCodeAt(i)
|
|
161
|
+
if (c < 0x80) bytes.push(c)
|
|
162
|
+
else if (c < 0x800) bytes.push(0xc0 | (c >> 6), 0x80 | (c & 63))
|
|
163
|
+
else if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length && text.charCodeAt(i + 1) >= 0xdc00 && text.charCodeAt(i + 1) <= 0xdfff) {
|
|
164
|
+
const cp = 0x10000 + ((c - 0xd800) << 10) + (text.charCodeAt(i + 1) - 0xdc00)
|
|
165
|
+
i++
|
|
166
|
+
bytes.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 63), 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63))
|
|
167
|
+
} else bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63))
|
|
168
|
+
}
|
|
169
|
+
const bitLen = bytes.length * 8
|
|
170
|
+
bytes.push(0x80)
|
|
171
|
+
while (bytes.length % 64 !== 56) bytes.push(0)
|
|
172
|
+
const hi = Math.floor(bitLen / 0x100000000)
|
|
173
|
+
bytes.push((hi >>> 24) & 255, (hi >>> 16) & 255, (hi >>> 8) & 255, hi & 255)
|
|
174
|
+
bytes.push((bitLen >>> 24) & 255, (bitLen >>> 16) & 255, (bitLen >>> 8) & 255, bitLen & 255)
|
|
175
|
+
let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a
|
|
176
|
+
let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19
|
|
177
|
+
const w = new Array(64)
|
|
178
|
+
for (let off = 0; off < bytes.length; off += 64) {
|
|
179
|
+
for (let i = 0; i < 16; i++) {
|
|
180
|
+
const j = off + i * 4
|
|
181
|
+
w[i] = ((bytes[j] << 24) | (bytes[j + 1] << 16) | (bytes[j + 2] << 8) | bytes[j + 3]) >>> 0
|
|
182
|
+
}
|
|
183
|
+
for (let i = 16; i < 64; i++) {
|
|
184
|
+
const x = w[i - 15], y = w[i - 2]
|
|
185
|
+
const s0 = (sha256Rotr(x, 7) ^ sha256Rotr(x, 18) ^ (x >>> 3)) >>> 0
|
|
186
|
+
const s1 = (sha256Rotr(y, 17) ^ sha256Rotr(y, 19) ^ (y >>> 10)) >>> 0
|
|
187
|
+
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0
|
|
188
|
+
}
|
|
189
|
+
let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7
|
|
190
|
+
for (let i = 0; i < 64; i++) {
|
|
191
|
+
const S1 = (sha256Rotr(e, 6) ^ sha256Rotr(e, 11) ^ sha256Rotr(e, 25)) >>> 0
|
|
192
|
+
const ch = ((e & f) ^ (~e & g)) >>> 0
|
|
193
|
+
const t1 = (h + S1 + ch + SHA256_K[i] + w[i]) >>> 0
|
|
194
|
+
const S0 = (sha256Rotr(a, 2) ^ sha256Rotr(a, 13) ^ sha256Rotr(a, 22)) >>> 0
|
|
195
|
+
const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0
|
|
196
|
+
const t2 = (S0 + maj) >>> 0
|
|
197
|
+
h = g; g = f; f = e; e = (d + t1) >>> 0
|
|
198
|
+
d = c; c = b; b = a; a = (t1 + t2) >>> 0
|
|
199
|
+
}
|
|
200
|
+
h0 = (h0 + a) >>> 0; h1 = (h1 + b) >>> 0; h2 = (h2 + c) >>> 0; h3 = (h3 + d) >>> 0
|
|
201
|
+
h4 = (h4 + e) >>> 0; h5 = (h5 + f) >>> 0; h6 = (h6 + g) >>> 0; h7 = (h7 + h) >>> 0
|
|
202
|
+
}
|
|
203
|
+
return [h0, h1, h2, h3, h4, h5, h6, h7].map((x) => ('00000000' + x.toString(16)).slice(-8)).join('')
|
|
204
|
+
}
|
|
205
|
+
|
|
71
206
|
// Stable error codes (ported from DSH agent-teams' typed-error discipline).
|
|
72
207
|
function v5err(code, message) {
|
|
73
208
|
const e = new Error(message || code)
|
|
@@ -110,6 +245,15 @@ export function apply(ctx) {
|
|
|
110
245
|
const tools = ctx.tools
|
|
111
246
|
const commands = ctx.commands
|
|
112
247
|
|
|
248
|
+
// A plugin UNLOAD must not leave an orphan compiler behind (docs/formal-verification.md §7-6):
|
|
249
|
+
// every session registers its Lean disposer here, and this effect's disposer terminates what
|
|
250
|
+
// is in flight and marks it `interrupted` (never `passed`). Registered FIRST so a host that
|
|
251
|
+
// records effect disposers (the tests do) can reach it deterministically.
|
|
252
|
+
const leanDisposers = new Set()
|
|
253
|
+
ctx.effect(() => () => {
|
|
254
|
+
for (const d of Array.from(leanDisposers)) { try { d() } catch (e) { /* best effort */ } }
|
|
255
|
+
})
|
|
256
|
+
|
|
113
257
|
// Optional services are resolved LAZILY at call time, never snapshotted in apply():
|
|
114
258
|
// a `ctx.get()` snapshot taken here is order-sensitive, so a service provided later
|
|
115
259
|
// would stay undefined for the whole session.
|
|
@@ -189,6 +333,9 @@ export function apply(ctx) {
|
|
|
189
333
|
lastProgressAt: now(),
|
|
190
334
|
artifactCount: 0,
|
|
191
335
|
diagnostics: [],
|
|
336
|
+
// The final-paper flow (stage machine + its artifacts state). Durable like everything
|
|
337
|
+
// else, so a restart resumes the same stage instead of writing a second paper.
|
|
338
|
+
paper: null,
|
|
192
339
|
}
|
|
193
340
|
}
|
|
194
341
|
function initState() { return { v: PROJECTION_VERSION, institutes: {}, order: [] } }
|
|
@@ -228,6 +375,16 @@ export function apply(ctx) {
|
|
|
228
375
|
if (patch.runId !== undefined) n.runId = String(patch.runId)
|
|
229
376
|
if (patch.lastProgressAt !== undefined) n.lastProgressAt = Number(patch.lastProgressAt) || 0
|
|
230
377
|
if (patch.artifactCount !== undefined) n.artifactCount = Number(patch.artifactCount) || 0
|
|
378
|
+
// The final-paper state. A FUNCTION is a MUTATION applied inside the fold (the same
|
|
379
|
+
// trick the verify queue uses): the paper stage, its parts and its office-consultation
|
|
380
|
+
// counters are updated from several places (member replies, office messages, meetings),
|
|
381
|
+
// and a whole-object read-modify-write outside the fold would lose updates.
|
|
382
|
+
if (patch.paper !== undefined) {
|
|
383
|
+
const next = typeof patch.paper === 'function'
|
|
384
|
+
? patch.paper(n.paper === undefined ? null : n.paper)
|
|
385
|
+
: patch.paper
|
|
386
|
+
if (next !== undefined) n.paper = next
|
|
387
|
+
}
|
|
231
388
|
return n
|
|
232
389
|
})
|
|
233
390
|
}
|
|
@@ -510,6 +667,54 @@ export function apply(ctx) {
|
|
|
510
667
|
leanCommand: 'lean',
|
|
511
668
|
leanArgs: [],
|
|
512
669
|
leanTimeoutMs: 120000,
|
|
670
|
+
// leanAsync: compile on a per-session background queue (default) instead of blocking the
|
|
671
|
+
// member's turn. `false` restores the previous synchronous `await` verbatim. Only
|
|
672
|
+
// `settled(ok)` (exit 0 AND the file's content hash unchanged) may mark an object
|
|
673
|
+
// `passed`; every other ending stays `attempted`.
|
|
674
|
+
leanAsync: true,
|
|
675
|
+
// leanInitiative: how EAGER the staff are about formalizing during normal work, kept
|
|
676
|
+
// separate from `formalVerify` (which only expresses the VERDICT-time requirement).
|
|
677
|
+
// 'off' = no daily drive (formalize only when verification asks); 'normal' (default) =
|
|
678
|
+
// today's behaviour (the reminder rides with formalVerify); 'eager' = push valuable small
|
|
679
|
+
// lemmas/propositions/definitions into the library even in daily rounds.
|
|
680
|
+
leanInitiative: 'normal',
|
|
681
|
+
// leanSearchPaths: extra `--search-path` roots injected BEFORE the automatic VibeMath
|
|
682
|
+
// root (deduped; an explicit --search-path/-R/--root in leanArgs wins).
|
|
683
|
+
leanSearchPaths: [],
|
|
684
|
+
// leanJobsMaxParallel: how many background compiles may run at once (default 1 = serial).
|
|
685
|
+
leanJobsMaxParallel: 1,
|
|
686
|
+
// ── math_computation (docs/math-computation.md; the shared module owns the semantics) ──
|
|
687
|
+
// mathComputation — 'off' | 'auto' (default) | 'on': whether the tool is available
|
|
688
|
+
// mathMode — 'typed+shell' (default: the host shell may be used as an unarchived
|
|
689
|
+
// fallback) | 'typed' (never mention the shell; engine='cli' refused)
|
|
690
|
+
// mathEngines — allowed engine names (cli is ON by default; drop it to disable)
|
|
691
|
+
// mathTimeoutMs — per-run budget (>=1000)
|
|
692
|
+
// mathPackages — packages/toolboxes a computation may require
|
|
693
|
+
// mathInstallScope — 'user' (default) | 'system' (per call only, never remembered)
|
|
694
|
+
// NOTE: the DEFAULTS come from the shared module so all four presets are byte-comparable;
|
|
695
|
+
// the arrays are COPIED (the module's defaults are frozen and must never be mutated).
|
|
696
|
+
mathComputation: MATH_PARAM_DEFAULTS.mathComputation,
|
|
697
|
+
mathMode: MATH_PARAM_DEFAULTS.mathMode,
|
|
698
|
+
mathEngines: MATH_PARAM_DEFAULTS.mathEngines.slice(),
|
|
699
|
+
mathTimeoutMs: MATH_PARAM_DEFAULTS.mathTimeoutMs,
|
|
700
|
+
mathPackages: MATH_PARAM_DEFAULTS.mathPackages.slice(),
|
|
701
|
+
mathInstallScope: MATH_PARAM_DEFAULTS.mathInstallScope,
|
|
702
|
+
// ── final paper (docs/final-paper.md; the phase runs BEFORE the completion flags) ──
|
|
703
|
+
// finalPaper — write the final paper when the run concludes (manual /v5 paper
|
|
704
|
+
// still works when this is false, and says so).
|
|
705
|
+
// paperFormat — 'both' | 'md' | 'tex'
|
|
706
|
+
// paperLanguage — 'zh' | 'en'
|
|
707
|
+
// paperCompilePdf — compile a PDF when a LaTeX engine is detected
|
|
708
|
+
// paperEditor — 'academician' (default: the only editor reachable unattended) |
|
|
709
|
+
// 'office' (manual /v5 paper only: the office must consult the
|
|
710
|
+
// institute first — messages + at least one meeting)
|
|
711
|
+
// paperLatexCommand — force ONE engine command instead of auto-detection ('' = auto)
|
|
712
|
+
finalPaper: true,
|
|
713
|
+
paperFormat: 'both',
|
|
714
|
+
paperLanguage: 'zh',
|
|
715
|
+
paperCompilePdf: true,
|
|
716
|
+
paperEditor: 'academician',
|
|
717
|
+
paperLatexCommand: '',
|
|
513
718
|
// ── model / tools ────────────────────────────────────────────────────
|
|
514
719
|
provider: '',
|
|
515
720
|
model: '',
|
|
@@ -604,7 +809,17 @@ export function apply(ctx) {
|
|
|
604
809
|
// i.e. the fallback would silently lose the whole institute across a restart.
|
|
605
810
|
async function ready() {
|
|
606
811
|
if (!backend) installBackend()
|
|
607
|
-
|
|
812
|
+
await awaitBackendLoad()
|
|
813
|
+
// Crash recovery for the Lean queue runs ONCE per session, after the state file is
|
|
814
|
+
// folded in: a leftover Formal/Jobs/*.json is re-queued or explicitly interrupted, and
|
|
815
|
+
// is NEVER silently turned into `passed` (docs/formal-verification.md §7-7).
|
|
816
|
+
if (!leanRecoveryDone) {
|
|
817
|
+
try { await recoverLeanJobs() } catch (e) { console.error('vibe-math-v5: lean recovery: ' + String((e && e.message) || e)) }
|
|
818
|
+
}
|
|
819
|
+
// The math availability line is derived at session start (and on every tuning that
|
|
820
|
+
// touches the six math_* keys); `probeMathEngines` caches per host, so this is cheap.
|
|
821
|
+
try { await refreshMathLine() } catch (e) { console.error('vibe-math-v5: math probe: ' + String((e && e.message) || e)) }
|
|
822
|
+
return true
|
|
608
823
|
}
|
|
609
824
|
// Load the CURRENT state path before its first read. `state()` is synchronous by design
|
|
610
825
|
// (the whole fold is read through it), so it kicks the load off eagerly; the caller that
|
|
@@ -671,7 +886,14 @@ export function apply(ctx) {
|
|
|
671
886
|
const cur = stateCache.institutes[key]
|
|
672
887
|
if (cur) {
|
|
673
888
|
phase = cur.phase || phase
|
|
674
|
-
params
|
|
889
|
+
// Restored params must go through the SAME normalisation as `vibe_v5_set`: a hand-edited
|
|
890
|
+
// or foreign-shaped state file (e.g. mathEngines as a comma STRING, a bogus enum, a
|
|
891
|
+
// sub-1000 timeout) would otherwise reach `visibleParams()` and v5's own
|
|
892
|
+
// `String(params.mathComputation/mathMode)` reads verbatim — the tool path is protected
|
|
893
|
+
// by the module's `effectiveMathParams`, the operator surface was not.
|
|
894
|
+
const droppedLoad = []
|
|
895
|
+
params = Object.assign({}, DEFAULT_PARAMS, normalizeParams(cur.params || {}, droppedLoad))
|
|
896
|
+
reportDroppedStateKeys(cur, droppedLoad, 'restore-load')
|
|
675
897
|
project = cur.project || project
|
|
676
898
|
instituteName = cur.institute || instituteName
|
|
677
899
|
}
|
|
@@ -1094,6 +1316,9 @@ export function apply(ctx) {
|
|
|
1094
1316
|
b.push('[形式化] ' + (formalMode() === 'require' ? '强制' : '鼓励') + ' Lean|已通过 ' + passed
|
|
1095
1317
|
+ '|已记录阻塞 ' + blocked + (formalTodo().length ? '|形式化待办 ' + formalTodo().length + ' 项(见 Formal/TODO.md)' : ''))
|
|
1096
1318
|
}
|
|
1319
|
+
// Settled async compiles are announced EXACTLY ONCE, in the member's next prompt
|
|
1320
|
+
// (docs/formal-verification.md §2.4: the announcement point is the next prompt).
|
|
1321
|
+
for (const line of takeLeanNoticesFor(member.id)) b.push(line)
|
|
1097
1322
|
const tasks = inst().tasks.filter((t) => t.status !== 'deleted')
|
|
1098
1323
|
const mine = tasks.filter((t) => t.ownerId === member.id && t.status === 'in_progress')
|
|
1099
1324
|
const ready = tasks.filter((t) => t.status === 'pending' && taskReady(t))
|
|
@@ -1185,7 +1410,7 @@ export function apply(ctx) {
|
|
|
1185
1410
|
// directory is what actually creates the tree — inside the policy.
|
|
1186
1411
|
async function mkdirs() {
|
|
1187
1412
|
const base = instRoot()
|
|
1188
|
-
const dirs = ['Shared/Chat', 'Shared/Meetings', 'Shared/Debates', 'State', 'Problems', 'Formal', 'Verified/Lean']
|
|
1413
|
+
const dirs = ['Shared/Chat', 'Shared/Meetings', 'Shared/Debates', 'State', 'Problems', 'Formal', 'Formal/Jobs', 'Verified/Lean', 'Computation']
|
|
1189
1414
|
// The REUSABLE Lean library is global (cross-project), so it hangs off the VibeMath
|
|
1190
1415
|
// root rather than the institute root — creating it under instRoot would scatter a
|
|
1191
1416
|
// second, invisible copy per institute.
|
|
@@ -1248,6 +1473,10 @@ export function apply(ctx) {
|
|
|
1248
1473
|
}
|
|
1249
1474
|
counters.message = n
|
|
1250
1475
|
await putCounters(counters)
|
|
1476
|
+
// The office talking to the institute is HALF of the `paperEditor='office'` consultation
|
|
1477
|
+
// requirement; counting it here (the one place a message really is queued) means the gate
|
|
1478
|
+
// cannot be satisfied by merely intending to consult (docs/final-paper.md §7).
|
|
1479
|
+
if (from === 'office') await notePaperConsult('message')
|
|
1251
1480
|
notifyActivity()
|
|
1252
1481
|
// Kick one scheduling pass so an ADDRESSED message (dm/office/assign) wakes its
|
|
1253
1482
|
// recipient promptly instead of waiting for the digest window. Plain chat stays
|
|
@@ -1679,6 +1908,7 @@ export function apply(ctx) {
|
|
|
1679
1908
|
L.push('')
|
|
1680
1909
|
if (initialTask) { L.push(resume ? '恢复说明:' : '你的初始任务/用途:'); L.push(' ' + initialTask); L.push('') }
|
|
1681
1910
|
if (member.direction && !resume) { L.push('给你的起点方向:' + member.direction); L.push('') }
|
|
1911
|
+
mathPushLine(L)
|
|
1682
1912
|
L.push('------------')
|
|
1683
1913
|
L.push(stateBlock(member))
|
|
1684
1914
|
L.push('------------')
|
|
@@ -1696,7 +1926,8 @@ export function apply(ctx) {
|
|
|
1696
1926
|
L.push('作为院士,除了做研究,你还要**统筹全所**:用 vibe_v5_overview 看清谁在做什么、')
|
|
1697
1927
|
L.push('哪里是瓶颈;把工作拆成任务并用 vibe_v5_assign 分派;必要时用 vibe_v5_nudge 督办。')
|
|
1698
1928
|
}
|
|
1699
|
-
if (
|
|
1929
|
+
if (leanDailyOn()) { L.push(''); L.push(formalWorkLine()) }
|
|
1930
|
+
mathPushLine(L)
|
|
1700
1931
|
L.push('')
|
|
1701
1932
|
L.push('------------')
|
|
1702
1933
|
L.push(stateBlock(member))
|
|
@@ -1712,7 +1943,8 @@ export function apply(ctx) {
|
|
|
1712
1943
|
L.push('读一读同事的库、推进你的子问题/引理/方法、尝试一条新路线;')
|
|
1713
1944
|
L.push('或者向团队发消息(say)、开一个议题(propose_meeting)、给某个方向开任务(task_create)。')
|
|
1714
1945
|
L.push('如果你确实已无路可走或认为原问题接近解决,请说明你的判断与理由。')
|
|
1715
|
-
if (
|
|
1946
|
+
if (leanDailyOn()) { L.push(''); L.push(formalWorkLine()) }
|
|
1947
|
+
mathPushLine(L)
|
|
1716
1948
|
L.push('')
|
|
1717
1949
|
L.push('------------')
|
|
1718
1950
|
L.push(stateBlock(member))
|
|
@@ -1739,6 +1971,7 @@ export function apply(ctx) {
|
|
|
1739
1971
|
L.push('(会议轮请把你的发言同时填进 JSON 的 "input" 字段,框架据此写会议纪要。)')
|
|
1740
1972
|
L.push('如果你认为原问题已解决,请填 "vote_solved": true —— 只有当**全体有表决权者**都')
|
|
1741
1973
|
L.push('一致认为是真时,本所才会停下来。')
|
|
1974
|
+
mathPushLine(L)
|
|
1742
1975
|
L.push('')
|
|
1743
1976
|
L.push('------------')
|
|
1744
1977
|
L.push(stateBlock(member))
|
|
@@ -1783,6 +2016,7 @@ export function apply(ctx) {
|
|
|
1783
2016
|
}
|
|
1784
2017
|
L.push('**不要为了配合别人而改票,也不要为了让流程往前走而给出你不相信的 1 或 0。**')
|
|
1785
2018
|
L.push('本所宁可留下未定论,也不要一个骗人的结论。')
|
|
2019
|
+
mathPushLine(L)
|
|
1786
2020
|
L.push('')
|
|
1787
2021
|
L.push('------------')
|
|
1788
2022
|
L.push(stateBlock(member))
|
|
@@ -1818,6 +2052,9 @@ export function apply(ctx) {
|
|
|
1818
2052
|
const delay = posMs(ms, posMs(params.activityTimeoutMs, 120000))
|
|
1819
2053
|
heartbeatDisposer = ctx.timeout(() => {
|
|
1820
2054
|
heartbeatDisposer = null
|
|
2055
|
+
// Drain the Lean compile queue FIRST: it is independent of the scheduler and must run
|
|
2056
|
+
// even when the pass below decides there is nothing to do (docs/formal-verification.md §7).
|
|
2057
|
+
runLeanQueue().catch((e) => console.error('vibe-math-v5: lean queue: ' + String((e && e.message) || e)))
|
|
1821
2058
|
scheduleNext().catch((e) => console.error('vibe-math-v5: heartbeat: ' + String((e && e.message) || e)))
|
|
1822
2059
|
}, delay)
|
|
1823
2060
|
}
|
|
@@ -1878,6 +2115,12 @@ export function apply(ctx) {
|
|
|
1878
2115
|
const FORMAL_MODES = ['off', 'encourage', 'require']
|
|
1879
2116
|
const formalMode = () => (FORMAL_MODES.indexOf(String(params.formalVerify)) !== -1 ? String(params.formalVerify) : 'off')
|
|
1880
2117
|
const formalOn = () => formalMode() !== 'off'
|
|
2118
|
+
// `leanInitiative` is SEPARATE from `formalVerify`: the latter only says how strong the
|
|
2119
|
+
// requirement is AT VERDICT TIME, this says how eager the staff should be about formalizing
|
|
2120
|
+
// during ordinary work. 'off' suppresses the daily reminder; 'eager' adds it even when
|
|
2121
|
+
// `formalVerify` is off; 'normal' keeps today's rule (the reminder rides with formalVerify).
|
|
2122
|
+
const leanInitiative = () => (['off', 'normal', 'eager'].indexOf(String(params.leanInitiative)) !== -1 ? String(params.leanInitiative) : 'normal')
|
|
2123
|
+
const leanDailyOn = () => leanInitiative() !== 'off' && (formalOn() || leanInitiative() === 'eager')
|
|
1881
2124
|
const formalRoot = () => instRoot() + '/Formal'
|
|
1882
2125
|
const formalLibRoot = () => vibeRoot() + '/Formal/Lib'
|
|
1883
2126
|
const formalProvedRoot = () => vibeRoot() + '/Formal/Proved'
|
|
@@ -1932,7 +2175,10 @@ export function apply(ctx) {
|
|
|
1932
2175
|
|
|
1933
2176
|
// Run the toolchain on one file. NEVER throws into the scheduler: every failure mode
|
|
1934
2177
|
// (no service, no executable, timeout, non-zero exit) becomes a readable result.
|
|
1935
|
-
|
|
2178
|
+
// `hooks.onHandle(handle)` (optional) hands the live process to the caller so a session
|
|
2179
|
+
// dispose can terminate it; it is a hook rather than a return value because the run must
|
|
2180
|
+
// stay a single awaitable result.
|
|
2181
|
+
async function leanRunFile(relPath, timeoutMs, hooks) {
|
|
1936
2182
|
const started = now()
|
|
1937
2183
|
const rel = String(relPath || '').trim()
|
|
1938
2184
|
if (!rel) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'file is required' }
|
|
@@ -1955,7 +2201,8 @@ export function apply(ctx) {
|
|
|
1955
2201
|
} catch (e) {
|
|
1956
2202
|
return { ok: false, code: 'LEAN_NOT_FOUND', message: 'cannot resolve "' + String(params.leanCommand || 'lean') + '": ' + String((e && e.message) || e) + ' — 仍可把形式化代码写下来归档,但无法在此宿主上执行', file: rel, ms: now() - started }
|
|
1957
2203
|
}
|
|
1958
|
-
const
|
|
2204
|
+
const pfx = await leanRunPrefix()
|
|
2205
|
+
const argv = [exe].concat(pfx.prefix.slice(1), [abs])
|
|
1959
2206
|
let handle
|
|
1960
2207
|
try {
|
|
1961
2208
|
handle = sub.spawn({
|
|
@@ -1964,6 +2211,11 @@ export function apply(ctx) {
|
|
|
1964
2211
|
stdio: { stdin: 'ignore', stdout: { maxBytes: 64 * 1024 }, stderr: { maxBytes: 64 * 1024 } },
|
|
1965
2212
|
graceMs: cap,
|
|
1966
2213
|
})
|
|
2214
|
+
// Hand the live process to the async queue (session dispose terminates it) and the
|
|
2215
|
+
// NORMALISED build prefix (engine + args + search paths, no file) to the settle check —
|
|
2216
|
+
// a job may only settle the build context it was queued under.
|
|
2217
|
+
if (hooks && typeof hooks.onHandle === 'function') hooks.onHandle(handle)
|
|
2218
|
+
if (hooks && typeof hooks.onBuildPrefix === 'function') hooks.onBuildPrefix(pfx.prefix)
|
|
1967
2219
|
} catch (e) {
|
|
1968
2220
|
return { ok: false, code: 'LEAN_SPAWN_FAILED', message: String((e && e.message) || e), file: rel, ms: now() - started }
|
|
1969
2221
|
}
|
|
@@ -2091,6 +2343,608 @@ export function apply(ctx) {
|
|
|
2091
2343
|
+ ' —— 已撤回「已通过」状态' + (removed ? ',并已使 Verified/Lean/ 中的归档证明失效(删除或覆盖为撤回声明)' : '') + ';请修正形式化、重新跑通后再投票。' + stillThere.join(''))
|
|
2092
2344
|
return { ok: true, target: t, status: 'attempted', removed }
|
|
2093
2345
|
}
|
|
2346
|
+
// ================= async Lean compile queue (docs/formal-verification.md §7) ==========
|
|
2347
|
+
// ONE compile at a time, per session. A tool call ENQUEUES and returns immediately; the
|
|
2348
|
+
// queue is drained by the existing heartbeat (`armHeartbeat`) and by a 0ms kick armed at
|
|
2349
|
+
// enqueue time, so a paused or concluded institute still drains what it accepted. Every job
|
|
2350
|
+
// is mirrored to Formal/Jobs/<jobId>.json for crash recovery and for `lean_lib.jobs`.
|
|
2351
|
+
// ONLY `settled(ok)` — exit 0 AND the file's content hash unchanged — may mark an object
|
|
2352
|
+
// `passed` and write Verified/Lean/<id>.lean; every other ending stays `attempted`.
|
|
2353
|
+
const leanJobs = new Map()
|
|
2354
|
+
const leanActive = new Map() // jobId -> { job, handle } (up to leanJobsMaxParallel)
|
|
2355
|
+
let leanActiveJob = null // the MOST RECENTLY started job (prompt/dispose reporting)
|
|
2356
|
+
let leanActiveHandle = null
|
|
2357
|
+
let leanDrainTimer = null
|
|
2358
|
+
let leanRecoveryDone = false
|
|
2359
|
+
let leanDisposed = false
|
|
2360
|
+
const leanNotices = [] // settled results not yet announced to a member
|
|
2361
|
+
function leanJobsMax() { return Math.max(1, Math.floor(Number(params.leanJobsMaxParallel) || 1)) }
|
|
2362
|
+
function leanJobRel(jobId) { return 'Formal/Jobs/' + jobId + '.json' }
|
|
2363
|
+
async function leanWriteJob(job) {
|
|
2364
|
+
try { await writeTextRel(leanJobRel(job.jobId), JSON.stringify(job, null, 2) + '\n') } catch (e) { /* best effort */ }
|
|
2365
|
+
}
|
|
2366
|
+
async function leanReadJobMirror(jobId) {
|
|
2367
|
+
try { return JSON.parse((await readTextRel(leanJobRel(jobId))) || 'null') } catch (e) { return null }
|
|
2368
|
+
}
|
|
2369
|
+
function leanJobMirrorSettled(rec) { return !!rec && rec.state === 'settled' && Number(rec.exitCode) === 0 }
|
|
2370
|
+
// Has this EXACT library content already been compiled successfully? The job id digests the
|
|
2371
|
+
// build context, so the check looks at the jobs rather than reconstructing an id: first the
|
|
2372
|
+
// in-memory queue, then the durable mirrors in Formal/Jobs/.
|
|
2373
|
+
async function leanLibArchivedOk(name, sha) {
|
|
2374
|
+
for (const j of leanJobs.values()) {
|
|
2375
|
+
if (j.kind === 'lib' && j.name === name && j.contentSha256 === sha && leanJobMirrorSettled(j)) return true
|
|
2376
|
+
}
|
|
2377
|
+
try {
|
|
2378
|
+
const t = await fs.resolve(instRoot() + '/Formal/Jobs')
|
|
2379
|
+
if (await fs.stat(t) !== undefined) {
|
|
2380
|
+
for (const e of (await fs.listDir(t)) || []) {
|
|
2381
|
+
if (!e || e.type !== 'file' || !/\.json$/.test(String(e.name))) continue
|
|
2382
|
+
const rec = await leanReadJobMirror(String(e.name).replace(/\.json$/, ''))
|
|
2383
|
+
if (rec && rec.kind === 'lib' && rec.name === name && rec.contentSha256 === sha && leanJobMirrorSettled(rec)) return true
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
} catch (e) { /* best effort: no mirror ⇒ no dedupe */ }
|
|
2387
|
+
return false
|
|
2388
|
+
}
|
|
2389
|
+
function leanJobsView() {
|
|
2390
|
+
return Array.from(leanJobs.values())
|
|
2391
|
+
.sort((a, b) => (a.enqueuedAt || 0) - (b.enqueuedAt || 0))
|
|
2392
|
+
.map((j) => ({ jobId: j.jobId, state: j.state, rel: j.file || j.rel || '', target: j.target || j.name || '', attempts: j.attempts || 1, settledAt: j.settledAt || 0 }))
|
|
2393
|
+
}
|
|
2394
|
+
function leanNextQueued() {
|
|
2395
|
+
let best = null
|
|
2396
|
+
for (const j of leanJobs.values()) if (j.state === 'queued' && (!best || (j.enqueuedAt || 0) < (best.enqueuedAt || 0))) best = j
|
|
2397
|
+
return best
|
|
2398
|
+
}
|
|
2399
|
+
// The absolute VibeMath root handed to `--search-path` (the compile runs with cwd=instRoot()).
|
|
2400
|
+
function leanSearchRootView() { return vibeRoot().replace(/\\/g, '/') }
|
|
2401
|
+
async function leanSearchRoot() {
|
|
2402
|
+
const root = vibeRoot()
|
|
2403
|
+
if (root.charAt(0) === '/' || /^[a-z]:/i.test(root)) return root.replace(/\\/g, '/')
|
|
2404
|
+
try {
|
|
2405
|
+
const t = await fs.resolve(root)
|
|
2406
|
+
const s = typeof t === 'string' ? t : String((t && (t.targetKey || t.displayPath)) || root)
|
|
2407
|
+
return String(s).replace(/\\/g, '/')
|
|
2408
|
+
} catch (e) { return root.replace(/\\/g, '/') }
|
|
2409
|
+
}
|
|
2410
|
+
async function leanHashFile(rel) {
|
|
2411
|
+
const abs = leanAbsPath(rel)
|
|
2412
|
+
if (abs === null) return undefined
|
|
2413
|
+
const txt = await readTextAbs(abs)
|
|
2414
|
+
return txt === undefined ? undefined : sha256Hex(leanHashText(txt))
|
|
2415
|
+
}
|
|
2416
|
+
function leanJobId(key, sha) {
|
|
2417
|
+
// `sha` is the BUILD digest: content + engine + normalised argv + search paths, so the
|
|
2418
|
+
// same text compiled under different flags gets a different job (amendment §4).
|
|
2419
|
+
return String(key) + '-' + String(sha || '').slice(0, 12)
|
|
2420
|
+
}
|
|
2421
|
+
// The NORMALISED build prefix (engine + user args + injected search flags, WITHOUT the file
|
|
2422
|
+
// name). It is what the job id digests and what the settle re-checks.
|
|
2423
|
+
async function leanRunPrefix() {
|
|
2424
|
+
const userArgs = (Array.isArray(params.leanArgs) ? params.leanArgs : []).map(String)
|
|
2425
|
+
const engine = String(params.leanCommand || 'lean')
|
|
2426
|
+
const searchList = await leanInjectedSearchList(userArgs)
|
|
2427
|
+
const searchArgs = []
|
|
2428
|
+
for (const p of searchList) searchArgs.push(LEAN_SEARCH_PATH_FLAG, p)
|
|
2429
|
+
return { engine, userArgs, searchList, searchArgs, prefix: [engine].concat(userArgs, searchArgs) }
|
|
2430
|
+
}
|
|
2431
|
+
// leanSearchPaths FIRST (the order the user gave), then the automatic VibeMath root; deduped;
|
|
2432
|
+
// empty when leanArgs already carries an explicit search flag (explicit override wins).
|
|
2433
|
+
async function leanInjectedSearchList(userArgs) {
|
|
2434
|
+
if (leanHasSearchFlag(userArgs)) return []
|
|
2435
|
+
const extra = (Array.isArray(params.leanSearchPaths) ? params.leanSearchPaths : [])
|
|
2436
|
+
.map((s) => String(s == null ? '' : s).trim()).filter(Boolean)
|
|
2437
|
+
const root = await leanSearchRoot()
|
|
2438
|
+
return Array.from(new Set(extra.concat([root])))
|
|
2439
|
+
}
|
|
2440
|
+
function leanBuildDigest(contentText, prefix) {
|
|
2441
|
+
return sha256Hex(leanHashText(String(contentText == null ? '' : contentText)) + '|' + JSON.stringify((prefix || []).map(String)))
|
|
2442
|
+
}
|
|
2443
|
+
function leanBuildHash(prefix) { return sha256Hex(JSON.stringify((prefix || []).map(String))) }
|
|
2444
|
+
function armLeanDrain(ms) {
|
|
2445
|
+
if (leanDisposed || leanDrainTimer) return
|
|
2446
|
+
// `posMs` treats 0 as "use the default" (120s!), which would stall the queue: the kick
|
|
2447
|
+
// must be an IMMEDIATE next-tick timer.
|
|
2448
|
+
const delay = (typeof ms === 'number' && ms > 0) ? Math.floor(ms) : 0
|
|
2449
|
+
leanDrainTimer = ctx.timeout(() => {
|
|
2450
|
+
leanDrainTimer = null
|
|
2451
|
+
runLeanQueue().catch((e) => console.error('vibe-math-v5: lean queue: ' + String((e && e.message) || e)))
|
|
2452
|
+
}, delay)
|
|
2453
|
+
}
|
|
2454
|
+
function clearLeanDrain() { if (leanDrainTimer) { try { leanDrainTimer() } catch (e) { /* ignore */ } leanDrainTimer = null } }
|
|
2455
|
+
// The toolchain probe shared by the enqueue path: a host with no subprocess service — or no
|
|
2456
|
+
// reachable executable — must keep today's honest NO_SUBPROCESS / LEAN_NOT_FOUND behaviour
|
|
2457
|
+
// rather than queueing a job that can never settle.
|
|
2458
|
+
async function leanPrecheck(relPath) {
|
|
2459
|
+
const rel = String(relPath == null ? '' : relPath).trim()
|
|
2460
|
+
if (!rel) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'file is required' }
|
|
2461
|
+
const abs = leanAbsPath(rel)
|
|
2462
|
+
if (abs === null) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'Lean file must live under ' + vibeRoot().replace(/\\/g, '/') + '/ (got ' + rel + ')' }
|
|
2463
|
+
if (!/\.lean$/.test(abs)) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'only .lean files can be executed' }
|
|
2464
|
+
if (await readTextAbs(abs) === undefined) return { ok: false, code: 'V5_NOT_FOUND', message: 'no such file: ' + rel }
|
|
2465
|
+
const sub = subprocessOf()
|
|
2466
|
+
if (sub === undefined || typeof sub.spawn !== 'function') {
|
|
2467
|
+
return { ok: false, code: 'NO_SUBPROCESS', message: 'the host exposes no subprocess service; Lean cannot be executed here', file: rel }
|
|
2468
|
+
}
|
|
2469
|
+
try { await sub.resolveExecutable(String(params.leanCommand || 'lean')) } catch (e) {
|
|
2470
|
+
return { ok: false, code: 'LEAN_NOT_FOUND', message: 'cannot resolve "' + String(params.leanCommand || 'lean') + '": ' + String((e && e.message) || e) + ' — 仍可把形式化代码写下来归档,但无法在此宿主上执行', file: rel }
|
|
2471
|
+
}
|
|
2472
|
+
return { ok: true, rel, abs }
|
|
2473
|
+
}
|
|
2474
|
+
async function enqueueLeanJob(spec) {
|
|
2475
|
+
const job = Object.assign({}, spec, {
|
|
2476
|
+
state: 'queued', attempts: 0, enqueuedAt: now(), startedAt: 0, settledAt: 0,
|
|
2477
|
+
exitCode: null, ok: false, timedOut: false, interrupted: false,
|
|
2478
|
+
})
|
|
2479
|
+
leanJobs.set(job.jobId, job)
|
|
2480
|
+
await leanWriteJob(job)
|
|
2481
|
+
// Kick on a LATER tick: the tool must return before any compiler process starts.
|
|
2482
|
+
armLeanDrain()
|
|
2483
|
+
return job
|
|
2484
|
+
}
|
|
2485
|
+
async function runLeanQueue() {
|
|
2486
|
+
let started = false
|
|
2487
|
+
// Up to leanJobsMaxParallel compiles at once (default 1 = strictly serial). Each job runs
|
|
2488
|
+
// to completion in the background; its own settle re-arms the queue.
|
|
2489
|
+
while (leanActive.size < leanJobsMax()) {
|
|
2490
|
+
const job = leanNextQueued()
|
|
2491
|
+
if (!job) break
|
|
2492
|
+
started = true
|
|
2493
|
+
const slot = { job, handle: null }
|
|
2494
|
+
leanActive.set(job.jobId, slot)
|
|
2495
|
+
runLeanJob(job, slot)
|
|
2496
|
+
.catch((e) => console.error('vibe-math-v5: lean job ' + job.jobId + ': ' + String((e && e.message) || e)))
|
|
2497
|
+
.finally(() => {
|
|
2498
|
+
leanActive.delete(job.jobId)
|
|
2499
|
+
if (leanActiveJob === job) { leanActiveJob = null; leanActiveHandle = null }
|
|
2500
|
+
if (leanNextQueued()) armLeanDrain()
|
|
2501
|
+
})
|
|
2502
|
+
}
|
|
2503
|
+
return started
|
|
2504
|
+
}
|
|
2505
|
+
async function runLeanJob(job, slot) {
|
|
2506
|
+
leanActiveJob = job
|
|
2507
|
+
leanActiveHandle = null
|
|
2508
|
+
job.state = 'running'
|
|
2509
|
+
job.startedAt = now()
|
|
2510
|
+
job.attempts = (Number(job.attempts) || 0) + 1
|
|
2511
|
+
await leanWriteJob(job)
|
|
2512
|
+
let run = null
|
|
2513
|
+
let actualPrefix = null
|
|
2514
|
+
try {
|
|
2515
|
+
run = await leanRunFile(job.rel, job.timeoutMs, {
|
|
2516
|
+
onHandle: (h) => { if (slot) slot.handle = h; if (leanActiveJob === job) leanActiveHandle = h },
|
|
2517
|
+
onBuildPrefix: (p) => { actualPrefix = p },
|
|
2518
|
+
})
|
|
2519
|
+
} catch (e) {
|
|
2520
|
+
run = { ok: false, code: 'LEAN_RUN_FAILED', message: String((e && e.message) || e), file: job.rel, ms: 0 }
|
|
2521
|
+
}
|
|
2522
|
+
const hashNow = await leanHashFile(job.rel)
|
|
2523
|
+
const hashMatched = hashNow !== undefined && hashNow === job.contentSha256
|
|
2524
|
+
// The build context must be the one the job was QUEUED under: args/search paths may have
|
|
2525
|
+
// been retuned while it waited, and a compile under different flags settles nothing.
|
|
2526
|
+
const buildMatched = !!actualPrefix && leanBuildHash(actualPrefix) === job.buildHash
|
|
2527
|
+
const settledOk = !!run.ok && hashMatched && buildMatched
|
|
2528
|
+
job.exitCode = run.exitCode === undefined ? null : run.exitCode
|
|
2529
|
+
job.ok = settledOk
|
|
2530
|
+
job.timedOut = !!run.timedOut
|
|
2531
|
+
job.buildMatched = buildMatched
|
|
2532
|
+
job.settledAt = now()
|
|
2533
|
+
job.state = settledOk ? 'settled' : (job.interrupted === true ? 'interrupted' : (job.timedOut ? 'timeout' : 'failed'))
|
|
2534
|
+
job.run = {
|
|
2535
|
+
at: job.settledAt, ok: !!job.ok, exitCode: job.exitCode, ms: run.ms || 0,
|
|
2536
|
+
timedOut: !!job.timedOut, interrupted: !!job.interrupted, buildMatched,
|
|
2537
|
+
stderrTail: tail(run.stderr || (buildMatched ? '' : '构建上下文已改变(leanArgs/搜索路径在入队后被修改),本次结果不用于判定'), 800),
|
|
2538
|
+
}
|
|
2539
|
+
try { await leanApplySettle(job, run, settledOk, hashMatched && buildMatched) } catch (e) { console.error('vibe-math-v5: lean settle ' + job.jobId + ': ' + String((e && e.message) || e)) }
|
|
2540
|
+
await leanWriteJob(job)
|
|
2541
|
+
leanQueueNotice(job, run, hashMatched && buildMatched)
|
|
2542
|
+
}
|
|
2543
|
+
// The ONE place a job's outcome reaches the state — and the ONLY place an object may become
|
|
2544
|
+
// `passed` from a compile (proof jobs, and only when settledOk).
|
|
2545
|
+
async function leanApplySettle(job, run, settledOk, hashMatched) {
|
|
2546
|
+
const asyncRec = {
|
|
2547
|
+
jobId: job.jobId, state: job.state, attempts: job.attempts || 1,
|
|
2548
|
+
enqueuedAt: job.enqueuedAt || 0, startedAt: job.startedAt || 0, settledAt: job.settledAt || 0,
|
|
2549
|
+
exitCode: job.exitCode === undefined ? null : job.exitCode,
|
|
2550
|
+
}
|
|
2551
|
+
const target = idSafe(String(job.target || job.name || ''))
|
|
2552
|
+
if (job.kind === 'proof') {
|
|
2553
|
+
if (!target) return
|
|
2554
|
+
const prev = formalOf(target)
|
|
2555
|
+
const stale = hashMatched ? '' : '(异步落地:编译完成后文件内容已改变,本次结果不用于判定)'
|
|
2556
|
+
const rec = Object.assign({}, prev, {
|
|
2557
|
+
status: settledOk ? 'passed' : 'attempted',
|
|
2558
|
+
file: job.rel,
|
|
2559
|
+
proof: settledOk ? 'Verified/Lean/' + target + '.lean' : '',
|
|
2560
|
+
decision: 'used',
|
|
2561
|
+
note: (String(prev.note || '') + stale).trim(),
|
|
2562
|
+
run: job.run, async: asyncRec, updatedAt: now(),
|
|
2563
|
+
})
|
|
2564
|
+
if (settledOk) await writeTextRel('Verified/Lean/' + target + '.lean', String(job.content == null ? '' : job.content))
|
|
2565
|
+
await putFormal(target, rec)
|
|
2566
|
+
await rebuildLeanLibIndexes()
|
|
2567
|
+
await saveChatLine('【形式化】' + (job.member || '成员') + ' 的后台作业 ' + job.jobId + ' 落地:' + target + ' → ' + rec.status
|
|
2568
|
+
+ (settledOk ? '(已归档 ' + rec.proof + ',验证转为忠实性审查)' : '(未通过:' + tail(run.stderr || run.message, 160) + ')'))
|
|
2569
|
+
return
|
|
2570
|
+
}
|
|
2571
|
+
if (job.kind === 'lib') {
|
|
2572
|
+
if (target) {
|
|
2573
|
+
const prev = formalOf(target)
|
|
2574
|
+
await putFormal(target, Object.assign({}, prev, { file: job.file || prev.file || '', run: job.run, async: asyncRec, updatedAt: now() }))
|
|
2575
|
+
}
|
|
2576
|
+
await rebuildLeanLibIndexes()
|
|
2577
|
+
await saveChatLine('【形式化】' + (job.member || '成员') + ' 的后台作业 ' + job.jobId + ' 落地:' + (job.file || job.rel) + ' → ' + (settledOk ? '通过' : '未通过')
|
|
2578
|
+
+ (settledOk ? '' : '(' + tail(run.stderr || run.message, 160) + ')'))
|
|
2579
|
+
return
|
|
2580
|
+
}
|
|
2581
|
+
if (target) {
|
|
2582
|
+
await formalSetRun(target, run)
|
|
2583
|
+
const rec = formalOf(target)
|
|
2584
|
+
await putFormal(target, Object.assign({}, rec, { async: asyncRec, updatedAt: now() }))
|
|
2585
|
+
}
|
|
2586
|
+
await writeFormalIndex()
|
|
2587
|
+
await saveChatLine('【形式化】' + (job.member || '成员') + ' 的后台作业 ' + job.jobId + ' 落地:' + (job.file || job.rel) + ' → ' + (settledOk ? '通过' : '未通过')
|
|
2588
|
+
+ (settledOk ? '' : '(' + tail(run.stderr || run.message, 160) + ')'))
|
|
2589
|
+
}
|
|
2590
|
+
function leanQueueNotice(job, run, hashMatched) {
|
|
2591
|
+
const secs = ((run && run.ms) || 0) / 1000
|
|
2592
|
+
let line
|
|
2593
|
+
if (job.state === 'settled') line = '【形式化结果】' + job.jobId + ':通过(exit 0,' + secs.toFixed(1) + 's' + (job.kind === 'proof' ? ',已归档 Verified/Lean/' + job.target + '.lean' : '') + ')'
|
|
2594
|
+
else if (job.state === 'timeout') line = '【形式化结果】' + job.jobId + ':超时(leanTimeoutMs=' + posMs(params.leanTimeoutMs, 120000) + ' 已 terminate)'
|
|
2595
|
+
else if (job.state === 'interrupted') line = '【形式化结果】' + job.jobId + ':中断(会话卸载或崩溃,已标记 attempted,可重跑)'
|
|
2596
|
+
else line = '【形式化结果】' + job.jobId + ':失败(exit ' + job.exitCode + (hashMatched ? '' : ';文件内容已变,结果不可用') + ',见 stderr 尾部)'
|
|
2597
|
+
leanNotices.push({ member: String(job.member || ''), jobId: job.jobId, line, at: now() })
|
|
2598
|
+
while (leanNotices.length > 20) leanNotices.shift()
|
|
2599
|
+
}
|
|
2600
|
+
// ONE-SHOT: a member sees each settled result exactly once, in its next round prompt
|
|
2601
|
+
// (the "announcement point is the next prompt" discipline).
|
|
2602
|
+
function takeLeanNoticesFor(memberId) {
|
|
2603
|
+
if (!leanNotices.length) return []
|
|
2604
|
+
const mine = leanNotices.filter((n) => n.member === memberId)
|
|
2605
|
+
if (!mine.length) return []
|
|
2606
|
+
for (const n of mine) { const i = leanNotices.indexOf(n); if (i !== -1) leanNotices.splice(i, 1) }
|
|
2607
|
+
return mine.map((n) => n.line)
|
|
2608
|
+
}
|
|
2609
|
+
// Crash recovery: an object is NEVER silently verified. Only a job record that says
|
|
2610
|
+
// exitCode 0 AND whose content hash still matches may补 the archive; everything else is
|
|
2611
|
+
// downgraded to `attempted` (or re-queued when the interrupted run is safe to retry).
|
|
2612
|
+
async function leanMarkAttempted(rec, why) {
|
|
2613
|
+
const target = idSafe(String(rec.target || ''))
|
|
2614
|
+
if (!target) return
|
|
2615
|
+
const prev = formalOf(target)
|
|
2616
|
+
const sameJob = !!(prev && prev.async && prev.async.jobId === rec.jobId)
|
|
2617
|
+
if (!sameJob && prev.status === 'passed') return // never downgrade a newer pass
|
|
2618
|
+
if (!sameJob && rec.kind !== 'proof') return
|
|
2619
|
+
const note = String(prev.note || '')
|
|
2620
|
+
await putFormal(target, Object.assign({}, prev, {
|
|
2621
|
+
status: prev.status === 'blocked' ? 'blocked' : 'attempted',
|
|
2622
|
+
proof: rec.kind === 'proof' ? '' : prev.proof,
|
|
2623
|
+
async: { jobId: rec.jobId, state: 'interrupted', attempts: Number(rec.attempts) || 1, enqueuedAt: rec.enqueuedAt || 0, startedAt: rec.startedAt || 0, settledAt: now(), exitCode: rec.exitCode === undefined ? null : rec.exitCode },
|
|
2624
|
+
note: (note.indexOf(why) === -1 ? (note + (note ? ' ' : '') + '(' + why + ')') : note).trim(),
|
|
2625
|
+
updatedAt: now(),
|
|
2626
|
+
}))
|
|
2627
|
+
await writeFormalIndex()
|
|
2628
|
+
}
|
|
2629
|
+
async function leanApplyRecovered(rec) {
|
|
2630
|
+
const target = idSafe(String(rec.target || ''))
|
|
2631
|
+
const asyncRec = { jobId: rec.jobId, state: 'settled', attempts: Number(rec.attempts) || 1, enqueuedAt: rec.enqueuedAt || 0, startedAt: rec.startedAt || 0, settledAt: rec.settledAt || now(), exitCode: 0 }
|
|
2632
|
+
if (rec.kind === 'proof' && target) {
|
|
2633
|
+
const prev = formalOf(target)
|
|
2634
|
+
await writeTextRel('Verified/Lean/' + target + '.lean', String(rec.content == null ? '' : rec.content))
|
|
2635
|
+
await putFormal(target, Object.assign({}, prev, {
|
|
2636
|
+
status: 'passed', file: rec.rel, proof: 'Verified/Lean/' + target + '.lean', decision: 'used',
|
|
2637
|
+
run: Object.assign({}, rec.run || {}, { ok: true, exitCode: 0 }), async: asyncRec, updatedAt: now(),
|
|
2638
|
+
}))
|
|
2639
|
+
await rebuildLeanLibIndexes()
|
|
2640
|
+
return true
|
|
2641
|
+
}
|
|
2642
|
+
if (target) {
|
|
2643
|
+
const prev = formalOf(target)
|
|
2644
|
+
await putFormal(target, Object.assign({}, prev, { run: Object.assign({}, rec.run || {}, { ok: true, exitCode: 0 }), async: asyncRec, updatedAt: now() }))
|
|
2645
|
+
}
|
|
2646
|
+
await rebuildLeanLibIndexes()
|
|
2647
|
+
return true
|
|
2648
|
+
}
|
|
2649
|
+
async function recoverLeanJobs() {
|
|
2650
|
+
if (leanRecoveryDone) return { scanned: 0, requeued: 0, attempted: 0 }
|
|
2651
|
+
leanRecoveryDone = true
|
|
2652
|
+
let entries = []
|
|
2653
|
+
try {
|
|
2654
|
+
const t = await fs.resolve(instRoot() + '/Formal/Jobs')
|
|
2655
|
+
if (await fs.stat(t) !== undefined) entries = await fs.listDir(t)
|
|
2656
|
+
} catch (e) { return { scanned: 0, requeued: 0, attempted: 0 } }
|
|
2657
|
+
let scanned = 0, requeued = 0, attempted = 0
|
|
2658
|
+
for (const e of entries || []) {
|
|
2659
|
+
if (!e || e.type !== 'file' || !/\.json$/.test(String(e.name))) continue
|
|
2660
|
+
const jobId = String(e.name).replace(/\.json$/, '')
|
|
2661
|
+
const rec = await leanReadJobMirror(jobId)
|
|
2662
|
+
if (!rec || !rec.jobId || !rec.rel) continue
|
|
2663
|
+
scanned++
|
|
2664
|
+
const hashNow = await leanHashFile(rec.rel)
|
|
2665
|
+
const matched = hashNow !== undefined && hashNow === rec.contentSha256
|
|
2666
|
+
if (rec.state === 'queued') {
|
|
2667
|
+
rec.attempts = Number(rec.attempts) || 0
|
|
2668
|
+
leanJobs.set(rec.jobId, rec)
|
|
2669
|
+
requeued++
|
|
2670
|
+
await saveChatLine('【形式化·恢复】后台作业 ' + rec.jobId + ' 从未开始,已重新入队。')
|
|
2671
|
+
continue
|
|
2672
|
+
}
|
|
2673
|
+
if (rec.state === 'running' || rec.state === 'interrupted') {
|
|
2674
|
+
rec.interrupted = true
|
|
2675
|
+
await leanMarkAttempted(rec, matched ? '崩溃恢复:上一次运行的结果不可知' : '崩溃恢复:运行期间文件已被改写')
|
|
2676
|
+
attempted++
|
|
2677
|
+
if (matched) {
|
|
2678
|
+
rec.state = 'queued'
|
|
2679
|
+
rec.startedAt = 0
|
|
2680
|
+
leanJobs.set(rec.jobId, rec)
|
|
2681
|
+
requeued++
|
|
2682
|
+
await saveChatLine('【形式化·恢复】后台作业 ' + rec.jobId + ' 中断且内容未变:已以新 attempts 重新入队(旧结果不可知)。')
|
|
2683
|
+
} else {
|
|
2684
|
+
rec.state = 'failed'
|
|
2685
|
+
await leanWriteJob(rec)
|
|
2686
|
+
await saveChatLine('【形式化·恢复】后台作业 ' + rec.jobId + ' 运行期间文件已被改写:**不自动重驱**,请手动重跑(对象保持 attempted)。')
|
|
2687
|
+
}
|
|
2688
|
+
continue
|
|
2689
|
+
}
|
|
2690
|
+
if (leanJobMirrorSettled(rec) && matched) {
|
|
2691
|
+
await leanApplyRecovered(rec)
|
|
2692
|
+
await saveChatLine('【形式化·恢复】后台作业 ' + rec.jobId + ' 已通过且内容未变:按作业记录补齐归档与 passed。')
|
|
2693
|
+
} else {
|
|
2694
|
+
await leanMarkAttempted(rec, '崩溃恢复:作业未通过或内容已变')
|
|
2695
|
+
attempted++
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
if (leanNextQueued()) armLeanDrain()
|
|
2699
|
+
return { scanned, requeued, attempted }
|
|
2700
|
+
}
|
|
2701
|
+
function leanJobView(job) {
|
|
2702
|
+
if (!job) return null
|
|
2703
|
+
return {
|
|
2704
|
+
jobId: job.jobId, state: job.state, kind: job.kind || 'run',
|
|
2705
|
+
file: job.file || job.rel || '', rel: job.rel || '', target: job.target || job.name || '',
|
|
2706
|
+
attempts: job.attempts || 1, exitCode: job.exitCode === undefined ? null : job.exitCode,
|
|
2707
|
+
ok: job.ok === true, timedOut: !!job.timedOut, interrupted: !!job.interrupted,
|
|
2708
|
+
buildMatched: job.buildMatched !== false,
|
|
2709
|
+
enqueuedAt: job.enqueuedAt || 0, startedAt: job.startedAt || 0, settledAt: job.settledAt || 0,
|
|
2710
|
+
jobFile: leanJobRel(job.jobId),
|
|
2711
|
+
proof: job.kind === 'proof' && job.ok === true ? 'Verified/Lean/' + job.target + '.lean' : '',
|
|
2712
|
+
stderrTail: (job.run && job.run.stderrTail) || '',
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
// `lean_job {jobId?, waitMs?}` — the WAIT path for a background compile. It polls the queue
|
|
2716
|
+
// itself (bounded by waitMs) and never touches the heartbeat; with no jobId it lists this
|
|
2717
|
+
// session's jobs. Returns the CURRENT state on timeout rather than throwing.
|
|
2718
|
+
async function leanJobTool(o) {
|
|
2719
|
+
const args = o || {}
|
|
2720
|
+
const jobId = String(args.jobId || '').trim()
|
|
2721
|
+
const waitMs = Math.max(0, Math.min(600000, Math.floor(Number(args.waitMs) || 0)))
|
|
2722
|
+
if (!jobId) return { ok: true, jobs: leanJobsView(), count: leanJobs.size, running: leanActive.size, maxParallel: leanJobsMax() }
|
|
2723
|
+
let job = leanJobs.get(jobId) || await leanReadJobMirror(jobId)
|
|
2724
|
+
if (!job) return { ok: false, code: 'V5_NOT_FOUND', message: 'no such job ' + jobId, jobs: leanJobsView() }
|
|
2725
|
+
const deadline = now() + waitMs
|
|
2726
|
+
let waitedMs = 0
|
|
2727
|
+
while (waitMs > 0 && (job.state === 'queued' || job.state === 'running') && now() < deadline) {
|
|
2728
|
+
const t0 = now()
|
|
2729
|
+
try { await runLeanQueue() } catch (e) { console.error('vibe-math-v5: lean wait: ' + String((e && e.message) || e)) }
|
|
2730
|
+
if (job.state === 'queued' || job.state === 'running') {
|
|
2731
|
+
await new Promise((resolve) => { ctx.timeout(() => resolve(true), Math.min(25, waitMs)) })
|
|
2732
|
+
}
|
|
2733
|
+
waitedMs += now() - t0
|
|
2734
|
+
job = leanJobs.get(jobId) || await leanReadJobMirror(jobId) || job
|
|
2735
|
+
}
|
|
2736
|
+
return { ok: true, job: leanJobView(job), waitedMs, timedOut: job.state === 'queued' || job.state === 'running' }
|
|
2737
|
+
}
|
|
2738
|
+
// Session dispose: terminate EVERY in-flight compile and mark it `interrupted` — never passed.
|
|
2739
|
+
function disposeLeanJobs() {
|
|
2740
|
+
leanDisposed = true
|
|
2741
|
+
clearLeanDrain()
|
|
2742
|
+
const jobs = []
|
|
2743
|
+
for (const slot of leanActive.values()) {
|
|
2744
|
+
const job = slot && slot.job
|
|
2745
|
+
if (job) { job.interrupted = true; job.state = 'interrupted'; job.settledAt = now(); jobs.push(job) }
|
|
2746
|
+
if (slot && slot.handle && typeof slot.handle.terminate === 'function') {
|
|
2747
|
+
try { slot.handle.terminate() } catch (e) { /* best effort */ }
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
if (leanActiveHandle && typeof leanActiveHandle.terminate === 'function') {
|
|
2751
|
+
try { leanActiveHandle.terminate() } catch (e) { /* best effort */ }
|
|
2752
|
+
}
|
|
2753
|
+
for (const job of jobs) {
|
|
2754
|
+
leanWriteJob(job).catch(() => {})
|
|
2755
|
+
leanQueueNotice(job, { ms: now() - (job.startedAt || now()) }, true)
|
|
2756
|
+
}
|
|
2757
|
+
return { terminated: jobs.length }
|
|
2758
|
+
}
|
|
2759
|
+
try { leanDisposers.add(disposeLeanJobs) } catch (e) { /* host without the disposer set */ }
|
|
2760
|
+
|
|
2761
|
+
// ============ math_computation seam (docs/math-computation.md) ============
|
|
2762
|
+
// The shared module (`./math-computation.js`) owns detection/argv/receipts/install; this
|
|
2763
|
+
// preset owns the HOST side. Every path the module hands over is institute-relative, and it
|
|
2764
|
+
// is re-guarded here (defence in depth): a '../' path can never escape the institute even if
|
|
2765
|
+
// the module's own guard regresses.
|
|
2766
|
+
function mathRelPath(rel) {
|
|
2767
|
+
const raw = String(rel == null ? '' : rel).trim().replace(/\\/g, '/')
|
|
2768
|
+
if (!raw || raw.charAt(0) === '/' || /^[a-z]:/i.test(raw)) return null
|
|
2769
|
+
const parts = []
|
|
2770
|
+
for (const seg of raw.split('/')) {
|
|
2771
|
+
if (!seg || seg === '.') continue
|
|
2772
|
+
if (seg === '..') { if (!parts.length) return null; parts.pop(); continue }
|
|
2773
|
+
parts.push(seg)
|
|
2774
|
+
}
|
|
2775
|
+
return parts.length ? parts.join('/') : null
|
|
2776
|
+
}
|
|
2777
|
+
async function mathWriteRel(rel, text) {
|
|
2778
|
+
const r = mathRelPath(rel)
|
|
2779
|
+
if (r === null) return false
|
|
2780
|
+
return await writeTextRel(r, String(text == null ? '' : text))
|
|
2781
|
+
}
|
|
2782
|
+
async function mathReadRel(rel) {
|
|
2783
|
+
const r = mathRelPath(rel)
|
|
2784
|
+
if (r === null) return undefined
|
|
2785
|
+
return await readTextRel(r)
|
|
2786
|
+
}
|
|
2787
|
+
async function mathExistsRel(rel) {
|
|
2788
|
+
const r = mathRelPath(rel)
|
|
2789
|
+
if (r === null) return false
|
|
2790
|
+
try {
|
|
2791
|
+
const t = await fs.resolve(instRoot() + '/' + r)
|
|
2792
|
+
return (await fs.stat(t)) !== undefined
|
|
2793
|
+
} catch (e) { return false }
|
|
2794
|
+
}
|
|
2795
|
+
// Optional host seam (P2a): the module uses it for the per-project retention cap. `''`/'.'
|
|
2796
|
+
// means the project root itself.
|
|
2797
|
+
async function mathListDirRel(rel) {
|
|
2798
|
+
const raw = String(rel == null ? '' : rel).trim()
|
|
2799
|
+
const r = raw === '' || raw === '.' ? '' : mathRelPath(raw)
|
|
2800
|
+
if (r === null) return []
|
|
2801
|
+
try {
|
|
2802
|
+
const t = await fs.resolve(instRoot() + (r ? '/' + r : ''))
|
|
2803
|
+
return (await fs.listDir(t)) || []
|
|
2804
|
+
} catch (e) { return [] }
|
|
2805
|
+
}
|
|
2806
|
+
async function mathResolveExecutable(cmd) {
|
|
2807
|
+
const sub = subprocessOf()
|
|
2808
|
+
if (sub === undefined || typeof sub.resolveExecutable !== 'function') throw new Error('NO_SUBPROCESS: the host exposes no subprocess service')
|
|
2809
|
+
const p = await sub.resolveExecutable(String(cmd))
|
|
2810
|
+
if (typeof p === 'string' && p) return p
|
|
2811
|
+
throw new Error('cannot resolve executable: ' + String(cmd))
|
|
2812
|
+
}
|
|
2813
|
+
// The module calls this with {argv, cwd, timeoutMs, stdoutCap, stderrCap} and expects the
|
|
2814
|
+
// FULL captured output (it writes the complete text to disk and only truncates the RETURN
|
|
2815
|
+
// shell itself, MATH_CAPS.stdout = 64KB) — so the stdio caps must be generous, never the
|
|
2816
|
+
// return-shell size. A host without a subprocess service returns null instead of throwing:
|
|
2817
|
+
// the module maps that to MATH_NO_SUBPROCESS (its `if (!r)` branch).
|
|
2818
|
+
async function mathSpawn(opts) {
|
|
2819
|
+
const o = opts || {}
|
|
2820
|
+
const sub = subprocessOf()
|
|
2821
|
+
if (sub === undefined || typeof sub.spawn !== 'function') return null
|
|
2822
|
+
const argv = (o.argv || []).map(String)
|
|
2823
|
+
if (!argv.length) return null
|
|
2824
|
+
const started = now()
|
|
2825
|
+
const cap = Math.max(1000, Math.floor(Number(o.timeoutMs) || Number(params.mathTimeoutMs) || 60000))
|
|
2826
|
+
const outCap = Math.max(MATH_CAPS.stdout, Math.floor(Number(o.stdoutCap) || MATH_CAPS.file))
|
|
2827
|
+
const errCap = Math.max(MATH_CAPS.stderr, Math.floor(Number(o.stderrCap) || MATH_CAPS.file))
|
|
2828
|
+
let handle = null
|
|
2829
|
+
try {
|
|
2830
|
+
handle = sub.spawn({
|
|
2831
|
+
argv,
|
|
2832
|
+
cwd: String(o.cwd || instRoot()),
|
|
2833
|
+
stdio: {
|
|
2834
|
+
stdin: o.stdin === undefined ? 'ignore' : 'pipe',
|
|
2835
|
+
stdout: { maxBytes: outCap },
|
|
2836
|
+
stderr: { maxBytes: errCap },
|
|
2837
|
+
},
|
|
2838
|
+
graceMs: cap,
|
|
2839
|
+
})
|
|
2840
|
+
} catch (e) {
|
|
2841
|
+
return { exit: null, timedOut: false, killed: false, ms: now() - started, stdout: '', stderr: String((e && e.message) || e) }
|
|
2842
|
+
}
|
|
2843
|
+
// A host whose spawn answers nothing usable (no service, refused, null handle) must reach
|
|
2844
|
+
// the module's `if (!r)` branch as MATH_NO_SUBPROCESS instead of throwing here.
|
|
2845
|
+
if (!handle) return null
|
|
2846
|
+
if (o.stdin !== undefined && handle && handle.stdin && typeof handle.stdin.write === 'function') {
|
|
2847
|
+
try { handle.stdin.write(String(o.stdin)); if (typeof handle.stdin.end === 'function') handle.stdin.end() } catch (e) { /* best effort */ }
|
|
2848
|
+
}
|
|
2849
|
+
// A TIMEOUT must actually KILL the process: `graceMs` is only a request to the host.
|
|
2850
|
+
let timerDisposer = null
|
|
2851
|
+
let timedOut = false
|
|
2852
|
+
const ran = Promise.resolve(handle.done).then(
|
|
2853
|
+
(v) => ({ settled: true, value: v }),
|
|
2854
|
+
(e) => ({ settled: false, error: e }))
|
|
2855
|
+
let outcome = null
|
|
2856
|
+
try {
|
|
2857
|
+
const r = await Promise.race([
|
|
2858
|
+
ran,
|
|
2859
|
+
new Promise((resolve) => {
|
|
2860
|
+
timerDisposer = ctx.timeout(() => {
|
|
2861
|
+
timedOut = true
|
|
2862
|
+
try { if (typeof handle.terminate === 'function') handle.terminate() } catch (e) { /* the race result is the report */ }
|
|
2863
|
+
resolve({ settled: true, value: { exitCode: null, signal: 'SIGTERM' } })
|
|
2864
|
+
}, cap)
|
|
2865
|
+
}),
|
|
2866
|
+
])
|
|
2867
|
+
if (r.settled) outcome = r.value
|
|
2868
|
+
} catch (e) {
|
|
2869
|
+
return { exit: null, timedOut, killed: timedOut, ms: now() - started, stdout: '', stderr: String((e && e.message) || e) }
|
|
2870
|
+
} finally {
|
|
2871
|
+
if (timerDisposer) { try { timerDisposer() } catch (e) { /* already settled */ } }
|
|
2872
|
+
}
|
|
2873
|
+
let out = '', err = ''
|
|
2874
|
+
try { if (handle.collected && handle.collected.stdout) out = handle.collected.stdout.readFrom(0).text } catch (e) { /* best effort */ }
|
|
2875
|
+
try { if (handle.collected && handle.collected.stderr) err = handle.collected.stderr.readFrom(0).text } catch (e) { /* best effort */ }
|
|
2876
|
+
const signal = (outcome && outcome.signal) || null
|
|
2877
|
+
return {
|
|
2878
|
+
exit: outcome && outcome.exitCode !== undefined ? outcome.exitCode : null,
|
|
2879
|
+
timedOut,
|
|
2880
|
+
killed: timedOut || !!signal,
|
|
2881
|
+
ms: now() - started,
|
|
2882
|
+
stdout: out,
|
|
2883
|
+
stderr: err,
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
function mathLog(kind, msg) {
|
|
2887
|
+
try { saveChatLine('【计算】' + String(msg == null ? '' : msg)) } catch (e) { /* a log line must never break a run */ }
|
|
2888
|
+
}
|
|
2889
|
+
// The per-session host the module talks to (also used by the availability-line probe, which
|
|
2890
|
+
// runs OUTSIDE a tool call and therefore cannot rely on an ambient "current session").
|
|
2891
|
+
const mathSessionHost = {
|
|
2892
|
+
register: () => {},
|
|
2893
|
+
params: () => Object.assign({}, params),
|
|
2894
|
+
projectRoot: () => instRoot(),
|
|
2895
|
+
designator: 'vibe-math-v5',
|
|
2896
|
+
writeText: (rel, text) => mathWriteRel(rel, text),
|
|
2897
|
+
readText: (rel) => mathReadRel(rel),
|
|
2898
|
+
exists: (rel) => mathExistsRel(rel),
|
|
2899
|
+
resolveExecutable: (cmd) => mathResolveExecutable(cmd),
|
|
2900
|
+
spawn: (opts) => mathSpawn(opts),
|
|
2901
|
+
// P2a optional fields: tell the module up front whether a compiler can run at all (so it
|
|
2902
|
+
// reports MATH_NO_SUBPROCESS before engine detection), and let it check the retention cap.
|
|
2903
|
+
hasSubprocess: () => { const sub = subprocessOf(); return !!(sub && typeof sub.spawn === 'function') },
|
|
2904
|
+
listDir: (rel) => mathListDirRel(rel),
|
|
2905
|
+
log: (kind, msg) => mathLog(kind, msg),
|
|
2906
|
+
}
|
|
2907
|
+
// The DYNAMIC per-round availability line: computed from a (cached) probe when the prompt is
|
|
2908
|
+
// built, never frozen into the persona. `mathComputation:'off'` produces NO line at all
|
|
2909
|
+
// (the zero-mention discipline); the mode decides whether the shell-fallback sentence is in.
|
|
2910
|
+
let mathLineZh = ''
|
|
2911
|
+
let mathLineEn = ''
|
|
2912
|
+
async function refreshMathLine() {
|
|
2913
|
+
if (String(params.mathComputation) === 'off') { mathLineZh = ''; mathLineEn = ''; return '' }
|
|
2914
|
+
try {
|
|
2915
|
+
const probe = await probeMathEngines(mathSessionHost)
|
|
2916
|
+
mathLineZh = mathAvailabilityLine(probe, 'zh', String(params.mathMode))
|
|
2917
|
+
mathLineEn = mathAvailabilityLine(probe, 'en', String(params.mathMode))
|
|
2918
|
+
// Drift guard: the shared line must still carry the frozen rule text (a future module
|
|
2919
|
+
// edit that drops the rules would silently remove a rule from every round prompt).
|
|
2920
|
+
if (mathLineZh.indexOf(MATH_RULE_LINES[0]) === -1) console.error('vibe-math-v5: the math availability line lost the frozen rules')
|
|
2921
|
+
if (mathLineEn.indexOf(MATH_RULE_LINES_EN[0]) === -1) console.error('vibe-math-v5: the EN math availability line lost the frozen rules')
|
|
2922
|
+
// P2a: the archive→edit→re-run workflow rule must reach the prompt too (it is what makes
|
|
2923
|
+
// `scriptChanged` actionable for a member).
|
|
2924
|
+
if (mathLineZh.indexOf(MATH_ARCHIVE_WORKFLOW_LINE) === -1) console.error('vibe-math-v5: the math availability line lost the archive workflow rule')
|
|
2925
|
+
if (mathLineEn.indexOf(MATH_ARCHIVE_WORKFLOW_LINE_EN) === -1) console.error('vibe-math-v5: the EN math availability line lost the archive workflow rule')
|
|
2926
|
+
return mathLineZh
|
|
2927
|
+
} catch (e) {
|
|
2928
|
+
// A probe failure must never break prompt construction: keep whatever we had.
|
|
2929
|
+
return mathLineZh
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
// The tool section of a member prompt: the availability line + the rules (both come from
|
|
2933
|
+
// the module, so all four presets stay byte-comparable).
|
|
2934
|
+
function mathPromptBlock(lang) {
|
|
2935
|
+
const line = lang === 'en' ? mathLineEn : mathLineZh
|
|
2936
|
+
if (line) return '\n' + line
|
|
2937
|
+
// Fallback: when the dynamic line could not be built, the member still needs to know the
|
|
2938
|
+
// tool exists, how to cite it, and (P2a) that an edited script needs a NEW receipt.
|
|
2939
|
+
if (String(params.mathComputation) === 'off') return ''
|
|
2940
|
+
return lang === 'en'
|
|
2941
|
+
? '\n' + MATH_PERSONA_TOOL_LINE + '\n' + MATH_ARCHIVE_WORKFLOW_LINE_EN
|
|
2942
|
+
: '\n' + MATH_PERSONA_TOOL_LINE + '\n' + MATH_ARCHIVE_WORKFLOW_LINE
|
|
2943
|
+
}
|
|
2944
|
+
// Push the tool section into a prompt under construction (a no-op in the 'off' mode, which
|
|
2945
|
+
// is the "off means zero mention" discipline).
|
|
2946
|
+
function mathPushLine(L) { const b = mathPromptBlock('zh'); if (b) L.push(b) }
|
|
2947
|
+
|
|
2094
2948
|
function formalPromptBlock(target) {
|
|
2095
2949
|
if (!formalOn()) return ''
|
|
2096
2950
|
const mode = formalMode()
|
|
@@ -2124,6 +2978,9 @@ export function apply(ctx) {
|
|
|
2124
2978
|
L.push(' 请复核这个判断是否成立;若你认为其实可以形式化,请指出来并动手做。')
|
|
2125
2979
|
} else {
|
|
2126
2980
|
L.push(' · 请先判断该对象的**实现难度**:若能在可接受的工作量内形式化,优先写 Lean 代码并执行。')
|
|
2981
|
+
// D4 criteria + async honesty (verbatim prompt text, docs/formal-verification.md §6).
|
|
2982
|
+
L.push(' · 形式化只写你有把握的版本;没把握就记 blocked 并写清难点——不要用形式化掩盖不确定。')
|
|
2983
|
+
L.push(' · 该对象若已有后台编译在队列中,**不得**在它落地前声称已通过或走忠实性审查;等 vibe_v5_lean_lib 显示 passed 再审。')
|
|
2127
2984
|
L.push(' · 工具:vibe_v5_lean_run(执行)· vibe_v5_lean_archive(归档)· vibe_v5_lean_lib(查已有可复用库)')
|
|
2128
2985
|
L.push(' · 工作目录:Formal/(相对研究所根);可复用定义放 ' + formalLibRoot().replace(/\\/g, '/') + '/,')
|
|
2129
2986
|
L.push(' 已证引理放 ' + formalProvedRoot().replace(/\\/g, '/') + '/;写之前先 vibe_v5_lean_lib 查重。')
|
|
@@ -2144,11 +3001,21 @@ export function apply(ctx) {
|
|
|
2144
3001
|
return L.join('\n')
|
|
2145
3002
|
}
|
|
2146
3003
|
function formalWorkLine() {
|
|
2147
|
-
if (!
|
|
2148
|
-
|
|
3004
|
+
if (!leanDailyOn()) return ''
|
|
3005
|
+
const eager = leanInitiative() === 'eager'
|
|
3006
|
+
return '【顺手形式化(' + (formalOn() ? (formalMode() === 'require' ? '强制' : '鼓励') : '主动(leanInitiative=eager)') + ')】'
|
|
3007
|
+
+ (eager ? '**主动**把工作中出现的有价值的小引理、命题、定义顺手形式化并存库;' : '')
|
|
3008
|
+
+ '把你工作中常用或可能复用的对象、假设、'
|
|
2149
3009
|
+ '新定义用 Lean 形式化定义并归档到全局可复用库(vibe_v5_lean_archive kind=\'def\'),已成立的引理归到 Proved/'
|
|
2150
3010
|
+ '(kind=\'lemma\');写之前先 vibe_v5_lean_lib 查重,避免重复定义。'
|
|
2151
3011
|
+ '归档前先跑通(vibe_v5_lean_run 或 run=true);跑不通的定义不要进可复用库。'
|
|
3012
|
+
+ '\n · 判断标准:① 有价值或可能复用;② 较为关键或必要;③ 你对该陈述有把握(置信度高)。**没把握的先别入库**——'
|
|
3013
|
+
+ '进了 Formal/Proved 的东西会被当成已核对引理;没把握就记 blocked 并写清难点,别用形式化掩盖不确定。'
|
|
3014
|
+
+ '\n · 复用优先:写新定义/证明前**先 vibe_v5_lean_lib 查已有库**;复用已归档内容用 '
|
|
3015
|
+
+ '`import Formal.Lib.<name>` / `import Formal.Proved.<name>`(模块根 = <VibeMath 根>,框架已把它加进编译搜索路径),'
|
|
3016
|
+
+ '或 `vibe_v5_lean_read {name}` 取原文逐字复制。**查不到再新写**;同内容重复归档会自动去重。'
|
|
3017
|
+
+ '\n · 编译默认走后台队列(leanAsync=true):入队后你可以继续工作;用 vibe_v5_lean_lib 的 jobs 字段或下一轮提示里的 '
|
|
3018
|
+
+ '【形式化结果】行看结果。**在作业落地为“通过”之前,不得把该对象当成已通过。**'
|
|
2152
3019
|
+ (formalMode() === 'require'
|
|
2153
3020
|
? '本模式下,任何要定论为真/假的对象都必须先有 Lean 通过或显式阻塞记录。'
|
|
2154
3021
|
: '这会让后续的验证与证明省掉大量重复工作。')
|
|
@@ -2201,7 +3068,10 @@ export function apply(ctx) {
|
|
|
2201
3068
|
const txt = (await readTextAbs(dirAbs + '/' + e.name)) || ''
|
|
2202
3069
|
const name = String(e.name).replace(/\.lean$/, '')
|
|
2203
3070
|
const first = (txt.split('\n').filter((l) => l.trim() && !/^\s*(\/\/|--|import)/.test(l))[0] || '').trim().slice(0, 110)
|
|
2204
|
-
|
|
3071
|
+
// Dependency column (docs/formal-verification.md §9): what this file imports — the
|
|
3072
|
+
// answer to "can I reuse it, and what does it drag in?".
|
|
3073
|
+
const depend = (txt.split('\n').filter((l) => /^\s*import\s+/.test(l)).map((l) => l.replace(/^\s*import\s+/, '').trim()).join('、')) || '—'
|
|
3074
|
+
rows.push('| ' + name + ' | ' + rel + ' | ' + kindLabel + ' | ' + first.replace(/\|/g, '/') + ' | ' + depend.replace(/\|/g, '/') + ' |')
|
|
2205
3075
|
}
|
|
2206
3076
|
} catch (e) { /* listing is best-effort */ }
|
|
2207
3077
|
return rows
|
|
@@ -2209,13 +3079,13 @@ export function apply(ctx) {
|
|
|
2209
3079
|
const libRows = await scan(formalLibRoot(), 'Formal/Lib', 'def')
|
|
2210
3080
|
await writeTextAbs(vibeRoot() + '/Formal/Lib/Index.md', ['# 可复用 Lean 定义库(跨项目)|' + instituteName, '',
|
|
2211
3081
|
'> 写新定义之前先查这里:能复用就不要重新定义。', '',
|
|
2212
|
-
'| 名称 | 文件 | 类别 | 摘要 |', '
|
|
2213
|
-
.concat(libRows.length ? libRows : ['| (暂无) | | | |']).join('\n') + '\n')
|
|
3082
|
+
'| 名称 | 文件 | 类别 | 摘要 | 依赖 |', '|---|---|---|---|---|']
|
|
3083
|
+
.concat(libRows.length ? libRows : ['| (暂无) | | | | |']).join('\n') + '\n')
|
|
2214
3084
|
const provedRows = await scan(formalProvedRoot(), 'Formal/Proved', 'lemma')
|
|
2215
3085
|
await writeTextAbs(vibeRoot() + '/Formal/Proved/Index.md', ['# 已成立的 Lean 命题 / 引理(机器已核对,可跨项目复用)|' + instituteName, '',
|
|
2216
3086
|
'> 这些文件是通过内核检查的引理,可直接 import 复用。', '',
|
|
2217
|
-
'| 名称 | 文件 | 类别 | 陈述 |', '
|
|
2218
|
-
.concat(provedRows.length ? provedRows : ['| (暂无) | | | |']).join('\n') + '\n')
|
|
3087
|
+
'| 名称 | 文件 | 类别 | 陈述 | 依赖 |', '|---|---|---|---|---|']
|
|
3088
|
+
.concat(provedRows.length ? provedRows : ['| (暂无) | | | | |']).join('\n') + '\n')
|
|
2219
3089
|
await writeFormalIndex()
|
|
2220
3090
|
await writeFormalTodo()
|
|
2221
3091
|
return { lib: libRows.length, proved: provedRows.length, objects: Object.keys(formalRecords()).length }
|
|
@@ -2227,9 +3097,30 @@ export function apply(ctx) {
|
|
|
2227
3097
|
// Execute one Lean file through the toolchain, record the run (optionally against an
|
|
2228
3098
|
// object), refresh the indexes, and report the outcome verbatim. Deliberately called
|
|
2229
3099
|
// even from `off` mode: a human debugging their toolchain may want it.
|
|
3100
|
+
// `leanAsync` (default): ENQUEUE and return immediately — the compiler has not been
|
|
3101
|
+
// started when this returns (docs/formal-verification.md §7-2). `false`: today's await.
|
|
2230
3102
|
async function leanRunTool(memberId, o) {
|
|
2231
3103
|
const args = o || {}
|
|
2232
|
-
const
|
|
3104
|
+
const rel = String(args.file || '')
|
|
3105
|
+
if (params.leanAsync !== false) {
|
|
3106
|
+
const pre = await leanPrecheck(rel)
|
|
3107
|
+
if (pre.ok === false) return Object.assign({ async: null }, pre)
|
|
3108
|
+
const fileText = await readTextAbs(pre.abs)
|
|
3109
|
+
const text = fileText === undefined ? '' : String(fileText)
|
|
3110
|
+
const pfx = await leanRunPrefix()
|
|
3111
|
+
const target = idSafe(String(args.target || ''))
|
|
3112
|
+
const job = await enqueueLeanJob({
|
|
3113
|
+
jobId: leanJobId(target || pre.rel, leanBuildDigest(text, pfx.prefix)), kind: 'run', rel: pre.rel, file: pre.rel,
|
|
3114
|
+
target, member: memberId, contentSha256: sha256Hex(leanHashText(text)), buildHash: leanBuildHash(pfx.prefix),
|
|
3115
|
+
buildPrefix: pfx.prefix, timeoutMs: args.timeout_ms,
|
|
3116
|
+
})
|
|
3117
|
+
return {
|
|
3118
|
+
ok: true, async: { jobId: job.jobId, state: 'queued' }, jobId: job.jobId,
|
|
3119
|
+
file: pre.rel, target: target || undefined,
|
|
3120
|
+
message: '已入队后台编译;你可以继续工作。结果会写入 Formal/ 与索引,并在下一轮提示里公告;也可用 vibe_v5_lean_lib 的 jobs 字段随时查看。',
|
|
3121
|
+
}
|
|
3122
|
+
}
|
|
3123
|
+
const run = await leanRunFile(rel, args.timeout_ms)
|
|
2233
3124
|
if (run.ok || run.file) {
|
|
2234
3125
|
if (String(args.target || '').trim()) await formalSetRun(String(args.target), run)
|
|
2235
3126
|
await writeFormalIndex()
|
|
@@ -2239,11 +3130,46 @@ export function apply(ctx) {
|
|
|
2239
3130
|
+ '(' + (run.ms / 1000).toFixed(1) + 's)' + (args.target ? '|对象 ' + args.target + ' 记为已尝试(若此前已通过/已阻塞则保留原状态)' : ''))
|
|
2240
3131
|
}
|
|
2241
3132
|
return Object.assign({ ok: !!run.ok }, run, {
|
|
3133
|
+
async: null,
|
|
2242
3134
|
hint: run.ok
|
|
2243
3135
|
? '通过。若是某个对象的证明,请用 vibe_v5_lean_archive kind=\'proof\' 归档(会写入 Verified/Lean/ 并把审查对象变成忠实性);若是可复用定义/引理,用 kind=\'def\'/\'lemma\' 归档到全局库。'
|
|
2244
3136
|
: '未通过。请按上面的编译器输出修复后重跑;若判断无法完成,用 vibe_v5_lean_archive kind=\'blocked\' 记录原因。',
|
|
2245
3137
|
})
|
|
2246
3138
|
}
|
|
3139
|
+
// Read an ARCHIVED definition/lemma verbatim (docs/formal-verification.md §5.4): the reuse
|
|
3140
|
+
// path is `lean_lib` (what exists) → `lean_read` (its exact text) → `import` or copy.
|
|
3141
|
+
async function leanRead(o) {
|
|
3142
|
+
const args = o || {}
|
|
3143
|
+
const raw = String(args.name || '').trim().replace(/\.lean$/i, '')
|
|
3144
|
+
if (!raw) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'name is required' }
|
|
3145
|
+
// Reject a path-shaped name outright: the guard must refuse `../`, absolute paths and
|
|
3146
|
+
// any sub-directory, not silently sanitise them into a different file.
|
|
3147
|
+
if (raw.indexOf('/') !== -1 || raw.indexOf('\\') !== -1 || raw.indexOf('..') !== -1 || /^[a-z]:/i.test(raw)) {
|
|
3148
|
+
return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'name must be a plain archived file name (no path separators, no .., no absolute path)' }
|
|
3149
|
+
}
|
|
3150
|
+
const name = idSafe(raw)
|
|
3151
|
+
if (!name || name !== raw) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'name is not a valid archived file name' }
|
|
3152
|
+
const kindArg = String(args.kind || 'auto')
|
|
3153
|
+
if (['auto', 'lib', 'proved'].indexOf(kindArg) === -1) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: "kind must be 'auto' | 'lib' | 'proved'" }
|
|
3154
|
+
const dirs = kindArg === 'auto' ? ['Lib', 'Proved'] : [kindArg === 'lib' ? 'Lib' : 'Proved']
|
|
3155
|
+
for (const d of dirs) {
|
|
3156
|
+
const rel = 'Formal/' + d + '/' + name + '.lean'
|
|
3157
|
+
const abs = leanAbsPath(instRootless(rel))
|
|
3158
|
+
if (abs === null) continue
|
|
3159
|
+
const txt = await readTextAbs(abs)
|
|
3160
|
+
if (txt === undefined) continue
|
|
3161
|
+
const full = String(txt)
|
|
3162
|
+
const truncated = full.length > LEAN_READ_MAX_BYTES
|
|
3163
|
+
return {
|
|
3164
|
+
ok: true, name, file: rel, kind: d === 'Lib' ? 'lib' : 'proved',
|
|
3165
|
+
sha256: sha256Hex(leanHashText(full)),
|
|
3166
|
+
bytes: new TextEncoder().encode(full).length,
|
|
3167
|
+
text: truncated ? full.slice(0, LEAN_READ_MAX_BYTES) : full,
|
|
3168
|
+
truncated,
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
return { ok: false, code: 'V5_NOT_FOUND', message: 'no archived ' + (kindArg === 'auto' ? 'definition/lemma' : kindArg) + ' named ' + name }
|
|
3172
|
+
}
|
|
2247
3173
|
async function leanArchive(memberId, o) {
|
|
2248
3174
|
const args = o || {}
|
|
2249
3175
|
const kind = String(args.kind || '')
|
|
@@ -2260,15 +3186,46 @@ export function apply(ctx) {
|
|
|
2260
3186
|
}
|
|
2261
3187
|
if (body === undefined) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'provide content, or from=<existing .lean file>' }
|
|
2262
3188
|
const rel = 'Formal/' + (kind === 'def' ? 'Lib' : 'Proved') + '/' + name + '.lean'
|
|
3189
|
+
const sha = sha256Hex(leanHashText(String(body)))
|
|
3190
|
+
// Content-hash dedupe (docs/formal-verification.md §5.3): the file already holds EXACTLY
|
|
3191
|
+
// this content AND the recorded run for that content succeeded ⇒ skip rewrite + compile.
|
|
3192
|
+
const fileSha = await leanHashFile(instRootless(rel))
|
|
3193
|
+
if (fileSha === sha && await leanLibArchivedOk(name, sha)) {
|
|
3194
|
+
return { ok: true, deduped: true, kind, name, file: rel, sha256: sha, note: '内容与已归档版本一致,跳过重写与重编译' }
|
|
3195
|
+
}
|
|
2263
3196
|
const okWrite = await writeTextAbs(instRootless(rel), body)
|
|
2264
3197
|
if (!okWrite) return { ok: false, code: 'V5_WRITE_FAILED', message: 'could not write ' + rel }
|
|
2265
|
-
|
|
2266
|
-
|
|
3198
|
+
if (args.run !== false && params.leanAsync !== false) {
|
|
3199
|
+
// The global library sits beside the project tree, so it is executed through its
|
|
3200
|
+
// ABSOLUTE path (the relative form would resolve inside the institute root).
|
|
3201
|
+
const pre = await leanPrecheck(instRootless(rel))
|
|
3202
|
+
if (pre.ok === true) {
|
|
3203
|
+
const pfx = await leanRunPrefix()
|
|
3204
|
+
const job = await enqueueLeanJob({
|
|
3205
|
+
jobId: leanJobId(name, leanBuildDigest(String(body), pfx.prefix)), kind: 'lib', rel: instRootless(rel), file: rel, name,
|
|
3206
|
+
member: memberId, contentSha256: sha, buildHash: leanBuildHash(pfx.prefix), buildPrefix: pfx.prefix, content: String(body),
|
|
3207
|
+
})
|
|
3208
|
+
await rebuildLeanLibIndexes()
|
|
3209
|
+
await saveChatLine('【形式化】' + memberId + ' 归档了' + (kind === 'def' ? '可复用定义' : '已证引理') + ' `' + name + '` → ' + rel + '(已入队后台编译)')
|
|
3210
|
+
return { ok: true, kind, name, file: rel, sha256: sha, async: { jobId: job.jobId, state: 'queued' }, jobId: job.jobId, note: '已并入全局可复用库(后台编译中):用 vibe_v5_lean_lib 的 jobs 字段查看结果' }
|
|
3211
|
+
}
|
|
3212
|
+
// No usable toolchain on this host: fall through to the synchronous path so the honest
|
|
3213
|
+
// NO_SUBPROCESS / LEAN_NOT_FOUND result is recorded exactly as before.
|
|
3214
|
+
}
|
|
2267
3215
|
const run = args.run === false ? null : await leanRunFile(instRootless(rel))
|
|
3216
|
+
// Mirror the outcome as a job record: later re-archives of the SAME content dedupe
|
|
3217
|
+
// against it (and it is what `Formal/Jobs/` recovery scans).
|
|
3218
|
+
if (run) await leanWriteJob({
|
|
3219
|
+
jobId: leanJobId(name, sha), kind: 'lib', rel: instRootless(rel), file: rel, name,
|
|
3220
|
+
member: memberId, contentSha256: sha, state: run.ok ? 'settled' : (run.timedOut ? 'timeout' : 'failed'),
|
|
3221
|
+
attempts: 1, enqueuedAt: now(), startedAt: now(), settledAt: now(),
|
|
3222
|
+
exitCode: run.exitCode === undefined ? null : run.exitCode, ok: !!run.ok,
|
|
3223
|
+
timedOut: !!run.timedOut, interrupted: false,
|
|
3224
|
+
})
|
|
2268
3225
|
await rebuildLeanLibIndexes()
|
|
2269
3226
|
await saveChatLine('【形式化】' + memberId + ' 归档了' + (kind === 'def' ? '可复用定义' : '已证引理') + ' `' + name + '` → ' + rel
|
|
2270
3227
|
+ (run ? '(运行 ' + (run.ok ? '通过' : '未通过') + ')' : ''))
|
|
2271
|
-
return { ok: true, kind, name, file: rel, run: run || undefined, note: '已并入全局可复用库,后续项目可直接 import 复用' }
|
|
3228
|
+
return { ok: true, kind, name, file: rel, sha256: sha, async: null, run: run || undefined, note: '已并入全局可复用库,后续项目可直接 import 复用' }
|
|
2272
3229
|
}
|
|
2273
3230
|
if (kind === 'proof') {
|
|
2274
3231
|
const target = idSafe(String(args.target || ''))
|
|
@@ -2281,9 +3238,41 @@ export function apply(ctx) {
|
|
|
2281
3238
|
}
|
|
2282
3239
|
if (body === undefined) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'provide content, or from=<existing .lean file>' }
|
|
2283
3240
|
const workRel = 'Formal/' + target + '.lean'
|
|
3241
|
+
const sha = sha256Hex(leanHashText(String(body)))
|
|
3242
|
+
const prev = formalOf(target)
|
|
3243
|
+
// Dedupe (§5.3): the object is already `passed` AND the work file still holds EXACTLY
|
|
3244
|
+
// this content ⇒ the compile result is already known: skip rewrite + recompile.
|
|
3245
|
+
if (prev.status === 'passed' && (await leanHashFile(workRel)) === sha) {
|
|
3246
|
+
return { ok: true, deduped: true, kind, target, file: workRel, sha256: sha, proof: prev.proof || ('Verified/Lean/' + target + '.lean'), note: '内容与已归档版本一致,跳过重写与重编译' }
|
|
3247
|
+
}
|
|
2284
3248
|
if (!await writeTextRel(workRel, body)) return { ok: false, code: 'V5_WRITE_FAILED', message: 'could not write ' + workRel }
|
|
3249
|
+
if (params.leanAsync !== false) {
|
|
3250
|
+
const pre = await leanPrecheck(workRel)
|
|
3251
|
+
if (pre.ok === true) {
|
|
3252
|
+
const pfx = await leanRunPrefix()
|
|
3253
|
+
const job = await enqueueLeanJob({
|
|
3254
|
+
jobId: leanJobId(target, leanBuildDigest(String(body), pfx.prefix)), kind: 'proof', rel: workRel, file: workRel, target,
|
|
3255
|
+
member: memberId, contentSha256: sha, buildHash: leanBuildHash(pfx.prefix), buildPrefix: pfx.prefix, content: String(body),
|
|
3256
|
+
})
|
|
3257
|
+
// The object is `attempted` while the job is queued: it may only become `passed`
|
|
3258
|
+
// when the job settles ok (exit 0 + unchanged hash). A previous proof pointer is
|
|
3259
|
+
// dropped because the file it referred to has just been overwritten.
|
|
3260
|
+
const rec0 = Object.assign({}, prev, {
|
|
3261
|
+
status: prev.status === 'blocked' ? 'blocked' : 'attempted',
|
|
3262
|
+
file: workRel, proof: '',
|
|
3263
|
+
decision: 'used', note: String(args.note || prev.note || ''),
|
|
3264
|
+
async: { jobId: job.jobId, state: 'queued', attempts: 0, enqueuedAt: job.enqueuedAt, startedAt: 0, settledAt: 0, exitCode: null },
|
|
3265
|
+
updatedAt: now(),
|
|
3266
|
+
})
|
|
3267
|
+
await putFormal(target, rec0)
|
|
3268
|
+
await rebuildLeanLibIndexes()
|
|
3269
|
+
await saveChatLine('【形式化】' + memberId + ' 为 ' + target + ' 归档形式化证明 ' + workRel
|
|
3270
|
+
+ '(已入队后台编译;落地为“通过”之前**不会**写 Verified/Lean/、也不会转为忠实性审查)')
|
|
3271
|
+
return { ok: true, kind, target, file: workRel, sha256: sha, async: { jobId: job.jobId, state: 'queued' }, jobId: job.jobId, status: rec0.status, note: '已入队后台编译;只有作业落地为“通过”才会写 Verified/Lean/ 并转为忠实性审查' }
|
|
3272
|
+
}
|
|
3273
|
+
// No toolchain: fall through to the synchronous path (honest NO_SUBPROCESS record).
|
|
3274
|
+
}
|
|
2285
3275
|
const run = await leanRunFile(workRel)
|
|
2286
|
-
const prev = formalOf(target)
|
|
2287
3276
|
const passed = !!run.ok
|
|
2288
3277
|
const rec = Object.assign({}, prev, {
|
|
2289
3278
|
status: passed ? 'passed' : 'attempted',
|
|
@@ -2297,6 +3286,7 @@ export function apply(ctx) {
|
|
|
2297
3286
|
decision: 'used',
|
|
2298
3287
|
note: String(args.note || prev.note || ''),
|
|
2299
3288
|
run: { at: now(), ok: !!run.ok, exitCode: run.exitCode === undefined ? null : run.exitCode, ms: run.ms || 0, stdoutTail: tail(run.stdout, 800), stderrTail: tail(run.stderr, 800) },
|
|
3289
|
+
async: null,
|
|
2300
3290
|
updatedAt: now(),
|
|
2301
3291
|
})
|
|
2302
3292
|
if (passed) await writeTextRel('Verified/Lean/' + target + '.lean', body)
|
|
@@ -2304,7 +3294,7 @@ export function apply(ctx) {
|
|
|
2304
3294
|
await rebuildLeanLibIndexes()
|
|
2305
3295
|
await saveChatLine('【形式化】' + memberId + ' 为 ' + target + ' 归档形式化证明 ' + workRel
|
|
2306
3296
|
+ '(运行 ' + (passed ? '**通过**,已归档到 ' + rec.proof + ',验证转为忠实性审查' : '**未通过**:' + tail(run.stderr || run.message, 160)) + ')')
|
|
2307
|
-
return { ok: true, kind, target, file: workRel, proof: rec.proof, passed, run, status: rec.status }
|
|
3297
|
+
return { ok: true, kind, target, file: workRel, sha256: sha, proof: rec.proof, passed, run, status: rec.status, async: null }
|
|
2308
3298
|
}
|
|
2309
3299
|
if (kind === 'blocked') {
|
|
2310
3300
|
const target = idSafe(String(args.target || ''))
|
|
@@ -3311,6 +4301,11 @@ export function apply(ctx) {
|
|
|
3311
4301
|
solveVotes.clear()
|
|
3312
4302
|
await putMeeting({ id, agenda: meeting.agenda, kind: meeting.kind, at: now(), file: 'Shared/Meetings/' + id + '.md' })
|
|
3313
4303
|
await saveChatLine('【会议 ' + id + '】召开:' + meeting.agenda + '(类型:' + meeting.kind + '|召集人:' + meeting.by + ')')
|
|
4304
|
+
// A meeting that ACTUALLY begins and was convened by the office is the other half of the
|
|
4305
|
+
// `paperEditor='office'` consultation requirement (docs/final-paper.md §7). Counted here, not at the
|
|
4306
|
+
// request: a parked/refused request is not a consultation. A FRAMEWORK-convened stall
|
|
4307
|
+
// meeting (`auto:true`) is not the office's own act, so it does not count either.
|
|
4308
|
+
if (meeting.by === 'office' && opts.auto !== true) await notePaperConsult('meeting')
|
|
3314
4309
|
await mkdirs()
|
|
3315
4310
|
await writeTextRel('Shared/Meetings/' + id + '.md', [
|
|
3316
4311
|
'# 会议纪要|' + id + '|' + instituteName,
|
|
@@ -3429,9 +4424,24 @@ export function apply(ctx) {
|
|
|
3429
4424
|
// Stop ONLY on a unanimous true solve-vote from every VOTING member. There is no
|
|
3430
4425
|
// forced/flat/near-consensus closure: any objection keeps the institute working.
|
|
3431
4426
|
async function checkSolved() {
|
|
4427
|
+
if (autoDone) return true
|
|
3432
4428
|
const vs = voters().map((m) => m.id)
|
|
3433
4429
|
if (!vs.length) return false
|
|
3434
4430
|
if (!vs.every((id) => solveVotes.get(id) === true)) return false
|
|
4431
|
+
// ── THE FINAL PAPER PHASE COMES FIRST (docs/final-paper.md §3) ────────────────────────────────
|
|
4432
|
+
// `finishRun` flips phase='solved' / autoDone / running=false, after which the machinery
|
|
4433
|
+
// REFUSES: `wakeIfIdle` returns false, `startMeeting` rejects a concluded institute and
|
|
4434
|
+
// nothing schedules — so the co-writing, the cross-review and the office consultation
|
|
4435
|
+
// could never run. The run therefore stays alive here until the paper is finalised
|
|
4436
|
+
// (or degraded with a report), and `finalizePaper` calls `finishRun` for us.
|
|
4437
|
+
if (params.finalPaper !== false) {
|
|
4438
|
+
const p = paper()
|
|
4439
|
+
if (!p) { await startPaper('run-complete', {}); return true }
|
|
4440
|
+
if (p.status !== 'finalized') {
|
|
4441
|
+
if (!p.completesRun) await mutatePaper((cur) => (cur ? Object.assign({}, cur, { completesRun: true }) : cur))
|
|
4442
|
+
return true
|
|
4443
|
+
}
|
|
4444
|
+
}
|
|
3435
4445
|
await finishRun('全体有表决权者一致认为原问题已解决')
|
|
3436
4446
|
return true
|
|
3437
4447
|
}
|
|
@@ -3469,6 +4479,916 @@ export function apply(ctx) {
|
|
|
3469
4479
|
notifyActivity()
|
|
3470
4480
|
}
|
|
3471
4481
|
|
|
4482
|
+
// ================= final paper (docs/final-paper.md) ============
|
|
4483
|
+
// THE PHASE RUNS BEFORE `finishRun` (docs/final-paper.md §3). After `phase='solved'` the machinery
|
|
4484
|
+
// refuses: `wakeIfIdle` (:3257) returns false, `startMeeting` refuses a concluded
|
|
4485
|
+
// institute, and a concluded run has no scheduling at all — so the co-writing, the
|
|
4486
|
+
// cross-review and the office consultation could never run. `checkSolved` therefore
|
|
4487
|
+
// enters this phase while the run is still alive and only calls `finishRun` once the
|
|
4488
|
+
// paper is finalised (or degraded with a report).
|
|
4489
|
+
//
|
|
4490
|
+
// Only evidence that ALREADY exists is written down: verified verdicts, the task board,
|
|
4491
|
+
// the Lean records, the members' libraries and the members' own text. The framework never
|
|
4492
|
+
// invents content — sections 3/6/7/8 are the members' own words, 4/5/9 are derived from
|
|
4493
|
+
// durable state, and undecided/refuted items are always marked as such.
|
|
4494
|
+
const PAPER_FILE = (id, file) => 'Paper/' + id + '/' + file
|
|
4495
|
+
function paper() { return inst().paper || null }
|
|
4496
|
+
function paperActive() { const p = paper(); return !!p && p.status !== 'finalized' }
|
|
4497
|
+
function paperParticipants() {
|
|
4498
|
+
return activeMembers().filter((m) => m.kind === 'academician' || m.kind === 'researcher').map((m) => m.id)
|
|
4499
|
+
}
|
|
4500
|
+
function paperActiveParticipants(p) {
|
|
4501
|
+
return ((p && p.participants) || []).filter((id) => { const m = memberById(id); return !!m && m.phase === 'active' })
|
|
4502
|
+
}
|
|
4503
|
+
function paperConfiguredEditor() { return params.paperEditor === 'office' ? 'office' : 'academician' }
|
|
4504
|
+
function paperIdFromState() {
|
|
4505
|
+
const a = idSafe(instituteName)
|
|
4506
|
+
if (a) return a
|
|
4507
|
+
const b = idSafe(String(runId || (inst() && inst().runId) || ''))
|
|
4508
|
+
return b || 'institute'
|
|
4509
|
+
}
|
|
4510
|
+
async function fileExistsAbs(abs) {
|
|
4511
|
+
try { const t = await fsTargetAbs(abs); return (await fs.stat(t)) !== undefined } catch (e) { return false }
|
|
4512
|
+
}
|
|
4513
|
+
function paperAbs(id, file) { return instRoot() + '/' + PAPER_FILE(id, file) }
|
|
4514
|
+
function paperDirRel(id) { return 'Paper/' + id }
|
|
4515
|
+
async function mutatePaper(fn) {
|
|
4516
|
+
await patchInstitute({ paper: (cur) => { const next = fn(cur === undefined ? null : cur); return next === undefined ? (cur === undefined ? null : cur) : next } })
|
|
4517
|
+
return paper()
|
|
4518
|
+
}
|
|
4519
|
+
async function paperLog(title, body) {
|
|
4520
|
+
const p = paper()
|
|
4521
|
+
const id = (p && p.id) || paperIdFromState()
|
|
4522
|
+
const rel = PAPER_FILE(id, 'paper.log.md')
|
|
4523
|
+
const prev = (await readTextRel(rel)) || ('# 最终论文流程日志|' + id + '\n\n')
|
|
4524
|
+
await writeTextRel(rel, prev + '## ' + title + '\n\n' + String(body || '') + '\n\n')
|
|
4525
|
+
return rel
|
|
4526
|
+
}
|
|
4527
|
+
function paperAskEvery() { return posMs(params.activityTimeoutMs, 120000) }
|
|
4528
|
+
function paperNextStep(p) {
|
|
4529
|
+
if (!p) return 'no paper flow yet: finish the run with finalPaper=true, or trigger /v5 paper manually'
|
|
4530
|
+
if (p.status === 'finalized') return 'finalised — /v5 paper force rewrites it'
|
|
4531
|
+
if (p.status === 'writing') return 'waiting for the staff parts (round ' + p.round + '/' + PAPER_MAX_ROUNDS + ')'
|
|
4532
|
+
if (p.status === 'reviewing') return 'waiting for the cross-reviews (each reviewer judges another part)'
|
|
4533
|
+
if (p.status === 'awaiting-editor' && p.editor === 'office') {
|
|
4534
|
+
return 'awaiting the OFFICE: send >=1 office message and convene >=1 meeting, then call vibe_v5_finalize_paper (consultation so far: messages=' +
|
|
4535
|
+
Number((p.consult || {}).messages || 0) + ', meetings=' + Number((p.consult || {}).meetings || 0) + ')'
|
|
4536
|
+
}
|
|
4537
|
+
if (p.status === 'awaiting-editor') return 'waiting for the academician to finalise'
|
|
4538
|
+
return p.status
|
|
4539
|
+
}
|
|
4540
|
+
function paperAutoNote() {
|
|
4541
|
+
return params.finalPaper === false ? '自动已关闭(finalPaper=false);本次为手动触发,仍会生成论文。' : ''
|
|
4542
|
+
}
|
|
4543
|
+
function paperSummary() {
|
|
4544
|
+
const p = paper()
|
|
4545
|
+
if (!p) return null
|
|
4546
|
+
return {
|
|
4547
|
+
id: p.id, status: p.status, stage: p.stage, round: p.round,
|
|
4548
|
+
editor: p.editor, editorFallback: !!p.editorFallback, completesRun: !!p.completesRun,
|
|
4549
|
+
participants: p.participants || [],
|
|
4550
|
+
parts: Object.keys(p.parts || {}).map((id) => ({ member: id, round: (p.parts[id] || {}).round, title: (p.parts[id] || {}).title || '' })),
|
|
4551
|
+
reviews: Object.keys(p.reviews || {}).map((id) => ({ member: id, of: (p.reviews[id] || {}).of || '', round: (p.reviews[id] || {}).round, deliverable: (p.reviews[id] || {}).deliverable === true })),
|
|
4552
|
+
reviewOf: p.reviewOf || {},
|
|
4553
|
+
consult: Object.assign({ messages: 0, meetings: 0 }, p.consult || {}),
|
|
4554
|
+
lang: p.lang, format: p.format, compilePdf: p.pdf === true,
|
|
4555
|
+
compile: p.compile || null, engine: p.engine || '', artifacts: p.artifacts || [],
|
|
4556
|
+
warnings: p.warnings || [], disagreement: p.disagreement || [], skipped: p.skipped || [],
|
|
4557
|
+
forcedAfterCap: !!p.forcedAfterCap, finalizedAt: p.finalizedAt || null,
|
|
4558
|
+
dir: 'Paper/' + p.id + '/', next: paperNextStep(p),
|
|
4559
|
+
meta: p.status === 'finalized' ? 'Paper/' + p.id + '/paper.meta.json' : null,
|
|
4560
|
+
}
|
|
4561
|
+
}
|
|
4562
|
+
|
|
4563
|
+
// ---- the three asks ----------------------------------------------------
|
|
4564
|
+
function paperWritePrompt(member, p) {
|
|
4565
|
+
const L = []
|
|
4566
|
+
L.push('【最终论文·撰写 —— ' + kindLabel(member.kind) + ' ' + member.id + '】')
|
|
4567
|
+
L.push('本所对原问题的一致结论已经达成,现在撰写**最终论文**(第 ' + p.round + '/' + PAPER_MAX_ROUNDS + ' 轮)。')
|
|
4568
|
+
L.push('请你**只写你自己库里已有证据支撑**的内容:')
|
|
4569
|
+
L.push('- 直接引用你的卡片(Members/' + member.id + '/Propos|Methods|Subproblems/)、Members/' + member.id + '/Progress/progress.md、你参与的表决记录;')
|
|
4570
|
+
L.push('- **不得编造**:没有证据的推测不要写成结论;未决 / 被否证的条目必须显式标注“未定论 / 已被否证”;')
|
|
4571
|
+
L.push('- 在 evidence 里写清证据路径,附录会逐条索引。')
|
|
4572
|
+
if (p.round > 1) {
|
|
4573
|
+
const rv = (p.reviews || {})[member.id]
|
|
4574
|
+
L.push('')
|
|
4575
|
+
L.push('上一轮互审给你(' + member.id + ')部分的意见:' + (rv ? String(rv.comments || '(无具体意见)') : '(无)'))
|
|
4576
|
+
L.push('请据此修订;若不同意该意见,请在 limits 里写明理由。')
|
|
4577
|
+
}
|
|
4578
|
+
L.push('')
|
|
4579
|
+
L.push('------------')
|
|
4580
|
+
L.push(stateBlock(member))
|
|
4581
|
+
L.push('------------')
|
|
4582
|
+
L.push('结束时只输出一个 JSON 对象:')
|
|
4583
|
+
L.push('{ "paper_part": {')
|
|
4584
|
+
L.push(' "title": "你这部分的标题",')
|
|
4585
|
+
L.push(' "solution": "你对**原问题完整解法**的贡献(推理链与结论,只写有证据的)",')
|
|
4586
|
+
L.push(' "methods": "你创造/发现的方法、理论、思想、有价值经验、数学理解",')
|
|
4587
|
+
L.push(' "rules": "你从这些工作中归纳出的可复用规律",')
|
|
4588
|
+
L.push(' "limits": "局限、未决、被否证之处(必须诚实、显式)",')
|
|
4589
|
+
L.push(' "evidence": ["Members/' + member.id + '/Propos/p-x.md"] } }')
|
|
4590
|
+
return L.join('\n')
|
|
4591
|
+
}
|
|
4592
|
+
function paperReviewPrompt(member, p, ofId) {
|
|
4593
|
+
const part = ofId ? (p.parts || {})[ofId] : null
|
|
4594
|
+
const L = []
|
|
4595
|
+
L.push('【最终论文·互审 —— ' + kindLabel(member.kind) + ' ' + member.id + '】')
|
|
4596
|
+
L.push('请你审阅 **' + (ofId || '(未指定)') + '** 撰写的部分,判断它是否可以交付(deliverable)。')
|
|
4597
|
+
L.push('要点:证据是否充分;是否与已定论的表决一致;有无编造或未标注的未决项;术语与符号是否清楚。')
|
|
4598
|
+
L.push('')
|
|
4599
|
+
L.push('------------ 待审部分(' + (ofId || '') + ')------------')
|
|
4600
|
+
L.push('标题:' + ((part && part.title) || '(无)'))
|
|
4601
|
+
L.push('【完整解法】' + String((part && part.solution) || '(空)').slice(0, 6000))
|
|
4602
|
+
L.push('【方法/理论/思想/经验/理解】' + String((part && part.methods) || '(空)').slice(0, 4000))
|
|
4603
|
+
L.push('【规律】' + String((part && part.rules) || '(空)').slice(0, 3000))
|
|
4604
|
+
L.push('【局限/未决】' + String((part && part.limits) || '(空)').slice(0, 3000))
|
|
4605
|
+
L.push('【声称的证据】' + ((((part && part.evidence) || []).join('、')) || '(无)'))
|
|
4606
|
+
L.push('------------')
|
|
4607
|
+
L.push('')
|
|
4608
|
+
L.push(stateBlock(member))
|
|
4609
|
+
L.push('')
|
|
4610
|
+
L.push('结束时只输出一个 JSON 对象:')
|
|
4611
|
+
L.push('{ "paper_review": { "of": "' + (ofId || '') + '", "deliverable": true, "comments": "具体意见" } }')
|
|
4612
|
+
L.push('deliverable 必须是 true(可交付)或 false(不可交付,需修改)。只有**全体参与成员**都投 true,定稿代表才能定稿。')
|
|
4613
|
+
return L.join('\n')
|
|
4614
|
+
}
|
|
4615
|
+
function paperFinalPrompt(member, p) {
|
|
4616
|
+
const L = []
|
|
4617
|
+
L.push('【最终论文·定稿(院士)—— ' + member.id + '】')
|
|
4618
|
+
L.push('全体参与成员已在互审中表示“可交付”。请你作为定稿代表做**最后一次**把关:')
|
|
4619
|
+
L.push('核对合并稿与互审意见,确认没有编造、没有未标注的未决项、没有与表决记录矛盾之处,然后给出决定。')
|
|
4620
|
+
L.push('')
|
|
4621
|
+
L.push('------------ 合并稿(各成员部分)------------')
|
|
4622
|
+
for (const id of paperActiveParticipants(p)) {
|
|
4623
|
+
const part = (p.parts || {})[id] || {}
|
|
4624
|
+
L.push('### ' + id + '|' + String(part.title || ''))
|
|
4625
|
+
L.push('【完整解法】' + String(part.solution || '').slice(0, 3000))
|
|
4626
|
+
L.push('【方法/规律】' + (String(part.methods || '') + ' ' + String(part.rules || '')).slice(0, 2000))
|
|
4627
|
+
L.push('【局限】' + String(part.limits || '').slice(0, 1500))
|
|
4628
|
+
L.push('')
|
|
4629
|
+
}
|
|
4630
|
+
L.push('------------ 互审结论 ------------')
|
|
4631
|
+
for (const id of Object.keys(p.reviews || {})) {
|
|
4632
|
+
const rv = p.reviews[id] || {}
|
|
4633
|
+
L.push('- ' + id + ' 审 ' + (rv.of || '?') + ':deliverable=' + (rv.deliverable === true) + '|' + String(rv.comments || '').slice(0, 500))
|
|
4634
|
+
}
|
|
4635
|
+
if ((p.disagreement || []).length) L.push('- ⚠ 已达轮次上限仍有分歧:' + JSON.stringify(p.disagreement[p.disagreement.length - 1]).slice(0, 500))
|
|
4636
|
+
L.push('------------')
|
|
4637
|
+
L.push('')
|
|
4638
|
+
L.push(stateBlock(member))
|
|
4639
|
+
L.push('')
|
|
4640
|
+
L.push('结束时只输出一个 JSON 对象:')
|
|
4641
|
+
L.push('{ "paper_final": { "decision": "deliverable" | "revise",')
|
|
4642
|
+
L.push(' "note": "定稿说明:你如何审阅、统一术语与符号、是否发现并纠正了问题",')
|
|
4643
|
+
L.push(' "conclusion": "(可选)定稿代表对原问题的最终结论(只写有证据的)" } }')
|
|
4644
|
+
L.push('decision="revise" 会退回继续修订(有轮次上限)。')
|
|
4645
|
+
return L.join('\n')
|
|
4646
|
+
}
|
|
4647
|
+
|
|
4648
|
+
// ---- the stage driver --------------------------------------------------
|
|
4649
|
+
let paperLock = false
|
|
4650
|
+
async function paperStep() {
|
|
4651
|
+
const p = paper()
|
|
4652
|
+
if (!p || p.status === 'finalized' || p.status === 'awaiting-editor') return false
|
|
4653
|
+
const active = paperActiveParticipants(p)
|
|
4654
|
+
if (!active.length) return await paperAdvance(p, active)
|
|
4655
|
+
const parts = p.parts || {}
|
|
4656
|
+
const reviews = p.reviews || {}
|
|
4657
|
+
const missing = p.status === 'writing'
|
|
4658
|
+
? active.filter((id) => !(parts[id] && parts[id].round === p.round))
|
|
4659
|
+
: active.filter((id) => !(reviews[id] && reviews[id].round === p.round))
|
|
4660
|
+
if (!missing.length) return await paperAdvance(p, active)
|
|
4661
|
+
const budget = Math.max(1, Math.floor(Number(params.maxParallel) || 3))
|
|
4662
|
+
let asked = 0
|
|
4663
|
+
for (const id of missing) {
|
|
4664
|
+
if (asked >= budget) break
|
|
4665
|
+
if (busy.has(id)) continue
|
|
4666
|
+
const m = memberById(id)
|
|
4667
|
+
if (!m || m.phase !== 'active') continue
|
|
4668
|
+
const last = (p.lastAskAt || {})[id] || 0
|
|
4669
|
+
if (last && (now() - last) < paperAskEvery()) continue
|
|
4670
|
+
const prompt = p.status === 'writing'
|
|
4671
|
+
? paperWritePrompt(m, p)
|
|
4672
|
+
: paperReviewPrompt(m, p, (p.reviewOf || {})[id])
|
|
4673
|
+
// `wakeMember`, not `wakeIfIdle`: the paper phase deliberately drives members with a
|
|
4674
|
+
// dedicated ask, and a research round must never be mistaken for a paper answer.
|
|
4675
|
+
const ok = await wakeMember(m, prompt, 'paper')
|
|
4676
|
+
if (ok) {
|
|
4677
|
+
asked += 1
|
|
4678
|
+
await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
|
|
4679
|
+
lastAskAt: Object.assign({}, cur.lastAskAt || {}, { [id]: now() }), updatedAt: now(),
|
|
4680
|
+
}) : cur))
|
|
4681
|
+
}
|
|
4682
|
+
}
|
|
4683
|
+
// Nothing to advance yet. ALWAYS re-arm: the pass that got here may have skipped an ask
|
|
4684
|
+
// because its member is busy or still inside the ask window, and `schedulePass` returns
|
|
4685
|
+
// from the paper branch BEFORE its own heartbeat arming — without this a paper whose ask
|
|
4686
|
+
// was paced out would stall until some unrelated member turn ended.
|
|
4687
|
+
if (asked) { armHeartbeat(); return false }
|
|
4688
|
+
// Stall watchdog (same clock as meetings/verifications): a member whose turn never ends
|
|
4689
|
+
// must not wedge the paper forever. The stuck members are recorded as non-participants.
|
|
4690
|
+
const stale = now() - Number(p.updatedAt || p.createdAt || now())
|
|
4691
|
+
if (stale >= recoverStallMs()) {
|
|
4692
|
+
const stuck = missing.filter((id) => busy.has(id))
|
|
4693
|
+
const name = stuck.length ? stuck : missing
|
|
4694
|
+
const warn = '看门狗:' + name.join('、') + ' 在 ' + Math.round(stale / 1000) + 's 内没有交稿,已按“未参与”处理并记入附录。'
|
|
4695
|
+
await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
|
|
4696
|
+
warnings: (cur.warnings || []).concat([warn]),
|
|
4697
|
+
skipped: Array.from(new Set((cur.skipped || []).concat(name))), updatedAt: now(),
|
|
4698
|
+
}) : cur))
|
|
4699
|
+
await paperLog('看门狗', warn)
|
|
4700
|
+
return await paperAdvance(paper(), paperActiveParticipants(paper()))
|
|
4701
|
+
}
|
|
4702
|
+
armHeartbeat()
|
|
4703
|
+
return false
|
|
4704
|
+
}
|
|
4705
|
+
async function paperAdvance(p, active) {
|
|
4706
|
+
if (p.status === 'writing') {
|
|
4707
|
+
const reviewOf = {}
|
|
4708
|
+
for (let i = 0; i < active.length; i++) reviewOf[active[i]] = active[(i + 1) % active.length]
|
|
4709
|
+
await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
|
|
4710
|
+
status: 'reviewing', stage: 'reviewing', reviewOf, reviews: {}, lastAskAt: {}, updatedAt: now(),
|
|
4711
|
+
}) : cur))
|
|
4712
|
+
await paperLog('进入互审(第 ' + p.round + ' 轮)', active.map((a) => a + ' 审 ' + reviewOf[a]).join(';') || '(无参与成员)')
|
|
4713
|
+
return true
|
|
4714
|
+
}
|
|
4715
|
+
if (p.status === 'reviewing') {
|
|
4716
|
+
const reviews = p.reviews || {}
|
|
4717
|
+
const refusers = active.filter((id) => !(reviews[id] && reviews[id].deliverable === true))
|
|
4718
|
+
if (!refusers.length) {
|
|
4719
|
+
await mutatePaper((cur) => (cur ? Object.assign({}, cur, { status: 'awaiting-editor', stage: 'awaiting-editor', lastAskAt: {}, updatedAt: now() }) : cur))
|
|
4720
|
+
await paperLog('互审一致:可交付', '全体参与成员(' + active.join('、') + ')均表示可交付。定稿代表:' + p.editor)
|
|
4721
|
+
return true
|
|
4722
|
+
}
|
|
4723
|
+
const detail = refusers.map((id) => id + ':' + String((reviews[id] || {}).comments || '未说明')).join(';')
|
|
4724
|
+
const entry = { round: p.round, members: refusers, detail, at: now() }
|
|
4725
|
+
if (p.round < PAPER_MAX_ROUNDS) {
|
|
4726
|
+
await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
|
|
4727
|
+
status: 'writing', stage: 'writing', round: cur.round + 1, lastAskAt: {}, updatedAt: now(),
|
|
4728
|
+
disagreement: (cur.disagreement || []).concat([entry]),
|
|
4729
|
+
}) : cur))
|
|
4730
|
+
await paperLog('互审未达成一致 → 进入第 ' + (p.round + 1) + ' 轮', detail)
|
|
4731
|
+
return true
|
|
4732
|
+
}
|
|
4733
|
+
const warn = '互审在 ' + PAPER_MAX_ROUNDS + ' 轮上限仍未达成全体一致(' + refusers.join('、') + ');分歧已写入论文附录。'
|
|
4734
|
+
await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
|
|
4735
|
+
status: 'awaiting-editor', stage: 'awaiting-editor', lastAskAt: {}, updatedAt: now(),
|
|
4736
|
+
warnings: (cur.warnings || []).concat([warn]), disagreement: (cur.disagreement || []).concat([entry]), forcedAfterCap: true,
|
|
4737
|
+
}) : cur))
|
|
4738
|
+
await paperLog('达到轮次上限:转交定稿代表', warn)
|
|
4739
|
+
return true
|
|
4740
|
+
}
|
|
4741
|
+
return false
|
|
4742
|
+
}
|
|
4743
|
+
async function paperAwaitEditor(p) {
|
|
4744
|
+
if (p.editor !== 'academician') return false // office: waits for vibe_v5_finalize_paper
|
|
4745
|
+
const id = academicianId()
|
|
4746
|
+
if (!id) {
|
|
4747
|
+
await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
|
|
4748
|
+
editor: 'office', editorFallback: true, updatedAt: now(),
|
|
4749
|
+
warnings: (cur.warnings || []).concat(['定稿代表 academician 不在册:已回退为 office(需人工定稿)。']),
|
|
4750
|
+
}) : cur))
|
|
4751
|
+
await paperLog('定稿代表回退', 'academician 不在册,回退为 office:用 /v5 paper 或 vibe_v5_finalize_paper 定稿(仍需先与全所交流并开会)。')
|
|
4752
|
+
return false
|
|
4753
|
+
}
|
|
4754
|
+
const m = memberById(id)
|
|
4755
|
+
if (!m || m.phase !== 'active') return false
|
|
4756
|
+
if (busy.has(id)) { armHeartbeat(); return false }
|
|
4757
|
+
const last = (p.lastAskAt || {})[id] || 0
|
|
4758
|
+
if (last && (now() - last) < paperAskEvery()) { armHeartbeat(); return false }
|
|
4759
|
+
const ok = await wakeMember(m, paperFinalPrompt(m, p), 'paper')
|
|
4760
|
+
if (ok) await mutatePaper((cur) => (cur ? Object.assign({}, cur, { lastAskAt: Object.assign({}, cur.lastAskAt || {}, { [id]: now() }), updatedAt: now() }) : cur))
|
|
4761
|
+
armHeartbeat()
|
|
4762
|
+
return false
|
|
4763
|
+
}
|
|
4764
|
+
// The ONE driver. Called from `onMemberEnd` (after the reply is folded), from the manual
|
|
4765
|
+
// tools/command, from the resume path and from `schedulePass`; `paperLock` keeps it single.
|
|
4766
|
+
async function paperPass() {
|
|
4767
|
+
if (paperLock) return false
|
|
4768
|
+
paperLock = true
|
|
4769
|
+
try {
|
|
4770
|
+
for (let i = 0; i < 6; i++) {
|
|
4771
|
+
const p = paper()
|
|
4772
|
+
if (!p || p.status === 'finalized') return false
|
|
4773
|
+
if (p.status === 'awaiting-editor') { await paperAwaitEditor(p); return false }
|
|
4774
|
+
const advanced = await paperStep()
|
|
4775
|
+
if (!advanced) return false
|
|
4776
|
+
}
|
|
4777
|
+
return false
|
|
4778
|
+
} catch (e) {
|
|
4779
|
+
console.error('vibe-math-v5: paper pass: ' + String((e && e.stack) || e))
|
|
4780
|
+
return false
|
|
4781
|
+
} finally { paperLock = false }
|
|
4782
|
+
}
|
|
4783
|
+
|
|
4784
|
+
// ---- start / record / consult / finalize -------------------------------
|
|
4785
|
+
async function startPaper(trigger, opts) {
|
|
4786
|
+
const o = opts || {}
|
|
4787
|
+
const trg = String(trigger || 'manual')
|
|
4788
|
+
const existing = paper()
|
|
4789
|
+
if (existing && existing.status === 'finalized' && !o.force) {
|
|
4790
|
+
const refill = await refillPaperArtifacts(existing)
|
|
4791
|
+
return { ok: true, alreadyFinalized: true, id: existing.id, dir: 'Paper/' + existing.id + '/', refill, autoDisabled: params.finalPaper === false, note: paperAutoNote() }
|
|
4792
|
+
}
|
|
4793
|
+
if (existing && existing.status !== 'finalized' && !o.force) {
|
|
4794
|
+
return { ok: true, alreadyRunning: true, id: existing.id, status: existing.status, stage: existing.stage, round: existing.round, next: paperNextStep(existing), autoDisabled: params.finalPaper === false, note: paperAutoNote() }
|
|
4795
|
+
}
|
|
4796
|
+
const participants = paperParticipants()
|
|
4797
|
+
// A per-call editor override (`/v5 paper editor=office`) is ONE-SHOT: it is not written
|
|
4798
|
+
// back into the persisted params. An AUTOMATIC (run-complete) trigger always uses the
|
|
4799
|
+
// academician — the office is the root session and has no wake path (docs/final-paper.md §7).
|
|
4800
|
+
const configured = (o.editor === 'office' || o.editor === 'academician') ? o.editor : paperConfiguredEditor()
|
|
4801
|
+
const editor = trg === 'run-complete' ? 'academician' : configured
|
|
4802
|
+
const configWantsOffice = configured === 'office'
|
|
4803
|
+
const editorNote = (trg === 'run-complete' && configWantsOffice)
|
|
4804
|
+
? 'paperEditor=office 已配置,但自动(收口)触发必须由**可唤醒**的院士定稿(所办是根会话,没有唤醒路径);本次自动流程用 academician。要所办定稿请在收尾后用 /v5 paper 手动触发。'
|
|
4805
|
+
: (editor === 'office' ? '所办定稿:必须先与全所交流(>=1 条所办消息)并至少召开一次会议,结论写进定稿说明,然后调用 vibe_v5_finalize_paper。' : '')
|
|
4806
|
+
const lang = (o.lang === 'en' || o.lang === 'zh') ? o.lang : (params.paperLanguage === 'en' ? 'en' : 'zh')
|
|
4807
|
+
const format = ['both', 'md', 'tex'].indexOf(o.format) !== -1 ? o.format : (['both', 'md', 'tex'].indexOf(params.paperFormat) !== -1 ? params.paperFormat : 'both')
|
|
4808
|
+
const p = {
|
|
4809
|
+
id: paperIdFromState(), status: 'writing', stage: 'writing', round: 1,
|
|
4810
|
+
trigger: trg, createdAt: now(), updatedAt: now(),
|
|
4811
|
+
participants, parts: {}, reviews: {}, reviewOf: {}, final: null,
|
|
4812
|
+
editor, editorNote, completesRun: trg === 'run-complete',
|
|
4813
|
+
lang, format, pdf: params.paperCompilePdf !== false,
|
|
4814
|
+
consult: { messages: 0, meetings: 0 },
|
|
4815
|
+
lastAskAt: {}, warnings: editorNote ? [editorNote] : [], disagreement: [], skipped: [], artifacts: [],
|
|
4816
|
+
forced: !!o.force, forceReason: o.force ? String(o.reason || 'manual force') : '',
|
|
4817
|
+
}
|
|
4818
|
+
await patchInstitute({ paper: p })
|
|
4819
|
+
await paperLog('流程开始(' + (trg === 'run-complete' ? '收口自动触发' : '手动触发') + ')', [
|
|
4820
|
+
'- 目录:Paper/' + p.id + '/',
|
|
4821
|
+
'- 语言:' + p.lang + '|格式:' + p.format + '|编译 PDF:' + p.pdf,
|
|
4822
|
+
'- 参与成员:' + (participants.join('、') || '(无常驻成员)'),
|
|
4823
|
+
'- 定稿代表:' + editor + (editorNote ? '(' + editorNote + ')' : ''),
|
|
4824
|
+
].join('\n'))
|
|
4825
|
+
await paperPass()
|
|
4826
|
+
return {
|
|
4827
|
+
ok: true, started: true, id: p.id, dir: 'Paper/' + p.id + '/', status: 'writing',
|
|
4828
|
+
participants, editor, next: paperNextStep(paper()),
|
|
4829
|
+
autoDisabled: params.finalPaper === false, note: paperAutoNote(),
|
|
4830
|
+
}
|
|
4831
|
+
}
|
|
4832
|
+
async function paperRecordPart(memberId, raw) {
|
|
4833
|
+
const p = paper()
|
|
4834
|
+
if (!p || p.status !== 'writing') return { ok: false, code: 'V5_PAPER_STATE', message: 'the paper flow is not in its writing stage (now: ' + (p ? p.status : 'none') + ')' }
|
|
4835
|
+
if ((p.participants || []).indexOf(memberId) === -1) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: memberId + ' is not a writing participant of this paper' }
|
|
4836
|
+
const part = {
|
|
4837
|
+
member: memberId, round: p.round,
|
|
4838
|
+
title: String(raw.title || '').slice(0, 300),
|
|
4839
|
+
solution: String(raw.solution || '').slice(0, 20000),
|
|
4840
|
+
methods: String(raw.methods || '').slice(0, 20000),
|
|
4841
|
+
rules: String(raw.rules || '').slice(0, 12000),
|
|
4842
|
+
limits: String(raw.limits || '').slice(0, 12000),
|
|
4843
|
+
evidence: Array.isArray(raw.evidence) ? raw.evidence.map(String).slice(0, 50) : [],
|
|
4844
|
+
at: now(),
|
|
4845
|
+
}
|
|
4846
|
+
if (!part.title && !part.solution && !part.methods && !part.rules && !part.limits) {
|
|
4847
|
+
await notice(memberId, 'paper_part 是空的:请至少给出 title 与你的贡献正文(solution/methods/rules/limits),只写库里已有证据支撑的内容。')
|
|
4848
|
+
return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'paper_part carries no content' }
|
|
4849
|
+
}
|
|
4850
|
+
await mutatePaper((cur) => (cur ? Object.assign({}, cur, { parts: Object.assign({}, cur.parts || {}, { [memberId]: part }), updatedAt: now() }) : cur))
|
|
4851
|
+
await paperLog('收到 ' + memberId + ' 的部分(第 ' + p.round + ' 轮)', '- 标题:' + (part.title || '(无)') + '\n- 声称证据:' + (part.evidence.length || 0) + ' 条')
|
|
4852
|
+
return { ok: true, recorded: memberId, round: p.round }
|
|
4853
|
+
}
|
|
4854
|
+
async function paperRecordReview(memberId, raw) {
|
|
4855
|
+
const p = paper()
|
|
4856
|
+
if (!p || p.status !== 'reviewing') return { ok: false, code: 'V5_PAPER_STATE', message: 'the paper flow is not in its cross-review stage (now: ' + (p ? p.status : 'none') + ')' }
|
|
4857
|
+
if ((p.participants || []).indexOf(memberId) === -1) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: memberId + ' is not a review participant of this paper' }
|
|
4858
|
+
if (raw.deliverable !== true && raw.deliverable !== false) {
|
|
4859
|
+
await notice(memberId, 'paper_review.deliverable 必须是 true 或 false(你敢不敢交付?未表态不算可交付)。')
|
|
4860
|
+
return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'paper_review.deliverable must be a boolean' }
|
|
4861
|
+
}
|
|
4862
|
+
const assigned = (p.reviewOf || {})[memberId]
|
|
4863
|
+
const rev = {
|
|
4864
|
+
member: memberId, of: String(raw.of || assigned || ''), round: p.round,
|
|
4865
|
+
deliverable: raw.deliverable === true,
|
|
4866
|
+
comments: String(raw.comments || '').slice(0, 8000), at: now(),
|
|
4867
|
+
}
|
|
4868
|
+
await mutatePaper((cur) => (cur ? Object.assign({}, cur, { reviews: Object.assign({}, cur.reviews || {}, { [memberId]: rev }), updatedAt: now() }) : cur))
|
|
4869
|
+
await paperLog('互审意见|' + memberId + ' → ' + rev.of, '- deliverable:' + rev.deliverable + '\n- 意见:' + (rev.comments || '(无)').slice(0, 2000))
|
|
4870
|
+
return { ok: true, recorded: memberId, of: rev.of, deliverable: rev.deliverable }
|
|
4871
|
+
}
|
|
4872
|
+
async function paperRecordFinal(memberId, raw) {
|
|
4873
|
+
const p = paper()
|
|
4874
|
+
if (!p || p.status !== 'awaiting-editor') return { ok: false, code: 'V5_PAPER_STATE', message: 'the paper is not awaiting its editor (now: ' + (p ? p.status : 'none') + ')' }
|
|
4875
|
+
if (p.editor !== 'academician' || memberId !== academicianId()) {
|
|
4876
|
+
return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'paperEditor is "' + p.editor + '": the academician does not finalise this paper' }
|
|
4877
|
+
}
|
|
4878
|
+
const decision = String(raw.decision || '')
|
|
4879
|
+
if (decision !== 'deliverable' && decision !== 'revise') {
|
|
4880
|
+
await notice(memberId, "paper_final.decision 必须是 'deliverable' 或 'revise'(收到 " + decision + ')。')
|
|
4881
|
+
return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'decision must be deliverable|revise' }
|
|
4882
|
+
}
|
|
4883
|
+
const fin = { by: memberId, decision, note: String(raw.note || '').slice(0, 4000), conclusion: String(raw.conclusion || '').slice(0, 20000), at: now() }
|
|
4884
|
+
if (decision === 'deliverable') {
|
|
4885
|
+
await mutatePaper((cur) => (cur ? Object.assign({}, cur, { final: fin, updatedAt: now() }) : cur))
|
|
4886
|
+
return await finalizePaper('academician-deliverable')
|
|
4887
|
+
}
|
|
4888
|
+
if (p.round < PAPER_MAX_ROUNDS) {
|
|
4889
|
+
await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
|
|
4890
|
+
final: fin, status: 'writing', stage: 'writing', round: cur.round + 1, lastAskAt: {}, updatedAt: now(),
|
|
4891
|
+
disagreement: (cur.disagreement || []).concat([{ round: cur.round, members: [memberId], detail: '定稿代表要求修订:' + fin.note, at: now() }]),
|
|
4892
|
+
}) : cur))
|
|
4893
|
+
await paperLog('定稿代表要求修订 → 第 ' + (p.round + 1) + ' 轮', fin.note || '(未说明)')
|
|
4894
|
+
return { ok: true, revised: true, round: p.round + 1 }
|
|
4895
|
+
}
|
|
4896
|
+
const warn = '定稿代表在第 ' + PAPER_MAX_ROUNDS + ' 轮上限仍要求修订;按规格记警告并定稿(分歧写入附录)。'
|
|
4897
|
+
await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
|
|
4898
|
+
final: fin, warnings: (cur.warnings || []).concat([warn]), forcedAfterCap: true, updatedAt: now(),
|
|
4899
|
+
disagreement: (cur.disagreement || []).concat([{ round: cur.round, members: [memberId], detail: '定稿代表要求修订:' + fin.note, at: now() }]),
|
|
4900
|
+
}) : cur))
|
|
4901
|
+
await paperLog('达到轮次上限:定稿代表仍要求修订', warn)
|
|
4902
|
+
return await finalizePaper('academician-revise-at-cap')
|
|
4903
|
+
}
|
|
4904
|
+
// The office consultation gate (v2 §A6 / the user's requirement): the office may finalise
|
|
4905
|
+
// ONLY after it has actually talked to the institute (>=1 office message) AND convened at
|
|
4906
|
+
// least one meeting; both counts are recorded in the finalisation note and the meta.
|
|
4907
|
+
async function finalizePaperByOffice(o) {
|
|
4908
|
+
const args = o || {}
|
|
4909
|
+
const p = paper()
|
|
4910
|
+
if (!p) return { ok: false, code: 'V5_PAPER_STATE', message: 'no paper flow has been started (finish the run with finalPaper=true, or use /v5 paper)' }
|
|
4911
|
+
if (p.status === 'finalized' && !args.force) {
|
|
4912
|
+
const refill = await refillPaperArtifacts(p)
|
|
4913
|
+
return { ok: true, alreadyFinalized: true, id: p.id, refill, autoDisabled: params.finalPaper === false, note: paperAutoNote() }
|
|
4914
|
+
}
|
|
4915
|
+
if (p.status !== 'awaiting-editor') {
|
|
4916
|
+
return { ok: false, code: 'V5_PAPER_STATE', message: 'the paper flow is in stage "' + p.status + '"; the editor finalises only after every part is written and cross-reviewed' }
|
|
4917
|
+
}
|
|
4918
|
+
if (p.editor !== 'office') {
|
|
4919
|
+
return { ok: false, code: 'V5_NOT_OFFICE', message: 'paperEditor="academician": the academician finalises from its own round; the office does not' }
|
|
4920
|
+
}
|
|
4921
|
+
const decision = String(args.decision || '')
|
|
4922
|
+
if (decision !== 'deliverable' && decision !== 'revise') return { ok: false, code: 'V5_INVALID_ARGUMENT', message: "decision must be 'deliverable' or 'revise'" }
|
|
4923
|
+
const note = String(args.note || '').trim()
|
|
4924
|
+
if (decision === 'deliverable' && !note) {
|
|
4925
|
+
return { ok: false, code: 'V5_INVALID_ARGUMENT', message: '定稿说明(note)是必填的:写明你如何与全所交流、商讨、优化、审查,以及结论' }
|
|
4926
|
+
}
|
|
4927
|
+
const consult = Object.assign({ messages: 0, meetings: 0 }, p.consult || {})
|
|
4928
|
+
if (!(Number(consult.messages) > 0) || !(Number(consult.meetings) > 0)) {
|
|
4929
|
+
return {
|
|
4930
|
+
ok: false, code: 'V5_PAPER_CONSULT_REQUIRED',
|
|
4931
|
+
message: 'paperEditor="office" 要求所办先与全所交流、商讨、优化、审查:至少 1 条所办消息(vibe_v5_message)+ 至少 1 次会议(vibe_v5_meeting),然后才能定稿。当前:messages=' +
|
|
4932
|
+
Number(consult.messages || 0) + ', meetings=' + Number(consult.meetings || 0),
|
|
4933
|
+
consult: { messages: Number(consult.messages || 0), meetings: Number(consult.meetings || 0) },
|
|
4934
|
+
}
|
|
4935
|
+
}
|
|
4936
|
+
const fin = {
|
|
4937
|
+
by: 'office', decision, note: String(note).slice(0, 4000),
|
|
4938
|
+
conclusion: String(args.conclusion || '').slice(0, 20000),
|
|
4939
|
+
consult: { messages: Number(consult.messages || 0), meetings: Number(consult.meetings || 0) },
|
|
4940
|
+
at: now(),
|
|
4941
|
+
}
|
|
4942
|
+
if (decision === 'deliverable') {
|
|
4943
|
+
await mutatePaper((cur) => (cur ? Object.assign({}, cur, { final: fin, updatedAt: now() }) : cur))
|
|
4944
|
+
return await finalizePaper('office-deliverable')
|
|
4945
|
+
}
|
|
4946
|
+
if (p.round < PAPER_MAX_ROUNDS) {
|
|
4947
|
+
await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
|
|
4948
|
+
final: fin, status: 'writing', stage: 'writing', round: cur.round + 1, lastAskAt: {}, updatedAt: now(),
|
|
4949
|
+
disagreement: (cur.disagreement || []).concat([{ round: cur.round, members: ['office'], detail: '所办要求修订:' + fin.note, at: now() }]),
|
|
4950
|
+
}) : cur))
|
|
4951
|
+
await paperLog('所办要求修订 → 第 ' + (p.round + 1) + ' 轮', fin.note)
|
|
4952
|
+
return { ok: true, revised: true, round: p.round + 1 }
|
|
4953
|
+
}
|
|
4954
|
+
const warn = '所办在第 ' + PAPER_MAX_ROUNDS + ' 轮上限仍要求修订;按规格记警告并定稿(分歧写入附录)。'
|
|
4955
|
+
await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
|
|
4956
|
+
final: fin, warnings: (cur.warnings || []).concat([warn]), forcedAfterCap: true, updatedAt: now(),
|
|
4957
|
+
disagreement: (cur.disagreement || []).concat([{ round: cur.round, members: ['office'], detail: '所办要求修订:' + fin.note, at: now() }]),
|
|
4958
|
+
}) : cur))
|
|
4959
|
+
await paperLog('达到轮次上限:所办仍要求修订', warn)
|
|
4960
|
+
return await finalizePaper('office-revise-at-cap')
|
|
4961
|
+
}
|
|
4962
|
+
// Count the office's consultation evidence. Called from the ONE place each act really
|
|
4963
|
+
// happens (`say` for a message, `beginMeeting` for a meeting that actually starts), so the
|
|
4964
|
+
// gate cannot be satisfied by merely intending to consult.
|
|
4965
|
+
async function notePaperConsult(kind) {
|
|
4966
|
+
const p = paper()
|
|
4967
|
+
if (!p || p.status === 'finalized') return
|
|
4968
|
+
const key = kind === 'meeting' ? 'meetings' : 'messages'
|
|
4969
|
+
await mutatePaper((cur) => {
|
|
4970
|
+
if (!cur || cur.status === 'finalized') return cur
|
|
4971
|
+
const consult = Object.assign({ messages: 0, meetings: 0 }, cur.consult || {})
|
|
4972
|
+
consult[key] = Number(consult[key] || 0) + 1
|
|
4973
|
+
return Object.assign({}, cur, { consult, updatedAt: now() })
|
|
4974
|
+
})
|
|
4975
|
+
}
|
|
4976
|
+
|
|
4977
|
+
// ---- artifacts ---------------------------------------------------------
|
|
4978
|
+
function paperEvidence() {
|
|
4979
|
+
const s = inst()
|
|
4980
|
+
const V = s.verdicts || {}
|
|
4981
|
+
const keys = Object.keys(V)
|
|
4982
|
+
const closed = (o) => keys.filter((k) => V[k] && V[k].closed && V[k].outcome === o)
|
|
4983
|
+
const tasks = (s.tasks || []).filter((t) => t && t.status !== 'deleted')
|
|
4984
|
+
const formal = formalRecords()
|
|
4985
|
+
return {
|
|
4986
|
+
s, V,
|
|
4987
|
+
trueV: closed('true'), falseV: closed('false'), undecidedV: closed('undecided'),
|
|
4988
|
+
openV: keys.filter((k) => V[k] && !V[k].closed),
|
|
4989
|
+
tasks, done: tasks.filter((t) => t.status === 'completed'), openT: tasks.filter((t) => t.status !== 'completed'),
|
|
4990
|
+
formal, formalKeys: Object.keys(formal),
|
|
4991
|
+
members: s.members || [],
|
|
4992
|
+
}
|
|
4993
|
+
}
|
|
4994
|
+
function verdictCardRel(kind, id) { return 'Verified/' + (kind === 'subproblem' ? '问题' : kind === 'method' ? '方法' : '命题') + '/' + id + '.md' }
|
|
4995
|
+
function cardDirOf(kind) { return kind === 'method' ? 'Methods' : kind === 'subproblem' ? 'Subproblems' : 'Propos' }
|
|
4996
|
+
function paperTitle(p) {
|
|
4997
|
+
const st = String((inst().problem || {}).statement || '').trim()
|
|
4998
|
+
const head = st ? st.split(/\n/)[0].slice(0, 100) : ''
|
|
4999
|
+
return head || ('研究所成果论文|' + p.id)
|
|
5000
|
+
}
|
|
5001
|
+
function paperSources(p, active, ev) {
|
|
5002
|
+
const out = []
|
|
5003
|
+
out.push('Problems/conclusion.md(结题记录)')
|
|
5004
|
+
if ((inst().problem || {}).id) out.push('Problems/' + inst().problem.id + '.md(原问题卡)')
|
|
5005
|
+
out.push('State/' + instituteName + '.v5state.json(权威状态)')
|
|
5006
|
+
for (const k of ev.trueV.concat(ev.falseV)) out.push(verdictCardRel((ev.V[k] || {}).kind, k) + '(Verified 卡片)')
|
|
5007
|
+
for (const k of ev.trueV.concat(ev.falseV)) out.push('Shared/Debates/' + k + '.md(辩论录)')
|
|
5008
|
+
for (const k of ev.formalKeys) {
|
|
5009
|
+
const r = ev.formal[k] || {}
|
|
5010
|
+
if (r.file) out.push(r.file + '(Lean 工作文件)')
|
|
5011
|
+
if (r.proof) out.push(r.proof + '(归档证明)')
|
|
5012
|
+
}
|
|
5013
|
+
if (ev.formalKeys.length) out.push('Formal/Index.md', 'Formal/TODO.md')
|
|
5014
|
+
for (const m of ev.members) {
|
|
5015
|
+
out.push('Members/' + m.id + '/Progress/progress.md(' + m.id + ' 的研究日志)')
|
|
5016
|
+
out.push('Members/' + m.id + '/{Propos,Methods,Subproblems}/(' + m.id + ' 的卡片)')
|
|
5017
|
+
}
|
|
5018
|
+
out.push('Institutes.md(编制镜像)', 'Shared/TaskBoard.md(任务板镜像)')
|
|
5019
|
+
out.push('Paper/' + p.id + '/paper.log.md(本论文流程往来)')
|
|
5020
|
+
for (const id of active) {
|
|
5021
|
+
const part = (p.parts || {})[id] || {}
|
|
5022
|
+
for (const e of (part.evidence || [])) out.push(String(e) + '(' + id + ' 声称的证据)')
|
|
5023
|
+
}
|
|
5024
|
+
return Array.from(new Set(out))
|
|
5025
|
+
}
|
|
5026
|
+
function paperSections(p, active, skipped, ev, extra) {
|
|
5027
|
+
const o = extra || {}
|
|
5028
|
+
const fin = o.final || p.final || null
|
|
5029
|
+
const prob = ev.s.problem || {}
|
|
5030
|
+
const S = []
|
|
5031
|
+
// 1
|
|
5032
|
+
const b1 = []
|
|
5033
|
+
b1.push({ p: '标题:' + paperTitle(p) })
|
|
5034
|
+
b1.push({ ul: [
|
|
5035
|
+
'作者:' + ((p.participants || []).map((id) => id + '(' + ((memberById(id) || {}).kind === 'academician' ? '院士' : '常驻研究员') + ')').join('、') || '(无在册作者)'),
|
|
5036
|
+
'研究所:' + instituteName + '|项目:' + project + '|运行:' + (ev.s.runId || runId || '(无)'),
|
|
5037
|
+
'日期:' + fmtTime(p.finalizedAt || now()).slice(0, 10) + '|语言:' + p.lang + '|定稿代表:' + p.editor,
|
|
5038
|
+
] })
|
|
5039
|
+
const mainConclusions = ev.trueV.length
|
|
5040
|
+
? ('已由表决确立的结论:' + ev.trueV.join('、'))
|
|
5041
|
+
: '尚无经表决确立为真的结论(见第 4、8 节)。'
|
|
5042
|
+
b1.push({ p: '摘要:' + (prob.statement ? String(prob.statement).replace(/\s+/g, ' ').slice(0, 400) : '(未设定原问题)') + '。' + mainConclusions })
|
|
5043
|
+
if (fin && fin.note) b1.push({ p: '定稿说明:' + fin.note + (fin.consult ? '(定稿前与全所交流:消息 ' + fin.consult.messages + ' 条、会议 ' + fin.consult.meetings + ' 次)' : '') })
|
|
5044
|
+
if (fin && fin.conclusion) b1.push({ p: '定稿结论:' + fin.conclusion })
|
|
5045
|
+
S.push({ title: PAPER_SECTIONS[0], blocks: b1 })
|
|
5046
|
+
// 2
|
|
5047
|
+
S.push({ title: PAPER_SECTIONS[1], blocks: [
|
|
5048
|
+
{ p: prob.statement ? String(prob.statement) : '(本所未记录原问题的完整陈述——请勿在论文中补写。)' },
|
|
5049
|
+
{ p: '问题 ID:' + (prob.id || '(无)') + '|参与成员:' + (p.participants || []).join('、') },
|
|
5050
|
+
] })
|
|
5051
|
+
// 3
|
|
5052
|
+
const b3 = []
|
|
5053
|
+
const solvers = active.filter((id) => String(((p.parts || {})[id] || {}).solution || '').trim())
|
|
5054
|
+
if (solvers.length) {
|
|
5055
|
+
for (const id of solvers) {
|
|
5056
|
+
const part = (p.parts || {})[id] || {}
|
|
5057
|
+
b3.push({ h: id + '|' + String(part.title || '贡献') })
|
|
5058
|
+
b3.push({ p: String(part.solution) })
|
|
5059
|
+
}
|
|
5060
|
+
} else {
|
|
5061
|
+
b3.push({ p: '(参与成员未提交可引用的完整解法叙述;请以第 4 节的表决结论与证据索引为准。)' })
|
|
5062
|
+
}
|
|
5063
|
+
if (o.conclusionText) b3.push({ h: '结题记录(Problems/conclusion.md)' }, { p: String(o.conclusionText).slice(0, 4000) })
|
|
5064
|
+
S.push({ title: PAPER_SECTIONS[2], blocks: b3 })
|
|
5065
|
+
// 4
|
|
5066
|
+
const b4 = []
|
|
5067
|
+
if (ev.trueV.length) {
|
|
5068
|
+
b4.push({ p: '以下对象经 m 票布尔一致判定为**真**(证据:Verified 卡片 + 辩论录):' })
|
|
5069
|
+
b4.push({ ul: ev.trueV.map((k) => {
|
|
5070
|
+
const v = ev.V[k] || {}
|
|
5071
|
+
return '**' + k + '**(判为真;平均概率 ' + Number(v.mean || 0).toFixed(2) + ';m=' + (v.m || '?') + ';表决者 ' + ((v.voters || []).join('、') || '?') +
|
|
5072
|
+
';证据:' + verdictCardRel(v.kind, k) + ';辩论:Shared/Debates/' + k + '.md;提出者:' + (v.proposer || '(office)') + ')'
|
|
5073
|
+
}) })
|
|
5074
|
+
} else b4.push({ p: '(没有经表决判定为真的命题。)' })
|
|
5075
|
+
if (ev.falseV.length) {
|
|
5076
|
+
b4.push({ p: '**已被否证(显式标注,不得当作结论使用)**:' })
|
|
5077
|
+
b4.push({ ul: ev.falseV.map((k) => {
|
|
5078
|
+
const v = ev.V[k] || {}
|
|
5079
|
+
return k + '(判为假;平均概率 ' + Number(v.mean || 0).toFixed(2) + ';m=' + (v.m || '?') + ';证据:' + verdictCardRel(v.kind, k) + ')'
|
|
5080
|
+
}) })
|
|
5081
|
+
}
|
|
5082
|
+
S.push({ title: PAPER_SECTIONS[3], blocks: b4 })
|
|
5083
|
+
// 5
|
|
5084
|
+
const b5 = []
|
|
5085
|
+
const subV = ev.trueV.filter((k) => (ev.V[k] || {}).kind === 'subproblem')
|
|
5086
|
+
if (subV.length) b5.push({ ul: subV.map((k) => '子问题 ' + k + '(已判定为真;证据:' + verdictCardRel('subproblem', k) + ')') })
|
|
5087
|
+
if (ev.done.length) b5.push({ ul: ev.done.map((t) => '任务 ' + t.id + ':' + String(t.subject || '') + '(owner=' + (t.ownerId || '?') + ';镜像:Shared/TaskBoard.md)') })
|
|
5088
|
+
if (!subV.length && !ev.done.length) b5.push({ p: '(没有已完成的子问题或任务被记录。)' })
|
|
5089
|
+
S.push({ title: PAPER_SECTIONS[4], blocks: b5 })
|
|
5090
|
+
// 6
|
|
5091
|
+
const b6 = []
|
|
5092
|
+
const methodParts = active.filter((id) => String(((p.parts || {})[id] || {}).methods || '').trim())
|
|
5093
|
+
for (const id of methodParts) b6.push({ h: id + '|' + String(((p.parts || {})[id] || {}).title || '') }, { p: String(((p.parts || {})[id] || {}).methods) })
|
|
5094
|
+
if (!methodParts.length) b6.push({ p: '(参与成员未提交方法/理论/思想/经验/数学理解的叙述。)' })
|
|
5095
|
+
S.push({ title: PAPER_SECTIONS[5], blocks: b6 })
|
|
5096
|
+
// 7
|
|
5097
|
+
const b7 = []
|
|
5098
|
+
const ruleParts = active.filter((id) => String(((p.parts || {})[id] || {}).rules || '').trim())
|
|
5099
|
+
for (const id of ruleParts) b7.push({ h: id + '|' + String(((p.parts || {})[id] || {}).title || '') }, { p: String(((p.parts || {})[id] || {}).rules) })
|
|
5100
|
+
if (!ruleParts.length) b7.push({ p: '(参与成员未归纳出可复用规律。)' })
|
|
5101
|
+
S.push({ title: PAPER_SECTIONS[6], blocks: b7 })
|
|
5102
|
+
// 8
|
|
5103
|
+
const b8 = []
|
|
5104
|
+
const limitParts = active.filter((id) => String(((p.parts || {})[id] || {}).limits || '').trim())
|
|
5105
|
+
for (const id of limitParts) b8.push({ h: id }, { p: String(((p.parts || {})[id] || {}).limits) })
|
|
5106
|
+
const unresolved = []
|
|
5107
|
+
for (const k of ev.undecidedV) unresolved.push('未定论:' + k + '(平均概率 ' + Number((ev.V[k] || {}).mean || 0).toFixed(2) + ',m=' + ((ev.V[k] || {}).m || '?') + ')')
|
|
5108
|
+
for (const k of ev.openV) unresolved.push('尚未裁决:' + k)
|
|
5109
|
+
for (const t of ev.openT) unresolved.push('未完成任务:' + t.id + '|' + String(t.subject || '') + '(' + t.status + ')')
|
|
5110
|
+
for (const m of ev.members.filter((x) => x.phase === 'failed' || x.phase === 'dismissed')) unresolved.push('成员 ' + m.id + ':' + m.phase + (m.error ? '(' + String(m.error).slice(0, 120) + ')' : ''))
|
|
5111
|
+
for (const id of skipped) unresolved.push('未交稿/未参与:' + id)
|
|
5112
|
+
if (unresolved.length) b8.push({ p: '**未决与未参与(显式标注)**:' }, { ul: unresolved })
|
|
5113
|
+
if ((p.warnings || []).length) b8.push({ p: '流程警告:' }, { ul: (p.warnings || []).map(String) })
|
|
5114
|
+
if (!limitParts.length && !unresolved.length && !(p.warnings || []).length) b8.push({ p: '(无特别记录的局限。)' })
|
|
5115
|
+
S.push({ title: PAPER_SECTIONS[7], blocks: b8 })
|
|
5116
|
+
// 9
|
|
5117
|
+
const b9 = [{ p: '以下路径均相对于研究所根目录(与 State/<institute>.v5state.json 同级)。' }]
|
|
5118
|
+
b9.push({ ul: paperSources(p, active, ev) })
|
|
5119
|
+
if ((p.disagreement || []).length) {
|
|
5120
|
+
b9.push({ h: '分歧记录(未达成全体一致 / 定稿代表要求修订)' })
|
|
5121
|
+
b9.push({ ul: (p.disagreement || []).map((d) => '第 ' + d.round + ' 轮|' + (d.members || []).join('、') + ':' + String(d.detail || '')) })
|
|
5122
|
+
}
|
|
5123
|
+
if (skipped.length) b9.push({ p: '按“未参与”处理的成员:' + skipped.join('、') })
|
|
5124
|
+
S.push({ title: PAPER_SECTIONS[8], blocks: b9 })
|
|
5125
|
+
return S
|
|
5126
|
+
}
|
|
5127
|
+
function renderPaperMd(p, S) {
|
|
5128
|
+
const L = ['# ' + paperTitle(p), '']
|
|
5129
|
+
S.forEach((sec, i) => {
|
|
5130
|
+
L.push('## ' + (i + 1) + '. ' + sec.title)
|
|
5131
|
+
L.push('')
|
|
5132
|
+
for (const b of sec.blocks) {
|
|
5133
|
+
if (b.h) { L.push('### ' + b.h); L.push('') }
|
|
5134
|
+
else if (b.p !== undefined) { L.push(String(b.p)); L.push('') }
|
|
5135
|
+
else if (b.ul) { for (const it of b.ul) L.push('- ' + String(it)); L.push('') }
|
|
5136
|
+
}
|
|
5137
|
+
})
|
|
5138
|
+
return L.join('\n')
|
|
5139
|
+
}
|
|
5140
|
+
const TEX_ESCAPES = { '\\': '\\textbackslash{}', '&': '\\&', '%': '\\%', '$': '\\$', '#': '\\#', '_': '\\_', '{': '\\{', '}': '\\}', '~': '\\textasciitilde{}', '^': '\\textasciicircum{}' }
|
|
5141
|
+
function texEscape(s) { return String(s == null ? '' : s).replace(/[\\&%$#_{}~^]/g, (c) => TEX_ESCAPES[c]) }
|
|
5142
|
+
function renderPaperTex(p, S) {
|
|
5143
|
+
const zh = p.lang !== 'en'
|
|
5144
|
+
const L = [
|
|
5145
|
+
'\\documentclass[11pt]{' + (zh ? 'ctexart' : 'article') + '}',
|
|
5146
|
+
'\\usepackage{amsmath,amssymb,amsthm}',
|
|
5147
|
+
'\\usepackage[margin=2.5cm]{geometry}',
|
|
5148
|
+
'\\usepackage[hidelinks]{hyperref}',
|
|
5149
|
+
'\\usepackage{longtable,booktabs}',
|
|
5150
|
+
'\\title{' + texEscape(paperTitle(p)) + '}',
|
|
5151
|
+
'\\author{' + texEscape(((p.participants || []).join('、')) || (zh ? '研究所' : 'Institute')) + '}',
|
|
5152
|
+
'\\date{' + texEscape(fmtTime(p.finalizedAt || now()).slice(0, 10)) + '}',
|
|
5153
|
+
'\\begin{document}',
|
|
5154
|
+
'\\maketitle',
|
|
5155
|
+
]
|
|
5156
|
+
S.forEach((sec, i) => {
|
|
5157
|
+
L.push('\\section{' + texEscape((i + 1) + '. ' + sec.title) + '}')
|
|
5158
|
+
for (const b of sec.blocks) {
|
|
5159
|
+
if (b.h) L.push('\\subsection{' + texEscape(b.h) + '}')
|
|
5160
|
+
else if (b.p !== undefined) L.push(texEscape(b.p) + '\n')
|
|
5161
|
+
else if (b.ul) { L.push('\\begin{itemize}'); for (const it of b.ul) L.push('\\item ' + texEscape(it)); L.push('\\end{itemize}') }
|
|
5162
|
+
}
|
|
5163
|
+
})
|
|
5164
|
+
L.push('\\end{document}')
|
|
5165
|
+
return L.join('\n') + '\n'
|
|
5166
|
+
}
|
|
5167
|
+
// The repair pass keeps only the packages the text itself needs (docs/final-paper.md §8):
|
|
5168
|
+
// every optional or unknown package is dropped before the engine swap retry.
|
|
5169
|
+
function texStripOptionalPackages(tex) {
|
|
5170
|
+
return String(tex).split('\n').filter((line) => {
|
|
5171
|
+
if (/^\s*\\(?:input|include)\b/.test(line)) return false
|
|
5172
|
+
const m = /^\s*\\usepackage(?:\[[^\]]*\])?\{([^}]+)\}/.exec(line)
|
|
5173
|
+
if (!m) return true
|
|
5174
|
+
return m[1].split(',').map((x) => x.trim()).some((pk) => PAPER_REPAIR_CORE.indexOf(pk) !== -1)
|
|
5175
|
+
}).join('\n')
|
|
5176
|
+
}
|
|
5177
|
+
function texMinimal(tex, lang) {
|
|
5178
|
+
const body = String(tex).replace(/[\s\S]*?\\begin\{document\}/, '').replace(/\\end\{document\}[\s\S]*$/, '')
|
|
5179
|
+
return '\\documentclass[11pt]{' + (lang === 'en' ? 'article' : 'ctexart') + '}\n\\begin{document}\n' + body.trim() + '\n\\end{document}\n'
|
|
5180
|
+
}
|
|
5181
|
+
function paperEngineCandidates(lang) {
|
|
5182
|
+
const order = PAPER_ENGINE_ORDER[lang === 'en' ? 'en' : 'zh'].slice()
|
|
5183
|
+
const forced = String(params.paperLatexCommand || '').trim()
|
|
5184
|
+
if (!forced) return order
|
|
5185
|
+
return [forced].concat(order.filter((n) => n !== forced))
|
|
5186
|
+
}
|
|
5187
|
+
async function latexEngines(lang) {
|
|
5188
|
+
const sub = subprocessOf()
|
|
5189
|
+
if (!sub || typeof sub.resolveExecutable !== 'function') {
|
|
5190
|
+
return { engines: [], reason: '宿主没有 subprocess 服务:无法检测或调用 LaTeX' }
|
|
5191
|
+
}
|
|
5192
|
+
const engines = []
|
|
5193
|
+
for (const name of paperEngineCandidates(lang)) {
|
|
5194
|
+
try { const exe = await sub.resolveExecutable(name); if (exe) engines.push({ name: String(name), exe: String(exe) }) } catch (e) { /* not installed */ }
|
|
5195
|
+
}
|
|
5196
|
+
return { engines, reason: engines.length ? '' : ('未检测到 LaTeX 引擎(' + paperEngineCandidates(lang).join('/') + ')') }
|
|
5197
|
+
}
|
|
5198
|
+
// ONE engine invocation. NEVER throws: every failure becomes a readable result (the Lean
|
|
5199
|
+
// seam's discipline) and a timeout really terminates the process.
|
|
5200
|
+
async function runPaperProcess(argv, cwd) {
|
|
5201
|
+
const sub = subprocessOf()
|
|
5202
|
+
const started = now()
|
|
5203
|
+
if (!sub || typeof sub.spawn !== 'function') return { ok: false, exitCode: null, ms: 0, message: 'no subprocess service' }
|
|
5204
|
+
let handle
|
|
5205
|
+
try {
|
|
5206
|
+
handle = sub.spawn({ argv, cwd, stdio: { stdin: 'ignore', stdout: { maxBytes: 64 * 1024 }, stderr: { maxBytes: 64 * 1024 } }, graceMs: 120000 })
|
|
5207
|
+
} catch (e) { return { ok: false, exitCode: null, ms: now() - started, message: String((e && e.message) || e) } }
|
|
5208
|
+
let outcome = null
|
|
5209
|
+
let timer = null
|
|
5210
|
+
try {
|
|
5211
|
+
const ran = Promise.resolve(handle.done).then((v) => ({ settled: true, value: v }), (e) => ({ settled: false, error: e }))
|
|
5212
|
+
const r = await Promise.race([
|
|
5213
|
+
ran,
|
|
5214
|
+
new Promise((resolve) => {
|
|
5215
|
+
timer = ctx.timeout(() => {
|
|
5216
|
+
try { if (typeof handle.terminate === 'function') handle.terminate() } catch (e) { /* the race result is the report */ }
|
|
5217
|
+
resolve({ settled: true, value: { exitCode: null, signal: 'SIGTERM' } })
|
|
5218
|
+
}, 120000)
|
|
5219
|
+
}),
|
|
5220
|
+
])
|
|
5221
|
+
if (!r.settled) return { ok: false, exitCode: null, ms: now() - started, message: String((r.error && r.error.message) || r.error) }
|
|
5222
|
+
outcome = r.value
|
|
5223
|
+
} finally { if (timer) { try { timer() } catch (e) { /* already settled */ } } }
|
|
5224
|
+
const exitCode = outcome ? outcome.exitCode : null
|
|
5225
|
+
return { ok: exitCode === 0, exitCode, ms: now() - started, message: '' }
|
|
5226
|
+
}
|
|
5227
|
+
// Compile attempts are CAPPED (docs/final-paper.md §8): full → nonstopmode rerun → engine swap with
|
|
5228
|
+
// the optional packages stripped → one minimal-template retry → report and degrade.
|
|
5229
|
+
async function compilePaperTex(id, tex, lang) {
|
|
5230
|
+
if (await fileExistsAbs(paperAbs(id, 'paper.pdf'))) {
|
|
5231
|
+
return { status: 'kept-existing', engine: '', reason: 'Paper/' + id + '/paper.pdf 已存在:不覆盖(docs/final-paper.md §5)', attempts: [] }
|
|
5232
|
+
}
|
|
5233
|
+
const det = await latexEngines(lang)
|
|
5234
|
+
if (!det.engines.length) return { status: 'not-detected', engine: '', reason: det.reason, attempts: [] }
|
|
5235
|
+
const e0 = det.engines[0], e1 = det.engines[1] || e0
|
|
5236
|
+
const plan = [
|
|
5237
|
+
{ label: 'full', engine: e0, text: tex, nonstopOnly: false },
|
|
5238
|
+
{ label: 'nonstopmode-rerun', engine: e0, text: tex, nonstopOnly: true },
|
|
5239
|
+
{ label: 'engine-swap+stripped', engine: e1, text: texStripOptionalPackages(tex), nonstopOnly: true },
|
|
5240
|
+
{ label: 'minimal-template', engine: e0, text: texMinimal(tex, lang), nonstopOnly: true },
|
|
5241
|
+
]
|
|
5242
|
+
const attempts = []
|
|
5243
|
+
for (const a of plan) {
|
|
5244
|
+
const r = await runLatexAttempt(id, a)
|
|
5245
|
+
attempts.push(r)
|
|
5246
|
+
if (r.ok) return { status: 'compiled', engine: a.engine.name, attempts }
|
|
5247
|
+
}
|
|
5248
|
+
return { status: 'failed', engine: '', reason: '编译在 ' + attempts.length + ' 次尝试后仍失败(已保留 paper.tex 与 paper.md)', attempts }
|
|
5249
|
+
}
|
|
5250
|
+
async function runLatexAttempt(id, a) {
|
|
5251
|
+
const dirRel = paperDirRel(id)
|
|
5252
|
+
const dirAbs = instRoot() + '/' + dirRel
|
|
5253
|
+
if (!await writeTextRel(dirRel + '/paper.tex', a.text)) {
|
|
5254
|
+
return { label: a.label, engine: a.engine.name, ok: false, exitCode: null, ms: 0, message: 'tex 写入失败' }
|
|
5255
|
+
}
|
|
5256
|
+
const started = now()
|
|
5257
|
+
const args = a.engine.name === 'tectonic'
|
|
5258
|
+
? ['-X', 'paper.tex']
|
|
5259
|
+
: (a.nonstopOnly ? ['-interaction=nonstopmode', 'paper.tex'] : ['-interaction=nonstopmode', '-halt-on-error', 'paper.tex'])
|
|
5260
|
+
const argv = [a.engine.exe].concat(args)
|
|
5261
|
+
// TWO passes (docs/final-paper.md §8): the second resolves references/TOC. A failed first pass is not
|
|
5262
|
+
// rerun — the retry PLAN is what varies the command, not a blind repeat.
|
|
5263
|
+
const first = await runPaperProcess(argv, dirAbs)
|
|
5264
|
+
if (!first.ok) return { label: a.label, engine: a.engine.name, ok: false, exitCode: first.exitCode, ms: now() - started, message: first.message || 'first pass failed' }
|
|
5265
|
+
const second = await runPaperProcess(argv, dirAbs)
|
|
5266
|
+
const pdf = await fileExistsAbs(paperAbs(id, 'paper.pdf'))
|
|
5267
|
+
return { label: a.label, engine: a.engine.name, ok: second.ok && pdf, exitCode: second.exitCode, ms: now() - started, message: pdf ? (second.message || '') : '编译器没有产出 paper.pdf' }
|
|
5268
|
+
}
|
|
5269
|
+
function paperMeta(p, extra) {
|
|
5270
|
+
const o = extra || {}
|
|
5271
|
+
const active = paperActiveParticipants(p)
|
|
5272
|
+
const skipped = (p.participants || []).filter((x) => active.indexOf(x) === -1)
|
|
5273
|
+
return {
|
|
5274
|
+
id: p.id,
|
|
5275
|
+
dir: 'Paper/' + p.id + '/',
|
|
5276
|
+
institute: instituteName, project, runId: inst().runId || runId || '',
|
|
5277
|
+
problemId: (inst().problem || {}).id || '',
|
|
5278
|
+
trigger: p.trigger,
|
|
5279
|
+
finalizedAt: o.finalizedAt || (p.finalizedAt || now()),
|
|
5280
|
+
language: p.lang, format: p.format, compilePdf: p.pdf === true,
|
|
5281
|
+
editor: p.editor, editorNote: p.editorNote || '', editorFallback: !!p.editorFallback,
|
|
5282
|
+
finalisation: p.final || null,
|
|
5283
|
+
consultation: Object.assign({ messages: 0, meetings: 0 }, p.consult || {}),
|
|
5284
|
+
participants: p.participants || [], skipped,
|
|
5285
|
+
rounds: p.round, maxRounds: PAPER_MAX_ROUNDS,
|
|
5286
|
+
parts: Object.keys(p.parts || {}).map((id) => ({ member: id, round: (p.parts[id] || {}).round, title: (p.parts[id] || {}).title || '', evidence: ((p.parts[id] || {}).evidence || []).length })),
|
|
5287
|
+
reviews: Object.keys(p.reviews || {}).map((id) => ({ member: id, of: (p.reviews[id] || {}).of, round: (p.reviews[id] || {}).round, deliverable: (p.reviews[id] || {}).deliverable === true })),
|
|
5288
|
+
disagreement: p.disagreement || [], warnings: o.warnings || p.warnings || [],
|
|
5289
|
+
forcedAfterCap: !!p.forcedAfterCap,
|
|
5290
|
+
sections: PAPER_SECTIONS.length,
|
|
5291
|
+
compile: o.compile || { status: p.compile || 'unknown', engine: p.engine || '', attempts: [] },
|
|
5292
|
+
params: {
|
|
5293
|
+
finalPaper: params.finalPaper, paperFormat: params.paperFormat, paperLanguage: params.paperLanguage,
|
|
5294
|
+
paperCompilePdf: params.paperCompilePdf, paperEditor: params.paperEditor, paperLatexCommand: params.paperLatexCommand,
|
|
5295
|
+
},
|
|
5296
|
+
files: o.files || [],
|
|
5297
|
+
}
|
|
5298
|
+
}
|
|
5299
|
+
async function refillPaperArtifacts(p) {
|
|
5300
|
+
const id = p.id
|
|
5301
|
+
const active = paperActiveParticipants(p)
|
|
5302
|
+
const skipped = (p.participants || []).filter((x) => active.indexOf(x) === -1)
|
|
5303
|
+
const conclusionText = await readTextRel('Problems/conclusion.md')
|
|
5304
|
+
const S = paperSections(p, active, skipped, paperEvidence(), { final: p.final, conclusionText })
|
|
5305
|
+
const filled = []
|
|
5306
|
+
if (p.format !== 'tex' && (await readTextAbs(paperAbs(id, 'paper.md'))) === undefined) {
|
|
5307
|
+
if (await writeTextRel(PAPER_FILE(id, 'paper.md'), renderPaperMd(p, S))) filled.push('paper.md')
|
|
5308
|
+
}
|
|
5309
|
+
let tex = await readTextAbs(paperAbs(id, 'paper.tex'))
|
|
5310
|
+
if (p.format !== 'md' && tex === undefined) {
|
|
5311
|
+
tex = renderPaperTex(p, S)
|
|
5312
|
+
if (await writeTextRel(PAPER_FILE(id, 'paper.tex'), tex)) filled.push('paper.tex')
|
|
5313
|
+
}
|
|
5314
|
+
if (p.format !== 'md' && p.pdf === true && !await fileExistsAbs(paperAbs(id, 'paper.pdf'))) {
|
|
5315
|
+
const c = await compilePaperTex(id, tex || renderPaperTex(p, S), p.lang)
|
|
5316
|
+
if (c.status === 'compiled') filled.push('paper.pdf')
|
|
5317
|
+
// Same rule as finalizePaper: the delivered tex is always the canonical generated one.
|
|
5318
|
+
if (tex !== undefined && tex !== null) await writeTextRel(PAPER_FILE(id, 'paper.tex'), tex)
|
|
5319
|
+
}
|
|
5320
|
+
if ((await readTextAbs(paperAbs(id, 'paper.meta.json'))) === undefined) {
|
|
5321
|
+
if (await writeTextRel(PAPER_FILE(id, 'paper.meta.json'), JSON.stringify(paperMeta(p, { refilled: true }), null, 2) + '\n')) filled.push('paper.meta.json')
|
|
5322
|
+
}
|
|
5323
|
+
if ((await readTextAbs(paperAbs(id, 'paper.log.md'))) === undefined) {
|
|
5324
|
+
await paperLog('重复触发:补写缺失产物', '- 本次补写:' + (filled.join('、') || '(无缺失)'))
|
|
5325
|
+
filled.push('paper.log.md')
|
|
5326
|
+
} else if (filled.length) {
|
|
5327
|
+
await paperLog('重复触发:补写缺失产物', '- 本次补写:' + filled.join('、'))
|
|
5328
|
+
}
|
|
5329
|
+
return { filled }
|
|
5330
|
+
}
|
|
5331
|
+
async function finalizePaper(reason) {
|
|
5332
|
+
const p = paper()
|
|
5333
|
+
if (!p) return { ok: false, code: 'V5_PAPER_STATE', message: 'no paper flow' }
|
|
5334
|
+
const id = p.id
|
|
5335
|
+
const active = paperActiveParticipants(p)
|
|
5336
|
+
const skipped = (p.participants || []).filter((x) => active.indexOf(x) === -1)
|
|
5337
|
+
const ev = paperEvidence()
|
|
5338
|
+
const conclusionText = await readTextRel('Problems/conclusion.md')
|
|
5339
|
+
const finalizedAt = now()
|
|
5340
|
+
const S = paperSections(p, active, skipped, ev, { final: p.final, conclusionText })
|
|
5341
|
+
const files = []
|
|
5342
|
+
if (p.format !== 'tex') {
|
|
5343
|
+
if (await writeTextRel(PAPER_FILE(id, 'paper.md'), renderPaperMd(p, S))) files.push('paper.md')
|
|
5344
|
+
}
|
|
5345
|
+
let tex = ''
|
|
5346
|
+
if (p.format !== 'md') {
|
|
5347
|
+
tex = renderPaperTex(p, S)
|
|
5348
|
+
if (await writeTextRel(PAPER_FILE(id, 'paper.tex'), tex)) files.push('paper.tex')
|
|
5349
|
+
}
|
|
5350
|
+
// `paperFormat=md` with `paperCompilePdf=true` must NOT warn about a missing tex: no tex
|
|
5351
|
+
// was produced, so compilation is skipped silently (docs/final-paper.md §8).
|
|
5352
|
+
let compile
|
|
5353
|
+
if (p.format === 'md') compile = { status: 'skipped', engine: '', reason: 'paperFormat=md:未产出 tex,跳过编译', attempts: [] }
|
|
5354
|
+
else if (p.pdf !== true) compile = { status: 'skipped', engine: '', reason: 'paperCompilePdf=false', attempts: [] }
|
|
5355
|
+
else compile = await compilePaperTex(id, tex, p.lang)
|
|
5356
|
+
// The repair attempts overwrite `paper.tex` with stripped/minimal variants to get past a
|
|
5357
|
+
// broken engine. Whatever the compile outcome, the DELIVERED tex must be the real,
|
|
5358
|
+
// fully-generated one (the attempts are recorded in the meta either way).
|
|
5359
|
+
if (p.format !== 'md') await writeTextRel(PAPER_FILE(id, 'paper.tex'), tex)
|
|
5360
|
+
if (await fileExistsAbs(paperAbs(id, 'paper.pdf'))) files.push('paper.pdf')
|
|
5361
|
+
const warnings = (p.warnings || []).slice()
|
|
5362
|
+
if (compile.status === 'failed') warnings.push('LaTeX 编译失败(已保留 paper.tex 与 paper.md,不阻塞定稿):' + (compile.attempts || []).map((a) => a.engine + '/' + a.label + ' exit=' + a.exitCode + (a.message ? '(' + a.message + ')' : '')).join(';'))
|
|
5363
|
+
if (compile.status === 'not-detected') warnings.push('未检测到 LaTeX 引擎(' + (compile.reason || '') + '):只交付 paper.tex 与 paper.md。')
|
|
5364
|
+
const meta = paperMeta(Object.assign({}, p, { finalizedAt, compile: compile.status, engine: compile.engine || '' }), {
|
|
5365
|
+
finalizedAt, warnings, compile: { status: compile.status, engine: compile.engine || '', reason: compile.reason || '', attempts: compile.attempts || [] }, files,
|
|
5366
|
+
})
|
|
5367
|
+
if (await writeTextRel(PAPER_FILE(id, 'paper.meta.json'), JSON.stringify(meta, null, 2) + '\n')) files.push('paper.meta.json')
|
|
5368
|
+
await paperLog('定稿(' + reason + ')', [
|
|
5369
|
+
'- 目录:Paper/' + id + '/',
|
|
5370
|
+
'- 产物:' + files.join('、'),
|
|
5371
|
+
'- 编译:' + compile.status + (compile.engine ? '(' + compile.engine + ')' : '') + '|尝试 ' + (compile.attempts || []).length + ' 次',
|
|
5372
|
+
'- 定稿代表:' + p.editor + '|轮次:' + p.round + '/' + PAPER_MAX_ROUNDS,
|
|
5373
|
+
'- 所办交流:消息 ' + Number((p.consult || {}).messages || 0) + ' 条、会议 ' + Number((p.consult || {}).meetings || 0) + ' 次',
|
|
5374
|
+
(p.final && p.final.note ? '- 定稿说明:' + p.final.note : ''),
|
|
5375
|
+
(warnings.length ? '- 警告:\n - ' + warnings.join('\n - ') : ''),
|
|
5376
|
+
].filter(Boolean).join('\n'))
|
|
5377
|
+
await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
|
|
5378
|
+
status: 'finalized', stage: 'finalized', finalizedAt, compile: compile.status,
|
|
5379
|
+
engine: compile.engine || '', warnings, artifacts: files, updatedAt: now(),
|
|
5380
|
+
}) : cur))
|
|
5381
|
+
await saveChatLine('【论文】最终论文已定稿:Paper/' + id + '/(' + (files.join('、') || '(无产物)') + ';编译 ' + compile.status + ')。')
|
|
5382
|
+
notifyActivity()
|
|
5383
|
+
// The run is only now complete (docs/final-paper.md §3): the paper phase ran BEFORE the flags.
|
|
5384
|
+
if (p.completesRun && !autoDone) await finishRun('全体有表决权者一致认为原问题已解决')
|
|
5385
|
+
return {
|
|
5386
|
+
ok: true, finalized: true, id, dir: 'Paper/' + id + '/', files,
|
|
5387
|
+
compile: compile.status, engine: compile.engine || '', attempts: (compile.attempts || []).length,
|
|
5388
|
+
warnings, agreement: !(p.disagreement || []).length, rounds: p.round,
|
|
5389
|
+
}
|
|
5390
|
+
}
|
|
5391
|
+
|
|
3472
5392
|
// ---- hire / fire -------------------------------------------------------
|
|
3473
5393
|
function employedTemps() { return activeMembers().filter((m) => m.kind === 'temp') }
|
|
3474
5394
|
// ANY academician or permanently-employed researcher may hire its own temp
|
|
@@ -3623,7 +5543,13 @@ export function apply(ctx) {
|
|
|
3623
5543
|
// activity wait, which costs no tokens while the institute is genuinely idle.
|
|
3624
5544
|
function syncParamsFromState() {
|
|
3625
5545
|
const cur = inst()
|
|
3626
|
-
|
|
5546
|
+
// Same rule as the load path above: never adopt a persisted value without normalising it
|
|
5547
|
+
// (a foreign-shaped state file must not leak straight into the runtime params).
|
|
5548
|
+
if (cur && cur.params) {
|
|
5549
|
+
const droppedSync = []
|
|
5550
|
+
params = Object.assign({}, DEFAULT_PARAMS, normalizeParams(cur.params, droppedSync))
|
|
5551
|
+
reportDroppedStateKeys(cur, droppedSync, 'restore-sync')
|
|
5552
|
+
}
|
|
3627
5553
|
if (cur && cur.project) project = cur.project
|
|
3628
5554
|
if (cur && cur.institute) instituteName = cur.institute
|
|
3629
5555
|
if (cur) phase = cur.phase || phase
|
|
@@ -3673,6 +5599,16 @@ export function apply(ctx) {
|
|
|
3673
5599
|
await beginMeeting({ agenda: p.agenda, kind: p.kind, target: p.target, by: p.by })
|
|
3674
5600
|
return
|
|
3675
5601
|
}
|
|
5602
|
+
// The final-paper phase (docs/final-paper.md §3) owns the room once it is active: a paper ask is its
|
|
5603
|
+
// own kind of turn, and a research round must not be mistaken for a paper answer. While
|
|
5604
|
+
// the flow waits for the OFFICE the institute keeps working normally — the required
|
|
5605
|
+
// consultation needs office messages and a real meeting to be deliverable.
|
|
5606
|
+
if (paperActive()) {
|
|
5607
|
+
await paperPass()
|
|
5608
|
+
const cur = paper()
|
|
5609
|
+
const waitingForOffice = !!cur && cur.status === 'awaiting-editor' && cur.editor === 'office'
|
|
5610
|
+
if (!waitingForOffice) return
|
|
5611
|
+
}
|
|
3676
5612
|
const budget = Math.max(1, Math.floor(Number(params.maxParallel) || 3))
|
|
3677
5613
|
const idleMs = posMs(params.activityTimeoutMs, 120000)
|
|
3678
5614
|
let filled = 0
|
|
@@ -3723,7 +5659,7 @@ export function apply(ctx) {
|
|
|
3723
5659
|
const stallMs = posMs(params.stallAutoMeetingMs, 360000)
|
|
3724
5660
|
if (!meeting && !pendingMeeting && !hasVerifyInFlight() && phase === 'active' &&
|
|
3725
5661
|
busy.size === 0 && (now() - lastProgressAt) >= stallMs) {
|
|
3726
|
-
await startMeeting('office', { agenda: '本所较长时间没有新进展。请你们自行讨论:现在最该推进的是什么?谁来做?是否需要发起验证?', kind: 'sync' })
|
|
5662
|
+
await startMeeting('office', { agenda: '本所较长时间没有新进展。请你们自行讨论:现在最该推进的是什么?谁来做?是否需要发起验证?', kind: 'sync', auto: true })
|
|
3727
5663
|
return
|
|
3728
5664
|
}
|
|
3729
5665
|
// (e) heartbeat: push the longest-idle member to make progress rather than just
|
|
@@ -3897,6 +5833,12 @@ export function apply(ctx) {
|
|
|
3897
5833
|
}
|
|
3898
5834
|
// (10) solve votes / personal judgement
|
|
3899
5835
|
if (p.vote_solved !== undefined) await recordSolveVote(member.id, p.vote_solved === true)
|
|
5836
|
+
// (10b) final-paper channels: the member's own part, a cross-review, or the editor's
|
|
5837
|
+
// decision. Each is only honoured in the stage that asked for it (the record functions
|
|
5838
|
+
// refuse otherwise), so a stray/stale field can never advance the paper.
|
|
5839
|
+
if (p.paper_part && typeof p.paper_part === 'object') await paperRecordPart(member.id, p.paper_part)
|
|
5840
|
+
if (p.paper_review && typeof p.paper_review === 'object') await paperRecordReview(member.id, p.paper_review)
|
|
5841
|
+
if (p.paper_final && typeof p.paper_final === 'object') await paperRecordFinal(member.id, p.paper_final)
|
|
3900
5842
|
// (11) meeting input collection (keyed on the LIVE member set, so a member who
|
|
3901
5843
|
// joined mid-meeting still has to speak and a dismissed one stops blocking it)
|
|
3902
5844
|
if (kind === 'meeting' && meeting) {
|
|
@@ -3952,6 +5894,9 @@ export function apply(ctx) {
|
|
|
3952
5894
|
try { await maybeRealCompact(childId, member) } catch (e) { /* compaction is best-effort */ }
|
|
3953
5895
|
wakeKind.delete(member.id)
|
|
3954
5896
|
if (member.activeMeetingId) delete member.activeMeetingId
|
|
5897
|
+
// The final-paper phase is driven by member turns, exactly like a meeting: the reply was
|
|
5898
|
+
// just folded, so advance the paper BEFORE asking the (possibly inert) scheduler.
|
|
5899
|
+
try { await paperPass() } catch (e) { console.error('vibe-math-v5: paper after end: ' + String((e && e.message) || e)) }
|
|
3955
5900
|
await scheduleNext()
|
|
3956
5901
|
}
|
|
3957
5902
|
// ---- toolFilter names the host may not register -------------------------
|
|
@@ -4009,16 +5954,26 @@ export function apply(ctx) {
|
|
|
4009
5954
|
}
|
|
4010
5955
|
|
|
4011
5956
|
// ---- control plane -----------------------------------------------------
|
|
4012
|
-
function normalizeParams(input) {
|
|
5957
|
+
function normalizeParams(input, dropped) {
|
|
4013
5958
|
const out = {}
|
|
4014
|
-
|
|
5959
|
+
// NOTE: new INT params go BEFORE the last two entries. The cross-preset
|
|
5960
|
+
// prompt-invariants self-probe mutates the exact TAIL of this array (dropping
|
|
5961
|
+
// leanTimeoutMs from the accept-set), so appending a key after it would silently
|
|
5962
|
+
// disarm that guard.
|
|
5963
|
+
const ints = ['leanJobsMaxParallel', 'mathTimeoutMs', 'researcherCount', 'quorumCap', 'verdictMaxRounds', 'maxTempPerMember', 'maxTempTotal',
|
|
4015
5964
|
'compactThreshold', 'compactAfterRounds', 'maxParallel', 'activityTimeoutMs', 'stallAutoMeetingMs',
|
|
4016
5965
|
'chatDigestMs', 'chatDigestMax', 'meetingKeepEvery', 'leanTimeoutMs']
|
|
4017
|
-
const bools = ['academician', 'academicianLeads', 'memberMayRejectAssign']
|
|
4018
|
-
const strs = ['quorumMode', 'provider', 'model', 'staffPersona', 'formalVerify', 'leanCommand'
|
|
4019
|
-
|
|
5966
|
+
const bools = ['academician', 'academicianLeads', 'memberMayRejectAssign', 'finalPaper', 'paperCompilePdf', 'leanAsync']
|
|
5967
|
+
const strs = ['quorumMode', 'provider', 'model', 'staffPersona', 'formalVerify', 'leanCommand',
|
|
5968
|
+
'paperFormat', 'paperLanguage', 'paperEditor', 'paperLatexCommand', 'leanInitiative',
|
|
5969
|
+
'mathComputation', 'mathMode', 'mathInstallScope']
|
|
5970
|
+
const arrs = ['toolAllow', 'toolDeny', 'tempToolAllow', 'tempToolDeny', 'leanArgs', 'leanSearchPaths',
|
|
5971
|
+
'mathEngines', 'mathPackages']
|
|
4020
5972
|
for (const k of ints) if (input[k] !== undefined) { const n = Math.floor(Number(input[k])); if (Number.isFinite(n)) out[k] = n }
|
|
4021
|
-
|
|
5973
|
+
// Explicit boolean coercion (docs/final-paper.md §2): the old `=== true || === 'true'` turned a
|
|
5974
|
+
// legitimate `1` / `'yes'` into FALSE and left junk values truthy-looking. An
|
|
5975
|
+
// unrecognised spelling is REJECTED (the default wins) rather than guessed.
|
|
5976
|
+
for (const k of bools) if (input[k] !== undefined) out[k] = coerceBool(input[k], false)
|
|
4022
5977
|
for (const k of strs) if (input[k] !== undefined) out[k] = String(input[k])
|
|
4023
5978
|
for (const k of arrs) {
|
|
4024
5979
|
if (input[k] === undefined) continue
|
|
@@ -4032,6 +5987,44 @@ export function apply(ctx) {
|
|
|
4032
5987
|
out.formalVerify = ['off', 'encourage', 'require'].indexOf(out.formalVerify) !== -1 ? out.formalVerify : 'off'
|
|
4033
5988
|
}
|
|
4034
5989
|
if (out.leanCommand !== undefined && !String(out.leanCommand).trim()) out.leanCommand = 'lean'
|
|
5990
|
+
// `leanAsync` gets an EXPLICIT branch (not just the shared bool loop): the mode must be
|
|
5991
|
+
// selected only by a real boolean (or the documented spellings) and an unknown value must
|
|
5992
|
+
// keep the DEFAULT (true) — and the string 'false' must never survive as truthy.
|
|
5993
|
+
if (input.leanAsync !== undefined) {
|
|
5994
|
+
out.leanAsync = (input.leanAsync === true || input.leanAsync === false)
|
|
5995
|
+
? input.leanAsync
|
|
5996
|
+
: coerceBool(input.leanAsync, DEFAULT_PARAMS.leanAsync)
|
|
5997
|
+
}
|
|
5998
|
+
// `leanInitiative` is a CLOSED enum (like quorumMode/formalVerify): a typo degrades to the
|
|
5999
|
+
// documented default ('normal') rather than becoming an unreachable fourth mode.
|
|
6000
|
+
if (out.leanInitiative !== undefined) {
|
|
6001
|
+
out.leanInitiative = ['off', 'normal', 'eager'].indexOf(String(out.leanInitiative)) !== -1 ? String(out.leanInitiative) : 'normal'
|
|
6002
|
+
}
|
|
6003
|
+
// Concurrency floor, same discipline as quorumCap/verdictMaxRounds.
|
|
6004
|
+
if (out.leanJobsMaxParallel !== undefined && out.leanJobsMaxParallel < 1) out.leanJobsMaxParallel = 1
|
|
6005
|
+
// The six math_computation keys are normalised by the SHARED module (one implementation
|
|
6006
|
+
// for all four presets — docs/math-computation.md): explicit enum/array/integer coercion,
|
|
6007
|
+
// so a string 'false', an unknown engine name or a sub-1000 timeout can never leak
|
|
6008
|
+
// through. ONLY the keys the caller actually passed are copied, so a partial update can
|
|
6009
|
+
// never reset the others back to their defaults.
|
|
6010
|
+
if (MATH_PARAM_NAMES.some((k) => input[k] !== undefined)) {
|
|
6011
|
+
for (const k of MATH_PARAM_NAMES) {
|
|
6012
|
+
if (input[k] === undefined) continue
|
|
6013
|
+
// The typed loops above already coerced the SHAPE (a comma string became an array, an
|
|
6014
|
+
// integer was floored); the module then applies the semantic rules (closed enums, known
|
|
6015
|
+
// engines, >=1000 timeout). A value the module rejects falls back to its own default.
|
|
6016
|
+
const src = out[k] !== undefined ? out[k] : input[k]
|
|
6017
|
+
const norm = normalizeMathParams({ [k]: src })
|
|
6018
|
+
const v = norm[k] !== undefined ? norm[k] : MATH_PARAM_DEFAULTS[k]
|
|
6019
|
+
out[k] = Array.isArray(v) ? v.slice() : v
|
|
6020
|
+
}
|
|
6021
|
+
}
|
|
6022
|
+
// ── final-paper enums (closed sets; an unknown value degrades to the documented
|
|
6023
|
+
// default instead of silently becoming an unreachable fourth mode) ────────────────
|
|
6024
|
+
if (out.paperFormat !== undefined) out.paperFormat = coercePaperEnum('paperFormat', out.paperFormat, ['both', 'md', 'tex'], 'both')
|
|
6025
|
+
if (out.paperLanguage !== undefined) out.paperLanguage = coercePaperEnum('paperLanguage', out.paperLanguage, ['zh', 'en'], 'zh')
|
|
6026
|
+
if (out.paperEditor !== undefined) out.paperEditor = coercePaperEnum('paperEditor', out.paperEditor, ['office', 'academician'], 'academician')
|
|
6027
|
+
if (out.paperLatexCommand !== undefined) out.paperLatexCommand = String(out.paperLatexCommand).trim()
|
|
4035
6028
|
// Guard every duration against a negative/NaN value: such a value would make a
|
|
4036
6029
|
// watchdog fire instantly and abandon all consensus (v4 §30-T41).
|
|
4037
6030
|
for (const k of ['activityTimeoutMs', 'stallAutoMeetingMs', 'chatDigestMs', 'leanTimeoutMs']) {
|
|
@@ -4048,13 +6041,44 @@ export function apply(ctx) {
|
|
|
4048
6041
|
if (out.compactThreshold !== undefined && !(out.compactThreshold > 0)) delete out.compactThreshold
|
|
4049
6042
|
if (out.chatDigestMax !== undefined && out.chatDigestMax < 1) out.chatDigestMax = 1
|
|
4050
6043
|
if (out.verdictMaxRounds !== undefined && out.verdictMaxRounds < 1) out.verdictMaxRounds = 1
|
|
6044
|
+
// round-4 item 2: an unknown key used to vanish with NO report (hand-edited state files).
|
|
6045
|
+
// Never lose information silently: collect the accepted-key set's complement and hand it to
|
|
6046
|
+
// the caller's collector (the restore/set sites turn it into a diagnostic + a warning).
|
|
6047
|
+
if (dropped) {
|
|
6048
|
+
const known = new Set(ints.concat(bools, strs, arrs))
|
|
6049
|
+
for (const k of Object.keys(input || {})) if (!known.has(k)) dropped.push(k)
|
|
6050
|
+
}
|
|
4051
6051
|
return out
|
|
4052
6052
|
}
|
|
6053
|
+
// round-4 item 2: report keys a state file (or a caller) carried but the whitelist rejected.
|
|
6054
|
+
// `stateObj` is the state the drop belongs to (the load site has it in hand); falls back to the
|
|
6055
|
+
// live institute. Deduplicated so a repeated sync cannot spam the diagnostics.
|
|
6056
|
+
function reportDroppedStateKeys(stateObj, dropped, where) {
|
|
6057
|
+
const keys = Array.isArray(dropped) ? dropped.slice().sort() : []
|
|
6058
|
+
if (!keys.length) return keys
|
|
6059
|
+
const target = (stateObj && Array.isArray(stateObj.diagnostics)) ? stateObj : (inst() || null)
|
|
6060
|
+
if (target && Array.isArray(target.diagnostics)) {
|
|
6061
|
+
const last = target.diagnostics[target.diagnostics.length - 1]
|
|
6062
|
+
const duplicate = !!(last && last.kind === 'state-dropped-keys' && JSON.stringify(last.keys) === JSON.stringify(keys))
|
|
6063
|
+
// round-4 item 4: only a NEWLY recorded entry warns, and the record goes through the same
|
|
6064
|
+
// retention as every other diagnostics writer in this preset (slice(-50)).
|
|
6065
|
+
if (duplicate) return keys
|
|
6066
|
+
target.diagnostics.push({ kind: 'state-dropped-keys', where: where, keys: keys })
|
|
6067
|
+
while (target.diagnostics.length > 50) target.diagnostics.shift()
|
|
6068
|
+
}
|
|
6069
|
+
console.warn('vibe-math-v5: 忽略未知参数键(不静默丢失,已记入 diagnostics):' + keys.join(', ') + '(来源:' + where + ')')
|
|
6070
|
+
return keys
|
|
6071
|
+
}
|
|
4053
6072
|
async function setParams(input) {
|
|
4054
|
-
const
|
|
6073
|
+
const droppedSet = []
|
|
6074
|
+
const patch = normalizeParams(input || {}, droppedSet)
|
|
6075
|
+
reportDroppedStateKeys(inst(), droppedSet, 'set')
|
|
4055
6076
|
const merged = Object.assign({}, params, patch)
|
|
4056
6077
|
await patchInstitute({ params: merged })
|
|
4057
6078
|
params = Object.assign({}, DEFAULT_PARAMS, merged)
|
|
6079
|
+
// The math availability line is MODE-DEPENDENT (mathComputation/mathMode/mathEngines), so
|
|
6080
|
+
// a tuning call must re-derive it immediately instead of leaving a stale line in prompts.
|
|
6081
|
+
if (MATH_PARAM_NAMES.some((k) => patch[k] !== undefined)) await refreshMathLine()
|
|
4058
6082
|
// An already-armed heartbeat keeps the delay it was armed with, so a lowered
|
|
4059
6083
|
// activityTimeoutMs (or a raised maxParallel) would not take effect until some
|
|
4060
6084
|
// unrelated event drove a pass. Tuning must apply immediately.
|
|
@@ -4076,6 +6100,17 @@ export function apply(ctx) {
|
|
|
4076
6100
|
chatDigestMax: params.chatDigestMax, meetingKeepEvery: params.meetingKeepEvery,
|
|
4077
6101
|
formalVerify: params.formalVerify, leanCommand: params.leanCommand,
|
|
4078
6102
|
leanArgs: params.leanArgs, leanTimeoutMs: params.leanTimeoutMs,
|
|
6103
|
+
leanAsync: params.leanAsync, leanInitiative: params.leanInitiative,
|
|
6104
|
+
leanSearchPaths: params.leanSearchPaths, leanJobsMaxParallel: params.leanJobsMaxParallel,
|
|
6105
|
+
// `visibleParams` is an EXPLICIT object (landing.md §3): a new key is invisible to
|
|
6106
|
+
// `/v5 status` until it is added here.
|
|
6107
|
+
mathComputation: params.mathComputation, mathMode: params.mathMode,
|
|
6108
|
+
mathEngines: (params.mathEngines || []).slice(), mathTimeoutMs: params.mathTimeoutMs,
|
|
6109
|
+
mathPackages: (params.mathPackages || []).slice(), mathInstallScope: params.mathInstallScope,
|
|
6110
|
+
// ── final paper ──────────────────────────────────────────────────────
|
|
6111
|
+
finalPaper: params.finalPaper, paperFormat: params.paperFormat,
|
|
6112
|
+
paperLanguage: params.paperLanguage, paperCompilePdf: params.paperCompilePdf,
|
|
6113
|
+
paperEditor: params.paperEditor, paperLatexCommand: params.paperLatexCommand,
|
|
4079
6114
|
provider: params.provider, model: params.model,
|
|
4080
6115
|
toolAllow: params.toolAllow, toolDeny: params.toolDeny,
|
|
4081
6116
|
}
|
|
@@ -4318,6 +6353,11 @@ export function apply(ctx) {
|
|
|
4318
6353
|
await saveChatLine('【恢复】研究所继续推进(重建成员 ' + respawned + ' 名)。')
|
|
4319
6354
|
notifyActivity()
|
|
4320
6355
|
await scheduleNext()
|
|
6356
|
+
// An interrupted FINAL PAPER resumes its own stage (the paper phase runs before the
|
|
6357
|
+
// completion flags, so a crash there leaves a live, resumable institute).
|
|
6358
|
+
if (paperActive()) { try { await paperPass() } catch (e) { console.error('vibe-math-v5: paper after resume: ' + String((e && e.message) || e)) } }
|
|
6359
|
+
// Leftover Lean jobs are re-driven (or explicitly interrupted) on the resume path too.
|
|
6360
|
+
try { await recoverLeanJobs() } catch (e) { /* already reported on the ready() path */ }
|
|
4321
6361
|
return { ok: true, resumed: true, members: members.map((m) => m.id), respawned, running: true }
|
|
4322
6362
|
}
|
|
4323
6363
|
function setPause() {
|
|
@@ -4388,12 +6428,16 @@ export function apply(ctx) {
|
|
|
4388
6428
|
mode: formalMode(),
|
|
4389
6429
|
objects: Object.keys(formalRecords()).map((k) => {
|
|
4390
6430
|
const r = formalRecords()[k] || {}
|
|
4391
|
-
return { target: k, status: r.status, file: r.file || '', proof: r.proof || '', note: r.note || '', run: r.run ? { ok: r.run.ok, exitCode: r.run.exitCode, ms: r.run.ms } : null }
|
|
6431
|
+
return { target: k, status: r.status, file: r.file || '', proof: r.proof || '', note: r.note || '', run: r.run ? { ok: r.run.ok, exitCode: r.run.exitCode, ms: r.run.ms } : null, async: r.async || null }
|
|
4392
6432
|
}),
|
|
4393
6433
|
passed: Object.keys(formalRecords()).filter((k) => (formalRecords()[k] || {}).status === 'passed'),
|
|
4394
6434
|
blocked: Object.keys(formalRecords()).filter((k) => (formalRecords()[k] || {}).status === 'blocked'),
|
|
4395
6435
|
todo: formalTodo(),
|
|
4396
6436
|
},
|
|
6437
|
+
// The final-paper flow: stage, who wrote/reviewed what, the office-consultation
|
|
6438
|
+
// counters and the compile result. Without this an operator cannot tell whether the
|
|
6439
|
+
// run is waiting for the office, for a member, or for nobody.
|
|
6440
|
+
paper: paperSummary(),
|
|
4397
6441
|
lastProgressAt, params: visibleParams(),
|
|
4398
6442
|
}
|
|
4399
6443
|
}
|
|
@@ -4460,6 +6504,21 @@ export function apply(ctx) {
|
|
|
4460
6504
|
L.push('- 可复用库:VibeMath/Formal/{Lib,Proved}/(跨项目)|本所形式化:Formal/|归档证明:Verified/Lean/')
|
|
4461
6505
|
}
|
|
4462
6506
|
L.push('')
|
|
6507
|
+
L.push('## 最终论文')
|
|
6508
|
+
{
|
|
6509
|
+
const pp = paper()
|
|
6510
|
+
if (!pp) L.push('- 尚未开始(' + (params.finalPaper === false ? '自动已关闭(finalPaper=false),可用 /v5 paper 手动生成' : '收口时会自动开始;也可用 /v5 paper 手动开始') + ')')
|
|
6511
|
+
else {
|
|
6512
|
+
L.push('- 目录:Paper/' + pp.id + '/|状态:' + pp.status + '|阶段:' + pp.stage + '|轮次:' + pp.round + '/' + PAPER_MAX_ROUNDS)
|
|
6513
|
+
L.push('- 定稿代表:' + pp.editor + (pp.editorFallback ? '(原定代表不在册,已回退)' : '') + '|语言:' + pp.lang + '|格式:' + pp.format)
|
|
6514
|
+
L.push('- 交稿:' + (Object.keys(pp.parts || {}).join('、') || '(无)') + '|互审:' + (Object.keys(pp.reviews || {}).map((k) => k + '→' + ((pp.reviews[k] || {}).of || '?') + (pp.reviews[k] && pp.reviews[k].deliverable === true ? '(可交付)' : '(需修改)')).join('、') || '(无)'))
|
|
6515
|
+
L.push('- 所办交流:消息 ' + Number((pp.consult || {}).messages || 0) + ' 条|会议 ' + Number((pp.consult || {}).meetings || 0) + ' 次')
|
|
6516
|
+
if (pp.compile) L.push('- 编译:' + pp.compile + (pp.engine ? '(' + pp.engine + ')' : ''))
|
|
6517
|
+
if ((pp.warnings || []).length) L.push('- 警告:' + pp.warnings.join(';'))
|
|
6518
|
+
L.push('- 下一步:' + paperNextStep(pp))
|
|
6519
|
+
}
|
|
6520
|
+
}
|
|
6521
|
+
L.push('')
|
|
4463
6522
|
L.push('## 文件位置')
|
|
4464
6523
|
L.push('- 根目录:' + instRoot())
|
|
4465
6524
|
L.push('- 已确立:Verified/|成员库:Members/<id>/|群聊:Shared/Chat/|会议:Shared/Meetings/|辩论:Shared/Debates/')
|
|
@@ -4523,9 +6582,16 @@ export function apply(ctx) {
|
|
|
4523
6582
|
// Lean formal verification (docs/formal-verification.md)
|
|
4524
6583
|
formalMode, formalOn, formalRecords, formalTodo, formalOf, rebuildLeanLibIndexes,
|
|
4525
6584
|
leanArchive, leanRunTool, writeFormalIndex, writeFormalTodo, recordFidelityDefect,
|
|
6585
|
+
leanRead, leanJobTool, leanJobView, leanJobsView, leanSearchRootView, leanInitiative, leanDailyOn,
|
|
6586
|
+
// math_computation (docs/math-computation.md)
|
|
6587
|
+
refreshMathLine, mathLine: () => mathLineZh, mathSessionHost: () => mathSessionHost,
|
|
6588
|
+
runLeanQueue, disposeLeanJobs, leanRecover: recoverLeanJobs,
|
|
6589
|
+
leanQueueApi: async () => { await runLeanQueue(); return { jobs: leanJobsView(), notices: leanNotices.length } },
|
|
4526
6590
|
leanRunToolApi: async (relPath, timeoutMs) => await leanRunFile(relPath, timeoutMs),
|
|
4527
6591
|
// consensus / meetings
|
|
4528
6592
|
maybeQueueVerify, castVerdict, currentVerify, hasVerifyInFlight, startMeeting, quorumM, voterCount,
|
|
6593
|
+
// final paper (docs/final-paper.md; the phase runs BEFORE finishRun)
|
|
6594
|
+
startPaper, paperStatus: paperSummary, finalizePaperByOffice,
|
|
4529
6595
|
// authorization helpers (used by tool handlers)
|
|
4530
6596
|
memberIdOfAgent, isOffice, isAcademician, isProvablyOffice, officeCaller, memberById, activeMembers,
|
|
4531
6597
|
}
|
|
@@ -4568,6 +6634,54 @@ export function apply(ctx) {
|
|
|
4568
6634
|
if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: ' + what + ' needs a resolved member id or the office (the session root)' }
|
|
4569
6635
|
return fn(caller)
|
|
4570
6636
|
}
|
|
6637
|
+
|
|
6638
|
+
// ================= math_computation registration (docs/math-computation.md) =================
|
|
6639
|
+
// The SHARED module is instantiated PER CALLING SESSION, so every host callback closes over
|
|
6640
|
+
// exactly one session's LIVE params/root/fs — there is NO ambient "current session" slot that
|
|
6641
|
+
// a concurrent call from another session could overwrite (audit C: a single slot meant that
|
|
6642
|
+
// while session A awaited a spawn, session B could take it over, and A's later
|
|
6643
|
+
// params/writeText/projectRoot would silently be B's). The tool FACE is installed exactly once
|
|
6644
|
+
// (the module's own contract: one `host.register` call per preset); the per-session instances
|
|
6645
|
+
// are created lazily and their `register` callback is deliberately ignored.
|
|
6646
|
+
// The module's tool NAME has no `vibe_` prefix on purpose (it is the same primitive in all
|
|
6647
|
+
// four presets), and `projectRoot()` is the INSTITUTE root, so receipts land in
|
|
6648
|
+
// <institute>/Computation/<runId>/.
|
|
6649
|
+
let mathToolMeta = null // { name, description, parameters } — the frozen face
|
|
6650
|
+
const mathSessionHandlers = new WeakMap() // session API object -> that session's module handler
|
|
6651
|
+
// The metadata instance: its callbacks are never invoked (its handler is never called), it
|
|
6652
|
+
// exists only so the module can hand us the frozen tool face exactly once.
|
|
6653
|
+
const mathMetaHost = {
|
|
6654
|
+
register: (name, description, parameters) => { if (!mathToolMeta) mathToolMeta = { name, description, parameters } },
|
|
6655
|
+
params: () => DEFAULT_PARAMS,
|
|
6656
|
+
projectRoot: () => '.',
|
|
6657
|
+
designator: 'vibe-math-v5',
|
|
6658
|
+
writeText: async () => false,
|
|
6659
|
+
readText: async () => undefined,
|
|
6660
|
+
exists: async () => false,
|
|
6661
|
+
resolveExecutable: async () => { throw new Error('metadata host') },
|
|
6662
|
+
spawn: async () => null,
|
|
6663
|
+
log: () => {},
|
|
6664
|
+
}
|
|
6665
|
+
registerMathComputation(mathMetaHost)
|
|
6666
|
+
// Drift guard: the installed face must BE the shared frozen text (all four presets must ship
|
|
6667
|
+
// the same description — a re-typed copy here would diverge silently).
|
|
6668
|
+
if (!mathToolMeta || mathToolMeta.description !== MATH_TOOL_DESCRIPTION) {
|
|
6669
|
+
console.error('vibe-math-v5: math_computation was registered with a description that is not MATH_TOOL_DESCRIPTION')
|
|
6670
|
+
}
|
|
6671
|
+
function mathHandlerFor(s) {
|
|
6672
|
+
let handler = mathSessionHandlers.get(s)
|
|
6673
|
+
if (!handler) {
|
|
6674
|
+
// One instance per session: its host callbacks are the session's own seam.
|
|
6675
|
+
const inst = registerMathComputation(s.mathSessionHost())
|
|
6676
|
+
handler = inst && typeof inst.handler === 'function' ? inst.handler : async () => ({ ok: false, code: 'MATH_INVALID_ARGUMENT', message: 'math_computation: no session handler' })
|
|
6677
|
+
mathSessionHandlers.set(s, handler)
|
|
6678
|
+
}
|
|
6679
|
+
return handler
|
|
6680
|
+
}
|
|
6681
|
+
if (mathToolMeta) {
|
|
6682
|
+
registerTool(mathToolMeta.name, mathToolMeta.description, mathToolMeta.parameters,
|
|
6683
|
+
(s, args) => mathHandlerFor(s)(args))
|
|
6684
|
+
}
|
|
4571
6685
|
/**
|
|
4572
6686
|
* Resolve the caller of an OFFICE-ONLY control and refuse anything that is not the PROVABLE
|
|
4573
6687
|
* session root. `officeCaller` answers 'office' for that root, a member id for a member child, and
|
|
@@ -4599,14 +6713,23 @@ export function apply(ctx) {
|
|
|
4599
6713
|
registerTool('vibe_v5_stop', 'Stop the institute: interrupt every member, clear coordination state, and release their child sessions.', objParams({}), (s, a, x) => withOffice(s, x, 'stop the institute', () => s.initStop()))
|
|
4600
6714
|
registerTool('vibe_v5_status', 'Machine-readable institute status (members, tasks, quorum, meetings, verification, mail).', objParams({}), (s) => s.status())
|
|
4601
6715
|
registerTool('vibe_v5_report', 'Human-readable institute report (staffing, tasks, consensus, meetings, file locations).', objParams({}), (s) => s.report())
|
|
4602
|
-
registerTool('vibe_v5_set', 'Tune institute parameters (persisted in State/<institute>.v5state.json). provider/model override staff LLM routes (empty = inherit the office route). toolAllow/toolDeny restrict PERMANENT staff tools; tempToolAllow/tempToolDeny restrict temp workers. quorumCap sets m = min(quorumCap, voters); an m-vote passes only when at least m Boolean votes (exactly 1 or exactly 0) exist AND no voter returns an opposing Boolean, so with the default roster it degenerates to unanimity among the current voters; only current voters count (a dismissed member\'s earlier ballot is dropped). quorumMode "m-unanimous" (v5) or "all-unanimous" (v4 legacy). formalVerify: "off" (default, no extra requirement) | "encourage" (agents decide by implementation difficulty whether to formalize in Lean; a passing Lean run turns the vote into a FIDELITY review of the Lean statements) | "require" (same, plus a gate: a true/false verdict is withheld as undecided until the object is Lean-passed or has an explicit reasoned blocker record).', objParams({
|
|
6716
|
+
registerTool('vibe_v5_set', 'Tune institute parameters (persisted in State/<institute>.v5state.json). provider/model override staff LLM routes (empty = inherit the office route). toolAllow/toolDeny restrict PERMANENT staff tools; tempToolAllow/tempToolDeny restrict temp workers. quorumCap sets m = min(quorumCap, voters); an m-vote passes only when at least m Boolean votes (exactly 1 or exactly 0) exist AND no voter returns an opposing Boolean, so with the default roster it degenerates to unanimity among the current voters; only current voters count (a dismissed member\'s earlier ballot is dropped). quorumMode "m-unanimous" (v5) or "all-unanimous" (v4 legacy). formalVerify: "off" (default, no extra requirement) | "encourage" (agents decide by implementation difficulty whether to formalize in Lean; a passing Lean run turns the vote into a FIDELITY review of the Lean statements) | "require" (same, plus a gate: a true/false verdict is withheld as undecided until the object is Lean-passed or has an explicit reasoned blocker record). LEAN TOOLCHAIN: leanCommand names the Lean executable (e.g. "lake" with leanArgs ["env","lean"]); leanArgs are inserted before the file name (the framework appends --search-path <VibeMath root> unless leanArgs already sets one); leanTimeoutMs is the per-run budget in ms (>=1000, and the per-job budget of the async queue). FINAL PAPER: finalPaper (default true) writes the final paper when the run concludes — the paper phase runs BEFORE the run is marked complete, the permanent staff write their own part, cross-review each other, and the editor named by paperEditor finalises; paperFormat "both"|"md"|"tex"; paperLanguage "zh"|"en"; paperCompilePdf compiles a PDF when a LaTeX engine is detected; paperEditor "academician" (default, the only editor an unattended run can reach) | "office" (manual /v5 paper only — the office must first consult the whole institute: >=1 office message AND >=1 meeting, recorded in the finalisation note); paperLatexCommand forces one engine command instead of auto-detection (empty = auto: xelatex -> latexmk -> pdflatex -> lualatex -> tectonic, English prefers pdflatex). LEAN ASYNC: leanAsync (default true) compiles on a per-session background queue (vibe_v5_lean_run / vibe_v5_lean_archive run=true enqueue and return immediately; inspect them with vibe_v5_lean_job or vibe_v5_lean_lib.jobs and wait with vibe_v5_lean_job {jobId,waitMs}); leanAsync=false restores the previous synchronous behaviour. Only a settled job (exit 0, unchanged content hash AND the same build context) may mark an object passed; a job id is the content+build-context digest. leanInitiative "off"|"normal" (default)|"eager" separates DAILY eagerness about formalizing from formalVerify (which stays the verdict-time requirement). leanSearchPaths (string[]) adds extra --search-path roots before the automatic VibeMath root (deduped; an explicit --search-path/-R/--root in leanArgs wins). leanJobsMaxParallel (default 1) caps simultaneous background compiles. MATH COMPUTATION: mathComputation "off"|"auto" (default)|"on" gates the math_computation tool; mathMode "typed+shell" (default: the host shell may be used as a fallback, but a shell run carries no receipt and its conclusion must be marked 未经工具归档/not tool-archived) | "typed" (never mention the shell; engine=cli is refused); mathEngines lists the allowed engines (cli is on by default, SageMath is a later phase); mathTimeoutMs is the per-run budget (>=1000); mathPackages are packages a computation may require; mathInstallScope "user" (default) | "system" (per call only, never remembered). Installs are two-step (plan then confirm-token) and commercial engines are never installed. Unknown spellings of these enums fall back to the documented default.', objParams({
|
|
4603
6717
|
academician: B, academicianLeads: B, memberMayRejectAssign: B, researcherCount: I,
|
|
4604
6718
|
quorumCap: I, quorumMode: S, verdictMaxRounds: I,
|
|
4605
6719
|
maxTempPerMember: I, maxTempTotal: I,
|
|
4606
6720
|
compactThreshold: I, compactAfterRounds: I, maxParallel: I,
|
|
4607
6721
|
activityTimeoutMs: I, stallAutoMeetingMs: I, chatDigestMs: I, chatDigestMax: I, meetingKeepEvery: I,
|
|
4608
6722
|
formalVerify: { type: 'string', enum: ['off', 'encourage', 'require'] },
|
|
4609
|
-
leanCommand: S, leanArgs: SA, leanTimeoutMs: I,
|
|
6723
|
+
leanCommand: S, leanArgs: SA, leanTimeoutMs: I, leanAsync: B,
|
|
6724
|
+
leanInitiative: { type: 'string', enum: ['off', 'normal', 'eager'] },
|
|
6725
|
+
leanSearchPaths: SA, leanJobsMaxParallel: I,
|
|
6726
|
+
mathComputation: { type: 'string', enum: ['off', 'auto', 'on'] },
|
|
6727
|
+
mathMode: { type: 'string', enum: ['typed', 'typed+shell'] },
|
|
6728
|
+
mathEngines: SA, mathTimeoutMs: I, mathPackages: SA,
|
|
6729
|
+
mathInstallScope: { type: 'string', enum: ['user', 'system'] },
|
|
6730
|
+
finalPaper: B, paperFormat: { type: 'string', enum: ['both', 'md', 'tex'] },
|
|
6731
|
+
paperLanguage: { type: 'string', enum: ['zh', 'en'] },
|
|
6732
|
+
paperCompilePdf: B, paperEditor: { type: 'string', enum: ['office', 'academician'] }, paperLatexCommand: S,
|
|
4610
6733
|
provider: S, model: S, staffPersona: S, toolAllow: SA, toolDeny: SA, tempToolAllow: SA, tempToolDeny: SA,
|
|
4611
6734
|
}), (s, a, x) => withOffice(s, x, 'tune institute parameters', () => s.setParams(a)))
|
|
4612
6735
|
registerTool('vibe_v5_message', 'Relay a message from the office/human into the institute (to a member id, to "all", or to "voters").', objParams({ to: S, content: S }, ['to', 'content']), (s, a, x) => {
|
|
@@ -4651,6 +6774,16 @@ export function apply(ctx) {
|
|
|
4651
6774
|
? s.removeResearcher(a.id)
|
|
4652
6775
|
: { ok: false, code: 'V5_NOT_OFFICE', message: 'only the office (the session root) may dismiss a permanent researcher' }
|
|
4653
6776
|
)))
|
|
6777
|
+
// ── final paper (docs/final-paper.md) ─────────────────────────────────────
|
|
6778
|
+
registerTool('vibe_v5_paper', 'Final paper: start (or re-run) the team-authored paper for this run. The permanent staff write their own part, cross-review another member\'s part, and the editor named by paperEditor finalises. lang/format override paperLanguage/paperFormat for THIS paper; editor overrides paperEditor for THIS paper only (office = the manual path, which parks the flow until the office consults the institute and calls vibe_v5_finalize_paper); force rewrites an already-finalised paper (idempotent otherwise: it only fills artifacts that are missing).', objParams({ lang: S, format: S, editor: { type: 'string', enum: ['office', 'academician'] }, force: B, reason: S }), (s, a, x) => withOffice(s, x, 'write the final paper', () => {
|
|
6779
|
+
const lang = a.lang === undefined ? undefined : String(a.lang)
|
|
6780
|
+
const format = a.format === undefined ? undefined : String(a.format)
|
|
6781
|
+
if (lang !== undefined && lang !== 'zh' && lang !== 'en') return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'lang must be zh|en' }
|
|
6782
|
+
if (format !== undefined && ['both', 'md', 'tex'].indexOf(format) === -1) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'format must be both|md|tex' }
|
|
6783
|
+
if (a.editor !== undefined && ['office', 'academician'].indexOf(String(a.editor)) === -1) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'editor must be office|academician' }
|
|
6784
|
+
return s.startPaper(a.reason === 'run-complete' ? 'run-complete' : 'manual', { lang, format, editor: a.editor, force: a.force === true, reason: a.reason })
|
|
6785
|
+
}))
|
|
6786
|
+
registerTool('vibe_v5_finalize_paper', 'Office: finalise the final paper after consulting the whole institute. Required when paperEditor="office": at least one office message (vibe_v5_message) AND at least one meeting convened by the office must happen first (paper.meta.json records them, and the note must state the conclusion). decision="revise" asks for another writing round (bounded).', objParams({ decision: { type: 'string', enum: ['deliverable', 'revise'] }, note: S, conclusion: S, force: B }, ['decision']), (s, a, x) => withOffice(s, x, 'finalise the final paper', () => s.finalizePaperByOffice(a)))
|
|
4654
6787
|
|
|
4655
6788
|
// ── member-facing controls ────────────────────────────────────────────────
|
|
4656
6789
|
registerTool('vibe_v5_say', '(member) Speak in the group chat (omit "to"), send a private message ("to":"r-2"), or address only the voters ("to":"voters").', objParams({ text: S, to: S }, ['text']), (s, a, x) => {
|
|
@@ -4730,25 +6863,33 @@ export function apply(ctx) {
|
|
|
4730
6863
|
// dynamic registration would depend on a runtime knob and break the effect discipline),
|
|
4731
6864
|
// while the MODE only decides whether the framework TELLS members about them. In 'off'
|
|
4732
6865
|
// mode they still work if a human or agent calls them deliberately.
|
|
4733
|
-
registerTool('vibe_v5_lean_run', '(member) Execute the Lean toolchain on one .lean file inside the workspace and report the result. Never throws: a host with no subprocess service returns NO_SUBPROCESS and a missing toolchain returns LEAN_NOT_FOUND (in both cases the code can still be written down with vibe_v5_lean_archive), a timeout terminates the process and returns LEAN_TIMEOUT
|
|
4734
|
-
registerTool('vibe_v5_lean_archive', '(member) Archive Lean code. kind="def": a REUSABLE definition/object/assumption → the global cross-project library (Formal/Lib). kind="lemma": a machine-checked lemma → Formal/Proved. kind="proof": the formal proof of a project object → Formal/<target>.lean, and (
|
|
4735
|
-
registerTool('vibe_v5_lean_lib', '(member) List (and by default rebuild) the Lean reuse library: your institute\'s Formal/Index.md, plus the global cross-project Formal/Lib and Formal/Proved indexes. Look here BEFORE writing a new definition so you reuse instead of redefining.', objParams({ refresh: B }), async (s, a) => {
|
|
6866
|
+
registerTool('vibe_v5_lean_run', '(member) Execute the Lean toolchain on one .lean file inside the workspace and report the result. With leanAsync (default true) the compile is ENQUEUED and this returns immediately with async:{jobId,state} — nothing is compiled yet; inspect it via vibe_v5_lean_lib.jobs or wait for the next round\'s 【形式化结果】 line. With leanAsync=false it blocks and returns the compiler output (exitCode/stdout/stderr). Never throws: a host with no subprocess service returns NO_SUBPROCESS and a missing toolchain returns LEAN_NOT_FOUND (in both cases the code can still be written down with vibe_v5_lean_archive), a timeout terminates the process and returns LEAN_TIMEOUT. Pass target=<object id> to also record the run against that object. The framework appends `--search-path <VibeMath root>` before the file name (unless leanArgs already sets a search root).', objParams({ file: S, target: S, timeout_ms: I }, ['file']), (s, a, x) => withCaller(s, x, 'a Lean run', (caller) => s.leanRunTool(caller, a)))
|
|
6867
|
+
registerTool('vibe_v5_lean_archive', '(member) Archive Lean code. kind="def": a REUSABLE definition/object/assumption → the global cross-project library (Formal/Lib). kind="lemma": a machine-checked lemma → Formal/Proved. kind="proof": the formal proof of a project object → Formal/<target>.lean, and (only once the queued compile settles ok) also Verified/Lean/<target>.lean, marking the object Lean-passed. Re-archiving IDENTICAL content is de-duplicated (deduped:true, no rewrite/recompile). kind="blocked": record an explicit, reasoned "cannot/not worth formalizing" decision (note required).', objParams({ kind: { type: 'string', enum: ['def', 'lemma', 'proof', 'blocked'] }, name: S, target: S, content: S, from: S, note: S, run: B }, ['kind']), (s, a, x) => withCaller(s, x, 'a Lean archive', (caller) => s.leanArchive(caller, a)))
|
|
6868
|
+
registerTool('vibe_v5_lean_lib', '(member) List (and by default rebuild) the Lean reuse library: your institute\'s Formal/Index.md (with an import-dependency column), plus the global cross-project Formal/Lib and Formal/Proved indexes, the background-compile jobs (jobs) and the injected search path (paths.searchPath). Look here BEFORE writing a new definition so you reuse instead of redefining.', objParams({ refresh: B }), async (s, a) => {
|
|
4736
6869
|
const r = a && a.refresh === false ? { lib: null, proved: null, objects: Object.keys(s.formalRecords()).length } : await s.rebuildLeanLibIndexes()
|
|
4737
6870
|
const st = s.status()
|
|
4738
6871
|
return {
|
|
4739
6872
|
ok: true, mode: s.formalMode(), rebuilt: !(a && a.refresh === false),
|
|
4740
|
-
counts: r, todo: s.formalTodo(),
|
|
4741
|
-
objects: Object.keys(s.formalRecords()).map((k) => ({ target: k, status: (s.formalRecords()[k] || {}).status, file: (s.formalRecords()[k] || {}).file, proof: (s.formalRecords()[k] || {}).proof, note: (s.formalRecords()[k] || {}).note })),
|
|
4742
|
-
paths: { project: 'Formal/(相对研究所根)', lib: 'VibeMath/Formal/Lib/', proved: 'VibeMath/Formal/Proved/', proofs: 'Verified/Lean/' },
|
|
4743
|
-
hint: '复用优先:先在 Lib/ 里找现成定义;新定义用 vibe_v5_lean_archive kind=\'def\' 归档,已证引理用 kind=\'lemma\'
|
|
6873
|
+
counts: r, todo: s.formalTodo(), jobs: s.leanJobsView(),
|
|
6874
|
+
objects: Object.keys(s.formalRecords()).map((k) => ({ target: k, status: (s.formalRecords()[k] || {}).status, file: (s.formalRecords()[k] || {}).file, proof: (s.formalRecords()[k] || {}).proof, note: (s.formalRecords()[k] || {}).note, async: (s.formalRecords()[k] || {}).async || null })),
|
|
6875
|
+
paths: { project: 'Formal/(相对研究所根)', lib: 'VibeMath/Formal/Lib/', proved: 'VibeMath/Formal/Proved/', proofs: 'Verified/Lean/', searchPath: s.leanSearchRootView() },
|
|
6876
|
+
hint: '复用优先:先在 Lib/ 里找现成定义;新定义用 vibe_v5_lean_archive kind=\'def\' 归档,已证引理用 kind=\'lemma\'。复用已归档内容:import Formal.Lib.<name> / import Formal.Proved.<name>,或用 vibe_v5_lean_read {name} 取原文逐字复制。同内容重复归档会自动去重。',
|
|
4744
6877
|
verify: st.verify ? st.verify.target : null,
|
|
4745
6878
|
}
|
|
4746
6879
|
})
|
|
6880
|
+
// Read-only: the EXACT text of an archived definition/lemma (docs/formal-verification.md §5.4).
|
|
6881
|
+
// Only <VibeMath root>/Formal/{Lib,Proved}/<name>.lean is reachable; a path-shaped name is
|
|
6882
|
+
// refused (never sanitised into a different file).
|
|
6883
|
+
registerTool('vibe_v5_lean_read', '(member) Read one archived Lean file VERBATIM so it can be reused: name is the archived file name (no extension needed), kind is "auto" (Lib then Proved, default), "lib" or "proved". Returns {ok,name,file,kind,sha256,bytes,text,truncated} (text capped at 64KB). Only the global reusable library is reachable.', objParams({ name: S, kind: { type: 'string', enum: ['auto', 'lib', 'proved'] } }, ['name']), (s, a) => s.leanRead(a))
|
|
6884
|
+
// Wait/observe the background compile queue (docs/formal-verification.md §7: no polling tool
|
|
6885
|
+
// for the member's own turn — `waitMs` is a bounded internal wait, and the state is returned
|
|
6886
|
+
// either way, so a member can also just check and continue).
|
|
6887
|
+
registerTool('vibe_v5_lean_job', '(member) Inspect the background Lean compile queue. With no jobId it lists this session\'s jobs ({jobs,count,running,maxParallel}); with jobId it returns that job\'s state/exitCode, its receipt Formal/Jobs/<jobId>.json and, when it passed, the archived proof path. waitMs>0 waits up to that many milliseconds for a queued/running job to settle (it polls the queue instead of blocking the heartbeat) and otherwise returns the CURRENT state.', objParams({ jobId: S, waitMs: I }), (s, a) => s.leanJobTool(a))
|
|
4747
6888
|
|
|
4748
6889
|
// ── /v5 slash command ────────────────────────────────────────────────────
|
|
4749
6890
|
ctx.effect(() => commands.register({
|
|
4750
6891
|
name: 'v5', description: 'control the Vibe Math V5 research institute',
|
|
4751
|
-
input: { hint: '[configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set]' },
|
|
6892
|
+
input: { hint: '[configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set|paper [lang=en] [format=tex] [editor=office] [force]]' },
|
|
4752
6893
|
handler: async function (inv) {
|
|
4753
6894
|
const s = getSession(inv && inv.agent)
|
|
4754
6895
|
if (!s) return { kind: 'error', text: JSON.stringify({ ok: false, error: 'no session' }) }
|
|
@@ -4784,7 +6925,28 @@ export function apply(ctx) {
|
|
|
4784
6925
|
else if (cmd === 'fire') r = await s.fire('office', { id: rest[0] || '', reason: rest.slice(1).join(' ') })
|
|
4785
6926
|
else if (cmd === 'add') r = await s.addResearcher('office', rest.join(' '))
|
|
4786
6927
|
else if (cmd === 'remove') r = await s.removeResearcher(rest[0] || '')
|
|
4787
|
-
else if (cmd === '
|
|
6928
|
+
else if (cmd === 'paper') {
|
|
6929
|
+
// `/v5 paper [lang=en] [format=tex] [force] [editor=office|academician]`
|
|
6930
|
+
const o = {}
|
|
6931
|
+
let force = false
|
|
6932
|
+
let bad = ''
|
|
6933
|
+
for (const tok of rest) {
|
|
6934
|
+
if (tok === 'force') { force = true; continue }
|
|
6935
|
+
const eq = tok.indexOf('=')
|
|
6936
|
+
if (eq <= 0) { bad = 'unknown paper argument: ' + tok; break }
|
|
6937
|
+
const k = tok.slice(0, eq), v = tok.slice(eq + 1)
|
|
6938
|
+
if (k === 'lang' || k === 'format' || k === 'editor') o[k] = v
|
|
6939
|
+
else { bad = 'unknown paper option: ' + k + ' (use lang=zh|en format=both|md|tex editor=office|academician force)'; break }
|
|
6940
|
+
}
|
|
6941
|
+
if (!bad && o.lang !== undefined && o.lang !== 'zh' && o.lang !== 'en') bad = 'lang must be zh|en'
|
|
6942
|
+
if (!bad && o.format !== undefined && ['both', 'md', 'tex'].indexOf(o.format) === -1) bad = 'format must be both|md|tex'
|
|
6943
|
+
if (!bad && o.editor !== undefined && ['office', 'academician'].indexOf(o.editor) === -1) bad = 'editor must be office|academician'
|
|
6944
|
+
if (bad) return { kind: 'error', text: JSON.stringify({ ok: false, code: 'V5_INVALID_ARGUMENT', message: bad }, null, 2) }
|
|
6945
|
+
// `editor=office` is the manual path that uses the office as the finalising
|
|
6946
|
+
// representative (docs/final-paper.md §7 — never reachable from an automatic run). One-shot: it
|
|
6947
|
+
// does not rewrite the persisted `paperEditor`.
|
|
6948
|
+
r = await s.startPaper('manual', { lang: o.lang, format: o.format, editor: o.editor, force })
|
|
6949
|
+
} else if (cmd === 'set') {
|
|
4788
6950
|
const upd = {}
|
|
4789
6951
|
for (const tok of rest) {
|
|
4790
6952
|
const eq = tok.indexOf('=')
|
|
@@ -4794,7 +6956,7 @@ export function apply(ctx) {
|
|
|
4794
6956
|
upd[k] = Number.isFinite(n) && v !== '' ? n : (v === 'true' ? true : v === 'false' ? false : v)
|
|
4795
6957
|
}
|
|
4796
6958
|
r = await s.setParams(upd)
|
|
4797
|
-
} else r = { ok: false, usage: 'configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set' }
|
|
6959
|
+
} else r = { ok: false, usage: 'configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set|paper [lang=zh|en] [format=both|md|tex] [editor=office|academician] [force]' }
|
|
4798
6960
|
// A business failure (the dispatch result's own ok:false) is a FAILED command: the host's
|
|
4799
6961
|
// CommandResult union distinguishes success from error, and returning 'success' made a rejected
|
|
4800
6962
|
// invocation look identical to a successful one (same fix as v2/v3).
|
|
@@ -4867,6 +7029,9 @@ export const __testHelpers = {
|
|
|
4867
7029
|
parseReply,
|
|
4868
7030
|
sanitizeToolFilter,
|
|
4869
7031
|
registeredToolsFromError,
|
|
7032
|
+
sha256Hex,
|
|
7033
|
+
leanHashText,
|
|
7034
|
+
leanHasSearchFlag,
|
|
4870
7035
|
}
|
|
4871
7036
|
|
|
4872
7037
|
function clamp01(v) { const n = Number(v); if (!Number.isFinite(n)) return 0.5; return Math.max(0, Math.min(1, n)) }
|
|
@@ -4889,6 +7054,32 @@ function slugify(s) {
|
|
|
4889
7054
|
return t || ''
|
|
4890
7055
|
}
|
|
4891
7056
|
|
|
7057
|
+
// Explicit parameter coercion (docs/final-paper.md §2). A boolean parameter spelled `'false'`, `0`, `'no'`
|
|
7058
|
+
// or `'off'` must be false — the old `=== true || === 'true'` silently treated `1`/`'yes'` as
|
|
7059
|
+
// false. An unrecognised spelling is REJECTED (the caller's default wins), never guessed.
|
|
7060
|
+
function coerceBool(v, def) {
|
|
7061
|
+
if (v === undefined) return def
|
|
7062
|
+
if (v === true || v === 1 || v === '1') return true
|
|
7063
|
+
if (v === false || v === 0 || v === '0') return false
|
|
7064
|
+
const t = String(v).trim().toLowerCase()
|
|
7065
|
+
if (['true', 'yes', 'on', 'y', 't'].indexOf(t) !== -1) return true
|
|
7066
|
+
if (['false', 'no', 'off', 'n', 'f', ''].indexOf(t) !== -1) return false
|
|
7067
|
+
return def
|
|
7068
|
+
}
|
|
7069
|
+
|
|
7070
|
+
// Enum coercion with legacy aliases (.v5 / older flat spellings) and a documented fallback.
|
|
7071
|
+
const ENUM_ALIASES = {
|
|
7072
|
+
paperFormat: { markdown: 'md', latex: 'tex', texonly: 'tex', all: 'both' },
|
|
7073
|
+
paperLanguage: { cn: 'zh', chinese: 'zh', 'zh-cn': 'zh', english: 'en', 'en-us': 'en' },
|
|
7074
|
+
paperEditor: { root: 'office', host: 'office', acad: 'academician', dean: 'academician' },
|
|
7075
|
+
}
|
|
7076
|
+
function coercePaperEnum(key, v, allowed, def) {
|
|
7077
|
+
const t = String(v == null ? '' : v).trim().toLowerCase()
|
|
7078
|
+
if (allowed.indexOf(t) !== -1) return t
|
|
7079
|
+
const alias = (ENUM_ALIASES[key] || {})[t]
|
|
7080
|
+
return alias !== undefined && allowed.indexOf(alias) !== -1 ? alias : def
|
|
7081
|
+
}
|
|
7082
|
+
|
|
4892
7083
|
function tryJson(s) { try { return JSON.parse(s) } catch (e) { return undefined } }
|
|
4893
7084
|
|
|
4894
7085
|
function parseReply(text) {
|