spexcode 0.1.6 → 0.2.0

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 (70) hide show
  1. package/README.md +86 -25
  2. package/package.json +5 -6
  3. package/spec-cli/README.md +86 -0
  4. package/spec-cli/bin/spex.mjs +15 -3
  5. package/spec-cli/hooks/dispatch.sh +20 -8
  6. package/spec-cli/hooks/harness.sh +18 -11
  7. package/spec-cli/src/board.ts +47 -18
  8. package/spec-cli/src/boardCache.ts +70 -0
  9. package/spec-cli/src/boardDelta.ts +90 -0
  10. package/spec-cli/src/boardStream.ts +178 -0
  11. package/spec-cli/src/cli.ts +172 -119
  12. package/spec-cli/src/client.ts +6 -4
  13. package/spec-cli/src/gateway.ts +49 -13
  14. package/spec-cli/src/git.ts +105 -92
  15. package/spec-cli/src/guide.ts +175 -12
  16. package/spec-cli/src/harness-select.ts +63 -0
  17. package/spec-cli/src/harness.ts +506 -100
  18. package/spec-cli/src/help.ts +360 -0
  19. package/spec-cli/src/hooks.ts +0 -14
  20. package/spec-cli/src/index.ts +272 -32
  21. package/spec-cli/src/init.ts +41 -1
  22. package/spec-cli/src/issues.ts +250 -0
  23. package/spec-cli/src/layout.ts +70 -28
  24. package/spec-cli/src/lint.ts +12 -10
  25. package/spec-cli/src/listen.ts +28 -0
  26. package/spec-cli/src/localIssues.ts +683 -0
  27. package/spec-cli/src/materialize.ts +182 -27
  28. package/spec-cli/src/mentions.ts +192 -0
  29. package/spec-cli/src/plugin-harness.ts +145 -0
  30. package/spec-cli/src/pty-bridge.ts +378 -81
  31. package/spec-cli/src/self.ts +123 -20
  32. package/spec-cli/src/sessions.ts +461 -298
  33. package/spec-cli/src/specs.ts +55 -14
  34. package/spec-cli/src/supervise.ts +23 -3
  35. package/spec-cli/src/tsx-bin.ts +14 -5
  36. package/spec-cli/src/uninstall.ts +146 -0
  37. package/spec-cli/templates/hooks/post-merge +27 -0
  38. package/spec-cli/templates/hooks/pre-commit +51 -31
  39. package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
  40. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
  41. package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
  42. package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
  43. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
  44. package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
  46. package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
  47. package/spec-cli/templates/spec/project/.config/spec.md +3 -3
  48. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
  49. package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
  50. package/spec-cli/templates/spec/project/spec.md +2 -2
  51. package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
  52. package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
  53. package/spec-dashboard/dist/index.html +17 -5
  54. package/spec-forge/src/cache.ts +16 -0
  55. package/spec-forge/src/drivers/github.ts +74 -4
  56. package/spec-forge/src/port.ts +25 -0
  57. package/spec-forge/src/resident.ts +40 -6
  58. package/spec-yatsu/src/cli.ts +227 -38
  59. package/spec-yatsu/src/evaltab.ts +169 -19
  60. package/spec-yatsu/src/filing.ts +48 -0
  61. package/spec-yatsu/src/freshness.ts +55 -20
  62. package/spec-yatsu/src/proof.ts +89 -3
  63. package/spec-yatsu/src/scenariofresh.ts +92 -0
  64. package/spec-yatsu/src/sidecar.ts +75 -11
  65. package/spec-yatsu/src/timeline.ts +47 -0
  66. package/spec-yatsu/src/yatsu.ts +47 -3
  67. package/spec-cli/src/relay.ts +0 -28
  68. package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
  69. package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
  70. package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
@@ -6,12 +6,12 @@
6
6
  // AND codex are covered with no hardcoded paths). It catches the SILENT failure: a shim whose handler is
7
7
  // missing, a PATH that can't resolve `spex`, a contract that never landed. Read-only today: `doctor`,
8
8
  // `contract` (print the surface:system text any agent reads), `env`. install/uninstall are STAGED (noteStaged).
9
- import { existsSync, readFileSync, accessSync, constants } from 'node:fs'
10
- import { join } from 'node:path'
9
+ import { existsSync, readFileSync, readdirSync, accessSync, constants } from 'node:fs'
10
+ import { join, dirname, basename } from 'node:path'
11
11
  import { fileURLToPath } from 'node:url'
12
12
  import { execFileSync } from 'node:child_process'
13
13
  import { homedir } from 'node:os'
