spexcode 0.5.7 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/package.json +5 -3
  2. package/spec-cli/bin/spex.mjs +1 -13
  3. package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  4. package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
  5. package/spec-cli/hooks/dispatch.sh +15 -19
  6. package/spec-cli/src/anchors.ts +199 -60
  7. package/spec-cli/src/attach.ts +7 -17
  8. package/spec-cli/src/cli.ts +144 -225
  9. package/spec-cli/src/client.ts +114 -132
  10. package/spec-cli/src/cockpit.ts +43 -0
  11. package/spec-cli/src/codex-runtime-generations.ts +556 -0
  12. package/spec-cli/src/commit-surgery.ts +0 -14
  13. package/spec-cli/src/contract-filter.ts +0 -14
  14. package/spec-cli/src/doctor.ts +0 -16
  15. package/spec-cli/src/gateway-auth.ts +0 -13
  16. package/spec-cli/src/gateway.ts +0 -22
  17. package/spec-cli/src/git.ts +151 -48
  18. package/spec-cli/src/graph.ts +28 -7
  19. package/spec-cli/src/graphCache.ts +253 -72
  20. package/spec-cli/src/graphDelta.ts +0 -13
  21. package/spec-cli/src/graphStream.ts +70 -37
  22. package/spec-cli/src/guide.ts +29 -6
  23. package/spec-cli/src/harness.ts +232 -248
  24. package/spec-cli/src/help.ts +27 -26
  25. package/spec-cli/src/host-resources.ts +11 -2
  26. package/spec-cli/src/index.ts +70 -95
  27. package/spec-cli/src/issues-cli.ts +276 -0
  28. package/spec-cli/src/issues.ts +15 -197
  29. package/spec-cli/src/layout.ts +78 -2
  30. package/spec-cli/src/lint.ts +42 -30
  31. package/spec-cli/src/localIssues.ts +22 -185
  32. package/spec-cli/src/loop-in.ts +78 -0
  33. package/spec-cli/src/materialize.ts +0 -42
  34. package/spec-cli/src/mentions.ts +49 -193
  35. package/spec-cli/src/opencode-headless.ts +7 -7
  36. package/spec-cli/src/opencode.ts +2 -5
  37. package/spec-cli/src/pi-headless.ts +5 -5
  38. package/spec-cli/src/pty-bridge.ts +4 -5
  39. package/spec-cli/src/reviewSnapshot.ts +1 -0
  40. package/spec-cli/src/reviews.ts +6 -1
  41. package/spec-cli/src/root-lru.ts +54 -0
  42. package/spec-cli/src/runtime-guard.ts +0 -9
  43. package/spec-cli/src/session-cursors.ts +97 -0
  44. package/spec-cli/src/session-follow.ts +182 -0
  45. package/spec-cli/src/session-timeline.ts +55 -127
  46. package/spec-cli/src/sessions.ts +207 -1022
  47. package/spec-cli/src/shim-runtime.ts +9 -16
  48. package/spec-cli/src/specs.ts +33 -10
  49. package/spec-cli/src/uninstall.ts +0 -9
  50. package/spec-cli/src/uploads.ts +240 -18
  51. package/spec-cli/src/worktree-sources.ts +0 -16
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
  54. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
  56. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  57. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
  58. package/spec-cli/templates/spexcode.json +12 -0
  59. package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
  60. package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
  61. package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
  62. package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
  63. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
  64. package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
  65. package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
  66. package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
  67. package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
  68. package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
  69. package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
  70. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
  71. package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
  72. package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
  73. package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
  74. package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
  75. package/spec-dashboard/dist/index.html +2 -2
  76. package/spec-dashboard/src/session.js +1 -28
  77. package/spec-eval/src/cache.ts +0 -14
  78. package/spec-eval/src/cli.ts +26 -5
  79. package/spec-eval/src/evaltab.ts +67 -108
  80. package/spec-eval/src/filing.ts +0 -18
  81. package/spec-eval/src/freshness.ts +78 -32
  82. package/spec-eval/src/humanok.ts +0 -15
  83. package/spec-eval/src/scenariofresh.ts +8 -80
  84. package/spec-eval/src/scenarios.ts +16 -67
  85. package/spec-eval/src/sessioneval.ts +157 -78
  86. package/spec-eval/src/sidecar.ts +1 -70
  87. package/spec-eval/src/timeline.ts +0 -23
  88. package/spec-forge/src/cache.ts +34 -12
  89. package/spec-forge/src/cli.ts +0 -6
  90. package/spec-forge/src/drivers/github.ts +1 -12
  91. package/spec-forge/src/drivers/gitlab.ts +4 -19
  92. package/spec-forge/src/drivers.ts +0 -18
  93. package/spec-forge/src/links.ts +0 -4
  94. package/spec-forge/src/needs-eval.ts +0 -1
  95. package/spec-forge/src/port.ts +0 -12
  96. package/spec-forge/src/resident.ts +4 -20
  97. package/spec-cli/src/maintenance-wrapper.ts +0 -276
  98. package/spec-cli/src/session-maintenance.ts +0 -751
  99. package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
  100. package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
  101. package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
  102. package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
  103. /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
@@ -17,27 +17,6 @@ export type MaterializedArtifact = {
17
17
  }
18
18
  export type MaterializeResult = { contentHash: string; planted: MaterializedArtifact[] }
19
19
 
