spexcode 0.4.0 → 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.
Files changed (111) hide show
  1. package/README.md +37 -17
  2. package/package.json +9 -5
  3. package/spec-cli/README.md +23 -14
  4. package/spec-cli/hooks/dispatch.sh +7 -5
  5. package/spec-cli/hooks/harness.sh +19 -8
  6. package/spec-cli/src/anchors.ts +163 -30
  7. package/spec-cli/src/cli.ts +97 -42
  8. package/spec-cli/src/client.ts +34 -11
  9. package/spec-cli/src/doctor.ts +174 -36
  10. package/spec-cli/src/gateway-auth.ts +194 -0
  11. package/spec-cli/src/gateway-hub.ts +360 -0
  12. package/spec-cli/src/gateway.ts +31 -16
  13. package/spec-cli/src/git.ts +12 -5
  14. package/spec-cli/src/graph.ts +53 -36
  15. package/spec-cli/src/graphStream.ts +265 -34
  16. package/spec-cli/src/guide.ts +157 -76
  17. package/spec-cli/src/harness-select.ts +20 -6
  18. package/spec-cli/src/harness.ts +266 -67
  19. package/spec-cli/src/help.ts +84 -41
  20. package/spec-cli/src/host.ts +610 -0
  21. package/spec-cli/src/identity-presets.d.ts +13 -0
  22. package/spec-cli/src/identity-presets.js +128 -0
  23. package/spec-cli/src/index.ts +163 -86
  24. package/spec-cli/src/init.ts +53 -25
  25. package/spec-cli/src/layout.ts +5 -5
  26. package/spec-cli/src/lint.ts +115 -169
  27. package/spec-cli/src/login-page.ts +12 -5
  28. package/spec-cli/src/materialize.ts +39 -13
  29. package/spec-cli/src/mentions.ts +16 -11
  30. package/spec-cli/src/opencode.ts +145 -0
  31. package/spec-cli/src/pi-harness.ts +124 -0
  32. package/spec-cli/src/project-identity.ts +73 -0
  33. package/spec-cli/src/pty-bridge.ts +321 -521
  34. package/spec-cli/src/pty-helper.mjs +86 -0
  35. package/spec-cli/src/reaper.ts +16 -8
  36. package/spec-cli/src/reviewSnapshot.ts +23 -0
  37. package/spec-cli/src/reviews.ts +347 -0
  38. package/spec-cli/src/session-timeline.ts +162 -0
  39. package/spec-cli/src/sessions.ts +210 -63
  40. package/spec-cli/src/shim-runtime.ts +169 -0
  41. package/spec-cli/src/slash-commands.ts +76 -0
  42. package/spec-cli/src/source-files.ts +79 -0
  43. package/spec-cli/src/specs.ts +47 -24
  44. package/spec-cli/src/supervise.ts +37 -15
  45. package/spec-cli/src/tree.ts +8 -8
  46. package/spec-cli/src/uninstall.ts +56 -23
  47. package/spec-cli/templates/hooks/prepare-commit-msg +32 -13
  48. package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +24 -18
  49. package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +3 -3
  50. package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  51. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +30 -0
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  53. package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
  54. package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
  56. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
  58. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
  59. package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
  60. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  61. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
  62. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
  63. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
  64. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
  65. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +62 -0
  66. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +19 -0
  67. package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
  68. package/spec-cli/templates/spec/project/spec.md +6 -5
  69. package/spec-cli/templates/spexcode.json +4 -2
  70. package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
  71. package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
  72. package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
  73. package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
  74. package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
  75. package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
  76. package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
  77. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
  78. package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
  79. package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
  80. package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
  81. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
  82. package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
  83. package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
  84. package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
  85. package/spec-dashboard/dist/index.html +10 -10
  86. package/spec-dashboard/src/reviewFilters.js +279 -0
  87. package/spec-dashboard/src/reviewQuery.js +174 -0
  88. package/spec-dashboard/src/session.js +167 -0
  89. package/spec-eval/src/cli.ts +80 -45
  90. package/spec-eval/src/evaltab.ts +4 -3
  91. package/spec-eval/src/humanok.ts +1 -1
  92. package/spec-eval/src/matrix.ts +693 -0
  93. package/spec-eval/src/scenarios.ts +116 -4
  94. package/spec-eval/src/sessioneval.ts +726 -66
  95. package/spec-cli/src/migrate-table.ts +0 -398
  96. package/spec-cli/src/migrate.ts +0 -386
  97. package/spec-cli/templates/spec/project/.plugins/distill/spec.md +0 -74
  98. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
  99. package/spec-cli/templates/spec/project/.plugins/tidy/spec.md +0 -25
  100. package/spec-dashboard/dist/assets/Dashboard-CTcH2eW9.js +0 -27
  101. package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
  102. package/spec-dashboard/dist/assets/FoldToggle-CVFbBpyW.js +0 -1
  103. package/spec-dashboard/dist/assets/IssuesPage-kULjonqj.js +0 -1
  104. package/spec-dashboard/dist/assets/MobileApp-B0ZJju8K.js +0 -1
  105. package/spec-dashboard/dist/assets/SessionInterface-BRKJqU2U.js +0 -71
  106. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
  107. package/spec-dashboard/dist/assets/SessionWindow-CDhEL7wO.js +0 -9
  108. package/spec-dashboard/dist/assets/Settings-BL6FV_8S.js +0 -1
  109. package/spec-dashboard/dist/assets/index-DmQsNYKK.css +0 -1
  110. package/spec-dashboard/dist/assets/index-DulGPk6A.js +0 -41
  111. /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
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 # installs the `spex` command
83
+ npm i -g spexcode # installs the `spex` command
84
84
  cd your-repo
