Generated banner for AI Workspace Hub
Generated logo for AI Workspace Hub

AI Workspace Hub

Vault template

First committed by Benboerba620

Published in Benboerba620/ai-workspace-hub. Publisher page

83 GitHub stars

What it is

All-in-one AI 研究工作台:把 Codex / Claude Code / Cursor 变成有长期记忆的研究系统。wiki 摄入、研究闭环、快速筛选、假设追踪、播客、日报六大能力,零 API key 可跑基座。
— the description by Benboerba620

Summary from the brain’s metadata

All-in-one AI 研究工作台:把 Codex / Claude Code / Cursor 变成有长期记忆的研究系统。wiki 摄入、研究闭环、快速筛选、假设追踪、播客、日报六大能力,零 API key 可跑基座。 Turn Codex, Claude Code, Cursor, and Cline into a persistent research workspace.

How it works

Memory model: Markdown vault

AutoGineer overview, from the brain’s own files

Memory model: Markdown vault. Folders: .github (agent config, 1 file); docs (docs, 2 files); examples (examples, 22 files); inbox (raw sources, 3 files); tests (tests, 18 files); tools (scripts, 39 files); wiki (wiki, 15 files). The agent reads hypothesis/, wiki/explorations/.

Folders
FolderRoleFiles
.githubagent-config1
docsdocs2
examplesexamples22
inboxraw-sources3
teststests18
toolsscripts39
wikiwiki15
configfolder2
evidencefolder1
hypothesisfolder1
outputfolder7
portfoliofolder2
systemfolder52
workspacefolder11

The agent reads

Requirements

Runtimes: Python

Works with: Claude Code, OpenAI Codex, GitHub Copilot, Cursor, Kiro, OpenCode, Amp, Cline, JetBrains Junie, Devin (ex-Windsurf)

Set it up

  1. Clone the repository at the commit AutoGineer scanned:

    git clone https://github.com/Benboerba620/ai-workspace-hub.git && cd ai-workspace-hub && git checkout e01a41a06886c6d59f714e6c133004d241ea91ac
  2. Claude Code reads CLAUDE.md from this folder automatically.

  3. cd ai-workspace-hub && claude

  4. AutoGineer scanned these files at commit e01a41a.

  5. First committed by Benboerba620. Published in Benboerba620/ai-workspace-hub. Licence: MIT License.

Usage

AutoGineer’s guidance for this type of brain

Copy the vault into a folder, open it in your notes app, and point your agent at its instruction file: the agent reads and writes the notes as its memory.

Documentation by Benboerba620

<div align="center">

AI Workspace Hub

Turn Codex, Claude Code, Cursor, and Cline into a persistent research workspace.

把 AI 编程助手变成一套能长期记忆、持续研究、自动沉淀的工作台。

Image: CI Image: Python 3.10+ Image: GitHub stars Image: License

30 秒上手 · 六大能力 · 免费数据源 · 第一周怎么用 · 故障排查

Image: 完整流程演示:一句话安装 → Core Mode READY → 首次股票研究 → 研究偏好 → 主题假设 → 日报跟踪

<sub>演示动图按安装协议与 <a href="START-HERE.md">首次股票研究流程</a>脚本化生成(<a href="docs/gen_demo_gif.py">生成脚本</a>),非实录。</sub>

</div>


最近更新(2026-09-30):当前 Agent 可直接完成材料摘要与标签;日报显示数据来源、日期和缺失状态;安装附带免费数据源指南,daily-watch 与 podcast 均已内置。

对话很聪明,但工作流没有记忆;今天做完,明天又从头解释。

AI Workspace Hub 解决这个问题。六大能力开箱即用——核心工作流不需要 API key。

📖 想先了解设计思路?看作者的公众号长文:《从0构建 AI 协作系统(一):从最小可运行的 MVP 开始》


⚡ 30 秒上手

把下面这句话发给你的 AI agent(Codex / Claude Code / Cursor / Cline):

帮我按这个协议安装 AI Workspace Hub:
https://raw.githubusercontent.com/Benboerba620/ai-workspace-hub/main/INSTALL-FOR-AI.md
抓不到协议全文就先 git clone 本仓库,再读其中的 INSTALL-FOR-AI.md 逐字执行。

Agent 会问你 4 个问题(路径、用途、现有 wiki、是否使用 Obsidian),然后自动创建完整工作区。装好后,先用样例确认安装:

把 inbox/first-note.md 整理进 personal wiki。

预期:agent 读 AGENTS.md → 按 wiki/_schema.md 整理 → 写入 wiki/sources/ → 在 active-context.md 记录进度。

不需要 API key,不需要联网,不需要写任何代码。

样例通过后,开始第一份真实股票研究:

研究一家公司,同时帮我建立第一版研究偏好。
公司是:____;我现在关注它是因为:____。

这次会同时产出研究报告、研究偏好 v0.1、可验证假设和后续跟踪指标。以后研究其他公司会复用并继续校准这套方法,而不是每次从空白开始。

<details> <summary>还没有 AI agent?先花 2 分钟装一个</summary>

任选其一,都有免费或试用档:

  • Claude Code(推荐):先装 Node.js,然后终端运行 npm install -g @anthropic-ai/claude-code,在任意目录输入 claude 登录 Claude 账号。

  • Codex CLI:npm install -g @openai/codex,输入 codex 登录 ChatGPT 账号。

  • Cursor:去 cursor.com 下载安装,打开一个文件夹后用内置对话。

