spexcode 0.2.1 → 0.2.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 (52) hide show
  1. package/README.md +158 -103
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +24 -1
  4. package/spec-cli/src/attach.ts +50 -0
  5. package/spec-cli/src/cli.ts +217 -64
  6. package/spec-cli/src/client.ts +47 -9
  7. package/spec-cli/src/{self.ts → doctor.ts} +26 -25
  8. package/spec-cli/src/guide.ts +79 -21
  9. package/spec-cli/src/harness.ts +53 -29
  10. package/spec-cli/src/help.ts +137 -49
  11. package/spec-cli/src/index.ts +31 -11
  12. package/spec-cli/src/issues.ts +48 -21
  13. package/spec-cli/src/layout.ts +3 -5
  14. package/spec-cli/src/lint.ts +34 -5
  15. package/spec-cli/src/localIssues.ts +44 -60
  16. package/spec-cli/src/materialize.ts +4 -2
  17. package/spec-cli/src/mentions.ts +22 -1
  18. package/spec-cli/src/pty-bridge.ts +39 -4
  19. package/spec-cli/src/ranker.ts +31 -12
  20. package/spec-cli/src/search.bench.mjs +30 -7
  21. package/spec-cli/src/search.ts +39 -0
  22. package/spec-cli/src/sessions.ts +160 -69
  23. package/spec-cli/src/specs.ts +16 -4
  24. package/spec-cli/src/supervise.ts +30 -6
  25. package/spec-cli/src/tree.ts +118 -0
  26. package/spec-cli/templates/hooks/post-merge +2 -2
  27. package/spec-cli/templates/hooks/pre-commit +34 -15
  28. package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
  29. package/spec-cli/templates/spexcode.json +7 -0
  30. package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
  31. package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +27 -0
  32. package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +3 -0
  33. package/spec-dashboard/dist/assets/FoldToggle-B5leylLf.js +1 -0
  34. package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +1 -0
  35. package/spec-dashboard/dist/assets/MobileApp-RHNECU6x.js +1 -0
  36. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
  37. package/spec-dashboard/dist/assets/SessionInterface-YLD6IOmC.js +71 -0
  38. package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +9 -0
  39. package/spec-dashboard/dist/assets/Settings-ZnOwskMZ.js +1 -0
  40. package/spec-dashboard/dist/assets/index-BdRQfrkR.js +41 -0
  41. package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +1 -0
  42. package/spec-dashboard/dist/index.html +2 -2
  43. package/spec-yatsu/src/cli.ts +128 -26
  44. package/spec-yatsu/src/evaltab.ts +7 -6
  45. package/spec-yatsu/src/filing.ts +6 -3
  46. package/spec-yatsu/src/proof.ts +10 -0
  47. package/spec-yatsu/src/scenariofresh.ts +100 -30
  48. package/spec-yatsu/src/sidecar.ts +25 -3
  49. package/spec-yatsu/src/timeline.ts +53 -23
  50. package/README.zh-CN.md +0 -135
  51. package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +0 -32
  52. package/spec-dashboard/dist/assets/index-DehTZ-h9.js +0 -145
@@ -3,13 +3,14 @@
3
3
  import net from 'node:net'
4
4
  import http from 'node:http'
5
5
  import { spawn, type ChildProcess } from 'node:child_process'
6
- import { statSync, readdirSync, type Dirent } from 'node:fs'
6
+ import { statSync, readdirSync, mkdirSync, writeFileSync, readFileSync, rmSync, type Dirent } from 'node:fs'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import { dirname, join } from 'node:path'
9
9
  import { installProcessGuards } from './resilience.js'
10
10
  import { listenOrExit } from './listen.js'
11
11
  import { resolvePublicConfig, startGateway, ensureDashboardBuilt, resolveDistDir } from './gateway.js'
12
12
  import { tsxBin } from './tsx-bin.js'
13
+ import { runtimeRoot } from './layout.js'
13
14
 
14
15
  // the supervisor OWNS the public port, so it must outlive any transient throw: an uncaught error here is
15
16
  // logged and survived, never an exit that closes the port (and the tmux session) and takes the frontend down.
