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..b7ec8977 --- /dev/null +++ b/external_plugins/guster/.codex-plugin/plugin.json @@ -0,0 +1,34 @@ +{ + "name": "guster", + "version": "0.9.1", + "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..28a7103c --- /dev/null +++ b/external_plugins/guster/.zcode-plugin/plugin.json @@ -0,0 +1,27 @@ +{ + "name": "guster", + "version": "0.9.1", + "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..e18ba833 --- /dev/null +++ b/external_plugins/guster/CHANGELOG.md @@ -0,0 +1,343 @@ +# Changelog + +Notable changes. Earlier history is in `git log`. + +## 0.9.1 — 2026-11-14 + +The format-spec release: the context format contract is now externally +deliverable — schema-checked in the editor, sourced to open conventions, and +stated in attributable vocabulary. + +- **JSON Schema contracts**: `schemas/hub.schema.json` and + `schemas/plan.schema.json` (draft 2020-12, derived from the code that + reads and writes each format) pin the `hub.yaml` manifest and the plan + JSON. The scaffolded `hub.yaml` ships a yaml-language-server modeline on + its first line, so the VS Code YAML extension validates it with zero + configuration, and an agent hand-writing a batch plan has the exact field + contract to check against before the `apply` dry-run. `tests/test_schemas.py` + keeps both contracts honest with a stdlib-only mini validator. +- **CTX-09 optional keys**: a note header may carry `name` and `description` + (the one-fact-per-file shape inspired by the Claude Code memory tool) and + `superseded_by`, the path or stem of the note that replaced this one — pure + metadata, never part of the pointer scan, no new finding. The naked-pointer + scan now skips front-matter, so `superseded_by` values are not misread as + naked pointers. `spec_version` stays 1. +- **Open conventions, sourced**: the spec's conventions section now states + AGENTS.md as an open format (nearest-wins nested discovery, the same + narrow-never-contradict discipline as CTX-01, plus the + `ln -s AGENTS.md CLAUDE.md` recipe) and YAML front-matter as the de-facto + standard Jekyll, Hugo and Gray Matter share. +- **README positioning**: both READMEs gain a schema-contracts chapter, the + conventions sourcing, a compact prior-art section (Cline Memory Bank, + Claude Code memory tool, Letta, Mem0, Zep — inspired-by wording + throughout), and the Anthropic vocabulary (structured note-taking, + 'just in time' context, context rot, compaction); the project tree lists + `schemas/`. English and Chinese stay semantically aligned. + +## 0.9.0 — 2026-11-14 + +The "installed and fully usable" release: the new-user journey is closed end +to end — first run, configuration, onboarding a project, and the patrol loop. + +- **First run without a config**: guster probes the conventional roots + (`~/Projects`, `~/code`, `~/dev`, `~/src`), scans hits under an + "Unconfigured" banner telling you to run `guster init`, and fails loud + (exit 1, pointing at `init --root`) when none exists. The silent fallback + to scanning the whole home directory is gone. `init` now writes the + detected roots instead of hardcoding `~/`; a bare `init` with no candidates + requires `--root`. +- **`guster check`**: `scan` → `doctor` → `score --trend` in one pass, one + report; the exit code carries the doctor semantics (2 high, 3 critical). + Human-driven patrol only — no scheduled runs. +- **`guster zones list|add|remove`**: manage the machine zones in + `config.json` with the same fail-loud validation as the rest of the config. +- **Config validation is uniform**: `roots` must be a list of existing + absolute paths (a bare string is a type error, never a list of characters), + `roots: []` is rejected, as are relative paths, missing zone directories + and a `hub` that is not a directory. +- **Default hub de-privatized**: nothing defaults to a private layout anymore; + without a config there is no hub and the banner says so. `hub status` + prints "no hub attached" and exits 0; `--hub` pointing at a non-hub + directory is a `hub-manifest-missing` finding instead of a silent clean + report. Hub wording: "missing" → "absent". +- **Registry drift (CTX-10)**: the machine table (`registry.json`), the hub's + human table (`projects/_registry.md`, read through its path column) and the + git roots on disk are three-way diffed; dead rows, unscanned rows and + unregistered projects are findings, never a CHI dimension. `host:/path` + remote rows are counted apart. +- **Data safety**: state writes are atomic (tmp + `os.replace`) and serialized + by an flock on `/.lock`; `--no-write` genuinely suppresses the + state file of `scan`, `doctor`, `score` and `check`; a corrupt registry + warns and rescans instead of silently self-healing. +- **Freshness and dates**: `verified_at` is the canonical date key; English + prose keys (`Last updated:`/`updated:`) join the Chinese family; without a + declared date the ladder falls back to the last git commit and then to file + mtime (labeled in the raw column). Index matching requires the backtick + form, so `a.md` no longer hits `data.md`. +- **Credentials**: rows with too few columns raise parse warnings instead of + vanishing; `guster credentials` prints bilingual scope labels; the fixed + 9-column order is documented with a copyable sample (README and spec). +- **Plans and recovery**: a bad plan reports the expected schema shape; + `.moved.md` pointers carry a `verified_at` date; only the newest 10 plan + backup directories are kept; a failed apply rolls back automatically, and + if the rollback itself fails the error names the backup directory for + manual recovery (there is no separate rollback command — the README now + says exactly this). +- **Skill**: new "New project onboarding" playbook (inventory roots/zones → + propose placement → user confirms → create + `init --project` → draft the + CTX-10 registry row → `scan`/`doctor` close the loop; git operations follow + the user's own git conventions). Waivers now state explicitly that a waiver + greens the gate, not the score. "Company repository" wording is replaced by + the mechanism: tracked files you do not own or have no authority over. + Private skill references are gone. +- **Docs**: README/README_CN gain a roots-and-zones configuration chapter + (including `scan_max_depth`), the `check` command, a full exit-code table + (0–4, shared with the skill), and the four fixed inaccuracies (backups + manual recovery, five state files, `export`'s default output path, the + credential sample row). Hub templates emit `verified_at` dates. The main + `guster --help` carries a one-line epilog about state writes and exit codes. + +## 0.8.0 — 2026-11-14 + +- **Declarative hub contract (schema 2)**: `hub.yaml` sections are now + declared, not prescribed. A schema-2 hub declares what it has — only + `knowledge` and `projects` are mandatory — so a hub without a `tools` + section validates instead of failing. Declared sections may point outside + the hub with an absolute or `~/` path (validated for existence, not + containment). Schema-1 manifests behave exactly as before. +- **Machine zones with governance profiles** (`config.json` `zones`): + `{path, profile}` entries describe tool areas that are not repositories. + The `light` profile scans AGENTS.md/README.md entry docs (pointer health, + freshness) and `.env` secrets without demanding `.agents/` slots — a + non-git tool layer is governed without pretending each tool is a project. + Registry and `status` carry the zone summary; bad zone config fails loud. +- **Tool entry docs join pointer checks**: artifacts of kind `tool` (hub + tools sections and machine zones) are now pointer-scanned like other entry + documents; previously only their AGENTS.md was seen. +- Pointer scan cap raised from 200 to 500 files per run. +- `status` warns when the cached registry was written by a different CLI + version, and prints declared zones. +- Default workspace roots no longer include the private `~/ZCodeProject`; + candidates are `~/IdeaProjects` and `~/Projects` (config overrides). +- `hub init` template now emits a schema-2 manifest. + +## 0.7.4 — 2026-11-12 + +- **CTX-09 opt-in now wins over malformedness** (`parse_note_header`): a + header that never declares `type:` can no longer report + `note-header-malformed`. Previously the parser bailed out as malformed + before reaching the `type:` gate, so legacy prose notes that merely open + with `---` (unclosed fence, prose inside the fence, a `----` opener, plain + non key:value lines) were flagged against the documented contract. The + opening fence is now strictly `---` (the same rule `parse_frontmatter` + applies), and only headers that opted in can be malformed. +- Note headers parse literal/folded block scalars (`updated: |` with an + indented block, `>` folded), aligning `parse_note_header` with + `parse_frontmatter` — a legal block-scalar date was previously reported + as a malformed line. +- **Apply rollback leaves no lying artifacts**: a `move` with a `.moved.md` + pointer now registers the pointer write in the undo list — a pre-existing + pointer is backed up and restored, a freshly written one is deleted — and + fresh `write` targets register an undo entry too. A failed batch now + restores sources, removes destinations, pointers and fresh writes, matching + the documented "backs up every touched path and rolls back on failure". + +## 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..392f621f --- /dev/null +++ b/external_plugins/guster/README.md @@ -0,0 +1,347 @@ +# 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. + +## Prior art + +The ideas are borrowed with credit; the implementation is guster's own. + +- **Cline Memory Bank** — inspired the layered responsibilities of the files (project cards, knowledge, state) and the banking rhythm of refreshing them per working session; both banks are plain markdown. +- **Claude Code memory tool** — inspired the one-fact-per-file note shape and front-matter metadata; guster's note headers spell the keys `name`, `description`, `type`. +- **Letta (MemGPT)** — inspired the label/limit abstraction of memory blocks behind guster's resident budgets. +- **Mem0** — inspired the decide-first stance (ADD/UPDATE/DELETE/NOOP, soft invalidation by marking instead of deleting) behind plan/apply/waiver; a waiver greens the gate, not the score. +- **Zep/Graphiti** — inspired the dual-timeline fact model (`valid_at`/`invalid_at`); `superseded_by` is its one-file simplification. +- The framing vocabulary — structured note-taking (agentic memory), 'just in time' context, context rot, compaction — is inspired by Anthropic's "Effective context engineering for AI agents" (2025-09-29). + +## 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`, backing up every touched path and rolling back + automatically when an operation fails. +- **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 +``` + +`guster check` runs the scan → doctor → score (with trend) pass as one command +when you want the whole picture at once. + +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 check` | `scan` + `doctor` + `score --trend` in one pass; exit code carries the doctor semantics | +| `guster zones list/add/remove` | Manage the machine zones in `config.json` | +| `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 | + +## Configuration: roots and zones + +`~/.agents/guster/config.json` declares where your context lives: + +```json +{ + "version": 1, + "roots": ["/home/me/Projects"], + "zones": [{ "path": "/home/me/tools", "profile": "light" }], + "scan_max_depth": 5 +} +``` + +- **`roots`** — the workspace roots scanned for git repositories. Must be a + list of existing absolute paths; a bare string, an empty list, a relative + path or a missing directory fails loud instead of being reinterpreted. +- **`hub`** — the attached Guster Hub path (written by `hub init`/`hub attach`). +- **`scan_max_depth`** — how deep under each root the scanner walks looking + for git repositories (default 5). Raise it for deeply nested layouts, lower + it to speed up huge trees. +- **`zones`** — tool areas that are not repositories: a tools directory you + want governed without pretending each tool is a project. A zone is scanned + light-style: its `AGENTS.md`/`README.md` entry docs join the pointer and + freshness checks, its `.env` files join the secret scan, and `.agents/` + slot compliance never applies. `profile` is recorded and must be + `light` or `full`; the scan itself is light for every zone today. + +Manage zones with the `zones` subcommand (`list`, `add --profile`, +`remove `); it writes `config.json` and applies the same validation. + +**First run without a config**: guster probes the conventional directories +`~/Projects`, `~/code`, `~/dev` and `~/src`. Hits are scanned with an +"Unconfigured" banner telling you to run `guster init` to pin them; if none +exists the command fails loud (exit 1) pointing at +`guster init --root `. Guster never silently scans your home directory. + +## 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 a declared key — `verified_at` (canonical), the Chinese + `最后更新` family or the English `Last updated:`/`updated:` — in the first 40 + lines, or a date that opens the filename (`2026-11-05-…`, `20261105-…`); + without one, the ladder falls back to the owning repository's last git + commit, then to file mtime (labeled as such in the raw column). 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..10` — `CTX-09` structured note header, `CTX-10` +machine-readable project registry — markdownlint-style stable IDs). It follows +open conventions instead of inventing new ones: the open `AGENTS.md` format +([agents.md](https://agents.md)) for instruction files — discovery is nested +and nearest-wins, the same discipline as CTX-01 (a nested `AGENTS.md` narrows +the parent, never contradicts it), and a host that only reads `CLAUDE.md` is +served with `ln -s AGENTS.md CLAUDE.md` — llms.txt-shaped link indexes +([llmstxt.org](https://llmstxt.org)), YAML front-matter for note headers (the +de-facto standard Jekyll, Hugo and Gray Matter share), and 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. + +## Schema contracts + +The two machine-readable formats carry JSON Schema (draft 2020-12) contracts, +derived from the code that reads and writes them: + +- [`schemas/hub.schema.json`](schemas/hub.schema.json) — `hub.yaml`. The + scaffolded manifest ships with a yaml-language-server modeline on its first + line, so the VS Code YAML extension (or any editor speaking + yaml-language-server) validates `hub.yaml` with zero configuration: + + ```yaml + # yaml-language-server: $schema=https://raw.githubusercontent.com/Lumos-789/guster/main/schemas/hub.schema.json + ``` + +- [`schemas/plan.schema.json`](schemas/plan.schema.json) — the plan JSON that + `plan` writes and `apply` executes. An agent hand-writing a batch plan has + the exact field contract to check against before the `apply` dry-run. + +## 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. A copyable row +(column order is fixed — see the format spec; the first six columns are +required, `keys`/`used_by`/`status` are optional): + +```markdown +| ID | scope | site | account | kind | locations | keys | used_by | status | +|---|---|---|---|---|---|---|---|---| +| example-host | personal | example.com | me@example.com | token | `~/.agents/credentials/example-host.env` | `EXAMPLE_HOST_TOKEN` | my-cli | active | +``` + +## 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` always writes a report file — `/context-health.md` by default, or the path you name with `--out` (plus a `.json` sibling with `--format json`). +- Guster keeps five files under its state directory (`~/.agents/guster/`, or `scoped//`): `config.json` (roots/zones/hub, written by `init`, `hub init`/`attach` and `zones`), `registry.json`, which every scanning command rewrites (`status` included, when no registry exists yet), `findings.json` from `doctor`, `metrics.jsonl` from `score`, and the transient `.lock` that serializes concurrent runs. `--no-write` suppresses the state writes of `scan`, `doctor`, `score` and `check` only. (`plans/` and `backups/` are directories, not files.) +- Credential values are never persisted or printed; scans keep only paths and redacted shapes. +- No automatic `git add`, `commit`, `push` or deploy. +- Tracked files you do not own or have no authority over — a shared or employer repository usually qualifies — are not ordinary migration targets. +- Plans are written to `~/.agents/guster/plans/`. `apply` copies every touched path under `~/.agents/guster/backups//` before changing it, and rolls back automatically when an operation fails; if the rollback itself fails, the error names that backup directory and you restore by copying the pre-change copies back. Only the newest 10 plan backup directories are kept. + +## Exit codes + +| Code | Meaning | +|---|---| +| `0` | success — clean run, or findings no worse than `medium` | +| `1` | tool/config error: malformed or unreadable plan, an apply that failed mid-operation and was rolled back, missing workspace root, invalid `config.json` (non-list `roots`, a bad root/zone/hub path, empty `roots`, no scan candidates — run `guster init`); `hub status` also returns 1 when the attached hub fails its checks | +| `2` | `doctor`/`check` found `high` (but no `critical`) findings; `score --fail-under` below target or CHI not computable; `hub validate` failed the lite contract; `link` found broken pointers; `upgrade` found disagreeing manifest versions | +| `3` | `doctor`/`check` found `critical` findings | +| `4` | `apply`, `init` or `hub init` refused the plan at preflight — nothing was touched (missing move source, existing destination, bad target type) | + +The same table ships in the `guster` skill so agents and humans read one +contract. Treat the exit code as the gate and CHI as the trend. + +## 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) +schemas/ JSON Schema contracts for hub.yaml and plan JSON +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..7e470494 --- /dev/null +++ b/external_plugins/guster/README_CN.md @@ -0,0 +1,287 @@ +# Guster + +Guster 是一个可分发的 Agent 插件,用来审计和演进整台机器的上下文:项目、指令文件、Skill、工具、知识、项目状态、证据和凭据引用;也可以初始化一个轻量 Hub,承载长期知识与项目状态。 + +它是上下文的**控制面**。Guster Hub 是可选的**数据面**:承载项目卡、知识、工具和报告的本地仓库。插件不替代 Hub,而是发现它、校验它,并帮助它保持自洽。 + +## 理念来源 + +理念皆有出处,实现是 guster 自己的: + +- **Cline Memory Bank** —— 启发了分层文件的职责划分(项目卡、知识、状态)与每个工作会话刷新一次的 banking 节奏;两边的记忆库都是纯 markdown。 +- **Claude Code memory tool** —— 启发了「一事实一文件」的笔记形状与 front-matter 元数据;guster 的笔记头把这些键拼写为 `name`、`description`、`type`。 +- **Letta (MemGPT)** —— 启发了 memory block 的 label/limit 抽象,guster 的常驻预算背后正是它。 +- **Mem0** —— 启发了先裁决再动手的语义(ADD/UPDATE/DELETE/NOOP、以标记代删除的软失效),落到 plan/apply/waiver;waiver 只让门禁变绿,不改分数。 +- **Zep/Graphiti** —— 启发了双时间轴事实建模(`valid_at`/`invalid_at`),`superseded_by` 是它的文件化简化。 +- 定位词汇——structured note-taking(agentic memory)、'just in time' context、context rot、compaction——借鉴自 Anthropic《Effective context engineering for AI agents》(2025-09-29)。 + +## 它做什么 + +- **发现** 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 +``` + +想一次拿到全貌时,`guster check` 一条命令跑 scan → doctor → 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 check` | `scan` + `doctor` + `score --trend` 一条命令跑完;退出码承接 doctor 语义 | +| `guster zones list/add/remove` | 管理 `config.json` 里的机器 zone | +| `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 版本一致性 | + +## 配置:roots 与 zones + +`~/.agents/guster/config.json` 声明你的上下文在哪里: + +```json +{ + "version": 1, + "roots": ["/home/me/Projects"], + "zones": [{ "path": "/home/me/tools", "profile": "light" }], + "scan_max_depth": 5 +} +``` + +- **`roots`** —— 扫 git 仓库的工作区根。必须是「已存在的绝对路径」列表; + 裸字符串、空列表、相对路径、不存在的目录都会 fail-loud,绝不悄悄换解释。 +- **`hub`** —— 已接入的 Guster Hub 路径(由 `hub init`/`hub attach` 写入)。 +- **`scan_max_depth`** —— 在每个 root 下找 git 仓库时的目录递归深度(默认 5)。 + 嵌套深的布局可以调大,巨大的目录树可以调小加速。 +- **`zones`** —— 不是仓库的工具区:想让一个工具目录被治理、又不必把每个工具 + 当成项目。zone 按 light 方式扫描:其 `AGENTS.md`/`README.md` 入口文档进指针 + 与新鲜度检查,`.env` 进密钥扫描,`.agents/` 六槽合规永不适用。`profile` + 会被记录且只允许 `light`/`full`;当前扫描对所有 zone 都是 light 行为。 + +zone 用 `zones` 子命令管理(`list`、`add --profile`、`remove `), +直接写 `config.json`,校验同上。 + +**没有 config 的首跑**:guster 探测约定目录 `~/Projects`、`~/code`、`~/dev`、 +`~/src`。命中即照常扫描并打「未配置」横幅提示跑 `guster init` 固定;一个都 +没有则 fail-loud(退出码 1)并指路 `guster init --root `。绝不静默扫 +整个 home 目录。 + +## 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/` 或机器级笔记不算状态文档。日期取自前 40 行内的声明键—— + `verified_at`(canonical)、中文 `最后更新` 族或英文 `Last updated:`/`updated:` + ——或文件名开头处的日期(`2026-11-05-…`、`20261105-…`);都没有时依次兜底到 + 所属仓库最后一次 git 提交、再兜底文件 mtime(Raw 列会标注来源)。正文深处的 + 日期和文件名里的工单号都不算新鲜度声明。 +- **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..10`——`CTX-09` 结构化条目头、`CTX-10` 机器可读项目登记表,markdownlint 式稳定编号)。它遵循开源惯例而不是另造一套:指令文件用 `AGENTS.md` 开放格式([agents.md](https://agents.md))——发现规则是嵌套 nearest-wins,与 CTX-01(嵌套只收窄、绝不矛盾)是同一条纪律,只认 `CLAUDE.md` 的宿主用 `ln -s AGENTS.md CLAUDE.md` 即可伺候——索引文件用 llms.txt 形态的链接列表([llmstxt.org](https://llmstxt.org))、笔记头用 YAML front-matter(Jekyll/Hugo/Gray Matter 共同坐实的业界事实标准)、规则编号学 markdownlint。 + +不守契约的文档不是被扣分——而是彻底隐身:正文里裸写的路径永远到不了指针检查、「上周更新」落到 git 时间兜底、索引里没有文件名等于没索引。0.7.0 起 `naked-pointer` finding 从另一侧堵上这个缺口:指令文档/索引页里留在链接和代码记号之外的文件路径会被直接报告,写不规范的形态同时也是会被标记的形态。 + +## Schema 契约 + +两份机器可读格式都带 JSON Schema(draft 2020-12)契约,字段全部从读写它们的代码实况推导: + +- [`schemas/hub.schema.json`](schemas/hub.schema.json) —— `hub.yaml` 清单。脚手架生成的 manifest 首行自带 yaml-language-server modeline,VS Code YAML 扩展(或任何接 yaml-language-server 的编辑器)零配置即可校验: + + ```yaml + # yaml-language-server: $schema=https://raw.githubusercontent.com/Lumos-789/guster/main/schemas/hub.schema.json + ``` + +- [`schemas/plan.schema.json`](schemas/plan.schema.json) —— `plan` 产出、`apply` 执行的 plan JSON。agent 手搓批量 plan 时,它就是完整的字段契约,`apply` dry-run 前先对照自查。 + +## 层级 + +| 层级 | 命令 | 计分维度 | +|---|---|---| +| 机器 | `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` 按范围输出。一行可复制的样例(列序固定——详见格式规范;前 6 列必填,`keys`/`used_by`/`status` 可选): + +```markdown +| ID | scope | site | account | kind | locations | keys | used_by | status | +|---|---|---|---|---|---|---|---|---| +| example-host | personal | example.com | me@example.com | token | `~/.agents/credentials/example-host.env` | `EXAMPLE_HOST_TOKEN` | my-cli | active | +``` + +## 八个平面 + +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` 总会写报告文件——默认 `/context-health.md`,或 `--out` 指定的路径(`--format json` 时另加 `.json` 姊妹文件)。 +- Guster 会在状态目录(`~/.agents/guster/` 或 `scoped//`)维护五个文件:`config.json`(roots/zones/hub,由 `init`、`hub init`/`attach` 和 `zones` 写入)、所有扫描类命令都会重写的 `registry.json`(`status` 在没有 registry 时也会写)、`doctor` 的 `findings.json`、`score` 的 `metrics.jsonl`,以及串行化并发运行的临时 `.lock`。`--no-write` 只对 `scan`、`doctor`、`score`、`check` 关闭本命令的状态写入。(`plans/` 与 `backups/` 是目录,不算文件。) +- 永不持久化或输出凭据值;扫描只保留路径和脱敏形态。 +- 不自动 `git add` / `commit` / `push` / 部署。 +- 不拥有、也无权变更其 tracked 文件的仓库——共享仓或雇主仓通常是——不是普通迁移目标。 +- plan 写 `~/.agents/guster/plans/`。`apply` 改动前把每个被触碰路径复制到 `~/.agents/guster/backups//`,操作失败自动回滚;若回滚本身也失败,报错会点名该备份目录,手工把改动前副本拷回即完成恢复。plan 备份目录只保留最近 10 个。 + +## 退出码 + +| 码 | 含义 | +|---|---| +| `0` | 成功——干净运行,或 finding 严重度不高于 `medium` | +| `1` | 工具/配置错误:plan 损坏或不可读、执行中失败且已回滚的 apply、工作区根不存在、`config.json` 无效(`roots` 非列表、root/zone/hub 路径坏、`roots` 为空、无可扫候选——跑 `guster init`);已接入 hub 校验不过时 `hub status` 也返回 1 | +| `2` | `doctor`/`check` 发现 `high`(但无 `critical`)finding;`score --fail-under` 低于目标或 CHI 不可算;`hub validate` 未过 Lite 契约;`link` 发现断链;`upgrade` 发现各 manifest 版本不一致 | +| `3` | `doctor`/`check` 发现 `critical` finding | +| `4` | `apply`、`init` 或 `hub init` 在 preflight 拒绝了 plan——什么都没动(move 源不存在、目标已存在、目标类型不对) | + +同一张表也随 `guster` skill 发布,agent 与人读同一份契约。门禁看退出码,CHI 看趋势。 + +## 插件宿主 + +| 宿主 | 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 脚手架(唯一随包发布的模板集) +schemas/ hub.yaml 与 plan JSON 的 JSON Schema 契约 +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/schemas/hub.schema.json b/external_plugins/guster/schemas/hub.schema.json new file mode 100644 index 00000000..fa72921e --- /dev/null +++ b/external_plugins/guster/schemas/hub.schema.json @@ -0,0 +1,111 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Lumos-789/guster/main/schemas/hub.schema.json", + "title": "Guster hub manifest", + "description": "Format contract for hub.yaml, the Guster hub manifest. Field semantics follow src/guster/layout.py and src/guster/hub.py. Guster reads values in their string form, so quoted and unquoted spellings of schema_version are equivalent.", + "type": "object", + "required": [ + "schema_version" + ], + "additionalProperties": false, + "properties": { + "schema_version": { + "description": "Manifest format major version; absent means 1.", + "enum": ["1", "2", 1, 2] + }, + "profile": { + "description": "Hub profile; any other value draws a `hub validate` warning.", + "type": "string", + "enum": ["lite", "full"] + }, + "hub_version": { + "description": "Version of the hub's own contract; informational, shown by `hub status`.", + "type": "string" + }, + "min_guster": { + "description": "Minimum guster CLI version this hub needs; compared numerically by dotted segments.", + "type": "string" + }, + "name": { + "description": "Human-readable hub name.", + "type": "string" + }, + "created_at": { + "description": "ISO date the hub was created.", + "type": "string" + }, + "paths": { + "description": "Declared hub sections. Relative values are hub-relative and must not contain '..'; a hub may also declare external sections with absolute or ~/ paths. Schema 2 must declare knowledge and projects.", + "type": "object", + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "properties": { + "knowledge": { + "type": "string", + "minLength": 1 + }, + "projects": { + "type": "string", + "minLength": 1 + }, + "tools": { + "type": "string", + "minLength": 1 + } + } + }, + "doctor": { + "description": "Settings for `guster doctor`.", + "type": "object", + "additionalProperties": false, + "properties": { + "waivers": { + "description": "Silence a finding; every waiver must carry a written reason.", + "type": "array", + "items": { + "type": "object", + "required": [ + "rule", + "reason" + ], + "additionalProperties": false, + "properties": { + "rule": { + "description": "Rule name of the waived finding, e.g. broken-pointer; the name a doctor finding reports, not the CTX spec id.", + "type": "string", + "minLength": 1 + }, + "path": { + "description": "Optional scope; only findings for this path are waived.", + "type": "string" + }, + "reason": { + "description": "Written justification; a waiver without one fails loud.", + "type": "string", + "minLength": 1 + } + } + } + } + } + } + }, + "if": { + "required": ["schema_version"], + "properties": { + "schema_version": { + "enum": ["2", 2] + } + } + }, + "then": { + "required": ["paths"], + "properties": { + "paths": { + "required": ["knowledge", "projects"] + } + } + } +} diff --git a/external_plugins/guster/schemas/plan.schema.json b/external_plugins/guster/schemas/plan.schema.json new file mode 100644 index 00000000..7435297e --- /dev/null +++ b/external_plugins/guster/schemas/plan.schema.json @@ -0,0 +1,161 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Lumos-789/guster/main/schemas/plan.schema.json", + "title": "Guster plan", + "description": "Format contract for the plan JSON produced by src/guster/planner.py (build_plan and the op_* constructors) and consumed by `guster apply` (src/guster/executor.py). mkdir/move/write run on apply; manual ops stay a skipped checklist.", + "type": "object", + "required": [ + "version", + "id", + "created_at", + "title", + "notes", + "operations" + ], + "additionalProperties": false, + "properties": { + "version": { + "description": "Plan format version; the integer 1.", + "type": "integer", + "enum": [1] + }, + "id": { + "description": "Unique plan id; also the plan file name stem.", + "type": "string", + "minLength": 1 + }, + "created_at": { + "description": "ISO timestamp of plan creation.", + "type": "string" + }, + "title": { + "description": "One-line plan title.", + "type": "string", + "minLength": 1 + }, + "notes": { + "description": "What the plan does and how to apply it; may be empty.", + "type": "string" + }, + "operations": { + "description": "Ordered operations applied one by one; a failure rolls back.", + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "required": [ + "kind", + "path" + ], + "additionalProperties": false, + "properties": { + "kind": { + "enum": ["mkdir"] + }, + "path": { + "description": "Directory to create.", + "type": "string", + "minLength": 1 + } + } + }, + { + "type": "object", + "required": [ + "kind", + "src", + "dst" + ], + "additionalProperties": false, + "properties": { + "kind": { + "enum": ["move"] + }, + "src": { + "description": "Existing file or directory to move.", + "type": "string", + "minLength": 1 + }, + "dst": { + "description": "Destination path; must not exist yet.", + "type": "string", + "minLength": 1 + }, + "pointer": { + "description": "Optional note left at the old location; null when unused.", + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + }, + "content": { + "type": "string" + } + } + } + ] + } + } + }, + { + "type": "object", + "required": [ + "kind", + "path", + "content" + ], + "additionalProperties": false, + "properties": { + "kind": { + "enum": ["write"] + }, + "path": { + "description": "File to write.", + "type": "string", + "minLength": 1 + }, + "content": { + "description": "Full file content.", + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "kind", + "path", + "action", + "reason" + ], + "additionalProperties": false, + "properties": { + "kind": { + "enum": ["manual"] + }, + "path": { + "description": "Path the action is about; may be empty.", + "type": "string" + }, + "action": { + "description": "What a human should do.", + "type": "string" + }, + "reason": { + "description": "Why; usually the finding rule and message.", + "type": "string" + } + } + } + ] + } + } + } +} 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..2bc955ff --- /dev/null +++ b/external_plugins/guster/skills/guster-hub/SKILL.md @@ -0,0 +1,85 @@ +--- +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 `verified_at: YYYY-MM-DD` in every state document (`最后更新:` is the + accepted Chinese form; `verified_at` is canonical — see the format spec). +- 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; propose durable notes to + the user and let them (or their capture workflow of choice) file them. diff --git a/external_plugins/guster/skills/guster/SKILL.md b/external_plugins/guster/skills/guster/SKILL.md new file mode 100644 index 00000000..6ec55140 --- /dev/null +++ b/external_plugins/guster/skills/guster/SKILL.md @@ -0,0 +1,214 @@ +--- +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 onboard a new project — place it, scaffold it and register it (see below); +- 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, declared `zones` and 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`, `score` +and `check`. + +For a human-driven patrol, `guster check` runs scan → doctor → score with the +trend in one pass; its exit code carries the doctor semantics. + +## 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 check # scan + doctor + score --trend in one pass +guster zones list # configured machine zones +guster zones add [--profile light|full] +guster zones remove +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 tracked files you do not own or have no authority over as + ordinary migration targets** — the test is ownership/authority over the + tracked files (a shared or employer repository usually fails it), not the + word "company". +- **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 (tracked +files owned upstream, host limitations), not to skip ordinary work. + +A waiver greens the gate, not the score: waived findings no longer set the +`doctor` exit code, but metrics are computed from the registry before waivers +apply, so CHI does not improve. A waived problem is an acknowledged problem, +not a healthy one. + +## Format spec + +Context assets follow the versioned format contract in `reference/spec.md` +(CTX-01..10): `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, and the +machine-readable project registry table. When writing or moving a context +asset, follow the spec; when a doctor finding fires, it names the rule that +was broken. CTX-09 note headers also accept optional `name` and `description` +keys (the one-fact-per-file shape inspired by the Claude Code memory tool) and +`superseded_by` (metadata only; `superseded_by` never joins the pointer scan), +and the plugin's `schemas/` directory carries JSON Schema +(draft 2020-12) contracts for `hub.yaml` and the plan JSON. + +## New project onboarding + +When the user starts a new project (新项目开张/立项/开新仓), the goal is that it +lands in the right place, gets the standard `.agents/` layout and shows up in +the hub registry — with the user confirming every structural step. This is +orchestration of existing commands, not new machinery. + +1. **Inventory the layout** — `guster status --json` (or read + `~/.agents/guster/config.json`): configured `roots`, `zones`, attached hub. + With no config yet the report banner names the auto-scanned roots; suggest + `guster init` to pin them before adding a project. +2. **Propose a placement** — under a configured root or zone the user already + governs, never a new top-level directory of your own invention. Naming and + sub-structure follow the user's own conventions (their instruction files, + their existing registry rows), not ours. +3. **User confirms the path** before anything is created. +4. **Create** — `mkdir` the directory; `git init` and all other git operations + follow the user's own git conventions (Guster never runs git). Then + scaffold the standard layout with `guster init --project ` — the + default dry run lists the tree it will create, which also catches typos in + the path — and apply with `--yes` once the user has read it. +5. **Register** — draft a CTX-10 row for the hub's `projects/_registry.md` + (slug, display name, project path, note) and hand it to the user to paste; + Guster does not edit hub content itself. +6. **Close the loop** — `guster scan` so the project enters the machine table, + then `guster doctor`: zero new findings (placement, registry row, freshness) + is the definition of done. `guster check` adds a score baseline in the same + pass. + +## 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 + +| Code | Meaning | +|---|---| +| `0` | success — clean run, or findings no worse than `medium` | +| `1` | tool/config error: malformed or unreadable plan, an apply that failed mid-operation and was rolled back, missing workspace root, invalid `config.json` (non-list `roots`, a bad root/zone/hub path, empty `roots`, no scan candidates — run `guster init`); `hub status` also returns 1 when the attached hub fails its checks | +| `2` | `doctor`/`check` found `high` (but no `critical`) findings; `score --fail-under` below target or CHI not computable; `hub validate` failed the lite contract; `link` found broken pointers; `upgrade` found disagreeing manifest versions | +| `3` | `doctor`/`check` found `critical` findings | +| `4` | `apply`, `init` or `hub init` refused the plan at preflight — nothing was touched (missing move source, existing destination, bad target type) | 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..8edd6af9 --- /dev/null +++ b/external_plugins/guster/skills/guster/reference/spec.md @@ -0,0 +1,112 @@ +# 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 open format for the tool-agnostic + instruction file. Discovery is nested: hosts collect `AGENTS.md` files + walking outward from the working directory and the nearest one wins. On + that precedence CTX-01 stacks a stricter discipline: a nested file narrows + the parent, never contradicts it. Guster reads `AGENTS.md` (and legacy + `CLAUDE.md`) wherever a host reads them and does not invent a competing + instruction format; a host that only knows `CLAUDE.md` is served with + `ln -s AGENTS.md CLAUDE.md`. +- **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. +- **YAML front-matter** — the `---`-delimited metadata block; the de-facto + standard Jekyll, Hugo and Gray Matter share. CTX-09 rides it rather than + inventing a note format. +- **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. The canonical key is `verified_at: YYYY-MM-DD`; the Chinese prose keys (`最后更新`/`更新时间`/`更新于`/`最近核实`/`最后核实`/`已核实`) and the English prose keys (`Last updated:`/`updated:`) are accepted equivalents within the first 40 lines, as is a date that opens the filename. Without a declared date the ladder falls back to the repository's last git commit, then to file mtime; no signal at all 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. Optional keys: `name` and `description` state what the note is (the one-fact-per-file shape inspired by the Claude Code memory tool), and `superseded_by` names the path or stem of the note that replaced this one; that key is metadata about the note, not a truth source, so it never joins the pointer scan and never raises a finding. | `note-header-malformed`, `broken-pointer`, `naked-pointer` | +| CTX-10 | The hub's project registry (`projects/_registry.md`) is a machine-readable table. The header row names the columns; whichever column header contains `path` or `路径` is the path column, and each row's cell there holds one project location — `~/...`, absolute, or relative to the hub root. The slug column (first column) never counts as a path. Remote forms (`host:/path`, URLs) and placeholder cells (`-`, `—`) are skipped, not resolved. | `registry-drift` | + +### CTX-10 in detail + +The registry table is read through its path column, so a row is only as good +as its path cell. Conventions that keep the three-way diff (machine table +`registry.json` ↔ this human table ↔ git roots on disk) meaningful: + +- Header row first: the parser finds the path column by header name + (`path` / `路径`, case-insensitive) and never guesses by position. +- One project per row; the path cell names the project **directory**, not a + file inside it. Trailing `/` and wrapping backticks are tolerated. +- `~/`-relative paths resolve against the user's home; bare relative paths + resolve against the **hub root**; absolute paths resolve as-is. +- A row whose path does not exist on disk is a dead row (`registry-drift`, + medium); a row that exists but is never scanned points outside the + configured roots (medium); a scanned project with no row is unregistered + (low). Remote references are counted apart instead of drifting forever. + +### Credential registry table + +CTX-08's registry (`~/.agents/credentials/registry.md`) is a markdown table +with a fixed column order; only paths and variable **names** live here, +never values: + +| # | Column | Required | +|---|---|---| +| 1 | ID | yes | +| 2 | scope (`company`/`personal`) | yes | +| 3 | site | yes | +| 4 | account | yes | +| 5 | kind | yes | +| 6 | locations | yes | +| 7 | keys (variable names) | no | +| 8 | used_by | no | +| 9 | status | no | + +A pipe row with fewer than the six required columns is a parse warning, not +silence; `guster credentials` prints it. + +## 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; a + new project gets its registry row by CTX-10. +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. + `name` and `description` are optional metadata; so is `superseded_by`, + which marks the note a better one replaced — the chain is for readers; + the scanner never resolves it. 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..30c9c1fa --- /dev/null +++ b/external_plugins/guster/src/guster/__init__.py @@ -0,0 +1,3 @@ +"""Guster: agent context steward.""" + +__version__ = "0.9.1" 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..9b3fbd11 --- /dev/null +++ b/external_plugins/guster/src/guster/cli.py @@ -0,0 +1,1004 @@ +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, count_zone_tools, 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_strict, + now_iso, + save_json, + state_lock, + write_text, +) + + +def default_state_dir(home: Path) -> Path: + return home / ".agents" / "guster" + + +def state_dir_from(args: argparse.Namespace) -> Path: + """Explicit --state-dir or the machine default; used by commands that do + not need a full scan config (init/apply/plan/hub/zones).""" + if args.state_dir: + return Path(args.state_dir).expanduser() + return default_state_dir(Path.home()) + + +def load_config(config_path: Path) -> Dict[str, Any]: + if not config_path.is_file(): + return {} + 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}") + return local + + +def resolve_root(root: Path) -> Path: + 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()}") + return root + + +def parse_roots(raw: Any, config_path: Path) -> List[Path]: + """Config roots must be existing absolute directories; one bad entry stops + the run instead of being silently reinterpreted (a bare string is a type + error, never a list of characters).""" + if not isinstance(raw, list): + raise ValueError( + f"Guster config 'roots' must be a list of absolute paths, " + f"got {type(raw).__name__}: {config_path}" + ) + if not raw: + raise ValueError(f"Guster config 'roots' must list at least one path: {config_path}") + roots: List[Path] = [] + for item in raw: + if not isinstance(item, str) or not item.strip(): + raise ValueError( + f"Guster config 'roots' entries must be non-empty strings: {item!r} in {config_path}" + ) + path = Path(item).expanduser() + if not path.is_absolute(): + raise ValueError( + f"Guster config 'roots' entries must be absolute paths: {item!r} in {config_path}" + ) + roots.append(resolve_root(path)) + return roots + + +def parse_zones(raw: Any, config_path: Path) -> List[Dict[str, str]]: + if not isinstance(raw, list): + raise ValueError(f"Guster config 'zones' must be a list: {config_path}") + zones: List[Dict[str, str]] = [] + for item in raw: + if not isinstance(item, dict) or not str(item.get("path", "")).strip(): + raise ValueError(f"zone entry needs a 'path' in {config_path}: {item!r}") + zone_path = Path(str(item["path"])).expanduser() + if not zone_path.is_absolute(): + raise ValueError( + f"Guster config 'zones' entries must be absolute paths: {item!r} in {config_path}" + ) + profile = str(item.get("profile", "light")) + if not zone_path.is_dir(): + raise ValueError(f"configured zone does not exist: {zone_path.resolve()}") + if profile not in {"light", "full"}: + raise ValueError(f"zone profile must be 'light' or 'full': {profile}") + zones.append({"path": str(zone_path), "profile": profile}) + return zones + + +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 cli_roots(raw: Optional[Sequence[str]]) -> List[Path]: + """--root values follow the config roots discipline: existing absolute dirs, + so the persisted registry never carries a path that drifts with CWD.""" + roots: List[Path] = [] + for item in raw or []: + path = Path(item).expanduser() + if not path.is_absolute(): + raise ValueError(f"--root paths must be absolute: {item!r}") + roots.append(resolve_root(path)) + return roots + + +def parse_max_depth(raw: Any, config_path: Path) -> int: + if isinstance(raw, bool) or not isinstance(raw, int) or raw <= 0: + raise ValueError( + f"Guster config 'scan_max_depth' must be a positive integer, " + f"got {raw!r} ({type(raw).__name__}) in {config_path}" + ) + return raw + + +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" + unconfigured = not config_path.is_file() + local = load_config(config_path) + + hub: Optional[Path] = None + if args.hub: + hub = Path(args.hub).expanduser() + elif "hub" in local: + raw_hub = local["hub"] + if not isinstance(raw_hub, str) or not raw_hub.strip(): + raise ValueError(f"Guster config 'hub' must be a path string: {config_path}") + hub = Path(raw_hub).expanduser() + if hub is not None: + if not hub.exists(): + raise ValueError(f"hub directory does not exist: {hub.resolve()}") + if not hub.is_dir(): + raise ValueError(f"hub is not a directory: {hub.resolve()}") + + explicit_roots = cli_roots(args.root) + 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" and hub is None: + raise ValueError("--scope hub needs --hub or an attached hub") + auto_roots = False + if scope == "hub": + roots: List[Path] = [] + elif explicit_roots: + roots = explicit_roots + elif "roots" in local: + roots = parse_roots(local.get("roots"), config_path) + else: + roots = default_roots(home) + if not roots: + raise ValueError( + "no workspace roots configured and none of " + "~/Projects, ~/code, ~/dev, ~/src exists — " + "run `guster init --root ` to configure Guster" + ) + auto_roots = True + + 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] + zones = parse_zones(local.get("zones", []), config_path) + + return ScanConfig( + home=home, + hub=hub, + roots=roots, + state_dir=state_dir, + plugin_root=plugin_root, + max_depth=parse_max_depth(local.get("scan_max_depth", 5), config_path), + scope=scope, + zones=zones, + unconfigured=unconfigured, + auto_roots=auto_roots, + ) + + +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) + with state_lock(config.state_dir): + registry = scan(config) + if write: + save_json(config.state_dir / "registry.json", registry) + return registry + + +def load_registry(config: ScanConfig) -> Optional[Dict[str, Any]]: + """Cached registry; corrupt content warns and reads as absent so `status` + rescans instead of silently self-healing.""" + path = config.state_dir / "registry.json" + data, error = load_json_strict(path) + if error is not None: + print(f"[guster] warning: {path} is corrupt ({error}); rescanning", file=sys.stderr) + return None + if data is None: + return None + if not isinstance(data, dict): + print(f"[guster] warning: {path} is not a JSON object; rescanning", file=sys.stderr) + return None + return data + + +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) + with state_lock(config.state_dir): + 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 as exc: + print(f"[guster] warning: {path} last line is corrupt ({exc}); no cached metric shown", file=sys.stderr) + return None + if not isinstance(item, dict): + print(f"[guster] warning: {path} last line is not a JSON object; no cached metric shown", file=sys.stderr) + 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) + with state_lock(config.state_dir): + 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"]), + "scope": registry.get("scope"), + "state_dir": str(config.state_dir), + })) + 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=not args.no_write) + 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=not args.no_write) + 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_check(args: argparse.Namespace) -> int: + """scan -> doctor -> score --trend in one pass; the exit code carries the + doctor semantics (3 critical, 2 high, 0 clean).""" + config = build_config(args) + registry = do_scan(config, write=not args.no_write) + result = evaluate(registry, config) + policy_result = result["policy"] + if not args.no_write: + write_findings(config, policy_result) + append_metric(config, result["metrics"]) + if args.json: + print(json_dumps({"policy": policy_result, "metrics": result["metrics"]})) + elif not args.quiet: + print(render_report(registry, policy_result, result["metrics"])) + path = config.state_dir / "metrics.jsonl" + if path.is_file(): + lines = [line for line in path.read_text(encoding="utf-8").splitlines() if line.strip()][-10:] + if lines: + 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 + severities = {item["severity"] for item in policy_result["findings"]} + if "critical" in severities: + return 3 + if "high" in severities: + return 2 + return 0 + + +def cmd_plan(args: argparse.Namespace) -> int: + if args.plan_action == "findings": + config = build_config(args) + ensure_state(config) + registry = do_scan(config, write=True) + result = evaluate(registry, config) + plan = plan_from_findings(result["policy"]["findings"]) + state_dir = config.state_dir + elif args.plan_action == "move": + src = Path(args.source).expanduser() + dst = Path(args.destination).expanduser() + plan = plan_move(src, dst) + state_dir = state_dir_from(args) + elif args.plan_action == "init": + plan = plan_init_project(Path(args.project).expanduser()) + state_dir = state_dir_from(args) + else: + raise SystemExit(f"unknown plan action: {args.plan_action}") + + path = save_plan(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: + 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, + state_dir_from(args), + 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: + state_dir = state_dir_from(args) + if args.project: + project = Path(args.project).expanduser() + plan = plan_init_project(project) + path = save_plan(state_dir, plan) + result = apply_plan(plan, state_dir, dry_run=not args.yes, yes=args.yes) + if not args.json and not args.quiet: + print(f"plan: {path}") + if result.get("dry_run"): + missing: List[Path] = [] + probe = project.resolve() + while not probe.exists() and probe != probe.parent: + missing.append(probe) + probe = probe.parent + if missing: + print(" these directories do not exist yet and will be created:") + for entry in sorted(missing, key=lambda item: len(item.parts)): + print(f" {entry}") + for index, operation in enumerate(plan.get("operations", []), start=1): + kind = operation.get("kind") + if kind == "mkdir": + print(f" {index}. mkdir {operation['path']}") + elif kind == "write": + print(f" {index}. write {operation['path']}") + else: + explicit_roots = cli_roots(args.root) + result = init_home( + state_dir, + yes=args.yes, + dry_run=not args.yes, + home=Path.home(), + roots=explicit_roots or None, + ) + 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: + state_dir = state_dir_from(args) + action = args.hub_action + local = load_config(state_dir / "config.json") + attached: Optional[Path] = None + if "hub" in local: + raw_hub = local["hub"] + if not isinstance(raw_hub, str) or not raw_hub.strip(): + raise ValueError( + f"Guster config 'hub' must be a path string: {state_dir / 'config.json'}" + ) + attached = Path(raw_hub).expanduser() + if attached is None and not getattr(args, "path", None) and not getattr(args, "hub", None): + if action == "status": + if args.json: + print(json_dumps({"attached": False, "hub": None})) + elif not args.quiet: + print("no hub attached") + return 0 + if action == "validate": + raise ValueError("no hub attached; pass --path to validate one") + target = Path( + getattr(args, "path", None) + or getattr(args, "hub", None) + or attached + or default_hub_path(Path.home()) + ).expanduser() + + if action == "init": + result = init_hub( + target, + 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(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']}") + print(" run `guster scan` to refresh the registry with the hub") + 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 '(absent)'} " + f"schema={manifest.get('schema_version') or '(absent)'} " + f"hub={manifest.get('hub_version') or '(absent)'} " + f"min_guster={manifest.get('min_guster') or '(absent)'}" + ) + print(f"Plugin: {info['plugin']}") + paths = info.get("paths") or {} + if paths: + print( + "Paths: " + f"knowledge={paths.get('knowledge', '(absent)')} " + f"projects={paths.get('projects', '(absent)')} " + f"tools={paths.get('tools', '(absent)')}" + ) + 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_zones(args: argparse.Namespace) -> int: + state_dir = state_dir_from(args) + config_path = state_dir / "config.json" + config = load_config(config_path) + action = args.zones_action + + if action == "list": + zones = parse_zones(config.get("zones", []), config_path) + if args.json: + print(json_dumps({"zones": zones})) + elif not args.quiet: + if not zones: + print("no zones configured; add one with `guster zones add `") + for item in zones: + zone_path = Path(item["path"]).expanduser() + print(f"{item['path']} profile={item['profile']} tools={count_zone_tools(zone_path)}") + return 0 + + zone_path = Path(args.path).expanduser() + raw_zones = config.get("zones", []) + if not isinstance(raw_zones, list): + raise ValueError(f"Guster config 'zones' must be a list: {config_path}") + + if action == "add": + if not zone_path.is_dir(): + raise ValueError(f"zone directory does not exist: {zone_path.resolve()}") + for item in raw_zones: + if isinstance(item, dict) and str(item.get("path", "")).strip(): + if Path(str(item["path"])).expanduser().resolve() == zone_path.resolve(): + raise ValueError(f"zone already configured: {zone_path.resolve()}") + entry = {"path": str(zone_path), "profile": args.profile} + config.setdefault("version", 1) + config["zones"] = raw_zones + [entry] + zones = parse_zones(config["zones"], config_path) + state_dir.mkdir(parents=True, exist_ok=True) + save_json(config_path, config) + if args.json: + print(json_dumps({"ok": True, "added": entry, "zones": zones, "config": str(config_path)})) + elif not args.quiet: + print(f"added zone: {entry['path']} (profile: {entry['profile']})") + print(f"config: {config_path}") + return 0 + + if action == "remove": + remaining = [ + item + for item in raw_zones + if not ( + isinstance(item, dict) + and str(item.get("path", "")).strip() + and Path(str(item["path"])).expanduser().resolve() == zone_path.resolve() + ) + ] + if len(remaining) == len(raw_zones): + raise ValueError(f"zone is not configured: {zone_path.resolve()}") + if remaining: + config["zones"] = remaining + else: + config.pop("zones", None) + state_dir.mkdir(parents=True, exist_ok=True) + save_json(config_path, config) + if args.json: + print(json_dumps({"ok": True, "removed": str(zone_path), "config": str(config_path)})) + elif not args.quiet: + print(f"removed zone: {zone_path}") + print(f"config: {config_path}") + return 0 + + raise SystemExit(f"unknown zones action: {action}") + + +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 warning in info.get("parse_warnings") or []: + print(f" warning: {warning}") + for scope in ("company", "personal", "unknown"): + items = scopes.pop(scope, []) + if not items: + continue + label = {"company": "company 公司", "personal": "personal 个人", "unknown": "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.", + epilog=( + "Scans refresh Guster's state files; workspace writes need a plan with --yes. " + "Exit codes: 1 tool/config error, 2 high findings, 3 critical findings, 4 plan refused at preflight." + ), + ) + 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 registry.json or 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 write registry.json or 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) + + check = subparsers.add_parser( + "check", + parents=[common], + help="Run scan, doctor and score --trend in one pass.", + ) + check.add_argument("--no-write", action="store_true", help="Do not write registry, findings or metrics.") + check.set_defaults(func=cmd_check) + + zones_parser = subparsers.add_parser("zones", help="List or manage configured machine zones.") + zones_sub = zones_parser.add_subparsers(dest="zones_action", required=True) + zones_list = zones_sub.add_parser("list", parents=[common], help="List configured zones.") + zones_list.set_defaults(func=cmd_zones) + zones_add = zones_sub.add_parser("add", parents=[common], help="Add a zone to config.json.") + zones_add.add_argument("path", help="Zone directory (must exist).") + zones_add.add_argument("--profile", choices=["light", "full"], default="light", help="Zone profile.") + zones_add.set_defaults(func=cmd_zones) + zones_remove = zones_sub.add_parser("remove", parents=[common], help="Remove a configured zone.") + zones_remove.add_argument("path", help="Zone directory as configured.") + zones_remove.set_defaults(func=cmd_zones) + + 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..1d6ff469 --- /dev/null +++ b/external_plugins/guster/src/guster/discover.py @@ -0,0 +1,1099 @@ +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_indexed, + 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) + # Declared machine zones (`config.json` `zones`): {"path": ..., "profile": ...}. + # "light" zones are tool areas — AGENTS/README artifacts and secrets, no + # project slots — so a non-git tool layer is governed without pretending + # each tool is a repository. + zones: List[Dict[str, str]] = field(default_factory=list) + # First-run UX: no config.json existed, and roots came from auto-detection. + # Drives the "run `guster init`" banner; empty candidates fail loud earlier. + unconfigured: bool = False + auto_roots: bool = False + + +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]: + """Generic first-run candidates; empty when none exist so callers fail loud.""" + candidates = [home / name for name in ("Projects", "code", "dev", "src")] + seen: Set[str] = set() + roots: List[Path] = [] + for candidate in candidates: + if candidate.is_dir(): + resolved = str(candidate.resolve()) + if resolved not in seen: + seen.add(resolved) + roots.append(candidate) + return roots + + +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]]] = {} + used_ids: Set[str] = set() + for root in sorted(project_roots, key=lambda item: str(item)): + tracked = git_tracked(root) + project_tracked[str(root)] = tracked + project = scan_project( + root=root, + group=root.parent.name, + tracked=tracked, + now=config.now, + taken=used_ids, + ) + used_ids.add(project["id"]) + projects.append(project) + + mark_containers(projects) + index_texts = load_index_texts(home, hub, projects) + + walk_errors: List[str] = [] + artifacts: List[Dict[str, Any]] = [] + zones = [ + item + for item in config.zones + if Path(item["path"]).expanduser().is_dir() + ] + if scope != "hub": + artifacts.extend(scan_project_agents(projects, index_texts, project_tracked, walk_errors)) + artifacts.extend(scan_zones(zones, walk_errors)) + artifacts.extend(scan_hub_knowledge(hub, index_texts, walk_errors)) + if scope != "hub": + artifacts.extend(scan_local_wiki(home, index_texts, walk_errors)) + 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, + zones=zones, + include_home=scope != "hub", + errors=walk_errors, + ) + 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, + "state_dir": str(config.state_dir), + "unconfigured": config.unconfigured, + "auto_roots": config.auto_roots, + "workspaces": [str(root) for root in roots], + "scan_errors": {"count": len(walk_errors), "examples": walk_errors[:10]}, + "zones": [ + { + "path": str(Path(item["path"]).expanduser()), + "profile": item.get("profile", "light"), + "tools": count_zone_tools(Path(item["path"]).expanduser()), + } + for item in zones + ], + "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, walk_errors), + ), + } + 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, + taken: Optional[Set[str]] = None, +) -> 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, taken) + 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, taken: Optional[Set[str]] = None) -> str: + """Slug id; a same-name sibling gets a short path-hash suffix so + `project:{id}` index keys never overwrite each other.""" + cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", name.strip()).strip("-") + base = cleaned or sha256_text(str(root))[:10] + if taken is not None and base in taken: + return f"{base}-{sha256_text(str(root))[:8]}" + return base + + +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) + tools_root = layout.get("tools") + if tools_root: + texts["tools"] = read_text(tools_root / "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 + mtime = 0.0 + try: + # One stat, no exists-then-stat window: a file deleted mid-scan reads + # as absent (0), never as a crash. + info = path.stat() + size = info.st_size + mtime = info.st_mtime + 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 + oversized = size > HASH_LIMIT + file_hash = "" if oversized else sha256_file(path) + 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": mtime, + "hash": file_hash, + "hash_error": not oversized and not file_hash, + "frontmatter": frontmatter, + "title": first_heading(body if text else "", path.stem), + "indexed": indexed, + "git_tracked": tracked_flag, + "verified_at": frontmatter.get("verified_at") or frontmatter.get("updated", ""), + "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]]], + errors: Optional[List[str]] = None, +) -> 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"}, errors=errors): + if "openspec" in path.parts[:-1]: + # OpenSpec store config and change metadata are state, not knowledge. + continue + if path.is_symlink(): + continue + indexed = is_indexed(path.name, 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], + errors: Optional[List[str]] = None, +) -> 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, errors=errors): + indexed = None + if check_index and kind == "knowledge": + indexed = is_indexed(path.name, 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 + ) + ) + tools_root = layout.get("tools") + for path in iter_files(tools_root, suffixes={".md"}, max_depth=3, errors=errors) if tools_root else []: + 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], + errors: Optional[List[str]] = None, +) -> 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"}, errors=errors): + if path.is_symlink(): + # Compatibility links point at a canonical file already counted. + continue + records.append( + artifact_record( + path, + "knowledge", + "knowledge", + "local", + None, + is_indexed(path.name, index_text), + ) + ) + return records + + +def count_zone_tools(root: Path) -> int: + """Tool count for a zone summary: flat zones hold tools at depth 1, + grouped zones (like common/company) hold them at depth 2.""" + if not root.is_dir(): + return 0 + count = 0 + for child in sorted(root.iterdir()): + if not child.is_dir() or child.name.startswith("."): + continue + if (child / "AGENTS.md").exists() or (child / "README.md").exists(): + count += 1 + continue + for grandchild in sorted(child.iterdir()): + if not grandchild.is_dir() or grandchild.name.startswith("."): + continue + if (grandchild / "AGENTS.md").exists() or (grandchild / "README.md").exists(): + count += 1 + return count + + +def scan_zones( + zones: Sequence[Dict[str, str]], + errors: Optional[List[str]] = None, +) -> List[Dict[str, Any]]: + """Light-profile zone scan: entry docs and nothing else. + + A declared tool zone is not a project tree. The light profile collects + AGENTS.md/README.md so pointer health and freshness can see them, and the + caller feeds the zone root to the secret scan; slot compliance never + applies because a tool is not a repository with `.agents/` slots. + """ + records: List[Dict[str, Any]] = [] + for zone in zones: + root = Path(zone["path"]).expanduser() + for path in iter_files(root, suffixes={".md"}, max_depth=3, errors=errors): + if path.is_symlink() or path.name not in {"AGENTS.md", "README.md"}: + continue + records.append( + artifact_record( + path, + "tool", + "capability", + "zone", + None, + None, + None, + root, + ) + ) + return records + + +def extra_skill_roots() -> List[Tuple[str, Path]]: + """Extra skill hosts from GUSTER_EXTRA_SKILL_ROOTS (name=path, os.pathsep separated). + + Declared hosts are configuration, not guesses: a malformed entry or a missing + directory stops the run instead of silently dropping the host.""" + 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(): + raise ValueError(f"GUSTER_EXTRA_SKILL_ROOTS entries must be name=path: {item!r}") + root = Path(raw.strip()).expanduser() + if not root.is_dir(): + raise ValueError( + f"GUSTER_EXTRA_SKILL_ROOTS host {name.strip()!r} directory does not exist: {root}" + ) + roots.append((name.strip(), root)) + 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]]], + zones: Sequence[Dict[str, str]] = (), + include_home: bool = True, + errors: Optional[List[str]] = None, +) -> 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, errors=errors) + if path.is_file() and path.suffix.lower() != ".md" + ) + if hub: + tools_root = resolve_hub_layout(hub).get("tools") + if tools_root and tools_root.is_dir(): + for path in iter_files(tools_root, max_depth=3, errors=errors): + if path.name in SECRET_SUFFIX_NAMES or path.name.startswith("credentials.") or path.name.endswith(".local.json"): + candidates.append(path) + for zone in zones: + candidates.extend(find_env_files(Path(zone["path"]).expanduser())) + 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 + try: + size = path.stat().st_size + except OSError: + size = 0 + records.append( + { + "path": str(path), + "canonical": canonical, + "git_tracked": tracked, + "size": size, + "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]] = [] + warnings: List[str] = [] + if path.is_file(): + for line_no, line in enumerate(read_text(path, limit=1_000_000).splitlines(), start=1): + stripped = line.strip() + if not stripped.startswith("|"): + continue + cells = [cell.strip() for cell in stripped.strip("|").split("|")] + if cells[0] in {"ID", ""} or set(cells[0]) <= {"-", ":"}: + continue + if len(cells) < 6: + # An entry-shaped row that cannot be an entry is a broken row, + # not silence: ID|scope|site|account|kind|locations are required. + warnings.append( + f"line {line_no}: expected at least 6 columns " + f"(ID|scope|site|account|kind|locations), got {len(cells)}: {stripped[:80]}" + ) + 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, + "parse_warnings": warnings, + } + + +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], errors: Optional[List[str]] = None) -> 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, errors=errors) + 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..5ff4e9ce --- /dev/null +++ b/external_plugins/guster/src/guster/executor.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from .discover import default_roots +from .util import now_iso, unique_suffix, write_text + + +class ApplyError(RuntimeError): + pass + + +BACKUPS_KEEP = 10 + + +def prune_backups(state_dir: Path, keep: int = BACKUPS_KEEP) -> List[str]: + """Keep the newest `keep` plan backup directories; hub-config files and + anything unexpected in backups/ stay untouched.""" + root = state_dir / "backups" + if not root.is_dir(): + return [] + + def stamp(item: Path) -> float: + try: + return item.stat().st_mtime + except OSError: + return 0.0 + + dirs = sorted( + (item for item in root.iterdir() if item.is_dir()), + key=stamp, + reverse=True, + ) + removed: List[str] = [] + for item in dirs[keep:]: + try: + shutil.rmtree(item) + except OSError: + continue + removed.append(str(item)) + return removed + + +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"]) + pointer_backup = backup_dir / f"{index:03d}-pointer-{pointer_path.name}" + if pointer_path.exists(): + shutil.copy2(pointer_path, pointer_backup) + undo.append( + ("write", {"path": str(pointer_path), "backup": str(pointer_backup)}) + ) + write_text(pointer_path, pointer.get("content", "")) + applied += 1 + elif kind == "write": + path = Path(operation["path"]) + backup = backup_dir / f"{index:03d}-{path.name}" + if path.exists(): + 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_error: Optional[BaseException] = None + try: + rollback(undo) + except BaseException as undo_exc: # noqa: BLE001 - reported, never hidden + rollback_error = undo_exc + if rollback_error is not None: + # Both the apply and the rollback failed: keep the root cause and + # name the recovery material instead of swallowing either. + raise ApplyError( + f"apply failed ({exc}); rollback also failed ({rollback_error}) — " + f"backups for manual recovery: {backup_dir}" + ) from exc + raise ApplyError(f"apply failed and was rolled back: {exc}") from exc + + pruned = prune_backups(state_dir) + return { + "ok": True, + "dry_run": False, + "plan": plan_id, + "errors": [], + "applied": applied, + "skipped": skipped, + "backup": str(backup_dir), + "pruned_backups": pruned, + "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, + roots: Optional[List[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() + if roots is None: + roots = default_roots(resolved_home) + if not roots: + raise ValueError( + f"no workspace roots detected under {resolved_home} " + "(looked for ~/Projects, ~/code, ~/dev, ~/src); " + "pass --root to declare where your projects live" + ) + config: Dict[str, Any] = { + "version": 1, + "roots": [str(root) for root in roots], + "scan_max_depth": 5, + } + 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('-', '')}-{unique_suffix()}", + "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..269e5562 --- /dev/null +++ b/external_plugins/guster/src/guster/hub.py @@ -0,0 +1,282 @@ +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 is_indexed, load_json_strict, now_iso, read_text, save_json, today_iso, unique_suffix + + +def default_hub_path(home: Path) -> Path: + return home / "guster-hub" + + +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"] + required_dirs = [] + if "knowledge" in layout: + required_files.append(layout["knowledge"] / "README.md") + required_dirs.extend( + [layout["knowledge"], layout["knowledge"] / "general", layout["knowledge"] / "projects"] + ) + if "projects" in layout: + required_files.append(layout["projects"] / "_registry.md") + required_dirs.append(layout["projects"]) + if "tools" in layout: + required_files.append(layout["tools"] / "README.md") + required_dirs.append(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 lite contract file is absent: {rel}") + else: + errors.append(f"required file is absent: {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 lite contract directory is absent: {rel}") + else: + errors.append(f"required directory is absent: {rel}") + + if not manifest: + message = "hub.yaml is absent 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") not in {"1", "2"}: + errors.append(f"unsupported hub schema: {manifest.get('schema_version') or '(absent)'}") + if manifest.get("profile") not in {"lite", "full"}: + warnings.append(f"unexpected hub profile: {manifest.get('profile') or '(absent)'}") + 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 not is_indexed(note.name, 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|(?i:last updated|updated))" + 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" + data, error = load_json_strict(config_path) + if error is not None: + raise ValueError(f"cannot read Guster config {config_path}: {error}") + if data is None: + config: Dict[str, Any] = {} + elif not isinstance(data, dict): + raise ValueError(f"Guster config must be a JSON object: {config_path}") + else: + config = data + 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}-{unique_suffix()}.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..e9380a2a --- /dev/null +++ b/external_plugins/guster/src/guster/layout.py @@ -0,0 +1,148 @@ +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", +} +# Schema 2 hubs declare what they have: only these two sections are mandatory, +# every other one (tools, ...) is optional and may live outside the hub. +HUB_CORE_SECTIONS = ("knowledge", "projects") + + +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 is_external_layout_value(raw: str) -> bool: + text = str(raw).strip() + return text.startswith("~") or Path(text).is_absolute() + + +def resolve_layout_entry(raw: str, key: str, hub_root: Path) -> Path: + """One declared section: relative stays inside the hub, absolute/~/ is external. + + External sections describe a world where the directory does not belong to + the hub; the declaration still has to name a real directory, which the + caller checks, but it is not confined by the hub boundary. + """ + text = str(raw).strip() + if not text: + raise ValueError(f"hub path '{key}' is empty") + if is_external_layout_value(text): + return Path(text).expanduser().resolve() + value = validate_layout_value(text, key) + candidate = (hub_root / value).resolve() + if candidate == hub_root or hub_root not in candidate.parents: + raise ValueError(f"hub path '{key}' escapes the hub: {value}") + return candidate + + +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] = {} + if str(manifest.get("schema_version", "1")) == "2": + for key in HUB_CORE_SECTIONS: + if key not in paths: + raise ValueError(f"hub schema 2 must declare the '{key}' section") + for key, raw in paths.items(): + resolved[key] = resolve_layout_entry(str(raw), key, root) + else: + for key, default in DEFAULT_PATHS.items(): + resolved[key] = resolve_layout_entry(str(paths.get(key, default)), key, root) + 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..a4c09aea --- /dev/null +++ b/external_plugins/guster/src/guster/metrics.py @@ -0,0 +1,199 @@ +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)), + "state_docs_fresh_from_mtime": int(stats.get("state_docs_fresh_from_mtime", 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)), + "scan_walk_errors": int(stats.get("scan_walk_errors", 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..494b0e74 --- /dev/null +++ b/external_plugins/guster/src/guster/planner.py @@ -0,0 +1,191 @@ +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, today_iso, unique_suffix + + +PLAN_SCHEMA_HINT = ( + '{"version": 1, "operations": [{"kind": "mkdir", "path": "..."}, ' + '{"kind": "move", "src": "...", "dst": "..."}, ' + '{"kind": "write", "path": "...", "content": "..."}, ' + '{"kind": "manual", "path": "...", "action": "...", "reason": "..."}]}' +) + + +def new_plan_id(title: str) -> str: + stamp = now_iso().replace(":", "").replace("-", "").replace("+", "p") + return f"{stamp}-{slugify(title)[:40]}-{unique_suffix()}" + + +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\n" + f"verified_at: {today_iso()}\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}") + version = data.get("version") + if isinstance(version, bool) or not isinstance(version, int) or version != 1: + raise ValueError( + f"unsupported plan version: {version!r} (expected the integer 1); " + f"a plan is shaped {PLAN_SCHEMA_HINT}" + ) + if not isinstance(data.get("operations"), list): + raise ValueError( + "plan has no operations list; expected an 'operations' array of operation " + f"objects with a 'kind' of mkdir/move/write/manual, e.g. {PLAN_SCHEMA_HINT}" + ) + 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..f98c81e1 --- /dev/null +++ b/external_plugins/guster/src/guster/policy.py @@ -0,0 +1,1050 @@ +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, + load_json, + 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 +VERIFIED_DATE_KEYS = r"(?:最后更新|更新时间|更新于|最近核实|最后核实|已核实|verified_at|(?i:last updated|updated))" +FRESH_SOURCE_STATS = { + "declared": "state_docs_fresh_declared", + "git": "state_docs_fresh_from_git", + "mtime": "state_docs_fresh_from_mtime", +} + + +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" + + +REGISTRY_PATH_COLUMN = re.compile(r"path|路径", re.IGNORECASE) +REGISTRY_ROW_SKIP = {"", "-", "—", ".", ".."} +# `host:/path` (and URLs) reference another machine; a local three-way diff +# can never see them, so they are counted apart instead of drifting forever. +REGISTRY_REMOTE_FORM = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:") + + +def parse_registry_rows(text: str) -> List[Dict[str, str]]: + """CTX-10: the hub's human project table, read through its path column. + + The column whose header mentions "path"/"路径" holds one location per row + (`~/...`, absolute, or relative to the hub root); the slug column never + counts as a path.""" + rows: List[Dict[str, str]] = [] + path_index: Optional[int] = None + for line in text.splitlines(): + stripped = line.strip() + if not stripped.startswith("|"): + continue + cells = [cell.strip().strip("`").rstrip("/") for cell in stripped.strip("|").split("|")] + if not cells: + continue + if path_index is None: + for index, cell in enumerate(cells): + if REGISTRY_PATH_COLUMN.search(cell): + path_index = index + break + continue + if cells[0] in {"ID", "slug", ""} or set(cells[0]) <= {"-", ":"}: + continue + raw = cells[path_index] if path_index < len(cells) else "" + if raw in REGISTRY_ROW_SKIP: + continue + rows.append({"slug": cells[0], "path": raw}) + return rows + + +def resolve_registry_row_path(raw: str, hub_root: Path, home: Path) -> Optional[Path]: + """A registry row path resolves against the hub root, `~` or an absolute.""" + value = raw.strip().rstrip("/") + if not value: + return None + if value.startswith("~"): + return home / value[2:] + candidate = Path(value) + if candidate.is_absolute(): + return candidate + return (hub_root / candidate).resolve() + + +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, + "state_docs_fresh_from_mtime": 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, + "registry_rows": 0, + "registry_rows_remote": 0, + "registry_drift_dead_rows": 0, + "registry_drift_unscanned_rows": 0, + "registry_drift_unregistered": 0, + "registry_drift_stale_machine": 0, + "scan_walk_errors": int((registry.get("scan_errors") or {}).get("count") or 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 a knowledge reference doc under knowledge/general/.", + ) + ) + + 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. The date ladder is declared (verified_at / updated / + # 最后更新 / filename date) → owning repository's git date → file mtime, + # each tier reported as its own freshness source. + 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 and item.get("mtime"): + # Files git never saw (e.g. gitignored `.agents/` docs) still carry + # an mtime; count it as its own source instead of a freshness cliff. + verified = ( + datetime.fromtimestamp(float(item["mtime"]), tz=timezone.utc) + .date() + .isoformat() + ) + source = "mtime" + 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[FRESH_SOURCE_STATS[source]] += 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; tool + # entry docs (hub sections and machine zones) carry + # pointers like any other entry document. + item.get("kind") == "instruction" + or item.get("kind") == "tool" + 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[:500] + 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, config_roots=config.roots) + 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("*"): + age = age_days(path) + if age is not None and age > 14 and path.is_file(): + 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}, + ) + ) + + # Registry drift (CTX-10): the machine table (registry.json), the hub's + # human table (_registry.md path column) and the git roots on disk each + # claim to know which projects exist; disagreement is a finding, never a + # CHI dimension. The human side needs a hub; the machine↔disk edge stands + # on its own for hub-less users. + if hub is not None and not (hub / "hub.yaml").is_file() and not (hub / "AGENTS.md").is_file(): + findings.append( + finding( + "hub-manifest-missing", + "high", + "Attached hub is not recognizable as a hub", + str(hub), + f"{hub} has neither hub.yaml nor AGENTS.md, so no hub contract applies to it.", + "Point config/--hub at a real hub (`guster hub init`), or drop the hub setting.", + {"hub": str(hub)}, + ) + ) + machine_paths: Set[str] = set() + machine_projects: Set[str] = set() + for project in projects: + try: + key = str(Path(project["path"]).resolve()) + except OSError: + continue + machine_paths.add(key) + if not project.get("container"): + machine_projects.add(key) + human_paths: Set[str] = set() + dead_rows: List[str] = [] + unscanned_rows: List[str] = [] + registry_md: Optional[Path] = None + if hub is not None: + projects_root = hub_layout.get("projects") + registry_md = (projects_root / "_registry.md") if projects_root else None + if registry_md is not None and registry_md.is_file(): + hub_root = hub.expanduser().resolve() + for row in parse_registry_rows(read_text(registry_md, limit=1_000_000)): + if REGISTRY_REMOTE_FORM.match(row["path"]): + stats["registry_rows_remote"] += 1 + continue + resolved = resolve_registry_row_path(row["path"], hub_root, home) + if resolved is None: + continue + try: + key = str(resolved.resolve()) + except OSError: + continue + human_paths.add(key) + if not resolved.exists(): + dead_rows.append(row["path"]) + elif scope == "machine" and key not in machine_paths: + unscanned_rows.append(row["path"]) + stats["registry_rows"] = len(human_paths) + stats["registry_drift_dead_rows"] = len(dead_rows) + stats["registry_drift_unscanned_rows"] = len(unscanned_rows) + if registry_md is not None and registry_md.is_file(): + if dead_rows: + findings.append( + finding( + "registry-drift", + "medium", + "Hub registry rows point at paths that do not exist", + str(registry_md), + f"{len(dead_rows)} row(s) in the human registry name a path that is not on disk.", + "Fix the row path, or delete the row; a registry row must resolve to the project directory.", + {"count": len(dead_rows), "rows": dead_rows[:10]}, + ) + ) + if unscanned_rows: + findings.append( + finding( + "registry-drift", + "medium", + "Hub registry rows are outside the machine scan", + str(registry_md), + f"{len(unscanned_rows)} registered path(s) exist but the scan never records them; " + "the machine table cannot see what the human table claims.", + "Add the containing directory to the configured roots (guster init/zones), or fix the row path.", + {"count": len(unscanned_rows), "rows": unscanned_rows[:10]}, + ) + ) + unregistered = sorted(machine_projects - human_paths) + stats["registry_drift_unregistered"] = len(unregistered) + if unregistered: + findings.append( + finding( + "registry-drift", + "low", + "Scanned projects are not registered in the hub", + unregistered[0], + f"{len(unregistered)} scanned project(s) have no row in the human registry.", + "Add a CTX-10 row per project, or waive deliberately unregistered ones in hub.yaml.", + {"count": len(unregistered), "examples": unregistered[:10]}, + ) + ) + persisted_registry = load_json(config.state_dir / "registry.json", None) + if isinstance(persisted_registry, dict) and isinstance(persisted_registry.get("projects"), list): + persisted_paths: Set[str] = set() + for project in persisted_registry["projects"]: + if not isinstance(project, dict) or not project.get("path"): + continue + try: + persisted_paths.add(str(Path(project["path"]).resolve())) + except OSError: + continue + if persisted_paths != machine_paths: + vanished = sorted(persisted_paths - machine_paths) + appeared = sorted(machine_paths - persisted_paths) + stats["registry_drift_stale_machine"] = len(vanished) + len(appeared) + findings.append( + finding( + "registry-drift", + "low", + "Machine registry is out of date with disk", + str(config.state_dir / "registry.json"), + f"{len(vanished)} project(s) listed in registry.json no longer scan; " + f"{len(appeared)} on disk are missing from it.", + "Re-run `guster scan`/`guster doctor` without --no-write to refresh the machine table.", + {"vanished": vanished[:10], "appeared": appeared[:10]}, + ) + ) + + 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( + VERIFIED_DATE_KEYS + r"\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..ad3b8023 --- /dev/null +++ b/external_plugins/guster/src/guster/report.py @@ -0,0 +1,366 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Set + +from . import __version__ +from .util import human_bytes, now_iso + + +SEVERITY_LABEL = { + "critical": "CRITICAL", + "high": "HIGH", + "medium": "MEDIUM", + "low": "LOW", + "info": "INFO", +} +RULE_ACTION_HINTS = { + "secret-in-git": "rotate the exposed credential, then remove it from the git index/history", + "secret-inline": "replace inline tokens with environment references and rotate them", + "secret-outside-root": "register or move unregistered credential files into the canonical root", + "secret-undeclared": "register credential files in ~/.agents/credentials/registry.md", + "secret-missing": "rotate or drop stale credential registry rows", + "broken-pointer": "fix the pointer path or replace it with a when-to-read pointer (`guster link` lists them)", + "naked-pointer": "wrap naked paths in a link or inline code (CTX-03)", + "misplaced-artifact": "move task bundles under a standard slot (`guster plan move`)", + "stale-state": "refresh verified_at on state documents or archive them", + "orphan-knowledge": "index knowledge files in their README, or delete them", + "duplicate-canonical": "keep one canonical home and turn the rest into pointers", + "skill-budget": "trim skill descriptions to one trigger sentence", + "skill-copy-drift": "make drifted skill copies byte-identical", + "resident-budget": "demote resident detail into knowledge or skills", + "registry-drift": "reconcile the hub registry rows with scanned projects (CTX-10)", + "hub-manifest-missing": "point the hub config at a real hub (`guster hub init`)", + "note-header-malformed": "fix note front-matter (type key + machine-readable date)", + "git-unavailable": "fix git access for the failing repositories", + "tmp-overdue": "archive or delete overdue files in .agents/tmp", + "governance-overlap": "reduce competing frameworks to one primary document", + "host-manifest-drift": "synchronize plugin manifest versions", +} + + +def next_actions(findings: Sequence[Dict[str, Any]]) -> List[str]: + """Actions derived from the findings that actually fired; an empty list + keeps the baseline ritual instead of naming problems that do not exist.""" + if not findings: + return [ + "No open findings — run `guster score` to record the baseline, and `guster check` after context edits." + ] + counts: Dict[str, int] = {} + for item in findings: + rule = str(item.get("rule") or "") + if rule == "out-of-scope": + continue + counts[rule] = counts.get(rule, 0) + 1 + actions: List[str] = [] + critical_rules = sorted( + { + str(item.get("rule") or "") + for item in findings + if item.get("severity") == "critical" and item.get("rule") != "out-of-scope" + } + ) + if critical_rules: + actions.append( + f"Fix the critical finding(s) first ({', '.join(critical_rules)}); " + "rotate any exposed credential before further edits." + ) + seen: Set[str] = set() + for item in findings: # findings arrive sorted by severity + rule = str(item.get("rule") or "") + if rule in seen or rule == "out-of-scope": + continue + seen.add(rule) + hint = RULE_ACTION_HINTS.get(rule) + if hint is None: + continue + actions.append(f"`{rule}` × {counts[rule]}: {hint}.") + if len(actions) >= 5: + break + actions.append( + "Turn the rest into a checklist with `guster plan findings`; structural changes go " + "through `guster plan move`/`guster plan init` and `guster apply --yes`." + ) + actions.append("Re-run `guster score` after fixes and compare the trend.") + return actions + + +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)}") + if registry.get("unconfigured"): + if registry.get("auto_roots") and workspaces: + lines.append( + f"> **Unconfigured:** auto-scanning {', '.join(workspaces)} — " + "run `guster init` to pin these roots." + ) + if not registry.get("hub"): + lines.append("> **No hub attached** — run `guster hub init` to create one.") + 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, " + f"{raw.get('state_docs_fresh_from_git', 0)} from git, " + f"{raw.get('state_docs_fresh_from_mtime', 0)} from mtime)", + ), + ("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 excluded from single-source checks (above the hash limit or unreadable): " + 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("") + for index, action in enumerate(next_actions(findings), start=1): + lines.append(f"{index}. {action}") + 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', []))}", + ] + zones = registry.get("zones") or [] + if zones: + lines.append( + "zones: " + + ", ".join( + f"{Path(item.get('path', '.')).name}({item.get('profile', 'light')}, {item.get('tools', 0)} tools)" + for item in zones + ) + ) + if registry.get("unconfigured"): + if registry.get("auto_roots") and registry.get("workspaces"): + lines.append( + "note: no config yet — auto-scanning " + + ", ".join(registry["workspaces"]) + + "; run `guster init` to pin roots" + ) + if not registry.get("hub"): + lines.append("note: no hub attached; run `guster hub init` to create one") + state_dir = registry.get("state_dir") + if state_dir and Path(state_dir).parent.name == "scoped": + lines.append(f"scope: {registry.get('scope', 'machine')} (state: {state_dir})") + lines.extend( + [ + 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 plugin.get("version") and plugin.get("version") != __version__: + lines.append( + f"(registry was scanned by guster {plugin.get('version')}; " + f"current CLI is {__version__} — run `guster doctor` to refresh)" + ) + 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..63da18e1 --- /dev/null +++ b/external_plugins/guster/src/guster/util.py @@ -0,0 +1,741 @@ +from __future__ import annotations + +import contextlib +import hashlib +import json +import os +import re +import subprocess +import tempfile +import time +from contextlib import contextmanager +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Iterator, List, Optional, Sequence, Set, Tuple + +try: + import fcntl +except ImportError: # pragma: no cover - non-POSIX hosts + fcntl = None # type: ignore[assignment] + + +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: + """Atomic replace: a concurrent reader sees the old or the new file, never a mix.""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp" + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(text) + os.replace(tmp_name, path) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(tmp_name) + raise + + +@contextmanager +def state_lock(state_dir: Path) -> Iterator[None]: + """Exclusive lock over one state dir so concurrent CLI runs cannot interleave + scan/policy writes. flock is POSIX-only; elsewhere the lock degrades to a no-op + while write_text itself stays atomic.""" + state_dir.mkdir(parents=True, exist_ok=True) + handle = (state_dir / ".lock").open("w") + try: + if fcntl is not None: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + yield + finally: + handle.close() + + +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 unique_suffix(length: int = 3) -> str: + """Random hex tail; same-second plan ids and backups must not overwrite.""" + return os.urandom(length).hex() + + +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 load_json_strict(path: Path) -> Tuple[Any, Optional[str]]: + """Value and error apart: absent is (None, None), corrupt is (None, reason). + + Missing and corrupt are different conditions; a caller that can rescan must + warn about corruption instead of silently treating it as absent.""" + try: + raw = path.read_text(encoding="utf-8") + except FileNotFoundError: + return None, None + except OSError as exc: + return None, str(exc) + try: + return json.loads(raw), None + except ValueError as exc: + return None, str(exc) + + +def is_indexed(name: str, index_text: str) -> bool: + """A backtick-wrapped index row, bare or as a `dir/name` tail. + + A bare substring does not count: `a.md` inside `data.md` is not an index row.""" + return f"`{name}`" in index_text or f"/{name}`" in index_text + + +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, + errors: Optional[List[str]] = None, +) -> Iterator[Path]: + if not root.exists(): + return + skip = EXCLUDE_DIRS if skip_dirs is None else skip_dirs + root = root.resolve() + + def on_error(exc: OSError) -> None: + if errors is not None: + errors.append(f"{exc.filename or root}: {exc.strerror or exc}") + + for current, dirs, files in os.walk(root, onerror=on_error): + 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: + if result.returncode == 128 and "does not have any commits yet" in ( + result.stderr or "" + ): + # A freshly `git init`-ed repo has no history to read: no dates, + # not a git failure. + return {} + 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, literal/folded block scalars) 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, and that gate wins over malformedness, + so legacy prose notes that merely open with dashes never report. A dict + with ``_malformed`` reports a header that opted in but cannot be parsed. + """ + lines = text.splitlines() + if not text.startswith("---") or not lines or lines[0].strip() != "---": + return None + keys: Dict[str, Any] = {} + list_key: Optional[str] = None + malformed: Optional[Dict[str, Any]] = None + end: Optional[int] = None + limit = min(len(lines), 80) + index = 1 + while index < limit: + line = lines[index] + if line.strip() == "---": + end = index + break + stripped = line.strip() + if not stripped or stripped.startswith("#"): + index += 1 + continue + if stripped.startswith("- ") and list_key is not None: + value = stripped[2:].split("#", 1)[0].strip() + if value: + keys[list_key].append(value) + index += 1 + continue + match = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", stripped) + if not match: + if malformed is None: + malformed = {"line": index + 1, "text": stripped[:60]} + index += 1 + continue + key = match.group(1) + value = match.group(2).split("#", 1)[0].strip() + if value in ("|", ">"): + block: List[str] = [] + index += 1 + while index < limit: + candidate = lines[index] + if candidate.startswith(" "): + block.append(candidate[2:]) + elif not candidate.strip(): + block.append("") + else: + break + index += 1 + keys[key] = ("\n" if value == "|" else " ").join(block).strip() + list_key = None + continue + 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 + index += 1 + 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 + if malformed is not None: + return {"_malformed": True, **malformed} + if end is None: + return {"_malformed": True, "line": len(lines), "text": "front-matter not closed"} + 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]: + # A leading front-matter block is metadata, not prose (CTX-09: the body, + # never the header, joins the pointer scan). + text = _strip_frontmatter(text) + 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"(? str: + """Body after a leading YAML front-matter block; header metadata is not prose.""" + if not text.startswith("---"): + return text + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return text + for index in range(1, len(lines)): + if lines[index].strip() == "---": + return "\n".join(lines[index + 1 :]) + return text + + +def extract_naked_pointers(text: str) -> 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. A leading front-matter block is + metadata, not prose — a path-valued key (CTX-09 `superseded_by`) is never + a naked pointer. + """ + text = _strip_frontmatter(text) + 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, + config_roots: Optional[Sequence[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] + anchors: 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: + anchors.append(repo_root) + if hub is not None: + anchors.append(hub) + if home is not None: + anchors.append(home) + if config_roots: + # Anchors follow the user's declared roots, never hardcoded layouts. + anchors.extend(config_roots) + if not any((root / anchor).exists() for root in anchors): + # 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 anchors] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def age_days(path: Path) -> Optional[float]: + """Age in days; None when stat fails — unknown age, never a fake zero.""" + try: + return max(0.0, (time.time() - path.stat().st_mtime) / 86400.0) + except OSError: + return None + + +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..b2bb8dc4 --- /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 `verified_at: 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..e1b1e685 --- /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 `verified_at` 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..c04c25c3 --- /dev/null +++ b/external_plugins/guster/templates/hub/hub.yaml.tmpl @@ -0,0 +1,11 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Lumos-789/guster/main/schemas/hub.schema.json +schema_version: 2 +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..199ddfcf --- /dev/null +++ b/external_plugins/guster/templates/hub/projects/_registry.md.tmpl @@ -0,0 +1,8 @@ +# Project Registry + +**verified_at**: {{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 76d6d9be..e89d4a21 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", @@ -1499,6 +1524,46 @@ "image-to-video", "mcp" ] + }, + { + "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.9.1", + "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 +}