装好后回到上面,把那句话发给它就行。

</details>

<details> <summary>更习惯手动 clone?</summary>

git clone https://github.com/Benboerba620/ai-workspace-hub.git my-ai-workspace
cd my-ai-workspace

用 AI agent 打开这个目录,直接试跑 inbox/first-note.md → wiki。

验证安装状态:

python3 system/scripts/check_workspace.py    # Windows: python system/scripts/check_workspace.py

Core Mode 显示 READY 即可开始使用。

</details>


📈 股票研究主流程

首次研究引导 → 研究偏好 v0.1 → 研究结论 → 可验证假设
      → 跟踪指标 → daily-watch 日报 → 独立证据 → 假设复盘 → 长期知识

START-HERE.md 是安装后的用户首页。信息摄入只是输入环节;系统的主线是让研究结论进入假设,并通过日报持续验证。

每份研究、假设和证据都有稳定 ID;当前状态只保存在 Markdown frontmatter。需要用户确认的 wiki 沉淀、假设调整、股票池新增和研究偏好变化会进入持久待确认队列。直接说:

看看我的研究系统现在有什么需要处理。

Agent 会汇总进行中的研究、待复盘证据、到期假设和待确认动作,并给出下一步。

🧩 六大能力

能力你可以怎么说写入哪里API key
📚wiki"把这篇文章整理进知识库"wiki/推荐(自动标签)
🔬research"帮我研究一下某公司 / 某行业"output/research/不需要
🔍screen"帮我筛选 AI 产业链股票"output/screen/可选
📊daily-watch"生成今天的盯盘日报"output/daily-watch/ + evidence/可选
🧪hypothesis"把这个投资假设建档并追踪"hypothesis/不需要
🎙️podcast"扫一下这几个播客并写进 wiki"wiki/sources/ + output/pod2wiki/当前 Agent 无需独立 Key;后台摘要需 Key

daily-watch 和 podcast 已经内置在 Hub 中,不需要再单独安装。 后台自动标签、独立运行的播客摘要和部分行情源需要按需填写 API key;交互整理可由当前 Agent 完成。

零 key 可用:wiki 可由当前 Agent 摄入和补齐有依据的标签,不调用独立模型 API;research、hypothesis、screen(websearch 模式)、daily-watch(报告骨架 + 美股降级源)照常可用。

默认建议配置:先按 免费数据源接入指南 选择市场——美股可零 Key 使用 Nasdaq,A 股可从 Tushare 的基础日线档起步,yfinance 可作为个人研究备用;再按需配置 LLM、FMP 或 Longbridge Skill。


🔄 工作流程

flowchart LR
    IN["inbox / web"] --> TAG["统一自动标签"]
    TAG --> KB["wiki 知识库"]
    KB --> R["research R*"]
    R --> H["hypothesis H*"]
    H --> W["唯一执行股票池"]
    W --> D["daily-watch 日报"]
    D --> E["evidence E-*"]
    E -->|"用户确认后复盘"| H
    R --> Q["review-queue Q-*"]
    H --> Q
    Q -->|"确认后执行"| KB
    S["workspace status"] -.-> R
    S -.-> H
    S -.-> E
    S -.-> Q

核心不是"文件夹长什么样",而是:agent 进入目录后知道先读什么、做研究时先查本地 wiki、输出时事实与推测分开、证据和判断不混写、待确认动作不会随对话消失、明天继续时能从断点接上。

wiki 变大后也不会每次全量加载。每次研究先运行 Research Preflight:按 rule -> pattern -> exploration 加载相关可复用知识,同时扫描 entities / concepts / sources,只返回少量命中页面、命中理由和过期/冲突提示,再决定哪些问题需要联网。扫描不调用大模型,并生成可检查的回执。知识按 source -> exploration -> pattern -> rule 逐级提炼;每次晋级、降级或退役都保留用户确认、复审日期和失效条件。

材料进入 wiki 时按同一标签契约补齐 domain / ticker / concepts / related / entity_salience / tags。交互模式由当前 Agent 处理,后台模式使用统一打标器;历史补标默认只预览,已有人工字段不会被覆盖。

用真实案例理解 Wiki 晋级

产能扩张周期见顶案例 把一条真实研究判断拆成 4 个 Source、2 个已提炼 Exploration 和 1 张 draft Pattern:光伏与锂电是产生该 Pattern 的母案例,因此不能再次冒充独立确认,primary_confirmations 保持为 0。日常研究默认不会加载这张草稿卡;复盘模式可以找到它,未来经过两次新的独立事前验证并由用户确认后,才允许变成 active Pattern。累计三次独立确认后,再讨论是否形成可执行 Rule。

案例目录可以独立运行 summary 和 load,仓库测试同时守住“母案例不计确认、draft 不进入日常加载、满足门槛后才可晋级”三条边界。

系统不在某个模型里,而在这套文件协议里。谁读懂这套协议,谁就接上你的工作流。


⚙️ Core Mode / Enhanced Mode

模式API key能做什么适合
Core不需要wiki 摄入(无脚本自动标签)、研究草稿、快速筛选、假设建档、断点续传临时未配置 API 时
Enhanced按市场选填wiki 自动标签、播客摘要、行情日报、A 股 / 多市场数据、自动监控默认日常使用

推荐路径:安装后运行 check_workspace.py → 按 DATA-SOURCES.md 选择免费或付费数据源 → 只填写实际需要的 key。暂时没有 key 时仍可先跑 Core Mode,美股基础行情还可走 Nasdaq 无 Key 源。


