spexcode 0.3.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 +341 -0
- package/spec-cli/src/cli.ts +20 -20
- package/spec-cli/src/gateway.ts +8 -1
- package/spec-cli/src/git.ts +21 -13
- package/spec-cli/src/graph.ts +13 -7
- package/spec-cli/src/guide.ts +63 -15
- package/spec-cli/src/harness.ts +85 -34
- package/spec-cli/src/help.ts +33 -17
- package/spec-cli/src/index.ts +39 -7
- package/spec-cli/src/init.ts +5 -4
- package/spec-cli/src/lint.ts +103 -28
- package/spec-cli/src/localIssues.ts +19 -0
- package/spec-cli/src/migrate-table.ts +27 -16
- package/spec-cli/src/search.bench.mjs +2 -2
- package/spec-cli/src/session-timeline.ts +148 -0
- package/spec-cli/src/sessions.ts +29 -4
- package/spec-cli/src/specs.ts +44 -21
- package/spec-cli/templates/hooks/prepare-commit-msg +6 -1
- package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +16 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +17 -0
- package/spec-cli/templates/spec/project/.plugins/spec.md +5 -2
- package/spec-cli/templates/spec/project/spec.md +1 -1
- package/spec-dashboard/dist/assets/{Dashboard-C7Bzsv86.js → Dashboard-C_fGmOKK.js} +3 -3
- package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-D5iB4Ac2.js → FoldToggle-x9gtO1OQ.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-CMFTsQhg.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-BW5f0OaW.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 +82 -21
- package/spec-eval/src/evaltab.ts +15 -6
- package/spec-eval/src/humanok.ts +43 -0
- package/spec-eval/src/scenarios.ts +116 -4
- package/spec-eval/src/sidecar.ts +35 -9
- package/spec-cli/templates/presets/careful/.plugins/clarify-before-code/spec.md +0 -11
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
- package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-DwuTKgdP.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-CBS5_cmK.js +0 -71
- package/spec-dashboard/dist/assets/SessionWindow-CqAnjWfI.js +0 -9
- package/spec-dashboard/dist/assets/index-Cc26X4ce.css +0 -1
- package/spec-dashboard/dist/assets/index-Ce0wDyQS.js +0 -41
- /package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/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/{forge-link → prompts/forge-link}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{memory-hygiene → prompts/memory-hygiene}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{reproduce-before-fix → prompts/reproduce-before-fix}/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/lint.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
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
|
-
import { repoRoot,
|
|
3
|
+
import { repoRoot, git, driftIndex, historyIndex, rowsFor } from './git.js'
|
|
4
4
|
import { loadSpecs } from './specs.js'
|
|
5
5
|
import { readJsonConfig } from './layout.js'
|
|
6
|
+
import { extractors, extractorFor, extOf, resolveAnchor, windowCommits, anchorHitCommits } from './anchors.js'
|
|
6
7
|
|
|
7
8
|
export type Finding = { level: 'error' | 'warn'; rule: string; spec?: string; file?: string; msg: string }
|
|
8
9
|
|
|
@@ -13,9 +14,12 @@ export type LintConfig = {
|
|
|
13
14
|
identifierExtensions: string[]// extensions the altitude bare-filename signal recognises (see IDENT below)
|
|
14
15
|
altitude: { lineBudget: number; charBudget: number; sizeable: number; dense: number; steps: number }
|
|
15
16
|
maxChildren: number // breadth budget: warn at >= this many direct children
|
|
16
|
-
driftErrorThreshold: number// commit-local gate HARD-BLOCKS a commit touching a node >= this many commits behind
|
|
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'],
|
|
@@ -24,9 +28,9 @@ const DEFAULT_CONFIG: LintConfig = {
|
|
|
24
28
|
identifierExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'md'],
|
|
25
29
|
altitude: { lineBudget: 50, charBudget: 4200, sizeable: 35, dense: 1.3, steps: 3 },
|
|
26
30
|
maxChildren: 8,
|
|
27
|
-
driftErrorThreshold: 3,
|
|
28
31
|
maxOwners: 3,
|
|
29
32
|
scenarioTags: ['frontend-e2e', 'backend-api', 'cli', 'desktop', 'mobile'],
|
|
33
|
+
scopedCodeMiss: 'warn',
|
|
30
34
|
}
|
|
31
35
|
export function loadConfig(root: string): LintConfig {
|
|
32
36
|
// Absent spexcode.json → tuned defaults; a MALFORMED one throws LOUD (readJsonConfig) rather than
|
|
@@ -45,6 +49,10 @@ export function loadConfig(root: string): LintConfig {
|
|
|
45
49
|
// ROOT-level files and leaks every nested test into coverage. A slash-less glob is a basename intent →
|
|
46
50
|
// prepend "**/" so it matches that basename at any depth (the default "**/*.test.*" already does).
|
|
47
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)}`)
|
|
48
56
|
const dedot = (xs: string[]) => xs.map((x) => x.replace(/^\.+/, ''))
|
|
49
57
|
return {
|
|
50
58
|
...cfg,
|
|
@@ -132,15 +140,25 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
132
140
|
const specs = await loadSpecs()
|
|
133
141
|
const out: Finding[] = []
|
|
134
142
|
|
|
135
|
-
// 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.
|
|
136
146
|
const owners = new Map<string, string[]>()
|
|
147
|
+
const claimed = new Set<string>()
|
|
137
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))
|
|
138
152
|
for (const f of s.code) {
|
|
139
153
|
if (!existsSync(join(root, f)))
|
|
140
154
|
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: f, msg: `spec '${s.id}' lists a missing file: ${f}` })
|
|
141
|
-
|
|
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])
|
|
142
159
|
}
|
|
143
|
-
// 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
|
|
144
162
|
// unambiguous subject (see [[governed-related]]). >1 is a defect — pick the true subject, demote the
|
|
145
163
|
// rest to related. ERROR (the node-side twin of too-many-owners' file-side bound). 0 is fine.
|
|
146
164
|
if (s.code.length > 1)
|
|
@@ -149,7 +167,6 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
149
167
|
// a file is COVERED if any node GOVERNS (code:) or merely REFERENCES (related:) it; integrity covers both.
|
|
150
168
|
// `related:` is the coverage net: govern is a sharp ideally-one-file pointer, so most files are reached by
|
|
151
169
|
// related, not govern (see [[governed-related]]). It carries coverage but never drift, never eval freshness.
|
|
152
|
-
const claimed = new Set<string>(owners.keys())
|
|
153
170
|
for (const s of specs) for (const f of s.related) {
|
|
154
171
|
if (!existsSync(join(root, f)))
|
|
155
172
|
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: f, msg: `spec '${s.id}' lists a missing related file: ${f}` })
|
|
@@ -277,14 +294,91 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
277
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:).` })
|
|
278
295
|
}
|
|
279
296
|
|
|
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.
|
|
307
|
+
const regs = extractors(root)
|
|
308
|
+
const [didx, hidx] = await Promise.all([driftIndex(root), historyIndex(root)])
|
|
309
|
+
const readyWarned = new Set<string>()
|
|
310
|
+
for (const s of specs) {
|
|
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))]
|
|
357
|
+
const shas = hits.map((h) => h.commit.slice(0, 8)).join(', ')
|
|
358
|
+
const unparseable = hits.filter((h) => h.unparseable)
|
|
359
|
+
const parseNote = unparseable.length ? ` (${unparseable.length} of these could not be parsed at that commit — counted as hits conservatively)` : ''
|
|
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)` })
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
280
368
|
// drift: a governed file has commits NOT yet reflected in its spec. Judged by true git ancestry —
|
|
281
369
|
// loadSpecs computes `driftFiles` via driftFor() over the one cached driftIndex walk (git.ts): a
|
|
282
370
|
// commit to the file counts iff it is NOT reachable from the spec's latest version (in-memory
|
|
283
371
|
// parent-edge reachability, the equivalent of `rev-list <version>..HEAD -- <file>`), never a
|
|
284
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.
|
|
285
376
|
for (const s of specs) {
|
|
286
|
-
|
|
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
|
|
287
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
|
+
}
|
|
288
382
|
}
|
|
289
383
|
|
|
290
384
|
// related drift: the SOFT tier ([[governed-related]]). A referenced file moved ahead of the node's
|
|
@@ -321,22 +415,3 @@ Diagnose, then apply the one honest remedy:
|
|
|
321
415
|
maps to a node, or file an issue and link it (defer honestly)
|
|
322
416
|
|
|
323
417
|
Never patch. A reasoned ack or a real fix are recorded and re-judged at review; a blind ack is a lie.`
|
|
324
|
-
|
|
325
|
-
// commit-local: an empty staged index (CI, audit) → no blockers, drift stays advisory; non-empty → block
|
|
326
|
-
// only when an OWN staged file belongs to a node already >= driftErrorThreshold behind. Sub-threshold drift
|
|
327
|
-
// on a touched node is returned for an advisory nudge; the backlog on untouched nodes never blocks.
|
|
328
|
-
export async function driftGate(): Promise<{ blocked: string[]; touched: { id: string; drift: number }[]; threshold: number }> {
|
|
329
|
-
const root = repoRoot()
|
|
330
|
-
const cfg = loadConfig(root)
|
|
331
|
-
const staged = stagedFiles(root)
|
|
332
|
-
if (!staged.length) return { blocked: [], touched: [], threshold: cfg.driftErrorThreshold }
|
|
333
|
-
const specs = await loadSpecs()
|
|
334
|
-
const owners = new Map<string, string[]>()
|
|
335
|
-
for (const s of specs) for (const f of s.code) owners.set(f, [...(owners.get(f) ?? []), s.id])
|
|
336
|
-
const byId = new Map(specs.map((s) => [s.id, s]))
|
|
337
|
-
const ids = new Set<string>()
|
|
338
|
-
for (const f of staged) for (const o of owners.get(f) ?? []) ids.add(o)
|
|
339
|
-
const touched = [...ids].map((id) => byId.get(id)!).filter((s) => s && s.drift > 0)
|
|
340
|
-
.map((s) => ({ id: s.id, drift: s.drift })).sort((a, b) => b.drift - a.drift)
|
|
341
|
-
return { blocked: touched.filter((t) => t.drift >= cfg.driftErrorThreshold).map((t) => t.id), touched, threshold: cfg.driftErrorThreshold }
|
|
342
|
-
}
|
|
@@ -272,6 +272,25 @@ function writeStoreFile(p: Issue, message: string): boolean {
|
|
|
272
272
|
return true
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
// @@@ the shared trunk-data commit — the committing half of the store discipline, exported for OTHER
|
|
276
|
+
// git-tracked data writes that need a durable landing with no worktree ritual behind them (the eval
|
|
277
|
+
// sidecar's human-ok, [[human-ok]]). Same rules as the issue store's own write: only the trunk checkout
|
|
278
|
+
// itself commits (a linked-worktree caller gets 'not-primary' back and leaves its append for its own
|
|
279
|
+
// session's ritual commit — the sidecar, unlike the issue store, legitimately lives per-branch and
|
|
280
|
+
// merges); the commit is `--no-verify` and provably scoped to the one data path; the shared store lock
|
|
281
|
+
// serializes it against every other trunk data write so racing writers never fight over the git index.
|
|
282
|
+
export function commitTrunkData(relPath: string, message: string): 'committed' | 'no-op' | 'not-primary' {
|
|
283
|
+
if (overrideStoreDir()) return 'not-primary' // a disposable-store test rig commits nothing, ever
|
|
284
|
+
if (!isPrimaryCheckout()) return 'not-primary'
|
|
285
|
+
return withStoreLock(() => {
|
|
286
|
+
const root = mainCheckout()
|
|
287
|
+
git(['-C', root, 'add', '--', relPath])
|
|
288
|
+
if (!git(['-C', root, 'status', '--porcelain', '--', relPath]).trim()) return 'no-op'
|
|
289
|
+
git(['-C', root, 'commit', '--no-verify', '-m', message, '--', relPath])
|
|
290
|
+
return 'committed'
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
275
294
|
// prepare (a FRESH read-modify or a new thread) + write + commit a single store file, all under the store
|
|
276
295
|
// lock so the read-modify-write is atomic. prepare() runs INSIDE the lock, so a reply/close reads the
|
|
277
296
|
// current thread, never a stale copy. A pre-rename store migrates first (before the lock — ensure takes it).
|
|
@@ -6,11 +6,9 @@
|
|
|
6
6
|
export type MigrateAsset = { rel: string; template: string; oldShas: string[] }
|
|
7
7
|
export const TEMPLATE_ASSETS: MigrateAsset[] = [
|
|
8
8
|
{
|
|
9
|
-
"rel": "
|
|
10
|
-
"template": "templates/
|
|
11
|
-
"oldShas": [
|
|
12
|
-
"a1b9c7e189df9b75a27005d381b43682d717ee6f"
|
|
13
|
-
]
|
|
9
|
+
"rel": "commands/spec.md",
|
|
10
|
+
"template": "templates/spec/project/.plugins/commands/spec.md",
|
|
11
|
+
"oldShas": []
|
|
14
12
|
},
|
|
15
13
|
{
|
|
16
14
|
"rel": "core/idle/idle.sh",
|
|
@@ -162,7 +160,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
|
|
|
162
160
|
},
|
|
163
161
|
{
|
|
164
162
|
"rel": "distill/digest.mjs",
|
|
165
|
-
"template": "templates/spec/project/.plugins/distill/digest.mjs",
|
|
163
|
+
"template": "templates/spec/project/.plugins/skills/distill/digest.mjs",
|
|
166
164
|
"oldShas": [
|
|
167
165
|
"b596d196d35688130157698ca90950d412bf527f",
|
|
168
166
|
"faff3509b004e8529d5c7f3abd29512fb389ceb9"
|
|
@@ -170,7 +168,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
|
|
|
170
168
|
},
|
|
171
169
|
{
|
|
172
170
|
"rel": "distill/spec.md",
|
|
173
|
-
"template": "templates/spec/project/.plugins/distill/spec.md",
|
|
171
|
+
"template": "templates/spec/project/.plugins/skills/distill/spec.md",
|
|
174
172
|
"oldShas": [
|
|
175
173
|
"3e3fd25db1d465f72b636e6af8bcd873c0ceb3c2",
|
|
176
174
|
"4a0b547ad63e15aa80ed7f826581ad90ca02d0f4",
|
|
@@ -179,7 +177,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
|
|
|
179
177
|
},
|
|
180
178
|
{
|
|
181
179
|
"rel": "extract/spec.md",
|
|
182
|
-
"template": "templates/spec/project/.plugins/extract/spec.md",
|
|
180
|
+
"template": "templates/spec/project/.plugins/commands/extract/spec.md",
|
|
183
181
|
"oldShas": [
|
|
184
182
|
"064efd8321925bd0cff7ee725771b500a7baf6e7",
|
|
185
183
|
"45c2b0fe12224ebc124b5f5fecafd1e8d82de93e",
|
|
@@ -196,7 +194,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
|
|
|
196
194
|
},
|
|
197
195
|
{
|
|
198
196
|
"rel": "forge-link/spec.md",
|
|
199
|
-
"template": "templates/spec/project/.plugins/forge-link/spec.md",
|
|
197
|
+
"template": "templates/spec/project/.plugins/prompts/forge-link/spec.md",
|
|
200
198
|
"oldShas": [
|
|
201
199
|
"2472c085e6f72364eea004ba1d1b1f8d6b4e13ac",
|
|
202
200
|
"c86439c5a3fe6cb3ece2c40186da0f476d1c52d4"
|
|
@@ -204,14 +202,19 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
|
|
|
204
202
|
},
|
|
205
203
|
{
|
|
206
204
|
"rel": "memory-hygiene/spec.md",
|
|
207
|
-
"template": "templates/spec/project/.plugins/memory-hygiene/spec.md",
|
|
205
|
+
"template": "templates/spec/project/.plugins/prompts/memory-hygiene/spec.md",
|
|
208
206
|
"oldShas": [
|
|
209
207
|
"2b5313e770c7d2e857817adcf7bb72aecaeca45f"
|
|
210
208
|
]
|
|
211
209
|
},
|
|
210
|
+
{
|
|
211
|
+
"rel": "prompts/spec.md",
|
|
212
|
+
"template": "templates/spec/project/.plugins/prompts/spec.md",
|
|
213
|
+
"oldShas": []
|
|
214
|
+
},
|
|
212
215
|
{
|
|
213
216
|
"rel": "regroup/spec.md",
|
|
214
|
-
"template": "templates/spec/project/.plugins/regroup/spec.md",
|
|
217
|
+
"template": "templates/spec/project/.plugins/commands/regroup/spec.md",
|
|
215
218
|
"oldShas": [
|
|
216
219
|
"37f632450faba3e9c5290468b52535e766faf45e",
|
|
217
220
|
"aa3097a1b7ff51c4a8a0bfab82ac06a585081caa",
|
|
@@ -220,7 +223,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
|
|
|
220
223
|
},
|
|
221
224
|
{
|
|
222
225
|
"rel": "reproduce-before-fix/spec.md",
|
|
223
|
-
"template": "templates/spec/project/.plugins/reproduce-before-fix/spec.md",
|
|
226
|
+
"template": "templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md",
|
|
224
227
|
"oldShas": [
|
|
225
228
|
"28eef30893a83b634900e43dfe729043c8f60ea8",
|
|
226
229
|
"2d056b7344cbc0c4eb0dbeb0ba69ccee0e8b47db",
|
|
@@ -229,6 +232,11 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
|
|
|
229
232
|
"fc3c060af70b01b2073163880a42d002e285ddc5"
|
|
230
233
|
]
|
|
231
234
|
},
|
|
235
|
+
{
|
|
236
|
+
"rel": "skills/spec.md",
|
|
237
|
+
"template": "templates/spec/project/.plugins/skills/spec.md",
|
|
238
|
+
"oldShas": []
|
|
239
|
+
},
|
|
232
240
|
{
|
|
233
241
|
"rel": "spec.md",
|
|
234
242
|
"template": "templates/spec/project/.plugins/spec.md",
|
|
@@ -249,7 +257,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
|
|
|
249
257
|
},
|
|
250
258
|
{
|
|
251
259
|
"rel": "supervisor/spec.md",
|
|
252
|
-
"template": "templates/spec/project/.plugins/supervisor/spec.md",
|
|
260
|
+
"template": "templates/spec/project/.plugins/commands/supervisor/spec.md",
|
|
253
261
|
"oldShas": [
|
|
254
262
|
"036d5602a038e2565cd968e4a8a443ce80026025",
|
|
255
263
|
"05963c00bc1031ba0d4f91ea68d60b1731f475f2",
|
|
@@ -271,7 +279,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
|
|
|
271
279
|
},
|
|
272
280
|
{
|
|
273
281
|
"rel": "tidy/spec.md",
|
|
274
|
-
"template": "templates/spec/project/.plugins/tidy/spec.md",
|
|
282
|
+
"template": "templates/spec/project/.plugins/commands/tidy/spec.md",
|
|
275
283
|
"oldShas": [
|
|
276
284
|
"096fc1dd48a1692aeb87614a93bcce2473f2050a",
|
|
277
285
|
"16ccbf044e74765a5c9cc212fba38356950a7e69",
|
|
@@ -286,6 +294,9 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
|
|
|
286
294
|
]
|
|
287
295
|
// old-world template assets the shipped package no longer carries (recognized → reported, not replaced):
|
|
288
296
|
export const RETIRED_ASSETS: Record<string, string[]> = {
|
|
297
|
+
"clarify-before-code/spec.md": [
|
|
298
|
+
"a1b9c7e189df9b75a27005d381b43682d717ee6f"
|
|
299
|
+
],
|
|
289
300
|
"core/harness-session-id/harness-session-id.sh": [
|
|
290
301
|
"32b3b963ba6c2c659f0543e82a2a34128c4860bf",
|
|
291
302
|
"626d7f3c56d6430c5dafb7713880a606b4ee8b03"
|
|
@@ -296,11 +307,11 @@ export const RETIRED_ASSETS: Record<string, string[]> = {
|
|
|
296
307
|
"c05f182fc92fcd224f4c7953dc118e59665971aa",
|
|
297
308
|
"c617fc05780f921c55280b68d9f55e60110f74eb"
|
|
298
309
|
],
|
|
299
|
-
"core/stop-gate/yatsu.evals.ndjson": [
|
|
310
|
+
"core/stop-gate/yatsu.evals.ndjson": [
|
|
300
311
|
"268c06c27f3b2dc71da8c0aca4d0507a227829f9",
|
|
301
312
|
"e681fe71b8519577c19bb4f13bc0f310fb92dc31"
|
|
302
313
|
],
|
|
303
|
-
"core/stop-gate/yatsu.md": [
|
|
314
|
+
"core/stop-gate/yatsu.md": [
|
|
304
315
|
"f83f6b2406eb359beb1181bf0145558241806116"
|
|
305
316
|
],
|
|
306
317
|
"e2e-review/spec.md": [
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Labels are node LEAF names, matched with the same de-collision rule the loader applies (specs.ts reId):
|
|
5
5
|
// a returned id matches a label if it IS the label or ends with `_<label>` — so a bare leaf keeps matching
|
|
6
|
-
// after collision-qualification renames it (e.g. `
|
|
6
|
+
// after collision-qualification renames it (e.g. a colliding `probe` leaf → `a_probe`). A label may
|
|
7
7
|
// also be written pre-qualified to pin one collision branch.
|
|
8
8
|
import { execFileSync } from 'node:child_process'
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
@@ -26,7 +26,7 @@ const CASES = [
|
|
|
26
26
|
['read-before-code', 'the one-shot nudge that makes an agent read its spec before touching code', ['spec-first']],
|
|
27
27
|
['hot-reload', 'zero-downtime backend reload without dropping connections', ['supervisor']],
|
|
28
28
|
['many-owners', 'can several specs own the same code file, and what happens if too many do?', ['governed-related']],
|
|
29
|
-
['active-spec-search', 'an injected sub-agent that searches specs for the agent, the spec analog of Explore', ['spec-
|
|
29
|
+
['active-spec-search', 'an injected sub-agent that searches specs for the agent, the spec analog of Explore', ['spec-search']],
|
|
30
30
|
['declare-done', 'how does a worker declare it is done', ['state']],
|
|
31
31
|
]
|
|
32
32
|
|
|
@@ -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
|
|