@@ -74,8 +75,11 @@ async function boot(): Promise<Backend | null> {
74
75
  const port = await freePort()
75
76
  // PORT pins the child's PRIVATE bind port; SPEXCODE_API_URL pins everything the child SPAWNS (launched
76
77
  // sessions + their hooks) at the PUBLIC port, so a launched agent's own `spex` reaches the stable proxy
77
- // and never inherits this ephemeral, soon-retired port (apiBase() prefers SPEXCODE_API_URL over PORT).
78
- const child = spawn(tsx, [entry], { stdio: 'inherit', env: { ...process.env, PORT: String(port), SPEXCODE_API_URL: process.env.SPEXCODE_API_URL || childApiBase } })
78
+ // and never inherits this ephemeral, soon-retired port. ALWAYS childApiBase, never the ambient
79
+ // process.env.SPEXCODE_API_URL: the env this serve itself inherited may carry ANOTHER project's backend
80
+ // (the exact misroute [[remote-client]]'s ladder exists to kill), and a worker's env is its routing
81
+ // LIFELINE — it must be a deterministic backend-injected fact, not an inheritance gamble.
82
+ const child = spawn(tsx, [entry], { stdio: 'inherit', env: { ...process.env, PORT: String(port), SPEXCODE_API_URL: childApiBase } })
79
83
  // if the ACTIVE backend dies unexpectedly (crash, OOM), restart it so the public port keeps serving.
80
84
  // Planned retirement sets current to the NEW child first, so the old child's exit fails this identity
81
85
  // check and is ignored. boot()'s ~5s health budget rate-limits any crash loop.
@@ -130,7 +134,27 @@ const proxy = net.createServer((client) => {
130
134
  client.pipe(up); up.pipe(client)
131
135
  })
132
136
 
133
- const shutdown = () => { try { current?.child.kill('SIGTERM') } catch { /* */ } process.exit(0) }
137
+ // @@@ endpoint record - this project's live backend endpoint, written into the per-project runtime tier
138
+ // (~/.spexcode/projects/<enc>/backend.json) only AFTER the public bind succeeds. It's what lets a bare
139
+ // `spex` run from this project's tree find ITS OWN backend instead of an env URL inherited from another
140
+ // project's ([[remote-client]]'s resolution ladder). Readers /health-probe before trusting, so a crash
141
+ // leaves at worst a dead record that is ignored — never followed. The recorded URL is the LOOPBACK face
142
+ // local agents reach (equals the public port when public mode is off), never the password-gated gateway.
143
+ const backendRecordPath = () => join(runtimeRoot(), 'backend.json')
144
+ function recordEndpoint(url: string): void {
145
+ try {
146
+ mkdirSync(runtimeRoot(), { recursive: true })
147
+ writeFileSync(backendRecordPath(), JSON.stringify({ url, pid: process.pid, startedAt: new Date().toISOString() }, null, 2) + '\n')
148
+ } catch (e) {
149
+ console.error(`[supervisor] could not record the backend endpoint (${(e as Error).message}) — cwd-based \`spex\` discovery won't find this backend`)
150
+ }
151
+ }
152
+ // best-effort removal on a clean stop, only if the record is OURS (a newer serve may have overwritten it).
153
+ function dropEndpoint(): void {
154
+ try { if (JSON.parse(readFileSync(backendRecordPath(), 'utf8'))?.pid === process.pid) rmSync(backendRecordPath()) } catch { /* not ours / already gone */ }
155
+ }
156
+
157
+ const shutdown = () => { dropEndpoint(); try { current?.child.kill('SIGTERM') } catch { /* */ } process.exit(0) }
134
158
  process.on('SIGINT', shutdown)
135
159
  process.on('SIGTERM', shutdown)
136
160
 
@@ -146,10 +170,10 @@ if (publicCfg) {
146
170
  // public mode: the raw proxy stays on loopback; the password-gated gateway owns the public port.
147
171
  const distDir = resolveDistDir() // bundled <pkg>/dashboard-dist when installed, else monorepo spec-dashboard/dist
148
172
  ensureDashboardBuilt(repoRoot, distDir)
149
- listenOrExit(proxy, proxyPort, { host: '127.0.0.1', label: 'supervisor (loopback proxy)', cleanup: reapChild, onListen: () => console.log(`spec-cli supervisor on loopback :${proxyPort} (zero-downtime reloads, backend :${first.port})`) })
173
+ listenOrExit(proxy, proxyPort, { host: '127.0.0.1', label: 'supervisor (loopback proxy)', cleanup: reapChild, onListen: () => { recordEndpoint(childApiBase); console.log(`spec-cli supervisor on loopback :${proxyPort} (zero-downtime reloads, backend :${first.port})`) } })
150
174
  startGateway({ publicPort, upstreamPort: proxyPort, password: publicCfg.password, tls: publicCfg.tls, distDir, onBindFail: reapChild })
151
175
  } else {
152
- listenOrExit(proxy, publicPort, { label: 'supervisor', cleanup: reapChild, onListen: () => console.log(`spec-cli supervisor serving on http://localhost:${publicPort} (zero-downtime reloads, backend :${first.port})`) })
176
+ listenOrExit(proxy, publicPort, { label: 'supervisor', cleanup: reapChild, onListen: () => { recordEndpoint(childApiBase); console.log(`spec-cli supervisor serving on http://localhost:${publicPort} (zero-downtime reloads, backend :${first.port})`) } })
153
177
  }
154
178
 
155
179
  // watch every imported source tree; debounce a burst of writes (a merge touching several files across
@@ -0,0 +1,118 @@
1
+ // @@@ spex tree - the CLI's human-readable graph view: the same assembled board the dashboard's
2
+ // tidy-tree renders, as an indented terminal tree. Pure presentation over buildBoard()'s nodes —
3
+ // no read path of its own; status colours and badge semantics mirror the dashboard (drift /
4
+ // stale-yatsu / open-issues counts). Governed by the spex-tree spec node.
5
+
6
+ // the subset of a board node this view consumes (buildBoard attaches more; we read only these).
7
+ export type TreeNode = {
8
+ id: string
9
+ parent: string | null
10
+ title: string
11
+ status: string
12
+ version?: number
13
+ drift?: number
14
+ ghost?: boolean
15
+ openIssues?: unknown[]
16
+ scenarios?: { name: string }[]
17
+ evals?: { scenario: string; fresh?: boolean }[]
18
+ }
19
+
20
+ export type TreeOpts = { node?: string; depth?: number; color?: boolean }
21
+
22
+ // dashboard status palette mapped onto ANSI: merged=green, active=cyan (live/in-flight, distinct
23
+ // from the warning yellow), drift=yellow (the dashboard's warning colour), pending=muted grey.
24
+ const STATUS_ANSI: Record<string, string> = { merged: '32', active: '36', drift: '33', pending: '90' }
25
+
26
+ // stale-yatsu count: declared scenarios whose LATEST reading exists but is no longer fresh.
27
+ // board `evals` is already latest-per-scenario, so this is a straight filter — the same freshness
28
+ // axis the dashboard's grey ✓/✗ badges read (score.jsx readingScore).
29
+ function staleYatsu(n: TreeNode): number {
30
+ if (!n.scenarios?.length || !n.evals?.length) return 0
31
+ const latest = new Map(n.evals.map((r) => [r.scenario, r]))
32
+ return n.scenarios.filter((s) => { const r = latest.get(s.name); return r && r.fresh === false }).length
33
+ }
34
+
35
+ function childrenIndex(nodes: TreeNode[]): Map<string | null, TreeNode[]> {
36
+ const byParent = new Map<string | null, TreeNode[]>()
37
+ for (const n of nodes) {
38
+ const list = byParent.get(n.parent) ?? []
39
+ list.push(n)
40
+ byParent.set(n.parent, list)
41
+ }
42
+ return byParent
43
+ }
44
+
45
+ // resolve the display roots: --node picks one subtree; default is the forest of parentless nodes
46
+ // (a node whose parent id isn't on the board — a ghost mid-add, say — surfaces as a root rather
47
+ // than vanishing). Unknown --node throws (fail loud, caller prints + exits 2), never an empty tree.
48
+ function roots(nodes: TreeNode[], nodeId?: string): TreeNode[] {
49
+ if (nodeId) {
50
+ const hit = nodes.find((n) => n.id === nodeId)
51
+ if (!hit) throw new Error(`no spec node "${nodeId}" — spex tree lists every id; spex search <topic> finds one by intent`)
52
+ return [hit]
53
+ }
54
+ const ids = new Set(nodes.map((n) => n.id))
55
+ return nodes.filter((n) => n.parent === null || !ids.has(n.parent))
56
+ }
57
+
58
+ export function renderTree(nodes: TreeNode[], opts: TreeOpts = {}): string {
59
+ const color = opts.color ?? false
60
+ const c = (code: string, t: string) => (color ? `\x1b[${code}m${t}\x1b[0m` : t)
61
+ const byParent = childrenIndex(nodes)
62
+ const lines: string[] = []
63
+
64
+ const badges = (n: TreeNode): string => {
65
+ const parts: string[] = []
66
+ if (n.ghost) parts.push(c('90', 'ghost'))
67
+ if (n.drift) parts.push(c('33', `drift:${n.drift}`))
68
+ const stale = staleYatsu(n)
69
+ if (stale) parts.push(c('90', `stale:${stale}`))
70
+ if (n.openIssues?.length) parts.push(c('31', `issues:${n.openIssues.length}`))
71
+ return parts.length ? ' ' + parts.join(' ') : ''
72
+ }
73
+
74
+ const line = (n: TreeNode, prefix: string, branch: string) => {
75
+ const code = STATUS_ANSI[n.status] ?? '0'
76
+ // base state is invisible: merged (~80% of nodes) is the healthy ground state, already carried by
77
+ // the dot's colour — only deviations get a text label. Without colour the word IS the signal, so
78
+ // every status (merged included) prints in plain-text mode.
79
+ const label = color && n.status === 'merged' ? '' : ' ' + c(code, `[${n.status}]`)
80
+ const title = n.title && n.title !== n.id ? ' ' + c('90', '· ' + n.title) : ''
81
+ lines.push(`${prefix}${branch}${c(code, '●')} ${n.id}${label}${title}${badges(n)}`)
82
+ }
83
+
84
+ const walk = (n: TreeNode, prefix: string, branch: string, childPrefix: string, depth: number) => {
85
+ line(n, prefix, branch)
86
+ const kids = byParent.get(n.id) ?? []
87
+ if (!kids.length) return
88
+ if (opts.depth !== undefined && depth >= opts.depth) {
89
+ lines.push(`${childPrefix}${c('90', `└─ … ${kids.length} more (raise --depth)`)}`)
90
+ return
91
+ }
92
+ kids.forEach((k, i) => {
93
+ const last = i === kids.length - 1
94
+ walk(k, childPrefix, last ? '└─ ' : '├─ ', childPrefix + (last ? ' ' : '│ '), depth + 1)
95
+ })
96
+ }
97
+
98
+ for (const r of roots(nodes, opts.node)) walk(r, '', '', '', 0)
99
+ return lines.join('\n')
100
+ }
101
+
102
+ // the machine exit: the same filtered subtree as NESTED objects, badge counts precomputed — a
103
+ // shaped view, not a replacement for `spex board` (which stays the full flat payload). Pruned
104
+ // children degrade to their ids, so --depth still tells the machine what exists below the cut.
105
+ export function treeJson(nodes: TreeNode[], opts: TreeOpts = {}): object[] {
106
+ const byParent = childrenIndex(nodes)
107
+ const shape = (n: TreeNode, depth: number): object => {
108
+ const kids = byParent.get(n.id) ?? []
109
+ const pruned = opts.depth !== undefined && depth >= opts.depth
110
+ return {
111
+ id: n.id, title: n.title, status: n.status, version: n.version ?? 0,
112
+ drift: n.drift ?? 0, staleYatsu: staleYatsu(n), openIssues: n.openIssues?.length ?? 0,
113
+ ...(n.ghost ? { ghost: true } : {}),
114
+ children: pruned ? kids.map((k) => k.id) : kids.map((k) => shape(k, depth + 1)),
115
+ }
116
+ }
117
+ return roots(nodes, opts.node).map((r) => shape(r, 0))
118
+ }
@@ -21,7 +21,7 @@ if command -v spex >/dev/null 2>&1; then
21
21
  spex issues nudge "$node"
22
22
  elif [ -x "$repo_root/node_modules/.bin/spex" ]; then
23
23
  "$repo_root/node_modules/.bin/spex" issues nudge "$node"
24
- elif [ -x "$main_root/spec-cli/node_modules/.bin/tsx" ] && [ -f "$main_root/spec-cli/src/cli.ts" ]; then
25
- "$main_root/spec-cli/node_modules/.bin/tsx" "$main_root/spec-cli/src/cli.ts" issues nudge "$node"
24
+ elif [ -x "$main_root/spec-cli/bin/spex.mjs" ]; then
25
+ "$main_root/spec-cli/bin/spex.mjs" issues nudge "$node"
26
26
  fi
27
27
  exit 0
@@ -7,7 +7,7 @@
7
7
  # no node_modules of its own, so the dogfood tsx path reaches the main checkout's. First that exists wins:
8
8
  # 1. `spex` on PATH - installed globally / on the shell PATH.
9
9
  # 2. <repo>/node_modules/.bin/spex - a project that did `npm i @spexcode/spec-cli` (the dep's bin).
10
- # 3. monorepo tsx + cli.ts by path - dogfood: run the CLI from source.
10
+ # 3. monorepo launcher by path - dogfood: bin/spex.mjs (owns tsx resolution + the mid-merge guard).
11
11
  main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
12
12
  repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
13
13
  spex_kind=
@@ -15,21 +15,33 @@ if command -v spex >/dev/null 2>&1; then
15
15
  spex_kind=path
16
16
  elif [ -x "$repo_root/node_modules/.bin/spex" ]; then
17
17
  spex_kind=local
18
- elif [ -x "$main_root/spec-cli/node_modules/.bin/tsx" ] && [ -f "$main_root/spec-cli/src/cli.ts" ]; then
19
- spex_kind=tsx
18
+ elif [ -x "$main_root/spec-cli/bin/spex.mjs" ]; then
19
+ spex_kind=pkg
20
20
  fi
21
21
  spex_cli() {
22
22
  case "$spex_kind" in
23
23
  path) spex "$@" ;;
24
24
  local) "$repo_root/node_modules/.bin/spex" "$@" ;;