85
- spex init # seeds .spec/, installs git hooks, materializes the agent contract
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
- `spex init` is additive. It works on any existing git repo and never overwrites your files: it
91
- creates a root `.spec/project/spec.md` and a starter `spexcode.json`, installs the git hooks, and
92
- writes a managed block into `CLAUDE.md`/`AGENTS.md` so any agent working in the repo
93
- discovers the workflow on its own.
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" --node settings
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 `--node` flag (or a
122
- `[[settings]]` mention in the prompt, same effect) sets the branch name and board attribution; the
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; `breadth`,
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 other
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.0",
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.0.0",
42
+ "node-pty": "^1.1.0",
39
43
  "tsx": "^4.19.2"
40
44
  },
41
45
  "devDependencies": {
@@ -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 # seed .spec/, a starter spexcode.json, and git hooks — nothing destructive
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 # the "coverage" warnings are your adoption TODO list
35
+ spex spec lint # the "coverage" warnings are your adoption TODO list
36
36
  ```
37
37
 
38
- Run it. The backend and the dashboard are **two commands on two ports**, so several projects can run
39
- side by side on one host (the cwd picks which project is served):
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 --port 8788 # the backend (API + sessions) for THIS repo
43
- spex dashboard --port 5174 --api-port 8788 # the board UI, pointed at that backend
42
+ spex serve # this project's backend (API + sessions), default :8787
43
+ # In another project: spex serve --port 8788
44
44
  ```
45
45
 
46
- Then open <http://localhost:5174>. With no flags, `spex serve` defaults to `:8787` and `spex dashboard`
47
- to `:5173`.
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 / board transitions as they happen |
55
- | `spex guide` | print the full workflow, plus the `spec.md` / `yatsu.md` file-format manuals |
56
- | `spex board` | dump the current board state as JSON |
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, with hot-reload — this is what `npm run web` is for, as opposed
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 $1 as the
20
- # event and default the harness to claude — so a stale shim still written as `dispatch.sh <Event>` keeps working.
21
- # `plugin` is the bundle form ([[plugin-harness]]): it parses payloads as the claude family in harness.sh (z-code/
22
- # Claude share Claude's tool names + file_path), so it joins the claude branch there via the default case.
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 host (z-code/
10
- # Claude) shares Claude's payload shape, so every `case "$SPEXCODE_HARNESS"` below routes it through the claude
11
- # branch via the default case there is no separate `plugin)` arm to maintain.
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
- # access → the file being READ or edited ([[spec-first]] fires on any code touch)
186
- # mutate → the file being EDITED ([[spec-of-file]] fires only on a mutation)
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 apply_patch|Bash) ;; *) return 0 ;; esac
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
- *) case "$tool" in Read|Edit|Write|NotebookEdit) ;; *) return 0 ;; esac ;;
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
 
