spexcode 0.2.0 → 0.2.2

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 (48) hide show
  1. package/README.md +149 -102
  2. package/README.zh-CN.md +170 -0
  3. package/package.json +1 -1
  4. package/spec-cli/bin/spex.mjs +24 -1
  5. package/spec-cli/src/attach.ts +50 -0
  6. package/spec-cli/src/cli.ts +227 -66
  7. package/spec-cli/src/client.ts +47 -9
  8. package/spec-cli/src/{self.ts → doctor.ts} +26 -25
  9. package/spec-cli/src/gateway.ts +15 -11
  10. package/spec-cli/src/guide.ts +73 -17
  11. package/spec-cli/src/harness.ts +48 -19
  12. package/spec-cli/src/help.ts +141 -51
  13. package/spec-cli/src/index.ts +41 -14
  14. package/spec-cli/src/issues.ts +109 -31
  15. package/spec-cli/src/layout.ts +4 -4
  16. package/spec-cli/src/localIssues.ts +59 -58
  17. package/spec-cli/src/materialize.ts +4 -2
  18. package/spec-cli/src/mentions.ts +22 -1
  19. package/spec-cli/src/pty-bridge.ts +39 -4
  20. package/spec-cli/src/ranker.ts +31 -12
  21. package/spec-cli/src/search.bench.mjs +30 -7
  22. package/spec-cli/src/search.ts +39 -0
  23. package/spec-cli/src/sessions.ts +149 -62
  24. package/spec-cli/src/specs.ts +16 -4
  25. package/spec-cli/src/supervise.ts +30 -6
  26. package/spec-cli/src/tree.ts +118 -0
  27. package/spec-cli/templates/hooks/post-merge +2 -2
  28. package/spec-cli/templates/hooks/pre-commit +34 -15
  29. package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
  30. package/spec-dashboard/dist/assets/Dashboard-BwZ2KzxB.js +27 -0
  31. package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
  32. package/spec-dashboard/dist/assets/EvalsPage-DV75EdP4.js +3 -0
  33. package/spec-dashboard/dist/assets/FoldToggle-GwE0-k1d.js +1 -0
  34. package/spec-dashboard/dist/assets/IssuesPage-B17pnl9I.js +1 -0
  35. package/spec-dashboard/dist/assets/MobileApp-WEZbR8M1.js +1 -0
  36. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
  37. package/spec-dashboard/dist/assets/SessionInterface-Sh8kHpnj.js +71 -0
  38. package/spec-dashboard/dist/assets/SessionWindow-EzFq-hLG.js +9 -0
  39. package/spec-dashboard/dist/assets/Settings-Dgtg-Xb9.js +1 -0
  40. package/spec-dashboard/dist/assets/index-CCmnCbKS.css +1 -0
  41. package/spec-dashboard/dist/assets/index-Dd0_U5rk.js +41 -0
  42. package/spec-dashboard/dist/index.html +2 -2
  43. package/spec-forge/src/cli.ts +4 -10
  44. package/spec-forge/src/drivers.ts +13 -0
  45. package/spec-yatsu/src/cli.ts +89 -15
  46. package/spec-yatsu/src/scenariofresh.ts +100 -30
  47. package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +0 -145
  48. package/spec-dashboard/dist/assets/index-BTU-44Os.css +0 -32
@@ -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