25
- tsx) "$main_root/spec-cli/node_modules/.bin/tsx" "$main_root/spec-cli/src/cli.ts" "$@" ;;
25
+ pkg) "$main_root/spec-cli/bin/spex.mjs" "$@" ;;
26
26
  *) return 127 ;;
27
27
  esac
28
28
  }
29
29
 
30
+ # @@@ tree-unchanged stamp - a commit whose tree EQUALS HEAD's tree (`git write-tree` over the commit's own
31
+ # index — the hook's GIT_INDEX_FILE, a temp HEAD-tree index for `commit --only --allow-empty`) adds NO
32
+ # content: it cannot smuggle code past the main-guard below and cannot introduce spec↔code drift, so the
33
+ # whole hook passes it. This is `spex ack`'s empty Spec-OK stamp — the reason ack works on a trunk merge
34
+ # commit (amending it would re-author the merge after MERGE_HEAD is gone and read as direct authoring) and
35
+ # with a DIRTY real index (the lint shim below reads the real index via git.ts's env-strip, which would
36
+ # otherwise block the stamp on the very drift it acknowledges). Deliberately a one-line tree compare, not an
37
+ # "is this an amend?" heuristic (undecidable at pre-commit time).
38
+ if [ "$(git write-tree 2>/dev/null)" = "$(git rev-parse 'HEAD^{tree}' 2>/dev/null || echo none)" ]; then
39
+ exit 0
40
+ fi
41
+
30
42
  # @@@ main-guard - the worktree model: never author directly on the trunk; the trunk only RECEIVES merges.