📅 第一周怎么用

天数动作目标
Day 1用当前 Agent 跑通 inbox/first-note.md → wiki;后台自动化再配置 Key确认摘要、自动标签和分类都能工作
Day 2放入 3-5 条真实材料建立第一批知识库
Day 3让 agent 研究一个公司 / 行业生成第一篇结构化报告
Day 4check_workspace.py,按免费数据源指南选择 Nasdaq / Tushare / yfinance 等增强行情能力
Day 5做一次主题筛选形成候选池
Day 6扫一次播客 / 博客建立外部信息流
Day 7运行结构体检,删掉没用规则防止系统变胖

适合谁?

适合:投资研究员、个人投资者、内容创作者、AI power user、Markdown / Obsidian 用户、想做长期项目而不是一次性对话的人。

不适合:想要 GUI 应用、自动交易系统,或不愿意用 Markdown 管理知识的人。


<details> <summary><b>📂 目录结构</b></summary>

ai-workspace-hub/
├── AGENTS.md                 # Codex 入口路由
├── CLAUDE.md                 # Claude Code 入口路由
├── INSTALL-FOR-AI.md         # 交给 AI agent 的安装协议
├── SMOKE-TEST.md             # 冒烟测试
├── ARCHITECTURE.md           # 架构说明
├── TROUBLESHOOTING.md        # 故障排查
├── workspace/                # 配置、断点、看板与归档
│   ├── monitoring/           # 用户阅读的监控看板
│   └── archive/              # 退出主流程的历史材料
├── inbox/                    # 临时输入材料
├── wiki/                     # personal wiki(exploration / pattern / rule)
├── output/                   # 研究、筛选、播客和日报输出
│   └── daily-watch/          # 日报归档
├── hypothesis/               # 投资假设和复盘
├── evidence/                 # 独立证据账本
├── portfolio/                # 交易记录
├── config/                   # 用户配置,不入 git
├── tools/                    # podcast / daily-watch 工具
└── system/                   # skills / integrations / scripts / templates

</details>

<details> <summary><b>🗄️ 数据源与边界</b></summary>

名称类型用途是否内置
Nasdaq无 key 降级源美股基础行情是
Finnhub免费账户 / 付费美股行情备选;免费端点和额度以官方账户为准是,需 key
EODHD免费小额度 / 付费港股、韩国、芬兰等最近 EOD 备用;免费账户当前 20 次/日,不是实时价是,需 key
yfinance无 Key多市场个人研究备用;非官方 Yahoo SDK,需额外安装是,需开启
tushare积分 / 单独权限A 股行情;当前 120 积分档可取非复权日线是,需 token
FMP免费 Basic / 付费免费档适合 EOD/profile 测试;全球覆盖和高级数据取决于套餐是,需 key
Longbridge Skill外部 Agent 扩展多市场查询、筛选、研究否,独立安装授权
websearchAgent 能力补充新闻、资料、公司信息取决于 agent

重要边界:本项目不做自动交易,不承诺数据源永远免费,不把 AI 输出包装成投资建议。缺少 API key 时优雅降级,不会让用户误以为工作区坏了。

</details>

<details> <summary><b>💻 常用命令</b></summary>

python3 system/scripts/check_workspace.py          # 总检查
python3 system/scripts/workspace_status.py         # 当前研究、证据和待办
python3 system/scripts/research_preflight.py --root . --context "你的研究问题和关键词" --research-id R-... --research-file output/research/报告.md --record  # 扫描 Wiki 并更新研究报告
python3 system/scripts/knowledge_lifecycle.py --root . summary  # 知识状态、到期复审和晋级候选
python3 system/scripts/knowledge_lifecycle.py --root . rebuild-index --apply  # 重建三个短索引
python3 system/scripts/review_queue.py list        # 查看持久待确认队列
python3 system/scripts/wiki_tagger.py --root . backfill wiki  # 预览历史 wiki 补标
python3 -m unittest discover -s tests -v            # 运行测试
python3 tools/daily-watch/scripts/check_setup.py --init  # 初始化日报配置
python3 tools/podcast/scripts/fetch_podcasts.py --help   # 播客工具帮助

Python 工具需要 3.10+。Windows 用户用 python 代替 python3,或 py -3 指定版本。

</details>

<details> <summary><b>❓ 新手常见问题</b></summary>

Q:我不会写代码,能用这个项目吗? 可以。Core Mode 只需要把文本放进 inbox/,然后用自然语言告诉 AI agent 做什么。

Q:一定要用 Codex / Claude Code 吗? 不一定。任何能读写文件的 AI agent 都可以。Codex 和 Claude Code 效果最好,Cursor 和 Cline 也可以。

Q:安装 Hub 后,还要单独安装 daily-watchlist 或 pod2wiki 吗? 不需要。两套工具及其配置模板已经包含在 tools/daily-watch/ 和 tools/podcast/;你只需要按用途安装可选依赖,并按 免费数据源接入指南 选择是否配置 key。

Q:wiki 越来越大怎么办? 用 Obsidian 打开 wiki/ 目录做可视化管理,或让 AI agent 帮你整理归档。

Q:API key 会不会泄露? config/ 已被 .gitignore 排除,不会被 git 提交。