20
- // @@@ materialize - the materialize step (≈0.85s), anchored on GIT-NATIVE events only ([[commit-surgery]]):
21
- // spex verbs (init/materialize), session-worktree creation, and the planted git hooks (pre-commit,
22
- // post-checkout, post-merge) — never a harness event; the harness is a READER of the materialized files, not
23
- // a trigger. It turns the spec tree's surface nodes into the flat artifacts each consumer reads
24
- // cheaply, so a USER-self-launched claude/codex (no SpexCode process in the launch) gets the whole system via
25
- // harness-auto-discovered files: (1) the hook MANIFEST (our dispatcher reads it), (2) the CONTRACT — the
26
- // surface:system plugin bodies (in name order) — written WHOLE into each
27
- // harness's contract file(s), (3) the thin SHIMS (every event → dispatch.sh), (4) the per-harness TRUST
28
- // (Codex's deterministic trusted_hash; Claude none). EVERY harness-specific fact is owned by the
29
- // [[harness-adapter]] (harness.ts) — this file just loops over HARNESSES.
30
- //
31
- // THE FORGETTING LAW ([[harness-delivery]]): materialize(P₂) ∘ materialize(P₁) = materialize(P₂) — one pass
32
- // under the current policy fully forgets every prior policy's artifacts; idempotence is the special case
33
- // P₂ = P₁, and dematerialize (= materialize(∅), what `spex uninstall` builds on) is the empty policy. The
34
- // implementation is ERASE-THEN-ASSERT over a CLOSED set of landing points: each is first erased
35
- // unconditionally by its IDENTITY STAMP (sentinel blocks, the shim's dispatch.sh command line, the generated
36
- // mark on skills/agents, the filter config namespace, the skip-worktree bit), then rewritten per the current
37
- // policy (possibly to nothing). There are no policy-pair branches. The one cross-tree migration receipt below
38
- // preserves old common ignore entries until every registered tree owns its local projection; it never reads or
39
- // reconstructs a sibling policy.
40
-
41
20
  const PKG = fileURLToPath(new URL('..', import.meta.url)) // installed spec-cli root
42
21
  const DISPATCH = join(PKG, 'hooks', 'dispatch.sh')
43
22
  // the ONE spex entry: the launcher (bin/spex.mjs), never a raw `tsx cli.ts` pair — the launcher owns tsx
@@ -62,14 +41,6 @@ export function contentHash(proj: string): string {
62
41
  } catch { return '' }
63
42
  }
64
43
 