31
- # Reject a direct commit while HEAD is the trunk. Merges pass (MERGE_HEAD present).
32
- # Escape hatch for seeding / eager topology: SPEXCODE_ALLOW_MAIN=1 git commit …
43
+ # Reject a direct commit while HEAD is the trunk. Merges pass (MERGE_HEAD present), and a tree-unchanged
44
+ # stamp already passed above. Escape hatch for seeding / eager topology: SPEXCODE_ALLOW_MAIN=1 git commit …
33
45
  #
34
46
  # The trunk is resolved the SAME way the rest of SpexCode resolves it — `spex internal trunk` = layout.ts
35
47
  # mainBranch() (config override → the main checkout's current branch → 'main') — so the guard protects
@@ -48,7 +60,7 @@ trunk=$(spex_cli internal trunk 2>/dev/null | head -1)
48
60
  if [ "$branch" = "$trunk" ] && [ ! -f "$git_dir/MERGE_HEAD" ] && [ -z "${SPEXCODE_ALLOW_MAIN:-}" ]; then
49
61
  echo "✗ SpexCode: direct commits on $trunk (the trunk) are blocked." >&2
50
62
  echo " Work in a worktree (.worktrees/<node>, branch node/<id>) and merge back." >&2
51
- echo " Merges pass automatically; for seeding/topology: SPEXCODE_ALLOW_MAIN=1 git commit …" >&2
63
+ echo " Merges and tree-unchanged stamps (spex ack) pass automatically; for seeding/topology: SPEXCODE_ALLOW_MAIN=1 git commit …" >&2
52
64
  exit 1
