Compare commits
81 Commits
6b42bcfe15
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6219fb2e51 | |||
| be9c35eadd | |||
| 4af96cee4b | |||
| ca778fe38f | |||
| 62c66f02ad | |||
| 1197b78f9a | |||
| 39f8fe5fbf | |||
| d79e78a411 | |||
| a3e51e2dae | |||
| a67f32b45f | |||
| fad932b0de | |||
| f020362fb0 | |||
| bd84ddd2f2 | |||
| c460ba3524 | |||
| 30bf94c908 | |||
| b9981aa48d | |||
| 669e89d3c3 | |||
| 2f1c4f36c2 | |||
| 6e39ae98d8 | |||
| 3f50ca3076 | |||
| 78b6915fff | |||
| bae5368da5 | |||
| d7586b27f6 | |||
| e567e5f717 | |||
| ca3bd7a4b1 | |||
| cd230bcb73 | |||
| 07c5b25043 | |||
| 7f93d6b0f5 | |||
| 09d20b3b33 | |||
| 68282f5d46 | |||
| 4ee757dbce | |||
| ade1713e86 | |||
| 3e0aa496f3 | |||
| 12642f3c2d | |||
| 7e0f13d678 | |||
| cacaed08c1 | |||
| 41a9b4eb9a | |||
| 04e3e775a7 | |||
| 50cac3575a | |||
| 58380d0ddd | |||
| 3b50e75ef8 | |||
| 3b106613d2 | |||
| 79476252b1 | |||
| bd9b7d65a8 | |||
| 9163590070 | |||
| 61aaed825d | |||
| 6b8c5bd33f | |||
| 98ee1cc996 | |||
| b44988aa81 | |||
| 629f933cf9 | |||
| 6030bf833d | |||
| f41ad0915e | |||
| 3da90f7df8 | |||
| 162ff5da97 | |||
| ea2d0f75f1 | |||
| ba33dda984 | |||
| af1e1eaa66 | |||
| c592525087 | |||
| c944b219d9 | |||
| 4b8a7bd484 | |||
| 8cdb3be765 | |||
| 70bd766b8e | |||
| 66d417256f | |||
| 3fc78ec9b1 | |||
| feb8fd4feb | |||
| 9145fe4b2c | |||
| fe6d2b4fe5 | |||
| 21ec0b2353 | |||
| e2037f48f0 | |||
| d5d162ccd8 | |||
| bed9cab437 | |||
| ff028912e6 | |||
| 5f34be5395 | |||
| be31235fc7 | |||
| c8b08add76 | |||
| 23dd398cd7 | |||
| 4e652596e5 | |||
| d5c62ef650 | |||
| 60391b9b4b | |||
| 62a831967a | |||
| 03689028c4 |
@@ -31,30 +31,42 @@ The list is discovered at runtime, so adding a new directory under `bundled-skil
|
||||
|
||||
## 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 |
|
||||
| --- | --- | --- |
|
||||
| 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 |
|
||||
All 21 platforms receive the full bundled-skill set:
|
||||
|
||||
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`.
|
||||
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))`.
|
||||
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.
|
||||
|
||||
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)
|
||||
|
||||
@@ -67,10 +79,10 @@ The mechanism that auto-dispatches bundled skills to platform skill roots lives
|
||||
|
||||
2. `packages/cli/src/configurators/shared.ts`
|
||||
- `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 the same shape as a `Map<filePath, content>` for the update / hash pipeline.
|
||||
- `collectSkillTemplates(skillsRoot, workflowSkills, bundledSkills)` returns workflow skills and bundled skill files together as a `Map<filePath, content>` rooted at `skillsRoot`.
|
||||
- `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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Common files:
|
||||
| Claude Code | `.claude/settings.json` |
|
||||
| Cursor | `.cursor/hooks.json` |
|
||||
| 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 |
|
||||
| Gemini CLI | `.gemini/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. |
|
||||
| `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-spec-context.py` | Matches path-scoped specs and manages budgeted delivery state. |
|
||||
| `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.
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "^(?:clear|compact)$",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 -X utf8 .codex/hooks/inject-spec-context.py",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+844
@@ -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)
|
||||
Regular → Executable
+13
-5
@@ -215,11 +215,12 @@ def truncate_utf8(data: bytes, cap: int) -> bytes:
|
||||
seq_len = 4
|
||||
else:
|
||||
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):
|
||||
i -= 1
|
||||
return truncated[: i - 1]
|
||||
|
||||
return truncated[:i]
|
||||
return truncated
|
||||
|
||||
|
||||
class _Budget:
|
||||
@@ -877,8 +878,15 @@ def _handle_codex_subagent_start(input_data: dict) -> None:
|
||||
if not subagent_type or not parent_session_id:
|
||||
return
|
||||
|
||||
cwd = _string_value(input_data.get("cwd")) or os.getcwd()
|
||||
repo_root = find_repo_root(cwd)
|
||||
# Payload cwd first, then our own — some hosts (CodeBuddy IDE 4.10.4)
|
||||
# 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:
|
||||
return
|
||||
|
||||
|
||||
Regular → Executable
+52
-16
@@ -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
|
||||
its per-turn event to ``BeforeAgent`` and its schema validator rejects the
|
||||
legacy name. ``_detect_platform`` picks the right value at runtime.
|
||||
Breadcrumb text is pulled exclusively from workflow.md
|
||||
[workflow-state:STATUS] tag blocks — workflow.md is the single source of
|
||||
truth. There are no fallback dicts in this script: when workflow.md is
|
||||
Breadcrumb text is pulled exclusively from the resolved workflow file's
|
||||
[workflow-state:STATUS] tag blocks — the active task may select a
|
||||
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
|
||||
"Refer to workflow.md for current step." line so users see (and fix)
|
||||
the broken state instead of the hook silently masking it.
|
||||
|
||||
Shared across all hook-capable platforms (Claude, Cursor, Codex, Qoder,
|
||||
CodeBuddy, Droid, Gemini, Copilot, Kiro). Kiro wires this via the CLI
|
||||
Which platforms register this hook is decided by SHARED_HOOKS_BY_PLATFORM
|
||||
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``
|
||||
``promptSubmit`` event; its output branch emits a plain-text breadcrumb
|
||||
(Kiro adds hook stdout directly to the conversation context). Written to
|
||||
each platform's hooks directory via writeSharedHooks() at init time.
|
||||
(Kiro adds hook stdout directly to the conversation context).
|
||||
|
||||
Silent exit 0 cases (no output):
|
||||
- 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:
|
||||
if isinstance(input_data.get("cursor_version"), str):
|
||||
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 = {
|
||||
# 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",
|
||||
"CLAUDE_PROJECT_DIR": "claude",
|
||||
"CURSOR_PROJECT_DIR": "cursor",
|
||||
"CODEBUDDY_PROJECT_DIR": "codebuddy",
|
||||
"FACTORY_PROJECT_DIR": "droid",
|
||||
@@ -108,6 +118,8 @@ def _detect_platform(input_data: dict) -> str | None:
|
||||
"KIRO_PROJECT_DIR": "kiro",
|
||||
"COPILOT_PROJECT_DIR": "copilot",
|
||||
"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():
|
||||
if os.environ.get(env_name):
|
||||
@@ -183,16 +195,40 @@ _TAG_RE = re.compile(
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
def load_breadcrumbs(root: Path) -> dict[str, str]:
|
||||
"""Parse workflow.md for [workflow-state:STATUS] blocks.
|
||||
def _resolve_workflow_md(root: Path, input_data: dict) -> Path:
|
||||
"""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
|
||||
(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
|
||||
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():
|
||||
return {}
|
||||
try:
|
||||
@@ -411,7 +447,7 @@ def main() -> int:
|
||||
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
|
||||
|
||||
templates = load_breadcrumbs(root)
|
||||
templates = load_breadcrumbs(root, data)
|
||||
platform = _detect_platform(data)
|
||||
task = get_active_task(root, data)
|
||||
if task is None:
|
||||
|
||||
Regular → Executable
+20
-1
@@ -452,6 +452,25 @@ def _strip_breadcrumb_tag_blocks(content: str) -> str:
|
||||
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:
|
||||
"""Inject only the compact Phase Index summary for SessionStart."""
|
||||
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("<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("<guidelines>\n")
|
||||
|
||||
@@ -8,8 +8,8 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
runs-on: tencent-prod
|
||||
timeout-minutes: 60
|
||||
|
||||
env:
|
||||
COMPOSE_PROJECT_NAME: zhixing-system
|
||||
|
||||
@@ -20,3 +20,6 @@ node_modules/
|
||||
.pnpm-store/
|
||||
dist/
|
||||
coverage/
|
||||
|
||||
# Playwright CLI session artifacts
|
||||
.playwright-cli/
|
||||
|
||||
@@ -772,10 +772,11 @@ function truncateUtf8(buf: Buffer, cap: number): Buffer {
|
||||
if ((lead & 0xe0) === 0xc0) seqLen = 2;
|
||||
else if ((lead & 0xf0) === 0xe0) seqLen = 3;
|
||||
else if ((lead & 0xf8) === 0xf0) seqLen = 4;
|
||||
// Drop the lead byte too if its full sequence didn't fit.
|
||||
if (i - 1 + seqLen > cap) i--;
|
||||
// Cut before the lead byte when its full sequence didn't fit;
|
||||
// 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 {
|
||||
@@ -1069,10 +1070,66 @@ function readTaskDir(root: string, key: string | null): string | null {
|
||||
}
|
||||
|
||||
// ── 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 =
|
||||
/\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n([\s\S]*?)\n\s*\[\/workflow-state:\1\]/g;
|
||||
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 "";
|
||||
const templates: Record<string, string> = {};
|
||||
for (const m of wf.matchAll(WF_RE)) {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
".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/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/generated-files.md": "7eb2d452eddb4f4226f7578c2ec6d5ee0434ed172ba4c36107cc8bdff7554dc6",
|
||||
".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/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd",
|
||||
".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/platform-map.md": "9e476e500f10b2a1a05278dd700837deb731a77e3f3993e90f8102528a9bdcca",
|
||||
".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-implement.toml": "388fb8f39797e0ee6cf4db447c859c79b4ac15f531f7e1e3c68c1d1d71a1c188",
|
||||
".codex/agents/trellis-research.toml": "4435ce73197ba1d29d40359a3279b6423f7e4f559a449f934c016808090066c4",
|
||||
".codex/hooks/session-start.py": "14de3be1cf6eb9c9feba348d8998b407f3837d6c0756b74210c9200543440677",
|
||||
".codex/hooks/inject-subagent-context.py": "abffa237eb53f87ae6ffa434063b46b03d58a36a84cdb8fe88bfc5f243aab609",
|
||||
".codex/hooks/inject-workflow-state.py": "9ce43910ac39cbb0e4d1783fbde931761eb04536c7f82661a96d95ea72c14bde",
|
||||
".codex/hooks.json": "85a58ba7cdf1e19e7f75ddcc64e5680180c487ca266a74bd5005f31abeee2e02",
|
||||
".codex/hooks/session-start.py": "91fbbd30ac974c3cd2b4db152aa38ac176a9c7e3d062cbaa1acacf07152b2592",
|
||||
".codex/hooks/inject-subagent-context.py": "db413933ff30e1503f37f1d292fb421b1ced3f39f1890351a6621e499ec16b2c",
|
||||
".codex/hooks/inject-workflow-state.py": "cda5888c29671035e7d2e033a0fcc631a9428a0543fb2dd071285b30c6ca4e23",
|
||||
".codex/hooks.json": "c16c9af7f6010bf4fabb64fd4bd497201d34583b96c665ea1d5739eed48b3fb5",
|
||||
".codex/config.toml": "9f2d20e28f0bc9c886312eca3ad3bba41533ef4615aaaafe25e98152302267bb",
|
||||
".pi/prompts/trellis-start.md": "28af1eb6645d8b517cf705277d8405370b712926e6b01667d6698564002c6a9d",
|
||||
".pi/prompts/trellis-continue.md": "12c2f0288ff67af3368c0b577a50027a11a25fec1d32ed34282a4dc84be08f1c",
|
||||
@@ -61,40 +61,44 @@
|
||||
".pi/agents/trellis-check.md": "1dbfedd3403f201fbfdbae8d810afba0a1f812b97f0f8e308908db7eaceea496",
|
||||
".pi/agents/trellis-implement.md": "9bb1f70d09b7104a671ef9a0ba072b4500d45b8556a253126a003e2b1e7281a2",
|
||||
".pi/agents/trellis-research.md": "ef77555f4c2c4ade36f1c23a076b6f4bb9d180ff24a2f00d7cdc2f8fd5af0b0a",
|
||||
".pi/extensions/trellis/index.ts": "b4bfd740d517462f943aaff920dd39371d3e6bdc3823dfa8c2752314c57eaf9b",
|
||||
".pi/extensions/trellis/index.ts": "770290e675fabfe0dd0889771876607927d36cb9ef68586552e6e2d56ca345fc",
|
||||
".pi/settings.json": "b68f37c04a7007d2b52d5a87326e3786edbc903bfa518358830dd85727c13d7c",
|
||||
"AGENTS.md": "6cacfe99748b435d0660c2463c697bc323d53798aecf3492283ca8eac1b29682",
|
||||
".trellis/agents/check.md": "edb4f57361407249a53bf5998ebf91c40d2b969e826a2c5e1b4e813a08bcb175",
|
||||
".trellis/agents/implement.md": "66e25ad046c94869442834bc3cdfbd5a9a7412d3ff54561d64d2886552c27e87",
|
||||
".trellis/config.yaml": "a966e6d374e9e6ff283cf761ccd99631323ee1754856cef51ad154ca0afb9dfa",
|
||||
".trellis/config.yaml": "eaba56c36fb07483fcbc96d74c4da0ab33b9739fb9ff10ea4cc1e1cd32bf23b2",
|
||||
".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c",
|
||||
".trellis/scripts/add_session.py": "876dad478edf70db59acccaae9cb4db646a155681f730bd99af48de72ddc9881",
|
||||
".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/config.py": "8d2e5f8ccfcd5f622cd2af002aa761f3d3ffcc653182fefb2268afd102e77bca",
|
||||
".trellis/scripts/common/config.py": "43a22c4e88a06d6316d1bcd4730731bdecc22ae782e9f217b25ea53aa85cd416",
|
||||
".trellis/scripts/common/developer.py": "f5f833123abe68890171b4da825a324216d24913f6b5ad9245afc556424ffd7b",
|
||||
".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/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf",
|
||||
".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/session_context.py": "4ed3e13b2878ba367e9f2e2cd709b396f806152902df2cd1cd1478317d069017",
|
||||
".trellis/scripts/common/task_context.py": "4ea260a022f4122361eb0d9dd9200a9324aeafbdb20cadd2848bea1649938f1d",
|
||||
".trellis/scripts/common/session_context.py": "3379ef1766e4e5ca77cbb7c040dbba3883fcca2548580299e3b38dbf22f4f7d5",
|
||||
".trellis/scripts/common/task_context.py": "be5fa407f99c2400075194dbaa8b6ec996ce8aac2122d191bb9cb11e479a9476",
|
||||
".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/tasks.py": "4436a8b0b53c270a35989e26d9dbd92669408c6562d88c02083a404562da85fe",
|
||||
".trellis/scripts/common/trellis_config.py": "e282e897183e3ec2f4e6e56349431946e5f98c1c31d3eca4de7fc44e1383a7bf",
|
||||
".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_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f",
|
||||
".trellis/scripts/hooks/linear_sync.py": "e09cc4ce4699aada908808718698f33f705a3edf55c4dcf8f777ad892f80ca79",
|
||||
".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e",
|
||||
".trellis/scripts/task.py": "e0ffed9f14994069f0c992141e3ec168524be5af32e3681e6ea30ba0a5da4bc4",
|
||||
".trellis/workflow.md": "e2c5ab7004ff83a5a804b50df81746aa1d558dd4480463287622605f86a82a76"
|
||||
".trellis/scripts/task.py": "7790d9510311d55ed1f66c71b9ce0bafc8871f1b675c1251dcf6a1f4e823d2b3",
|
||||
".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
@@ -1 +1 @@
|
||||
0.6.12
|
||||
0.7.0-beta.3
|
||||
@@ -76,6 +76,21 @@ max_journal_lines: 2000
|
||||
# Default package used when --package is not specified.
|
||||
# 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
|
||||
#-------------------------------------------------------------------------------
|
||||
@@ -145,6 +160,38 @@ channel:
|
||||
# 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
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# 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
|
||||
#-------------------------------------------------------------------------------
|
||||
|
||||
@@ -23,8 +23,15 @@ DIR_WORKFLOW = ".trellis"
|
||||
DIR_TASKS = "tasks"
|
||||
DIR_RUNTIME = ".runtime"
|
||||
DIR_SESSIONS = "sessions"
|
||||
DIR_CURSOR_SHELL = "cursor-shell"
|
||||
CURSOR_SHELL_TICKET_TTL_SECONDS = 30
|
||||
DIR_SHELL_TICKETS = "shell-tickets"
|
||||
# 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"}
|
||||
|
||||
_SESSION_KEYS = ("session_id", "sessionId", "sessionID")
|
||||
@@ -50,35 +57,75 @@ _KNOWN_PLATFORMS = {
|
||||
"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, ...]], ...] = (
|
||||
("claude", ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")),
|
||||
("codex", ("CODEX_SESSION_ID", "CODEX_THREAD_ID")),
|
||||
("cursor", ("CURSOR_SESSION_ID",)),
|
||||
("opencode", ("OPENCODE_SESSION_ID", "OPENCODE_SESSIONID", "OPENCODE_RUN_ID")),
|
||||
# REAL, undocumented (verified 2026-08-05 in a live Claude Code 2.1.221 bash
|
||||
# child; absent from code.claude.com/docs/en/env-vars). CLAUDE_SESSION_ID
|
||||
# was removed here — verified absent from that same live environment.
|
||||
("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",)),
|
||||
("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",)),
|
||||
("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",)),
|
||||
# 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")),
|
||||
("pi", ("PI_SESSION_ID", "PI_SESSIONID")),
|
||||
("trae", ("TRAE_SESSION_ID",)),
|
||||
# ZCode reuses CLAUDE_SESSION_ID (it does not document a ZCODE_SESSION_ID).
|
||||
# Platform-scoped lookup (_iter_env_keys filters by platform name), so this
|
||||
# only fires when the resolver already detected "zcode" — no collision with
|
||||
# REASONED, UNVERIFIED (2026-08-05): ZCode is closed-source and not
|
||||
# installable here. It mirrors Claude's naming elsewhere (CLAUDE_PLUGIN_ROOT
|
||||
# / CLAUDE_PLUGIN_DATA compat aliases are in its docs), and the previously
|
||||
# declared CLAUDE_SESSION_ID does not exist on Claude Code either — so the
|
||||
# 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.
|
||||
("zcode", ("CLAUDE_SESSION_ID",)),
|
||||
# Snow CLI exports SNOW_SESSION_ID into hook/terminal/sub-agent children.
|
||||
# TRELLIS_CONTEXT_ID remains the preferred override when present.
|
||||
("zcode", ("CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID")),
|
||||
# REAL by vendor design (verified 2026-08-05): Snow's sessionIdentityEnv.ts
|
||||
# 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",)),
|
||||
)
|
||||
_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")),
|
||||
)
|
||||
_ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("claude", ("CLAUDE_TRANSCRIPT_PATH",)),
|
||||
("codex", ("CODEX_TRANSCRIPT_PATH",)),
|
||||
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): documented for Cursor hook
|
||||
# scripts; empty in the agent's own shell env.
|
||||
("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",)),
|
||||
("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")),
|
||||
("qoder", ("QODER_TRANSCRIPT_PATH",)),
|
||||
@@ -90,11 +137,15 @@ _ENV_PLATFORM_ALIASES = {
|
||||
"factory-ai": "droid",
|
||||
"github-copilot": "copilot",
|
||||
}
|
||||
# ZCode intentionally reuses CLAUDE_SESSION_ID. Hooks know the host is ZCode,
|
||||
# while later shell commands see only the shared env name and resolve it through
|
||||
# the Claude entry. Canonicalize both paths to one runtime filename.
|
||||
# ZCode intentionally reuses Claude's session env var name. Hooks know the host
|
||||
# is ZCode, while later shell commands see only the shared env name and resolve
|
||||
# it through the claude entry. Canonicalize both paths to one runtime filename.
|
||||
_CONTEXT_KEY_PLATFORM_ALIASES = {
|
||||
"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, ...]], ...],
|
||||
platform_name: str | None,
|
||||
) -> 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:
|
||||
return env_keys
|
||||
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
|
||||
|
||||
|
||||
def _cursor_shell_ticket_dir(repo_root: Path) -> Path:
|
||||
return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_CURSOR_SHELL
|
||||
def _shell_ticket_dirs(repo_root: Path) -> tuple[Path, ...]:
|
||||
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:
|
||||
@@ -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")
|
||||
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
|
||||
_remove_file(ticket_path)
|
||||
return False
|
||||
@@ -352,13 +413,18 @@ def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _matching_cursor_ticket_context_key(
|
||||
def _matching_ticket_context_key(
|
||||
ticket_path: Path,
|
||||
repo_root: Path,
|
||||
now: float,
|
||||
) -> 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)
|
||||
if ticket is None or ticket.get("platform") != "cursor":
|
||||
if ticket is None:
|
||||
return None
|
||||
if not _ticket_is_fresh(ticket, ticket_path, now):
|
||||
return None
|
||||
@@ -369,29 +435,30 @@ def _matching_cursor_ticket_context_key(
|
||||
return _string_value(ticket.get("context_key"))
|
||||
|
||||
|
||||
def _lookup_cursor_shell_ticket_context_key() -> str | None:
|
||||
"""Resolve Cursor conversation identity from a short-lived shell ticket.
|
||||
def _lookup_shell_ticket_context_key() -> str | None:
|
||||
"""Resolve session identity from a short-lived shell ticket.
|
||||
|
||||
Cursor exposes `conversation_id` to `beforeShellExecution`, but does not
|
||||
export it into the shell command environment. The Cursor hook writes a
|
||||
short-lived ticket just before `task.py` runs. We accept a ticket only when
|
||||
the current `task.py` subcommand matches and exactly one fresh context key
|
||||
matches, which avoids cross-window pointer contamination.
|
||||
No researched platform exports its session id into a shell child, but every
|
||||
hook-capable one hands that id to a hook. So the hook that runs just before
|
||||
a shell command writes a ticket, and this reads it back. A ticket counts
|
||||
only when it is fresh, was written for this repo, and matches the `task.py`
|
||||
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()
|
||||
if repo_root is None:
|
||||
return None
|
||||
|
||||
ticket_dir = _cursor_shell_ticket_dir(repo_root)
|
||||
if not ticket_dir.is_dir():
|
||||
return None
|
||||
|
||||
now = time.time()
|
||||
candidates: set[str] = set()
|
||||
for ticket_path in ticket_dir.glob("*.json"):
|
||||
context_key = _matching_cursor_ticket_context_key(ticket_path, repo_root, now)
|
||||
if context_key:
|
||||
candidates.add(context_key)
|
||||
for ticket_dir in _shell_ticket_dirs(repo_root):
|
||||
if not ticket_dir.is_dir():
|
||||
continue
|
||||
for ticket_path in ticket_dir.glob("*.json"):
|
||||
context_key = _matching_ticket_context_key(ticket_path, repo_root, now)
|
||||
if context_key:
|
||||
candidates.add(context_key)
|
||||
|
||||
if len(candidates) == 1:
|
||||
return next(iter(candidates))
|
||||
@@ -435,8 +502,10 @@ def resolve_context_key(
|
||||
if env_context_key:
|
||||
return env_context_key
|
||||
|
||||
if allow_environment_context and platform_name in (None, "session", "cursor"):
|
||||
return _lookup_cursor_shell_ticket_context_key()
|
||||
# Last in the chain on purpose: a platform that genuinely exports identity
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
@@ -281,6 +281,24 @@ def get_codex_dispatch_mode(repo_root: Path | None = None) -> str:
|
||||
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_ARTIFACT_BYTES = 65536
|
||||
DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES = 131072
|
||||
|
||||
@@ -27,6 +27,8 @@ from .packages_context import (
|
||||
get_context_packages_text,
|
||||
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 .workflow_phase import (
|
||||
filter_platform,
|
||||
@@ -57,9 +59,9 @@ def main() -> None:
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
"-m",
|
||||
choices=["default", "record", "packages", "phase"],
|
||||
choices=["default", "record", "packages", "phase", "spec"],
|
||||
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(
|
||||
"--step",
|
||||
@@ -69,6 +71,10 @@ def main() -> None:
|
||||
"--platform",
|
||||
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()
|
||||
|
||||
@@ -95,6 +101,32 @@ def main() -> None:
|
||||
)
|
||||
content = filter_platform(content, effective)
|
||||
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:
|
||||
if args.json:
|
||||
output_json()
|
||||
|
||||
@@ -94,6 +94,34 @@ def get_developer(repo_root: Path | None = None) -> str | 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:
|
||||
"""Check if developer is initialized.
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ Provides:
|
||||
get_context_text_record - Text for record mode
|
||||
output_json - Print JSON
|
||||
output_text - Print text
|
||||
get_update_hint - Once-per-session "update available" line
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -417,8 +418,16 @@ def _compare_versions(left: str, right: str) -> int | None:
|
||||
return _compare_prerelease(left_prerelease, right_prerelease)
|
||||
|
||||
|
||||
def _update_marker_path(repo_root: Path) -> Path:
|
||||
context_key = resolve_context_key()
|
||||
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()
|
||||
if not context_key:
|
||||
terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
|
||||
context_key = terminal_key or f"ppid-{os.getppid()}"
|
||||
@@ -433,8 +442,11 @@ def _update_marker_path(repo_root: Path) -> Path:
|
||||
)
|
||||
|
||||
|
||||
def _mark_update_check_attempted(repo_root: Path) -> bool:
|
||||
marker_path = _update_marker_path(repo_root)
|
||||
def _mark_update_check_attempted(
|
||||
repo_root: Path,
|
||||
context_key: str | None = None,
|
||||
) -> bool:
|
||||
marker_path = _update_marker_path(repo_root, context_key)
|
||||
if marker_path.exists():
|
||||
return False
|
||||
try:
|
||||
@@ -445,8 +457,14 @@ def _mark_update_check_attempted(repo_root: Path) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _get_update_hint(repo_root: Path) -> str | None:
|
||||
marker_path = _update_marker_path(repo_root)
|
||||
def get_update_hint(repo_root: Path, context_key: str | None = None) -> str | None:
|
||||
"""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():
|
||||
return None
|
||||
|
||||
@@ -458,7 +476,7 @@ def _get_update_hint(repo_root: Path) -> str | None:
|
||||
if not latest_version:
|
||||
return None
|
||||
|
||||
_mark_update_check_attempted(repo_root)
|
||||
_mark_update_check_attempted(repo_root, context_key)
|
||||
comparison = _compare_versions(current_version, latest_version)
|
||||
if comparison is None or comparison >= 0:
|
||||
return None
|
||||
@@ -867,7 +885,7 @@ def output_text(repo_root: Path | None = None) -> None:
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
update_hint = _get_update_hint(repo_root)
|
||||
update_hint = get_update_hint(repo_root)
|
||||
if update_hint:
|
||||
print(update_hint)
|
||||
print("")
|
||||
|
||||
Executable
+439
@@ -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
|
||||
Executable
+395
@@ -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 []
|
||||
@@ -25,7 +25,7 @@ from .config import get_context_injection_limits
|
||||
from .git import branch_exists_locally
|
||||
from .io import read_json
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
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:
|
||||
"""Validate a single JSONL file.
|
||||
|
||||
@@ -220,14 +273,14 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
|
||||
continue
|
||||
|
||||
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 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)}")
|
||||
errors += 1
|
||||
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)}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
@@ -56,6 +56,7 @@ from .task_utils import (
|
||||
resolve_task_dir,
|
||||
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)
|
||||
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
|
||||
assignee = args.assignee
|
||||
if not assignee:
|
||||
@@ -385,6 +411,10 @@ def cmd_create(args: argparse.Namespace) -> int:
|
||||
"notes": "",
|
||||
"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)
|
||||
|
||||
|
||||
@@ -22,11 +22,12 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .paths import DIR_WORKFLOW, get_repo_root
|
||||
from . import workflow_selection
|
||||
from .paths import get_repo_root
|
||||
|
||||
|
||||
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]"
|
||||
_MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$")
|
||||
|
||||
+177
@@ -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)
|
||||
@@ -11,6 +11,7 @@ Usage:
|
||||
python3 task.py start <dir> # Set active task
|
||||
python3 task.py current [--source] [--json] # Show 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-base-branch <dir> <branch> # Set PR target branch
|
||||
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.task_utils import resolve_task_dir, run_task_hooks
|
||||
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)
|
||||
from common.task_store import (
|
||||
@@ -204,6 +206,72 @@ def cmd_current(args: argparse.Namespace) -> int:
|
||||
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
|
||||
# =============================================================================
|
||||
@@ -382,12 +450,15 @@ Usage:
|
||||
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> --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 validate <dir> Validate jsonl files
|
||||
python3 task.py list-context <dir> List jsonl entries
|
||||
python3 task.py start <dir> Set active task
|
||||
python3 task.py current [--source] Show 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-base-branch <dir> <branch> Set PR target branch
|
||||
python3 task.py set-scope <dir> <scope> Set scope for PR title
|
||||
@@ -490,6 +561,10 @@ def main() -> int:
|
||||
action="store_true",
|
||||
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
|
||||
p_add = subparsers.add_parser("add-context", help="Add context entry")
|
||||
@@ -520,6 +595,12 @@ def main() -> int:
|
||||
# finish
|
||||
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
|
||||
p_branch = subparsers.add_parser("set-branch", help="Set git branch")
|
||||
p_branch.add_argument("dir", help="Task directory")
|
||||
@@ -580,6 +661,7 @@ def main() -> int:
|
||||
"start": cmd_start,
|
||||
"current": cmd_current,
|
||||
"finish": cmd_finish,
|
||||
"workflow": cmd_workflow,
|
||||
"set-branch": cmd_set_branch,
|
||||
"set-base-branch": cmd_set_base_branch,
|
||||
"set-scope": cmd_set_scope,
|
||||
|
||||
+4
@@ -5,3 +5,7 @@
|
||||
{"file":".trellis/tasks/08-27-onechartlab-research/prd.md","reason":"逐条核对两份独立报告、复现深度、证据定位和范围边界的验收条件。"}
|
||||
{"file":".trellis/tasks/08-27-onechartlab-research/research/sector-capital-radar-evidence.md","reason":"核对板块报告的已确认事实、推断边界和排名复算结果。"}
|
||||
{"file":".trellis/tasks/08-27-onechartlab-research/research/macro-timing-evidence.md","reason":"核对宏观报告的阈值、状态、贡献恒等式和核心公式缺口。"}
|
||||
{"file": ".trellis/spec/frontend/index.md", "reason": "核对本轮前端实现是否符合项目分层和门禁。"}
|
||||
{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "核对无限查询、query key、取消信号和服务器状态归属。"}
|
||||
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "核对滚动表格、状态提示、语义和可访问性。"}
|
||||
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "核对测试覆盖以及 format、lint、typecheck、test、build 结果。"}
|
||||
+8
@@ -32,3 +32,11 @@
|
||||
## 验证与回滚
|
||||
|
||||
最终检查逐条核对 PRD 验收标准、报告内引用的可访问性、事实/推断分级、两模块的一致术语和复现说明完整性。报告是新增 Markdown 文件,回滚只需移除新增报告和任务底稿,不影响运行时系统。
|
||||
|
||||
## 板块排名页交互迭代
|
||||
|
||||
排名接口继续沿用现有 `page` / `page_size` 服务端契约,前端 query 层新增无限查询封装,以首屏页为起点,根据已加载条数和响应 `total` 推导下一页。页面只消费 React Query 管理的分页集合,不把服务端响应复制到 Zustand 或组件本地状态;筛选条件参与 query key,条件变化自然切换到新的首批数据。
|
||||
|
||||
表格自身保持唯一纵向滚动容器和 sticky 表头,在滚动位置接近底部时调用 `fetchNextPage`。加载下一批时在表格底部提供紧凑状态,加载失败时保留已加载行并提供可重试操作,全部加载后不显示传统页码或每页条数控制。
|
||||
|
||||
移除 `RadarStatusSummary` 及其静态标题、免责声明、版本和发布元数据块。部分发布、后台刷新、刷新失败、首次加载失败和无数据仍属于操作性状态,继续以紧凑提示或状态容器呈现,不删除关键异常反馈。
|
||||
+4
@@ -5,3 +5,7 @@
|
||||
{"file":".trellis/tasks/08-27-onechartlab-research/design.md","reason":"提供证据等级、两路页面调研分工、视频暂缓边界、复现链路和访问限制。"}
|
||||
{"file":".trellis/tasks/08-27-onechartlab-research/research/sector-capital-radar-evidence.md","reason":"提供板块页面、公开分片、排名算法、失败边界和主会话复算证据。"}
|
||||
{"file":".trellis/tasks/08-27-onechartlab-research/research/macro-timing-evidence.md","reason":"提供宏观页面、状态/贡献协议、阈值、证据缺口和主会话复算证据。"}
|
||||
{"file": ".trellis/spec/frontend/index.md", "reason": "板块资金雷达页面是 React/TanStack Query 前端改动,需遵守前端目录、交互和质量门禁。"}
|
||||
{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "无限列表继续由 TanStack Query 管理服务端分页和取消信号。"}
|
||||
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "表格滚动、状态提示和控件需遵守现有组件、Tailwind 与可访问性约定。"}
|
||||
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "实现后需更新行为测试并通过前端质量命令。"}
|
||||
+13
@@ -10,6 +10,10 @@
|
||||
- [x] 编写 `docs/research/onechartlab-sector-capital-radar.md`,覆盖输入、加工链路、排序/打分/映射、输出信号、更新和异常边界、复现伪代码及证据置信度。
|
||||
- [x] 编写 `docs/research/onechartlab-macro-timing.md`,覆盖输入、状态定义、阈值/状态机、滞后与前视风险、资产解释、复现伪代码及证据置信度。
|
||||
- [x] 执行最终内容检查,确认每条关键技术结论有出处或推断标签,两份报告可以独立阅读且不包含收益承诺或未经确认的精确参数。
|
||||
- [x] 在 sector radar query 层加入基于现有分页接口的无限查询,并用单元测试覆盖 query key、取消信号和下一页判定。
|
||||
- [x] 将板块排名表改为滚动接近底部时加载下一页,移除共享分页器接入,确保筛选变化时列表重置且下一批失败不会清空已加载行。
|
||||
- [x] 删除 `RadarStatusSummary` 大块描述内容,把部分发布等必要状态保留为紧凑提示,并更新页面行为测试。
|
||||
- [x] 运行前端 format、lint、typecheck、相关 Vitest、完整测试与 build,随后进行可见页面检查。
|
||||
|
||||
## 验证命令与人工检查
|
||||
|
||||
@@ -23,6 +27,15 @@ git diff --check
|
||||
|
||||
人工核对报告中的关键 URL 和公开请求字段,检查证据等级是否与正文措辞一致,并确认视频暂缓状态及其影响已写清楚。
|
||||
|
||||
页面迭代额外执行:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
## 风险与停止条件
|
||||
|
||||
如果网站需要绕过登录、付费、验证码、地域限制或反爬机制才能继续,停止该取证路径并记录缺口。若压缩/混淆脚本只能支持候选实现而不能证明算法,降级为推断。若公开数据只给出最终分值,报告提供可实施的验证实验,而不虚构原始公式。
|
||||
+5
-1
@@ -35,10 +35,14 @@
|
||||
- [x] 两份报告不存在投资收益承诺,并明确其用途是技术与产品逻辑研究,不构成投资建议。
|
||||
- [x] 生成一份 Tushare 数据需求研究文档,能够作为后续采集、落库和独立重算的接口清单与实施边界。
|
||||
- [x] Tushare 文档明确区分页面已暴露的确定接口、为复现建议补充的接口和仍需实测/开通权限确认的接口;关键字段及权限信息引用官方来源。
|
||||
- [x] 板块资金雷达排名页取消底部分页器,首批数据展示后可在表格滚动区接近底部时继续加载下一批,直到当前筛选条件下全部结果加载完成。
|
||||
- [x] 切换交易日、板块类型、指标视角、榜单范围或搜索条件后,无限列表从首批结果重新开始,不混入旧筛选条件的数据。
|
||||
- [x] 移除排名表上方包含标题、免责声明、实现标签、版本号和发布元数据的整块描述性卡片,不再用大面积静态说明挤占榜单空间。
|
||||
- [x] 刷新失败、后台刷新、部分发布、加载失败和无数据等操作性状态仍能以紧凑且可访问的方式向用户表达。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 绕过登录、会员、验证码、地域限制、反爬机制或其他访问控制。
|
||||
- 对未公开的后端源码、私有数据源或专有算法作确定性归因。
|
||||
- 开发、回测或上线等价策略,评估真实交易收益,或提供个性化投资建议。
|
||||
- 开发、回测或上线等价策略,评估真实交易收益,或提供个性化投资建议;本轮仅迭代既有板块资金雷达页面的列表交互与信息密度。
|
||||
- 修改 OneChartLab 网站或向其作者、用户发送消息。
|
||||
+40
@@ -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。
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
"name": "onechartlab-research",
|
||||
"title": "OneChartLab 板块资金雷达与宏观择时技术调研",
|
||||
"description": "",
|
||||
"status": "in_progress",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
@@ -11,7 +11,7 @@
|
||||
"creator": "yuxuanhui",
|
||||
"assignee": "yuxuanhui",
|
||||
"createdAt": "2026-08-27",
|
||||
"completedAt": null,
|
||||
"completedAt": "2026-09-01",
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
@@ -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。
|
||||
+22
@@ -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 读取并核对金额、涨跌幅和成员。单板块数据不能验证全市场排名或相似度,不得发布为完整排名池;全池排名/历史边界使用受控数据验证。缺权限或历史数据不足时记录实际限制,不伪造通过。
|
||||
|
||||
## 验证与风险
|
||||
后端验证单位、成员日期、历史截止日、独立发布版本、缺失和在榜计数;前端验证两面板、展开、弹窗、切换和导出交互。真实调用前核实环境与权限,密钥不进入日志。新增详情来源失败不得让已有完整榜单丢失;旧发布缺字段可读且明确为空。当前仍处规划阶段,尚未进行产品修改或真实采集。
|
||||
+62
@@ -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()
|
||||
+243
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+20
@@ -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
|
||||
}
|
||||
}
|
||||
+77
@@ -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()
|
||||
+1349
File diff suppressed because it is too large
Load Diff
+27
@@ -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()
|
||||
+100
@@ -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`。本研究已完成,没有产品实施待批准。
|
||||
+128
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+76
@@ -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 任务;未修改产品实现、共享规格或生产数据。
|
||||
+72
@@ -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))
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+55
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
+137
@@ -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 和结果均与生产代码隔离。
|
||||
+246
@@ -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"
|
||||
}
|
||||
]
|
||||
BIN
Binary file not shown.
+116
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -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"}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user