Q:自动标签会把什么发给大模型? 会把当前 wiki 页面的 frontmatter 和正文发送给你在 config/pod2wiki.env 选择的模型服务商,只用于返回结构化标签。敏感材料应先确认所选服务商的数据政策,或跳过自动标签。

Q:可以和 Obsidian 一起用吗? 可以。安装时选择 Obsidian(或在已有 vault 中安装),Hub 会增加 reading-hub.md 和 reading-hub.base,用 9 个动态视图汇总待读内容;可直接在表格中填写 已读 / 精读 / 跳过,并优先按资料自身日期而不是本机文件时间排序。默认不安装,也不影响非 Obsidian 用户。需要 Obsidian 1.9+ 的 Bases 功能。

更多问题见 TROUBLESHOOTING.md。

</details>


Roadmap

  • 更多真实研究工作流样例

  • 更强的 screen 预设

  • 更完善的 daily-watch 降级策略

  • 证据来源质量分级和跨来源事件去重

  • 知识索引的自动健康检查与过期提示


Contributing

欢迎提 issue / PR,尤其是新 agent 适配、研究报告模板、数据源接入、真实使用摩擦点、README / 教程 / 安装流程改进。


Star History

如果这个项目对你有帮助,欢迎点一下 ⭐ Star。它会帮助更多需要"长期 AI 工作流"的人发现这个项目,也会让我知道哪些方向值得继续做。

Image: Star History Chart


Disclaimer

AI Workspace Hub 是个人研究与知识管理工具,不构成投资建议。所有市场数据、公司信息、财务数字、新闻和监管信息都应以原始来源为准。

<!-- 文件说明:项目首页、价值介绍和快速上手。 -->

为什么已经在 Agent 中使用,还可能需要模型 Key?

交互整理可以由当前 Agent 完成:播客工具使用 --agent-handoff 准备原文与待处理队列,Agent 阅读原文后完成摘要和标签,不要求另配模型 Key。脚本在后台独立生成摘要才需要模型 API。两种方式的步骤与完成标准见 Podcast 流程。

system/scripts/workspace_doctor.py

Shown as plain text.

#!/usr/bin/env python3
"""Validate research object schemas and cross-file links in a workspace."""

from __future__ import annotations

import argparse
import json
import re
import sys
from datetime import date
from pathlib import Path
from typing import Any

try:
    import yaml
except ImportError:  # Core Mode remains dependency-free.
    yaml = None

for _stream in (sys.stdout, sys.stderr):
    if hasattr(_stream, "reconfigure"):
        _stream.reconfigure(encoding="utf-8", errors="replace")

RESEARCH_STATUSES = {"draft", "active", "closed", "archived"}
HYPOTHESIS_STATUSES = {
    "new",
    "active",
    "paused",
    "invalidated",
    "realized",
    "archived",
    # Legacy values remain valid during gradual migration.
    "新建",
    "观察",
    "暂停",
    "推翻",
    "已兑现",
    "closed",
    "rejected",
}
EVIDENCE_DIRECTIONS = {"pending", "strengthen", "weaken", "neutral", "mixed"}
EVIDENCE_REVIEW_STATUSES = {"pending", "confirmed", "rejected"}
KNOWLEDGE_PATHS = {
    "exploration": ("wiki/explorations/*.md",),
    "pattern": ("wiki/patterns/*.md", "workspace/patterns/*.md"),
    "rule": ("wiki/rules/*.md",),
}
KNOWLEDGE_PREFIXES = {"exploration": "EXP-", "pattern": "PAT-", "rule": "RULE-"}
KNOWLEDGE_STATUSES = {
    "exploration": {
        "tentative",
        "validated",
        "weakened",
        "invalidated",
        "promoted",
        "archived",
    },
    "pattern": {"draft", "active", "weakened", "promoted", "retired", "archived"},
    "rule": {"candidate", "active", "weakened", "deprecated", "archived"},
}


def find_workspace_root(start: Path) -> Path:
    current = start.resolve()
    if current.is_file():
        current = current.parent
    for candidate in (current, *current.parents):
        if (candidate / "workspace" / "workspace-config.md").is_file():
            return candidate
    return current


def relative(path: Path, root: Path) -> str:
    return str(path.relative_to(root)).replace("\\", "/")


def read_frontmatter(path: Path) -> tuple[dict[str, Any], str | None]:
    try:
        content = path.read_text(encoding="utf-8-sig")
    except (OSError, UnicodeError) as exc:
        return {}, str(exc)
    match = re.match(r"^---\s*\n(.*?)\n---\s*(?:\n|$)", content, re.DOTALL)
    if not match:
        return {}, "missing frontmatter"
    if yaml is not None:
        try:
            payload = yaml.safe_load(match.group(1)) or {}
        except yaml.YAMLError as exc:
            return {}, f"invalid YAML: {exc}"
        return (payload, None) if isinstance(payload, dict) else ({}, "frontmatter is not a mapping")

    values: dict[str, Any] = {}
    current_key: str | None = None
    for line in match.group(1).splitlines():
        field = re.match(r"^([A-Za-z_][\w-]*):\s*(.*?)\s*$", line)
        if field:
            key = field.group(1)
            value = field.group(2).strip().strip('"\'')
            current_key = key if not value else None
            if value.startswith("[") and value.endswith("]"):
                values[key] = [
                    item.strip().strip('"\'')
                    for item in value[1:-1].split(",")
                    if item.strip()
                ]
            else:
                values[key] = value or None
            continue
        list_item = re.match(r"^-\s+(.+?)\s*$", line)
        if list_item and current_key:
            if not isinstance(values.get(current_key), list):
                values[current_key] = []
            values[current_key].append(list_item.group(1).strip('"\''))
            continue
        nested = re.match(r"^\s+([^:]+):\s*(.*?)\s*$", line)
        if nested and current_key:
            if not isinstance(values.get(current_key), dict):
                values[current_key] = {}
            values[current_key][nested.group(1).strip()] = nested.group(2).strip('"\'')
    return values, None