14
- import { loadSystemConfig } from './specs.js'
14
+ import { loadSystemConfig, loadSkillConfig } from './specs.js'
15
15
  import { runtimeRoot, envSessionId, readAliasedRawRecord } from './layout.js'
16
16
 
17
17
  // this file lives at <pkgRoot>/src/self.ts, so `..` is the package root — the same derivation init.ts/
@@ -74,6 +74,110 @@ function manifestScripts(text: string): string[] {
74
74
  return [...out]
75
75
  }
76
76
 
77
+ // @@@ double-delivery - the SILENT conflict on the OTHER axis from under-delivery: not "did the contract
78
+ // land?" but "did it land TWICE?". A self-launched agent can be reached by BOTH the loose native delivery
79
+ // materialize() writes into the worktree AND a `spexcode` plugin bundle the user installed independently
80
+ // (Claude marketplace) or a stale leftover — doubling every hook (dispatch.sh fires per copy), shadowing
81
+ // skills, confusing the `/` menu. We never sniff payload: every count is by IDENTITY STAMP — a shim's
82
+ // `dispatch.sh` command line (the hook-routing stamp, the same one cleanHarness keys on), a plugin.json
83
+ // `name:"spexcode"` (the bundle stamp), and our own materialized skill NAMES. Per harness we count, on three
84
+ // channels, how many spexcode-stamped copies reach the agent; any channel >1 is a double-delivery conflict.
85
+
86
+ // a plugin bundle dir found under a harness's plugins root, carrying a spexcode stamp.
87
+ type Bundle = { dir: string; name: string; scope: string; hooksToDispatch: boolean; skillsDir: string }
88
+
89
+ // the bundle's declared name, from plugin.json (root or the .claude-plugin/ convention), or null when none.
90
+ function pluginName(dir: string): string | null {
91
+ for (const p of [join(dir, 'plugin.json'), join(dir, '.claude-plugin', 'plugin.json')]) {
92
+ if (!existsSync(p)) continue
93
+ try { const n = (JSON.parse(readFileSync(p, 'utf8')) as { name?: unknown }).name; if (typeof n === 'string') return n } catch { /* malformed → treat as nameless */ }
94
+ }
95
+ return null
96
+ }
97
+ // does this bundle wire a hooks shim that routes to OUR dispatch.sh? (hooks/hooks.json or a root hooks.json)
98
+ function bundleHooksToDispatch(dir: string): boolean {
99
+ return [join(dir, 'hooks', 'hooks.json'), join(dir, 'hooks.json')].some((p) => /dispatch\.sh/.test(read(p)))
100
+ }
101
+ // scan one plugins root's immediate children for SpexCode-stamped bundles (name=="spexcode", a dispatch.sh
102
+ // shim, or one of our own skill names) — three stamps, any one suffices. A non-dir / missing root → [].
103
+ function scanBundles(root: string, scope: string, ourSkills: string[]): Bundle[] {
104
+ let names: string[] = []
105
+ try { names = readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) } catch { return [] }
106
+ const out: Bundle[] = []
107
+ for (const n of names) {
108
+ const dir = join(root, n)
109
+ const skillsDir = join(dir, 'skills')
110
+ const pname = pluginName(dir)
111
+ const hooksToDispatch = bundleHooksToDispatch(dir)
112
+ const hasOurSkill = ourSkills.some((s) => existsSync(join(skillsDir, s, 'SKILL.md')) || existsSync(join(skillsDir, s)))
113
+ if (pname === 'spexcode' || hooksToDispatch || hasOurSkill) out.push({ dir, name: pname ?? n, scope, hooksToDispatch, skillsDir })
114
+ }
115
+ return out
116
+ }
117
+
118
+ // per-harness double-delivery report. For each harness it derives the plugins roots FROM THE ADAPTER's own
119
+ // shim path (`<cfgdir>/plugins` in-tree + `~/<cfgdir>/plugins` global — no hardcoded `.claude`, so a new
120
+ // harness scans for free), finds the spexcode bundles, then counts three channels against the loose native
121
+ // delivery. Returns the printable lines + whether ANY channel >1 (a live double-delivery).
122
+ async function doubleDeliveryReport(base: string): Promise<{ lines: string[]; conflict: boolean }> {
123
+ const { HARNESSES } = await import('./harness.js')
124
+ const ourSkills = (() => { try { return loadSkillConfig().map((c) => c.name) } catch { return [] as string[] } })()
125
+ const L: string[] = []
126
+ const line = (k: string, v: string) => L.push(` ${k.padEnd(16)}: ${v}`)
127
+ let conflict = false
128
+ const rel = (f: string) => f.startsWith(base + '/') ? f.slice(base.length + 1) : f
129
+
130
+ for (const h of HARNESSES) {
131
+ const shimFile = h.shimFile(base)
132
+ const looseDispatch = /dispatch\.sh/.test(read(shimFile))
133
+ const looseSkillDir = h.skillDir(base)
134
+ const looseContract = h.contractFiles(base).some((f) => /<!--\s*spexcode:start\s*-->/.test(read(f)))
135
+ const looseSkill = (s: string) => !!looseSkillDir && existsSync(join(looseSkillDir, s, 'SKILL.md'))
136
+ const looseDelivery = looseDispatch || looseContract || ourSkills.some(looseSkill)
137
+
138
+ const cfgDir = dirname(shimFile) // <proj>/.claude (codex: <main>/.codex)
139
+ const roots: [string, string][] = [[join(cfgDir, 'plugins'), 'workspace'], [join(homedir(), basename(cfgDir), 'plugins'), 'global']]
140
+ const bundles = roots.flatMap(([r, scope]) => scanBundles(r, scope, ourSkills))
141
+
142
+ // channel 1 — hooks → dispatch.sh (loose shim + any bundle hooks shim)
143
+ const hookSrc = [
144
+ ...(looseDispatch ? [`loose ${rel(shimFile)}`] : []),
145
+ ...bundles.filter((b) => b.hooksToDispatch).map((b) => `plugin "${b.name}" (${b.scope})`),
146
+ ]
147
+ // channel 2 — same-named skill in loose skillDir AND a bundle's skills dir
148
+ const skillHits: string[] = []
149
+ for (const s of ourSkills) {
150
+ let c = looseSkill(s) ? 1 : 0
151
+ c += bundles.filter((b) => existsSync(join(b.skillsDir, s, 'SKILL.md')) || existsSync(join(b.skillsDir, s))).length
152
+ if (c > 1) skillHits.push(`${s} ×${c}`)
153
+ }
154
+ // channel 3 — total delivery sources reaching this harness (loose + each spexcode bundle)
155
+ const sources = (looseDelivery ? 1 : 0) + bundles.length
156
+
157
+ const hConflict = hookSrc.length > 1 || skillHits.length > 0 || sources > 1
158
+ if (hConflict) conflict = true
159
+
160
+ L.push(`${h.id}${hConflict ? ' — DOUBLE-DELIVERY CONFLICT' : ''}`)
161
+ line('delivery srcs', `${sources}${sources > 1 ? ' (>1 → CONFLICT)' : sources === 1 ? ' (single — ok)' : ' (none)'}`)
162
+ line(' loose', looseDelivery ? `present (${[looseDispatch && 'shim→dispatch', looseContract && 'contract', ourSkills.some(looseSkill) && 'skills'].filter(Boolean).join('+')})` : 'absent')
163
+ for (const b of bundles) line(' plugin', `"${b.name}" (${b.scope}) — ${b.dir}`)
164
+ line('hooks→dispatch', `${hookSrc.length}${hookSrc.length > 1 ? ' (>1 → CONFLICT): ' + hookSrc.join(', ') : hookSrc.length === 1 ? ' (single — ok)' : ' (none wired)'}`)
165
+ line('skill shadowing', skillHits.length ? `CONFLICT: ${skillHits.join(', ')}` : 'none')
166
+ L.push('')
167
+ }
168
+
169
+ if (conflict) {
170
+ L.push('Repair — SpexCode is reaching this agent through MORE THAN ONE discovery channel. Keep exactly one:')
171
+ L.push(' • remove the independently-installed plugin bundle (delete its dir, or `claude plugin uninstall spexcode`); or')
172
+ L.push(' • if you WANT the plugin, stop the native delivery: set spexcode.json "harnesses" to a plugin target')
173
+ L.push(' (e.g. ["plugin",{"plugin":".claude"}] → {"plugin":".claude"}) so `spex materialize` prunes the loose shim/contract/skills; or')
174
+ L.push(' • remove the loose copy directly (`spex self uninstall` [staged] / `spex uninstall`).')
175
+ } else {
176
+ L.push('No double-delivery: each harness is reached by at most one spexcode-stamped channel.')
177
+ }
178
+ return { lines: L, conflict }
179
+ }
180
+
77
181
  // ping the backend (apiBase) with a short timeout so doctor never hangs on a dead/wrong SPEXCODE_API_URL.
