diff --git a/assets/guster/icon.png b/assets/guster/icon.png new file mode 100644 index 00000000..c9db9a66 Binary files /dev/null and b/assets/guster/icon.png differ diff --git a/external_plugins/guster/.codex-plugin/plugin.json b/external_plugins/guster/.codex-plugin/plugin.json new file mode 100644 index 00000000..a014c462 --- /dev/null +++ b/external_plugins/guster/.codex-plugin/plugin.json @@ -0,0 +1,34 @@ +{ + "name": "guster", + "version": "0.7.3", + "description": "Discover, audit, plan and evolve agent context across projects, skills, tools, knowledge and credentials; optionally scaffold a lightweight Guster Hub for durable knowledge and project state.", + "author": { + "name": "Lumos-789", + "url": "https://github.com/Lumos-789" + }, + "homepage": "https://github.com/Lumos-789/guster", + "repository": "https://github.com/Lumos-789/guster", + "license": "MIT", + "keywords": [ + "agent", + "context", + "governance", + "skills", + "mcp", + "codex", + "zcode", + "dsh" + ], + "skills": "./skills/", + "interface": { + "displayName": "Guster", + "shortDescription": "Audit and evolve agent context", + "longDescription": "Guster discovers agent projects, instruction files, skills, tools, knowledge, state, evidence and credential references. It diagnoses drift, computes a context health score, produces reviewable migration plans, and can scaffold a lightweight hub for durable knowledge and project state.", + "developerName": "Lumos-789", + "category": "Developer Tools", + "capabilities": [ + "Read", + "Write with confirmation" + ] + } +} diff --git a/external_plugins/guster/.zcode-plugin/plugin.json b/external_plugins/guster/.zcode-plugin/plugin.json new file mode 100644 index 00000000..c8b5c40a --- /dev/null +++ b/external_plugins/guster/.zcode-plugin/plugin.json @@ -0,0 +1,27 @@ +{ + "name": "guster", + "version": "0.7.3", + "description": "Discover, audit, plan and evolve agent context across projects, skills, tools, knowledge and credentials; optionally scaffold a lightweight Guster Hub for durable knowledge and project state.", + "description_i18n": { + "en": "Discover, audit, plan and evolve agent context across projects, skills, tools, knowledge and credentials; optionally scaffold a lightweight Guster Hub for durable knowledge and project state.", + "zh-CN": "发现、诊断、规划和演进全机 agent 上下文:项目、指令、Skill、工具、知识、状态与凭据引用;可初始化一个轻量 Guster Hub,承载长期知识与项目状态。" + }, + "author": { + "name": "Lumos-789", + "url": "https://github.com/Lumos-789" + }, + "homepage": "https://github.com/Lumos-789/guster", + "repository": "https://github.com/Lumos-789/guster", + "license": "MIT", + "keywords": [ + "agent", + "context", + "governance", + "skills", + "mcp", + "codex", + "zcode", + "dsh" + ], + "skills": "skills" +} diff --git a/external_plugins/guster/CHANGELOG.md b/external_plugins/guster/CHANGELOG.md new file mode 100644 index 00000000..460b95a0 --- /dev/null +++ b/external_plugins/guster/CHANGELOG.md @@ -0,0 +1,203 @@ +# Changelog + +Notable changes. Earlier history is in `git log`. + +## 0.7.3 — 2026-11-11 + +- **Legacy context reformat playbook** (`skills/guster/reference/reformat.md`): + step-by-step guidance for bringing a scattered, pre-spec context site under + the format contract — the piece the 0.7.0 spec lacked. Four passes: + inventory and classify (doctor findings as the worklist, one destination + class per artifact, duplicates pick a canonical copy), placement first + (`plan init` for the slots, `plan move` or one hand-written batch plan — + apply backs up every path and rolls back), content second (the CTX symptom + → transform table: index lines, naked paths to links, date lines, duplicate + to pointer card), then verify (`doctor` zero new findings, `score --trend` + before/after). Principles are explicit: meaning-preserving (no invented + facts), classify-before-touch (garbage is proposed for deletion, not + formatted), placement before content (paths final before pointers are + written). Deletions and distillations ride the classification table for + user approval; company-tracked files are never migration targets. + Field-tested on two real projects (a migrated repo with a stale cross-repo + link and a stray root state doc): the lessons — search referencers by stem + before moving, override shape-based slot suggestions by classification, + delete `.moved.md` transition pointers after referencers update — are + folded back into the playbook. +- SKILL.md wiring: trigger phrases for scattered/legacy context, a + "Reformatting legacy context" section, and the reference entry. + +## 0.7.2 — 2026-11-09 + +- **CTX-09 structured note headers** (opt-in): knowledge notes may carry YAML + front-matter following the Obsidian properties convention — the interchange + format Foam/Quartz/Logseq also accept, so guster defines no schema of its + own. A header that declares `type:` is machine-checked (`note-header-malformed`): + it must parse and carry a machine-readable date (`updated`/`date`/`lastmod`/ + `created`, YYYY-MM-DD), and the note's body then joins the CTX-03 pointer + scan so a pointer card's truth links are verified. Truth sources live in the + body as ordinary links, never as invented header keys. Legacy prose notes + without `type:` are unaffected. + +## 0.7.1 — 2026-11-09 + +- Fixed the placement tree in `reference/framework.md`: the three arrows for + status/proof/durable-understanding questions pointed at the wrong plane + numbers (4/5/6 instead of 5/6/4), contradicting the plane definitions in the + same file. +- Documentation now teaches bare commands instead of the no-op flags + `score --write` and `init --home`: the flags are still accepted for existing + scripts, but writing metrics and initializing the state home are the defaults + with or without them (README, README_CN, AGENTS.md, SKILL.md, doctor's + next-action hint. + +## 0.7.0 — 2026-11-09 + +- **Context format spec v1** (`skills/guster/reference/spec.md`): the format + contract behind the metrics, written down and versioned — eight rules + (`CTX-01..08`, markdownlint-style stable IDs) covering instruction files, + indexes, pointers, dates, slots, single-source, skill metadata and + credentials, each naming the finding that enforces it. Follows open + conventions: `AGENTS.md` (agents.md) for instruction files, llms.txt-shaped + link indexes, stable rule IDs. The skill cites it when writing or moving + context assets; project and hub scaffolds now state the pointer rule. +- **New finding `naked-pointer` (medium)**: file paths left outside a markdown + link or code span in an instruction or index document are reported — until + now such paths were invisible to the pointer check, so a broken link written + in prose never fired. Directory references in prose are deliberately not + flagged. Advisory only: no CHI dimension changes, scores stay comparable. +- `metrics.raw.naked_pointers` counts the new finding; the guster skill + workflow adds a post-apply `doctor` pass and triggers on 上下文格式/格式规范. + +## 0.6.1 — 2026-11-05 + +- **Workspace and group roots are no longer scored as projects.** A directory + that holds other scanned projects (a per-user workspace root, a per-group + checkout root) carries group-level `AGENTS.md` files but is not a project: counting it + inflated `Projects:` and turned one container's partial `.agents/` into a + permanent placement failure. `registry.json` marks `container`, PCR/ICR skip + them, and the report reads `Projects: 62 (workspace containers: 2)`. +- **PCR explains itself.** The raw column breaks a shortfall into extra + top-level entries / missing slots / missing index. "9/41 · 25 extra entries" + is actionable; "21.4%" alone reads like a crisis. +- Two boundaries are now written down instead of being implicit, so they stop + coming back as "bugs": PCR's denominator is adopters only (a repo whose sole + context asset is one well-placed `AGENTS.md` has nothing to place — demanding + six empty slot directories there would be structure theater, and + `/skills/` is product source in a skill repository, not unplaced + context), and SIR does not scan arbitrary file content for token shapes (the + release gate owns that; SIR only claims host configuration it actually read). + +## 0.6.0 — 2026-11-05 + +Measurement honesty pass. Every change below removes a case where the score +claimed knowledge it did not have. + +- **An empty denominator is now `n/a`, not 1.0.** A dimension with no evidence + is dropped and the remaining weights are renormalized; a scope with no + evidence reports `CHI: n/a`. This reverses the 0.5.5 behavior ("empty + denominators still print `0/0` and score 1.0"), which let a scope with fewer + artifacts outrank a measured one. `project`/`group` CHI therefore drops when + their denominators are empty, and scores are no longer comparable across + scopes with different coverage. RBR follows the same rule per component: a + missing entry file is not a perfect budget. +- **Git probe failures are loud instead of clean.** `git ls-files` and + `git log` returning an error used to look like "no tracked files" and "no + commit dates": credentials read as isolated and state documents read as + undated. Both now return `None`, surface as a new `git-unavailable` finding, + and SIR counts the affected credentials as unverified. `registry.json` + records `git_state` per project. +- **SIR stops punishing registered credentials.** The numerator is now + unregistered-outside + tracked + unverified + inline, so a file declared in + `~/.agents/credentials/registry.md` no longer costs points; previously 30 of + 31 in-place company `.env` files were counted as failures while the matching + finding was suppressed. The `secret-outside-root` finding and SIR finally + share one definition. +- **Dates are read where a freshness claim actually lives.** A filename date + must open the name (`2026-11-05-x`, `20261105-x`); `ticket-20261105-notes.md` + no longer counts as today's work. `最后更新` is matched in the first 40 lines + only, so prose mentioning another file cannot date this one. +- **PHR sees directory pointers** (`knowledge/general/`) and resolves paths + against the enclosing repository root, so `.claude/CLAUDE.md` and + `.agents/README.md` stop reporting root-relative links as broken. The report + states when the 200-file pointer cap narrowed the scan. +- **RBR probes the host that is actually installed.** It used to read + `~/.zcode/AGENTS.md` only; a DSH-only machine measured 0 bytes and scored + full marks. It now sums the entry files that exist, deduplicated by real path. +- **Wider credential detection, same read-only boundary.** `.env` discovery + goes two levels deep instead of one, and inline detection covers `api_key`, + `secret_key`, `auth_token` and `sk-…` shapes besides `Bearer`. Values stay + redacted and are never printed. +- **ICR requires a non-empty index**: an empty `README.md` no longer counts as + indexing `.agents/`. +- **`status` reports a snapshot instead of mixing two scans.** It used to run + fresh policy against a cached registry; it now prints the cached registry plus + the last `metrics.jsonl` entry, and says so. +- **`score --fail-under N`** turns CHI into an opt-in gate (exit 2). `score` + still exits 0 by default. +- **`metrics.jsonl` records the scope and both value sets** (raw `metrics` and + capped `scores`), so a trend line no longer mixes a budget ratio with a score. +- Knowledge files up to 8 MB are hashed (was 2 MB); oversized ones are counted + in `knowledge_unhashed` instead of silently skipping single-source checks. +- The report now prints how many projects have no `.agents/` directory, which + dimension had no evidence, and when the pointer scan was truncated. + +## 0.5.5 — 2026-11-05 + +- FRS now counts state documents under the hub project root (`projects/`), + `handoff/`, `provision/` and `.agents/plans/`, not only `kind: state`. + Project `.agents/plans/*` therefore enters the FRS denominator, so a project + or group with a stale plan loses freshness points instead of reporting + `100.0 (0/0)`. Plans use the existing 90-day rule: a declared + `verified_at`/`最后更新` or filename date, otherwise the last git commit of the + repository that owns them. Stale-state findings now point at the offending + in-scope document, so a project/group report explains the lost points instead + of filtering the finding out as hub-level. Empty denominators still print + `0/0` and score 1.0. +- `doctor --scope hub` reports the project cards known to the hub + (`Projects: 3 (scanned roots: 0)`) instead of the number of scanned roots, and + omits the empty `Workspaces:` header line when the scope has no workspaces. + +## 0.5.4 — 2026-11-04 + +- Removed three skill sub-pages that nothing pointed at: `init/SETUP.md`, + `use/OPERATE.md`, `test/SELFTEST.md`. `SKILL.md` carries the workflow itself; + the sub-pages had drifted and were never loaded. + +## 0.5.3 — 2026-11-04 + +- `doctor` reports exactly the dimensions that enter CHI for the run's scope. + `--scope hub` no longer prints RBR/PCR/SIR rows it never scores, and the + always-informational GCR value is labeled `Not counted in CHI`. +- `project` and `group` runs now apply one boundary to artifacts and findings: + hub knowledge, hub state documents and the machine-level local wiki no longer + sit in ICR/FRS/PHR denominators while their findings are filtered out. A + denominator now only contains documents that can raise an in-scope finding, + so those scopes no longer inherit a score from out-of-scope health. +- Removed the unused `templates/` file skeletons and `schema/*.json`: nothing + read them, and the project `AGENTS.md` / `.agents/README.md` text already + lives in `planner.py`. `templates/hub/` is the only shipped template set and + `package.json` no longer ships `schema/`. + +## 0.5.2 — 2026-11-04 + +- Bad plan files no longer crash: a non-object plan, a malformed operation, or an + unreadable plan path fails through the `[guster] ...` / plan-error output and a + non-zero exit. +- `guster apply --dry-run` is accepted as the documented explicit dry run and is + rejected when combined with `--yes`. +- A missing `--root` workspace and an unreadable or non-object Guster config now + fail loud instead of silently scanning defaults. +- Pointer health counts each target once per file, so a link whose text is also + inline code is no longer double-counted. +- Project `.agents/README.md` and hub knowledge indexes are now actually + pointer-checked; the filter listed them but they were never scanned. +- A refused `apply`, `init` or `hub init` prints `FAILED` instead of `APPLIED`. +- The home directory is no longer classified as a project just because it holds + the user-level `~/.agents/`; a fresh `init --home` setup scores clean. +- The setup skill points at the shipped `skills/` directory. + +## 0.5.1 — 2026-11-03 + +- Added reasoned `doctor.waivers` and precise pointer checks. +- Fixed double-counting of linked wiki files and OpenSpec state. +- Added `GUSTER_EXTRA_SKILL_ROOTS` for additional skill hosts. diff --git a/external_plugins/guster/LICENSE b/external_plugins/guster/LICENSE new file mode 100644 index 00000000..b6c14d9c --- /dev/null +++ b/external_plugins/guster/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lumos-789 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/external_plugins/guster/README.md b/external_plugins/guster/README.md new file mode 100644 index 00000000..7e450011 --- /dev/null +++ b/external_plugins/guster/README.md @@ -0,0 +1,246 @@ +# Guster + +Guster is a distributable agent plugin that audits and evolves the context of a whole machine: projects, instruction files, skills, tools, knowledge, project state, evidence and credential references. It can also scaffold a lightweight Hub for durable knowledge and project state. + +It is the **control plane** for agent context. A Guster Hub is the optional **data plane**: the local repository that holds project cards, knowledge, tools and reports. The plugin does not replace the hub; it discovers it, validates it, and helps keep it coherent. + +## What it does + +- **Discovers** Git roots, `AGENTS.md` / `CLAUDE.md`, `.agents/` directories, skills, tools, knowledge, state, evidence and credential locations. +- **Diagnoses** resident budgets, skill metadata budgets, copy drift, duplicate canonical sources, orphan knowledge, broken pointers, misplaced task folders, stale state, inline tokens and credentials outside the canonical root. +- **Scores** context health with seven dimensions and a CHI dashboard. +- **Plans** structural changes as reviewable JSON plans. +- **Applies** only with `--yes`, with a backup and rollback path. +- **Initializes** a standard `.agents/` layout for a new project or a new machine. +- **Scaffolds** a lite Guster Hub when the user does not have a data plane yet. + +## Quick start + +```bash +cd +./bin/guster init --yes +./bin/guster hub init --path --yes +./bin/guster scan +./bin/guster doctor +./bin/guster score +``` + +The CLI is Python 3.9+ and has no third-party Python dependencies. + +Install a stable command on your `PATH`: + +```bash +ln -s "$(pwd)/bin/guster" ~/.local/bin/guster +``` + +## Commands + +| Command | Purpose | +|---|---| +| `guster status` | Show plugin version, registry and health summary | +| `guster scan` | Discover context assets and write `registry.json` | +| `guster doctor` | Run policy checks and write `findings.json` | +| `guster score` | Compute metrics and CHI; append `metrics.jsonl`. `--fail-under N` turns it into a gate (exit 2) | +| `guster plan findings` | Turn findings into a review checklist | +| `guster plan move` | Plan a move with backup and pointer | +| `guster plan init` | Plan a standard `.agents/` layout | +| `guster apply ` | Dry-run by default; `--yes` writes with backup | +| `guster init` | Initialize the state home or a project | +| `guster hub init` | Create a lite Hub and attach it to the local config | +| `guster hub attach` | Attach an existing Hub without copying its content | +| `guster hub status` | Show the Hub manifest and contract checks | +| `guster hub validate` | Validate the lite Hub contract | +| `guster link` | Check context pointers | +| `guster export` | Export a redacted Markdown/JSON report | +| `guster credentials` | List registered credentials by scope (company / personal) | +| `guster upgrade` | Check Codex/ZCode/package manifest versions | + +## Lite Hub + +The plugin can create a deliberately small data plane: + +```text +Guster/ +├── AGENTS.md +├── README.md +├── hub.yaml +├── knowledge/ +│ ├── README.md +│ └── general/start-here.md +├── projects/ +│ └── _registry.md +└── tools/ + └── README.md +``` + +`tools/` is present as an index and contract, with no tool preinstalled. `handoff/` and `reports/` are optional and are not created by the lite profile. The scanner skips missing optional directories. + +`guster hub init` refuses a non-empty target, writes a reviewable plan, and attaches the new Hub to `~/.agents/guster/config.json`. Use `guster hub attach --path ` for an existing Hub. + +The three core roots can be chosen at creation time: + +```bash +guster hub init --path --knowledge notes --projects work --tools scripts --yes +``` + +They are recorded in `hub.yaml` under `paths` and can be changed later. Paths must be relative to the Hub and must stay inside it. + +Scanning follows the directories that exist, not a mode: `profile` in `hub.yaml` is only a label. A hub is scanned across its knowledge root (`general/`, `projects/` and `workgroups/` under it), the configured project state root, `handoff/`, `provision/`, `reports/`, `Human Output/`, `manuals/`, `cron-tasks/`, `openspec/`, `archive/`, `.agents/plans/` and `strategy.md` whenever those exist. Generated or runtime areas such as `out/`, `.manual-site/`, `.playwright-mcp/` and `.codegraph/` are not treated as knowledge sources. `--scope hub` is Hub-only; local wiki and machine-level secrets belong to the machine scope. + +## Health model + +```text +CHI = 0.20 SSR + 0.15 RBR + 0.15 PCR + 0.15 ICR + + 0.15 FRS + 0.10 PHR + 0.10 SIR +``` + +- **SSR** single-source rate +- **RBR** resident budget ratio +- **PCR** placement compliance rate: share of `.agents/` dirs with all six + standard slots, a non-empty index and no unknown top-level entry. The + denominator is `.agents/` dirs only — a repository that never adopted the + layout is not scored, and a directory that holds other scanned projects (a + workspace or group root) is not a project. The report prints the failure + breakdown, so a low number says why instead of just being low. +- **ICR** index coverage rate +- **FRS** state freshness rate: share of state documents under the hub's + project root (`projects/`), `handoff/`, `provision/` and `.agents/plans/` + that were verified within the 90-day SLA. The denominator is those four + prefixes only — a hub `reports/` file or a machine-level note is not state. + The date comes from `verified_at`, a `最后更新` line in the first 40 lines, a + date that opens the filename (`2026-11-05-…`, `20261105-…`), or the last git + commit of the repository that owns the document. A date buried in prose or a + ticket number inside the name is not a freshness claim. +- **PHR** pointer health rate +- **SIR** secret isolation rate: credential files plus literal tokens in host + configuration. Guster does not scan arbitrary file content for token shapes — + that is the release gate's job; SIR only claims what it actually looked at. + +Critical findings do not rescale CHI: they make `doctor` exit 3 (any `high` +finding exits 2), and the score stays the weighted sum above. Treat the exit code +as the gate and CHI as the trend. + +A dimension with no evidence is reported as `n/a` and dropped from the weighted +sum instead of scoring 1.0: an empty denominator means "not measured", not +"perfect". When every dimension of a scope is empty, CHI itself is `n/a`. + +## Context format spec + +The metrics are static — filename stems, content hashes, path resolution, git +dates — so they only see documents that keep the format contract. The contract +is written down and versioned: [`skills/guster/reference/spec.md`](skills/guster/reference/spec.md) +(spec_version 1, rules `CTX-01..08` plus `CTX-09` structured note header in 0.7.2, markdownlint-style stable IDs). It follows +open conventions instead of inventing new ones: `AGENTS.md` for instruction +files ([agents.md](https://agents.md)), llms.txt-shaped link indexes +([llmstxt.org](https://llmstxt.org)), stable rule IDs like markdownlint. + +A document that ignores the contract is not scored lower — it is invisible: a +naked path in prose never reaches the pointer check, a date written as "last +week" falls back to git, an index without filenames counts as missing. Since +0.7.0 the `naked-pointer` finding closes that gap from the other side: file +paths left outside a link or code span in an instruction or index document are +reported, so the unwritable form is also the flagged form. + +## Scopes + +| Level | Command | Scored dimensions | +|---|---|---| +| machine | `guster doctor` | SSR RBR PCR ICR FRS PHR SIR | +| hub | `guster doctor --scope hub` | SSR ICR FRS PHR | +| group | `guster doctor --root A --root B` | SSR PCR ICR FRS PHR | +| project | `guster doctor --root ` | SSR PCR ICR FRS PHR | + +Scoped runs write to `~/.agents/guster/scoped//`. + +Every scoped run applies one boundary to both sides of the report: an artifact +counts toward a denominator only when a finding about it could also appear in +that scope. `project` and `group` runs therefore drop hub knowledge, hub state +documents and the machine-level local wiki from ICR/FRS/PHR unless they live +under one of the given roots; the report table shows exactly the dimensions that +enter CHI, and any extra value is labeled `Not counted in CHI`. A dimension with +no in-scope artifacts stays vacuous (`0/0` in the raw column, `n/a` score) instead +of borrowing health from out-of-scope files. This changes CHI for `project` and +`group` scopes; hub health keeps being reported by `--scope hub` and the +machine-wide run. Because empty dimensions are dropped and the weights are +renormalized over what remains, CHI is not comparable across scopes with +different coverage. + +## Credential registry + +Guster reads `~/.agents/credentials/registry.md` and checks it against every credential file it finds: + +- `secret-undeclared` - a credential file is not registered. +- `secret-missing` - a registered location no longer exists. +- `git-unavailable` - a repository could not be queried, so tracked state and + commit dates are unknown. SIR counts those credentials as unverified rather + than clean. + +The registry holds scope (`company` / `personal`), site, account, kind, location and variable names. +It never holds values. `guster credentials` prints the split. + +## The eight planes + +1. **Truth** — code, tests, build and runtime config. +2. **Instruction** — `AGENTS.md` and policies. +3. **Capability** — skills, tools, MCP, plugins, scripts. +4. **Knowledge** — durable principles, methods, business semantics, pitfalls. +5. **State** — project cards, handoffs, environment and port registries. +6. **Evidence** — reports, screenshots, videos, test output. +7. **Runtime** — sessions, caches, logs, temp files, browser profiles. +8. **Secret** — passwords, tokens, keys, cookies; references only. + +## Safety + +- Read-only on your workspace by default. `apply --yes`, `init --yes`, `hub init --yes` and `hub attach` are the only commands that change workspace files or Guster config on their own; `export --out ` writes the report path you name. +- Guster keeps three state files under its state directory (`~/.agents/guster/`, or `scoped//`): `registry.json`, which every scanning command rewrites (`status` included, when no registry exists yet), plus `findings.json` from `doctor` and `metrics.jsonl` from `score`. `--no-write` suppresses the state file of `scan`, `doctor` and `score` only. +- Credential values are never persisted or printed; scans keep only paths and redacted shapes. +- No automatic `git add`, `commit`, `push` or deploy. +- Company/public repository tracked files are not ordinary migration targets. +- Plans are written to `~/.agents/guster/plans/`; backups to `~/.agents/guster/backups/`. + +## Plugin hosts + +| Host | Manifest | +|---|---| +| Codex | `.codex-plugin/plugin.json` | +| ZCode | `.zcode-plugin/plugin.json` | +| DSH | `package.json` + `cordis.patch.yml` | + +All three point at `skills/` (the `guster` and `guster-hub` skills). Keep their versions synchronized. + +```bash +# DSH bundle +dsh plugin add /path/to/guster +``` + +### Extra skill roots + +By default guster scans `~/.agents/skills`, `~/.codex/skills`, `~/.dsh/skills` and +`~/.claude/skills`. Additional hosts stay local and are injected without changing +the published source: + +```bash +export GUSTER_EXTRA_SKILL_ROOTS="name=/path/to/skills:other=/another/path" +``` + +## Project layout + +```text +skills/guster/ agent-facing skill +skills/guster-hub/ hub bootstrap and validation skill +src/guster/ Python CLI and policy engine +bin/guster executable wrapper +templates/hub/ lite Hub scaffold (the only shipped template set) +tests/ fixture and plugin tests +``` + +## Test + +```bash +python3 -m unittest discover -s tests -v +node --test tests/test_plugin.mjs +``` + +## License + +MIT © Lumos-789 diff --git a/external_plugins/guster/README_CN.md b/external_plugins/guster/README_CN.md new file mode 100644 index 00000000..55c7a4e4 --- /dev/null +++ b/external_plugins/guster/README_CN.md @@ -0,0 +1,209 @@ +# Guster + +Guster 是一个可分发的 Agent 插件,用来审计和演进整台机器的上下文:项目、指令文件、Skill、工具、知识、项目状态、证据和凭据引用;也可以初始化一个轻量 Hub,承载长期知识与项目状态。 + +它是上下文的**控制面**。Guster Hub 是可选的**数据面**:承载项目卡、知识、工具和报告的本地仓库。插件不替代 Hub,而是发现它、校验它,并帮助它保持自洽。 + +## 它做什么 + +- **发现** Git 根、`AGENTS.md` / `CLAUDE.md`、`.agents/`、Skill、工具、知识、状态、证据和凭据位置。 +- **诊断** 常驻预算、Skill 元数据预算、副本漂移、多规范源、孤儿知识、断链、错位任务目录、陈旧状态、内联 token 和凭据越界。 +- **评分** 用七个维度和一个 CHI 仪表盘衡量上下文健康。 +- **计划** 把结构变更写成可审阅的 JSON plan。 +- **执行** 只有带 `--yes` 才写入,并且先备份、可回滚。 +- **初始化** 新项目或新机器的标准 `.agents/` 布局。 +- **脚手架** 用户还没有数据面时,生成一个 Lite Guster Hub。 + +## 快速开始 + +```bash +cd +./bin/guster init --yes +./bin/guster hub init --path --yes +./bin/guster scan +./bin/guster doctor +./bin/guster score +``` + +CLI 依赖 Python 3.9+,无第三方 Python 依赖。 + +安装一个稳定的 `guster` 命令: + +```bash +ln -s "$(pwd)/bin/guster" ~/.local/bin/guster +``` + +## 命令 + +| 命令 | 用途 | +|---|---| +| `guster status` | 查看插件版本、registry 和健康摘要 | +| `guster scan` | 发现上下文资产并写 `registry.json` | +| `guster doctor` | 跑规则并写 `findings.json` | +| `guster score` | 计算指标和 CHI,追加 `metrics.jsonl`;`--fail-under N` 变成门禁(低于则退出码 2) | +| `guster plan findings` | 把发现转成审阅清单 | +| `guster plan move` | 计划一次带备份和指针的移动 | +| `guster plan init` | 计划标准 `.agents/` 布局 | +| `guster apply ` | 默认 dry-run;`--yes` 才写入 | +| `guster init` | 初始化状态目录或项目布局 | +| `guster hub init` | 创建 Lite Hub 并接入本地配置 | +| `guster hub attach` | 接入已有 Hub,不复制其内容 | +| `guster hub status` | 查看 Hub manifest 和契约检查 | +| `guster hub validate` | 校验 Lite Hub 契约 | +| `guster link` | 检查上下文指针 | +| `guster export` | 导出脱敏 Markdown/JSON 报告 | +| `guster credentials` | 按范围(公司 / 个人)列出已登记凭据 | +| `guster upgrade` | 检查 Codex/ZCode/package 版本一致性 | + +## Lite Hub + +插件可以生成一个刻意保持很小的数据面: + +```text +Guster/ +├── AGENTS.md +├── README.md +├── hub.yaml +├── knowledge/ +│ ├── README.md +│ └── general/start-here.md +├── projects/ +│ └── _registry.md +└── tools/ + └── README.md +``` + +`tools/` 只提供索引和收录契约,不预装任何工具;`handoff/`、`reports/` 仍是可选模块,Lite 档不创建,扫描器会跳过缺失的可选目录。 + +`guster hub init` 会拒绝非空目录,先写可审阅的 plan,再把新 Hub 接入 `~/.agents/guster/config.json`。已有 Hub 用 `guster hub attach --path `。 + +三个核心目录可以在创建时自定义: + +```bash +guster hub init --path --knowledge notes --projects work --tools scripts --yes +``` + +它们记录在 `hub.yaml` 的 `paths` 下,之后也可以修改。路径必须是 Hub 内的相对路径。 + +扫描按目录是否存在走,不是开关:`hub.yaml` 里的 `profile` 只是标签。Hub 只要有 knowledge 根(其下的 `general/`、`projects/`、`workgroups/`)、配置的项目状态根、`handoff/`、`provision/`、`reports/`、`Human Output/`、`manuals/`、`cron-tasks/`、`openspec/`、`archive/`、`.agents/plans/` 和 `strategy.md`,就会被扫到。`out/`、`.manual-site/`、`.playwright-mcp/`、`.codegraph/` 等生成物或运行态不作为知识源。`--scope hub` 只算 Hub;本地 wiki 和机器级凭据归机器范围。 + +## 健康模型 + +```text +CHI = 0.20 SSR + 0.15 RBR + 0.15 PCR + 0.15 ICR + + 0.15 FRS + 0.10 PHR + 0.10 SIR +``` + +- **SSR** 单源率 +- **RBR** 常驻预算比 +- **PCR** 落位合规率:`.agents/` 中同时具备 6 个标准槽、非空索引且无未知顶层项的比例。 + 分母只算 `.agents/` 目录本身 —— 从未采用该布局的仓库不计分,而「包含其他已扫描项目」 + 的目录(工作区或组根)不算项目。报告会打出不合格原因分布,低分能自己解释为什么低。 +- **ICR** 索引覆盖率 +- **FRS** 状态新鲜度:Hub 项目卡目录(`projects/`)、`handoff/`、`provision/` + 与 `.agents/plans/` 四类状态文档中在 90 天 SLA 内核实过的比例;分母只认这四个 + 前缀,Hub `reports/` 或机器级笔记不算状态文档。日期依次取自 `verified_at`、 + 前 40 行内的 `最后更新`、文件名开头处的日期(`2026-11-05-…`、`20261105-…`), + 或该文档所属仓库的最后一次 git 提交。正文深处的日期和文件名里的工单号都不算 + 新鲜度声明。 +- **PHR** 指针健康率 +- **SIR** 敏感隔离率:凭据文件 + host 配置里的字面令牌。Guster 不扫描任意文件内容找 + token 形状 —— 那是发布闸的职责;SIR 只声称它确实看过的东西。 + +严重 finding 不改写 CHI:它让 `doctor` 以 3 退出(任意 `high` 退出 2),分数仍是上面的加权和。门禁看退出码,CHI 看趋势。 + +某个维度没有证据时记为 `n/a` 并从加权和中剔除,而不是记 1.0:空分母表示「没测」,不是「满分」。某 scope 的所有维度都为空时,CHI 本身为 `n/a`。 + +## 上下文格式规范 + +指标全是静态的——文件名主干、内容哈希、路径解析、git 日期——所以只有守住格式契约的文档才被测得到。契约本身已写成文档并版本化:[`skills/guster/reference/spec.md`](skills/guster/reference/spec.md)(spec_version 1,规则 `CTX-01..08`(0.7.2 增 `CTX-09` 结构化条目头),markdownlint 式稳定编号)。它遵循开源惯例而不是另造一套:指令文件用 `AGENTS.md`([agents.md](https://agents.md) 标准)、索引文件用 llms.txt 形态的链接列表([llmstxt.org](https://llmstxt.org))、规则编号学 markdownlint。 + +不守契约的文档不是被扣分——而是彻底隐身:正文里裸写的路径永远到不了指针检查、「上周更新」落到 git 时间兜底、索引里没有文件名等于没索引。0.7.0 起 `naked-pointer` finding 从另一侧堵上这个缺口:指令文档/索引页里留在链接和代码记号之外的文件路径会被直接报告,写不规范的形态同时也是会被标记的形态。 + +## 层级 + +| 层级 | 命令 | 计分维度 | +|---|---|---| +| 机器 | `guster doctor` | SSR RBR PCR ICR FRS PHR SIR | +| Hub | `guster doctor --scope hub` | SSR ICR FRS PHR | +| 组 | `guster doctor --root A --root B` | SSR PCR ICR FRS PHR | +| 项目 | `guster doctor --root ` | SSR PCR ICR FRS PHR | + +带范围的运行写入 `~/.agents/guster/scoped//`。 + +每个带范围的运行对报告两侧使用同一条边界:一个 artifact 只有在「针对它的 finding 也能出现在该 scope」时,才计入分母。因此 `project` / `group` 运行会把 Hub 知识、Hub 状态文档和机器级本地 wiki 从 ICR/FRS/PHR 分母中剔除,除非它们就在给定 root 之下;报告表格只列真正进入 CHI 的维度,额外展示的数值必须标注 `Not counted in CHI`。某个维度在该 scope 内没有 artifact 时保持空集口径(Raw 列显示 `0/0`,得分 `n/a`),而不是借用 scope 外文件的健康度。这会改变 `project` / `group` 的 CHI;Hub 健康度仍由 `--scope hub` 和全机运行报告。空维度被剔除后权重在剩余维度上归一,因此覆盖度不同的 scope 之间 CHI 不可横向比较。 + +## 凭据登记 + +Guster 读取 `~/.agents/credentials/registry.md`,并对照它检查发现的每个凭据文件: + +- `secret-undeclared` — 凭据文件没有登记。 +- `secret-missing` — 已登记的位置已不存在。 +- `git-unavailable` — 某个仓库查询失败,跟踪状态与提交日期未知;SIR 会把这类凭据记为「未核实」而不是「干净」。 + +登记表保存范围(`company` / `personal`)、站点、账号、类型、位置和变量名,从不保存凭据值。`guster credentials` 按范围输出。 + +## 八个平面 + +1. **Truth** — 代码、测试、构建和运行配置。 +2. **Instruction** — `AGENTS.md` 和策略。 +3. **Capability** — Skill、工具、MCP、插件、脚本。 +4. **Knowledge** — 稳定原则、方法、业务语义、坑。 +5. **State** — 项目卡、交接、环境和端口台账。 +6. **Evidence** — 报告、截图、视频、测试输出。 +7. **Runtime** — 会话、缓存、日志、临时文件、浏览器 profile。 +8. **Secret** — 密码、token、key、cookie;只存引用。 + +## 安全边界 + +- 默认不写工作区:只有 `apply --yes`、`init --yes`、`hub init --yes` 和 `hub attach` 会自行改动工作区文件或 Guster 配置;`export --out ` 只写你指定的报告路径。 +- Guster 会在状态目录(`~/.agents/guster/` 或 `scoped//`)维护三个状态文件:所有扫描类命令都会重写 `registry.json`(`status` 在没有 registry 时也会写),`doctor` 另写 `findings.json`,`score` 追加 `metrics.jsonl`。`--no-write` 只对 `scan`、`doctor`、`score` 关闭本命令的状态文件。 +- 永不持久化或输出凭据值;扫描只保留路径和脱敏形态。 +- 不自动 `git add` / `commit` / `push` / 部署。 +- 公司/公共仓 tracked 文件不是普通迁移目标。 +- plan 写 `~/.agents/guster/plans/`,备份写 `~/.agents/guster/backups/`。 + +## 插件宿主 + +| 宿主 | Manifest | +|---|---| +| Codex | `.codex-plugin/plugin.json` | +| ZCode | `.zcode-plugin/plugin.json` | +| DSH | `package.json` + `cordis.patch.yml` | + +三端都指向 `skills/`(`guster` 与 `guster-hub` 两个 Skill),版本必须同步。 + +```bash +# DSH bundle +dsh plugin add /path/to/guster +``` + +### 额外 Skill 根 + +默认扫描 `~/.agents/skills`、`~/.codex/skills`、`~/.dsh/skills` 和 `~/.claude/skills`。其他宿主保持在本机,通过环境变量注入,不改发布源: + +```bash +export GUSTER_EXTRA_SKILL_ROOTS="name=/path/to/skills:other=/another/path" +``` + +## 项目结构 + +```text +skills/guster/ agent 侧治理 skill +skills/guster-hub/ Hub 初始化与校验 skill +src/guster/ Python CLI 与策略引擎 +bin/guster 可执行入口 +templates/hub/ Lite Hub 脚手架(唯一随包发布的模板集) +tests/ fixture 与插件测试 +``` + +## 测试 + +```bash +python3 -m unittest discover -s tests -v +node --test tests/test_plugin.mjs +``` + +## License + +MIT © Lumos-789 diff --git a/external_plugins/guster/assets/guster/icon.png b/external_plugins/guster/assets/guster/icon.png new file mode 100644 index 00000000..c9db9a66 Binary files /dev/null and b/external_plugins/guster/assets/guster/icon.png differ diff --git a/external_plugins/guster/bin/guster b/external_plugins/guster/bin/guster new file mode 100755 index 00000000..e97baaeb --- /dev/null +++ b/external_plugins/guster/bin/guster @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +SOURCE="${BASH_SOURCE[0]}" +while [ -L "$SOURCE" ]; do + DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" + SOURCE="$(readlink "$SOURCE")" + case "$SOURCE" in + /*) ;; + *) SOURCE="$DIR/$SOURCE" ;; + esac +done +HERE="$(cd -P "$(dirname "$SOURCE")/.." && pwd)" +PYTHONPATH="$HERE/src${PYTHONPATH:+:$PYTHONPATH}" exec python3 -m guster "$@" diff --git a/external_plugins/guster/cordis.patch.yml b/external_plugins/guster/cordis.patch.yml new file mode 100644 index 00000000..e172e1ba --- /dev/null +++ b/external_plugins/guster/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: guster + name: 'guster' diff --git a/external_plugins/guster/index.js b/external_plugins/guster/index.js new file mode 100644 index 00000000..bb454824 --- /dev/null +++ b/external_plugins/guster/index.js @@ -0,0 +1,84 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const skillRoots = { + guster: fileURLToPath(new URL("./skills/guster/", import.meta.url)), + "guster-hub": fileURLToPath(new URL("./skills/guster-hub/", import.meta.url)), +}; + +function parseFrontmatter(source, skillPath) { + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source); + if (!match) throw new Error(`guster: missing YAML frontmatter in ${skillPath}`); + const header = {}; + for (const rawLine of match[1].split(/\r?\n/)) { + const line = rawLine.trimEnd(); + if (!line || line.startsWith("#") || /^\s/.test(line)) continue; + const colon = line.indexOf(":"); + if (colon < 1) continue; + const key = line.slice(0, colon).trim(); + let value = line.slice(colon + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + header[key] = value; + } + return { header, body: source.slice(match[0].length).trimStart() }; +} + +function loadSkill(skillName) { + const skillRoot = skillRoots[skillName]; + const skillPath = join(skillRoot, "SKILL.md"); + const source = readFileSync(skillPath, "utf8"); + const { header, body } = parseFrontmatter(source, skillPath); + if (typeof header.name !== "string" || typeof header.description !== "string") { + throw new Error(`guster: invalid skill metadata in ${skillPath}`); + } + return { + name: header.name, + description: header.description, + content: body, + source: "bundled", + path: skillPath, + resourceBase: { kind: "directory", path: skillRoot }, + invocation: { modelInvocable: true, userInvocable: true }, + }; +} + +const skills = Object.keys(skillRoots).map((skillName) => loadSkill(skillName)); + +export const name = "guster"; +export const inject = ["skills", "agents"]; + +export function apply(ctx) { + for (const skill of skills) ctx.skills.register(skill); + + const agentRegistrations = new Map(); + const registerForAgent = (agent) => { + if (agentRegistrations.has(agent)) return; + const injection = agent.ctx.inject(["skills"], (agentCtx) => { + for (const skill of skills) agentCtx.skills.register(skill); + }); + agentRegistrations.set(agent, () => injection.dispose()); + }; + + for (const agent of ctx.agents.list()) registerForAgent(agent); + + ctx.on("agent/created", ({ agent }) => registerForAgent(agent)); + ctx.on("agent/disposed", ({ agent }) => { + agentRegistrations.get(agent)?.(); + agentRegistrations.delete(agent); + }); + + ctx.effect( + () => () => { + const disposals = [...agentRegistrations.values()].map((dispose) => dispose()); + agentRegistrations.clear(); + return Promise.all(disposals); + }, + "guster agent registrations", + ); +} diff --git a/external_plugins/guster/skills/guster-hub/SKILL.md b/external_plugins/guster/skills/guster-hub/SKILL.md new file mode 100644 index 00000000..740188ee --- /dev/null +++ b/external_plugins/guster/skills/guster-hub/SKILL.md @@ -0,0 +1,83 @@ +--- +name: guster-hub +description: Create, attach or validate a lightweight Guster Hub data plane for the Guster plugin. Use when the user says 建文件阵地/Guster Hub/精简 Hub/初始化知识枢纽/接入现有 Hub. +--- + +# Guster Hub + +The Hub is the data plane: a plain repository for durable knowledge and project state. The Guster plugin is the control plane and reads this repository; it does not replace it. + +## Lite profile + +The lite Hub intentionally ships only: + +- `AGENTS.md`, `README.md` and `hub.yaml` +- `knowledge/` with a single index and one starter note +- `projects/` with a project registry +- `tools/` with an index and inclusion rules, but no tool preinstalled + +It does **not** ship `handoff/` or `reports/`. Add those later only when the workflow needs them. Missing optional directories are ignored by the scanner. + +The three core roots default to `knowledge/`, `projects/` and `tools/`, but they are configurable. They live in `hub.yaml` under `paths`. + +There is no mode switch: the scanner follows the directories that exist, and `profile` in `hub.yaml` is only a label. A fuller hub is also scanned across `handoff/`, `reports/`, `Human Output/`, `provision/`, `manuals/`, `cron-tasks/`, `openspec/`, `archive/`, `.agents/plans/`, `strategy.md` and `knowledge/workgroups/` when present. Generated directories such as `out/` are not knowledge sources. `--scope hub` excludes local wiki and machine-level secrets. + +## Workflow + +1. Check whether a hub is already attached: + +```bash +guster hub status +``` + +2. Create a new lite hub: + +```bash +guster hub init --path --yes +``` + +This refuses a non-empty target and attaches the new hub to `~/.agents/guster/config.json`. + +Choose custom core paths at creation time: + +```bash +guster hub init --path --knowledge notes --projects work --tools scripts --yes +``` + +Paths are relative to the Hub and must not escape it. To change them later, edit the `paths` map in `hub.yaml`, then run `guster hub validate`. + +The same manifest carries `doctor.waivers`: findings the owner knowingly accepts, each with a `reason` and an optional `path`. Keep them in the hub so the decision is versioned next to the corpus it protects. + +3. Validate and score it: + +```bash +guster hub validate +guster scan +guster doctor --scope hub +``` + +4. Add content: + +- Create one topic per file under `knowledge/general/` or `knowledge/projects/`. +- Add the file name to `knowledge/README.md`. +- Register a project in `projects/_registry.md`, then create `projects/{slug}.md`. +- Add a tool only when it is a run-once command: one directory with its own `AGENTS.md`, then a row in `tools/README.md`. +- Put `最后更新: YYYY-MM-DD` in every state document. +- Optional structured header (CTX-09): front-matter that declares `type:` plus a date key (Obsidian properties convention) opts the note into machine checks. Keep truth sources as body markdown links, never as invented header keys. + +## Existing hub + +If the directory already exists, use: + +```bash +guster hub attach --path +``` + +`attach` validates the core layout and backs up the Guster config before changing the hub path. Legacy hubs without `hub.yaml` are accepted with warnings; `guster hub validate` is the strict lite-contract check. + +## Boundaries + +- Never copy private content from another hub into a new one. +- Never add credentials, company secrets, generated caches or scratch files. +- Do not add optional modules just to fill the tree. +- Do not write Hub content from the governance skill; use `knowledge-capture` for durable notes. diff --git a/external_plugins/guster/skills/guster/SKILL.md b/external_plugins/guster/skills/guster/SKILL.md new file mode 100644 index 00000000..24fc91df --- /dev/null +++ b/external_plugins/guster/skills/guster/SKILL.md @@ -0,0 +1,163 @@ +--- +name: guster +description: Audit and evolve agent context across projects, skills, tools, knowledge and credential references. Use when the user says 上下文治理/项目上下文/上下文结构/上下文格式/格式规范/整理 .agents/归位/搬迁/断链/健康评分/凭据登记, or asks where a fact, file, skill or credential belongs, or wants to reformat scattered legacy context into the standard format, or wants a before/after context health score. +--- + +# Guster + +Guster is the control plane for agent context. It discovers projects, instruction files, skills, tools, knowledge, state, evidence and credential references; diagnoses drift; computes a health score; and produces reviewable migration plans. + +Guster Hub is the data plane: the optional local repository that holds knowledge, project cards, tools and reports. Guster the plugin does not replace the hub. + +For a new data plane, use the companion `guster-hub` skill or `guster hub init`. + +## When to use + +Use Guster when the user asks: + +- to inspect or reorganize the machine's agent context structure; +- where a file, skill, tool, project card, knowledge note or credential reference belongs; +- to audit drift, duplicates, broken pointers, budgets or stale state; +- to initialize a project with the standard `.agents/` layout; +- to migrate task folders into `plans/`, `wiki/`, `evidence/`, `tmp/` or `archive/`; +- to make context documents machine-verifiable under the context format spec; +- to reformat scattered, legacy (pre-spec) context into the standard format; +- for a context health score or a before/after comparison; +- to install the same context rules on another machine or workspace. + +Do not use Guster for ordinary code edits, runtime debugging or content writing unless the request is about the context structure itself. + +## Scope + +Four levels. `--scope` is inferred from `--root` when omitted. + +| Level | Command | Covers | Scored dimensions | +|---|---|---|---| +| machine | `guster doctor` | every repo under the workspace roots plus the hub | SSR RBR PCR ICR FRS PHR SIR | +| hub | `guster doctor --scope hub` | knowledge, project cards, tools, reports, handoffs | SSR ICR FRS PHR | +| group | `guster doctor --root A --root B [--root C]` | a requirement family or any set of related repos | SSR PCR ICR FRS PHR | +| project | `guster doctor --root ` | one repository | SSR PCR ICR FRS PHR | + +Rules: + +- Machine level owns resident budgets, skill budgets and credential layout. +- Hub level owns knowledge indexing, project-card freshness and hub pointers. +- Group level exposes cross-repo facts: the same file name with different content, + duplicated copies, and per-repo placement gaps. +- Project level shows only that repo's findings and states how many machine-level + findings were hidden. +- Every scoped run writes to `~/.agents/guster/scoped//`, so it never + overwrites the machine-wide registry or trend. +- Pointer checks skip documentation templates (`draft-NNN.md`, + `daily-YYYY-MM-DD.md`, `0X-xxx.md`) and `~/` paths that sit on an + `ssh`/`scp`/`rsync` command line, because those name a remote home. + +## Workflow + +1. `guster status` +2. `guster scan --json` (add `--root ` for a single project) +3. `guster doctor` +4. `guster score` +5. `guster plan findings` or a targeted `guster plan move|init` +6. Read the plan with the user. +7. `guster apply --yes` +8. `guster doctor` again — zero new findings means the edit kept the format contract +9. `guster score --trend` + +Workspace writes are gated behind a plan and `--yes`. Reading is not fully +read-only: every scanning command refreshes `~/.agents/guster/registry.json`, +`doctor` also writes `findings.json`, and `score` appends `metrics.jsonl`. +`--no-write` skips that command's own state file for `scan`, `doctor` and `score`. + +## Commands + +```bash +guster status +guster scan --root ~/Projects --json +guster doctor +guster score --trend +guster score --fail-under 80 # optional gate: exit 2 when CHI is below the target +guster plan findings +guster plan move --from --to +guster plan init --project +guster apply --yes +guster init +guster init --project --yes +guster hub init --path --yes +guster hub attach --path +guster hub status +guster hub validate +guster link +guster export --format md +guster credentials +guster upgrade +``` + +## Gates + +- **Never persist or print credential values.** Paths, variable names and redacted forms only. +- **Never auto-commit, auto-push or auto-deploy.** +- **Never apply a plan without explicit confirmation.** +- **Never treat a company repository's tracked files as ordinary migration targets.** +- **Fail loud** on an unknown workspace, malformed plan or missing backup. +- Prefer no-focus, CLI-only operation. Do not activate apps or switch Spaces. +- If the scan finds a credential in git, stop structural work and report it as critical. + +## Waivers + +A finding you knowingly accept is waived in `/hub.yaml` under `doctor.waivers`, +never by deleting the check: + +```yaml +doctor: + waivers: + - rule: broken-pointer + path: ~/work/company-repo + reason: company-tracked doc, owned upstream +``` + +`path` is optional; without it the whole rule is waived. Every waiver needs a +`reason` — a malformed block fails loud. Waived findings still appear in +`doctor --json` and in the report's `Waived` section, so the decision stays +visible. Use a waiver only when the fix is outside your authority (company +tracked files, host limitations), not to skip ordinary work. + +## Format spec + +Context assets follow the versioned format contract in `reference/spec.md` +(CTX-01..09): `AGENTS.md` instruction files, llms.txt-shaped indexes, +machine-verifiable pointers, machine-readable dates, the six `.agents/` slots, +one home per fact, skill metadata budgets, credentials by reference. When +writing or moving a context asset, follow the spec; when a doctor finding +fires, it names the rule that was broken. + +## Reformatting legacy context + +When the target is not a new asset but an inherited mess — a project or hub +whose context predates the spec — run the playbook in +`reference/reformat.md`: **inventory and classify** every artifact (doctor +findings as the worklist, one destination class each) → **placement** +(`plan init` for the slots, one `plan move` or one batch plan for moves — +paths final before pointers are written) → **content** (index lines, naked +paths to links, date lines, duplicates to pointer cards, per the CTX table) → +**verify** (`doctor` zero new findings, `score --trend` before/after). The +pass is meaning-preserving; deletions and distillations are proposed in the +classification table for the user to approve, never silent. + +## References + +- `reference/spec.md` — context format spec v1 (CTX rules and their findings). +- `reference/reformat.md` — legacy context reformat playbook (four passes, classify-before-touch). +- `reference/framework.md` — 1-3-5-8 framework and placement tree. +- `reference/metrics.md` — metric definitions, targets and CHI. +- `reference/placement.md` — user/hub/project directory trees and the git boundary. + +## Exit codes + +- `0`: success (including `doctor` with no high/critical findings). +- `1`: tool error — unreadable or malformed plan, missing workspace root, unreadable + or non-object config; `hub status` also returns 1 when the hub is invalid. +- `2`: `doctor` found high (but no critical) findings; `hub validate` failed the + lite contract; `link` found broken pointers; `upgrade` found disagreeing versions. +- `3`: `doctor` found critical findings. +- `4`: `apply`, `init` or `hub init` could not apply the plan (preflight errors, or `FAILED`). diff --git a/external_plugins/guster/skills/guster/reference/framework.md b/external_plugins/guster/skills/guster/reference/framework.md new file mode 100644 index 00000000..61667f27 --- /dev/null +++ b/external_plugins/guster/skills/guster/reference/framework.md @@ -0,0 +1,55 @@ +# Guster Framework + +## One principle + +One fact has one home; one home tells one story; show it only when needed. + +## Three laws + +1. **Single home** — one canonical source per topic; other locations are + pointers, generated copies or immutable evidence. +2. **Load on demand** — resident instructions only for rules that apply to + every task in scope, change default behavior, and have costly failure. +3. **Layer isolation** — a container holds content with the same scope, + authority, lifetime, sensitivity and load mode. + +## Five sameness checks + +- scope: global / workgroup / project / subdirectory / task / session +- authority: platform / instruction / code / runtime / knowledge / evidence +- lifetime: invariant / monthly / weekly / daily / hourly / one-shot +- sensitivity: public / internal / confidential / secret +- load: resident / run-start / triggered / retrieved / never + +If one container mixes two values on any axis, split it. + +## Eight planes + +1. Truth: code, tests, build and runtime config. +2. Instruction: AGENTS.md and policies. +3. Capability: skills, tools, MCP, plugins, scripts. +4. Knowledge: durable principles, methods, business semantics, pitfalls. +5. State: project cards, handoffs, environment and port registries. +6. Evidence: reports, screenshots, videos, test output. +7. Runtime: sessions, caches, logs, temp files, browser profiles. +8. Secret: passwords, tokens, keys, cookies; references only. + +## Placement tree + +1. Secret? → 8. +2. Code/config? → 1. +3. Changes default behavior for all tasks? → 2. +4. Repeatable action/procedure? → 3. +5. Current status/next step? → 5. +6. Proof/artifact? → 6. +7. Durable understanding? → 4. +8. Temporary working data? → 7. + +If content hits multiple planes, split it into conclusion + evidence + pointer. + +## Conflict rules + +- Safety: strictest wins. +- Fact: closest to runtime/code wins. +- Preference: most specific scope wins. +- Same level: do not guess; fix the missing canonical source. diff --git a/external_plugins/guster/skills/guster/reference/metrics.md b/external_plugins/guster/skills/guster/reference/metrics.md new file mode 100644 index 00000000..fe7c2649 --- /dev/null +++ b/external_plugins/guster/skills/guster/reference/metrics.md @@ -0,0 +1,52 @@ +# Guster Metrics + +Every number is a static count: files, bytes, characters, dates, path +resolution and git metadata. Nothing measures runtime token usage. + +## Core metrics + +| ID | Metric | Target | Numerator / denominator | +|---|---|---:|---| +| SSR | single-source rate | ≥95% | topics with one source / all topics (filename stem + content hash) | +| RBR | resident budget ratio | ≤100% | bytes of existing host entry files + hub `AGENTS.md` over 20 KB; skill descriptions over 6,000 chars | +| PCR | placement compliance rate | ≥90% | `.agents/` dirs with all six slots, a non-empty index and no unknown top-level entry / dirs that have `.agents/` (workspace containers excluded) | +| ICR | index coverage rate | knowledge 100%, `.agents` ≥90% | mean of indexed knowledge files and `.agents/` dirs with an index | +| FRS | state freshness rate | ≥90% | state docs verified within 90 days / state docs | +| PHR | pointer health rate | 100% | resolvable pointers / resolvable-looking pointers | +| SIR | secret isolation rate | ≥95% | credential files that are unregistered, tracked, unverifiable or inline / credential files + inline tokens. Inline means host config, not arbitrary file content | +| GCR | governance complexity ratio | ≤0.5 | governance documents / 8; reported, never scored | + +## Targets + +- Resident: one ≤20 KB budget for the entry files that exist (deduplicated by + real path, host symlinks do not double count) plus the hub instruction file. + Soft line budgets are 150 per user entry and 50 for the hub. +- Skills: ≤6,000 characters of description in total, ≤500 per skill. +- State: one 90-day SLA for every state document — hub `projects/`, `handoff/`, + `provision/` and `.agents/plans/`. There is no separate 7- or 30-day tier. + The date is `verified_at`, a `最后更新` line in the first 40 lines, a date that + opens the filename, or the owning repository's last commit — that last one is + "touched", not "verified". +- Knowledge: index every durable file; review every 90–180 days. +- Secrets: 0 in git; 1 canonical root; 0 inline tokens. A file kept in place is + accepted when `registry.md` declares it; an unreadable repository counts as + unverified, never as clean. + +## No evidence means n/a + +An empty denominator produces `n/a`, not 1.0: the dimension is dropped and the +remaining weights are renormalized. A scope with no evidence at all reports +`CHI: n/a`. Scores from scopes with different coverage are not comparable. + +## CHI + +The Context Health Index is a dashboard, not a KPI: + +```text +CHI = 0.20 SSR + 0.15 RBR + 0.15 PCR + 0.15 ICR + + 0.15 FRS + 0.10 PHR + 0.10 SIR +``` + +Critical findings do not rescale the score. They set the exit code: `doctor` +returns 3 on any critical and 2 on any high finding. `score --fail-under N` +turns the index into an explicit gate. diff --git a/external_plugins/guster/skills/guster/reference/placement.md b/external_plugins/guster/skills/guster/reference/placement.md new file mode 100644 index 00000000..94c26631 --- /dev/null +++ b/external_plugins/guster/skills/guster/reference/placement.md @@ -0,0 +1,51 @@ +# Guster Placement + +## User level + +```text +~/.agents/ +├── skills/ # shared capabilities +├── wiki/ # local/sensitive knowledge +├── credentials/ # secret root (target) +├── tmp/ # scratch +└── archive/ +``` + +User instructions remain a single canonical file with host symlinks. + +## Guster Hub + +```text +/ +├── projects/ # state +├── handoff/ # state +├── knowledge/ # knowledge +├── provision/ # state +├── tools/ # capability +├── reports/ # evidence +├── Human Output/ # evidence / deliverables +└── .agents/ # local plans and scratch +``` + +## Project level + +```text +/.agents/ +├── README.md # required index +├── plans/ # active plans +├── wiki/ # durable project knowledge +├── evidence/ # screenshots, videos, test output +├── skills/ # project-local skills +├── tmp/ # 14-day TTL +└── archive/ # superseded material +``` + +Topic directories are allowed only under a slot, e.g. +`evidence/brand-report-20261028/`, not at `.agents/` top level. + +## Git boundary + +- Portable, non-sensitive knowledge → hub `knowledge/` and git. +- Local paths, accounts, production IDs, sensitive pitfalls → `~/.agents/wiki/`. +- Plans, evidence, scratch → project `.agents/`, ignored. +- Credentials → secret root only; never git, docs, reports or chat. diff --git a/external_plugins/guster/skills/guster/reference/reformat.md b/external_plugins/guster/skills/guster/reference/reformat.md new file mode 100644 index 00000000..b2a04fad --- /dev/null +++ b/external_plugins/guster/skills/guster/reference/reformat.md @@ -0,0 +1,97 @@ +# Legacy Context Reformat + +The playbook for bringing a scattered, pre-spec context site under the format +contract (`spec.md`). Reach for it when inheriting a project or hub whose +context predates the spec: instruction files written as essays, knowledge with +no index, naked paths in prose, undated state documents, duplicated copies, +task folders sitting at the top level. The agent supplies the judgment; this +page fixes the order of operations so the deterministic tools stay useful +from the first inventory to the final verify. + +## Principles + +- **Meaning-preserving.** Reformat moves and links what is already there; it + never invents facts, fills gaps with guesses or rewrites wording for style. + A claim that cannot be verified keeps its text, flagged for the user. +- **Classify before touching.** Formatting garbage launders it. Every + artifact gets a destination class first; what has no future reader is + proposed for deletion or distillation, not formatted. +- **Placement before content.** A pointer written before its target moves is + a pointer rewritten after. Moves and deletions land first, content edits + second, so paths are final when pointers get written. +- **One scope, one pass.** Leave the scope internally consistent — indexed, + pointed, dated — even if the pass ends at its boundary. + +## Pass 1 — Inventory and classify + +1. `guster doctor --root ` (add `--json`): the findings list is the + worklist and each finding names the CTX rule it broke. `guster scan` adds + the asset inventory behind the findings. +2. Classify every artifact — flagged or not — into exactly one class: + + | Class | Destination | + |---|---| + | instruction | root `AGENTS.md`, narrowed per CTX-01 | + | durable knowledge | hub `knowledge/` (cross-project) or `.agents/wiki/` (project-local) | + | plan / task bundle | `.agents/plans/` | + | evidence | `.agents/evidence/` | + | skill | `.agents/skills/` (project) or `~/.agents/skills/` (machine) | + | scratch | `.agents/tmp/` — or delete | + | retired | `.agents/archive/` — or distill into wiki, then delete | + | code or config | the repository itself, not context | + | no future reader | propose deletion, never format | + +3. Duplicate stems (CTX-06): pick the canonical copy by freshness and + completeness; mark the rest to become pointer cards in pass 3. +4. A credential value found anywhere stops structural work: report critical, + then continue with references only (CTX-08). + +## Pass 2 — Placement + +- No `.agents/` yet: `guster plan init --project ` gives the six slots, + README and a minimal `AGENTS.md`. +- Before any move, search the repo for the artifact's path stem: every + referencer found now is a pointer you update in pass 3; doctor after the + move confirms none were missed (it catches what the search missed). +- The slot a `misplaced-artifact` finding suggests is shape-based — + classification can override it (a finished review report is a wiki + conclusion, not a live plan). +- One artifact: `guster plan move --from --to ` (backup plus a + pointer at the old location on apply). +- A batch: hand-write one plan JSON — `version: 1`, an `operations` list of + `mkdir`/`move`/`write` objects, same shape `plan move` emits — covering the + whole destination slot, then `guster apply ` dry-run, read it with the + user, `--yes`. Apply backs up every touched path and rolls back on failure. +- Nothing inside a moved file is rewritten in this pass. + +## Pass 3 — Content (per scope, table order) + +| Symptom | Transform | Rule | +|---|---|---| +| Scope has files but no index | Write the llms.txt-shaped README: H1, one-line blockquote, one link per file | CTX-02 | +| Knowledge file missing from its index | Add `- [title](path): one-line description` — the description says when to read it | CTX-02 | +| Naked path in prose or index | Markdown link in indexes, `` `code span` `` in prose, when-to-read in the same sentence | CTX-03 | +| State document without a machine-readable date | Add `verified_at: YYYY-MM-DD` / `最后更新: YYYY-MM-DD` — today only if you re-verified, otherwise the git date | CTX-04 | +| Task bundle at `.agents/` top level | Move into its slot (pass 2) | CTX-05 | +| Non-canonical duplicate | Replace with a pointer card: front-matter `type: pointer` plus a date key, truth links in the body; volatile values are deleted, not copied | CTX-06, CTX-09 | +| Instruction file over budget or contradicting a narrower one | Cut or split — nesting narrows, never contradicts | CTX-01 | +| Referencer still pointing at a pre-move path | Update it to the new path (doctor reports each as `broken-pointer`); `~`-prefixed links are resolved against home, so cross-repo pointers to a hub stay verifiable | CTX-03 | +| `.moved.md` left by an applied move | Delete once every referencer is updated — a transition pointer nobody reads re-triggers `misplaced-artifact`; keep it only for referencers outside your control | CTX-05 | + +Content edits are ordinary file edits by the agent; plans are for moves. + +## Pass 4 — Verify + +1. `guster doctor` — the pass kept the contract iff zero new findings appear. +2. `guster link` for a pointer-only check; `guster score --trend` for the + before/after number to hand the user. +3. Committing is the user's workflow; guster never auto-commits. + +## Scale and review + +Batch by directory or topic. For a large legacy site, deliver the pass-1 +classification table (artifact → class → keep/merge/delete) to the user +before pass 2 — one approval for the map, not one per file. Deletion and +distillation proposals live in that table, never execute silently. A +company-tracked file is never a migration target: report it, waive it with a +reason if the user accepts, move on. diff --git a/external_plugins/guster/skills/guster/reference/spec.md b/external_plugins/guster/skills/guster/reference/spec.md new file mode 100644 index 00000000..ced8abfa --- /dev/null +++ b/external_plugins/guster/skills/guster/reference/spec.md @@ -0,0 +1,62 @@ +# Context Format Spec + +spec_version: 1. The format contract for context assets — the shapes that let +both an agent and `guster doctor` verify a document without reading minds. +Each rule carries a stable ID (markdownlint style) and names the finding that +mechanically enforces it. What no finding enforces is guidance for the agent +that writes the document. + +## Open conventions this spec follows + +- **AGENTS.md** (agents.md) — the tool-agnostic instruction file. Guster reads + `AGENTS.md` (and legacy `CLAUDE.md`) wherever a host reads them; it does not + invent a competing instruction format. +- **llms.txt** (llmstxt.org) — index files as curated link lists. A knowledge + index is an H1, a one-line blockquote summary, then one markdown link per + file with a one-line description. Same shape, local files instead of URLs. +- **Stable rule IDs** (markdownlint) — rules are numbered, cited by findings + and waivers, and only change meaning on a spec_version bump. + +## Rules + +| ID | Rule | Enforced by | +|---|---|---| +| CTX-01 | `AGENTS.md` is the instruction file; nested ones narrow, never contradict. Budgets: hub ≤50 lines, user entry ≤150 lines, resident total ≤20 KB. | `resident-budget` | +| CTX-02 | Every knowledge scope has a README index in llms.txt shape; every knowledge file appears there verbatim, as `- [title](path): one-line description`. An index that names nothing is empty. | `orphan-knowledge` | +| CTX-03 | Any file path in an instruction or index document is machine-verifiable: a markdown link or inline code, never naked in prose. A pointer also states when to read the target. | `broken-pointer`, `naked-pointer` | +| CTX-04 | State documents carry a machine-readable date — `verified_at` or `最后更新: YYYY-MM-DD` within the first 40 lines, or a `YYYYMMDD` in the filename; otherwise git history is the fallback, and no signal means stale. | `stale-state` | +| CTX-05 | `.agents/` has exactly the six slots — `plans/`, `wiki/`, `evidence/`, `skills/`, `tmp/`, `archive/` — plus `README.md`/`AGENTS.md`. Task bundles live in a slot, never at the top level. | `misplaced-artifact` | +| CTX-06 | One home per fact. Same topic, same filename stem; the second home becomes a pointer to the first. Copies are either byte-identical or a mistake. | `duplicate-canonical`, `skill-copy-drift` | +| CTX-07 | Skill frontmatter carries `name` and `description` only; one trigger sentence, ≤500 chars each, ≤6,000 chars across the library. | `skill-budget` | +| CTX-08 | Credentials appear by reference — a path in the registry under the canonical root — never as a value in a document, a config or git. | `secret-in-git`, `secret-outside-root`, `secret-inline`, `secret-undeclared`, `secret-missing` | +| CTX-09 | A knowledge note may carry YAML front-matter following the Obsidian properties convention (the interchange format Foam/Quartz/Logseq also accept). The header is opt-in: it is machine-checked only when it declares `type:`. A checked header must parse and carry a machine-readable date (`updated`/`date`/`lastmod`/`created` as YYYY-MM-DD), and the note's body then joins the CTX-03 pointer scan — truth sources go in the body as links, never as invented header keys. | `note-header-malformed`, `broken-pointer`, `naked-pointer` | + +## Why a format contract + +The metrics are static: filename stems, content hashes, path resolution, git +dates. A document that ignores the contract is not scored lower — it is +invisible. A naked path in prose never reaches the pointer check; a date +written as "last week" falls back to git; an index without filenames counts as +missing. The contract is what makes measurement possible, and the score is +what keeps the contract from drifting. Both directions are load-bearing. + +## Writing guide (no finding enforces this) + +When creating or moving a context asset: + +1. Pick the home by CTX-06, the slot by CTX-05, the index entry by CTX-02. +2. Write pointers as `[label](relative/path.md)` in indexes and `` `path` `` + in prose; add when-to-read in the same sentence. +3. Add the date line before saving a state document; run `guster doctor` + after edits — a new finding names the rule you just broke. +4. A note that summarizes knowledge whose truth lives elsewhere (a pointer + card) opts into CTX-09: standard front-matter (`type: pointer`, a date + key, `tags` if useful) plus a truth section of ordinary markdown links. + Volatile values (latest numbers, current picks) stay out of the card and + are read from the truth sources instead. + +## Versioning + +Additive changes keep spec_version; a rule that changes meaning bumps it and +gets a CHANGELOG entry. Findings cite rule IDs, waivers accept them, so a +spec change never silently re-targets an existing waiver. diff --git a/external_plugins/guster/src/guster/__init__.py b/external_plugins/guster/src/guster/__init__.py new file mode 100644 index 00000000..0c250172 --- /dev/null +++ b/external_plugins/guster/src/guster/__init__.py @@ -0,0 +1,3 @@ +"""Guster: agent context steward.""" + +__version__ = "0.7.3" diff --git a/external_plugins/guster/src/guster/__main__.py b/external_plugins/guster/src/guster/__main__.py new file mode 100644 index 00000000..a049ad7a --- /dev/null +++ b/external_plugins/guster/src/guster/__main__.py @@ -0,0 +1,5 @@ +from .cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/external_plugins/guster/src/guster/cli.py b/external_plugins/guster/src/guster/cli.py new file mode 100644 index 00000000..76fd4160 --- /dev/null +++ b/external_plugins/guster/src/guster/cli.py @@ -0,0 +1,700 @@ +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence + +from . import __version__ +from .discover import ScanConfig, default_hub, default_roots, scan +from .executor import ApplyError, apply_plan, init_home +from .hub import attach_hub, default_hub_path, init_hub, validate_hub +from .metrics import compute_metrics, metric_line +from .planner import ( + load_plan, + plan_from_findings, + plan_init_project, + plan_move, + save_plan, +) +from .policy import run_policy +from .report import redacted_registry, render_report, render_status +from .util import json_dumps, load_json, now_iso, save_json, write_text + + +def default_state_dir(home: Path) -> Path: + return home / ".agents" / "guster" + + +def add_common(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--root", action="append", default=None, help="Workspace root; repeatable.") + parser.add_argument( + "--scope", + choices=["auto", "machine", "hub", "project", "group"], + default="auto", + help="Context scope: machine (default), hub, one project, or a group of projects.", + ) + parser.add_argument("--hub", default=None, help="Guster Hub path.") + parser.add_argument("--state-dir", default=None, help="Guster state directory.") + parser.add_argument("--json", action="store_true", help="Emit JSON.") + parser.add_argument("--quiet", action="store_true", help="Only print errors.") + + +def build_config(args: argparse.Namespace) -> ScanConfig: + home = Path.home() + base_state = Path(args.state_dir).expanduser() if args.state_dir else default_state_dir(home) + config_path = base_state / "config.json" + local: Any = {} + if config_path.is_file(): + try: + local = json.loads(config_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise ValueError(f"cannot read Guster config {config_path}: {exc}") from exc + if not isinstance(local, dict): + raise ValueError(f"Guster config must be a JSON object: {config_path}") + + if args.hub: + hub = Path(args.hub).expanduser() + elif isinstance(local, dict) and local.get("hub"): + hub = Path(local["hub"]).expanduser() + else: + hub = default_hub(home) + + explicit_roots = [Path(item).expanduser() for item in args.root] if args.root else [] + for root in explicit_roots: + if not root.exists(): + raise ValueError(f"workspace root does not exist: {root.resolve()}") + if not root.is_dir(): + raise ValueError(f"workspace root is not a directory: {root.resolve()}") + scope = getattr(args, "scope", "auto") or "auto" + if scope == "auto": + scope = "machine" if not explicit_roots else ("project" if len(explicit_roots) == 1 else "group") + if scope in {"project", "group"} and not explicit_roots: + raise ValueError("--scope project|group needs at least one --root") + if scope == "hub": + roots: List[Path] = [] + elif explicit_roots: + roots = explicit_roots + elif isinstance(local, dict) and local.get("roots"): + roots = [Path(item).expanduser() for item in local["roots"]] + else: + roots = default_roots(home) + + state_dir = base_state + if not args.state_dir and (scope != "machine" or explicit_roots): + # Scoped runs keep their own registry and trend so they never overwrite + # the machine-wide view. + key = "|".join([scope] + sorted(str(root.resolve()) for root in roots) + [str(hub or "")]) + state_dir = base_state / "scoped" / hashlib.sha1(key.encode()).hexdigest()[:10] + + plugin_root = Path(__file__).resolve().parents[2] + return ScanConfig( + home=home, + hub=hub, + roots=roots, + state_dir=state_dir, + plugin_root=plugin_root, + max_depth=int(local.get("scan_max_depth", 5)) if isinstance(local, dict) else 5, + scope=scope, + ) + + +def ensure_state(config: ScanConfig) -> None: + config.state_dir.mkdir(parents=True, exist_ok=True) + (config.state_dir / "plans").mkdir(parents=True, exist_ok=True) + (config.state_dir / "backups").mkdir(parents=True, exist_ok=True) + + +def do_scan(config: ScanConfig, write: bool = True) -> Dict[str, Any]: + ensure_state(config) + registry = scan(config) + if write: + save_json(config.state_dir / "registry.json", registry) + return registry + + +def load_registry(config: ScanConfig) -> Optional[Dict[str, Any]]: + path = config.state_dir / "registry.json" + if not path.is_file(): + return None + data = load_json(path, None) + return data if isinstance(data, dict) else None + + +def evaluate(registry: Dict[str, Any], config: ScanConfig) -> Dict[str, Any]: + policy_result = run_policy(registry, config) + metric_result = compute_metrics(registry, policy_result) + return {"policy": policy_result, "metrics": metric_result} + + +def append_metric(config: ScanConfig, metric_result: Dict[str, Any]) -> None: + ensure_state(config) + path = config.state_dir / "metrics.jsonl" + line = { + "at": metric_result.get("generated_at", now_iso()), + "scope": metric_result.get("scope"), + "chi": metric_result.get("chi"), + "dims": metric_result.get("dims"), + "unscored_dims": metric_result.get("unscored_dims"), + "metrics": metric_result.get("metrics", {}), + "scores": metric_result.get("scores", {}), + } + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(line, ensure_ascii=False) + "\n") + + +def last_metric(config: ScanConfig) -> Optional[Dict[str, Any]]: + """Newest persisted snapshot; `status` reports this instead of mixing a stale scan with fresh policy.""" + path = config.state_dir / "metrics.jsonl" + if not path.is_file(): + return None + lines = [line for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + if not lines: + return None + try: + item = json.loads(lines[-1]) + except ValueError: + return None + if not isinstance(item, dict): + return None + item.setdefault("generated_at", item.get("at")) + return item + + +def write_findings(config: ScanConfig, policy_result: Dict[str, Any]) -> None: + ensure_state(config) + save_json(config.state_dir / "findings.json", policy_result) + + +def print_scan_summary(registry: Dict[str, Any], config: ScanConfig) -> None: + summary = registry.get("summary", {}) + print("Guster scan") + print(f" workspaces: {', '.join(registry.get('workspaces', []))}") + print(f" hub: {registry.get('hub') or '(none)'}") + if config.scope == "hub": + print( + f" projects: {summary.get('project_cards', 0)} " + f"(scanned roots: {summary.get('projects', 0)})" + ) + else: + print(f" projects: {summary.get('projects', 0)}") + print(f" .agents: {summary.get('agents_dirs', 0)} (indexed {summary.get('agents_with_index', 0)})") + print(f" skills: {summary.get('skills', 0)} unique / {summary.get('skill_copies', 0)} copies") + print(f" knowledge: {summary.get('knowledge', 0)}") + print(f" state: {summary.get('state', 0)}") + print(f" evidence: {summary.get('evidence', 0)}") + print(f" credentials:{summary.get('secret_files', 0)} files; {summary.get('inline_bearers', 0)} inline") + print(f" registry: {config.state_dir / 'registry.json'}") + + +def cmd_status(args: argparse.Namespace) -> int: + config = build_config(args) + registry = load_registry(config) + cached = registry is not None + if registry is None: + registry = do_scan(config, write=True) + policy_result: Optional[Dict[str, Any]] = None + if cached: + # The cached registry describes an earlier scan. Running policy against + # it would mix old artifacts with fresh file contents, so report the + # stored snapshot and let `doctor` own the fresh evaluation. + metric_result = last_metric(config) + else: + result = evaluate(registry, config) + policy_result = result["policy"] + metric_result = result["metrics"] + if args.json: + print(json_dumps({ + "registry": { + "plugin": registry.get("plugin"), + "generated_at": registry.get("generated_at"), + "hub": registry.get("hub"), + "workspaces": registry.get("workspaces"), + "summary": registry.get("summary"), + }, + "metrics": metric_result, + "cached": cached, + "findings": None if policy_result is None else len(policy_result["findings"]), + })) + else: + print(render_status(registry, metric_result)) + if policy_result is None: + print("cached registry; run `guster doctor` for a fresh evaluation") + elif metric_result is not None: + print(metric_line(metric_result)) + print(f"findings: {len(policy_result['findings'])}") + return 0 + + +def cmd_scan(args: argparse.Namespace) -> int: + config = build_config(args) + registry = do_scan(config, write=not args.no_write) + if args.json: + print(json_dumps(registry)) + elif not args.quiet: + print_scan_summary(registry, config) + return 0 + + +def cmd_doctor(args: argparse.Namespace) -> int: + config = build_config(args) + registry = do_scan(config, write=True) + result = evaluate(registry, config) + policy_result = result["policy"] + if not args.no_write: + write_findings(config, policy_result) + findings = policy_result["findings"] + if args.json: + print(json_dumps(policy_result)) + elif not args.quiet: + print(render_report(registry, policy_result, result["metrics"])) + severities = {item["severity"] for item in findings} + if "critical" in severities: + return 3 + if "high" in severities: + return 2 + return 0 + + +def cmd_score(args: argparse.Namespace) -> int: + config = build_config(args) + registry = do_scan(config, write=True) + result = evaluate(registry, config) + metric_result = result["metrics"] + if not args.no_write: + append_metric(config, metric_result) + if args.json: + print(json_dumps(metric_result)) + elif not args.quiet: + chi = metric_result.get("chi") + print(f"Guster Context Health: {'n/a' if chi is None else chi} / 100") + print("") + dims = set(metric_result.get("dims") or []) + for key, value in metric_result["scores"].items(): + if dims and key not in dims: + continue + print(f" {key.upper():4s} {'n/a' if value is None else f'{value:6.1f}'}") + print("") + print(metric_line(metric_result)) + print(f"registry: {config.state_dir / 'registry.json'}") + print(f"metrics: {config.state_dir / 'metrics.jsonl'}") + if args.trend: + path = config.state_dir / "metrics.jsonl" + if path.is_file(): + lines = path.read_text(encoding="utf-8").splitlines()[-10:] + print("") + print("Recent trend:") + for line in lines: + try: + item = json.loads(line) + print(f" {item.get('at')} CHI {item.get('chi')}") + except ValueError: + continue + if args.fail_under is not None: + chi = metric_result.get("chi") + if chi is None: + print("error: CHI is not computable; no dimension has evidence", file=sys.stderr) + return 2 + if chi < args.fail_under: + print(f"error: CHI {chi} is below --fail-under {args.fail_under}", file=sys.stderr) + return 2 + return 0 + + +def cmd_plan(args: argparse.Namespace) -> int: + config = build_config(args) + ensure_state(config) + if args.plan_action == "findings": + registry = do_scan(config, write=True) + result = evaluate(registry, config) + plan = plan_from_findings(result["policy"]["findings"]) + elif args.plan_action == "move": + src = Path(args.source).expanduser() + dst = Path(args.destination).expanduser() + plan = plan_move(src, dst) + elif args.plan_action == "init": + plan = plan_init_project(Path(args.project).expanduser()) + else: + raise SystemExit(f"unknown plan action: {args.plan_action}") + + path = save_plan(config.state_dir, plan) + if args.json: + print(json_dumps(plan)) + elif not args.quiet: + print(f"plan: {path}") + print(f"operations: {len(plan.get('operations', []))}") + for index, operation in enumerate(plan.get("operations", []), start=1): + kind = operation.get("kind") + if kind == "move": + print(f" {index}. move {operation['src']} -> {operation['dst']}") + elif kind == "mkdir": + print(f" {index}. mkdir {operation['path']}") + elif kind == "write": + print(f" {index}. write {operation['path']}") + elif kind == "manual": + print(f" {index}. manual {operation['path']}: {operation['action']}") + print("review the plan; apply with `guster apply --yes`.") + return 0 + + +def cmd_apply(args: argparse.Namespace) -> int: + config = build_config(args) + ensure_state(config) + if args.dry_run and args.yes: + raise ValueError("--dry-run and --yes are mutually exclusive") + plan = load_plan(Path(args.plan).expanduser()) + result = apply_plan( + plan, + config.state_dir, + dry_run=not args.yes, + yes=args.yes, + ) + if args.json: + print(json_dumps(result)) + elif not args.quiet: + if result.get("dry_run"): + mode = "DRY RUN" + elif result.get("ok"): + mode = "APPLIED" + else: + mode = "FAILED" + print(f"{mode}: {result.get('plan')}") + print(f" applied: {result.get('applied', 0)}") + print(f" skipped: {result.get('skipped', 0)}") + if result.get("backup"): + print(f" backup: {result['backup']}") + if result.get("errors"): + for error in result["errors"]: + print(f" error: {error}") + return 0 if result.get("ok") else 4 + + +def cmd_init(args: argparse.Namespace) -> int: + config = build_config(args) + if args.project: + plan = plan_init_project(Path(args.project).expanduser()) + path = save_plan(config.state_dir, plan) + result = apply_plan(plan, config.state_dir, dry_run=not args.yes, yes=args.yes) + if not args.json and not args.quiet: + print(f"plan: {path}") + else: + result = init_home(config.state_dir, yes=args.yes, dry_run=not args.yes) + if args.json: + print(json_dumps(result)) + elif not args.quiet: + if result.get("dry_run"): + mode = "DRY RUN" + elif result.get("ok"): + mode = "APPLIED" + else: + mode = "FAILED" + print(f"{mode}: init") + print(f" applied: {result.get('applied', 0)}") + print(f" skipped: {result.get('skipped', 0)}") + if result.get("errors"): + for error in result["errors"]: + print(f" error: {error}") + return 0 if result.get("ok") else 4 + + +def cmd_hub(args: argparse.Namespace) -> int: + config = build_config(args) + action = args.hub_action + target = Path( + getattr(args, "path", None) + or getattr(args, "hub", None) + or config.hub + or default_hub_path(Path.home()) + ).expanduser() + + if action == "init": + result = init_hub( + target, + config.state_dir, + yes=args.yes, + dry_run=not args.yes, + name=args.name, + knowledge=args.knowledge, + projects=args.projects, + tools=args.tools, + ) + if args.json: + print(json_dumps(result)) + elif not args.quiet: + if result.get("dry_run"): + mode = "DRY RUN" + elif result.get("ok"): + mode = "APPLIED" + else: + mode = "FAILED" + print(f"{mode}: hub {result['hub']}") + print(f" plan: {result.get('plan')}") + print(f" applied: {result.get('applied', 0)}") + print(f" skipped: {result.get('skipped', 0)}") + if result.get("attach"): + print(f" config: {result['attach']['config']}") + paths = result.get("paths") or {} + if paths: + print( + " paths: " + f"knowledge={paths['knowledge']} " + f"projects={paths['projects']} " + f"tools={paths['tools']}" + ) + if result.get("errors"): + for error in result["errors"]: + print(f" error: {error}") + return 0 if result.get("ok") else 4 + + if action == "attach": + result = attach_hub(config.state_dir, target) + if args.json: + print(json_dumps(result)) + elif not args.quiet: + print(f"attached: {result['hub']}") + print(f" config: {result['config']}") + if result.get("backup"): + print(f" backup: {result['backup']}") + return 0 + + info = validate_hub(target, strict=action == "validate") + if args.json: + print(json_dumps(info)) + elif not args.quiet: + manifest = info.get("manifest") or {} + print(f"Hub: {info['hub']}") + if info.get("profile") == "legacy": + print("Manifest: legacy (no hub.yaml)") + else: + print( + "Manifest: " + f"{info.get('profile') or '(missing)'} " + f"schema={manifest.get('schema_version') or '(missing)'} " + f"hub={manifest.get('hub_version') or '(missing)'} " + f"min_guster={manifest.get('min_guster') or '(missing)'}" + ) + print(f"Plugin: {info['plugin']}") + paths = info.get("paths") or {} + if paths: + print( + "Paths: " + f"knowledge={paths.get('knowledge', '(missing)')} " + f"projects={paths.get('projects', '(missing)')} " + f"tools={paths.get('tools', '(missing)')}" + ) + if info["errors"]: + for error in info["errors"]: + print(f" error: {error}") + for warning in info["warnings"]: + print(f" warning: {warning}") + if info["ok"]: + print("Checks: OK") + if action == "validate": + return 0 if info["ok"] else 2 + return 0 if info["ok"] else 1 + + +def cmd_link(args: argparse.Namespace) -> int: + config = build_config(args) + registry = do_scan(config, write=True) + result = evaluate(registry, config) + findings = [item for item in result["policy"]["findings"] if item["rule"] == "broken-pointer"] + if args.json: + print(json_dumps({"findings": findings, "stats": result["policy"]["stats"]})) + elif not args.quiet: + print(f"broken pointer files: {len(findings)}") + for item in findings: + print(f" {item['path']}") + for pointer in item.get("evidence", {}).get("pointers", []): + print(f" - {pointer}") + return 0 if not findings else 2 + + +def cmd_export(args: argparse.Namespace) -> int: + config = build_config(args) + registry = do_scan(config, write=True) + result = evaluate(registry, config) + report = render_report(registry, result["policy"], result["metrics"]) + out = Path(args.out).expanduser() if args.out else config.state_dir / "context-health.md" + write_text(out, report) + if args.format == "json": + json_out = out.with_suffix(".json") + save_json(json_out, { + "registry": redacted_registry(registry), + "policy": result["policy"], + "metrics": result["metrics"], + }) + if not args.quiet: + print(f"report: {out}") + print(f"json: {json_out}") + elif not args.quiet: + print(f"report: {out}") + return 0 + + +def cmd_credentials(args: argparse.Namespace) -> int: + config = build_config(args) + registry = do_scan(config, write=True) + info = (registry.get("secrets") or {}).get("registry") or {} + entries = info.get("entries") or [] + scopes: Dict[str, List[Dict[str, str]]] = {} + for entry in entries: + scopes.setdefault(entry.get("scope", "unknown"), []).append(entry) + if args.json: + print(json_dumps({"path": info.get("path"), "entries": entries})) + return 0 + if not args.quiet: + print(f"registry: {info.get('path') or '(missing)'}") + for scope in ("company", "personal", "unknown"): + items = scopes.pop(scope, []) + if not items: + continue + label = {"company": "公司", "personal": "个人", "unknown": "未分类"}[scope] + print(f"\n{label}({len(items)})") + for entry in items: + keys = f" [{entry['keys']}]" if entry.get("keys") not in {"", "-"} else "" + print(f" {entry['id']:24} {entry['site'][:16]:18} {entry['kind']:8} {entry['status']}{keys}") + for scope, items in scopes.items(): + print(f"\n{scope}({len(items)})") + for entry in items: + print(f" {entry['id']:24} {entry['site'][:16]:18} {entry['kind']:8} {entry['status']}") + return 0 + + +def cmd_upgrade(args: argparse.Namespace) -> int: + config = build_config(args) + registry = do_scan(config, write=True) + manifests = registry.get("plugin_manifests", {}) + versions = { + key: value.get("version") + for key, value in manifests.items() + if isinstance(value, dict) and value.get("exists") + } + if args.json: + print(json_dumps({"plugin": __version__, "manifests": versions})) + elif not args.quiet: + print(f"Guster CLI: {__version__}") + for key, version in versions.items(): + print(f" {key}: {version or '(missing)'}") + if len(set(versions.values())) > 1: + print(" WARNING: manifest versions differ") + return 0 if len(set(versions.values())) <= 1 else 2 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="guster", + description="Discover, audit, plan and evolve agent context.", + ) + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + subparsers = parser.add_subparsers(dest="command", required=True) + + common = argparse.ArgumentParser(add_help=False) + add_common(common) + + status = subparsers.add_parser("status", parents=[common], help="Show plugin, registry and health status.") + status.set_defaults(func=cmd_status) + + scan_parser = subparsers.add_parser("scan", parents=[common], help="Discover context assets.") + scan_parser.add_argument("--no-write", action="store_true", help="Do not write registry.json.") + scan_parser.set_defaults(func=cmd_scan) + + doctor = subparsers.add_parser("doctor", parents=[common], help="Run policy checks.") + doctor.add_argument("--no-write", action="store_true", help="Do not write findings.json.") + doctor.set_defaults(func=cmd_doctor) + + score = subparsers.add_parser("score", parents=[common], help="Compute metrics and CHI.") + score.add_argument("--write", action="store_true", help="Write metrics (default; kept for explicit scripts).") + score.add_argument("--no-write", action="store_true", help="Do not append metrics.jsonl.") + score.add_argument("--trend", action="store_true", help="Show the last 10 metric snapshots.") + score.add_argument( + "--fail-under", + type=float, + default=None, + metavar="CHI", + help="Exit 2 when CHI is below this value (opt-in gate for CI).", + ) + score.set_defaults(func=cmd_score) + + plan = subparsers.add_parser("plan", help="Create a reviewable plan.") + plan_sub = plan.add_subparsers(dest="plan_action", required=True) + plan_findings = plan_sub.add_parser("findings", parents=[common], help="Turn findings into a review checklist.") + plan_findings.set_defaults(func=cmd_plan) + plan_move_parser = plan_sub.add_parser("move", parents=[common], help="Plan a move with backup and pointer.") + plan_move_parser.add_argument("--from", dest="source", required=True, help="Source path.") + plan_move_parser.add_argument("--to", dest="destination", required=True, help="Destination path.") + plan_move_parser.set_defaults(func=cmd_plan) + plan_init_parser = plan_sub.add_parser("init", parents=[common], help="Plan a standard .agents layout.") + plan_init_parser.add_argument("--project", required=True, help="Project root.") + plan_init_parser.set_defaults(func=cmd_plan) + + apply_parser = subparsers.add_parser("apply", parents=[common], help="Apply a plan.") + apply_parser.add_argument("plan", help="Plan JSON path.") + apply_parser.add_argument("--yes", action="store_true", help="Actually write; without it this is a dry run.") + apply_parser.add_argument( + "--dry-run", + action="store_true", + help="Explicit dry run (the default); cannot be combined with --yes.", + ) + apply_parser.set_defaults(func=cmd_apply) + + init = subparsers.add_parser("init", parents=[common], help="Initialize Guster home or a project layout.") + init.add_argument("--home", action="store_true", help="Initialize the Guster state home (default when --project is omitted).") + init.add_argument("--project", default=None, help="Project root; omit to initialize the Guster state home.") + init.add_argument("--yes", action="store_true", help="Actually write; without it this is a dry run.") + init.set_defaults(func=cmd_init) + + hub = subparsers.add_parser("hub", help="Create, attach or inspect a Guster Hub.") + hub_sub = hub.add_subparsers(dest="hub_action", required=True) + hub_init = hub_sub.add_parser("init", parents=[common], help="Create a lite Guster Hub.") + hub_init.add_argument("--path", default=None, help="Hub directory; defaults to the attached hub or the conventional Guster Hub path.") + hub_init.add_argument("--name", default=None, help="Hub display name.") + hub_init.add_argument("--knowledge", default="knowledge", help="Knowledge root relative to the hub.") + hub_init.add_argument("--projects", default="projects", help="Project state root relative to the hub.") + hub_init.add_argument("--tools", default="tools", help="Tool root relative to the hub.") + hub_init.add_argument("--yes", action="store_true", help="Actually write; without it this is a dry run.") + hub_init.set_defaults(func=cmd_hub) + hub_attach = hub_sub.add_parser("attach", parents=[common], help="Attach an existing hub to Guster config.") + hub_attach.add_argument("--path", default=None, help="Hub directory; defaults to the attached hub or the conventional Guster Hub path.") + hub_attach.set_defaults(func=cmd_hub) + hub_status = hub_sub.add_parser("status", parents=[common], help="Show hub manifest and validation status.") + hub_status.add_argument("--path", default=None, help="Hub directory; defaults to the attached hub or the conventional Guster Hub path.") + hub_status.set_defaults(func=cmd_hub) + hub_validate = hub_sub.add_parser("validate", parents=[common], help="Validate a hub against the lite contract.") + hub_validate.add_argument("--path", default=None, help="Hub directory; defaults to the attached hub or the conventional Guster Hub path.") + hub_validate.set_defaults(func=cmd_hub) + + link = subparsers.add_parser("link", parents=[common], help="Check context pointers.") + link.set_defaults(func=cmd_link) + + export = subparsers.add_parser("export", parents=[common], help="Export a redacted health report.") + export.add_argument("--format", choices=["md", "json"], default="md") + export.add_argument("--out", default=None, help="Output path.") + export.set_defaults(func=cmd_export) + + creds = subparsers.add_parser("credentials", parents=[common], help="List registered credentials by scope.") + creds.set_defaults(func=cmd_credentials) + + upgrade = subparsers.add_parser("upgrade", parents=[common], help="Check plugin manifest versions.") + upgrade.set_defaults(func=cmd_upgrade) + + return parser + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return int(args.func(args)) + except (ApplyError, OSError, ValueError) as exc: + if getattr(args, "json", False): + print(json_dumps({"ok": False, "error": str(exc)})) + else: + print(f"[guster] {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/external_plugins/guster/src/guster/discover.py b/external_plugins/guster/src/guster/discover.py new file mode 100644 index 00000000..a1008027 --- /dev/null +++ b/external_plugins/guster/src/guster/discover.py @@ -0,0 +1,974 @@ +from __future__ import annotations + +import os +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple + +from . import __version__ +from .layout import resolve_hub_layout +from .util import ( + EXCLUDE_DIRS, + find_git_roots, + first_heading, + git_tracked, + is_under, + iter_files, + load_json, + now_iso, + parse_frontmatter, + read_text, + relative, + sha256_file, + sha256_text, +) + + +STANDARD_AGENTS_SLOTS = ["plans", "wiki", "evidence", "skills", "tmp", "archive"] +ARTIFACT_KIND_PRIORITY = { + "instruction": 0, + "knowledge": 1, + "plan": 2, + "tool": 3, + "evidence": 4, + "state": 5, + "archive": 6, +} +GENERIC_TOPIC_STEMS = { + "readme", + "index", + "agents", + "notes", + "notesmd", + "proposal", + "tasks", + "design", + "spec", + "plan", + "todo", + "changelog", + "summary", + "skill", + "openspec", + "config", + "example", + "template", +} +SECRET_SUFFIX_NAMES = {".env", "config.json", "credentials.json", "credentials.local.json"} +INLINE_CONFIG_FILES = [ + "~/.codex/config.toml", + "~/.zcode/cli/config.json", + "~/.dsh/cordis.patch.yml", +] +INLINE_PATTERNS = [ + # Host configs carry tokens in more shapes than one: keep the detector + # narrow enough to stay quiet, broad enough not to miss a literal key. + r"\bBearer\s+([A-Za-z0-9._\-]{8,})", + r"\bsk-[A-Za-z0-9]{16,}", + r"(?i)\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|secret[_-]?key)" + r"\s*[:=]\s*[\"']?([A-Za-z0-9._\-]{16,})", +] +SECRET_ENV_DEPTH = 2 +HASH_LIMIT = 8_000_000 + + +@dataclass +class ScanConfig: + home: Path + hub: Optional[Path] + roots: List[Path] + state_dir: Path + plugin_root: Optional[Path] = None + max_depth: int = 5 + scope: str = "machine" + now: str = field(default_factory=now_iso) + + +def scope_roots(config: ScanConfig) -> Optional[List[Path]]: + """Directories that define the run's scope; None means the machine-wide view. + + The same boundary filters artifacts and findings, so a scoped report never + counts a document it can no longer raise a finding about. + """ + if config.scope == "hub": + return [config.hub.expanduser().resolve()] if config.hub else [] + if config.scope in {"project", "group"}: + return [root.expanduser().resolve() for root in config.roots] + return None + + +def in_scope_path(value: str, roots: Sequence[Path]) -> bool: + if not value: + return False + try: + candidate = Path(value).expanduser().resolve() + except OSError: + return False + return any(candidate == root or root in candidate.parents for root in roots) + + +def default_roots(home: Path) -> List[Path]: + candidates = [home / "IdeaProjects", home / "ZCodeProject"] + seen: Set[str] = set() + roots: List[Path] = [] + for candidate in candidates: + if candidate.exists(): + resolved = str(candidate.resolve()) + if resolved not in seen: + seen.add(resolved) + roots.append(candidate) + if not roots: + roots.append(home) + return roots + + +def default_hub(home: Path) -> Optional[Path]: + candidate = home / "IdeaProjects" / "Guster" + return candidate if candidate.exists() else None + + +def dedupe_artifacts(items: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Keep one record per path so ratios count files, not scan passes.""" + best: Dict[str, Tuple[int, Dict[str, Any]]] = {} + order: List[str] = [] + for item in items: + path = item["path"] + rank = ARTIFACT_KIND_PRIORITY.get(str(item.get("kind")), 9) + current = best.get(path) + if current is None: + order.append(path) + best[path] = (rank, item) + elif rank < current[0]: + best[path] = (rank, item) + return [best[path][1] for path in order] + + +def topic_key(path: Path) -> str: + stem = path.stem.strip().lower().lstrip(".") + stem = re.sub(r"[-_ ]?20\d{6}$", "", stem) + stem = re.sub(r"[-_ ]?\d{4}-\d{2}-\d{2}$", "", stem) + return stem + + +def derive_topics( + artifacts: Sequence[Dict[str, Any]], + skills: Sequence[Dict[str, Any]], +) -> Dict[str, Any]: + """Group knowledge into facts and report the ones with more than one home.""" + knowledge_all = [ + item + for item in artifacts + if item.get("kind") == "knowledge" + and Path(item["path"]).name.lower() not in {"readme.md", "agents.md"} + ] + knowledge = [item for item in knowledge_all if item.get("hash")] + unhashed = sum(1 for item in knowledge_all if not item.get("hash")) + consumed: Set[str] = set() + topics: List[Dict[str, Any]] = [] + + by_stem: Dict[str, List[Dict[str, Any]]] = {} + for item in knowledge: + stem = topic_key(Path(item["path"])) + if not stem or stem in GENERIC_TOPIC_STEMS or len(stem) < 4: + continue + by_stem.setdefault(stem, []).append(item) + for stem, items in sorted(by_stem.items()): + hashes = {item["hash"] for item in items} + if len(hashes) > 1: + topics.append( + { + "key": stem, + "reason": "same-name-different-content", + "sources": len(hashes), + "paths": [item["path"] for item in items], + } + ) + consumed.update(item["path"] for item in items) + + by_hash: Dict[str, List[Dict[str, Any]]] = {} + for item in knowledge: + if item["path"] in consumed: + continue + by_hash.setdefault(item["hash"], []).append(item) + for digest, items in by_hash.items(): + topics.append( + { + "key": topic_key(Path(items[0]["path"])) or digest[:8], + "reason": "duplicate-content" if len(items) > 1 else "single", + "sources": len(items), + "paths": [item["path"] for item in items], + } + ) + + for skill in skills: + copies = skill.get("copies") or [] + hashes = {copy.get("hash", "") for copy in copies} or {skill.get("hash", "")} + topics.append( + { + "key": f"skill:{skill['name']}", + "reason": "skill-copy-drift" if len(hashes) > 1 else "single", + "sources": len(hashes) if len(hashes) > 1 else 1, + "paths": [copy.get("root", "") for copy in copies] or [skill.get("root", "")], + } + ) + + multi = [topic for topic in topics if int(topic.get("sources", 1)) > 1] + return { + "total": len(topics), + "multi": len(multi), + "multi_topics": multi, + "unhashed": unhashed, + } + + +def scan(config: ScanConfig) -> Dict[str, Any]: + home = config.home + hub = config.hub + scope = config.scope + roots = [root for root in config.roots if root.exists()] + git_roots = find_git_roots(roots, max_depth=config.max_depth) + project_roots: List[Path] = list(git_roots) + known = {item.resolve() for item in project_roots} + for root in roots: + resolved = root.resolve() + if resolved in known: + continue + if resolved == home.resolve() and not has_project_marker(root): + # `~/.agents/` is the user-level context root; the home directory is + # a workspace, not a project, unless it carries its own markers. + continue + if looks_like_project(root): + project_roots.append(root) + known.add(resolved) + + projects: List[Dict[str, Any]] = [] + project_tracked: Dict[str, Optional[Set[str]]] = {} + for root in sorted(project_roots, key=lambda item: str(item)): + tracked = git_tracked(root) + project_tracked[str(root)] = tracked + projects.append( + scan_project( + root=root, + group=root.parent.name, + tracked=tracked, + now=config.now, + ) + ) + + mark_containers(projects) + project_by_id = {project["id"]: project for project in projects} + index_texts = load_index_texts(home, hub, projects) + + artifacts: List[Dict[str, Any]] = [] + if scope != "hub": + artifacts.extend(scan_project_agents(projects, index_texts, project_tracked)) + artifacts.extend(scan_hub_knowledge(hub, index_texts)) + if scope != "hub": + artifacts.extend(scan_local_wiki(home, index_texts)) + artifacts = dedupe_artifacts(artifacts) + scoped = scope_roots(config) + if scoped is not None: + # Scoped runs must not carry artifacts whose findings they filter out: + # that inflates ICR/FRS/PHR denominators and depresses the score. + artifacts = [item for item in artifacts if in_scope_path(item["path"], scoped)] + + skills = scan_skills(home) if scope != "hub" else [] + topics = derive_topics(artifacts, skills) + secret_files = scan_secret_files( + home, + hub, + projects, + project_tracked, + include_home=scope != "hub", + ) + credential_registry = scan_credential_registry(home) + inline_bearers = scan_inline_bearers(home) if scope != "hub" else [] + governance_docs = find_governance_docs(home, hub) + if scope == "hub" and hub: + governance_docs = [ + item for item in governance_docs if is_under(Path(item), hub) + ] + manifests = scan_plugin_manifests(config.plugin_root) if scope != "hub" else {} + + registry: Dict[str, Any] = { + "version": 1, + "plugin": {"name": "guster", "version": __version__}, + "generated_at": config.now, + "scope": config.scope, + "home": str(home), + "hub": str(hub) if hub else None, + "workspaces": [str(root) for root in roots], + "projects": projects, + "skills": skills, + "topics": topics, + "artifacts": artifacts, + "secrets": { + "canonical_roots": [ + str(home / ".agents" / "credentials"), + ], + "files": secret_files, + "inline_bearers": inline_bearers, + "registry": credential_registry, + }, + "governance_docs": governance_docs, + "plugin_manifests": manifests, + "summary": summarize( + projects, + skills, + artifacts, + secret_files, + inline_bearers, + count_project_cards(hub), + ), + } + return registry + + +def mark_containers(projects: List[Dict[str, Any]]) -> None: + """A directory holding other scanned projects is a workspace/group, not a project. + + Group-level AGENTS.md files are instruction assets of the group; counting the + container as a project inflates the project count and turns one container's + partial `.agents/` into a permanent placement failure. + """ + roots = [Path(project["path"]) for project in projects] + for project in projects: + root = Path(project["path"]) + project["container"] = any(root in other.parents for other in roots if other != root) + + +def looks_like_project(root: Path) -> bool: + return ( + has_project_marker(root) + or (root / ".agents").is_dir() + or (root / "skills").is_dir() + ) + + +def has_project_marker(root: Path) -> bool: + return ( + (root / "AGENTS.md").is_file() + or (root / "CLAUDE.md").is_file() + or (root / "package.json").is_file() + ) + + +def scan_project( + root: Path, + group: str, + tracked: Optional[Set[str]], + now: str, +) -> Dict[str, Any]: + agent_files = find_agent_files(root, max_depth=3) + agents_dir = root / ".agents" + agents_entries: List[str] = [] + standard_slots: List[str] = [] + has_index = False + if agents_dir.is_dir(): + try: + agents_entries = sorted( + item.name for item in agents_dir.iterdir() if item.name != ".DS_Store" + ) + except OSError: + agents_entries = [] + standard_slots = [slot for slot in STANDARD_AGENTS_SLOTS if (agents_dir / slot).exists()] + has_index = agents_index_present(agents_dir) + + status = "unknown" + for agent_file in agent_files: + text = read_text(agent_file, limit=200_000) + match = re.search(r"\*\*状态\*\*\s*[::]\s*(.+)", text) + if match: + status = match.group(1).strip() + break + + project_id = unique_project_id(root.name, root) + return { + "id": project_id, + "path": str(root), + "group": group, + "git_root": str(root), + "agent_files": [relative(path, root) for path in agent_files], + "agents_dir": str(agents_dir) if agents_dir.is_dir() else None, + "agents_entries": agents_entries, + "standard_slots": standard_slots, + "has_index": has_index, + "skills": find_project_skills(root), + "tools": find_project_tools(root), + "status": status, + "last_scan": now, + "container": False, + "git_state": "ok" if tracked is not None else "error", + "tracked_count": len(tracked) if tracked is not None else None, + } + + +def agents_index_present(agents_dir: Path) -> bool: + """An index must have content; an empty placeholder indexes nothing.""" + for name in ("README.md", "AGENTS.md"): + path = agents_dir / name + try: + if path.is_file() and path.stat().st_size > 0: + return True + except OSError: + continue + return False + + +def unique_project_id(name: str, root: Path) -> str: + cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", name.strip()).strip("-") + return cleaned or sha256_text(str(root))[:10] + + +def find_agent_files(root: Path, max_depth: int = 3) -> List[Path]: + results: List[Path] = [] + for name in ("AGENTS.md", "CLAUDE.md"): + candidate = root / name + if candidate.is_file(): + results.append(candidate) + root_depth = len(root.resolve().parts) + for current, dirs, files in os.walk(root): + current_path = Path(current) + depth = len(current_path.resolve().parts) - root_depth + if depth >= max_depth: + dirs[:] = [] + dirs[:] = sorted(d for d in dirs if d not in EXCLUDE_DIRS and not d.startswith(".git")) + for name in ("AGENTS.md", "CLAUDE.md"): + candidate = current_path / name + if candidate.is_file() and candidate not in results: + results.append(candidate) + return sorted(results, key=lambda item: str(item)) + + +def find_project_skills(root: Path) -> List[str]: + roots = [root / "skills", root / ".agents" / "skills", root / ".claude" / "skills"] + found: List[str] = [] + for skill_root in roots: + if not skill_root.is_dir(): + continue + for child in sorted(skill_root.iterdir()): + if (child / "SKILL.md").is_file(): + found.append(str(child)) + return found + + +def find_project_tools(root: Path) -> List[str]: + tools_root = root / "tools" + if not tools_root.is_dir(): + return [] + found: List[str] = [] + for child in sorted(tools_root.iterdir()): + if child.is_dir() and ((child / "AGENTS.md").exists() or (child / "README.md").exists()): + found.append(str(child)) + return found + + +def load_index_texts(home: Path, hub: Optional[Path], projects: Sequence[Dict[str, Any]]) -> Dict[str, str]: + texts: Dict[str, str] = {} + if hub: + layout = resolve_hub_layout(hub) + texts["hub"] = read_text(layout["knowledge"] / "README.md", limit=2_000_000) + texts["tools"] = read_text(layout["tools"] / "README.md", limit=2_000_000) + texts["local_wiki"] = read_text(home / ".agents" / "wiki" / "README.md", limit=2_000_000) + for project in projects: + agents_dir = project.get("agents_dir") + if agents_dir: + texts[f"project:{project['id']}"] = read_text( + Path(agents_dir) / "README.md", limit=1_000_000 + ) + return texts + + +def artifact_record( + path: Path, + kind: str, + plane: str, + scope: str, + project_id: Optional[str], + indexed: Optional[bool], + tracked: Optional[Set[str]] = None, + base: Optional[Path] = None, +) -> Dict[str, Any]: + text = "" + if path.suffix.lower() in {".md", ".markdown", ".txt", ".json", ".yaml", ".yml", ".toml"}: + text = read_text(path, limit=1_000_000) + frontmatter, body = parse_frontmatter(text) + size = 0 + try: + size = path.stat().st_size + except OSError: + pass + tracked_flag: Optional[bool] = False + if tracked is None: + # Unknown is not "untracked": a failed git probe must stay visible. + tracked_flag = None + elif base is not None: + try: + tracked_flag = str(path.resolve().relative_to(base.resolve())) in tracked + except (OSError, ValueError): + tracked_flag = False + return { + "path": str(path), + "rel": relative(path, base) if base else str(path), + "kind": kind, + "plane": plane, + "scope": scope, + "project": project_id, + "size": size, + "mtime": path.stat().st_mtime if path.exists() else 0, + "hash": sha256_file(path) if size <= HASH_LIMIT else "", + "frontmatter": frontmatter, + "title": first_heading(body if text else "", path.stem), + "indexed": indexed, + "git_tracked": tracked_flag, + "verified_at": frontmatter.get("verified_at", ""), + "stale_after": frontmatter.get("stale_after", ""), + "status": frontmatter.get("status", ""), + } + + +def scan_project_agents( + projects: Sequence[Dict[str, Any]], + index_texts: Dict[str, str], + project_tracked: Dict[str, Optional[Set[str]]], +) -> List[Dict[str, Any]]: + records: List[Dict[str, Any]] = [] + for project in projects: + root = Path(project["path"]) + tracked = project_tracked.get(project["path"], set()) + for agent_rel in project.get("agent_files", []): + path = root / agent_rel + records.append( + artifact_record(path, "instruction", "instruction", "project", project["id"], None, tracked, root) + ) + agents_dir = project.get("agents_dir") + if not agents_dir: + continue + agents_path = Path(agents_dir) + index_text = index_texts.get(f"project:{project['id']}", "") + index_path = agents_path / "README.md" + if index_path.is_file() and not index_path.is_symlink(): + records.append( + artifact_record( + index_path, "instruction", "instruction", "project", project["id"], None, tracked, root + ) + ) + for slot, kind, plane in ( + ("plans", "plan", "state"), + ("wiki", "knowledge", "knowledge"), + ("evidence", "evidence", "evidence"), + ("archive", "archive", "evidence"), + ): + slot_root = agents_path / slot + if not slot_root.is_dir(): + continue + if slot_root.is_symlink(): + # A shared slot (e.g. a wiki linked from a sibling checkout) is + # scanned under the project that owns the real directory. + continue + for path in iter_files(slot_root, suffixes={".md", ".json", ".yaml", ".yml"}): + if "openspec" in path.parts[:-1]: + # OpenSpec store config and change metadata are state, not knowledge. + continue + if path.is_symlink(): + continue + indexed = path.name in index_text if kind == "knowledge" else None + records.append( + artifact_record(path, kind, plane, "project", project["id"], indexed, tracked, root) + ) + return records + + +def scan_hub_knowledge( + hub: Optional[Path], + index_texts: Dict[str, str], +) -> List[Dict[str, Any]]: + if not hub: + return [] + records: List[Dict[str, Any]] = [] + tracked = git_tracked(hub) + knowledge_index = index_texts.get("hub", "") + layout = resolve_hub_layout(hub) + knowledge_index_path = layout["knowledge"] / "README.md" + if knowledge_index_path.is_file(): + records.append( + artifact_record( + knowledge_index_path, "instruction", "instruction", "project", None, None, tracked, hub + ) + ) + default_suffixes = {".md", ".json", ".yaml", ".yml"} + groups = [ + (layout["knowledge"] / "general", "knowledge", "knowledge", "general", True, default_suffixes), + (layout["knowledge"] / "projects", "knowledge", "knowledge", "project", True, default_suffixes), + (layout["knowledge"] / "workgroups", "knowledge", "knowledge", "workgroup", True, default_suffixes), + (layout["projects"], "state", "state", "project", False, default_suffixes), + (hub / "handoff", "state", "state", "project", False, default_suffixes), + (hub / "provision", "state", "state", "project", False, default_suffixes), + (hub / "reports", "evidence", "evidence", "project", False, default_suffixes), + (hub / "Human Output", "evidence", "evidence", "project", False, default_suffixes), + (hub / "manuals", "state", "state", "project", False, default_suffixes), + (hub / "cron-tasks", "state", "state", "project", False, default_suffixes | {".plist"}), + (hub / "openspec", "plan", "state", "project", False, default_suffixes), + (hub / "archive", "archive", "archive", "project", False, default_suffixes), + (hub / ".agents" / "plans", "plan", "state", "project", False, default_suffixes), + ] + for root, kind, plane, scope, check_index, suffixes in groups: + if not root.is_dir(): + continue + for path in iter_files(root, suffixes=suffixes): + indexed = None + if check_index and kind == "knowledge": + indexed = path.name in knowledge_index + records.append( + artifact_record(path, kind, plane, scope, None, indexed, tracked, hub) + ) + strategy = hub / "strategy.md" + if strategy.is_file(): + records.append( + artifact_record( + strategy, "state", "state", "project", None, None, tracked, hub + ) + ) + for path in iter_files(layout["tools"], suffixes={".md"}, max_depth=3): + if path.name in {"AGENTS.md", "README.md"}: + records.append( + artifact_record(path, "tool", "capability", "project", None, None, tracked, hub) + ) + return records + + +def scan_local_wiki( + home: Path, + index_texts: Dict[str, str], +) -> List[Dict[str, Any]]: + root = home / ".agents" / "wiki" + if not root.is_dir(): + return [] + index_text = index_texts.get("local_wiki", "") + records: List[Dict[str, Any]] = [] + for path in iter_files(root, suffixes={".md"}, skip_dirs=EXCLUDE_DIRS | {"assets"}): + if path.is_symlink(): + # Compatibility links point at a canonical file already counted. + continue + records.append( + artifact_record( + path, + "knowledge", + "knowledge", + "local", + None, + path.name in index_text, + ) + ) + return records + + +def extra_skill_roots() -> List[Tuple[str, Path]]: + """Extra skill hosts from GUSTER_EXTRA_SKILL_ROOTS (name=path, os.pathsep separated).""" + roots: List[Tuple[str, Path]] = [] + for item in os.environ.get("GUSTER_EXTRA_SKILL_ROOTS", "").split(os.pathsep): + item = item.strip() + if not item: + continue + name, sep, raw = item.partition("=") + if not sep or not name.strip() or not raw.strip(): + continue + roots.append((name.strip(), Path(raw.strip()).expanduser())) + return roots + + +def scan_skills(home: Path) -> List[Dict[str, Any]]: + roots = [ + ("agents", home / ".agents" / "skills"), + ("codex", home / ".codex" / "skills"), + ("dsh", home / ".dsh" / "skills"), + ("claude", home / ".claude" / "skills"), + ] + extra_skill_roots() + raw: List[Dict[str, Any]] = [] + for host, root in roots: + if not root.is_dir(): + continue + for skill_md in sorted(root.glob("*/SKILL.md")): + text = read_text(skill_md, limit=500_000) + frontmatter, _ = parse_frontmatter(text) + name = frontmatter.get("name") or skill_md.parent.name + description = frontmatter.get("description", "") + raw.append( + { + "name": name, + "root": str(skill_md.parent), + "host": host, + "description": description, + "description_chars": len(description), + "hash": sha256_file(skill_md), + } + ) + + grouped: Dict[str, List[Dict[str, Any]]] = {} + for item in raw: + grouped.setdefault(item["name"], []).append(item) + + skills: List[Dict[str, Any]] = [] + for name in sorted(grouped): + copies = sorted(grouped[name], key=lambda item: item["root"]) + primary = copies[0] + skills.append( + { + "name": name, + "root": primary["root"], + "host": primary["host"], + "description": primary["description"], + "description_chars": len(primary["description"]), + "hash": primary["hash"], + "copies": [ + {"host": item["host"], "root": item["root"], "hash": item["hash"]} + for item in copies + ], + } + ) + return skills + + +def find_env_files(root: Path, max_depth: int = SECRET_ENV_DEPTH) -> List[Path]: + """`.env` files under a project, bounded by depth so the scan stays cheap.""" + found: List[Path] = [] + stack: List[Tuple[Path, int]] = [(root, 0)] + while stack: + current, depth = stack.pop() + try: + entries = list(current.iterdir()) + except OSError: + continue + for entry in entries: + if entry.is_file() and entry.name == ".env": + found.append(entry) + elif ( + entry.is_dir() + and depth < max_depth + and not entry.name.startswith(".") + and entry.name not in EXCLUDE_DIRS + and not entry.is_symlink() + ): + stack.append((entry, depth + 1)) + return sorted(found) + + +def scan_secret_files( + home: Path, + hub: Optional[Path], + projects: Sequence[Dict[str, Any]], + project_tracked: Dict[str, Optional[Set[str]]], + include_home: bool = True, +) -> List[Dict[str, Any]]: + candidates: List[Path] = [] + if include_home: + for root in (home / ".agents" / "credentials",): + if root.is_dir(): + candidates.extend( + path + for path in iter_files(root) + if path.is_file() and path.suffix.lower() != ".md" + ) + if hub: + tools_root = resolve_hub_layout(hub)["tools"] + if tools_root.is_dir(): + for path in iter_files(tools_root, max_depth=3): + if path.name in SECRET_SUFFIX_NAMES or path.name.startswith("credentials.") or path.name.endswith(".local.json"): + candidates.append(path) + for project in projects: + candidates.extend(find_env_files(Path(project["path"]))) + + seen: Set[str] = set() + records: List[Dict[str, Any]] = [] + for path in candidates: + try: + resolved = str(path.resolve()) + except OSError: + continue + if resolved in seen: + continue + seen.add(resolved) + canonical = is_under(path, home / ".agents" / "credentials") + tracked: Optional[bool] = None + for project in projects: + project_root = Path(project["path"]) + if not is_under(path, project_root): + continue + tracked_set = project_tracked.get(str(project_root)) + if tracked_set is None: + tracked = None + else: + try: + rel = str(path.resolve().relative_to(project_root.resolve())) + except ValueError: + rel = "" + tracked = rel in tracked_set + break + if tracked is None and not any(is_under(path, Path(p["path"])) for p in projects): + # Outside every scanned repository there is nothing that could track it. + tracked = False + records.append( + { + "path": str(path), + "canonical": canonical, + "git_tracked": tracked, + "size": path.stat().st_size if path.exists() else 0, + "kind": infer_secret_kind(path), + } + ) + return records + + +def scan_credential_registry(home: Path) -> Dict[str, Any]: + """Read the credential registry table; it never contains secret values.""" + path = home / ".agents" / "credentials" / "registry.md" + entries: List[Dict[str, str]] = [] + if path.is_file(): + for line in read_text(path, limit=1_000_000).splitlines(): + stripped = line.strip() + if not stripped.startswith("|"): + continue + cells = [cell.strip() for cell in stripped.strip("|").split("|")] + if len(cells) < 6: + continue + if cells[0] in {"ID", ""} or set(cells[0]) <= {"-", ":"}: + continue + entries.append( + { + "id": cells[0], + "scope": cells[1], + "site": cells[2], + "account": cells[3], + "kind": cells[4], + "locations": cells[5], + "keys": cells[6] if len(cells) > 6 else "", + "used_by": cells[7] if len(cells) > 7 else "", + "status": cells[8] if len(cells) > 8 else "", + } + ) + return {"path": str(path) if path.is_file() else None, "entries": entries} + + +def infer_secret_kind(path: Path) -> str: + name = path.name.lower() + if name.endswith(".env") or name.endswith(".env.example"): + return "env" + if "token" in name or "secret" in name or "credential" in name: + return "token" + if name.endswith((".json", ".yaml", ".yml", ".toml")): + return "config" + return "file" + + +def scan_inline_bearers(home: Path) -> List[Dict[str, Any]]: + records: List[Dict[str, Any]] = [] + for raw in INLINE_CONFIG_FILES: + path = home / raw[2:] if raw.startswith("~/") else Path(raw) + if not path.is_file(): + continue + text = read_text(path, limit=2_000_000) + values: List[str] = [] + for pattern in INLINE_PATTERNS: + for match in re.finditer(pattern, text): + value = match.group(1) + redacted = f"{value[:4]}...{value[-4:]}" + if redacted not in values: + values.append(redacted) + if values: + records.append({"path": str(path), "count": len(values), "redacted": values}) + return records + + +def find_governance_docs(home: Path, hub: Optional[Path]) -> List[str]: + # Framework docs, not topic authorities. A doc that labels itself a facet + # ("分册") belongs to the primary framework and does not compete with it. + named = { + "agent-context-map.md", + "codex-context-stack.md", + "context-engineering-principles.md", + "knowledge-taxonomy.md", + "project-agents-layout.md", + } + candidates: List[Path] = [] + if hub: + general = resolve_hub_layout(hub)["knowledge"] / "general" + if general.is_dir(): + candidates.extend(path for path in general.iterdir() if path.name in named) + candidates.extend( + path + for path in (home / ".agents" / "wiki").glob("*.md") + if path.name in named + ) + primary = [] + for path in candidates: + header = "\n".join(read_text(path, limit=4000).splitlines()[:5]) + if "分册" not in header: + primary.append(str(path)) + return sorted(primary) + + +def scan_plugin_manifests(plugin_root: Optional[Path]) -> Dict[str, Any]: + if plugin_root is None: + return {} + manifests: Dict[str, Any] = {} + for label, rel in ( + ("codex", ".codex-plugin/plugin.json"), + ("zcode", ".zcode-plugin/plugin.json"), + ("package", "package.json"), + ): + path = plugin_root / rel + data = load_json(path, {}) + manifests[label] = { + "path": str(path), + "exists": path.is_file(), + "version": data.get("version") if isinstance(data, dict) else None, + "name": data.get("name") if isinstance(data, dict) else None, + } + return manifests + + +PROJECT_CARD_NAMES = {"_registry.md", "readme.md", "agents.md"} + + +def count_project_cards(hub: Optional[Path]) -> int: + """Project cards known to the hub, not the project roots a run scanned.""" + if not hub: + return 0 + root = resolve_hub_layout(hub)["projects"] + if not root.is_dir(): + return 0 + return sum( + 1 + for path in iter_files(root, suffixes={".md"}, max_depth=1) + if path.parent == root and path.name.lower() not in PROJECT_CARD_NAMES + ) + + +def summarize( + projects: Sequence[Dict[str, Any]], + skills: Sequence[Dict[str, Any]], + artifacts: Sequence[Dict[str, Any]], + secret_files: Sequence[Dict[str, Any]], + inline_bearers: Sequence[Dict[str, Any]], + project_cards: int = 0, +) -> Dict[str, Any]: + return { + "projects": len(projects), + "containers": sum(1 for project in projects if project.get("container")), + "project_cards": project_cards, + "agents_dirs": sum(1 for project in projects if project.get("agents_dir")), + "agents_with_index": sum(1 for project in projects if project.get("has_index")), + "git_errors": sum(1 for project in projects if project.get("git_state") == "error"), + "skills": len(skills), + "skill_copies": sum(len(skill.get("copies", [])) for skill in skills), + "artifacts": len(artifacts), + "knowledge": sum(1 for item in artifacts if item.get("kind") == "knowledge"), + "state": sum(1 for item in artifacts if item.get("kind") == "state"), + "evidence": sum(1 for item in artifacts if item.get("kind") == "evidence"), + "secret_files": len(secret_files), + "secret_outside_root": sum(1 for item in secret_files if not item.get("canonical")), + "secret_in_git": sum(1 for item in secret_files if item.get("git_tracked")), + "inline_bearer_files": len(inline_bearers), + "inline_bearers": sum(item.get("count", 0) for item in inline_bearers), + } diff --git a/external_plugins/guster/src/guster/executor.py b/external_plugins/guster/src/guster/executor.py new file mode 100644 index 00000000..558f1f69 --- /dev/null +++ b/external_plugins/guster/src/guster/executor.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from .discover import default_hub, default_roots +from .util import now_iso, write_text + + +class ApplyError(RuntimeError): + pass + + +def preflight(plan: Dict[str, Any]) -> List[str]: + errors: List[str] = [] + operations = plan.get("operations", []) + if not isinstance(operations, list): + return ["plan operations must be a list"] + for index, operation in enumerate(operations, start=1): + if not isinstance(operation, dict): + errors.append(f"op {index}: operation is not a JSON object") + continue + kind = operation.get("kind") + if not isinstance(kind, str) or not kind: + errors.append(f"op {index}: missing operation kind") + continue + if kind == "move": + src_value = operation.get("src") + dst_value = operation.get("dst") + missing = [ + key + for key, value in (("src", src_value), ("dst", dst_value)) + if not isinstance(value, str) or not value + ] + if missing: + errors.append(f"op {index}: move needs string field(s): {', '.join(missing)}") + continue + src = Path(src_value) + dst = Path(dst_value) + if not src.exists(): + errors.append(f"op {index}: source does not exist: {src}") + if dst.exists(): + errors.append(f"op {index}: destination already exists: {dst}") + if src == dst: + errors.append(f"op {index}: source and destination are identical") + elif kind in {"write", "mkdir"}: + value = operation.get("path") + if not isinstance(value, str) or not value: + errors.append(f"op {index}: {kind} needs a string 'path'") + continue + path = Path(value) + if kind == "mkdir": + if path.exists() and not path.is_dir(): + errors.append(f"op {index}: target exists and is not a directory: {path}") + elif path.exists() and not path.is_file(): + errors.append(f"op {index}: target is not a file: {path}") + elif kind != "manual": + errors.append(f"op {index}: unknown operation kind: {kind}") + return errors + + +def apply_plan( + plan: Dict[str, Any], + state_dir: Path, + dry_run: bool = True, + yes: bool = False, +) -> Dict[str, Any]: + if not dry_run and not yes: + raise ApplyError("refusing to write without --yes") + errors = preflight(plan) + if errors: + return { + "ok": False, + "dry_run": dry_run, + "plan": plan.get("id"), + "errors": errors, + "applied": 0, + "skipped": 0, + } + + plan_id = plan.get("id") or "plan" + backup_dir = state_dir / "backups" / plan_id + undo: List[Tuple[str, Dict[str, Any]]] = [] + applied = 0 + skipped = 0 + + if dry_run: + for operation in plan.get("operations", []): + if operation.get("kind") == "manual": + skipped += 1 + else: + applied += 1 + return { + "ok": True, + "dry_run": True, + "plan": plan_id, + "errors": [], + "applied": applied, + "skipped": skipped, + "operations": plan.get("operations", []), + } + + backup_dir.mkdir(parents=True, exist_ok=True) + try: + for index, operation in enumerate(plan.get("operations", []), start=1): + kind = operation.get("kind") + if kind == "manual": + skipped += 1 + continue + if kind == "mkdir": + path = Path(operation["path"]) + path.mkdir(parents=True, exist_ok=True) + applied += 1 + elif kind == "move": + src = Path(operation["src"]) + dst = Path(operation["dst"]) + backup = backup_dir / f"{index:03d}-{src.name}" + if src.is_dir(): + shutil.copytree(src, backup) + else: + shutil.copy2(src, backup) + undo.append(("move", {"src": str(src), "dst": str(dst), "backup": str(backup)})) + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(src), str(dst)) + pointer = operation.get("pointer") + if pointer and pointer.get("path"): + pointer_path = Path(pointer["path"]) + write_text(pointer_path, pointer.get("content", "")) + applied += 1 + elif kind == "write": + path = Path(operation["path"]) + if path.exists(): + backup = backup_dir / f"{index:03d}-{path.name}" + shutil.copy2(path, backup) + undo.append(("write", {"path": str(path), "backup": str(backup)})) + write_text(path, operation.get("content", "")) + applied += 1 + else: + raise ApplyError(f"unsupported operation: {kind}") + except Exception as exc: + rollback(undo) + raise ApplyError(f"apply failed and was rolled back: {exc}") from exc + + return { + "ok": True, + "dry_run": False, + "plan": plan_id, + "errors": [], + "applied": applied, + "skipped": skipped, + "backup": str(backup_dir), + "finished_at": now_iso(), + } + + +def rollback(undo: List[Tuple[str, Dict[str, Any]]]) -> None: + for kind, payload in reversed(undo): + if kind == "move": + src = Path(payload["src"]) + dst = Path(payload["dst"]) + backup = Path(payload["backup"]) + if dst.exists(): + if dst.is_dir(): + shutil.rmtree(dst) + else: + dst.unlink() + if backup.is_dir(): + shutil.copytree(backup, src, dirs_exist_ok=True) + elif backup.exists(): + src.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(backup, src) + elif kind == "write": + path = Path(payload["path"]) + backup = Path(payload["backup"]) + if backup.exists(): + shutil.copy2(backup, path) + elif path.exists(): + path.unlink() + + +def init_home( + state_dir: Path, + yes: bool = False, + dry_run: bool = True, + home: Optional[Path] = None, +) -> Dict[str, Any]: + operations = [ + {"kind": "mkdir", "path": str(state_dir)}, + {"kind": "mkdir", "path": str(state_dir / "plans")}, + {"kind": "mkdir", "path": str(state_dir / "backups")}, + ] + config_path = state_dir / "config.json" + if not config_path.exists(): + resolved_home = (home or Path.home()).expanduser().resolve() + config: Dict[str, Any] = { + "version": 1, + "roots": [str(root) for root in default_roots(resolved_home)], + "scan_max_depth": 5, + } + hub = default_hub(resolved_home) + if hub is not None: + config["hub"] = str(hub) + operations.append( + { + "kind": "write", + "path": str(config_path), + "content": json.dumps( + config, + ensure_ascii=False, + indent=2, + ) + + "\n", + } + ) + plan = { + "version": 1, + "id": f"init-home-{now_iso().replace(':', '').replace('-', '')}", + "created_at": now_iso(), + "title": "Initialize Guster state home", + "notes": "Creates the local state directory and default config.", + "operations": operations, + } + return apply_plan(plan, state_dir, dry_run=dry_run, yes=yes) diff --git a/external_plugins/guster/src/guster/hub.py b/external_plugins/guster/src/guster/hub.py new file mode 100644 index 00000000..a26c8075 --- /dev/null +++ b/external_plugins/guster/src/guster/hub.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any, Dict, List, Optional + +from . import __version__ +from .executor import apply_plan +from .layout import DEFAULT_PATHS, load_manifest, resolve_hub_layout, validate_layout_value +from .planner import build_plan, op_mkdir, op_write, save_plan +from .util import load_json, now_iso, read_text, save_json, today_iso + + +def default_hub_path(home: Path) -> Path: + return home / "IdeaProjects" / "Guster" + + +def template_root() -> Path: + root = Path(__file__).resolve().parents[2] / "templates" / "hub" + if not root.is_dir(): + raise FileNotFoundError(f"lite hub template not found: {root}") + return root + + +def _version_tuple(value: str) -> tuple: + parts = [int(item) for item in re.findall(r"\d+", str(value))[:3]] + return tuple(parts + [0] * (3 - len(parts))) + + +def _render(text: str, replacements: Dict[str, str]) -> str: + for key, value in replacements.items(): + text = text.replace("{{" + key + "}}", value) + if "{{" in text: + raise ValueError("hub template still contains an unresolved placeholder") + return text + + +def _relative(path: Path, root: Path) -> str: + try: + return str(path.relative_to(root)) + except ValueError: + return str(path) + + +def plan_init_hub( + target: Path, + name: Optional[str] = None, + knowledge: str = DEFAULT_PATHS["knowledge"], + projects: str = DEFAULT_PATHS["projects"], + tools: str = DEFAULT_PATHS["tools"], +) -> Dict[str, Any]: + root = template_root() + hub = target.expanduser().resolve() + path_values = { + "knowledge": validate_layout_value(knowledge, "knowledge"), + "projects": validate_layout_value(projects, "projects"), + "tools": validate_layout_value(tools, "tools"), + } + layout = {key: (hub / value).resolve() for key, value in path_values.items()} + if len(set(layout.values())) != len(layout): + raise ValueError("hub paths must point to distinct directories") + replacements = { + "DATE": today_iso(), + "HUB_NAME": name or hub.name, + "PLUGIN_VERSION": __version__, + "KNOWLEDGE_PATH": path_values["knowledge"], + "PROJECTS_PATH": path_values["projects"], + "TOOLS_PATH": path_values["tools"], + } + operations: List[Dict[str, Any]] = [op_mkdir(hub)] + for path in layout.values(): + operations.append(op_mkdir(path)) + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(root) + if rel.parts[0] in layout: + output = layout[rel.parts[0]].joinpath(*rel.parts[1:]) + else: + output = hub / rel + if path.name == "gitignore.tmpl": + output = hub / rel.parent / ".gitignore" + elif path.suffix == ".tmpl": + output = output.with_suffix("") + content = ( + "" + if path.name == ".gitkeep" + else _render(path.read_text(encoding="utf-8"), replacements) + ) + operations.append(op_write(output, content)) + return build_plan( + f"Initialize Guster Hub {hub.name}", + operations, + notes=( + "Creates the lite data plane: knowledge, project state, tool index " + "and the hub contract." + ), + ) + + +def validate_hub(target: Path, strict: bool = True) -> Dict[str, Any]: + hub = target.expanduser().resolve() + errors: List[str] = [] + warnings: List[str] = [] + if not hub.is_dir(): + return { + "ok": False, + "hub": str(hub), + "manifest": {}, + "errors": [f"hub directory does not exist: {hub}"], + "warnings": [], + "plugin": __version__, + } + + manifest = load_manifest(hub / "hub.yaml") + try: + layout = resolve_hub_layout(hub) + except ValueError as exc: + layout = {key: (hub / value).resolve() for key, value in DEFAULT_PATHS.items()} + errors.append(f"invalid hub layout: {exc}") + + legacy = ( + not manifest + and (hub / "AGENTS.md").is_file() + and (hub / "knowledge").is_dir() + and (hub / "projects").is_dir() + ) + required_files = [ + hub / "AGENTS.md", + hub / "README.md", + hub / "hub.yaml", + layout["knowledge"] / "README.md", + layout["projects"] / "_registry.md", + layout["tools"] / "README.md", + ] + required_dirs = [ + layout["knowledge"], + layout["knowledge"] / "general", + layout["knowledge"] / "projects", + layout["projects"], + layout["tools"], + ] + for path in required_files: + if path.is_file(): + continue + rel = _relative(path, hub) + if legacy and not strict: + warnings.append(f"legacy hub is missing the lite contract file: {rel}") + else: + errors.append(f"missing required file: {rel}") + for path in required_dirs: + if path.is_dir(): + continue + rel = _relative(path, hub) + if legacy and not strict: + warnings.append(f"legacy hub is missing the lite directory: {rel}") + else: + errors.append(f"missing required directory: {rel}") + + if not manifest: + message = "hub.yaml is missing or empty" + if legacy and not strict: + warnings.append(message + " (legacy hub)") + elif (hub / "hub.yaml").is_file(): + errors.append(message) + else: + if manifest.get("schema_version") != "1": + errors.append(f"unsupported hub schema: {manifest.get('schema_version') or '(missing)'}") + if manifest.get("profile") not in {"lite", "full"}: + warnings.append(f"unexpected hub profile: {manifest.get('profile') or '(missing)'}") + minimum = manifest.get("min_guster") + if minimum and _version_tuple(minimum) > _version_tuple(__version__): + errors.append(f"hub requires Guster >= {minimum}; installed CLI is {__version__}") + + agents = hub / "AGENTS.md" + if agents.is_file() and len(read_text(agents).splitlines()) > 50: + warnings.append("AGENTS.md exceeds the 50-line soft budget") + + if strict: + index_path = layout["knowledge"] / "README.md" + if index_path.is_file(): + index = read_text(index_path) + for folder in ("general", "projects"): + root = layout["knowledge"] / folder + if not root.is_dir(): + continue + for note in sorted(root.rglob("*.md")): + if note.name.lower() == "readme.md": + continue + if note.name not in index: + errors.append(f"knowledge note is not indexed: {_relative(note, hub)}") + + registry_path = layout["projects"] / "_registry.md" + if registry_path.is_file(): + registry = read_text(registry_path) + if not re.search( + r"(?:最后更新|更新时间|更新于|最近核实|最后核实|已核实|verified_at)" + r"\s*\*{0,2}\s*[::]\s*\*{0,2}\s*\d{4}[-/]\d{1,2}[-/]\d{1,2}", + registry, + ): + errors.append(f"{_relative(registry_path, hub)} has no verified date") + + return { + "ok": not errors, + "hub": str(hub), + "manifest": manifest, + "paths": {key: _relative(value, hub) for key, value in layout.items()}, + "profile": manifest.get("profile") or ("legacy" if legacy else ""), + "errors": errors, + "warnings": warnings, + "plugin": __version__, + } + + +def attach_hub(state_dir: Path, target: Path) -> Dict[str, Any]: + info = validate_hub(target, strict=False) + if not info["ok"]: + raise ValueError("hub validation failed: " + "; ".join(info["errors"])) + hub = target.expanduser().resolve() + state_dir.mkdir(parents=True, exist_ok=True) + config_path = state_dir / "config.json" + config = load_json(config_path, {}) + if not isinstance(config, dict): + config = {} + backup: Optional[Path] = None + if config_path.is_file(): + backup_dir = state_dir / "backups" + backup_dir.mkdir(parents=True, exist_ok=True) + stamp = re.sub(r"[^0-9A-Za-z]", "", now_iso()) + backup = backup_dir / f"hub-config-{stamp}.json" + backup.write_bytes(config_path.read_bytes()) + config.setdefault("version", 1) + config["hub"] = str(hub) + save_json(config_path, config) + return { + "ok": True, + "hub": str(hub), + "config": str(config_path), + "backup": str(backup) if backup else None, + } + + +def init_hub( + target: Path, + state_dir: Path, + yes: bool = False, + dry_run: bool = True, + name: Optional[str] = None, + knowledge: str = DEFAULT_PATHS["knowledge"], + projects: str = DEFAULT_PATHS["projects"], + tools: str = DEFAULT_PATHS["tools"], +) -> Dict[str, Any]: + hub = target.expanduser().resolve() + if hub.exists(): + if not hub.is_dir(): + raise ValueError(f"hub target is not a directory: {hub}") + if any(item.name != ".DS_Store" for item in hub.iterdir()): + raise ValueError(f"hub target is not empty: {hub}") + path_values = { + "knowledge": validate_layout_value(knowledge, "knowledge"), + "projects": validate_layout_value(projects, "projects"), + "tools": validate_layout_value(tools, "tools"), + } + plan = plan_init_hub( + hub, + name=name, + knowledge=path_values["knowledge"], + projects=path_values["projects"], + tools=path_values["tools"], + ) + plan_path = save_plan(state_dir, plan) + result = apply_plan(plan, state_dir, dry_run=dry_run, yes=yes) + result["hub"] = str(hub) + result["plan"] = str(plan_path) + result["paths"] = path_values + if result.get("ok") and not dry_run: + result["attach"] = attach_hub(state_dir, hub) + return result diff --git a/external_plugins/guster/src/guster/layout.py b/external_plugins/guster/src/guster/layout.py new file mode 100644 index 00000000..470f6fa4 --- /dev/null +++ b/external_plugins/guster/src/guster/layout.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from .util import read_text + + +DEFAULT_PATHS = { + "knowledge": "knowledge", + "projects": "projects", + "tools": "tools", +} + + +def load_manifest(path: Path) -> Dict[str, Any]: + """Parse the small flat YAML subset used by hub.yaml, including one paths map.""" + data: Dict[str, Any] = {} + current: Optional[str] = None + for raw_line in read_text(path).splitlines(): + if not raw_line.strip() or raw_line.lstrip().startswith("#"): + continue + indent = len(raw_line) - len(raw_line.lstrip(" ")) + line = raw_line.strip() + if ":" not in line: + continue + key, value = line.split(":", 1) + key = key.strip() + value = value.strip().strip("'\"") + if indent == 0: + current = key if key == "paths" and not value else None + if current: + data.setdefault("paths", {}) + else: + data[key] = value + elif current == "paths": + data.setdefault("paths", {})[key] = value + return data + + +def validate_layout_value(value: str, key: str) -> str: + raw = str(value).strip() + if not raw: + raise ValueError(f"hub path '{key}' is empty") + if raw.startswith("~"): + raise ValueError(f"hub path '{key}' must be relative to the hub") + path = Path(raw) + if path.is_absolute(): + raise ValueError(f"hub path '{key}' must be relative to the hub") + if ".." in path.parts: + raise ValueError(f"hub path '{key}' must not contain '..'") + if raw in {".", "./"}: + raise ValueError(f"hub path '{key}' must not be the hub root") + return str(path) + + +def resolve_hub_layout(hub: Path) -> Dict[str, Path]: + root = hub.expanduser().resolve() + manifest = load_manifest(root / "hub.yaml") + raw_paths = manifest.get("paths") + paths = raw_paths if isinstance(raw_paths, dict) else {} + resolved: Dict[str, Path] = {} + for key, default in DEFAULT_PATHS.items(): + value = validate_layout_value(str(paths.get(key, default)), key) + candidate = (root / value).resolve() + if candidate == root or root not in candidate.parents: + raise ValueError(f"hub path '{key}' escapes the hub: {value}") + resolved[key] = candidate + if len(set(resolved.values())) != len(resolved): + raise ValueError("hub paths must point to distinct directories") + return resolved + + +def load_waivers(hub: Optional[Path]) -> List[Dict[str, str]]: + """Read `doctor.waivers` from `/hub.yaml`. + + Waivers are the only way to silence a finding, so each entry must carry a + rule and a written reason; a broken block fails loud instead of guessing. + """ + if hub is None: + return [] + manifest = Path(hub) / "hub.yaml" + if not manifest.is_file(): + return [] + waivers: List[Dict[str, str]] = [] + current: Optional[Dict[str, str]] = None + in_block = False + for raw_line in read_text(manifest).splitlines(): + if not raw_line.strip() or raw_line.lstrip().startswith("#"): + continue + indent = len(raw_line) - len(raw_line.lstrip(" ")) + line = raw_line.strip() + if line.endswith("waivers:"): + in_block = True + current = None + continue + if not in_block: + continue + if indent == 0: + break + if line.startswith("- "): + current = {} + waivers.append(current) + line = line[2:].strip() + if current is None or ":" not in line: + continue + key, value = line.split(":", 1) + current[key.strip()] = value.strip().strip("'\"") + for entry in waivers: + rule = entry.get("rule", "").strip() + reason = entry.get("reason", "").strip() + if not rule or not reason: + raise ValueError(f"doctor waiver needs rule and reason in {manifest}") + entry["path"] = entry.get("path", "").strip() + return [ + {"rule": entry["rule"].strip(), "path": entry["path"], "reason": entry["reason"].strip()} + for entry in waivers + ] diff --git a/external_plugins/guster/src/guster/metrics.py b/external_plugins/guster/src/guster/metrics.py new file mode 100644 index 00000000..82e632ea --- /dev/null +++ b/external_plugins/guster/src/guster/metrics.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Sequence + +from .util import now_iso + + +CHI_WEIGHTS = { + "ssr": 0.20, + "rbr": 0.15, + "pcr": 0.15, + "icr": 0.15, + "frs": 0.15, + "phr": 0.10, + "sir": 0.10, +} +SCOPE_DIMS = { + "machine": ["ssr", "rbr", "pcr", "icr", "frs", "phr", "sir"], + "project": ["ssr", "pcr", "icr", "frs", "phr"], + "group": ["ssr", "pcr", "icr", "frs", "phr"], + "hub": ["ssr", "icr", "frs", "phr"], +} +RBR_BUDGET_BYTES = 20_000 +RBR_BUDGET_SKILL_CHARS = 6_000 +GCR_TARGET = 0.5 + + +def dimensions_for_scope(scope: str) -> List[str]: + """Dimensions that enter CHI for a scope; the report must show exactly these.""" + return list(SCOPE_DIMS.get(scope) or SCOPE_DIMS["machine"]) + + +def safe_ratio(numerator: float, denominator: float) -> Optional[float]: + """A ratio, or None when the denominator is empty. + + An empty denominator means the run has no evidence, not a perfect score; a + default of 1.0 would let an unmeasured scope outrank a measured one. + """ + if denominator <= 0: + return None + return max(0.0, min(1.0, numerator / denominator)) + + +def mean_available(values: Sequence[Optional[float]]) -> Optional[float]: + present = [value for value in values if value is not None] + if not present: + return None + return sum(present) / len(present) + + +def percent(value: Optional[float]) -> Optional[float]: + return None if value is None else round(value * 100, 1) + + +def compute_metrics(registry: Dict[str, Any], policy_result: Dict[str, Any]) -> Dict[str, Any]: + stats = policy_result.get("stats", {}) + governance_count = int(stats.get("governance_docs", 0)) + + topic_data = registry.get("topics") or {} + topic_total = int(topic_data.get("total", 0)) + topic_multi = int(topic_data.get("multi", 0)) + ssr = safe_ratio(topic_total - topic_multi, topic_total) + + resident_bytes = max(0, int(stats.get("resident_bytes", 0))) + skill_chars = max(0, int(stats.get("skill_chars", 0))) + resident_ratio = resident_bytes / RBR_BUDGET_BYTES if resident_bytes > 0 else None + skill_ratio = skill_chars / RBR_BUDGET_SKILL_CHARS if skill_chars > 0 else None + resident_score = None if resident_ratio is None else min(1.0, 1.0 / resident_ratio) + skill_score = None if skill_ratio is None else min(1.0, 1.0 / skill_ratio) + rbr = mean_available([resident_ratio, skill_ratio]) + rbr_score = mean_available([resident_score, skill_score]) + + pcr = safe_ratio( + int(stats.get("agents_dirs_compliant", 0)), + int(stats.get("agents_dirs_total", 0)), + ) + knowledge_icr = safe_ratio( + int(stats.get("knowledge_indexed", 0)), + int(stats.get("knowledge_total", 0)), + ) + agents_icr = safe_ratio( + int(stats.get("agents_indexed", 0)), + int(stats.get("agents_dirs_total", 0)), + ) + icr = mean_available([knowledge_icr, agents_icr]) + frs = safe_ratio( + int(stats.get("state_docs_fresh", 0)), + int(stats.get("state_docs_total", 0)), + ) + phr = safe_ratio( + int(stats.get("pointers_valid", 0)), + int(stats.get("pointers_checked", 0)), + ) + inline_bearers = int(stats.get("inline_bearers", 0)) + undeclared = int( + stats.get("secret_undeclared_outside", stats.get("secret_outside_root", 0)) + ) + secret_bad = ( + undeclared + + inline_bearers + + int(stats.get("secret_in_git", 0)) + + int(stats.get("secret_tracking_unknown", 0)) + ) + secret_total = int(stats.get("secret_total", 0)) + inline_bearers + sir = None if secret_total <= 0 else 1.0 - min(1.0, secret_bad / secret_total) + gcr = governance_count / 8.0 if governance_count else 0.0 + gcr_score = 1.0 if gcr == 0 else min(1.0, GCR_TARGET / max(gcr, 0.01)) + + dim_values: Dict[str, Optional[float]] = { + "ssr": ssr, + "rbr": rbr_score, + "pcr": pcr, + "icr": icr, + "frs": frs, + "phr": phr, + "sir": sir, + } + raw_values: Dict[str, Optional[float]] = { + "ssr": ssr, + "rbr": rbr, + "pcr": pcr, + "icr": icr, + "frs": frs, + "phr": phr, + "sir": sir, + } + scope = registry.get("scope", "machine") + scope_dims = dimensions_for_scope(scope) + scored_dims = [dim for dim in scope_dims if dim_values[dim] is not None] + if scored_dims: + total_weight = sum(CHI_WEIGHTS[dim] for dim in scored_dims) + chi = sum(CHI_WEIGHTS[dim] * dim_values[dim] for dim in scored_dims) / total_weight + else: + chi = None + + metrics: Dict[str, Optional[float]] = { + dim: (None if value is None else round(value, 4)) for dim, value in raw_values.items() + } + metrics["gcr"] = round(gcr, 4) + scores: Dict[str, Optional[float]] = {dim: percent(value) for dim, value in dim_values.items()} + scores["gcr"] = round(gcr_score * 100, 1) + + return { + "generated_at": now_iso(), + "metrics": metrics, + "scores": scores, + "chi": None if chi is None else round(chi * 100, 1), + "scope": scope, + "dims": scope_dims, + "scored_dims": scored_dims, + "unscored_dims": [dim for dim in scope_dims if dim not in scored_dims], + "raw": { + "resident_bytes": resident_bytes, + "resident_lines": int(stats.get("resident_lines", 0)), + "resident_entries": int(stats.get("resident_entries", 0)), + "skill_chars": skill_chars, + "topics_total": topic_total, + "topics_multi": topic_multi, + "knowledge_unhashed": int(topic_data.get("unhashed", 0)), + "agents_dirs_total": int(stats.get("agents_dirs_total", 0)), + "agents_dirs_compliant": int(stats.get("agents_dirs_compliant", 0)), + "agents_dirs_missing_index": int(stats.get("agents_dirs_missing_index", 0)), + "agents_dirs_missing_slots": int(stats.get("agents_dirs_missing_slots", 0)), + "agents_dirs_extra_entries": int(stats.get("agents_dirs_extra_entries", 0)), + "agents_indexed": int(stats.get("agents_indexed", 0)), + "knowledge_total": int(stats.get("knowledge_total", 0)), + "knowledge_indexed": int(stats.get("knowledge_indexed", 0)), + "state_docs_total": int(stats.get("state_docs_total", 0)), + "state_docs_fresh": int(stats.get("state_docs_fresh", 0)), + "state_docs_fresh_declared": int(stats.get("state_docs_fresh_declared", 0)), + "state_docs_fresh_from_git": int(stats.get("state_docs_fresh_from_git", 0)), + "pointers_checked": int(stats.get("pointers_checked", 0)), + "pointers_valid": int(stats.get("pointers_valid", 0)), + "broken_pointers": int(stats.get("broken_pointers", 0)), + "naked_pointers": int(stats.get("naked_pointers", 0)), + "pointer_files_total": int(stats.get("pointer_files_total", 0)), + "pointer_files_scanned": int(stats.get("pointer_files_scanned", 0)), + "secret_total": int(stats.get("secret_total", 0)), + "secret_outside_root": int(stats.get("secret_outside_root", 0)), + "secret_undeclared_outside": undeclared, + "secret_tracking_unknown": int(stats.get("secret_tracking_unknown", 0)), + "secret_in_git": int(stats.get("secret_in_git", 0)), + "inline_bearers": inline_bearers, + "git_probe_failures": int(stats.get("git_probe_failures", 0)), + "governance_docs": governance_count, + }, + } + + +def metric_line(result: Dict[str, Any]) -> str: + chi = result.get("chi") + parts = [f"CHI {'n/a' if chi is None else chi} ({result.get('scope')})"] + scores = result.get("scores") or {} + for dim in result.get("dims") or []: + value = scores.get(dim) + parts.append(f"{dim.upper()} {'n/a' if value is None else value}") + return " | ".join(parts) diff --git a/external_plugins/guster/src/guster/planner.py b/external_plugins/guster/src/guster/planner.py new file mode 100644 index 00000000..a8e2ed16 --- /dev/null +++ b/external_plugins/guster/src/guster/planner.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence + +from .util import now_iso, save_json, slugify + + +def new_plan_id(title: str) -> str: + stamp = now_iso().replace(":", "").replace("-", "").replace("+", "p") + return f"{stamp}-{slugify(title)[:40]}" + + +def build_plan(title: str, operations: Sequence[Dict[str, Any]], notes: Optional[str] = None) -> Dict[str, Any]: + plan_id = new_plan_id(title) + return { + "version": 1, + "id": plan_id, + "created_at": now_iso(), + "title": title, + "notes": notes or "", + "operations": list(operations), + } + + +def op_mkdir(path: Path) -> Dict[str, Any]: + return {"kind": "mkdir", "path": str(path)} + + +def op_move( + src: Path, + dst: Path, + pointer: Optional[Dict[str, str]] = None, +) -> Dict[str, Any]: + return { + "kind": "move", + "src": str(src), + "dst": str(dst), + "pointer": pointer or None, + } + + +def op_write(path: Path, content: str) -> Dict[str, Any]: + return {"kind": "write", "path": str(path), "content": content} + + +def op_manual(path: str, action: str, reason: str) -> Dict[str, Any]: + return {"kind": "manual", "path": path, "action": action, "reason": reason} + + +def plan_move(src: Path, dst: Path) -> Dict[str, Any]: + if not src.exists(): + raise FileNotFoundError(f"source does not exist: {src}") + if dst.exists(): + raise FileExistsError(f"destination already exists: {dst}") + operations = [ + op_mkdir(dst.parent), + op_move( + src, + dst, + pointer={ + "path": str(src.parent / f"{src.name}.moved.md"), + "content": f"# Moved\n\nThis path moved to `{dst}`.\n", + }, + ), + ] + return build_plan( + f"Move {src.name} to {dst.name}", + operations, + notes="Dry-run by default. Apply creates a backup and keeps a pointer at the old location.", + ) + + +def plan_init_project(project_root: Path) -> Dict[str, Any]: + root = project_root.resolve() + agents = root / ".agents" + operations: List[Dict[str, Any]] = [] + for slot in ("plans", "wiki", "evidence", "skills", "tmp", "archive"): + operations.append(op_mkdir(agents / slot)) + readme = agents / "README.md" + if not readme.exists(): + operations.append( + op_write( + readme, + "# Project Agent Context\n\n" + "| Area | Path | Purpose |\n" + "|---|---|---|\n" + "| Plans | `plans/` | Active plans, PRDs and checklists |\n" + "| Wiki | `wiki/` | Stable project knowledge |\n" + "| Evidence | `evidence/` | Screenshots, videos, test output, acceptance proof |\n" + "| Skills | `skills/` | Project-local skills |\n" + "| Tmp | `tmp/` | Scratch, 14-day TTL |\n" + "| Archive | `archive/` | Superseded plans and evidence |\n", + ) + ) + agents_md = root / "AGENTS.md" + if not agents_md.exists(): + operations.append( + op_write( + agents_md, + "# Project Instructions\n\n" + "## Scope\n\n" + "This project uses Guster's standard `.agents/` layout. " + "Put active plans in `.agents/plans/`, durable project knowledge in `.agents/wiki/`, " + "evidence in `.agents/evidence/`, and scratch files in `.agents/tmp/`.\n\n" + "## Boundaries\n\n" + "- Code and tests are the source of truth for behavior.\n" + "- Do not put credentials in this repository.\n" + "- Do not commit `.agents/` unless the repository explicitly chooses to share it.\n\n" + "## Context format\n\n" + "File paths in this file and `.agents/README.md` are markdown links or inline code, " + "never naked in prose, so `guster doctor` can verify them (CTX-03).\n", + ) + ) + return build_plan( + f"Initialize {root.name}", + operations, + notes="Creates the standard .agents layout, README index and a minimal AGENTS.md if absent.", + ) + + +def plan_from_findings(findings: Sequence[Dict[str, Any]]) -> Dict[str, Any]: + operations: List[Dict[str, Any]] = [] + for item in findings: + if item.get("severity") in {"critical", "high", "medium"}: + if item.get("rule") == "misplaced-artifact": + suggested = (item.get("evidence") or {}).get("suggested") or {} + for entry, slot in suggested.items(): + operations.append( + op_manual( + f"{item.get('path', '')}/.agents/{entry}", + f"Move under .agents/{slot}/ after updating any references", + f"misplaced-artifact: {entry} does not match a standard slot", + ) + ) + continue + operations.append( + op_manual( + item.get("path", ""), + item.get("suggestion", ""), + f"{item.get('rule')}: {item.get('message')}", + ) + ) + return build_plan( + "Review context findings", + operations, + notes="This plan is a review checklist. It contains no automatic writes.", + ) + + +def save_plan(state_dir: Path, plan: Dict[str, Any]) -> Path: + path = state_dir / "plans" / f"{plan['id']}.json" + save_json(path, plan) + return path + + +def load_plan(path: Path) -> Dict[str, Any]: + try: + raw = path.read_text(encoding="utf-8") + except OSError as exc: + raise ValueError(f"cannot read plan {path}: {exc}") from exc + try: + data = json.loads(raw) + except ValueError as exc: + raise ValueError(f"invalid plan JSON {path}: {exc}") from exc + if not isinstance(data, dict): + raise ValueError(f"plan must be a JSON object: {path}") + if data.get("version") != 1: + raise ValueError(f"unsupported plan version: {data.get('version')}") + if not isinstance(data.get("operations"), list): + raise ValueError("plan has no operations") + return data diff --git a/external_plugins/guster/src/guster/policy.py b/external_plugins/guster/src/guster/policy.py new file mode 100644 index 00000000..a19059e7 --- /dev/null +++ b/external_plugins/guster/src/guster/policy.py @@ -0,0 +1,850 @@ +from __future__ import annotations + +import glob +import os +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple + +from .discover import ScanConfig, STANDARD_AGENTS_SLOTS, in_scope_path, scope_roots +from .layout import load_waivers, resolve_hub_layout +from .util import ( + NOTE_DATE_KEYS, + age_days, + extract_naked_pointers, + extract_pointers, + git_last_commit_dates, + note_header_date, + parse_date, + parse_note_header, + read_text, + relative, + resolve_pointer, +) + + +SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} +COMMON_KNOWLEDGE_NAMES = {"readme.md", "agents.md", "index.md", "skill.md"} +MEDIA_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".mp4", ".mov", ".wav", ".mp3"} +RESIDENT_ENTRIES = ( + (".zcode", "AGENTS.md"), + (".dsh", "AGENTS.md"), + (".codex", "AGENTS.md"), + (".claude", "CLAUDE.md"), +) +DATE_HEADER_LINES = 40 + + +def resident_entries(home: Path) -> List[Path]: + """Host entry files that exist, deduped by real path (hosts often symlink one file).""" + seen: Set[str] = set() + entries: List[Path] = [] + for folder, name in RESIDENT_ENTRIES: + path = home / folder / name + if not path.is_file(): + continue + try: + real = str(path.resolve()) + except OSError: + continue + if real in seen: + continue + seen.add(real) + entries.append(path) + return entries + + +def finding( + rule: str, + severity: str, + title: str, + path: str, + message: str, + suggestion: str, + evidence: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + return { + "rule": rule, + "severity": severity, + "title": title, + "path": path, + "message": message, + "suggestion": suggestion, + "evidence": evidence or {}, + } + + +def suggest_slot(name: str, is_dir: bool) -> str: + """Guess the standard slot an unplaced top-level entry belongs to.""" + lowered = name.lower() + if is_dir: + if lowered in {"skills", "tools", "scripts"}: + return "skills" + if lowered in {"wiki", "knowledge"}: + return "wiki" + if any( + key in lowered + for key in ( + "shot", "screenshot", "截图", "review", "evidence", "audit", + "verify", "verification", "acceptance", "visual", "frame", + "panorama", "photo", + ) + ): + return "evidence" + if any(key in lowered for key in ("scratch", "tmp", "backup", "bak", "cache")): + return "archive" + return "plans" + suffix = Path(name).suffix.lower() + if suffix in MEDIA_SUFFIXES or suffix in {".pdf", ".xlsx"}: + return "evidence" + if suffix in {".py", ".sh", ".mjs"}: + return "skills" + if suffix in {".json", ".log", ".txt", ".bak", ".tsv", ".html"}: + return "tmp" + return "plans" + + +def run_policy(registry: Dict[str, Any], config: ScanConfig) -> Dict[str, Any]: + findings: List[Dict[str, Any]] = [] + stats: Dict[str, Any] = { + "pointers_checked": 0, + "pointers_valid": 0, + "broken_pointers": 0, + "naked_pointers": 0, + "agents_dirs_total": 0, + "agents_dirs_compliant": 0, + "agents_dirs_missing_index": 0, + "agents_dirs_missing_slots": 0, + "agents_dirs_extra_entries": 0, + "knowledge_total": 0, + "knowledge_indexed": 0, + "agents_index_total": 0, + "agents_indexed": 0, + "state_docs_total": 0, + "state_docs_fresh": 0, + "state_docs_fresh_declared": 0, + "state_docs_fresh_from_git": 0, + "duplicate_knowledge": 0, + "note_headers_total": 0, + "governance_docs": len(registry.get("governance_docs", [])), + "misplaced_entries": 0, + "resident_entries": 0, + "secret_total": 0, + "secret_outside_root": 0, + "secret_in_git": 0, + "secret_tracking_unknown": 0, + "secret_undeclared_outside": 0, + "pointer_files_total": 0, + "pointer_files_scanned": 0, + "git_probe_failures": 0, + "inline_bearers": 0, + } + home = config.home + hub = config.hub + scope = getattr(config, "scope", "machine") + hub_layout = resolve_hub_layout(hub) if hub else {} + artifacts = registry.get("artifacts", []) + projects = registry.get("projects", []) + git_failures: List[str] = [ + str(project.get("path") or project.get("id")) + for project in projects + if project.get("git_state") == "error" + ] + skills = registry.get("skills", []) + + # Resident budget: every host entry file that exists, counted once. + user_agents = resident_entries(home) if scope != "hub" else [] + hub_agents = (hub / "AGENTS.md") if hub and (hub / "AGENTS.md").is_file() else None + resident_bytes = sum(file_size(path) for path in user_agents) + ( + file_size(hub_agents) if hub_agents else 0 + ) + resident_lines = sum(count_lines(path) for path in user_agents) + ( + count_lines(hub_agents) if hub_agents else 0 + ) + stats["resident_bytes"] = resident_bytes + stats["resident_lines"] = resident_lines + stats["resident_entries"] = len(user_agents) + if resident_bytes > 20_000: + findings.append( + finding( + "resident-budget", + "medium", + "Resident context exceeds 20 KB", + str(user_agents[0] if user_agents else (hub_agents or "")), + f"User + hub resident instruction is {resident_bytes} bytes / {resident_lines} lines.", + "Move procedures, examples and state tables into knowledge or skills; keep only rules and pointers.", + {"bytes": resident_bytes, "lines": resident_lines, "target_bytes": 20_000}, + ) + ) + for path in user_agents: + lines = count_lines(path) + if lines > 150: + findings.append( + finding( + "resident-budget", + "medium", + "User-level instruction exceeds soft line budget", + str(path), + f"{lines} lines; soft target is 150.", + "Demote long procedures and examples.", + ) + ) + if hub_agents and count_lines(hub_agents) > 50: + findings.append( + finding( + "resident-budget", + "medium", + "Hub instruction exceeds soft line budget", + str(hub_agents), + f"{count_lines(hub_agents)} lines; soft target is 50.", + "Move detail into guster-hub-reference.md.", + ) + ) + + if scope != "hub": + # Skill metadata budget + total_skill_chars = sum(int(skill.get("description_chars", 0)) for skill in skills) + stats["skill_chars"] = total_skill_chars + for skill in skills: + chars = int(skill.get("description_chars", 0)) + if chars <= 500: + continue + findings.append( + finding( + "skill-budget", + "high", + "Skill description exceeds per-item budget", + skill.get("root", ""), + f"{skill.get('name')} description is {chars} chars; limit is 500.", + "Shorten the description to trigger conditions only; move detail into SKILL.md body.", + {"skill": skill.get("name"), "chars": chars}, + ) + ) + if total_skill_chars > 6_000: + findings.append( + finding( + "skill-budget", + "high", + "Skill metadata exceeds total budget", + str(home / ".agents" / "skills"), + f"Unique skill descriptions total {total_skill_chars} chars; budget is 6,000.", + "Trim the longest descriptions first; keep each description to one clear trigger sentence.", + {"chars": total_skill_chars, "target": 6_000}, + ) + ) + + # Skill copy drift + for skill in skills: + copies = skill.get("copies", []) + hashes = {copy.get("hash") for copy in copies if copy.get("hash")} + if len(copies) > 1 and len(hashes) > 1: + findings.append( + finding( + "skill-copy-drift", + "high", + "Skill copies drifted", + skill.get("root", ""), + f"{skill.get('name')} has {len(hashes)} different copies.", + "Make copies byte-identical or replace the copy strategy with symlinks where the host supports it.", + {"copies": copies}, + ) + ) + + # Knowledge index coverage and duplicates + knowledge = [item for item in artifacts if item.get("kind") == "knowledge"] + stats["knowledge_total"] = len(knowledge) + orphan_groups: Dict[str, List[Dict[str, Any]]] = {} + for item in knowledge: + if item.get("indexed") is True: + stats["knowledge_indexed"] += 1 + elif item.get("indexed") is False: + if Path(item["path"]).name.lower() in COMMON_KNOWLEDGE_NAMES: + # An index does not index itself. + continue + orphan_groups.setdefault(item.get("scope", "unknown"), []).append(item) + + for scope, items in orphan_groups.items(): + severity = "medium" if scope in {"general", "project", "workgroup"} else "low" + examples = [relative(Path(item["path"]), Path(item["path"]).parent) for item in items[:10]] + findings.append( + finding( + "orphan-knowledge", + severity, + "Knowledge files are not indexed", + str(items[0]["path"]), + f"{len(items)} knowledge file(s) in scope '{scope}' are absent from the relevant README index.", + "Add each file to its README index, or merge/delete it if obsolete.", + {"count": len(items), "examples": examples}, + ) + ) + + multi_topics = (registry.get("topics") or {}).get("multi_topics") or [] + stats["duplicate_knowledge"] = len(multi_topics) + if multi_topics: + examples = [path for topic in multi_topics[:4] for path in topic.get("paths", [])[:2]] + reasons = sorted({str(topic.get("reason")) for topic in multi_topics}) + findings.append( + finding( + "duplicate-canonical", + "medium", + "The same fact has more than one home", + examples[0] if examples else "", + f"{len(multi_topics)} topic(s) have more than one source ({', '.join(reasons)}).", + "Pick one canonical home and replace the others with pointers; keep a copy only when it is generated and verified.", + { + "count": len(multi_topics), + "topics": [topic.get("key") for topic in multi_topics[:10]], + "examples": examples[:10], + }, + ) + ) + + # Structured note headers (CTX-09). Opt-in: only notes whose front-matter + # declares a ``type:`` are checked, so legacy prose notes never fire. The + # format follows the Obsidian properties convention (see reference/spec.md); + # truth pointers live in the body as CTX-03 links, not in the header. + structured_notes: List[Dict[str, Any]] = [] + for item in knowledge: + if Path(item["path"]).suffix.lower() != ".md": + continue + text = read_text(Path(item["path"]), limit=200_000) + header = parse_note_header(text) + if header is None: + continue + stats["note_headers_total"] += 1 + structured_notes.append(item) + problems: List[str] = [] + if header.get("_malformed"): + problems.append( + f"front-matter cannot be parsed (line {header.get('line')}: " + f"{header.get('text')})" + ) + else: + if not note_header_date(header): + problems.append( + "no machine-readable date (one of " + f"{', '.join(NOTE_DATE_KEYS)} as YYYY-MM-DD)" + ) + if problems: + findings.append( + finding( + "note-header-malformed", + "medium", + "Structured note header is not well-formed", + item["path"], + "; ".join(problems) + ".", + "Fix the front-matter (Obsidian properties convention: type + a " + "date key), or drop the type key to make it a plain note.", + {"problems": problems}, + ) + ) + + # Project .agents layout + for project in projects: + if not project.get("agents_dir"): + continue + if project.get("container"): + # A workspace/group root keeps container-level context; judging it as + # a project would score scaffolding the group never needed. + continue + stats["agents_dirs_total"] += 1 + if project.get("has_index"): + stats["agents_indexed"] += 1 + unknown = [ + entry + for entry in project.get("agents_entries", []) + if entry not in STANDARD_AGENTS_SLOTS and entry not in {"README.md", "AGENTS.md"} + ] + slots_ok = set(STANDARD_AGENTS_SLOTS).issubset(set(project.get("standard_slots", []))) + if project.get("has_index") and slots_ok and not unknown: + stats["agents_dirs_compliant"] += 1 + elif not project.get("has_index"): + stats["agents_dirs_missing_index"] += 1 + elif not slots_ok: + stats["agents_dirs_missing_slots"] += 1 + else: + stats["agents_dirs_extra_entries"] += 1 + if unknown: + stats["misplaced_entries"] += len(unknown) + agents_root = Path(project.get("agents_dir") or "") + suggested = { + entry: suggest_slot(entry, (agents_root / entry).is_dir()) + for entry in unknown + } + findings.append( + finding( + "misplaced-artifact", + "medium", + "Project .agents has task-shaped top-level entries", + project["path"], + f"{len(unknown)} top-level entr{'y' if len(unknown) == 1 else 'ies'} do not match the standard slots.", + "Move task bundles under evidence/, plans/, wiki/ or archive/, then add a README index.", + {"entries": unknown[:100], "suggested": suggested}, + ) + ) + + # State freshness. Plans are state-plane documents too, even though their + # kind is "plan"; select on the plane so project/group scopes are not + # vacuously fresh. Freshness is declared (verified_at / 最后更新 / filename + # date) and otherwise falls back to the owning repository's git date. + state_prefixes = ["handoff/", "provision/", ".agents/plans/"] + if hub_layout: + state_prefixes.append(relative(hub_layout["projects"], hub).rstrip("/") + "/") + state_docs = [ + item + for item in artifacts + if item.get("plane") == "state" + and any(item.get("rel", "").startswith(prefix) for prefix in state_prefixes) + ] + stats["state_docs_total"] = len(state_docs) + commit_dates = git_last_commit_dates(hub) if hub else {} + if commit_dates is None: + if str(hub) not in git_failures: + git_failures.append(str(hub)) + commit_dates = {} + project_roots = {str(project["id"]): Path(project["path"]) for project in projects} + project_commit_dates: Dict[str, Dict[str, str]] = {} + stale_missing: List[str] = [] + stale_old: List[Tuple[str, str]] = [] + for item in state_docs: + path = Path(item["path"]) + text = read_text(path, limit=500_000) + verified = ( + item.get("verified_at") + or extract_verified_date(text) + or extract_filename_date(path) + ) + source = "declared" + if not verified: + project_root = project_roots.get(str(item.get("project"))) + if project_root is None: + verified = commit_dates.get(item.get("rel", ""), "") + else: + key = str(project_root) + if key not in project_commit_dates: + dates = git_last_commit_dates(project_root) + if dates is None: + if key not in git_failures: + git_failures.append(key) + dates = {} + project_commit_dates[key] = dates + verified = project_commit_dates[key].get(item.get("rel", ""), "") + source = "git" + if not verified: + stale_missing.append(str(path)) + continue + parsed = parse_date(verified) + if parsed is None: + stale_missing.append(str(path)) + continue + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + if (datetime.now(timezone.utc) - parsed.astimezone(timezone.utc)).days <= 90: + stats["state_docs_fresh"] += 1 + stats["state_docs_fresh_declared" if source == "declared" else "state_docs_fresh_from_git"] += 1 + else: + stale_old.append((str(path), verified)) + if stale_missing: + findings.append( + finding( + "stale-state", + "medium", + "State documents lack a verifiable freshness date", + stale_missing[0], + f"{len(stale_missing)} state document(s) have no verified_at or update date.", + "Add verified_at to project cards/handoffs/environment docs; refresh active cards before relying on them.", + {"count": len(stale_missing), "examples": stale_missing[:10]}, + ) + ) + if stale_old: + findings.append( + finding( + "stale-state", + "medium", + "State documents are older than 90 days", + stale_old[0][0], + f"{len(stale_old)} state document(s) are older than the 90-day SLA.", + "Re-verify against git/runtime and update verified_at, or archive the document.", + {"count": len(stale_old), "examples": stale_old[:10]}, + ) + ) + stats["git_probe_failures"] = len(git_failures) + if git_failures: + findings.append( + finding( + "git-unavailable", + "high", + "Repository state could not be read", + git_failures[0], + f"{len(git_failures)} repository(ies) could not be queried; tracked state and commit dates are unknown.", + "Fix git access for these repositories and re-run; SIR and FRS do not count them as clean.", + {"count": len(git_failures), "repos": git_failures[:10]}, + ) + ) + + # Pointers + pointer_files = [ + item + for item in artifacts + if Path(item["path"]).suffix.lower() == ".md" + and ( + Path(item["path"]).name in {"AGENTS.md", "CLAUDE.md"} + or ( + Path(item["path"]).name == "README.md" + and ( + # Project .agents indexes and hub knowledge indexes are + # instruction artifacts, including custom hub layouts. + item.get("kind") == "instruction" + or item.get("path", "").endswith( + ( + "/knowledge/README.md", + "/tools/README.md", + "/.agents/README.md", + "/.agents/wiki/README.md", + ) + ) + ) + ) + ) + ] + broken_by_file: List[Dict[str, Any]] = [] + naked_by_file: List[Dict[str, Any]] = [] + # Structured notes (CTX-09) opt their whole body into pointer checks, so a + # pointer card's truth links are verified like any CTX-03 pointer. + seen_pointer_paths = {item["path"] for item in pointer_files} + pointer_files += [ + item for item in structured_notes if item["path"] not in seen_pointer_paths + ] + stats["pointer_files_total"] = len(pointer_files) + scanned_pointer_files = pointer_files[:200] + stats["pointer_files_scanned"] = len(scanned_pointer_files) + for item in scanned_pointer_files: + path = Path(item["path"]) + text = read_text(path, limit=1_000_000) + pointers = extract_pointers(text) + broken: List[str] = [] + for pointer in pointers: + resolved = resolve_pointer(pointer, path, hub, home) + if resolved is None: + continue + stats["pointers_checked"] += 1 + if resolved.exists(): + stats["pointers_valid"] += 1 + else: + broken.append(pointer) + stats["broken_pointers"] += 1 + if broken: + broken_by_file.append( + finding( + "broken-pointer", + "high", + "Broken context pointer", + str(path), + f"{len(broken)} pointer(s) do not resolve.", + "Fix the path or replace it with a pointer that states when to read the target.", + {"pointers": broken[:10]}, + ) + ) + naked = extract_naked_pointers(text) + if naked: + stats["naked_pointers"] += len(naked) + naked_by_file.append( + finding( + "naked-pointer", + "medium", + "Path in prose is not machine-verifiable", + str(path), + f"{len(naked)} file path(s) sit outside a link or code span, so the pointer check never sees them.", + "Wrap each path in a markdown link or inline code (CTX-03, reference/spec.md).", + {"paths": naked[:10]}, + ) + ) + findings.extend(broken_by_file[:100]) + findings.extend(naked_by_file[:100]) + + # Secrets + secret_data = registry.get("secrets", {}) + secret_files = secret_data.get("files", []) + inline = secret_data.get("inline_bearers", []) + stats["secret_total"] = len(secret_files) + stats["inline_bearers"] = sum(int(item.get("count", 0)) for item in inline) + outside = [item for item in secret_files if not item.get("canonical")] + stats["secret_outside_root"] = len(outside) + stats["secret_tracking_unknown"] = sum( + 1 for item in secret_files if item.get("git_tracked") is None + ) + registry_info = secret_data.get("registry") or {} + entries = registry_info.get("entries") or [] + declared_paths: Set[str] = set() + missing_locations: List[str] = [] + declared_entries: Set[str] = set() + for entry in entries: + for location in str(entry.get("locations", "")).split(";"): + location = location.strip() + if not location or location == "-": + continue + pattern = str(home / location[2:]) if location.startswith("~/") else os.path.expanduser(location) + matches = glob.glob(pattern) + if not matches: + missing_locations.append(location) + continue + declared_entries.add(str(entry.get("id"))) + for match in matches: + try: + declared_paths.add(str(Path(match).resolve())) + except OSError: + continue + tracked = [item for item in secret_files if item.get("git_tracked")] + stats["secret_in_git"] = len(tracked) + for item in tracked[:20]: + findings.append( + finding( + "secret-in-git", + "critical", + "Credential file is tracked by git", + item["path"], + "A credential-bearing file is tracked; this must be removed from the index and history, not just deleted.", + "Stop, rotate the credential, remove it from the index/history, and add an ignore rule.", + ) + ) + outside_undeclared = [] + for item in outside: + try: + if str(Path(item["path"]).resolve()) not in declared_paths: + outside_undeclared.append(item["path"]) + except OSError: + continue + stats["secret_undeclared_outside"] = len(outside_undeclared) + if outside_undeclared: + findings.append( + finding( + "secret-outside-root", + "high", + "Credential files are unregistered outside the canonical root", + outside_undeclared[0], + f"{len(outside_undeclared)} credential-bearing file(s) live outside" + " ~/.agents/credentials and are not in the registry.", + "Register each location with its scope, or move the value into the canonical root.", + { + "count": len(outside_undeclared), + "declared_outside_root": len(outside) - len(outside_undeclared), + "examples": outside_undeclared[:10], + }, + ) + ) + for item in inline: + findings.append( + finding( + "secret-inline", + "high", + "Literal bearer token in host configuration", + item["path"], + f"{item.get('count', 0)} bearer token(s) are inline; values are redacted.", + "Replace with an environment/credential reference supported by the host; rotate if the file was shared.", + {"count": item.get("count", 0), "redacted": item.get("redacted", [])}, + ) + ) + + # Credential registry coverage + stats["credentials_registered"] = len(entries) + stats["credentials_by_scope"] = {} + for entry in entries: + scope = entry.get("scope", "unknown") + stats["credentials_by_scope"][scope] = stats["credentials_by_scope"].get(scope, 0) + 1 + if entries: + undeclared = [] + for item in secret_files: + try: + resolved = str(Path(item["path"]).resolve()) + except OSError: + continue + if resolved not in declared_paths: + undeclared.append(item["path"]) + stats["credentials_undeclared"] = len(undeclared) + stats["credentials_missing"] = len(missing_locations) + if undeclared: + findings.append( + finding( + "secret-undeclared", + "medium", + "Credential file is not registered", + undeclared[0], + f"{len(undeclared)} credential file(s) are absent from ~/.agents/credentials/registry.md.", + "Add an entry with scope (company/personal), site, account, location and keys.", + {"count": len(undeclared), "examples": undeclared[:10]}, + ) + ) + if missing_locations: + findings.append( + finding( + "secret-missing", + "medium", + "Registered credential location does not exist", + registry_info.get("path") or "", + f"{len(missing_locations)} registered location(s) resolve to nothing.", + "Rotate/restore the secret or drop the stale registry row.", + {"count": len(missing_locations), "examples": missing_locations[:10]}, + ) + ) + + # Governance document overlap + governance_docs = registry.get("governance_docs", []) + if len(governance_docs) > 4: + findings.append( + finding( + "governance-overlap", + "high", + "Too many documents claim to define context governance", + governance_docs[0], + f"{len(governance_docs)} primary framework documents are present.", + "Keep one primary framework and label the rest as facets or history.", + {"docs": governance_docs}, + ) + ) + + # Tmp and evidence hygiene + tmp_old: List[str] = [] + for project in projects: + agents_dir = project.get("agents_dir") + if not agents_dir: + continue + tmp_root = Path(agents_dir) / "tmp" + if not tmp_root.is_dir(): + continue + for path in tmp_root.rglob("*"): + if path.is_file() and age_days(path) > 14: + tmp_old.append(str(path)) + if tmp_old: + findings.append( + finding( + "tmp-overdue", + "low", + "Temporary files exceed the 14-day TTL", + tmp_old[0], + f"{len(tmp_old)} file(s) in .agents/tmp are older than 14 days.", + "Archive what matters and delete the rest; tmp is not a knowledge store.", + {"count": len(tmp_old), "examples": tmp_old[:10]}, + ) + ) + + # Plugin manifest drift + manifests = registry.get("plugin_manifests", {}) + versions = { + key: value.get("version") + for key, value in manifests.items() + if isinstance(value, dict) and value.get("exists") and value.get("version") + } + if len(set(versions.values())) > 1: + findings.append( + finding( + "host-manifest-drift", + "high", + "Plugin manifests disagree on version", + str(manifests.get("package", {}).get("path", "")), + f"Manifest versions: {versions}", + "Synchronize Codex, ZCode and package versions before release.", + {"versions": versions}, + ) + ) + + scope = getattr(config, "scope", "machine") + scoped = scope_roots(config) + if scoped is not None: + kept = [ + item for item in findings if in_scope_path(item.get("path", ""), scoped) + ] + hidden = [ + item for item in findings if not in_scope_path(item.get("path", ""), scoped) + ] + stats["scoped_hidden_findings"] = len(hidden) + if hidden: + by_rule: Dict[str, int] = {} + for item in hidden: + by_rule[item["rule"]] = by_rule.get(item["rule"], 0) + 1 + kept.append( + finding( + "out-of-scope", + "info", + f"Machine-level findings hidden in {scope} scope", + "", + f"{len(hidden)} finding(s) sit outside the {scope} scope.", + "Run `guster doctor` without --root for the machine-wide view.", + {"by_rule": by_rule}, + ) + ) + findings = kept + + waived: List[Dict[str, Any]] = [] + waivers = load_waivers(hub) + if waivers: + kept, waived = [], [] + for item in findings: + reason = _waiver_reason(item, waivers) + if reason is None: + kept.append(item) + else: + item["waiver_reason"] = reason + waived.append(item) + stats["waived_findings"] = len(waived) + findings = kept + + findings.sort(key=lambda item: (SEVERITY_ORDER.get(item["severity"], 9), item["rule"], item["path"])) + for index, item in enumerate(findings, start=1): + item["id"] = f"F{index:04d}" + return {"findings": findings, "waived": waived, "stats": stats} + + +def _waiver_reason(item: Dict[str, Any], waivers: Sequence[Dict[str, str]]) -> Optional[str]: + target = str(item.get("path") or "") + for waiver in waivers: + if waiver["rule"] != item.get("rule"): + continue + scope_path = waiver.get("path", "") + if not scope_path: + return waiver["reason"] + root = Path(os.path.expanduser(scope_path)) + try: + if Path(target).resolve() == root.resolve(): + return waiver["reason"] + if root.resolve() in Path(target).resolve().parents: + return waiver["reason"] + except OSError: + continue + return None + + +def extract_verified_date(text: str) -> str: + """Declared date from the document header; prose deeper down is not a claim about this file.""" + head = "\n".join(text.splitlines()[:DATE_HEADER_LINES]) + match = re.search( + r"(?:最后更新|更新时间|更新于|最近核实|最后核实|已核实|verified_at)\s*\*{0,2}\s*[::]\s*\*{0,2}\s*(\d{4}[-/]\d{1,2}[-/]\d{1,2})", + head, + ) + return match.group(1) if match else "" + + +def extract_filename_date(path: Path) -> str: + """Only a date that opens the filename counts; a ticket id is not a freshness claim.""" + stem = path.stem.lstrip(".") + match = re.match(r"(20\d{2})[-_]?(\d{2})[-_]?(\d{2})(?:$|[-_.])", stem) + if not match: + return "" + year, month, day = match.groups() + try: + datetime(int(year), int(month), int(day)) + except ValueError: + return "" + return f"{year}-{month}-{day}" + + +def count_lines(path: Path) -> int: + text = read_text(path, limit=5_000_000) + return len(text.splitlines()) if text else 0 + + +def file_size(path: Path) -> int: + try: + return path.stat().st_size + except OSError: + return 0 diff --git a/external_plugins/guster/src/guster/report.py b/external_plugins/guster/src/guster/report.py new file mode 100644 index 00000000..a75c8e1a --- /dev/null +++ b/external_plugins/guster/src/guster/report.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional + +from .util import human_bytes, now_iso + + +SEVERITY_LABEL = { + "critical": "CRITICAL", + "high": "HIGH", + "medium": "MEDIUM", + "low": "LOW", + "info": "INFO", +} + + +def render_report( + registry: Dict[str, Any], + policy_result: Dict[str, Any], + metric_result: Dict[str, Any], +) -> str: + summary = registry.get("summary", {}) + metrics = metric_result.get("metrics", {}) + scores = metric_result.get("scores", {}) + raw = metric_result.get("raw", {}) + findings = policy_result.get("findings", []) + scope = metric_result.get("scope", registry.get("scope", "machine")) + workspaces = registry.get("workspaces", []) + if scope == "hub": + project_cards = summary.get("project_cards", 0) + projects_line = f"- Projects: **{project_cards}** (scanned roots: {summary.get('projects', 0)})" + else: + total_projects = int(summary.get("projects", 0)) + containers = int(summary.get("containers", 0)) + projects_line = f"- Projects: **{total_projects - containers}**" + if containers: + projects_line += f" (workspace containers: {containers})" + + lines: List[str] = [] + lines.append("# Guster Context Health") + lines.append("") + lines.append(f"> Generated: {metric_result.get('generated_at', now_iso())}") + if workspaces: + lines.append(f"> Workspaces: {', '.join(workspaces)}") + lines.append("") + lines.append("## Summary") + lines.append("") + lines.append(projects_line) + projects_total = int(summary.get("projects", 0)) + agents_dirs = int(summary.get("agents_dirs", 0)) + lines.append( + f"- `.agents/` dirs: **{agents_dirs}** " + f"(with index: {summary.get('agents_with_index', 0)}); " + f"projects without one: {max(0, projects_total - agents_dirs)}" + ) + lines.append( + f"- Skills: **{summary.get('skills', 0)}** unique / " + f"**{summary.get('skill_copies', 0)}** copies" + ) + lines.append( + f"- Knowledge: **{summary.get('knowledge', 0)}** files; " + f"state: {summary.get('state', 0)}; evidence: {summary.get('evidence', 0)}" + ) + lines.append( + f"- Credentials: **{summary.get('secret_files', 0)}** files " + f"({raw.get('secret_undeclared_outside', 0)} outside and unregistered, " + f"{raw.get('inline_bearers', 0)} inline)" + ) + lines.append("") + lines.append("## Context Health Index") + lines.append("") + dims = metric_result.get("dims") or [] + label = f"{scope} scope" + chi = metric_result.get("chi") + lines.append(f"**CHI: {'n/a' if chi is None else chi} / 100** ({label})") + lines.append("") + lines.append("| Dimension | Value | Score | Raw |") + lines.append("|---|---:|---:|---|") + rows = [ + ( + "Single source", + "ssr", + metrics.get("ssr"), + scores.get("ssr"), + f"{raw.get('topics_total', 0) - raw.get('topics_multi', 0)}/{raw.get('topics_total', 0)} topics single-source", + ), + ( + "Resident budget", + "rbr", + metrics.get("rbr"), + scores.get("rbr"), + f"{human_bytes(raw.get('resident_bytes', 0))} over {raw.get('resident_entries', 0)} entry files; " + f"skills {raw.get('skill_chars', 0)} chars", + ), + ( + "Placement", + "pcr", + metrics.get("pcr"), + scores.get("pcr"), + f"{raw.get('agents_dirs_compliant', 0)}/{raw.get('agents_dirs_total', 0)} compliant" + f" ({raw.get('agents_dirs_extra_entries', 0)} extra entries, " + f"{raw.get('agents_dirs_missing_slots', 0)} missing slots, " + f"{raw.get('agents_dirs_missing_index', 0)} no index)", + ), + ( + "Index coverage", + "icr", + metrics.get("icr"), + scores.get("icr"), + f"knowledge {raw.get('knowledge_indexed', 0)}/{raw.get('knowledge_total', 0)}; " + f".agents index {raw.get('agents_indexed', 0)}/{raw.get('agents_dirs_total', 0)}", + ), + ( + "State freshness", + "frs", + metrics.get("frs"), + scores.get("frs"), + f"{raw.get('state_docs_fresh', 0)}/{raw.get('state_docs_total', 0)} fresh " + f"({raw.get('state_docs_fresh_declared', 0)} declared, {raw.get('state_docs_fresh_from_git', 0)} from git)", + ), + ("Pointer health", "phr", metrics.get("phr"), scores.get("phr"), f"{raw.get('pointers_valid', 0)}/{raw.get('pointers_checked', 0)} valid"), + ( + "Secret isolation", + "sir", + metrics.get("sir"), + scores.get("sir"), + f"{raw.get('secret_undeclared_outside', 0)} undeclared outside; " + f"{raw.get('inline_bearers', 0)} inline; {raw.get('secret_in_git', 0)} in git; " + f"{raw.get('secret_tracking_unknown', 0)} unverifiable", + ), + ] + for label, key, value, score, detail in rows: + if dims and key not in dims: + continue + lines.append( + f"| {label} | {'n/a' if value is None else value} " + f"| {'n/a' if score is None else score} | {detail} |" + ) + lines.append("") + unscored = metric_result.get("unscored_dims") or [] + if unscored: + lines.append( + f"_No evidence: {', '.join(dim.upper() for dim in unscored)}. " + "CHI is normalized over the dimensions that had evidence, so it is not comparable " + "across scopes with different coverage._" + ) + lines.append("") + if raw.get("pointer_files_total", 0) > raw.get("pointer_files_scanned", 0): + lines.append( + f"_Pointer scan covered {raw.get('pointer_files_scanned')} of " + f"{raw.get('pointer_files_total')} pointer files._" + ) + lines.append("") + if raw.get("knowledge_unhashed", 0): + lines.append( + f"_Knowledge files above the hash limit, excluded from single-source checks: " + f"{raw.get('knowledge_unhashed')}._" + ) + lines.append("") + lines.append( + f"_Not counted in CHI: Governance simplicity {metrics.get('gcr')} " + f"(score {scores.get('gcr')}, {raw.get('governance_docs', 0)} docs)._" + ) + lines.append("") + lines.append("## Findings") + lines.append("") + if not findings: + lines.append("No findings.") + else: + counts: Dict[str, int] = {} + for item in findings: + counts[item["severity"]] = counts.get(item["severity"], 0) + 1 + lines.append( + "Counts: " + + ", ".join( + f"{SEVERITY_LABEL.get(level, level)} {counts.get(level, 0)}" + for level in ("critical", "high", "medium", "low", "info") + if counts.get(level) + ) + ) + lines.append("") + for item in findings[:80]: + label = SEVERITY_LABEL.get(item["severity"], item["severity"]) + lines.append(f"### [{label}] {item['title']}") + lines.append("") + lines.append(f"- Rule: `{item['rule']}`") + lines.append(f"- Path: `{item['path']}`") + lines.append(f"- {item['message']}") + lines.append(f"- Action: {item['suggestion']}") + evidence = item.get("evidence") or {} + if evidence: + compact = json.dumps(evidence, ensure_ascii=False) + if len(compact) > 500: + compact = compact[:500] + "..." + lines.append(f"- Evidence: `{compact}`") + lines.append("") + if len(findings) > 80: + lines.append(f"_...and {len(findings) - 80} more findings; use `guster doctor --json`._") + lines.append("") + waived = policy_result.get("waived", []) + if waived: + lines.append(f"## Waived ({len(waived)})") + lines.append("") + for item in waived: + lines.append(f"- `{item['rule']}` — `{item['path']}` — {item.get('waiver_reason', '')}") + lines.append("") + lines.append("## Next Actions") + lines.append("") + lines.append("1. Fix critical findings first: credential in git, broken resident pointer, duplicate canonical source.") + lines.append("2. Run `guster plan findings` to turn the remaining findings into a review checklist.") + lines.append("3. Run `guster plan move ...` or `guster plan init ...` for structural changes.") + lines.append("4. Apply with `guster apply --yes` only after reviewing the plan.") + lines.append("5. Re-run `guster score` and compare the trend.") + lines.append("") + return "\n".join(lines) + + +def render_status(registry: Dict[str, Any], metric_result: Optional[Dict[str, Any]] = None) -> str: + plugin = registry.get("plugin", {}) + summary = registry.get("summary", {}) + projects = ( + summary.get("project_cards", 0) + if registry.get("scope") == "hub" + else summary.get("projects", 0) + ) + lines = [ + f"Guster {plugin.get('version', '?')}", + f"generated: {registry.get('generated_at', '?')}", + f"hub: {registry.get('hub') or '(none)'}", + f"workspaces: {', '.join(registry.get('workspaces', []))}", + f"projects: {projects}", + f"skills: {summary.get('skills', 0)} unique / {summary.get('skill_copies', 0)} copies", + f"knowledge: {summary.get('knowledge', 0)}", + f"credentials: {summary.get('secret_files', 0)} files; {summary.get('inline_bearers', 0)} inline", + ] + if metric_result: + chi = metric_result.get("chi") + stamp = metric_result.get("generated_at") + suffix = f" (scored {stamp})" if stamp else "" + lines.append(f"CHI: {'n/a' if chi is None else chi}{suffix}") + return "\n".join(lines) + + +def redacted_registry(registry: Dict[str, Any]) -> Dict[str, Any]: + def scrub(value: Any) -> Any: + if isinstance(value, dict): + return { + key: scrub(item) + for key, item in value.items() + if key.lower() not in {"value", "password", "token", "secret", "authorization"} + } + if isinstance(value, list): + return [scrub(item) for item in value] + return value + + return scrub(registry) + diff --git a/external_plugins/guster/src/guster/util.py b/external_plugins/guster/src/guster/util.py new file mode 100644 index 00000000..f4d58be2 --- /dev/null +++ b/external_plugins/guster/src/guster/util.py @@ -0,0 +1,614 @@ +from __future__ import annotations + +import hashlib +import json +import os +import re +import subprocess +import time +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Iterator, List, Optional, Sequence, Set, Tuple + + +EXCLUDE_DIRS = { + ".git", + ".hg", + ".svn", + "node_modules", + "build", + "dist", + "out", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + ".next", + ".nuxt", + ".venv", + "venv", + "target", + ".idea", + ".vscode", +} + +TEXT_SUFFIXES = { + ".md", + ".markdown", + ".txt", + ".json", + ".yaml", + ".yml", + ".toml", + ".ini", + ".cfg", + ".conf", + ".sh", + ".bash", + ".zsh", + ".py", + ".js", + ".mjs", + ".ts", + ".tsx", + ".jsx", + ".go", + ".java", + ".kt", + ".rs", + ".rb", + ".php", + ".sql", +} + +POINTER_SUFFIXES = { + ".md", + ".json", + ".yaml", + ".yml", + ".toml", + ".sh", + ".bash", + ".zsh", + ".py", + ".js", + ".mjs", + ".ts", + ".tsx", + ".jsx", + ".go", + ".java", + ".kt", + ".rs", + ".sql", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".svg", + ".mp4", + ".mov", + ".jsonl", + ".csv", +} + + +def now_iso() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def today_iso() -> str: + return datetime.now().astimezone().date().isoformat() + + +def read_text(path: Path, limit: Optional[int] = None) -> str: + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + if limit is not None and len(text) > limit: + return text[:limit] + return text + + +def write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def sha256_text(text: str) -> str: + return sha256_bytes(text.encode("utf-8")) + + +def sha256_file(path: Path) -> str: + try: + return sha256_bytes(path.read_bytes()) + except OSError: + return "" + + +def is_under(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + return True + except (OSError, ValueError): + return False + + +def relative(path: Path, base: Path) -> str: + try: + return str(path.resolve().relative_to(base.resolve())) + except (OSError, ValueError): + return str(path) + + +def slugify(value: str) -> str: + value = value.strip().lower() + value = re.sub(r"[^a-z0-9._-]+", "-", value) + value = re.sub(r"-{2,}", "-", value).strip("-") + return value or "item" + + +def human_bytes(value: int) -> str: + if value < 1024: + return f"{value} B" + if value < 1024 * 1024: + return f"{value / 1024:.1f} KB" + return f"{value / (1024 * 1024):.1f} MB" + + +def json_dumps(data: Any, pretty: bool = True) -> str: + return json.dumps(data, ensure_ascii=False, indent=2 if pretty else None, sort_keys=False) + + +def load_json(path: Path, default: Any = None) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return default + + +def save_json(path: Path, data: Any) -> None: + write_text(path, json_dumps(data) + "\n") + + +def parse_frontmatter(text: str) -> Tuple[Dict[str, str], str]: + """Parse the small YAML frontmatter subset used by agent files. + + Supports scalar keys, quoted values and literal/folded blocks. It deliberately + avoids a PyYAML dependency so the plugin runs on a stock Python install. + """ + if not text.startswith("---"): + return {}, text + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return {}, text + end: Optional[int] = None + for index in range(1, len(lines)): + if lines[index].strip() == "---": + end = index + break + if end is None: + return {}, text + + data: Dict[str, str] = {} + index = 1 + while index < end: + line = lines[index] + if not line.strip() or line.lstrip().startswith("#"): + index += 1 + continue + if line.startswith((" ", "\t")) or ":" not in line: + index += 1 + continue + key, raw = line.split(":", 1) + key = key.strip() + value = raw.strip() + if value in ("|", ">"): + block: List[str] = [] + index += 1 + while index < end: + candidate = lines[index] + if candidate.startswith(" "): + block.append(candidate[2:]) + elif not candidate.strip(): + block.append("") + else: + break + index += 1 + data[key] = ("\n" if value == "|" else " ").join(block).strip() + continue + data[key] = unquote(value) + index += 1 + return data, "\n".join(lines[end + 1 :]) + + +def unquote(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): + return value[1:-1] + return value + + +def first_heading(text: str, fallback: str = "") -> str: + for line in text.splitlines(): + if line.startswith("# "): + return line[2:].strip() + return fallback + + +def iter_files( + root: Path, + suffixes: Optional[Set[str]] = None, + max_depth: Optional[int] = None, + skip_dirs: Optional[Set[str]] = None, +) -> Iterator[Path]: + if not root.exists(): + return + skip = EXCLUDE_DIRS if skip_dirs is None else skip_dirs + root = root.resolve() + for current, dirs, files in os.walk(root): + current_path = Path(current) + depth = len(current_path.relative_to(root).parts) + if max_depth is not None and depth >= max_depth: + dirs[:] = [] + dirs[:] = sorted(d for d in dirs if d not in skip) + for name in sorted(files): + path = current_path / name + if suffixes is not None and path.suffix.lower() not in suffixes: + continue + yield path + + +def find_git_roots(roots: Sequence[Path], max_depth: int = 5) -> List[Path]: + found: List[Path] = [] + seen: Set[str] = set() + + def visit(path: Path, depth: int) -> None: + try: + resolved = str(path.resolve()) + except OSError: + return + if resolved in seen: + return + seen.add(resolved) + if not path.is_dir(): + return + if (path / ".git").exists(): + found.append(path) + return + if depth >= max_depth: + return + try: + entries = sorted(path.iterdir(), key=lambda item: item.name) + except OSError: + return + for entry in entries: + if entry.name in EXCLUDE_DIRS or entry.name.startswith("."): + continue + if entry.is_dir(): + visit(entry, depth + 1) + + for root in roots: + visit(root, 0) + return sorted(found, key=lambda item: str(item)) + + +def git_tracked(root: Path) -> Optional[Set[str]]: + """Tracked paths; None means git could not answer, never a silent empty set.""" + if not (root / ".git").exists(): + return set() + try: + result = subprocess.run( + ["git", "-C", str(root), "ls-files", "-z"], + check=False, + capture_output=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return None + if result.returncode != 0: + return None + tracked: Set[str] = set() + for raw in result.stdout.split(b"\x00"): + if not raw: + continue + tracked.add(raw.decode("utf-8", errors="replace")) + return tracked + + +def git_last_commit_dates(root: Path) -> Optional[Dict[str, str]]: + """Map repo-relative paths to the newest commit date; None when git failed.""" + if not (root / ".git").exists(): + return {} + try: + result = subprocess.run( + ["git", "-C", str(root), "log", "--format=%x00%cI", "--name-only"], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + return None + if result.returncode != 0: + return None + dates: Dict[str, str] = {} + current = "" + for line in result.stdout.splitlines(): + if line.startswith("\x00"): + current = line[1:].strip() + continue + rel = line.strip() + if not rel or not current: + continue + dates.setdefault(rel, current) + return dates + + +NOTE_DATE_KEYS = ("updated", "date", "lastmod", "created") +_NOTE_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}(?:[T ][\d:]+)?") + + +def parse_note_header(text: str) -> Optional[Dict[str, Any]]: + """Parse a knowledge note's YAML front-matter (CTX-09). + + Follows the Obsidian properties convention — the interchange format the + open-source markdown knowledge bases (Foam, Quartz, Logseq) all accept. + Hand-rolled for the simple subset (scalar keys, one list per key, ``#`` + comments) because the CLI runs on stock Python without third-party deps. + + Returns None when the text has no front-matter or declares no ``type`` — + the structured header is opt-in. A dict with ``_malformed`` reports a + header that opted in but cannot be parsed. + """ + if not text.startswith("---"): + return None + lines = text.splitlines() + keys: Dict[str, Any] = {} + list_key: Optional[str] = None + end: Optional[int] = None + for i in range(1, min(len(lines), 80)): + line = lines[i] + if line.strip() == "---": + end = i + break + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped.startswith("- ") and list_key is not None: + value = stripped[2:].split("#", 1)[0].strip() + if value: + keys[list_key].append(value) + continue + match = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", stripped) + if not match: + return {"_malformed": True, "line": i + 1, "text": stripped[:60]} + key = match.group(1) + value = match.group(2).split("#", 1)[0].strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + if value: + keys[key] = value + list_key = None + else: + keys[key] = [] + list_key = key + if end is None: + return {"_malformed": True, "line": len(lines), "text": "front-matter not closed"} + note_type = keys.get("type") + if isinstance(note_type, list): + note_type = note_type[0] if note_type else "" + if not note_type or not str(note_type).strip().strip("[]"): + return None + return keys + + +def note_header_date(header: Dict[str, Any]) -> Optional[str]: + """First machine-readable date value among the convention's date keys.""" + for key in NOTE_DATE_KEYS: + value = header.get(key) + if isinstance(value, str) and _NOTE_DATE_RE.match(value.strip()): + return value.strip() + return None + + +def extract_pointers(text: str) -> List[str]: + text = re.sub(r"```.*?```", "", text, flags=re.DOTALL) + pointers: List[str] = [] + + def add(candidate: str) -> None: + # `[`path`](path)` matches both the link and the inline-code pass; count + # each target once per file so pointer health is not double-weighted. + if _looks_like_pointer(candidate) and candidate not in pointers: + pointers.append(candidate) + + for match in re.finditer(r"\[[^\]]*\]\(([^)]+)\)", text): + add(match.group(1).strip()) + for match in re.finditer(r"`([^`\n]+)`", text): + candidate = match.group(1).strip() + if candidate.startswith("~/") and _is_remote_command(_line_at(text, match.start())): + continue + add(candidate) + return pointers + + +def _line_at(text: str, offset: int) -> str: + start = text.rfind("\n", 0, offset) + 1 + end = text.find("\n", offset) + return text[start : end if end != -1 else len(text)] + + +def _is_remote_command(line: str) -> bool: + # `ssh mini` + `~/...` in the same line names the remote home, not a local path. + if re.search(r"\bssh\s+(?:-[\w-]+\s+)*[A-Za-z0-9._-]+", line): + return True + return re.search(r"\b(?:scp|rsync)\b[^\n]*?[A-Za-z0-9._-]+:~/", line) is not None + + +def _looks_like_pointer(candidate: str) -> bool: + if not candidate or _is_placeholder(candidate): + return False + if any(char.isspace() for char in candidate): + return False + if candidate.startswith(("http://", "https://", "mailto:", "tel:", "#", "...")): + return False + suffix = Path(candidate).suffix.lower() + if candidate.startswith("~/"): + return True + if candidate.startswith(("./", "../")): + return True + if candidate.startswith("/"): + known_roots = ( + "/Users/", + "/home/", + "/tmp/", + "/Applications/", + "/System/", + "/opt/", + "/usr/", + "/var/", + "/etc/", + "/private/", + "/Volumes/", + ) + return candidate.startswith(known_roots) + if candidate.endswith("/"): + # A relative directory pointer (`knowledge/general/`) has no suffix and + # is still a link worth checking. + return True + return "/" in candidate and suffix in POINTER_SUFFIXES + + +def _is_placeholder(pointer: str) -> bool: + if any(token in pointer for token in ("<", ">", "*", "{", "}", "$", "|")): + return True + # File templates in docs: draft-NNN.md, daily-YYYY-MM-DD.md, 0X-xxx.md. + return re.search(r"(?:N{2,}|Y{4}|X{3,}|x{3,}|0[xX][-_/])", pointer) is not None + + +def _strip_pointer(pointer: str) -> str: + pointer = pointer.strip() + if pointer.startswith("<") and ">" in pointer: + pointer = pointer[1 : pointer.index(">")] + pointer = pointer.split(" ", 1)[0] + if pointer.startswith("file://"): + pointer = pointer[7:] + pointer = re.sub(r":\d+(?::\d+)?$", "", pointer) + return pointer + + +# Path-shaped token in prose: optional ~/ ./ ../ / prefix, then at least one +# path segment. The lookbehind keeps the match from starting inside a word, a +# URL or an email; CJK characters do not block, so `见docs/x.md` still matches. +NAKED_POINTER_TOKEN_RE = re.compile( + r"(? List[str]: + """File-shaped paths left in prose after links, code spans and code blocks are removed. + + Only suffix-bearing paths count: directory references in prose (a bare + `~/some/repo`) name a place far more often than they point at a read, and + flagging them would drown the signal. + """ + residual = re.sub(r"```.*?```", "", text, flags=re.DOTALL) + residual = re.sub(r"\[[^\]]*\]\([^)]+\)", "", residual) + residual = re.sub(r"`[^`\n]+`", "", residual) + found: List[str] = [] + for match in NAKED_POINTER_TOKEN_RE.finditer(residual): + token = match.group(0).rstrip(".,;:!?\"')") + if not token or token in found: + continue + if _is_placeholder(token): + continue + if token.startswith(("http://", "https://", "mailto:", "tel:", "#")): + continue + if token.startswith(("/tmp/", "/private/tmp/", "/var/folders/")): + continue + if Path(token).suffix.lower() not in POINTER_SUFFIXES: + continue + found.append(token) + return found + + +def find_repo_root(start: Path) -> Optional[Path]: + """Nearest enclosing git work tree; `.claude/` and `.agents/` docs address the repo root.""" + cursor = start + for _ in range(8): + if (cursor / ".git").exists(): + return cursor + if cursor.parent == cursor: + return None + cursor = cursor.parent + return None + + +def resolve_pointer( + pointer: str, + base_file: Path, + hub: Optional[Path] = None, + home: Optional[Path] = None, +) -> Optional[Path]: + if not pointer or _is_placeholder(pointer): + return None + cleaned = _strip_pointer(pointer) + lowered = cleaned.lower() + if lowered.startswith(("http://", "https://", "mailto:", "tel:", "#")): + return None + if not cleaned: + return None + expanded = Path(os.path.expanduser(cleaned)) + if expanded.is_absolute(): + if str(expanded).startswith(("/tmp/", "/private/tmp/", "/var/folders/")): + # Runtime scratch paths are never expected to exist at rest. + return None + return expanded + if cleaned.startswith(("./", "../")): + return (base_file.parent / expanded).resolve() + anchor = cleaned.split("/", 1)[0] + roots: List[Path] = [base_file.parent] + repo_root = find_repo_root(base_file.parent) + if repo_root is not None and repo_root != base_file.parent: + roots.append(repo_root) + if hub is not None: + roots.append(hub) + if home is not None: + roots.extend([home, home / "IdeaProjects", home / "ZCodeProject"]) + if not any((root / anchor).exists() for root in roots): + # A bare relative path only counts as a pointer when its first segment + # exists in one of the known roots. Otherwise it is prose about another repo. + return None + candidates: List[Path] = [(root / expanded).resolve() for root in roots] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def age_days(path: Path) -> float: + try: + return max(0.0, (time.time() - path.stat().st_mtime) / 86400.0) + except OSError: + return 0.0 + + +def parse_date(value: str) -> Optional[datetime]: + value = (value or "").strip() + if not value: + return None + for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S"): + try: + return datetime.strptime(value, fmt) + except ValueError: + continue + try: + return datetime.fromisoformat(value) + except ValueError: + return None diff --git a/external_plugins/guster/templates/hub/AGENTS.md b/external_plugins/guster/templates/hub/AGENTS.md new file mode 100644 index 00000000..dbc48330 --- /dev/null +++ b/external_plugins/guster/templates/hub/AGENTS.md @@ -0,0 +1,23 @@ +# Guster Hub + +This repository is the data plane for agent context. The Guster plugin reads it; it does not replace it. + +## Map + +- Knowledge index: [{{KNOWLEDGE_PATH}}/README.md]({{KNOWLEDGE_PATH}}/README.md) +- Project registry: [{{PROJECTS_PATH}}/_registry.md]({{PROJECTS_PATH}}/_registry.md) +- Tool index: [{{TOOLS_PATH}}/README.md]({{TOOLS_PATH}}/README.md) +- Hub contract: [hub.yaml](hub.yaml) + +## Rules + +- One topic per knowledge file. Put durable, reusable knowledge in `{{KNOWLEDGE_PATH}}/general/` or `{{KNOWLEDGE_PATH}}/projects/`. +- Register a project in `{{PROJECTS_PATH}}/_registry.md` before adding `{{PROJECTS_PATH}}/{slug}.md`. +- State documents must carry `最后更新: YYYY-MM-DD`. +- File paths are markdown links or inline code, never naked in prose, so `guster doctor` can verify them (CTX-03). +- Code and tests remain the source of truth; this hub stores decisions and pointers. +- Never store credentials, company secrets, generated caches or scratch files here. + +## Optional modules + +Add `handoff/` or `reports/` only when the workflow needs them. diff --git a/external_plugins/guster/templates/hub/README.md b/external_plugins/guster/templates/hub/README.md new file mode 100644 index 00000000..1a955016 --- /dev/null +++ b/external_plugins/guster/templates/hub/README.md @@ -0,0 +1,18 @@ +# Guster Hub (Lite) + +A minimal data plane for the Guster plugin. It holds durable knowledge and project state; governance lives in the plugin. + +## Start + +```bash +guster hub status +guster scan +guster doctor --scope hub +``` + +## Add content + +- Knowledge: create one topic per file under `{{KNOWLEDGE_PATH}}/general/` or `{{KNOWLEDGE_PATH}}/projects/`, then add it to `{{KNOWLEDGE_PATH}}/README.md`. +- Project: add a row to `{{PROJECTS_PATH}}/_registry.md`, then create `{{PROJECTS_PATH}}/{slug}.md` with a `最后更新` date. +- Tool: add one directory under `{{TOOLS_PATH}}/` with its own `AGENTS.md`, then register it in `{{TOOLS_PATH}}/README.md`. +- Handoff and reports are optional modules. Add them only when the workflow needs them. diff --git a/external_plugins/guster/templates/hub/gitignore.tmpl b/external_plugins/guster/templates/hub/gitignore.tmpl new file mode 100644 index 00000000..9fa88ac4 --- /dev/null +++ b/external_plugins/guster/templates/hub/gitignore.tmpl @@ -0,0 +1,2 @@ +.DS_Store +*.log diff --git a/external_plugins/guster/templates/hub/hub.yaml.tmpl b/external_plugins/guster/templates/hub/hub.yaml.tmpl new file mode 100644 index 00000000..ef8dbaa4 --- /dev/null +++ b/external_plugins/guster/templates/hub/hub.yaml.tmpl @@ -0,0 +1,10 @@ +schema_version: 1 +profile: lite +hub_version: 0.1.0 +min_guster: {{PLUGIN_VERSION}} +name: {{HUB_NAME}} +created_at: {{DATE}} +paths: + knowledge: {{KNOWLEDGE_PATH}} + projects: {{PROJECTS_PATH}} + tools: {{TOOLS_PATH}} diff --git a/external_plugins/guster/templates/hub/knowledge/README.md b/external_plugins/guster/templates/hub/knowledge/README.md new file mode 100644 index 00000000..ff1efcbd --- /dev/null +++ b/external_plugins/guster/templates/hub/knowledge/README.md @@ -0,0 +1,5 @@ +# Knowledge Index + +| File | Topic | +|---|---| +| `general/start-here.md` | How this hub is organized | diff --git a/external_plugins/guster/templates/hub/knowledge/general/start-here.md b/external_plugins/guster/templates/hub/knowledge/general/start-here.md new file mode 100644 index 00000000..b233ffee --- /dev/null +++ b/external_plugins/guster/templates/hub/knowledge/general/start-here.md @@ -0,0 +1,11 @@ +# Start Here + +This hub is the data plane. The Guster plugin discovers it, checks its structure and scores its health. + +## What goes where + +- `general/` — reusable principles, methods and tool notes that apply across projects. +- `projects/` — durable business and architecture knowledge for one project. +- the hub's project state lives outside this knowledge root and is listed in its registry. + +Keep one topic per file. Add every knowledge file to [the knowledge index](../README.md). diff --git a/external_plugins/guster/templates/hub/knowledge/projects/.gitkeep b/external_plugins/guster/templates/hub/knowledge/projects/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/external_plugins/guster/templates/hub/knowledge/projects/.gitkeep @@ -0,0 +1 @@ + diff --git a/external_plugins/guster/templates/hub/projects/_registry.md.tmpl b/external_plugins/guster/templates/hub/projects/_registry.md.tmpl new file mode 100644 index 00000000..cef491a2 --- /dev/null +++ b/external_plugins/guster/templates/hub/projects/_registry.md.tmpl @@ -0,0 +1,8 @@ +# Project Registry + +**最后更新**: {{DATE}} + +> Register a project here before creating `{slug}.md` in this directory. + +| slug | Display name | Project path | Notes | +|---|---|---|---| diff --git a/external_plugins/guster/templates/hub/tools/README.md b/external_plugins/guster/templates/hub/tools/README.md new file mode 100644 index 00000000..471d00b7 --- /dev/null +++ b/external_plugins/guster/templates/hub/tools/README.md @@ -0,0 +1,30 @@ +# Tools + +This is the index for manual tools in this hub. The Lite profile ships no tool by default; add one only when it earns a place. + +## What belongs here + +- Run-once commands that a person or agent invokes deliberately. +- One directory per tool, with an `AGENTS.md` covering purpose, entry point, inputs, outputs, failure behavior and credential references. +- The smallest executable entry point needed to do the job. + +## What does not + +- Long-running services, cron jobs or background daemons. +- Skills or agent instructions; those belong in the plugin or project `.agents/`. +- Knowledge notes; those belong in `knowledge/`. +- Generated caches, logs or scratch files. + +## Index + +| Tool | Purpose | Entry point | Credentials | +|---|---|---|---| +| _(none yet)_ | | | | + +## Add a tool + +1. Create `/` next to this file. +2. Add an `AGENTS.md` with the contract above. +3. Add the entry point. +4. Add a row to the index. +5. Reference credentials by path or variable name only; never commit values. diff --git a/marketplace.json b/marketplace.json index c7f09a06..63619822 100644 --- a/marketplace.json +++ b/marketplace.json @@ -10,7 +10,10 @@ "url": "https://z.ai" }, "unlistedPlugins": [ - { "name": "openpencil", "source": "./external_plugins/openpencil" } + { + "name": "openpencil", + "source": "./external_plugins/openpencil" + } ], "plugins": [ { @@ -827,7 +830,9 @@ }, { "name": "excalidraw", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "Excalidraw", "displayName_i18n": { "en": "Excalidraw", @@ -850,7 +855,9 @@ "name": "photocraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/photocraft/icon.png", "source": "./external_plugins/photocraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "PhotoCraft", "displayName_i18n": { "en": "PhotoCraft", @@ -877,7 +884,9 @@ "name": "vectorcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/vectorcraft/icon.png", "source": "./external_plugins/vectorcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "VectorCraft", "displayName_i18n": { "en": "VectorCraft", @@ -904,7 +913,9 @@ "name": "pdfcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/pdfcraft/icon.png", "source": "./external_plugins/pdfcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "PdfCraft", "displayName_i18n": { "en": "PdfCraft", @@ -931,7 +942,9 @@ "name": "designcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/designcraft/icon.png", "source": "./external_plugins/designcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "DesignCraft", "displayName_i18n": { "en": "DesignCraft", @@ -958,7 +971,9 @@ "name": "wordcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/wordcraft/icon.png", "source": "./external_plugins/wordcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "WordCraft", "displayName_i18n": { "en": "WordCraft", @@ -985,7 +1000,9 @@ "name": "gridcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/gridcraft/icon.png", "source": "./external_plugins/gridcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "GridCraft", "displayName_i18n": { "en": "GridCraft", @@ -1012,7 +1029,9 @@ "name": "deckcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/deckcraft/icon.png", "source": "./external_plugins/deckcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "DeckCraft", "displayName_i18n": { "en": "DeckCraft", @@ -1039,7 +1058,9 @@ "name": "cadcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/cadcraft/icon.png", "source": "./external_plugins/cadcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "CADCraft", "displayName_i18n": { "en": "CADCraft", @@ -1066,7 +1087,9 @@ "name": "soundcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/soundcraft/icon.png", "source": "./external_plugins/soundcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "SoundCraft", "displayName_i18n": { "en": "SoundCraft", @@ -1149,7 +1172,9 @@ }, "category": "developer-tools", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/kicad/icon.png", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "KiCad", "displayName_i18n": { "en": "KiCad", @@ -1443,6 +1468,46 @@ "mcp", "verification" ] + }, + { + "name": "guster", + "source": "./external_plugins/guster", + "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/guster/icon.png", + "description": "Discover, audit, plan and evolve agent context across projects, skills, tools, knowledge and credentials; optionally scaffold a lightweight Guster Hub for durable knowledge and project state.", + "description_i18n": { + "en": "Discover, audit, plan and evolve agent context across projects, skills, tools, knowledge and credentials; optionally scaffold a lightweight Guster Hub for durable knowledge and project state.", + "zh-CN": "发现、诊断、规划和演进全机 agent 上下文:项目、指令、Skill、工具、知识、状态与凭据引用;可初始化一个轻量 Guster Hub,承载长期知识与项目状态。" + }, + "version": "0.7.3", + "author": { + "name": "Lumos-789", + "url": "https://github.com/Lumos-789" + }, + "category": "developer-tools", + "keywords": [ + "agent", + "context", + "governance", + "skills", + "mcp", + "codex", + "zcode", + "dsh" + ], + "examplePrompts": [ + "Run a full context audit and show me the health score", + "Initialize this project with the standard .agents layout" + ], + "examplePrompts_i18n": { + "zh-CN": [ + "跑一遍全机上下文审计,给我健康分", + "用标准 .agents 布局初始化这个项目" + ] + }, + "displayName": "Guster", + "displayName_i18n": { + "zh-CN": "Guster" + } } ] -} \ No newline at end of file +}