spexcode 0.4.0 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +37 -17
  2. package/package.json +9 -5
  3. package/spec-cli/README.md +23 -14
  4. package/spec-cli/hooks/dispatch.sh +7 -5
  5. package/spec-cli/hooks/harness.sh +19 -8
  6. package/spec-cli/src/anchors.ts +163 -30
  7. package/spec-cli/src/cli.ts +97 -42
  8. package/spec-cli/src/client.ts +34 -11
  9. package/spec-cli/src/doctor.ts +174 -36
  10. package/spec-cli/src/gateway-auth.ts +194 -0
  11. package/spec-cli/src/gateway-hub.ts +360 -0
  12. package/spec-cli/src/gateway.ts +31 -16
  13. package/spec-cli/src/git.ts +12 -5
  14. package/spec-cli/src/graph.ts +53 -36
  15. package/spec-cli/src/graphStream.ts +265 -34
  16. package/spec-cli/src/guide.ts +157 -76
  17. package/spec-cli/src/harness-select.ts +20 -6
  18. package/spec-cli/src/harness.ts +266 -67
  19. package/spec-cli/src/help.ts +84 -41
  20. package/spec-cli/src/host.ts +610 -0
  21. package/spec-cli/src/identity-presets.d.ts +13 -0
  22. package/spec-cli/src/identity-presets.js +128 -0
  23. package/spec-cli/src/index.ts +163 -86
  24. package/spec-cli/src/init.ts +53 -25
  25. package/spec-cli/src/layout.ts +5 -5
  26. package/spec-cli/src/lint.ts +115 -169
  27. package/spec-cli/src/login-page.ts +12 -5
  28. package/spec-cli/src/materialize.ts +39 -13
  29. package/spec-cli/src/mentions.ts +16 -11
  30. package/spec-cli/src/opencode.ts +145 -0
  31. package/spec-cli/src/pi-harness.ts +124 -0
  32. package/spec-cli/src/project-identity.ts +73 -0
  33. package/spec-cli/src/pty-bridge.ts +321 -521
  34. package/spec-cli/src/pty-helper.mjs +86 -0
  35. package/spec-cli/src/reaper.ts +16 -8
  36. package/spec-cli/src/reviewSnapshot.ts +23 -0
  37. package/spec-cli/src/reviews.ts +347 -0
  38. package/spec-cli/src/session-timeline.ts +162 -0
  39. package/spec-cli/src/sessions.ts +210 -63
  40. package/spec-cli/src/shim-runtime.ts +169 -0
  41. package/spec-cli/src/slash-commands.ts +76 -0
  42. package/spec-cli/src/source-files.ts +79 -0
  43. package/spec-cli/src/specs.ts +47 -24
  44. package/spec-cli/src/supervise.ts +37 -15
  45. package/spec-cli/src/tree.ts +8 -8
  46. package/spec-cli/src/uninstall.ts +56 -23
  47. package/spec-cli/templates/hooks/prepare-commit-msg +32 -13
  48. package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +24 -18
  49. package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +3 -3
  50. package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  51. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +30 -0
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  53. package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
  54. package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
  56. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
  58. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
  59. package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
  60. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  61. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
  62. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
  63. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
  64. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
  65. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +62 -0
  66. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +19 -0
  67. package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
  68. package/spec-cli/templates/spec/project/spec.md +6 -5
  69. package/spec-cli/templates/spexcode.json +4 -2
  70. package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
  71. package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
  72. package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
  73. package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
  74. package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
  75. package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
  76. package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
  77. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
  78. package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
  79. package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
  80. package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
  81. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
  82. package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
  83. package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
  84. package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
  85. package/spec-dashboard/dist/index.html +10 -10
  86. package/spec-dashboard/src/reviewFilters.js +279 -0
  87. package/spec-dashboard/src/reviewQuery.js +174 -0
  88. package/spec-dashboard/src/session.js +167 -0
  89. package/spec-eval/src/cli.ts +80 -45
  90. package/spec-eval/src/evaltab.ts +4 -3
  91. package/spec-eval/src/humanok.ts +1 -1
  92. package/spec-eval/src/matrix.ts +693 -0
  93. package/spec-eval/src/scenarios.ts +116 -4
  94. package/spec-eval/src/sessioneval.ts +726 -66
  95. package/spec-cli/src/migrate-table.ts +0 -398
  96. package/spec-cli/src/migrate.ts +0 -386
  97. package/spec-cli/templates/spec/project/.plugins/distill/spec.md +0 -74
  98. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
  99. package/spec-cli/templates/spec/project/.plugins/tidy/spec.md +0 -25
  100. package/spec-dashboard/dist/assets/Dashboard-CTcH2eW9.js +0 -27
  101. package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
  102. package/spec-dashboard/dist/assets/FoldToggle-CVFbBpyW.js +0 -1
  103. package/spec-dashboard/dist/assets/IssuesPage-kULjonqj.js +0 -1
  104. package/spec-dashboard/dist/assets/MobileApp-B0ZJju8K.js +0 -1
  105. package/spec-dashboard/dist/assets/SessionInterface-BRKJqU2U.js +0 -71
  106. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
  107. package/spec-dashboard/dist/assets/SessionWindow-CDhEL7wO.js +0 -9
  108. package/spec-dashboard/dist/assets/Settings-BL6FV_8S.js +0 -1
  109. package/spec-dashboard/dist/assets/index-DmQsNYKK.css +0 -1
  110. package/spec-dashboard/dist/assets/index-DulGPk6A.js +0 -41
  111. /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