def as_list(value: Any) -> list[str]:
    if value is None:
        return []
    if isinstance(value, list):
        return [str(item) for item in value if str(item).strip()]
    return [str(value)] if str(value).strip() else []


def finding(
    severity: str, code: str, path: str, message: str
) -> dict[str, str]:
    return {"severity": severity, "code": code, "path": path, "message": message}


def collect_objects(
    root: Path,
    pattern: str,
    expected_prefix: str,
) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
    objects: list[dict[str, Any]] = []
    findings: list[dict[str, str]] = []
    seen: dict[str, str] = {}
    for path in sorted(root.glob(pattern)):
        if path.name.lower() == "readme.md":
            continue
        relpath = relative(path, root)
        meta, error = read_frontmatter(path)
        if error:
            findings.append(finding("ERROR", "frontmatter", relpath, error))
            continue
        object_id = str(meta.get("id") or "").strip()
        if not object_id:
            findings.append(finding("ERROR", "missing-id", relpath, "缺少稳定 id"))
            continue
        if not object_id.upper().startswith(expected_prefix.upper()):
            findings.append(
                finding(
                    "ERROR",
                    "invalid-id",
                    relpath,
                    f"id `{object_id}` 不符合 {expected_prefix}* 约定",
                )
            )
        if object_id in seen:
            findings.append(
                finding(
                    "ERROR",
                    "duplicate-id",
                    relpath,
                    f"id `{object_id}` 已被 {seen[object_id]} 使用",
                )
            )
        else:
            seen[object_id] = relpath
        objects.append({"id": object_id, "path": relpath, "meta": meta})
    return objects, findings


def parse_watchlist_hypotheses(path: Path) -> list[tuple[str, str]]:
    if not path.is_file():
        return []
    headers: list[str] | None = None
    references: list[tuple[str, str]] = []
    for line in path.read_text(encoding="utf-8-sig").splitlines():
        if not line.strip().startswith("|"):
            continue
        cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
        if headers is None and "Ticker" in cells and "Hypothesis" in cells:
            headers = cells
            continue
        if headers is not None and cells == headers:
            continue
        if headers is None or len(cells) != len(headers) or set("".join(cells)) == {"-"}:
            continue
        row = dict(zip(headers, cells, strict=True))
        for hypothesis_id in re.split(r"[,;/\s]+", row.get("Hypothesis", "")):
            if hypothesis_id:
                references.append((row.get("Ticker", ""), hypothesis_id))
    return references


def parse_iso_date(value: Any) -> date | None:
    if not value:
        return None
    try:
        return date.fromisoformat(str(value)[:10])
    except ValueError:
        return None


def collect_knowledge(
    root: Path,
) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
    pages: list[dict[str, Any]] = []
    findings: list[dict[str, str]] = []
    seen: dict[str, str] = {}
    for knowledge_type, patterns in KNOWLEDGE_PATHS.items():
        for pattern in patterns:
            for path in sorted(root.glob(pattern)):
                if path.name.startswith("_") or path.name.lower() == "readme.md":
                    continue
                relpath = relative(path, root)
                meta, error = read_frontmatter(path)
                if error:
                    findings.append(
                        finding(
                            "WARN",
                            "legacy-knowledge-frontmatter",
                            relpath,
                            f"旧知识页尚未接入生命周期:{error}",
                        )
                    )
                    continue
                item_id = str(meta.get("id") or "").strip()
                if not item_id:
                    findings.append(
                        finding(
                            "WARN",
                            "legacy-knowledge-id",
                            relpath,
                            "缺少稳定 id;下次复盘时迁移,不自动改写旧页面",
                        )
                    )
                elif not item_id.upper().startswith(KNOWLEDGE_PREFIXES[knowledge_type]):
                    findings.append(
                        finding(
                            "ERROR",
                            "knowledge-id",
                            relpath,
                            f"id `{item_id}` 应使用 {KNOWLEDGE_PREFIXES[knowledge_type]} 前缀",
                        )
                    )
                elif item_id in seen:
                    findings.append(
                        finding(
                            "ERROR",
                            "duplicate-knowledge-id",
                            relpath,
                            f"id `{item_id}` 已被 {seen[item_id]} 使用",
                        )
                    )
                else:
                    seen[item_id] = relpath

                declared_type = str(meta.get("type") or "")
                if declared_type and declared_type != knowledge_type:
                    findings.append(
                        finding(
                            "ERROR",
                            "knowledge-type",
                            relpath,
                            f"目录类型为 {knowledge_type},frontmatter 却是 `{declared_type}`",
                        )
                    )
                status = str(meta.get("status") or "")
                if status not in KNOWLEDGE_STATUSES[knowledge_type]:
                    findings.append(
                        finding(
                            "ERROR",
                            "knowledge-status",
                            relpath,
                            f"非法 status `{status}`",
                        )
                    )
                for required in ("summary", "recall_signals", "decision_scenarios", "review_due"):
                    if not meta.get(required):
                        findings.append(
                            finding(
                                "WARN",
                                "knowledge-load-field",
                                relpath,
                                f"缺少加载或复审字段 `{required}`",
                            )
                        )
                if meta.get("review_due") and parse_iso_date(meta.get("review_due")) is None:
                    findings.append(
                        finding("ERROR", "knowledge-review-date", relpath, "review_due 必须是 YYYY-MM-DD")
                    )
                elif (
                    parse_iso_date(meta.get("review_due"))
                    and parse_iso_date(meta.get("review_due")) < date.today()
                    and status in {"tentative", "validated", "active", "promoted", "candidate", "weakened"}
                ):
                    findings.append(
                        finding("WARN", "knowledge-review-overdue", relpath, "知识卡已到复审日期")
                    )
                if knowledge_type == "pattern" and status in {"active", "promoted"}:
                    if not as_list(meta.get("failure_signals")):
                        findings.append(
                            finding("ERROR", "pattern-failure-signals", relpath, "生效 pattern 必须定义 failure_signals")
                        )
                    if not as_list(meta.get("source_explorations")):
                        findings.append(
                            finding("ERROR", "pattern-sources", relpath, "生效 pattern 必须关联 source_explorations")
                        )
                if knowledge_type == "rule" and status == "active":
                    if not meta.get("scope"):
                        findings.append(finding("ERROR", "rule-scope", relpath, "生效 rule 必须限定 scope"))
                    if not as_list(meta.get("invalidation_signals")):
                        findings.append(
                            finding("ERROR", "rule-invalidation", relpath, "生效 rule 必须定义 invalidation_signals")
                        )
                    try:
                        confirmations = int(meta.get("independent_confirmations") or 0)
                    except (TypeError, ValueError):
                        confirmations = 0
                    if confirmations < 3:
                        findings.append(
                            finding("ERROR", "rule-confirmations", relpath, "生效 rule 至少需要三个独立案例确认")
                        )
                pages.append(
                    {"id": item_id, "type": knowledge_type, "path": relpath, "meta": meta}
                )
    return pages, findings