53
65
  fi
54
66
 
@@ -58,16 +70,23 @@ fi
58
70
  # hook's GIT_DIR, so lint validates THIS worktree's `.spec`).
59
71
  if [ -z "${SPEXCODE_SKIP_LINT:-}" ]; then
60
72
  if [ -n "$spex_kind" ]; then
61
- if ! spex_cli lint >&2; then
73
+ spex_cli lint >&2
74
+ lint_rc=$?
75
+ # exit 75 = the launcher's mid-merge guard (spex's own source holds conflict markers — transient).
76
+ # Degrade to advisory rather than walling every commit behind a merge someone else is resolving.
77
+ if [ "$lint_rc" -eq 75 ]; then
78
+ echo "• SpexCode: spec-lint skipped — spex is paused mid-merge (exit 75); CI still enforces." >&2
79
+ elif [ "$lint_rc" -ne 0 ]; then
62
80
  echo "✗ SpexCode: spec-lint failed — fix the spec↔code links or bypass with SPEXCODE_SKIP_LINT=1." >&2
63
81
  exit 1
64
- fi
65
- # @@@ yatsu backstop - reject a staged yatsu offender: a stray pixel blob copied into the tree (blobs
66
- # belong in the shared git common dir, never committed) OR a malformed yatsu.md (a scenario schema
67
- # violation). Logic lives in spec-yatsu; this is the lint-shim's twin. (this node's contract)
68
- if ! spex_cli yatsu check-staged >&2; then
69
- echo "✗ SpexCode: yatsu check-staged failed (see above) — fix it or bypass with SPEXCODE_SKIP_LINT=1." >&2
70
- exit 1
82
+ else
83
+ # @@@ yatsu backstop - reject a staged yatsu offender: a stray pixel blob copied into the tree (blobs
84
+ # belong in the shared git common dir, never committed) OR a malformed yatsu.md (a scenario schema
85
+ # violation). Logic lives in spec-yatsu; this is the lint-shim's twin. (this node's contract)
86
+ if ! spex_cli yatsu check-staged >&2; then
87
+ echo "✗ SpexCode: yatsu check-staged failed (see above) — fix it or bypass with SPEXCODE_SKIP_LINT=1." >&2
88
+ exit 1
89
+ fi
71
90
  fi
72
91
  else
73
92
  # Advisory hook: no CLI found (project hasn't installed @spexcode/spec-cli; monorepo not `npm install`ed).
@@ -33,5 +33,12 @@ if [ -z "$sid" ] && [ -n "${CODEX_THREAD_ID:-}" ]; then # codex: alias the threa
33
33
  fi
34
34
 
35
35
  [ -z "$sid" ] && exit 0
36
- printf '\nSession: %s\n' "$sid" >> "$msg_file"
36
+ # Stamp via interpret-trailers (git's own sample-hook mechanism), NOT a raw printf append: appending after a
37
+ # message that already carries a trailer block (e.g. `spex ack`'s Spec-OK) opens a new paragraph, and git then
38
+ # parses ONLY the last paragraph as trailers — the earlier trailers silently become body prose. interpret-trailers
39
+ # appends into the existing block, keeping ONE parseable block. It needs the file newline-terminated first:
40
+ # on an unterminated message (git merge -m writes MERGE_MSG without one) it folds the trailer into the subject
41
+ # paragraph, where git's trailer parser never looks — the reason merge commits' Session: never parsed.
42
+ [ -n "$(tail -c1 "$msg_file")" ] && echo >> "$msg_file"
43
+ git interpret-trailers --in-place --trailer "Session: $sid" "$msg_file"
37
44
  exit 0