@@ -11,6 +11,12 @@ import { resolveHarnessTargets, partitionHarnesses } from './harness-select.js'
11
11
  import { emitPlugin, cleanPlugin, pluginBundleDir, pluginVersion } from './plugin-harness.js'
12
12
  import { plantContractFilter, removeContractFilter, settleIndexStat } from './contract-filter.js'
13
13
 
14
+ export type MaterializedArtifact = {
15
+ kind: 'hook manifest' | 'contract' | 'shim' | 'skill' | 'agent' | 'plugin bundle' | 'trust'
16
+ path: string
17
+ }
18
+ export type MaterializeResult = { contentHash: string; planted: MaterializedArtifact[] }
19
+
14
20
  // @@@ materialize - the materialize step (≈0.85s), anchored on GIT-NATIVE events only ([[commit-surgery]]):
15
21
  // spex verbs (init/materialize), session-worktree creation, and the planted git hooks (pre-commit,
16
22
  // post-checkout, post-merge) — never a harness event; the harness is a READER of the materialized files, not
@@ -156,22 +162,36 @@ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { s
156
162
  // and even unfiltered the phantom-`M` lingers) — settle the index stat, content-guarded so a user's real
157
163
  // unstaged edit is never staged ([[content-filter]] edge 2).
158
164
  try { settleIndexStat(proj, HARNESSES.flatMap((h) => h.contractFiles(proj))) } catch { /* not a git repo */ }
159
- // leaving nothing behind: drop the now-EMPTY dirs the assert phase mkdir'ed (.claude/.codex and their
160
- // skills/agents subdirs children listed before parents). rmdirSync is NON-recursive, so a dir holding
165
+ // leaving nothing behind: drop the now-EMPTY dirs the assert phase mkdir'ed (.claude/.codex/.opencode/.pi
166
+ // and their skills/agents/plugins/extensions subdirs). Each dir AND its parent are swept deepest-first,
167
+ // because a harness may nest its shim a level below its home (opencode's .opencode/plugins/, pi's
168
+ // .pi/extensions/) — but never the checkout roots themselves. rmdirSync is NON-recursive, so a dir holding
161
169
  // any user file survives untouched; `.git/spexcode/` is deliberately NOT swept (shared per-clone home).
162
170
  for (const h of HARNESSES) {
163
171
  const anchor = h.worktreeHookAnchor(proj)
164
- for (const d of [h.skillDir(proj), h.agentDir(proj), dirname(h.shimFile(proj)), anchor ? dirname(anchor) : null])
165
- if (d) { try { rmdirSync(d) } catch { /* non-empty or absent — keep */ } }
172
+ const dirs = [h.skillDir(proj), h.agentDir(proj), dirname(h.shimFile(proj)), anchor ? dirname(anchor) : null]
173
+ .filter((d): d is string => !!d)
174
+ const roots = new Set([proj, mainCheckout(proj)])
175
+ const sweep = [...new Set([...dirs, ...dirs.map((d) => dirname(d))])]
176
+ .filter((d) => !roots.has(d))
177
+ .sort((a, b) => b.length - a.length)
178
+ for (const d of sweep) { try { rmdirSync(d) } catch { /* non-empty or absent — keep */ } }
166
179
  }
167
180
  }
168
181
 