@@ -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: blob-oid memo, anchor resolution (dead/ambiguous), diff-hunk
11
- // unit-range intersection over the drift window. It never knows which language it is measuring.
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
 
@@ -27,8 +28,44 @@ export type Extractor = {
27
28
  export type CodeEntry = { path: string; anchor: string | null }
28
29
  export function parseCodeEntry(raw: string): CodeEntry {
29
30
  const i = raw.indexOf('#')
30
- if (i < 0) return { path: raw, anchor: null }
31
- return { path: raw.slice(0, i), anchor: raw.slice(i + 1).trim() || null }
31
+ if (i < 0) return { path: raw.trim(), anchor: null }
32
+ return { path: raw.slice(0, i).trim(), anchor: raw.slice(i + 1).trim() || null }
33
+ }
34
+
35
+ // ---- relation parsing: ONE structured path+selector grammar for code: AND related: ----
36
+ // A relation's raw rows group per base path: a row is bare (`path`, whole-file — today's semantics,
37
+ // unchanged) or scoped (`path#symbol`), and any number of scoped rows on the SAME base file fold into
38
+ // one entry whose selectors are OR'd (a commit hitting any counts once; no selector-count cap — the
39
+ // benchmark roster's 1–3 was an annotation rubric, never product syntax). STRUCTURAL verdicts live
40
+ // here, pure and loud: an exact duplicate row, mixing bare with selectors on one base path, and a
41
+ // selector on a glob are all `problems` the caller turns into integrity errors. Filesystem/git
42
+ // verdicts (existence, directories, dead/ambiguous units, extractor readiness) stay the caller's —
43
+ // this parser never touches fs.
44
+ export type RelationEntry = { path: string; selectors: string[] }
45
+ export type RelationParse = { entries: RelationEntry[]; problems: string[] }
46
+ export function parseRelation(raws: string[], relation: 'code' | 'related'): RelationParse {
47
+ const order: string[] = []
48
+ const byPath = new Map<string, { bare: boolean; selectors: string[] }>()
49
+ const problems: string[] = []
50
+ for (const raw of raws) {
51
+ const { path, anchor } = parseCodeEntry(raw)
52
+ let e = byPath.get(path)
53
+ if (!e) { e = { bare: false, selectors: [] }; byPath.set(path, e); order.push(path) }
54
+ if (anchor === null) {
55
+ if (e.bare) problems.push(`${relation}: lists '${path}' twice — drop the duplicate entry`)
56
+ e.bare = true
57
+ } else if (e.selectors.includes(anchor)) {
58
+ problems.push(`${relation}: lists selector '${path}#${anchor}' twice — drop the duplicate`)
59
+ } else e.selectors.push(anchor)
60
+ }
61
+ for (const path of order) {
62
+ const e = byPath.get(path)!
63
+ if (e.bare && e.selectors.length)
64
+ problems.push(`${relation}: mixes bare '${path}' with '${path}#…' selectors — one base path is either whole-file or selector-scoped, never both; drop one form`)
65
+ if (e.selectors.length && path.includes('*'))
66
+ problems.push(`${relation}: '${path}#${e.selectors[0]}' puts a selector on a glob — a selector scopes ONE real file`)
67
+ }
68
+ return { entries: order.map((p) => ({ path: p, selectors: byPath.get(p)!.selectors })), problems }
32
69
  }
33
70
 
34
71
  // ---- extractor: ts-ast (the designated extractor for the JS family) ----
@@ -114,9 +151,20 @@ export type LangSpec = {
114
151
  id: string
115
152
  extensions: string[]
116
153
  // column-0 declaration patterns; capture group 1 = the unit name (or the declarator list when declList)
117
- decls: { re: RegExp; kind: string; typeOnly?: boolean; classOpener?: boolean; declList?: boolean }[]
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
+ }[]
118
163
  // class-member pattern, active while inside a classOpener's balanced-bracket body (name -> Class.name)
119
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 }
120
168
  // a column-0 line matching this ENDS the previous unit (comment-aware so trailing comment blocks
