spexcode 0.4.1 → 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.
- package/README.md +37 -17
- package/package.json +9 -5
- package/spec-cli/README.md +23 -14
- package/spec-cli/hooks/dispatch.sh +7 -5
- package/spec-cli/hooks/harness.sh +19 -8
- package/spec-cli/src/anchors.ts +110 -18
- package/spec-cli/src/cli.ts +88 -37
- package/spec-cli/src/client.ts +34 -11
- package/spec-cli/src/doctor.ts +174 -36
- package/spec-cli/src/gateway-auth.ts +194 -0
- package/spec-cli/src/gateway-hub.ts +360 -0
- package/spec-cli/src/gateway.ts +27 -19
- package/spec-cli/src/git.ts +12 -5
- package/spec-cli/src/graph.ts +54 -43
- package/spec-cli/src/graphStream.ts +265 -34
- package/spec-cli/src/guide.ts +105 -55
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +181 -33
- package/spec-cli/src/help.ts +64 -27
- package/spec-cli/src/host.ts +610 -0
- package/spec-cli/src/identity-presets.d.ts +13 -0
- package/spec-cli/src/identity-presets.js +128 -0
- package/spec-cli/src/index.ts +145 -86
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +25 -121
- package/spec-cli/src/login-page.ts +12 -5
- package/spec-cli/src/materialize.ts +39 -13
- package/spec-cli/src/mentions.ts +16 -11
- package/spec-cli/src/opencode.ts +145 -0
- package/spec-cli/src/pi-harness.ts +124 -0
- package/spec-cli/src/project-identity.ts +73 -0
- package/spec-cli/src/pty-bridge.ts +321 -521
- package/spec-cli/src/pty-helper.mjs +86 -0
- package/spec-cli/src/reaper.ts +16 -8
- package/spec-cli/src/reviewSnapshot.ts +23 -0
- package/spec-cli/src/reviews.ts +347 -0
- package/spec-cli/src/session-timeline.ts +14 -0
- package/spec-cli/src/sessions.ts +188 -66
- package/spec-cli/src/shim-runtime.ts +169 -0
- package/spec-cli/src/slash-commands.ts +76 -0
- package/spec-cli/src/source-files.ts +79 -0
- package/spec-cli/src/specs.ts +12 -7
- package/spec-cli/src/supervise.ts +37 -15
- package/spec-cli/src/tree.ts +8 -8
- package/spec-cli/src/uninstall.ts +56 -23
- package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
- package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
- package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
- package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
- package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
- package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
- package/spec-cli/templates/spec/project/spec.md +6 -5
- package/spec-cli/templates/spexcode.json +4 -2
- package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
- package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
- package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
- package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
- package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
- package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
- package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
- package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
- package/spec-dashboard/dist/index.html +10 -10
- package/spec-dashboard/src/reviewFilters.js +279 -0
- package/spec-dashboard/src/reviewQuery.js +174 -0
- package/spec-dashboard/src/session.js +167 -0
- package/spec-eval/src/cli.ts +49 -28
- package/spec-eval/src/humanok.ts +1 -1
- package/spec-eval/src/matrix.ts +693 -0
- package/spec-eval/src/sessioneval.ts +726 -66
- package/spec-cli/src/migrate-table.ts +0 -408
- package/spec-cli/src/migrate.ts +0 -386
- package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
- package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
- package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
- package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
- package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
- package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
- package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
- package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +0 -6
package/README.md
CHANGED
|
@@ -80,17 +80,34 @@ the spec and the evals; the diff gets read once, at merge time.
|
|
|
80
80
|
Requires Node ≥ 22 and git. This part is plain tooling — no AI involved yet.
|
|
81
81
|
|
|
82
82
|
```sh
|
|
83
|
-
npm i -g spexcode
|
|
83
|
+
npm i -g spexcode # installs the `spex` command
|
|
84
84
|
cd your-repo
|
|
85
|
-
spex init
|
|
86
|
-
spex serve # API backend on :8787
|
|
87
|
-
spex serve ui # dashboard on :5173, proxying to the backend
|
|
85
|
+
spex init --harness claude,codex,opencode,pi # seeds .spec/, installs hooks, materializes the agent contracts
|
|
88
86
|
```
|
|
89
87
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
88
|
+
That's the whole adoption. The example lists all the built-in harnesses — remove the ones you don't
|
|
89
|
+
use: `--harness` is required, has no default, and takes any one id or comma-separated subset.
|
|
90
|
+
`spex init` is additive: it works on any existing git repo and never
|
|
91
|
+
overwrites your files — it creates a root `.spec/project/spec.md` and a starter `spexcode.json`,
|
|
92
|
+
installs the git hooks, and writes the selected harness's managed contract, so any agent working in
|
|
93
|
+
the repo discovers the workflow on its own.
|
|
94
|
+
|
|
95
|
+
When you want the live board — the graph, sessions, evals — start the runtime:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
spex serve # this project's backend — prints its URL, registers itself for your user
|
|
99
|
+
spex dashboard # once per user, any directory: the one dashboard — open the URL it prints
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Run `spex serve` from each project you want online. Every backend registers itself, and the single
|
|
103
|
+
`spex dashboard` finds them all — backends already running and ones you start later, in any order.
|
|
104
|
+
`/projects` switches and manages projects; each project's board lives under `/p/:id/`. There is no
|
|
105
|
+
per-project dashboard process and no port pairing to remember: if a port is taken, give that
|
|
106
|
+
backend its own with `spex serve --port <n>`, and trust the URL each command prints.
|
|
107
|
+
|
|
108
|
+
Those are installed-user commands. Contributors working from this source checkout use `npm run api`
|
|
109
|
+
for the reloadable backend and `npm run web` for the Vite/HMR frontend; see
|
|
110
|
+
[Contributing](#contributing).
|
|
94
111
|
|
|
95
112
|
Then grow the tree:
|
|
96
113
|
|
|
@@ -115,11 +132,11 @@ This part needs tmux and a logged-in [Claude Code](https://www.anthropic.com/cla
|
|
|
115
132
|
on the machine.
|
|
116
133
|
|
|
117
134
|
```sh
|
|
118
|
-
spex session new "make the settings page remember the last tab"
|
|
135
|
+
spex session new "[[settings]] make the settings page remember the last tab"
|
|
119
136
|
```
|
|
120
137
|
|
|
121
|
-
launches a worker session in its own worktree on branch `node/settings-…`. The
|
|
122
|
-
`[[settings]]` mention
|
|
138
|
+
launches a worker session in its own worktree on branch `node/settings-…`. The prompt's first
|
|
139
|
+
`[[settings]]` mention sets the branch name and board attribution; the
|
|
123
140
|
worker still finds and reads the governing spec itself before touching code. It makes the change,
|
|
124
141
|
rewrites the spec body to match, commits
|
|
125
142
|
both (a hook stamps the `Session:` trailer), then proposes a merge and stops. Workers never merge
|
|
@@ -186,21 +203,24 @@ and recorded video evidence in the middle.*
|
|
|
186
203
|
|
|
187
204
|
- **integrity** (error): a `code:` or `related:` path that doesn't exist
|
|
188
205
|
- **living** (error): a changelog heading in a spec body
|
|
189
|
-
- **altitude** (warn): a body that slid from contract prose into an implementation dump. The usual
|
|
190
|
-
smell is a numbered step list or a wall of function names; this rule is why spec bodies stay
|
|
191
|
-
short enough to actually read
|
|
192
206
|
- **coverage** (warn): unclaimed source files
|
|
193
207
|
- **drift** (warn): governed code changed after its spec's last version, derived live from git
|
|
194
208
|
|
|
195
|
-
plus naming and ownership rules (`one-govern`, `id-format`, `mention` as errors;
|
|
209
|
+
plus naming and ownership rules (`one-govern`, `id-format`, `mention` as errors;
|
|
196
210
|
`related-drift`, `owners`, `confusable-id` as warns) — `spex guide spec` lists them all.
|
|
197
211
|
|
|
212
|
+
`spex doctor` is the opt-in, read-only health diagnosis. Its altitude and breadth checks report bodies that
|
|
213
|
+
look like implementation dumps and nodes with wide child fan-out, with per-node evidence and repair, without
|
|
214
|
+
putting heuristic judgment in the lint or commit gate.
|
|
215
|
+
|
|
198
216
|
## Configuration
|
|
199
217
|
|
|
200
|
-
`spexcode.json` (committed, portable: layout, lint budgets, dashboard identity, launcher names) and
|
|
218
|
+
`spexcode.json` (committed, portable: layout, lint policy, doctor health budgets, project dashboard identity, launcher names) and
|
|
201
219
|
`spexcode.local.json` (gitignored, host-specific: absolute launcher paths, cert paths) cover every
|
|
202
220
|
setting. There is no imperative settings verb: you edit the two files by hand (or ask your agent
|
|
203
|
-
to), and `spex guide settings` documents every field. The
|
|
221
|
+
to), and `spex guide settings` documents every field. The Projects admin UI writes a project's icon back
|
|
222
|
+
to that same `dashboard.icon` field; the global gateway icon is the one separate host fact at
|
|
223
|
+
`$SPEXCODE_HOME/config.json` `gateway.icon`, never copied into a repo. The other
|
|
204
224
|
manuals are `spex guide` (the workflow), `spex guide spec`, `spex guide eval`, and
|
|
205
225
|
`spex guide footprint`; `spex help` maps the commands.
|
|
206
226
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spexcode",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
"spec-forge/src",
|
|
19
19
|
"!spec-forge/src/**/*.test.ts",
|
|
20
20
|
"spec-dashboard/dist",
|
|
21
|
+
"spec-dashboard/src/reviewFilters.js",
|
|
22
|
+
"spec-dashboard/src/reviewQuery.js",
|
|
23
|
+
"spec-dashboard/src/session.js",
|
|
21
24
|
"README.md"
|
|
22
25
|
],
|
|
23
26
|
"engines": {
|
|
@@ -26,16 +29,17 @@
|
|
|
26
29
|
"scripts": {
|
|
27
30
|
"api": "cd spec-cli && npm run serve",
|
|
28
31
|
"web": "cd spec-dashboard && npm run dev",
|
|
29
|
-
"lint": "cd spec-cli && npm run -s lint",
|
|
32
|
+
"lint": "node scripts/sync-init-plugins.mjs --check && cd spec-cli && npm run -s lint",
|
|
33
|
+
"sync:init-plugins": "node scripts/sync-init-plugins.mjs --write",
|
|
34
|
+
"test:init-plugins": "node --test scripts/sync-init-plugins.test.mjs",
|
|
30
35
|
"hooks": "bash scripts/install-hooks.sh",
|
|
31
|
-
"prepack": "node scripts/prepack.mjs"
|
|
32
|
-
"postinstall": "node -e \"try{require('fs').readdirSync('node_modules/node-pty/prebuilds').forEach(d=>{try{require('fs').chmodSync('node_modules/node-pty/prebuilds/'+d+'/spawn-helper',0o755)}catch{}})}catch{}\""
|
|
36
|
+
"prepack": "node scripts/sync-init-plugins.mjs --check && node scripts/prepack.mjs"
|
|
33
37
|
},
|
|
34
38
|
"dependencies": {
|
|
35
39
|
"@hono/node-server": "^1.13.7",
|
|
36
40
|
"@hono/node-ws": "^1.0.4",
|
|
37
41
|
"hono": "^4.6.14",
|
|
38
|
-
"node-pty": "^1.
|
|
42
|
+
"node-pty": "^1.1.0",
|
|
39
43
|
"tsx": "^4.19.2"
|
|
40
44
|
},
|
|
41
45
|
"devDependencies": {
|
package/spec-cli/README.md
CHANGED
|
@@ -29,31 +29,38 @@ Adopt it in your project — `spex init` is **additive**, it never restructures
|
|
|
29
29
|
|
|
30
30
|
```sh
|
|
31
31
|
cd ~/my-app
|
|
32
|
-
spex init
|
|
32
|
+
spex init --harness claude # seed .spec/, starter config, hooks, and the agent contract
|
|
33
33
|
# 1. edit .spec/project/spec.md to describe your project
|
|
34
34
|
# 2. point spexcode.json's lint.governedRoots at your real source dir(s)
|
|
35
|
-
spex lint
|
|
35
|
+
spex spec lint # the "coverage" warnings are your adoption TODO list
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
Run
|
|
39
|
-
|
|
38
|
+
Run one backend from every project you want online. Each successful `spex serve` publishes its
|
|
39
|
+
endpoint to the current user's host registry; additional projects only need a free backend port:
|
|
40
40
|
|
|
41
41
|
```sh
|
|
42
|
-
spex serve
|
|
43
|
-
|
|
42
|
+
spex serve # this project's backend (API + sessions), default :8787
|
|
43
|
+
# In another project: spex serve --port 8788
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
In another shell, start the host gateway/UI once for the current user:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
spex dashboard # shared dashboard, default :5173
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Open <http://localhost:5173/projects>. The gateway automatically discovers backends already running
|
|
53
|
+
and those started later. `/projects` is the global project switcher and management surface; project
|
|
54
|
+
dashboards live under `/p/:id/`. There is no per-project UI process or API/UI port pairing.
|
|
48
55
|
|
|
49
56
|
Day to day:
|
|
50
57
|
|
|
51
58
|
| command | what it does |
|
|
52
59
|
| --- | --- |
|
|
53
|
-
| `spex lint` | check the spec↔code graph — coverage, drift, and the living-body rules |
|
|
54
|
-
| `spex watch` | stream session
|
|
55
|
-
| `spex guide` | print the
|
|
56
|
-
| `spex
|
|
60
|
+
| `spex spec lint` | check the spec↔code graph — coverage, drift, and the living-body rules |
|
|
61
|
+
| `spex session watch` | stream session transitions as they happen |
|
|
62
|
+
| `spex guide` | print the setup workflow; topics cover the `spec.md` and `eval.md` formats |
|
|
63
|
+
| `spex graph --json` | dump the current assembled view as JSON |
|
|
57
64
|
|
|
58
65
|
The spec tree is ground truth and git is its database: every change is a `spec.md` node, **rewritten in
|
|
59
66
|
place** (never a `## vN` changelog) and versioned by its commits.
|
|
@@ -72,14 +79,16 @@ npm --prefix spec-dashboard install
|
|
|
72
79
|
npm run hooks # install the per-clone git hooks (main-guard + the session-stamp hook)
|
|
73
80
|
```
|
|
74
81
|
|
|
75
|
-
The development loop runs from source
|
|
76
|
-
to an installed user's `spex dashboard`:
|
|
82
|
+
The development loop runs from source with hot reload:
|
|
77
83
|
|
|
78
84
|
```sh
|
|
79
85
|
npm run api # backend on :8787, hot-reloads on spec-cli/src changes
|
|
80
86
|
npm run web # the dashboard via Vite (HMR), proxying /api → :8787
|
|
81
87
|
```
|
|
82
88
|
|
|
89
|
+
These are contributor-only source commands. Installed users run `spex serve` in each project and one
|
|
90
|
+
`spex dashboard` for the host; they do not run the Vite development server.
|
|
91
|
+
|
|
83
92
|
The contribution ritual in one breath: branch `node/<id>` off `main`, make the code change **and** its
|
|
84
93
|
`spec.md` *together*, commit, then `spex session done --propose merge` — a human performs the `--no-ff`
|
|
85
94
|
merge. That ritual, the spec-node model, the lint rules, and the reflexive config system are all spelled
|
|
@@ -16,12 +16,14 @@
|
|
|
16
16
|
# session-worktree creation, and the pre-commit/post-checkout/post-merge hooks). .plugins edits are
|
|
17
17
|
# git-transactional: they take effect at the commit/checkout/merge that carries them, like any other source.
|
|
18
18
|
set -u
|
|
19
|
-
# args: `<harness> <Event>`. A harness id as $1 (claude|codex|plugin) is consumed; otherwise we keep
|
|
20
|
-
# event and default the harness to claude — so a stale shim still written as `dispatch.sh <Event>`
|
|
21
|
-
# `plugin` is the bundle form ([[plugin-harness]])
|
|
22
|
-
#
|
|
19
|
+
# args: `<harness> <Event>`. A harness id as $1 (claude|codex|opencode|pi|plugin) is consumed; otherwise we keep
|
|
20
|
+
# $1 as the event and default the harness to claude — so a stale shim still written as `dispatch.sh <Event>`
|
|
21
|
+
# keeps working. `plugin` is the bundle form ([[plugin-harness]]), `opencode` the generated event-bus plugin
|
|
22
|
+
# ([[opencode-harness]]), and `pi` the generated extension ([[pi-harness]]): all three SYNTHESIZE claude-shaped
|
|
23
|
+
# payloads (Claude tool names + file_path), so they join the claude branch in harness.sh via the default case —
|
|
24
|
+
# no parse arm of their own.
|
|
23
25
|
harness=claude
|
|
24
|
-
case "${1:-}" in claude|codex|plugin) harness="$1"; shift ;; esac
|
|
26
|
+
case "${1:-}" in claude|codex|opencode|pi|plugin) harness="$1"; shift ;; esac
|
|
25
27
|
event="${1:?usage: dispatch.sh <harness> <Event>}"
|
|
26
28
|
export SPEXCODE_HARNESS="$harness"
|
|
27
29
|
# the harness.sh path (the adapter's shell mirror) — sibling of this script; hook handlers source it, and we
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# @@@ harness.sh - the SHELL face of the [[harness-adapter]] (spec-cli/src/harness.ts). The hook scripts run
|
|
3
3
|
# as pure shell, so they cannot import the TS adapter; this sourced library is its mirror. dispatch.sh sources
|
|
4
|
-
# it and exports SPEXCODE_HARNESS (claude|codex) — baked into the shim by each adapter, so a hook learns its
|
|
4
|
+
# it and exports SPEXCODE_HARNESS (claude|codex|opencode) — baked into the shim by each adapter, so a hook learns its
|
|
5
5
|
# harness deterministically, never by sniffing the payload shape. EVERY harness-divergent payload-parse lives
|
|
6
6
|
# HERE; the hook scripts stay harness-agnostic and just call hp_* (the one place Claude's tool names appear in
|
|
7
7
|
# shell, plus codex's Bash-command mapping). The session-id + global-store resolution is harness-agnostic and
|
|
8
8
|
# lives here too, so the six hooks no longer each repeat the git-common-dir → project-key dance.
|
|
9
|
-
# SPEXCODE_HARNESS is claude|codex|plugin: `plugin` is the bundle form ([[plugin-harness]]) and its
|
|
10
|
-
# Claude) shares Claude's payload shape
|
|
11
|
-
#
|
|
9
|
+
# SPEXCODE_HARNESS is claude|codex|opencode|pi|plugin: `plugin` is the bundle form ([[plugin-harness]]) and its
|
|
10
|
+
# host (z-code/Claude) shares Claude's payload shape; `opencode`'s generated plugin ([[opencode-harness]]) and
|
|
11
|
+
# `pi`'s generated extension ([[pi-harness]]) SYNTHESIZE claude-shaped payloads by construction — so every
|
|
12
|
+
# `case "$SPEXCODE_HARNESS"` below routes all three through the claude branch via the default case; only codex
|
|
13
|
+
# keeps parse arms of its own.
|
|
14
|
+
|
|
12
15
|
|
|
13
16
|
# the string value of a top-level JSON string field (first match). Harness-agnostic — both harnesses' payloads
|
|
14
17
|
# carry session_id / tool_name as plain string fields. $1 = payload, $2 = field name. The value is scanned as a
|
|
@@ -182,8 +185,9 @@ hp_is_ask() {
|
|
|
182
185
|
hp_ask_note() { hp_field "$1" question; }
|
|
183
186
|
|
|
184
187
|
# the CODE file(s) a payload touches, mapped to the trigger the spec hooks key on. $2 = mode:
|
|
185
|
-
#
|
|
186
|
-
# mutate → the file being EDITED
|
|
188
|
+
# read → the file being READ ([[spec-first]] gates the first GOVERNED read)
|
|
189
|
+
# mutate → the file being EDITED ([[spec-of-file]] fires only on a mutation)
|
|
190
|
+
# access → either operation
|
|
187
191
|
# Echoes the path(s), ONE PER LINE — a codex multi-file apply_patch (several `*** Update File:` markers)
|
|
188
192
|
# touches several files in one tool call, so every consuming hook iterates the lines. Echoes nothing when the
|
|
189
193
|
# payload is not a code touch of that mode. The harness divergence:
|
|
@@ -196,13 +200,18 @@ hp_code_path() {
|
|
|
196
200
|
tool=$(hp_tool "$payload")
|
|
197
201
|
case "$SPEXCODE_HARNESS" in
|
|
198
202
|
codex)
|
|
199
|
-
case "$tool" in
|
|
203
|
+
case "$mode:$tool" in
|
|
204
|
+
read:Bash|mutate:apply_patch|mutate:Bash|access:apply_patch|access:Bash) ;;
|
|
205
|
+
*) return 0 ;;
|
|
206
|
+
esac
|
|
200
207
|
_hp_codex_cmd_path "$(hp_field "$payload" command)" "$mode"
|
|
201
208
|
;;
|
|
202
209
|
*)
|
|
203
210
|
case "$mode" in
|
|
211
|
+
read) case "$tool" in Read) ;; *) return 0 ;; esac ;;
|
|
204
212
|
mutate) case "$tool" in Edit|Write|NotebookEdit) ;; *) return 0 ;; esac ;;
|
|
205
|
-
|
|
213
|
+
access) case "$tool" in Read|Edit|Write|NotebookEdit) ;; *) return 0 ;; esac ;;
|
|
214
|
+
*) return 0 ;;
|
|
206
215
|
esac
|
|
207
216
|
local p; p=$(hp_field "$payload" file_path); [ -n "$p" ] || p=$(hp_field "$payload" notebook_path)
|
|
208
217
|
printf '%s' "$p"
|
|
@@ -228,12 +237,14 @@ _hp_codex_cmd_path() {
|
|
|
228
237
|
cmd=$(printf '%s' "$1" | awk '{gsub(/\\n/,"\n"); gsub(/\\t/,"\t")}1')
|
|
229
238
|
case "$cmd" in
|
|
230
239
|
*apply_patch*|*applypatch*|*'*** Add File:'*|*'*** Update File:'*|*'*** Delete File:'*)
|
|
240
|
+
[ "$mode" = read ] && return 0
|
|
231
241
|
printf '%s\n' "$cmd" | sed -n 's/^\*\*\* \(Add\|Update\|Delete\) File: \(.*\)$/\2/p' | sed 's/[[:space:]]*$//'
|
|
232
242
|
return 0 ;;
|
|
233
243
|
esac
|
|
234
244
|
local is_mutate=0
|
|
235
245
|
case "$cmd" in *' >> '*|*' > '*|*' >>'*|*' >'*|*' tee '*|*'sed -i'*|*' dd '*) is_mutate=1 ;; esac
|
|
236
246
|
[ "$mode" = mutate ] && [ "$is_mutate" = 0 ] && return 0
|
|
247
|
+
[ "$mode" = read ] && [ "$is_mutate" = 1 ] && return 0
|
|
237
248
|
printf '%s\n' "$cmd" | tr ' \t' '\n\n' | grep -E '^[^-].*[/.][A-Za-z0-9_]+' | grep -vE '^(apply_patch|applypatch)$' | tail -1
|
|
238
249
|
}
|
|
239
250
|
|
package/spec-cli/src/anchors.ts
CHANGED
|
@@ -7,8 +7,9 @@ import { gitA, type DriftIndex, ancestorsOf, inAncestors, ackCoverFor } from './
|
|
|
7
7
|
// Everything below the entry parse splits into two layers:
|
|
8
8
|
// - the LANGUAGE SEAM: pure extractors (content, filename) -> Unit[] — no git, no cache, no fs.
|
|
9
9
|
// Each extension maps to exactly ONE designated extractor; there is NO cross-tier fallback.
|
|
10
|
-
// - the LANGUAGE-AGNOSTIC ENGINE:
|
|
11
|
-
// unit-range intersection over the drift window. It never knows
|
|
10
|
+
// - the LANGUAGE-AGNOSTIC ENGINE: file-revision memo (keyed by Git object id), anchor resolution
|
|
11
|
+
// (dead/ambiguous), diff-hunk ∩ unit-range intersection over the drift window. It never knows
|
|
12
|
+
// which language it is measuring.
|
|
12
13
|
|
|
13
14
|
export type Unit = { name: string; kind: string; start: number; end: number; typeOnly?: boolean }
|
|
14
15
|
|
|
@@ -150,9 +151,20 @@ export type LangSpec = {
|
|
|
150
151
|
id: string
|
|
151
152
|
extensions: string[]
|
|
152
153
|
// column-0 declaration patterns; capture group 1 = the unit name (or the declarator list when declList)
|
|
153
|
-
decls: {
|
|
154
|
+
decls: {
|
|
155
|
+
re: RegExp
|
|
156
|
+
kind: string
|
|
157
|
+
typeOnly?: boolean
|
|
158
|
+
classOpener?: boolean
|
|
159
|
+
declList?: boolean
|
|
160
|
+
scopeOpener?: boolean
|
|
161
|
+
memberOf?: { parentKind: string; kind: string }
|
|
162
|
+
}[]
|
|
154
163
|
// class-member pattern, active while inside a classOpener's balanced-bracket body (name -> Class.name)
|
|
155
164
|
member?: { re: RegExp; blacklist?: RegExp }
|
|
165
|
+
// indentation-significant languages use declaration nesting for qualified names and ranges. The
|
|
166
|
+
// declaration regexes remain language data; this only selects a generic boundary strategy.
|
|
167
|
+
indentScopes?: { decorator?: RegExp }
|
|
156
168
|
// a column-0 line matching this ENDS the previous unit (comment-aware so trailing comment blocks
|
|
157
169
|
// attach to the NEXT unit, not the previous one)
|
|
158
170
|
boundary: RegExp
|
|
@@ -172,12 +184,72 @@ function declNames(head: string): string[] {
|
|
|
172
184
|
return segs.map((s) => s.match(/^\s*([A-Za-z_$][\w$]*)\s*(?::|=|$)/)?.[1]).filter((x): x is string => !!x)
|
|
173
185
|
}
|
|
174
186
|
|
|
187
|
+
const indentation = (line: string): number => {
|
|
188
|
+
let n = 0
|
|
189
|
+
for (const ch of line) {
|
|
190
|
+
if (ch === ' ') n++
|
|
191
|
+
else if (ch === '\t') n += 8 - (n % 8)
|
|
192
|
+
else break
|
|
193
|
+
}
|
|
194
|
+
return n
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function extractIndentScoped(content: string, spec: LangSpec): Unit[] {
|
|
198
|
+
const lines = content.split('\n')
|
|
199
|
+
type ScopedUnit = Unit & { declaration: number; indent: number }
|
|
200
|
+
const units: ScopedUnit[] = []
|
|
201
|
+
const scopes: { indent: number; name: string; kind: string }[] = []
|
|
202
|
+
|
|
203
|
+
for (let i = 0; i < lines.length; i++) {
|
|
204
|
+
const line = lines[i]
|
|
205
|
+
if (!line.trim() || /^\s*#/.test(line)) continue
|
|
206
|
+
const indent = indentation(line)
|
|
207
|
+
while (scopes.length && scopes[scopes.length - 1].indent >= indent) scopes.pop()
|
|
208
|
+
for (const d of spec.decls) {
|
|
209
|
+
const m = line.match(d.re)
|
|
210
|
+
if (!m) continue
|
|
211
|
+
const local = m[1]
|
|
212
|
+
const name = [...scopes.map((s) => s.name), local].join('.')
|
|
213
|
+
const parent = scopes[scopes.length - 1]
|
|
214
|
+
const kind = d.memberOf && parent?.kind === d.memberOf.parentKind ? d.memberOf.kind : d.kind
|
|
215
|
+
let start = i + 1
|
|
216
|
+
if (spec.indentScopes?.decorator) {
|
|
217
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
218
|
+
if (indentation(lines[j]) !== indent || !spec.indentScopes.decorator.test(lines[j])) break
|
|
219
|
+
start = j + 1
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
units.push({ name, kind, start, end: i + 1, declaration: i, indent, ...(d.typeOnly ? { typeOnly: true } : {}) })
|
|
223
|
+
if (d.scopeOpener) scopes.push({ indent, name: local, kind: d.kind })
|
|
224
|
+
break
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const unit of units) {
|
|
229
|
+
let boundary: number | null = null
|
|
230
|
+
for (let i = unit.declaration + 1; i < lines.length; i++) {
|
|
231
|
+
const line = lines[i]
|
|
232
|
+
if (!line.trim()) continue
|
|
233
|
+
const indent = indentation(line)
|
|
234
|
+
if (/^\s*#/.test(line)) {
|
|
235
|
+
if (indent <= unit.indent && boundary === null) boundary = i
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
if (indent <= unit.indent) { boundary ??= i; break }
|
|
239
|
+
boundary = null
|
|
240
|
+
}
|
|
241
|
+
unit.end = Math.max(unit.start, (boundary ?? lines.length) )
|
|
242
|
+
}
|
|
243
|
+
return units.map(({ declaration: _declaration, indent: _indent, ...unit }) => unit)
|
|
244
|
+
}
|
|
245
|
+
|
|
175
246
|
export function heuristicExtractor(spec: LangSpec): Extractor {
|
|
176
247
|
return {
|
|
177
248
|
id: spec.id,
|
|
178
249
|
claims: (ext) => spec.extensions.includes(ext),
|
|
179
250
|
ready: () => true,
|
|
180
251
|
extract(content) {
|
|
252
|
+
if (spec.indentScopes) return extractIndentScoped(content, spec)
|
|
181
253
|
const lines = content.split('\n')
|
|
182
254
|
const units: Unit[] = []
|
|
183
255
|
let cls: string | null = null, depth = 0
|
|
@@ -236,12 +308,32 @@ export const JS_LANG_R5B: LangSpec = {
|
|
|
236
308
|
boundary: /^(?:[A-Za-z_$]|\/\/|\/\*)/,
|
|
237
309
|
}
|
|
238
310
|
|
|
311
|
+
// Python is a LangSpec DATA row over the same generic engine: declaration names come from patterns;
|
|
312
|
+
// significant indentation supplies lexical qualification and ranges. It is intentionally structural,
|
|
313
|
+
// not a Python runtime or full grammar (the user-facing boundary is documented by [[code-anchor]]).
|
|
314
|
+
const PY_ID = String.raw`[\p{ID_Start}_][\p{ID_Continue}_]*`
|
|
315
|
+
export const PYTHON_LANG: LangSpec = {
|
|
316
|
+
id: 'heuristic-python',
|
|
317
|
+
extensions: ['py', 'pyi'],
|
|
318
|
+
decls: [
|
|
319
|
+
{
|
|
320
|
+
re: new RegExp(`^\\s*(?:async\\s+)?def\\s+(${PY_ID})\\s*\\(`, 'u'),
|
|
321
|
+
kind: 'function',
|
|
322
|
+
scopeOpener: true,
|
|
323
|
+
memberOf: { parentKind: 'class', kind: 'method' },
|
|
324
|
+
},
|
|
325
|
+
{ re: new RegExp(`^\\s*class\\s+(${PY_ID})(?:\\s*\\(|\\s*:)`, 'u'), kind: 'class', scopeOpener: true },
|
|
326
|
+
],
|
|
327
|
+
indentScopes: { decorator: /^\s*@/ },
|
|
328
|
+
boundary: /^\S/,
|
|
329
|
+
}
|
|
330
|
+
|
|
239
331
|
// ---- registry: extension -> its ONE designated extractor ----
|
|
240
332
|
// The registry's shape is the Extractor INTERFACE, not any engine: a future language row may be a
|
|
241
333
|
// heuristicExtractor(LangSpec) or a web-tree-sitter extractor carrying its own wasm-grammar/query
|
|
242
334
|
// config — whatever the implementation needs rides inside its own factory, never in the registry.
|
|
243
335
|
export function extractors(root: string): Extractor[] {
|
|
244
|
-
return [tsAstExtractor(root)]
|
|
336
|
+
return [tsAstExtractor(root), ...[PYTHON_LANG].map(heuristicExtractor)]
|
|
245
337
|
}
|
|
246
338
|
// first claiming extractor IS the designation (the registry order defines it); null = no anchor support
|
|
247
339
|
// for this language yet (lint ERRORS — the remedy is a LangSpec data row, or dropping the anchor).
|
|
@@ -265,24 +357,24 @@ export function resolveAnchor(units: Unit[], symbol: string): AnchorResolution {
|
|
|
265
357
|
|
|
266
358
|
// ---- the historical hit engine (language-agnostic; batch short-lived git, no resident process) ----
|
|
267
359
|
|
|
268
|
-
// units of a file AS OF a commit, memoized by (
|
|
269
|
-
// memo never invalidates; distinct file
|
|
270
|
-
// 'unparseable' = the extractor rejected that
|
|
271
|
-
type
|
|
272
|
-
const
|
|
360
|
+
// units of a file AS OF a commit, memoized by (Git object id, extractor id). File content identified by
|
|
361
|
+
// an object id is immutable, so the memo never invalidates; distinct file revisions in a window are few.
|
|
362
|
+
// 'absent' = no file at that commit; 'unparseable' = the extractor rejected that revision's content.
|
|
363
|
+
type FileRevisionUnits = { units: Unit[] } | { absent: true } | { unparseable: string }
|
|
364
|
+
const fileRevisionUnitMemo = new Map<string, FileRevisionUnits>()
|
|
273
365
|
const MEMO_MAX = 4096
|
|
274
|
-
async function
|
|
366
|
+
async function unitsAtFileRevision(root: string, commit: string, path: string, x: Extractor): Promise<FileRevisionUnits> {
|
|
275
367
|
const oid = (await gitA(['-C', root, 'rev-parse', `${commit}:${path}`])).trim()
|
|
276
368
|
if (!oid) return { absent: true }
|
|
277
369
|
const key = `${oid}\0${x.id}`
|
|
278
|
-
const hit =
|
|
370
|
+
const hit = fileRevisionUnitMemo.get(key)
|
|
279
371
|
if (hit) return hit
|
|
280
|
-
const text = await gitA(['-C', root, 'cat-file', 'blob', oid])
|
|
281
|
-
let
|
|
282
|
-
try {
|
|
283
|
-
if (
|
|
284
|
-
|
|
285
|
-
return
|
|
372
|
+
const text = await gitA(['-C', root, 'cat-file', 'blob', oid]) // dead-words-ok: git plumbing — 'blob' is Git's object type, not product vocabulary
|
|
373
|
+
let result: FileRevisionUnits
|
|
374
|
+
try { result = { units: x.extract(text, path) } } catch (e: any) { result = { unparseable: e?.message ?? String(e) } }
|
|
375
|
+
if (fileRevisionUnitMemo.size >= MEMO_MAX) fileRevisionUnitMemo.clear()
|
|
376
|
+
fileRevisionUnitMemo.set(key, result)
|
|
377
|
+
return result
|
|
286
378
|
}
|
|
287
379
|
|
|
288
380
|
// post-image line ranges of one commit's diff to one file (`@@ -a,b +c,d @@`, --unified=0). d>0 → lines
|
|
@@ -326,7 +418,7 @@ export type AnchorHit = { commit: string; selectors: string[]; unparseable?: str
|
|
|
326
418
|
export async function anchorHitCommits(root: string, win: string[], path: string, symbols: string[], x: Extractor): Promise<AnchorHit[]> {
|
|
327
419
|
const hits: AnchorHit[] = []
|
|
328
420
|
for (const c of win) {
|
|
329
|
-
const at = await
|
|
421
|
+
const at = await unitsAtFileRevision(root, c, path, x)
|
|
330
422
|
if ('absent' in at) continue // file not in that commit's tree — nothing of the anchor to touch
|
|
331
423
|
if ('unparseable' in at) { hits.push({ commit: c, selectors: [...symbols], unparseable: at.unparseable }); continue }
|
|
332
424
|
const bySym = symbols
|