169
- // the whole pay-per-change materialize. proj defaults to cwd. Returns the new content-hash it stamped.
170
- export function materialize(proj = process.cwd()): string {
182
+ // the whole pay-per-change materialize. proj defaults to cwd. Its receipt is populated at each successful
183
+ // write so callers report the actual selected footprint instead of maintaining a second artifact inventory.
184
+ export function materialize(proj = process.cwd()): MaterializeResult {
171
185
  const rt = treeSlotDir(proj) // this tree's slot in the global store, not the worktree
172
186
  mkdirSync(rt, { recursive: true })
187
+ const planted: MaterializedArtifact[] = []
188
+ const record = (kind: MaterializedArtifact['kind'], path: string) => {
189
+ if (!planted.some((a) => a.kind === kind && a.path === path)) planted.push({ kind, path })
190
+ }
173
191
  // (1) hook manifest (persistent — the dispatcher reads it; regenerated only here, on change).
174
- writeFileSync(join(rt, 'hooks-manifest'), compileManifest())
192
+ const manifest = join(rt, 'hooks-manifest')
193
+ writeFileSync(manifest, compileManifest())
194
+ record('hook manifest', manifest)
175
195
  // (2) the contract = the tracked docs guide (the hand-written agent/contributor notes — the ONE piece of
176
196
  // in-tree prose) FOLLOWED BY the surface:system bodies (in name order), written WHOLE into EACH harness's
177
197
  // contract file(s) + (3) each harness's thin shim → dispatch.sh + (4) its trust. All owned by the adapter.
@@ -216,17 +236,18 @@ export function materialize(proj = process.cwd()): string {
216
236
  const machinePaths: string[] = []
217
237
  const contractPaths: string[] = []
218
238
  for (const h of selected) {
219
- if (contract) for (const f of h.contractFiles(proj)) { writeManagedBlock(f, contract); contractPaths.push(f) }
239
+ if (contract) for (const f of h.contractFiles(proj)) { writeManagedBlock(f, contract); contractPaths.push(f); record('contract', f) }
220
240
  const shimFile = h.shimFile(proj)
221
241
  mkdirSync(dirname(shimFile), { recursive: true })
222
242
  const shim = h.shim(DISPATCH, SPEX)
223
- writeFileSync(shimFile, shim.json)
224
- h.writeTrust(proj, shim.cmd)
243
+ writeFileSync(shimFile, shim.content)
244
+ record('shim', shimFile)
245
+ for (const f of h.writeTrust(proj, shim.cmd)) record('trust', f)
225
246
  machinePaths.push(shimFile)
226
247
  // a linked-worktree ANCHOR copy of the shim, when the harness needs one (codex: the shim lives at the main
227
248
  // checkout, so the worktree gets no `.codex/` unless we place one). One adapter line; null otherwise.
228
249
  const anchor = h.worktreeHookAnchor(proj)
229
- if (anchor) { mkdirSync(dirname(anchor), { recursive: true }); writeFileSync(anchor, shim.json); machinePaths.push(anchor) }
250
+ if (anchor) { mkdirSync(dirname(anchor), { recursive: true }); writeFileSync(anchor, shim.content); machinePaths.push(anchor); record('shim', anchor) }
230
251
  }
231
252
  // (6) skills + (7) sub-agents — each surface node → the file the harness auto-discovers, one per selected
232
253
  // harness that has the primitive (skillDir/agentDir null skips — the divergence is the adapter's line).
@@ -237,6 +258,7 @@ export function materialize(proj = process.cwd()): string {
237
258
  mkdirSync(dirname(f), { recursive: true })
238
259
  writeFileSync(f, skillArtifact(sk))
239
260
  artifactPaths.push(f)
261
+ record('skill', f)
240
262
  }
241
263
  }
242
264
  for (const ag of agentNodes) {
@@ -246,6 +268,7 @@ export function materialize(proj = process.cwd()): string {
246
268
  mkdirSync(dirname(f), { recursive: true })
247
269
  writeFileSync(f, agentArtifact(ag))
248
270
  artifactPaths.push(f)
271
+ record('agent', f)
249
272
  }
250
273
  }
251
274
  // (8) the PLUGIN target ([[plugin-harness]]): materialize the whole system into one self-contained Claude-plugin
@@ -271,7 +294,10 @@ export function materialize(proj = process.cwd()): string {
271
294
  spex: SPEX,
272
295
  version: pluginVersion(),
273
296
  }
274
- for (const p of plugins) emitPlugin(proj, p.folder, bundle)
297
+ for (const p of plugins) {
298
+ emitPlugin(proj, p.folder, bundle)
299
+ record('plugin bundle', pluginBundleDir(proj, p.folder))
300
+ }
275
301
  }
276
302
  writeFileSync(ledger, curFolders.join('\n'))
277
303
  // (9) the ignore rules — ALWAYS the per-clone .git/info/exclude ([[residence]]): the exclude is not a
@@ -313,5 +339,5 @@ export function materialize(proj = process.cwd()): string {
313
339
  // (5) stamp the content-hash marker LAST (a diagnostic freshness record; a crash mid-materialize leaves it stale).
314
340
  const h = contentHash(proj)
315
341
  writeFileSync(join(rt, 'content-hash'), h)
316
- return h
342
+ return { contentHash: h, planted }
317
343
  }
@@ -9,8 +9,9 @@
9
9
  // `@<token>` at a word boundary is an actor; `[[<id>]]` is a topic. Token chars are any unicode
10
10
  // letter/number plus [_-] (a CJK session handle or node id is first-class — same charset the launch-side
11
11
  // MENTION and the dashboard's MENTION_RE use); a session id, a short label/prefix, or the literal `new`.
12
- // Both forms are deduped in first-seen order.
13
- const ACTOR_RE = /(?:^|\s)@([\p{L}\p{N}_-]+)/gu
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
14
15
  const NODE_RE = /\[\[([^\]\s]+)\]\]/g
15
16
 
16
17
  const uniq = (xs: string[]): string[] => [...new Set(xs)]
