spexcode 0.2.1 → 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.
- package/README.md +147 -103
- package/README.zh-CN.md +123 -88
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +24 -1
- package/spec-cli/src/attach.ts +50 -0
- package/spec-cli/src/cli.ts +214 -62
- package/spec-cli/src/client.ts +47 -9
- package/spec-cli/src/{self.ts → doctor.ts} +26 -25
- package/spec-cli/src/guide.ts +63 -11
- package/spec-cli/src/harness.ts +48 -19
- package/spec-cli/src/help.ts +137 -49
- package/spec-cli/src/index.ts +31 -11
- package/spec-cli/src/issues.ts +48 -21
- package/spec-cli/src/layout.ts +4 -4
- package/spec-cli/src/localIssues.ts +44 -60
- package/spec-cli/src/materialize.ts +4 -2
- package/spec-cli/src/mentions.ts +22 -1
- package/spec-cli/src/pty-bridge.ts +39 -4
- package/spec-cli/src/ranker.ts +31 -12
- package/spec-cli/src/search.bench.mjs +30 -7
- package/spec-cli/src/search.ts +39 -0
- package/spec-cli/src/sessions.ts +149 -62
- package/spec-cli/src/specs.ts +16 -4
- package/spec-cli/src/supervise.ts +30 -6
- package/spec-cli/src/tree.ts +118 -0
- package/spec-cli/templates/hooks/post-merge +2 -2
- package/spec-cli/templates/hooks/pre-commit +34 -15
- package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
- package/spec-dashboard/dist/assets/Dashboard-BwZ2KzxB.js +27 -0
- package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
- package/spec-dashboard/dist/assets/EvalsPage-DV75EdP4.js +3 -0
- package/spec-dashboard/dist/assets/FoldToggle-GwE0-k1d.js +1 -0
- package/spec-dashboard/dist/assets/IssuesPage-B17pnl9I.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-WEZbR8M1.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-Sh8kHpnj.js +71 -0
- package/spec-dashboard/dist/assets/SessionWindow-EzFq-hLG.js +9 -0
- package/spec-dashboard/dist/assets/Settings-Dgtg-Xb9.js +1 -0
- package/spec-dashboard/dist/assets/index-CCmnCbKS.css +1 -0
- package/spec-dashboard/dist/assets/index-Dd0_U5rk.js +41 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-yatsu/src/cli.ts +89 -15
- package/spec-yatsu/src/scenariofresh.ts +100 -30
- package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +0 -32
- package/spec-dashboard/dist/assets/index-DehTZ-h9.js +0 -145
|
@@ -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/
|
|
25
|
-
"$main_root/spec-cli/
|
|
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
|
|
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/
|
|
19
|
-
spex_kind=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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
|