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.
Files changed (106) hide show
  1. package/README.md +37 -17
  2. package/package.json +9 -5
  3. package/spec-cli/README.md +23 -14
  4. package/spec-cli/hooks/dispatch.sh +7 -5
  5. package/spec-cli/hooks/harness.sh +19 -8
  6. package/spec-cli/src/anchors.ts +110 -18
  7. package/spec-cli/src/cli.ts +88 -37
  8. package/spec-cli/src/client.ts +34 -11
  9. package/spec-cli/src/doctor.ts +174 -36
  10. package/spec-cli/src/gateway-auth.ts +194 -0
  11. package/spec-cli/src/gateway-hub.ts +360 -0
  12. package/spec-cli/src/gateway.ts +27 -19
  13. package/spec-cli/src/git.ts +12 -5
  14. package/spec-cli/src/graph.ts +54 -43
  15. package/spec-cli/src/graphStream.ts +265 -34
  16. package/spec-cli/src/guide.ts +105 -55
  17. package/spec-cli/src/harness-select.ts +20 -6
  18. package/spec-cli/src/harness.ts +181 -33
  19. package/spec-cli/src/help.ts +64 -27
  20. package/spec-cli/src/host.ts +610 -0
  21. package/spec-cli/src/identity-presets.d.ts +13 -0
  22. package/spec-cli/src/identity-presets.js +128 -0
  23. package/spec-cli/src/index.ts +145 -86
  24. package/spec-cli/src/init.ts +53 -25
  25. package/spec-cli/src/layout.ts +5 -5
  26. package/spec-cli/src/lint.ts +25 -121
  27. package/spec-cli/src/login-page.ts +12 -5
  28. package/spec-cli/src/materialize.ts +39 -13
  29. package/spec-cli/src/mentions.ts +16 -11
  30. package/spec-cli/src/opencode.ts +145 -0
  31. package/spec-cli/src/pi-harness.ts +124 -0
  32. package/spec-cli/src/project-identity.ts +73 -0
  33. package/spec-cli/src/pty-bridge.ts +321 -521
  34. package/spec-cli/src/pty-helper.mjs +86 -0
  35. package/spec-cli/src/reaper.ts +16 -8
  36. package/spec-cli/src/reviewSnapshot.ts +23 -0
  37. package/spec-cli/src/reviews.ts +347 -0
  38. package/spec-cli/src/session-timeline.ts +14 -0
  39. package/spec-cli/src/sessions.ts +188 -66
  40. package/spec-cli/src/shim-runtime.ts +169 -0
  41. package/spec-cli/src/slash-commands.ts +76 -0
  42. package/spec-cli/src/source-files.ts +79 -0
  43. package/spec-cli/src/specs.ts +12 -7
  44. package/spec-cli/src/supervise.ts +37 -15
  45. package/spec-cli/src/tree.ts +8 -8
  46. package/spec-cli/src/uninstall.ts +56 -23
  47. package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
  48. package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
  49. package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
  50. package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  51. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
  54. package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
  56. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
  58. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
  59. package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
  60. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  61. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
  62. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
  63. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
  64. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
  65. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
  66. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
  67. package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
  68. package/spec-cli/templates/spec/project/spec.md +6 -5
  69. package/spec-cli/templates/spexcode.json +4 -2
  70. package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
  71. package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
  72. package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
  73. package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
  74. package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
  75. package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
  76. package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
  77. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
  78. package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
  79. package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
  80. package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
  81. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
  82. package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
  83. package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
  84. package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
  85. package/spec-dashboard/dist/index.html +10 -10
  86. package/spec-dashboard/src/reviewFilters.js +279 -0
  87. package/spec-dashboard/src/reviewQuery.js +174 -0
  88. package/spec-dashboard/src/session.js +167 -0
  89. package/spec-eval/src/cli.ts +49 -28
  90. package/spec-eval/src/humanok.ts +1 -1
  91. package/spec-eval/src/matrix.ts +693 -0
  92. package/spec-eval/src/sessioneval.ts +726 -66
  93. package/spec-cli/src/migrate-table.ts +0 -408
  94. package/spec-cli/src/migrate.ts +0 -386
  95. package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
  96. package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
  97. package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
  98. package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
  99. package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
  100. package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
  101. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
  102. package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
  103. package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
  104. package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
  105. package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
  106. 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)
@@ -1,11 +1,12 @@
1
- // @@@ socket-level connection reaper ([[spec-cli]]) - the ONE mechanism that actually reaps abandoned
2
- // sockets, because Node's own http.Server timeouts do not. MEASURED (eval server-reaps-abandoned-connections,
3
- // minimal http.createServer on Node 20/22/24): `headersTimeout` and `requestTimeout` DO NOT reap an
4
- // incomplete request a slow-loris (TCP connect + partial headers, never completed) survives indefinitely
5
- // past the `connectionsCheckingInterval` sweep; only `keepAliveTimeout` (the idle-between-requests case) ever
6
- // fires. So the abandoned-connection pileup protection those options claim (the 135-conn starvation that
7
- // wedged the public port and triggered the mass-restore cascade) was NOT delivered. This helper is the real
8
- // mechanism: an explicit per-socket deadline at the server boundary, independent of the platform sweep.
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.