@@ -1,5 +1,12 @@
1
1
  {
2
2
  "lint": {
3
3
  "governedRoots": ["."]
4
+ },
5
+ "sessions": {
6
+ "launchers": {
7
+ "claude": { "harness": "claude", "cmd": "claude --dangerously-skip-permissions" },
8
+ "codex": { "harness": "codex", "cmd": "codex --yolo" }
9
+ },
10
+ "defaultLauncher": "claude"
4
11
  }
5
12
  }
@@ -0,0 +1 @@
1
+ .react-flow{direction:ltr;--xy-edge-stroke-default: #b1b1b7;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #555;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(255, 255, 255, .5);--xy-minimap-background-color-default: #fff;--xy-minimap-mask-background-color-default: rgba(240, 240, 240, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #e2e2e2;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: transparent;--xy-background-pattern-dots-color-default: #91919a;--xy-background-pattern-lines-color-default: #eee;--xy-background-pattern-cross-color-default: #e2e2e2;background-color:var(--xy-background-color, var(--xy-background-color-default));--xy-node-color-default: inherit;--xy-node-border-default: 1px solid #1a192b;--xy-node-background-color-default: #fff;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(0, 0, 0, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #1a192b;--xy-node-border-radius-default: 3px;--xy-handle-background-color-default: #1a192b;--xy-handle-border-color-default: #fff;--xy-selection-background-color-default: rgba(0, 89, 220, .08);--xy-selection-border-default: 1px dotted rgba(0, 89, 220, .8);--xy-controls-button-background-color-default: #fefefe;--xy-controls-button-background-color-hover-default: #f4f4f4;--xy-controls-button-color-default: inherit;--xy-controls-button-color-hover-default: inherit;--xy-controls-button-border-color-default: #eee;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #ffffff;--xy-edge-label-color-default: inherit;--xy-resize-background-color-default: #3367d9}.react-flow.dark{--xy-edge-stroke-default: #3e3e3e;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #727272;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(150, 150, 150, .25);--xy-minimap-background-color-default: #141414;--xy-minimap-mask-background-color-default: rgba(60, 60, 60, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #2b2b2b;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: #141414;--xy-background-pattern-dots-color-default: #777;--xy-background-pattern-lines-color-default: #777;--xy-background-pattern-cross-color-default: #777;--xy-node-color-default: #f8f8f8;--xy-node-border-default: 1px solid #3c3c3c;--xy-node-background-color-default: #1e1e1e;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(255, 255, 255, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #999;--xy-handle-background-color-default: #bebebe;--xy-handle-border-color-default: #1e1e1e;--xy-selection-background-color-default: rgba(200, 200, 220, .08);--xy-selection-border-default: 1px dotted rgba(200, 200, 220, .8);--xy-controls-button-background-color-default: #2b2b2b;--xy-controls-button-background-color-hover-default: #3e3e3e;--xy-controls-button-color-default: #f8f8f8;--xy-controls-button-color-hover-default: #fff;--xy-controls-button-border-color-default: #5b5b5b;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #141414;--xy-edge-label-color-default: #f8f8f8}.react-flow__background{background-color:var(--xy-background-color-props, var(--xy-background-color, var(--xy-background-color-default)));pointer-events:none;z-index:-1}.react-flow__container{position:absolute;width:100%;height:100%;top:0;left:0}.react-flow__pane{z-index:1;touch-action:none}.react-flow__pane.draggable{cursor:grab}.react-flow__pane.dragging{cursor:grabbing}.react-flow__pane.selection{cursor:pointer}.react-flow__viewport{transform-origin:0 0;z-index:2;pointer-events:none}.react-flow__renderer{z-index:4}.react-flow__selection{z-index:6}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible{outline:none}.react-flow__edge-path{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default));stroke-width:var(--xy-edge-stroke-width, var(--xy-edge-stroke-width-default));fill:none}.react-flow__connection-path{stroke:var(--xy-connectionline-stroke, var(--xy-connectionline-stroke-default));stroke-width:var(--xy-connectionline-stroke-width, var(--xy-connectionline-stroke-width-default));fill:none}.react-flow .react-flow__edges{position:absolute}.react-flow .react-flow__edges svg{overflow:visible;position:absolute;pointer-events:none}.react-flow__edge{pointer-events:visibleStroke}.react-flow__edge.selectable{cursor:pointer}.react-flow__edge.animated path{stroke-dasharray:5;animation:dashdraw .5s linear infinite}.react-flow__edge.animated path.react-flow__edge-interaction{stroke-dasharray:none;animation:none}.react-flow__edge.inactive{pointer-events:none}.react-flow__edge.selected,.react-flow__edge:focus,.react-flow__edge:focus-visible{outline:none}.react-flow__edge.selected .react-flow__edge-path,.react-flow__edge.selectable:focus .react-flow__edge-path,.react-flow__edge.selectable:focus-visible .react-flow__edge-path{stroke:var(--xy-edge-stroke-selected, var(--xy-edge-stroke-selected-default))}.react-flow__edge-textwrapper{pointer-events:all}.react-flow__edge .react-flow__edge-text{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__arrowhead polyline{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__arrowhead polyline.arrowclosed{fill:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__connection{pointer-events:none}.react-flow__connection .animated{stroke-dasharray:5;animation:dashdraw .5s linear infinite}svg.react-flow__connectionline{z-index:1001;overflow:visible;position:absolute}.react-flow__nodes{pointer-events:none;transform-origin:0 0}.react-flow__node{position:absolute;-webkit-user-select:none;-moz-user-select:none;user-select:none;pointer-events:all;transform-origin:0 0;box-sizing:border-box;cursor:default}.react-flow__node.selectable{cursor:pointer}.react-flow__node.draggable{cursor:grab;pointer-events:all}.react-flow__node.draggable.dragging{cursor:grabbing}.react-flow__nodesselection{z-index:3;transform-origin:left top;pointer-events:none}.react-flow__nodesselection-rect{position:absolute;pointer-events:all;cursor:grab}.react-flow__handle{position:absolute;pointer-events:none;min-width:5px;min-height:5px;width:6px;height:6px;background-color:var(--xy-handle-background-color, var(--xy-handle-background-color-default));border:1px solid var(--xy-handle-border-color, var(--xy-handle-border-color-default));border-radius:100%}.react-flow__handle.connectingfrom{pointer-events:all}.react-flow__handle.connectionindicator{pointer-events:all;cursor:crosshair}.react-flow__handle-bottom{top:auto;left:50%;bottom:0;transform:translate(-50%,50%)}.react-flow__handle-top{top:0;left:50%;transform:translate(-50%,-50%)}.react-flow__handle-left{top:50%;left:0;transform:translate(-50%,-50%)}.react-flow__handle-right{top:50%;right:0;transform:translate(50%,-50%)}.react-flow__edgeupdater{cursor:move;pointer-events:all}.react-flow__pane.selection .react-flow__panel{pointer-events:none}.react-flow__panel{position:absolute;z-index:5;margin:15px}.react-flow__panel.top{top:0}.react-flow__panel.bottom{bottom:0}.react-flow__panel.top.center,.react-flow__panel.bottom.center{left:50%;transform:translate(-15px) translate(-50%)}.react-flow__panel.left{left:0}.react-flow__panel.right{right:0}.react-flow__panel.left.center,.react-flow__panel.right.center{top:50%;transform:translateY(-15px) translateY(-50%)}.react-flow__attribution{font-size:10px;background:var(--xy-attribution-background-color, var(--xy-attribution-background-color-default));padding:2px 3px;margin:0}.react-flow__attribution a{text-decoration:none;color:#999}@keyframes dashdraw{0%{stroke-dashoffset:10}}.react-flow__edgelabel-renderer{position:absolute;width:100%;height:100%;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;left:0;top:0}.react-flow__viewport-portal{position:absolute;width:100%;height:100%;left:0;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__minimap{background:var( --xy-minimap-background-color-props, var(--xy-minimap-background-color, var(--xy-minimap-background-color-default)) )}.react-flow__minimap-svg{display:block}.react-flow__minimap-mask{fill:var( --xy-minimap-mask-background-color-props, var(--xy-minimap-mask-background-color, var(--xy-minimap-mask-background-color-default)) );stroke:var( --xy-minimap-mask-stroke-color-props, var(--xy-minimap-mask-stroke-color, var(--xy-minimap-mask-stroke-color-default)) );stroke-width:var( --xy-minimap-mask-stroke-width-props, var(--xy-minimap-mask-stroke-width, var(--xy-minimap-mask-stroke-width-default)) )}.react-flow__minimap-node{fill:var( --xy-minimap-node-background-color-props, var(--xy-minimap-node-background-color, var(--xy-minimap-node-background-color-default)) );stroke:var( --xy-minimap-node-stroke-color-props, var(--xy-minimap-node-stroke-color, var(--xy-minimap-node-stroke-color-default)) );stroke-width:var( --xy-minimap-node-stroke-width-props, var(--xy-minimap-node-stroke-width, var(--xy-minimap-node-stroke-width-default)) )}.react-flow__background-pattern.dots{fill:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-dots-color-default)) )}.react-flow__background-pattern.lines{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-lines-color-default)) )}.react-flow__background-pattern.cross{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-cross-color-default)) )}.react-flow__controls{display:flex;flex-direction:column;box-shadow:var(--xy-controls-box-shadow, var(--xy-controls-box-shadow-default))}.react-flow__controls.horizontal{flex-direction:row}.react-flow__controls-button{display:flex;justify-content:center;align-items:center;height:26px;width:26px;padding:4px;border:none;background:var(--xy-controls-button-background-color, var(--xy-controls-button-background-color-default));border-bottom:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) );color:var( --xy-controls-button-color-props, var(--xy-controls-button-color, var(--xy-controls-button-color-default)) );cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__controls-button svg{width:100%;max-width:12px;max-height:12px;fill:currentColor}.react-flow__edge.updating .react-flow__edge-path{stroke:#777}.react-flow__edge-text{font-size:10px}.react-flow__node.selectable:focus,.react-flow__node.selectable:focus-visible{outline:none}.react-flow__node-input,.react-flow__node-default,.react-flow__node-output,.react-flow__node-group{padding:10px;border-radius:var(--xy-node-border-radius, var(--xy-node-border-radius-default));width:150px;font-size:12px;color:var(--xy-node-color, var(--xy-node-color-default));text-align:center;border:var(--xy-node-border, var(--xy-node-border-default));background-color:var(--xy-node-background-color, var(--xy-node-background-color-default))}.react-flow__node-input.selectable:hover,.react-flow__node-default.selectable:hover,.react-flow__node-output.selectable:hover,.react-flow__node-group.selectable:hover{box-shadow:var(--xy-node-boxshadow-hover, var(--xy-node-boxshadow-hover-default))}.react-flow__node-input.selectable.selected,.react-flow__node-input.selectable:focus,.react-flow__node-input.selectable:focus-visible,.react-flow__node-default.selectable.selected,.react-flow__node-default.selectable:focus,.react-flow__node-default.selectable:focus-visible,.react-flow__node-output.selectable.selected,.react-flow__node-output.selectable:focus,.react-flow__node-output.selectable:focus-visible,.react-flow__node-group.selectable.selected,.react-flow__node-group.selectable:focus,.react-flow__node-group.selectable:focus-visible{box-shadow:var(--xy-node-boxshadow-selected, var(--xy-node-boxshadow-selected-default))}.react-flow__node-group{background-color:var(--xy-node-group-background-color, var(--xy-node-group-background-color-default))}.react-flow__nodesselection-rect,.react-flow__selection{background:var(--xy-selection-background-color, var(--xy-selection-background-color-default));border:var(--xy-selection-border, var(--xy-selection-border-default))}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible,.react-flow__selection:focus,.react-flow__selection:focus-visible{outline:none}.react-flow__controls-button:hover{background:var( --xy-controls-button-background-color-hover-props, var(--xy-controls-button-background-color-hover, var(--xy-controls-button-background-color-hover-default)) );color:var( --xy-controls-button-color-hover-props, var(--xy-controls-button-color-hover, var(--xy-controls-button-color-hover-default)) )}.react-flow__controls-button:disabled{pointer-events:none}.react-flow__controls-button:disabled svg{fill-opacity:.4}.react-flow__controls-button:last-child{border-bottom:none}.react-flow__controls.horizontal .react-flow__controls-button{border-bottom:none;border-right:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) )}.react-flow__controls.horizontal .react-flow__controls-button:last-child{border-right:none}.react-flow__resize-control{position:absolute}.react-flow__resize-control.left,.react-flow__resize-control.right{cursor:ew-resize}.react-flow__resize-control.top,.react-flow__resize-control.bottom{cursor:ns-resize}.react-flow__resize-control.top.left,.react-flow__resize-control.bottom.right{cursor:nwse-resize}.react-flow__resize-control.bottom.left,.react-flow__resize-control.top.right{cursor:nesw-resize}.react-flow__resize-control.handle{width:5px;height:5px;border:1px solid #fff;border-radius:1px;background-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));translate:-50% -50%}.react-flow__resize-control.handle.left{left:0;top:50%}.react-flow__resize-control.handle.right{left:100%;top:50%}.react-flow__resize-control.handle.top{left:50%;top:0}.react-flow__resize-control.handle.bottom{left:50%;top:100%}.react-flow__resize-control.handle.top.left,.react-flow__resize-control.handle.bottom.left{left:0}.react-flow__resize-control.handle.top.right,.react-flow__resize-control.handle.bottom.right{left:100%}.react-flow__resize-control.line{border-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));border-width:0;border-style:solid}.react-flow__resize-control.line.left,.react-flow__resize-control.line.right{width:1px;transform:translate(-50%);top:0;height:100%}.react-flow__resize-control.line.left{left:0;border-left-width:1px}.react-flow__resize-control.line.right{left:100%;border-right-width:1px}.react-flow__resize-control.line.top,.react-flow__resize-control.line.bottom{height:1px;transform:translateY(-50%);left:0;width:100%}.react-flow__resize-control.line.top{top:0;border-top-width:1px}.react-flow__resize-control.line.bottom{border-bottom-width:1px;top:100%}.react-flow__edge-textbg{fill:var(--xy-edge-label-background-color, var(--xy-edge-label-background-color-default))}.react-flow__edge-text{fill:var(--xy-edge-label-color, var(--xy-edge-label-color-default))}