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.
- 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 +163 -30
- package/spec-cli/src/cli.ts +97 -42
- 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 +31 -16
- package/spec-cli/src/git.ts +12 -5
- package/spec-cli/src/graph.ts +53 -36
- package/spec-cli/src/graphStream.ts +265 -34
- package/spec-cli/src/guide.ts +157 -76
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +266 -67
- package/spec-cli/src/help.ts +84 -41
- 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 +163 -86
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +115 -169
- 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 +162 -0
- package/spec-cli/src/sessions.ts +210 -63
- 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 +47 -24
- 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 -13
- package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +24 -18
- package/spec-cli/templates/spec/project/.plugins/{regroup → 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 +30 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
- 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 +62 -0
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +19 -0
- 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 +80 -45
- package/spec-eval/src/evaltab.ts +4 -3
- package/spec-eval/src/humanok.ts +1 -1
- package/spec-eval/src/matrix.ts +693 -0
- package/spec-eval/src/scenarios.ts +116 -4
- package/spec-eval/src/sessioneval.ts +726 -66
- package/spec-cli/src/migrate-table.ts +0 -398
- package/spec-cli/src/migrate.ts +0 -386
- package/spec-cli/templates/spec/project/.plugins/distill/spec.md +0 -74
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
- package/spec-cli/templates/spec/project/.plugins/tidy/spec.md +0 -25
- package/spec-dashboard/dist/assets/Dashboard-CTcH2eW9.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
- package/spec-dashboard/dist/assets/FoldToggle-CVFbBpyW.js +0 -1
- package/spec-dashboard/dist/assets/IssuesPage-kULjonqj.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B0ZJju8K.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-BRKJqU2U.js +0 -71
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
- package/spec-dashboard/dist/assets/SessionWindow-CDhEL7wO.js +0 -9
- package/spec-dashboard/dist/assets/Settings-BL6FV_8S.js +0 -1
- package/spec-dashboard/dist/assets/index-DmQsNYKK.css +0 -1
- package/spec-dashboard/dist/assets/index-DulGPk6A.js +0 -41
- /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)
|
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
|
+
}
|
|
@@ -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
|
+
}
|