spexcode 0.4.1 → 0.5.0

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 +110 -18
  7. package/spec-cli/src/claude-headless.ts +271 -0
  8. package/spec-cli/src/cli.ts +112 -38
  9. package/spec-cli/src/client.ts +42 -11
  10. package/spec-cli/src/doctor.ts +174 -36
  11. package/spec-cli/src/gateway-auth.ts +194 -0
  12. package/spec-cli/src/gateway-hub.ts +360 -0
  13. package/spec-cli/src/gateway.ts +27 -19
  14. package/spec-cli/src/git.ts +12 -5
  15. package/spec-cli/src/graph.ts +54 -43
  16. package/spec-cli/src/graphStream.ts +265 -34
  17. package/spec-cli/src/guide.ts +115 -56
  18. package/spec-cli/src/harness-select.ts +20 -6
  19. package/spec-cli/src/harness.ts +295 -39
  20. package/spec-cli/src/help.ts +68 -28
  21. package/spec-cli/src/host.ts +610 -0
  22. package/spec-cli/src/identity-presets.d.ts +13 -0
  23. package/spec-cli/src/identity-presets.js +128 -0
  24. package/spec-cli/src/index.ts +158 -87
  25. package/spec-cli/src/init.ts +53 -25
  26. package/spec-cli/src/layout.ts +6 -5
  27. package/spec-cli/src/lint.ts +25 -121
  28. package/spec-cli/src/login-page.ts +12 -5
  29. package/spec-cli/src/materialize.ts +39 -13
  30. package/spec-cli/src/mentions.ts +16 -11
  31. package/spec-cli/src/message-stream.ts +147 -0
  32. package/spec-cli/src/opencode-headless.ts +95 -0
  33. package/spec-cli/src/opencode.ts +145 -0
  34. package/spec-cli/src/pi-harness.ts +124 -0
  35. package/spec-cli/src/pi-headless.ts +195 -0
  36. package/spec-cli/src/project-identity.ts +73 -0
  37. package/spec-cli/src/pty-bridge.ts +321 -521
  38. package/spec-cli/src/pty-helper.mjs +86 -0
  39. package/spec-cli/src/reaper.ts +16 -8
  40. package/spec-cli/src/reviewSnapshot.ts +23 -0
  41. package/spec-cli/src/reviews.ts +347 -0
  42. package/spec-cli/src/session-timeline.ts +14 -0
  43. package/spec-cli/src/sessions.ts +211 -75
  44. package/spec-cli/src/shim-runtime.ts +169 -0
  45. package/spec-cli/src/slash-commands.ts +76 -0
  46. package/spec-cli/src/source-files.ts +79 -0
  47. package/spec-cli/src/specs.ts +12 -7
  48. package/spec-cli/src/supervise.ts +37 -15
  49. package/spec-cli/src/tree.ts +8 -8
  50. package/spec-cli/src/uninstall.ts +56 -23
  51. package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
  52. package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
  53. package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
  54. package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  55. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
  56. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
  58. package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  59. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
  60. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
  61. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
  62. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
  63. package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
  64. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  65. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
  66. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
  67. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
  68. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
  69. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
  70. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
  71. package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
  72. package/spec-cli/templates/spec/project/spec.md +6 -5
  73. package/spec-cli/templates/spexcode.json +10 -2
  74. package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +27 -0
  75. package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +2 -0
  76. package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +1 -0
  77. package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +2 -0
  78. package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +1 -0
  79. package/spec-dashboard/dist/assets/PageScroll-B_dKCuXx.js +1 -0
  80. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +1 -0
  81. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
  82. package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +39 -0
  83. package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +13 -0
  84. package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +1 -0
  85. package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +1 -0
  86. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
  87. package/spec-dashboard/dist/assets/index-BKaTHjmU.js +41 -0
  88. package/spec-dashboard/dist/assets/index-DcnCaBAC.css +1 -0
  89. package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
  90. package/spec-dashboard/dist/index.html +10 -10
  91. package/spec-dashboard/src/reviewFilters.js +279 -0
  92. package/spec-dashboard/src/reviewQuery.js +174 -0
  93. package/spec-dashboard/src/session.js +167 -0
  94. package/spec-eval/src/cli.ts +49 -28
  95. package/spec-eval/src/humanok.ts +1 -1
  96. package/spec-eval/src/matrix.ts +693 -0
  97. package/spec-eval/src/sessioneval.ts +726 -66
  98. package/spec-cli/src/migrate-table.ts +0 -408
  99. package/spec-cli/src/migrate.ts +0 -386
  100. package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
  101. package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
  102. package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
  103. package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
  104. package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
  105. package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
  106. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
  107. package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
  108. package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
  109. package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
  110. package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
  111. 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 # 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,claude-headless,opencode-headless,pi-headless # 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.1",
3
+ "version": "0.5.0",
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
 
@@ -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: { 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
+ }[]
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 (blob oid, extractor id) a blob is immutable, so the
269
- // memo never invalidates; distinct file versions in a window are few. 'absent' = no blob at that commit;
270
- // 'unparseable' = the extractor rejected that version's content (the caller treats it conservatively).
271
- type BlobUnits = { units: Unit[] } | { absent: true } | { unparseable: string }
272
- 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>()
273
365
  const MEMO_MAX = 4096
274
- 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> {
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 = unitMemo.get(key)
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 v: BlobUnits
282
- try { v = { units: x.extract(text, path) } } catch (e: any) { v = { unparseable: e?.message ?? String(e) } }
283
- if (unitMemo.size >= MEMO_MAX) unitMemo.clear()
284
- unitMemo.set(key, v)
285
- 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
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 unitsAt(root, c, path, x)
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