65
- // @@@ footprint kinds ([[residence]]) - the vote axis is RETIRED: materialized artifacts carry no facts, so
66
- // they are NEVER tracked — there is exactly ONE residence behavior, not three. `.spec` + `spexcode.json` are ALWAYS
67
- // tracked (git is the database — no knob can untrack them); machine facts (shims, spexcode.local.json),
68
- // run residue (.worktrees/) stays in the common exclude; tree-selected artifacts are hidden by a managed
69
- // working .gitignore block whose tracked bytes stay pristine through the content filter. A contract file the host TRACKS — or one the user has begun
70
- // writing THEIR OWN prose into — is covered by the clean/smudge content filter ([[content-filter]]). An
71
- // environment without the generator (a teammate's clone, CI, a cloud agent) runs `spex materialize` in its
72
- // setup step — there is no committed-artifact delivery mode.
73
44
  export function retiredAxisNotice(cfg: { render?: string; private?: boolean }): void {
74
45
  if (!cfg.render?.trim() && !cfg.private) return
75
46
  const field = cfg.render?.trim() ? `"render": "${cfg.render.trim()}"` : '"private": true'
@@ -123,12 +94,6 @@ function publishSelection(path: string, body: string): void {
123
94
  renameSync(prepared, path)
124
95
  }
125
96
 
126
- // @@@ contract kind detection ([[residence]]) - a contract file's residence is a LIVE CONTENT FACT, not
127
- // an install-time choice, re-judged on every materialize: TRACKED → filter domain; untracked + wholly ours
128
- // (nothing left after stripping our sentinel block) → exclude domain; untracked + HOST CONTENT present (the
129
- // user began writing their own prose into it) → neither hidden nor tracked-for-them: the exclude entry is
130
- // withheld (hiding user content would make their prose invisible to git — data-loss shaped) and the clean
131
- // filter is pre-armed so their eventual, entirely-their-own `git add` strips our block automatically.
132
97
  const SENTINEL_RE = /\n*<!-- spexcode:start -->[\s\S]*?<!-- spexcode:end -->\n*/
133
98
  export function stripSpexcodeBlock(text: string): string {
134
99
  const m = SENTINEL_RE.exec(text)
@@ -170,13 +135,6 @@ function sweepGeneratedAgents(dir: string | null): void {
170
135
  }
171
136
  }
172
137
 
173
- // @@@ dematerialize - materialize(∅): the ERASE phase, also the whole of a backout ([[spex-uninstall]] adds
174
- // only the global store + plugin sweep + optional git hooks on top). Every removal is gated on an identity
175
- // stamp, so it deletes ONLY what a materialize wrote — never the user's prose, settings, or any .spec data. Order
176
- // matters once: the managed blocks leave the WORKING contract files before the content filter's config goes
177
- // (edge ③ in [[content-filter]] — a block outliving its clean filter surfaces as an uncommitted change).
178
- // `arts` (live skill/agent node names) widens the sweep to pre-stamp legacy files; the GENERATED_MARK sweep
179
- // covers everything materialized since, including products of renamed/deleted nodes.
180
138
  function eraseTree(proj: string, arts: HarnessArtifacts, preserveProject: boolean): void {
181
139
  for (const h of HARNESSES) {
182
140
  // h.clean = the adapter's surgical inverse: contract block (sentinels, deleteIfEmpty), the dispatch.sh-
@@ -1,225 +1,81 @@
1
- // @@@ mentions - the two universal in-text reference primitives ([[mentions]]): `[[node]]` (a TOPIC — a spec
2
- // node) and `@session` (an ACTOR — a live session, or `@new` for a fresh worker). The same parser resolves
3
- // them in ANY input box; the resolve+dispatch live HERE (CLI-first) so the issues page, the composer, and an agent's
4
- // own prompt share one implementation. An `@` "just auto-sends a prompt": resolve it against the live board
5
- // sessions and dispatch via [[dispatch]]'s sendText / [[launch]]'s bounded create owner — storage and delivery stay
6
- // separate, and sessions.ts is imported LAZILY so a mention-free post pays nothing.
7
-
8
- // ── parse (pure) ──────────────────────────────────────────────────────────────────────────────────────
9
- // `@<token>` at a word boundary is an actor; `[[<id>]]` is a topic. Token chars are any unicode
10
- // letter/number plus [_-] (a CJK session handle or node id is first-class — same charset the launch-side
11
- // MENTION and the dashboard's MENTION_RE use); a session id, a short label/prefix, or the literal `new`.
12
- // Only `new` may carry `:<launcher>` ([[launcher-select]]); keeping that choice in the token makes the same
13
- // prose work from the CLI and dashboard, with no composer-only field. Both forms are deduped first-seen.
14
- const ACTOR_RE = /(?:^|\s)@([\p{L}\p{N}_-]+)(?::([\p{L}\p{N}_.-]+))?/gu
1
+ // @@@ mentions - the two passive in-text references: `[[node]]` names a topic and
2
+ // `@session` names a retained session. The grammar is shared by every composer; neither
3
+ // reference is control. Sending to a session, launching one, and inheriting one remain
4
+ // explicit `spex session send`, `spex session new`, and `/distill <id>` actions.
5
+ const SESSION_RE = /(?:^|\s)@([\p{L}\p{N}_-]+(?::[\p{L}\p{N}_.-]+)?)/gu
15
6
  const NODE_RE = /\[\[([^\]\s]+)\]\]/g
16
7
 
17
8
  const uniq = (xs: string[]): string[] => [...new Set(xs)]
18
9
 
19
- // ── CLI sigil tolerance ───────────────────────────────────────────────────────────────────────────────
20
- // In FREE TEXT the sigils are required they are what marks a reference apart from prose. In a CLI
21
- // ARGUMENT the whole token IS the reference, so the sigil is optional: `spex session review @graph` ≡
22
- // `spex session review graph`, `spex eval add [[cli-surface]]` ≡ `spex eval add cli-surface`. One shared strip,
23
- // applied by the session-selector matcher and every node-arg read site, so the habit a user learns in the
24
- // dashboard's input boxes works verbatim on the CLI — never a second grammar to learn.
10
+ // In free text a sigil separates a reference from prose. A CLI argument is already a
11
+ // reference, so it tolerates either sigil without widening its normal matching grammar.
25
12
  export function stripRefSigil(token: string): string {
26
13
  const wrapped = /^\[\[(.*)\]\]$/.exec(token)
27
14
  if (wrapped) return wrapped[1]
28
15
  return token.startsWith('@') ? token.slice(1) : token
29
16
  }
30
17
 
31
- export function parseMentions(text: string): { actors: string[]; nodes: string[] } {
32
- const actors: string[] = []
18
+ export function parseMentions(text: string): { sessions: string[]; nodes: string[] } {
19
+ const sessions: string[] = []
33
20
  const nodes: string[] = []
34
- for (const m of text.matchAll(ACTOR_RE)) actors.push(m[1] === 'new' && m[2] ? `new:${m[2]}` : m[1])
21
+ for (const m of text.matchAll(SESSION_RE)) sessions.push(m[1])
35
22
  for (const m of text.matchAll(NODE_RE)) nodes.push(m[1])
36
- return { actors: uniq(actors), nodes: uniq(nodes) }
37
- }
38
-
39
- // ── resolve actors against live sessions (pure) ───────────────────────────────────────────────────────
40
- // A minimal shape of what dispatchMentions needs from a Session (so this stays testable without sessions.ts).
41
- export type ActorSession = { id: string; node: string | null; name: string | null; title: string | null; liveness: string }
42
- export type Resolved =
43
- | { token: string; kind: 'new'; launcher?: string }
44
- | { token: string; kind: 'session'; session: ActorSession }
45
- | { token: string; kind: 'unresolved' }
46
-
47
- // Resolve each actor token to a `new` sentinel (optionally carrying its explicit launcher), an ONLINE
48
- // session (never a dead one — you don't summon a closed agent; `@new` acts on its behalf), or unresolved.
49
- // Match order: new/new:<launcher> → exact id → id prefix → name/title (case-insensitive) → name/title prefix.
50
- // First online match wins.
51
- export function resolveActors(tokens: string[], sessions: ActorSession[]): Resolved[] {
52
- const online = sessions.filter((s) => s.liveness === 'online')
53
- return tokens.map((token): Resolved => {
54
- const fresh = /^new(?::(.+))?$/.exec(token)
55
- if (fresh) return { token, kind: 'new', ...(fresh[1] ? { launcher: fresh[1] } : {}) }
56
- const t = token.toLowerCase()
57
- const label = (s: ActorSession) => (s.name || s.title || '').toLowerCase()
58
- const hit =
59
- online.find((s) => s.id === token) ||
60
- online.find((s) => s.id.startsWith(token)) ||
61
- online.find((s) => label(s) === t) ||
62
- online.find((s) => label(s).startsWith(t) && t.length >= 2)
63
- return hit ? { token, kind: 'session', session: hit } : { token, kind: 'unresolved' }
64
- })
23
+ return { sessions: uniq(sessions), nodes: uniq(nodes) }
65
24
  }
66
25
 
67
- // Any spawn's parent = its originator ([[session-nesting]]): the `@new` worker nests under the session that
68
- // wrote the mention but ONLY when the author IS a real board session id. A dashboard 'human', a CLI
69
- // 'unknown', or a forge login resolves to no session null a top-level worker, never a phantom nest.
70
- // Exact id match only (lineage is provenance, not addressing — no prefix/name resolution), any liveness:
71
- // a parent that later closes is auto-promoted at read time by the derived tree.
72
- export function spawnParent(author: string, sessions: { id: string }[]): string | null {
73
- return sessions.some((s) => s.id === author) ? author : null
26
+ // The originator courtesy is the only automatic notification left in this module. It
27
+ // resolves explicit stored session ids against online rows; prose @ references never call it.
28
+ type Session = { id: string; node: string | null; name: string | null; title: string | null; liveness: string }
29
+
30
+ function resolveOnlineSession(token: string, sessions: Session[]): Session | null {
31
+ const online = sessions.filter((session) => session.liveness === 'online')
32
+ const text = token.toLowerCase()
33
+ const label = (session: Session) => (session.name || session.title || '').toLowerCase()
34
+ return online.find((session) => session.id === token)
35
+ || online.find((session) => session.id.startsWith(token))
36
+ || online.find((session) => label(session) === text)
37
+ || online.find((session) => label(session).startsWith(text) && text.length >= 2)
38
+ || null
74
39
  }
75
40
 
76
- // ── dispatch (integration) ─────────────────────────────────────────────────────────────────────────────
77
- export type DispatchOutcome = { token: string; result: 'sent' | 'spawned' | 'offline' | 'unresolved' | 'failed'; detail?: string; note?: string }
78
-
79
- // The prompt an @-mentioned actor receives: the comment verbatim + a pointer back to the thread. It is a
80
- // nudge to look, never a rigid command — what to do is up to the comment's words.
81
- function mentionPrompt(threadId: string, node: string | null, author: string, text: string): string {
82
- const re = node ? ` (re: ${node})` : ''
83
- return `You were @-mentioned in issue thread "${threadId}"${re} by ${author}:\n\n ${text.trim()}\n\n` +
84
- `Read the thread and act as the comment asks (often just a look): \`spex issue ls --all\` lists them; ` +
85
- `reply with \`spex issue reply ${threadId} --body -\`.`
86
- }
87
- // A non-open thread is settled work: a fresh worker spawned onto it must not re-implement what already
88
- // landed, so the prompt leads with the status and a verify-on-main-first instruction.
89
- export function newWorkerPrompt(threadId: string, node: string | null, author: string, text: string, status?: string | null): string {
90
- // Keep inherited scope inside the text the worker receives: the create transaction derives its node only from the
91
- // raw prompt's first [[id]] mention, so issue dispatch gets no private node-binding argument.
92
- const on = node ? ` on node [[${node}]]` : ''
93
- const settled = status && status !== 'open'
94
- ? `NOTE: this thread is already resolved (status: ${status}) — the work it describes has likely LANDED. ` +
95
- `Verify the current state on main FIRST; if main already satisfies the thread, reply with that finding ` +
96
- `instead of re-implementing.\n\n`
97
- : ''
98
- return `Issue thread "${threadId}"${on} @-mentioned @new (by ${author}) for a fresh look:\n\n ${text.trim()}\n\n` +
99
- settled +
100
- `Read the thread (\`spex issue ls --all\`, find ${threadId}) and act on it${node ? `; the relevant node is ${node}` : ''}.`
101
- }
41
+ export type LoopIn = { originator: string }
42
+ export type LoopInPick =
43
+ | { kind: 'deliver'; originator: string; session: Session }
44
+ | { kind: 'none' }
102
45
 
103
- // Parse a committed issue post's text for `@` actors and deliver to each. Best-effort and LOUD: the thread is
104
- // already committed, so a failed dispatch never fails the post it is reported. sessions.ts is imported
105
- // lazily; with no actor mentions this returns [] without loading it or hitting the backend.
106
- export async function dispatchMentions(
107
- text: string,
108
- ctx: { threadId: string; node: string | null; author: string; status?: string | null },
109
- ): Promise<DispatchOutcome[]> {
110
- const { actors } = parseMentions(text)
111
- if (!actors.length) return []
112
- const { sendText, listSessions, sessionCreateRequest } = await import('./sessions.js')
113
- const sessions = await listSessions()
114
- const resolved = resolveActors(actors, sessions as unknown as ActorSession[])
115
- const out: DispatchOutcome[] = []
116
- for (const r of resolved) {
117
- if (r.kind === 'unresolved') { out.push({ token: r.token, result: 'unresolved' }); continue }
118
- if (r.kind === 'new') {
119
- // the drain guard ([[mentions]]): @new on a settled thread still spawns (the summons may be a
120
- // deliberate audit/re-measure), but the worker prompt carries the status and the outcome line warns.
121
- const settled = ctx.status && ctx.status !== 'open' ? ctx.status : undefined
122
- try {
123
- const created = await sessionCreateRequest({
124
- prompt: newWorkerPrompt(ctx.threadId, ctx.node, ctx.author, text, ctx.status),
125
- parent: spawnParent(ctx.author, sessions),
126
- launcher: r.launcher,
127
- })
128
- if (created.status !== 201) throw new Error(`${created.code || 'session_create_failed'}: ${created.error}`)
129
- out.push({ token: r.token, result: 'spawned', detail: created.session.id, ...(settled ? { note: `thread ${settled}` } : {}) })
130
- } catch (e) { out.push({ token: r.token, result: 'failed', detail: e instanceof Error ? e.message : String(e) }) }
131
- continue
132
- }
133
- const res = await sendText(r.session.id, mentionPrompt(ctx.threadId, ctx.node, ctx.author, text), 'issues')
134
- out.push(res.ok ? { token: r.token, result: 'sent', detail: r.session.id }
135
- : { token: r.token, result: 'offline', detail: res.error })
46
+ // A committed reply can notify its originator as a courtesy. This is not a mention
47
+ // dispatch or assignment, and it never creates a worker or retries an offline session.
48
+ export function pickLoopIn(chain: (string | null)[], replier: string, sessions: Session[]): LoopInPick {
49
+ const seen = new Set<string>()
50
+ for (const originator of chain) {
51
+ if (!originator || originator === replier || seen.has(originator)) continue
52
+ seen.add(originator)
53
+ const session = resolveOnlineSession(originator, sessions)
54
+ if (session) return { kind: 'deliver', originator, session }
136
55
  }
137
- return out
56
+ return { kind: 'none' }
138
57
  }
139
58
 
140
- // ── implicit originator loop-in + the dispatch fallback chain ([[mentions]] / [[remark-substrate]] R3) ────
141
- // A committed reply is ALSO auto-delivered as a COURTESY — never an assignment — to a FALLBACK CHAIN of
142
- // candidates, in order, stopping at the FIRST one that can be reached: for a remark this is the reading's
143
- // filer session, then the node's governing session, then nobody (it still surfaces on the board via the
144
- // teeth). This is a NOTIFICATION chain only — it resolves NOTHING (resolve stays a deliberate `spex remark resolve`,
145
- // R3); it just reaches an agent who can act. It is the same delivery pipe as dispatchMentions (one
146
- // online-resolution + one sendText), with the same cuts that keep courtesy ≠ assignment: deliver ONLY to an
147
- // ONLINE session (an unreachable link is skipped for the next, NEVER spawns a worker, NEVER drains — only an
148
- // explicit @new spawns); SKIP a candidate that is the replier (no self-notify); a candidate already reached by
149
- // an explicit @-target of this same text counts as delivered, so the chain STOPS (no double-delivery, no
150
- // needless escalation). Store-agnostic: a forge issue's author is a github login, resolves to no live session,
151
- // and the chain runs dry silently — exactly right.
152
- export type LoopIn = { originator: string } // the candidate we actually reached (a filer OR a governing-session fallback)
153
-
154
- // The courtesy prompt — framed as a heads-up, never a command (that is what an @-mention's mentionPrompt is).
155
59
  function originatorPrompt(threadId: string, node: string | null, replier: string, text: string): string {
156
60
  const re = node ? ` (re: ${node})` : ''
157
- return `A new reply landed on a thread you originated "${threadId}"${re}, from ${replier}:\n\n ${text.trim()}\n\n` +
158
- `This is a courtesy heads-up (you started this thread), not an assignment. Look if it concerns you; ` +
159
- `\`spex issue ls --all\` lists them, reply with \`spex issue reply ${threadId} --body -\`.`
61
+ return `A new reply landed on a thread you originated - "${threadId}"${re}, from ${replier}:\n\n ${text.trim()}\n\n` +
62
+ `This is a courtesy heads-up, not an assignment. Look if it concerns you; ` +
63
+ `\`spex issue ls --all\` lists threads and \`spex issue reply ${threadId} --body -\` replies.`
160
64
  }
161
65
 
162
- // The pure fallback decision (testable without sessions.ts): walk the ordered chain (nulls/dupes/the-replier
163
- // pruned) and return the FIRST link that resolves to an online session — that is who the courtesy goes to. A
164
- // link already reached by an explicit @-target of this same text short-circuits to `reached` (stop, no
165
- // double-delivery — the actor already has it); an offline/absent link falls through to the next. `none` means
166
- // the chain ran dry (nobody online). This is the whole fallback logic; delivery is a thin sendText around it.
167
- export type LoopInPick =
168
- | { kind: 'deliver'; originator: string; session: ActorSession }
169
- | { kind: 'reached' }
170
- | { kind: 'none' }
171
- export function pickLoopIn(
172
- chain: (string | null)[],
173
- replier: string,
174
- sessions: ActorSession[],
175
- alreadyDelivered?: Set<string>,
176
- ): LoopInPick {
177
- const seen = new Set<string>()
178
- const candidates = chain.filter((c): c is string => !!c && c !== replier && !seen.has(c) && (seen.add(c), true))
179
- for (const originator of candidates) {
180
- const [resolved] = resolveActors([originator], sessions)
181
- if (resolved.kind !== 'session') continue // offline / no live session → try the next fallback link
182
- if (alreadyDelivered?.has(resolved.session.id)) return { kind: 'reached' } // already an explicit @-target: stop
183
- return { kind: 'deliver', originator, session: resolved.session }
184
- }
185
- return { kind: 'none' }
186
- }
187
-
188
- // `chain` is the ordered fallback list. We deliver the courtesy to the first online link and STOP; an
189
- // offline/failed link falls through to the next. NOTIFICATION ONLY — this never touches a `resolved` bit
190
- // (resolve is a deliberate `spex remark resolve`), never spawns (only `@new` spawns).
191
66
  export async function notifyOriginator(
192
67
  chain: (string | null)[],
193
68
  replier: string,
194
69
  text: string,
195
- ctx: { threadId: string; node: string | null; alreadyDelivered?: Set<string> },
70
+ ctx: { threadId: string; node: string | null },
196
71
  ): Promise<LoopIn | null> {
197
- const seen = new Set<string>()
198
- if (!chain.some((c) => c && c !== replier && !seen.has(c) && (seen.add(c), true))) return null // nothing to do → no session load
72
+ if (!chain.some((candidate) => candidate && candidate !== replier)) return null
199
73
  const { sendText, listSessions } = await import('./sessions.js')
200
- const pick = pickLoopIn(chain, replier, await listSessions() as unknown as ActorSession[], ctx.alreadyDelivered)
201
- if (pick.kind !== 'deliver') return null // reached via @ / nobody online → silent
202
- const res = await sendText(pick.session.id, originatorPrompt(ctx.threadId, ctx.node, replier, text), 'issues')
203
- return res.ok ? { originator: pick.originator } : null // a failed send behaves like offline: silent
74
+ const pick = pickLoopIn(chain, replier, await listSessions() as unknown as Session[])
75
+ if (pick.kind !== 'deliver') return null
76
+ const result = await sendText(pick.session.id, originatorPrompt(ctx.threadId, ctx.node, replier, text), 'issues')
77
+ return result.ok ? { originator: pick.originator } : null
204
78
  }
205
79
 
206
- // The set of session ids a dispatch ALREADY reached (sent or spawned) what the loop-in skips to avoid a
207
- // double-delivery to an originator who was also an explicit @-target.
208
- export function deliveredIds(outcomes: DispatchOutcome[]): Set<string> {
209
- return new Set(outcomes.filter((o) => (o.result === 'sent' || o.result === 'spawned') && o.detail).map((o) => o.detail!))
210
- }
211
-
212
- // A one-line human summary of what a dispatch did, for the CLI to echo after a post. The optional loop-in is
213
- // noted DISTINCT from the @-dispatch — a courtesy copy, not an assignment.
214
- export function summarize(outcomes: DispatchOutcome[], loopIn?: LoopIn | null): string {
215
- const parts: string[] = []
216
- if (outcomes.length) parts.push('@ ' + outcomes.map((o) => {
217
- if (o.result === 'sent') return `${o.token}→sent`
218
- if (o.result === 'spawned') return `${o.token}→${o.detail}${o.note ? ` ⚠ ${o.note} — likely already landed` : ''}`
219
- if (o.result === 'offline') return `${o.token}→offline (stored)`
220
- if (o.result === 'unresolved') return `${o.token}→? (no live session; stored)`
221
- return `${o.token}→failed (${o.detail})`
222
- }).join(' · '))
223
- if (loopIn) parts.push(`↩ looped in originator @${loopIn.originator} (online)`)
224
- return parts.join(' · ')
225
- }
80
+ export const summarizeLoopIn = (loopIn?: LoopIn | null): string =>
81
+ loopIn ? `looped in originator @${loopIn.originator} (online)` : ''
@@ -131,7 +131,7 @@ function turnExited(rec: HarnessDeliveryRecord, outcome: Extract<TurnOutcome, {
131
131
  const cas = outcome.casCode === 0 ? '' : `; error CAS reporter also exited with code ${outcome.casCode}`
132
132
  return {
133
133
  ok: false,
134
- error: `opencode-headless turn exited with code ${outcome.code} during startup for session ${rec.session}${cas} - prompt delivery FAILED`,
134
+ error: `opencode-headless turn exited with code ${outcome.code} during startup for session ${rec.session}${cas} - immediate wake failed`,
135
135
  }
136
136
  }
137
137
 
@@ -173,7 +173,7 @@ export async function spawnOpenCodeHeadlessTurn(
173
173
  const outcome = readTurnOutcome(outcomePath)
174
174
  if (outcome?.state === 'exit') return turnExited(rec, outcome)
175
175
  if (outcome?.state === 'invalid') {
176
- return { ok: false, error: `opencode-headless turn for session ${rec.session} wrote an invalid exit outcome - prompt delivery NOT confirmed` }
176
+ return { ok: false, error: `opencode-headless turn for session ${rec.session} wrote an invalid exit outcome - immediate wake not confirmed` }
177
177
  }
178
178
  if (Date.now() >= deadline) break
179
179
  await sleep(Math.min(OUTCOME_POLL_MS, deadline - Date.now()))
@@ -181,19 +181,19 @@ export async function spawnOpenCodeHeadlessTurn(
181
181
 
182
182
  const first = readTurnOutcome(outcomePath)
183
183
  if (first?.state === 'exit') return turnExited(rec, first)
184
- if (!first) return { ok: false, error: `opencode-headless turn for session ${rec.session} never confirmed startup - prompt delivery FAILED` }
185
- if (first.state === 'invalid') return { ok: false, error: `opencode-headless turn for session ${rec.session} wrote an invalid exit outcome - prompt delivery NOT confirmed` }
184
+ if (!first) return { ok: false, error: `opencode-headless turn for session ${rec.session} never confirmed startup - immediate wake failed` }
185
+ if (first.state === 'invalid') return { ok: false, error: `opencode-headless turn for session ${rec.session} wrote an invalid exit outcome - immediate wake not confirmed` }
186
186
  if (first.state === 'reporting') {
187
- return { ok: false, error: `opencode-headless turn exited with code ${first.code} but its error CAS reporter did not finish for session ${rec.session} - prompt delivery FAILED` }
187
+ return { ok: false, error: `opencode-headless turn exited with code ${first.code} but its error CAS reporter did not finish for session ${rec.session} - immediate wake failed` }
188
188
  }
189
189
  if (!pidAlive(first.pid)) {
190
- return { ok: false, error: `opencode-headless turn wrapper died before reporting an outcome for session ${rec.session} - prompt delivery FAILED` }
190
+ return { ok: false, error: `opencode-headless turn wrapper died before reporting an outcome for session ${rec.session} - immediate wake failed` }
191
191
  }
192
192
  await sleep(OUTCOME_POLL_MS)
193
193
  const settled = readTurnOutcome(outcomePath)
194
194
  if (settled?.state === 'exit') return turnExited(rec, settled)
195
195
  if (settled?.state !== 'running' || settled.pid !== first.pid || !pidAlive(settled.pid)) {
196
- return { ok: false, error: `opencode-headless turn did not remain live through startup for session ${rec.session} - prompt delivery FAILED` }
196
+ return { ok: false, error: `opencode-headless turn did not remain live through startup for session ${rec.session} - immediate wake failed` }
197
197
  }
198
198
  return { ok: true }
199
199
  } catch (error) {
@@ -92,11 +92,8 @@ export const SpexcodePlugin = async (ctx) => {
92
92
  })()
93
93
  }
94
94
 
95
- // the rendezvous daemon, from the shared runtime: {type:"reply"} injects into the root session (a plugin
96
- // that adopted no session yet reply-rejects synchronously, BEFORE repaint-done, so the sender fails loud
97
- // instead of confirming a prompt that can never inject); {type:"repaint"} answers repaint-done in the same
98
- // synchronous parse pass — confirmation means PARSED, not processed. The injection itself (a whole model
99
- // turn: the SDK prompt call resolves only when the TURN ends) runs BEHIND the confirm.
95
+ // The shared rendezvous server receives best-effort reply pokes. An unadopted plugin simply leaves the
96
+ // timeline line for its next turn boundary; the SDK injection remains host-owned.
100
97
  rt.serveRendezvous(injectPrompt, { canInject: () => !!(client && rootSession) })
101
98
 
102
99
  const toolPayload = (input, output) => {
@@ -8,7 +8,7 @@ import type { DispatchResult, HarnessDeliveryRecord } from './harness.js'
8
8
  import { controlRequest, withTimeout } from './headless-controller.js'
9
9
  import { shQuote } from './sh.js'
10
10
 
11
- type ControlRequest = { type: 'deliver'; text: string }
11
+ type ControlRequest = { type: 'deliver'; text: string; mid?: string }
12
12
  type ChildTurn = { process: ChildProcess; exited: Promise<number | null> }
13
13
 
14
14
  const PKG = fileURLToPath(new URL('..', import.meta.url))
@@ -24,7 +24,7 @@ export function piHeadlessLaunchCommand(id: string, runtimeDir: string, piCmd: s
24
24
  }
25
25
 
26
26
  export const deliverViaPiHeadless = (rec: HarnessDeliveryRecord, text: string) =>
27
- controlRequest(piHeadlessSock(rec.session), { type: 'deliver', text }, {
27
+ controlRequest(piHeadlessSock(rec.session), { type: 'deliver', text, mid: rec.mid }, {
28
28
  name: 'pi-headless', session: rec.session, timeoutMs: CONTROL_TIMEOUT_MS,
29
29
  rejected: 'pi-headless controller rejected the request',
30
30
  })
@@ -101,10 +101,10 @@ export class PiHeadlessController {
101
101
  if (request.type !== 'deliver') return { ok: false, error: 'unknown pi-headless control request' }
102
102
  if (!request.text) return { ok: false, error: 'empty prompt - nothing to deliver' }
103
103
 
104
- // A live extension listener is an in-flight pi turn. The native steer path is parse-confirmed by the
105
- // shared rendezvous protocol. Only a proven absent listener may cold-wake a saved session.
104
+ // A live extension listener is an in-flight pi turn. Only a proven absent listener may cold-wake a saved
105
+ // session; an inconclusive probe must not start a duplicate turn.
106
106
  const { deliverViaSocketOrWake } = await import('./harness.js')
107
- return deliverViaSocketOrWake(this.id, request.text, async () => {
107
+ return deliverViaSocketOrWake(this.id, request.text, request.mid, async () => {
108
108
  if (this.child) await withTimeout(this.child.exited, 5_000, `previous pi-headless turn did not exit for session ${this.id}`)
109
109
  await this.spawnTurn(request.text, true)
110
110
  return { ok: true }
@@ -2,7 +2,6 @@ import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { promisify } from 'node:util'
4
4
  import { alive, withSessionInputLock } from './sessions.js'
5
- import { runSessionOperationSync } from './session-maintenance.js'
6
5
 
7
6
  const pexec = promisify(execFile)
8
7
  const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
@@ -363,10 +362,10 @@ const MAX_INPUT_BYTES = 64 * 1024
363
362
  export function forwardInput(id: string, viewer: Viewer, data: string): boolean {
364
363
  const subscription = currentSubscription(id, viewer)
365
364
  if (!subscription?.visible || !subscription.bridge || !data || Buffer.byteLength(data, 'utf8') > MAX_INPUT_BYTES) return false
366
- return runSessionOperationSync({ op: 'terminal-input', sessionId: id }, () => withSessionInputLock(id, () => {
367
- sendControl(subscription.bridge!, { t: 'input', data })
368
- return true
369
- }) ?? false)
365
+ return withSessionInputLock(id, () => {
366
+ sendControl(subscription.bridge!, { t: 'input', data })
367
+ return true
368
+ }) ?? false
370
369
  }
371
370
 
372
371
  async function restoreBridge(id: string, viewer: Viewer, subscription: Subscription): Promise<void> {
@@ -9,6 +9,7 @@ export type ReviewEvalNode = {
9
9
  export type ReviewSnapshot = {
10
10
  issues: any[]
11
11
  evalNodes: ReviewEvalNode[]
12
+ forgeRevision: number
12
13
  }
13
14
 
14
15
  let current: ReviewSnapshot | null = null
@@ -1,11 +1,12 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { listSessions } from './sessions.js'
3
- import { getBoard } from './graphCache.js'
3
+ import { getBoard, getBoardForForgeRevision } from './graphCache.js'
4
4
  import { buildSessionEvals, type SessionEvals } from '../../spec-eval/src/sessioneval.js'
5
5
  import { evalTimeline } from '../../spec-eval/src/evaltab.js'
6
6
  import { issuesEnabled as issuesEnabledForReview } from './localIssues.js'
7
7
  import { issueStores as issueStoresForReview } from './issues.js'
8
8
  import { hasReviewSnapshot, readReviewSnapshot } from './reviewSnapshot.js'
9
+ import { residentForgeRevision, residentForgeState } from '../../spec-forge/src/resident.js'
9
10
  // @ts-expect-error The dashboard module is deliberately plain JS so the browser and server execute the
10
11
  // exact same tokenizer/matcher. It is shipped beside the built dashboard by the root package manifest.
11
12
  import { EVAL_FILTER_KIND, evalFilterModel, evalReviewState, issueFilterModel, tokenFilterState } from '../../spec-dashboard/src/reviewFilters.js'
@@ -124,7 +125,11 @@ export async function issuesReview(query: string | undefined, requestedPage: unk
124
125
  // The first request must wait for the first atomic publication. Once one exists, a graph refresh may be
125
126
  // rebuilding unrelated board/session state; the published review source remains a valid answer and its
126
127
  // revision/poll path will deliver the next generation without making this page join that flight.
128
+ residentForgeState()
127
129
  if (!hasReviewSnapshot()) await getBoard()
130
+ else if (readReviewSnapshot().forgeRevision < residentForgeRevision()) {
131
+ await getBoardForForgeRevision(residentForgeRevision())
132
+ }
128
133
  const sessions = await listSessions()
129
134
  const issues = readReviewSnapshot().issues.slice().sort(issueOrder)
130
135
  const text = String(query ?? '').trim() || ISSUE_QUERY_DEFAULT
@@ -0,0 +1,54 @@
1
+ // @@@ root-lru - ONE bounded root→key cache policy, for every layer that keeps immutable per-HEAD work warm.
2
+ // A leaf module on purpose: it imports nothing from the spec graph, the eval sidecar, or git, so both layers
3
+ // can depend on it without either owning the other ([[source-of-truth]]).
4
+ //
5
+ // The policy is reference-counted, not plain LRU, and the distinction is the whole point. Entries are keyed by
6
+ // something IMMUTABLE (a HEAD, or a ledger path + HEAD), so two checkouts sitting on the same commit must share
7
+ // one entry rather than build it twice. A root moving A→B therefore drops A only when NO other root still
8
+ // points at it; otherwise a sequence of successful rebuilds retains one whole index per commit until the slot
9
+ // bound finally evicts them. Bumping an unchanged root is a pure recency touch (delete + reinsert), which is
10
+ // what makes `roots` insertion-ordered enough for the eviction loop to mean "oldest root".
11
+ //
12
+ // This existed twice, verbatim in logic and even in name — `touchRoot` in git.ts (index/drift) and again in
13
+ // spec-eval's scenariofresh.ts (scenario chains), whose comment said it was "mirroring historyIndex/driftIndex
14
+ // in git.ts". Both authors knew; neither had anywhere to put it. Now they do.
15
+
16
+ // slot bound for one cache family. Every caller names its own env knob and default so operators can tune the
17
+ // families independently, but nobody gets to invent a different FLOOR or a bare literal — a cache whose bound
18
+ // is a magic number cannot be tuned in the field at all (scenariofresh's was a hardcoded 16).
19
+ // @@@ unparseable is not zero - the shape both copies used, `Math.max(4, Number(env || fallback))`, returns
20
+ // NaN for a mistyped value, and `size > NaN` is always false: one typo in an env var silently turned the
21
+ // bound OFF and let the cache grow without limit. A bound that fails open is worse than no bound, because
22
+ // nothing reports it. Anything that does not parse to a positive number falls back to the caller's default.
23
+ export const rootSlots = (env: string | undefined, fallback: number): number => {
24
+ const asked = Number(env)
25
+ return Math.max(4, Number.isFinite(asked) && asked > 0 ? asked : fallback)
26
+ }
27
+
28
+ // Record that `root` now wants `key`, evicting what no root wants any more and keeping `roots` within `slots`.
29
+ // `cache` is the caller's own keyed store; this owns only the ROOT→key bookkeeping and the eviction decision.
30
+ export function touchRoot(
31
+ roots: Map<string, string>,
32
+ cache: Map<string, unknown> & { delete(key: string): boolean },
33
+ root: string,
34
+ key: string,
35
+ slots: number,
36
+ ): void {
37
+ const previous = roots.get(root)
38
+ if (previous !== key) {
39
+ roots.set(root, key)
40
+ // the old key survives only while some OTHER root still names it — immutable entries are shared, so
41
+ // dropping one root's view must not throw away a sibling checkout's warm work.
42
+ if (previous && ![...roots.values()].includes(previous)) cache.delete(previous)
43
+ } else {
44
+ roots.delete(root)
45
+ roots.set(root, key) // recency bump: reinsertion is what makes the eviction loop below pick the oldest
46
+ }
47
+ while (roots.size > slots) {
48
+ const oldest = roots.keys().next().value as string | undefined
49
+ if (oldest === undefined) break
50
+ const oldKey = roots.get(oldest)
51
+ roots.delete(oldest)
52
+ if (oldKey && ![...roots.values()].includes(oldKey)) cache.delete(oldKey)
53
+ }
54
+ }
@@ -1,14 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process'
2
2
 
3
- // @@@ session-runtime guard ([[platform-support]]) - SpexCode's session orchestration rests on POSIX
4
- // primitives with no native-Windows analog: tmux (the durable detached PTY + capture-pane scrollback +
5
- // multi-client reattach fabric), hand-written bash launch scripts, and filesystem-path AF_UNIX control
6
- // sockets. The supported runtime is POSIX — Linux, macOS, or Windows *via WSL2* (a real Linux kernel where
7
- // tmux/bash/unix-sockets all work). This is the honest gate at the entry to the session runtime: detect the
8
- // load-bearing primitive (tmux) missing and print ONE actionable line naming the fix, instead of letting a
9
- // cryptic downstream ENOENT be the user's first signal. Read-only CLI (init/lint/board) never calls this,
10
- // so it still runs anywhere the launcher does — only the session-launch path (`spex serve`) is gated.
11
-
12
3
  // tmux presence is the primitive we actually depend on, so probe THAT rather than assuming by platform:
13
4
  // this also catches a bare POSIX box that simply hasn't installed tmux, not only native Windows.
14
5
  export function hasTmux(): boolean {