def validate(root: Path) -> dict[str, Any]:
    research, findings = collect_objects(root, "output/research/*.md", "R")
    preflights, more = collect_objects(root, "output/research/preflight/*.md", "PF-")
    findings.extend(more)
    hypotheses, more = collect_objects(root, "hypothesis/H*.md", "H")
    findings.extend(more)
    evidence, more = collect_objects(root, "evidence/**/*.md", "E-")
    findings.extend(more)
    knowledge, more = collect_knowledge(root)
    findings.extend(more)

    research_ids = {item["id"] for item in research}
    preflight_by_id = {item["id"]: item for item in preflights}
    hypothesis_ids = {item["id"] for item in hypotheses}
    knowledge_ids = {item["id"] for item in knowledge if item["id"]}
    exploration_ids = {
        item["id"] for item in knowledge if item["id"] and item["type"] == "exploration"
    }
    pattern_ids = {
        item["id"] for item in knowledge if item["id"] and item["type"] == "pattern"
    }

    for item in knowledge:
        meta = item["meta"]
        references: list[tuple[str, list[str], set[str]]] = []
        if item["type"] == "pattern":
            references.append(("source_explorations", as_list(meta.get("source_explorations")), exploration_ids))
        if item["type"] == "rule":
            references.append(("source_patterns", as_list(meta.get("source_patterns")), pattern_ids))
        references.append(("supersedes", as_list(meta.get("supersedes")), knowledge_ids))
        references.append(("superseded_by", as_list(meta.get("superseded_by")), knowledge_ids))
        promoted_to = as_list(meta.get("promoted_to"))
        if promoted_to:
            references.append(("promoted_to", promoted_to, knowledge_ids))
        for field, values, valid_ids in references:
            for reference in values:
                if reference not in valid_ids:
                    findings.append(
                        finding(
                            "ERROR",
                            "broken-knowledge-link",
                            item["path"],
                            f"{field} 引用了不存在的知识 `{reference}`",
                        )
                    )

    for item in research:
        meta = item["meta"]
        status = str(meta.get("status") or "")
        if status not in RESEARCH_STATUSES:
            findings.append(
                finding("ERROR", "research-status", item["path"], f"非法 status `{status}`")
            )
        for hypothesis_id in as_list(meta.get("linked_hypotheses")):
            if hypothesis_id not in hypothesis_ids:
                findings.append(
                    finding(
                        "ERROR",
                        "broken-hypothesis-link",
                        item["path"],
                        f"关联假设 `{hypothesis_id}` 不存在",
                    )
                )
        preflight_id = str(meta.get("preflight_id") or "").strip()
        if not preflight_id and status in {"active", "closed"}:
            findings.append(
                finding(
                    "WARN",
                    "missing-research-preflight",
                    item["path"],
                    "进行中或已收口研究缺少 Research Preflight 回执",
                )
            )
        elif preflight_id and preflight_id not in preflight_by_id:
            findings.append(
                finding(
                    "ERROR",
                    "broken-research-preflight",
                    item["path"],
                    f"引用的 preflight `{preflight_id}` 不存在",
                )
            )
        elif preflight_id:
            receipt = preflight_by_id[preflight_id]
            if str(receipt["meta"].get("research_id") or "") != item["id"]:
                findings.append(
                    finding(
                        "ERROR",
                        "preflight-research-mismatch",
                        item["path"],
                        f"回执 `{preflight_id}` 属于另一研究",
                    )
                )
            for field in ("knowledge_used", "wiki_pages_loaded"):
                if set(as_list(meta.get(field))) != set(as_list(receipt["meta"].get(field))):
                    findings.append(
                        finding(
                            "ERROR",
                            "preflight-load-mismatch",
                            item["path"],
                            f"报告与回执的 `{field}` 不一致",
                        )
                    )

    for item in hypotheses:
        meta = item["meta"]
        status = str(meta.get("status") or "")
        if status not in HYPOTHESIS_STATUSES:
            findings.append(
                finding("ERROR", "hypothesis-status", item["path"], f"非法 status `{status}`")
            )
        certainty = meta.get("certainty")
        if isinstance(certainty, str):
            certainty = certainty.rstrip("%")
        try:
            certainty_value = float(certainty)
            if not 0 <= certainty_value <= 100:
                raise ValueError
        except (TypeError, ValueError):
            findings.append(
                finding("ERROR", "certainty", item["path"], "certainty 必须是 0-100 的数字")
            )
        for research_id in as_list(meta.get("linked_research")):
            if research_id not in research_ids:
                findings.append(
                    finding(
                        "ERROR",
                        "broken-research-link",
                        item["path"],
                        f"来源研究 `{research_id}` 不存在",
                    )
                )

    for item in evidence:
        meta = item["meta"]
        direction = str(meta.get("direction") or "")
        review_status = str(meta.get("review_status") or "")
        if direction not in EVIDENCE_DIRECTIONS:
            findings.append(
                finding("ERROR", "evidence-direction", item["path"], f"非法 direction `{direction}`")
            )
        if review_status not in EVIDENCE_REVIEW_STATUSES:
            findings.append(
                finding(
                    "ERROR",
                    "evidence-review-status",
                    item["path"],
                    f"非法 review_status `{review_status}`",
                )
            )
        linked = as_list(meta.get("linked_hypotheses"))
        if not linked:
            findings.append(finding("ERROR", "orphan-evidence", item["path"], "证据未关联任何假设"))
        for hypothesis_id in linked:
            if hypothesis_id not in hypothesis_ids:
                findings.append(
                    finding(
                        "ERROR",
                        "broken-hypothesis-link",
                        item["path"],
                        f"关联假设 `{hypothesis_id}` 不存在",
                    )
                )
        raw_impacts = meta.get("hypothesis_impacts")
        impacts = raw_impacts or {}
        if raw_impacts is None:
            findings.append(
                finding(
                    "WARN",
                    "missing-hypothesis-impacts",
                    item["path"],
                    "旧证据缺少 hypothesis_impacts,建议下次复盘时补齐",
                )
            )
        elif not isinstance(impacts, dict):
            findings.append(
                finding("ERROR", "hypothesis-impacts", item["path"], "hypothesis_impacts 必须是映射")
            )
            impacts = {}
        if raw_impacts is not None:
            for hypothesis_id in linked:
                impact = str(impacts.get(hypothesis_id) or "")
                if impact not in EVIDENCE_DIRECTIONS - {"mixed"}:
                    findings.append(
                        finding(
                            "ERROR",
                            "hypothesis-impact",
                            item["path"],
                            f"假设 `{hypothesis_id}` 缺少合法的逐假设影响判断",
                        )
                    )
        for hypothesis_id in impacts:
            if str(hypothesis_id) not in linked:
                findings.append(
                    finding(
                        "ERROR",
                        "orphan-impact",
                        item["path"],
                        f"hypothesis_impacts 包含未链接假设 `{hypothesis_id}`",
                    )
                )
        for required in (
            "source_provider",
            "source_url",
            "source_published_at",
            "fetched_at",
            "raw_value",
            "dedup_key",
        ):
            if not meta.get(required):
                findings.append(
                    finding("WARN", "missing-provenance", item["path"], f"缺少来源字段 `{required}`")
                )

    watchlist_path = root / "config" / "daily-watchlist-watchlist.md"
    for ticker, hypothesis_id in parse_watchlist_hypotheses(watchlist_path):
        if hypothesis_id not in hypothesis_ids:
            findings.append(
                finding(
                    "ERROR",
                    "watchlist-hypothesis",
                    relative(watchlist_path, root),
                    f"{ticker or '未知标的'} 引用了不存在的假设 `{hypothesis_id}`",
                )
            )

    errors = sum(item["severity"] == "ERROR" for item in findings)
    warnings = sum(item["severity"] == "WARN" for item in findings)
    return {
        "workspace": str(root),
        "objects": {
            "research": len(research),
            "preflights": len(preflights),
            "hypotheses": len(hypotheses),
            "evidence": len(evidence),
            "knowledge": len(knowledge),
        },
        "errors": errors,
        "warnings": warnings,
        "findings": findings,
    }


