spexcode 0.4.0 → 0.4.1
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/package.json +1 -1
- package/spec-cli/src/anchors.ts +53 -12
- package/spec-cli/src/cli.ts +9 -5
- package/spec-cli/src/gateway.ts +8 -1
- package/spec-cli/src/graph.ts +13 -7
- package/spec-cli/src/guide.ts +52 -21
- package/spec-cli/src/harness.ts +85 -34
- package/spec-cli/src/help.ts +20 -14
- package/spec-cli/src/index.ts +25 -7
- package/spec-cli/src/lint.ts +91 -49
- package/spec-cli/src/migrate-table.ts +16 -6
- package/spec-cli/src/session-timeline.ts +148 -0
- package/spec-cli/src/sessions.ts +29 -4
- package/spec-cli/src/specs.ts +35 -17
- package/spec-cli/templates/hooks/prepare-commit-msg +6 -1
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +16 -0
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +17 -0
- package/spec-cli/templates/spec/project/.plugins/spec.md +3 -3
- package/spec-dashboard/dist/assets/{Dashboard-CTcH2eW9.js → Dashboard-C_fGmOKK.js} +3 -3
- package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-CVFbBpyW.js → FoldToggle-x9gtO1OQ.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-kULjonqj.js → IssuesPage-5f_vL-JV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +66 -0
- package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +13 -0
- package/spec-dashboard/dist/assets/{Settings-BL6FV_8S.js → Settings-DKb5Ji_X.js} +1 -1
- package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +41 -0
- package/spec-dashboard/dist/assets/index-BbMkwuix.css +1 -0
- package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +6 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-eval/src/cli.ts +32 -18
- package/spec-eval/src/evaltab.ts +4 -3
- package/spec-eval/src/scenarios.ts +116 -4
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
- 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/SessionWindow-CDhEL7wO.js +0 -9
- 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/{extract → commands/extract}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{supervisor → commands/supervisor}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{tidy → commands/tidy}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/spec.md +0 -0
package/spec-cli/src/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { Hono } from 'hono'
|
|
|
5
5
|
import { cors } from 'hono/cors'
|
|
6
6
|
import { etag } from 'hono/etag'
|
|
7
7
|
import { createNodeWebSocket } from '@hono/node-ws'
|
|
8
|
-
import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadConfig } from './specs.js'
|
|
8
|
+
import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadConfig, loadReviewConfig } from './specs.js'
|
|
9
9
|
import { issuesEnabled, remarkOnHost, resolveRemark, retractRemark } from './localIssues.js'
|
|
10
10
|
import { closeIssue, createIssue, findIssue, issueStores, mergedIssues, promote, replyIssue } from './issues.js'
|
|
11
11
|
import { residentForgeState, refreshForgeNow } from '../../spec-forge/src/resident.js'
|
|
@@ -16,6 +16,7 @@ import { getBoardJson } from './graphCache.js'
|
|
|
16
16
|
import { boardStream, notifyBoardChanged } from './graphStream.js'
|
|
17
17
|
import { gitA, gitTry, repoRoot } from './git.js'
|
|
18
18
|
import { newSession, listSessions, sendText, rawKey, stopSession, closeSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, superviseQueue } from './sessions.js'
|
|
19
|
+
import { superviseTimeline, readTimeline } from './session-timeline.js'
|
|
19
20
|
import { defaultHarness, HARNESSES, launcherList, launcherDefault } from './harness.js'
|
|
20
21
|
import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
|
|
21
22
|
import { putBlob } from '../../spec-eval/src/cache.js'
|
|
@@ -64,15 +65,19 @@ app.get('/api/specs', async (c) => c.json(await loadSpecs()))
|
|
|
64
65
|
// the search corpus ([[graph-lean]]): a filesystem-only {id,title,path,desc,body} for every node, NO git. The
|
|
65
66
|
// board omits `body` to stay lean, so the search palette fetches this ONCE when it opens (cached client-side)
|
|
66
67
|
// to rank nodes over their prose — off the board's hot poll. A literal segment, before the `:id` routes.
|
|
67
|
-
// Scenario prose rides the same corpus: the board's `scenarios` fold is slim ({name, tags}), so a measurable
|
|
68
|
-
// node's row here carries its declared scenarios' description/expected (+ per-scenario code) — one fetch
|
|
68
|
+
// Scenario prose rides the same corpus: the board's `scenarios` fold is slim ({name, tags, test}), so a measurable
|
|
69
|
+
// node's row here carries its declared scenarios' description/expected (+ test reference and per-scenario code) — one fetch
|
|
69
70
|
// serves both the palette's scenario plane and the focus-panel preview.
|
|
70
71
|
app.get('/api/specs/lite', (c) => {
|
|
71
72
|
const scByNode = new Map(evalNodes(repoRoot()).map((y) => [y.id, y.scenarios]))
|
|
72
73
|
return c.json(loadSpecsLite().map((row) => {
|
|
73
74
|
const sc = scByNode.get(row.id)
|
|
74
75
|
return sc?.length
|
|
75
|
-
? { ...row, scenarios: sc.map((s) => ({
|
|
76
|
+
? { ...row, scenarios: sc.map((s) => ({
|
|
77
|
+
name: s.name, description: s.description, expected: s.expected,
|
|
78
|
+
...(s.test ? { test: s.test } : {}),
|
|
79
|
+
...(s.code?.length ? { code: s.code } : {}),
|
|
80
|
+
})) }
|
|
76
81
|
: row
|
|
77
82
|
}))
|
|
78
83
|
})
|
|
@@ -171,7 +176,9 @@ app.get('/api/settings', async (c) => c.json({
|
|
|
171
176
|
// the `surface: command` plugin-root nodes (built/active only) for the new-session `/` dropdown — each with
|
|
172
177
|
// its prompt `body` ({{targets}} placeholder), `kind`, and folder `dir` + co-located `files`. surface is a
|
|
173
178
|
// frontmatter field, not a dir (specs.ts loadSurface); `surface: system` siblings are gathered elsewhere.
|
|
174
|
-
|
|
179
|
+
// `?surface=review` lists the review-track presets instead ([[review-commands]] — the eval detail's
|
|
180
|
+
// remark-composer `/` dropdown); the exposed surfaces stay this explicit whitelist, never a passthrough.
|
|
181
|
+
app.get('/api/plugins', (c) => c.json(c.req.query('surface') === 'review' ? loadReviewConfig() : loadConfig()))
|
|
175
182
|
// the ISSUES read surface ([[issues]]) for the dashboard's issues page — the merged list over every store
|
|
176
183
|
// (local threads + the resident forge slice), the SAME mergedIssues() the CLI drain reads, verbatim
|
|
177
184
|
// (the dashboard computes nothing over it: no re-sort, no salience ranking). The `enabled` flag mirrors
|
|
@@ -404,6 +411,14 @@ app.get('/api/sessions/:id/capture', async (c) => {
|
|
|
404
411
|
if (r.reason === 'offline') return c.text('session offline (no live pane)', 409)
|
|
405
412
|
return c.text('capture failed', 502)
|
|
406
413
|
})
|
|
414
|
+
// the session's persisted interaction history ([[session-timeline]]): authored status transitions (with the
|
|
415
|
+
// FULL note text) + delivered prompts, timestamped, oldest first — what a terminal-free surface renders as
|
|
416
|
+
// the conversation. `?limit=<n>` caps the tail (default 500). 404 for an unknown/non-governed id.
|
|
417
|
+
app.get('/api/sessions/:id/timeline', (c) => {
|
|
418
|
+
const limit = Number(c.req.query('limit'))
|
|
419
|
+
const r = readTimeline(c.req.param('id'), Number.isFinite(limit) && limit > 0 ? limit : undefined)
|
|
420
|
+
return r ? c.json(r) : c.json({ error: 'no such session' }, 404)
|
|
421
|
+
})
|
|
407
422
|
// the session RECORD detail (`spex session show`): the board row (status · node · branch · launcher · …)
|
|
408
423
|
// plus the full originating prompt (the row itself carries only the preview). One id-addressed read backs
|
|
409
424
|
// the CLI's show; 404 for an unknown id.
|
|
@@ -475,8 +490,10 @@ app.post('/api/sessions/:id/input', async (c) => {
|
|
|
475
490
|
const body = await c.req.json().catch(() => ({}))
|
|
476
491
|
if (body?.kind === 'text') {
|
|
477
492
|
// `from` (the sender's session id) rides only an agent-to-agent send → the backend records the comms
|
|
478
|
-
// edge ([[comms-edge]]); a raw human dispatch omits it and is not logged.
|
|
479
|
-
|
|
493
|
+
// edge ([[comms-edge]]); a raw human dispatch omits it and is not logged. `replyVia:"note"` marks a
|
|
494
|
+
// terminal-free sender ([[session-timeline]]): the server appends the note-reply insert to the delivery.
|
|
495
|
+
const r = await sendText(c.req.param('id'), typeof body?.text === 'string' ? body.text : '', typeof body?.from === 'string' ? body.from : undefined,
|
|
496
|
+
body?.replyVia === 'note' ? { replyVia: 'note' } : {})
|
|
480
497
|
return c.json(r, r.ok ? 200 : 502)
|
|
481
498
|
}
|
|
482
499
|
if (body?.kind === 'keys') {
|
|
@@ -531,6 +548,7 @@ installConnectionReaper(server as unknown as HttpServer)
|
|
|
531
548
|
injectWebSocket(server)
|
|
532
549
|
superviseBridges() // keep a warm tmux client per live session, so opening a tab is instant
|
|
533
550
|
superviseQueue() // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
|
|
551
|
+
superviseTimeline() // record authored-lifecycle transitions to each session's durable timeline ([[session-timeline]])
|
|
534
552
|
console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`)
|
|
535
553
|
|
|
536
554
|
// graceful drain (the other half of zero-downtime reload, supervise.ts): on SIGTERM stop accepting new
|
package/spec-cli/src/lint.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
|
1
|
+
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { repoRoot, git, driftIndex, historyIndex, rowsFor } from './git.js'
|
|
4
4
|
import { loadSpecs } from './specs.js'
|
|
@@ -16,6 +16,10 @@ export type LintConfig = {
|
|
|
16
16
|
maxChildren: number // breadth budget: warn at >= this many direct children
|
|
17
17
|
maxOwners: number // warn when a file is governed (code:) by > this many nodes
|
|
18
18
|
scenarioTags: string[] // the closed vocabulary an eval scenario's `tags:` must draw from; extend it to mint a new tag
|
|
19
|
+
scopedCodeMiss: 'warn' | 'ignore' // the file-level drift ADVISORY on a selector-scoped code: file whose window has no
|
|
20
|
+
// selector hit ([[code-anchor]]). 'warn' (default) keeps today's drift warning; 'ignore'
|
|
21
|
+
// silences ONLY that advisory — hit blocks, bare code drift, integrity, acks, related
|
|
22
|
+
// semantics, and eval freshness are all untouched by this knob.
|
|
19
23
|
}
|
|
20
24
|
const DEFAULT_CONFIG: LintConfig = {
|
|
21
25
|
governedRoots: ['spec-dashboard/src', 'spec-cli/src'],
|
|
@@ -26,6 +30,7 @@ const DEFAULT_CONFIG: LintConfig = {
|
|
|
26
30
|
maxChildren: 8,
|
|
27
31
|
maxOwners: 3,
|
|
28
32
|
scenarioTags: ['frontend-e2e', 'backend-api', 'cli', 'desktop', 'mobile'],
|
|
33
|
+
scopedCodeMiss: 'warn',
|
|
29
34
|
}
|
|
30
35
|
export function loadConfig(root: string): LintConfig {
|
|
31
36
|
// Absent spexcode.json → tuned defaults; a MALFORMED one throws LOUD (readJsonConfig) rather than
|
|
@@ -44,6 +49,10 @@ export function loadConfig(root: string): LintConfig {
|
|
|
44
49
|
// ROOT-level files and leaks every nested test into coverage. A slash-less glob is a basename intent →
|
|
45
50
|
// prepend "**/" so it matches that basename at any depth (the default "**/*.test.*" already does).
|
|
46
51
|
export function normalizeConfig(cfg: LintConfig): LintConfig {
|
|
52
|
+
// a mistyped enum silently reverting to the default would green-wash (or over-warn) exactly the
|
|
53
|
+
// advisory the author meant to tune — same fail-loud rule as a malformed spexcode.json.
|
|
54
|
+
if (cfg.scopedCodeMiss !== 'warn' && cfg.scopedCodeMiss !== 'ignore')
|
|
55
|
+
throw new Error(`spexcode.json lint.scopedCodeMiss must be "warn" or "ignore", got ${JSON.stringify(cfg.scopedCodeMiss)}`)
|
|
47
56
|
const dedot = (xs: string[]) => xs.map((x) => x.replace(/^\.+/, ''))
|
|
48
57
|
return {
|
|
49
58
|
...cfg,
|
|
@@ -131,15 +140,25 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
131
140
|
const specs = await loadSpecs()
|
|
132
141
|
const out: Finding[] = []
|
|
133
142
|
|
|
134
|
-
// integrity + build the file -> owners map.
|
|
143
|
+
// integrity + build the file -> owners map. A relation's STRUCTURAL problems (a duplicate entry,
|
|
144
|
+
// bare/scoped mixing on one base path, a selector on a glob — all from parseRelation,
|
|
145
|
+
// [[code-anchor]]) are integrity errors: malformed edges block like broken ones.
|
|
135
146
|
const owners = new Map<string, string[]>()
|
|
147
|
+
const claimed = new Set<string>()
|
|
136
148
|
for (const s of specs) {
|
|
149
|
+
for (const p of s.relationProblems)
|
|
150
|
+
out.push({ level: 'error', rule: 'integrity', spec: s.id, msg: `'${s.id}' ${p}` })
|
|
151
|
+
const scopedPaths = new Set(s.codeScoped.map((e) => e.path))
|
|
137
152
|
for (const f of s.code) {
|
|
138
153
|
if (!existsSync(join(root, f)))
|
|
139
154
|
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: f, msg: `spec '${s.id}' lists a missing file: ${f}` })
|
|
140
|
-
|
|
155
|
+
claimed.add(f)
|
|
156
|
+
// a selector-SCOPED entry claims named units, not the whole file, so it stays out of the owners
|
|
157
|
+
// bound below ([[code-anchor]]) — `spex spec owner` still displays it as a (scoped) governor.
|
|
158
|
+
if (!scopedPaths.has(f)) owners.set(f, [...(owners.get(f) ?? []), s.id])
|
|
141
159
|
}
|
|
142
|
-
// one-govern: a node is source of truth for at most ONE file
|
|
160
|
+
// one-govern: a node is source of truth for at most ONE file — DISTINCT base paths; several
|
|
161
|
+
// `path#symbol` selectors on the same file are one subject — so drift/eval/ack have a single
|
|
143
162
|
// unambiguous subject (see [[governed-related]]). >1 is a defect — pick the true subject, demote the
|
|
144
163
|
// rest to related. ERROR (the node-side twin of too-many-owners' file-side bound). 0 is fine.
|
|
145
164
|
if (s.code.length > 1)
|
|
@@ -148,7 +167,6 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
148
167
|
// a file is COVERED if any node GOVERNS (code:) or merely REFERENCES (related:) it; integrity covers both.
|
|
149
168
|
// `related:` is the coverage net: govern is a sharp ideally-one-file pointer, so most files are reached by
|
|
150
169
|
// related, not govern (see [[governed-related]]). It carries coverage but never drift, never eval freshness.
|
|
151
|
-
const claimed = new Set<string>(owners.keys())
|
|
152
170
|
for (const s of specs) for (const f of s.related) {
|
|
153
171
|
if (!existsSync(join(root, f)))
|
|
154
172
|
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: f, msg: `spec '${s.id}' lists a missing related file: ${f}` })
|
|
@@ -276,55 +294,73 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
276
294
|
out.push({ level: 'warn', rule: 'owners', msg: `${over.length} file(s) are governed by > ${cfg.maxOwners} nodes — each holds more separately-specified functionality than one file should. Worst: ${top}. SPLIT the file so each governor owns its own module (or merge the nodes, or give it a single foundation owner + related:).` })
|
|
277
295
|
}
|
|
278
296
|
|
|
279
|
-
// code anchors ([[code-anchor]]): a code: entry may pin
|
|
280
|
-
// the BLOCKING tier of drift: a window
|
|
281
|
-
// governed file) whose --unified=0 hunks
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
297
|
+
// code anchors ([[code-anchor]]): a code:/related: entry may pin named units (`path#symbol` — any
|
|
298
|
+
// number per base file, OR'd). On code:, the anchor is the BLOCKING tier of drift: a window
|
|
299
|
+
// commit (spec's last version..HEAD, non-merge, touching the governed file) whose --unified=0 hunks
|
|
300
|
+
// intersect any pinned unit's line range — extracted from the file AS OF that commit, by the
|
|
301
|
+
// extension's ONE designated extractor — is ONE anchor-drift ERROR naming the hit selectors, unless a
|
|
302
|
+
// Spec-OK ack covers it. On related:, the SAME engine yields only a soft warn on a hit — a scoped
|
|
303
|
+
// related miss is silent (never blocks, no ack, no eval freshness). Resolution failures are never
|
|
304
|
+
// silent for either relation: a dead or ambiguous selector, a selector on a directory, an unparseable
|
|
305
|
+
// working-tree file, an extension with no designated extractor, and a designated extractor that can't
|
|
306
|
+
// run here (no host typescript) all ERROR with the repair spelled out.
|
|
286
307
|
const regs = extractors(root)
|
|
287
308
|
const [didx, hidx] = await Promise.all([driftIndex(root), historyIndex(root)])
|
|
288
309
|
const readyWarned = new Set<string>()
|
|
289
310
|
for (const s of specs) {
|
|
290
|
-
for (const {
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
311
|
+
for (const { relation, entries } of [{ relation: 'code' as const, entries: s.codeScoped }, { relation: 'related' as const, entries: s.relatedScoped }]) {
|
|
312
|
+
for (const { path, selectors } of entries) {
|
|
313
|
+
const x = extractorFor(regs, extOf(path))
|
|
314
|
+
if (!x) {
|
|
315
|
+
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `'${s.id}' anchors ${path}#${selectors.join(', #')} (${relation}:), but no extractor is designated for '.${extOf(path)}' files — this language has no anchor support yet: add a LangSpec row (anchors.ts) or drop the selector(s)` })
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
const ready = x.ready()
|
|
319
|
+
if (ready !== true) {
|
|
320
|
+
// once per (extractor, reason), even across several anchored nodes — one repair, one message.
|
|
321
|
+
if (!readyWarned.has(x.id + ready)) { readyWarned.add(x.id + ready); out.push({ level: 'error', rule: 'integrity', msg: `anchor extractor '${x.id}' cannot run: ${ready}` }) }
|
|
322
|
+
continue
|
|
323
|
+
}
|
|
324
|
+
if (!existsSync(join(root, path))) continue // the missing FILE already errored above
|
|
325
|
+
if (statSync(join(root, path)).isDirectory()) {
|
|
326
|
+
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `'${s.id}' puts a selector on a directory (${relation}: ${path}#${selectors[0]}) — a selector scopes ONE real file` })
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
let units
|
|
330
|
+
try { units = x.extract(readFileSync(join(root, path), 'utf8'), path) } catch (e: any) {
|
|
331
|
+
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `anchor ${path}#${selectors.join(', #')} ('${s.id}') is unverifiable — the current file does not parse: ${e?.message ?? e}` })
|
|
332
|
+
continue
|
|
333
|
+
}
|
|
334
|
+
// each selector resolves (or errors) on its own; only the live ones feed the window engine.
|
|
335
|
+
const live: string[] = []
|
|
336
|
+
for (const sym of selectors) {
|
|
337
|
+
const res = resolveAnchor(units, sym)
|
|
338
|
+
if ('dead' in res) {
|
|
339
|
+
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `dead anchor: ${path}#${sym} ('${s.id}') names no unit on the current tree — the unit was deleted or renamed; update the spec's ${relation}: entry to follow it` })
|
|
340
|
+
continue
|
|
341
|
+
}
|
|
342
|
+
if ('ambiguous' in res) {
|
|
343
|
+
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `ambiguous anchor: ${path}#${sym} ('${s.id}') names ${res.ambiguous} same-named units in one file — an anchor must be unique; rename one unit` })
|
|
344
|
+
continue
|
|
345
|
+
}
|
|
346
|
+
if (res.ok.typeOnly)
|
|
347
|
+
out.push({ level: 'warn', rule: 'anchor', spec: s.id, file: path, msg: `${path}#${sym} anchors a ${res.ok.kind} — anchoring a type is usually wrong (types reshape with every refactor); anchor the behaviour-bearing unit instead` })
|
|
348
|
+
live.push(sym)
|
|
349
|
+
}
|
|
350
|
+
if (!live.length) continue
|
|
351
|
+
const since = rowsFor(hidx, s.path)[0]?.hash || ''
|
|
352
|
+
const win = windowCommits(didx, since, path)
|
|
353
|
+
if (!win.length) continue
|
|
354
|
+
const hits = await anchorHitCommits(root, win, path, live, x)
|
|
355
|
+
if (!hits.length) continue
|
|
356
|
+
const hitSyms = [...new Set(hits.flatMap((h) => h.selectors))]
|
|
325
357
|
const shas = hits.map((h) => h.commit.slice(0, 8)).join(', ')
|
|
358
|
+
const unparseable = hits.filter((h) => h.unparseable)
|
|
326
359
|
const parseNote = unparseable.length ? ` (${unparseable.length} of these could not be parsed at that commit — counted as hits conservatively)` : ''
|
|
327
|
-
|
|
360
|
+
if (relation === 'code')
|
|
361
|
+
out.push({ level: 'error', rule: 'anchor-drift', spec: s.id, file: path, msg: `${path}#${hitSyms.join(', #')} was changed by ${hits.length} commit(s) since spec '${s.id}' v${s.version} [${shas}]${parseNote} — the anchored contract's code moved: update the spec, or 'spex spec ack ${s.id} --reason "…"' if the contract still holds` })
|
|
362
|
+
else
|
|
363
|
+
out.push({ level: 'warn', rule: 'related-drift', spec: s.id, file: path, msg: `related ${path}#${hitSyms.join(', #')} ('${s.id}') was changed by ${hits.length} commit(s) since v${s.version} [${shas}]${parseNote} — a scoped dependency shifted, worth a glance (SOFT: never blocks, no ack, no eval staleness)` })
|
|
328
364
|
}
|
|
329
365
|
}
|
|
330
366
|
}
|
|
@@ -334,9 +370,15 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
334
370
|
// commit to the file counts iff it is NOT reachable from the spec's latest version (in-memory
|
|
335
371
|
// parent-edge reachability, the equivalent of `rev-list <version>..HEAD -- <file>`), never a
|
|
336
372
|
// log-position or timestamp guess.
|
|
373
|
+
// A selector-SCOPED code file keeps this file-level advisory by default (a miss still nudges); the
|
|
374
|
+
// committed `lint.scopedCodeMiss: "ignore"` silences ONLY this advisory for scoped entries — the
|
|
375
|
+
// anchor engine's verdicts above (hit = block, resolution failures = integrity) are untouched.
|
|
337
376
|
for (const s of specs) {
|
|
338
|
-
|
|
377
|
+
const scopedPaths = new Set(s.codeScoped.map((e) => e.path))
|
|
378
|
+
for (const d of s.driftFiles) {
|
|
379
|
+
if (cfg.scopedCodeMiss === 'ignore' && scopedPaths.has(d.file)) continue
|
|
339
380
|
out.push({ level: 'warn', rule: 'drift', spec: s.id, file: d.file, msg: `${d.file} is ${d.behind} commit(s) ahead of spec '${s.id}' (v${s.version}) — may be stale` })
|
|
381
|
+
}
|
|
340
382
|
}
|
|
341
383
|
|
|
342
384
|
// related drift: the SOFT tier ([[governed-related]]). A referenced file moved ahead of the node's
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
// else is flagged for review, never rewritten.
|
|
6
6
|
export type MigrateAsset = { rel: string; template: string; oldShas: string[] }
|
|
7
7
|
export const TEMPLATE_ASSETS: MigrateAsset[] = [
|
|
8
|
+
{
|
|
9
|
+
"rel": "commands/spec.md",
|
|
10
|
+
"template": "templates/spec/project/.plugins/commands/spec.md",
|
|
11
|
+
"oldShas": []
|
|
12
|
+
},
|
|
8
13
|
{
|
|
9
14
|
"rel": "core/idle/idle.sh",
|
|
10
15
|
"template": "templates/spec/project/.plugins/core/idle/idle.sh",
|
|
@@ -155,7 +160,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
|
|
|
155
160
|
},
|
|
156
161
|
{
|
|
157
162
|
"rel": "distill/digest.mjs",
|
|
158
|
-
"template": "templates/spec/project/.plugins/distill/digest.mjs",
|
|
163
|
+
"template": "templates/spec/project/.plugins/skills/distill/digest.mjs",
|
|
159
164
|
"oldShas": [
|
|
160
165
|
"b596d196d35688130157698ca90950d412bf527f",
|
|
161
166
|
"faff3509b004e8529d5c7f3abd29512fb389ceb9"
|
|
@@ -163,7 +168,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
|
|
|
163
168
|
},
|
|
164
169
|
{
|
|
165
170
|
"rel": "distill/spec.md",
|
|
166
|
-
"template": "templates/spec/project/.plugins/distill/spec.md",
|
|
171
|
+
"template": "templates/spec/project/.plugins/skills/distill/spec.md",
|
|
167
172
|
"oldShas": [
|
|
168
173
|
"3e3fd25db1d465f72b636e6af8bcd873c0ceb3c2",
|
|
169
174
|
"4a0b547ad63e15aa80ed7f826581ad90ca02d0f4",
|
|
@@ -172,7 +177,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
|
|
|
172
177
|
},
|
|
173
178
|
{
|
|
174
179
|
"rel": "extract/spec.md",
|
|
175
|
-
"template": "templates/spec/project/.plugins/extract/spec.md",
|
|
180
|
+
"template": "templates/spec/project/.plugins/commands/extract/spec.md",
|
|
176
181
|
"oldShas": [
|
|
177
182
|
"064efd8321925bd0cff7ee725771b500a7baf6e7",
|
|
178
183
|
"45c2b0fe12224ebc124b5f5fecafd1e8d82de93e",
|
|
@@ -209,7 +214,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
|
|
|
209
214
|
},
|
|
210
215
|
{
|
|
211
216
|
"rel": "regroup/spec.md",
|
|
212
|
-
"template": "templates/spec/project/.plugins/regroup/spec.md",
|
|
217
|
+
"template": "templates/spec/project/.plugins/commands/regroup/spec.md",
|
|
213
218
|
"oldShas": [
|
|
214
219
|
"37f632450faba3e9c5290468b52535e766faf45e",
|
|
215
220
|
"aa3097a1b7ff51c4a8a0bfab82ac06a585081caa",
|
|
@@ -227,6 +232,11 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
|
|
|
227
232
|
"fc3c060af70b01b2073163880a42d002e285ddc5"
|
|
228
233
|
]
|
|
229
234
|
},
|
|
235
|
+
{
|
|
236
|
+
"rel": "skills/spec.md",
|
|
237
|
+
"template": "templates/spec/project/.plugins/skills/spec.md",
|
|
238
|
+
"oldShas": []
|
|
239
|
+
},
|
|
230
240
|
{
|
|
231
241
|
"rel": "spec.md",
|
|
232
242
|
"template": "templates/spec/project/.plugins/spec.md",
|
|
@@ -247,7 +257,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
|
|
|
247
257
|
},
|
|
248
258
|
{
|
|
249
259
|
"rel": "supervisor/spec.md",
|
|
250
|
-
"template": "templates/spec/project/.plugins/supervisor/spec.md",
|
|
260
|
+
"template": "templates/spec/project/.plugins/commands/supervisor/spec.md",
|
|
251
261
|
"oldShas": [
|
|
252
262
|
"036d5602a038e2565cd968e4a8a443ce80026025",
|
|
253
263
|
"05963c00bc1031ba0d4f91ea68d60b1731f475f2",
|
|
@@ -269,7 +279,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
|
|
|
269
279
|
},
|
|
270
280
|
{
|
|
271
281
|
"rel": "tidy/spec.md",
|
|
272
|
-
"template": "templates/spec/project/.plugins/tidy/spec.md",
|
|
282
|
+
"template": "templates/spec/project/.plugins/commands/tidy/spec.md",
|
|
273
283
|
"oldShas": [
|
|
274
284
|
"096fc1dd48a1692aeb87614a93bcce2473f2050a",
|
|
275
285
|
"16ccbf044e74765a5c9cc212fba38356950a7e69",
|
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
// record a CONFIRMED prompt delivery (called by sendText after the harness accepted it). `text` is the
|
|
122
|
+
// caller's message BEFORE any mechanism insert (the note-reply hint is transport, not conversation);
|
|
123
|
+
// `replyVia` marks that the hint rode along so a surface can badge it.
|
|
124
|
+
export function recordSent(id: string, text: string, from: string | null, replyVia?: 'note'): void {
|
|
125
|
+
try { if (!readAliasedRawRecord(id)?.governed) return } catch { return }
|
|
126
|
+
append(id, { ts: new Date().toISOString(), kind: 'sent', text, from, ...(replyVia ? { replyVia } : {}) })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// the read surface behind GET /api/sessions/:id/timeline: the last `limit` events, oldest first, each
|
|
130
|
+
// status event carrying its composed display word. null = no such session (the route 404s).
|
|
131
|
+
// Adjacent status lines with identical (status, proposal, note) fold into their first: TWO serve processes
|
|
132
|
+
// observing one store (a throwaway worktree/eval serve beside the live one) each keep their own lastSeen,
|
|
133
|
+
// so a single record move can append twice — cross-process write locking isn't worth buying, so the log
|
|
134
|
+
// stays best-effort append-only and the read is where duplicates die, same stance as the board.
|
|
135
|
+
export function readTimeline(id: string, limit = 500): { events: TimelineEvent[] } | null {
|
|
136
|
+
let raw: ReturnType<typeof readAliasedRawRecord>
|
|
137
|
+
try { raw = readAliasedRawRecord(id) } catch { return null }
|
|
138
|
+
if (!raw || !raw.governed) return null
|
|
139
|
+
const folded: TimelineEvent[] = []
|
|
140
|
+
for (const e of readEvents(id)) {
|
|
141
|
+
const prev = folded[folded.length - 1]
|
|
142
|
+
if (e.kind === 'status' && prev?.kind === 'status' && prev.status === e.status
|
|
143
|
+
&& (prev.proposal ?? null) === (e.proposal ?? null) && (prev.note ?? null) === (e.note ?? null)) continue
|
|
144
|
+
folded.push(e)
|
|
145
|
+
}
|
|
146
|
+
const tail = folded.slice(Math.max(0, folded.length - Math.max(1, limit)))
|
|
147
|
+
return { events: tail.map((e) => (e.kind === 'status' ? { ...e, display: displayOf(e) } : e)) }
|
|
148
|
+
}
|
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { loadSpecs } from './specs.js'
|
|
|
10
10
|
import { defaultHarness, defaultLauncher, harnessById, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
|
|
11
11
|
import { materialize } from './materialize.js'
|
|
12
12
|
import { mainBranch, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, readAliasedRawRecord, envSessionId, type RawRecord } from './layout.js'
|
|
13
|
+
import { recordSent } from './session-timeline.js'
|
|
13
14
|
import { stripRefSigil } from './mentions.js'
|
|
14
15
|
|
|
15
16
|
// @@@ sessions - the WORKTREE is the durable unit; tmux is a disposable runtime handle. The per-session
|
|
@@ -863,6 +864,14 @@ export function withSenderHint(text: string, sender: MsgSender | null): string {
|
|
|
863
864
|
const who = sender.label && sender.label !== sender.id ? `session "${sender.label}" (${sender.id})` : `session ${sender.id}`
|
|
864
865
|
return `${text}\n\n— from ${who}. To reply: spex session send ${sender.id} "<your reply>"`
|
|
865
866
|
}
|
|
867
|
+
// @@@ withNoteReplyHint - the TERMINAL-FREE sender's insert, withSenderHint's sibling: a phone (or any
|
|
868
|
+
// no-terminal surface, [[mobile-ui]]) cannot read the pane, so the only text that ever reaches its human is
|
|
869
|
+
// the declaration NOTE ([[session-timeline]]). This one-line insert tells the agent exactly that, so its
|
|
870
|
+
// next stop carries the complete answer in `--note` instead of prose that dies in an unseen terminal.
|
|
871
|
+
// Appended server-side (the input route passes replyVia:'note'), so the phrase lives in ONE place and any
|
|
872
|
+
// surface — desktop included, later — can opt in with the same flag.
|
|
873
|
+
export const withNoteReplyHint = (text: string): string =>
|
|
874
|
+
`${text}\n\n— sent from a terminal-free client: the sender CANNOT see your terminal output. When you next stop (done/ask/park), put your COMPLETE reply to this message in the declaration's --note (e.g. spex session done --note "<full answer>") — the note is the only text that reaches them.`
|
|
866
875
|
async function postJSON(path: string, body: unknown): Promise<void> {
|
|
867
876
|
try {
|
|
868
877
|
await fetch(`${await apiBase()}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
@@ -1843,21 +1852,37 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
|
|
|
1843
1852
|
}
|
|
1844
1853
|
|
|
1845
1854
|
// @@@ sendText - PROMPT control for a session, delivered through the session's HARNESS ADAPTER
|
|
1846
|
-
// ([[harness-adapter]]) — claude the rendezvous control socket (
|
|
1847
|
-
//
|
|
1855
|
+
// ([[harness-adapter]]) — claude the rendezvous control socket (atomic reply+repaint chunk: repaint-done proves
|
|
1856
|
+
// the daemon parsed it, a kicked connection resends, wall expiry on a live connection is busy-not-lost → ok),
|
|
1857
|
+
// codex app-server JSON-RPC into the visible TUI's thread. Either way there is NO silent
|
|
1848
1858
|
// fallback: a prompt that can't be delivered — no socket / dead agent (claude), no app-server/thread (codex) — FAILS LOUD, returning
|
|
1849
1859
|
// ok:false with a reason that propagates to the caller (API non-2xx, `spex session send`, the merge dispatch),
|
|
1850
1860
|
// instead of reporting a false success. The harness is resolved from the record; an unknown id fails before any
|
|
1851
1861
|
// harness transport is addressed. (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
|
|
1852
|
-
export async function sendText(id: string, text: string, from?: string): Promise<DispatchResult> {
|
|
1862
|
+
export async function sendText(id: string, text: string, from?: string, opts: { replyVia?: 'note' } = {}): Promise<DispatchResult> {
|
|
1853
1863
|
if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
|
|
1854
1864
|
const rec = readRecord(id)
|
|
1855
1865
|
if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
|
|
1856
1866
|
const h = harnessById(rec.harness || defaultHarness.id)
|
|
1857
|
-
|
|
1867
|
+
// the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
|
|
1868
|
+
// prompt its channel confirms (claude's sessions panel), checkable only from the pane — refuse loudly with
|
|
1869
|
+
// the recovery named instead of reporting a false success. A missing pane (window gone, probe failure) skips
|
|
1870
|
+
// the guard: the delivery channel itself is the authority on whether the agent is reachable.
|
|
1871
|
+
if (h.deliveryBlockedBy) {
|
|
1872
|
+
try {
|
|
1873
|
+
const blocked = h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', id], TMUX_PROBE_TIMEOUT_MS))
|
|
1874
|
+
if (blocked) return { ok: false, error: blocked }
|
|
1875
|
+
} catch { /* no pane to consult — let the delivery channel decide */ }
|
|
1876
|
+
}
|
|
1877
|
+
// a terminal-free sender's dispatch carries the note-reply insert ([[session-timeline]]); appended here,
|
|
1878
|
+
// beside the delivery, so every input surface shares the one phrase and the timeline records the message
|
|
1879
|
+
// WITHOUT it (the hint is transport, not conversation).
|
|
1880
|
+
const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot() }, opts.replyVia === 'note' ? withNoteReplyHint(text) : text)
|
|
1858
1881
|
// record the delivered agent-to-agent message ([[comms-edge]]): only when it carries a sender (an agent
|
|
1859
1882
|
// send, not a raw human dispatch) and actually landed. Fire-and-forget — never gates the send result.
|
|
1860
1883
|
if (r.ok && from) void recordComms(id, from)
|
|
1884
|
+
// the durable interaction history ([[session-timeline]]): every confirmed delivery is a `sent` event.
|
|
1885
|
+
if (r.ok) recordSent(id, text, from ?? null, opts.replyVia)
|
|
1861
1886
|
return r
|
|
1862
1887
|
}
|
|
1863
1888
|
|