diff options
| author | sillylaird <sillyfanboy@gmail.com> | 2026-09-03 00:33:59 +0000 |
|---|---|---|
| committer | sillylaird <sillyfanboy@gmail.com> | 2026-09-03 00:33:59 +0000 |
| commit | 898b52edcb47bcb3e9d6106e74ca73e74ea01e70 (patch) | |
| tree | 85c6ee5ad58b860144551184d4cf86b560c62b91 /.agents/skills/obsidian-project-memory | |
| download | www-898b52edcb47bcb3e9d6106e74ca73e74ea01e70.tar.gz www-898b52edcb47bcb3e9d6106e74ca73e74ea01e70.zip | |
Diffstat (limited to '')
12 files changed, 3459 insertions, 0 deletions
diff --git a/.agents/skills/obsidian-project-memory/SKILL.md b/.agents/skills/obsidian-project-memory/SKILL.md new file mode 100644 index 0000000..f6cd9e4 --- /dev/null +++ b/.agents/skills/obsidian-project-memory/SKILL.md @@ -0,0 +1,205 @@ +--- +name: obsidian-project-memory +description: This skill should be used when the user asks to maintain an Obsidian knowledge base for a research project, import an existing research repository into Obsidian, keep project memory or daily notes synchronized, summarize project context into durable notes, or update experiments, results, papers, writing, and plans in an Obsidian vault without requiring MCP. +--- + +# Obsidian Project Memory + +Maintain a **filesystem-first, agent-driven** Obsidian knowledge base for a research project. + +Default note output language follows the project's configured `note_language`; if no note language is configured, default to **English**. Keep technical terms, paper titles, and established folder names in their original form when that is clearer. Only switch note prose to another language when the user explicitly asks. + +Prefer this skill when working inside a repository that: +- already contains `.claude/project-memory/registry.yaml`, or +- clearly looks like a research project and should be bound to an Obsidian vault. + +## Core principles + +- Use **scripts for project state management**. +- Use **agents for project understanding and synthesis**. +- Write only **durable research knowledge** into a **small vault structure**. +- Do **not** require MCP, API keys, REST plugins, or `.base` artifacts. +- Do not depend on `.canvas` globally, but allow literature workflows to maintain `Maps/literature.canvas` as a default literature graph artifact. +- Treat internal experiment summary reports as **durable result-facing notes** under `Results/Reports/`, not as `Writing/` notes. + +## Default vault structure + +Write into this project layout only: + +```text +Research/{project-slug}/ + 00-Hub.md + 01-Plan.md + Knowledge/ + Papers/ + Experiments/ + Results/ + Reports/ + Writing/ + Daily/ + Archive/ +``` + +Read [references/SCHEMA.md](references/SCHEMA.md) for the exact structure and note roles. + +## Deterministic helper script + +Use `scripts/project_kb.py` only for low-freedom operations such as detect, bootstrap, sync, and lifecycle management: + +```bash +python3 scripts/project_kb.py detect --cwd "$PWD" +python3 scripts/project_kb.py bootstrap --cwd "$PWD" --vault-path "$OBSIDIAN_VAULT_PATH" +python3 scripts/project_kb.py sync --cwd "$PWD" --scope auto +python3 scripts/project_kb.py lifecycle --cwd "$PWD" --mode archive +python3 scripts/project_kb.py query-context --cwd "$PWD" --kind broad +python3 scripts/project_kb.py query-context --cwd "$PWD" --kind experiment --query freezing +python3 scripts/project_kb.py find-canonical-note --cwd "$PWD" --kind experiment --query freezing +python3 scripts/project_kb.py note-lifecycle --cwd "$PWD" --mode archive --note "Results/Old-Result.md" +``` + +Do **not** expect the script to understand project meaning. It manages state; it does not replace synthesis. + +Read [references/SCRIPT-VS-AGENT.md](references/SCRIPT-VS-AGENT.md) when deciding whether a task belongs in the script or must stay agent-driven. + +## Default workflow + +### 1. Detect and bind + +1. Run `scripts/project_kb.py detect --cwd "$PWD"`. +2. If the repo is already bound, continue with the existing project. +3. If the repo is not yet bound but is a strong research-project candidate, bootstrap it with `project_kb.py bootstrap`. + +For the detailed lifecycle, read [references/WORKFLOW.md](references/WORKFLOW.md). + +### 2. Read the minimum context + +Before writing anything, read only the minimum stable context: +- `.claude/project-memory/<project_id>.md` +- `00-Hub.md` +- `01-Plan.md` +- today's `Daily/YYYY-MM-DD.md` if it exists + +If the task is about project understanding, existing docs, or historical results, load more context selectively using the references below. + +### 3. Classify the knowledge delta + +Route the current turn into one or more of these buckets: +- `knowledge` +- `paper` +- `experiment` +- `result` +- `writing` +- `daily` +- `project-structure` + +Read [references/NOTE-ROUTING.md](references/NOTE-ROUTING.md) before writing. + +### 4. Follow the default durable research path + +Default path for substantive research work: +- `Papers/` -> extract reusable ideas, baselines, and project relevance +- `Experiments/` -> turn those into testable hypotheses, runbooks, or ablations +- `Results/` -> promote stable findings with evidence and interpretation +- `Results/Reports/` -> store one round or one batch's internal experiment report when a complete retrospective has been written +- `Writing/` -> externalize durable claims into reviews, proposals, drafts, slides, or rebuttal notes + +Use `Daily/` as chronology and staging, not the final home for durable research knowledge. + +Read [references/PAPERS-TO-WRITING.md](references/PAPERS-TO-WRITING.md) when deciding how a turn should advance along this path. + +### 5. Decide whether agent-first synthesis is required + +Use **agent-first import/synthesis** when: +- importing an existing repository for the first time, +- the user says the knowledge base is empty or lacks background, +- multiple source documents must be synthesized into stable project knowledge, +- the project needs a durable overview, research questions, experiment map, or results summary. + +In these cases, first use an agent to read key sources, then write the synthesized result back into Obsidian. + +Read [references/AGENT-FIRST-IMPORT.md](references/AGENT-FIRST-IMPORT.md) for the recommended source-reading order. + +### 6. Write back minimally + +Always keep write-back conservative. + +Write back at least: +- today's `Daily/YYYY-MM-DD.md` when this turn changes project state, +- `00-Hub.md` only when recent progress or top-level status truly changes, +- `.claude/project-memory/<project_id>.md` when project state changes. + +Then write only the durable note that matches the bucket: +- `knowledge` -> `Knowledge/` +- `paper` -> `Papers/` +- `experiment` -> `Experiments/` +- `result` -> `Results/` +- `writing` -> `Writing/` +- `daily` -> `Daily/` +- `project-structure` -> usually `Knowledge/Project-Overview.md` or `Knowledge/Source-Inventory.md` + +Internal experiment round reports should default to: +- `Results/Reports/YYYY-MM-DD--{experiment-line}--r{round}--{purpose}.md` + +Read [references/NOTE-TEMPLATES.md](references/NOTE-TEMPLATES.md) when a note needs a stable shape. + +## Knowledge CRUD rules + +Treat the vault as a small set of **canonical notes** plus supporting daily context. + +### Create + +- Ingest new knowledge deliberately; do not equate every new Markdown file with a durable note. +- Keep **one canonical note per durable object** whenever possible: + - one stable project overview, + - one stable experiment note per experiment line, + - one stable result note per durable finding, + - one stable paper note per paper. +- For new Markdown files, default to **summarize first, then route**: + - promote directly only when the file is already stable and self-contained, + - otherwise merge into an existing canonical note or stage it in `Daily/`. +- If the new durable object is a full internal experiment report, store it under `Results/Reports/` and link the matching `Experiments/` and canonical `Results/` notes. + +### Read + +- Query narrowly first: + - broad project questions -> `00-Hub.md` + key `Knowledge/` notes, + - active work questions -> `01-Plan.md` + today's `Daily/` + project memory, + - specific experiment/result/paper questions -> the matching canonical note first, + - specific internal experiment retrospective -> the matching note in `Results/Reports/` first. +- Use agent synthesis only when the answer spans multiple durable sources or still depends on repo material after reading canonical notes. + +### Update + +- Prefer **updating** an existing canonical note over creating a sibling note. +- Treat raw material as input, not as a final vault object. +- Allow fast append-only logging in `Daily/`, but keep durable knowledge in `Knowledge/`, `Papers/`, `Experiments/`, `Results/`, `Results/Reports/`, or `Writing/`. + +### Delete + +- Treat “remove”, “delete”, or “stop using” as **archive by default**. +- Purge only when the user explicitly asks for permanent deletion. +- When archiving or purging a durable note, repair direct links in `00-Hub.md`, `01-Plan.md`, and explicit index notes so the main working surface does not point to missing files. + +## Safety rules + +- Do not mirror the whole repository into the vault. +- Do not generate empty folder taxonomies or placeholder notes without real content. +- Do not write every repo delta into a new note. +- Do not treat every code change as a knowledge update. +- Do not create `.base` files unless the user explicitly asks for them. +- Do not create arbitrary `.canvas` sprawl; the main default exception is `Maps/literature.canvas` for literature workflows. +- For engineering-only turns, prefer `Daily/` plus project memory unless there is a real experiment, result, or planning impact. +- Treat “remove project knowledge” as **archive** by default; purge only when the user explicitly asks for permanent deletion. + +## Reference files + +Load only what is needed: +- `references/SCHEMA.md` - vault structure and note roles +- `references/WORKFLOW.md` - detect/bootstrap/sync/archive workflow +- `references/PAPERS-TO-WRITING.md` - default handoff from literature to experiments, results, and writing +- `references/SCRIPT-VS-AGENT.md` - boundary between low-freedom script operations and agent-only reasoning +- `references/KNOWLEDGE-CRUD.md` - create/read/update/delete rules for durable research knowledge +- `references/NOTE-ROUTING.md` - where each kind of knowledge should go +- `references/NEW-MD-INGESTION.md` - how to ingest a newly created Markdown file +- `references/AGENT-FIRST-IMPORT.md` - how to import an existing project with agent synthesis +- `references/NOTE-TEMPLATES.md` - lightweight note shapes for common note types diff --git a/.agents/skills/obsidian-project-memory/references/AGENT-FIRST-IMPORT.md b/.agents/skills/obsidian-project-memory/references/AGENT-FIRST-IMPORT.md new file mode 100644 index 0000000..9346bbd --- /dev/null +++ b/.agents/skills/obsidian-project-memory/references/AGENT-FIRST-IMPORT.md @@ -0,0 +1,52 @@ +# Agent-First Import + +## When to use this workflow + +Use agent-first import when: +- a repository already contains substantial project documents, +- the user says the knowledge base lacks background, +- multiple sources must be combined into a stable project summary, +- the project needs a real overview instead of a folder skeleton. + +## Recommended source-reading order + +Read only the most informative sources first: + +1. `README.md` if it is meaningful +2. `plan/` or `docs/` design notes +3. `outputs/analysis/` reports and summaries +4. `run/conf/` for task protocol and experimental assumptions +5. `src/analysis_module/` or other orchestration code for analysis modes +6. `TODO.md` or scratch notes only as supplemental context + +## What the agent should extract + +Ask the agent to produce these sections: +1. project background and research goal +2. core research questions +3. current main experiment lines +4. key results and conclusions +5. codebase-to-knowledge-base mapping +6. recommended durable notes to create or update +7. top content for `00-Hub.md` + +## After the agent returns + +Do not paste the entire agent response into the vault. + +Instead: +- convert the synthesis into durable notes, +- keep one idea per note, +- put high-level framing in `Knowledge/`, +- put experiment logic in `Experiments/`, +- put findings in `Results/`, +- put literature-facing material in `Papers/`. + +## Anti-pattern + +Do not treat file paths as meaning. + +Examples of bad behavior: +- mapping every `plan/*.md` file into a one-to-one note automatically, +- generating empty notes because a folder exists, +- creating new notes before understanding which sources matter. diff --git a/.agents/skills/obsidian-project-memory/references/KNOWLEDGE-CRUD.md b/.agents/skills/obsidian-project-memory/references/KNOWLEDGE-CRUD.md new file mode 100644 index 0000000..9f83e6f --- /dev/null +++ b/.agents/skills/obsidian-project-memory/references/KNOWLEDGE-CRUD.md @@ -0,0 +1,125 @@ +# Knowledge CRUD + +Use these rules when maintaining a research-project knowledge base. + +## Core defaults + +- **One canonical note per durable object** +- **Prefer updating over duplicating** +- **Raw material is not durable knowledge** +- **Archive by default; purge only on explicit request** +- **Query narrowly first, synthesize second** + + +## Default research progression + +When a research turn is substantive, prefer moving durable knowledge forward along this path: + +``` +Papers -> Experiments -> Results -> Writing +``` + +Interpretation: +- a paper note should often end with a testable takeaway, not only a summary, +- an experiment note should clarify what evidence would justify a result note, +- a result note should usually clarify what writing object should absorb the claim next. + +This progression does not mean every turn must touch all four folders. It means the next durable handoff should be made explicit whenever it is already clear. + +## Create + +When new knowledge appears, answer two questions first: + +1. Which type is it? + - `knowledge` + - `paper` + - `experiment` + - `result` + - `writing` + - `daily` +2. Is it a **durable note** or **raw material**? + +Default policy: **summarize first, then route**. + +Promote directly only when the content is already: +- self-contained, +- stable, +- clearly bounded, +- likely to be referenced later. + +Otherwise: +- merge it into the existing canonical note for the same object, or +- stage it in `Daily/` if it is not stable enough yet. + +Never: +- map new files one-to-one into new durable notes by path alone, +- create a new canonical note for the same experiment/result/paper without a real distinction, +- turn every discovered Markdown file into a formal vault object. + +## Read + +Use the smallest sufficient read set first. + +### Query presets + +- broad project question -> `00-Hub.md` + `Knowledge/Project-Overview.md` + `Knowledge/Research-Questions.md` +- next step / active work -> `01-Plan.md` + today's `Daily/` + project memory +- specific experiment -> matching note in `Experiments/` +- specific result -> matching note in `Results/` +- literature question -> matching note in `Papers/` + +### Query order + +1. canonical note +2. neighboring durable notes +3. daily or scratch context +4. repo source docs or outputs +5. agent synthesis + +Do not start by scanning the entire vault or repo when a canonical note already exists. + +## Update + +When new material overlaps an existing durable object: +- update the canonical note, +- do not create a sibling note by default. + +Update style by folder: +- `Knowledge/` -> rewrite stable conclusions; avoid timestamp noise +- `Experiments/` -> preserve experiment identity; add updates, findings, next steps +- `Results/` -> update headline, evidence, interpretation +- `Writing/` -> continue draft evolution or split by output object when necessary +- `Daily/` -> append freely; later promote durable parts + +Merge and split rules: +- merge several small notes when they are clearly about one durable object, +- split a note when it has grown into multiple durable objects with different lifecycles. + +## Delete + +Interpret deletion intent carefully: +- “remove / delete / stop using / no longer needed” -> archive +- “keep history but stop using” -> archive +- “permanently delete / purge” -> purge + +### Archive + +Default action: +- move the target note into `Archive/`, +- repair direct links in `00-Hub.md`, `01-Plan.md`, and explicit index notes, +- avoid leaving the main working surface with broken links. + +### Purge + +Only on explicit permanent-delete intent: +- delete the target note, +- clean direct links in `00-Hub.md`, `01-Plan.md`, and explicit index notes, +- ensure the deleted note was not the only canonical carrier of still-needed knowledge. + +### Rename or move + +Treat rename or move as: +- update of the same durable object, plus +- link repair + +Do not treat rename as delete-plus-create unless the object meaning actually changed. diff --git a/.agents/skills/obsidian-project-memory/references/NEW-MD-INGESTION.md b/.agents/skills/obsidian-project-memory/references/NEW-MD-INGESTION.md new file mode 100644 index 0000000..90c4070 --- /dev/null +++ b/.agents/skills/obsidian-project-memory/references/NEW-MD-INGESTION.md @@ -0,0 +1,81 @@ +# New Markdown Ingestion + +Use this guide when a new Markdown file appears and should be considered for the knowledge base. + +## Three-step decision + +### 1. Classify + +Classify the file as one of: +- `knowledge` +- `paper` +- `experiment` +- `result` +- `writing` +- `daily` + +### 2. Decide: durable note or raw material + +Treat it as a **durable note** only if it is already: +- complete enough to stand on its own, +- stable in scope, +- likely to be referenced later. + +Otherwise treat it as **raw material**. + +### 3. Choose the action + +Choose one of these: + +- **promote** + - use only when the file is already a stable, complete, long-lived note +- **merge** + - use when it supplements an existing canonical note for the same object +- **stage-to-daily** + - use when it is still unstable, incomplete, or not worth promoting yet + +Default answer: **summarize first, then route**. + +## Examples + +### New paper summary + +- likely classification: `paper` +- if complete and stable -> promote to `Papers/` +- if partial reading notes for an existing paper note -> merge + +### New experiment plan + +- likely classification: `experiment` +- if it defines a distinct experiment line -> promote to `Experiments/` +- if it refines an existing experiment -> merge into that note + +### New result memo + +- likely classification: `result` +- if it contains a durable conclusion with evidence -> promote to `Results/` +- if it is a full internal experiment summary report -> promote to `Results/Reports/` using the stable naming contract +- if it is still exploratory -> stage in `Daily/` or merge into the existing experiment note first + +### New meeting note + +- likely classification: `daily` +- default -> stage in `Daily/` +- promote only if the meeting produced a stable decision that belongs in `Knowledge/`, `Experiments/`, or `Writing/` + +### New scratch idea + +- likely classification: `daily` or `knowledge` +- default -> stage in `Daily/` +- promote later only if it becomes a stable research question, method direction, or experiment plan + + +### Cross-stage routing hint + +When a new file is about papers, experiments, results, or writing, do not stop at file classification alone. Also ask whether it clearly implies the next durable handoff: + +- paper note -> should an experiment note be updated? +- experiment note -> is there already a stable finding that belongs in `Results/`? +- result memo -> does a writing note need an update? + +If that next handoff is already clear, prefer updating the downstream canonical note in the same turn. diff --git a/.agents/skills/obsidian-project-memory/references/NOTE-ROUTING.md b/.agents/skills/obsidian-project-memory/references/NOTE-ROUTING.md new file mode 100644 index 0000000..f85bd5a --- /dev/null +++ b/.agents/skills/obsidian-project-memory/references/NOTE-ROUTING.md @@ -0,0 +1,140 @@ +# Note Routing + +## First decide: durable note or raw material + +### Durable note + +Treat content as a durable note when it is: +- likely to be referenced again after days or weeks, +- about a clearly bounded object, +- already stable enough to stand on its own, +- suitable to become the canonical note for that object. + +### Raw material + +Treat content as raw material when it is: +- a short-lived intermediate artifact, +- a draft, memo, scratch note, or meeting fragment, +- an unverified analysis dump, +- incomplete support for a note that already exists. + +Default rule: **raw material should be summarized before promotion**. + +## Route by knowledge type + +### Knowledge + +Write to `Knowledge/` when the content is stable and explanatory: +- project background +- research questions +- dataset protocol +- method landscape +- source inventory +- codebase overview + +Do not put these here by default: +- temporary ideas, +- unverified hypotheses with no stable framing, +- daily execution logs. + +### Papers + +Write to `Papers/` when the content is primarily literature-facing: +- single paper notes +- related-work summaries +- paper-to-project relevance notes +- reading notes and literature synthesis + +Do not put these here by default: +- project-only summaries with no literature object, +- raw meeting notes about papers, +- unrelated implementation notes. + +### Experiments + +Write to `Experiments/` when the content is about what was run or will be run: +- experiment design +- runbook +- ablation +- baseline comparison setup +- freezing / transfer / screening study + +Do not put these here by default: +- raw metric dumps with no interpretation, +- broad project framing, +- final cross-experiment claims that belong in `Results/`. + +### Results + +Write to `Results/` when the content captures a durable finding: +- final comparison +- mechanism conclusion +- collapse diagnosis +- figure and csv index +- cross-experiment interpretation +- stable canonical claim that should survive beyond one report + +Do not put these here by default: +- unprocessed analysis output, +- notes that merely restate the experiment setup, +- temporary result speculation that still belongs in `Daily/`. + +### Results Reports + +Write to `Results/Reports/` when the content is a **complete internal experiment summary report**: +- one experiment round retrospective, +- one batch report for a coherent experiment line, +- one decision-oriented wrap-up note backed by analysis artifacts. + +These notes should use the naming contract: +- `YYYY-MM-DD--{experiment-line}--r{round}--{purpose}.md` + +Do not put these here by default: +- manuscript-facing draft text, +- raw metric dumps, +- vague summaries without one date / line / round / purpose. + +### Writing + +Write to `Writing/` when the content is meant for external output: +- paper draft fragments +- slide narrative +- rebuttal notes +- proposal text + +Internal experiment reports do **not** belong here unless they are already external-facing writing artifacts. + +### Daily + +Write to `Daily/` when the content is transient or process-oriented: +- what happened today +- short sync queue +- quick scratch ideas +- temporary planning fragments +- lightweight meeting notes + +Do not let `Daily/` become the long-term home for canonical project knowledge. Promote durable content later. + +## Main routing rule + +If a note will still matter after several days or weeks, prefer `Knowledge/`, `Experiments/`, `Results/`, `Results/Reports/`, `Papers/`, or `Writing/`. + +If the note is mainly about today's progress or temporary organization, prefer `Daily/`. + +## Cross-folder promotion defaults + +Treat these folders as a research pipeline, not as independent buckets: + +- `Papers/` should usually answer: what should we test, compare, or borrow? +- `Experiments/` should usually answer: what exactly are we running, and what finding would matter? +- `Results/` should usually answer: what do we now believe, with evidence? +- `Results/Reports/` should usually answer: what happened in this round or batch, and what decision does it imply? +- `Writing/` should usually answer: what should be said externally because of those results? + +Default promotion path: +- paper insight -> experiment note +- stable experiment finding -> result note +- coherent round/batch retrospective -> results report note +- durable result claim -> writing note + +If a turn reaches only one stage, keep it there. But when the next stage is already clear, prefer updating the next canonical note instead of leaving the chain broken. diff --git a/.agents/skills/obsidian-project-memory/references/NOTE-TEMPLATES.md b/.agents/skills/obsidian-project-memory/references/NOTE-TEMPLATES.md new file mode 100644 index 0000000..b28c97f --- /dev/null +++ b/.agents/skills/obsidian-project-memory/references/NOTE-TEMPLATES.md @@ -0,0 +1,418 @@ +# Note Templates + +Templates should follow the project's configured `note_language`; if no note language is configured, default to English. + +The examples below are provided in both English and Chinese so agents can mirror the active note language while keeping a stable note shape. + +## Project Overview + +### English + +```markdown +# Project Overview + +## What this project studies +- ... + +## Why this problem is hard +- ... + +## Current project positioning +- ... + +## Most mature research direction so far +- ... +``` + +### 中文 + +```markdown +# 项目概览 + +## 这个项目研究什么 +- ... + +## 为什么这个问题难 +- ... + +## 当前项目定位 +- ... + +## 目前最成熟的研究分支 +- ... +``` + +## Research Questions + +### English + +```markdown +# Research Questions + +## Question 1 +- ... + +## Question 2 +- ... +``` + +### 中文 + +```markdown +# 研究问题 + +## 问题 1 +- ... + +## 问题 2 +- ... +``` + +## Experiment Note + +### English + +```markdown +# Experiment Name + +## Goal +- ... + +## Motivation from papers or prior results +- ... + +## Setup +- ... + +## Main steps +- ... + +## Updates +- ... + +## Findings +- ... + +## Promotion criteria for Results +- ... + +## Next steps +- ... +``` + +### 中文 + +```markdown +# 实验名称 + +## 目标 +- ... + +## 来自论文或已有结果的动机 +- ... + +## 设置 +- ... + +## 主要步骤 +- ... + +## 更新 +- ... + +## 发现 +- ... + +## 晋升到 Results 的条件 +- ... + +## 下一步 +- ... +``` + +## Result Note + +### English + +```markdown +# Result Name + +## Core conclusion +- ... + +## Evidence +- ... + +## Interpretation +- ... + +## Reusable writing points +- ... + +## Why it matters +- ... +``` + +### 中文 + +```markdown +# 结果名称 + +## 核心结论 +- ... + +## 证据 +- ... + +## 解读 +- ... + +## 可延续到写作中的内容 +- ... + +## 为什么重要 +- ... +``` + +## Results Report Note + +### English + +```markdown +--- +type: results-report +date: YYYY-MM-DD +experiment_line: example-line +round: 1 +purpose: transfer-summary +status: active +source_artifacts: + - analysis-output/analysis-report.md +linked_experiments: + - Experiments/Example.md +linked_results: + - Results/Example-Result.md +--- + +# Example Experiment Line / Round 1 / transfer-summary / YYYY-MM-DD + +## Executive summary +- ... + +## Experiment identity and decision context +- ... + +## Setup and evaluation protocol +- ... + +## Main findings +- ... + +## Statistical validation +- ... + +## Figure-by-figure interpretation +- ... + +## Failures / negative results / limitations +- ... + +## Which evidence changed the decision +- ... + +## Next actions +- ... + +## Artifact and reproduction index +- ... +``` + +### 中文 + +```markdown +--- +type: results-report +date: YYYY-MM-DD +experiment_line: example-line +round: 1 +purpose: transfer-summary +status: active +source_artifacts: + - analysis-output/analysis-report.md +linked_experiments: + - Experiments/Example.md +linked_results: + - Results/Example-Result.md +--- + +# 示例实验线 / 第 1 轮 / transfer-summary / YYYY-MM-DD + +## 执行摘要 +- ... + +## 实验身份与决策背景 +- ... + +## 设置与评测协议 +- ... + +## 主要发现 +- ... + +## 统计验证 +- ... + +## 图表逐项解读 +- ... + +## 失败案例 / 负结果 / 局限性 +- ... + +## 哪些证据改变了判断 +- ... + +## 下一步动作 +- ... + +## 产物与复现索引 +- ... +``` + +## Paper Note + +### English + +```markdown +# Paper Title + +## Citation +- ... + +## Core claims +- ... + +## Method +- ... + +## Evidence +- ... + +## Limitations +- ... + +## Direct relevance to this repository +- ... + +## Relation to other papers +- ... +``` + +### 中文 + +```markdown +# 论文标题 + +## 引文 +- ... + +## 核心主张 +- ... + +## 方法 +- ... + +## 证据 +- ... + +## 局限 +- ... + +## 与当前仓库的直接相关性 +- ... + +## 与其他论文的关系 +- ... +``` + +## Daily Note + +### English + +```markdown +# Daily Note - YYYY-MM-DD + +## Focus +- ... + +## Progress +- ... + +## Follow-up knowledge to promote +- ... + +## Next steps +- ... +``` + +### 中文 + +```markdown +# 日志 - YYYY-MM-DD + +## 关注重点 +- ... + +## 进展 +- ... + +## 后续可沉淀 +- ... + +## 下一步 +- ... +``` + + +## Writing Note + +### English + +```markdown +# Writing Item Name + +## Purpose +- ... + +## Supporting results +- ... + +## Paper background or comparisons +- ... + +## Draft argument +- ... + +## What is still missing before publication use +- ... +``` + +### 中文 + +```markdown +# 写作对象名称 + +## 目的 +- ... + +## 有哪些结果支撑 +- ... + +## 论文背景或对比 +- ... + +## 草拟论点 +- ... + +## 距离可发表使用还差什么 +- ... +``` diff --git a/.agents/skills/obsidian-project-memory/references/PAPERS-TO-WRITING.md b/.agents/skills/obsidian-project-memory/references/PAPERS-TO-WRITING.md new file mode 100644 index 0000000..e4b8237 --- /dev/null +++ b/.agents/skills/obsidian-project-memory/references/PAPERS-TO-WRITING.md @@ -0,0 +1,112 @@ +# Papers -> Experiments -> Results -> Writing + +Use this as the default durable research pipeline inside the project knowledge base. + +## Why this pipeline matters + +The vault should not treat literature, experiments, results, and writing as isolated folders. + +Default expectation: +1. `Papers/` produces hypotheses, reusable methods, baselines, and evaluation criteria. +2. `Experiments/` turns those into actionable test plans or updates an existing experiment line. +3. `Results/` captures the durable findings that survive beyond one run or one day. +4. `Writing/` turns those findings into external-facing synthesis: literature review, proposal text, draft claims, slides, rebuttal notes. + +`Daily/` is the staging area for temporary work and chronology, not the final destination for durable research objects. + +## Default handoff rules + +### Papers -> Experiments + +Promote from `Papers/` to `Experiments/` when a paper note yields: +- a testable hypothesis, +- a method variation worth implementing, +- a baseline worth reproducing, +- an ablation worth adding, +- an evaluation protocol or metric worth adopting. + +Default action: +- update the existing canonical experiment note if the idea belongs to an existing experiment line, +- otherwise create one new experiment note for the distinct experiment line, +- add a short back-link from the paper note to that experiment note. + +Do not stop at “this paper is relevant”; push at least to “what should we test because of it?” when the turn supports that level of specificity. + +### Experiments -> Results + +Promote from `Experiments/` to `Results/` when an experiment yields: +- a stable comparison, +- a repeatable failure pattern, +- a durable negative result, +- a mechanism insight, +- a decision-changing observation. + +Default action: +- keep transient run noise in `Daily/` or inside the experiment note, +- create or update a result note only when the observation is stable enough to cite later, +- link the result note back to the experiment note and vice versa. + +Do not create a result note for every run. Create one when the finding survives beyond raw logs. + +### Results -> Writing + +Promote from `Results/` to `Writing/` when a result yields: +- a claim that belongs in a paper, report, slide, or proposal, +- a useful comparison matrix, +- a project narrative update, +- a conclusion that should appear in a literature review or discussion section. + +Default action: +- update an existing writing note when the output object already exists, +- otherwise create one writing note per external object (review, proposal, draft section, slide outline, rebuttal block), +- keep links back to the result notes and key paper notes that support the claim. + +Do not let writing drift away from evidence. Every durable writing claim should link back to supporting results, and when useful, to the motivating papers. + +## Folder-by-folder default questions + +### For a paper note +Ask: +- What is the main reusable idea? +- Does it change what we should test? +- Which existing experiment line should absorb it? +- Does it belong in the active writing narrative yet? + +### For an experiment note +Ask: +- Which paper or prior result motivated this experiment? +- What decision would this experiment change? +- What evidence would justify promotion into `Results/`? +- What writing object would benefit if this experiment succeeds or fails? + +### For a result note +Ask: +- What is the durable claim? +- What evidence supports it? +- Which experiments and papers does it connect? +- Which writing artifact should absorb this claim next? + +### For a writing note +Ask: +- Which result notes support this text? +- Which paper notes provide context or comparison? +- Is this writing object current, or should it be updated from newer results? + +## Main anti-patterns + +Avoid these weak workflows: +- paper notes that never produce experiment decisions, +- experiment notes that never clarify what finding would count as durable, +- result notes that never feed any writing object, +- writing notes that drift away from linked evidence, +- keeping durable insights in `Daily/` instead of promoting them. + +## Default promotion heuristic + +When unsure, use this order: +1. update the existing paper note, +2. if it changes what should be tested, update or create the experiment note, +3. if it changes what is now believed, update or create the result note, +4. if it changes what should be said externally, update the writing note. + +This is the default durable research path unless the user clearly wants a narrower operation. diff --git a/.agents/skills/obsidian-project-memory/references/SCHEMA.md b/.agents/skills/obsidian-project-memory/references/SCHEMA.md new file mode 100644 index 0000000..a897ebf --- /dev/null +++ b/.agents/skills/obsidian-project-memory/references/SCHEMA.md @@ -0,0 +1,62 @@ +# Obsidian Project Knowledge Base Schema + +## Repository-local memory files + +- `.claude/project-memory/registry.yaml` — registry keyed by `project_id` +- `.claude/project-memory/<project_id>.md` — compact project memory snapshot used on project turns + +Note: `registry.yaml` is currently JSON-formatted on disk for historical compatibility. + +Optional per-project registry fields: +- `note_language` — note language for generated/synced notes. Supported values: `en`, `zh-CN`. + +Language resolution priority: +1. project config in `.claude/project-memory/registry.yaml` +2. environment variable `OBSIDIAN_NOTE_LANGUAGE` +3. default `en` + +## Vault layout + +```text +Research/{project-slug}/ + 00-Hub.md + 01-Plan.md + Knowledge/ + Papers/ + Experiments/ + Results/ + Reports/ + Writing/ + Daily/ + Archive/ +``` + +## Role of each top-level location + +- `00-Hub.md` — project homepage, current state, must-remember numbers, key links +- `01-Plan.md` — active goals, tasks, open questions, next actions +- `Knowledge/` — stable project understanding such as background, research questions, method survey, data protocol, source inventory +- `Papers/` — paper notes, literature summaries, related-work assets +- `Experiments/` — experiment designs, runbooks, ablations, mechanism studies +- `Results/` — canonical durable findings, diagnostics, figure/table indexes, cross-experiment conclusions +- `Results/Reports/` — internal experiment round reports and batch retrospectives with stable naming +- `Writing/` — paper drafting, slides, proposal text, rebuttal material +- `Daily/` — daily logs, lightweight sync queue, scratch notes, meeting fragments +- `Archive/` — inactive or historical material that should not stay in the main working surface + +## Minimum note types + +- `project` +- `daily` +- `paper` +- `experiment` +- `result` +- `results-report` +- `synthesis` +- `meta` +- `writing` +- `task` + +## Main design rule + +This schema is intentionally small. Prefer a few durable notes over many placeholder notes. diff --git a/.agents/skills/obsidian-project-memory/references/SCRIPT-VS-AGENT.md b/.agents/skills/obsidian-project-memory/references/SCRIPT-VS-AGENT.md new file mode 100644 index 0000000..b066064 --- /dev/null +++ b/.agents/skills/obsidian-project-memory/references/SCRIPT-VS-AGENT.md @@ -0,0 +1,49 @@ +# Script vs Agent Boundary + +Use this guide to decide whether a task belongs in `project_kb.py` or must remain agent-driven. + +## Good fit for the script + +These tasks are low-freedom, deterministic, and should behave the same every time: + +- detect whether a repo is bound to a project vault +- bootstrap the standard vault structure +- sync repo-driven state into daily, hub, plan, memory, and auto-sync notes +- list or suggest the canonical notes to read for a question shape +- archive, purge, or rename a single note with direct-link repair in explicit index notes +- maintain source inventory and codebase overview + +Current script-facing commands: +- `detect` +- `bootstrap` +- `sync` +- `lifecycle` +- `query-context` +- `find-canonical-note` +- `note-lifecycle` + +## Must stay with the agent + +These tasks require semantic judgment and should not be hard-coded into the script: + +- deciding whether a new Markdown file is durable knowledge or raw material +- deciding whether to promote, merge, or stage a new Markdown file +- deciding which existing note is the canonical note when multiple semantic candidates exist +- synthesizing background from many project documents +- interpreting experimental meaning or result significance +- deciding whether a note should be split or merged based on conceptual overlap +- deciding whether a result is stable enough for `Results/` + +## Practical rule + +If the task can be framed as: + +- “find”, “move”, “rename”, “archive”, “sync”, or “suggest reads” + +it is probably script-suitable. + +If the task can be framed as: + +- “understand”, “interpret”, “decide meaning”, “summarize”, “merge concepts”, or “promote to durable knowledge” + +it should remain agent-driven. diff --git a/.agents/skills/obsidian-project-memory/references/WORKFLOW.md b/.agents/skills/obsidian-project-memory/references/WORKFLOW.md new file mode 100644 index 0000000..6e57ee3 --- /dev/null +++ b/.agents/skills/obsidian-project-memory/references/WORKFLOW.md @@ -0,0 +1,139 @@ +# Workflow + +## 1. Detect + +Run: + +```bash +python3 scripts/project_kb.py detect --cwd "$PWD" +``` + +Use this to decide whether the repo: +- is already bound, +- should be bootstrapped, +- or should be left alone. + +## 2. Bootstrap + +Bootstrap only when the repository is a strong research-project candidate and no binding exists yet. + +```bash +python3 scripts/project_kb.py bootstrap --cwd "$PWD" --vault-path "$OBSIDIAN_VAULT_PATH" +``` + +Bootstrap should create only the compact schema from `SCHEMA.md`, including `Results/Reports/` for internal experiment reports. + +To bootstrap Chinese notes explicitly: + +```bash +python3 scripts/project_kb.py bootstrap --cwd "$PWD" --vault-path "$OBSIDIAN_VAULT_PATH" --note-language zh-CN +``` + +Language priority for generated/synced notes: +1. per-project `note_language` in `.claude/project-memory/registry.yaml` +2. environment variable `OBSIDIAN_NOTE_LANGUAGE` +3. default `en` + +Section updates remain compatible with both English and Chinese headings so older notes can still sync safely after switching the configured language. + +## 3. Daily or repo-driven sync + +Use: + +```bash +python3 scripts/project_kb.py sync --cwd "$PWD" --scope auto +``` + +Use sync for deterministic state maintenance only: +- refresh `00-Hub.md` +- refresh `01-Plan.md` +- refresh project memory +- write daily sync information +- keep source inventory and codebase overview fresh + +Do not rely on sync to derive project meaning from raw files. + +For read-side assistance or single-note lifecycle operations, use: + +```bash +python3 scripts/project_kb.py query-context --cwd "$PWD" --kind broad +python3 scripts/project_kb.py query-context --cwd "$PWD" --kind result --query "syllable-channel" +python3 scripts/project_kb.py find-canonical-note --cwd "$PWD" --kind experiment --query "freezing S7 speaking" +python3 scripts/project_kb.py note-lifecycle --cwd "$PWD" --mode archive --note "Results/Old-Result.md" +``` + +## 4. Agent-first import or synthesis + +When the vault lacks background or context, do not extend the script first. + +Instead: +1. ask an agent to read the most informative project sources, +2. synthesize project-level knowledge, +3. write durable notes back into `Knowledge/`, `Experiments/`, `Results/`, `Results/Reports/`, or `Papers/`. + +## 5. Advance along the main research path + +For substantive research turns, prefer advancing knowledge along this path: + +```text +Papers -> Experiments -> Results -> Writing +``` + +Typical progression: +- new paper understanding -> update `Papers/` and decide whether an experiment note should absorb a new hypothesis, baseline, or evaluation rule +- experiment planning or execution -> update `Experiments/` and decide what evidence would justify a result note +- stable finding -> update `Results/` and decide whether a round or batch retrospective should be written under `Results/Reports/` +- draft or review work -> update `Writing/` and keep links back to supporting results and papers + +Do not treat these folders as isolated silos. The default durable workflow is to move knowledge forward across them when the turn supports it. + +## 6. Incremental update rule + +For most turns, write the minimum durable delta only. + +Examples: +- small engineering change -> `Daily/` plus project memory +- new experiment design -> `Experiments/` +- new result interpretation -> `Results/` +- new internal experiment retrospective -> `Results/Reports/` +- new project framing -> `Knowledge/` +- new paper note -> `Papers/` + +## 7. Ingest a new Markdown file + +When a new `.md` file appears, do not route it by path alone. + +Use this sequence: +1. classify it as `knowledge`, `paper`, `experiment`, `result`, `writing`, or `daily`, +2. decide whether it is a **durable note** or **raw material**, +3. choose one of: + - **promote** into the matching top-level folder, + - **merge** into an existing canonical note, + - **stage to Daily** when it is still unstable. + +Examples: +- new `plan/new_idea.md` -> usually summarize first, then update `01-Plan.md` or `Knowledge/Research-Questions.md` +- a complete experiment summary -> usually promote to `Results/Reports/`, and update `Results/` if a stable conclusion is now supported +- a scratch meeting memo -> usually stage in `Daily/` + +`project_kb.py` may manage state around this process, but it does not decide promote vs merge. + +## 8. Update / archive / purge durable notes + +For durable notes: +- update the canonical note when the object already exists, +- create a new note only when the object is genuinely distinct, +- archive by default when the user wants to remove something, +- purge only on explicit permanent-delete intent. + +When archiving or purging, repair direct links in `00-Hub.md`, `01-Plan.md`, and explicit index notes. + +## 9. Lifecycle actions + +Default removal behavior is archive: + +```bash +python3 scripts/project_kb.py lifecycle --cwd "$PWD" --mode archive +``` + +Only purge when the user explicitly asks for permanent deletion. diff --git a/.agents/skills/obsidian-project-memory/scripts/project_kb.py b/.agents/skills/obsidian-project-memory/scripts/project_kb.py new file mode 100755 index 0000000..eb142ac --- /dev/null +++ b/.agents/skills/obsidian-project-memory/scripts/project_kb.py @@ -0,0 +1,1833 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +IGNORE_DIRS = { + '.git', '.hg', '.svn', '.venv', 'venv', 'node_modules', '__pycache__', + '.mypy_cache', '.pytest_cache', '.ruff_cache', '.idea', '.vscode', + 'dist', 'build', 'checkpoints', 'checkpoint', 'cache', '.cache', '.claude', + 'temp', 'tmp', '.tmp' +} +MAX_LIST_ITEMS = 40 +MAX_SYNC_PATHS = 24 +RECENT_BULLET_LIMIT = 8 +CODE_EXTENSIONS = { + '.py', '.ipynb', '.sh', '.bash', '.zsh', '.js', '.ts', '.tsx', '.jsx', + '.rs', '.go', '.java', '.cpp', '.cc', '.c', '.h', '.hpp', '.yaml', '.yml', + '.toml', '.json', '.ini', '.cfg', '.conf' +} +DOC_EXTENSIONS = {'.md', '.txt', '.rst'} +RESULT_EXTENSIONS = {'.csv', '.json', '.md', '.txt', '.log'} +SYNC_TOPICS = ('plan', 'literature', 'experiments', 'results', 'writing', 'meetings') +DEFAULT_NOTE_LANGUAGE = 'en' +NOTE_LANGUAGE_ENV_VARS = ('OBSIDIAN_NOTE_LANGUAGE',) +NOTE_LANGUAGE_ALIASES = { + 'en': 'en', + 'en-us': 'en', + 'en-gb': 'en', + 'english': 'en', + 'zh': 'zh-CN', + 'zh-cn': 'zh-CN', + 'zh-hans': 'zh-CN', + 'cn': 'zh-CN', + 'chinese': 'zh-CN', +} +SUPPORTED_NOTE_LANGUAGES = ('en', 'zh-CN') +NOTE_KIND_FOLDERS = { + 'knowledge': 'Knowledge', + 'paper': 'Papers', + 'experiment': 'Experiments', + 'result': 'Results', + 'writing': 'Writing', + 'daily': 'Daily', +} +INDEX_NOTE_REL_PATHS = ( + '00-Hub.md', + '01-Plan.md', + 'Knowledge/Source-Inventory.md', + 'Knowledge/Codebase-Overview.md', + 'Results/Figure-and-CSV-Index.md', +) +SECTION_LABELS = { + 'recent_progress': {'en': 'Recent Progress', 'zh-CN': '近期进展'}, + 'active_goals': {'en': 'Active Goals', 'zh-CN': '当前目标'}, + 'active_tasks': {'en': 'Active Tasks', 'zh-CN': '当前任务'}, + 'open_questions': {'en': 'Open Questions', 'zh-CN': '待解决问题'}, + 'focus': {'en': 'Focus', 'zh-CN': '关注重点'}, + 'planned_tasks': {'en': 'Planned Tasks', 'zh-CN': '计划任务'}, + 'notes': {'en': 'Notes', 'zh-CN': '备注'}, + 'current_question': {'en': 'Current Question', 'zh-CN': '当前问题'}, + 'hypotheses': {'en': 'Hypotheses', 'zh-CN': '研究假设'}, + 'open_experiments': {'en': 'Open Experiments', 'zh-CN': '进行中的实验'}, + 'recent_results': {'en': 'Recent Results', 'zh-CN': '近期结果'}, + 'recent_sync_status': {'en': 'Recent Sync Status', 'zh-CN': '最近同步状态'}, + 'repository_signals': {'en': 'Repository Signals', 'zh-CN': '仓库信号'}, + 'sync_queue': {'en': 'Sync Queue', 'zh-CN': '同步队列'}, + 'sync_updates': {'en': 'Sync Updates', 'zh-CN': '同步更新'}, +} +TEXT = { + 'none': {'en': 'None', 'zh-CN': '无'}, + 'none_detected': {'en': 'None detected', 'zh-CN': '未检测到内容'}, + 'source_inventory_title': {'en': 'Source Inventory - {repo_name}', 'zh-CN': '资料清单 - {repo_name}'}, + 'source_inventory_h1': {'en': 'Source Inventory', 'zh-CN': '资料清单'}, + 'source_inventory_imported_from': {'en': 'Imported from `{repo_root}`.', 'zh-CN': '导入来源:`{repo_root}`。'}, + 'markdown_sources': {'en': 'Markdown Sources', 'zh-CN': 'Markdown 资料'}, + 'code_and_config_files': {'en': 'Code and Config Files', 'zh-CN': '代码与配置文件'}, + 'result_and_report_files': {'en': 'Result and Report Files', 'zh-CN': '结果与报告文件'}, + 'codebase_overview_title': {'en': 'Codebase Overview - {repo_name}', 'zh-CN': '代码库概览 - {repo_name}'}, + 'codebase_overview_h1': {'en': 'Codebase Overview', 'zh-CN': '代码库概览'}, + 'repository_root': {'en': 'Repository root', 'zh-CN': '仓库根目录'}, + 'detected_languages': {'en': 'Detected languages', 'zh-CN': '检测到的语言'}, + 'research_project_score': {'en': 'Research-project score', 'zh-CN': '科研项目评分'}, + 'matched_signals': {'en': 'Matched signals', 'zh-CN': '命中的信号'}, + 'top_level_directories': {'en': 'Top-level directories', 'zh-CN': '顶层目录'}, + 'key_entry_files': {'en': 'Key entry files', 'zh-CN': '关键入口文件'}, + 'suggested_knowledge_targets': {'en': 'Suggested knowledge targets', 'zh-CN': '建议沉淀的知识对象'}, + 'suggested_target_1': { + 'en': 'Link experiment scripts to `Experiments/` notes.', + 'zh-CN': '将实验脚本关联到 `Experiments/` 笔记。', + }, + 'suggested_target_2': { + 'en': 'Link evaluation scripts and generated reports to canonical `Results/` notes and `Results/Reports/` when a full retrospective exists.', + 'zh-CN': '将评测脚本和生成报告关联到规范的 `Results/` 与 `Results/Reports/` 笔记。', + }, + 'suggested_target_3': { + 'en': 'Keep planning and TODO updates synchronized with `01-Plan.md` and `Daily/`.', + 'zh-CN': '将计划与 TODO 的更新同步到 `01-Plan.md` 和 `Daily/`。', + }, + 'project_label': {'en': 'Project', 'zh-CN': '项目'}, + 'canvas_description': { + 'en': 'Use this canvas to connect papers, concepts, experiments, and results.', + 'zh-CN': '使用这个画布连接论文、概念、实验与结果。', + }, + 'today_daily_note': {'en': "Today's Daily Note", 'zh-CN': '今日日志'}, + 'hub_mission_heading': {'en': 'Mission', 'zh-CN': '项目使命'}, + 'hub_mission_body': { + 'en': 'Keep the project grounded in a small set of research-facing folders: Knowledge, Papers, Experiments, Results, Results/Reports, Writing, and Daily.', + 'zh-CN': '让项目稳定沉淀在少量研究导向文件夹中:Knowledge、Papers、Experiments、Results、Results/Reports、Writing 和 Daily。', + }, + 'hub_core_index': {'en': 'Core Index', 'zh-CN': '核心索引'}, + 'hub_folder_layout': {'en': 'Folder Layout', 'zh-CN': '目录结构'}, + 'hub_initialized': { + 'en': 'Project knowledge base initialized at {timestamp}.', + 'zh-CN': '项目知识库已于 {timestamp} 初始化。', + }, + 'plan_title_prefix': {'en': 'Plan', 'zh-CN': '计划'}, + 'plan_h1': {'en': 'Plan', 'zh-CN': '计划'}, + 'plan_goal_1': {'en': 'Clarify current research question.', 'zh-CN': '澄清当前研究问题。'}, + 'plan_goal_2': { + 'en': 'Keep experiments, results, and writing synchronized with the vault.', + 'zh-CN': '保持实验、结果与写作内容和知识库同步。', + }, + 'plan_task_1': {'en': 'Review imported project structure', 'zh-CN': '检查已导入的项目结构'}, + 'plan_task_2': {'en': 'Fill in project hypothesis', 'zh-CN': '补全当前研究假设'}, + 'plan_task_3': {'en': 'Add current experiment queue', 'zh-CN': '添加当前实验队列'}, + 'plan_question_1': {'en': 'What is the current milestone?', 'zh-CN': '当前里程碑是什么?'}, + 'plan_question_2': {'en': 'Which experiments are blocked?', 'zh-CN': '哪些实验处于阻塞状态?'}, + 'plan_question_3': { + 'en': 'Which papers or notes should be linked next?', + 'zh-CN': '下一步应该关联哪些论文或笔记?', + }, + 'daily_title_prefix': {'en': 'Daily', 'zh-CN': '日志'}, + 'daily_h1_prefix': {'en': 'Daily Log', 'zh-CN': '日志'}, + 'daily_project_label': {'en': 'Project', 'zh-CN': '项目'}, + 'daily_task_1': {'en': "Review today's objectives", 'zh-CN': '检查今天的目标'}, + 'daily_task_2': {'en': 'Log research or engineering progress', 'zh-CN': '记录研究或工程进展'}, + 'daily_task_3': { + 'en': 'Link new findings to `Experiments/`, `Results/`, or `Papers/` when they become durable', + 'zh-CN': '当新发现稳定后,将其关联到 `Experiments/`、`Results/` 或 `Papers/`', + }, + 'daily_initialized': { + 'en': 'Initialized automatically from project bootstrap.', + 'zh-CN': '由项目 bootstrap 自动初始化。', + }, + 'project_memory_title_prefix': {'en': 'Project Memory', 'zh-CN': '项目记忆'}, + 'project_memory_task_1': {'en': 'Review imported repository structure.', 'zh-CN': '检查已导入的仓库结构。'}, + 'project_memory_task_2': {'en': 'Populate current experiments and results.', 'zh-CN': '补充当前实验和结果。'}, + 'project_memory_task_3': { + 'en': 'Start linking papers and durable project knowledge.', + 'zh-CN': '开始关联论文与可沉淀的项目知识。', + }, + 'project_memory_no_experiments': {'en': 'None recorded yet.', 'zh-CN': '暂无记录。'}, + 'project_memory_initialized': {'en': 'Knowledge base initialized.', 'zh-CN': '知识库已初始化。'}, + 'project_memory_bootstrap_completed': { + 'en': 'Bootstrap completed at {timestamp}.', + 'zh-CN': '已于 {timestamp} 完成 bootstrap。', + }, + 'summary': {'en': 'Summary', 'zh-CN': '摘要'}, + 'changed_paths': {'en': 'Changed Paths', 'zh-CN': '变更路径'}, + 'auto_sync_heading': {'en': 'Auto Sync {timestamp}', 'zh-CN': '自动同步 {timestamp}'}, + 'scope': {'en': 'Scope', 'zh-CN': '范围'}, + 'git_head': {'en': 'Git head', 'zh-CN': 'Git head'}, + 'changed_file_count': {'en': 'Changed files', 'zh-CN': '变更文件数'}, + 'category_summary': {'en': 'Categories', 'zh-CN': '分类'}, + 'sample_paths': {'en': 'Sample paths', 'zh-CN': '样例路径'}, + 'no_trackable_changes': {'en': 'No trackable changes', 'zh-CN': '无可追踪变更'}, + 'sync_hub_bullet': { + 'en': 'Auto sync `{scope}` at {timestamp} recorded {count} changed files ({summary}). See [[{daily_ref}]].', + 'zh-CN': '自动同步 `{scope}` 于 {timestamp} 记录了 {count} 个变更文件({summary})。详见 [[{daily_ref}]]。', + }, + 'sync_time': {'en': 'Recent sync time', 'zh-CN': '最近同步时间'}, + 'check_plan_changes': { + 'en': 'Review plan/TODO/README changes and synchronize `01-Plan.md`.', + 'zh-CN': '检查 plan/TODO/README 的变更,并同步更新 `01-Plan.md`。', + }, + 'record_experiment_changes': { + 'en': 'Record new training, inference, and config changes in `Archive/Auto-Sync/Experiments-Latest-Sync.md`.', + 'zh-CN': '将新的训练、推理和配置变更记录到 `Archive/Auto-Sync/Experiments-Latest-Sync.md`。', + }, + 'summarize_result_changes': { + 'en': 'Summarize new analysis, report, and result files in `Archive/Auto-Sync/Results-Latest-Sync.md`.', + 'zh-CN': '将新变更的分析、报告和结果文件总结到 `Archive/Auto-Sync/Results-Latest-Sync.md`。', + }, + 'review_writing_and_literature': { + 'en': 'Review writing and literature notes, and only promote stable content into `Writing/` or `Papers/`.', + 'zh-CN': '检查写作和文献相关笔记,只在内容稳定时再沉淀到 `Writing/` 或 `Papers/`。', + }, + 'check_engineering_impact': { + 'en': 'Check whether engineering-only code changes affect experiments or results, and write follow-up actions into `01-Plan.md` when needed.', + 'zh-CN': '检查纯工程代码变更是否会影响实验或结果,并在需要时把后续动作写入 `01-Plan.md`。', + }, + 'no_follow_up_tasks': { + 'en': 'Current repository changes do not require follow-up tasks.', + 'zh-CN': '当前仓库变更未检测到需要跟进的任务。', + }, + 'latest_experiment_sync': {'en': 'Latest Experiment Sync', 'zh-CN': '最新实验同步'}, + 'latest_experiment_sync_summary_1': { + 'en': 'Auto sync captured {count} experiment-related paths.', + 'zh-CN': '自动同步捕获了 {count} 条与实验相关的路径。', + }, + 'latest_experiment_sync_summary_2': { + 'en': 'Review training, inference, config, or model changes and convert them into durable experiment notes.', + 'zh-CN': '请检查配置、训练、推理或模型改动,并将其转化为可持续维护的实验笔记。', + }, + 'latest_result_sync': {'en': 'Latest Result Sync', 'zh-CN': '最新结果同步'}, + 'latest_result_sync_summary_1': { + 'en': 'Auto sync captured {count} result-related paths.', + 'zh-CN': '自动同步捕获了 {count} 条与结果相关的路径。', + }, + 'latest_result_sync_summary_2': { + 'en': 'Review analysis, reports, and outputs, and promote important findings into durable result notes.', + 'zh-CN': '请检查分析、报告和输出产物,并将重要发现沉淀为稳定的结果笔记。', + }, + 'sync_memory_experiment_line': {'en': '{timestamp}: touched `{path}`', 'zh-CN': '{timestamp}:涉及 `{path}`'}, + 'sync_memory_result_line': {'en': '{timestamp}: touched `{path}`', 'zh-CN': '{timestamp}:涉及 `{path}`'}, + 'sync_memory_no_results': {'en': 'No result changes recorded.', 'zh-CN': '暂无结果变更记录。'}, + 'sync_memory_status_line': { + 'en': '{timestamp}: scope `{scope}`, git head `{head}`, changed files={count} ({summary}).', + 'zh-CN': '{timestamp}:范围 `{scope}`,git head `{head}`,变更文件数={count}({summary})。', + }, +} + + +@dataclass(frozen=True) +class ProjectBinding: + project_id: str + repo_root: Path + vault_name: str + vault_path: Path + project_root: Path + hub_note: str + status: str + auto_sync: bool + archive_root: Path + note_language: str + + +@dataclass(frozen=True) +class SyncContext: + binding: ProjectBinding + memory_path: Path + project_title: str + timestamp: str + current_head: str + last_synced_head: str + changed_paths: tuple[str, ...] + categorized: dict[str, list[str]] + scope: str + + +def normalize_note_language(value: str | None) -> str | None: + if not value: + return None + return NOTE_LANGUAGE_ALIASES.get(value.strip().lower()) + + +def env_note_language() -> str | None: + for key in NOTE_LANGUAGE_ENV_VARS: + normalized = normalize_note_language(os.environ.get(key)) + if normalized: + return normalized + return None + + +def note_language_from_project_memory(repo_root: Path, project_id: str) -> str | None: + memory_path = repo_root / '.claude' / 'project-memory' / f'{project_id}.md' + frontmatter = parse_frontmatter(read_text(memory_path)) + return normalize_note_language(frontmatter.get('language')) + + +def resolve_note_language( + repo_root: Path, + project_id: str | None = None, + entry: dict[str, Any] | None = None, +) -> str: + if entry: + normalized = normalize_note_language(str(entry.get('note_language', ''))) + if normalized: + return normalized + if project_id: + normalized = note_language_from_project_memory(repo_root, project_id) + if normalized: + return normalized + return env_note_language() or DEFAULT_NOTE_LANGUAGE + + +def resolve_bootstrap_note_language(value: str | None) -> str: + return normalize_note_language(value) or env_note_language() or DEFAULT_NOTE_LANGUAGE + + +def text_value(note_language: str, key: str, **kwargs: Any) -> str: + template = TEXT[key][note_language] + return template.format(**kwargs) + + +def section_heading(section_key: str, note_language: str) -> str: + return SECTION_LABELS[section_key][note_language] + + +def now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z') + + +def slugify(value: str) -> str: + slug = re.sub(r'[^A-Za-z0-9]+', '-', value).strip('-').lower() + return slug or 'research-project' + + +def normalize_note_token(value: str) -> str: + return re.sub(r'[^a-z0-9]+', '-', value.lower()).strip('-') + + +def token_set(value: str) -> set[str]: + return {token for token in re.split(r'[^a-z0-9]+', value.lower()) if token} + + +def titleize_slug(slug: str) -> str: + return ' '.join(part.capitalize() for part in slug.split('-')) + + +def find_repo_root(cwd: Path) -> Path: + try: + output = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], cwd=str(cwd), stderr=subprocess.DEVNULL) + return Path(output.decode().strip()) + except Exception: + cur = cwd.resolve() + for candidate in [cur, *cur.parents]: + if (candidate / '.git').exists(): + return candidate + return cur + + +def registry_path(repo_root: Path) -> Path: + return repo_root / '.claude' / 'project-memory' / 'registry.yaml' + + +def load_registry(path: Path) -> dict[str, Any]: + if not path.exists(): + return {'projects': {}} + text = path.read_text(encoding='utf-8').strip() + if not text: + return {'projects': {}} + data = json.loads(text) + if 'projects' not in data or not isinstance(data['projects'], dict): + data = {'projects': {}} + return data + + +def save_registry(path: Path, data: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') + + +def detect_project_features(repo_root: Path) -> dict[str, Any]: + feature_checks = { + '.git': (repo_root / '.git').exists(), + 'README.md': (repo_root / 'README.md').exists(), + 'docs/*.md': (repo_root / 'docs').exists(), + 'notes/*.md': (repo_root / 'notes').exists(), + 'plan/': (repo_root / 'plan').exists(), + 'results/': (repo_root / 'results').exists(), + 'outputs/': (repo_root / 'outputs').exists(), + 'src/': (repo_root / 'src').exists(), + 'scripts/': (repo_root / 'scripts').exists(), + } + config_hits = [] + for name in ['pyproject.toml', 'requirements.txt', 'environment.yml', 'configs', 'conf', 'Makefile']: + if (repo_root / name).exists(): + config_hits.append(name) + score = sum(1 for value in feature_checks.values() if value) + min(len(config_hits), 2) + return { + 'score': score, + 'matched': [name for name, value in feature_checks.items() if value], + 'config_hits': config_hits, + 'is_candidate': score >= 3, + } + + +def relative_note_path(target: Path, vault_path: Path) -> str: + return str(target.relative_to(vault_path)).replace(os.sep, '/') + + +def should_ignore_relative_path(path: str) -> bool: + parts = Path(path).parts + return any(part in IGNORE_DIRS for part in parts) + + +def safe_walk(base: Path): + for root, dirs, files in os.walk(base): + dirs[:] = [name for name in dirs if name not in IGNORE_DIRS and not name.startswith('.DS_Store')] + yield Path(root), dirs, files + + +def collect_files(repo_root: Path, extensions: set[str], limit: int = MAX_LIST_ITEMS) -> list[Path]: + collected: list[Path] = [] + for root, _, files in safe_walk(repo_root): + for file_name in files: + path = root / file_name + if path.suffix.lower() in extensions: + collected.append(path) + if len(collected) >= limit: + return sorted(collected) + return sorted(collected) + + +def collect_markdown_sources(repo_root: Path) -> list[Path]: + preferred = ['README.md', 'docs', 'notes', 'plan', 'plans', 'TODO.md', 'todo.md'] + result: list[Path] = [] + for name in preferred: + path = repo_root / name + if path.is_file() and path.suffix.lower() in DOC_EXTENSIONS: + result.append(path) + elif path.is_dir(): + for root, _, files in safe_walk(path): + for file_name in files: + candidate = root / file_name + if candidate.suffix.lower() in DOC_EXTENSIONS: + result.append(candidate) + if len(result) >= MAX_LIST_ITEMS: + return sorted(result) + if len(result) < MAX_LIST_ITEMS: + seen = {path.resolve() for path in result} + for path in collect_files(repo_root, DOC_EXTENSIONS, MAX_LIST_ITEMS): + if path.resolve() not in seen: + result.append(path) + if len(result) >= MAX_LIST_ITEMS: + break + return sorted(result) + + +def collect_result_files(repo_root: Path) -> list[Path]: + result_dirs = ['results', 'outputs', 'reports', 'logs'] + result: list[Path] = [] + for name in result_dirs: + path = repo_root / name + if path.is_dir(): + for root, _, files in safe_walk(path): + for file_name in files: + candidate = root / file_name + if candidate.suffix.lower() in RESULT_EXTENSIONS: + result.append(candidate) + if len(result) >= MAX_LIST_ITEMS: + return sorted(result) + return sorted(result) + + +def top_level_dirs(repo_root: Path) -> list[str]: + names: list[str] = [] + for child in sorted(repo_root.iterdir()): + if child.name.startswith('.') or child.name in IGNORE_DIRS: + continue + if child.is_dir(): + names.append(child.name) + return names[:20] + + +def key_entry_files(repo_root: Path) -> list[str]: + candidates: list[str] = [] + names = ['README.md', 'pyproject.toml', 'requirements.txt', 'Makefile', 'run.py', 'train.py', 'main.py', 'setup.py'] + for name in names: + if (repo_root / name).exists(): + candidates.append(name) + for root, _, files in safe_walk(repo_root): + if root == repo_root: + continue + for file_name in sorted(files): + if file_name in {'train.py', 'run.py', 'main.py', 'analyze.py', 'evaluate.py'}: + candidates.append(str((root / file_name).relative_to(repo_root))) + deduped: list[str] = [] + for item in candidates: + if item not in deduped: + deduped.append(item) + return deduped[:20] + + +def detect_language_hints(repo_root: Path) -> list[str]: + hits: list[str] = [] + if (repo_root / 'pyproject.toml').exists() or list(repo_root.glob('*.py')) or (repo_root / 'src').exists(): + hits.append('Python') + if list(repo_root.glob('*.ts')) or list(repo_root.glob('*.js')) or (repo_root / 'package.json').exists(): + hits.append('JavaScript/TypeScript') + if (repo_root / 'Cargo.toml').exists(): + hits.append('Rust') + if (repo_root / 'go.mod').exists(): + hits.append('Go') + return hits or ['Unknown'] + + +def build_source_inventory(repo_root: Path, note_language: str) -> str: + docs = collect_markdown_sources(repo_root) + results = collect_result_files(repo_root) + code = collect_files(repo_root, CODE_EXTENSIONS, MAX_LIST_ITEMS) + + def render(paths: list[Path], label: str) -> str: + if not paths: + return f'## {label}\n\n- {text_value(note_language, "none_detected")}\n' + lines = [f'## {label}', ''] + for path in paths: + lines.append(f'- `{path.relative_to(repo_root)}`') + return '\n'.join(lines) + '\n' + + header = [ + '---', + 'type: meta', + f'title: {text_value(note_language, "source_inventory_title", repo_name=repo_root.name)}', + f'project: {slugify(repo_root.name)}', + f'language: {note_language}', + f'updated: {now_iso()}', + '---', + '', + f'# {text_value(note_language, "source_inventory_h1")}', + '', + text_value(note_language, 'source_inventory_imported_from', repo_root=repo_root), + '', + ] + body = ( + render(docs, text_value(note_language, 'markdown_sources')) + + '\n' + + render(code, text_value(note_language, 'code_and_config_files')) + + '\n' + + render(results, text_value(note_language, 'result_and_report_files')) + ) + return '\n'.join(header) + body + + +def build_codebase_overview(repo_root: Path, note_language: str) -> str: + dirs = top_level_dirs(repo_root) + entry_files = key_entry_files(repo_root) + feature_info = detect_project_features(repo_root) + languages = detect_language_hints(repo_root) + lines = [ + '---', + 'type: meta', + f'title: {text_value(note_language, "codebase_overview_title", repo_name=repo_root.name)}', + f'project: {slugify(repo_root.name)}', + f'language: {note_language}', + f'updated: {now_iso()}', + '---', + '', + f'# {text_value(note_language, "codebase_overview_h1")}', + '', + f'- **{text_value(note_language, "repository_root")}**: `{repo_root}`', + f'- **{text_value(note_language, "detected_languages")}**: {", ".join(languages)}', + f'- **{text_value(note_language, "research_project_score")}**: {feature_info["score"]}', + f'- **{text_value(note_language, "matched_signals")}**: {", ".join(feature_info["matched"] or [text_value(note_language, "none")])}', + '', + f'## {text_value(note_language, "top_level_directories")}', + '', + ] + if dirs: + lines.extend(f'- `{name}`' for name in dirs) + else: + lines.append(f'- {text_value(note_language, "none")}') + lines.extend(['', f'## {text_value(note_language, "key_entry_files")}', '']) + if entry_files: + lines.extend(f'- `{name}`' for name in entry_files) + else: + lines.append(f'- {text_value(note_language, "none")}') + lines.extend([ + '', + f'## {text_value(note_language, "suggested_knowledge_targets")}', + '', + f'- {text_value(note_language, "suggested_target_1")}', + f'- {text_value(note_language, "suggested_target_2")}', + f'- {text_value(note_language, "suggested_target_3")}', + ]) + return '\n'.join(lines) + '\n' + + +def base_file(title: str, folder: str, note_type: str) -> str: + return f'''filters:\n and:\n - 'project == "{{{{this.project}}}}"'\n - 'type == "{note_type}"'\n\nproperties:\n title:\n displayName: "标题"\n status:\n displayName: "状态"\n updated:\n displayName: "更新时间"\n file.path:\n displayName: "路径"\n\nviews:\n - type: table\n name: "{title}"\n filters:\n and:\n - 'file.inFolder("{folder}")'\n order:\n - title\n - status\n - updated\n - file.path\n''' + + +def canvas_file(project_slug: str, title: str, note_language: str) -> str: + return json.dumps( + { + 'nodes': [ + { + 'id': 'hub-node', + 'type': 'text', + 'x': 0, + 'y': 0, + 'width': 440, + 'height': 220, + 'text': ( + f'# {title}\n\n' + f'{text_value(note_language, "project_label")}: [[00-Hub]]\n\n' + f'{text_value(note_language, "canvas_description")}' + ), + }, + { + 'id': 'plan-node', + 'type': 'file', + 'x': 520, + 'y': -20, + 'width': 320, + 'height': 220, + 'file': '../01-Plan.md' + } + ], + 'edges': [ + { + 'id': 'edge-plan', + 'fromNode': 'hub-node', + 'fromSide': 'right', + 'toNode': 'plan-node', + 'toSide': 'left', + 'toEnd': 'arrow', + 'label': project_slug + } + ] + }, + ensure_ascii=False, + indent=2, + ) + '\n' + + +def daily_note_path(project_root: Path) -> Path: + return project_root / 'Daily' / (datetime.now().strftime('%Y-%m-%d') + '.md') + + +def hub_note(project_slug: str, project_title: str, note_language: str) -> str: + today = datetime.now().strftime('%Y-%m-%d') + return f'''--- +type: project +title: {project_title} +project: {project_slug} +language: {note_language} +status: active +tags: + - research/project +updated: {now_iso()} +--- + +# {project_title} + +## {text_value(note_language, 'hub_mission_heading')} +- {text_value(note_language, 'hub_mission_body')} + +## {text_value(note_language, 'hub_core_index')} +- [[01-Plan]] +- [[Daily/{today}|{text_value(note_language, 'today_daily_note')}]] +- [[Knowledge/Source-Inventory]] +- [[Knowledge/Codebase-Overview]] +- `Results/Reports/` + +## {section_heading('recent_progress', note_language)} +- {text_value(note_language, 'hub_initialized', timestamp=now_iso())} + +## {text_value(note_language, 'hub_folder_layout')} +- `Knowledge/` +- `Papers/` +- `Experiments/` +- `Results/` +- `Results/Reports/` +- `Writing/` +- `Daily/` +''' + + +def plan_note(project_slug: str, project_title: str, note_language: str) -> str: + return f'''--- +type: project +title: {text_value(note_language, 'plan_title_prefix')} - {project_title} +project: {project_slug} +language: {note_language} +status: active +updated: {now_iso()} +--- + +# {text_value(note_language, 'plan_h1')} + +## {section_heading('active_goals', note_language)} +- {text_value(note_language, 'plan_goal_1')} +- {text_value(note_language, 'plan_goal_2')} + +## {section_heading('active_tasks', note_language)} +- [ ] {text_value(note_language, 'plan_task_1')} +- [ ] {text_value(note_language, 'plan_task_2')} +- [ ] {text_value(note_language, 'plan_task_3')} + +## {section_heading('open_questions', note_language)} +- {text_value(note_language, 'plan_question_1')} +- {text_value(note_language, 'plan_question_2')} +- {text_value(note_language, 'plan_question_3')} +''' + + +def daily_note(project_slug: str, project_title: str, note_language: str) -> str: + today = datetime.now().strftime('%Y-%m-%d') + return f'''--- +type: daily +title: {text_value(note_language, 'daily_title_prefix')} - {today} +project: {project_slug} +language: {note_language} +status: active +updated: {now_iso()} +--- + +# {text_value(note_language, 'daily_h1_prefix')} - {today} + +## {section_heading('focus', note_language)} +- {text_value(note_language, 'daily_project_label')}: [[00-Hub|{project_title}]] + +## {section_heading('planned_tasks', note_language)} +- [ ] {text_value(note_language, 'daily_task_1')} +- [ ] {text_value(note_language, 'daily_task_2')} +- [ ] {text_value(note_language, 'daily_task_3')} + +## {section_heading('notes', note_language)} +- {text_value(note_language, 'daily_initialized')} +''' + + +def project_memory(project_id: str, repo_root: Path, project_root: Path, hub_rel: str, note_language: str) -> str: + head = get_git_head(repo_root) + return f'''--- +project_id: {project_id} +repo_root: {repo_root} +vault_root: {project_root} +hub_note: {hub_rel} +language: {note_language} +last_sync_at: {now_iso()} +last_synced_head: {head} +status: active +auto_sync: true +--- + +# {text_value(note_language, 'project_memory_title_prefix')}: {project_id} + +## {section_heading('current_question', note_language)} +- TODO + +## {section_heading('hypotheses', note_language)} +- TODO + +## {section_heading('active_tasks', note_language)} +- {text_value(note_language, 'project_memory_task_1')} +- {text_value(note_language, 'project_memory_task_2')} +- {text_value(note_language, 'project_memory_task_3')} + +## {section_heading('open_experiments', note_language)} +- {text_value(note_language, 'project_memory_no_experiments')} + +## {section_heading('recent_results', note_language)} +- {text_value(note_language, 'project_memory_initialized')} + +## {section_heading('recent_sync_status', note_language)} +- {text_value(note_language, 'project_memory_bootstrap_completed', timestamp=now_iso())} +''' + + +def get_git_head(repo_root: Path) -> str: + try: + output = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=str(repo_root), stderr=subprocess.DEVNULL) + return output.decode().strip() + except Exception: + return 'unknown' + + +def ensure_note(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if not path.exists(): + path.write_text(content, encoding='utf-8') + + +def bootstrap_project( + repo_root: Path, + vault_path: Path, + project_name: str | None = None, + force: bool = False, + note_language: str | None = None, +) -> dict[str, Any]: + project_slug = slugify(project_name or repo_root.name) + project_title = project_name or titleize_slug(project_slug) + resolved_language = resolve_bootstrap_note_language(note_language) + project_root = vault_path / 'Research' / project_slug + archive_root = vault_path / 'Archive' + project_root.mkdir(parents=True, exist_ok=True) + for rel in ['Knowledge', 'Papers', 'Experiments', 'Results', 'Results/Reports', 'Writing', 'Daily', 'Archive']: + (project_root / rel).mkdir(parents=True, exist_ok=True) + + ensure_note(project_root / '00-Hub.md', hub_note(project_slug, project_title, resolved_language)) + ensure_note(project_root / '01-Plan.md', plan_note(project_slug, project_title, resolved_language)) + ensure_note(daily_note_path(project_root), daily_note(project_slug, project_title, resolved_language)) + ensure_note(project_root / 'Knowledge' / 'Source-Inventory.md', build_source_inventory(repo_root, resolved_language)) + ensure_note(project_root / 'Knowledge' / 'Codebase-Overview.md', build_codebase_overview(repo_root, resolved_language)) + + reg_path = registry_path(repo_root) + registry = load_registry(reg_path) + entry = { + 'project_id': project_slug, + 'repo_roots': [str(repo_root)], + 'vault_name': os.environ.get('OBSIDIAN_VAULT_NAME', vault_path.name), + 'vault_root': str(project_root), + 'hub_note': relative_note_path(project_root / '00-Hub.md', vault_path), + 'status': 'active', + 'auto_sync': True, + 'note_language': resolved_language, + 'created_at': now_iso(), + 'updated_at': now_iso(), + 'archive_root': str(archive_root), + } + registry['projects'][project_slug] = entry + save_registry(reg_path, registry) + + memory_path = repo_root / '.claude' / 'project-memory' / f'{project_slug}.md' + if force or not memory_path.exists(): + memory_path.parent.mkdir(parents=True, exist_ok=True) + memory_path.write_text( + project_memory(project_slug, repo_root, project_root, entry['hub_note'], resolved_language), + encoding='utf-8', + ) + + return { + 'project_id': project_slug, + 'repo_root': str(repo_root), + 'vault_root': str(project_root), + 'hub_note': entry['hub_note'], + 'memory_file': str(memory_path), + 'note_language': resolved_language, + } + + +def detect(repo_root: Path) -> dict[str, Any]: + reg_path = registry_path(repo_root) + registry = load_registry(reg_path) + matched = None + for project_id, entry in registry.get('projects', {}).items(): + for root in entry.get('repo_roots', []): + try: + if Path(root).resolve() == repo_root.resolve(): + matched = {'project_id': project_id, **entry} + break + except Exception: + continue + if matched: + break + feature_info = detect_project_features(repo_root) + return { + 'repo_root': str(repo_root), + 'registry_path': str(reg_path), + 'is_registered': matched is not None, + 'project': matched, + 'resolved_note_language': resolve_note_language( + repo_root, + matched['project_id'] if matched else None, + matched, + ), + 'candidate': feature_info, + } + + +def resolve_binding(repo_root: Path, project_id: str | None = None) -> ProjectBinding: + registry = load_registry(registry_path(repo_root)) + if not registry.get('projects'): + raise SystemExit('No registered projects found in .claude/project-memory/registry.yaml') + + if project_id is None: + detected = detect(repo_root) + if detected.get('project'): + project_id = detected['project']['project_id'] + elif len(registry['projects']) == 1: + project_id = next(iter(registry['projects'])) + else: + raise SystemExit('Multiple projects registered; pass --project-id') + + entry = registry['projects'].get(project_id) + if not entry: + raise SystemExit(f'Project {project_id!r} not found in registry') + + project_root = Path(entry['vault_root']) + vault_path = project_root.parent.parent + return ProjectBinding( + project_id=project_id, + repo_root=repo_root, + vault_name=entry.get('vault_name', vault_path.name), + vault_path=vault_path, + project_root=project_root, + hub_note=entry.get('hub_note', relative_note_path(project_root / '00-Hub.md', vault_path)), + status=entry.get('status', 'active'), + auto_sync=bool(entry.get('auto_sync', True)), + archive_root=Path(entry.get('archive_root') or (vault_path / 'Archive')), + note_language=resolve_note_language(repo_root, project_id, entry), + ) + + +def lifecycle(repo_root: Path, mode: str, project_id: str | None = None) -> dict[str, Any]: + reg_path = registry_path(repo_root) + registry = load_registry(reg_path) + binding = resolve_binding(repo_root, project_id) + entry = registry['projects'][binding.project_id] + memory_path = repo_root / '.claude' / 'project-memory' / f'{binding.project_id}.md' + + if mode == 'detach': + entry['auto_sync'] = False + entry['status'] = 'detached' + entry['repo_roots'] = [] + entry['updated_at'] = now_iso() + save_registry(reg_path, registry) + return {'mode': mode, 'project_id': binding.project_id, 'registry_path': str(reg_path)} + + if mode == 'archive': + archive_root = binding.archive_root + archive_root.mkdir(parents=True, exist_ok=True) + archive_target = archive_root / binding.project_root.name + if archive_target.exists(): + archive_target = archive_root / f'{binding.project_root.name}-{datetime.now().strftime("%Y%m%d-%H%M%S")}' + if binding.project_root.exists(): + shutil.move(str(binding.project_root), str(archive_target)) + entry['status'] = 'archived' + entry['auto_sync'] = False + entry['repo_roots'] = [] + entry['vault_root'] = str(archive_target) + entry['hub_note'] = str(Path('Archive') / archive_target.name / '00-Hub.md').replace(os.sep, '/') + entry['updated_at'] = now_iso() + save_registry(reg_path, registry) + return {'mode': mode, 'project_id': binding.project_id, 'archive_target': str(archive_target)} + + if mode == 'purge': + if binding.project_root.exists(): + shutil.rmtree(binding.project_root) + if memory_path.exists(): + memory_path.unlink() + del registry['projects'][binding.project_id] + save_registry(reg_path, registry) + return {'mode': mode, 'project_id': binding.project_id, 'purged': True} + + raise SystemExit(f'Unsupported mode: {mode}') + + +def read_text(path: Path, default: str = '') -> str: + return path.read_text(encoding='utf-8') if path.exists() else default + + +def write_text(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content.rstrip() + '\n', encoding='utf-8') + + +def format_frontmatter_value(value: Any) -> str: + if isinstance(value, bool): + return 'true' if value else 'false' + return str(value) + + +def set_frontmatter_value(content: str, key: str, value: Any) -> str: + formatted = format_frontmatter_value(value) + if content.startswith('---\n'): + end = content.find('\n---\n', 4) + if end != -1: + frontmatter = content[4:end] + body = content[end + 5:] + pattern = re.compile(rf'^{re.escape(key)}:\s*.*$', re.M) + if pattern.search(frontmatter): + frontmatter = pattern.sub(f'{key}: {formatted}', frontmatter) + else: + frontmatter = frontmatter.rstrip() + f'\n{key}: {formatted}' + return f'---\n{frontmatter}\n---\n{body.lstrip()}' + return f'---\n{key}: {formatted}\n---\n\n{content.lstrip()}' + + +def parse_frontmatter(content: str) -> dict[str, str]: + if not content.startswith('---\n'): + return {} + end = content.find('\n---\n', 4) + if end == -1: + return {} + data: dict[str, str] = {} + for line in content[4:end].splitlines(): + if ':' not in line or line.strip().startswith('- '): + continue + key, value = line.split(':', 1) + data[key.strip()] = value.strip() + return data + + +def section_heading_candidates(section_key: str) -> list[str]: + values = [SECTION_LABELS[section_key][language] for language in SUPPORTED_NOTE_LANGUAGES] + deduped: list[str] = [] + for value in values: + if value not in deduped: + deduped.append(value) + return deduped + + +def section_heading_pattern(section_key: str) -> str: + return '|'.join(re.escape(item) for item in section_heading_candidates(section_key)) + + +def upsert_section(content: str, section_key: str, body: str, note_language: str) -> str: + section_header = f'## {section_heading(section_key, note_language)}' + body_text = body.strip() or '- None' + pattern = re.compile(rf'(^##\s+(?:{section_heading_pattern(section_key)})\s*\n)(.*?)(?=^##\s+|\Z)', re.M | re.S) + replacement = f'{section_header}\n{body_text}\n\n' + if pattern.search(content): + return pattern.sub(replacement, content, count=1).rstrip() + '\n' + return content.rstrip() + f'\n\n{replacement}' + + +def get_section_body(content: str, section_key: str) -> str: + pattern = re.compile(rf'^##\s+(?:{section_heading_pattern(section_key)})\s*\n(.*?)(?=^##\s+|\Z)', re.M | re.S) + match = pattern.search(content) + return match.group(1).strip() if match else '' + + +def bullet_lines_from_section(content: str, section_key: str) -> list[str]: + section = get_section_body(content, section_key) + return [line.strip() for line in section.splitlines() if line.strip().startswith('- ')] + + +def prepend_bullets( + content: str, + section_key: str, + new_lines: list[str], + note_language: str, + limit: int = RECENT_BULLET_LIMIT, +) -> str: + existing = bullet_lines_from_section(content, section_key) + merged: list[str] = [] + for line in [*new_lines, *existing]: + if line not in merged: + merged.append(line) + return upsert_section(content, section_key, '\n'.join(merged[:limit]), note_language) + + +def append_block(content: str, section_key: str, block: str, note_language: str, limit: int = 4) -> str: + existing = get_section_body(content, section_key) + blocks = [piece.strip() for piece in re.split(r'\n(?=###\s+)', existing) if piece.strip()] + merged = [block.strip(), *blocks] + return upsert_section(content, section_key, '\n\n'.join(merged[:limit]), note_language) + + +def render_bullets(items: list[str], empty: str = '- None recorded.') -> str: + if not items: + return empty + return '\n'.join(items) + + +def limited_paths(paths: list[str], limit: int = MAX_SYNC_PATHS) -> list[str]: + return paths[:limit] + + +def project_note_ref(path: Path, project_root: Path) -> str: + rel = path.relative_to(project_root).as_posix() + return rel[:-3] if rel.endswith('.md') else rel + + +def note_folder_for_kind(kind: str) -> Path: + folder = NOTE_KIND_FOLDERS.get(kind) + if not folder: + raise SystemExit(f'Unsupported note kind: {kind}') + return Path(folder) + + +def list_kind_notes(project_root: Path, kind: str) -> list[Path]: + folder = project_root / note_folder_for_kind(kind) + if not folder.exists(): + return [] + return sorted(path for path in folder.rglob('*.md') if path.is_file()) + + +def index_note_paths(project_root: Path) -> list[Path]: + result: list[Path] = [] + for rel in INDEX_NOTE_REL_PATHS: + path = project_root / rel + if path.exists(): + result.append(path) + return result + + +def unique_stem_in_project(project_root: Path, target: Path) -> bool: + stem = target.stem + matches = list(project_root.rglob(f'{stem}.md')) + return len(matches) == 1 + + +def search_note_candidates(project_root: Path, kind: str, query: str, limit: int = 5) -> list[Path]: + notes = list_kind_notes(project_root, kind) + if not notes: + return [] + + raw_query = query.strip() + query_path = project_root / raw_query + if query_path.exists() and query_path.suffix.lower() == '.md': + return [query_path] + + if raw_query.endswith('.md'): + rel_match = project_root / raw_query + if rel_match.exists(): + return [rel_match] + + query_ref = raw_query[:-3] if raw_query.endswith('.md') else raw_query + query_norm = normalize_note_token(query_ref) + query_tokens = token_set(query_ref) + scored: list[tuple[tuple[int, int, int], Path]] = [] + for note in notes: + ref = project_note_ref(note, project_root) + stem_norm = normalize_note_token(note.stem) + ref_norm = normalize_note_token(ref) + stem_tokens = token_set(note.stem) + ref_tokens = token_set(ref) + score: tuple[int, int, int] | None = None + if ref == query_ref or note.stem == raw_query: + score = (0, len(ref), len(note.stem)) + elif ref_norm == query_norm or stem_norm == query_norm: + score = (1, len(ref_norm), len(stem_norm)) + elif query_norm and query_norm in stem_norm: + score = (2, len(stem_norm), len(ref_norm)) + elif query_norm and query_norm in ref_norm: + score = (3, len(ref_norm), len(stem_norm)) + elif query_tokens: + overlap = len(query_tokens & (stem_tokens | ref_tokens)) + if overlap: + score = (4, -overlap, len(ref_norm)) + if score is not None: + scored.append((score, note)) + scored.sort(key=lambda item: (item[0], project_note_ref(item[1], project_root))) + return [note for _, note in scored[:limit]] + + +def query_context(repo_root: Path, kind: str, query: str | None = None, project_id: str | None = None) -> dict[str, Any]: + binding = resolve_binding(repo_root, project_id) + project_root = binding.project_root + memory_path = repo_root / '.claude' / 'project-memory' / f'{binding.project_id}.md' + today_path = daily_note_path(project_root) + context_paths: list[Path] = [] + + def add(path: Path) -> None: + if path.exists() and path not in context_paths: + context_paths.append(path) + + add(memory_path) + add(project_root / '00-Hub.md') + add(project_root / '01-Plan.md') + + candidate_paths: list[Path] = [] + primary: Path | None = None + + if kind == 'broad': + add(project_root / 'Knowledge' / 'Source-Inventory.md') + add(project_root / 'Knowledge' / 'Codebase-Overview.md') + elif kind == 'next-step': + add(today_path) + elif kind in NOTE_KIND_FOLDERS: + if kind == 'daily': + add(today_path) + candidate_paths = [today_path] if today_path.exists() else [] + elif query: + candidate_paths = search_note_candidates(project_root, kind, query) + if candidate_paths: + primary = candidate_paths[0] + add(primary) + elif kind == 'knowledge': + add(project_root / 'Knowledge' / 'Source-Inventory.md') + add(project_root / 'Knowledge' / 'Codebase-Overview.md') + candidate_paths = [path for path in context_paths if path.parent.name == 'Knowledge'] + else: + raise SystemExit(f'Unsupported query kind: {kind}') + + return { + 'project_id': binding.project_id, + 'kind': kind, + 'query': query or '', + 'primary_note': str(primary) if primary else '', + 'candidate_notes': [str(path) for path in candidate_paths], + 'recommended_reads': [str(path) for path in context_paths], + } + + +def find_canonical_note(repo_root: Path, kind: str, query: str, project_id: str | None = None) -> dict[str, Any]: + if kind not in NOTE_KIND_FOLDERS or kind == 'daily': + raise SystemExit('find-canonical-note supports only knowledge, paper, experiment, result, or writing') + if not query.strip(): + raise SystemExit('find-canonical-note requires a non-empty --query') + + binding = resolve_binding(repo_root, project_id) + project_root = binding.project_root + candidates = search_note_candidates(project_root, kind, query) + primary = candidates[0] if candidates else None + + recommended_reads: list[str] = [] + for path in [ + repo_root / '.claude' / 'project-memory' / f'{binding.project_id}.md', + project_root / '00-Hub.md', + project_root / '01-Plan.md', + primary, + ]: + if path and path.exists(): + text = str(path) + if text not in recommended_reads: + recommended_reads.append(text) + + return { + 'project_id': binding.project_id, + 'kind': kind, + 'query': query, + 'recommended_canonical_note': str(primary) if primary else '', + 'candidate_notes': [str(path) for path in candidates], + 'recommended_reads': recommended_reads, + 'guidance': ( + 'Use this as a candidate finder only. The agent must still decide whether to update ' + 'the recommended note, create a new one, or merge into another durable note.' + ), + } + + +def resolve_project_note(project_root: Path, note: str) -> Path: + candidate = (project_root / note).resolve() + if candidate.exists() and candidate.suffix.lower() == '.md': + return candidate + if note.endswith('.md'): + raise SystemExit(f'Note not found: {note}') + candidate_md = (project_root / f'{note}.md').resolve() + if candidate_md.exists(): + return candidate_md + raise SystemExit(f'Note not found: {note}') + + +def archive_target_for_note(binding: ProjectBinding, note_path: Path) -> Path: + rel = note_path.relative_to(binding.project_root) + target = binding.project_root / 'Archive' / rel + if target.exists(): + target = target.with_name(f'{target.stem}-{datetime.now().strftime("%Y%m%d-%H%M%S")}{target.suffix}') + return target + + +def replace_note_links(content: str, old_path: Path, project_root: Path, new_path: Path | None = None) -> str: + old_ref = project_note_ref(old_path, project_root) + new_ref = project_note_ref(new_path, project_root) if new_path else None + refs = [old_ref] + if unique_stem_in_project(project_root, old_path): + refs.append(old_path.stem) + + def replace_variant(text: str, source_ref: str) -> str: + escaped = re.escape(source_ref) + if new_ref is None: + text = re.sub( + rf'\[\[{escaped}\|([^\]]+)\]\]', + lambda m: f'`{m.group(1)} (deleted)`', + text, + ) + text = re.sub( + rf'\[\[{escaped}\]\]', + f'`{Path(source_ref).name} (deleted)`', + text, + ) + return text + text = re.sub( + rf'\[\[{escaped}\|([^\]]+)\]\]', + lambda m: f'[[{new_ref}|{m.group(1)}]]', + text, + ) + text = re.sub( + rf'\[\[{escaped}\]\]', + f'[[{new_ref}]]', + text, + ) + return text + + updated = content + for source_ref in refs: + updated = replace_variant(updated, source_ref) + return updated + + +def repair_index_links(binding: ProjectBinding, old_path: Path, new_path: Path | None = None) -> list[str]: + touched: list[str] = [] + for path in index_note_paths(binding.project_root): + original = read_text(path) + updated = replace_note_links(original, old_path, binding.project_root, new_path) + if updated != original: + write_text(path, updated) + touched.append(str(path)) + return touched + + +def note_lifecycle(repo_root: Path, mode: str, note: str, dest: str | None = None, project_id: str | None = None) -> dict[str, Any]: + binding = resolve_binding(repo_root, project_id) + note_path = resolve_project_note(binding.project_root, note) + if not note_path.is_relative_to(binding.project_root): + raise SystemExit('Note path must stay inside the project root') + archive_root = (binding.project_root / 'Archive').resolve() + if note_path.is_relative_to(archive_root) and mode != 'rename': + raise SystemExit('Note is already under Archive/') + + if mode == 'archive': + target = archive_target_for_note(binding, note_path) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(note_path), str(target)) + repaired = repair_index_links(binding, note_path, target) + return { + 'mode': mode, + 'note': str(note_path), + 'target': str(target), + 'repaired_index_notes': repaired, + } + + if mode == 'purge': + repaired = repair_index_links(binding, note_path, None) + note_path.unlink() + return { + 'mode': mode, + 'note': str(note_path), + 'purged': True, + 'repaired_index_notes': repaired, + } + + if mode == 'rename': + if not dest: + raise SystemExit('Rename requires --dest') + target = (binding.project_root / dest).resolve() + if target.suffix.lower() != '.md': + target = target.with_suffix('.md') + if not str(target).startswith(str(binding.project_root.resolve())): + raise SystemExit('Rename target must stay inside the project root') + target.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(note_path), str(target)) + repaired = repair_index_links(binding, note_path, target) + return { + 'mode': mode, + 'note': str(note_path), + 'target': str(target), + 'repaired_index_notes': repaired, + } + + raise SystemExit(f'Unsupported note lifecycle mode: {mode}') + + +def git_output(repo_root: Path, args: list[str]) -> str: + try: + output = subprocess.check_output(['git', *args], cwd=str(repo_root), stderr=subprocess.DEVNULL) + return output.decode() + except Exception: + return '' + + +def git_lines(repo_root: Path, args: list[str]) -> list[str]: + output = git_output(repo_root, args) + return [line.rstrip() for line in output.splitlines() if line.strip()] + + +def parse_status_path(line: str) -> str: + payload = line[3:] if len(line) > 3 else line + if ' -> ' in payload: + payload = payload.split(' -> ', 1)[1] + return payload.strip() + + +def collect_repo_changes(repo_root: Path, last_synced_head: str) -> list[str]: + paths: list[str] = [] + seen: set[str] = set() + + if last_synced_head and last_synced_head != 'unknown': + for path in git_lines(repo_root, ['diff', '--name-only', f'{last_synced_head}..HEAD']): + if not should_ignore_relative_path(path) and path not in seen: + paths.append(path) + seen.add(path) + + for path in [parse_status_path(line) for line in git_lines(repo_root, ['status', '--short'])]: + if not path or should_ignore_relative_path(path) or path in seen: + continue + paths.append(path) + seen.add(path) + + return sorted(paths) + + +def classify_path(path: str) -> set[str]: + lowered = path.lower() + top = Path(path).parts[0] if Path(path).parts else '' + categories: set[str] = set() + + if top in {'plan', 'plans', 'docs'} or lowered in {'readme.md', 'todo.md', 'todo.txt'}: + categories.update({'plan', 'writing'}) + if top in {'outputs', 'results', 'reports', 'logs'} or 'report' in lowered or 'metrics' in lowered: + categories.add('results') + if top in {'run', 'scripts'} or lowered.startswith('src/trainer_module') or lowered.startswith('src/model_module'): + categories.add('experiments') + if lowered.startswith('src/analysis_module') or 'analysis' in lowered or 'eda' in lowered: + categories.add('results') + if lowered.startswith('src/data_module') or lowered.startswith('src/model_module') or lowered.startswith('src/trainer_module'): + categories.add('experiments') + if any(token in Path(path).name.lower() for token in ['train', 'inference', 'infer', 'eval', 'experiment']): + categories.add('experiments') + if 'paper' in lowered or 'citation' in lowered or top in {'papers', 'literature'}: + categories.update({'literature', 'writing'}) + if top in {'meeting', 'meetings'}: + categories.add('meetings') + if top in {'src', 'tests', 'test'}: + categories.add('engineering') + if not categories: + categories.add('engineering') + return categories + + +def categorize_paths(paths: list[str]) -> dict[str, list[str]]: + categorized: dict[str, list[str]] = {topic: [] for topic in [*SYNC_TOPICS, 'engineering']} + for path in paths: + for category in classify_path(path): + categorized.setdefault(category, []).append(path) + for key in categorized: + categorized[key] = sorted(dict.fromkeys(categorized[key])) + return categorized + + +def summarize_categories(categorized: dict[str, list[str]]) -> list[str]: + ordered = ['plan', 'experiments', 'results', 'literature', 'writing', 'meetings', 'engineering'] + return [f'{name}={len(categorized.get(name, []))}' for name in ordered if categorized.get(name)] + + +def selected_topics(scope: str, categorized: dict[str, list[str]]) -> set[str]: + if scope == 'all': + return set(SYNC_TOPICS) + if scope in SYNC_TOPICS: + return {scope} + if scope == 'daily': + return set() + auto = {topic for topic in SYNC_TOPICS if categorized.get(topic)} + if any(categorized.get(topic) for topic in ('experiments', 'results', 'writing', 'literature', 'engineering')): + auto.add('plan') + return auto + + +def repo_change_bullets(categorized: dict[str, list[str]], note_language: str) -> list[str]: + bullets: list[str] = [] + if categorized.get('plan'): + bullets.append(f'- {text_value(note_language, "check_plan_changes")}') + if categorized.get('experiments'): + bullets.append(f'- {text_value(note_language, "record_experiment_changes")}') + if categorized.get('results'): + bullets.append(f'- {text_value(note_language, "summarize_result_changes")}') + if categorized.get('literature') or categorized.get('writing'): + bullets.append(f'- {text_value(note_language, "review_writing_and_literature")}') + if categorized.get('engineering'): + bullets.append(f'- {text_value(note_language, "check_engineering_impact")}') + if not bullets: + bullets.append(f'- {text_value(note_language, "no_follow_up_tasks")}') + return bullets + + +def topic_note( + title: str, + note_type: str, + project_id: str, + summary: list[str], + paths: list[str], + note_language: str, + extra_heading: str | None = None, + extra_lines: list[str] | None = None, +) -> str: + sections = [ + '---', + f'type: {note_type}', + f'title: {title}', + f'project: {project_id}', + f'language: {note_language}', + 'status: active', + f'updated: {now_iso()}', + '---', + '', + f'# {title}', + '', + f'## {text_value(note_language, "summary")}', + '', + *summary, + '', + f'## {text_value(note_language, "changed_paths")}', + '', + *(f'- `{path}`' for path in limited_paths(paths)), + ] + if extra_heading and extra_lines: + sections.extend(['', f'## {extra_heading}', '', *extra_lines]) + return '\n'.join(sections) + '\n' + + +def build_sync_context(binding: ProjectBinding, scope: str) -> SyncContext: + memory_path = binding.repo_root / '.claude' / 'project-memory' / f'{binding.project_id}.md' + memory_text = read_text(memory_path) + frontmatter = parse_frontmatter(memory_text) + last_synced_head = frontmatter.get('last_synced_head', 'unknown') + changed_paths = tuple(collect_repo_changes(binding.repo_root, last_synced_head)) + categorized = categorize_paths(list(changed_paths)) + return SyncContext( + binding=binding, + memory_path=memory_path, + project_title=titleize_slug(binding.project_id), + timestamp=now_iso(), + current_head=get_git_head(binding.repo_root), + last_synced_head=last_synced_head, + changed_paths=changed_paths, + categorized=categorized, + scope=scope, + ) + + +def refresh_meta(ctx: SyncContext) -> None: + write_text( + ctx.binding.project_root / 'Knowledge' / 'Source-Inventory.md', + build_source_inventory(ctx.binding.repo_root, ctx.binding.note_language), + ) + write_text( + ctx.binding.project_root / 'Knowledge' / 'Codebase-Overview.md', + build_codebase_overview(ctx.binding.repo_root, ctx.binding.note_language), + ) + + +def sync_daily(ctx: SyncContext) -> Path: + daily_path = daily_note_path(ctx.binding.project_root) + if not daily_path.exists(): + write_text(daily_path, daily_note(ctx.binding.project_id, ctx.project_title, ctx.binding.note_language)) + content = read_text(daily_path) + content = set_frontmatter_value(content, 'language', ctx.binding.note_language) + content = set_frontmatter_value(content, 'updated', ctx.timestamp) + category_summary = ', '.join(summarize_categories(ctx.categorized)) or text_value(ctx.binding.note_language, 'none') + sample_paths = [f' - `{path}`' for path in limited_paths(list(ctx.changed_paths), 10)] or [f' - {text_value(ctx.binding.note_language, "none")}'] + block = '\n'.join([ + f'### {text_value(ctx.binding.note_language, "auto_sync_heading", timestamp=ctx.timestamp)}', + f'- {text_value(ctx.binding.note_language, "scope")}: `{ctx.scope}`', + f'- {text_value(ctx.binding.note_language, "git_head")}: `{ctx.current_head}`', + f'- {text_value(ctx.binding.note_language, "changed_file_count")}: {len(ctx.changed_paths)}', + f'- {text_value(ctx.binding.note_language, "category_summary")}: {category_summary}', + f'- {text_value(ctx.binding.note_language, "sample_paths")}:', + *sample_paths, + ]) + content = append_block(content, 'sync_updates', block, ctx.binding.note_language) + write_text(daily_path, content) + return daily_path + + +def sync_hub(ctx: SyncContext, daily_path: Path) -> None: + hub_path = ctx.binding.project_root / '00-Hub.md' + content = read_text(hub_path, hub_note(ctx.binding.project_id, ctx.project_title, ctx.binding.note_language)) + content = set_frontmatter_value(content, 'language', ctx.binding.note_language) + content = set_frontmatter_value(content, 'updated', ctx.timestamp) + summary = ', '.join(summarize_categories(ctx.categorized)) or text_value(ctx.binding.note_language, 'no_trackable_changes') + bullet = '- ' + text_value( + ctx.binding.note_language, + 'sync_hub_bullet', + scope=ctx.scope, + timestamp=ctx.timestamp, + count=len(ctx.changed_paths), + summary=summary, + daily_ref=daily_path.relative_to(ctx.binding.project_root).as_posix(), + ) + content = prepend_bullets(content, 'recent_progress', [bullet], ctx.binding.note_language) + write_text(hub_path, content) + + +def sync_plan(ctx: SyncContext) -> None: + plan_path = ctx.binding.project_root / '01-Plan.md' + content = read_text(plan_path, plan_note(ctx.binding.project_id, ctx.project_title, ctx.binding.note_language)) + content = set_frontmatter_value(content, 'language', ctx.binding.note_language) + content = set_frontmatter_value(content, 'updated', ctx.timestamp) + signal_lines = [ + f'- {text_value(ctx.binding.note_language, "sync_time")}: {ctx.timestamp}', + f'- {text_value(ctx.binding.note_language, "git_head")}: `{ctx.current_head}`', + f'- {text_value(ctx.binding.note_language, "changed_file_count")}: {len(ctx.changed_paths)}', + f'- {text_value(ctx.binding.note_language, "category_summary")}: {", ".join(summarize_categories(ctx.categorized)) or text_value(ctx.binding.note_language, "none")}', + ] + content = upsert_section(content, 'repository_signals', '\n'.join(signal_lines), ctx.binding.note_language) + content = upsert_section( + content, + 'sync_queue', + render_bullets(repo_change_bullets(ctx.categorized, ctx.binding.note_language)), + ctx.binding.note_language, + ) + write_text(plan_path, content) + + +def sync_experiments(ctx: SyncContext) -> None: + paths = ctx.categorized.get('experiments', []) + if not paths: + return + write_text( + ctx.binding.project_root / 'Archive' / 'Auto-Sync' / 'Experiments-Latest-Sync.md', + topic_note( + title=text_value(ctx.binding.note_language, 'latest_experiment_sync'), + note_type='experiment', + project_id=ctx.binding.project_id, + summary=[ + f'- {text_value(ctx.binding.note_language, "latest_experiment_sync_summary_1", count=len(paths))}', + f'- {text_value(ctx.binding.note_language, "latest_experiment_sync_summary_2")}', + ], + paths=paths, + note_language=ctx.binding.note_language, + ), + ) + + +def sync_results(ctx: SyncContext) -> None: + paths = ctx.categorized.get('results', []) + if not paths: + return + write_text( + ctx.binding.project_root / 'Archive' / 'Auto-Sync' / 'Results-Latest-Sync.md', + topic_note( + title=text_value(ctx.binding.note_language, 'latest_result_sync'), + note_type='result', + project_id=ctx.binding.project_id, + summary=[ + f'- {text_value(ctx.binding.note_language, "latest_result_sync_summary_1", count=len(paths))}', + f'- {text_value(ctx.binding.note_language, "latest_result_sync_summary_2")}', + ], + paths=paths, + note_language=ctx.binding.note_language, + ), + ) + + +def sync_writing(ctx: SyncContext) -> None: + return + + +def sync_project_memory(ctx: SyncContext) -> None: + content = read_text( + ctx.memory_path, + project_memory( + ctx.binding.project_id, + ctx.binding.repo_root, + ctx.binding.project_root, + ctx.binding.hub_note, + ctx.binding.note_language, + ), + ) + for key, value in { + 'repo_root': ctx.binding.repo_root, + 'vault_root': ctx.binding.project_root, + 'hub_note': ctx.binding.hub_note, + 'language': ctx.binding.note_language, + 'last_sync_at': ctx.timestamp, + 'last_synced_head': ctx.current_head, + 'status': 'active', + 'auto_sync': True, + }.items(): + content = set_frontmatter_value(content, key, value) + + existing_tasks = bullet_lines_from_section(content, 'active_tasks') + generated_tasks = [line.replace('- [ ] ', '- ').replace('- ', '- ') for line in repo_change_bullets(ctx.categorized, ctx.binding.note_language)] + merged_tasks: list[str] = [] + for line in [*existing_tasks, *generated_tasks]: + if line not in merged_tasks: + merged_tasks.append(line) + content = upsert_section( + content, + 'active_tasks', + render_bullets(merged_tasks[:RECENT_BULLET_LIMIT]), + ctx.binding.note_language, + ) + + experiment_lines = [ + f'- {text_value(ctx.binding.note_language, "sync_memory_experiment_line", timestamp=ctx.timestamp, path=path)}' + for path in limited_paths(ctx.categorized.get('experiments', []), 8) + ] + result_lines = [ + f'- {text_value(ctx.binding.note_language, "sync_memory_result_line", timestamp=ctx.timestamp, path=path)}' + for path in limited_paths(ctx.categorized.get('results', []), 8) + ] + content = upsert_section( + content, + 'open_experiments', + render_bullets(experiment_lines, f'- {text_value(ctx.binding.note_language, "project_memory_no_experiments")}'), + ctx.binding.note_language, + ) + content = upsert_section( + content, + 'recent_results', + render_bullets(result_lines, f'- {text_value(ctx.binding.note_language, "sync_memory_no_results")}'), + ctx.binding.note_language, + ) + + summary = ', '.join(summarize_categories(ctx.categorized)) or text_value(ctx.binding.note_language, 'no_trackable_changes') + sync_line = '- ' + text_value( + ctx.binding.note_language, + 'sync_memory_status_line', + timestamp=ctx.timestamp, + scope=ctx.scope, + head=ctx.current_head, + count=len(ctx.changed_paths), + summary=summary, + ) + content = prepend_bullets(content, 'recent_sync_status', [sync_line], ctx.binding.note_language) + write_text(ctx.memory_path, content) + + +def update_registry_after_sync(ctx: SyncContext) -> None: + path = registry_path(ctx.binding.repo_root) + registry = load_registry(path) + entry = registry['projects'][ctx.binding.project_id] + entry['updated_at'] = ctx.timestamp + entry['status'] = 'active' + entry['auto_sync'] = True + save_registry(path, registry) + + +def sync_project(repo_root: Path, scope: str, project_id: str | None = None) -> dict[str, Any]: + binding = resolve_binding(repo_root, project_id) + if binding.status == 'archived': + raise SystemExit('Project is archived; rebuild or rebind before syncing') + + ctx = build_sync_context(binding, scope) + refresh_meta(ctx) + daily_path = sync_daily(ctx) + sync_hub(ctx, daily_path) + sync_project_memory(ctx) + + selected = selected_topics(scope, ctx.categorized) + if scope in {'all', 'plan'} or 'plan' in selected: + sync_plan(ctx) + if scope in {'all', 'experiments'} or 'experiments' in selected: + sync_experiments(ctx) + if scope in {'all', 'results'} or 'results' in selected: + sync_results(ctx) + if scope in {'all', 'literature', 'writing'} or {'literature', 'writing'} & selected: + sync_writing(ctx) + + update_registry_after_sync(ctx) + return { + 'project_id': ctx.binding.project_id, + 'scope': scope, + 'project_root': str(ctx.binding.project_root), + 'daily_note': str(daily_path), + 'changed_files': len(ctx.changed_paths), + 'categories': {key: len(value) for key, value in ctx.categorized.items() if value}, + 'selected_topics': sorted(selected), + 'sample_paths': limited_paths(list(ctx.changed_paths), 12), + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description='Bootstrap and manage Obsidian project knowledge bases.') + sub = parser.add_subparsers(dest='cmd', required=True) + + detect_parser = sub.add_parser('detect') + detect_parser.add_argument('--cwd', default='.') + + boot_parser = sub.add_parser('bootstrap') + boot_parser.add_argument('--cwd', default='.') + boot_parser.add_argument('--vault-path', default=os.environ.get('OBSIDIAN_VAULT_PATH', '')) + boot_parser.add_argument('--project-name', default='') + boot_parser.add_argument('--note-language', default='') + boot_parser.add_argument('--force', action='store_true') + + life_parser = sub.add_parser('lifecycle') + life_parser.add_argument('--cwd', default='.') + life_parser.add_argument('--mode', required=True, choices=['detach', 'archive', 'purge']) + life_parser.add_argument('--project-id', default='') + + sync_parser = sub.add_parser('sync') + sync_parser.add_argument('--cwd', default='.') + sync_parser.add_argument('--scope', default='auto', choices=['auto', 'daily', 'plan', 'literature', 'experiments', 'results', 'all']) + sync_parser.add_argument('--project-id', default='') + + query_parser = sub.add_parser('query-context') + query_parser.add_argument('--cwd', default='.') + query_parser.add_argument('--kind', required=True, choices=['broad', 'next-step', 'knowledge', 'paper', 'experiment', 'result', 'writing', 'daily']) + query_parser.add_argument('--query', default='') + query_parser.add_argument('--project-id', default='') + + canonical_parser = sub.add_parser('find-canonical-note') + canonical_parser.add_argument('--cwd', default='.') + canonical_parser.add_argument('--kind', required=True, choices=['knowledge', 'paper', 'experiment', 'result', 'writing']) + canonical_parser.add_argument('--query', required=True) + canonical_parser.add_argument('--project-id', default='') + + note_parser = sub.add_parser('note-lifecycle') + note_parser.add_argument('--cwd', default='.') + note_parser.add_argument('--mode', required=True, choices=['archive', 'purge', 'rename']) + note_parser.add_argument('--note', required=True, help='Project-relative path to the markdown note') + note_parser.add_argument('--dest', default='', help='Destination path for rename, relative to the project root') + note_parser.add_argument('--project-id', default='') + + return parser.parse_args() + + +def main() -> None: + args = parse_args() + repo_root = find_repo_root(Path(args.cwd).resolve()) + + if args.cmd == 'detect': + print(json.dumps(detect(repo_root), ensure_ascii=False, indent=2)) + return + + if args.cmd == 'bootstrap': + if not args.vault_path: + raise SystemExit('Missing vault path. Pass --vault-path or set OBSIDIAN_VAULT_PATH.') + result = bootstrap_project( + repo_root, + Path(args.vault_path).expanduser().resolve(), + args.project_name or None, + args.force, + args.note_language or None, + ) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return + + if args.cmd == 'lifecycle': + result = lifecycle(repo_root, args.mode, args.project_id or None) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return + + if args.cmd == 'sync': + result = sync_project(repo_root, args.scope, args.project_id or None) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return + + if args.cmd == 'query-context': + result = query_context(repo_root, args.kind, args.query or None, args.project_id or None) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return + + if args.cmd == 'find-canonical-note': + result = find_canonical_note(repo_root, args.kind, args.query, args.project_id or None) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return + + if args.cmd == 'note-lifecycle': + result = note_lifecycle(repo_root, args.mode, args.note, args.dest or None, args.project_id or None) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return + + +if __name__ == '__main__': + main() diff --git a/.agents/skills/obsidian-project-memory/scripts/project_views.py b/.agents/skills/obsidian-project-memory/scripts/project_views.py new file mode 100755 index 0000000..e388e73 --- /dev/null +++ b/.agents/skills/obsidian-project-memory/scripts/project_views.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +import project_kb # type: ignore + +NOTE_WIDTH = 320 +NOTE_HEIGHT = 220 + + +def write_if_changed(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists() and path.read_text(encoding='utf-8') == content: + return + path.write_text(content, encoding='utf-8') + + +def file_node(node_id: str, rel_path: str, x: int, y: int) -> dict: + return { + 'id': node_id, + 'type': 'file', + 'file': rel_path, + 'x': x, + 'y': y, + 'width': NOTE_WIDTH, + 'height': NOTE_HEIGHT, + } + + +def text_node(node_id: str, text: str, x: int, y: int, width: int = 280, height: int = 120) -> dict: + return { + 'id': node_id, + 'type': 'text', + 'text': text, + 'x': x, + 'y': y, + 'width': width, + 'height': height, + } + + +def edge(edge_id: str, from_node: str, to_node: str, label: str = '') -> dict: + payload = { + 'id': edge_id, + 'fromNode': from_node, + 'toNode': to_node, + 'fromSide': 'right', + 'toSide': 'left', + } + if label: + payload['label'] = label + return payload + + +def build_base_content(folder: str, title: str, order: list[str], extra_filters: list[str] | None = None) -> str: + filter_expr = [f'file.inFolder("{folder}")'] + if extra_filters: + filter_expr.extend(extra_filters) + if len(filter_expr) == 1: + filters_block = f"filters: '{filter_expr[0]}'" + else: + inner = '\n'.join(f" - '{expr}'" for expr in filter_expr) + filters_block = f"filters:\n and:\n{inner}" + + order_block = '\n'.join(f' - {item}' for item in order) + return f'''# Generated by Claude Scholar project_views.py +{filters_block} +properties: + title: + displayName: "Title" + status: + displayName: "Status" + updated: + displayName: "Updated" + year: + displayName: "Year" + venue: + displayName: "Venue" + zotero_key: + displayName: "Zotero Key" + linked_experiment: + displayName: "Experiment" + decision: + displayName: "Decision" +views: + - type: table + name: "{title}" + order: +{order_block} +''' + + +def generate_bases(project_root: Path) -> list[str]: + views_root = project_root / 'Views' + files = { + 'papers.base': build_base_content( + 'Papers', 'Papers', + ['file.name', 'title', 'year', 'venue', 'status', 'updated', 'zotero_key'] + ), + 'experiments.base': build_base_content( + 'Experiments', 'Experiments', + ['file.name', 'title', 'status', 'updated'] + ), + 'results.base': build_base_content( + 'Results', 'Results', + ['file.name', 'title', 'linked_experiment', 'decision', 'updated'] + ), + 'tasks.base': build_base_content( + 'Daily', 'Tasks', + ['file.name', 'title', 'status', 'updated'], + extra_filters=['file.ext == "md"'] + ), + } + written = [] + for name, content in files.items(): + path = views_root / name + write_if_changed(path, content) + written.append(str(path)) + return written + + +def existing_rel(project_root: Path, rel: str) -> str | None: + path = project_root / rel + if path.exists(): + return rel + return None + + +def generate_project_canvas(project_root: Path) -> str: + maps_root = project_root / 'Maps' + nodes = [] + edges = [] + center = text_node('n0', 'Project map\nMain canonical notes', 40, 220) + nodes.append(center) + + candidate_notes = [ + '00-Hub.md', + '01-Plan.md', + 'Knowledge/Project-Overview.md', + 'Knowledge/Research-Questions.md', + 'Knowledge/Literature-Overview.md', + 'Writing/literature-review.md', + ] + x = 420 + y = 40 + idx = 1 + for rel in candidate_notes: + if existing_rel(project_root, rel): + node_id = f'n{idx}' + nodes.append(file_node(node_id, rel, x, y)) + edges.append(edge(f'e{idx}', 'n0', node_id)) + y += 260 + idx += 1 + + content = json.dumps({'nodes': nodes, 'edges': edges}, ensure_ascii=False, indent=2) + '\n' + path = maps_root / 'project.canvas' + write_if_changed(path, content) + return str(path) + + +def note_title(path: Path) -> str: + return path.stem + + +def result_links_experiment(result_path: Path, experiment_path: Path) -> bool: + text = result_path.read_text(encoding='utf-8', errors='ignore') + stem = experiment_path.stem + return stem in text or f'[[Experiments/{stem}' in text + + +def generate_experiments_canvas(project_root: Path) -> str: + maps_root = project_root / 'Maps' + exp_paths = sorted((project_root / 'Experiments').glob('*.md')) if (project_root / 'Experiments').exists() else [] + res_paths = sorted((project_root / 'Results').glob('*.md')) if (project_root / 'Results').exists() else [] + + nodes = [text_node('n0', 'Experiments → Results', 40, 220)] + edges = [] + node_counter = 1 + exp_ids: dict[Path, str] = {} + res_ids: dict[Path, str] = {} + + for i, exp_path in enumerate(exp_paths): + node_id = f'n{node_counter}' + node_counter += 1 + exp_ids[exp_path] = node_id + rel = str(exp_path.relative_to(project_root)).replace('\\', '/') + nodes.append(file_node(node_id, rel, 420, 40 + i * 260)) + edges.append(edge(f'e{len(edges)+1}', 'n0', node_id, 'tests')) + + for i, res_path in enumerate(res_paths): + node_id = f'n{node_counter}' + node_counter += 1 + res_ids[res_path] = node_id + rel = str(res_path.relative_to(project_root)).replace('\\', '/') + nodes.append(file_node(node_id, rel, 860, 40 + i * 260)) + + for exp_path, exp_id in exp_ids.items(): + linked = False + for res_path, res_id in res_ids.items(): + if result_links_experiment(res_path, exp_path): + edges.append(edge(f'e{len(edges)+1}', exp_id, res_id, 'evidence')) + linked = True + if not linked: + # keep minimal visibility even if explicit links are absent + pass + + content = json.dumps({'nodes': nodes, 'edges': edges}, ensure_ascii=False, indent=2) + '\n' + path = maps_root / 'experiments.canvas' + write_if_changed(path, content) + return str(path) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description='Generate optional Obsidian Bases and canvas helpers for a bound project.') + parser.add_argument('--cwd', default='.') + parser.add_argument('--include-canvases', action='store_true', help='Also generate Maps/project.canvas and Maps/experiments.canvas') + parser.add_argument('--project-id', default='') + return parser.parse_args() + + +def main() -> None: + args = parse_args() + repo_root = project_kb.find_repo_root(Path(args.cwd).resolve()) + binding = project_kb.resolve_binding(repo_root, args.project_id or None) + outputs = {'views': generate_bases(binding.project_root), 'canvases': []} + if args.include_canvases: + outputs['canvases'] = [ + generate_project_canvas(binding.project_root), + generate_experiments_canvas(binding.project_root), + ] + print(json.dumps(outputs, ensure_ascii=False, indent=2)) + + +if __name__ == '__main__': + main() |