def render_text(result: dict[str, Any]) -> str:
    objects = result["objects"]
    lines = [
        "AI Workspace Hub 结构验证",
        f"工作区:{result['workspace']}",
        f"对象:研究 {objects['research']} / Preflight {objects['preflights']} / 假设 {objects['hypotheses']} / 证据 {objects['evidence']}",
        f"知识:{objects['knowledge']} 张生命周期卡",
        f"结果:{result['errors']} errors / {result['warnings']} warnings",
    ]
    for item in result["findings"]:
        lines.append(
            f"- [{item['severity']}] {item['code']} {item['path']} - {item['message']}"
        )
    if not result["findings"]:
        lines.append("- OK:对象格式和关联关系正常。")
    return "\n".join(lines)


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--root", type=Path, default=Path.cwd())
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()
    result = validate(find_workspace_root(args.root))
    print(json.dumps(result, ensure_ascii=False, indent=2) if args.json else render_text(result))
    return 2 if result["errors"] else 0


if __name__ == "__main__":
    raise SystemExit(main())
193 files · 8 not served here

Advantages

  • Local-first: the memory stays in files or a database on your own machine (a rule-derived fact from the brain’s files, evidence: README.md)
  • Plain markdown memory that you can read and edit yourself (a rule-derived fact from the brain’s files, evidence: wiki)
  • No API key is named in its files (a rule-derived fact from the brain’s files, evidence: README.md)
  • Open licence (MIT) (a rule-derived fact from the brain’s files)

