spexcode 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -17
- package/package.json +9 -5
- package/spec-cli/README.md +23 -14
- package/spec-cli/hooks/dispatch.sh +7 -5
- package/spec-cli/hooks/harness.sh +19 -8
- package/spec-cli/src/anchors.ts +110 -18
- package/spec-cli/src/cli.ts +88 -37
- package/spec-cli/src/client.ts +34 -11
- package/spec-cli/src/doctor.ts +174 -36
- package/spec-cli/src/gateway-auth.ts +194 -0
- package/spec-cli/src/gateway-hub.ts +360 -0
- package/spec-cli/src/gateway.ts +27 -19
- package/spec-cli/src/git.ts +12 -5
- package/spec-cli/src/graph.ts +54 -43
- package/spec-cli/src/graphStream.ts +265 -34
- package/spec-cli/src/guide.ts +105 -55
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +181 -33
- package/spec-cli/src/help.ts +64 -27
- package/spec-cli/src/host.ts +610 -0
- package/spec-cli/src/identity-presets.d.ts +13 -0
- package/spec-cli/src/identity-presets.js +128 -0
- package/spec-cli/src/index.ts +145 -86
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +25 -121
- package/spec-cli/src/login-page.ts +12 -5
- package/spec-cli/src/materialize.ts +39 -13
- package/spec-cli/src/mentions.ts +16 -11
- package/spec-cli/src/opencode.ts +145 -0
- package/spec-cli/src/pi-harness.ts +124 -0
- package/spec-cli/src/project-identity.ts +73 -0
- package/spec-cli/src/pty-bridge.ts +321 -521
- package/spec-cli/src/pty-helper.mjs +86 -0
- package/spec-cli/src/reaper.ts +16 -8
- package/spec-cli/src/reviewSnapshot.ts +23 -0
- package/spec-cli/src/reviews.ts +347 -0
- package/spec-cli/src/session-timeline.ts +14 -0
- package/spec-cli/src/sessions.ts +188 -66
- package/spec-cli/src/shim-runtime.ts +169 -0
- package/spec-cli/src/slash-commands.ts +76 -0
- package/spec-cli/src/source-files.ts +79 -0
- package/spec-cli/src/specs.ts +12 -7
- package/spec-cli/src/supervise.ts +37 -15
- package/spec-cli/src/tree.ts +8 -8
- package/spec-cli/src/uninstall.ts +56 -23
- package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
- package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
- package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
- package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
- package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
- package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
- package/spec-cli/templates/spec/project/spec.md +6 -5
- package/spec-cli/templates/spexcode.json +4 -2
- package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
- package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
- package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
- package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
- package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
- package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
- package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
- package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
- package/spec-dashboard/dist/index.html +10 -10
- package/spec-dashboard/src/reviewFilters.js +279 -0
- package/spec-dashboard/src/reviewQuery.js +174 -0
- package/spec-dashboard/src/session.js +167 -0
- package/spec-eval/src/cli.ts +49 -28
- package/spec-eval/src/humanok.ts +1 -1
- package/spec-eval/src/matrix.ts +693 -0
- package/spec-eval/src/sessioneval.ts +726 -66
- package/spec-cli/src/migrate-table.ts +0 -408
- package/spec-cli/src/migrate.ts +0 -386
- package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
- package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
- package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
- package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
- package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
- package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
- package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
- package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +0 -6
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import * as pty from 'node-pty'
|
|
2
|
+
import { execFileSync } from 'node:child_process'
|
|
3
|
+
|
|
4
|
+
const [id, colsArg, rowsArg] = process.argv.slice(2)
|
|
5
|
+
const cols = Number(colsArg)
|
|
6
|
+
const rows = Number(rowsArg)
|
|
7
|
+
const socket = process.env.SPEXCODE_TMUX || 'spexcode'
|
|
8
|
+
|
|
9
|
+
if (!id || !(cols > 0 && rows > 0)) {
|
|
10
|
+
process.stderr.write('ERROR invalid helper arguments\n')
|
|
11
|
+
process.exit(2)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Establish the outer-terminal contract before this process owns a PTY. tmux must preserve hyperlinks and
|
|
15
|
+
// wrap client updates in DEC 2026 for xterm 6; doing the setup before forkpty preserves helper isolation.
|
|
16
|
+
try {
|
|
17
|
+
// The dashboard renders the pane, not tmux's client chrome. Keeping this as a tmux session option makes
|
|
18
|
+
// the requested PTY grid the pane grid too; filtering a coloured final row in xterm would corrupt real
|
|
19
|
+
// pane content. SpexCode owns these sessions, so a later foreground attach sees the same status-free pane.
|
|
20
|
+
execFileSync('tmux', ['-L', socket, 'set-option', '-t', id, 'status', 'off'])
|
|
21
|
+
// Control follows the person actually driving: `latest` sizes the window to the most recently
|
|
22
|
+
// active client, so a small screen takes the grid the moment its user interacts and sees the WHOLE
|
|
23
|
+
// pane, while an idle larger viewer letterboxes. `largest` optimized the big display's real estate
|
|
24
|
+
// but left a small concurrent viewer a cropped corner it could never take back.
|
|
25
|
+
execFileSync('tmux', ['-L', socket, 'set-window-option', '-t', id, 'window-size', 'latest'])
|
|
26
|
+
execFileSync('tmux', ['-L', socket, 'set-option', '-g', 'mouse', 'on'])
|
|
27
|
+
execFileSync('tmux', ['-L', socket, 'set-option', '-g', 'history-limit', '50000'])
|
|
28
|
+
// Wheel routing stays tmux-default: copy-mode history for a plain pane, pass-through to a
|
|
29
|
+
// mouse-owning TUI. Claude's TUI virtual-scrolls its own transcript on those reports (it lives on
|
|
30
|
+
// the alternate screen and repaints in place, so tmux history cannot substitute — an in-place
|
|
31
|
+
// renderer never scrolls lines off the top). Restore the default bindings idempotently in case an
|
|
32
|
+
// earlier release rebound them server-wide; the unbind may already be clean, so it fails quietly.
|
|
33
|
+
execFileSync('tmux', ['-L', socket, 'bind-key', '-n', 'WheelUpPane', 'if', '-F', '-t=', '#{||:#{pane_in_mode},#{mouse_any_flag}}', 'send-keys -M', 'copy-mode -et='])
|
|
34
|
+
try { execFileSync('tmux', ['-L', socket, 'unbind-key', '-n', 'WheelDownPane']) } catch { /* already unbound */ }
|
|
35
|
+
const features = execFileSync('tmux', ['-L', socket, 'show-options', '-gsv', 'terminal-features'], { encoding: 'utf8' })
|
|
36
|
+
const xtermFeatures = new Set(features.split('\n').filter((line) => line.startsWith('xterm*:')).flatMap((line) => line.split(':').slice(1)))
|
|
37
|
+
const missing = ['sync', 'hyperlinks'].filter((feature) => !xtermFeatures.has(feature))
|
|
38
|
+
if (missing.length) {
|
|
39
|
+
execFileSync('tmux', ['-L', socket, 'set-option', '-as', 'terminal-features', `,xterm*:${missing.join(':')}`])
|
|
40
|
+
}
|
|
41
|
+
} catch { /* attach below fails loudly if the tmux server/session is unavailable */ }
|
|
42
|
+
|
|
43
|
+
const terminal = pty.spawn('tmux', ['-u', '-L', socket, 'attach-session', '-t', id], {
|
|
44
|
+
name: 'xterm-256color',
|
|
45
|
+
cols,
|
|
46
|
+
rows,
|
|
47
|
+
env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' },
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
terminal.onData((data) => process.stdout.write(Buffer.from(data, 'utf8')))
|
|
51
|
+
terminal.onExit(({ exitCode }) => process.exit(exitCode === 0 ? 0 : 1))
|
|
52
|
+
process.stderr.write(`READY ${terminal.pid}\n`)
|
|
53
|
+
|
|
54
|
+
let input = ''
|
|
55
|
+
process.stdin.setEncoding('utf8')
|
|
56
|
+
process.stdin.on('data', (chunk) => {
|
|
57
|
+
input += chunk
|
|
58
|
+
let newline
|
|
59
|
+
while ((newline = input.indexOf('\n')) >= 0) {
|
|
60
|
+
const line = input.slice(0, newline)
|
|
61
|
+
input = input.slice(newline + 1)
|
|
62
|
+
if (!line) continue
|
|
63
|
+
try {
|
|
64
|
+
const message = JSON.parse(line)
|
|
65
|
+
if (message?.t === 'resize' && message.cols > 0 && message.rows > 0) {
|
|
66
|
+
const nextCols = Math.floor(message.cols)
|
|
67
|
+
const nextRows = Math.floor(message.rows)
|
|
68
|
+
terminal.resize(nextCols, nextRows)
|
|
69
|
+
process.stderr.write(`RESIZED ${nextCols} ${nextRows}\n`)
|
|
70
|
+
} else if (message?.t === 'input' && typeof message.data === 'string' && Buffer.byteLength(message.data, 'utf8') <= 64 * 1024) {
|
|
71
|
+
terminal.write(message.data)
|
|
72
|
+
}
|
|
73
|
+
} catch { /* malformed controls cannot become terminal input */ }
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
let closing = false
|
|
78
|
+
function close() {
|
|
79
|
+
if (closing) return
|
|
80
|
+
closing = true
|
|
81
|
+
try { terminal.kill() } catch { /* already gone */ }
|
|
82
|
+
setTimeout(() => process.exit(0), 100).unref()
|
|
83
|
+
}
|
|
84
|
+
process.stdin.on('end', close)
|
|
85
|
+
process.on('SIGTERM', close)
|
|
86
|
+
process.on('SIGINT', close)
|
package/spec-cli/src/reaper.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
// @@@ socket-level connection reaper ([[spec-cli]]) - the ONE mechanism that
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
1
|
+
// @@@ socket-level connection reaper ([[spec-cli]]) - the ONE mechanism that reaps abandoned sockets (the
|
|
2
|
+
// 135-conn starvation that wedged the public port and triggered the mass-restore cascade), and the SINGLE
|
|
3
|
+
// OWNER of the deadlines it enforces. Node's own `headersTimeout`/`keepAliveTimeout` cover the same phases
|
|
4
|
+
// (pre-request, idle-between-requests) and so are a second mechanism racing this one: MEASURED (eval
|
|
5
|
+
// server-reaps-abandoned-connections, issue #65) a `headersTimeout: 20000` set beside the reaper won the
|
|
6
|
+
// race on every reap at default config and silently capped SPEXCODE_REAP_HEADER_MS above 20s — the knob
|
|
7
|
+
// went dead while the close still looked timely (Node's 408, not the reaper's destroy). So install()
|
|
8
|
+
// DISABLES those overlapping Node timeouts on the server it guards; this helper is an explicit per-socket
|
|
9
|
+
// deadline at the server boundary, with no platform machinery shadowing it.
|
|
9
10
|
//
|
|
10
11
|
// It keys on "no request has completed yet / idle between requests" — never on response DURATION — so a
|
|
11
12
|
// long-lived ESTABLISHED stream (the /api/graph/stream SSE, a terminal WebSocket upgrade) is exempt for as
|
|
@@ -61,6 +62,13 @@ export function installConnectionReaper(server: HttpServer, opts: ReaperOptions
|
|
|
61
62
|
const headerMs = resolveMs(opts.headerMs, process.env.SPEXCODE_REAP_HEADER_MS, 30000)
|
|
62
63
|
const idleMs = resolveMs(opts.idleMs, process.env.SPEXCODE_REAP_IDLE_MS, 15000)
|
|
63
64
|
|
|
65
|
+
// claim single ownership of the phases the reaper covers (see header comment): Node's overlapping
|
|
66
|
+
// timeouts would otherwise race these deadlines and silently cap the env knobs. `requestTimeout` is
|
|
67
|
+
// deliberately LEFT at Node's default (~5 min): it bounds the in-flight request-body phase the reaper
|
|
68
|
+
// exempts (a silently-abandoned mid-body upload has no other reaper), and 5 min shadows no sane knob.
|
|
69
|
+
server.headersTimeout = 0
|
|
70
|
+
server.keepAliveTimeout = 0
|
|
71
|
+
|
|
64
72
|
// per-socket tracking, on the SAME socket object 'request'/'upgrade' will report (see header comment).
|
|
65
73
|
const track = (socket: Socket) => {
|
|
66
74
|
const state: SocketState = {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type ReviewEvalNode = {
|
|
2
|
+
id: string
|
|
3
|
+
hue?: number
|
|
4
|
+
scenarios: any[]
|
|
5
|
+
evals: any[]
|
|
6
|
+
readings: any[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ReviewSnapshot = {
|
|
10
|
+
issues: any[]
|
|
11
|
+
evalNodes: ReviewEvalNode[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let current: ReviewSnapshot | null = null
|
|
15
|
+
|
|
16
|
+
export function publishReviewSnapshot(snapshot: ReviewSnapshot): void {
|
|
17
|
+
current = snapshot
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function readReviewSnapshot(): ReviewSnapshot {
|
|
21
|
+
if (!current) throw new Error('review snapshot is unavailable before the first successful graph build')
|
|
22
|
+
return current
|
|
23
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { listSessions } from './sessions.js'
|
|
3
|
+
import { getBoard } from './graphCache.js'
|
|
4
|
+
import { buildSessionEvals, type SessionEvals } from '../../spec-eval/src/sessioneval.js'
|
|
5
|
+
import { evalTimeline } from '../../spec-eval/src/evaltab.js'
|
|
6
|
+
import { issuesEnabled as issuesEnabledForReview } from './localIssues.js'
|
|
7
|
+
import { issueStores as issueStoresForReview } from './issues.js'
|
|
8
|
+
import { readReviewSnapshot } from './reviewSnapshot.js'
|
|
9
|
+
// @ts-expect-error The dashboard module is deliberately plain JS so the browser and server execute the
|
|
10
|
+
// exact same tokenizer/matcher. It is shipped beside the built dashboard by the root package manifest.
|
|
11
|
+
import { EVAL_FILTER_KIND, evalFilterModel, evalReviewState, issueFilterModel, tokenFilterState } from '../../spec-dashboard/src/reviewFilters.js'
|
|
12
|
+
// @ts-expect-error See the shared-domain note above.
|
|
13
|
+
import { EVAL_QUERY_DEFAULT, ISSUE_QUERY_DEFAULT, readToken } from '../../spec-dashboard/src/reviewQuery.js'
|
|
14
|
+
|
|
15
|
+
export const REVIEW_PER_PAGE = 25
|
|
16
|
+
|
|
17
|
+
type ReviewItem = Record<string, unknown>
|
|
18
|
+
type ReviewOption = { value: string; label?: string; count?: number }
|
|
19
|
+
type ReviewFacet = { key: string; label?: string; value: string; meaningful?: boolean; options: ReviewOption[] }
|
|
20
|
+
type EvalNeighbor = { node: string; scenario: string; state: string }
|
|
21
|
+
|
|
22
|
+
export type PagedReview<T extends ReviewItem = ReviewItem> = {
|
|
23
|
+
items: T[]
|
|
24
|
+
page: number
|
|
25
|
+
perPage: number
|
|
26
|
+
total: number
|
|
27
|
+
sourceTotal: number
|
|
28
|
+
pageCount: number
|
|
29
|
+
prev: number | null
|
|
30
|
+
next: number | null
|
|
31
|
+
revision: string
|
|
32
|
+
counts: Record<string, number>
|
|
33
|
+
facets: Record<string, ReviewFacet>
|
|
34
|
+
section: { key: string; value: string; options: ReviewOption[] } | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type EvalDetailReview = {
|
|
38
|
+
scope: string | null
|
|
39
|
+
selected: ReviewItem | null
|
|
40
|
+
history: ReviewItem[]
|
|
41
|
+
neighbors: {
|
|
42
|
+
prev: EvalNeighbor[]
|
|
43
|
+
next: EvalNeighbor[]
|
|
44
|
+
total: number
|
|
45
|
+
index: number | null
|
|
46
|
+
order: 'default'
|
|
47
|
+
}
|
|
48
|
+
revision: string
|
|
49
|
+
summary?: SessionEvals['summary']
|
|
50
|
+
evalRevision?: SessionEvals['evalRevision']
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const revisionOf = (value: unknown): string => createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
|
54
|
+
|
|
55
|
+
export function reviewPageNumber(value: unknown): number {
|
|
56
|
+
const raw = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : Number.NaN
|
|
57
|
+
return Number.isSafeInteger(raw) && raw > 0 ? raw : 1
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function responseModel(model: any): Pick<PagedReview, 'counts' | 'facets' | 'section'> {
|
|
61
|
+
const facets = Object.fromEntries(Object.entries(model.facets ?? {}).map(([key, raw]) => {
|
|
62
|
+
const facet = raw as any
|
|
63
|
+
return [key, {
|
|
64
|
+
key,
|
|
65
|
+
...(facet.label != null ? { label: String(facet.label) } : {}),
|
|
66
|
+
value: String(facet.value ?? ''),
|
|
67
|
+
...(facet.meaningful != null ? { meaningful: !!facet.meaningful } : {}),
|
|
68
|
+
options: (facet.options ?? []).map((option: any) => ({
|
|
69
|
+
value: String(option.value ?? ''),
|
|
70
|
+
...(option.label != null ? { label: String(option.label) } : {}),
|
|
71
|
+
...(Number.isFinite(option.count) ? { count: Number(option.count) } : {}),
|
|
72
|
+
})),
|
|
73
|
+
}]
|
|
74
|
+
})) as Record<string, ReviewFacet>
|
|
75
|
+
const section = model.section ? {
|
|
76
|
+
key: String(model.section.key),
|
|
77
|
+
...(model.section.label != null ? { label: String(model.section.label) } : {}),
|
|
78
|
+
value: String(model.section.value ?? ''),
|
|
79
|
+
...(model.section.meaningful != null ? { meaningful: !!model.section.meaningful } : {}),
|
|
80
|
+
options: (model.section.options ?? []).map((option: any) => ({
|
|
81
|
+
value: String(option.value ?? ''),
|
|
82
|
+
...(option.label != null ? { label: String(option.label) } : {}),
|
|
83
|
+
...(Number.isFinite(option.count) ? { count: Number(option.count) } : {}),
|
|
84
|
+
})),
|
|
85
|
+
} : null
|
|
86
|
+
return { counts: { ...(model.sections ?? {}) }, facets, section }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function paginateReview<T extends ReviewItem>(
|
|
90
|
+
source: T[],
|
|
91
|
+
shown: T[],
|
|
92
|
+
model: any,
|
|
93
|
+
requestedPage: unknown,
|
|
94
|
+
revisionInputs: unknown,
|
|
95
|
+
): PagedReview<T> {
|
|
96
|
+
const page = reviewPageNumber(requestedPage)
|
|
97
|
+
const total = shown.length
|
|
98
|
+
const pageCount = Math.ceil(total / REVIEW_PER_PAGE)
|
|
99
|
+
const start = (page - 1) * REVIEW_PER_PAGE
|
|
100
|
+
const overflow = pageCount > 0 ? page > pageCount : page > 1
|
|
101
|
+
return {
|
|
102
|
+
items: shown.slice(start, start + REVIEW_PER_PAGE),
|
|
103
|
+
page,
|
|
104
|
+
perPage: REVIEW_PER_PAGE,
|
|
105
|
+
total,
|
|
106
|
+
sourceTotal: source.length,
|
|
107
|
+
pageCount,
|
|
108
|
+
prev: page > 1 ? page - 1 : null,
|
|
109
|
+
next: page < pageCount || overflow ? page + 1 : null,
|
|
110
|
+
revision: revisionOf(revisionInputs),
|
|
111
|
+
...responseModel(model),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const issueOrder = (a: any, b: any): number => String(b.created ?? '').localeCompare(String(a.created ?? ''))
|
|
116
|
+
|| String(a.id ?? '').localeCompare(String(b.id ?? ''))
|
|
117
|
+
|
|
118
|
+
export async function issuesReview(query: string | undefined, requestedPage: unknown) {
|
|
119
|
+
const [, sessions] = await Promise.all([getBoard(), listSessions()])
|
|
120
|
+
const issues = readReviewSnapshot().issues.slice().sort(issueOrder)
|
|
121
|
+
const text = String(query ?? '').trim() || ISSUE_QUERY_DEFAULT
|
|
122
|
+
const model = issueFilterModel(issues, tokenFilterState(text, 'issue'), { sessions, defaultSection: '' })
|
|
123
|
+
return {
|
|
124
|
+
enabled: issuesEnabledForReview(),
|
|
125
|
+
stores: issueStoresForReview(),
|
|
126
|
+
...paginateReview(issues, model.shown, model, requestedPage, {
|
|
127
|
+
domain: 'issues', issues, sessions: sessions.map((session) => session.id),
|
|
128
|
+
}),
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const byNewest = (a: any, b: any): number => String(b.ts ?? '').localeCompare(String(a.ts ?? ''))
|
|
133
|
+
|| String(a.node ?? '').localeCompare(String(b.node ?? ''))
|
|
134
|
+
|| String(a.scenario ?? '').localeCompare(String(b.scenario ?? ''))
|
|
135
|
+
|
|
136
|
+
export function trunkEvalReviewItems(nodes: any[]): ReviewItem[] {
|
|
137
|
+
const blind: any[] = []
|
|
138
|
+
const items: any[] = []
|
|
139
|
+
for (const node of nodes ?? []) {
|
|
140
|
+
const latest = new Map<string, any>()
|
|
141
|
+
for (const reading of node.evals ?? []) if (!latest.has(reading.scenario)) latest.set(reading.scenario, reading)
|
|
142
|
+
for (const scenario of node.scenarios ?? []) {
|
|
143
|
+
const reading = latest.get(scenario.name)
|
|
144
|
+
if (!reading) {
|
|
145
|
+
blind.push({
|
|
146
|
+
scenario: scenario.name,
|
|
147
|
+
expected: scenario.expected,
|
|
148
|
+
tags: scenario.tags,
|
|
149
|
+
node: node.id,
|
|
150
|
+
hue: node.hue,
|
|
151
|
+
filterKind: EVAL_FILTER_KIND.BLIND,
|
|
152
|
+
})
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
items.push({
|
|
156
|
+
...reading,
|
|
157
|
+
expected: scenario.expected ?? reading.expected,
|
|
158
|
+
tags: scenario.tags,
|
|
159
|
+
state: evalReviewState(reading),
|
|
160
|
+
node: node.id,
|
|
161
|
+
hue: node.hue,
|
|
162
|
+
filterKind: EVAL_FILTER_KIND.RESULT,
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
blind.sort((a, b) => String(a.node).localeCompare(String(b.node)) || String(a.scenario).localeCompare(String(b.scenario)))
|
|
167
|
+
return [...blind, ...items.sort(byNewest)]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function scopedEvalReviewItems(model: SessionEvals): ReviewItem[] {
|
|
171
|
+
const blind: any[] = []
|
|
172
|
+
const own: any[] = []
|
|
173
|
+
const inherited: any[] = []
|
|
174
|
+
for (const node of model.nodes ?? []) {
|
|
175
|
+
const latest = new Map<string, any>()
|
|
176
|
+
for (const reading of node.evals ?? []) if (!latest.has(reading.scenario)) latest.set(reading.scenario, reading)
|
|
177
|
+
for (const scenario of node.scenarios ?? []) {
|
|
178
|
+
const reading = latest.get(scenario.name)
|
|
179
|
+
if (!reading) {
|
|
180
|
+
blind.push({
|
|
181
|
+
scenario: scenario.name,
|
|
182
|
+
expected: scenario.expected,
|
|
183
|
+
tags: scenario.tags,
|
|
184
|
+
impact: scenario.impact,
|
|
185
|
+
node: node.id,
|
|
186
|
+
hue: node.hue,
|
|
187
|
+
filterKind: EVAL_FILTER_KIND.BLIND,
|
|
188
|
+
})
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
const item = {
|
|
192
|
+
...reading,
|
|
193
|
+
expected: scenario.expected ?? reading.expected,
|
|
194
|
+
tags: scenario.tags,
|
|
195
|
+
state: evalReviewState(reading),
|
|
196
|
+
node: node.id,
|
|
197
|
+
hue: node.hue,
|
|
198
|
+
filterKind: EVAL_FILTER_KIND.RESULT,
|
|
199
|
+
}
|
|
200
|
+
;(reading.inSession ? own : inherited).push(item)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return [...blind, ...own.sort(byNewest), ...inherited.sort(byNewest)]
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const evalItemKey = (item: any): string => `${String(item?.node ?? '')}\0${String(item?.scenario ?? '')}`
|
|
207
|
+
|
|
208
|
+
function evalNeighbor(item: any): EvalNeighbor {
|
|
209
|
+
return {
|
|
210
|
+
node: String(item.node),
|
|
211
|
+
scenario: String(item.scenario),
|
|
212
|
+
state: String(item.state ?? evalReviewState(item)),
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function boundedEvalNeighbors(items: ReviewItem[], node: string, scenario: string, want = 5) {
|
|
217
|
+
const key = `${node}\0${scenario}`
|
|
218
|
+
const index = items.findIndex((item) => evalItemKey(item) === key)
|
|
219
|
+
if (index < 0) return { prev: [], next: [], total: items.length, index: null, order: 'default' as const }
|
|
220
|
+
const before = index
|
|
221
|
+
const after = items.length - index - 1
|
|
222
|
+
const take = Math.min(want, before + after)
|
|
223
|
+
const nextN = Math.min(after, Math.max(Math.ceil(take / 2), take - before))
|
|
224
|
+
const prevN = Math.min(before, take - nextN)
|
|
225
|
+
return {
|
|
226
|
+
prev: items.slice(index - prevN, index).reverse().map(evalNeighbor),
|
|
227
|
+
next: items.slice(index + 1, index + 1 + nextN).map(evalNeighbor),
|
|
228
|
+
total: items.length,
|
|
229
|
+
index,
|
|
230
|
+
order: 'default' as const,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function projectEvalDetail(
|
|
235
|
+
items: ReviewItem[],
|
|
236
|
+
historySource: ReviewItem[],
|
|
237
|
+
node: string,
|
|
238
|
+
scenario: string,
|
|
239
|
+
metadata: { scope?: string | null; summary?: SessionEvals['summary']; evalRevision?: SessionEvals['evalRevision'] } = {},
|
|
240
|
+
): EvalDetailReview {
|
|
241
|
+
const results = items.filter((item: any) => item.filterKind === EVAL_FILTER_KIND.RESULT)
|
|
242
|
+
const selected = results.find((item) => evalItemKey(item) === `${node}\0${scenario}`) ?? null
|
|
243
|
+
const history = historySource.filter((reading: any) => String(reading.scenario) === scenario)
|
|
244
|
+
const neighbors = boundedEvalNeighbors(results, node, scenario)
|
|
245
|
+
const scope = metadata.scope ?? null
|
|
246
|
+
return {
|
|
247
|
+
scope,
|
|
248
|
+
selected,
|
|
249
|
+
history,
|
|
250
|
+
neighbors,
|
|
251
|
+
revision: revisionOf({ scope, selected, history, neighbors, summary: metadata.summary, evalRevision: metadata.evalRevision }),
|
|
252
|
+
...(metadata.summary ? { summary: metadata.summary } : {}),
|
|
253
|
+
...(metadata.evalRevision ? { evalRevision: metadata.evalRevision } : {}),
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function evalDetailReview(node: string, scenario: string, scope?: string | null): Promise<EvalDetailReview | null> {
|
|
258
|
+
if (scope) {
|
|
259
|
+
const model = await buildSessionEvals(scope)
|
|
260
|
+
if (!model) return null
|
|
261
|
+
const sourceNode = model.nodes.find((candidate) => candidate.id === node)
|
|
262
|
+
return projectEvalDetail(scopedEvalReviewItems(model), sourceNode?.evals ?? [], node, scenario, {
|
|
263
|
+
scope,
|
|
264
|
+
summary: model.summary,
|
|
265
|
+
evalRevision: model.evalRevision,
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
await getBoard()
|
|
269
|
+
const snapshot = readReviewSnapshot()
|
|
270
|
+
const sourceNode = snapshot.evalNodes.find((candidate) => candidate.id === node)
|
|
271
|
+
return projectEvalDetail(trunkEvalReviewItems(snapshot.evalNodes), sourceNode?.readings ?? [], node, scenario)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function timelineEvalReview(text: string, requestedPage: unknown) {
|
|
275
|
+
const node = readToken(text, 'node')
|
|
276
|
+
if (!node) return null
|
|
277
|
+
const [timeline, sessions] = await Promise.all([evalTimeline(node), listSessions()])
|
|
278
|
+
const measured = new Set(timeline.readings.map((reading) => reading.scenario))
|
|
279
|
+
const items = [
|
|
280
|
+
...timeline.scenarios.filter((scenario) => !measured.has(scenario.name)).map((scenario) => ({
|
|
281
|
+
...scenario,
|
|
282
|
+
scenario: scenario.name,
|
|
283
|
+
node,
|
|
284
|
+
filterKind: EVAL_FILTER_KIND.UNMEASURED,
|
|
285
|
+
})),
|
|
286
|
+
...timeline.readings.map((reading, index) => ({
|
|
287
|
+
...reading,
|
|
288
|
+
state: evalReviewState(reading),
|
|
289
|
+
node,
|
|
290
|
+
filterKind: EVAL_FILTER_KIND.RESULT,
|
|
291
|
+
filterKey: `${EVAL_FILTER_KIND.RESULT}:${index}`,
|
|
292
|
+
})),
|
|
293
|
+
...(timeline.dangling ?? []).map((track) => ({
|
|
294
|
+
...track,
|
|
295
|
+
node,
|
|
296
|
+
filterKind: EVAL_FILTER_KIND.DANGLING,
|
|
297
|
+
filterKey: `${EVAL_FILTER_KIND.DANGLING}:${track.threadId}`,
|
|
298
|
+
})),
|
|
299
|
+
]
|
|
300
|
+
const filtered = evalFilterModel(items, tokenFilterState(text, 'eval'), { sessions, defaultKind: 'all', defaultSection: '' })
|
|
301
|
+
return {
|
|
302
|
+
scope: null,
|
|
303
|
+
view: 'timeline',
|
|
304
|
+
node,
|
|
305
|
+
hasEvalFile: timeline.hasEvalFile,
|
|
306
|
+
gates: [],
|
|
307
|
+
unknown: 0,
|
|
308
|
+
...paginateReview(items, filtered.shown, filtered, requestedPage, {
|
|
309
|
+
domain: 'evals', view: 'timeline', node, timeline, sessions: sessions.map((session) => session.id),
|
|
310
|
+
}),
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function evalsReview(query: string | undefined, requestedPage: unknown, options: { view?: string } = {}) {
|
|
315
|
+
const text = String(query ?? '').trim() || EVAL_QUERY_DEFAULT
|
|
316
|
+
if (options.view === 'timeline') return timelineEvalReview(text, requestedPage)
|
|
317
|
+
const scope = readToken(text, 'scope') || null
|
|
318
|
+
if (scope) {
|
|
319
|
+
const model = await buildSessionEvals(scope)
|
|
320
|
+
if (!model) return null
|
|
321
|
+
const items = scopedEvalReviewItems(model)
|
|
322
|
+
const sessions = await listSessions()
|
|
323
|
+
const filtered = evalFilterModel(items, tokenFilterState(text, 'eval'), { sessions, defaultKind: 'all', defaultSection: '' })
|
|
324
|
+
return {
|
|
325
|
+
scope,
|
|
326
|
+
gates: model.gates,
|
|
327
|
+
unknown: model.nodes.reduce((count, node) => count + (node.unknownCoverage?.length ?? 0), 0),
|
|
328
|
+
summary: model.summary,
|
|
329
|
+
evalRevision: model.evalRevision,
|
|
330
|
+
...paginateReview(items, filtered.shown, filtered, requestedPage, {
|
|
331
|
+
domain: 'evals', scope, items, gates: model.gates, summary: model.summary,
|
|
332
|
+
evalRevision: model.evalRevision, sessions: sessions.map((session) => session.id),
|
|
333
|
+
}),
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const board = await getBoard()
|
|
337
|
+
const items = trunkEvalReviewItems(readReviewSnapshot().evalNodes)
|
|
338
|
+
const filtered = evalFilterModel(items, tokenFilterState(text, 'eval'), { sessions: board.sessions, defaultKind: 'all', defaultSection: '' })
|
|
339
|
+
return {
|
|
340
|
+
scope: null,
|
|
341
|
+
gates: [],
|
|
342
|
+
unknown: 0,
|
|
343
|
+
...paginateReview(items, filtered.shown, filtered, requestedPage, {
|
|
344
|
+
domain: 'evals', items, sessions: board.sessions.map((session) => session.id),
|
|
345
|
+
}),
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -118,6 +118,20 @@ export function superviseTimeline(): void {
|
|
|
118
118
|
scan()
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// the channel of the LAST HUMAN send (from == null): 'note' when the note-reply hint rode along, else null.
|
|
122
|
+
// This is what makes the reply-channel hints SYMMETRIC ([[sessions-core]] sendText): a human send with no
|
|
123
|
+
// note flag arriving after a note-send is the "back at a terminal" transition, and the delivery gets the
|
|
124
|
+
// counter-insert. Derived from the durable log — no new state, and it survives a server restart. Agent
|
|
125
|
+
// senders (`from` set) say nothing about where the HUMAN is reading, so they neither set nor clear it.
|
|
126
|
+
export function lastHumanSendVia(id: string): 'note' | null {
|
|
127
|
+
const evs = readEvents(id)
|
|
128
|
+
for (let i = evs.length - 1; i >= 0; i--) {
|
|
129
|
+
const e = evs[i]
|
|
130
|
+
if (e.kind === 'sent' && e.from == null) return e.replyVia === 'note' ? 'note' : null
|
|
131
|
+
}
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
|
|
121
135
|
// record a CONFIRMED prompt delivery (called by sendText after the harness accepted it). `text` is the
|
|
122
136
|
// caller's message BEFORE any mechanism insert (the note-reply hint is transport, not conversation);
|
|
123
137
|
// `replyVia` marks that the hint rode along so a surface can badge it.
|