121
169
  // attach to the NEXT unit, not the previous one)
122
170
  boundary: RegExp
@@ -136,12 +184,72 @@ function declNames(head: string): string[] {
136
184
  return segs.map((s) => s.match(/^\s*([A-Za-z_$][\w$]*)\s*(?::|=|$)/)?.[1]).filter((x): x is string => !!x)
137
185
  }
138
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
+
139
246
  export function heuristicExtractor(spec: LangSpec): Extractor {
140
247
  return {
141
248
  id: spec.id,
142
249
  claims: (ext) => spec.extensions.includes(ext),
143
250
  ready: () => true,
144
251
  extract(content) {
252
+ if (spec.indentScopes) return extractIndentScoped(content, spec)
145
253
  const lines = content.split('\n')
146
254
  const units: Unit[] = []
147
255
  let cls: string | null = null, depth = 0
@@ -200,12 +308,32 @@ export const JS_LANG_R5B: LangSpec = {
200
308
  boundary: /^(?:[A-Za-z_$]|\/\/|\/\*)/,
201
309
  }
202
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
+
203
331
  // ---- registry: extension -> its ONE designated extractor ----
204
332
  // The registry's shape is the Extractor INTERFACE, not any engine: a future language row may be a
205
333
  // heuristicExtractor(LangSpec) or a web-tree-sitter extractor carrying its own wasm-grammar/query
206
334
  // config — whatever the implementation needs rides inside its own factory, never in the registry.
207
335
  export function extractors(root: string): Extractor[] {
208
- return [tsAstExtractor(root)]
336
+ return [tsAstExtractor(root), ...[PYTHON_LANG].map(heuristicExtractor)]
209
337
  }
210
338
  // first claiming extractor IS the designation (the registry order defines it); null = no anchor support
211
339
  // for this language yet (lint ERRORS — the remedy is a LangSpec data row, or dropping the anchor).
@@ -229,24 +357,24 @@ export function resolveAnchor(units: Unit[], symbol: string): AnchorResolution {
229
357
 
230
358
  // ---- the historical hit engine (language-agnostic; batch short-lived git, no resident process) ----
231
359
 
232
- // units of a file AS OF a commit, memoized by (blob oid, extractor id) a blob is immutable, so the
233
- // memo never invalidates; distinct file versions in a window are few. 'absent' = no blob at that commit;
234
- // 'unparseable' = the extractor rejected that version's content (the caller treats it conservatively).
235
- type BlobUnits = { units: Unit[] } | { absent: true } | { unparseable: string }
236
- const unitMemo = new Map<string, BlobUnits>()
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>()
237
365
  const MEMO_MAX = 4096
238
- async function unitsAt(root: string, commit: string, path: string, x: Extractor): Promise<BlobUnits> {
366
+ async function unitsAtFileRevision(root: string, commit: string, path: string, x: Extractor): Promise<FileRevisionUnits> {
239
367
  const oid = (await gitA(['-C', root, 'rev-parse', `${commit}:${path}`])).trim()
240
368
  if (!oid) return { absent: true }
241
369
  const key = `${oid}\0${x.id}`
242
- const hit = unitMemo.get(key)
370
+ const hit = fileRevisionUnitMemo.get(key)
243
371
  if (hit) return hit
244
- const text = await gitA(['-C', root, 'cat-file', 'blob', oid])
245
- let v: BlobUnits
246
- try { v = { units: x.extract(text, path) } } catch (e: any) { v = { unparseable: e?.message ?? String(e) } }
247
- if (unitMemo.size >= MEMO_MAX) unitMemo.clear()
248
- unitMemo.set(key, v)
249
- return v
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
250
378
  }
251
379
 
252
380
  // post-image line ranges of one commit's diff to one file (`@@ -a,b +c,d @@`, --unified=0). d>0 → lines
@@ -280,21 +408,26 @@ export function windowCommits(idx: DriftIndex, sinceHash: string, path: string):
280
408
  return (idx.fileCommits.get(path) ?? []).filter((h) => !inAncestors(idx, base, h) && !cover.some((a) => inAncestors(idx, a, h)))
281
409
  }
282
410
 
283
- // which window commits TOUCHED the anchored unit: the commit's --unified=0 hunks intersect the unit's
284
- // line range extracted from the file AS IT EXISTED AT THAT COMMIT (never from HEAD — units later renamed
285
- // or moved still attribute correctly). A version whose content the extractor cannot parse is a
286
- // CONSERVATIVE hit (`unparseable` set) over-warn, never silently skip.
287
- export type AnchorHit = { commit: string; unparseable?: string }
288
- export async function anchorHitCommits(root: string, win: string[], path: string, symbol: string, x: Extractor): Promise<AnchorHit[]> {
411
+ // which window commits TOUCHED any of the anchored units: the commit's --unified=0 hunks intersect a
412
+ // unit's line range extracted from the file AS IT EXISTED AT THAT COMMIT (never from HEAD — units later
413
+ // renamed or moved still attribute correctly). Several selectors are OR a commit appears ONCE, with
414
+ // `selectors` naming exactly which units its hunks intersected (so diagnostics can attribute the hit).
415
+ // A version whose content the extractor cannot parse is a CONSERVATIVE hit for every selector
416
+ // (`unparseable` set) over-warn, never silently skip.
417
+ export type AnchorHit = { commit: string; selectors: string[]; unparseable?: string }
418
+ export async function anchorHitCommits(root: string, win: string[], path: string, symbols: string[], x: Extractor): Promise<AnchorHit[]> {
289
419
  const hits: AnchorHit[] = []
290
420
  for (const c of win) {
291
- const at = await unitsAt(root, c, path, x)
421
+ const at = await unitsAtFileRevision(root, c, path, x)
292
422
  if ('absent' in at) continue // file not in that commit's tree — nothing of the anchor to touch
293
- if ('unparseable' in at) { hits.push({ commit: c, unparseable: at.unparseable }); continue }
294
- const ranges = at.units.filter((u) => u.name === symbol)
295
- if (!ranges.length) continue // unit didn't exist under this name at that commit
423
+ if ('unparseable' in at) { hits.push({ commit: c, selectors: [...symbols], unparseable: at.unparseable }); continue }
424
+ const bySym = symbols
425
+ .map((sym) => ({ sym, ranges: at.units.filter((u) => u.name === sym) }))
426
+ .filter((s) => s.ranges.length) // a unit absent under this name at that commit can't be touched
427
+ if (!bySym.length) continue
296
428
  const hunks = await hunksAt(root, c, path)
297
- if (hunks.some(([a, b]) => ranges.some((u) => a <= u.end && u.start <= b))) hits.push({ commit: c })
429
+ const touched = bySym.filter((s) => hunks.some(([a, b]) => s.ranges.some((u) => a <= u.end && u.start <= b))).map((s) => s.sym)
430
+ if (touched.length) hits.push({ commit: c, selectors: touched })
298
431
  }
299
432
  return hits
300
433
  }