Compare commits

...

69 Commits

Author SHA1 Message Date
sakibcc 6219fb2e51 Merge pull request 'Develop' (#37) from develop into main
Deploy Production / deploy (push) Successful in 2m23s
Reviewed-on: #37
2026-09-26 01:19:47 +08:00
yuxuanhui be9c35eadd Implement structural updates and optimizations across multiple modules 2026-09-26 01:19:17 +08:00
yuxuanhui 4af96cee4b chore(task): 删除过时的选股页面迭代计划文档 2026-09-25 23:49:54 +08:00
yuxuanhui ca778fe38f chore: record journal 2026-09-25 23:49:18 +08:00
yuxuanhui 62c66f02ad chore(task): archive 09-07-api-performance-diagnosis 2026-09-25 23:48:22 +08:00
yuxuanhui 1197b78f9a chore(task): archive 09-06-capital-radar-daily-detail 2026-09-25 23:48:21 +08:00
yuxuanhui 39f8fe5fbf chore(task): archive 09-05-selection-layout-sector-filter 2026-09-25 23:48:20 +08:00
yuxuanhui d79e78a411 chore(task): archive 09-04-add-gold-brick-strategy 2026-09-25 23:48:20 +08:00
yuxuanhui a3e51e2dae chore(task): archive 09-01-optimize-stock-list-radar 2026-09-25 23:48:19 +08:00
sakibcc a67f32b45f Merge pull request 'Develop' (#36) from develop into main
Deploy Production / deploy (push) Has been cancelled
Reviewed-on: #36
2026-09-25 23:44:26 +08:00
yuxuanhui fad932b0de feat: Add OneChart score reconstruction research files and validation results
- Introduced new JSON files for normalized and raw inputs, source metadata, and public inputs.
- Added findings document detailing the methodology and results of the score reconstruction.
- Included member differences and worked examples for clarity on data discrepancies.
- Implemented a Python script for reproducing scores based on public inputs.
- Created SQL for raw reaggregation of data.
- Added task metadata for tracking the research completion and validation metrics.
2026-09-25 23:43:53 +08:00
yuxuanhui f020362fb0 fix(deploy): change runner from ubuntu-latest to tencent-prod 2026-09-25 23:43:40 +08:00
sakibcc bd84ddd2f2 Merge pull request 'Develop' (#35) from develop into main
Deploy Production / deploy (push) Successful in 1m2s
Reviewed-on: sakibcc/zhixing-system#35
2026-09-22 00:03:48 +08:00
yuxuanhui c460ba3524 chore: record journal 2026-09-21 23:58:39 +08:00
yuxuanhui 30bf94c908 chore(task): archive 09-21-radar-weighted-score-rank-change 2026-09-21 23:58:39 +08:00
yuxuanhui b9981aa48d feat(sector-radar): add weighted scores and rank-change views 2026-09-21 23:57:55 +08:00
yuxuanhui 669e89d3c3 feat(sector-radar): add IME-safe sector autocomplete and detail lookup 2026-09-21 22:37:07 +08:00
yuxuanhui 2f1c4f36c2 Support swing radar metrics and sector detail dialogs 2026-09-21 22:28:35 +08:00
sakibcc 6e39ae98d8 Merge pull request 'feat(docker-compose): add observability labels and logging configuration for services' (#34) from develop into main
Deploy Production / deploy (push) Successful in 1m7s
Reviewed-on: sakibcc/zhixing-system#34
2026-09-16 16:25:34 +08:00
yuxuanhui 3f50ca3076 feat(docker-compose): add observability labels and logging configuration for services 2026-09-16 16:25:10 +08:00
sakibcc 78b6915fff Merge pull request 'perf(sector_radar): narrow publication reads and index rank history' (#33) from develop into main
Deploy Production / deploy (push) Successful in 20s
Reviewed-on: sakibcc/zhixing-system#33
2026-09-07 14:46:27 +08:00
sakibcc bae5368da5 Merge pull request 'Develop' (#32) from develop into main
Deploy Production / deploy (push) Successful in 29s
Reviewed-on: sakibcc/zhixing-system#32
2026-09-07 13:25:58 +08:00
yuxuanhui d7586b27f6 perf(sector_radar): narrow publication reads and index rank history 2026-09-07 12:16:25 +08:00
yuxuanhui e567e5f717 feat(trellis): enhance bundled skills and workflow integration
- Updated bundled skills documentation to clarify the structure and usage across all platforms, ensuring consistency in skill root paths.
- Introduced a new `inject-spec-context.py` hook for path-scoped spec context injection, improving the relevance of injected specs during file interactions.
- Enhanced existing hooks to support workflow resolution, allowing for dynamic selection of workflows based on task context.
- Added a command to manage workflow selections for active tasks, enabling better task management and workflow adherence.
- Updated configuration options for spec injection, including character limits and refresh windows, to optimize performance and usability.
2026-09-07 11:23:15 +08:00
yuxuanhui ca3bd7a4b1 style(sector_radar): compact detail dialog header 2026-09-06 20:25:39 +08:00
sakibcc cd230bcb73 Merge pull request 'Develop' (#31) from develop into main
Deploy Production / deploy (push) Successful in 30s
Reviewed-on: sakibcc/zhixing-system#31
2026-09-06 16:19:48 +08:00
yuxuanhui 07c5b25043 refactor(selection): drop residual concept-sector surface from sub-industry path
- remove the sector_type parameter from the selection port, adapter, and
  use case; the adapter now passes SectorType.INDUSTRY directly, deleting
  the dead concept vocabulary mapping
- stop parsing concepts/concept_total/concept_limit from the sector-radar
  membership response in the web API layer and narrow the response type to
  industries only
- update tests and the task PRD accordingly; backend pyright errors drop
  from the 16 baseline to 14
2026-09-06 16:15:08 +08:00
yuxuanhui 7f93d6b0f5 feat(sector_radar): enhance sector radar functionality with active moneyflow and detailed metrics
- Introduced ActiveMoneyflowSource to fetch optional active-order flow, enhancing the sector radar's data capabilities.
- Updated StockFactRecord and DailyAggregateRecord to include pct_change and active_buy_net_amount_yuan for improved financial insights.
- Modified the build process to incorporate active moneyflow data without invalidating main rankings on failure.
- Enhanced the HTTP API to return detailed sector history and metrics, including pct_change and active buy metrics for members.
- Updated tests to validate the new functionality and ensure data integrity across various scenarios.
2026-09-06 16:06:17 +08:00
yuxuanhui 09d20b3b33 Merge branch 'develop'
Deploy Production / deploy (push) Successful in 27s
2026-09-05 21:55:47 +08:00
yuxuanhui 68282f5d46 refactor(selection): replace concept sector filter with sub-industry
- pin the selection sector vocabulary to industry: the /sectors endpoint
  no longer accepts sector_type, and the port, adapter, and use case all
  resolve counts and member codes with sector_type="industry"
- relabel the results filter to 细分行业 and drop concept-type plumbing
  from the frontend API, query keys, and types
- show only 细分行业 in the signal detail panel; remove the concept-board
  chips and keep the snapshot-date tooltip on the industry line
- update backend and frontend tests to the industry vocabulary and record
  the revised scope in the task PRD
2026-09-05 21:04:00 +08:00
yuxuanhui 4ee757dbce Merge branch 'develop'
Deploy Production / deploy (push) Successful in 28s
2026-09-05 19:49:02 +08:00
yuxuanhui ade1713e86 docs(trellis): record selection layout and sector filter task 2026-09-05 19:48:41 +08:00
yuxuanhui 3e0aa496f3 style(selection): apply ruff format to gold_brick module 2026-09-05 19:48:41 +08:00
yuxuanhui 12642f3c2d feat(selection): rework results layout and add sector filter
- move the filter bar out of the list column into a full-width top section;
  list and detail stay side by side below on md+, stacking search > list >
  detail on small screens
- add sector select fed by the run's concept-board aggregates; options show
  per-sector stock counts ordered by count desc, stale selections reset when
  the aggregate list changes
- persist sector in the route search, results/runs query keys, and refresh
  sector aggregates when a selection run finishes
2026-09-05 19:48:41 +08:00
yuxuanhui 7e0f13d678 feat(selection): expose run sector aggregates and sector filter on results API
- sector_radar: add batch sector-count aggregation and sector member lookup
  over the strict last-good membership snapshot (postgres + in-memory fakes)
- selection: add SelectionSectorReader port, list_sector_counts use case,
  and sector_stock_codes filtering via run identity resolution; queries stay
  inside the selection context per ADR 0001
- http: add GET /api/v1/selection/sectors and forward sector param on
  /results and /runs/{run_id}
- fix stale positional args in pattern-scoring run tests; cover new behavior
  with read-service, application, and HTTP contract tests
2026-09-05 19:48:30 +08:00
yuxuanhui cacaed08c1 Merge branch 'develop' into main
Deploy Production / deploy (push) Successful in 29s
2026-09-05 16:27:43 +08:00
yuxuanhui 41a9b4eb9a feat(selection): show stock industry and concept boards in detail panel
Add point-in-time stock membership lookup to the sector radar module
(GET /sector-radar/stocks/{ts_code}/membership) reading the existing
dc_index/dc_member snapshots, and surface industries plus concept chips
in the selection signal detail panel with graceful no-data hiding.
2026-09-05 16:23:50 +08:00
yuxuanhui 04e3e775a7 Merge pull request 'fix(selection): render gold brick chart as TDX brick blocks' from develop into main
Deploy Production / deploy (push) Successful in 29s
2026-09-05 14:58:05 +08:00
yuxuanhui 50cac3575a fix(selection): render gold brick chart as TDX brick blocks with strong-red flag
Match the ZXB1 sub-chart formula: each brick spans between yesterday's
and today's brick value via a stacked range bar — rising bricks are
hollow red (solid on strong_red), falling bricks solid green. The chart
API now also returns brick_strong_red per point.
2026-09-05 14:58:02 +08:00
yuxuanhui 58380d0ddd Merge pull request 'feat(selection): show gold brick chart below volume in stock detail panel' from develop into main
Deploy Production / deploy (push) Successful in 28s
2026-09-05 13:41:38 +08:00
yuxuanhui 3b50e75ef8 feat(selection): show gold brick chart below volume in stock detail panel
Add an optional brick_chart series to the selection chart API, computed
from the shared gold-brick formula when strategy=gold_brick, and render
a fourth grid with red/green brick bars between the volume and J grids.
2026-09-05 13:41:25 +08:00
sakibcc 3b106613d2 Merge pull request 'feat(selection): implement gold brick resonance strategy with evaluation and logging enhancements' (#30) from develop into main
Deploy Production / deploy (push) Successful in 31s
Reviewed-on: sakibcc/zhixing-system#30
2026-09-05 10:33:23 +08:00
yuxuanhui 79476252b1 feat(selection): implement gold brick resonance strategy with evaluation and logging enhancements 2026-09-05 10:22:08 +08:00
sakibcc bd9b7d65a8 Merge pull request 'feat(selection): integrate yet-another-react-lightbox for enhanced image viewing in PatternCaseImage component' (#29) from develop into main
Deploy Production / deploy (push) Successful in 34s
Reviewed-on: sakibcc/zhixing-system#29
2026-09-02 18:54:47 +08:00
yuxuanhui 9163590070 feat(selection): integrate yet-another-react-lightbox for enhanced image viewing in PatternCaseImage component 2026-09-02 18:50:13 +08:00
sakibcc 61aaed825d Merge pull request 'Develop' (#28) from develop into main
Deploy Production / deploy (push) Successful in 27s
Reviewed-on: sakibcc/zhixing-system#28
2026-09-02 18:39:32 +08:00
yuxuanhui 6b8c5bd33f feat(selection): enhance PatternScore component with detailed breakdown labels and update tests for accuracy 2026-09-02 18:38:57 +08:00
yuxuanhui 98ee1cc996 fix(selection): update chart labels and improve layout for clarity in selection components 2026-09-02 18:36:02 +08:00
sakibcc b44988aa81 Merge pull request 'refactor(selection): update component descriptions and improve layout for stock detail panel' (#27) from develop into main
Deploy Production / deploy (push) Successful in 27s
Reviewed-on: sakibcc/zhixing-system#27
2026-09-02 18:27:39 +08:00
yuxuanhui 629f933cf9 refactor(selection): update component descriptions and improve layout for stock detail panel 2026-09-02 18:26:57 +08:00
sakibcc 6030bf833d Merge pull request 'Develop' (#26) from develop into main
Deploy Production / deploy (push) Successful in 27s
Reviewed-on: sakibcc/zhixing-system#26
2026-09-01 22:58:23 +08:00
yuxuanhui f41ad0915e feat(selection): add Zhixing line indicators to selection chart and update related components 2026-09-01 22:57:58 +08:00
yuxuanhui 3da90f7df8 feat(codex): create initial workspace index and journal for AI development sessions 2026-09-01 22:47:17 +08:00
sakibcc 162ff5da97 Merge pull request 'refactor(web): adjust layout and styling for improved responsiveness and visual consistency' (#25) from develop into main
Deploy Production / deploy (push) Successful in 26s
Reviewed-on: sakibcc/zhixing-system#25
2026-09-01 22:42:12 +08:00
yuxuanhui ea2d0f75f1 refactor(web): adjust layout and styling for improved responsiveness and visual consistency 2026-09-01 22:40:24 +08:00
sakibcc ba33dda984 Merge pull request 'feat(web): 选股列表改为滚动加载并精简操作区' (#24) from develop into main
Deploy Production / deploy (push) Successful in 28s
Reviewed-on: sakibcc/zhixing-system#24
2026-09-01 19:29:15 +08:00
yuxuanhui af1e1eaa66 feat(web): 选股列表改为滚动加载并精简操作区
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 19:27:41 +08:00
sakibcc c592525087 Merge pull request 'Develop' (#23) from develop into main
Deploy Production / deploy (push) Successful in 27s
Reviewed-on: sakibcc/zhixing-system#23
2026-09-01 14:59:12 +08:00
yuxuanhui c944b219d9 feat(web): 用紧凑卡片替换选股表格并精简资金雷达列
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 14:57:27 +08:00
yuxuanhui 4b8a7bd484 feat(research): add OneChartLab radar metrics analysis and documentation 2026-09-01 14:48:57 +08:00
sakibcc 8cdb3be765 Merge pull request 'Develop' (#22) from develop into main
Deploy Production / deploy (push) Successful in 33s
Reviewed-on: sakibcc/zhixing-system#22
2026-09-01 13:48:46 +08:00
yuxuanhui 70bd766b8e Merge branch 'codex/chart' into develop
# Conflicts:
#	.trellis/workspace/yuxuanhui/index.md
2026-09-01 13:44:30 +08:00
yuxuanhui 66d417256f chore: record journal 2026-09-01 13:38:57 +08:00
yuxuanhui 3fc78ec9b1 chore(task): archive 09-01-stock-chart-display 2026-09-01 13:38:45 +08:00
yuxuanhui feb8fd4feb feat(web): 完善选股详情图表与案例图 2026-09-01 13:37:19 +08:00
yuxuanhui 9145fe4b2c feat(selection): 新增个股技术图表接口 2026-09-01 13:37:11 +08:00
yuxuanhui fe6d2b4fe5 feat(sector-radar): align dual ranking radar interactions 2026-09-01 13:24:43 +08:00
yuxuanhui 21ec0b2353 chore: record journal 2026-09-01 12:14:37 +08:00
yuxuanhui e2037f48f0 chore(task): archive 09-01-sector-radar-dual-ranking 2026-09-01 12:14:25 +08:00
222 changed files with 19611 additions and 1682 deletions
@@ -31,30 +31,42 @@ The list is discovered at runtime, so adding a new directory under `bundled-skil
## Where Bundled Skills Land Per Platform ## Where Bundled Skills Land Per Platform
Each platform configurator calls `writeSkills(<root>, <workflowSkills>, resolveBundledSkills(ctx))` during `trellis init`. `resolveBundledSkills` reads every directory under `templates/common/bundled-skills/`, resolves placeholders, and returns a flat list of `{relativePath, content}` entries. `writeSkills` then mirrors them under the platform's skill root. A platform's whole file set — commands, workflow skills, agents, hooks, bundled skills — is described exactly once, by `collect<Platform>Templates()` in `packages/cli/src/configurators/<platform>.ts`. For bundled skills that description is two calls: `resolveBundledSkills(ctx)` reads every directory under `templates/common/bundled-skills/`, resolves placeholders, and returns a flat list of `{relativePath, content}` entries; `collectSkillTemplates(<skillsRoot>, <workflowSkills>, <bundledSkills>)` folds them into the platform's `Map<filePath, content>` under `<skillsRoot>/<skill>/<relativePath>`.
| Platform | Bundled skill root | Notes | All 21 platforms receive the full bundled-skill set:
| --- | --- | --- |
| Claude Code | `.claude/skills/<skill>/` | `configureClaude` |
| Cursor | `.cursor/skills/<skill>/` | `configureCursor` |
| Codex | `.agents/skills/<skill>/` | `configureCodex` writes the shared `.agents/skills/` root, which Gemini CLI 0.40+ also reads |
| Gemini CLI | `.agents/skills/<skill>/` | Same shared root as Codex; the two configurators are required to produce byte-identical output |
| Kiro | `.kiro/skills/<skill>/` | `configureKiro` (skills-based platform — no commands) |
| Qoder | `.qoder/skills/<skill>/` | `configureQoder` |
| Codebuddy | `.codebuddy/skills/<skill>/` | `configureCodebuddy` |
| Copilot | `.github/skills/<skill>/` | `configureCopilot` |
| Droid | `.factory/skills/<skill>/` | `configureDroid` |
| Antigravity | `.agent/skills/<skill>/` | `configureAntigravity` |
| Devin | `.devin/skills/<skill>/` | `configureDevin` |
| Kilo | `.kilocode/skills/<skill>/` | `configureKilo` |
| ZCode | `.zcode/skills/<skill>/` | `configureZcode` |
| OpenCode | (handled by `collectOpenCodeTemplates`) | Uses the same `resolveBundledSkills(ctx)` output |
| Pi, Reasonix | (their own collectors) | Same `resolveBundledSkills(ctx)` output |
Two paths exercise the same data: | Platform | Bundled skill root |
| --- | --- |
| Claude Code | `.claude/skills/<skill>/` |
| Cursor | `.cursor/skills/<skill>/` |
| OpenCode | `.opencode/skills/<skill>/` |
| Codex | `.agents/skills/<skill>/` |
| Gemini CLI | `.agents/skills/<skill>/` |
| Pi | `.agents/skills/<skill>/` |
| Kimi | `.agents/skills/<skill>/` |
| Kilo | `.kilocode/skills/<skill>/` |
| Kiro | `.kiro/skills/<skill>/` |
| Antigravity | `.agent/skills/<skill>/` |
| Devin | `.devin/skills/<skill>/` |
| Qoder | `.qoder/skills/<skill>/` |
| Codebuddy | `.codebuddy/skills/<skill>/` |
| Copilot | `.github/skills/<skill>/` |
| Droid | `.factory/skills/<skill>/` |
| Reasonix | `.reasonix/skills/<skill>/` |
| ZCode | `.zcode/skills/<skill>/` |
| Trae | `.trae/skills/<skill>/` |
| OMP | `.omp/skills/<skill>/` |
| Grok | `.grok/skills/<skill>/` |
| Snow | `.snow/skills/<skill>/` |
1. `configureX(cwd)` writes files during `trellis init`. Codex, Gemini CLI, Pi and Kimi share the `.agents/skills/` root (the upstream Agent Skills workspace alias). Their collectors are required to emit byte-identical content for every file more than one of them writes there.
2. `collectPlatformTemplates(platformId)` (in `configurators/index.ts`) returns a `Map<filePath, content>` that `trellis update` uses to detect drift and to populate `.trellis/.template-hashes.json`. Both must produce byte-identical output, so they both call `resolveBundledSkills(ctx)` and `collectSkillTemplates(root, …, resolveBundledSkills(ctx))`.
One description, two consumers:
1. `trellis init` → `configurePlatform(platformId, cwd)` → `writeTemplateMap(cwd, collect<Platform>Templates())`. For 18 of the 21 platforms the registry entry in `configurators/index.ts` is literally `fromTemplates(collect<Platform>Templates)`, which *is* that composition. Claude Code, Codex and ZCode spell out a `configure` of their own, each for work a `Map<path, content>` cannot express (an opt-in `--with-statusline` flag, an intentionally empty `.codex/skills/` directory, a one-shot console notice) — none of them restates the file list.
2. `trellis update` → `collectPlatformTemplates(platformId)` (in `configurators/index.ts`) → the same map, used to detect drift and to populate `.trellis/.template-hashes.json`.
Because both consumers read the one description, init and update cannot disagree about which files a bundled skill produces.
## Dispatch Wiring (Code Path) ## Dispatch Wiring (Code Path)
@@ -67,10 +79,10 @@ The mechanism that auto-dispatches bundled skills to platform skill roots lives
2. `packages/cli/src/configurators/shared.ts` 2. `packages/cli/src/configurators/shared.ts`
- `resolveBundledSkills(ctx)` flattens that list into `ResolvedSkillFile[]` with `<skill>/<relativePath>` paths and resolved placeholders. - `resolveBundledSkills(ctx)` flattens that list into `ResolvedSkillFile[]` with `<skill>/<relativePath>` paths and resolved placeholders.
- `writeSkills(skillsRoot, workflowSkills, bundledSkills)` writes both workflow skills and bundled skill files under `skillsRoot`. - `collectSkillTemplates(skillsRoot, workflowSkills, bundledSkills)` returns workflow skills and bundled skill files together as a `Map<filePath, content>` rooted at `skillsRoot`.
- `collectSkillTemplates(skillsRoot, workflowSkills, bundledSkills)` returns the same shape as a `Map<filePath, content>` for the update / hash pipeline. - `writeTemplateMap(cwd, files)` is the single writer that puts a collected map on disk.
Every platform configurator that supports skills imports both helpers (see `claude.ts`, `cursor.ts`, `codex.ts`, `gemini.ts`, `kiro.ts`, `qoder.ts`, `codebuddy.ts`, `copilot.ts`, `droid.ts`, `antigravity.ts`, `devin.ts`, `kilo.ts`). The `index.ts` `PLATFORM_FUNCTIONS` registry also calls `resolveBundledSkills(ctx)` inside each `collectTemplates` closure so `trellis update` tracking stays consistent. Every platform that supports skills reaches those two helpers from its own `collect<Platform>Templates()` — either directly (`claude.ts`, `codex.ts`, `copilot.ts`, `gemini.ts`, `grok.ts`, `kimi.ts`, `kiro.ts`, `omp.ts`, `opencode.ts`, `pi.ts`, `reasonix.ts`, `snow.ts`, `zcode.ts`) or through `collectBothTemplates(ctx, cmdPath, skillRoot)` in `shared.ts`, which makes the same two calls on behalf of platforms that have both a commands directory and a skills root (`antigravity.ts`, `codebuddy.ts`, `cursor.ts`, `devin.ts`, `droid.ts`, `kilo.ts`, `qoder.ts`, `trae.ts`).
## Adding a New Bundled Skill ## Adding a New Bundled Skill
@@ -137,7 +149,7 @@ There is no per-project opt-out flag for bundled skills. Two options:
2. **Pin a Trellis version that did not ship the skill.** The bundled-skill set is determined at build time, so installing an older release of the CLI is the only way to permanently exclude a skill that the current release ships. 2. **Pin a Trellis version that did not ship the skill.** The bundled-skill set is determined at build time, so installing an older release of the CLI is the only way to permanently exclude a skill that the current release ships.
A third option — globally disabling all bundled skills — is not supported. The dispatch is unconditional in every configurator. Adding such a flag would require changing `PLATFORM_FUNCTIONS` in `configurators/index.ts` and every `configureX` function. A third option — globally disabling all bundled skills — is not supported. The dispatch is unconditional: `collect<Platform>Templates()` takes no arguments, so there is nowhere for a flag to enter. Adding one would mean changing that signature across all 21 platforms plus `collectPlatformTemplates` in `configurators/index.ts`.
## Operating Rules ## Operating Rules
@@ -19,7 +19,7 @@ Common files:
| Claude Code | `.claude/settings.json` | | Claude Code | `.claude/settings.json` |
| Cursor | `.cursor/hooks.json` | | Cursor | `.cursor/hooks.json` |
| Codex | `.codex/hooks.json`, `.codex/config.toml` | | Codex | `.codex/hooks.json`, `.codex/config.toml` |
| OpenCode | `.opencode/package.json`, `.opencode/plugins/*` | | OpenCode | `.opencode/package.json`, `.opencode/plugins/*`, `.opencode/hooks/inject-spec-context.py` |
| Kiro | `.kiro/hooks/` + platform config | | Kiro | `.kiro/hooks/` + platform config |
| Gemini CLI | `.gemini/settings.json` | | Gemini CLI | `.gemini/settings.json` |
| Qoder | `.qoder/settings.json` | | Qoder | `.qoder/settings.json` |
@@ -40,6 +40,7 @@ Whether these files exist in a project depends on which `trellis init --<platfor
| `session-start.py` | Generates session-start context. | | `session-start.py` | Generates session-start context. |
| `inject-workflow-state.py` | Parses `[workflow-state:STATUS]` blocks in `.trellis/workflow.md` and emits the body matching the current task status. Falls back to `Refer to workflow.md for current step.` when no matching block exists. | | `inject-workflow-state.py` | Parses `[workflow-state:STATUS]` blocks in `.trellis/workflow.md` and emits the body matching the current task status. Falls back to `Refer to workflow.md for current step.` when no matching block exists. |
| `inject-subagent-context.py` | Injects PRD, JSONL context, and related spec/research into sub-agents. | | `inject-subagent-context.py` | Injects PRD, JSONL context, and related spec/research into sub-agents. |
| `inject-spec-context.py` | Matches path-scoped specs and manages budgeted delivery state. |
| `inject-shell-session-context.py` | Lets shell commands inherit Trellis session identity. | | `inject-shell-session-context.py` | Lets shell commands inherit Trellis session identity. |
Not every platform has every hook. Do not copy files from another platform just because a platform lacks a hook; first confirm whether that platform supports the corresponding event. Not every platform has every hook. Do not copy files from another platform just because a platform lacks a hook; first confirm whether that platform supports the corresponding event.
+25
View File
@@ -1,5 +1,17 @@
{ {
"hooks": { "hooks": {
"SessionStart": [
{
"matcher": "^(?:clear|compact)$",
"hooks": [
{
"type": "command",
"command": "python3 -X utf8 .codex/hooks/inject-spec-context.py",
"timeout": 15
}
]
}
],
"UserPromptSubmit": [ "UserPromptSubmit": [
{ {
"hooks": [ "hooks": [
@@ -22,6 +34,19 @@
} }
] ]
} }
],
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3 -X utf8 .codex/hooks/inject-spec-context.py",
"timeout": 15,
"additionalContextLimit": 0
}
]
}
] ]
} }
} }
+844
View File
@@ -0,0 +1,844 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Path-Scoped Spec Context Injection Hook (ticket-refresh model)
When the agent touches a file, the specs that govern that file are surfaced
right then — small, relevant, budgeted — instead of everything up front or
nothing at all. Spec .md files under .trellis/spec/ declare which code paths
they govern via YAML frontmatter (`paths:` glob list); the matching engine
lives in .trellis/scripts/common/spec_match.py and the decision engine in
.trellis/scripts/common/spec_inject.py. This file is the IO shell: stdin,
config, identity, state files, locking, GC, one print.
Triggers:
* Claude Code PostToolUse (matcher "Read|Edit|Write|MultiEdit") receives one
structured file path after the tool runs.
* Codex PreToolUse (matcher "Edit|Write") receives an ``apply_patch`` command.
Every patch header is matched before the patch runs. When a FULL spec is
emitted, the patch is denied once so the model can read the injected rules
and retry; ticket-only reminders do not block.
* OpenCode ``tool.execute.before`` adapts ``write``, ``edit``, and
``apply_patch`` into the same PreToolUse payload. FULL delivery uses the same
deny-once decision before the JS plugin surfaces context as a tool error.
Behavior — per matched spec, per event (recency-decay aware):
h = sha256(spec bytes)
last = newest emission recorded for (identity, spec) # stateless → None
if stateless: emit TICKET # bounded cost, always
elif last is None: emit FULL # first time this session
elif last.sha256 != h: emit FULL # spec changed → re-teach
elif reset since last: emit FULL # the text is gone, re-teach
elif within window: silent # fixed window; no state append
else: emit TICKET # refresh attention cheaply
A FULL block inlines the (budgeted) spec body with a sha256 attr; a TICKET is
a short reminder pointing back at the spec. Both append a state record; silent
hits do not (fixed window, not sliding — continuous editing is exactly when
drift is worst).
Identity (misfire asymmetry: a collision that MISSES an injection is
unacceptable; drift that OVER-injects is fine): the session/window key is
delegated to common.active_task.resolve_context_key — the shared resolver
every other hook uses — called payload-first, environment-inclusive second,
so two live sessions can never collapse onto one exported env value. A
`+a-<agent_id>` suffix (appended after each part is sanitized) keeps a
subagent's state separate from its parent's. When the resolver is unavailable
(older installed scripts tree), a minimal payload-only ladder (session keys,
then transcript hash) keeps the hook working. No key from any source, or an
unwritable state dir → stateless: no state IO at all, every hit is a TICKET
(circuit breaker — never a FULL re-emission loop).
State: user-global, out of the repo, one append-only JSONL file per identity
under ${TRELLIS_SPEC_STATE_DIR:-~/.trellis/spec-inject}/<project16>/<identity>.jsonl.
SessionStart(clear|compact) appends an opaque reset marker to the base session
shard; parent and subagent emission histories stay separate but observe that
shared marker. A best-effort fcntl lock is held across read→decide→append;
where fcntl is unavailable (Windows) the worst case is a duplicate injection.
A once-per-hour GC prunes conforming shards older than 48 h.
Budget (config.yaml `spec_injection:`): per-spec cap `max_spec_chars`
(default 9400) with code-point truncation + in-body notice; per-event cap
`max_total_chars` (default 9500 — below Claude Code's documented
additionalContext ceiling, and enforced directly for Codex). Once the total
budget is exhausted, remaining FULL bodies degrade to one <spec-index> block;
tickets are counted last and dropped (with a stderr warning) only if even they
do not fit.
Refresh window (config.yaml `spec_injection:`): `refresh_window_seconds`
(default 2700; `0` = never refresh unchanged content solely because time
passed).
Fail-open on errors: non-matching events, malformed paths, no matches, or any
internal error → exit 0 with no stdout (stderr warnings allowed). The only
deliberate block is a Codex patch that just received a FULL governing spec.
"""
from __future__ import annotations
# IMPORTANT: Suppress all warnings FIRST
import warnings
warnings.filterwarnings("ignore")
import hashlib
import json
import os
import re
import sys
import time
import uuid
from pathlib import Path
# IMPORTANT: Force UTF-8 on Windows for the streams this hook uses.
# stdin carries the payload (non-ASCII file paths), stdout carries the spec
# bodies, stderr carries warnings that quote both; without this the default
# ANSI codepage raises UnicodeDecodeError / UnicodeEncodeError.
if sys.platform.startswith("win"):
import io as _io
for _stream_name in ("stdin", "stdout", "stderr"):
_stream = getattr(sys, _stream_name, None)
if _stream is None:
continue
if hasattr(_stream, "reconfigure"):
try:
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
except Exception:
pass # Optional Windows stream setup; keep hook startup non-fatal.
elif hasattr(_stream, "detach"):
try:
setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace"))
except Exception:
pass # Optional Windows stream setup; keep hook startup non-fatal.
# =============================================================================
# Constants
# =============================================================================
DIR_WORKFLOW = ".trellis"
DIR_SPEC = "spec"
# Tools whose events trigger spec matching (Claude Code tool names). Touching a
# file — even a Read — counts; the miss path stays a fast exit. Overridable via
# config `spec_injection.tools` (e.g. to drop "Read").
DEFAULT_EDIT_TOOLS = ("Read", "Edit", "Write", "MultiEdit")
# Budget defaults sized against Claude Code's documented 10,000-CHARACTER
# additionalContext ceiling — stay under with margin. The <spec-context>
# wrapper (~152 chars with typical rel paths) counts against the total, so a
# spec lands whole only up to ~9348 chars; at the per-spec cap the block is
# derived-truncated further to fit the event ceiling. `0` = unlimited.
DEFAULT_MAX_SPEC_CHARS = 9400
DEFAULT_MAX_TOTAL_CHARS = 9500
# Refresh-window default. `0` = never refresh solely because time passed.
DEFAULT_REFRESH_WINDOW_SECONDS = 2700
# State-file base dir (overridable for tests / hermeticity) and GC policy.
STATE_ENV_DIR = "TRELLIS_SPEC_STATE_DIR"
STATE_DEFAULT_DIR = "~/.trellis/spec-inject"
GC_MARKER = ".last-gc"
GC_INTERVAL_SECONDS = 60 * 60 # GC runs at most once per hour
STATE_MAX_AGE_SECONDS = 48 * 60 * 60 # shards older than this are pruned
# GC scope: exactly `<base>/<project16>/<identity>[.<pid>].jsonl`, never a
# recursive walk — a hostile or mistyped TRELLIS_SPEC_STATE_DIR must not turn
# this hook into an unlink loop over someone's files. The optional `.<pid>`
# alternative covers shards written by the pre-lock layout.
GC_PROJECT_DIR_RE = re.compile(r"^[0-9a-f]{16}$")
# `+` is part of the identity charset: subagent shards use the `+a-<agent_id>`
# suffix (contract amendment 2 — without it those shards were never pruned).
GC_SHARD_NAME_RE = re.compile(r"^[A-Za-z0-9_+-]+(\.[0-9]+)?\.jsonl$")
PATCH_PATH_RE = re.compile(
r"^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$"
)
def _warn(message: str) -> None:
print(f"[inject-spec-context] WARN: {message}", file=sys.stderr)
def _patch_paths(command: str) -> list[str]:
"""Return file paths from the shared apply_patch grammar."""
paths: list[str] = []
for line in command.splitlines():
match = PATCH_PATH_RE.fullmatch(line)
if match:
path = match.group(1).strip()
if path and path not in paths:
paths.append(path)
return paths
def _agent_id(payload: dict) -> str:
"""The subagent id carried by the event, or "" for a main-session event."""
raw = payload.get("agent_id")
return raw.strip() if isinstance(raw, str) else ""
def find_trellis_root(start: Path) -> Path | None:
"""Walk up from start to find the directory containing .trellis/.
Handles CWD drift: subdirectory launches, monorepo packages, etc.
Returns None if no .trellis/ found (silent no-op).
"""
cur = start.resolve()
while cur != cur.parent:
if (cur / DIR_WORKFLOW).is_dir():
return cur
cur = cur.parent
return None
def _scripts_dir_on_path(root: Path) -> None:
scripts_dir = root / DIR_WORKFLOW / "scripts"
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
# =============================================================================
# Config (.trellis/config.yaml `spec_injection:` section)
# =============================================================================
def _read_trellis_config(root: Path) -> dict:
"""Load .trellis/config.yaml via the bundled trellis_config helper.
The helper lives in .trellis/scripts/common; the hook lives outside the
scripts tree, so we extend sys.path before importing.
"""
_scripts_dir_on_path(root)
try:
from common.trellis_config import read_trellis_config # type: ignore[import-not-found]
except Exception:
return {}
try:
return read_trellis_config(root)
except Exception:
return {}
def _parse_tools(raw: object) -> tuple[str, ...] | None:
"""Parse `spec_injection.tools` into a tuple of tool names.
Two grammars, because the bundled YAML reader hands the value over in two
shapes: a block list (``- Edit`` items) arrives as a list, a flow sequence
(``tools: [Edit, Write]``) arrives as the raw string. ``[]`` in either
shape is a deliberate "never trigger" and is respected. Returns None for a
value that is neither (the caller warns and keeps the defaults).
"""
if isinstance(raw, list):
return tuple(t.strip() for t in raw if isinstance(t, str) and t.strip())
if isinstance(raw, str):
text = raw.strip()
if text.startswith("[") and text.endswith("]"):
items = (part.strip().strip("\"'").strip() for part in text[1:-1].split(","))
return tuple(item for item in items if item)
return None
def get_spec_injection_settings(
root: Path,
) -> tuple[bool, int, int, int, tuple[str, ...]]:
"""Return (enabled, max_spec_chars, max_total_chars,
refresh_window_seconds, tools).
Reads the ``spec_injection:`` section of ``.trellis/config.yaml``:
spec_injection:
enabled: true
max_spec_chars: 9400
max_total_chars: 9500
refresh_window_seconds: 2700
tools:
- Read
- Edit
- Write
- MultiEdit
Missing keys use their defaults; ``0`` disables the corresponding limit
(``max_spec_chars: 0`` = inline the whole body, ``max_total_chars: 0`` =
no per-event ceiling) or refresh (window keys). ``tools`` also accepts a
flow sequence (``tools: [Edit, Write]``), and ``tools: []`` disables every
trigger. Invalid values fall back to the default for that key with a
stderr warning; tool names outside the known set warn once.
"""
enabled = True
tools = DEFAULT_EDIT_TOOLS
numbers = {
"max_spec_chars": DEFAULT_MAX_SPEC_CHARS,
"max_total_chars": DEFAULT_MAX_TOTAL_CHARS,
"refresh_window_seconds": DEFAULT_REFRESH_WINDOW_SECONDS,
}
config = _read_trellis_config(root)
section = config.get("spec_injection") if isinstance(config, dict) else None
if isinstance(section, dict):
raw_enabled = section.get("enabled", True)
if isinstance(raw_enabled, bool):
enabled = raw_enabled
else:
s = str(raw_enabled).strip().lower()
if s in ("false", "no", "0", "off"):
enabled = False
elif s not in ("true", "yes", "1", "on"):
_warn(
f"invalid spec_injection.enabled value: {raw_enabled!r}; "
f"using true (default)"
)
# int() coercion stays local to this hook by decision (audit round,
# 2026-07-25): widening the shared common/config.py helpers has more
# blast radius than this small duplication costs.
for key, default_value in list(numbers.items()):
if key not in section:
continue
raw = section[key]
try:
value = int(raw)
except (TypeError, ValueError):
value = -1
if value < 0:
_warn(
f"invalid spec_injection.{key} value: {raw!r}; "
f"using default {default_value}"
)
continue
numbers[key] = value
if "tools" in section:
parsed_tools = _parse_tools(section["tools"])
if parsed_tools is None:
_warn(
f"invalid spec_injection.tools value: {section['tools']!r}; "
f"using default {list(DEFAULT_EDIT_TOOLS)}"
)
else:
tools = parsed_tools
unknown = [t for t in tools if t not in DEFAULT_EDIT_TOOLS]
if unknown:
_warn(
f"unknown spec_injection.tools entries {unknown} — "
f"they will never match; known tools: "
f"{list(DEFAULT_EDIT_TOOLS)}"
)
return (
enabled,
numbers["max_spec_chars"],
numbers["max_total_chars"],
numbers["refresh_window_seconds"],
tools,
)
# =============================================================================
# Identity ladder
# =============================================================================
def _sanitize(raw: str) -> str:
"""Map a session/agent id to a filename-safe, collision-free token.
A readable head (the first 80 characters, every character outside
``[A-Za-z0-9_-]`` replaced one-for-one by ``-``) plus, whenever anything
was replaced or the id was longer than 80 characters, ``-`` and 8 hex of
sha256(raw). The suffix is what makes the mapping injective: without it,
"a/b" and "a:b" — or two ids sharing an 80-character prefix — would fold
onto one state file, and a collision that MISSES an injection is the
unacceptable failure. Output stays inside the GC name class.
"""
raw = raw.strip()
head = raw[:80]
safe = re.sub(r"[^A-Za-z0-9_-]", "-", head)
if safe != head or len(raw) > 80:
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:8]
return f"{safe}-{digest}"
return safe
def _shared_context_key(root: Path, payload: dict) -> str | None:
"""Session/window key from the shared resolver every other hook uses.
``common.active_task.resolve_context_key`` is the single source of truth
for session identity: payload keys in all casings (``session_id`` /
``sessionId`` / ``sessionID``, conversation and transcript variants),
nested payload shapes, the explicit ``TRELLIS_CONTEXT_ID`` override,
per-platform env fallbacks, and Cursor shell tickets — plus the platform
fixes accumulated behind them. Payload identity is preferred over
environment context so two live sessions can never collapse onto one
exported env value (collision → missed injection is the unacceptable
direction); the environment pass still runs when the payload carries
nothing.
"""
try:
_scripts_dir_on_path(root)
from common.active_task import resolve_context_key # type: ignore[import-not-found]
key = resolve_context_key(payload, allow_environment_context=False)
if key:
return key
return resolve_context_key(payload)
except Exception:
return None
def resolve_base_identity(root: Path, payload: dict) -> tuple[str, bool]:
"""Return the base session identity for reset and refresh state.
When the shared resolver is unavailable (older installed scripts tree), a
minimal payload-only ladder keeps the hook working. ``stateless=True``
means no state IO is possible.
"""
identity_payload = payload
if payload.get("hook_event_name") == "SessionStart":
# In this event `source` means startup/clear/compact, not platform.
# The shared resolver also accepts a generic `source` platform hint,
# so remove the lifecycle field to keep the same session identity as
# later PostToolUse events.
identity_payload = dict(payload)
identity_payload.pop("source", None)
key = _shared_context_key(root, identity_payload)
if not key:
# Minimal payload-only fallback for scripts trees that predate
# resolve_context_key. Mirrors its payload lookup order.
for k in ("session_id", "sessionId", "sessionID"):
value = payload.get(k)
if isinstance(value, str) and value.strip():
key = "s-" + value.strip()
break
if not key:
transcript = payload.get("transcript_path")
if isinstance(transcript, str) and transcript.strip():
digest = hashlib.sha256(
transcript.strip().encode("utf-8")
).hexdigest()
key = "t-" + digest[:16]
if not key:
return "", True
return _sanitize(key), False
# =============================================================================
# State (one append-only JSONL file per identity, locked, user-global)
# =============================================================================
def _state_base_dir() -> Path:
override = os.environ.get(STATE_ENV_DIR)
if override and override.strip():
return Path(override.strip())
return Path(os.path.expanduser(STATE_DEFAULT_DIR))
def _project_id(root: Path) -> str:
return hashlib.sha256(os.path.realpath(str(root)).encode("utf-8")).hexdigest()[:16]
def _maybe_gc(base_dir: Path) -> None:
"""Prune conforming shards older than 48 h, at most once per hour.
Scope is exact-depth (``<base>/<project16>/<shard>.jsonl``) and name-gated;
foreign files and directories are never touched. Containment is enforced
against symlinks on both levels — a symlinked project dir or shard is
skipped outright, and every unlink candidate must still be under the
resolved base after realpath — so a planted link cannot walk this GC out
of its own tree. Best-effort, errors ignored.
"""
try:
base_real = os.path.realpath(str(base_dir))
marker = base_dir / GC_MARKER
now = time.time()
try:
age = now - marker.stat().st_mtime
except OSError:
age = None
if age is not None and age < GC_INTERVAL_SECONDS:
return
try:
base_dir.mkdir(parents=True, exist_ok=True)
marker.touch()
except OSError:
return
try:
project_dirs = list(base_dir.iterdir())
except OSError:
return
for project_dir in project_dirs:
if not GC_PROJECT_DIR_RE.match(project_dir.name):
continue
try:
if project_dir.is_symlink() or not project_dir.is_dir():
continue
shards = list(project_dir.iterdir())
except OSError:
continue
for shard in shards:
if not GC_SHARD_NAME_RE.match(shard.name):
continue
try:
if shard.is_symlink() or not shard.is_file():
continue
shard_real = os.path.realpath(str(shard))
if not shard_real.startswith(base_real + os.sep):
continue
if now - shard.stat().st_mtime > STATE_MAX_AGE_SECONDS:
shard.unlink()
except OSError:
continue
except Exception:
pass
def open_shard(shard_path: Path) -> int | None:
"""Open (creating) the identity's shard for read+append.
Doubles as the writability probe: a failure here trips the circuit breaker
and the event runs stateless (ticket-only), which is bounded, instead of
re-emitting full specs on every event forever.
"""
try:
shard_path.parent.mkdir(parents=True, exist_ok=True)
except OSError:
_warn(f"state dir {shard_path.parent} unusable — running stateless")
return None
try:
return os.open(
str(shard_path),
os.O_RDWR | os.O_CREAT | os.O_APPEND,
0o644,
)
except OSError:
_warn(f"state shard {shard_path} unusable — running stateless")
return None
def lock_shard(fd: int) -> None:
"""Best-effort exclusive lock held across read→decide→append.
Closes the duplicate-injection race between concurrent hook processes on
POSIX. No fcntl (Windows) or an unsupported filesystem → no lock; the
worst case is a duplicate injection, never a lost one.
"""
try:
import fcntl
fcntl.flock(fd, fcntl.LOCK_EX)
except Exception:
pass
def unlock_shard(fd: int) -> None:
try:
import fcntl
fcntl.flock(fd, fcntl.LOCK_UN)
except Exception:
pass
def load_state(
fd: int,
state_version: int,
) -> tuple[dict[str, dict], str | None] | None:
"""Read the shard through the already-open fd; newest record per spec wins
(``ts`` decides, and on an exact tie the later line in the file does —
appends are ordered, and two records one float apart must not resolve to
the older one). The latest reset marker is returned separately. Malformed
lines and foreign schema versions are skipped silently; read failures
return None so the caller uses stateless ticket mode."""
result: dict[str, dict] = {}
latest_reset: str | None = None
try:
os.lseek(fd, 0, os.SEEK_SET)
chunks: list[bytes] = []
while True:
chunk = os.read(fd, 1 << 20)
if not chunk:
break
chunks.append(chunk)
except OSError:
return None
text = b"".join(chunks).decode("utf-8", errors="replace")
for line in text.splitlines():
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
except (json.JSONDecodeError, ValueError):
continue
if not isinstance(record, dict):
continue
if record.get("v") != state_version:
continue
spec = record.get("spec")
reset = record.get("reset")
if not isinstance(spec, str):
if isinstance(reset, str) and reset:
latest_reset = reset
continue
ts = record.get("ts")
if not isinstance(ts, (int, float)):
continue
previous = result.get(spec)
if previous is None or ts >= previous.get("ts", float("-inf")):
result[spec] = record
return result, latest_reset
def append_records(fd: int, records: list[dict]) -> bool:
"""Append records as JSONL (O_APPEND) and report whether all bytes landed."""
if not records:
return True
try:
blob = "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in records)
encoded = blob.encode("utf-8")
if os.write(fd, encoded) != len(encoded):
_warn("could not write complete state shard — state may be incomplete")
return False
return True
except OSError:
_warn("could not write state shard — state may be incomplete")
return False
# =============================================================================
# Entry
# =============================================================================
def main() -> int:
if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1":
return 0
try:
input_data = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError, UnicodeDecodeError):
return 0
if not isinstance(input_data, dict):
return 0
cwd = input_data.get("cwd") or os.getcwd()
root = find_trellis_root(Path(cwd))
if root is None:
return 0
# Bail out before any spec scan when the project has no spec directory.
if not (root / DIR_WORKFLOW / DIR_SPEC).is_dir():
return 0
(
enabled,
max_spec_chars,
max_total_chars,
win_seconds,
tools,
) = get_spec_injection_settings(root)
if not enabled:
return 0
_scripts_dir_on_path(root)
try:
from common.spec_inject import STATE_VERSION # type: ignore[import-not-found]
except Exception:
return 0
if input_data.get("hook_event_name") == "SessionStart":
if input_data.get("source") not in ("clear", "compact"):
return 0
base_identity, stateless = resolve_base_identity(root, input_data)
if stateless:
_warn("SessionStart reset has no stable session identity")
return 0
base_dir = _state_base_dir()
_maybe_gc(base_dir)
reset_path = base_dir / _project_id(root) / f"{base_identity}.jsonl"
reset_fd = open_shard(reset_path)
if reset_fd is None:
return 0
try:
lock_shard(reset_fd)
append_records(
reset_fd,
[{"v": STATE_VERSION, "reset": uuid.uuid4().hex, "ts": time.time()}],
)
finally:
unlock_shard(reset_fd)
try:
os.close(reset_fd)
except OSError:
pass
return 0
event_name = input_data.get("hook_event_name")
tool_name = input_data.get("tool_name", "") or input_data.get("toolName", "")
if not isinstance(tool_name, str) or not tool_name:
return 0
is_pre_tool_use = event_name == "PreToolUse"
is_patch_tool = event_name == "PreToolUse" and tool_name == "apply_patch"
logical_tool = "Edit" if is_patch_tool else tool_name
# An empty `tools` list is the documented "disable every trigger" switch.
if not tools or logical_tool not in tools:
return 0
# snake_case is Claude Code's shape; camelCase keeps parity with the
# sibling hooks that already accept both (other platforms emit toolInput).
tool_input = input_data.get("tool_input")
if not isinstance(tool_input, dict):
tool_input = input_data.get("toolInput")
if not isinstance(tool_input, dict):
return 0
try:
from common.spec_match import ( # type: ignore[import-not-found]
match_specs_for_file,
normalize_repo_relative,
)
from common.spec_inject import ( # type: ignore[import-not-found]
assemble_payload,
)
except Exception:
return 0 # matching/decision engine unavailable — degrade to nothing
if is_patch_tool:
command = tool_input.get("command")
if not isinstance(command, str):
return 0
raw_paths = _patch_paths(command)
else:
file_path = tool_input.get("file_path")
if not isinstance(file_path, str) or not file_path.strip():
return 0
raw_paths = [file_path.strip()]
file_paths: list[str] = []
for raw_path in raw_paths:
normalized = normalize_repo_relative(root, raw_path)
if normalized is not None and normalized not in file_paths:
file_paths.append(normalized)
if not file_paths:
return 0
matches = []
match_files: dict[str, str] = {}
for file_path in file_paths:
for match in match_specs_for_file(root, file_path):
if match.rel_path in match_files:
continue
matches.append(match)
match_files[match.rel_path] = file_path
if not matches:
return 0
base_identity, stateless = resolve_base_identity(root, input_data)
identity = base_identity
agent = _agent_id(input_data)
if agent:
identity += "+a-" + _sanitize(agent)
state_records: dict[str, dict] = {}
clock = {"reset": None, "ts": time.time()}
fd: int | None = None
if not stateless:
base_dir = _state_base_dir()
_maybe_gc(base_dir)
project_dir = base_dir / _project_id(root)
base_fd = open_shard(project_dir / f"{base_identity}.jsonl")
if base_fd is None:
# Circuit breaker: unwritable state → ticket-only for this event.
stateless = True
else:
lock_shard(base_fd)
base_snapshot = load_state(base_fd, STATE_VERSION)
if base_snapshot is None:
stateless = True
unlock_shard(base_fd)
os.close(base_fd)
else:
base_records, reset_id = base_snapshot
if identity == base_identity:
fd = base_fd
state_records = base_records
else:
unlock_shard(base_fd)
os.close(base_fd)
fd = open_shard(project_dir / f"{identity}.jsonl")
if fd is None:
stateless = True
else:
lock_shard(fd)
snapshot = load_state(fd, STATE_VERSION)
if snapshot is None:
stateless = True
unlock_shard(fd)
os.close(fd)
fd = None
else:
state_records, _ = snapshot
if not stateless:
clock = {
"reset": reset_id,
"ts": time.time(),
}
edited_rel = match_files[matches[0].rel_path]
records_persisted = True
try:
payload, records = assemble_payload(
edited_rel,
matches,
stateless,
state_records,
clock,
max_spec_chars,
max_total_chars,
win_seconds,
match_files=match_files,
)
if fd is not None and records:
records_persisted = append_records(fd, records)
finally:
if fd is not None:
unlock_shard(fd)
try:
os.close(fd)
except OSError:
pass
if not payload:
return 0
hook_specific_output = {
"hookEventName": "PreToolUse" if is_pre_tool_use else "PostToolUse",
"additionalContext": payload,
}
if (
is_pre_tool_use
and records_persisted
and any(record.get("mode") == "full" for record in records)
):
hook_specific_output.update(
{
"permissionDecision": "deny",
"permissionDecisionReason": (
"Trellis injected governing specs. Review them, then retry "
"this tool call."
),
}
)
output = {"hookSpecificOutput": hook_specific_output}
print(json.dumps(output, ensure_ascii=False))
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception:
# Hook failures must never break the tool result or the session.
sys.exit(0)
+13 -5
View File
@@ -215,11 +215,12 @@ def truncate_utf8(data: bytes, cap: int) -> bytes:
seq_len = 4 seq_len = 4
else: else:
seq_len = 1 seq_len = 1
# Drop the lead byte too if its full sequence didn't fit. # Cut before the lead byte when its full sequence didn't fit;
# otherwise the trailing sequence is complete — keep it whole.
if (i - 1) + seq_len > len(truncated): if (i - 1) + seq_len > len(truncated):
i -= 1 return truncated[: i - 1]
return truncated[:i] return truncated
class _Budget: class _Budget:
@@ -877,8 +878,15 @@ def _handle_codex_subagent_start(input_data: dict) -> None:
if not subagent_type or not parent_session_id: if not subagent_type or not parent_session_id:
return return
cwd = _string_value(input_data.get("cwd")) or os.getcwd() # Payload cwd first, then our own — some hosts (CodeBuddy IDE 4.10.4)
repo_root = find_repo_root(cwd) # report "/" for every hook event. See inject-workflow-state.py.
repo_root = None
for candidate in (_string_value(input_data.get("cwd")), os.getcwd()):
if not candidate:
continue
repo_root = find_repo_root(candidate)
if repo_root:
break
if not repo_root: if not repo_root:
return return
+52 -16
View File
@@ -10,19 +10,24 @@ The emitted ``hookEventName`` field is platform-aware: most hosts expect
CodeBuddy / Droid / Codex / Copilot wiring), but Gemini CLI 0.40.x renamed CodeBuddy / Droid / Codex / Copilot wiring), but Gemini CLI 0.40.x renamed
its per-turn event to ``BeforeAgent`` and its schema validator rejects the its per-turn event to ``BeforeAgent`` and its schema validator rejects the
legacy name. ``_detect_platform`` picks the right value at runtime. legacy name. ``_detect_platform`` picks the right value at runtime.
Breadcrumb text is pulled exclusively from workflow.md Breadcrumb text is pulled exclusively from the resolved workflow file's
[workflow-state:STATUS] tag blocks — workflow.md is the single source of [workflow-state:STATUS] tag blocks — the active task may select a
truth. There are no fallback dicts in this script: when workflow.md is per-task variant (`.trellis/workflows/<id>.md` via task.json `workflow`),
otherwise personal, team, and global defaults are resolved in order.
There are no fallback dicts in this script: when the resolved workflow is
missing or a tag is absent, the breadcrumb degrades to a generic missing or a tag is absent, the breadcrumb degrades to a generic
"Refer to workflow.md for current step." line so users see (and fix) "Refer to workflow.md for current step." line so users see (and fix)
the broken state instead of the hook silently masking it. the broken state instead of the hook silently masking it.
Shared across all hook-capable platforms (Claude, Cursor, Codex, Qoder, Which platforms register this hook is decided by SHARED_HOOKS_BY_PLATFORM
CodeBuddy, Droid, Gemini, Copilot, Kiro). Kiro wires this via the CLI in templates/shared-hooks/index.ts — currently Claude, Codex, Gemini,
Qoder, Copilot, CodeBuddy, Droid, Kiro, Trae and ZCode. That table is the
source of truth; each listed platform's collect<Platform>Templates() pulls
this file into its template map through collectSharedHooks(), and a single
writer puts that map on disk at init time. Kiro wires this via the CLI
custom agent's ``hooks.userPromptSubmit`` and the IDE ``.kiro.hook`` custom agent's ``hooks.userPromptSubmit`` and the IDE ``.kiro.hook``
``promptSubmit`` event; its output branch emits a plain-text breadcrumb ``promptSubmit`` event; its output branch emits a plain-text breadcrumb
(Kiro adds hook stdout directly to the conversation context). Written to (Kiro adds hook stdout directly to the conversation context).
each platform's hooks directory via writeSharedHooks() at init time.
Silent exit 0 cases (no output): Silent exit 0 cases (no output):
- No .trellis/ directory found (not a Trellis project) - No .trellis/ directory found (not a Trellis project)
@@ -95,11 +100,16 @@ def find_trellis_root(start: Path) -> Optional[Path]:
def _detect_platform(input_data: dict) -> str | None: def _detect_platform(input_data: dict) -> str | None:
if isinstance(input_data.get("cursor_version"), str): if isinstance(input_data.get("cursor_version"), str):
return "cursor" return "cursor"
# CLAUDE_PROJECT_DIR is a compatibility alias that several hosts set
# alongside their own variable — CodeBuddy, ZCode and Trae all do. It must
# therefore be checked LAST, or every one of them is detected as claude and
# the context key becomes `claude_<their-session-id>`. That key does not
# match the session file `task.py start` wrote under the host's real name,
# so every turn reports no_task while the pointer exists on disk.
# Observed on CodeBuddy IDE 4.10.4: session file `codebuddy_ae54840e….json`
# alongside marker `update-check-claude_ae54840e….marker`, same id.
env_map = { env_map = {
# ZCode may set both ZCODE_PROJECT_DIR and CLAUDE_PROJECT_DIR; check
# ZCODE first so ZCode sessions aren't misdetected as claude.
"ZCODE_PROJECT_DIR": "zcode", "ZCODE_PROJECT_DIR": "zcode",
"CLAUDE_PROJECT_DIR": "claude",
"CURSOR_PROJECT_DIR": "cursor", "CURSOR_PROJECT_DIR": "cursor",
"CODEBUDDY_PROJECT_DIR": "codebuddy", "CODEBUDDY_PROJECT_DIR": "codebuddy",
"FACTORY_PROJECT_DIR": "droid", "FACTORY_PROJECT_DIR": "droid",
@@ -108,6 +118,8 @@ def _detect_platform(input_data: dict) -> str | None:
"KIRO_PROJECT_DIR": "kiro", "KIRO_PROJECT_DIR": "kiro",
"COPILOT_PROJECT_DIR": "copilot", "COPILOT_PROJECT_DIR": "copilot",
"TRAE_PROJECT_DIR": "trae", "TRAE_PROJECT_DIR": "trae",
# Last: the shared alias, only meaningful once no vendor key matched.
"CLAUDE_PROJECT_DIR": "claude",
} }
for env_name, platform in env_map.items(): for env_name, platform in env_map.items():
if os.environ.get(env_name): if os.environ.get(env_name):
@@ -183,16 +195,40 @@ _TAG_RE = re.compile(
re.DOTALL, re.DOTALL,
) )
def load_breadcrumbs(root: Path) -> dict[str, str]: def _resolve_workflow_md(root: Path, input_data: dict) -> Path:
"""Parse workflow.md for [workflow-state:STATUS] blocks. """Resolve the active task's workflow file, falling back to the global one.
Returns {status: body_text}. workflow.md is the single source of The per-task resolution rule lives in common.workflow_selection inside
.trellis/scripts. Older installed projects may not ship that module, and
hooks must never crash the session — ANY failure (import error, old
scripts tree, resolver bug) falls back to the global workflow.md.
"""
try:
scripts_dir = root / ".trellis" / "scripts"
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from common.workflow_selection import resolve_workflow_md # type: ignore[import-not-found]
return resolve_workflow_md(
root, input_data, platform=_detect_platform(input_data)
)
except Exception:
return root / ".trellis" / "workflow.md"
def load_breadcrumbs(root: Path, input_data: dict) -> dict[str, str]:
"""Parse the resolved workflow file for [workflow-state:STATUS] blocks.
Returns {status: body_text}. The workflow file is the single source of
truth — there are no fallback dicts in this script. Missing tags truth — there are no fallback dicts in this script. Missing tags
(or a missing/unreadable workflow.md) fall back to a generic line (or a missing/unreadable workflow file) fall back to a generic line
in build_breadcrumb so users see the broken state and fix in build_breadcrumb so users see the broken state and fix
workflow.md, rather than the hook silently masking the issue. workflow.md, rather than the hook silently masking the issue.
The active task's per-task workflow selection (task.json `workflow`
field) is honored via _resolve_workflow_md; without a selection this
reads the global .trellis/workflow.md exactly as before.
""" """
workflow = root / ".trellis" / "workflow.md" workflow = _resolve_workflow_md(root, input_data)
if not workflow.is_file(): if not workflow.is_file():
return {} return {}
try: try:
@@ -411,7 +447,7 @@ def main() -> int:
if prompt_has_skip_keyword(data.get("prompt", ""), _resolve_skip_keyword(config)): if prompt_has_skip_keyword(data.get("prompt", ""), _resolve_skip_keyword(config)):
return 0 # user opted out of the per-turn breadcrumb for this turn return 0 # user opted out of the per-turn breadcrumb for this turn
templates = load_breadcrumbs(root) templates = load_breadcrumbs(root, data)
platform = _detect_platform(data) platform = _detect_platform(data)
task = get_active_task(root, data) task = get_active_task(root, data)
if task is None: if task is None:
Regular → Executable
+20 -1
View File
@@ -452,6 +452,25 @@ def _strip_breadcrumb_tag_blocks(content: str) -> str:
return re.sub(r"\n{3,}", "\n\n", stripped).strip() return re.sub(r"\n{3,}", "\n\n", stripped).strip()
def _resolve_workflow_md(root: Path, input_data: dict) -> Path:
"""Resolve the active task's workflow file, falling back to the global one.
The per-task resolution rule lives in common.workflow_selection inside
.trellis/scripts. Older installed projects may not ship that module, and
hooks must never crash the session — ANY failure (import error, old
scripts tree, resolver bug) falls back to the global workflow.md.
"""
try:
scripts_dir = root / ".trellis" / "scripts"
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from common.workflow_selection import resolve_workflow_md # type: ignore[import-not-found]
return resolve_workflow_md(root, input_data, platform="codex")
except Exception:
return root / ".trellis" / "workflow.md"
def _build_workflow_toc(workflow_path: Path) -> str: def _build_workflow_toc(workflow_path: Path) -> str:
"""Inject only the compact Phase Index summary for SessionStart.""" """Inject only the compact Phase Index summary for SessionStart."""
content = read_file(workflow_path) content = read_file(workflow_path)
@@ -505,7 +524,7 @@ Trellis compact SessionStart context. Use it to orient the session; load details
output.write("\n</current-state>\n\n") output.write("\n</current-state>\n\n")
output.write("<trellis-workflow>\n") output.write("<trellis-workflow>\n")
output.write(_build_workflow_toc(trellis_dir / "workflow.md")) output.write(_build_workflow_toc(_resolve_workflow_md(project_dir, hook_input)))
output.write("\n</trellis-workflow>\n\n") output.write("\n</trellis-workflow>\n\n")
output.write("<guidelines>\n") output.write("<guidelines>\n")
+2 -2
View File
@@ -8,8 +8,8 @@ on:
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: tencent-prod
timeout-minutes: 30 timeout-minutes: 60
env: env:
COMPOSE_PROJECT_NAME: zhixing-system COMPOSE_PROJECT_NAME: zhixing-system
+3
View File
@@ -20,3 +20,6 @@ node_modules/
.pnpm-store/ .pnpm-store/
dist/ dist/
coverage/ coverage/
# Playwright CLI session artifacts
.playwright-cli/
+61 -4
View File
@@ -772,10 +772,11 @@ function truncateUtf8(buf: Buffer, cap: number): Buffer {
if ((lead & 0xe0) === 0xc0) seqLen = 2; if ((lead & 0xe0) === 0xc0) seqLen = 2;
else if ((lead & 0xf0) === 0xe0) seqLen = 3; else if ((lead & 0xf0) === 0xe0) seqLen = 3;
else if ((lead & 0xf8) === 0xf0) seqLen = 4; else if ((lead & 0xf8) === 0xf0) seqLen = 4;
// Drop the lead byte too if its full sequence didn't fit. // Cut before the lead byte when its full sequence didn't fit;
if (i - 1 + seqLen > cap) i--; // otherwise the trailing sequence is complete — keep it whole.
if (i - 1 + seqLen > cap) return buf.subarray(0, i - 1);
} }
return buf.subarray(0, i); return buf.subarray(0, cap);
} }
function stripInlineComment(value: string): string { function stripInlineComment(value: string): string {
@@ -1069,10 +1070,66 @@ function readTaskDir(root: string, key: string | null): string | null {
} }
// ── Workflow State Breadcrumb ───────────────────────────────────────── // ── Workflow State Breadcrumb ─────────────────────────────────────────
const WORKFLOW_ID_RE = /^[A-Za-z0-9_-]+$/;
const DEFAULT_WORKFLOW_RE =
/^default_workflow:\s*(['"]?)([A-Za-z0-9_-]+)\1\s*(?:#.*)?$/m;
function workflowVariant(root: string, workflowId: string): string {
if (!WORKFLOW_ID_RE.test(workflowId)) return "";
const path = join(root, ".trellis", "workflows", `${workflowId}.md`);
return exists(path) ? path : "";
}
function developerWorkflowId(root: string): string {
for (const line of readText(join(root, ".trellis", ".developer")).split(
/\r?\n/,
)) {
if (line.startsWith("workflow="))
return line.slice("workflow=".length).trim();
}
return "";
}
function configDefaultWorkflowId(root: string): string {
return (
readText(join(root, ".trellis", "config.yaml")).match(
DEFAULT_WORKFLOW_RE,
)?.[2] ?? ""
);
}
/** Mirrors common/workflow_selection.py without spawning another Python
* process on every Pi turn. Invalid or missing selections fall through. */
function resolveWorkflowMd(root: string, key: string | null): string {
const taskDir = readTaskDir(root, key);
let workflowId = "";
if (taskDir) {
try {
const task = JSON.parse(
readText(join(taskDir, "task.json")),
) as JsonObject;
workflowId = typeof task.workflow === "string" ? task.workflow : "";
} catch {}
}
if (workflowId) {
const pinned = workflowVariant(root, workflowId);
if (pinned) return pinned;
console.error(
`Warning: active task selects workflow ${JSON.stringify(workflowId)} but .trellis/workflows/ has no matching file; using default workflow resolution`,
);
}
const personal = workflowVariant(root, developerWorkflowId(root));
if (personal) return personal;
const team = workflowVariant(root, configDefaultWorkflowId(root));
if (team) return team;
return join(root, ".trellis", "workflow.md");
}
const WF_RE = const WF_RE =
/\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n([\s\S]*?)\n\s*\[\/workflow-state:\1\]/g; /\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n([\s\S]*?)\n\s*\[\/workflow-state:\1\]/g;
function workflowBreadcrumb(root: string, key: string | null): string { function workflowBreadcrumb(root: string, key: string | null): string {
const wf = readText(join(root, ".trellis", "workflow.md")); const wf = readText(resolveWorkflowMd(root, key));
if (!wf) return ""; if (!wf) return "";
const templates: Record<string, string> = {}; const templates: Record<string, string> = {};
for (const m of wf.matchAll(WF_RE)) { for (const m of wf.matchAll(WF_RE)) {
+22 -18
View File
@@ -24,7 +24,7 @@
".agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d", ".agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d",
".agents/skills/trellis-meta/references/customize-local/change-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519", ".agents/skills/trellis-meta/references/customize-local/change-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519",
".agents/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4", ".agents/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4",
".agents/skills/trellis-meta/references/local-architecture/bundled-skills.md": "7a8d1a5dcc8d1140c4c6bd19d02949364cdd01801bd0825a975a308cf85b8f37", ".agents/skills/trellis-meta/references/local-architecture/bundled-skills.md": "aa6a0bf83060205ee4ea621c467fb900a7db06b4476a4ad472cc4e248c887389",
".agents/skills/trellis-meta/references/local-architecture/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084", ".agents/skills/trellis-meta/references/local-architecture/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084",
".agents/skills/trellis-meta/references/local-architecture/generated-files.md": "7eb2d452eddb4f4226f7578c2ec6d5ee0434ed172ba4c36107cc8bdff7554dc6", ".agents/skills/trellis-meta/references/local-architecture/generated-files.md": "7eb2d452eddb4f4226f7578c2ec6d5ee0434ed172ba4c36107cc8bdff7554dc6",
".agents/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9", ".agents/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9",
@@ -34,7 +34,7 @@
".agents/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6", ".agents/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6",
".agents/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd", ".agents/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd",
".agents/skills/trellis-meta/references/platform-files/agents.md": "9f41349b78f7ae64698a38490a317882561f3805b26a79bda587a13d03fda245", ".agents/skills/trellis-meta/references/platform-files/agents.md": "9f41349b78f7ae64698a38490a317882561f3805b26a79bda587a13d03fda245",
".agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "154af08f7ee8afe7a704968ec0b9fc21e905b7cfb03871c9dbafcdd6cc654319", ".agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "cb7c1c7dd976004e4981181ab9462f8eedc4e9c014721419bd666d6608f4dd72",
".agents/skills/trellis-meta/references/platform-files/overview.md": "1aec9087ccedd56a213af877b5db474131aa4b4789b6ac0e8da73b058aa43bd4", ".agents/skills/trellis-meta/references/platform-files/overview.md": "1aec9087ccedd56a213af877b5db474131aa4b4789b6ac0e8da73b058aa43bd4",
".agents/skills/trellis-meta/references/platform-files/platform-map.md": "9e476e500f10b2a1a05278dd700837deb731a77e3f3993e90f8102528a9bdcca", ".agents/skills/trellis-meta/references/platform-files/platform-map.md": "9e476e500f10b2a1a05278dd700837deb731a77e3f3993e90f8102528a9bdcca",
".agents/skills/trellis-meta/references/platform-files/skills-and-commands.md": "e39831d860bd27a04e7757f7bd941ab83b3c5b11ad4460cec03975b655f26cc3", ".agents/skills/trellis-meta/references/platform-files/skills-and-commands.md": "e39831d860bd27a04e7757f7bd941ab83b3c5b11ad4460cec03975b655f26cc3",
@@ -50,10 +50,10 @@
".codex/agents/trellis-check.toml": "79070c63fa404fc53061cca5194bf66db839132b67a57b9c8d6a295037ba7308", ".codex/agents/trellis-check.toml": "79070c63fa404fc53061cca5194bf66db839132b67a57b9c8d6a295037ba7308",
".codex/agents/trellis-implement.toml": "388fb8f39797e0ee6cf4db447c859c79b4ac15f531f7e1e3c68c1d1d71a1c188", ".codex/agents/trellis-implement.toml": "388fb8f39797e0ee6cf4db447c859c79b4ac15f531f7e1e3c68c1d1d71a1c188",
".codex/agents/trellis-research.toml": "4435ce73197ba1d29d40359a3279b6423f7e4f559a449f934c016808090066c4", ".codex/agents/trellis-research.toml": "4435ce73197ba1d29d40359a3279b6423f7e4f559a449f934c016808090066c4",
".codex/hooks/session-start.py": "14de3be1cf6eb9c9feba348d8998b407f3837d6c0756b74210c9200543440677", ".codex/hooks/session-start.py": "91fbbd30ac974c3cd2b4db152aa38ac176a9c7e3d062cbaa1acacf07152b2592",
".codex/hooks/inject-subagent-context.py": "abffa237eb53f87ae6ffa434063b46b03d58a36a84cdb8fe88bfc5f243aab609", ".codex/hooks/inject-subagent-context.py": "db413933ff30e1503f37f1d292fb421b1ced3f39f1890351a6621e499ec16b2c",
".codex/hooks/inject-workflow-state.py": "9ce43910ac39cbb0e4d1783fbde931761eb04536c7f82661a96d95ea72c14bde", ".codex/hooks/inject-workflow-state.py": "cda5888c29671035e7d2e033a0fcc631a9428a0543fb2dd071285b30c6ca4e23",
".codex/hooks.json": "85a58ba7cdf1e19e7f75ddcc64e5680180c487ca266a74bd5005f31abeee2e02", ".codex/hooks.json": "c16c9af7f6010bf4fabb64fd4bd497201d34583b96c665ea1d5739eed48b3fb5",
".codex/config.toml": "9f2d20e28f0bc9c886312eca3ad3bba41533ef4615aaaafe25e98152302267bb", ".codex/config.toml": "9f2d20e28f0bc9c886312eca3ad3bba41533ef4615aaaafe25e98152302267bb",
".pi/prompts/trellis-start.md": "28af1eb6645d8b517cf705277d8405370b712926e6b01667d6698564002c6a9d", ".pi/prompts/trellis-start.md": "28af1eb6645d8b517cf705277d8405370b712926e6b01667d6698564002c6a9d",
".pi/prompts/trellis-continue.md": "12c2f0288ff67af3368c0b577a50027a11a25fec1d32ed34282a4dc84be08f1c", ".pi/prompts/trellis-continue.md": "12c2f0288ff67af3368c0b577a50027a11a25fec1d32ed34282a4dc84be08f1c",
@@ -61,40 +61,44 @@
".pi/agents/trellis-check.md": "1dbfedd3403f201fbfdbae8d810afba0a1f812b97f0f8e308908db7eaceea496", ".pi/agents/trellis-check.md": "1dbfedd3403f201fbfdbae8d810afba0a1f812b97f0f8e308908db7eaceea496",
".pi/agents/trellis-implement.md": "9bb1f70d09b7104a671ef9a0ba072b4500d45b8556a253126a003e2b1e7281a2", ".pi/agents/trellis-implement.md": "9bb1f70d09b7104a671ef9a0ba072b4500d45b8556a253126a003e2b1e7281a2",
".pi/agents/trellis-research.md": "ef77555f4c2c4ade36f1c23a076b6f4bb9d180ff24a2f00d7cdc2f8fd5af0b0a", ".pi/agents/trellis-research.md": "ef77555f4c2c4ade36f1c23a076b6f4bb9d180ff24a2f00d7cdc2f8fd5af0b0a",
".pi/extensions/trellis/index.ts": "b4bfd740d517462f943aaff920dd39371d3e6bdc3823dfa8c2752314c57eaf9b", ".pi/extensions/trellis/index.ts": "770290e675fabfe0dd0889771876607927d36cb9ef68586552e6e2d56ca345fc",
".pi/settings.json": "b68f37c04a7007d2b52d5a87326e3786edbc903bfa518358830dd85727c13d7c", ".pi/settings.json": "b68f37c04a7007d2b52d5a87326e3786edbc903bfa518358830dd85727c13d7c",
"AGENTS.md": "6cacfe99748b435d0660c2463c697bc323d53798aecf3492283ca8eac1b29682", "AGENTS.md": "6cacfe99748b435d0660c2463c697bc323d53798aecf3492283ca8eac1b29682",
".trellis/agents/check.md": "edb4f57361407249a53bf5998ebf91c40d2b969e826a2c5e1b4e813a08bcb175", ".trellis/agents/check.md": "edb4f57361407249a53bf5998ebf91c40d2b969e826a2c5e1b4e813a08bcb175",
".trellis/agents/implement.md": "66e25ad046c94869442834bc3cdfbd5a9a7412d3ff54561d64d2886552c27e87", ".trellis/agents/implement.md": "66e25ad046c94869442834bc3cdfbd5a9a7412d3ff54561d64d2886552c27e87",
".trellis/config.yaml": "a966e6d374e9e6ff283cf761ccd99631323ee1754856cef51ad154ca0afb9dfa", ".trellis/config.yaml": "eaba56c36fb07483fcbc96d74c4da0ab33b9739fb9ff10ea4cc1e1cd32bf23b2",
".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c", ".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c",
".trellis/scripts/add_session.py": "876dad478edf70db59acccaae9cb4db646a155681f730bd99af48de72ddc9881", ".trellis/scripts/add_session.py": "876dad478edf70db59acccaae9cb4db646a155681f730bd99af48de72ddc9881",
".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22", ".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22",
".trellis/scripts/common/active_task.py": "31271e3b69b5a5eca958d8ce25f61fe852eb6e48d32c150471da8e7272fd6119", ".trellis/scripts/common/active_task.py": "28a81f8828538fb70a15c88edd90eda9d685adbde8862f67f630bce5b27d9832",
".trellis/scripts/common/cli_adapter.py": "5d6bd9d6f5c631e7e792db7dd343351317f9643bc87b73a9a98abd51cefb4307", ".trellis/scripts/common/cli_adapter.py": "5d6bd9d6f5c631e7e792db7dd343351317f9643bc87b73a9a98abd51cefb4307",
".trellis/scripts/common/config.py": "8d2e5f8ccfcd5f622cd2af002aa761f3d3ffcc653182fefb2268afd102e77bca", ".trellis/scripts/common/config.py": "43a22c4e88a06d6316d1bcd4730731bdecc22ae782e9f217b25ea53aa85cd416",
".trellis/scripts/common/developer.py": "f5f833123abe68890171b4da825a324216d24913f6b5ad9245afc556424ffd7b", ".trellis/scripts/common/developer.py": "f5f833123abe68890171b4da825a324216d24913f6b5ad9245afc556424ffd7b",
".trellis/scripts/common/git.py": "6fc5845d0104dd506ebd8b366a24cb4b1e3d8777e4e6acc12ea15c9d8e2662f2", ".trellis/scripts/common/git.py": "6fc5845d0104dd506ebd8b366a24cb4b1e3d8777e4e6acc12ea15c9d8e2662f2",
".trellis/scripts/common/git_context.py": "fa30ced454f1a91ffc9f8b2abeb32225e3447cbdc90bad783797374eba07265d", ".trellis/scripts/common/git_context.py": "be0c68d4b566319484cf95a3ca6d6dee18e00f040cecadea6a98f10dc445f967",
".trellis/scripts/common/io.py": "75648caae03d5b1107d7aeccaa785d133b25762266e54a520d90ca8c76b43bdb", ".trellis/scripts/common/io.py": "75648caae03d5b1107d7aeccaa785d133b25762266e54a520d90ca8c76b43bdb",
".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf", ".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf",
".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7", ".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7",
".trellis/scripts/common/paths.py": "05898ef136cc7c4d861b05fbf2b16d53ddd3e6f311a231d4fcfcb81bde7c45ee", ".trellis/scripts/common/paths.py": "5f66eb073c296a8a920048c1c53d8236783ccb998c8c25a68a540534d30abd02",
".trellis/scripts/common/safe_commit.py": "baa5c82324eb62154374ec63394ecdc8609bb37d93892e3bcb88f452bb7d6446", ".trellis/scripts/common/safe_commit.py": "baa5c82324eb62154374ec63394ecdc8609bb37d93892e3bcb88f452bb7d6446",
".trellis/scripts/common/session_context.py": "4ed3e13b2878ba367e9f2e2cd709b396f806152902df2cd1cd1478317d069017", ".trellis/scripts/common/session_context.py": "3379ef1766e4e5ca77cbb7c040dbba3883fcca2548580299e3b38dbf22f4f7d5",
".trellis/scripts/common/task_context.py": "4ea260a022f4122361eb0d9dd9200a9324aeafbdb20cadd2848bea1649938f1d", ".trellis/scripts/common/task_context.py": "be5fa407f99c2400075194dbaa8b6ec996ce8aac2122d191bb9cb11e479a9476",
".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5", ".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5",
".trellis/scripts/common/task_store.py": "e3c2fbf8b79b591e39fc3c9f4e2f3ee0c840c8201c94a16709ec743fa45037f6", ".trellis/scripts/common/task_store.py": "793b9e7863fade04b0d84c7668bacb4229c435f0fcdba008bd333421ec3e17b3",
".trellis/scripts/common/task_utils.py": "90c0a6d50bad502c3f01cb24c1ccfeb0eece5e2c69efaff8d65eb827fba43871", ".trellis/scripts/common/task_utils.py": "90c0a6d50bad502c3f01cb24c1ccfeb0eece5e2c69efaff8d65eb827fba43871",
".trellis/scripts/common/tasks.py": "4436a8b0b53c270a35989e26d9dbd92669408c6562d88c02083a404562da85fe", ".trellis/scripts/common/tasks.py": "4436a8b0b53c270a35989e26d9dbd92669408c6562d88c02083a404562da85fe",
".trellis/scripts/common/trellis_config.py": "e282e897183e3ec2f4e6e56349431946e5f98c1c31d3eca4de7fc44e1383a7bf", ".trellis/scripts/common/trellis_config.py": "e282e897183e3ec2f4e6e56349431946e5f98c1c31d3eca4de7fc44e1383a7bf",
".trellis/scripts/common/types.py": "9962081cc2608fb9d1deb32c6880e336f62cdca6b338e7ae813304701e155ee9", ".trellis/scripts/common/types.py": "9962081cc2608fb9d1deb32c6880e336f62cdca6b338e7ae813304701e155ee9",
".trellis/scripts/common/workflow_phase.py": "79ee522de20246acf1e2c222e8ad180ad25aaec7fec98214a93d9e81b350d9a8", ".trellis/scripts/common/workflow_phase.py": "c3d00011a4d8c3d958ec57cea50b97f4170dce7c28381ad3915dae0870167d6e",
".trellis/scripts/get_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5", ".trellis/scripts/get_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5",
".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f", ".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f",
".trellis/scripts/hooks/linear_sync.py": "e09cc4ce4699aada908808718698f33f705a3edf55c4dcf8f777ad892f80ca79", ".trellis/scripts/hooks/linear_sync.py": "e09cc4ce4699aada908808718698f33f705a3edf55c4dcf8f777ad892f80ca79",
".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e", ".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e",
".trellis/scripts/task.py": "e0ffed9f14994069f0c992141e3ec168524be5af32e3681e6ea30ba0a5da4bc4", ".trellis/scripts/task.py": "7790d9510311d55ed1f66c71b9ce0bafc8871f1b675c1251dcf6a1f4e823d2b3",
".trellis/workflow.md": "e2c5ab7004ff83a5a804b50df81746aa1d558dd4480463287622605f86a82a76" ".trellis/workflow.md": "e2c5ab7004ff83a5a804b50df81746aa1d558dd4480463287622605f86a82a76",
".trellis/scripts/common/workflow_selection.py": "b136d6ae41aac95f4d5f425b641b62b7002f7cff2d26aa44b056cef9a2242753",
".trellis/scripts/common/spec_match.py": "baf3b17b15c279475f99699ac6e039bc68285d5dbfbda28f279aa8346cb0c59a",
".trellis/scripts/common/spec_inject.py": "9987d213eed3d2ec1b2c16adcad8b10a21d3b68dcce838f85c42d83c56e69e0b",
".codex/hooks/inject-spec-context.py": "4df3945be18163afe066ec3061d8b95a687f676e3a80dbf0aada4dec8c339f90"
} }
} }
+1 -1
View File
@@ -1 +1 @@
0.6.12 0.7.0-beta.3
+47
View File
@@ -76,6 +76,21 @@ max_journal_lines: 2000
# Default package used when --package is not specified. # Default package used when --package is not specified.
# default_package: frontend # default_package: frontend
#-------------------------------------------------------------------------------
# Default workflow
#-------------------------------------------------------------------------------
# Team-shared default workflow for tasks that do not pin one. The id names a
# variant file in `.trellis/workflows/<id>.md` (populate it with
# `trellis workflow --save <id>`). This value is committed, so the whole team
# shares the same default. A per-developer override lives in the gitignored
# `.developer` file as a `workflow=<id>` line and takes precedence over this.
#
# Resolution precedence: per-task (task.json `workflow`) > personal
# (`.developer` `workflow=`) > this `default_workflow` > global
# `.trellis/workflow.md`.
#
# default_workflow: native
#------------------------------------------------------------------------------- #-------------------------------------------------------------------------------
# Channel worker OOM guard # Channel worker OOM guard
#------------------------------------------------------------------------------- #-------------------------------------------------------------------------------
@@ -145,6 +160,38 @@ channel:
# max_artifact_bytes: 65536 # per task artifact (prd.md / design.md / implement.md) # max_artifact_bytes: 65536 # per task artifact (prd.md / design.md / implement.md)
# max_total_bytes: 131072 # whole injected payload; overflow degrades to index lines # max_total_bytes: 131072 # whole injected payload; overflow degrades to index lines
#-------------------------------------------------------------------------------
# Path-scoped spec injection
#-------------------------------------------------------------------------------
# When the agent touches a file (Read/Edit/Write/MultiEdit), spec .md files
# under .trellis/spec/ whose frontmatter `paths:` globs match the touched path
# are surfaced into the session right then. The first time a spec matches it is
# injected in full; while its content is unchanged and the refresh window has
# not elapsed it stays silent; once the window elapses a short `<spec-ticket>`
# reminder is emitted to counter recency decay. Editing the spec itself — or a
# SessionStart after /clear or /compact — re-injects the full text.
# Oversized specs are truncated with a notice; once the per-event payload cap
# is reached, remaining full bodies degrade to index lines (path + description)
# instead of being inlined.
#
# Character values: the ceiling this budget respects is Claude Code's
# documented 10,000-CHARACTER additionalContext limit, so the caps count
# characters too (byte caps made CJK specs pay 3x for the same text).
# `0` disables the corresponding limit.
# The refresh window uses wall-clock seconds. `0` disables time-based reminders;
# SessionStart resets after /clear or /compact still force a full re-injection.
#
# spec_injection:
# enabled: true # false disables injection entirely
# max_spec_chars: 9400 # per matched spec file
# max_total_chars: 9500 # whole per-event payload; overflow degrades to index lines
# refresh_window_seconds: 2700 # touches past this interval re-emit a ticket
# tools: # tool events that trigger injection
# - Read
# - Edit
# - Write
# - MultiEdit
#------------------------------------------------------------------------------- #-------------------------------------------------------------------------------
# Per-turn prompt injection # Per-turn prompt injection
#------------------------------------------------------------------------------- #-------------------------------------------------------------------------------
+109 -40
View File
@@ -23,8 +23,15 @@ DIR_WORKFLOW = ".trellis"
DIR_TASKS = "tasks" DIR_TASKS = "tasks"
DIR_RUNTIME = ".runtime" DIR_RUNTIME = ".runtime"
DIR_SESSIONS = "sessions" DIR_SESSIONS = "sessions"
DIR_CURSOR_SHELL = "cursor-shell" DIR_SHELL_TICKETS = "shell-tickets"
CURSOR_SHELL_TICKET_TTL_SECONDS = 30 # Pre-0.6.13 name, when the bridge was Cursor-only. Still read so a session that
# was mid-command across an upgrade does not silently degrade; never written.
# Tickets are 30-second ephemera, so the old directory ages out by itself —
# there is nothing to migrate, only a glob on a directory that is normally
# absent. The alternative (ignore it) would land its one lost command on the
# platform that works today.
DIR_LEGACY_CURSOR_SHELL_TICKETS = "cursor-shell"
SHELL_TICKET_TTL_SECONDS = 30
TASK_SESSION_COMMANDS = {"start", "current", "finish"} TASK_SESSION_COMMANDS = {"start", "current", "finish"}
_SESSION_KEYS = ("session_id", "sessionId", "sessionID") _SESSION_KEYS = ("session_id", "sessionId", "sessionID")
@@ -50,35 +57,75 @@ _KNOWN_PLATFORMS = {
"snow", "snow",
} }
# Every name below records how it was checked. Do NOT add a name by analogy
# with a neighbour: a 2026-08-05 audit of all 21 platforms found 12 of the 21
# declared names had never existed anywhere — they were pattern-guessed from a
# `<PLATFORM>_SESSION_ID` shape no vendor agreed to, and the uniformity was the
# only "evidence" behind them. A platform with no verified name belongs in no
# table; it resolves through TRELLIS_CONTEXT_ID or its hook/plugin bridge.
_ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = ( _ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
("claude", ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")), # REAL, undocumented (verified 2026-08-05 in a live Claude Code 2.1.221 bash
("codex", ("CODEX_SESSION_ID", "CODEX_THREAD_ID")), # child; absent from code.claude.com/docs/en/env-vars). CLAUDE_SESSION_ID
("cursor", ("CURSOR_SESSION_ID",)), # was removed here — verified absent from that same live environment.
("opencode", ("OPENCODE_SESSION_ID", "OPENCODE_SESSIONID", "OPENCODE_RUN_ID")), ("claude", ("CLAUDE_CODE_SESSION_ID",)),
# REAL, undocumented (verified 2026-08-05: injected by codex-cli 0.146.0
# into shell children, absent from the parent env; openai/codex#19937).
# CODEX_SESSION_ID was removed — absent from a live `codex exec` env.
("codex", ("CODEX_THREAD_ID",)),
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): set by Gemini's
# hookRunner.ts. Its shell tool builds the child env in
# shellExecutionService.ts and adds only GEMINI_CLI/TERM/PAGER/GIT_PAGER, so
# this never reaches a bash child — it resolves only inside a hook process.
("gemini", ("GEMINI_SESSION_ID",)), ("gemini", ("GEMINI_SESSION_ID",)),
("droid", ("FACTORY_SESSION_ID", "DROID_SESSION_ID")), # REAL but HOOK-SCOPE ONLY (verified 2026-08-05): docs.qoder.com/zh/
# extensions/hooks documents it as injected during hook execution by the
# Qoder *IDE plugin*. Absent from the Qoder CLI hook docs and from Lingma.
("qoder", ("QODER_SESSION_ID",)), ("qoder", ("QODER_SESSION_ID",)),
("codebuddy", ("CODEBUDDY_SESSION_ID",)), # UNVERIFIED (2026-08-05): absent from kiro.dev/docs/hooks/, but Dynatrace
# dtctl, oh-my-agent and gastown all key agent detection on it and one notes
# it is "set in both interactive and --no-interactive". Kept because that is
# absence of evidence, not evidence of absence. To settle: run
# `env | grep KIRO` from a Kiro shell-tool call on a machine with Kiro.
("kiro", ("KIRO_SESSION_ID",)), ("kiro", ("KIRO_SESSION_ID",)),
# UNVERIFIED (2026-08-05): absent from docs.github.com/en/copilot/reference/
# hooks-reference and from the CLI programmatic reference. To settle: run
# `copilot help environment` (the authoritative list per those docs) — not
# runnable here, the CLI is not installed and copilot-cli ships no source.
("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")), ("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")),
("pi", ("PI_SESSION_ID", "PI_SESSIONID")), # REASONED, UNVERIFIED (2026-08-05): ZCode is closed-source and not
("trae", ("TRAE_SESSION_ID",)), # installable here. It mirrors Claude's naming elsewhere (CLAUDE_PLUGIN_ROOT
# ZCode reuses CLAUDE_SESSION_ID (it does not document a ZCODE_SESSION_ID). # / CLAUDE_PLUGIN_DATA compat aliases are in its docs), and the previously
# Platform-scoped lookup (_iter_env_keys filters by platform name), so this # declared CLAUDE_SESSION_ID does not exist on Claude Code either — so the
# only fires when the resolver already detected "zcode" — no collision with # name ZCode would actually reuse is CLAUDE_CODE_SESSION_ID. Try that first,
# keep the historical name as a fallback: if neither exists nothing changes.
# Platform-scoped lookup (_iter_env_keys filters by platform name), so the
# entry only fires once the resolver detected "zcode" — no collision with
# the claude entry above. # the claude entry above.
("zcode", ("CLAUDE_SESSION_ID",)), ("zcode", ("CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID")),
# Snow CLI exports SNOW_SESSION_ID into hook/terminal/sub-agent children. # REAL by vendor design (verified 2026-08-05): Snow's sessionIdentityEnv.ts
# TRELLIS_CONTEXT_ID remains the preferred override when present. # exports SNOW_SESSION_ID into hook/terminal/sub-agent children and names
# Trellis in its source header. TRELLIS_CONTEXT_ID stays the preferred
# override — Snow sets that too.
("snow", ("SNOW_SESSION_ID",)), ("snow", ("SNOW_SESSION_ID",)),
) )
_ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = ( _ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
# REAL in cursor-agent (CLI), undocumented (verified 2026-08-05: the value
# matches ~/.cursor/chats/<ws>/<id>). The Cursor *IDE* is unverified — a
# 2026-05 forum request for it drew no staff reply. The invented
# CURSOR_SESSION_ID was removed from the session table: empty in a live
# cursor-agent shell. Cursor's other path is the shell ticket below
# (_lookup_shell_ticket_context_key), which is not Cursor-specific.
("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")), ("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")),
) )
_ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = ( _ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
("claude", ("CLAUDE_TRANSCRIPT_PATH",)), # REAL but HOOK-SCOPE ONLY (verified 2026-08-05): documented for Cursor hook
("codex", ("CODEX_TRANSCRIPT_PATH",)), # scripts; empty in the agent's own shell env.
("cursor", ("CURSOR_TRANSCRIPT_PATH",)), ("cursor", ("CURSOR_TRANSCRIPT_PATH",)),
# UNVERIFIED — never researched. The 2026-08-05 audit covered the session
# table only, so do not infer these are real *or* fake from that work
# (CLAUDE_/CODEX_TRANSCRIPT_PATH were removed because those two *were*
# checked: absent from docs and from live envs). To settle each: run
# `env | grep _TRANSCRIPT_PATH` inside a hook and inside a shell-tool call.
("gemini", ("GEMINI_TRANSCRIPT_PATH",)), ("gemini", ("GEMINI_TRANSCRIPT_PATH",)),
("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")), ("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")),
("qoder", ("QODER_TRANSCRIPT_PATH",)), ("qoder", ("QODER_TRANSCRIPT_PATH",)),
@@ -90,11 +137,15 @@ _ENV_PLATFORM_ALIASES = {
"factory-ai": "droid", "factory-ai": "droid",
"github-copilot": "copilot", "github-copilot": "copilot",
} }
# ZCode intentionally reuses CLAUDE_SESSION_ID. Hooks know the host is ZCode, # ZCode intentionally reuses Claude's session env var name. Hooks know the host
# while later shell commands see only the shared env name and resolve it through # is ZCode, while later shell commands see only the shared env name and resolve
# the Claude entry. Canonicalize both paths to one runtime filename. # it through the claude entry. Canonicalize both paths to one runtime filename.
_CONTEXT_KEY_PLATFORM_ALIASES = { _CONTEXT_KEY_PLATFORM_ALIASES = {
"zcode": "claude", "zcode": "claude",
# Factory Droid's config directory is `.factory/`, so a hook that names its
# platform after the directory it was installed in reports "factory". Its
# sibling hooks report "droid". One runtime filename either way.
"factory": "droid",
} }
@@ -222,6 +273,12 @@ def _iter_env_keys(
env_keys: tuple[tuple[str, tuple[str, ...]], ...], env_keys: tuple[tuple[str, tuple[str, ...]], ...],
platform_name: str | None, platform_name: str | None,
) -> tuple[tuple[str, tuple[str, ...]], ...]: ) -> tuple[tuple[str, tuple[str, ...]], ...]:
"""Narrow an env-key table to one platform, or return all of it.
A platform with no entry yields an empty tuple, and the caller's `for` loop
simply does not run. That is the normal case, not an error: platforms with
no verified env var name are deliberately absent from these tables.
"""
if not platform_name: if not platform_name:
return env_keys return env_keys
matched = tuple((name, keys) for name, keys in env_keys if name == platform_name) matched = tuple((name, keys) for name, keys in env_keys if name == platform_name)
@@ -275,8 +332,12 @@ def _find_repo_root_from_cwd() -> Path | None:
current = current.parent current = current.parent
def _cursor_shell_ticket_dir(repo_root: Path) -> Path: def _shell_ticket_dirs(repo_root: Path) -> tuple[Path, ...]:
return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_CURSOR_SHELL runtime_dir = repo_root / DIR_WORKFLOW / DIR_RUNTIME
return (
runtime_dir / DIR_SHELL_TICKETS,
runtime_dir / DIR_LEGACY_CURSOR_SHELL_TICKETS,
)
def _remove_file(path: Path) -> bool: def _remove_file(path: Path) -> bool:
@@ -334,7 +395,7 @@ def _ticket_is_fresh(ticket: dict[str, Any], ticket_path: Path, now: float) -> b
created_at = ticket.get("created_at_epoch") created_at = ticket.get("created_at_epoch")
if isinstance(created_at, (int, float)): if isinstance(created_at, (int, float)):
if now - created_at <= CURSOR_SHELL_TICKET_TTL_SECONDS: if now - created_at <= SHELL_TICKET_TTL_SECONDS:
return True return True
_remove_file(ticket_path) _remove_file(ticket_path)
return False return False
@@ -352,13 +413,18 @@ def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool:
return True return True
def _matching_cursor_ticket_context_key( def _matching_ticket_context_key(
ticket_path: Path, ticket_path: Path,
repo_root: Path, repo_root: Path,
now: float, now: float,
) -> str | None: ) -> str | None:
"""Accept a ticket on its merits, never on which platform wrote it.
The `platform` field a ticket carries is debugging metadata; gating on it
was what kept this bridge invisible to every platform but Cursor.
"""
ticket = _read_json(ticket_path) ticket = _read_json(ticket_path)
if ticket is None or ticket.get("platform") != "cursor": if ticket is None:
return None return None
if not _ticket_is_fresh(ticket, ticket_path, now): if not _ticket_is_fresh(ticket, ticket_path, now):
return None return None
@@ -369,27 +435,28 @@ def _matching_cursor_ticket_context_key(
return _string_value(ticket.get("context_key")) return _string_value(ticket.get("context_key"))
def _lookup_cursor_shell_ticket_context_key() -> str | None: def _lookup_shell_ticket_context_key() -> str | None:
"""Resolve Cursor conversation identity from a short-lived shell ticket. """Resolve session identity from a short-lived shell ticket.
Cursor exposes `conversation_id` to `beforeShellExecution`, but does not No researched platform exports its session id into a shell child, but every
export it into the shell command environment. The Cursor hook writes a hook-capable one hands that id to a hook. So the hook that runs just before
short-lived ticket just before `task.py` runs. We accept a ticket only when a shell command writes a ticket, and this reads it back. A ticket counts
the current `task.py` subcommand matches and exactly one fresh context key only when it is fresh, was written for this repo, and matches the `task.py`
matches, which avoids cross-window pointer contamination. subcommand now running — and only when exactly one fresh context key
matches. Two concurrent windows therefore both degrade rather than one
inheriting the other's pointer.
""" """
repo_root = _find_repo_root_from_cwd() repo_root = _find_repo_root_from_cwd()
if repo_root is None: if repo_root is None:
return None return None
ticket_dir = _cursor_shell_ticket_dir(repo_root)
if not ticket_dir.is_dir():
return None
now = time.time() now = time.time()
candidates: set[str] = set() candidates: set[str] = set()
for ticket_dir in _shell_ticket_dirs(repo_root):
if not ticket_dir.is_dir():
continue
for ticket_path in ticket_dir.glob("*.json"): for ticket_path in ticket_dir.glob("*.json"):
context_key = _matching_cursor_ticket_context_key(ticket_path, repo_root, now) context_key = _matching_ticket_context_key(ticket_path, repo_root, now)
if context_key: if context_key:
candidates.add(context_key) candidates.add(context_key)
@@ -435,8 +502,10 @@ def resolve_context_key(
if env_context_key: if env_context_key:
return env_context_key return env_context_key
if allow_environment_context and platform_name in (None, "session", "cursor"): # Last in the chain on purpose: a platform that genuinely exports identity
return _lookup_cursor_shell_ticket_context_key() # into the shell outranks a ticket, and no platform name gates the lookup.
if allow_environment_context:
return _lookup_shell_ticket_context_key()
return None return None
+18
View File
@@ -281,6 +281,24 @@ def get_codex_dispatch_mode(repo_root: Path | None = None) -> str:
return "inline" return "inline"
def get_default_workflow(repo_root: Path | None = None) -> str | None:
"""Return the team-shared default workflow id from config.yaml.
Reads the top-level ``default_workflow`` key — a slug naming a variant in
``.trellis/workflows/<id>.md``. Returns None when unset or blank. This is
the git-tracked, team-shared default; a per-developer override lives in the
gitignored ``.developer`` file (``workflow=<id>``, see
``paths.get_developer_workflow``) and outranks it. Fail-open: a missing or
non-string value simply means "no team default" — no warning (this is read
on every turn by hooks and must not add per-turn noise).
"""
config = _load_config(repo_root)
raw = config.get("default_workflow")
if not isinstance(raw, str):
return None
return raw.strip() or None
DEFAULT_CONTEXT_INJECTION_MAX_FILE_BYTES = 32768 DEFAULT_CONTEXT_INJECTION_MAX_FILE_BYTES = 32768
DEFAULT_CONTEXT_INJECTION_MAX_ARTIFACT_BYTES = 65536 DEFAULT_CONTEXT_INJECTION_MAX_ARTIFACT_BYTES = 65536
DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES = 131072 DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES = 131072
+34 -2
View File
@@ -27,6 +27,8 @@ from .packages_context import (
get_context_packages_text, get_context_packages_text,
get_context_packages_json, get_context_packages_json,
) )
from .paths import get_repo_root
from .spec_match import match_specs_for_file
from .trellis_config import read_trellis_config from .trellis_config import read_trellis_config
from .workflow_phase import ( from .workflow_phase import (
filter_platform, filter_platform,
@@ -57,9 +59,9 @@ def main() -> None:
parser.add_argument( parser.add_argument(
"--mode", "--mode",
"-m", "-m",
choices=["default", "record", "packages", "phase"], choices=["default", "record", "packages", "phase", "spec"],
default="default", default="default",
help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction)", help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction), spec (specs governing a file)",
) )
parser.add_argument( parser.add_argument(
"--step", "--step",
@@ -69,6 +71,10 @@ def main() -> None:
"--platform", "--platform",
help="Platform name for --mode phase, e.g. cursor, claude-code. Filters platform-tagged blocks.", help="Platform name for --mode phase, e.g. cursor, claude-code. Filters platform-tagged blocks.",
) )
parser.add_argument(
"--file",
help="File path (absolute or repo-relative) for --mode spec. Lists spec files whose frontmatter paths match it.",
)
args = parser.parse_args() args = parser.parse_args()
@@ -95,6 +101,32 @@ def main() -> None:
) )
content = filter_platform(content, effective) content = filter_platform(content, effective)
print(content, end="") print(content, end="")
elif args.mode == "spec":
if not args.file:
parser.error("--file is required with --mode spec")
matches = match_specs_for_file(get_repo_root(), args.file)
if args.json:
print(
json.dumps(
{
"file": args.file,
"matches": [
{
"path": match.rel_path,
"description": match.description,
}
for match in matches
],
},
indent=2,
ensure_ascii=False,
)
)
elif matches:
for match in matches:
print(f"{match.rel_path} — {match.description or '(no description)'}")
else:
print(f"No spec files declare paths matching {args.file}.")
else: else:
if args.json: if args.json:
output_json() output_json()
+28
View File
@@ -94,6 +94,34 @@ def get_developer(repo_root: Path | None = None) -> str | None:
return None return None
def get_developer_workflow(repo_root: Path | None = None) -> str | None:
"""Get the personal workflow override from the .developer file.
Reads an optional ``workflow=<id>`` line from the gitignored ``.developer``
file — the per-developer, git-excluded override that outranks the
team-shared ``default_workflow`` in config.yaml. Returns None when the file
or the line is absent/blank. Never raises. Additive to ``get_developer``:
the ``name=`` reader ignores this line and vice versa.
"""
if repo_root is None:
repo_root = get_repo_root()
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
if not dev_file.is_file():
return None
try:
content = dev_file.read_text(encoding="utf-8")
for line in content.splitlines():
if line.startswith("workflow="):
return line.split("=", 1)[1].strip() or None
except (OSError, IOError):
pass
return None
def check_developer(repo_root: Path | None = None) -> bool: def check_developer(repo_root: Path | None = None) -> bool:
"""Check if developer is initialized. """Check if developer is initialized.
+25 -7
View File
@@ -9,6 +9,7 @@ Provides:
get_context_text_record - Text for record mode get_context_text_record - Text for record mode
output_json - Print JSON output_json - Print JSON
output_text - Print text output_text - Print text
get_update_hint - Once-per-session "update available" line
""" """
from __future__ import annotations from __future__ import annotations
@@ -417,7 +418,15 @@ def _compare_versions(left: str, right: str) -> int | None:
return _compare_prerelease(left_prerelease, right_prerelease) return _compare_prerelease(left_prerelease, right_prerelease)
def _update_marker_path(repo_root: Path) -> Path: def _update_marker_path(repo_root: Path, context_key: str | None = None) -> Path:
"""Path of the once-per-session marker that throttles the update check.
`context_key` lets a caller that already resolved session identity pass it
in — the SessionStart hook reads the session id from hook stdin, which is
more reliable than this function's environment-only fallback chain. Shell
entry points leave it None and keep the previous behavior.
"""
if not context_key:
context_key = resolve_context_key() context_key = resolve_context_key()
if not context_key: if not context_key:
terminal_key = os.environ.get("TERM_SESSION_ID", "").strip() terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
@@ -433,8 +442,11 @@ def _update_marker_path(repo_root: Path) -> Path:
) )
def _mark_update_check_attempted(repo_root: Path) -> bool: def _mark_update_check_attempted(
marker_path = _update_marker_path(repo_root) repo_root: Path,
context_key: str | None = None,
) -> bool:
marker_path = _update_marker_path(repo_root, context_key)
if marker_path.exists(): if marker_path.exists():
return False return False
try: try:
@@ -445,8 +457,14 @@ def _mark_update_check_attempted(repo_root: Path) -> bool:
return True return True
def _get_update_hint(repo_root: Path) -> str | None: def get_update_hint(repo_root: Path, context_key: str | None = None) -> str | None:
marker_path = _update_marker_path(repo_root) """Return the "update available" line for this session, at most once.
Public because the SessionStart hook imports it: the text-mode CLI path
(`get_context.py`) used to be the only caller, so hook-driven platforms —
Claude Code included — never saw the reminder at all.
"""
marker_path = _update_marker_path(repo_root, context_key)
if marker_path.exists(): if marker_path.exists():
return None return None
@@ -458,7 +476,7 @@ def _get_update_hint(repo_root: Path) -> str | None:
if not latest_version: if not latest_version:
return None return None
_mark_update_check_attempted(repo_root) _mark_update_check_attempted(repo_root, context_key)
comparison = _compare_versions(current_version, latest_version) comparison = _compare_versions(current_version, latest_version)
if comparison is None or comparison >= 0: if comparison is None or comparison >= 0:
return None return None
@@ -867,7 +885,7 @@ def output_text(repo_root: Path | None = None) -> None:
""" """
if repo_root is None: if repo_root is None:
repo_root = get_repo_root() repo_root = get_repo_root()
update_hint = _get_update_hint(repo_root) update_hint = get_update_hint(repo_root)
if update_hint: if update_hint:
print(update_hint) print(update_hint)
print("") print("")
+439
View File
@@ -0,0 +1,439 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Decision logic for path-scoped spec injection (ticket-refresh model).
Pure logic only: the per-spec decision engine, block rendering and budgeted
payload assembly. Importing this module has no side effects; every piece of IO
orchestration (stdin, config, identity, state files, locking, GC) lives in the
platform hook that calls it. Unit tests import this module directly.
Clock
The periodic refresh window uses epoch seconds. Context resets are
explicit lifecycle events: the hook records an opaque reset identifier,
and a mismatch with the last emission re-teaches the spec in full.
Budget
All caps are in characters, because the platform's ``additionalContext``
ceiling is 10,000 *characters* (counting bytes made CJK specs pay 3x).
Truncation slices code points, which can never split a multi-byte
sequence. The per-event cap is enforced on the assembled payload string —
wrappers, ``\\n\\n`` separators, index block and tickets all counted — so
nothing is ever appended unchecked.
"""
from __future__ import annotations
import hashlib
import sys
from typing import Any, Sequence
from .spec_match import SpecMatch
# Bound on the size of a spec file we are willing to read and hash. A spec
# larger than this degrades to an index line (warned) — an inlined body that
# big could never fit the budget anyway, and the read+hash would be unbounded.
MAX_SPEC_SOURCE_BYTES = 10 * 1024 * 1024
# Upper bound on the room reserved for named index lines while FULL blocks are
# still being packed. Beyond it the reserve falls back to the summary line
# alone: a big fan-out must not starve the specs that can still be taught.
INDEX_RESERVE_MAX_CHARS = 900
# State record schema version. Records with any other version are ignored
# (safe direction: an ignored record re-injects rather than stays silent).
STATE_VERSION = 2
def _warn(message: str) -> None:
print(f"[WARN] spec_inject: {message}", file=sys.stderr)
# =============================================================================
# Clock
# =============================================================================
def within_window(
clock: dict[str, Any],
last: dict[str, Any],
win_seconds: int,
) -> bool:
"""True when the last emission is still inside the refresh window (→ stay
silent).
A window of ``0`` means never refresh (infinite window → always True).
Missing timestamps or a negative delta are past-window (False → refresh),
the safe side of the misfire asymmetry.
"""
cur_ts = clock.get("ts")
last_ts = last.get("ts")
if isinstance(cur_ts, (int, float)) and isinstance(last_ts, (int, float)):
if win_seconds == 0:
return True
delta = cur_ts - last_ts
return 0 <= delta < win_seconds
return False
def decide(
stateless: bool,
last: dict[str, Any] | None,
sha256_hex: str,
clock: dict[str, Any],
win_seconds: int,
) -> str:
"""Return one of ``"full"`` | ``"ticket"`` | ``"silent"`` for a spec.
Order is contractual: statelessness first (bounded cost, no state to
consult), then first sight, content change, context reset, the refresh
window, and finally completeness. A ticket says "you were shown this spec",
which is a lie when the recorded FULL was truncated, so an incomplete
record is re-taught in full instead.
"""
if stateless:
return "ticket"
if last is None:
return "full"
if last.get("sha256") != sha256_hex:
return "full"
if clock.get("reset") != last.get("reset"):
return "full"
if within_window(clock, last, win_seconds):
return "silent"
# "complete" is optional and absent means a whole body was shown.
if last.get("complete") is False:
return "full"
return "ticket"
# =============================================================================
# Rendering
# =============================================================================
def truncate_chars(text: str, cap: int) -> str:
"""Slice ``text`` to at most ``cap`` code points. ``cap <= 0`` = no limit."""
if cap <= 0 or len(text) <= cap:
return text
return text[:cap]
def truncation_notice(rel_path: str, cap: int) -> str:
return (
f"\n[Trellis: truncated at {cap} characters — "
f"read {rel_path} for the full content]"
)
def render_full(edited_rel: str, spec_rel: str, sha12: str, body: str) -> str:
return (
f'<spec-context file="{edited_rel}" spec="{spec_rel}" sha256="{sha12}">\n'
f"{body}\n"
f"</spec-context>"
)
def render_ticket(
edited_rel: str,
spec_rel: str,
sha12: str,
stateless: bool,
) -> str:
"""Render a ticket block.
``stateless=True`` covers both the no-identity and circuit-breaker paths:
there is no record of a prior emission, so the wording must not claim one.
"""
if stateless:
body = (
"This spec governs the file you just touched. If you have not read it in\n"
f"this session, Read {spec_rel} before continuing."
)
else:
body = (
"You were shown this spec earlier in this session and its content is unchanged.\n"
"It still governs edits to matching files. If you no longer remember it, Read\n"
f"{spec_rel} before continuing."
)
return (
f'<spec-ticket file="{edited_rel}" spec="{spec_rel}" sha256="{sha12}">\n'
f"{body}\n"
f"</spec-ticket>"
)
def _index_block(lines: Sequence[str]) -> str:
return "<spec-index>\n" + "\n".join(lines) + "\n</spec-index>"
# =============================================================================
# State records
# =============================================================================
def make_record(
rel_path: str,
sha256_hex: str,
mode: str,
clock: dict[str, Any],
complete: bool = True,
) -> dict[str, Any]:
"""Build a state record. ``complete=False`` marks a FULL whose body was
truncated below the whole spec — an absent flag means whole."""
record: dict[str, Any] = {
"v": STATE_VERSION,
"spec": rel_path,
"sha256": sha256_hex,
"mode": mode,
"ts": clock.get("ts"),
}
if isinstance(clock.get("reset"), str):
record["reset"] = clock["reset"]
if not complete:
record["complete"] = False
return record
# =============================================================================
# Payload assembly
# =============================================================================
def _derive_fitting_full(
edited_rel: str,
spec_rel: str,
sha12: str,
text: str,
max_spec_chars: int,
fits,
) -> tuple[str, bool] | None:
"""Largest truncated FULL block that fits the remaining total budget.
Binary search over the body cap: the rendered block's length is monotone
non-decreasing in the cap, so the largest cap whose block still ``fits``
is found in ~log2(len(text)) renders (this also absorbs the digit-length
wobble of the notice text, which a closed-form estimate cannot).
The search ceiling is ``max_spec_chars`` when set and the whole body when
it is ``0`` (unlimited) — with a ceiling of 1, as an unguarded
``max(1, 0)`` would give, nothing but a one-character spec could ever be
derived. Returns ``(block, complete)`` — ``complete`` is True only when
the winning cap covered the whole body — or None when no non-empty prefix
fits (the caller degrades to an index line).
"""
def candidate_for(cap: int) -> str:
body = truncate_chars(text, cap)
if len(body) < len(text):
body += truncation_notice(spec_rel, cap)
return render_full(edited_rel, spec_rel, sha12, body)
ceiling = len(text) if max_spec_chars <= 0 else min(max_spec_chars, len(text))
lo, hi = 1, max(1, ceiling)
best: tuple[str, bool] | None = None
while lo <= hi:
mid = (lo + hi) // 2
candidate = candidate_for(mid)
if fits(candidate):
best = (candidate, mid >= len(text))
lo = mid + 1
else:
hi = mid - 1
return best
def _index_line(match: SpecMatch) -> str:
return f"- {match.rel_path} — {match.description or 'no description'}"
def assemble_payload(
edited_rel: str,
matches: Sequence[SpecMatch],
stateless: bool,
state_records: dict[str, dict[str, Any]],
clock: dict[str, Any],
max_spec_chars: int,
max_total_chars: int,
win_seconds: int,
match_files: dict[str, str] | None = None,
) -> tuple[str, list[dict[str, Any]]]:
"""Assemble the additionalContext payload from the matched specs.
Returns ``(payload, records)`` where ``records`` are the state lines to
append for the emissions that actually made it into the payload (silent
hits and budget-dropped emissions record nothing — they stay eligible).
Every candidate block is measured against the *assembled* payload string
(``"\\n\\n".join(...)``), so the per-event character ceiling holds for the
exact string that is emitted.
``match_files`` maps a governing spec to the first matching file in a
multi-file tool call. Single-file callers omit it and retain the original
``edited_rel`` behavior.
"""
blocks: list[str] = []
def file_for(match: SpecMatch) -> str:
return (match_files or {}).get(match.rel_path, edited_rel)
def fits(candidate: str, reserve: int = 0) -> bool:
"""Does ``candidate`` fit the per-event ceiling, keeping ``reserve``
characters free for what still has to be appended after it?"""
if max_total_chars <= 0:
return True
return len("\n\n".join([*blocks, candidate])) + reserve <= max_total_chars
# Reserve while candidates are still pending: the index lines those
# candidates would actually need (true strings, not estimates) plus the
# summary line — so a derived-cap FULL cannot eat the budget and starve
# the specs behind it (measured: 10-spec fan-out at max_total_chars 3000
# emitted one 3000-char FULL and dropped the other nine silently). The
# named part is only guaranteed within INDEX_RESERVE_MAX_CHARS; beyond
# that the reserve falls back to the summary line alone.
_all_index_lines = [_index_line(m) for m in matches]
_summary_upper = (
f"- (+{len(matches)} more governing specs over budget — run "
f"python3 ./.trellis/scripts/get_context.py --mode spec "
f"--file {edited_rel} to list them)"
)
_summary_reserve = len("\n\n" + _index_block([_summary_upper]))
def reserve_for(pending: Sequence[str]) -> int:
if not pending:
return 0 # Nothing can follow this block — nothing to reserve.
named = len("\n\n" + _index_block([*pending, _summary_upper]))
if named > INDEX_RESERVE_MAX_CHARS:
return _summary_reserve
return named
index_lines: list[str] = []
ticket_pending: list[tuple[str, str, str]] = [] # (file, spec, sha256)
records: list[dict[str, Any]] = []
for match_idx, match in enumerate(matches):
try:
size = match.spec_path.stat().st_size
except OSError:
size = 0
if size > MAX_SPEC_SOURCE_BYTES:
# Too big to read+hash, let alone inline: name it and move on.
_warn(
f"{match.rel_path} is {size} bytes (over "
f"{MAX_SPEC_SOURCE_BYTES}) — degraded to an index line"
)
index_lines.append(_index_line(match))
continue
try:
data = match.spec_path.read_bytes()
except OSError:
_warn(f"cannot read {match.rel_path} — skipped")
continue
sha256_hex = hashlib.sha256(data).hexdigest()
sha12 = sha256_hex[:12]
last = None if stateless else state_records.get(match.rel_path)
decision = decide(stateless, last, sha256_hex, clock, win_seconds)
if decision == "silent":
continue
if decision == "ticket":
# Deferred: tickets are counted against the budget last.
ticket_pending.append((file_for(match), match.rel_path, sha256_hex))
continue
pending = [*index_lines, *_all_index_lines[match_idx + 1 :]]
reserve = reserve_for(pending)
_fits = (lambda c: fits(c, reserve))
text = data.decode("utf-8", errors="replace")
body = truncate_chars(text, max_spec_chars)
complete = len(body) >= len(text)
if not complete:
body += truncation_notice(match.rel_path, max_spec_chars)
matching_file = file_for(match)
block = render_full(matching_file, match.rel_path, sha12, body)
if not _fits(block):
# Contract amendment 1: before degrading, truncate FURTHER to the
# largest body prefix that fits the remaining total budget
# (wrapper + notice counted). Without this, the frozen defaults
# made the truncation path unreachable (body cap + notice +
# wrapper > total cap) and long specs fell straight to an index
# line — the rejected index-only mode by another route.
derived = _derive_fitting_full(
matching_file,
match.rel_path,
sha12,
text,
max_spec_chars,
_fits,
)
if derived is not None:
derived_block, derived_complete = derived
blocks.append(derived_block)
records.append(
make_record(
match.rel_path, sha256_hex, "full", clock, derived_complete
)
)
continue
# No usable prefix fits — degrade to an index line, never drop
# silently. Not recorded: stays eligible for a later event.
index_lines.append(_index_line(match))
continue
blocks.append(block)
records.append(
make_record(match.rel_path, sha256_hex, "full", clock, complete)
)
if index_lines:
# The index block is budget-bounded too: lines that do not fit collapse
# into one summary line (count + how to list them via pull mode) so the
# ceiling is honored without silently dropping a governing spec.
chosen: list[str] = []
dropped = 0
for line in index_lines:
if fits(_index_block([*chosen, line])):
chosen.append(line)
else:
dropped += 1
if dropped:
# Contract amendment 3: the summary must actually be reachable.
# Greedy packing rarely leaves a summary-sized gap, so pop chosen
# lines (re-counting them as dropped) until the summary fits —
# only an absurdly small total budget can drop it entirely.
while True:
noun = "spec" if dropped == 1 else "specs"
summary = (
f"- (+{dropped} more governing {noun} over budget — run "
f"python3 ./.trellis/scripts/get_context.py --mode spec "
f"--file {edited_rel} to list them)"
)
if fits(_index_block([*chosen, summary])):
chosen.append(summary)
break
if not chosen:
_warn(
f"spec index summary for {edited_rel} dropped — "
f"per-event budget exhausted"
)
break
chosen.pop()
dropped += 1
if chosen:
blocks.append(_index_block(chosen))
for matching_file, spec_rel, sha256_hex in ticket_pending:
ticket = render_ticket(
matching_file, spec_rel, sha256_hex[:12], stateless
)
if not fits(ticket):
_warn(f"ticket for {spec_rel} dropped — per-event budget exhausted")
continue
blocks.append(ticket)
records.append(make_record(spec_rel, sha256_hex, "ticket", clock))
return "\n\n".join(blocks), records
+395
View File
@@ -0,0 +1,395 @@
#!/usr/bin/env python3
"""
Path-scoped spec matching for on-demand spec injection.
Spec files under `.trellis/spec/**/*.md` MAY start with a YAML-like
frontmatter block declaring which repo paths they govern:
---
name: commands-workflow
description: workflow command conventions
paths:
- packages/cli/src/commands/workflow.ts
- packages/cli/src/utils/workflow-resolver.ts
---
The parser is hand-rolled (house pattern, modeled on
``trellis_config.parse_simple_yaml`` — no YAML dependency) and reads only a
bounded head of each file (16 KiB / 200 lines, whichever ends first). Only
files whose first line is exactly ``---`` are considered. ``name:`` /
``description:`` single-line strings are recognized (description is reused in
index lines). ``paths:`` accepts both a block list (``- <glob>`` items) and a
flow sequence (``paths: [a, b]``).
The parser is deliberately tolerant — a spec is prose that happens to carry a
routing hint, not a config file. Unknown keys, unrecognized line shapes and
stray ``- item`` lines are ignored; block scalars (``key: >`` / ``key: |``)
consume their more-indented continuation lines, so a SKILL.md-style
``description: >`` paragraph does not disqualify the file. An opening ``---``
with no recognized key before the closing marker is not frontmatter at all
(a Markdown horizontal rule opening the prose) and is ignored silently. Two
things are errors, and both warn + skip the whole file rather than route on a
half-read block: a malformed ``paths:`` (a scalar where a list belongs — that
key is the one thing the rest of the pipeline depends on), and a frontmatter
block that is still open when the head bound is reached.
Glob grammar (repo-relative, POSIX separators):
- ``*`` matches within a single path segment (never crosses ``/``)
- ``?`` matches exactly one character within a segment
- ``**`` as a whole segment matches zero or more segments
- a trailing ``/`` is sugar for ``/**``
- ``**`` embedded in a segment with other characters degrades to ``*``
Validation rejects only what is unsafe or meaningless: empty globs, a leading
``/`` (globs are repo-relative), ``..`` segments, backslashes (POSIX
separators only) and control characters. Everything else is legal — real
repositories carry ``@scope`` packages, ``[slug]`` routes, ``(marketing)``
groups and non-ASCII directories, and the translation escapes literals
character by character. An invalid glob is skipped with a stderr warning; the
rest of the file's globs still apply.
Translation examples (glob → matches / non-matches):
packages/cli/src/commands/update.ts
matches only that exact file
packages/cli/src/commands/*.ts
matches packages/cli/src/commands/update.ts
not packages/cli/src/commands/channel/spawn.ts
packages/cli/src/templates/**
matches packages/cli/src/templates/trellis/index.ts (any depth)
not packages/cli/src/templates (the directory itself)
packages/**/index.ts
matches packages/index.ts and packages/cli/src/index.ts
src/util?.py
matches src/utils.py, not src/util.py or src/utilXY.py
packages/cli/
same as packages/cli/**
Provides:
SpecMatch - frozen match record (spec_path, rel_path, description)
match_specs_for_file - map an edited file to the specs that govern it
normalize_repo_relative - the canonical repo-relative path normalization
parse_spec_frontmatter - parse the optional frontmatter head block
glob_to_regex - deterministic glob → compiled regex translation
"""
from __future__ import annotations
import re
import sys
import unicodedata
from dataclasses import dataclass
from pathlib import Path
from .paths import DIR_SPEC, DIR_WORKFLOW
from .trellis_config import _strip_inline_comment, _unquote
# Bounded head-read limits for frontmatter scanning (design contract).
HEAD_MAX_BYTES = 16384
HEAD_MAX_LINES = 200
# Recognized frontmatter keys. An opening `---` block that declares none of
# them is prose under a horizontal rule, not frontmatter.
_KNOWN_KEYS = ("paths", "name", "description")
_GLOB_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
_KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*):(.*)$")
# YAML block-scalar introducers; the value lives in the indented lines below.
_BLOCK_SCALARS = ("|", ">", "|-", ">-", "|+", ">+")
# macOS and Windows filesystems are case-insensitive: the very same file can
# be handed to us in a case the glob author never wrote. Match case-insensitively
# there — over-injecting a spec is the safe side of the asymmetry.
_CASE_INSENSITIVE_FS = sys.platform == "darwin" or sys.platform.startswith("win")
_GLOB_FLAGS = re.IGNORECASE if _CASE_INSENSITIVE_FS else 0
@dataclass(frozen=True)
class SpecFrontmatter:
"""Parsed frontmatter head. ``paths`` is None when the key is absent."""
paths: tuple[str, ...] | None
name: str | None
description: str | None
@dataclass(frozen=True)
class SpecMatch:
spec_path: Path
"""Absolute path to the spec file."""
rel_path: str
"""Repo-relative POSIX path, for display."""
description: str | None
"""Frontmatter ``description:`` value, if declared."""
def _warn(message: str) -> None:
print(f"[WARN] spec_match: {message}", file=sys.stderr)
def _parse_flow_sequence(value: str) -> list[str]:
"""Split a YAML flow sequence body (``[a, b]``) into unquoted items.
Commas separate; each item is trimmed and unquoted. Empty items (a
trailing comma, ``[]``) collapse away.
"""
inner = value[1:-1]
items = (_unquote(part.strip()).strip() for part in inner.split(","))
return [item for item in items if item]
def _read_head(path: Path) -> str:
"""Read at most HEAD_MAX_BYTES from the file, decoded as UTF-8."""
with open(path, "rb") as f:
data = f.read(HEAD_MAX_BYTES)
return data.decode("utf-8", errors="replace")
def parse_spec_frontmatter(head_text: str) -> SpecFrontmatter | None:
"""Parse the optional frontmatter block from a spec file's head.
Returns None when the file has no frontmatter: either the first line is not
``---``, or the block declares no recognized key before its closing marker
(a horizontal rule opening a prose file — silent, not an error).
Raises ValueError on a malformed ``paths:`` key (a scalar where a list
belongs) and on a block that is still open when the head bound
(HEAD_MAX_LINES / HEAD_MAX_BYTES) is reached — routing on a half-read
frontmatter would be worse than skipping the file loudly. Every other line
shape is tolerated and ignored.
"""
lines = head_text.splitlines()[:HEAD_MAX_LINES]
if not lines:
return None
first = lines[0].lstrip("\ufeff") # tolerate a UTF-8 BOM
if first != "---":
return None
paths: list[str] | None = None
name: str | None = None
description: str | None = None
pending_key: str | None = None
block_indent: int | None = None
saw_known_key = False
closed = False
for line in lines[1:]:
stripped = line.strip()
indent = len(line) - len(line.lstrip())
if block_indent is not None:
# Inside a block scalar: everything more indented (and blank lines)
# is its value. A dedent ends the block; that line still counts.
if not stripped or indent > block_indent:
continue
block_indent = None
if stripped == "---":
closed = True
break
if not stripped or stripped.startswith("#"):
continue
if stripped == "-" or stripped.startswith("- "):
if pending_key == "paths" and paths is not None:
item = _unquote(_strip_inline_comment(stripped[1:].strip()).strip())
paths.append(item)
# List items outside `paths:` are tolerated and ignored.
continue
key_match = _KEY_RE.match(stripped)
if key_match is None:
continue # Unrecognized line shape — tolerated and ignored.
key = key_match.group(1)
saw_known_key = saw_known_key or key in _KNOWN_KEYS
raw_value = key_match.group(2).strip()
if raw_value in _BLOCK_SCALARS:
if key == "paths":
raise ValueError("'paths' must be a list of globs")
pending_key = None
block_indent = indent
continue
value = _unquote(_strip_inline_comment(raw_value).strip())
if value:
pending_key = None
if key == "paths":
if not (value.startswith("[") and value.endswith("]")):
raise ValueError("'paths' must be a list of globs")
paths = _parse_flow_sequence(value)
elif key == "name":
name = value
elif key == "description":
description = value
# Unknown scalar keys are tolerated and ignored.
else:
pending_key = key
if key == "paths":
paths = []
if not saw_known_key:
# An opening `---` with no recognized key is a horizontal rule, not a
# frontmatter block. Silent by design: prose files are not malformed.
return None
if not closed:
raise ValueError(
f"frontmatter block never closed within the head bound "
f"({HEAD_MAX_BYTES} bytes / {HEAD_MAX_LINES} lines)"
)
return SpecFrontmatter(
paths=tuple(paths) if paths is not None else None,
name=name,
description=description,
)
def validate_glob(glob: str) -> str | None:
"""Return an error message for an invalid glob, or None when valid.
Deny-list, not allow-list: only what is unsafe or meaningless is rejected
(see module docstring). Everything else — ``@scope``, ``[slug]``,
``(marketing)``, non-ASCII directory names — is a legal path in a real
repository and translates fine.
"""
if not glob:
return "empty glob"
if glob.startswith("/"):
return "absolute paths are not allowed (globs are repo-relative)"
if ".." in glob.split("/"):
return "'..' segments are not allowed"
if "\\" in glob:
return "backslashes are not allowed (globs use POSIX '/' separators)"
if _GLOB_CONTROL_RE.search(glob):
return "contains control characters"
return None
def glob_to_regex(glob: str) -> re.Pattern[str]:
"""Translate a validated glob to a compiled full-match regex.
Deterministic, segment-based translation (see module docstring for the
grammar and examples): ``**`` as a whole segment spans zero or more
segments; ``*`` becomes ``[^/]*``; ``?`` becomes ``[^/]``; everything
else is escaped literally. A trailing ``/`` is expanded to ``/**`` first.
On case-insensitive filesystems (macOS, Windows) the pattern compiles with
``re.IGNORECASE`` — see ``_CASE_INSENSITIVE_FS``.
"""
if glob.endswith("/"):
glob += "**"
segments = glob.split("/")
parts: list[str] = []
for i, seg in enumerate(segments):
is_last = i == len(segments) - 1
if seg == "**":
# Last: consume the rest of the path (at least the separator
# boundary is already emitted by the previous segment). Not last:
# zero or more whole segments including their separators.
parts.append(".*" if is_last else r"(?:[^/]+/)*")
continue
piece = "".join(
"[^/]*" if ch == "*" else "[^/]" if ch == "?" else re.escape(ch)
for ch in seg
)
parts.append(piece if is_last else piece + "/")
return re.compile("^" + "".join(parts) + "$", _GLOB_FLAGS)
def normalize_repo_relative(repo_root: Path, file_path: str | Path) -> str | None:
"""Canonical repo-relative POSIX path — the one normalization in the
pipeline, used both for matching and for display.
Root and file are fully resolved (``strict=False``, so a file that no
longer exists still normalizes): symlinked repo roots, macOS's
``/tmp`` → ``/private/tmp`` and ``..`` segments cannot make one file look
like two different paths. The result is NFC-normalized (macOS hands out
NFD filenames). Relative inputs are taken as repo-relative. Returns None
when the file resolves outside the repo.
"""
try:
root = Path(repo_root).resolve(strict=False)
candidate = Path(file_path)
if not candidate.is_absolute():
text = str(file_path).replace("\\", "/")
while text.startswith("./"):
text = text[2:]
if not text:
return None
candidate = root / text
rel = candidate.resolve(strict=False).relative_to(root).as_posix()
except (OSError, ValueError):
return None
return unicodedata.normalize("NFC", rel)
def match_specs_for_file(repo_root: Path, file_path: str | Path) -> list[SpecMatch]:
"""Return specs whose frontmatter ``paths:`` globs match file_path.
``file_path`` may be absolute or repo-relative. More specific matching
globs are returned first; ``rel_path`` is the deterministic tie-break.
Scans ``.trellis/spec/**/*.md`` with bounded head-reads only. Never raises;
unreadable or malformed spec files are skipped with a stderr warning.
"""
try:
repo_root = Path(repo_root).resolve()
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
if not spec_dir.is_dir():
return []
rel = normalize_repo_relative(repo_root, file_path)
if rel is None:
return []
matches: list[SpecMatch] = []
specificity: dict[str, tuple[int, int, int, int]] = {}
for spec_file in spec_dir.rglob("*.md"):
spec_rel = spec_file.relative_to(repo_root).as_posix()
try:
head = _read_head(spec_file)
except OSError as exc:
_warn(f"cannot read {spec_rel}: {exc}")
continue
try:
frontmatter = parse_spec_frontmatter(head)
except ValueError as exc:
_warn(f"malformed frontmatter in {spec_rel}: {exc}")
continue
if frontmatter is None or not frontmatter.paths:
continue
for glob in frontmatter.paths:
error = validate_glob(glob)
if error is not None:
_warn(f"invalid glob {glob!r} in {spec_rel}: {error}")
continue
if glob_to_regex(glob).match(rel):
scored_glob = glob + "**" if glob.endswith("/") else glob
wildcard_count = scored_glob.count("*") + scored_glob.count("?")
segments = scored_glob.split("/")
literal_segments = sum(
"*" not in segment and "?" not in segment
for segment in segments
)
specificity[spec_rel] = (
0 if wildcard_count == 0 else 1,
-literal_segments,
wildcard_count,
-(len(scored_glob) - wildcard_count),
)
matches.append(
SpecMatch(
spec_path=spec_file,
rel_path=spec_rel,
description=frontmatter.description,
)
)
break
# Payload assembly spends its budget in this order. Exact and narrowly
# scoped matches must therefore outrank broad tree globs; alphabetic
# order is only a deterministic tie-break.
matches.sort(key=lambda m: (*specificity[m.rel_path], m.rel_path))
return matches
except Exception as exc: # Never raise — callers are hooks/context tools.
_warn(f"spec scan failed: {exc}")
return []
+57 -4
View File
@@ -25,7 +25,7 @@ from .config import get_context_injection_limits
from .git import branch_exists_locally from .git import branch_exists_locally
from .io import read_json from .io import read_json
from .log import Colors, colored from .log import Colors, colored
from .paths import FILE_TASK_JSON, get_repo_root from .paths import DIR_ARCHIVE, DIR_TASKS, DIR_WORKFLOW, FILE_TASK_JSON, get_repo_root
from .task_utils import resolve_task_dir from .task_utils import resolve_task_dir
# Extensions that look like code rather than spec/research docs. Entries with # Extensions that look like code rather than spec/research docs. Entries with
@@ -170,6 +170,59 @@ def _is_exempt_from_code_file_warning(file_path: str, task_rel: str) -> bool:
return False return False
def _resolve_context_entry_path(
file_path: str, repo_root: Path, task_dir: Path | None
) -> Path | None:
"""Resolve a JSONL entry, binding archived self-references to the archive copy.
Exact historical self-references are remapped only for archived tasks.
``None`` means the remapped path traversed or resolved outside that archive.
"""
repo_path = repo_root / file_path
if task_dir is None:
return repo_path
try:
task_parts = task_dir.resolve().relative_to(repo_root.resolve()).parts
except ValueError:
return repo_path
archive_prefix = (DIR_WORKFLOW, DIR_TASKS, DIR_ARCHIVE)
if len(task_parts) != 5 or task_parts[:3] != archive_prefix:
return repo_path
year_month = task_parts[3]
if (
len(year_month) != 7
or year_month[4] != "-"
or not year_month[:4].isdigit()
or not year_month[5:].isdigit()
):
return repo_path
historical_root = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_dir.name}"
posix_path = file_path.replace("\\", "/")
if posix_path == historical_root:
relative_parts: tuple[str, ...] = ()
elif posix_path.startswith(f"{historical_root}/"):
relative_path = posix_path[len(historical_root) + 1 :]
if relative_path.endswith("/"):
relative_path = relative_path[:-1]
relative_parts = tuple(relative_path.split("/")) if relative_path else ()
if any(part in ("", ".", "..") for part in relative_parts):
return None
else:
return repo_path
try:
archive_root = task_dir.resolve()
resolved_path = task_dir.joinpath(*relative_parts).resolve()
resolved_path.relative_to(archive_root)
except (OSError, RuntimeError, ValueError):
return None
return resolved_path
def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = None) -> int: def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = None) -> int:
"""Validate a single JSONL file. """Validate a single JSONL file.
@@ -220,14 +273,14 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
continue continue
real_entries += 1 real_entries += 1
full_path = repo_root / file_path full_path = _resolve_context_entry_path(file_path, repo_root, task_dir)
if entry_type == "directory": if entry_type == "directory":
if not full_path.is_dir(): if full_path is None or not full_path.is_dir():
print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}") print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}")
errors += 1 errors += 1
continue continue
if not full_path.is_file(): if full_path is None or not full_path.is_file():
print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}") print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}")
errors += 1 errors += 1
continue continue
+30
View File
@@ -56,6 +56,7 @@ from .task_utils import (
resolve_task_dir, resolve_task_dir,
run_task_hooks, run_task_hooks,
) )
from .workflow_selection import DIR_WORKFLOWS, WORKFLOW_ID_RE
# ============================================================================= # =============================================================================
@@ -254,6 +255,31 @@ def cmd_create(args: argparse.Namespace) -> int:
# Inferred: default_package → None (no task.json yet for create) # Inferred: default_package → None (no task.json yet for create)
package = resolve_package(repo_root=repo_root) package = resolve_package(repo_root=repo_root)
# Validate --workflow (CLI source: fail-fast on invalid id; a missing
# library file only warns — it may be saved later via `trellis workflow --save`)
workflow_id: str | None = getattr(args, "workflow", None)
if workflow_id:
if not WORKFLOW_ID_RE.match(workflow_id):
print(
colored(
f"Error: invalid workflow id '{workflow_id}' (allowed: letters, digits, '-', '_')",
Colors.RED,
),
file=sys.stderr,
)
return 1
workflow_md = repo_root / DIR_WORKFLOW / DIR_WORKFLOWS / f"{workflow_id}.md"
if not workflow_md.is_file():
print(
colored(
f"Warning: {DIR_WORKFLOW}/{DIR_WORKFLOWS}/{workflow_id}.md does not exist yet; "
"default workflow resolution is used until it is saved "
"(trellis workflow --save).",
Colors.YELLOW,
),
file=sys.stderr,
)
# Default assignee to current developer # Default assignee to current developer
assignee = args.assignee assignee = args.assignee
if not assignee: if not assignee:
@@ -385,6 +411,10 @@ def cmd_create(args: argparse.Namespace) -> int:
"notes": "", "notes": "",
"meta": meta, "meta": meta,
} }
# Optional per-task workflow selection: key present only when opted in,
# so tasks without a selection keep today's task.json shape byte-for-byte.
if workflow_id:
task_data["workflow"] = workflow_id
write_json(task_json_path, task_data) write_json(task_json_path, task_data)
+3 -2
View File
@@ -22,11 +22,12 @@ from __future__ import annotations
import re import re
from .paths import DIR_WORKFLOW, get_repo_root from . import workflow_selection
from .paths import get_repo_root
def _workflow_md_path(): def _workflow_md_path():
return get_repo_root() / DIR_WORKFLOW / "workflow.md" return workflow_selection.resolve_workflow_md(get_repo_root())
# Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]" # Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]"
_MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$") _MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$")
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
Per-task workflow selection.
Resolves which workflow markdown file consumers should read. A task may pin
a workflow variant by storing `"workflow": "<id>"` in its task.json; the
variant body lives at `.trellis/workflows/<id>.md` (user-managed library).
Resolution precedence (single source of truth for all consumers), highest
to lowest — each layer resolves an id to `.trellis/workflows/<id>.md` and
falls through when unset, invalid, or pointing at a missing file:
1. Per-task pin - active task's task.json `workflow` (session-bound,
explicit; a bad id or missing file warns once on stderr, then falls
through rather than aborting).
2. Personal - `.developer` `workflow=<id>` (gitignored, per-developer;
outranks the team default). Silent on miss.
3. Team default - config.yaml `default_workflow` (git-tracked, shared).
Silent on miss.
4. Global - `.trellis/workflow.md`.
With neither a per-task pin nor the personal/team keys set, this is identical
to reading the global `.trellis/workflow.md`. Never raises.
Provides:
workflow_md_for_task - Full precedence for an already-resolved task dir
resolve_workflow_md - Session-aware wrapper via the active task resolver
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from .paths import DIR_WORKFLOW, FILE_TASK_JSON
# Workflow variant library directory under .trellis/ (plural on purpose:
# `.trellis/workflow/` is reserved by the YAML-manifest migration).
DIR_WORKFLOWS = "workflows"
# Workflow ids must be plain slugs; anything else (path separators, dots)
# is rejected so a task.json value can never escape .trellis/workflows/.
WORKFLOW_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
def _global_workflow_md(repo_root: Path) -> Path:
return repo_root / DIR_WORKFLOW / "workflow.md"
def _library_variant(repo_root: Path, workflow_id: str | None) -> Path | None:
"""Map a workflow id to its library file if valid and present, else None.
Shared by every layer (per-task pin, personal, team). An id with path
separators/dots/blanks, or one whose `.trellis/workflows/<id>.md` file does
not exist, returns None so the caller falls through. Never raises.
"""
if not isinstance(workflow_id, str) or not workflow_id:
return None
if not WORKFLOW_ID_RE.match(workflow_id):
return None
variant = repo_root / DIR_WORKFLOW / DIR_WORKFLOWS / f"{workflow_id}.md"
return variant if variant.is_file() else None
def _developer_workflow_id(repo_root: Path) -> str | None:
"""Personal override id from the gitignored .developer file (fail-open)."""
try:
from .paths import get_developer_workflow
return get_developer_workflow(repo_root)
except Exception:
return None
def _config_default_id(repo_root: Path) -> str | None:
"""Team-shared default id from config.yaml `default_workflow` (fail-open)."""
try:
from .config import get_default_workflow
return get_default_workflow(repo_root)
except Exception:
return None
def _default_workflow_md(repo_root: Path) -> Path:
"""Resolve the non-per-task default: personal -> team -> global.
Personal (`.developer` `workflow=`) outranks the team-shared
(config.yaml `default_workflow`) layer; both fall through to the global
`.trellis/workflow.md` when unset, invalid, or naming a missing file. These
layers are silent on miss (they are defaults, not an explicit per-task
choice — a per-turn warning would be noise).
"""
for get_id in (_developer_workflow_id, _config_default_id):
variant = _library_variant(repo_root, get_id(repo_root))
if variant is not None:
return variant
return _global_workflow_md(repo_root)
def _task_pin_variant(repo_root: Path, task_dir: Path | None) -> Path | None:
"""Return the per-task pinned variant path, or None to fall through.
Emits a stderr warning on an invalid id or a missing variant file (an
explicit per-task choice that cannot be honored), then returns None so
resolution continues with the personal/team defaults. Never raises.
"""
if task_dir is None:
return None
try:
raw = json.loads((task_dir / FILE_TASK_JSON).read_text(encoding="utf-8"))
if not isinstance(raw, dict):
return None
workflow_id = raw.get("workflow")
if not isinstance(workflow_id, str) or not workflow_id:
return None
if not WORKFLOW_ID_RE.match(workflow_id):
print(
f"Warning: task '{task_dir.name}' has invalid workflow id "
f"{workflow_id!r}; using default workflow resolution",
file=sys.stderr,
)
return None
variant = _library_variant(repo_root, workflow_id)
if variant is not None:
return variant
print(
f"Warning: task '{task_dir.name}' selects workflow '{workflow_id}' but "
f"{DIR_WORKFLOW}/{DIR_WORKFLOWS}/{workflow_id}.md is missing; "
f"using default workflow resolution",
file=sys.stderr,
)
return None
except Exception:
return None
def workflow_md_for_task(repo_root: Path, task_dir: Path | None) -> Path:
"""Return the workflow.md path for an already-resolved task dir (or None).
Applies the full precedence documented in the module docstring:
per-task pin -> personal (.developer) -> team (config.yaml) -> global.
Never raises; any failure falls through toward the global workflow path.
"""
pin = _task_pin_variant(repo_root, task_dir)
if pin is not None:
return pin
return _default_workflow_md(repo_root)
def resolve_workflow_md(
repo_root: Path,
input_data: dict | None = None,
platform: str | None = None,
) -> Path:
"""Resolve the session-aware active task, then apply the resolution rule.
``input_data`` is the raw hook payload (session/conversation identity);
CLI callers may omit it — the active-task resolver then falls back to
environment context. Never raises; any failure resolves to the global
`.trellis/workflow.md`.
"""
try:
from .active_task import resolve_active_task, resolve_task_ref
active = resolve_active_task(repo_root, input_data, platform)
task_dir: Path | None = None
if active.task_path:
task_dir = resolve_task_ref(active.task_path, repo_root)
return workflow_md_for_task(repo_root, task_dir)
except Exception:
return _global_workflow_md(repo_root)
+82
View File
@@ -11,6 +11,7 @@ Usage:
python3 task.py start <dir> # Set active task python3 task.py start <dir> # Set active task
python3 task.py current [--source] [--json] # Show active task python3 task.py current [--source] [--json] # Show active task
python3 task.py finish # Clear active task python3 task.py finish # Clear active task
python3 task.py workflow <id>|--clear # Set/clear per-task workflow selection
python3 task.py set-branch <dir> <branch> # Set git branch python3 task.py set-branch <dir> <branch> # Set git branch
python3 task.py set-base-branch <dir> <branch> # Set PR target branch python3 task.py set-base-branch <dir> <branch> # Set PR target branch
python3 task.py set-scope <dir> <scope> # Set scope for PR title python3 task.py set-scope <dir> <scope> # Set scope for PR title
@@ -47,6 +48,7 @@ from common.active_task import (
from common.io import read_json, write_json from common.io import read_json, write_json
from common.task_utils import resolve_task_dir, run_task_hooks from common.task_utils import resolve_task_dir, run_task_hooks
from common.tasks import iter_active_tasks, children_progress from common.tasks import iter_active_tasks, children_progress
from common.workflow_selection import WORKFLOW_ID_RE, workflow_md_for_task
# Import command handlers from split modules (also re-exports for plan.py compatibility) # Import command handlers from split modules (also re-exports for plan.py compatibility)
from common.task_store import ( from common.task_store import (
@@ -204,6 +206,72 @@ def cmd_current(args: argparse.Namespace) -> int:
return 1 return 1
# =============================================================================
# Command: workflow
# =============================================================================
def cmd_workflow(args: argparse.Namespace) -> int:
"""Set or clear the workflow selection on the current session's active task."""
repo_root = get_repo_root()
if args.clear and args.id:
print(colored("Error: pass either <id> or --clear, not both", Colors.RED))
return 1
if not args.clear and not args.id:
print(colored("Error: workflow id required (or --clear)", Colors.RED))
print("Usage: python3 task.py workflow <id> | --clear")
return 1
active = resolve_active_task(repo_root)
if not active.task_path:
print(colored("Error: No current task set", Colors.RED))
print("Hint: run task.py start <dir> first")
return 1
task_dir = repo_root / active.task_path
task_json_path = task_dir / FILE_TASK_JSON
if not task_json_path.is_file():
print(colored(f"Error: task.json not found at {task_dir}", Colors.RED))
return 1
data = read_json(task_json_path)
if not data:
print(colored(f"Error: failed to read {task_json_path}", Colors.RED))
return 1
if args.clear:
if data.pop("workflow", None) is None:
print(colored("No workflow selection set on this task", Colors.YELLOW))
else:
if not write_json(task_json_path, data):
print(colored("Error: failed to update task.json", Colors.RED))
return 1
print(colored("✓ Workflow selection cleared", Colors.GREEN))
else:
workflow_id = args.id
if not WORKFLOW_ID_RE.match(workflow_id):
print(colored(
f"Error: invalid workflow id '{workflow_id}' (allowed: letters, digits, '-', '_')",
Colors.RED,
))
return 1
data["workflow"] = workflow_id
if not write_json(task_json_path, data):
print(colored("Error: failed to update task.json", Colors.RED))
return 1
print(colored(f"✓ Workflow set to: {workflow_id}", Colors.GREEN))
# workflow_md_for_task warns on stderr itself when the selected variant
# file is missing (it can be saved later via `trellis workflow --save`).
effective = workflow_md_for_task(repo_root, task_dir)
try:
effective_display = effective.relative_to(repo_root).as_posix()
except ValueError:
effective_display = str(effective)
print(f"Effective workflow: {effective_display}")
return 0
# ============================================================================= # =============================================================================
# Command: list # Command: list
# ============================================================================= # =============================================================================
@@ -382,12 +450,15 @@ Usage:
python3 task.py create <title> --package <pkg> Create task for a specific package python3 task.py create <title> --package <pkg> Create task for a specific package
python3 task.py create <title> --parent <dir> Create task as child of parent python3 task.py create <title> --parent <dir> Create task as child of parent
python3 task.py create <title> --no-start Create without making it active in this session python3 task.py create <title> --no-start Create without making it active in this session
python3 task.py create <title> --workflow <id> Create task pinned to a workflow variant
python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl
python3 task.py validate <dir> Validate jsonl files python3 task.py validate <dir> Validate jsonl files
python3 task.py list-context <dir> List jsonl entries python3 task.py list-context <dir> List jsonl entries
python3 task.py start <dir> Set active task python3 task.py start <dir> Set active task
python3 task.py current [--source] Show active task python3 task.py current [--source] Show active task
python3 task.py finish Clear active task python3 task.py finish Clear active task
python3 task.py workflow <id> Select workflow variant for active task
python3 task.py workflow --clear Clear selection (use default resolution)
python3 task.py set-branch <dir> <branch> Set git branch python3 task.py set-branch <dir> <branch> Set git branch
python3 task.py set-base-branch <dir> <branch> Set PR target branch python3 task.py set-base-branch <dir> <branch> Set PR target branch
python3 task.py set-scope <dir> <scope> Set scope for PR title python3 task.py set-scope <dir> <scope> Set scope for PR title
@@ -490,6 +561,10 @@ def main() -> int:
action="store_true", action="store_true",
help="Create the task without making it active in this session", help="Create the task without making it active in this session",
) )
p_create.add_argument(
"--workflow",
help="Workflow variant id for this task (.trellis/workflows/<id>.md)",
)
# add-context # add-context
p_add = subparsers.add_parser("add-context", help="Add context entry") p_add = subparsers.add_parser("add-context", help="Add context entry")
@@ -520,6 +595,12 @@ def main() -> int:
# finish # finish
subparsers.add_parser("finish", help="Clear active task") subparsers.add_parser("finish", help="Clear active task")
# workflow
p_workflow = subparsers.add_parser("workflow", help="Set/clear per-task workflow selection")
p_workflow.add_argument("id", nargs="?", help="Workflow id (.trellis/workflows/<id>.md)")
p_workflow.add_argument("--clear", action="store_true",
help="Remove the workflow selection (use default resolution)")
# set-branch # set-branch
p_branch = subparsers.add_parser("set-branch", help="Set git branch") p_branch = subparsers.add_parser("set-branch", help="Set git branch")
p_branch.add_argument("dir", help="Task directory") p_branch.add_argument("dir", help="Task directory")
@@ -580,6 +661,7 @@ def main() -> int:
"start": cmd_start, "start": cmd_start,
"current": cmd_current, "current": cmd_current,
"finish": cmd_finish, "finish": cmd_finish,
"workflow": cmd_workflow,
"set-branch": cmd_set_branch, "set-branch": cmd_set_branch,
"set-base-branch": cmd_set_base_branch, "set-base-branch": cmd_set_base_branch,
"set-scope": cmd_set_scope, "set-scope": cmd_set_scope,
@@ -0,0 +1,40 @@
# OneChartLab Radar 三个指标补充核验(2026-09-01)
## 当前版本
- 首页:<https://onechartlab.com/>
- Manifest:<https://onechartlab.com/radar_manifest.json>
- 观测到 `LATEST_DATE=2026-08-31`、31 个交易日、最新日期分片 `radar_data/dates/2026-08-31.c094ac3098c9.json`。
- 最新分片 791 条:概念 414、行业 377;每条 35 个字段,含 `pct_change`、`Swing_Ratio_Val`、`Swing_Amount_Val`、`Swing_Score`、`Ratio_Raw_Pct`、`Ratio_Score`、`Amount_Raw_BN`、`Amount_Score` 及各类排名字段。
## 页面证据
当前首页内联 Vue 模板(本次保存为 `/tmp/onechartlab-index-20260901.html`)显示:
- `pct_change` 直接用于左右榜涨跌幅展示和表头排序,格式为带符号两位小数百分比;非有限值显示 `-`。
- `Swing`/`Ratio` 模式的加权评分分别读取 `Swing_Score`/`Ratio_Score`,保留 1 位小数;tooltip 是“后端特征算法算出的综合加权值”。前端没有计算 score 的代码。
- `Swing` 模式的波段流入率读取 `Swing_Ratio_Val * 100`,保留两位小数百分比;`Ratio` 模式读取 `Ratio_Raw_Pct * 100`。
- 普通榜候选先按 `type` 分池,再按对应 `RankPct >= 90` 或 `<= 10` 取前后 10%;`Swing` 和 `Ratio` 默认按各自 `*_Score` 排序,`Amount` 默认按 `Amount_Raw_BN` 排序。`Amount_Score` 存在但不参与 `Amount` 榜默认排序。
## 数值关系
2026-08-31 的概念样例:
| 板块 | `pct_change` | `Swing_Ratio_Val` | `Swing_Score` | `Ratio_Raw_Pct` | `Ratio_Score` | `Amount_Raw_BN` |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| 知识产权 | 4.03 | 0.0320857 | 985.0391 | 0.1494700 | 988.6650 | 25.0232 |
| 盲盒经济 | 4.04 | 0.0465369 | 899.3116 | 0.2279457 | 952.2123 | 16.0316 |
| 元宇宙概念 | 2.83 | 0.0152171 | 976.3810 | 0.0695193 | 1032.4349 | 61.7966 |
概念与行业两个排名池中,`Swing_RankPos` 与 `Swing_Score`、`Ratio_RankPos` 与 `Ratio_Score`、`Amount_RankPos` 与 `Amount_Raw_BN` 均零不匹配。该结果只能确认排序键,不能推出 score 公式。
横截面统计(Pearson 相关系数):`pct_change` 与 `Ratio_Score` 在概念/行业分别约为 0.714/0.584,与 `Swing_Score` 约为 0.392/0.259;对应 raw 值与 score 的相关性更高但仍非恒等关系。因此涨跌幅可能是后端特征之一,也可能只是与资金强弱共同受行情驱动,不能据此声明“score=涨跌幅+资金”的公式。
将连续日期的 `Ratio_Raw_Pct` 与当前 `Swing_Ratio_Val` 对齐,在可匹配样本上,最近 3 日简单均值是最相近候选(概念/行业 `R²` 约 0.886/0.882),但拟合存在斜率和截距,4—10 日候选更弱,故不能确认波段值就是 3 日平均。
## 证据边界
- 作者视频 `01:36—01:58`:单日流入率概念上是主力流入相对当天成交额的比例,并警告小样本、低活跃度和低成交量失真。
- 作者视频 `01:58—02:23`:波段流入率是 3—10 日加权的单日流入率并排名;靠前代表资金留存比例较高,后续延续性只是可能更好。
- 页面成分 manifest:`pct_change` 来源 `tushare.daily.pct_chg`,主力净额来源 `tushare.moneyflow_dc.net_amount`,聚合为 `sum`,多日涨跌幅使用 `compound_return`。
- 公开日期分片没有逐股成交额、成员快照和资金流明细,故无法从当前公开 payload 直接重算 `Ratio_Raw_Pct`、`Swing_Ratio_Val` 或任一 score。
@@ -0,0 +1,3 @@
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "前端质量门禁与测试规范"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "组件可访问性与列表交互规范"}
{"file": ".trellis/tasks/09-01-optimize-stock-list-radar/prd.md", "reason": "用户需求与验收标准"}
@@ -0,0 +1,19 @@
# 技术设计
## 边界与组件结构
选股模块保留 `SelectionResultsWorkbench` 的筛选、分页、选中状态和详情面板结构,将当前桌面 `SignalTable` 与移动 `SignalRecordList` 的双实现收敛为一套响应式紧凑卡片列表。列表继续位于左侧结果区内部滚动;条目用语义化 `article` 和真实按钮承载选择、展开交互,不改变路由搜索参数或 API 类型。
资金雷达继续使用语义化表格与现有无限滚动容器,只删除展示层中的资金覆盖率和质量表头/单元格,并同步空状态 `colSpan`。通过缩小表头和数据行的垂直 padding、减少板块名称与代码间距来提高密度,不改动返回数据类型或质量计算。
## 数据流与兼容性
两处改动均消费现有响应字段,不改变 query、路由和后端契约。选股卡片仍用 `getStockKey`、`getStockJValue`、`PatternScoreSummary`、`SignalDetails` 和现有 category 样式函数,确保内容与详情行为保持一致。资金雷达保留 `quality` 和 `moneyflow_coverage` 在 TypeScript 响应类型中,仅不展示,以避免形成不必要的接口破坏。
## 取舍
选股列表采用单一响应式组件,消除桌面/移动两套 DOM 和测试分叉;卡片通过横向信息区和较小间距兼顾密度与窄屏换行。资金雷达保留 table,因为用户仅要求缩减列和行高,表格仍最适合对齐排名数据。
## 回滚
改动局限于选股展示组件、工作台装配、资金雷达页面及对应测试;如出现视觉或交互回归,可逐文件回退,不涉及数据迁移。
@@ -0,0 +1,3 @@
{"file": ".trellis/spec/frontend/index.md", "reason": "前端开发入口与检查清单"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "组件、样式与可访问性规范"}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "测试与质量门禁,约束卡片列表与雷达表格的断言方式"}
@@ -0,0 +1,10 @@
# 实施计划
1. 将选股结果收敛为单一紧凑卡片列表,保留选中、键盘、展开详情、空状态与内部滚动。
2. 更新工作台装配,移除 table 展示路径和重复组件引用,并按需要删除不再使用的文件。
3. 更新选股页面测试,改为断言卡片列表结构、核心字段和交互,不再依赖 table 表头。
4. 从资金雷达表格移除资金覆盖率与质量两列,修正 `colSpan` 并缩小表头、数据行间距。
5. 更新资金雷达测试,明确断言精简列不存在而保留百分位和样本;保留无限滚动与排名变化回归覆盖。
6. 在 `zhixing-web/` 运行目标 Vitest,然后运行 `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test`;必要时运行 `pnpm build` 验证产物。
风险点是选股卡片按钮嵌套和键盘行为、断点下滚动高度链,以及资金雷达 rank-change 模式的动态列数。实现时以语义结构和现有页面布局约束为准,出现回归时优先回滚对应组件的局部改动。
@@ -0,0 +1,29 @@
# 优化选股列表与资金雷达看板布局
## Goal
提升选股结果和资金雷达的浏览密度与可读性,让用户在同一屏看到更多条目,并减少不必要的辅助指标占用。
## Background and confirmed facts
当前选股结果在桌面端由 `features/selection/components/signal-table.tsx` 以 `<table>` 展示,移动端另有 `signal-record-list.tsx` 卡片列表;工作台同时挂载两者并通过断点切换。资金雷达页面的 `RadarTable` 表头和行包含“排名百分位、样本、资金覆盖率、质量”等列,行使用较大的垂直 padding。
## Requirements
1. 选股结果在所有屏幕尺寸统一使用可滚动列表,不再渲染 table。每个股票条目以紧凑卡片展示股票名称/代码、信号标签、图形评分、J 值和收盘价,并保留点击选中、键盘可操作和右侧详情面板。列表容器应继续支持内部滚动和空结果提示;筛选、排序保留,不再展示底部分页,改为滚动加载更多。
2. 资金雷达看板保留核心排名、板块、指标值、排名百分位和样本信息,移除“资金覆盖率”和“质量”两列及其单元格内容;表头和数据行改为更紧凑的垂直间距,在不改变滚动加载、错误/空数据状态和排名变化视图的前提下提升可见行数。
3. 现有筛选、排序和选中股票详情的数据契约不变;仅调整展示层结构和样式。执行状态入口放到工具栏“重新执行”按钮右侧。
## Acceptance Criteria
- [ ] 选股工作台 DOM 中不再出现 `role="table"`/`<table>`,桌面和移动宽度均可看到滚动卡片列表;每张卡片展示股票名称、代码、至少一个信号标签(有信号时)、评分、J 值和收盘价。卡片上不再出现“查看详情”。
- [ ] 选股卡片点击或键盘 Enter/Space 可选中股票,详情面板仍显示当前选中项。
- [ ] 选股结果不再渲染底部分页控件;列表滚到底部会加载下一页,加载中和失败重试提示保持可见。
- [ ] 执行状态入口(如“部分成功”)出现在工具栏“重新执行/重试执行/执行策略”按钮右侧。
- [ ] 资金雷达表头不再包含“资金覆盖率”和“质量”,对应行内容也不再渲染;“排名百分位”和“样本”仍可见。
- [ ] 资金雷达数据行垂直间距小于当前实现,滚动到底部仍能触发一次加载更多,加载中和失败重试提示保持可见。
- [ ] `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test` 在 `zhixing-web/` 下通过。
## Out of scope
不调整后端接口、指标计算、筛选/排序语义、详情面板字段或全局主题 token。
@@ -0,0 +1,26 @@
{
"id": "optimize-stock-list-radar",
"name": "optimize-stock-list-radar",
"title": "优化选股列表与资金雷达看板布局",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "codex",
"assignee": "codex",
"createdAt": "2026-09-01",
"completedAt": "2026-09-25",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -0,0 +1,5 @@
{"file": ".trellis/spec/frontend/index.md", "reason": "核对 feature 边界、URL 状态和质量门禁。"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "核对 Tab 语义、表格列对齐、滚动、状态提示和可访问性。"}
{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "核对双榜 query key、取消信号、分页停止条件和错误状态归属。"}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "核对测试覆盖以及 format、lint、typecheck、test、build 结果。"}
{"file": "docs/research/onechartlab-sector-capital-radar.md", "reason": "确认未把原站未知的评分、涨跌幅或在榜天数伪造成已实现字段。"}
@@ -0,0 +1,39 @@
# 板块资金雷达双榜交互技术设计
## 范围与边界
本轮只修改 `zhixing-web` 的板块资金雷达页面、相关 query 组合和测试,不改变 FastAPI 参数、响应模型、数据库表或指标构建逻辑。后端现有 `GET /api/v1/sector-radar/rankings` 已支持 `side=top` 与 `side=bottom`,前端用两条独立无限查询组合双榜;两侧仍共享交易日、板块类型、指标视角、排名变化参数、搜索词和 `page_size`。
路由继续兼容既有 `side` 查询参数,但双榜页面不再用它决定可见榜单,也不提供“榜单范围”控件。旧链接无论携带 `side=all/top/bottom` 都进入同一双榜视图,避免为本轮视觉迭代扩大路由类型迁移范围。
## Tab 与工具栏
板块类型和指标视角改为 feature 内的语义化按钮 Tab,使用 `role="tablist"`、`role="tab"` 与 `aria-selected`,沿用现有 `updateSearch` 写回 URL,并在切换时重置 `page=1`。不新增共享 Tabs primitive或第三方依赖;交易日、排名变化指标和对比区间继续使用现有 Select,搜索继续使用 Input。
指标 Tab 顺序按目标截图组织为波段资金率、单日资金率、单日净额、排名变化。排名变化激活时,附属的“变化指标”和“对比区间”控件紧凑显示,不改变主 Tab 层级。
## 双查询与滚动数据流
页面从同一基础筛选分别构造 `side="top"` 和 `side="bottom"` 的 `RadarRankingsQuery`,调用两次现有 `useSectorRadarRankings`。现有 query key 已包含 `side` 且排除 `page`,因此两侧缓存和分页相互独立,无需修改后端契约。
每侧分别展平 `InfiniteData.pages`,按数组索引把强榜第 N 行和弱榜第 N 行配成一个视觉行。任一侧行数不足时对应单元格留空,另一侧继续正常展示。唯一滚动容器接近底部时,只为仍有下一页、未请求中且未处于加载更多错误的侧调用 `fetchNextPage`;请求级互斥覆盖同一滚动周期,避免连续事件重复取数。两侧都没有下一页时停止。
加载更多失败按侧记录并保留已加载行;提示区说明强榜或弱榜哪一侧失败,并只重试失败侧。首次请求、后台刷新、刷新失败、部分发布、无发布和搜索无匹配继续保持明确分支,不用一侧的下一页错误覆盖另一侧现有数据。
## 镜像表格结构
使用一张语义化 `<table>` 和显式列宽布局承载左右镜像双榜。第一层表头分为“资金进攻榜 TOP 10%”、当前指标和“BOTTOM 10% 资金撤离榜”;第二层列从左到右为:
`排名 / 板块 | 排名百分位 | 样本 | 资金覆盖率 | 质量 | 流入 | 流出 | 质量 | 资金覆盖率 | 样本 | 排名百分位 | 排名 / 板块`
右侧文本和列标题采用与阅读方向匹配的对齐方式,中间两列增加稳定分隔线。表头继续 sticky,表格保留足够 `min-width`,窄视口通过单一横向滚动容器查看,不压缩成多行或错位结构。
普通资金指标使用 `metric_value`:`CNY_100M` 以亿元格式化,`ratio` 以百分比格式化;强榜显示显式流入方向,弱榜显示显式流出方向,并使用现有主题中的红/绿语义和低饱和背景条增强对比。排名变化视角中间列改为“排名上升 / 排名下降”,值使用 `rank_change`,参考指标不伪装成独立流入/流出字段。
## 延期字段
涨跌幅、加权评分和在榜天数明确延期。当前 ranking row 只有单一 `metric_value`、排名、百分位、样本、覆盖率、质量与 1—5 日 `rank_change`;虽然数据源局部出现涨跌幅,聚合和排名表并未持久化该字段,加权评分公式也未公开,在榜天数没有历史轨迹契约。后续讨论必须先定义三项字段的业务语义、point-in-time 输入、公式、历史深度、异常处理与迁移兼容,不能在本轮 UI 中用现有字段代替或推算。
## 兼容、验证与回滚
本轮不改 HTTP 与数据库,可回滚为原单查询表格而不影响数据。验证包括 query 双实例参数、Tab 的 URL 更新、双榜配对、两侧独立续页、一侧结束或失败时另一侧继续、表头/行列数一致、正负格式化、可访问性、完整前端质量门禁和真实浏览器桌面布局检查。
@@ -0,0 +1,5 @@
{"file": ".trellis/spec/frontend/index.md", "reason": "确认本轮属于 sector-radar feature,遵守前端分层、URL 状态与质量门禁。"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Tab、镜像表格、状态提示和滚动容器需遵守现有组件、Tailwind 与可访问性约定。"}
{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "双榜继续由 TanStack Query 管理两侧独立无限查询、缓存和取消信号。"}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "实现需补齐用户可见行为测试并通过完整前端质量命令。"}
{"file": "docs/research/onechartlab-sector-capital-radar.md", "reason": "提供原站四种指标、TOP/BOTTOM 分层、公开字段和未知评分公式的证据边界。"}
@@ -0,0 +1,33 @@
# 板块资金雷达双榜交互执行计划
## 执行清单
- [ ] 更新页面筛选区,将板块类型和指标视角改为语义化 Tab;移除榜单范围 Select,保留交易日、搜索和排名变化附属控制,并验证 URL 状态重置。
- [ ] 从共享筛选构造强榜与弱榜两条无限查询,分别展平分页结果,组合双侧加载、刷新、错误和重试状态。
- [ ] 将单侧 `RadarTable` 重构为左右镜像双榜表格,建立稳定列宽、双层 sticky 表头、中间流入/流出(排名变化时为排名升降)以及双侧空位规则。
- [ ] 为净额、比例和排名变化实现方向明确的格式化与低饱和条形表达,保留质量、覆盖率和样本等知行已有字段,不实现延期字段。
- [ ] 更新页面行为测试,覆盖 Tab、双查询参数、行配对、双侧分页、一侧提前结束、一侧加载失败重试、格式化语义以及旧分页/榜单范围控件消失。
- [ ] 运行相关 Vitest、`pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test`、`pnpm build` 和 `git diff --check`,随后在真实浏览器中检查桌面布局、滚动加载和控制台。
## 重点文件
- `zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx`
- `zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx`
- 如双侧组合需要抽取 query helper,再最小修改 `zhixing-web/src/features/sector-radar/api/sector-radar.query.ts` 及其测试;不修改后端、迁移和共享 Pagination。
## 验证命令
```bash
cd zhixing-web
pnpm exec vitest run src/features/sector-radar/api/sector-radar.query.test.ts src/features/sector-radar/pages/sector-radar-page.test.tsx
pnpm check
pnpm build
cd ..
git diff --check
```
## 回滚点与风险
双榜由两条独立 HTTP 请求组成,不保证同一数据库事务快照;两条请求使用同一交易日与查询条件,且排名发布不可变时结果一致。若后续服务允许发布过程中变更,需要再引入 publication id 固定或后端双榜原子响应,本轮不扩大契约。
镜像表格列数多,桌面窄视口不可避免横向滚动;验收重点是列宽与表头稳定、文本不换行和单一滚动容器,而不是压缩所有列到任意宽度。
@@ -0,0 +1,47 @@
# 板块资金雷达双榜交互对齐
## Goal
把知行项目的板块资金雷达从“筛选器 + 单边普通表格”进一步对齐 OneChartLab 原站的核心浏览方式,让用户在同一屏内通过 Tab 切换板块类型和排序指标,并横向对照强榜与弱榜的流入、流出表现。
用户价值是减少筛选与翻页操作,在一个连续滚动榜单中直接比较资金进入端和撤离端,同时保持现有知行独立指标及数据质量边界。
## Background
- 当前页面已经支持服务端分页上的无限滚动,但仍以 Select 选择板块类型、指标视角和榜单范围,并只展示单侧榜单。
- 用户提供的目标截图显示:搜索框后使用“概念 / 行业”Tab,右侧使用指标 Tab;主体表格同时呈现左侧强榜和右侧弱榜,中间用“流入 / 流出”两列形成视觉对照。
- 本轮不使用 `lark-design-prototype`,以用户截图、现有项目视觉体系和实际数据契约为准。
## Requirements
- 将“概念板块 / 行业板块”从 Select 改为可直接点击的 Tab,并与 URL 状态、数据查询及刷新行为保持同步。
- 将指标视角改为 Tab 交互,至少覆盖现有的波段资金率、单日资金率、单日净额和排名变化;排名变化所需的指标与对比区间仍需有紧凑的附属控制。
- 强榜与弱榜必须在同一个连续滚动列表中成对展示,不再要求用户通过“榜单范围”筛选器切换。
- 表头结构、左右方向和列对齐参照目标截图:左侧为资金进攻榜,右侧为资金撤离榜,中间明确区分流入和流出;强榜从左向中间阅读,弱榜从中间向右阅读。
- 双榜外围只使用知行已有且可验证的排名百分位、样本、资金覆盖率和质量字段;左右按镜像顺序排列,并以固定列宽或等价布局保证表头和行数据对齐。
- 流入和流出数值使用方向明确、可比较的正负与色彩表达,并保持文本语义和可访问性,不只依赖颜色区分。
- 波段资金率、单日资金率和单日净额视角的中间列使用“流入 / 流出”;排名变化视角使用与排名升降一致的表头和数值语义,不把排名变化伪装成资金流量。
- 保留表头吸附、连续滚动加载、首次加载、后台刷新、部分发布、加载更多失败重试、空数据和致命错误等现有行为。
- 不新增与目标交互无关的大块说明卡、指标墙或辅助面板。
## Acceptance Criteria
- [ ] 用户可通过“概念 / 行业”Tab 切换板块类型,激活状态清晰且 URL 查询状态同步更新。
- [ ] 用户可通过指标 Tab 切换波段资金率、单日资金率、单日净额和排名变化,当前指标与中间流入/流出表头一致。
- [ ] 每个可见数据行同时展示一个强榜板块和一个弱榜板块,左右榜单分别保持各自排名顺序。
- [ ] 表头和数据列在桌面视口下稳定对齐,左右镜像列均展示排名/板块、排名百分位、样本、资金覆盖率和质量,中间流入/流出区域与两侧之间有明确边界,横向滚动时结构不塌陷。
- [ ] 中间指标值按当前视角正确格式化:净额以亿元显示,比例以百分比显示,排名变化以升降名次数显示;强弱方向有显式正负号或文字语义。
- [ ] 滚动接近底部时强榜与弱榜继续加载下一批,任一侧提前结束时另一侧仍可继续展示,直到两侧都加载完成。
- [ ] 加载更多失败时已加载的两侧数据不丢失,并提供可操作的重试入口。
- [ ] 不再显示“榜单范围”Select、底部分页器或大块纯描述卡。
- [ ] 相关前后端契约测试、前端行为测试、格式检查、lint、类型检查、完整测试和构建通过,并完成真实浏览器交互与布局检查。
## Out of Scope
- 复刻原站未在知行数据契约中存在的股票勾选、加权评分或自选功能。
- 更改板块指标公式、排序定义、数据构建任务或历史数据口径。
- 引入与本轮交互无关的新页面模块。
## Deferred Items
- 原站外围的“涨跌幅、加权评分、在榜天数”本轮不实现。当前知行排名持久化与 HTTP row 均没有这三个字段,其中加权评分的公开证据不足以确认公式;后续需分别讨论指标语义、输入时点、计算公式、持久化和历史兼容后再立项。
@@ -0,0 +1,26 @@
{
"id": "sector-radar-dual-ranking",
"name": "sector-radar-dual-ranking",
"title": "板块资金雷达双榜交互对齐",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-09-01",
"completedAt": "2026-09-01",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -0,0 +1,10 @@
{"file":".trellis/spec/backend/selection.md","reason":"核验 qfq、目标日截断、KDJ 和 selection 语义"}
{"file":".trellis/spec/backend/http-api-contracts.md","reason":"核验新增 HTTP 响应与错误契约"}
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"核验 Ruff、Pyright、pytest 和后端代码边界"}
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"核验布局、状态、可访问性与组件边界"}
{"file":".trellis/spec/frontend/hook-guidelines.md","reason":"核验 React Query、AbortSignal 和同源请求"}
{"file":".trellis/spec/frontend/type-safety.md","reason":"核验前端 API 类型与 strict TypeScript"}
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"核验 Vitest 行为覆盖和完整前端门禁"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"核验跨层字段、状态和测试没有遗漏"}
{"file":".trellis/tasks/09-01-stock-chart-display/research/echarts.md","reason":"核验 ECharts 三轴缩放、按需模块和生命周期"}
{"file":".trellis/tasks/09-01-stock-chart-display/research/repository-evidence.md","reason":"核验图片映射、below-threshold 和详情接口缺口均已闭环"}
@@ -0,0 +1,73 @@
# 选股模块图表展示技术设计
## 1. 边界与总体方案
本任务继续使用现有 `selection` bounded context,不新建业务上下文。分页结果接口继续只承载列表与目标日摘要;新增独立只读详情接口按 `ts_code + target_trade_date` 返回最多 250 个升序 qfq 日线点及对应 KDJ,避免每页股票都携带大数组。
前端 selection feature 用独立 React Query 缓存详情图表。从现有 `md` 双栏断点起,工作台统一使用左 `1fr`、右 `3fr`,删除更大断点的旧比例覆盖;移动端沿用现有记录卡和展开交互,不在本次增加移动端复合图表。右侧详情依次展示股票摘要、复合图表、最佳案例 JPG、评分分解和信号指标。
## 2. 后端数据流与契约
新增 application 用例 `GetSelectionChart`,依赖已有 `MarketDataReader`。用例接收 `ts_code`、`target_trade_date` 和固定上限 250,读取目标日前完整 qfq 历史,在完整历史上复用 `compute_kdj` 计算递归 K/D/J,再把 OHLCV 与指标对齐并截取末尾最多 250 条。这样返回量受控,同时 KDJ 初值与策略计算保持一致。
新增接口:
```text
GET /api/v1/selection/stocks/{ts_code}/chart?target_trade_date=YYYY-MM-DD
```
成功响应:
```json
{
"ts_code": "603259.SH",
"name": "药明康德",
"target_trade_date": "2026-08-31",
"source_adj": "qfq",
"points": [
{
"trade_date": "2026-08-31",
"open": 1.0,
"high": 1.0,
"low": 1.0,
"close": 1.0,
"volume": 1.0,
"k": 50.0,
"d": 50.0,
"j": 50.0
}
]
}
```
OHLCV 或 KDJ 不可用的点用 `null` 保留日期对齐,不制造数值。前端遇到 OHLC 不完整的日期时保留 category 但不绘制该蜡烛,volume/K/D/J 也保持空点,KDJ 禁止跨空点连线。没有任何目标日前行情时返回 `404 chart_data_not_found`;数据库读取失败返回 `503 selection_storage_unavailable`。接口继续使用 FastAPI Pydantic response model 和同源 `/api/v1` 路径。
## 3. 最佳案例评分与图片
不改变十案例评分算法、阈值、排序或持久化。调整 HTTP 映射:只要评分状态是 `matched` 或 `below_threshold` 且内部结果完整,都返回实际 `value`、`threshold`、`version`、`case` 和 `breakdown`;状态仍保留原值,因此 UI 可以区分“达到阈值”和“最接近但低于阈值”。评分失败返回 `status="failed"` 且无 case;评分未执行沿用现有 `score: null` 契约,前端明确显示“本次运行未执行图形评分”,两者均不展示案例图。契约测试锁定 `null` 的既有语义,避免把字段遗漏误当成功结果。
把用户提供的十张 JPG 复制到 `zhixing-web/public/patterns/b1-cases/` 并保留原文件名。selection feature 维护穷举的 `case.id -> public URL` 映射;案例 ID 是稳定业务键,中文名只用于展示。若响应出现未知 case id,页面显示“案例图片暂不可用”,不猜测文件名。
## 4. 前端数据与组件
新增 `SelectionChart`、`SelectionChartPoint` 类型,API 函数通过 `requestJson` 调用详情接口并传递 `AbortSignal`,query key 包含股票代码和目标交易日。仅当存在选中股票时启用查询;切换股票时 React Query 取消或隔离旧请求,详情分别呈现加载、失败、空数据和成功状态。
新增 selection feature 内的复合图表组件,直接使用 `echarts/core`,按需注册 candlestick、bar、line、grid、tooltip、legend、dataZoom 和 Canvas renderer。三组 category x 轴共享相同交易日数组,candlestick、volume、K/D/J series 分别绑定三个 grid;inside 与 slider dataZoom 同时控制三组 x 轴。后端最多返回 250 点,前端通过 category `startValue`/`endValue` 精确选择末尾 120 点,不足 120 点时展示全部,并允许缩放查看返回的全部数据。
ECharts 初始化只发生在图表 DOM 挂载后;数据变化调用 `setOption`,容器变化通过 `ResizeObserver` 调用 `resize`,卸载时 `dispose`。option 构造与 API 数据转换保持为纯函数,避免在 jsdom 中依赖 Canvas 像素渲染。
最佳案例组件从 `score.case.id` 查找静态 JPG,使用语义标题、描述性 `alt`、固定 2:1 比例和 `loading="lazy"`。图像加载失败时保留案例名称和可读错误状态。
## 5. 兼容性与取舍
- 不把历史序列扩展到 `/selection/results`,避免分页和轮询响应膨胀。
- 不新增图片数据库、上传接口或动态生成逻辑;案例库固定,因此静态资源映射是最小充分方案。
- 不引入 React ECharts wrapper,减少 React 19 兼容面;只增加 `echarts` 一个运行时依赖。
- 移动端暂不加载大图表,维持现有详情展开行为;桌面端按需求提供完整详情。
- 不新增迁移。最佳案例字段已经持久化,图表读取复用现有 market data。
## 6. 风险、回滚与验证重点
主要风险是 KDJ 窗口被错误截断、股票切换显示上一只股票的数据、ECharts 容器未 resize/dispose、缺失行情点错误连线、未知案例 ID 产生错误图片,以及低于阈值的最佳案例仍被 HTTP 丢弃。测试分别锁定完整历史计算后截断、query key、加载/错误/空状态、三轴同步 option、缺失点处理、静态映射完整性和 below-threshold 契约。案例图片行为测试必须断言详情中恰有一张 `<img>`,其 `src` 由返回的 `case.id` 映射,案例名称和评分与同一响应一致;低于阈值也执行同一断言。
回滚可以按层撤销:删除新增详情接口与用例、恢复评分 HTTP 映射、移除 ECharts 组件/依赖和静态 JPG,再恢复原 grid class;没有数据库迁移或不可逆数据写入。
@@ -0,0 +1,14 @@
{"file":".trellis/spec/backend/index.md","reason":"后端 selection bounded context、HTTP 契约与质量入口"}
{"file":".trellis/spec/backend/selection.md","reason":"qfq 历史读取、目标日边界、KDJ 与选股结果契约"}
{"file":".trellis/spec/backend/http-api-contracts.md","reason":"新增详情接口的 Pydantic、路由和同源 API 约束"}
{"file":".trellis/spec/backend/error-handling.md","reason":"图表空数据与存储失败的错误映射"}
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Python 类型、文档字符串与测试门禁"}
{"file":".trellis/spec/frontend/index.md","reason":"React feature 分层和质量入口"}
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"详情组件、Tailwind、页面状态与可访问性"}
{"file":".trellis/spec/frontend/hook-guidelines.md","reason":"requestJson、AbortSignal、React Query key 与状态归属"}
{"file":".trellis/spec/frontend/type-safety.md","reason":"跨层响应类型与 strict TypeScript 约束"}
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"Vitest 行为测试和前端质量门禁"}
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"复用 reader、requestJson、query key 和 UI primitive"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"后端响应到前端 query/页面的完整契约链"}
{"file":".trellis/tasks/09-01-stock-chart-display/research/echarts.md","reason":"ECharts 复合金融图、dataZoom 和生命周期方案"}
{"file":".trellis/tasks/09-01-stock-chart-display/research/repository-evidence.md","reason":"现有数据能力、最佳案例图片和契约缺口"}
@@ -0,0 +1,36 @@
# 选股模块图表展示实施计划
## 1. 后端图表详情用例与契约
- [x] 在 selection application 层新增 `GetSelectionChart` 及具名返回模型,复用 `MarketDataReader.load_history` 和 `compute_kdj`,在完整目标日前历史上计算后截取末尾最多 250 点。
- [x] 为用例补充单元测试,覆盖升序输出、目标日截断、超过 250 点、KDJ 对齐、空历史和可空值。
- [x] 在 selection HTTP 层新增 `GET /stocks/{ts_code}/chart` 的 Pydantic response model、依赖组合和错误映射。
- [x] 扩展 HTTP 测试,覆盖成功、404 空行情、503 存储错误、日期参数校验和完整响应字段。
## 2. 最佳案例契约与静态资源
- [x] 调整 `_pattern_score_response`,让 `below_threshold` 与 `matched` 一样返回真实 value/case/breakdown,同时保留状态与阈值语义;补充后端 HTTP 测试。
- [x] 将用户提供目录中的十张 JPG 复制到 `zhixing-web/public/patterns/b1-cases/`,保留文件名,并核对十个 case id 均有且仅有一个资源。
- [x] 在 selection feature 中新增穷举案例图片映射和展示组件;测试断言匹配和低于阈值时详情恰有一张 `<img>`,其 `src`、案例名称和分数来自同一个 `case.id`,并覆盖未知案例、图片加载失败、`score: null` 未评分和评分失败状态。
## 3. 前端详情数据链路
- [x] 在 `selection.types.ts` 定义图表响应类型,在 `selection.api.ts` 增加同源详情请求,在 `selection.query.ts` 增加包含股票代码和目标日的 query key/hook,并传递 `AbortSignal`。
- [x] 为 API URL 编码、查询参数和 query 启用条件补充窄测试。
- [x] 在工作台/详情组件中接入图表 query,保证选择切换不会显示旧股票数据,并呈现加载、错误、空数据和成功状态。
## 4. ECharts 复合图表与布局
- [x] 用 pnpm 添加 `echarts` 运行时依赖并更新 lockfile,不引入 React wrapper。
- [x] 新增 option 纯构造函数和图表组件;按需注册 candlestick、bar、line、grid、tooltip、legend、dataZoom、Canvas renderer,绑定三组 grid/axis/series。
- [x] 实现最多 250 点、用 category `startValue`/`endValue` 精确显示最后 120 点(不足时全量)的 inside + slider 同步缩放,以及 ResizeObserver resize 和卸载 dispose。
- [x] 用纯函数测试锁定 `[open, close, low, high]` 顺序、volume/KDJ 对齐、缺失 OHLC 不绘制蜡烛、空 KDJ 不跨点连线、三个 xAxis 索引和精确 120 点初始范围;页面行为测试断言可见状态与可访问名称。
- [x] 从 `md` 双栏断点起将 grid 统一改为左 `1fr`、右 `3fr`,移除 `lg` 的旧比例覆盖并保持移动端现有记录卡行为;在图表正下方展示最佳案例 JPG,再展示评分分解和关键指标。
## 5. 验证与回滚点
- [x] 运行后端窄测试:`uv run --directory zhixing-server pytest tests/unit/selection tests/test_selection_http.py -q`。
- [x] 运行前端窄测试:`pnpm --dir zhixing-web test -- selection`,并运行 `pnpm --dir zhixing-web typecheck`。
- [x] 运行完整质量门禁:`./dev.sh check`、`./dev.sh test`,再运行 `pnpm --dir zhixing-web build` 验证产物。
- [x] 使用本地页面核对 1:3 布局、药明康德/方正科技案例图、股票切换、缩放同步和详情滚动;当前数据库未用于验收,已使用确定性 Mock API 与真实浏览器核对。
- [x] 回滚前先确认无数据库迁移;按“HTTP/用例、前端 query/组件、静态资产与依赖、grid class”顺序撤销即可恢复旧行为。
@@ -0,0 +1,37 @@
# 选股模块图表展示迭代
## Goal
提升选股模块详情页的信息密度和技术分析能力,使用户可以在查看选股结果时直接核对股票的日线走势、成交量、KDJ 指标,并查看该股票最高相似度案例对应的参考图。
## Background
当前需求由用户明确提出,包含列表与详情页布局调整、详情页技术图表扩展,以及最佳匹配案例图片展示。当前桌面工作台实际是左宽右窄的两列布局,详情响应只有目标交易日标量,不包含历史序列。PostgreSQL 已保存按交易日升序的 qfq 日线 OHLCV,selection 领域已有 KDJ 计算,因此完整技术图表的数据基础已经具备,但需要新增只读详情契约。
仓库另有固定的十个图形匹配案例,评分器会把选中股票逐一与十个案例比较并保留最高分案例。对应的十张 `2800x1400` JPG 位于 `/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/docs/references/patterns/b1-cases`,文件中的 `case_001`、`case_002`、`case_003`、`case_004`、`case_006` 至 `case_011` 与当前代码案例定义一一对应。用户已明确:每只股票只展示最高相似度案例对应的一张 JPG;例如药明康德最高匹配方正科技时,展示方正科技 JPG。
## Requirements
- 选股模块从现有 `md` 桌面双栏断点起,左侧列表与右侧详情区域的宽度比例调整为 `1:3`,更小视口保持现有移动布局。
- 右侧详情区域增加一张完整技术图表,至少包含日线 K 线、成交量和 KDJ 三部分。
- 技术图表下方展示当前股票最高相似度案例对应的一张 JPG,并同时保留可读的案例名称和评分信息。
- 每个已完成评分的股票都应返回最高相似度分数和最佳案例,即使该分数低于业务匹配阈值;评分失败或未执行时不伪造案例图片。
- 可以引入与现有前端技术栈兼容的第三方图表库,但应控制新增依赖和改动范围。
- 图表行情必须使用目标交易日及之前的 qfq 日线,不能读取目标日之后的数据。
- 详情接口最多返回目标交易日前 250 个交易日;图表默认聚焦最近 120 个交易日,并允许用户缩放查看接口返回的全部数据。
## Acceptance Criteria
- [x] 从 `md` 双栏断点起,左侧列表和右侧详情区域呈现 `1:3` 的可用宽度比例,且不存在更大断点覆盖回旧比例。
- [x] 选中一只股票后,右侧能够展示与该股票对应的日线 K 线、成交量和 KDJ 指标,三部分时间轴或数据窗口保持一致。
- [x] 图表数据不超过目标交易日前 250 个交易日,首屏聚焦最近 120 个交易日;缩放操作同步作用于日线、成交量和 KDJ 三个区域。
- [x] 图表下方只展示当前股票最高相似度案例对应的一张 JPG;图片、案例名称和评分属于同一个最佳案例,药明康德匹配方正科技的场景会展示方正科技 JPG。
- [x] 低于阈值但评分成功的股票仍展示实际最高分、最佳案例及对应 JPG;未执行或失败时展示明确状态且不显示错误图片。
- [x] 页面在加载、空数据和接口失败时不会崩溃,并给出符合现有页面风格的状态反馈。
- [x] 相关前后端静态检查和自动化测试通过,且没有破坏现有选股列表与详情交互。
## Out of Scope
- 暂不改变选股算法、图形相似度评分规则或股票筛选逻辑。
- 暂不增加用户自定义技术指标、画线工具或交易下单能力。
- 暂不动态生成案例图片,也不展示最佳案例之外的其他九张 JPG。
@@ -0,0 +1,36 @@
# Apache ECharts 复合行情图研究
## 结论
本任务使用 `echarts` 本体并通过 `echarts/core` 按需注册,不引入 React 包装库。一个 ECharts 实例通过三个 `grid`、三组 category `xAxis`/value `yAxis`,以及分别绑定索引的 candlestick、bar、line series 组合日线、成交量和 KDJ。`inside` 与 `slider` 两个 `dataZoom` 同时控制三个 x 轴,使三个区域保持同一数据窗口。
前端组件在 DOM 挂载后调用 `echarts.init` 和 `setOption`,容器尺寸变化时调用 `resize`,卸载时调用 `dispose`。配置与数据转换应提取为纯函数,便于在 Vitest/jsdom 中验证 series、axis、数据顺序和初始缩放,而不依赖 Canvas 像素结果。
## 所需模块
- charts:`CandlestickChart`、`BarChart`、`LineChart`
- components:`GridComponent`、`TooltipComponent`、`LegendComponent`、`DataZoomComponent`
- renderer:`CanvasRenderer`
- 类型:使用 ECharts 提供的 `ComposeOption` 组合已注册组件的 option 类型
## 数据约定
- K 线数据顺序为 `[open, close, low, high]`。
- 日线、成交量、K、D、J 共用升序交易日 category 数据。
- 后端最多返回 250 点;前端用 category 的 `startValue` 和 `endValue` 精确选择最后 120 个点,不足 120 点时展示全部。
- 三组 series 通过 `xAxisIndex`/`yAxisIndex` 绑定各自 grid;两个 dataZoom 的 `xAxisIndex` 同时包含 `[0, 1, 2]`。
- OHLC 不完整的日期使用 ECharts 缺失数据占位,不绘制蜡烛;volume/K/D/J 的空值保持空点,KDJ series 设置 `connectNulls: false`,禁止跨缺失日期连线。
## 资料来源
2026-09-01 通过 Context7 查询官方 Apache ECharts 文档 `/apache/echarts-doc`。相关官方资料包括:
- `en/tutorial/data-zoom.md`:slider 与 inside dataZoom 组合,以及用 axis index 控制指定轴。
- `en/option/component/data-zoom.md`:一个 dataZoom 同时控制多个轴。
- `slides/arch-brief/asset/ec-demo/list-sample1.html`:candlestick、volume、多 grid、多 xAxis/yAxis 和 dataZoom 的金融图表示例。
- `en/tutorial/basic-concepts-overview.md`:`echarts.init`、option、多个 axis/series 与 `setOption` 的基本实例生命周期。
- `zh/tutorial/whats-new-in-echarts-v5.md`:按需引入时通过 `ComposeOption` 获得严格 TypeScript 类型。
## 本仓库约束
前端为 React 19、TypeScript strict、Vite 8;服务器状态应通过 TanStack Query 获取,API 走同源 `/api/v1`。图表属于 `selection` feature,不提升为 shared primitive。
@@ -0,0 +1,22 @@
# 选股详情图表仓库证据
## 当前页面与契约
- `zhixing-web/src/features/selection/components/selection-results-workbench.tsx` 当前桌面布局左宽右窄,目标应改为左 `1fr`、右 `3fr`。
- `zhixing-web/src/features/selection/components/signal-detail-panel.tsx` 当前只展示目标日价格、信号、评分和指标,没有历史图表或案例图片。
- `zhixing-web/src/features/selection/api/selection.types.ts` 与后端 `SelectionStockResponse` 只有目标日标量,没有历史 OHLCV/KDJ 序列。
- 现有前端依赖没有图表库。
## 行情与指标能力
- `PostgresMarketDataReader.load_history` 已按 `source_adj = 'qfq'`、`trade_date <= target_trade_date` 升序读取 OHLCV。
- `StockHistory`/`SelectionBar` 已描述存储无关的升序日线。
- `compute_kdj` 已实现与策略一致的 K、D、J 计算。本任务应在完整目标日前历史上计算后再截取末尾最多 250 点,避免只用返回窗口重新初始化 KDJ 状态。
- 现有 selection HTTP 只有 runs/results;复合图表需要独立的股票详情只读接口,避免把大序列塞进分页列表响应。
## 最佳案例图片
- 当前 `ZHIXING_B1_PATTERN_CASES` 是 `case_001`、`case_002`、`case_003`、`case_004`、`case_006` 至 `case_011` 共十个案例。
- 用户提供目录 `/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/docs/references/patterns/b1-cases` 含对应的十张 `2800x1400` JPG;文件名中的 case id、股票代码、名称与日期与当前代码定义一一对应。
- 评分结果已持久化最高案例。HTTP 对 `matched` 返回 `case.id/name/breakout_date`,但 `below_threshold` 当前丢弃实际 value/case/breakdown;本任务需要让所有成功评分状态保留这些真实结果。
- 图片作为本前端固定静态资产按 `case.id` 映射,不需要数据库表、图片上传或后端文件服务。
@@ -0,0 +1,26 @@
{
"id": "stock-chart-display",
"name": "stock-chart-display",
"title": "选股模块图表展示迭代",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-09-01",
"completedAt": "2026-09-01",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -0,0 +1,5 @@
{"file":".trellis/spec/backend/selection.md","reason":"复核策略语义、目标日边界、批量读取和持久化契约"}
{"file":".trellis/spec/backend/market-data-sync.md","reason":"复核数据来源、qfq 口径和目标日 daily_basic 完整性"}
{"file":".trellis/spec/backend/tushare-listed-stock-universe.md","reason":"复核沪深非 ST 股票范围未扩大"}
{"file":".trellis/spec/backend/error-handling.md","reason":"复核日志可诊断且不泄露敏感配置"}
{"file":".trellis/spec/frontend/type-safety.md","reason":"复核前后端策略标识和展示类型一致"}
@@ -0,0 +1,6 @@
{"file":".trellis/spec/backend/selection.md","reason":"历史选股策略、qfq 读取、运行持久化与策略扩展约束"}
{"file":".trellis/spec/backend/market-data-sync.md","reason":"目标日行情与 daily_basic 数据可用性契约"}
{"file":".trellis/spec/backend/tushare-listed-stock-universe.md","reason":"当前上市沪深非 ST 股票范围约束"}
{"file":".trellis/spec/backend/error-handling.md","reason":"可复制诊断日志与错误边界约束"}
{"file":".trellis/spec/backend/directory-structure.md","reason":"selection bounded context 分层与导入方向"}
{"file":".trellis/spec/frontend/type-safety.md","reason":"新增策略标识的前端联合类型与接口契约"}
@@ -0,0 +1,40 @@
# 新增金砖共振选股策略
## Goal
在现有历史选股能力中增加“金砖共振”独立策略,按收盘后、Tushare qfq 日线、当前上市沪深非 ST A 股口径运行,让用户能够在线上完整数据上执行并通过日志定位失败股票和数据问题。
## Background
- 原始公式来自 `../zgnb-project/docs/references/formulas/金砖共振选gu(通达信).txt`。
- 公式使用日线 OHLCV、股票代码、通达信 B1 七类子信号、KDJ、3 日 RSI、趋势线、砖型图、动能和目标日换手率。
- 项目已同步六年 qfq 日线和 `daily_basic.turnover_rate`,并已实现 B1 七类子信号及通达信风格指标。
- 通达信 `DYNAINFO(37) >= 0.0099` 对应百分数口径换手率至少 `0.99`;Tushare `turnover_rate` 直接使用百分数口径。
## Requirements
1. 新增独立、可执行、可持久化和可查询的金砖共振策略,不改变现有 `zhixing_b1` 语义和结果。
2. 金砖策略复用现有 B1 指标与七类子信号计算,并补充原公式中的砖型图、黄柱、X 动能、强红、趋势、上影线、换手率和两类共振条件。
3. 策略只读取目标交易日及之前的数据,价格口径固定为 qfq,股票范围继续使用当前上市沪深非 ST A 股。
4. 批量选股读取必须为每只股票提供目标交易日的 `turnover_rate`;缺失换手率时该股票不得产生金砖信号,并记录可定位原因。
5. 金砖策略至少要求 200 根升序日线;目标日行情缺失、历史不足、换手率缺失、公式结果无效或单股计算异常时,必须形成明确状态或错误原因。
6. 在策略准备、批量读取、批量计算、结果持久化和失败收敛位置增加包含策略名、目标交易日、批次、股票代码、历史行数、换手率状态、结果状态及异常类型的结构化日志。日志不得包含 Token、数据库连接串或完整异常敏感上下文。
7. 前后端沿用现有策略选择、执行、进度和结果查看流程,并向用户展示“金砖共振”策略名称。
8. 本次不执行测试、lint、type-check、构建或线上数据请求;由用户部署到线上后提供日志进行后续排查。
## Acceptance Criteria
- [ ] 用户能在现有选股入口选择并执行“金砖共振”,运行记录和查询接口使用稳定的独立策略标识。
- [ ] 符合原公式最终 `买入条件` 的股票被选中,不符合、缺少目标日换手率或历史少于 200 根的股票不会被误选。
- [ ] 全市场批量执行能够读取目标日 `turnover_rate`,并将通达信阈值正确换算为 Tushare 的 `0.99` 百分数口径。
- [ ] 现有 `zhixing_b1` 仍走原有公式和结果契约,不因新增换手率读取或策略路由发生语义变化。
- [ ] 线上运行发生准备失败、读取失败、批次失败或单股失败时,日志能够通过策略名、目标交易日、批次与股票代码关联完整路径,且不泄露敏感配置。
- [ ] 本地未运行任何测试或验证命令,最终交付明确列出未验证风险和建议复制的日志范围。
## Out of Scope
- 盘中实时行情和实时预警。
- 北交所、ST、退市股票或无幸存者偏差历史股票池。
- 未复权、后复权或多复权口径切换。
- 新增 Tushare 数据接口、财务数据、资金流、板块或涨跌停条件。
- 调整原始公式参数或优化策略收益表现。
@@ -0,0 +1,26 @@
{
"id": "add-gold-brick-strategy",
"name": "add-gold-brick-strategy",
"title": "新增金砖共振选股策略",
"description": "按收盘后、qfq、沪深非ST口径新增金砖共振策略,复用B1指标并增加可复制诊断日志。",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P1",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-09-04",
"completedAt": "2026-09-25",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,44 @@
# 选股页面迭代:布局重构与细分行业筛选
## 需求
1. 选股页面从左右布局改为「上搜索栏 + 左列表 / 右详情」三段式布局。
2. 搜索模块增加细分行业筛选:下拉选项为当次策略结果的细分行业聚合,选项旁展示数量,按数量倒序。
3. 选股模块不展示「板块」(概念板块):列表筛选与详情页均去掉;详情页「行业」口径改称「细分行业」。(2026-09-05 修订,原口径为概念板块)
## 关键决策
- 板块口径:细分行业(sector_type=industry,即东财行业快照),selection 上下文固定为 industry——HTTP `/sectors` 不再接受 sector_type 参数,端口/适配器方法签名不再携带 sector_type,适配器固定传 `SectorType.INDUSTRY`;「细分行业」仍是 sector 词汇的一种,内部参数名保持 `sector` 不变。
- 详情面板 membership 消费面收窄到 industries:前端 `sector-radar.api.ts` / 类型不再解析 concepts 字段(后端 `/sector-radar/stocks/{ts_code}/membership` 契约仍返回 concepts,供板块雷达等其余消费方使用)。
- 单选下拉;排序按 stock_count 倒序(后端保证),名称升序 tie-break。
- 行业数据不在 selection 表中,按 ADR 0001 通过端口委托 sector_radar 读服务(不跨上下文 join SQL)。
## 实现
后端(zhixing-server):
- sector_radar:`domain/persistence.py` 新增 `SectorCountEntry` + 2 个协议方法;`infrastructure/postgres.py` / `infrastructure/memory.py` 实现 `load_sector_counts` / `load_sector_member_codes`;`application/read.py` 新增 `sector_counts` / `sector_member_codes`(先解析 last-good publication)。
- selection:`domain/runs.py` 新增 `SelectionSectorReader` 端口、`SelectionSectorCount` / `SelectionSectorMembership` / `SelectionRunIdentity`,`SelectionResultQuery.sector`;`application/run.py` 新增 `list_sector_counts`(固定 industry),`get_run`/`get_latest` 经 identity → 成员代码 → `sector_stock_codes` 过滤;`infrastructure/postgres_runs.py` `_stock_filter` 支持 `ts_code = ANY(...)` / FALSE;`infrastructure/sector_membership.py` 桥接适配器;`presentation/http.py` 新增 `GET /sectors`,results/runs 增加 `sector` 参数。
前端(zhixing-web):
- `selection.types.ts` / `selection.api.ts` / `selection.query.ts`:`SelectionSectors`(sector_type 收窄为 "industry")、`getSelectionResultSectors`、`useSelectionResultSectors`(不传 sector_type),query key 加 sector,结果失效同时失效 sectors。
- `route-tree.tsx`:selection 路由 search 增加 `sector`。
- `selection-results-page.tsx`:结果查询带 sector,页面调用 sectors hook 并下传 workbench;切策略重置 sector。
- `selection-results-workbench.tsx`:布局重构为上搜索栏 + 下方 320px 列表/详情两栏;细分行业下拉(全部细分行业 + 聚合选项带数量徽标);聚合加载后自动清掉失效 sector。
- `signal-detail-panel.tsx`:展示「细分行业:…」(title 提示成分快照日期),不再展示概念板块 chips。
## 测试
- 后端:`test_read.py` +8、`test_sector_filter.py` 新建 9 个(口径 industry)、`test_selection_http.py` +3(不再有 sector_type 转发/422 用例)。
- 前端:页面测试新增「filters by sub-industry and shows per-industry counts」;详情面板测试改为细分行业断言并删除概念 chips 用例。
## 验收
- [x] 页面布局为上搜索栏 + 左列表右详情(移动端纵向堆叠 搜索→列表→详情)
- [x] 细分行业下拉选项为当次策略结果的细分行业聚合,选项旁展示数量,按数量倒序
- [x] 选择细分行业后结果列表服务端过滤,`筛选结果 N 只` 反映叠加计数
- [x] 详情面板只展示细分行业,不展示概念板块
- [x] 后端 ruff/pyright/pytest 与前端 lint/typecheck/test/build 门禁通过(遗留项均为基线原有)
## 遗留(均为基线原有,非本次引入)
- 后端 pyright 16 个错误(chart.py/gold_brick.py pandas 相关、test_run.py 旧 fake 类型);前端 4 个执行状态抽屉测试失败;前端 format:check 有基线未格式化文件(.playwright-cli、sector-radar 部分文件;signal-detail-panel.tsx 已随本次格式化移除)。
@@ -0,0 +1,26 @@
{
"id": "selection-layout-sector-filter",
"name": "selection-layout-sector-filter",
"title": "选股页面迭代:布局重构与板块筛选",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-09-05",
"completedAt": "2026-09-25",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -0,0 +1,6 @@
{"file": ".trellis/spec/backend/market-data-sync.md", "reason": "Tushare 落库及同步契约"}
{"file": ".trellis/spec/backend/tushare-listed-stock-universe.md", "reason": "当前上市母集约束"}
{"file": ".trellis/spec/backend/http-api-contracts.md", "reason": "详情与排名API"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "弹窗与榜单组件"}
{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "按日期的查询状态"}
{"file": ".trellis/tasks/09-06-capital-radar-daily-detail/design.md", "reason": "本次数据及交互设计"}
@@ -0,0 +1,21 @@
# 资金雷达单日详情设计
## 数据边界
继续使用 sector_radar bounded context。Tushare 原始响应先进入现有 snapshot 持久化链路,再扩展事实与查询投影;浏览器只访问同源 API,不直接请求 Tushare 或参考站点。扩展板块日事实保存 pct_change 与领涨标识,股票事实保存 daily.pct_chg 和独立 active_buy_net_amount_yuan。新字段可空,旧发布保持可读。moneyflow 作为详情可选来源,不将其失败当成主力净额为零;与原始 snapshot、发布日期和输入版本关联。
## 查询契约
rankings 在原字段上增加 pct_change、daily_net_amount_yuan、daily_ratio 和对应侧的 30 日在榜次数;领域 percentile/coverage 保留,仅移除两种视角的展示列。避免逐板块查询历史,按日期和类型批量加载历史排名,按每日池的 TOP/BOTTOM 阈值计数。
新增板块 history 与 detail 读取接口,键包含 sector_type、sector_code、trade_date;返回解析后的 publication 标识和实际日期。history 含日期、三指标排名、当日池规模/百分位和缺失状态,严格截至目标日,最多 30 个交易日;同日选最新成功发布,历史版本不兼容时不混用。详情包含当日指标摘要、成员三种强弱数据、成员列表和相似板块。共享历史读取实现,避免重复计算。
## 指标
沿用现有主力净额/成交额流入率及波段策略。板块涨跌幅优先使用 dc_index 源字段;成员 daily.pct_chg 独立于资金流是否缺失。主买净额来自 moneyflow.net_mf_amount,须核对官方单位后转为元,与主力 net_amount 分开。重合度为交集数量/并集数量,使用同日可确认的当前上市母集成员;未知成员不参与,空并集不计算。同分稳定按代码排列。
## 前端
将两种单日榜列配置与其他视角隔离;拆分历史格子和详情弹窗组件,沿用项目 UI primitive 和 ECharts。详情请求按需触发,query key 包含类型/代码/目标日,切换日期时不显示前一天的详情。格子按日期从近到远,曲线按时间从早到晚且排名 1 在上方,缺失处断线。导出及复制仅使用 API 返回成员,生成本地 CSV/文本,CSV 对不可信字段进行转义与公式注入防护。
## 兼容与回滚
采用增量 nullable 数据迁移,不删除原字段。旧发布缺详情时显式显示暂无数据;可通过重新构建目标日补齐,不在读取路径触发网络取数。新增来源失败保留最近有效发布并体现详情缺失。真实自测使用隔离的本地数据库或受控测试批次,不覆盖完整发布。若迁移/验证失败,停止新构建并保留原始快照,回退代码前确认旧代码可读取新增 nullable 列。
## 实施前核验
确认 moneyflow 官方单位、当前采集权限和现有 source 失败策略;确定具体 migration 编号与可复用 UI primitive。原站私有算法不构成本次完成条件。真实全池历史不足仅报告覆盖,不人为补齐。
@@ -0,0 +1,6 @@
{"file": ".trellis/spec/backend/market-data-sync.md", "reason": "Tushare 落库及同步契约"}
{"file": ".trellis/spec/backend/tushare-listed-stock-universe.md", "reason": "当前上市母集约束"}
{"file": ".trellis/spec/backend/http-api-contracts.md", "reason": "详情与排名API"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "弹窗与榜单组件"}
{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "按日期的查询状态"}
{"file": ".trellis/tasks/09-06-capital-radar-daily-detail/design.md", "reason": "本次数据及交互设计"}
@@ -0,0 +1,17 @@
# 实施与验证计划
- [x] 用户审核当前规划后,加载 Phase 1.3/1.4,校验上下文清单并进入 in_progress。
- [x] 读取受影响层规范和确切修改代码,确认 Tushare moneyflow 字段单位、权限及本地隔离数据库方案。
- [x] 扩展 source、事实和 PostgreSQL migration,保存板块涨跌幅与成分行情/主买净额,保持旧发布可读;更新 memory repository。
- [x] 增加批量历史/详情读取和 HTTP 响应,补充单日榜附加字段、在榜次数、三指标历史、前后5名、成员及重合度。
- [x] 调整前端 API/types/query,完成两种单日列、名称入口、在榜展开、详情弹窗与复制/导出。
- [x] 后端测试重点:单位转换、来源缺失不归零、同日发布去重、30交易日边界、无未来数据、每天排名池变化、相似度与历史成员;运行 `cd zhixing-server && uv run pytest tests/unit/sector_radar tests/test_sector_radar_http.py`,有数据库时补集成测试 `tests/integration/test_sector_radar_repository.py`。
- [x] 前端测试重点:视角切换列名、左右板块指标、展开收起、日期切换、详情指标切换、少量样本、关闭/焦点和导出内容;运行 `cd zhixing-web && pnpm test src/features/sector-radar`。
- [x] 使用一个真实板块完成 Tushare 采集、落库、API核对,记录请求日期、条数、缺失及核对值,不记录密钥;不得将单板块测试发布为全市场榜单。
- [x] 后端运行 `uv run ruff format --check .`、`uv run ruff check .`、`uv run pyright`;前端运行 `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm build`。按变更范围扩大回归,不重复无关检查。
- [x] 浏览器验证本地两个面板与弹窗、滚动及窄屏。核对 API 无请求时取 Tushare 的行为。
- [x] 主会话完成最终验证并汇报实际证据;不自动提交用户改动,不更新全局知识或 specs。
风险位置:sector_radar/application/build.py 的成功发布门、infrastructure/postgres.py 的快照/事实事务与新迁移、application/read.py 的日期回退。新增可选详情来源不应改变旧榜成功条件。
执行结果与全局既有检查例外详见 verification.md;勾选代表本步骤已执行,不表示全仓检查全部通过。
@@ -0,0 +1,23 @@
# 资金雷达:单日榜单与板块详情
## 目标与边界
尽可能复刻 OneChartLab 单日榜单和截图中的板块详情交互,支持解释当日资金强弱与历史持续性。所有业务数据通过 Tushare 采集落库,再由本地分析和 API 提供;参考站点仅用于交互研究。保留现有独立指标,不反演或新增原站未公开的加权评分,不改动波段榜及排名变化榜的算法,不提交已有用户改动。
## 已确认背景
当前排名接口没有涨跌幅及附加指标,见 `zhixing-server/src/zhixing_server/modules/sector_radar/presentation/http.py:83`。归一化事实只存成交额和主力净额,见同模块 `domain/persistence.py:101`。但采集已请求 `dc_index.pct_change/leading_code` 和 `daily.pct_chg`,见同模块 `infrastructure/tushare.py:40`。现有 3—10 日聚合及三指标排名可复用,30 日详情接口和前端交互尚未实现。
## 需求与验收
R1:单日流入率移除排名百分位和样本列,增加涨跌幅、净额、在榜;板块仅显示名称。按用户截图将“净值”解释为主力净额,金额以亿元显示。左右榜字段镜像排列,保留现有独立流入率排序。验收检查列名、数据、正负颜色和名称点击。
R2:单日净额移除排名百分位和样本列,增加涨跌幅、单日流入率;同样只展示板块名称。验收核对附加指标属于同一板块、同一交易日和同一发布版本。
R3:在榜为截至所选日近 30 个交易日内进入对应 TOP/BOTTOM 10% 的次数,不是连续天数。点击展开日期和当日排名格子,支持收起;分别用每日同类型排名池确定强弱。缺失记录显示缺失,不补零,不借用未来数据;不足 30 日标明覆盖范围。
R4:点击板块打开可滚动详情弹窗,包含名称、类型、代码、日期、领涨股、当日涨跌幅、波段/单日率/单日额排名摘要;三指标近 30 交易日轨迹可切换,悬停显示日期和排名;成分强弱支持涨跌幅、主力净额、主买净额和前后 5 名;提供相似板块、复制及导出成员。少于 5 个有效成员按实际数量展示;缺失字段不伪装为 0。弹窗支持关闭按钮、Escape、焦点返回和加载/失败/空状态。
R5:优先使用落库的 dc_index 板块涨跌幅,不以成分平均值静默替代。成分涨跌幅来自 daily,主力与主买净额分别保存。相似度使用同日成员集合 Jaccard 重合度,明确为本系统口径;最多显示四个非自身板块,允许概念与行业交叉比较。
R6:真实 Tushare 单板块自测先采集落库,再通过 API 读取并核对金额、涨跌幅和成员。单板块数据不能验证全市场排名或相似度,不得发布为完整排名池;全池排名/历史边界使用受控数据验证。缺权限或历史数据不足时记录实际限制,不伪造通过。
## 验证与风险
后端验证单位、成员日期、历史截止日、独立发布版本、缺失和在榜计数;前端验证两面板、展开、弹窗、切换和导出交互。真实调用前核实环境与权限,密钥不进入日志。新增详情来源失败不得让已有完整榜单丢失;旧发布缺字段可读且明确为空。当前仍处规划阶段,尚未进行产品修改或真实采集。
@@ -0,0 +1,62 @@
"""Collect one real sector into an isolated PostgreSQL snapshot store.
Run with the server's uv environment from zhixing-server. No production
publication is created; facts must be read back from this store for validation.
"""
from datetime import date
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
import json
import time
import tushare as ts
from zhixing_server.bootstrap.config import Settings
from zhixing_server.modules.sector_radar.domain.source import build_source_snapshot
from zhixing_server.modules.sector_radar.infrastructure.postgres import PostgresSectorRadarRepository
def main():
"""Persist provider responses before inspecting their values; redact errors."""
settings = Settings(_env_file='../.env')
url = urlsplit(settings.database_url)
host = url.netloc.rsplit('@', 1)[0] + '@127.0.0.1:5433'
database = urlunsplit((url.scheme, host, '/radar_detail_selftest_0906', url.query, ''))
repository = PostgresSectorRadarRepository(database, max_connections=2)
client = ts.pro_api(settings.tushare_token)
target = date(2026, 9, 4)
counts = []
def collect(api, fields, **params):
"""Store each raw response atomically and return persisted rows."""
time.sleep(0.25)
frame = client.query(api, fields=fields, **params)
snapshot = build_source_snapshot(api_name=api, params=params,
rows=frame.to_dict('records'), target_trade_date=target)
repository.save_source_snapshots((snapshot,))
counts.append({'api':api, 'rows':snapshot.row_count, 'snapshot':snapshot.snapshot_id})
return snapshot.rows
try:
collect('dc_index','ts_code,trade_date,name,idx_type,level,pct_change,leading_code',
trade_date='20260904', ts_code='BK1147.DC')
members = collect('dc_member','trade_date,ts_code,con_code,name',
trade_date='20260904', ts_code='BK1147.DC')
collect('stock_basic','ts_code,symbol,name,market,exchange,list_status,list_date,delist_date',list_status='L')
collect('suspend_d','ts_code,trade_date,suspend_timing,suspend_type',trade_date='20260904')
collect('trade_cal','exchange,cal_date,is_open,pretrade_date',exchange='SSE',start_date='20260720',end_date='20260904')
for member in members:
code = member['con_code']
collect('daily','ts_code,trade_date,close,pre_close,pct_chg,vol,amount',trade_date='20260904',ts_code=code)
collect('moneyflow_dc','trade_date,ts_code,name,net_amount,net_amount_rate,pct_change,close',trade_date='20260904',ts_code=code)
collect('moneyflow','trade_date,ts_code,net_mf_amount',trade_date='20260904',ts_code=code)
result={'status':'collected','sector':'BK1147.DC','trade_date':str(target),'members':len(members),'snapshots':counts}
except Exception as exc:
result={'status':'partial','snapshots':counts,'error_type':type(exc).__name__,
'message':str(exc).replace(settings.tushare_token,'[redacted]')[:300]}
finally:
repository.close()
Path('../.trellis/tasks/09-06-capital-radar-daily-detail/research/collection-result.json').write_text(json.dumps(result,ensure_ascii=False,indent=2))
print(json.dumps({k:v for k,v in result.items() if k!='snapshots'},ensure_ascii=False))
print('Stored snapshots:',len(counts))
if __name__ == '__main__':
main()
@@ -0,0 +1,243 @@
{
"status": "collected",
"sector": "BK1147.DC",
"trade_date": "2026-09-04",
"members": 14,
"snapshots": [
{
"api": "dc_index",
"rows": 1,
"snapshot": "73f94be4b5c94238b611396b7cdf2e314f17cdd23532c3773febf3e3277f1674"
},
{
"api": "dc_member",
"rows": 14,
"snapshot": "2046df68966d3eda9a392b0254b84576842c42c487ea0a2f1044b6cc75de3afa"
},
{
"api": "stock_basic",
"rows": 5556,
"snapshot": "1c87ce65e081e670add1c1167473f798e97e8c9cf41db3e4ac71df0e3de5bb90"
},
{
"api": "suspend_d",
"rows": 8,
"snapshot": "b91d0981345538463407939a7ed863b8fb8de5e447b8ec731b8767b7ae13aa5d"
},
{
"api": "trade_cal",
"rows": 47,
"snapshot": "afff3cba955380cc0af9cdaac1d8861c17a70b089c70ea95aadb1cc568f8a26e"
},
{
"api": "daily",
"rows": 1,
"snapshot": "4f2c4f92685180b3de23a26c9d0602a8acf3fedb7dbc2413ac92bda76d8fa65a"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "7c1907d19c2db8134a807edba87fe165b44a1e96d6b3731e65a26f52715f6f12"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "521254cf598c28608571973eeec1e52c56c856ee547bbd2d9ceacc4e1a29727e"
},
{
"api": "daily",
"rows": 1,
"snapshot": "9bf1ca69c3be480113c933c25681dab1379e0a068bdf9a31452794de8c4bff94"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "8b81006f266edabf8ac1e2aa292e9b278ca9131eea47f86fbef89373197215f6"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "e90138a791bf7e3d458d3865838ac3aee394fe491ebf93cb363425852d1bca92"
},
{
"api": "daily",
"rows": 1,
"snapshot": "81ca417096a073698375daa03f0a660398d53c3943b16c9598a8faa918ff177f"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "40fb4ad0d2bbe8413e6dfcc213e8efe7f3367f84f492f68e430f1a9d08e9cc51"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "47d32d618d5d8a2118253b1e3d7b354628bae22a7b10880f4cfdd3ee370dae22"
},
{
"api": "daily",
"rows": 1,
"snapshot": "2418e65c6800ef5633e6f260cbe617eb3b5bbda4d71e16e56589cd1603827b6b"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "d3559b1413d931dfe882fa64f501b75c76c91125fa0fbe616b385e9a3b7c9e15"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "76a6e0cbe16e92c707af828a9936d87bd810844c357256c18e4d9ef10d86e40d"
},
{
"api": "daily",
"rows": 1,
"snapshot": "a3307443f9e89ff5523043055509dc309c6766067a5d9eb83bdef5d19e6e44c1"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "8d4c401bf8ade3dae6c0eab35069d59e61d4c6383b190e21a420d7735d0ab5a2"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "ee3a90e0e406940f60358edfa3688f8774687bc768c4e9c069ca6d8337a39bcc"
},
{
"api": "daily",
"rows": 1,
"snapshot": "bf21400564f20c771905d46be0dd225eb9d0579393ab5734038ba93db63042f4"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "66611bdd15e87cd5ccec0f48d7e94282cedb210f0393a313519b6896abf0de1f"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "eb98e9d138478b7ec2ae57557a1e61d34f825fd9faf8f30ef530c4d54f5e391e"
},
{
"api": "daily",
"rows": 1,
"snapshot": "550627fe30dccb760e4dbc73dd0ecee85676f781dac1ba059483e7d83b701091"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "5d1e6b1222b37896ad79d759e8fb8a240fb438bf6d50eda687c56181648c3a12"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "7bc22506f40cd9c550a48699c93bee28f00c8717e304cd26761b33d9e66dbf35"
},
{
"api": "daily",
"rows": 1,
"snapshot": "39dda855afcfcf4e32ccd67e4cd28942f8eed13a171b82fa438000bfcff87252"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "b9042adf9d7c2823a7155691c6c349f451b6d84fe7eb2ca83ab749a51eaa6c6e"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "aed26a777f4d4eae9236b677d6ed7180a093aa49076eeee3b796325db3ce2e9f"
},
{
"api": "daily",
"rows": 1,
"snapshot": "26d7d7beb26a8318f9b1104b5bc7446562663bba3e7142d9957562f5dce16c64"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "560d0e8e0a1d1220acab7a4d22ba6aadb4d47555344f75bbdbaf7650d62d95bf"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "7f6759aea68eb193c271c6f8230de9237bcd25bb445eef786adc5b39d5527e08"
},
{
"api": "daily",
"rows": 1,
"snapshot": "467d8fa44adf1aeecb81bb970a3239db4f48c75ec680bd802310ba0a40b9a92f"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "cc80509ebf2c47e85cb1eebe0735db7c84facd01eba73212db218fbabcf6c980"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "7f145408f62626e955b83617821e4c41ce0af3353533de790de22211f02ba36c"
},
{
"api": "daily",
"rows": 1,
"snapshot": "e515db0257399b1433fc14c4062a020a4837dfe6ff968cd70ab2b60f1035794e"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "31aa693eb092cf03a7b1e0da65ffd46e79389831f93809e6738789bfa6e8d619"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "b4073fdbd1e5f73c52d64aae07ea3dd8a9613f12cdea316abcbbaf4bdff6f28e"
},
{
"api": "daily",
"rows": 1,
"snapshot": "ffd0d7d09fee0dc7e5a035c9131220042563a8d5c852ff5ca8d0a4d8b7ba8004"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "7e9bff54c0b00afc12bbcc7a98dfcd7e504ce780e47ac7a3c3443e989d9da031"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "fd045165e72b1127df96bd73487a99d2b561c641783a0dfa3b0ed8b470e4abda"
},
{
"api": "daily",
"rows": 1,
"snapshot": "30ccd5069b7a8c361cc5408ecb9e0013f36d314cf5dde4c6d79b9e10f8942315"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "bbcc7b25b1e2d51487dcb76b28404966db7e83c9153af8f3119e1653f20f8618"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "c210e7f840c5baecc94d6fd333f73fd98b994b65575f2ce38faed428e59b8f9f"
},
{
"api": "daily",
"rows": 1,
"snapshot": "bbb69dfcf4862e214b3a47265ba3607476695cb9147eed3d9f8199a0e7501ff1"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "fce61a39b30de56dc3b898f6d8e4bf156504bedc001c5c85abfceaecbb490f36"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "2e0739949d25df4a56631c761e064121ff8f99ead4e95306c1c1f1bfa44111ae"
}
]
}
@@ -0,0 +1,20 @@
{
"trade_date": "2026-09-04",
"sector": "BK1147.DC",
"members": 14,
"pct_change": 1.82,
"main_net_yuan": "485456500.00",
"turnover_yuan": "2615472881.48000",
"daily_ratio": "0.1856094564915916866216729052",
"active_net_yuan": "-255455700.00",
"rows_by_api": {
"dc_index": 1,
"dc_member": 14,
"stock_basic": 5556,
"suspend_d": 8,
"trade_cal": 47,
"daily": 14,
"moneyflow_dc": 14,
"moneyflow": 14
}
}
@@ -0,0 +1,77 @@
"""Replay the persisted real SPD sample in an isolated database, with no network.
The one-sector publication is exclusively a local integration fixture; its ranks
must never be interpreted as a market-wide ranking.
"""
import os
import json
from datetime import date
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
import psycopg
from alembic import command
from alembic.config import Config
from zhixing_server.bootstrap.config import Settings, get_settings
from zhixing_server.modules.sector_radar.application.build import BuildSectorRadar, BuildSectorRadarCommand
from zhixing_server.modules.sector_radar.domain.models import SectorType
from zhixing_server.modules.sector_radar.domain.source import (
SourceSnapshot, SourceResult, TradeCalendarRow, SectorIndexRow, SectorMemberRow,
StockBasicRow, SuspendRow, DailyRow, MoneyflowDcRow, MoneyflowRow, build_source_snapshot,
)
from zhixing_server.modules.sector_radar.infrastructure.postgres import PostgresSectorRadarRepository
def database_url():
"""Resolve only the named localhost fixture database without printing secrets."""
s=Settings(_env_file='../.env'); u=urlsplit(s.database_url)
return urlunsplit((u.scheme,u.netloc.rsplit('@',1)[0]+'@127.0.0.1:5433','/radar_detail_selftest_0906',u.query,''))
class StoredSource:
"""Implement provider port using already committed raw snapshots only."""
def __init__(self, dsn):
self.by_api={}
with psycopg.connect(dsn) as c:
for r in c.execute('SELECT id,api_name,normalized_params,target_trade_date,partition_key,observed_at,payload,row_count,returned_fields,content_sha256,row_limit,limit_reached FROM sector_radar_source_snapshot').fetchall():
snap=SourceSnapshot(r[0],r[1],tuple(sorted(r[2].items())),r[3],r[4],r[5],tuple(r[6]),r[7],tuple(r[8]),r[9],r[10],r[11])
self.by_api.setdefault(r[1],[]).append(snap)
def result(self, api, parser):
snaps=tuple(self.by_api[api])
return SourceResult(snaps,tuple(parser(row) for s in snaps for row in s.rows))
def fetch_trade_calendar(self,start,end):
return self.result('trade_cal',TradeCalendarRow.from_mapping)
def fetch_sector_indices(self,target,kind):
if kind is SectorType.INDUSTRY:
# Explicitly empty industry scope for this one-concept test fixture.
snap=build_source_snapshot(api_name='dc_index',params={'test_scope':'empty_industry'},rows=(),target_trade_date=target)
return SourceResult((snap,),())
return self.result('dc_index',lambda row:SectorIndexRow.from_mapping(row,kind))
def fetch_sector_members(self,target,codes):
return self.result('dc_member',SectorMemberRow.from_mapping)
def fetch_stock_basics(self):
return self.result('stock_basic',StockBasicRow.from_mapping)
def fetch_suspensions(self,target):
return self.result('suspend_d',SuspendRow.from_mapping)
def fetch_daily(self,target):
return self.result('daily',DailyRow.from_mapping)
def fetch_moneyflow_dc(self,target,codes):
return self.result('moneyflow_dc',MoneyflowDcRow.from_mapping)
def fetch_moneyflow(self,target):
return self.result('moneyflow',MoneyflowRow.from_mapping)
def main():
"""Run actual build and HTTP reads, checking independently recomputed facts."""
dsn=database_url()
os.environ['ZHIXING_DATABASE_URL']=dsn
get_settings.cache_clear()
command.upgrade(Config('alembic.ini'),'head')
repository=PostgresSectorRadarRepository(dsn,max_connections=2)
result=BuildSectorRadar(StoredSource(dsn),repository,today=date(2026,9,6)).execute(BuildSectorRadarCommand(trade_date=date(2026,9,4)))
print(json.dumps(result.as_dict(),ensure_ascii=False))
repository.close()
if __name__=='__main__':
main()
@@ -0,0 +1,27 @@
"""Verify API facts against independently calculated persisted real data."""
import json
from decimal import Decimal
from pathlib import Path
from urllib.request import urlopen
root='http://127.0.0.1:8016/api/v1/sector-radar/'
d=json.load(urlopen(root+'sectors/concept/BK1147.DC/detail?trade_date=2026-09-04'))
assert d['status']=='success'
assert len(d['members'])==14
assert Decimal(d['pct_change'])==Decimal('1.82')
assert sum(Decimal(m['net_amount_yuan']) for m in d['members'])==Decimal('485456500')
assert sum(Decimal(m['active_buy_net_amount_yuan']) for m in d['members'])==Decimal('-255455700')
assert Decimal(d['summary']['amount']['metric_value'])==Decimal('4.854565')
# Storage uses 12 fractional digits for persisted metric observations.
assert abs(Decimal(d['summary']['ratio']['metric_value'])-Decimal('0.18560945649159168662'))<Decimal('1e-12')
assert d['history']['available_days']==1
assert len(d['history']['points'])==30
assert d['summary']['swing']['missing'] is True
assert len(d['leaders']['pct_change']['top'])==5
for view in ['amount','ratio']:
r=json.load(urlopen(root+'rankings?sector_type=concept&view='+view+'&side=top&trade_date=2026-09-04'))['rows'][0]
assert Decimal(r['pct_change'])==Decimal('1.82')
assert Decimal(r['daily_net_amount_yuan'])==Decimal('485456500')
assert r['on_list_count']==1
Path(__file__).with_name('verified-detail.json').write_text(json.dumps(d,ensure_ascii=False,indent=2))
print('PASS: real persisted SPD data matches detail and both ranking APIs; 14 members, 30 slots / 1 available day, absent swing remains null.')
@@ -0,0 +1,26 @@
{
"id": "capital-radar-daily-detail",
"name": "capital-radar-daily-detail",
"title": "资金雷达:单日榜单与板块详情",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-09-06",
"completedAt": "2026-09-25",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "实施及本地验证完成,详见 verification.md。代码按用户边界保持未提交,未自动归档;正常业务库尚未应用本次迁移。",
"meta": {}
}
@@ -0,0 +1,25 @@
# 本地实施验证 · 2026-09-06
本次已实现单日流入率/净额两种表头、板块名称入口、30 交易日在榜展开、详情三指标排名曲线、成员三指标前后 5 名切换、Jaccard 相似板块和成员复制/CSV 导出。沿用当前项目主题,参考站点私有加权评分未作为此次范围。前端读取同源 API;详情和历史仅从已持久化的发布及原始快照分析,不在读请求中调用 Tushare。
## 真实数据核验
使用独立数据库 `radar_detail_selftest_0906`,未覆盖正常业务库。采集 SPD概念 `BK1147.DC` 在 2026-09-04 的 Tushare 数据,14 只成员,原始响应先入库,再从已保存快照重放构建。数据库已升级至 `0009_radar_sector_detail`。采集、独立计算和 API 核对脚本在本任务 `research/`,不包含凭据。
数据库独立汇总与最新 HTTP 返回一致:板块涨跌幅 1.82%,主力净额 485456500 元,成交额 2615472881.48 元,单日流入率 18.560945649159…%,主买净额 -255455700 元。两种资金指标分别保留,未互相替代。`research/verify_http.py` 在重启最新服务后通过,验证 14 个成员、三指标摘要及两种榜单附加字段。
该自测只有 1 个板块、1 日有效发布;近 30 个交易日中的其余日期显式缺失,波段指标保持空值。单板块第 1 名仅验证功能链路,不代表全市场排名,也不能用于检验参考站点全池历史的数值一致性。
## 检查结果
- 后端相关单元、HTTP、PostgreSQL 测试:96 passed。隔离审查库 `radar_detail_review_0906` 从空库完成迁移;覆盖最新发布、30 日边界、未来隔离、每日池变化、版本隔离、可选来源失败、历史成员、Jaccard 和字段持久化。
- 后端全量 Ruff lint/format:通过,129 个文件格式符合。资金雷达源码及相关测试 Pyright:0 errors。
- 前端资金雷达测试:41 passed。全量 ESLint、TypeScript、生产构建通过;资金雷达范围 Prettier 通过。
- 浏览器已核验两个面板、名称入口、在榜展开、默认指标跟随当前面板、成员指标与前后 5 切换、复制/导出 14 名成员及弹窗下部。390px 窄屏发现的 grid/canvas 撑宽已修复,弹窗 clientWidth 和 scrollWidth 均为 358px。单点历史保留可见圆点。
- `git diff --check` 通过。代码未提交,未执行自动归档或修改 specs。
## 已有全局检查问题与使用边界
全量后端 Pyright 仍报告 14 个错误,范围仅在未修改的 selection/application/chart.py、selection/domain/gold_brick.py 和 tests/unit/selection/test_run.py。全量前端 format:check 仍被两份已有 `.playwright-cli/page-2026-09-01T14-54-32-480Z.yml`、`page-2026-09-05T07-35-10-548Z.yml` 阻断。未扩大修复这些文件。构建另有大 chunk 提示,Alembic 有既有 path_separator 弃用提示。
正式业务数据库使用前需应用新迁移并按原构建流程采集/构建目标日;既有发布缺少可选主买净额时显示缺失,不通过实时请求补值。当前本地预览为 `http://127.0.0.1:5516/sector-radar`,连接上述单板块隔离库,后端端口 8016。最新截图在 `output/playwright/radar-detail.png`。
@@ -0,0 +1,3 @@
{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "后端必要检查与测试形状"}
{"file": ".trellis/spec/backend/http-api-contracts.md", "reason": "HTTP 兼容性"}
{"file": ".trellis/tasks/09-07-api-performance-diagnosis/design.md", "reason": "已批准设计与性能证据"}
@@ -0,0 +1,44 @@
# 性能诊断与建议设计(已批准实施)
## 证据与边界
2026-09-07,低频公网 GET:示例 detail 四次 3.045 / 3.059 / 3.074 / 3.442 秒,200,10750 字节;显式绕过本机代理复测 3.312557 秒,TLS 完成 0.443439 秒,首字节 3.312484 秒。直连 healthz 0.810132 秒、dates 0.815043 秒、ratio 榜单 2.570251 秒。默认网络路径 history 1.834179 秒、2020-01-01 无数据 detail 0.746555 秒。请求成功验证示例为 SPD概念,成员 14 个。
这些是客户端端到端耗时,不是服务端或 SQL 独立耗时;样本不足以推断 P95、并发容量或全站所有接口。生产代码版本、CPU/IO/锁等待及 SQL 执行计划未验证。
## 当前调用链
- presentation/http.py:355 -> ReadRadarDetails.detail -> history -> history_data。
- application/details.py:145 的 history_data 加载最多 30 个发布日的全部排名;为了取得 calendar,调用 snapshots 加载整个批次全部原始输入。
- application/details.py:294 的 detail 再次调用 snapshots,并在 Python 中重建股票基础信息、全体成员关系、行情和资金索引,再计算相似板块。
- infrastructure/postgres.py:194 的 load_publication_sources 按 publication_id 连接来源表和快照表,SELECT 包括完整 JSONB payload,没有 source_group 或证券过滤;:1112 还把每个 payload 行复制为 dict。
- application/details.py:182 的 ranking_extras 具有相同的重复读取模式;read.py:285 在 amount/ratio 榜单调用它。
- presentation/http.py:258 已按进程缓存仓储;postgres.py:48 默认连接池上限 4。当前部署 Dockerfile:48 没有显式指定 worker 数量,但生产环境覆盖与并发压力未知,不能据此确诊排队。
本地使用 tests/unit/sector_radar/test_read.py 的内存仓储夹具,仅代理计数真实应用层调用,不改产品代码:detail 为 get_successful_publication=1, load_history_publications=1, load_publication_rankings=1, load_publication_sources=2;普通金额榜单另有 load_rankings=1。此实验确认调用次数,不测量生产 SQL 成本。
## 已批准的优化次序
1. 取得服务端分段计时与只读查询计划,分别测连接池等待、SQL 执行与取数、JSON 转换、Python 组装;先对最重的快照读取确认行数和字节数。
2. 日历只读取 calendar 来源;同一请求避免重复读取同一发布输入,先减少明显多余工作。
3. 详情优先读取既有 publication 归属的事实和聚合投影;成员及股票只取所需范围,相似板块考虑在发布阶段预计算。旧批次投影缺失必须保持当前缺失语义,不能换用全局最新成员或直接读 Tushare。
4. 历史请求保留同类同版本的真实排名池大小、名次、百分位和缺失状态,SQL 只返回所需板块结果与分组统计,避免每次构造全量排名对象并重复扫描。
5. 仅在查询计划显示需要时提出索引;当前 publication/source/ranking 已有主键及索引,不能笼统归因为缺索引。
6. 优化冷请求后,依据重复访问与并发数据决定是否加入有界进程缓存或 Redis。
## Redis 取舍与一致性
Redis 可缓存最终响应/紧凑投影,适合读多写少的已发布收盘数据,尤其多进程/多实例需要共享结果时。它不是当前诊断的必要前提;只安装服务并不加速,必须接入读取、写入和失效逻辑,未命中仍走原查询。
缓存键至少含响应 schema 版本、请求参数、当前 publication_id;含历史曲线的响应还依赖此前各日选中的 publication_id/source_version/metric_version,历史补录或重建也必须改变键或触发失效,不能仅使用目标日期或当前批次 ID。缓存只保存成功且版本明确的投影,设置容量上限、TTL、并发回填保护和故障回源。TTL 不替代明确的发布版本语义。
官方资料:https://redis.io/docs/latest/develop/use-cases/cache-aside/ ,已经 Context7 与官方网页核验通用 cache-aside、TTL 和显式失效机制;当前项目未发现 Redis 依赖,未选择版本。
## 兼容与回滚
不改变 HTTP 字段、精度、历史缺失语义、最后有效发布规则。缓存层应可关闭回源;如后续需要新增投影或迁移,应先独立评审与授权。用户已批准本地实现与验证;线上发布由用户负责。
## 本轮落实的读取设计
- 新增 publication-scoped 原始行投影读取接口,明确 sources、trade_date、ts_codes 过滤,不伪造带原快照哈希的裁剪快照。保留 source_order 和快照内行顺序,保证重复键覆盖行为不变。
- 历史只读 calendar;榜单额外只读指数;详情先读取指数/股票基础/成员用于当前上市池和重合率,再按目标日与成员读取 daily/moneyflow_dc/moneyflow。
- 新增按所需板块读取历史排名的查询,完整排名池分组统计在过滤目标板块前完成;不存在板块也保留该池大小。应用层一次建立按板块、指标、版本的查找表,避免循环扫描。
- 既有 stock_fact 非 available 会清除 net_amount 且没有 publication_id,旧字段也可能空,不能直接无损替代独立来源读数;本轮不迁移、不重建、不引入全局缓存。相似度在请求内基于必要成员集计算。
- 性能目标用同一隔离 PostgreSQL 数据集前后对比与传输范围断言验证,实际公网改善由用户发布后验证。
## 验证结论
实现只改四个 sector_radar 后端文件,新增读取回归测试文件。无迁移、依赖、HTTP 字段或全局缓存改动。真实数据库结果、完整响应一致性及前后耗时记录在 research/performance.json。详情仍需要相似板块和上市过滤使用的完整成员/基础信息;不兼容来源版本的少量池统计仍在查询后丢弃,这两点保留为后续测量候选。
@@ -0,0 +1,3 @@
{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "后端必要检查与测试形状"}
{"file": ".trellis/spec/backend/http-api-contracts.md", "reason": "HTTP 兼容性"}
{"file": ".trellis/tasks/09-07-api-performance-diagnosis/design.md", "reason": "已批准设计与性能证据"}
@@ -0,0 +1,30 @@
# 后续执行建议(已批准实施)
## 当前已完成
- [x] 公网低频请求复现与分段计时,包含显式直连对照。
- [x] 检查路由、应用层、仓储、既有迁移和部署配置。
- [x] 用现有内存仓储夹具追踪重复读取,未修改产品代码。
- [x] Redis 取舍与发布/历史依赖失效边界分析。
## 实施顺序
1. 核对生产版本并收集脱敏的 server/request 分段耗时、池等待、查询行数与字节数;针对 SELECT 使用只读执行计划并设超时,不做生产压力测试。
2. 审阅需修改文件全文并加载 backend 规格,明确最终性能目标;补齐真实 implement/check 上下文清单后再 task.py start。
3. 优先修复完整快照重复读取和日历过量读取,增加能约束调用次数、过滤范围及历史一致性的回归测试。
4. 根据实测再决定缩小排名结果、使用既有投影或新增预计算。每项范围变化都更新设计;不以增加连接数或 worker 数替代测量。
5. 运行受影响测试、后端规定的 Ruff、Pyright 和 pytest;固定样本比较输出语义与耗时。在获批的环境验证冷/热请求与必要并发,不凭公网少量样本宣称 P95。
6. 仅在确认缓存需求后评审 Redis;验证重建、历史补录、并发回填、容量淘汰和 Redis 不可用时回源。
## 风险与授权
用户已明确批准按方案实施本地优化和验证。隔离本地 PostgreSQL 用于查询正确性与对比,禁止连接生产执行写入或压测。用户随后明确要求提交到本地 develop;不推送、不部署、不更新共享规范。
## 本轮实现与验证结果
- [x] publication 行投影查询按 source_group、目标日、ts_code 过滤,source_order/ordinality 稳定,无原始快照重复取数。
- [x] 历史排名在 SQL 中统计完整池后过滤板块;应用层索引查找;缺失板块、旧指标版本、不兼容来源版本、请求期间重建钉住均覆盖。
- [x] 真实 PostgreSQL 隔离 schema 验证,旧版仅原始快照数据仍可读取独立缺失值;未排行和行业池不会污染概念池。
- [x] 独立只读审查 /root/read_path_review 完成,无阻塞发现;主代理负责修改与执行验证。
- [x] 本地同一数据集三端点各 5 次 before/after 比较,全量响应一致。详情中位 1.7107→0.4841s;history 0.8580→0.0262s;rankings 1.2415→0.0645s。详见 research/performance.json,不能作为生产 SLA。
- [x] Ruff format/check 通过;受改文件 Pyright 验证;全仓 Pyright 14 个 selection 错误与修改前文件/行/内容逐项完全一致。
- [x] 后端全套以 unit→HTTP→integration 顺序执行:226 passed / 1 failed。剩余市场数据集成测试在修改前复现 Connection.executemany AttributeError;不扩大修改范围。默认集成测试优先顺序另有既存 Alembic fileConfig 污染 caplog 的问题。
- [ ] 用户自行发布后复测公网 detail/history/rankings;无 Redis、无迁移、不要求历史重跑。
本地实现已完成。质量门禁存在明确的既有阻塞,未宣称全仓全绿;用户已授权将实现、回归测试和性能证据提交到本地 develop;任务待用户发布验证,暂不归档。
@@ -0,0 +1,27 @@
# 接口性能诊断与缓存方案评估
## 目标
解释用户观察到的多个接口约 3 秒延迟,以概念板块 BK1147.DC 在 2026-09-04 的详情接口为切入点,在不引入 Redis 的情况下优化冷请求读取与计算,供用户自行发布后评估效果。
## 已确认事实
- 用户已在诊断与方案回顾后明确批准按优化顺序开始实施,由用户自行发布;Redis 留待上线效果验证后评估。
- 公网 GET 四次均返回 HTTP 200,总耗时 3.045–3.442 秒,响应体 10750 字节;主要等待发生在首字节之前。
- 排查开始时本地 develop 分支工作区干净;生产是否与当前提交一致尚未确认。
- 静态检查及本地调用追踪确认:详情与金额榜单每次调用两次 load_publication_sources;来源查询读取完整 payload(application/details.py:145、:182、:294;infrastructure/postgres.py:194)。
## 范围与要求
- 低频只读测量示例请求,分析本地路由、查询、连接管理与部署配置。
- 区分观测事实、代码风险和待生产证据验证的假设。
- 实施资金雷达详情、历史和榜单的最小充分读取优化;不修改外部系统。
## 验收标准
- 详情/榜单/历史不再加载完整发布快照,按需读取来源组、目标日与成员;历史只传输目标板块排名及完整池统计。
- 保留全部 HTTP 字段、Decimal 精度、旧批次独立缺失值、历史发布选择与相似板块口径。
- 通过真实 PostgreSQL 查询验证、语义回归测试和后端质量门禁,给出同一数据集的前后性能比较;不承诺未上线的公网秒数。
- 记录可复现请求的分段耗时,避免把总耗时直接等同于 SQL 耗时。
- 为关键判断提供代码位置或实测依据。
- 说明 Redis 是否必要、适用条件及失效策略边界。
- 明确未验证事项及下一步建议。
## 不在范围内
生产压测、部署、迁移、创建索引、接入 Redis、修改共享规范、推送远端。用户已另行授权提交本地 develop。
@@ -0,0 +1,126 @@
"""Task-local benchmark: isolated localhost PostgreSQL only; no production writes.
Run from zhixing-server with DATABASE_URL pointing to a disposable test database.
Set PYTHONPATH to the before/after source tree to compare the same persisted data.
Use --seed once, then --run before.json / --run after.json (five reads per endpoint).
"""
import argparse
import hashlib
import json
import os
import runpy
import statistics
import time
from dataclasses import replace
from datetime import timedelta
from decimal import Decimal
from pathlib import Path
from urllib.parse import urlsplit
from unittest.mock import patch
import psycopg
from alembic import command
from alembic.config import Config
from zhixing_server.bootstrap.config import Settings, sqlalchemy_database_url
from zhixing_server.modules.sector_radar.application.details import ReadRadarDetails
from zhixing_server.modules.sector_radar.application.read import ReadSectorRadar, RadarQuery
from zhixing_server.modules.sector_radar.domain.models import PublicationStatus, SectorType, MetricKind, MetricUnit
from zhixing_server.modules.sector_radar.domain.metrics import AmountNetStrategy, RatioTurnoverStrategy, SwingEqualThreeToTenStrategy
from zhixing_server.modules.sector_radar.domain.persistence import PublicationSourceGroup as Group, PublicationSourceRecord, RankingRecord
from zhixing_server.modules.sector_radar.domain.ranking import rank_metric_observations
from zhixing_server.modules.sector_radar.domain.source import build_source_snapshot
from zhixing_server.modules.sector_radar.infrastructure.postgres import PostgresSectorRadarRepository
from zhixing_server.modules.sector_radar.presentation.http import _detail_response, _history_response, _rankings_response
parser = argparse.ArgumentParser()
parser.add_argument('--seed', action='store_true')
parser.add_argument('--run', type=Path)
args = parser.parse_args()
url = os.environ['DATABASE_URL']
assert urlsplit(url).hostname in {'localhost', '127.0.0.1'}, 'disposable local database only'
samples = runpy.run_path('tests/unit/sector_radar/test_detail_reads.py')
target, now, code = samples['TARGET'], samples['NOW'], samples['CODE']
repo = PostgresSectorRadarRepository(url)
if args.seed:
config = Config('alembic.ini')
config.set_main_option('sqlalchemy.url', sqlalchemy_database_url(url).replace('%', '%%'))
config.config_file_name = None
with patch('zhixing_server.bootstrap.config.get_settings', return_value=Settings(database_url=url)):
command.upgrade(config, 'head')
samples['seed_detail'](repo)
current = repo.get_successful_publication(target)
template = repo.load_rankings(current.publication_id)[0].observation
stocks = [f'{i:06d}.SZ' for i in range(10, 3010)]
sectors = [f'PERF{i:04d}.DC' for i in range(500)]
def save(group, rows, order, day=target):
snapshot = build_source_snapshot(api_name=group.value, params={'batch': str(order)}, rows=rows,
target_trade_date=day, observed_at=now)
repo.save_source_snapshots((snapshot,))
repo.save_publication_sources((PublicationSourceRecord(current.publication_id, group, order, snapshot),))
save(Group.STOCK_BASICS, tuple({'ts_code': s, 'symbol': s[:6], 'name': s, 'exchange': 'SZSE',
'list_status': 'L', 'list_date': '20200101'} for s in stocks), 1)
save(Group.CONCEPT_INDICES, tuple({'ts_code': s, 'name': s, 'trade_date': str(target),
'pct_change': '1.1234'} for s in sectors), 1)
save(Group.MEMBERS, tuple({'ts_code': sector, 'con_code': stocks[(i*7+j)%len(stocks)],
'name': stocks[(i*7+j)%len(stocks)], 'trade_date': str(target)}
for i, sector in enumerate(sectors) for j in range(100)), 1)
for offset in range(10):
day = target-timedelta(days=offset)
for group in (Group.DAILY, Group.MONEYFLOW_DC, Group.MONEYFLOW):
rows = tuple({'ts_code': stock, 'trade_date': str(day), 'name': stock,
'pct_chg': str(Decimal(i % 123)/100), 'amount': str(i*12),
'net_amount': str(i-1500), 'net_mf_amount': str(i-500),
'close': str(Decimal(i%1000)/10+1), 'vol': str(i*33)}
for i, stock in enumerate(stocks))
save(group, rows, offset+1, day)
versions = ((MetricKind.AMOUNT, AmountNetStrategy.metric_version, MetricUnit.CNY_100M),
(MetricKind.RATIO, RatioTurnoverStrategy.metric_version, MetricUnit.RATIO),
(MetricKind.SWING, SwingEqualThreeToTenStrategy.metric_version, MetricUnit.RATIO))
for offset in range(30):
day = target-timedelta(days=offset)
if offset < 2:
publication = repo.get_successful_publication(day)
else:
running = replace(current, publication_id=f'bench-{offset}', target_trade_date=day,
status=PublicationStatus.RUNNING, input_hash=None, finished_at=None)
repo.create_publication(running)
publication = replace(running, status=PublicationStatus.SUCCESS, input_hash='a'*64,
finished_at=now+timedelta(seconds=1))
repo.finish_publication(publication)
rankings = []
for kind, version, unit in versions:
observations = tuple(replace(template, trade_date=day, sector_code=sector, sector_name=sector,
metric_kind=kind, metric_version=version, unit=unit,
value=Decimal(i+1)) for i, sector in enumerate(sectors))
rankings.extend(RankingRecord(publication.publication_id, row)
for row in rank_metric_observations(observations))
repo.save_rankings(rankings)
with psycopg.connect(url) as connection:
connection.execute('ANALYZE')
print('dataset', connection.execute('SELECT count(*), sum(row_count), sum(octet_length(payload::text)) FROM sector_radar_source_snapshot').fetchone(),
'rankings', connection.execute('SELECT count(*) FROM sector_radar_ranking').fetchone())
if args.run:
repo.open()
reads = {
'detail': lambda: _detail_response(ReadRadarDetails(repo).detail(target, SectorType.CONCEPT, code)),
'history': lambda: _history_response(ReadRadarDetails(repo).history(target, SectorType.CONCEPT, code)),
'rankings': lambda: _rankings_response(ReadSectorRadar(repo).query(RadarQuery(trade_date=target, page_size=20))),
}
report = {}
for label, read in reads.items():
timings = []
responses = []
for _ in range(5):
start = time.perf_counter()
response = read().model_dump(mode='json')
timings.append(time.perf_counter()-start)
responses.append(response)
assert all(item == responses[0] for item in responses)
fingerprint = hashlib.sha256(json.dumps(responses[0], sort_keys=True).encode()).hexdigest()
report[label] = {'seconds': timings, 'median_seconds': statistics.median(timings),
'response_sha256': fingerprint, 'response': responses[0]}
print(label, 'median', report[label]['median_seconds'], 'sha256', fingerprint, flush=True)
args.run.write_text(json.dumps(report, ensure_ascii=False, indent=2))
repo.close()
@@ -0,0 +1,100 @@
{
"environment": "isolated local PostgreSQL 16-alpine, Python 3.12.11, psycopg 3.3.4; same data, sequential requests, warm database/connection pool, no application cache",
"baseline_commit": "e567e5f",
"dataset": {
"snapshot_count": 41,
"source_rows": 143521,
"source_json_bytes": 22785765,
"ranking_rows": 45003
},
"samples_per_endpoint": 5,
"measurements": {
"detail": {
"before_seconds": [
1.7919143750004878,
1.7288594170004217,
1.7106771250000747,
1.7057415830004174,
1.6912810410012753
],
"after_seconds": [
0.6212627920012892,
0.4883682920008141,
0.44150824999996985,
0.43969708400072705,
0.4841277909999917
],
"before_median_seconds": 1.7106771250000747,
"after_median_seconds": 0.4841277909999917,
"reduction_percent": 71.7,
"responses_identical": true,
"response_sha256": "d2d5693152e949767a2db8e807852fe6fd2cb6ec175664a5dab9f615cdda4467"
},
"history": {
"before_seconds": [
0.8467425830003776,
0.8580366249989311,
0.8756502500000352,
0.8701953330000833,
0.8388115420002578
],
"after_seconds": [
0.026230833000226994,
0.024217250000219792,
0.025365250001414097,
0.028609290999156656,
0.026671499999793014
],
"before_median_seconds": 0.8580366249989311,
"after_median_seconds": 0.026230833000226994,
"reduction_percent": 96.9,
"responses_identical": true,
"response_sha256": "edf098e1f124a6d5526d9f202b67c6e97986095a6cb18fad3d0d6ba07d10d387"
},
"rankings": {
"before_seconds": [
1.3065002089988411,
1.2318050410012802,
1.2226990420003858,
1.3149193330009439,
1.2414632910004002
],
"after_seconds": [
0.06522883400066348,
0.06445087500105728,
0.06437025000013818,
0.06701250000151049,
0.06281666699942434
],
"before_median_seconds": 1.2414632910004002,
"after_median_seconds": 0.06445087500105728,
"reduction_percent": 94.8,
"responses_identical": true,
"response_sha256": "dbe90217c0086e22a570990062e08583c44d72ca1b4cf87b39208b033ce12e62"
}
},
"query_plan_samples": [
{
"query": "source_rows",
"execution_ms": 31.641,
"planning_ms": 1.534,
"rows": 3
},
{
"query": "rank_history",
"execution_ms": 23.911,
"planning_ms": 0.664,
"rows": 90
}
],
"validation": {
"pytest_passed": 226,
"pytest_failed_baseline": 1,
"baseline_failure": "test_market_data_repository_pool.py:65 Connection.executemany AttributeError, reproduced before edits",
"pyright_baseline_errors": 14,
"pyright_new_errors": 0,
"ruff": "passed",
"review_agent": "/root/read_path_review complete: no blocking SQL/data correctness findings",
"caveat": "Default integration-first ordering also disables caplog loggers through pre-existing Alembic fileConfig. Full tests were run unit/HTTP/integration order with both DB environment variables set; one baseline integration failure remained."
}
}
@@ -0,0 +1,26 @@
{
"id": "api-performance-diagnosis",
"name": "api-performance-diagnosis",
"title": "接口性能诊断与缓存方案评估",
"description": "优化资金雷达详情、历史与榜单读取;无 Redis、无迁移,待用户发布验证。",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-09-07",
"completedAt": "2026-09-25",
"branch": "develop",
"base_branch": "develop",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "用户已授权提交本地 develop。226 项测试通过;1 个既有集成测试失败和 14 个既有类型错误均在修改前复现。",
"meta": {}
}
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,9 @@
# 研究设计
这是只读分析任务,不进入产品实现阶段。
公开证据链:页面展示 → 实际引用脚本 → 实际请求的公开数据 → 评分与排名字段。数据库证据链:库表目录 → 字段及单位 → 重叠交易日原始值 → 候选公式复算 → 与公开评分比较。
优先检验可解释的低自由度公式。分开检验资金比率、横截面排序/归一化、时间窗口聚合;用多日和不同板块类型验证,避免单点拟合。识别数据修订、单位换算、成分股聚合与板块原始数据的口径差异。
数据库连接强制 default_transaction_read_only,设置查询超时。仅在本机保留任务所需数据;公开资料可以缓存供复核,凭据不落盘。报告不将无法唯一识别的参数写成确定结论。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,9 @@
# 分析步骤(无产品实现)
- [x] 读取网站公开脚本和数据,记录字段、日期和来源。
- [x] 只读确认数据库版本、数据表及覆盖范围。
- [x] 建立日期、板块和单位映射,对比原始输入。
- [x] 逐层验证单日评分和波段评分候选公式,记录误差。
- [x] 核验关键结论,完成研究记录与用户答复。
验证使用实际数据计算与证据核对;不运行与分析无关的产品测试。任务不包含代码实施、共享知识推广、提交和发布。
@@ -0,0 +1,25 @@
# 还原 OneChart 波段与单日资金流评分
## Goal
分析 https://onechartlab.com/ 板块资金雷达的波段流入率、单日流入率及其加权评分,结合用户本机 PostgreSQL 数据给出可复核的公式证据、复算结果和不确定性。
## Requirements
- 区分原始资金比率、评分和排名,明确时间窗口、权重、标准化、排名池和缺失数据规则。
- 优先读取网站实际公开的 HTML、脚本和数据;不将本项目独立指标策略视为该站点真实公式。
- 数据库仅使用只读连接及有范围限制的 SELECT,先确认可用库、表、字段与日期。
- 使用相同日期、板块标识和数据口径进行多样本验证,记录误差及候选公式可识别性。
- 用户已同意创建任务并记录分析。只修改当前任务记录,不修改产品代码、数据库或共享规格,不提交或发布。
- 凭据不写入任务记录、研究脚本、结果文件或报告;本机数据不传给外部服务。
## Acceptance Criteria
- [x] 列出评分相关公开字段及来源,说明公式是否直接公开。
- [x] 给出单日与波段评分的可验证公式,或明确最有依据的候选公式和未解决参数。
- [x] 用数据库与网站重叠样本核验,报告样本范围、误差和差异原因。
- [x] 保存必要分析记录与可复算证据,最终回答清楚区分事实、推断和限制。
## 结果
公开原始字段可精确重现最近 12 日 9,492 条记录,两种评分最大绝对误差约 3.41e-13;数据库最新日原始快照试算平均误差为单日 0.9783 分、波段 1.9076 分,个别板块仍有较大输入差异。详见 `research/findings.md` 和 `research/database-validation.md`。本研究已完成,没有产品实施待批准。
@@ -0,0 +1,128 @@
{
"normalized_aggregate": {
"joined_rows": 12654,
"joined_dates": 16,
"latest_rows": 791,
"Ratio": {
"checked_rows": 791,
"mean_absolute_error": 4.66428796165067,
"max_absolute_error": 129.76729492953035,
"same_one_decimal_display": 204,
"same_final_rank": 215
},
"Swing": {
"checked_rows": 791,
"mean_absolute_error": 4.803044287121097,
"max_absolute_error": 179.7496672672861,
"same_one_decimal_display": 96,
"same_final_rank": 199
},
"turnover_within_1_01_yuan": 294,
"weight_within_1e_10": 280,
"examples": [
{
"ts_code": "BK0581.DC",
"index_name": "智能电网 (概念)",
"ratio_db": 0.01288923245126,
"weight_db": 1.08086957077924,
"pred_Ratio_Score": 684.0285689472486,
"Ratio_Score": 789.4114202884311,
"pred_Swing_Score": 386.3978175732549,
"Swing_Score": 433.9148866486079
},
{
"ts_code": "BK0615.DC",
"index_name": "中药概念 (概念)",
"ratio_db": 0.080175906138025,
"weight_db": 1.041522634544339,
"pred_Ratio_Score": 1036.4911242325306,
"Ratio_Score": 1036.9806099966886,
"pred_Swing_Score": 825.1676911365778,
"Swing_Score": 823.0404356041679
},
{
"ts_code": "BK0653.DC",
"index_name": "养老概念 (概念)",
"ratio_db": 0.065820711061371,
"weight_db": 1.050692084122948,
"pred_Ratio_Score": 1038.0025661987577,
"Ratio_Score": 1035.4711369170884,
"pred_Swing_Score": 1005.0098195958633,
"Swing_Score": 1005.0161034783504
},
{
"ts_code": "BK1657.DC",
"index_name": "病原体防治 (概念)",
"ratio_db": 0.064807487656449,
"weight_db": 1.05614239070725,
"pred_Ratio_Score": 1040.8359792477243,
"Ratio_Score": 1038.383599887465,
"pred_Swing_Score": 829.0972873909569,
"Swing_Score": 830.4517488043484
}
]
},
"raw_snapshot_reaggregation": {
"joined_rows": 8701,
"joined_dates": 11,
"latest_rows": 791,
"Ratio": {
"checked_rows": 791,
"mean_absolute_error": 0.9783318452555938,
"max_absolute_error": 105.32457821281037,
"same_one_decimal_display": 581,
"same_final_rank": 576
},
"Swing": {
"checked_rows": 791,
"mean_absolute_error": 1.9075528008117222,
"max_absolute_error": 150.16516952571226,
"same_one_decimal_display": 318,
"same_final_rank": 363
},
"turnover_within_1_01_yuan": 610,
"weight_within_1e_10": 496,
"examples": [
{
"ts_code": "BK0581.DC",
"index_name": "智能电网 (概念)",
"ratio_db": 0.012878069596386,
"weight_db": 1.080961651218729,
"pred_Ratio_Score": 684.0868420756208,
"Ratio_Score": 789.4114202884311,
"pred_Swing_Score": 387.73624445889186,
"Swing_Score": 433.9148866486079
},
{
"ts_code": "BK0615.DC",
"index_name": "中药概念 (概念)",
"ratio_db": 0.079794956978515,
"weight_db": 1.041976772408121,
"pred_Ratio_Score": 1036.9430681935887,
"Ratio_Score": 1036.9806099966886,
"pred_Swing_Score": 823.0106390759795,
"Swing_Score": 823.0404356041679
},
{
"ts_code": "BK0653.DC",
"index_name": "养老概念 (概念)",
"ratio_db": 0.065820711061371,
"weight_db": 1.050692084122948,
"pred_Ratio_Score": 1035.4646626139197,
"Ratio_Score": 1035.4711369170884,
"pred_Swing_Score": 1005.0098195958633,
"Swing_Score": 1005.0161034783504
},
{
"ts_code": "BK1657.DC",
"index_name": "病原体防治 (概念)",
"ratio_db": 0.064737394636734,
"weight_db": 1.05622244732493,
"pred_Ratio_Score": 1038.3636136745088,
"Ratio_Score": 1038.383599887465,
"pred_Swing_Score": 829.160133769571,
"Swing_Score": 830.4517488043484
}
]
}
}
@@ -0,0 +1,76 @@
# PostgreSQL 独立核验
## 查询与范围
数据库:`zhixing-system`,PostgreSQL 18.4。本次通过用户授权的 SSH 隧道访问;连接参数强制 `default_transaction_read_only=on`、`statement_timeout=30000`、`lock_timeout=2000`,实测 `transaction_read_only=on`。只执行目录检查和 SELECT/CTE;没有数据库写入。凭据和私钥内容不保存在任务文件中。
读取了以下数据:
- `sector_radar_publication`:为每个交易日选取最近成功发布。
- `sector_radar_daily_aggregate`:2026-08-28 至 2026-09-21,16 个有成功发布的交易日、16,000 行。9 月 7 日没有成功发布对应的汇总,因此未用别日汇总冒充。
- `sector_radar_source_snapshot`:保存的原始 `dc_member`、`daily`、`moneyflow_dc`。对 2026-09-07 至 2026-09-21 的 11 日重新聚合,共 11,000 个板块日;SQL 保存在 `raw-reaggregation.sql`。
- 小范围检查 `sector_radar_stock_fact` 状态,以及四个板块的当日成员快照。
原始快照重聚合按日期和分区选择最近观测,成员使用逐板块分区,股票字段按日期/代码去重;`daily.amount × 1000` 与 `moneyflow_dc.net_amount × 10000` 统一为元。原始重聚合没有沿用产品的沪深 A 股过滤,目的仅是调查网站口径,未改变产品规则。
股票名单、金额数据仅在本机处理,没有传给外部文档查询或其他服务。
## 验证设计
对齐网站实际的日期、板块代码与类型,使用数据库提供的净额与成交额独立计算 r、3/10 日均值及 5 日成交额权重。预测阶段不使用网站的比率、分数或最终排名。
本次试算将数据库聚合成交额向下取整到元,再使用 `r = 净额/(成交额+100)`、`W = log10(1+MA5(成交额))/10`。这是与公开数值关系一致的候选输入口径,不能把该试算本身当作后端代码证据。
计算百分位前特意限制到网站的板块池。数据库有概念 504、行业 496,共 1000 个板块;网站是概念 414、行业 377,共 791 个。即使原始金额相同,在不同 N 和不同成员的池中计算百分位也不能复刻网站分数。
## 最新日结果
2026-09-21 共 791 条对齐记录:
| 数据输入 | 单日评分 MAE | 单日最大误差 | 单日一位小数一致 | 波段评分 MAE | 波段最大误差 | 波段一位小数一致 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| 当前产品规范化汇总 | 4.6643 | 129.7673 | 204/791 | 4.8030 | 179.7497 | 96/791 |
| 库中原始快照重新聚合 | 0.9783 | 105.3246 | 581/791 | 1.9076 | 150.1652 | 318/791 |
原始快照重聚合后,610/791 个最新日成交额与从网站金额/比率反求的成交额相差不超过 1.01 元;496/791 个近五日成交额权重达到 `1e-10` 内一致。这为“近五日成交额取对数作为权重”提供了不依赖网站评分预测输入的数据库佐证。
实际例子:
| 板块 | 数据库单日复算 | 网站单日分数 | 数据库波段复算 | 网站波段分数 |
| --- | ---: | ---: | ---: | ---: |
| 中药概念 | 1036.9431 | 1036.9806 | 823.0106 | 823.0404 |
| 养老概念 | 1035.4647 | 1035.4711 | 1005.0098 | 1005.0161 |
| 病原体防治 | 1038.3636 | 1038.3836 | 829.1601 | 830.4517 |
| 智能电网 | 684.0868 | 789.4114 | 387.7362 | 433.9149 |
不能只报告平均误差而忽略个别大偏差;数据库原始快照尚未逐板块精确复刻网站输入。本次请求中的评分机制已完成数值还原,但输入采集、板块池和成员版本的完整复刻属于进一步工作。
## 已确认的输入差异
1. **股票范围不同。** 产品 `normalize.py:242` 起要求当前上市、沪深证券,排除北交所/B 股;`facts.py:72` 起只将 `AVAILABLE` 股票累加到板块金额。最新日事实中有 `lifecycle_invalid=435`、`suspended=12`、`available=5209`。改用原始快照后误差显著减少,但没有完全消失。
2. **板块池不同。** 1000 与 791 的差异已在上述比较中控制;真正独立生产还需要明确网站选择这 791 个板块的规则。
3. **公开成员表与数据库当日快照不同。** 按股票代码去除交易所后缀再比较:
| 板块 | 库内成员 | 网站公开成员 | 交集 |
| --- | ---: | ---: | ---: |
| 智能电网 | 197 | 195 | 190 |
| 中药概念 | 146 | 145 | 144 |
| 碳交易 | 142 | 138 | 137 |
| 超跌股 | 167 | 22 | 7 |
智能电网库内独有 `002851/003043/301236/301669/605336/688187/920222`;网站公开表独有 `001388/002063/300140/600522/920375`。完整差集见 `member-differences.json`。网站这份 `CONSTITUENT_MAP` 不是按交易日分片的历史成员证据,不能据此认定所有历史评分都使用同一份名单;这里证明的是输入版本确实存在差异,不宣称它解释了每一分残差。
4. **上游净额也不完全一致。** 即使成交额近似一致,个股资金流按万元提供的小数精度、板块级净额来源、成员与观测时点仍可能造成净额差异。网站页脚同时提及东财板块日线资金流;本数据库没有对应 `moneyflow_ind_dc` 快照,不能将两种来源强行视为逐值相同。
5. **9 月 7 日原始资金流不完整。** 该日重聚合样本的资金流覆盖明显不足,不把它用于声称全部 11 日的评分准确度;最终 9 月 21 日的最近十日窗口从 9 月 8 日开始。
第 1–3 项有直接目录、代码和数值证据;第 4 项中的具体上游精度与发布时间机制尚未取得网站构建端证据,因此保留为差异候选原因。
## 保存与复现
- `db-raw-inputs.json.gz`:原始快照重聚合结果。
- `db-normalized-inputs.json.gz`:当前产品汇总,作为对照。
- `db-source-metadata.json`:库版本、只读设置及各数据源覆盖范围。
- `database_reproduce.py`:只读取固定文件,在对齐的排名池中独立计算。
- `database-validation.json`:精确误差、显示与排名一致数量。
运行 `database_reproduce.py` 不需要数据库凭据或在线连接。所有产物限于当前 Trellis 任务;未修改产品实现、共享规格或生产数据。
@@ -0,0 +1,72 @@
"""使用已读取的 PostgreSQL 金额快照独立算分,无数据库连接和凭据。"""
import gzip
import json
import numpy as np
import pandas as pd
from reproduce import ROOT
def load(name: str) -> list[dict]:
return json.loads(gzip.decompress((ROOT / name).read_bytes()))["rows"]
def compare(public: pd.DataFrame, rows: list[dict], normalized: bool) -> dict:
"""对齐网站实际排名池;评分只使用库内净额和成交额构造。"""
source = pd.DataFrame(rows)
source["ts_code"] = source["sector_code"]
keys = ["trade_date", "ts_code"]
if normalized:
source["type"] = source["sector_type"].map({"concept": "概念板块", "industry": "行业板块"})
keys.append("type")
for field in ["net_amount_yuan", "turnover_yuan"]:
source[field] = pd.to_numeric(source[field])
frame = public.merge(source, on=keys, validate="one_to_one").sort_values(["ts_code", "trade_date"])
frame["amount_db"] = np.floor(frame["turnover_yuan"])
frame["ratio_db"] = frame["net_amount_yuan"] / (frame["amount_db"] + 100)
for field, windows in [("amount_db", [5]), ("ratio_db", [3, 10])]:
for window in windows:
frame[f"{field}{window}"] = frame.groupby("ts_code")[field].transform(
lambda values: values.rolling(window, min_periods=window).mean()
)
frame["weight_db"] = np.log10(frame["amount_db5"] + 1) / 10
groups = frame.groupby(["trade_date", "type"])
frame["pred_Ratio_Score"] = 1000 * groups["ratio_db"].rank(pct=True) * frame["weight_db"]
frame["pred_Swing_Score"] = (
500 * (groups["ratio_db3"].rank(pct=True) + groups["ratio_db10"].rank(pct=True))
* frame["weight_db"]
)
latest = frame[frame["trade_date"].eq("2026-09-21")].copy()
result = {"joined_rows": len(frame), "joined_dates": frame["trade_date"].nunique(), "latest_rows": len(latest)}
for metric in ["Ratio", "Swing"]:
expected, prediction = f"{metric}_Score", f"pred_{metric}_Score"
errors = (latest[prediction] - latest[expected]).abs()
ranks = latest.groupby("type")[prediction].rank(ascending=False)
result[metric] = {
"checked_rows": int(errors.notna().sum()), "mean_absolute_error": errors.mean(),
"max_absolute_error": errors.max(),
"same_one_decimal_display": int(latest[prediction].round(1).eq(latest[expected].round(1)).sum()),
"same_final_rank": int(ranks.eq(latest[f"{metric}_RankPos"]).sum()),
}
# 从网站两个原始字段得到的成交额仅用于末端验证,不参与库内评分预测。
target_turnover = latest["Amount_Raw_BN"] * 1e8 / latest["Ratio_Raw_Pct"] - 100
result["turnover_within_1_01_yuan"] = int(latest["turnover_yuan"].sub(target_turnover).abs().lt(1.01).sum())
website_weight = latest["Ratio_Score"] / (1000 * latest.groupby("type")["Ratio_Raw_Pct"].rank(pct=True))
result["weight_within_1e_10"] = int(latest["weight_db"].sub(website_weight).abs().lt(1e-10).sum())
examples = latest[latest["ts_code"].isin(["BK0615.DC", "BK0653.DC", "BK1657.DC", "BK0581.DC"])][[
"ts_code", "index_name", "ratio_db", "weight_db", "pred_Ratio_Score", "Ratio_Score", "pred_Swing_Score", "Swing_Score"
]]
result["examples"] = json.loads(examples.to_json(orient="records", force_ascii=False, double_precision=15))
return result
if __name__ == "__main__":
public = pd.DataFrame(load("public-inputs.json.gz"))
result = {
"normalized_aggregate": compare(public, load("db-normalized-inputs.json.gz"), True),
"raw_snapshot_reaggregation": compare(public, load("db-raw-inputs.json.gz"), False),
}
(ROOT / "database-validation.json").write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
print(json.dumps(result, ensure_ascii=False, indent=2))
@@ -0,0 +1,55 @@
{
"postgres_version": "18.4",
"transaction_read_only": "on",
"sources": [
{
"api_name": "daily",
"first_date": "2026-08-28",
"last_date": "2026-09-21",
"snapshot_count": 17,
"source_rows": 94336
},
{
"api_name": "dc_index",
"first_date": "2026-08-28",
"last_date": "2026-09-21",
"snapshot_count": 34,
"source_rows": 17000
},
{
"api_name": "dc_member",
"first_date": "2026-08-28",
"last_date": "2026-09-21",
"snapshot_count": 17105,
"source_rows": 1678901
},
{
"api_name": "moneyflow",
"first_date": "2026-09-07",
"last_date": "2026-09-21",
"snapshot_count": 11,
"source_rows": 61054
},
{
"api_name": "moneyflow_dc",
"first_date": "2026-08-28",
"last_date": "2026-09-21",
"snapshot_count": 102,
"source_rows": 101285
},
{
"api_name": "suspend_d",
"first_date": "2026-08-28",
"last_date": "2026-09-21",
"snapshot_count": 17,
"source_rows": 193
},
{
"api_name": "trade_cal",
"first_date": "2026-08-28",
"last_date": "2026-09-21",
"snapshot_count": 17,
"source_rows": 787
}
]
}
@@ -0,0 +1,137 @@
# OneChart 波段与单日加权评分还原
研究日期:2026-09-21。这是一次只读算法研究,不包含产品修改或数据库写入。
## 结论与证据等级
已找到一组低自由度、可直接执行的公式,使用网站公开的原始流入率和净额,精确重现 2026-09-04 至 2026-09-21 共 12 个交易日、9,492 条记录的单日评分、波段评分及最终排名。两个评分最大绝对误差均为 `3.410605131648481e-13`,即浮点运算误差。
这是**对观测数据的数值还原**,不是取得网站后端源码。不能据此保证所有历史版本、未来版本及无观测的边界情况都采用相同实现。旧研究未识别出权重的结论不再适用于本次验证区间,但本任务没有修改旧报告或共享规格。
## 公式
对每个板块及日期,定义:
- `r = Ratio_Raw_Pct`,以小数表示的当日流入率,页面显示时乘 100。
- `F = Amount_Raw_BN × 100000000`,主力净流入金额,单位元。虽然字段包含 `BN`,网站实际展示单位是亿元。
- `MA_n(x)`:按板块、日期排序后最近 n 条有效观测的简单均值,包含当日;不擅自补齐公开历史中的缺失记录。稳定验证区间每天都公开了 791 个板块,但部分窗口的前置历史仍有日期缺失。
- `P_t(x)`:**同日、同板块类型**的升序排名百分位,`rank(x)/N`,取值从 `1/N` 到 1;概念和行业各自排名。该排名由原始指标计算,不使用网站最终 `*_RankPct` 作为输入。
### 单日评分
```text
Ratio_Score = 1000 × P_t(r) × W
```
### 波段评分
```text
R3 = MA_3(r)
R10 = MA_10(r)
Swing_Score = 1000 × [0.5 × P_t(R3) + 0.5 × P_t(R10)] × W
```
关键是**先分别求 3 日、10 日流入率均值的横截面排名,再各乘 50%**。如果改成先把两个均值合成波段流入率,再对合成值排名,就会得到不同评分;2026-09-21 该错误方法平均偏差约 62.54 分。
### 页面显示的波段流入率与波段净额
```text
Swing_Ratio_Val = 0.5 × MA_3(r) + 0.5 × MA_10(r)
Swing_Amount_Val = 0.5 × MA_3(Amount_Raw_BN)
+ 0.5 × MA_10(Amount_Raw_BN)
```
所以页面所称“3–10 日多周期协同加权”,在验证数据中可具体化为 **3 日与 10 日两个窗口,各占 50%**。没有证据表明必须引入 4、5、6、7、8、9 日窗口。该波段流入率展开到每日后,最近 3 日每一天占 `13/60 ≈ 21.6667%`,再往前 7 日每一天占 5%;最近三日合计占 65%。这种每日线性展开仅适用于原始波段流入率,不能直接替代带横截面排名的波段评分。
### 成交额权重 W
从公开字段可以精确验证的表达式是:
```text
V_proxy = F / r
W = log10(MA_5(V_proxy) - 99) / 10
```
若定义与网站计算口径对应的成交额 `A = V_proxy - 100`(元),则等价于:
```text
W = log10(1 + MA_5(A)) / 10
r = F / (A + 100)
```
`-99` 由公开数据中的金额/比率关系定位,在 791 个最新日样本中,反求的 5 日成交额与 `MA_5(F/r)` 的差均为约 99 元;使用该修正后,评分误差降至机器精度。单凭公开字段,不能证明后端源码里真的写了“分母加 100 元”,也不能断言这是防零分母常量而不是单位换算产生的等价结果;原始金额、成员和取整口径还需结合数据库核对。
经济含义是用成交活跃程度调节排名得分,采用对数使规模差异的影响较温和。近 5 日平均成交额为 1 亿、10 亿、100 亿、1000 亿元时,W 约为 0.8、0.9、1.0、1.1。因而评分可以超过 1000;它不是限定在 0–1000 的百分制,也不是收益概率。
作为交叉校验,JSON 中虽然未用于单日净额榜默认排序的 `Amount_Score` 也满足:
```text
Amount_Score = 1000 × P_t(Amount_Raw_BN) × W
```
## 最新日计算例子
2026-09-21,概念池 N=414,病原体防治(BK1657.DC):
| 项目 | 数值 |
| --- | ---: |
| 单日流入率 | 6.47373795% |
| 单日流入率原始百分位 | 407/414 = 0.9830917874 |
| 3 日均值的百分位 | 376/414 = 0.9082125604 |
| 10 日均值的百分位 | 275/414 = 0.6642512077 |
| 近 5 日成交额权重 W | 约 1.056243 |
| 单日评分 | 1038.383599887465 |
| 波段评分 | 830.4517488043484 |
| 网站最终单日/波段排名 | 1 / 47 |
```text
单日 = 1000 × (407/414) × W = 1038.3836 → 页面 1038.4
波段 = 1000 × [(376/414 + 275/414)/2] × W = 830.4517 → 页面 830.5
```
它的单日原始流入率并非全池最高,成交额权重加成后,单日综合评分可以排到第一。完整的三板块样例保存在 `worked-examples.json`。
## 验证范围与限制
公开数据总计 23,613 行、30 个交易日,覆盖 2026-08-11 至 2026-09-21。最新日 791 个板块:概念 414、行业 377。
| 验证对象 | 稳定区间样本 | 最大绝对误差 |
| --- | ---: | ---: |
| Ratio_Score | 9,492 | 3.41e-13 |
| Swing_Score | 9,492 | 3.41e-13 |
| Swing_Ratio_Val | 9,492 | 2.78e-17 |
| Swing_Amount_Val(亿元) | 9,492 | 5.68e-14 |
| Amount_Score(交叉校验) | 9,492 | 4.55e-13 |
最终排名按分数降序完全吻合;`RankPct = 100 × (N - RankPos + 1) / N`。金额榜的最终排名依据是原始净额,不是 Amount_Score。
复算过程中仅使用日期、板块代码、类型、原始单日流入率和净额;评分、最终排名只在最后比较时读取。因此没有用答案反过来构造预测输入。主会话执行了 `reproduce.py`;独立代理 `/root/score_formula_audit` 已完成同样输入边界下的核验,结果一致。公开前端取证由 `/root/onechart_public_evidence` 完成。
不能把上述准确度推广到整份 30 日历史:
- 最早 4/9 个观测缺少足够的 5/10 日前置历史,分别无法计算成交额权重/波段窗口。
- 单日评分在 2026-08-17 至 08-26 有差异,最大约 6.680462 分;8 月 27 日起可计算样本吻合。
- 波段评分在 2026-08-24 至 09-03 有差异,最大约 261.703574 分。8 月 27 日公开板块只有 707 条,部分后续窗口的输入不全;早期还存在板块集合或数据修订差异,尚未逐项确认原因。
- 稳定验证区间没有原始比率或分数并列,不能确定后端的并列排名规则。脚本选用 `average` 仅作为明确的复算约定。
- 全量没有 `r=0`,不能由本样本识别零净流入、零成交额、极低流动性和无历史板块的所有边界策略。
## 公开实现证据
- [首页](https://onechartlab.com/) 只读取 `${activeTab}_Score`,提示“后端特征算法算出的综合加权值”;本次缓存 `index.html:800–805`。前端没有公开评分构造函数。
- 首页 `index.html:1032` 说明“3-10 个交易日多周期协同加权”;具体 50%/50% 来自数值检验,而非这句话本身。
- 首页 `index.html:1253–1257` 按板块类型过滤;`1292–1314` 指定 Swing/Ratio 默认按各自分数排序,并用最终 RankPct 切前后 10%。
- [radar_manifest.json](https://onechartlab.com/radar_manifest.json) 列出日期分片;已保存本次 manifest。
- [完整公开 JSON](https://onechartlab.com/radar_data_latest.json) 和 [2026-09-21 分片](https://onechartlab.com/radar_data/dates/2026-09-21.22531a461a8b.json) 给出数值证据。`public-inputs.json.gz` 保存了所需字段和原始文件 SHA-256,避免未来网站更新导致样本改变。
- [Tushare daily](https://tushare.pro/document/2?doc_id=27) 的 amount 单位为千元;[moneyflow_dc](https://tushare.pro/document/2?doc_id=349) 的 net_amount 单位为万元。数据库重聚合分别乘 1000 和 10000 后统一成元。
## 复现
在仓库根目录运行:
```bash
zhixing-server/.venv/bin/python .trellis/tasks/archive/2026-09/09-21-onechart-score-reconstruction/research/reproduce.py
```
脚本读取固定样本,不需要网络或数据库凭据,输出 `public-validation.json`。本次使用 Python 3.12.11、pandas 3.0.5、NumPy 2.5.1。
数据库的独立核对另见本目录 `database-validation.md`;研究 SQL 和结果均与生产代码隔离。
@@ -0,0 +1,246 @@
[
{
"code": "BK0581.DC",
"name": "智能电网",
"db_count": 197,
"site_count": 195,
"intersection_count": 190,
"db_only": [
"002851",
"003043",
"301236",
"301669",
"605336",
"688187",
"920222"
],
"site_only": [
"001388",
"002063",
"300140",
"600522",
"920375"
],
"observed_at": "2026-09-21 10:40:57.972405+00:00"
},
{
"code": "BK0615.DC",
"name": "中药概念",
"db_count": 146,
"site_count": 145,
"intersection_count": 144,
"db_only": [
"000626",
"920367"
],
"site_only": [
"300391"
],
"observed_at": "2026-09-21 10:41:08.518051+00:00"
},
{
"code": "BK0966.DC",
"name": "碳交易",
"db_count": 142,
"site_count": 138,
"intersection_count": 137,
"db_only": [
"000875",
"002734",
"600389",
"601678",
"603612"
],
"site_only": [
"600028"
],
"observed_at": "2026-09-21 10:43:25.392556+00:00"
},
{
"code": "BK1671.DC",
"name": "超跌股",
"db_count": 167,
"site_count": 22,
"intersection_count": 7,
"db_only": [
"000002",
"000010",
"000016",
"000639",
"000677",
"002104",
"002217",
"002227",
"002368",
"002514",
"002542",
"002547",
"002657",
"002691",
"002731",
"002869",
"002891",
"300045",
"300068",
"300100",
"300245",
"300255",
"300290",
"300352",
"300396",
"300430",
"300465",
"300484",
"300492",
"300530",
"300539",
"300584",
"300652",
"300663",
"300682",
"300703",
"300723",
"300779",
"300844",
"300879",
"300896",
"300918",
"300940",
"300995",
"301000",
"301052",
"301076",
"301139",
"301325",
"301498",
"301590",
"301601",
"301622",
"301632",
"600053",
"600180",
"600325",
"600363",
"600418",
"600491",
"600530",
"600702",
"600745",
"601127",
"601865",
"601929",
"603008",
"603189",
"603200",
"603300",
"603359",
"603370",
"603382",
"603392",
"603567",
"603630",
"603718",
"603767",
"603815",
"603848",
"605499",
"688013",
"688066",
"688068",
"688089",
"688121",
"688166",
"688189",
"688201",
"688303",
"688408",
"688496",
"688499",
"688500",
"688567",
"688573",
"688577",
"688588",
"688631",
"688639",
"688648",
"688658",
"688775",
"920001",
"920005",
"920007",
"920056",
"920061",
"920075",
"920090",
"920101",
"920106",
"920108",
"920112",
"920145",
"920146",
"920184",
"920237",
"920239",
"920247",
"920252",
"920263",
"920271",
"920273",
"920274",
"920346",
"920351",
"920375",
"920392",
"920395",
"920414",
"920429",
"920454",
"920469",
"920505",
"920508",
"920522",
"920523",
"920533",
"920578",
"920579",
"920627",
"920634",
"920689",
"920693",
"920719",
"920720",
"920770",
"920781",
"920807",
"920896",
"920906",
"920914",
"920925",
"920926",
"920932",
"920942",
"920982",
"920985",
"920992"
],
"site_only": [
"000004",
"000056",
"000638",
"002630",
"300081",
"300344",
"300561",
"600355",
"600599",
"600696",
"603369",
"605199",
"688287",
"920130",
"920305"
],
"observed_at": "2026-09-21 10:50:20.423741+00:00"
}
]
@@ -0,0 +1,116 @@
{
"rows": 23613,
"dates": 30,
"windows": {
"all_available": {
"rows": 23613,
"dates": 30,
"Ratio_Score": {
"checked_rows": 20449,
"mean_absolute_error": 0.18366711208184938,
"max_absolute_error": 6.680461750893414,
"errors_above_1e-8": 1651
},
"Swing_Score": {
"checked_rows": 16494,
"mean_absolute_error": 0.5097113919412869,
"max_absolute_error": 261.7035740929656,
"errors_above_1e-8": 3199
},
"Amount_Score": {
"checked_rows": 20449,
"mean_absolute_error": 0.1882733317113178,
"max_absolute_error": 7.649258428013809,
"errors_above_1e-8": 1651
},
"Swing_Ratio_Val": {
"checked_rows": 16494,
"mean_absolute_error": 6.7217831480409905e-06,
"max_absolute_error": 0.012044989384502386,
"errors_above_1e-8": 28
},
"Swing_Amount_Val": {
"checked_rows": 16494,
"mean_absolute_error": 0.008108515254276354,
"max_absolute_error": 24.526995094,
"errors_above_1e-8": 28
}
},
"2026-09-04_to_2026-09-21": {
"rows": 9492,
"dates": 12,
"Ratio_Score": {
"checked_rows": 9492,
"mean_absolute_error": 3.2767252412841164e-14,
"max_absolute_error": 3.410605131648481e-13,
"errors_above_1e-8": 0
},
"Swing_Score": {
"checked_rows": 9492,
"mean_absolute_error": 3.441106555949961e-14,
"max_absolute_error": 3.410605131648481e-13,
"errors_above_1e-8": 0
},
"Amount_Score": {
"checked_rows": 9492,
"mean_absolute_error": 3.245420975553957e-14,
"max_absolute_error": 4.547473508864641e-13,
"errors_above_1e-8": 0
},
"Swing_Ratio_Val": {
"checked_rows": 9492,
"mean_absolute_error": 4.942276978457954e-18,
"max_absolute_error": 2.7755575615628914e-17,
"errors_above_1e-8": 0
},
"Swing_Amount_Val": {
"checked_rows": 9492,
"mean_absolute_error": 2.053051893003829e-15,
"max_absolute_error": 5.684341886080802e-14,
"errors_above_1e-8": 0
},
"Ratio_ranking": {
"rank_position_mismatches": 0,
"rank_percentile_max_error": 1.4210854715202004e-14
},
"Swing_ranking": {
"rank_position_mismatches": 0,
"rank_percentile_max_error": 1.4210854715202004e-14
}
},
"2026-09-14_to_2026-09-21": {
"rows": 4746,
"dates": 6,
"Ratio_Score": {
"checked_rows": 4746,
"mean_absolute_error": 3.302167267444722e-14,
"max_absolute_error": 3.410605131648481e-13,
"errors_above_1e-8": 0
},
"Swing_Score": {
"checked_rows": 4746,
"mean_absolute_error": 3.4959814225990485e-14,
"max_absolute_error": 3.410605131648481e-13,
"errors_above_1e-8": 0
},
"Amount_Score": {
"checked_rows": 4746,
"mean_absolute_error": 3.260761983972608e-14,
"max_absolute_error": 4.547473508864641e-13,
"errors_above_1e-8": 0
},
"Swing_Ratio_Val": {
"checked_rows": 4746,
"mean_absolute_error": 4.952800461538844e-18,
"max_absolute_error": 2.7755575615628914e-17,
"errors_above_1e-8": 0
},
"Swing_Amount_Val": {
"checked_rows": 4746,
"mean_absolute_error": 2.056700338399156e-15,
"max_absolute_error": 5.684341886080802e-14,
"errors_above_1e-8": 0
}
}
}
}
@@ -0,0 +1 @@
{"AVAILABLE_DATES":["2026-08-11","2026-08-12","2026-08-13","2026-08-14","2026-08-17","2026-08-18","2026-08-19","2026-08-20","2026-08-21","2026-08-24","2026-08-25","2026-08-26","2026-08-27","2026-08-28","2026-08-31","2026-09-01","2026-09-02","2026-09-03","2026-09-04","2026-09-07","2026-09-08","2026-09-09","2026-09-10","2026-09-11","2026-09-14","2026-09-15","2026-09-16","2026-09-17","2026-09-18","2026-09-21"],"DATA_SOURCE":"","LATEST_DATE":"2026-09-21","files":{"constituents":"radar_data/constituents.25ffae2b8f53.json","dates":{"2026-08-11":"radar_data/dates/2026-08-11.3ed082fdf5aa.json","2026-08-12":"radar_data/dates/2026-08-12.a61e39835037.json","2026-08-13":"radar_data/dates/2026-08-13.db6f36c2dd06.json","2026-08-14":"radar_data/dates/2026-08-14.fb36a77c6ae4.json","2026-08-17":"radar_data/dates/2026-08-17.d69d703faef6.json","2026-08-18":"radar_data/dates/2026-08-18.2c339eebabd6.json","2026-08-19":"radar_data/dates/2026-08-19.fca1cdf24170.json","2026-08-20":"radar_data/dates/2026-08-20.88ff543739cc.json","2026-08-21":"radar_data/dates/2026-08-21.a08c301a8dd7.json","2026-08-24":"radar_data/dates/2026-08-24.9135129011a3.json","2026-08-25":"radar_data/dates/2026-08-25.9393e22d8754.json","2026-08-26":"radar_data/dates/2026-08-26.f49c721846fb.json","2026-08-27":"radar_data/dates/2026-08-27.2dc5f5627eeb.json","2026-08-28":"radar_data/dates/2026-08-28.c92100ea9ae1.json","2026-08-31":"radar_data/dates/2026-08-31.c094ac3098c9.json","2026-09-01":"radar_data/dates/2026-09-01.e6253d4364f6.json","2026-09-02":"radar_data/dates/2026-09-02.1e4a8e6102d5.json","2026-09-03":"radar_data/dates/2026-09-03.ec356ce27a57.json","2026-09-04":"radar_data/dates/2026-09-04.deaf468364a2.json","2026-09-07":"radar_data/dates/2026-09-07.874c2aaa1925.json","2026-09-08":"radar_data/dates/2026-09-08.f93f7b9b2eab.json","2026-09-09":"radar_data/dates/2026-09-09.7a9919c800d9.json","2026-09-10":"radar_data/dates/2026-09-10.29e5d3c66a03.json","2026-09-11":"radar_data/dates/2026-09-11.738e1fec6654.json","2026-09-14":"radar_data/dates/2026-09-14.6b21a73a38f6.json","2026-09-15":"radar_data/dates/2026-09-15.9d5afec9a676.json","2026-09-16":"radar_data/dates/2026-09-16.48d01087bfbd.json","2026-09-17":"radar_data/dates/2026-09-17.04d53b204a8f.json","2026-09-18":"radar_data/dates/2026-09-18.b7a298aedf8d.json","2026-09-21":"radar_data/dates/2026-09-21.22531a461a8b.json"},"rank_history":"radar_data/rank_history.1d389cbfa3e1.json"},"schema_version":1,"version":"7a4fc78a8366"}
@@ -0,0 +1 @@
WITH s AS MATERIALIZED (SELECT DISTINCT ON (api_name,target_trade_date,partition_key) api_name,target_trade_date,partition_key,payload,observed_at FROM sector_radar_source_snapshot WHERE target_trade_date BETWEEN '2026-09-07' AND '2026-09-21' AND api_name IN ('dc_member','daily','moneyflow_dc') ORDER BY api_name,target_trade_date,partition_key,observed_at DESC), d AS (SELECT DISTINCT ON (target_trade_date,e->>'ts_code') target_trade_date,e->>'ts_code' AS code,(e->>'amount')::numeric*1000 AS amount FROM s CROSS JOIN LATERAL jsonb_array_elements(payload) e WHERE api_name='daily' ORDER BY target_trade_date,e->>'ts_code',observed_at DESC), f AS (SELECT DISTINCT ON (target_trade_date,e->>'ts_code') target_trade_date,e->>'ts_code' AS code,(e->>'net_amount')::numeric*10000 AS net FROM s CROSS JOIN LATERAL jsonb_array_elements(payload) e WHERE api_name='moneyflow_dc' ORDER BY target_trade_date,e->>'ts_code',observed_at DESC), m AS (SELECT DISTINCT target_trade_date,e->>'ts_code' AS sector,e->>'con_code' AS code FROM s CROSS JOIN LATERAL jsonb_array_elements(payload) e WHERE api_name='dc_member' AND partition_key<>'all') SELECT m.target_trade_date AS trade_date,m.sector AS sector_code,count(*) AS member_count,count(d.amount) AS daily_count,count(f.net) AS flow_count,sum(d.amount) AS turnover_yuan,sum(f.net) AS net_amount_yuan,sum(d.amount) FILTER (WHERE m.code LIKE '%.BJ') AS bj_turnover_yuan FROM m LEFT JOIN d ON d.target_trade_date=m.target_trade_date AND d.code=m.code LEFT JOIN f ON f.target_trade_date=m.target_trade_date AND f.code=m.code GROUP BY m.target_trade_date,m.sector ORDER BY m.target_trade_date,m.sector
@@ -0,0 +1,97 @@
"""从固定公开输入复算 OneChart 指标;预测阶段不使用网站评分或排名。"""
from __future__ import annotations
import gzip
import json
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parent
def calculate(rows: list[dict]) -> pd.DataFrame:
"""按板块有观测的历史记录计算;预热不足保留 NaN,不填补未知输入。"""
frame = pd.DataFrame(rows).sort_values(["ts_code", "trade_date"])
if frame.duplicated(["trade_date", "type", "ts_code"]).any():
raise ValueError("公开输入存在重复主键")
if frame["Ratio_Raw_Pct"].eq(0).any():
raise ValueError("零流入率无法通过金额/比率反求成交额;需要原始成交额")
frame["turnover_proxy_yuan"] = (
frame["Amount_Raw_BN"] * 1e8 / frame["Ratio_Raw_Pct"]
)
for field in ["Ratio_Raw_Pct", "Amount_Raw_BN", "turnover_proxy_yuan"]:
for window in [3, 5, 10]:
frame[f"{field}_ma{window}"] = frame.groupby("ts_code")[field].transform(
lambda series: series.rolling(window, min_periods=window).mean()
)
# -99 是公开金额/比率所能直接验证的代数修正,不把它当成原始成交额。
frame["liquidity_weight"] = (
np.log10(frame["turnover_proxy_yuan_ma5"] - 99) / 10
)
groups = frame.groupby(["trade_date", "type"])
for field in ["Ratio_Raw_Pct", "Ratio_Raw_Pct_ma3", "Ratio_Raw_Pct_ma10", "Amount_Raw_BN"]:
frame[f"{field}_percentile"] = groups[field].rank(method="average", pct=True)
frame["pred_Ratio_Score"] = (
1000 * frame["Ratio_Raw_Pct_percentile"] * frame["liquidity_weight"]
)
frame["pred_Swing_Score"] = (
1000
* 0.5
* (frame["Ratio_Raw_Pct_ma3_percentile"] + frame["Ratio_Raw_Pct_ma10_percentile"])
* frame["liquidity_weight"]
)
frame["pred_Amount_Score"] = (
1000 * frame["Amount_Raw_BN_percentile"] * frame["liquidity_weight"]
)
frame["pred_Swing_Ratio_Val"] = (
0.5 * (frame["Ratio_Raw_Pct_ma3"] + frame["Ratio_Raw_Pct_ma10"])
)
frame["pred_Swing_Amount_Val"] = (
0.5 * (frame["Amount_Raw_BN_ma3"] + frame["Amount_Raw_BN_ma10"])
)
return frame
def validate(frame: pd.DataFrame) -> dict:
"""比较固定公式的预测与网站输出,报告预热、全历史与稳定覆盖窗口。"""
metrics = ["Ratio_Score", "Swing_Score", "Amount_Score", "Swing_Ratio_Val", "Swing_Amount_Val"]
report = {"rows": len(frame), "dates": frame["trade_date"].nunique(), "windows": {}}
for name, subset in [
("all_available", frame),
("2026-09-04_to_2026-09-21", frame[frame["trade_date"].ge("2026-09-04")]),
("2026-09-14_to_2026-09-21", frame[frame["trade_date"].ge("2026-09-14")]),
]:
result = {"rows": len(subset), "dates": subset["trade_date"].nunique()}
for metric in metrics:
errors = (subset[metric] - subset[f"pred_{metric}"]).abs().dropna()
result[metric] = {
"checked_rows": len(errors), "mean_absolute_error": errors.mean(),
"max_absolute_error": errors.max(), "errors_above_1e-8": int(errors.gt(1e-8).sum()),
}
if name == "2026-09-04_to_2026-09-21":
groups = subset.groupby(["trade_date", "type"])
count = groups["ts_code"].transform("size")
for metric in ["Ratio", "Swing"]:
ranks = groups[f"pred_{metric}_Score"].rank(ascending=False)
percentiles = 100 * (count - ranks + 1) / count
result[f"{metric}_ranking"] = {
"rank_position_mismatches": int(ranks.ne(subset[f"{metric}_RankPos"]).sum()),
"rank_percentile_max_error": percentiles.sub(subset[f"{metric}_RankPct"]).abs().max(),
}
report["windows"][name] = result
return report
if __name__ == "__main__":
fixture = json.loads(gzip.decompress((ROOT / "public-inputs.json.gz").read_bytes()))
calculated = calculate(fixture["rows"])
result = validate(calculated)
(ROOT / "public-validation.json").write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
print(json.dumps(result, ensure_ascii=False, indent=2))
@@ -0,0 +1,59 @@
[
{
"index_name":"中药概念 (概念)",
"ts_code":"BK0615.DC",
"type":"概念板块",
"Ratio_Raw_Pct":0.079794960955587,
"Ratio_Raw_Pct_ma3":0.033754406576305,
"Ratio_Raw_Pct_ma10":-0.005866010935792,
"Ratio_Raw_Pct_percentile":0.995169082125604,
"Ratio_Raw_Pct_ma3_percentile":0.929951690821256,
"Ratio_Raw_Pct_ma10_percentile":0.64975845410628,
"turnover_proxy_yuan_ma5":26311461138.200000762939453,
"liquidity_weight":1.042014496452983,
"pred_Ratio_Score":1036.9806099966886,
"Ratio_Score":1036.9806099966886,
"pred_Swing_Score":823.040435604167897,
"Swing_Score":823.040435604167897,
"Ratio_RankPos":2,
"Swing_RankPos":49
},
{
"index_name":"养老概念 (概念)",
"ts_code":"BK0653.DC",
"type":"概念板块",
"Ratio_Raw_Pct":0.065820703429991,
"Ratio_Raw_Pct_ma3":0.037193483577325,
"Ratio_Raw_Pct_ma10":0.006875783666745,
"Ratio_Raw_Pct_percentile":0.985507246376812,
"Ratio_Raw_Pct_ma3_percentile":0.949275362318841,
"Ratio_Raw_Pct_ma10_percentile":0.963768115942029,
"turnover_proxy_yuan_ma5":32135609228.599998474121094,
"liquidity_weight":1.050698653636457,
"pred_Ratio_Score":1035.471136917088188,
"Ratio_Score":1035.471136917088415,
"pred_Swing_Score":1005.016103478350374,
"Swing_Score":1005.016103478350374,
"Ratio_RankPos":3,
"Swing_RankPos":2
},
{
"index_name":"病原体防治 (概念)",
"ts_code":"BK1657.DC",
"type":"概念板块",
"Ratio_Raw_Pct":0.064737379547795,
"Ratio_Raw_Pct_ma3":0.029828519014713,
"Ratio_Raw_Pct_ma10":-0.005451455299003,
"Ratio_Raw_Pct_percentile":0.983091787439614,
"Ratio_Raw_Pct_ma3_percentile":0.908212560386474,
"Ratio_Raw_Pct_ma10_percentile":0.664251207729468,
"turnover_proxy_yuan_ma5":36511340146.0,
"liquidity_weight":1.056242777281107,
"pred_Ratio_Score":1038.383599887464925,
"Ratio_Score":1038.383599887464925,
"pred_Swing_Score":830.451748804348426,
"Swing_Score":830.451748804348426,
"Ratio_RankPos":1,
"Swing_RankPos":47
}
]
@@ -0,0 +1,33 @@
{
"id": "onechart-score-reconstruction",
"name": "onechart-score-reconstruction",
"title": "还原 OneChart 波段与单日资金流评分",
"description": "只读数值还原 OneChart 单日与波段资金评分;12 日 9492 条公开样本精确重现,并完成 PostgreSQL 原始快照对照。",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-09-21",
"completedAt": "2026-09-21",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "研究完成。没有实施产品修改、修改数据库、共享规格推广或提交。输入版本与成员口径差异详见 research/database-validation.md。",
"meta": {
"work_kind": "read_only_research",
"public_validation_rows": 9492,
"public_score_max_abs_error": 3.410605131648481e-13,
"database_latest_rows": 791,
"database_ratio_mae": 0.9783318452555938,
"database_swing_mae": 1.9075528008117222
}
}
@@ -0,0 +1,9 @@
{"file": ".trellis/spec/backend/index.md", "reason": "后端包边界与必需检查"}
{"file": ".trellis/spec/backend/tushare-listed-stock-universe.md", "reason": "继续遵守当前上市证券母集,不能为对齐网站擅自改变范围"}
{"file": ".trellis/spec/backend/http-api-contracts.md", "reason": "评分与三指标变化字段的端到端契约"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "镜像榜单、控件与可访问性"}
{"file": ".trellis/spec/frontend/type-safety.md", "reason": "API nullable 字段与严格解析"}
{"file": ".trellis/tasks/archive/2026-09/09-21-radar-weighted-score-rank-change/research/formula-evidence.md", "reason": "已验证的评分结构及数值证据边界"}
{"file": ".trellis/tasks/archive/2026-09/09-21-radar-weighted-score-rank-change/research/code-evidence.md", "reason": "现有实现定位与需保持的版本/持久化行为"}
{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "后端 Ruff、Pyright、pytest 和持久化检查"}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "前端格式、lint、类型、行为与构建检查"}
@@ -0,0 +1,48 @@
# 设计:加权评分与排名变化
## 计算与领域边界
评分留在 sector_radar 领域/发布阶段,不在 React 中根据当前页数据计算百分位。当前每板块 MetricStrategy 只能得到该板块历史,因此新增全池评分步骤,接收同一目标日所有板块的原始特征,按日期、类型分池计算。
新版本拟为 `zhixing_ratio_weighted_v2` 和 `zhixing_swing_weighted_v2`。设 A 为现有聚合成交额元、F 为聚合主力净额元;按前置研究的可复算输入约定取 `r=F/(A+100)`,A<=0 或输入未知时不制造比率。W=`log10(1+MA5(A))/10`;P 为原始指标升序平均名次/N。
```text
单日原值 = r
单日评分 = 1000 * P(r) * W
波段原值 = .5*MA3(r) + .5*MA10(r)
波段评分 = 1000*(.5*P(MA3(r)) + .5*P(MA10(r)))*W
```
使用 Decimal 计算金额、比率和对数;仅在输出展示时保留 1 位小数。P 的并列值采用平均名次,最终分数并列沿用 sector_code 升序作为稳定破同分规则。原始值与 weighted_score 分离;窗口不足时保留可确定的原始值,评分和对应名次为空,不以原始值替代缺失评分。质量状态和覆盖率继续传播。
新版本使用保存的交易日历确定窗口与对比日期,缺少某个应有交易日输入时保留未知;不得悄悄以更早成功发布替代缺失交易日。第一个可复算日期受库内真实历史覆盖限制。
## 排名、历史与版本
`rank_metric_observations` 对金额继续使用原始金额,对新版本单日/波段使用 weighted_score。上下普通榜筛选使用最终百分位;底榜排序也须与评分键一致。排名变化在同版本最终名次上计算 `past_rank-current_rank`;取 ceil(有效排名池大小×10%),历史不可比者不进入变化候选。
一行排名变化响应提供所选基准兼容字段 rank_change,以及 amount/ratio/swing 三个变化值,供中心主列和两侧辅助列复用。查询选出的前后榜由所选基准确定;点击辅助列只改变该侧当前候选排列。字段均允许 null。
旧发布按其实际 metric_versions 解析定义与历史,不用新版本常量把旧数据过滤成空,也不比较 v1 与 v2 名次。榜单、详情和历史折线共享版本解析与排名事实。首次切换期间旧发布评分显示“—”;历史重算完成后,同日最新成功发布提供新评分。
## 持久化与历史重算
新增 Alembic migration,为 `sector_radar_ranking` 增加 nullable NUMERIC weighted_score,保持 metric_value 原义。同步所有 INSERT、SELECT、序列化和反序列化;内存仓库保持同等语义。
发布构建使用统一评分服务,版本参与 input_hash。增加离线重算入口,仅使用已落库聚合事实和原发布的来源关联,不初始化 Tushare 客户端。按交易日先后创建新派生发布,保留旧发布与原始快照;固定源 publication IDs 避免重算期间新发布改变本轮输入。复用现有按日锁、事务和 last-good 规则,重算幂等键包含源发布身份/输入指纹和新策略版本。
重算时必须复制/关联原聚合的 pct_change、leading_code 及来源组,使新发布的详情仍可读取;不能只写 ranking 而产生空详情。适配器需要提供 publication 精确的 aggregate records 与来源读取,避免现有 history 方法丢失这些元信息。
生产数据库的迁移和重算是独立运行步骤;在本地代码和验证准备完成前不执行外部写入。回退可恢复旧代码版本并重新选择保留的旧发布;nullable 新列本身保持兼容。
## HTTP 与前端
- 在榜单行和必要详情摘要中扩展 weighted_score;排名变化补充三指标变化映射与对比日期信息,同步 Pydantic、TypeScript、解析器和 query 测试。
- 单日/波段左右增加评分列,1 位小数、缺失“—”;其余原始百分比保留独立展示。
- rank_change 使用独立次行,左侧三枚排序基准按钮,右侧近 1–5 日下拉。默认波段率和 1 日,仅在 URL 没有显式值时采用默认。
- 标题为排名飙升榜/排名暴跌榜,中央为“{基准全名}排名变化”;主列显示所选指标变化,辅助列显示另外两项,增加涨跌幅。
- 延续现有主题、表格镜像、板块详情入口和响应式横向滚动;不添加未要求的全局导出、搜索改版或主题重制。
## 主要风险
新算法会改变单日/波段榜及历史名次;必须依靠新版本与离线重算切换,不能给旧名次套新评分。知行的 1000 个板块及按日成员与 OneChart 的 791 板块输入不同,算法结构一致不保证逐值一致。原站并列、缺失与极低成交额策略未完全公开,本系统明确采用上述确定规则。
@@ -0,0 +1,7 @@
{"file": ".trellis/spec/backend/index.md", "reason": "后端包边界与必需检查"}
{"file": ".trellis/spec/backend/tushare-listed-stock-universe.md", "reason": "继续遵守当前上市证券母集,不能为对齐网站擅自改变范围"}
{"file": ".trellis/spec/backend/http-api-contracts.md", "reason": "评分与三指标变化字段的端到端契约"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "镜像榜单、控件与可访问性"}
{"file": ".trellis/spec/frontend/type-safety.md", "reason": "API nullable 字段与严格解析"}
{"file": ".trellis/tasks/archive/2026-09/09-21-radar-weighted-score-rank-change/research/formula-evidence.md", "reason": "已验证的评分结构及数值证据边界"}
{"file": ".trellis/tasks/archive/2026-09/09-21-radar-weighted-score-rank-change/research/code-evidence.md", "reason": "现有实现定位与需保持的版本/持久化行为"}
@@ -0,0 +1,39 @@
# 执行计划
状态:本地实现与范围内验收完成;全仓既有失败已在实施前版本复现。详细结果及生产应用命令见 `validation.md`。
1. 完整阅读要修改的文件;确认已保存的评分研究、接口契约、版本及缺失语义。核对需要使用的 Alembic/Pydantic 等当前版本与官方文档。
2. 实现版本化评分特征和全池计算,分离 raw value/score;更换单日/波段排序键,补独立数值样例、并列、未来数据排除及缺失窗口测试。
3. 新增 nullable score 列迁移;同步 PostgreSQL/内存仓库所有读写与 historical ranking 查询。补充真实数据库往返、旧行 NULL 和事务失败测试。
4. 接入发布构建与 input_hash;实现不访问 Tushare 的历史重算入口、新旧版本解析及严格对比日期。验证幂等、旧发布保留、失败 last-good 与详情来源完整。
5. 扩展榜单/详情 HTTP 输出和前端类型解析,支持评分、三指标变化值及对比日期;覆盖不匹配版本与缺失历史。
6. 单日/波段表格添加评分列与侧内排序;按截图调整排名变化次级控制行、双榜标题、中央标题、涨跌幅和两项辅助变化列,保持 URL/查询联动。
7. 执行领域、发布、读取、HTTP、仓库、前端 API 和页面行为测试;用本地测试数据库验证迁移和离线重算,避免使用用户生产库进行测试写入。
8. 完成后端 Ruff/Pyright/pytest 与前端格式、lint、typecheck、Vitest/build。浏览器实际检查三基准、1–5 日、空历史、评分列和窄屏;保存必要截图和验证结果。
9. 检查 diff 范围、已有用户改动和未验证事项;交付本地成果及明确的迁移/重算命令,不自行提交、部署或写入生产库。
## 验证命令
```bash
cd zhixing-server
uv run ruff format --check .
uv run ruff check .
uv run pyright
uv run pytest
```
```bash
cd zhixing-web
pnpm check
pnpm build
```
实现期间先执行相关测试,最后执行项目规定完整检查;通过后仅在新改动或未解决问题需要时重复。
## 高风险核验点
- P 必须来自完整同类池,不能来自当前页面 TOP10% 子集。
- Swing 分数是两次排名后合成,不是合成 raw 后再排名。
- 历史不足时不能伪造评分/0变化;不能跨失败交易日跳位或跨版本相减。
- migration 列顺序涉及所有 ranking SELECT 与 fake row,不得只改写入。
- 离线重算的新发布必须可继续打开详情,且不得重新请求上游或改变旧快照。
@@ -0,0 +1,46 @@
# 雷达加权评分与排名变化复刻
## Goal
根据已验证的 OneChart 评分公式,为单日和波段榜增加加权评分,并复刻排名变化的排序基准、1至5日窗口和双榜交互。
## Requirements
- R1:单日流入率与波段流入率双榜各增加“加权评分”列,左右镜像排列,保留 1 位小数,支持表头排序;原始流入率仍单独展示。
- R2:采用本轮前置研究验证的评分结构。单日和波段榜按各自加权评分形成最终排名;波段原始流入率采用 3 日、10 日单日流入率均值各 50%。单日净额榜按净额形成排名。
- R3:排名变化视图按参考截图提供独立次级控制行:左侧“排序基准”含波段率、单日率、单日额,右侧“统计天数”含近 1–5 日;首次进入默认波段率、近 1 日,并保留 URL 中显式指定的选择。
- R4:排名变化视图展示“排名飙升榜 TOP 10% / 排名暴跌榜 BOTTOM 10%”,中央标题随基准切换;每行显示涨跌幅、所选基准的名次变化以及其余两个指标的名次变化。上下榜由所选基准决定,表头排序只重排该榜现有候选。
- R5:名次变化为过去名次减当前名次,统计基于交易日,概念与行业分池。历史缺失、算法版本不兼容时显示“—”,不得伪造 0 或混用新旧算法名次。
- R6:评分、最终名次、排名变化和详情历史使用相同算法版本;提供基于已有聚合事实重算历史的能力,旧发布保持可追溯。已有旧版本数据升级前仍能安全读取。
- R7:继续使用知行当前数据库、板块池、按日成员快照与当前上市股票范围。复刻评分结构与交互,不以抓取 OneChart 结果替代业务计算,也不承诺与不同输入口径的网站逐值相等。
## 范围边界
- 本任务包含必要的领域计算、数据库派生字段迁移、HTTP 契约、前端及测试,以及历史重算命令。
- 不包含调整证券母集、将网站当前成员回填历史、额外的榜单导出或搜索功能重写。
- 本地实现与非破坏性验证按任务执行;生产迁移、生产历史重算、部署不在本轮执行范围内;Git 提交与推送已由后续用户请求明确授权。
## Acceptance Criteria
- [x] AC1/R1:单日/波段榜显示左右“加权评分”列,有限值为 1 位小数,缺失为“—”,排序与表头状态正确。
- [x] AC2/R2:独立样例验证百分位、5 日成交额权重、3/10 日各 50% 及“先分别排名再合成”;高原始比率但低评分的反例能按评分正确排名。
- [x] AC3/R3–R4:三基准 × 五窗口切换正确更新双榜、中央标题和辅助两列,URL 刷新可恢复;正负方向、镜像布局和窄屏横向滚动正确。
- [x] AC4/R5:交易日跨周末、缺发布、缺板块、零变化、并列及新旧版本不匹配均有测试,榜单选取为对应池 ceil(N×10%) 个有效变化候选的上限。
- [x] AC5/R6:旧行 weighted_score 为空时可读取;历史重算创建可追溯新发布且幂等,不请求外部数据;失败不影响最近成功发布,详情与榜单的当前名次一致。
- [ ] AC6/R7:沿用当前股票/板块数据范围;单元、HTTP、前端、持久化及浏览器验证覆盖变更,项目规定的质量检查通过。
## 已确认的现状与依据
- 前置研究:`research/formula-evidence.md`。公开输入 9,492 条样本的评分与最终名次可精确重现;数据库输入存在成员/板块池差异。
- 当前单日/波段只有原始 metric_value;旧波段策略实际为 3 至 10 日八个净额/成交额窗口等权,并非新研究中的两个窗口,见 `domain/metrics.py:140` 起。
- 当前排名变化已有基础 API 与 1–5 日变化值,但 UI 控件、标题、辅助列不匹配截图,且历史名次基于旧原始指标,定位见 `research/code-evidence.md`。
## 决策状态
用户已明确回复“同意,开始实施”,批准本版方案。执行现有数据口径下的评分和交互升级;按个人约定由主代理编码与最终验证,子代理仅承担只读探索和独立核验。
## 本轮验收状态
AC1–AC5 已完成。AC6 的变更范围检查和浏览器验收通过;全仓检查仍有在实施前 HEAD 上独立复现的既有失败,因此保留未完全通过状态。详见 `validation.md`。本地实现已交付;功能代码已提交为 `b9981aa`,生产迁移、重算和部署未执行。
用户已于本轮授权提交并推送。本任务的实现和范围内验证完成;AC6 的全仓基线失败仍明确保留为已知限制,不扩展修复选股/行情模块。线上重算说明已补入 `docs/market-data-sync.md`。

Some files were not shown because too many files have changed in this diff Show More