Limitations

  • No ZIP download: install it from its source (a rule-derived fact from the brain’s files)

Security

Risk level: Low risk

This brain runs

Agent instructions that run or fetch something (24)

  • AGENTS.md: 工具代码在 `tools/` 目录下。首次使用时 agent 检查依赖,缺什么用 `python3 -m pip install ...` 安装;如果环境只有 `python`,再替换成 `python -m pip ...`。
  • AGENTS.md: 用户说“看看现在该做什么”时,运行 `python system/scripts/workspace_status.py`(macOS/Linux 可用 `python3`)。
  • AGENTS.md: python3 system/scripts/prune_active_context.py # dry-run,先看会剪什么
  • AGENTS.md: python3 system/scripts/prune_active_context.py --apply # 确认后执行
  • AGENTS.md: 装好上手后一次性瘦身:`system/skills/post-install-cleanup.md`(清安装脚手架 + 精简必读文件)。
  • AGENTS.md: active-context 三道闸门:`python3 system/scripts/prune_active_context.py --apply`。
  • CLAUDE.md: 工具代码在 `tools/` 目录下。首次使用时 agent 检查依赖,缺什么用 `python3 -m pip install ...` 安装;如果环境只有 `python`,再替换成 `python -m pip ...`。
  • CLAUDE.md: 用户说“看看现在该做什么”时,运行 `python system/scripts/workspace_status.py`(macOS/Linux 可用 `python3`)。
  • CLAUDE.md: python3 system/scripts/prune_active_context.py # dry-run,先看会剪什么
  • CLAUDE.md: python3 system/scripts/prune_active_context.py --apply # 确认后执行
  • CLAUDE.md: 装好上手后一次性瘦身:`system/skills/post-install-cleanup.md`(清安装脚手架 + 精简必读文件)。
  • CLAUDE.md: active-context 三道闸门:`python3 system/scripts/prune_active_context.py --apply`。
  • system/templates/AGENTS.md: 工具代码在 `tools/` 目录下。首次使用时 agent 检查依赖,缺什么用 `python3 -m pip install ...` 安装;如果环境只有 `python`,再替换成 `python -m pip ...`。
  • system/templates/AGENTS.md: 用户说“看看现在该做什么”时,运行 `python system/scripts/workspace_status.py`(macOS/Linux 可用 `python3`)。
  • system/templates/AGENTS.md: python3 system/scripts/prune_active_context.py # dry-run,先看会剪什么
  • system/templates/AGENTS.md: python3 system/scripts/prune_active_context.py --apply # 确认后执行
  • system/templates/AGENTS.md: 装好上手后一次性瘦身:`system/skills/post-install-cleanup.md`(清安装脚手架 + 精简必读文件)。
  • system/templates/AGENTS.md: active-context 三道闸门:`python3 system/scripts/prune_active_context.py --apply`。
  • system/templates/CLAUDE.md: 工具代码在 `tools/` 目录下。首次使用时 agent 检查依赖,缺什么用 `python3 -m pip install ...` 安装;如果环境只有 `python`,再替换成 `python -m pip ...`。
  • system/templates/CLAUDE.md: 用户说“看看现在该做什么”时,运行 `python system/scripts/workspace_status.py`(macOS/Linux 可用 `python3`)。
  • system/templates/CLAUDE.md: python3 system/scripts/prune_active_context.py # dry-run,先看会剪什么
  • system/templates/CLAUDE.md: python3 system/scripts/prune_active_context.py --apply # 确认后执行
  • system/templates/CLAUDE.md: 装好上手后一次性瘦身:`system/skills/post-install-cleanup.md`(清安装脚手架 + 精简必读文件)。
  • system/templates/CLAUDE.md: active-context 三道闸门:`python3 system/scripts/prune_active_context.py --apply`。
  • Low risk: network-instructions (AGENTS.md:52)
  • Low risk: network-instructions (CLAUDE.md:52)
  • Low risk: scripts-present (system/scripts/prune_active_context.py)
  • Low risk: network-instructions (system/templates/AGENTS.md:52)
  • Low risk: network-instructions (system/templates/CLAUDE.md:52)

Attribution and licence

First committed by
GitHub avatar of Benboerba620Benboerba620
Publisher
Benbor (Community)
Source
Benboerba620/ai-workspace-hub · commit e01a41a
Licence
MIT License (files served here)
  • ATTRIBUTION.md (written by AutoGineer)
Synced
Contributors
Benboerba620

AutoGineer is not affiliated with or endorsed by Benboerba620.

Install from source

8 of its files are not served here, so there is no ZIP. Benboerba620/ai-workspace-hub at e01a41a