spexcode 0.4.0 → 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 (111) 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 +163 -30
  7. package/spec-cli/src/cli.ts +97 -42
  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 +31 -16
  13. package/spec-cli/src/git.ts +12 -5
  14. package/spec-cli/src/graph.ts +53 -36
  15. package/spec-cli/src/graphStream.ts +265 -34
  16. package/spec-cli/src/guide.ts +157 -76
  17. package/spec-cli/src/harness-select.ts +20 -6
  18. package/spec-cli/src/harness.ts +266 -67
  19. package/spec-cli/src/help.ts +84 -41
  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 +163 -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 +115 -169
  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 +162 -0
  39. package/spec-cli/src/sessions.ts +210 -63
  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 +47 -24
  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 -13
  48. package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +24 -18
  49. package/spec-cli/templates/spec/project/.plugins/{regroup → 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 +30 -0
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  53. package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
  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 +62 -0
  66. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +19 -0
  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 +80 -45
  90. package/spec-eval/src/evaltab.ts +4 -3
  91. package/spec-eval/src/humanok.ts +1 -1
  92. package/spec-eval/src/matrix.ts +693 -0
  93. package/spec-eval/src/scenarios.ts +116 -4
  94. package/spec-eval/src/sessioneval.ts +726 -66
  95. package/spec-cli/src/migrate-table.ts +0 -398
  96. package/spec-cli/src/migrate.ts +0 -386
  97. package/spec-cli/templates/spec/project/.plugins/distill/spec.md +0 -74
  98. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
  99. package/spec-cli/templates/spec/project/.plugins/tidy/spec.md +0 -25
  100. package/spec-dashboard/dist/assets/Dashboard-CTcH2eW9.js +0 -27
  101. package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
  102. package/spec-dashboard/dist/assets/FoldToggle-CVFbBpyW.js +0 -1
  103. package/spec-dashboard/dist/assets/IssuesPage-kULjonqj.js +0 -1
  104. package/spec-dashboard/dist/assets/MobileApp-B0ZJju8K.js +0 -1
  105. package/spec-dashboard/dist/assets/SessionInterface-BRKJqU2U.js +0 -71
  106. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
  107. package/spec-dashboard/dist/assets/SessionWindow-CDhEL7wO.js +0 -9
  108. package/spec-dashboard/dist/assets/Settings-BL6FV_8S.js +0 -1
  109. package/spec-dashboard/dist/assets/index-DmQsNYKK.css +0 -1
  110. package/spec-dashboard/dist/assets/index-DulGPk6A.js +0 -41
  111. /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
@@ -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
+ }
@@ -0,0 +1,162 @@
1
+ import { watch, existsSync, readFileSync, appendFileSync, mkdirSync, type FSWatcher } from 'node:fs'
2
+ import { sessionsRoot, sessionStoreDir, sessionArtifactPath, listSessionIds, readAliasedRawRecord } from './layout.js'
3
+ import type { Lifecycle, Proposal } from './sessions.js'
4
+
5
+ // @@@ session-timeline - the PERSISTED interaction history of a session: every authored-lifecycle
6
+ // transition (status + proposal + the FULL note text) and every delivered prompt, timestamped, appended to
7
+ // `timeline.ndjson` in the session's global store dir. This is what a TERMINAL-FREE surface (the phone face,
8
+ // [[mobile-ui]]) renders instead of a live pane: without the terminal, the declaration notes ARE the agent's
9
+ // replies, and the timeline is the whole conversation.
10
+ //
11
+ // WHY an observer, not writer instrumentation: the lifecycle has a writer the TS layer never sees — the
12
+ // mark-active hook value-replaces status/proposal/note in session.json with pure-shell sed ([[state]]).
13
+ // Instrumenting every writer would always miss that one, so the recorder OBSERVES the store instead: one
14
+ // fs.watch on the sessions root (debounced) plus a slow reconcile tick (the fs.watch is best-effort, same
15
+ // stance as [[graph-stream]]'s source 1), and on each tick it diffs every governed record's
16
+ // (status, proposal, note) against the last seen and appends what moved. One mechanism covers every writer
17
+ // by construction. Granularity is the debounce window — a flap faster than ~100ms can collapse, exactly like
18
+ // the board itself.
19
+ //
20
+ // The recorder runs ONLY in the serve process (superviseTimeline is called from index.ts) so exactly one
21
+ // process appends; timestamps are observation times, honest to within the debounce. Only the AUTHORED axis
22
+ // is recorded — liveness (offline/starting/unknown) is a present-tense derivation ([[state]]), re-derived
23
+ // per probe and never history, so it stays off the durable log; a surface shows the CURRENT liveness from
24
+ // the board row. The timeline lives and dies with the session record (close sweeps the store dir), like
25
+ // comms.ndjson. `sent` events are appended by sendText on a CONFIRMED delivery (all prompt deliveries flow
26
+ // through it: dashboard/phone input, `spex session send`, the merge dispatch); `from` is the sending
27
+ // session's id, null = a human surface.
28
+
29
+ export type TimelineEvent =
30
+ | { ts: string; kind: 'status'; status: Lifecycle; proposal: Proposal | null; note: string | null; display?: string }
31
+ | { ts: string; kind: 'sent'; text: string; from: string | null; replyVia?: 'note' }
32
+
33
+ const timelinePath = (id: string): string => sessionArtifactPath(id, 'timeline.ndjson')
34
+
35
+ function append(id: string, ev: TimelineEvent): void {
36
+ try {
37
+ mkdirSync(sessionStoreDir(id), { recursive: true })
38
+ appendFileSync(timelinePath(id), JSON.stringify(ev) + '\n')
39
+ } catch { /* best-effort: a failed history append must never break the state machine or a delivery */ }
40
+ }
41
+
42
+ function readEvents(id: string): TimelineEvent[] {
43
+ try {
44
+ const p = timelinePath(id)
45
+ if (!existsSync(p)) return []
46
+ return readFileSync(p, 'utf8').split('\n').filter(Boolean).map((l) => {
47
+ try { return JSON.parse(l) as TimelineEvent } catch { return null }
48
+ }).filter((e): e is TimelineEvent => e != null && (e.kind === 'status' || e.kind === 'sent'))
49
+ } catch { return [] }
50
+ }
51
+
52
+ // the display word for an authored state — the SAME composition reconcile uses for the authored axis
53
+ // (awaiting → its proposal's label, active → working), duplicated here as a tiny read-time map rather than
54
+ // importing the state machine (sessions.ts imports THIS module for recordSent; a value import back would
55
+ // be a cycle — the Lifecycle/Proposal imports above are type-only, erased at runtime).
56
+ const PROPOSAL_DISPLAY: Record<string, string> = { merge: 'review', nothing: 'done', close: 'close-pending' }
57
+ const displayOf = (e: { status: Lifecycle; proposal: Proposal | null }): string =>
58
+ e.status === 'awaiting' ? (PROPOSAL_DISPLAY[e.proposal ?? 'nothing'] ?? 'done')
59
+ : e.status === 'active' ? 'working' : e.status
60
+
61
+ // ---- the recorder (serve-process only) ----
62
+
63
+ // id → fingerprint of the last recorded (status, proposal, note); seeded per id from the persisted last
64
+ // status line so a server restart appends nothing for a session that didn't move while the server was down —
65
+ // and DOES append (with an honest observed-now timestamp) when it did.
66
+ const lastSeen = new Map<string, string>()
67
+ const fpOf = (status: string, proposal: string | null, note: string | null): string => JSON.stringify([status, proposal, note])
68
+
69
+ function lastStatusEvent(id: string): { status: string; proposal: string | null; note: string | null } | null {
70
+ const evs = readEvents(id)
71
+ for (let i = evs.length - 1; i >= 0; i--) { const e = evs[i]; if (e.kind === 'status') return e }
72
+ return null
73
+ }
74
+
75
+ function scan(): void {
76
+ let ids: string[] = []
77
+ try { ids = listSessionIds() } catch { return }
78
+ for (const id of ids) {
79
+ try {
80
+ const raw = readAliasedRawRecord(id)
81
+ if (!raw || !raw.governed) continue
82
+ const status = (raw.status || 'active') as Lifecycle
83
+ const proposal = (raw.proposal || null) as Proposal | null
84
+ const note = raw.note || null
85
+ const fp = fpOf(status, proposal, note)
86
+ if (lastSeen.get(id) === fp) continue
87
+ if (!lastSeen.has(id)) {
88
+ const last = lastStatusEvent(id)
89
+ if (last && fpOf(last.status, last.proposal ?? null, last.note ?? null) === fp) { lastSeen.set(id, fp); continue }
90
+ }
91
+ lastSeen.set(id, fp)
92
+ append(id, { ts: new Date().toISOString(), kind: 'status', status, proposal, note })
93
+ } catch { /* one bad record must not stall the sweep */ }
94
+ }
95
+ const live = new Set(ids)
96
+ for (const k of [...lastSeen.keys()]) if (!live.has(k)) lastSeen.delete(k)
97
+ }
98
+
99
+ let watcher: FSWatcher | null = null
100
+ let debounce: ReturnType<typeof setTimeout> | null = null
101
+ let reconcile: ReturnType<typeof setInterval> | null = null
102
+
103
+ // start the recorder: one debounced fs.watch on the store (a lifecycle write lands as a session.json write)
104
+ // backstopped by a slow reconcile tick, plus an immediate first sweep. Idempotent; never throws — the
105
+ // timeline is an accessory record, and its failure must never take the server down.
106
+ export function superviseTimeline(): void {
107
+ if (!reconcile) reconcile = setInterval(scan, 60000)
108
+ if (!watcher) {
109
+ const root = sessionsRoot()
110
+ try { mkdirSync(root, { recursive: true }) } catch { /* best-effort */ }
111
+ try {
112
+ watcher = watch(root, { recursive: true }, () => {
113
+ if (debounce) return
114
+ debounce = setTimeout(() => { debounce = null; scan() }, 100)
115
+ })
116
+ } catch { watcher = null /* the reconcile tick still covers */ }
117
+ }
118
+ scan()
119
+ }
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
+
135
+ // record a CONFIRMED prompt delivery (called by sendText after the harness accepted it). `text` is the
136
+ // caller's message BEFORE any mechanism insert (the note-reply hint is transport, not conversation);
137
+ // `replyVia` marks that the hint rode along so a surface can badge it.
138
+ export function recordSent(id: string, text: string, from: string | null, replyVia?: 'note'): void {
139
+ try { if (!readAliasedRawRecord(id)?.governed) return } catch { return }
140
+ append(id, { ts: new Date().toISOString(), kind: 'sent', text, from, ...(replyVia ? { replyVia } : {}) })
141
+ }
142
+
143
+ // the read surface behind GET /api/sessions/:id/timeline: the last `limit` events, oldest first, each
144
+ // status event carrying its composed display word. null = no such session (the route 404s).
145
+ // Adjacent status lines with identical (status, proposal, note) fold into their first: TWO serve processes
146
+ // observing one store (a throwaway worktree/eval serve beside the live one) each keep their own lastSeen,
147
+ // so a single record move can append twice — cross-process write locking isn't worth buying, so the log
148
+ // stays best-effort append-only and the read is where duplicates die, same stance as the board.
149
+ export function readTimeline(id: string, limit = 500): { events: TimelineEvent[] } | null {
150
+ let raw: ReturnType<typeof readAliasedRawRecord>
151
+ try { raw = readAliasedRawRecord(id) } catch { return null }
152
+ if (!raw || !raw.governed) return null
153
+ const folded: TimelineEvent[] = []
154
+ for (const e of readEvents(id)) {
155
+ const prev = folded[folded.length - 1]
156
+ if (e.kind === 'status' && prev?.kind === 'status' && prev.status === e.status
157
+ && (prev.proposal ?? null) === (e.proposal ?? null) && (prev.note ?? null) === (e.note ?? null)) continue
158
+ folded.push(e)
159
+ }
160
+ const tail = folded.slice(Math.max(0, folded.length - Math.max(1, limit)))
161
+ return { events: tail.map((e) => (e.kind === 'status' ? { ...e, display: displayOf(e) } : e)) }
162
+ }