@@ -30,7 +31,7 @@ export function stripRefSigil(token: string): string {
30
31
  export function parseMentions(text: string): { actors: string[]; nodes: string[] } {
31
32
  const actors: string[] = []
32
33
  const nodes: string[] = []
33
- for (const m of text.matchAll(ACTOR_RE)) actors.push(m[1])
34
+ for (const m of text.matchAll(ACTOR_RE)) actors.push(m[1] === 'new' && m[2] ? `new:${m[2]}` : m[1])
34
35
  for (const m of text.matchAll(NODE_RE)) nodes.push(m[1])
35
36
  return { actors: uniq(actors), nodes: uniq(nodes) }
36
37
  }
@@ -39,17 +40,19 @@ export function parseMentions(text: string): { actors: string[]; nodes: string[]
39
40
  // A minimal shape of what dispatchMentions needs from a Session (so this stays testable without sessions.ts).
40
41
  export type ActorSession = { id: string; node: string | null; name: string | null; title: string | null; liveness: string }
41
42
  export type Resolved =
42
- | { token: string; kind: 'new' }
43
+ | { token: string; kind: 'new'; launcher?: string }
43
44
  | { token: string; kind: 'session'; session: ActorSession }
44
45
  | { token: string; kind: 'unresolved' }
45
46
 
46
- // Resolve each actor token to a `new` sentinel, an ONLINE session (never a dead one you don't summon a
47
- // closed agent; `@new` acts on its behalf), or unresolved. Match order: literal `new` → exact id → id prefix
48
- // → name/title (case-insensitive) → name/title prefix. First online match wins.
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.
49
51
  export function resolveActors(tokens: string[], sessions: ActorSession[]): Resolved[] {
50
52
  const online = sessions.filter((s) => s.liveness === 'online')
51
53
  return tokens.map((token): Resolved => {
52
- if (token === 'new') return { token, kind: 'new' }
54
+ const fresh = /^new(?::(.+))?$/.exec(token)
55
+ if (fresh) return { token, kind: 'new', ...(fresh[1] ? { launcher: fresh[1] } : {}) }
53
56
  const t = token.toLowerCase()
54
57
  const label = (s: ActorSession) => (s.name || s.title || '').toLowerCase()
55
58
  const hit =
@@ -84,7 +87,9 @@ function mentionPrompt(threadId: string, node: string | null, author: string, te
84
87
  // A non-open thread is settled work: a fresh worker spawned onto it must not re-implement what already
85
88
  // landed, so the prompt leads with the status and a verify-on-main-first instruction.
86
89
  export function newWorkerPrompt(threadId: string, node: string | null, author: string, text: string, status?: string | null): string {
87
- const on = node ? ` on node ${node}` : ''
90
+ // Keep inherited scope inside the text the worker receives: newSession 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}]]` : ''
88
93
  const settled = status && status !== 'open'
89
94
  ? `NOTE: this thread is already resolved (status: ${status}) — the work it describes has likely LANDED. ` +
90
95
  `Verify the current state on main FIRST; if main already satisfies the thread, reply with that finding ` +
@@ -115,7 +120,7 @@ export async function dispatchMentions(
115
120
  // deliberate audit/re-measure), but the worker prompt carries the status and the outcome line warns.
116
121
  const settled = ctx.status && ctx.status !== 'open' ? ctx.status : undefined
117
122
  try {
118
- const s = await newSession(ctx.node, newWorkerPrompt(ctx.threadId, ctx.node, ctx.author, text, ctx.status), spawnParent(ctx.author, sessions))
123
+ const s = await newSession(newWorkerPrompt(ctx.threadId, ctx.node, ctx.author, text, ctx.status), spawnParent(ctx.author, sessions), r.launcher)
119
124
  out.push({ token: r.token, result: 'spawned', detail: s.id, ...(settled ? { note: `thread ${settled}` } : {}) })
120
125
  } catch (e) { out.push({ token: r.token, result: 'failed', detail: e instanceof Error ? e.message : String(e) }) }
121
126
  continue
@@ -205,7 +210,7 @@ export function summarize(outcomes: DispatchOutcome[], loopIn?: LoopIn | null):
205
210
  const parts: string[] = []
206
211
  if (outcomes.length) parts.push('@ ' + outcomes.map((o) => {
207
212
  if (o.result === 'sent') return `${o.token}→sent`
208
- if (o.result === 'spawned') return `new→${o.detail}${o.note ? ` ⚠ ${o.note} — likely already landed` : ''}`
213
+ if (o.result === 'spawned') return `${o.token}→${o.detail}${o.note ? ` ⚠ ${o.note} — likely already landed` : ''}`
209
214
  if (o.result === 'offline') return `${o.token}→offline (stored)`
210
215
  if (o.result === 'unresolved') return `${o.token}→? (no live session; stored)`
211
216
  return `${o.token}→failed (${o.detail})`
@@ -0,0 +1,145 @@
1
+ import { shimRuntimeSource } from './shim-runtime.js'
2
+
3
+ // @@@ opencode plugin generator - the opencode adapter's "shim" is not a settings JSON but a generated
4
+ // opencode PLUGIN (.opencode/plugins/spexcode.ts, auto-loaded by opencode from the project tree) that runs
5
+ // inside the agent process. It is a THIN HOST over the shared shim runtime ([[shim-runtime]], embedded
6
+ // verbatim): this generator declares opencode's event-bus mapping (session.created → SessionStart,
7
+ // chat.message → UserPromptSubmit, tool.execute.before/after → Pre/PostToolUse, session.idle → Stop) and its
8
+ // host bindings; the payload synthesis, the block verdict (exit 2 + stdout decision:block JSON), and the
9
+ // rendezvous server come from the runtime. opencode's host-specific verdict consumers ([[opencode-harness]]):
10
+ // a PreToolUse block THROWS (opencode aborts the tool call); a Stop block re-injects the gate's reason as a
11
+ // follow-up prompt via the SDK client, closing the stop-gate loop. What re-enters is always the parsed
12
+ // REASON, never the escaped wire JSON. The rendezvous inject is client.session.prompt into the ROOT session,
13
+ // gated by canInject (a plugin with no adopted session reply-rejects instead of confirming an undeliverable
14
+ // prompt). What genuinely stays opencode-only below: session tracking (root vs subagent children, the
15
+ // agent_id stamp), the minted-id capture, and the two resume seeds.
16
+ // This module is a PURE content producer (no imports from harness.ts — the adapter object lives there, in the
17
+ // one HARNESSES seam) so there is no import cycle.
18
+
19
+ // the lifecycle events the generated plugin can emit (drives the shim + hooks-manifest binding). opencode's
20
+ // bus has no failed-stop and no idle/attention notification event, so StopFailure/Notification are genuinely
21
+ // absent — the same five codex binds.
22
+ export const OPENCODE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
23
+
24
+ // opencode tool name → Claude tool name, so [[inject-spec-first]] / [[inject-spec-of-file]] and every other
25
+ // claude-family handler read the payload with zero opencode knowledge. Unknown tools pass through untouched
26
+ // (opencode's names are lowercase). Exported for tests.
27
+ export const OPENCODE_TOOL_NAMES: Record<string, string> = {
28
+ bash: 'Bash', edit: 'Edit', write: 'Write', read: 'Read', grep: 'Grep', glob: 'Glob', list: 'LS',
29
+ patch: 'Edit', todowrite: 'TodoWrite', todoread: 'TodoRead', webfetch: 'WebFetch', task: 'Task',
30
+ }
31
+
32
+ // the generated plugin source. `dispatch` and `spex` are absolute paths baked at materialize time (same as
33
+ // every shim); the 'dispatch.sh' substring doubles as the identity stamp cleanHarness gates removal on. The
34
+ // file is plain TS with no @opencode-ai/plugin type import, so it loads regardless of the host project's
35
+ // node_modules; opencode calls every exported function with its plugin context.
36
+ export function opencodePluginSource(dispatch: string, spex: string): string {
37
+ return `// spexcode: GENERATED by spex materialize — do not edit (regenerated per clone; see [[opencode-harness]])
38
+ // Bridges opencode's event bus into SpexCode's dispatch.sh (claude-shaped payloads) and serves the
39
+ // per-session rendezvous socket for prompt delivery + liveness.
40
+ import { spawn } from "node:child_process"
41
+ ${shimRuntimeSource('opencode', dispatch, spex)}
42
+
43
+ const TOOL_NAMES = ${JSON.stringify(OPENCODE_TOOL_NAMES)}
44
+
45
+ export const SpexcodePlugin = async (ctx) => {
46
+ const client = ctx && ctx.client
47
+ const cwd = (ctx && (ctx.directory || (ctx.app && ctx.app.path && ctx.app.path.cwd))) || process.cwd()
48
+ const recordId = (process.env.SPEXCODE_SESSION_ID || "").trim()
49
+ const rt = spexShimRuntime({ sessionId: () => recordId, cwd: () => cwd })
50
+
51
+ // session tracking: the ROOT opencode session is this worker's conversation; child sessions (subagents)
52
+ // are stamped agent_id so a parent's declared state stays out of its subagents' reach (the same
53
+ // discriminator claude's Task subagents carry). A RESUMED session re-fires no bus event until poked, so
54
+ // event-driven adoption alone leaves a resumed worker unreachable (the daemon reply-rejects every
55
+ // delivery): a --session resume seeds rootSession from the launch env (the resume marker's id), and a
56
+ // --continue resume falls back to asking the SDK for the newest root session.
57
+ let rootSession = (process.env.SPEXCODE_OPENCODE_RESUME_ID || "").trim()
58
+ const children = new Set()
59
+ const stamp = (sid) => (sid && children.has(sid) ? { agent_id: sid } : {})
60
+ // opencode MINTS its own session id — report it once so the backend stores it as harness_session_id
61
+ // (the id session resume passes to \`opencode --session <id>\`). Fire-and-forget; a failure only costs resume-by-id.
62
+ let captured = !!rootSession // a seeded (resumed) id is already on the record — don't re-report it
63
+ const capture = (sid) => {
64
+ if (captured || !sid || !recordId) return
65
+ captured = true
66
+ try { spawn(SPEX, ["internal", "opencode-capture", sid], { cwd, env: process.env, stdio: "ignore" }) } catch { /* resume falls back to --continue */ }
67
+ }
68
+ const adopt = (sid) => { if (!rootSession && sid) { rootSession = sid; capture(sid) } }
69
+ const injectPrompt = async (text) => {
70
+ if (!client || !rootSession) throw new Error("no session to prompt")
71
+ await client.session.prompt({ path: { id: rootSession }, body: { parts: [{ type: "text", text }] } })
72
+ }
73
+
74
+ // --continue resume: no owned id was ever captured, and a reattached session fires no bus event until
75
+ // poked — ask the SDK for the newest root session (what --continue itself reattached) so deliveries can
76
+ // inject. Gated on the launch marker so a FRESH launch can never adopt a stale session; best-effort with
77
+ // bounded retries (the server may still be loading at plugin init), shape-defensive, and adopt() ignores
78
+ // the late result if a real event won the race.
79
+ if (!rootSession && process.env.SPEXCODE_OPENCODE_CONTINUE && client && client.session && typeof client.session.list === "function") {
80
+ (async () => {
81
+ for (let i = 0; i < 10 && !rootSession; i++) {
82
+ try {
83
+ const res = await client.session.list()
84
+ const arr = (res && (res.data || res)) || []
85
+ const roots = (Array.isArray(arr) ? arr : []).filter((s) => s && s.id && !s.parentID)
86
+ const at = (s) => (s.time && (s.time.updated || s.time.created)) || 0
87
+ roots.sort((a, b) => at(b) - at(a))
88
+ if (roots.length) { adopt(roots[0].id); return }
89
+ } catch { /* not up yet — retry */ }
90
+ await new Promise((r) => setTimeout(r, 1000))
91
+ }
92
+ })()
93
+ }
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.
100
+ rt.serveRendezvous(injectPrompt, { canInject: () => !!(client && rootSession) })
101
+
102
+ const toolPayload = (input, output) => {
103
+ const sid = (input && input.sessionID) || ""
104
+ return {
105
+ ...stamp(sid), // agent_id must precede tool_input — hp_is_subagent checks keys only before tool_input
106
+ tool_name: TOOL_NAMES[(input && input.tool) || ""] || ((input && input.tool) || ""),
107
+ tool_input: rt.toolInput(output && output.args, "filePath"),
108
+ }
109
+ }
110
+
111
+ return {
112
+ event: async ({ event }) => {
113
+ const t = event && event.type
114
+ const p = (event && event.properties) || {}
115
+ if (t === "session.created") {
116
+ const info = p.info || {}
117
+ if (info.parentID) children.add(info.id)
118
+ else if (!rootSession) { adopt(info.id); await rt.dispatchEvent("SessionStart", { source: "startup" }) }
119
+ } else if (t === "session.idle") {
120
+ const sid = p.sessionID || ""
121
+ if (sid && rootSession && sid !== rootSession) return // a subagent going idle is not this worker's Stop
122
+ adopt(sid)
123
+ // the stop-gate loop closes in-process via the runtime's dispatchStop: stop_hook_active rides the
124
+ // payload (the gate's loop-termination bit) and the gate's parsed reason re-enters as a follow-up
125
+ // prompt; an inject the host can no longer take is reported loud.
126
+ await rt.dispatchStop((reason) => injectPrompt(reason), "blocked by a spexcode hook")
127
+ }
128
+ },
129
+ "chat.message": async (input, output) => {
130
+ const sid = (output && output.message && output.message.sessionID) || (input && input.sessionID) || ""
131
+ if (!(sid && children.has(sid))) adopt(sid)
132
+ const text = ((output && output.parts) || []).filter((x) => x && x.type === "text").map((x) => x.text).join("\\n")
133
+ await rt.dispatchEvent("UserPromptSubmit", { ...stamp(sid), prompt: text })
134
+ },
135
+ "tool.execute.before": async (input, output) => {
136
+ const r = await rt.dispatchEvent("PreToolUse", toolPayload(input, output))
137
+ if (rt.blocked(r)) throw new Error(rt.blockReason(r, "blocked by a spexcode hook")) // aborts the tool call — the PreToolUse block contract
138
+ },
139
+ "tool.execute.after": async (input, output) => {
140
+ await rt.dispatchEvent("PostToolUse", toolPayload(input, output))
141
+ },
142
+ }
143
+ }
144
+ `
145
+ }
@@ -0,0 +1,124 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, realpathSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+ import { shimRuntimeSource } from './shim-runtime.js'
5
+
6
+ // @@@ pi-harness helpers - the pi-ONLY machinery behind harness.ts's piHarness adapter: the generated
7
+ // extension source (pi's shim is a TypeScript extension, not a hooks JSON — pi has no external hook binding;
8
+ // its lifecycle surface IS the extension API) and the trust writer (pi's project-trust store,
9
+ // ~/.pi/agent/trust.json). Kept out of harness.ts so the adapter table stays scannable; imports NOTHING from
10
+ // harness.ts at runtime (no cycle — harness.ts imports us).
11
+
12
+ // ---------------------------------------------------------------------------------------------------------
13
+ // the pi shim = a generated extension at .pi/extensions/spexcode.ts (pi runs .ts extensions natively). It is
14
+ // a THIN HOST over the shared shim runtime ([[shim-runtime]], embedded verbatim): this generator declares
15
+ // only pi's event-name mapping and its ExtensionAPI bindings —
16
+ // session_start → SessionStart · input → UserPromptSubmit · tool_call → PreToolUse (blockable) ·
17
+ // tool_result → PostToolUse · agent_end + agent_settled → Stop (dual binding, one shared bit)
18
+ // — while the payload synthesis, the block-verdict parse (exit 2 + stdout decision:block JSON), and the
19
+ // rendezvous server all come from the runtime. pi's two host-specific verdict consumers: a tool_call block
20
+ // returns pi's typed { block: true, reason }; a Stop block has no blocking return, so Stop rides the
21
+ // runtime's dispatchStop on a DUAL binding — agent_end (awaited in the run loop: a queued teach drains as
22
+ // the same prompt's continuation, never an orphan) plus agent_settled (fires once per prompt after every
23
+ // drain: the terminal backstop whose flagged dispatch guarantees the gate's escape runs before settlement
24
+ // completes) — with stop_hook_active synthesized across both and the gate's reason SENT BACK IN as a user
25
+ // message; a no-longer-injectable host is reported loud — never silently dropped (a dropped rejection is
26
+ // a session stuck `active` forever).
27
+ // The rendezvous inject is pi.sendUserMessage({deliverAs: steer}), always able → no canInject gate.
28
+ export function piExtensionSource(dispatch: string, spex: string): string {
29
+ return `// generated by spexcode materialize — DO NOT EDIT (regenerated per clone; identity: dispatch.sh)
30
+ // SpexCode's pi shim: forwards pi lifecycle events to dispatch.sh as Claude-shaped hook payloads, and serves
31
+ // this session's rendezvous socket for prompt delivery + liveness.
32
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
33
+ ${shimRuntimeSource('pi', dispatch, spex)}
34
+
35
+ // pi tool names → the Claude tool vocabulary harness.sh parses (unknown names pass through untouched).
36
+ const TOOL: Record<string, string> = { read: "Read", edit: "Edit", write: "Write", bash: "Bash" }
37
+
38
+ export default function spexcode(pi: ExtensionAPI) {
39
+ let sessionId = process.env.SPEXCODE_SESSION_ID || ""
40
+ const rt = spexShimRuntime({ sessionId: () => sessionId })
41
+ const toolFields = (toolName: string, input: unknown) =>
42
+ ({ tool_name: TOOL[toolName] ?? toolName, tool_input: rt.toolInput(input, "path") })
43
+
44
+ // bound as soon as the extension loads so liveness reads online early; only a governed launch carries the
45
+ // socket env (sessions.ts rvEnv) — a self-launched bare \`pi\` skips this.
46
+ const rv = rt.serveRendezvous((text: string) => pi.sendUserMessage(text, { deliverAs: "steer" }))
47
+
48
+ pi.on("session_start", async (_event, ctx) => {
49
+ sessionId = ctx.sessionManager.getSessionId() || sessionId
50
+ process.env.PI_SESSION_ID = sessionId // tool subprocesses inherit it → spex CLI knows its session
51
+ await rt.dispatchEvent("SessionStart")
52
+ })
53
+ pi.on("input", async (event) => { await rt.dispatchEvent("UserPromptSubmit", { prompt: event.text }) })
54
+ pi.on("tool_call", async (event) => {
55
+ const r = await rt.dispatchEvent("PreToolUse", toolFields(event.toolName, event.input))
56
+ if (rt.blocked(r)) return { block: true, reason: rt.blockReason(r, "blocked by a SpexCode hook") }
57
+ })
58
+ pi.on("tool_result", async (event) => { await rt.dispatchEvent("PostToolUse", toolFields(event.toolName, event.input)) })
59
+ // Stop rides the runtime's dispatchStop on TWO bindings that never duplicate: agent_end performs the
60
+ // NORMAL dispatch (pi awaits its listeners inside the run loop and drains a queued teach as the SAME
61
+ // awaited prompt's continuation — never the orphaned settle-time prompt whose late inject threw
62
+ // "extension ctx is stale"), while agent_settled — which fires exactly once per prompt, after every
63
+ // drain — consumes AT MOST ONE PENDING BLOCKED stop (measured gap: in real dispatched runs the drained
64
+ // continuation's own agent_end does not reliably re-reach the extension, so without the backstop the
65
+ // flagged escape never runs). A naturally allowed agent_end leaves no
66
+ // pending state and settle dispatches NOTHING (exactly one gate entry per stop); only a blocked
67
+ // agent_end arms it, and the flagged settle dispatch always ends allowed (the gate's continuation paths
68
+ // terminate), clearing the pending bit through a subprocess write with no duplicate inject. An
69
+ // uninjectable host stays caught-loud.
70
+ const stop = () => rt.dispatchStop(
71
+ async (reason: string) => pi.sendUserMessage(reason, { deliverAs: "steer" }),
72
+ "a SpexCode Stop hook blocked this stop without giving a reason",
73
+ )
74
+ pi.on("agent_end", async () => { await stop() })
75
+ pi.on("agent_settled", async () => { if (rt.stopPending()) await stop() })
76
+ pi.on("session_shutdown", async () => { rv?.close() })
77
+ }
78
+ `
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------------------------------------
82
+ // pi trust — make a dispatched/self-launched pi load our project-local extension with zero prompts. pi gates
83
+ // EVERY project-local resource (.pi/extensions, .pi/skills, .pi/prompts, .pi/settings.json) behind project
84
+ // trust: saved decisions live in ~/.pi/agent/trust.json as a flat { "<canonical dir>": true|false } map, and
85
+ // the CLOSEST saved decision on the cwd's parent chain wins (pi trust-manager findNearestTrustEntry). So ONE
86
+ // entry keyed by the MAIN CHECKOUT covers every .worktrees/<id> worktree beneath it. The launch additionally
87
+ // carries `--approve` (one-run trust) as defence for a worktree living OUTSIDE the checkout. Writes are
88
+ // idempotent and surgical: other projects' decisions are untouched; a corrupt trust.json FAILS LOUD rather
89
+ // than being clobbered; remove only deletes a `true` we could have written, never a user's saved "false".
90
+
91
+ // pi has no config-dir env of its own (hardcoded ~/.pi/agent) — SPEXCODE_PI_AGENT_DIR is OUR test seam only.
92
+ const piAgentDir = () => process.env.SPEXCODE_PI_AGENT_DIR || join(homedir(), '.pi', 'agent')
93
+ // pi canonicalizes trust keys (realpath); mirror it so our key matches its lookup.
94
+ const canonical = (p: string) => { try { return realpathSync(p) } catch { return p } }
95
+
96
+ function readTrust(file: string): Record<string, unknown> {
97
+ if (!existsSync(file)) return {}
98
+ const raw = readFileSync(file, 'utf8')
99
+ try { return JSON.parse(raw) as Record<string, unknown> } catch (e) {
100
+ throw new Error(`${file} is not valid JSON — refusing to rewrite pi's trust store: ${String(e)}`)
101
+ }
102
+ }
103
+
104
+ export function writePiTrust(proj: string): string {
105
+ const dir = piAgentDir()
106
+ const file = join(dir, 'trust.json')
107
+ const key = canonical(proj)
108
+ const data = readTrust(file)
109
+ if (data[key] === true) return file // already trusted — no churn
110
+ data[key] = true
111
+ mkdirSync(dir, { recursive: true })
112
+ writeFileSync(file, JSON.stringify(data, null, 2) + '\n')
113
+ return file
114
+ }
115
+
116
+ export function removePiTrust(proj: string): void {
117
+ const file = join(piAgentDir(), 'trust.json')
118
+ if (!existsSync(file)) return
119
+ const key = canonical(proj)
120
+ const data = readTrust(file)
121
+ if (data[key] !== true) return // absent, or a user's own "do not trust" — never touch it
122
+ delete data[key]
123
+ writeFileSync(file, JSON.stringify(data, null, 2) + '\n')
124
+ }
@@ -0,0 +1,73 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { basename, dirname, join } from 'node:path'
4
+ import { spexcodeHome } from './layout.js'
5
+ import {
6
+ resolvedIdentityIcon, DEFAULT_GATEWAY_ICON, DEFAULT_PROJECT_ICON, requireIdentityChoice,
7
+ } from './identity-presets.js'
8
+
9
+ export type ResolvedIdentity = { title: string; icon: string }
10
+ export type GatewayIdentitySource = { identity: ResolvedIdentity; revision: string }
11
+
12
+ const revisionOf = (raw: string | null): string =>
13
+ createHash('sha256').update(raw === null ? 'missing' : `present\0${raw}`).digest('hex')
14
+
15
+ function readObject(file: string): { value: Record<string, any>; raw: string | null } {
16
+ if (!existsSync(file)) return { value: {}, raw: null }
17
+ const raw = readFileSync(file, 'utf8')
18
+ let value: unknown
19
+ try { value = JSON.parse(raw) }
20
+ catch (e) { throw new Error(`malformed ${file}: ${(e as Error).message}`) }
21
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${file} must contain one top-level JSON object`)
22
+ return { value: value as Record<string, any>, raw }
23
+ }
24
+
25
+ export function resolveProjectIdentity(configRoot: string, canonicalRoot = configRoot): ResolvedIdentity {
26
+ const { value } = readObject(join(configRoot, 'spexcode.json'))
27
+ const dashboard = value.dashboard && typeof value.dashboard === 'object' && !Array.isArray(value.dashboard)
28
+ ? value.dashboard as Record<string, unknown>
29
+ : {}
30
+ const configuredTitle = typeof dashboard.title === 'string' ? dashboard.title.trim() : ''
31
+ return {
32
+ title: configuredTitle || basename(canonicalRoot),
33
+ icon: resolvedIdentityIcon(dashboard.icon, DEFAULT_PROJECT_ICON),
34
+ }
35
+ }
36
+
37
+ export const hostConfigPath = (): string => join(spexcodeHome(), 'config.json')
38
+
39
+ export function readGatewayIdentity(): GatewayIdentitySource {
40
+ const { value, raw } = readObject(hostConfigPath())
41
+ const gateway = value.gateway && typeof value.gateway === 'object' && !Array.isArray(value.gateway)
42
+ ? value.gateway as Record<string, unknown>
43
+ : {}
44
+ return {
45
+ identity: { title: 'Projects', icon: resolvedIdentityIcon(gateway.icon, DEFAULT_GATEWAY_ICON) },
46
+ revision: revisionOf(raw),
47
+ }
48
+ }
49
+
50
+ export function writeGatewayIcon(icon: unknown, revision: string): GatewayIdentitySource {
51
+ const canonical = requireIdentityChoice(icon)
52
+ const file = hostConfigPath()
53
+ const current = readObject(file)
54
+ if (revision !== revisionOf(current.raw)) {
55
+ const error = new Error('host config changed on disk — reload before saving') as Error & { status?: number }
56
+ error.status = 409
57
+ throw error
58
+ }
59
+ const gateway = current.value.gateway && typeof current.value.gateway === 'object' && !Array.isArray(current.value.gateway)
60
+ ? current.value.gateway as Record<string, unknown>
61
+ : {}
62
+ const next = { ...current.value, gateway: { ...gateway, icon: canonical } }
63
+ const raw = `${JSON.stringify(next, null, 2)}\n`
64
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
65
+ const tmp = `${file}.${process.pid}.tmp`
66
+ try {
67
+ writeFileSync(tmp, raw, { mode: 0o600 })
68
+ renameSync(tmp, file)
69
+ } finally {
70
+ try { rmSync(tmp) } catch { /* rename consumed it / write never created it */ }
71
+ }
72
+ return { identity: { title: 'Projects', icon: canonical }, revision: revisionOf(raw) }
73
+ }