78
182
  async function backendReachable(): Promise<{ base: string; up: boolean }> {
79
183
  let base = 'http://127.0.0.1:8787'
@@ -170,6 +274,11 @@ async function doctor(): Promise<number> {
170
274
  L.push('\nLayer 4 — session orchestration (backend-only: dispatch · queue · comms)')
171
275
  line('backend', up ? `reachable at ${backendBase}` : `not reachable at ${backendBase}`)
172
276
 
277
+ // --- double-delivery: the same agent reached through two discovery channels (loose + a plugin bundle) ---
278
+ const dd = await doubleDeliveryReport(base)
279
+ L.push('\nLayer 5 — double-delivery (one harness, two discovery channels)')
280
+ L.push(...dd.lines)
281
+
173
282
  // --- verdict ---
174
283
  L.push('\nCoverage verdict')
175
284
  line('preconditions', resolveOnPath('spex') ? 'spex resolves' : 'BLOCKED — spex not on PATH (fix first)')
@@ -177,6 +286,7 @@ async function doctor(): Promise<number> {
177
286
  line('layer 2', body.length === 0 ? 'ABSENT (no contract)' : 'see per-harness above')
178
287
  line('layer 3', manifestText ? 'see handler-existence above' : 'ABSENT (no manifest — agent ungoverned)')
179
288
  line('layer 4', up ? 'present' : managed ? 'EXPECTED but backend down' : 'absent (normal for bring-your-own-agent)')
289
+ line('layer 5', dd.conflict ? 'CONFLICT (double-delivery — see Layer 5; `spex self conflicts`)' : 'clean (single channel)')
180
290
 
181
291
  // --- footprint: every artifact Spex wrote here, + any slot held by something not ours ---
182
292
  L.push('\nFootprint (what Spex wrote into this environment)')
@@ -205,24 +315,17 @@ function contract(): number {
205
315
  return 0
206
316
  }
207
317
 
208
- // raw environment facts doctor reasons over for debugging the diagnosis itself.
209
- function env(): number {
318
+ // the focused double-delivery check: JUST Layer 5, exit non-zero when a conflict is live so it gates a script
319
+ // / yatsu. Anchors at the repo root like doctor (the shims + contract + skills live there).
320
+ async function conflicts(): Promise<number> {
210
321
  const cwd = process.cwd()
211
- const facts: Record<string, string> = {
212
- cwd,
213
- repoRoot: repoRoot(cwd) ?? '(not a git repo)',
214
- branch: git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']) ?? '—',
215
- node: process.version,
216
- CLAUDE_CODE_SESSION_ID: process.env.CLAUDE_CODE_SESSION_ID ?? '',
217
- CODEX_THREAD_ID: process.env.CODEX_THREAD_ID ?? '',
218
- SPEXCODE_API_URL: process.env.SPEXCODE_API_URL ?? '(default :8787)',
219
- 'PATH spex': resolveOnPath('spex') ?? '(not on PATH)',
220
- tmux: process.env.TMUX ? 'inside tmux' : 'no',
221
- }
222
- for (const [k, v] of Object.entries(facts)) console.log(`${k.padEnd(24)}: ${v}`)
223
- return 0
322
+ const base = repoRoot(cwd) ?? cwd
323
+ const { lines, conflict } = await doubleDeliveryReport(base)
324
+ console.log(['spex self conflicts does SpexCode reach this agent through more than one discovery channel?\n', ...lines].join('\n'))
325
+ return conflict ? 1 : 0
224
326
  }
225
327
 
328
+
226
329
  // install/uninstall are STAGED: wiring layer-3 hooks into a standalone repo is only SAFE once the hooks
227
330
  // detect a missing managed session and degrade. So the diagnosis ships first; the installer lands behind it.
228
331
  function noteStaged(verb: string): number {
@@ -237,7 +340,7 @@ function usage(): number {
237
340
  console.error(`spex self — diagnose how the SpexCode workflow reaches your agent
238
341
  doctor per-layer report: preconditions · git-hook floor · contract · hooks(+handlers) · backend · footprint (default)
239
342
  contract print the surface:system contract text (hand it to any agent)
240
- env raw environment facts the diagnosis reads
343
+ conflicts detect double-delivery the same agent reached via loose native delivery AND a plugin bundle (exits non-zero on conflict)
241
344
  install [staged] wire the materialized contract + hooks into your agent (--agent claude, --minimal)
242
345
  uninstall [staged] reverse exactly what install wrote`)
243
346
  return 0
@@ -247,7 +350,7 @@ export async function runSelf(args: string[]): Promise<number> {
247
350
  switch (args[0] ?? 'doctor') {
248
351
  case 'doctor': return await doctor()
249
352
  case 'contract': return contract()
250
- case 'env': return env()
353
+ case 'conflicts': return await conflicts()
251
354
  case 'install': return noteStaged('install')
252
355
  case 'uninstall': return noteStaged('uninstall')
253
356
  case 'help': case '--help': case '-h': return usage()