diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96b1913..c988783 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,3 +71,10 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: cargo run -p liyi-cli -- check --root . + + check-template-sync: + name: Check template sync + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - run: python3 scripts/check-template-sync.py diff --git a/.github/workflows/ci.yml.liyi.jsonc b/.github/workflows/ci.yml.liyi.jsonc index 0ca1fba..c29118c 100644 --- a/.github/workflows/ci.yml.liyi.jsonc +++ b/.github/workflows/ci.yml.liyi.jsonc @@ -86,6 +86,18 @@ "tree_path": "key.jobs::key.\"liyi-check\"", "source_hash": "sha256:991baeb9c5382d8fab9e2c777bd2af27a7c6c9fcc08b64263f7b5963f7ba0909", "source_anchor": " liyi-check:" + }, + { + "item": "jobs::check-template-sync", + "reviewed": true, + "intent": "Verify that AGENTS.md is derived from docs/liyi-design.md's liyi agent-instructions block, with the section heading added. Must fail the PR if the two diverge.", + "source_span": [ + 75, + 80 + ], + "tree_path": "key.jobs::key.\"check-template-sync\"", + "source_hash": "sha256:fa834b9241a1a982101e82894486ecafd3aae730ca0021efcd91925205d87182", + "source_anchor": " check-template-sync:" } ] } diff --git a/AGENTS.md b/AGENTS.md index 6d53315..584d3ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,12 +20,12 @@ For Rust code: - Conform to official rustfmt style. - Keep free from Clippy lints. -Before committing, run `make lint` (rustfmt check, Clippy, and the linter's -own self-check) and ensure it passes, or `make verify` to also run the test -suite. Both gates abort at the first failure and end with a success banner -(`lint: all gates passed` / `verify: all gates passed`), so you can confirm -success by the final line — no need to capture the exit code or redirect -output to a log. +Before committing, run `make lint` (rustfmt check, Clippy, the linter's own +self-check, and the agent-template sync check) and ensure it passes, or +`make verify` to also run the test suite. Both gates abort at the first +failure and end with a success banner (`lint: all gates passed` / +`verify: all gates passed`), so you can confirm success by the final line — +no need to capture the exit code or redirect output to a log. **If you are an AI agent, prefer the quiet variants `make lint-short` and `make verify-short`.** They run the exact same gates but suppress normal @@ -69,27 +69,44 @@ See the contributing guide for full details and examples. --- - -## 立意 (Intent Specs) + + +## The 立意 (Intent Specs) design pattern for agents When writing or modifying code: -1. For each non-trivial item (function, struct, macro invocation, decorated endpoint, etc.), infer what it SHOULD do (not what it does). Write intent to a sidecar file named `.liyi.jsonc` (e.g., `money.rs` → `money.rs.liyi.jsonc`). Record `source_span` (start/end lines). Do not write `source_hash` or `source_anchor` — the tool fills them in. Do not write `"reviewed"` — that is set by the human via CLI or IDE. **If you change the `intent` text of a spec that has `"reviewed": true`, you must set `"reviewed"` to `false`** (or delete the field). The human reviewed the *old* intent; your rewrite has not been reviewed. Failing to unset it silently self-approves agent-written intent, bypassing human review. Use `"intent": "=doc"` only when the docstring contains behavioral requirements (constraints, error conditions, properties), not just a functional summary — a docstring that says "Returns the sum" is not adequate; one that says "Must reject mismatched currencies with an error" is. For trivial items (simple getters, one-line wrappers), annotate with `@liyi:trivial` instead of writing a spec. Alternatively, when working sidecar-first, use `"intent": "=trivial"` to mark items as trivial without requiring a source annotation. When `liyi init` emits `_hints` in a sidecar scaffold, use them to prioritize which items warrant deeper investigation (e.g., `git log`, reading tests) within your token/call budget; for unhinted items, infer from source alone. +1. For each non-trivial item (function, struct, macro invocation, decorated endpoint, etc.), infer what it SHOULD do (not what it does). Write intent to a sidecar file named `.liyi.jsonc` (e.g., `money.rs` → `money.rs.liyi.jsonc`). Record `source_span` (start/end lines). Do not write `source_hash` or `source_anchor` — the tool fills them in. Do not write `"reviewed"` — that is set by the human via CLI or IDE. **If you change the `intent` text of a spec that has `"reviewed": true`, you must set `"reviewed"` to `false`** (or delete the field). The human reviewed the *old* intent; your rewrite has not been reviewed. Failing to unset it silently self-approves agent-written intent, bypassing human review. Use `"intent": "=doc"` only when the docstring contains behavioral requirements (constraints, error conditions, properties), not just a functional summary — a docstring that says "Returns the sum" is not adequate; one that says "Must reject mismatched currencies with an error" is. For trivial items (simple getters, one-line wrappers, and simple expressions with no load-bearing operand order, short-circuit side effects, nested-call effects, or cost), annotate with `@liyi:trivial` instead of writing a spec. Alternatively, when working sidecar-first, use `"intent": "=trivial"` to mark items as trivial without requiring a source annotation. Use `"intent": "=self-doc"` for an item that is **not** trivial (it may be substantial — a plain data struct, a recursive helper, a container module) but whose intent is fully intrinsic to its surface form, such that any prose would tautologically restate the name/signature/fields. `=trivial` is a *risk* claim (small enough that nothing hides); `=self-doc` is an *epistemic* claim (nothing implicit hides behind the form) — do not stretch `=trivial` onto substantial code. A `=self-doc` claim holds ONLY if a reader competent in the language but a stranger to this codebase, domain, and session — holding only the item's name, signature, and durable co-located context (enclosing module, in-scope types, a governing `@liyi:note`) — could recover every property any observer (caller, attacker, scheduler, profiler, regulator) could rely on, INCLUDING non-value-domain ones: timing, resource use, termination, information flow, nondeterminism, ordering, idempotency. Side effects are presumptively disqualifying; a sequence `A; B; C` is not self-documenting when its ordering, non-idempotency, or partial-failure semantics are load-bearing and invisible in the sequence. Claiming `=self-doc` obliges you to have checked for these disqualifiers and found none — a false `=self-doc` is a lie about work done, not a judgment call. When `liyi init` emits `_hints` in a sidecar scaffold, use them to prioritize which items warrant deeper investigation (e.g., `git log`, reading tests) within your token/call budget; for unhinted items, infer from source alone. 2. When module-level invariants or governing context are apparent, write a `@liyi:note` block — in the directory's existing module doc (`README.md`, `doc.go`, `mod.rs` doc comment, etc.) or in a dedicated `LIYI.md`. Use the doc markup language's comment syntax for the marker, and close embedded blocks with `@liyi:end-note`. A note is a **context primitive, not a tracked spec**: it is marker-only — never write it to a `.liyi.jsonc`, never hash it, never add a `"reviewed"` flag. Notes apply to their directory subtree (shadowed by deeper notes); to pull a named note into a specific item's context regardless of location, annotate the item with `@liyi:see `. (Optionally name a note: `@liyi:note `; same-named blocks aggregate.) 3. If a source item has a `@liyi:related ` annotation, record the dependency in `.liyi.jsonc` as `"related": {"": null}`. The tool fills in the requirement's current hash. 4. For each `@liyi:requirement ` block encountered (closed by `@liyi:end-requirement `), ensure it has a corresponding entry in the co-located `.liyi.jsonc` with `"requirement"` and `"source_span"`. (The tool fills in `"source_hash"`.) 5. If a spec has `"related"` edges referencing a requirement, do not overwrite the requirement text during inference. Update the spec (update `source_span`) but preserve the `"related"` edges. Do not write `source_hash` — the tool fills it in. -6. Only generate adversarial tests from items that have a `@liyi:intent` annotation in source or `"reviewed": true` in the sidecar (i.e., human-reviewed intent). When `@liyi:intent` is present in source, use its prose (or the docstring for `=doc`) as the authoritative intent for test generation. +6. Only generate adversarial tests from items that have a `@liyi:intent` annotation in source or `"reviewed": true` in the sidecar (i.e., human-reviewed intent), excluding sentinel intents (`=trivial`, `=self-doc`), which carry no agent-authored intent for a second model to attack. When `@liyi:intent` is present in source, use its prose (or the docstring for `=doc`) as the authoritative intent for test generation. 7. Tests should target boundary conditions, error-handling gaps, property violations, and semantic mismatches. Prioritize tests a subtly wrong implementation would fail. -8. Skip items annotated with `@liyi:ignore` or `@liyi:trivial`, files matched by `.liyiignore`, and files bearing a `@liyi:file ignore` directive. Respect `@liyi:nontrivial` — if present, always infer a spec for that item and never override with `@liyi:trivial`. Honor `@liyi:file language=` as the authoritative language for a file when present (it overrides extension-based detection). +8. Skip items annotated with `@liyi:ignore` or `@liyi:trivial`, files matched by `.liyiignore`, and files bearing a `@liyi:file ignore` directive. Respect `@liyi:nontrivial` — if present, always infer a spec for that item and never override with `@liyi:trivial` or `"=self-doc"`. Honor `@liyi:file language=` as the authoritative language for a file when present (it overrides extension-based detection). **Exclude clearly-derivative items from inference entirely** — write no sidecar entry for an item whose whole purpose is to exercise or assert another already-specced item, adding no invariant of its own (chiefly tests: a test named for the behavior it checks derives its intent from that behavior's spec). This targets the derivative *shape*, not "tests" by name — a test that encodes a novel invariant the code does not otherwise state does carry independent intent and should be specced. The end state for a derivative item is no entry; do not reach for `"=trivial"` or `"=self-doc"` to file it away. 9. Use a different model for test generation than the one that wrote the code, when possible. 10. When `liyi check` reports stale items, choose one of two paths: - **Direct re-inference** (preferred during interactive editing with few stale items): re-read the source, update `source_span` and `intent` in the sidecar, leave `"reviewed"` unset. Appropriate when you are the agent that just made the change, the number of stale items is small, and the changes are straightforward. - **Triage** (preferred for batch workflows, CI, or when many items are stale): assess each item — is the change cosmetic, semantic, or an intent violation? Write the assessment to `.liyi/triage.json` following the triage report schema. For cosmetic changes, run `liyi triage --apply` to auto-fix. For semantic changes, propose updated intent in `suggested_intent`. For intent violations, flag for human review. Prefer triage when stale items have `"reviewed": true` or `@liyi:intent` in source — these carry human-vouched intent that deserves explicit assessment, not silent re-inference. - In either path, **never compute `source_hash` manually** and **never leave `"reviewed": true` on a spec whose `intent` you changed** (see rule 1). When `liyi check --fix` cannot auto-rehash a stale item (e.g., because it has `"reviewed": true`), update `source_span` and `intent`, **set `"reviewed"` to `false`**, and **delete** the `source_hash` field (or set it to `null`). The human will run `liyi check --fix` or `liyi approve --rehash` after reviewing the updated intent. This rule exists because agents consistently produce wrong hashes (trailing-newline differences, encoding mismatches) — the tool reads the actual bytes and is the only authority. + In either path, **never compute `source_hash` manually** and **never leave `"reviewed": true` on a spec whose `intent` you changed** (see rule 1). When `liyi check --fix` cannot auto-rehash a stale item (e.g., because it has `"reviewed": true`), update `source_span` and `intent`, **set `"reviewed"` to `false`**, and **delete** the `source_hash` field (or set it to `null`). The human will run `liyi check --fix` or `liyi approve` after reviewing the updated intent. This rule exists because agents consistently produce wrong hashes (trailing-newline differences, encoding mismatches) — the tool reads the actual bytes and is the only authority. 11. Before committing, run `liyi check`. If it reports coverage gaps (missing requirement specs, missing related edges), resolve **all** gaps in the same commit. When running as an agent, prefer `liyi check --prompt` for structured JSON output with per-gap resolution instructions. Do not commit with unresolved coverage gaps — CI will reject it. +### Key principles of the intent protocol + +- **Adversarial, not confirmatory.** Find bugs, not confirm correctness. +- **Spec is the referee.** If the spec says one thing and the code does another, the test exposes the gap. The human decides who's right. +- **Model diversity.** Different model for tests than for code, when possible. +- **Never modify source code logic** during the protocol. Only create/update `.liyi.jsonc` files, `@liyi:note` blocks (in docs or doc comments), test files, and annotation comments (`@liyi:trivial`, `@liyi:ignore`, `@liyi:requirement`, `@liyi:related`). Annotation comments are metadata, not logic — adding them does not change program behavior. + +### Resolving rule conflicts + +When two normative instructions here appear to conflict, or a task's correct completion turns on an ambiguity these instructions do not resolve: + +1. **Do not guess.** Name the conflicting rules (and any design-doc sections they reference). +2. **If a human is reachable:** ask once — state the candidate resolutions and your recommendation. Consolidate into a single question rather than a series. +3. **If no human is reachable** (batch, CI): take the most conservative reading, leave the affected specs `"reviewed": false`, complete the unambiguous parts, and report the unresolved conflict prominently. +4. **Do not over-apply this.** Where the standard delegates judgment to you — e.g. whether an item qualifies for `=self-doc` under the observer test — reason it out and state your reasoning so a human can veto. That is not a conflict. + ### `.liyi.jsonc` Schema (v0.1) Sidecar files must conform to the following JSON Schema. The top-level object has three required fields: `"version"` (must be `"0.1"`), `"source"` (repo-relative path to the source file), and `"specs"` (array of item or requirement entries). Each spec entry is either an **item spec** or a **requirement spec**, distinguished by the presence of `"item"` vs `"requirement"`. @@ -147,11 +164,11 @@ Sidecar files must conform to the following JSON Schema. The top-level object ha "reviewed": { "type": "boolean", "default": false, - "description": "Optional. Whether a human has reviewed and accepted this intent via sidecar approval. Defaults to false when absent. The linter also considers an item reviewed if \u0040liyi:intent is present in source." + "description": "Optional. Whether a human has reviewed and accepted this intent via sidecar approval. Defaults to false when absent. The linter also considers an item reviewed if @liyi:intent is present in source." }, "intent": { "type": "string", - "description": "Natural-language description of what the item SHOULD do, or the sentinel value '=doc' meaning the source docstring captures intent, or '=trivial' meaning the item is trivial and needs no intent spec." + "description": "Natural-language description of what the item SHOULD do, or the sentinel value '=doc' meaning the source docstring captures intent, '=trivial' meaning the item is trivial and needs no intent spec, or '=self-doc' meaning the item is self-documenting (intent intrinsic to its surface form) and needs no intent spec." }, "source_span": { "$ref": "#/$defs/sourceSpan" }, "tree_path": { @@ -388,4 +405,4 @@ When `liyi check` reports stale items, the agent assesses each and writes the re } } ``` - + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a89a04..734c2b7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,8 @@ structure, code style, commit conventions, and the AIGC policy. 1. **Open an issue first** for non-trivial changes so we can discuss scope. 2. Keep PRs small and focused — one logical change per PR. 3. Ensure `make verify` passes — it runs the lint gates (rustfmt check, - Clippy, and the project's own linter, which dogfoods itself) plus + Clippy, the project's own linter, which dogfoods itself, and the + agent-template sync check) plus `cargo test --workspace`, aborting at the first failure and ending with a `verify: all gates passed` banner. (`make lint` runs just the lint gates.) AI agents should prefer the quiet `make verify-short` / `make lint-short` diff --git a/Makefile b/Makefile index 9c0d067..98f2847 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 OR MIT -.PHONY: verify lint fmt-check clippy liyi-check test \ +.PHONY: verify lint fmt-check clippy liyi-check check-template-sync sync-template test \ verify-short lint-short test-short # Run every pre-commit gate (lint + tests) in one shot. @@ -14,7 +14,7 @@ verify: lint test @echo "verify: all gates passed" # Run every pre-commit lint gate (matches CI). Ends with a success banner. -lint: fmt-check clippy liyi-check +lint: fmt-check clippy liyi-check check-template-sync @echo "lint: all gates passed" fmt-check: @@ -27,6 +27,17 @@ clippy: liyi-check: cargo run -p liyi-cli -- check --root . +# Verify AGENTS.md materializes docs/liyi-design.md's liyi:template block. +# The design doc is the source; AGENTS.md adds the section heading so +# `liyi init` can ship the block unchanged. +check-template-sync: + python3 scripts/check-template-sync.py + +# Rewrite AGENTS.md's liyi:template block from the design doc's, adding +# the section heading. Review the resulting diff before committing. +sync-template: + python3 scripts/check-template-sync.py --sync + test: cargo test --workspace @echo "test: passed" @@ -58,6 +69,10 @@ lint-short: tail -n 20 $$LOG; rm -f $$LOG; \ echo "lint-short: liyi check FAILED — re-run \`make liyi-check\` for the full report"; exit 1; \ fi; \ + if ! python3 scripts/check-template-sync.py >$$LOG 2>&1; then \ + tail -n 20 $$LOG; rm -f $$LOG; \ + echo "lint-short: check-template-sync FAILED — re-run \`make check-template-sync\` for the full output"; exit 1; \ + fi; \ rm -f $$LOG; \ echo "lint-short: all gates passed" diff --git a/README.md b/README.md index 620e5bd..63d207e 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,15 @@ liyi check --root . 3. **`liyi migrate`** — upgrades sidecar files when the schema version changes. Idempotent. 4. **Human reviews** — sets `"reviewed": true` in the sidecar to approve, or adds `@liyi:intent` in source to provide the authoritative human version. +## Harness compatibility + +立意 adopts the [AGENTS.md convention](https://agents.md) and writes its agent +instructions to `AGENTS.md` by default. +Any harness that reads `AGENTS.md` should work out of the box. +If your harness does not read `AGENTS.md`, it is your responsibility to find +another suitable place for the instruction block and make sure it is actually +read by your harness. + ## Progressive Adoption | Level | What you do | What you get | diff --git a/README.zh.md b/README.zh.md index 7910606..bbc7180 100644 --- a/README.zh.md +++ b/README.zh.md @@ -36,6 +36,12 @@ liyi check --root . 3. **`liyi migrate`** — 当 schema 版本变更时升级 sidecar 文件。幂等。 4. **由人类复核** — 在 `.liyi.jsonc` 中设置 `"reviewed": true` 以批准,或在源码中添加 `@liyi:intent` 以明确给出人类版本。 +## 驾驭框架兼容性 + +《立意》遵循 [AGENTS.md 约定](https://agents.md),默认将其所用的智能体指令写入 `AGENTS.md`。 +凡是能读取 `AGENTS.md` 的驾驭框架都应当直接工作。如果您所用的驾驭框架不读取 +`AGENTS.md`,则您需要负责为《立意》的指令块寻找合适的存放位置,并确保您的驾驭框架能够读取它。 + ## 渐进式采用 | 级别 | 操作 | 收益 | diff --git a/crates/liyi-cli/src/cli.rs b/crates/liyi-cli/src/cli.rs index 6dba0ae..9fe2120 100644 --- a/crates/liyi-cli/src/cli.rs +++ b/crates/liyi-cli/src/cli.rs @@ -72,7 +72,7 @@ pub enum Commands { prompt: bool, }, - /// Migrate sidecar files to the current schema version + /// Migrate sidecar files and agent instruction blocks to the current versions Migrate { /// Sidecar files or directories to migrate (recursive) files: Vec, diff --git a/crates/liyi-cli/src/cli.rs.liyi.jsonc b/crates/liyi-cli/src/cli.rs.liyi.jsonc index 8eca112..8de9c6c 100644 --- a/crates/liyi-cli/src/cli.rs.liyi.jsonc +++ b/crates/liyi-cli/src/cli.rs.liyi.jsonc @@ -36,7 +36,7 @@ 139 ], "tree_path": "enum.Commands", - "source_hash": "sha256:e89ae347b2740462601beeece7d4ec5e24135706e545e8ec19904ec8bcb4c13e", + "source_hash": "sha256:9bd42fe639060abbdc278d30ae577ab9958456be4b0147e43f8b391bbead7cc2", "source_anchor": "pub enum Commands {" } ] diff --git a/crates/liyi-cli/src/main.rs b/crates/liyi-cli/src/main.rs index 3f2f549..a64c277 100644 --- a/crates/liyi-cli/src/main.rs +++ b/crates/liyi-cli/src/main.rs @@ -1,5 +1,6 @@ use std::env; use std::io::{self, IsTerminal}; +use std::path::PathBuf; use std::process; use clap::Parser; @@ -115,21 +116,42 @@ fn main() { process::exit(2); } - let targets = match liyi::discovery::resolve_sidecar_targets(&files) { + let agents_files = match liyi::discovery::resolve_agents_md_targets(&files) { Ok(t) => t, Err(e) => { eprintln!("Error: {e}"); process::exit(2); } }; + // Explicit agent-instruction files are not source files, so keep + // them out of sidecar resolution (which would otherwise demand a + // co-located `.liyi.jsonc`). + let sidecar_inputs: Vec = files + .iter() + .filter(|p| !agents_files.contains(p)) + .cloned() + .collect(); + let sidecar_targets = if sidecar_inputs.is_empty() { + Vec::new() + } else { + match liyi::discovery::resolve_sidecar_targets(&sidecar_inputs) { + Ok(t) => t, + Err(e) => { + eprintln!("Error: {e}"); + process::exit(2); + } + } + }; - if targets.is_empty() { - eprintln!("no .liyi.jsonc files found in the given paths"); + if sidecar_targets.is_empty() && agents_files.is_empty() { + eprintln!( + "no .liyi.jsonc sidecars or agent-instruction blocks found in the given paths" + ); process::exit(2); } let mut errors = 0; - for sidecar_path in &targets { + for sidecar_path in &sidecar_targets { match liyi::reanchor::run_reanchor(sidecar_path, None, None, true) { Ok(()) => { println!("Migrated: {}", sidecar_path.display()); @@ -140,6 +162,17 @@ fn main() { } } } + for agents_path in &agents_files { + match liyi::init::migrate_agents_md(agents_path) { + Ok(_) => { + println!("Migrated: {}", agents_path.display()); + } + Err(e) => { + eprintln!("Error ({}): {e}", agents_path.display()); + errors += 1; + } + } + } if errors > 0 { process::exit(2); @@ -180,7 +213,14 @@ fn main() { None => { let root = env::current_dir().unwrap_or_default(); match liyi::init::init_agents_md(&root, force) { - Ok(path) => println!("Initialized: {}", path.display()), + Ok(path) => { + println!("Initialized: {}", path.display()); + println!( + "note: these instructions follow the AGENTS.md convention \ + (https://agents.md); if your harness does not read AGENTS.md, it is \ + your responsibility to place the block where your harness will read it." + ); + } Err(e) => { eprintln!("Error: {e}"); process::exit(1); diff --git a/crates/liyi-cli/src/main.rs.liyi.jsonc b/crates/liyi-cli/src/main.rs.liyi.jsonc index 37646fb..fddb185 100644 --- a/crates/liyi-cli/src/main.rs.liyi.jsonc +++ b/crates/liyi-cli/src/main.rs.liyi.jsonc @@ -8,8 +8,8 @@ "reviewed": true, "intent": "Reset SIGPIPE to SIG_DFL so that piping output into programs like `head` causes silent termination instead of a panic.", "source_span": [ - 15, - 19 + 16, + 20 ], "tree_path": "fn.reset_sigpipe", "source_hash": "sha256:88c175f1caa9ce830cd8d1f4e54d8942fa5f709dbc2589d8291a7dbe3dcffbd0", @@ -20,11 +20,11 @@ "reviewed": true, "intent": "=doc", "source_span": [ - 26, - 294 + 27, + 334 ], "tree_path": "fn.main", - "source_hash": "sha256:b4e0b493e99a15f9fd1cca0946da94ddcf8c800f4b0b4cfce6fadb031de9f39f", + "source_hash": "sha256:9370c0ab92fba386fdcdc65a989afe5726b8ab693927a12b7682803e01ac5dfd", "source_anchor": "fn main() {" }, { @@ -32,8 +32,8 @@ "reviewed": true, "intent": "Parse the target into a path (and optional trailing :line), locate the repo root, resolve applicable notes via the live note scan, print the rendered report to stdout, and exit 0. Exit 2 on usage or resolution errors (bad line number, path outside the repo, missing root).", "source_span": [ - 304, - 355 + 344, + 395 ], "tree_path": "fn.run_context", "source_hash": "sha256:4cf6f64065c246654eea0d69ba8cc555065004c96785ae9022f8722bc9eed0da", @@ -44,8 +44,8 @@ "reviewed": true, "intent": "=doc", "source_span": [ - 361, - 363 + 401, + 403 ], "tree_path": "fn.is_tty", "source_hash": "sha256:021dc096ffc5aa501d5fe90e8fd1835c4ce784a7de59434995a9be221f97e40f", diff --git a/crates/liyi-cli/tests/cli.rs b/crates/liyi-cli/tests/cli.rs index 7558a06..7c8951c 100644 --- a/crates/liyi-cli/tests/cli.rs +++ b/crates/liyi-cli/tests/cli.rs @@ -121,6 +121,265 @@ fn init_creates_agents_md_when_absent() { content.contains("立意"), "generated AGENTS.md should contain the liyi template block" ); + assert!( + content.contains("START liyi agent instructions"), + "generated AGENTS.md should carry the pragma, got: {content}" + ); + assert!( + content.contains("DON'T EDIT THIS BLOCK"), + "generated AGENTS.md should carry the reminder, got: {content}" + ); + assert!( + content + .trim_end() + .ends_with(""), + "generated AGENTS.md should close the block, got: {content}" + ); +} + +#[test] +fn init_announces_agents_md_convention() { + let tmp = tempfile::TempDir::new().unwrap(); + let out = run_in(tmp.path(), &["init"]); + assert!( + out.status.success(), + "init should exit 0: {:?}", + stderr(&out) + ); + let text = stdout(&out); + assert!( + text.contains("AGENTS.md convention"), + "init should announce the AGENTS.md convention, got: {text}" + ); + assert!( + text.contains("https://agents.md"), + "init note should link the convention, got: {text}" + ); + assert!( + text.contains("your responsibility"), + "init note should state the harness-placement responsibility, got: {text}" + ); +} + +#[test] +fn init_force_replaces_the_agents_md_block() { + let tmp = tempfile::TempDir::new().unwrap(); + let root = tmp.path(); + let agents = root.join("AGENTS.md"); + std::fs::write( + &agents, + "# AGENTS.md\n\n\n\nstale body\n\n", + ) + .unwrap(); + + let refused = run_in(root, &["init"]); + assert!( + !refused.status.success(), + "init should refuse an existing block without --force" + ); + + let forced = run_in(root, &["init", "--force"]); + assert!( + forced.status.success(), + "init --force: {:?}", + stderr(&forced) + ); + + let content = std::fs::read_to_string(&agents).unwrap(); + assert!(content.contains("rev. 1"), "got: {content}"); + assert!(content.contains("## The 立意"), "got: {content}"); + assert!(!content.contains("stale body"), "got: {content}"); + assert_eq!( + content.matches("START liyi agent instructions").count(), + 1, + "force must replace, not append" + ); +} + +#[test] +fn init_is_idempotent_for_agents_md() { + let tmp = tempfile::TempDir::new().unwrap(); + let root = tmp.path(); + + let first = run_in(root, &["init"]); + assert!(first.status.success(), "first init: {:?}", stderr(&first)); + + let second = run_in(root, &["init"]); + assert!( + !second.status.success(), + "a second init should refuse rather than duplicate the block" + ); + assert!(stderr(&second).contains("already exists")); + + let content = std::fs::read_to_string(root.join("AGENTS.md")).unwrap(); + assert_eq!( + content.matches("START liyi agent instructions").count(), + 1, + "init must not append a duplicate block" + ); +} + +#[test] +fn migrate_rewrites_an_older_agents_md_block() { + let tmp = tempfile::TempDir::new().unwrap(); + let root = tmp.path(); + let agents = root.join("AGENTS.md"); + std::fs::write( + &agents, + "# AGENTS.md\n\n\n\nstale body\n\n", + ) + .unwrap(); + + let out = run_in(root, &["migrate", "AGENTS.md"]); + assert!(out.status.success(), "migrate: {:?}", stderr(&out)); + + let content = std::fs::read_to_string(&agents).unwrap(); + assert!( + content.contains("rev. 1"), + "block should be at the current revision, got: {content}" + ); + assert!( + content.contains("## The 立意"), + "block should carry the template, got: {content}" + ); + assert!( + !content.contains("stale body"), + "the stale body should be replaced" + ); +} + +#[test] +fn migrate_directory_updates_agents_md_and_sidecars() { + let tmp = tempfile::TempDir::new().unwrap(); + let root = tmp.path(); + std::fs::write( + root.join("AGENTS.md"), + "# AGENTS.md\n\n\n\nstale body\n\n", + ) + .unwrap(); + + let src = root.join("sample.rs"); + std::fs::write(&src, "pub fn f() {}\n").unwrap(); + run_in(root, &["init", "sample.rs"]); + + let out = run_in(root, &["migrate", "."]); + assert!(out.status.success(), "migrate: {:?}", stderr(&out)); + + let content = std::fs::read_to_string(root.join("AGENTS.md")).unwrap(); + assert!(content.contains("rev. 1")); + assert!(!content.contains("stale body")); +} + +#[test] +fn migrate_is_idempotent_at_the_cli() { + let tmp = tempfile::TempDir::new().unwrap(); + let root = tmp.path(); + let init = run_in(root, &["init"]); + assert!(init.status.success(), "init: {:?}", stderr(&init)); + + let first = run_in(root, &["migrate", "AGENTS.md"]); + assert!(first.status.success(), "migrate: {:?}", stderr(&first)); + let after_first = std::fs::read_to_string(root.join("AGENTS.md")).unwrap(); + + let second = run_in(root, &["migrate", "AGENTS.md"]); + assert!(second.status.success(), "migrate: {:?}", stderr(&second)); + assert_eq!( + std::fs::read_to_string(root.join("AGENTS.md")).unwrap(), + after_first, + "a second migrate must leave the file byte-identical" + ); +} + +#[test] +fn migrate_directory_with_only_agents_md_succeeds() { + let tmp = tempfile::TempDir::new().unwrap(); + let root = tmp.path(); + std::fs::write( + root.join("AGENTS.md"), + "# AGENTS.md\n\n\n\nstale\n\n", + ) + .unwrap(); + + let out = run_in(root, &["migrate", "."]); + assert!( + out.status.success(), + "a directory with only an AGENTS.md must still migrate: {:?}", + stderr(&out) + ); + let content = std::fs::read_to_string(root.join("AGENTS.md")).unwrap(); + assert!(content.contains("rev. 1"), "got: {content}"); +} + +#[test] +fn migrate_accepts_an_explicit_custom_location_file() { + let tmp = tempfile::TempDir::new().unwrap(); + let root = tmp.path(); + let custom = root.join("agent-instructions.md"); + std::fs::write( + &custom, + "# Instructions\n\n\n\n## The 立意 (Intent Specs) design pattern for agents\nstale\n\n", + ) + .unwrap(); + + let out = run_in(root, &["migrate", "agent-instructions.md"]); + assert!(out.status.success(), "migrate: {:?}", stderr(&out)); + let content = std::fs::read_to_string(&custom).unwrap(); + assert!(content.contains("rev. 1"), "got: {content}"); + assert!(content.starts_with("# Instructions\n\n"), "got: {content}"); +} + +#[test] +fn migrate_directory_leaves_heading_less_blocks_alone() { + let tmp = tempfile::TempDir::new().unwrap(); + let root = tmp.path(); + let design = root.join("liyi-design.md"); + let design_body = "\nWhen writing or modifying code:\nstale\n\n"; + std::fs::write(&design, design_body).unwrap(); + std::fs::write( + root.join("AGENTS.md"), + "# AGENTS.md\n\n\n\nstale\n\n", + ) + .unwrap(); + + let out = run_in(root, &["migrate", "."]); + assert!(out.status.success(), "migrate: {:?}", stderr(&out)); + + let agents = std::fs::read_to_string(root.join("AGENTS.md")).unwrap(); + assert!(agents.contains("rev. 1"), "got: {agents}"); + assert_eq!( + std::fs::read_to_string(&design).unwrap(), + design_body, + "a block that is not named AGENTS.md must be left untouched" + ); +} + +#[test] +fn migrate_directory_does_not_recurse_into_ungoverned_subtrees() { + let tmp = tempfile::TempDir::new().unwrap(); + let root = tmp.path(); + let vendored = root.join("3rdparty/vendored"); + std::fs::create_dir_all(&vendored).unwrap(); + let old_block = "\n\nstale\n\n"; + std::fs::write(vendored.join("AGENTS.md"), old_block).unwrap(); + std::fs::write( + root.join("AGENTS.md"), + format!("# AGENTS.md\n\n{old_block}"), + ) + .unwrap(); + + let out = run_in(root, &["migrate", "."]); + assert!(out.status.success(), "migrate: {:?}", stderr(&out)); + + assert!( + std::fs::read_to_string(root.join("AGENTS.md")) + .unwrap() + .contains("rev. 1") + ); + assert_eq!( + std::fs::read_to_string(vendored.join("AGENTS.md")).unwrap(), + old_block, + "a vendored/ungoverned subtree must not be rewritten" + ); } #[test] diff --git a/crates/liyi/src/approve.rs.liyi.jsonc b/crates/liyi/src/approve.rs.liyi.jsonc index 8a3a1a9..028203a 100644 --- a/crates/liyi/src/approve.rs.liyi.jsonc +++ b/crates/liyi/src/approve.rs.liyi.jsonc @@ -102,7 +102,7 @@ { "item": "RequirementInfo", "reviewed": true, - "intent": "=trivial", + "intent": "=self-doc", "source_span": [ 232, 238 @@ -114,7 +114,7 @@ { "item": "CandidateFileCtx", "reviewed": true, - "intent": "=trivial", + "intent": "=self-doc", "source_span": [ 240, 250 diff --git a/crates/liyi/src/check.rs b/crates/liyi/src/check.rs index a6cc8af..3ef310a 100644 --- a/crates/liyi/src/check.rs +++ b/crates/liyi/src/check.rs @@ -1579,7 +1579,8 @@ fn check_review_status( } } -/// Check trivial/ignore source markers and the sidecar `=trivial` sentinel. +/// Check trivial/ignore source markers and the sidecar `=trivial` / +/// `=self-doc` sentinels. fn check_trivial_ignore( file: &Path, label: &str, @@ -1630,8 +1631,12 @@ fn check_trivial_ignore( } } - // Sidecar "=trivial" sentinel - if item.intent == "=trivial" { + // Sidecar "=trivial" / "=self-doc" sentinels — both mark the item as + // needing no behavioral spec (trivial = too small to hide behavior; + // self-doc = intent intrinsic to the surface form) and are tracked + // identically: info-level, not stale, skipped by adversarial testing, + // and in conflict with an in-span @liyi:nontrivial. + if item.intent == "=trivial" || item.intent == "=self-doc" { let has_nontrivial = source_markers.iter().any(|m| { matches!(m, SourceMarker::Nontrivial { line } if *line >= span_start.saturating_sub(1) && *line <= span_end) @@ -1642,8 +1647,10 @@ fn check_trivial_ignore( item_or_req: label.to_string(), kind: DiagnosticKind::ConflictingTriviality, severity: Severity::Error, - message: "\x40liyi:nontrivial in source conflicts with \"=trivial\" in sidecar" - .into(), + message: format!( + "\x40liyi:nontrivial in source conflicts with \"{}\" in sidecar", + item.intent + ), fix_hint: None, fixed: false, span_start: Some(span_start), @@ -1657,7 +1664,7 @@ fn check_trivial_ignore( item_or_req: label.to_string(), kind: DiagnosticKind::Trivial, severity: Severity::Info, - message: "intent \"=trivial\"".into(), + message: format!("intent \"{}\"", item.intent), fix_hint: None, fixed: false, span_start: Some(span_start), diff --git a/crates/liyi/src/check.rs.liyi.jsonc b/crates/liyi/src/check.rs.liyi.jsonc index dac7d7b..6b7c67a 100644 --- a/crates/liyi/src/check.rs.liyi.jsonc +++ b/crates/liyi/src/check.rs.liyi.jsonc @@ -312,13 +312,13 @@ { "item": "check_trivial_ignore", "reviewed": true, - "intent": "Emit Trivial or Ignored info diagnostics for @liyi:trivial and @liyi:ignore markers within the item's span. For the sidecar =trivial sentinel, emit Trivial info unless @liyi:nontrivial is present in the span, in which case emit ConflictingTriviality error.", + "intent": "Emit Trivial or Ignored info diagnostics for @liyi:trivial and @liyi:ignore markers within the item's span. For the sidecar =trivial and =self-doc sentinels, emit Trivial info unless @liyi:nontrivial is present in the span, in which case emit ConflictingTriviality error. Both sentinels are treated identically for tracking.", "source_span": [ - 1583, - 1670 + 1584, + 1677 ], "tree_path": "fn.check_trivial_ignore", - "source_hash": "sha256:434e82e6c548233c1113313ec013030b349da1f922c4f5e8b9a5c5f2a6f4feb5", + "source_hash": "sha256:5d17d6d16f123247119356ecf46bd318c1fc9971c64575213ca18fd368781bc6", "source_anchor": "fn check_trivial_ignore(" }, { @@ -326,8 +326,8 @@ "reviewed": true, "intent": "Validate existing related edges (emit UnknownRequirement or ReqChanged using computed_hash for cascading staleness), sync source @liyi:related markers into sidecar as MissingRelatedEdge (auto-fix inserts edge with current hash), and fill null hashes on existing edges when --fix is active. Return true if the item was modified.", "source_span": [ - 1675, - 1788 + 1682, + 1795 ], "tree_path": "fn.check_related_edges", "source_hash": "sha256:66f6a7ee8df06a18e9a4e29e19851b775ee70717bc5b01a179afc8738a00aa12", @@ -342,8 +342,8 @@ "reviewed": true, "intent": "=doc", "source_span": [ - 1798, - 1850 + 1805, + 1857 ], "tree_path": "fn.detect_requirement_cycles", "source_hash": "sha256:c7124f27dcaade014657e1b571274470d11511922c508ca2a3836d7df20e808d", @@ -354,8 +354,8 @@ "reviewed": true, "intent": "Read a file's contents with caching: return the cached string if already loaded, otherwise read from disk, store in the cache, and return. Return None on I/O failure.", "source_span": [ - 1853, - 1864 + 1860, + 1871 ], "tree_path": "fn.read_cached", "source_hash": "sha256:c5e98a29f80d683b44e3c883feff6cc1eee817296cfa669df07d5c7175deb94f", diff --git a/crates/liyi/src/context.rs.liyi.jsonc b/crates/liyi/src/context.rs.liyi.jsonc index da0625d..d0ee125 100644 --- a/crates/liyi/src/context.rs.liyi.jsonc +++ b/crates/liyi/src/context.rs.liyi.jsonc @@ -54,7 +54,7 @@ { "item": "ResolvedNote", "reviewed": true, - "intent": "=trivial", + "intent": "=self-doc", "source_span": [ 52, 55 @@ -81,7 +81,7 @@ { "item": "parent_dir", "reviewed": true, - "intent": "=trivial", + "intent": "=self-doc", "source_span": [ 67, 72 @@ -180,138 +180,6 @@ "tree_path": "fn.render", "source_hash": "sha256:50a5c675ffb1288c9ca19c6557db00549a264bb141cb30c8dfdab21fe667ce38", "source_anchor": "pub fn render(notes: &[ResolvedNote]) -> String {" - }, - { - "item": "tests", - "reviewed": true, - "intent": "=trivial", - "source_span": [ - 314, - 477 - ], - "tree_path": "mod.tests", - "source_hash": "sha256:d6b92ac9e4dadc18d78fbe0cc6e4555b592ae99d39d6a6c8eb7e0f449da602e9", - "source_anchor": "mod tests {" - }, - { - "item": "tests::write", - "reviewed": true, - "intent": "=trivial", - "source_span": [ - 319, - 325 - ], - "tree_path": "mod.tests::fn.write", - "source_hash": "sha256:82b96522cb4cc15ba6f48d5bbbe23a405de2cd9a807b61a8c94cf04146452d7a", - "source_anchor": " fn write(root: &Path, rel: &str, content: &str) {" - }, - { - "item": "tests::ancestor_dirs_walks_to_root", - "reviewed": true, - "intent": "=trivial", - "source_span": [ - 328, - 334 - ], - "tree_path": "mod.tests::fn.ancestor_dirs_walks_to_root", - "source_hash": "sha256:22202a8e9ab3c24a797902e3de7d0373c040e1038af8737e2b188a3f0edaf65f", - "source_anchor": " fn ancestor_dirs_walks_to_root() {" - }, - { - "item": "tests::extract_named_block", - "reviewed": true, - "intent": "=trivial", - "source_span": [ - 337, - 344 - ], - "tree_path": "mod.tests::fn.extract_named_block", - "source_hash": "sha256:3195f3031b76e2938ee0007e040b625bbec78be6cebef62b870203e40bfd3288", - "source_anchor": " fn extract_named_block() {" - }, - { - "item": "tests::unclosed_block_extends_to_eof", - "reviewed": true, - "intent": "=trivial", - "source_span": [ - 347, - 354 - ], - "tree_path": "mod.tests::fn.unclosed_block_extends_to_eof", - "source_hash": "sha256:85d909eb318bec8693733b629fb69d640486da782fff38c5826160166a1acc33", - "source_anchor": " fn unclosed_block_extends_to_eof() {" - }, - { - "item": "tests::directory_scope_includes_ancestor_note", - "reviewed": true, - "intent": "=trivial", - "source_span": [ - 357, - 371 - ], - "tree_path": "mod.tests::fn.directory_scope_includes_ancestor_note", - "source_hash": "sha256:d6e6de7681791f1ce33c9eea85b2ce58686421a6f63ffd3a1840ae667d1d6533", - "source_anchor": " fn directory_scope_includes_ancestor_note() {" - }, - { - "item": "tests::nearer_note_shadows_same_name", - "reviewed": true, - "intent": "=trivial", - "source_span": [ - 374, - 393 - ], - "tree_path": "mod.tests::fn.nearer_note_shadows_same_name", - "source_hash": "sha256:bdd669b5072564d6e8b1e65a474552b395ee244468664fbc38f461b87d6f02d6", - "source_anchor": " fn nearer_note_shadows_same_name() {" - }, - { - "item": "tests::see_pulls_remote_note", - "reviewed": true, - "intent": "=trivial", - "source_span": [ - 396, - 414 - ], - "tree_path": "mod.tests::fn.see_pulls_remote_note", - "source_hash": "sha256:b6d52e04b45a2c946a5a192bdfac8d281b91e2210bbdd2bc2da6d78322d731ef", - "source_anchor": " fn see_pulls_remote_note() {" - }, - { - "item": "tests::see_none_suppresses_directory_scope", - "reviewed": true, - "intent": "=trivial", - "source_span": [ - 417, - 432 - ], - "tree_path": "mod.tests::fn.see_none_suppresses_directory_scope", - "source_hash": "sha256:f94b1e57167a7d9fb302b8fb67d9f86b1000b900a6f55ea0a582e328bf06026c", - "source_anchor": " fn see_none_suppresses_directory_scope() {" - }, - { - "item": "tests::see_survives_opt_out", - "reviewed": true, - "intent": "=trivial", - "source_span": [ - 435, - 458 - ], - "tree_path": "mod.tests::fn.see_survives_opt_out", - "source_hash": "sha256:7155f36a1e3bbef73f93e06d3494988760883eeed7c8a2e7a2d2415b0a851dc0", - "source_anchor": " fn see_survives_opt_out() {" - }, - { - "item": "tests::unmarked_readme_is_invisible", - "reviewed": true, - "intent": "=trivial", - "source_span": [ - 461, - 476 - ], - "tree_path": "mod.tests::fn.unmarked_readme_is_invisible", - "source_hash": "sha256:1d90fe171cb951bbae9616ed448af93d6eab2fbb306cffa16324d61ff6c300e4", - "source_anchor": " fn unmarked_readme_is_invisible() {" } ] } diff --git a/crates/liyi/src/diagnostics.rs b/crates/liyi/src/diagnostics.rs index bd875f6..0419427 100644 --- a/crates/liyi/src/diagnostics.rs +++ b/crates/liyi/src/diagnostics.rs @@ -411,7 +411,7 @@ pub fn format_github_actions(d: &Diagnostic, root: &std::path::Path) -> String { // Append intent text to the message when available and non-sentinel. let message = match &d.intent { - Some(intent) if intent != "=doc" && intent != "=trivial" => { + Some(intent) if intent != "=doc" && intent != "=trivial" && intent != "=self-doc" => { format!("{message}%0AIntent: {}", escape(intent)) } _ => message, diff --git a/crates/liyi/src/diagnostics.rs.liyi.jsonc b/crates/liyi/src/diagnostics.rs.liyi.jsonc index 3d37d6a..b5aa544 100644 --- a/crates/liyi/src/diagnostics.rs.liyi.jsonc +++ b/crates/liyi/src/diagnostics.rs.liyi.jsonc @@ -108,13 +108,13 @@ { "item": "format_github_actions", "reviewed": true, - "intent": "Format a single diagnostic as a GitHub Actions workflow command (::notice, ::warning, or ::error) with file, line, and title parameters. Maps severity to annotation level, strips the repo root from file paths, escapes %, \\r, \\n per the Actions toolkit spec, uses the item name with a severity icon as the annotation title, and appends the intent text to the message body when present and non-sentinel (not =doc or =trivial).", + "intent": "Format a single diagnostic as a GitHub Actions workflow command (::notice, ::warning, or ::error) with file, line, and title parameters. Maps severity to annotation level, strips the repo root from file paths, escapes %, \\r, \\n per the Actions toolkit spec, uses the item name with a severity icon as the annotation title, and appends the intent text to the message body when present and non-sentinel (not =doc, =trivial, or =self-doc).", "source_span": [ 394, 440 ], "tree_path": "fn.format_github_actions", - "source_hash": "sha256:4b40f8d57fb98a81e056599bccfca5433e9329071c6f3b39d9b521075a191533", + "source_hash": "sha256:b10ff1b5515b6e23424a14058dadff259d21ab8da81b0b28951850064b74434c", "source_anchor": "pub fn format_github_actions(d: &Diagnostic, root: &std::path::Path) -> String {" } ] diff --git a/crates/liyi/src/discovery.rs b/crates/liyi/src/discovery.rs index ddd4bed..e79e86e 100644 --- a/crates/liyi/src/discovery.rs +++ b/crates/liyi/src/discovery.rs @@ -286,6 +286,44 @@ fn sidecar_path_for_source(source: &Path) -> PathBuf { source.with_file_name(format!("{name}{SIDECAR_SUFFIX}")) } +/// Expand a list of file/directory paths into agent-instruction files. +/// +/// Targeting strictly mirrors `liyi init`: a directory contributes exactly its +/// own `AGENTS.md` (never a recursive walk, so ungoverned subtrees such as +/// `3rdparty/` or `vendor/` are never touched), and an explicitly-passed file +/// is included so a repository's preferred location can be migrated directly. +/// A file qualifies only when it carries the portable +/// `START liyi agent instructions` pragma. +pub fn resolve_agents_md_targets(paths: &[PathBuf]) -> Result, String> { + let mut result: Vec = Vec::new(); + for p in paths { + if p.is_dir() { + let candidate = p.join(crate::init::AGENTS_MD_FILENAME); + if has_agents_md_block(&candidate) { + result.push(candidate); + } + } else if p.is_file() { + if has_agents_md_block(p) { + result.push(p.clone()); + } + } else { + return Err(format!("path does not exist: {}", p.display())); + } + } + result.sort(); + result.dedup(); + Ok(result) +} + +/// Whether `path` contains a portable `AGENTS.md` instruction block. +/// +/// Unreadable or binary files simply do not qualify. +fn has_agents_md_block(path: &Path) -> bool { + std::fs::read_to_string(path) + .map(|text| crate::init::find_agents_md_block(&text).is_some()) + .unwrap_or(false) +} + /// Compute `path` relative to `base` using pure lexical processing. fn pathdiff(path: &Path, base: &Path) -> Option { path.strip_prefix(base) @@ -459,4 +497,77 @@ mod tests { assert!(err.contains("no sidecar found"), "got: {err}"); assert!(err.contains("foo.rs.liyi.jsonc"), "got: {err}"); } + + #[test] + fn resolve_agents_md_targets_finds_agents_md_in_a_directory() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + fs::write( + root.join("AGENTS.md"), + "\n\nbody\n\n", + ) + .unwrap(); + fs::write(root.join("OTHER.md"), "no pragma here").unwrap(); + + let targets = resolve_agents_md_targets(&[root.to_path_buf()]).unwrap(); + assert_eq!(targets, vec![root.join("AGENTS.md")]); + } + + #[test] + fn resolve_agents_md_targets_includes_explicit_files_with_pragma() { + let tmp = TempDir::new().unwrap(); + let custom = tmp.path().join("agent-instructions.md"); + fs::write( + &custom, + "\n\nbody\n\n", + ) + .unwrap(); + + let targets = resolve_agents_md_targets(std::slice::from_ref(&custom)).unwrap(); + assert_eq!(targets, vec![custom]); + } + + #[test] + fn resolve_agents_md_targets_skips_files_without_pragma() { + let tmp = TempDir::new().unwrap(); + let agents = tmp.path().join("AGENTS.md"); + fs::write(&agents, "# AGENTS.md\n\nno pragma\n").unwrap(); + + let targets = resolve_agents_md_targets(std::slice::from_ref(&agents)).unwrap(); + assert!(targets.is_empty()); + } + + #[test] + fn resolve_agents_md_targets_ignores_custom_named_files_in_directories() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let block = "\nbody\n\n"; + // A block under a non-default name (e.g. the design doc's heading-less + // source) must not be picked up by a directory walk. + fs::write(root.join("instructions.md"), block).unwrap(); + fs::write(root.join("AGENTS.md"), block).unwrap(); + + let targets = resolve_agents_md_targets(&[root.to_path_buf()]).unwrap(); + assert_eq!(targets, vec![root.join("AGENTS.md")]); + } + + #[test] + fn resolve_agents_md_targets_does_not_recurse() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let sub = root.join("subprojects/a"); + fs::create_dir_all(&sub).unwrap(); + let block = "\nbody\n\n"; + fs::write(root.join("AGENTS.md"), block).unwrap(); + fs::write(sub.join("AGENTS.md"), block).unwrap(); + + // A directory contributes only its own AGENTS.md; nested ones are + // reachable only by naming their directory, so ungoverned subtrees + // (3rdparty/, vendor/) can never be rewritten by a parent walk. + let top = resolve_agents_md_targets(&[root.to_path_buf()]).unwrap(); + assert_eq!(top, vec![root.join("AGENTS.md")]); + + let nested = resolve_agents_md_targets(&[sub.clone()]).unwrap(); + assert_eq!(nested, vec![sub.join("AGENTS.md")]); + } } diff --git a/crates/liyi/src/discovery.rs.liyi.jsonc b/crates/liyi/src/discovery.rs.liyi.jsonc index 6706ed0..779f742 100644 --- a/crates/liyi/src/discovery.rs.liyi.jsonc +++ b/crates/liyi/src/discovery.rs.liyi.jsonc @@ -95,6 +95,30 @@ "tree_path": "fn.sidecar_path_for_source", "source_hash": "sha256:552d2551c532e7542d3d3bce735fcc3715b9af100b67481f282a74df37e49b2c", "source_anchor": "fn sidecar_path_for_source(source: &Path) -> PathBuf {" + }, + { + "item": "resolve_agents_md_targets", + "reviewed": true, + "intent": "Expand a list of file/directory paths into agent-instruction files, strictly mirroring `liyi init`'s targeting. A directory contributes exactly its own AGENTS.md (never a recursive walk, so ungoverned subtrees like 3rdparty/ or vendor/ are never touched); an explicitly-passed file is included when it carries the pragma, so a repository's preferred location can be migrated directly. A file qualifies only when it contains the portable `START liyi agent instructions` pragma. Error if a path does not exist. Return the deduplicated, sorted target list.", + "source_span": [ + 297, + 316 + ], + "tree_path": "fn.resolve_agents_md_targets", + "source_hash": "sha256:e080976caa652aeef085c70cc5038bc72efb5ae22e28ef5f0b8e93f705699287", + "source_anchor": "pub fn resolve_agents_md_targets(paths: &[PathBuf]) -> Result, String> {" + }, + { + "item": "has_agents_md_block", + "reviewed": true, + "intent": "=self-doc", + "source_span": [ + 321, + 325 + ], + "tree_path": "fn.has_agents_md_block", + "source_hash": "sha256:7bf55e6bd57ba5e329ac41f4a57f1186c447fe939b03a3aabc6b9c0132e6dea9", + "source_anchor": "fn has_agents_md_block(path: &Path) -> bool {" } ] } diff --git a/crates/liyi/src/init.rs b/crates/liyi/src/init.rs index e9361f6..007c270 100644 --- a/crates/liyi/src/init.rs +++ b/crates/liyi/src/init.rs @@ -9,27 +9,89 @@ use crate::tree_path::{detect_language, discover_items}; /// so that `liyi init` can extract the portable template block. const AGENTS_MD_FULL: &str = include_str!("../../../AGENTS.md"); -const TEMPLATE_START: &str = "\n"; -const TEMPLATE_END: &str = "\n"; +/// Current revision of the portable agent-instruction template. +/// +/// Bumped when the template content changes; `liyi migrate` rewrites an +/// adopter's block to this revision. +pub const TEMPLATE_REVISION: u32 = 1; + +/// File name `liyi init` scaffolds the agent instruction block into. +/// +/// `liyi migrate` uses this same constant for directory targets, so the two +/// commands can never disagree about which file they manage. +pub const AGENTS_MD_FILENAME: &str = "AGENTS.md"; + +/// Opening words of the `START` pragma; the revision number follows. +const PRAGMA_START_PREFIX: &str = ""; +/// Reminder line that follows the `START` pragma. +const REMINDER: &str = + ""; +/// Closing marker line. +const END_MARKER: &str = ""; + +/// The `START liyi agent instructions rev. N` pragma line for `rev`. +fn pragma_line(rev: u32) -> String { + format!("{PRAGMA_START_PREFIX}{rev}{PRAGMA_SUFFIX}") +} + +/// Extract the portable agent instruction block from the repo's AGENTS.md, +/// re-rendered at [`TEMPLATE_REVISION`] and including the pragma, reminder, +/// and closing marker — i.e. exactly what `liyi init` ships and `liyi migrate` +/// writes. +/// +/// Panics if the pragma or end marker is missing — but since the content is +/// baked in via `include_str!`, this is effectively a build-time guarantee: +/// any AGENTS.md without the pragma won't produce a working binary. +fn agents_md_block() -> String { + let start = format!("{}\n{REMINDER}\n", pragma_line(TEMPLATE_REVISION)); + let start_idx = AGENTS_MD_FULL + .find(&start) + .expect("AGENTS.md missing START liyi agent instructions pragma") + + start.len(); + let end_idx = start_idx + + AGENTS_MD_FULL[start_idx..] + .find(&format!("\n{END_MARKER}")) + .expect("AGENTS.md missing END liyi agent instructions marker"); + format!( + "{start}{}\n{END_MARKER}", + &AGENTS_MD_FULL[start_idx..end_idx] + ) +} -/// Extract the portable agent instruction block from the repo's AGENTS.md. +/// Locate the portable agent-instruction block in `text`. /// -/// The block is delimited by `` and -/// `` HTML comment markers. Panics at -/// runtime (not compile time) if the markers are missing — but since -/// the content is baked in via `include_str!`, this is effectively a -/// build-time guarantee: any AGENTS.md without markers won't produce -/// a working binary. -fn agents_md_block() -> &'static str { - let start = AGENTS_MD_FULL - .find(TEMPLATE_START) - .expect("AGENTS.md missing marker") - + TEMPLATE_START.len(); - let end = start - + AGENTS_MD_FULL[start..] - .find(TEMPLATE_END) - .expect("AGENTS.md missing marker"); - &AGENTS_MD_FULL[start..end] +/// Returns the byte range from the `START` pragma through the `END` marker, +/// or `None` if no block is present. The revision is not inspected, so a block +/// written by an older `liyi` is still found. +pub fn find_agents_md_block(text: &str) -> Option<(usize, usize)> { + let start = text.find(PRAGMA_START_PREFIX)?; + let end = start + text[start..].find(END_MARKER)? + END_MARKER.len(); + Some((start, end)) +} + +/// Rewrite the portable agent-instruction block in `path` to +/// [`TEMPLATE_REVISION`]. +/// +/// Returns `Ok(true)` when the file contains a block (rewritten in place, +/// idempotently) and `Ok(false)` when it does not. +pub fn migrate_agents_md(path: &Path) -> Result { + let content = + fs::read_to_string(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?; + let Some((start, end)) = find_agents_md_block(&content) else { + return Ok(false); + }; + let block = agents_md_block(); + if content[start..end] == block { + return Ok(true); + } + let mut new_content = String::with_capacity(content.len()); + new_content.push_str(&content[..start]); + new_content.push_str(&block); + new_content.push_str(&content[end..]); + fs::write(path, new_content).map_err(|e| format!("cannot write {}: {e}", path.display()))?; + Ok(true) } /// Error type for init operations. @@ -60,28 +122,34 @@ impl From for InitError { } } -/// `liyi init` — append agent instruction to `AGENTS.md`. +/// `liyi init` — create or update the agent instruction section in `AGENTS.md`. /// -/// Creates the file if it doesn't exist; appends the block if it does. -/// If `force` is true, overwrites the agent instruction block even if -/// a `## 立意` section already exists. +/// Creates the file if it doesn't exist and appends the block if it does. If a +/// portable block is already present, returns [`InitError::AlreadyExists`] +/// unless `force` is set, in which case the existing block is replaced with the +/// current revision. pub fn init_agents_md(root: &Path, force: bool) -> Result { - let agents_path = root.join("AGENTS.md"); + let agents_path = root.join(AGENTS_MD_FILENAME); + let block = agents_md_block(); if agents_path.is_file() { let content = fs::read_to_string(&agents_path)?; - if content.contains("## 立意") && !force { - return Err(InitError::AlreadyExists(agents_path)); + if let Some((start, end)) = find_agents_md_block(&content) { + if !force { + return Err(InitError::AlreadyExists(agents_path)); + } + let mut new_content = String::with_capacity(content.len()); + new_content.push_str(&content[..start]); + new_content.push_str(&block); + new_content.push_str(&content[end..]); + fs::write(&agents_path, new_content)?; + } else { + let mut new_content = content; + new_content.push('\n'); + new_content.push_str(&block); + fs::write(&agents_path, new_content)?; } - // Append the block - let block = agents_md_block(); - let mut new_content = content; - new_content.push('\n'); - new_content.push_str(block); - fs::write(&agents_path, new_content)?; } else { - // Create new file - let block = agents_md_block(); let content = format!("# AGENTS.md\n\n{block}"); fs::write(&agents_path, content)?; } @@ -209,15 +277,29 @@ pub fn init_sidecar( #[cfg(test)] mod tests { use super::*; + use tempfile::TempDir; #[test] fn agents_md_block_extracts_valid_template() { let block = agents_md_block(); - // Must start with the section heading. + // The shipped block carries the pragma, reminder, content, and marker. + assert!( + block.starts_with(PRAGMA_START_PREFIX), + "block must start with the START pragma" + ); + assert!( + block.contains(&pragma_line(TEMPLATE_REVISION)), + "block must carry the current revision" + ); + assert!(block.contains(REMINDER), "block must carry the reminder"); assert!( - block.starts_with("## 立意"), - "extracted block must start with ## 立意 heading" + block.ends_with(END_MARKER), + "block must end with the END marker" + ); + assert!( + block.contains("## The 立意"), + "block must include the section heading" ); // Key invariants: the block contains the sidecar schema and @@ -240,4 +322,117 @@ mod tests { "template must include the triage JSON schema" ); } + + #[test] + fn find_agents_md_block_locates_a_block_at_any_revision() { + let text = "before\n\n\nbody\n\nafter\n"; + let (start, end) = find_agents_md_block(text).expect("block must be found"); + assert_eq!( + &text[start..end], + "\n\nbody\n" + ); + assert!(find_agents_md_block("no pragma here").is_none()); + } + + #[test] + fn migrate_agents_md_rewrites_an_older_block_idempotently() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("AGENTS.md"); + fs::write( + &path, + "# AGENTS.md\n\n\n\nstale body\n\n", + ) + .unwrap(); + + assert!(migrate_agents_md(&path).unwrap()); + let migrated = fs::read_to_string(&path).unwrap(); + assert!(migrated.contains(&pragma_line(TEMPLATE_REVISION))); + assert!(migrated.contains("## The 立意")); + assert!(!migrated.contains("stale body")); + + // Idempotent: a second run leaves the file byte-identical. + assert!(migrate_agents_md(&path).unwrap()); + assert_eq!(fs::read_to_string(&path).unwrap(), migrated); + } + + #[test] + fn migrate_agents_md_reports_absent_blocks() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("AGENTS.md"); + fs::write(&path, "# AGENTS.md\n\nNo pragma here.\n").unwrap(); + assert!(!migrate_agents_md(&path).unwrap()); + } + + #[test] + fn init_agents_md_creates_a_pragma_wrapped_block() { + let tmp = TempDir::new().unwrap(); + let path = init_agents_md(tmp.path(), false).unwrap(); + let content = fs::read_to_string(&path).unwrap(); + + assert!( + content.starts_with("# AGENTS.md\n\n\n\nstale body\n\n", + ) + .unwrap(); + + // Without --force an existing block is refused. + assert!(matches!( + init_agents_md(tmp.path(), false), + Err(InitError::AlreadyExists(_)) + )); + + // With --force the stale block is replaced in place. + init_agents_md(tmp.path(), true).unwrap(); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains(&pragma_line(TEMPLATE_REVISION))); + assert!(content.contains("## The 立意")); + assert!(!content.contains("stale body")); + assert_eq!(content.matches(PRAGMA_START_PREFIX).count(), 1); + } + + #[test] + fn migrate_agents_md_preserves_surrounding_content() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("AGENTS.md"); + fs::write( + &path, + "# Before\n\n\nORIGINAL-BODY-MARKER\n\n\n# After\n", + ) + .unwrap(); + + migrate_agents_md(&path).unwrap(); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.starts_with("# Before\n\n"), "got: {content}"); + assert!(content.ends_with("\n\n# After\n"), "got: {content}"); + assert!(!content.contains("ORIGINAL-BODY-MARKER"), "got: {content}"); + } } diff --git a/crates/liyi/src/init.rs.liyi.jsonc b/crates/liyi/src/init.rs.liyi.jsonc index dce24cb..a7f48a6 100644 --- a/crates/liyi/src/init.rs.liyi.jsonc +++ b/crates/liyi/src/init.rs.liyi.jsonc @@ -16,48 +16,132 @@ "source_anchor": "const AGENTS_MD_FULL: &str = include_str!(\"../../../AGENTS.md\");" }, { - "item": "TEMPLATE_START", + "item": "TEMPLATE_REVISION", + "reviewed": true, + "intent": "=doc", + "source_span": [ + 16, + 16 + ], + "tree_path": "const.TEMPLATE_REVISION", + "source_hash": "sha256:d7c0b12409c8d457e6d0465410f2ef6fde03b25a04f28ade7f4fc3ac7a5f4bc6", + "source_anchor": "pub const TEMPLATE_REVISION: u32 = 1;" + }, + { + "item": "AGENTS_MD_FILENAME", + "reviewed": true, + "intent": "=trivial", + "source_span": [ + 22, + 22 + ], + "tree_path": "const.AGENTS_MD_FILENAME", + "source_hash": "sha256:8ff8a1171e5dbed15dc495b57bfdf90b22c6e623755d6724678d5cbf17824224", + "source_anchor": "pub const AGENTS_MD_FILENAME: &str = \"AGENTS.md\";" + }, + { + "item": "PRAGMA_START_PREFIX", + "reviewed": true, + "intent": "=trivial", + "source_span": [ + 25, + 25 + ], + "tree_path": "const.PRAGMA_START_PREFIX", + "source_hash": "sha256:5d8a40c558d26e13d92efd83f8debd82257865100f7273e69c9ca8136dd26ce7", + "source_anchor": "const PRAGMA_START_PREFIX: &str = \"\";" + }, + { + "item": "REMINDER", "reviewed": true, "intent": "=trivial", "source_span": [ - 12, - 12 + 29, + 30 ], - "tree_path": "const.TEMPLATE_START", - "source_hash": "sha256:a2bb1acca95830bc995d5334ba98ad79cb29cf0cbeadb78bf5276b6fff08250b", - "source_anchor": "const TEMPLATE_START: &str = \"\\n\";" + "tree_path": "const.REMINDER", + "source_hash": "sha256:2797c357b6d27729c53c87772900ee1fa84dcd61aee7f6482bd1919e8345de86", + "source_anchor": "const REMINDER: &str =" }, { - "item": "TEMPLATE_END", + "item": "END_MARKER", "reviewed": true, "intent": "=trivial", "source_span": [ - 13, - 13 + 32, + 32 + ], + "tree_path": "const.END_MARKER", + "source_hash": "sha256:ef40ba603cf9fdf6ad6916acaea2c30fcb80a5c184d400b55b0a1cde221f823e", + "source_anchor": "const END_MARKER: &str = \"\";" + }, + { + "item": "pragma_line", + "reviewed": true, + "intent": "=doc", + "source_span": [ + 35, + 37 ], - "tree_path": "const.TEMPLATE_END", - "source_hash": "sha256:b27d30cac4bea8226105bc507779eb865acb57718a13e9f72bd7d834147c942e", - "source_anchor": "const TEMPLATE_END: &str = \"\\n\";" + "tree_path": "fn.pragma_line", + "source_hash": "sha256:5ee49009ad56ef46c546c72292f8c96500f537d5476f23890bb9c266cf5f77bf", + "source_anchor": "fn pragma_line(rev: u32) -> String {" }, { "item": "agents_md_block", "reviewed": true, - "intent": "Extract the portable agent instruction block from the compile-time-included AGENTS.md by finding text between and markers. Must panic if either marker is missing.", + "intent": "Extract the portable agent instruction block from the compile-time-included AGENTS.md, re-rendered at TEMPLATE_REVISION and including the pragma, reminder, and END marker. Must panic if the pragma or end marker is missing.", "source_span": [ - 23, - 33 + 47, + 61 ], "tree_path": "fn.agents_md_block", - "source_hash": "sha256:d872315f683788bef2aa149d652ee2b1ee73dc57b0d0920fcab6233fbcc6c9e9", - "source_anchor": "fn agents_md_block() -> &'static str {" + "source_hash": "sha256:a5d5d49815883b0619f35017aa79ccc1d693fab6e84e0b94254000c4a9ee3d6b", + "source_anchor": "fn agents_md_block() -> String {" + }, + { + "item": "find_agents_md_block", + "reviewed": true, + "intent": "Return the byte range from the START pragma through the END marker in text, or None when no block is present. Must not inspect the revision, so a block written by an older liyi is still found.", + "source_span": [ + 68, + 72 + ], + "tree_path": "fn.find_agents_md_block", + "source_hash": "sha256:43f12077d2c8da65aee38604f315653d789c09d9ec76366ee431271e51a08772", + "source_anchor": "pub fn find_agents_md_block(text: &str) -> Option<(usize, usize)> {" + }, + { + "item": "migrate_agents_md", + "reviewed": true, + "intent": "Rewrite the agent-instruction block in path to the current TEMPLATE_REVISION, preserving the surrounding content, and return Ok(false) when the file has no block. Must be idempotent.", + "source_span": [ + 79, + 95 + ], + "tree_path": "fn.migrate_agents_md", + "source_hash": "sha256:4108930e2ab51f6fbb91a3ce5c066f9c9fbefbf07049883a968326645c17310e", + "source_anchor": "pub fn migrate_agents_md(path: &Path) -> Result {" }, { "item": "InitError", "reviewed": true, "intent": "Error type with two variants: AlreadyExists (target file exists and --force was not set) and Io (underlying I/O error). Must implement Display and From.", "source_span": [ - 37, - 42 + 99, + 104 ], "tree_path": "enum.InitError", "source_hash": "sha256:90a3bd7254f7d5a74ce2d55135699a5d8a0caedb42a3866f1ab9d5629676210e", @@ -66,13 +150,13 @@ { "item": "init_agents_md", "reviewed": true, - "intent": "If AGENTS.md does not exist, create it with a heading and the agent block extracted from the repo's own AGENTS.md via agents_md_block(). If it exists but does not contain '## 立意', append the block. If '## 立意' is present and force is false, return AlreadyExists. If force is true, append regardless.", + "intent": "Create AGENTS.md when absent, writing `# AGENTS.md` plus the portable agent-instruction block. When the file already contains such a block, return AlreadyExists unless force is set, in which case replace the existing block with the current revision; when the file exists without a block, append it after a blank line. Must key off the block pragma, never the section heading, and target AGENTS_MD_FILENAME so `liyi migrate` uses the same file.", "source_span": [ - 68, - 90 + 131, + 158 ], "tree_path": "fn.init_agents_md", - "source_hash": "sha256:71c4198c9e3243a50ecc0e95de56c2d05dc1d6829747f5071859468533b557f7", + "source_hash": "sha256:efa8a81c629db9922e3843249a7f323aced8e1c81f713028bcd29f3858adf541", "source_anchor": "pub fn init_agents_md(root: &Path, force: bool) -> Result {" }, { @@ -80,8 +164,8 @@ "reviewed": true, "intent": "Create a .liyi.jsonc sidecar next to the source file. When discover is true and the language is tree-sitter-supported, pre-populate specs with discovered items including _hints (_has_doc, _body_lines, _likely_trivial based on trivial_threshold). Always scan the source file for @liyi:requirement markers and emit corresponding RequirementSpec entries regardless of file type or discover flag. When discover is false, emit only requirement specs (no items). If the sidecar exists and force is false, return AlreadyExists. Sidecar file name must be .liyi.jsonc.", "source_span": [ - 111, - 207 + 179, + 275 ], "tree_path": "fn.init_sidecar", "source_hash": "sha256:bc2c80e5d18b5780374676c330ec4ff2cc2304ed41be1cb350529fd56d8b3eb6", diff --git a/crates/liyi/src/prompt.rs.liyi.jsonc b/crates/liyi/src/prompt.rs.liyi.jsonc index bcaa12b..5ab3a3f 100644 --- a/crates/liyi/src/prompt.rs.liyi.jsonc +++ b/crates/liyi/src/prompt.rs.liyi.jsonc @@ -30,7 +30,7 @@ { "item": "GroupMeta", "reviewed": true, - "intent": "=trivial", + "intent": "=self-doc", "source_span": [ 73, 77 diff --git a/crates/liyi/src/recovery.rs.liyi.jsonc b/crates/liyi/src/recovery.rs.liyi.jsonc index 81ef1c1..a1c994a 100644 --- a/crates/liyi/src/recovery.rs.liyi.jsonc +++ b/crates/liyi/src/recovery.rs.liyi.jsonc @@ -6,7 +6,7 @@ { "item": "RecoveryMethod", "reviewed": true, - "intent": "=trivial", + "intent": "=self-doc", "source_span": [ 6, 10 diff --git a/crates/liyi/src/schema.rs.liyi.jsonc b/crates/liyi/src/schema.rs.liyi.jsonc index 6424065..8b47db0 100644 --- a/crates/liyi/src/schema.rs.liyi.jsonc +++ b/crates/liyi/src/schema.rs.liyi.jsonc @@ -15,7 +15,7 @@ "source_hash": "sha256:eb13a8e5c419c2fd0a1bc441fe4eaa5b8cd52ec411cb425c9fb0ca622d5ee96c", "source_anchor": "pub fn validate_version(version: &str) -> Result<(), String> {", "related": { - "version-field-required": "sha256:361fd9e237617e9cf0e80df99dd31b06274eca55e15e33384fb4a8f745d07fb9" + "version-field-required": "sha256:0dc645decb5df864c85904dbc09dbfbaaea627945632d6e83696bce196c8b7e8" } }, { diff --git a/crates/liyi/src/tree_path/inject/mod.rs.liyi.jsonc b/crates/liyi/src/tree_path/inject/mod.rs.liyi.jsonc index 9f3e5e0..87d4adf 100644 --- a/crates/liyi/src/tree_path/inject/mod.rs.liyi.jsonc +++ b/crates/liyi/src/tree_path/inject/mod.rs.liyi.jsonc @@ -166,7 +166,7 @@ { "item": "find_descendant_by_kind", "reviewed": true, - "intent": "=trivial", + "intent": "=self-doc", "source_span": [ 222, 234 @@ -178,7 +178,7 @@ { "item": "find_flow_scalar", "reviewed": true, - "intent": "=trivial", + "intent": "=self-doc", "source_span": [ 237, 250 diff --git a/crates/liyi/src/tree_path/lang_python.rs b/crates/liyi/src/tree_path/lang_python.rs index cbdae40..3430124 100644 --- a/crates/liyi/src/tree_path/lang_python.rs +++ b/crates/liyi/src/tree_path/lang_python.rs @@ -31,17 +31,52 @@ fn python_has_doc_comment(node: &Node, source: &str) -> bool { false } +/// Custom name extraction for Python module-level constants. +/// +/// A module-level assignment (`NAME = ...` or `NAME: Final = ...`) declares a +/// module constant and maps to the `const` shorthand. The +/// `expression_statement` wrapper is traversed via `transparent_kinds`. +/// +/// Requiring the module scope and a plain-identifier target excludes local +/// bindings (`x = 0`, `x: int = 0`) and attribute assignments (`self.x = 0`) +/// inside function or class bodies, which are not constants. +fn python_node_name(node: &Node, source: &str) -> Option { + if node.kind() != "assignment" { + return None; + } + + // Only assignments directly under the module root qualify. + let statement = node.parent()?; + if statement.kind() != "expression_statement" || statement.parent()?.kind() != "module" { + return None; + } + + let name = node.child_by_field_name("left")?; + if name.kind() != "identifier" { + return None; + } + + Some(source[name.byte_range()].to_string()) +} + /// Python language configuration. pub(super) static CONFIG: LanguageConfig = LanguageConfig { ts_language: || tree_sitter_python::LANGUAGE.into(), extensions: &["py", "pyi"], - kind_map: &[("fn", "function_definition"), ("class", "class_definition")], + kind_map: &[ + ("fn", "function_definition"), + ("class", "class_definition"), + ("const", "assignment"), + ], name_field: "name", name_overrides: &[], body_fields: &["body"], - custom_name: None, + custom_name: Some(python_node_name), doc_comment_detector: Some(python_has_doc_comment), - transparent_kinds: &[], + // Module-level assignments are wrapped in an `expression_statement`, so + // the resolver must look through it to reach the underlying `assignment` + // node. + transparent_kinds: &["expression_statement"], }; #[cfg(test)] @@ -50,6 +85,11 @@ mod tests { const SAMPLE_PYTHON: &str = r#"# A simple order processing module +from typing import Final + +TOPLEVEL_CONST: Final = [1, 2, 3] + + class Order: def __init__(self, amount): self.amount = amount @@ -109,6 +149,91 @@ def calculate_total(items): ); } + #[test] + fn resolve_python_toplevel_const() { + let span = resolve_tree_path(SAMPLE_PYTHON, "const.TOPLEVEL_CONST", Language::Python); + assert!(span.is_some(), "should resolve const.TOPLEVEL_CONST"); + let [start, end] = span.unwrap(); + let lines: Vec<&str> = SAMPLE_PYTHON.lines().collect(); + assert!( + lines[start - 1].contains("TOPLEVEL_CONST: Final"), + "span should point to TOPLEVEL_CONST, got: {}", + lines[start - 1] + ); + assert_eq!(start, end, "constant span should be a single line"); + } + + #[test] + fn compute_python_toplevel_const_path() { + let lines: Vec<&str> = SAMPLE_PYTHON.lines().collect(); + let start = lines + .iter() + .position(|l| l.contains("TOPLEVEL_CONST: Final")) + .unwrap() + + 1; + + let path = compute_tree_path(SAMPLE_PYTHON, [start, start], Language::Python); + assert_eq!(path, "const.TOPLEVEL_CONST"); + } + + #[test] + fn roundtrip_python_toplevel_const() { + let resolved_span = + resolve_tree_path(SAMPLE_PYTHON, "const.TOPLEVEL_CONST", Language::Python).unwrap(); + + let computed_path = compute_tree_path(SAMPLE_PYTHON, resolved_span, Language::Python); + assert_eq!(computed_path, "const.TOPLEVEL_CONST"); + + let re_resolved = + resolve_tree_path(SAMPLE_PYTHON, &computed_path, Language::Python).unwrap(); + assert_eq!(re_resolved, resolved_span); + } + + /// Module-level assignments are constants whether annotated or not; + /// assignments inside class/function bodies are not. + const SAMPLE_PYTHON_SCOPES: &str = r#"MAX = 10 +LIMIT: Final = 20 + + +class Config: + retries: int = 3 + + def load(self): + local: int = 1 + self.value: int = 2 + return local +"#; + + #[test] + fn discover_python_only_module_level_consts() { + let items = discover_items(SAMPLE_PYTHON_SCOPES, Language::Python); + let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect(); + + for (name, tree_path) in [ + ("MAX", "const.MAX"), + ("LIMIT", "const.LIMIT"), + ("Config", "class.Config"), + ("Config::load", "class.Config::fn.load"), + ] { + let item = items + .iter() + .find(|i| i.name == name) + .unwrap_or_else(|| panic!("{name} should be discovered")); + assert_eq!(item.tree_path, tree_path); + } + + for absent in [ + "Config::retries", + "Config::load::local", + "Config::load::self.value", + ] { + assert!( + !names.contains(&absent), + "unexpected item discovered: {absent}" + ); + } + } + #[test] fn compute_python_function_path() { let lines: Vec<&str> = SAMPLE_PYTHON.lines().collect(); diff --git a/crates/liyi/src/tree_path/lang_python.rs.liyi.jsonc b/crates/liyi/src/tree_path/lang_python.rs.liyi.jsonc index f39f1fb..f670c9d 100644 --- a/crates/liyi/src/tree_path/lang_python.rs.liyi.jsonc +++ b/crates/liyi/src/tree_path/lang_python.rs.liyi.jsonc @@ -3,16 +3,28 @@ "version": "0.1", "source": "crates/liyi/src/tree_path/lang_python.rs", "specs": [ + { + "item": "python_node_name", + "reviewed": true, + "intent": "Custom name extractor for Python module-level constants: for an assignment whose parent expression_statement sits directly under the module root and whose target is a plain identifier, return that identifier; return None for local bindings and attribute assignments inside function or class bodies, and for non-assignment nodes.", + "source_span": [ + 43, + 60 + ], + "tree_path": "fn.python_node_name", + "source_hash": "sha256:39f4a4f7c22f0ee5657e227e8a58d79f35b432ce04fc4411d2842aceb0a64a3b", + "source_anchor": "fn python_node_name(node: &Node, source: &str) -> Option {" + }, { "item": "CONFIG", "reviewed": true, - "intent": "Define the Python language configuration for tree_path resolution: register tree-sitter-python grammar, .py/.pyi extensions, kind mappings for fn (function_definition) and class (class_definition), using standard name field with no custom name extraction, and python_has_doc_comment detector for triple-quoted docstrings.", + "intent": "Define the Python language configuration for tree_path resolution: register tree-sitter-python grammar, .py/.pyi extensions, kind mappings for fn (function_definition), class (class_definition), and const (assignment) for module-level constants, use the standard name field for fn/class and the python_node_name callback for const, the python_has_doc_comment detector for triple-quoted docstrings, and expression_statement as a transparent kind so the resolver reaches module-level assignment nodes.", "source_span": [ - 35, - 45 + 63, + 80 ], "tree_path": "static.CONFIG", - "source_hash": "sha256:db4a475937b5f454b05d37957988bcaa60f5acfb74c31f86169109b0db1ee398", + "source_hash": "sha256:a03265d4a3af69f431ba826d64694c88cd79018d1bcf1bfcfb2714a96bf092f7", "source_anchor": "pub(super) static CONFIG: LanguageConfig = LanguageConfig {" } ] diff --git a/crates/liyi/src/tree_path/mod.rs.liyi.jsonc b/crates/liyi/src/tree_path/mod.rs.liyi.jsonc index 9f37ef4..f65b2ff 100644 --- a/crates/liyi/src/tree_path/mod.rs.liyi.jsonc +++ b/crates/liyi/src/tree_path/mod.rs.liyi.jsonc @@ -269,7 +269,7 @@ { "item": "find_ancestor_pair", "reviewed": true, - "intent": "=trivial", + "intent": "=self-doc", "source_span": [ 934, 943 diff --git a/crates/liyi/tests/fixtures/self_doc_sidecar/example.rs b/crates/liyi/tests/fixtures/self_doc_sidecar/example.rs new file mode 100644 index 0000000..5691328 --- /dev/null +++ b/crates/liyi/tests/fixtures/self_doc_sidecar/example.rs @@ -0,0 +1,23 @@ +pub enum RecoveryMethod { + TreePath, + SiblingScan, + ShiftHeuristic, +} + +fn find_descendant_by_kind(nodes: &[u32], kind: u32) -> Option { + for n in nodes { + if *n == kind { + return Some(*n); + } + } + None +} + +// @liyi:nontrivial +fn settle_remainder(total: u64, shares: &mut [u64]) -> u64 { + let each = total / shares.len() as u64; + for s in shares.iter_mut() { + *s = each; + } + total - each * shares.len() as u64 +} diff --git a/crates/liyi/tests/fixtures/self_doc_sidecar/example.rs.liyi.jsonc b/crates/liyi/tests/fixtures/self_doc_sidecar/example.rs.liyi.jsonc new file mode 100644 index 0000000..ba22002 --- /dev/null +++ b/crates/liyi/tests/fixtures/self_doc_sidecar/example.rs.liyi.jsonc @@ -0,0 +1,25 @@ +// liyi v0.1 spec file +{ + "version": "0.1", + "source": "example.rs", + "specs": [ + { + "item": "RecoveryMethod", + "reviewed": true, + "intent": "=self-doc", + "source_span": [1, 5] + }, + { + "item": "find_descendant_by_kind", + "reviewed": true, + "intent": "=self-doc", + "source_span": [7, 14] + }, + { + "item": "settle_remainder", + "reviewed": true, + "intent": "=self-doc", + "source_span": [17, 23] + } + ] +} diff --git a/crates/liyi/tests/golden.rs b/crates/liyi/tests/golden.rs index 7803e6b..40921bf 100644 --- a/crates/liyi/tests/golden.rs +++ b/crates/liyi/tests/golden.rs @@ -869,6 +869,61 @@ fn trivial_sidecar_sentinel() { assert_eq!(exit_code, LiyiExitCode::CheckFailure); } +// --------------------------------------------------------------------------- +// =self-doc sidecar sentinel tests +// --------------------------------------------------------------------------- + +#[test] +fn self_doc_sidecar_sentinel() { + let (_tmp, root) = fixture_in_tmp("self_doc_sidecar"); + let flags = CheckFlags { + fail_on_stale: false, + fail_on_unreviewed: false, + fail_on_req_changed: false, + fail_on_untracked: false, + }; + + // First pass: fix to fill in source_hash / source_anchor. + let _ = run_check(&root, &[], true, false, &flags); + + // Second pass: check diagnostics. + let (diagnostics, exit_code) = run_check(&root, &[], false, false, &flags); + + // RecoveryMethod: plain enum, =self-doc, no source annotation → Trivial info + let enum_self_doc = diagnostics + .iter() + .any(|d| d.item_or_req == "RecoveryMethod" && matches!(d.kind, DiagnosticKind::Trivial)); + assert!( + enum_self_doc, + "expected Trivial diagnostic for RecoveryMethod (=self-doc intent), got: {diagnostics:#?}" + ); + + // find_descendant_by_kind: non-trivial (has a loop) but self-doc → Trivial info, + // never flagged stale. This is the case =trivial could not honestly cover. + let helper_self_doc = diagnostics.iter().any(|d| { + d.item_or_req == "find_descendant_by_kind" && matches!(d.kind, DiagnosticKind::Trivial) + }); + assert!( + helper_self_doc, + "expected Trivial diagnostic for find_descendant_by_kind (=self-doc intent), got: {diagnostics:#?}" + ); + + // settle_remainder: @liyi:nontrivial in source + =self-doc in sidecar + // → ConflictingTriviality error (side-effecting remainder logic must not + // be filed as self-documenting). + let has_conflict = diagnostics.iter().any(|d| { + d.item_or_req == "settle_remainder" + && matches!(d.kind, DiagnosticKind::ConflictingTriviality) + }); + assert!( + has_conflict, + "expected ConflictingTriviality for settle_remainder, got: {diagnostics:#?}" + ); + + // ConflictingTriviality should cause check failure + assert_eq!(exit_code, LiyiExitCode::CheckFailure); +} + // --------------------------------------------------------------------------- // Init discovery tests // --------------------------------------------------------------------------- diff --git a/docs/contributing-guide.en.md b/docs/contributing-guide.en.md index 6a95f29..ab5c4e0 100644 --- a/docs/contributing-guide.en.md +++ b/docs/contributing-guide.en.md @@ -203,12 +203,12 @@ Before finalizing a commit, verify: For Rust code, additionally verify: -- `make lint` passes (rustfmt check, Clippy, and the linter's own self-check), - or `make verify` to run the lint gates plus the test suite. Both abort at the - first failing gate and print a success banner on completion - (`lint: all gates passed` / `verify: all gates passed`); the banner is the - signal that every gate passed, so there is no need to inspect the exit code - or redirect output to a log. +- `make lint` passes (rustfmt check, Clippy, the linter's own self-check, and + the agent-template sync check), or `make verify` to run the lint gates plus + the test suite. Both abort at the first failing gate and print a success + banner on completion (`lint: all gates passed` / `verify: all gates passed`); + the banner is the signal that every gate passed, so there is no need to + inspect the exit code or redirect output to a log. > **For AI agents:** prefer the quiet variants `make lint-short` and > `make verify-short`. They run the same gates but suppress normal output to diff --git a/docs/contributing-guide.zh.md b/docs/contributing-guide.zh.md index af85011..3910266 100644 --- a/docs/contributing-guide.zh.md +++ b/docs/contributing-guide.zh.md @@ -200,8 +200,8 @@ Signed-off-by: Contributor Name 对于 Rust 代码,请额外检查: -- `make lint` 通过(rustfmt 检查、Clippy 以及 linter 自身的自检),或运行 - `make verify` 在 lint 各项之外再跑测试套件。两者都会在第一个失败的环节中止, +- `make lint` 通过(rustfmt 检查、Clippy、linter 自身的自检,以及 agent 模板同步检查), + 或运行 `make verify` 在 lint 各项之外再跑测试套件。两者都会在第一个失败的环节中止, 并在全部通过时打印成功横幅(`lint: all gates passed` / `verify: all gates passed`);这一行横幅即为全部通过的标志,无需再去查看退出码或把输出重定向到 日志中。 diff --git a/docs/liyi-design.md b/docs/liyi-design.md index e36ca7c..b4d44d9 100644 --- a/docs/liyi-design.md +++ b/docs/liyi-design.md @@ -404,11 +404,17 @@ The `source` path is relative to the repository root — the same path you'd pas `source_hash`, `source_anchor`, and `tree_path` are tool-managed — the agent writes only `source_span` and the tool fills in the rest (see *Per-item staleness* and *Structural identity via `tree_path`* below). Agents MAY write `tree_path` if they can infer the AST path, but the tool will overwrite it with the canonical form on the next `liyi check --fix`. `"intent": "=doc"` is a reserved sentinel meaning "the docstring already captures intent" — the agent uses it when the source docstring contains behavioral requirements (constraints, error conditions, properties), not just a functional summary (see *`"=doc"` in the sidecar* below). -`"version"` is required. The linter checks it and rejects unknown versions. This costs nothing now and prevents painful migration when the schema evolves (e.g., adding `"related"` edges, or deferred structured fields). A JSON Schema definition ships alongside the linter for editor validation and autocompletion (see *Appendix: JSON Schema* below). When the schema changes, the linter will accept both `"0.1"` and the new version during a transition window, and `liyi migrate` will upgrade sidecar files in place. +`"version"` is required. The linter checks it and rejects unknown versions. +This costs nothing now and prevents painful migration when the schema evolves, e.g. adding `related` edges, or deferred structured fields. +A JSON Schema definition is to be shipped alongside the linter for editor validation and autocompletion. +When the schema changes, a new release of `liyi` is to accept both the currently supported version(s) and the new version during a transition window, and `liyi migrate` will upgrade sidecar files in place. + **`liyi migrate` behavior.** When the schema version changes (e.g., 0.1 → 0.2), `--migrate` reads each `.liyi.jsonc`, adds any newly required fields with default values, removes deprecated fields, updates `"version"` to the new version, and writes the file back. It is idempotent — running it twice produces the same output. It does not re-hash spans or re-infer intent; it only transforms the schema envelope. Migration is always additive in 0.x: no field present in 0.1 will change meaning, only new fields may appear. +It also refreshes the prescribed agent instructions in a repository's `AGENTS.md`: it locates the region delimited by the `START liyi agent instructions rev. N` / `END liyi agent instructions` pragma and rewrites it from the template embedded in the running `liyi`, bumping the revision. Targeting mirrors `liyi init`: a directory argument contributes only its own `AGENTS.md`, never a recursive walk, so ungoverned subtrees (e.g. `3rdparty/`, `vendor/`) are never rewritten; a file argument migrates exactly that file, which is how a repository's preferred location is refreshed. The rest of the file is left untouched and the operation is idempotent. The revision changes only when a newer `liyi` release ships a different instruction template — not on every release. + After human review — either the human adds `@liyi:intent` in the source file (see *Source-level intent* below), or sets `"reviewed": true` in the sidecar via CLI or IDE code action. Both paths mark the item as reviewed. When `"reviewed"` is set to `true`, `"confidence"` is removed — a human voucher replaces agent self-assessment. If the source later changes and the agent re-infers (producing a new unreviewed spec), `"confidence"` reappears: ```jsonc @@ -974,6 +980,7 @@ This gives agents a DRY option: well-documented code gets `"intent": "=doc"` ins |---|---|---| | Agent | `"intent": "=doc"` in sidecar | "The docstring captures it" | | Agent | `"intent": "=trivial"` in sidecar | “This item is trivial — no behavioral spec warranted” | +| Agent | `"intent": "=self-doc"` in sidecar | “The code documents itself — a spec would tautologically restate the surface form” | | Agent | `"intent": ""` in sidecar | "Here’s what I infer it should do" | | Human | `@liyi:intent=doc` in source | "I confirm the docstring is my intent" | | Human | `@liyi:intent ` in source | "Here’s what I say it should do" | @@ -992,7 +999,9 @@ The `@liyi:trivial` source annotation tells agents and the linter to skip an ite } ``` -Meaning: “I evaluated this item and it’s self-evident — a spec would add no value.” +Meaning: “I evaluated this item and it’s trivial — small enough to carry no hidden behavior, so a spec would add no value.” + +`"=trivial"` is a **risk claim**: the item is too small and simple for anything to go subtly wrong, so a reviewer proves triviality by reading the body. It is *not* a claim that the item is self-evident despite being non-trivial — that is what `"=self-doc"` is for (see below). Do not stretch `"=trivial"` to cover substantial code whose intent happens to be recoverable from its surface form. "Simple" means more than line count: a body that is a single expression is still not trivial if its operand order, short-circuit evaluation, nested calls, or cost are load-bearing — a compound boolean guard with side-effecting operands belongs in prose. **When to use which:** @@ -1003,12 +1012,57 @@ Meaning: “I evaluated this item and it’s self-evident — a spec would add n The second case matters for the scaffold workflow (see *Tree-sitter item discovery in scaffold* below): `liyi init` pre-populates every discovered item, the agent judges triviality and writes `"=trivial"` instead of deleting the entry. The full inventory is preserved — reviewers can see what was evaluated and judged trivial, not just what was specced. -**Linter behavior.** `liyi check` treats `"intent": "=trivial"` like `@liyi:trivial` in source — emits an info-level `Trivial` diagnostic, does not flag as stale, does not count the item as unreviewed. The adversarial testing agent skips items with `"=trivial"` (same as `@liyi:trivial`). +**Linter behavior.** A sidecar `"intent": "=trivial"` is an ordinary tracked spec: `liyi check` emits an info-level `Trivial` diagnostic, and the item is still reviewed and staleness-tracked like any other — a human approves the triviality claim, and any source change re-opens it. Only the source `@liyi:trivial` marker is lighter, because it implies no spec exists to review. The adversarial testing agent skips items with `"=trivial"` (same as `@liyi:trivial`). **Review interaction.** `liyi approve` displays `=trivial` items with a “trivial” tag. A reviewer who disagrees can override — clearing `"=trivial"` and either writing explicit intent or adding `@liyi:nontrivial` in source to prevent the agent from re-classifying. **No source-annotation counterpart.** Unlike `=doc`, there is no `@liyi:intent =trivial` source marker. If you’re in the source, use `@liyi:trivial` directly — it’s shorter and the intent is the same. `"=trivial"` is specifically for sidecar-only workflows (scaffold, batch init, third-party code). +### `"=self-doc"` in the sidecar — the self-documenting sentinel + +`"=trivial"` answers "is this small enough that nothing can hide?" — a *risk* claim about size. But some items are **not** small yet still carry no intent a spec could usefully add: a plain data struct whose fields *are* its meaning, a recursive tree-search whose name fully states its job, a container module whose purpose is the sum of its members. Forcing prose onto these produces a tautology; marking them `"=trivial"` is a lie about their size (the 11-line context struct, a data-only enum like `RecoveryMethod`). `"=self-doc"` is the honest label for this class: + +```jsonc +{ + "item": "RecoveryMethod", + "intent": "=self-doc", + "source_span": [6, 10], + "tree_path": "enum.RecoveryMethod" +} +``` + +Meaning: “The item documents itself — any prose intent I could write would tautologically restate its surface form. There is nothing implicit hidden behind that form.” + +`"=self-doc"` is an **epistemic claim**, distinct from `"=trivial"`'s risk claim. The two are complementary, not interchangeable: + +| Sentinel | Claim | Reviewer action | +|---|---|---| +| `"=trivial"` | Too small to carry hidden behavior | Read the code, prove triviality | +| `"=self-doc"` | Intent is intrinsic to the surface form | Read the code, prove nothing implicit hides behind the form | +| `"=doc"` | Intent is in the docstring | Read the docstring | +| `""` | Intent stated by the agent | Read intent, read code, prove equivalence | + +An item can be non-trivial yet self-documenting (a recursive search), or trivial yet not needing the self-doc claim at all (a one-line getter — just use `"=trivial"`). The boundary: **`"=trivial"` = correctness proven by *reading the body*; `"=self-doc"` = correctness proven by *trusting the name/signature without tracing the body*.** + +**The self-documenting test (the discipline that keeps the sentinel honest).** "Self-documenting" is a phrase programmers reach for to excuse skipping documentation, so the claim carries a strict bar. `"=self-doc"` holds **only if** a reader who is *competent in the language and paradigm but a stranger to this codebase, its domain, and the authoring session* — holding only the item's name, signature, and **durable co-located context** (its enclosing module, in-scope types, any governing `@liyi:note`) — can recover **every property any observer could correctly rely on**, including properties observable *outside the value domain*: timing, resource use, termination, information flow, nondeterminism, ordering, idempotency. Concretely: + +- **The tautology test, over the behavioral surface — not the syntactic one.** A sequence `A; B; C` of side-effecting steps is *not* self-documenting merely because prose would restate the sequence: the *reason for the order*, the *cost of repetition*, and the *partial-failure semantics* are load-bearing and invisible in the sequence. If any true constraint would be *news* to a signature-only reader, prose is required. +- **The observer counterfactual (the general form — there is no finite effect checklist).** Ask: *is there any observer — a caller, an attacker, a scheduler, a profiler, a regulator — who could distinguish a correct implementation from a plausible-but-wrong one, where the distinction is not visible in the surface?* If yes, it is not self-documenting. A constant-time comparison is pure, deterministic, idempotent, and stateless, yet **fails** this test: the attacker is the observer, and the timing invariant is domain knowledge the stranger-reader does not hold. The listed effect kinds (timing, resources, termination, information flow, nondeterminism, ordering, idempotency) are a **non-exhaustive prompt**, never a boundary — malice moved one dimension over still fails the counterfactual. +- **Durable context only.** Context counts toward self-documentation only if it travels with the code (same file/module/subtree, or a `@liyi:note`). Context that lives only in the current session, the author's memory, the present set of callers, or a PR discussion does **not** count. If the item is self-evident only to someone holding ephemeral context, it is not self-documenting — promote the premise to a `@liyi:note` (so it *becomes* durable) or write the intent. +- **Side effects are presumptively disqualifying.** `"=self-doc"` is available by default to pure, total items (data types, pure helpers). A side-effecting function *may* still qualify, but only after the agent has explicitly checked it for hidden ordering, non-idempotency, and extrinsic constraints — and found none. + +**Discharge, don't just assert.** Unlike `"=trivial"` (a glance suffices), a `"=self-doc"` claim obliges the agent to have *checked for the disqualifiers* — no observable state change, no order-dependence, no non-idempotency, no cross-call invariant, no non-value-domain effect, no extrinsic constraint it could find. A false `"=self-doc"` is then a lie about work done, not a defensible judgment call — the same standard that stops silent self-approval elsewhere in this design. + +**Threat-model boundary (a documented non-goal).** `"=self-doc"` — and 立意 as a whole — guarantees that *hidden intent cannot be silently filed as visible*: every load-bearing property either lives in reviewed prose or is conspicuously absent. It does **not** guarantee that reviewed prose is *true*. When malice is exported out of the item's observable, co-located surface — a remainder swept to a distant or runtime-controlled constant, masked by a plausible-but-false comment ("virtual sink for internal accounting") — the code matches its stated intent and the deception survives every structural check. Catching that requires a security-versed reasoner with a threat model running *on top of* 立意's routing, and even then is best-effort, prior-dependent flagging, never a guarantee. 立意 surfaces hidden intent; it does not verify the honesty of intent deliberately misrepresented via non-co-located or runtime state. This boundary is where the convention ends and adversarial security review begins. + +**Linter behavior.** `liyi check` treats `"=self-doc"` exactly like `"=trivial"` for tracking purposes: an info-level diagnostic on a spec that is still human-reviewed and staleness-tracked like any other (a source change re-opens the self-doc claim for re-approval), and it is skipped by the adversarial testing agent (its intent is intrinsic — there is nothing for a second model to attack). `@liyi:nontrivial` in source conflicts with a sidecar `"=self-doc"` the same way it conflicts with `"=trivial"`. Because a `"=self-doc"` claim on a security- or money-sensitive item is exactly the case where all value-domain checks pass yet a constraint hides in an omitted dimension, reviewers should treat `"=self-doc"` on such items as a conspicuous smell to challenge, never a rubber stamp. + +### Derivative items — exclude from inference, do not spec + +Some items carry **no independent intent at all**: their entire purpose is to exercise or assert another already-specced item, adding no invariant of their own. The overwhelming case is tests — a test named `see_none_suppresses_directory_scope` asserts a behavioral claim about `resolve`, so its intent *is* `resolve`'s spec. Inferring a per-item entry for each such test produces sidecar noise, a fake review burden (a reviewer has nothing to approve *on*), a hash-tracking liability (every test edit reads as staleness), and circularity (adversarial tests are *generated from* reviewed intent — specifying the tests' own intent is the pipeline eating its tail). + +The correct treatment is **exclusion from inference, not a lighter spec**: agents should not write a sidecar entry for clearly-derivative forms at all. This targets the derivative *shape* — "an item whose whole purpose is to exercise or assert another specced item, adding no invariant of its own" — not "tests" by name. A test that encodes a *novel* invariant the code does not otherwise state (a property test asserting a business rule) *does* carry independent intent and should be specced normally. The end state for a derivative item is **no entry**; do not reach for `"=trivial"` or `"=self-doc"` to file it away, as both still leave a row to review and rehash. + ### Why two review paths The source-level path (`@liyi:intent`) and the sidecar path (`"reviewed": true`) serve different needs: @@ -1064,7 +1118,7 @@ Every reviewed item that uses `@liyi:intent` gets a comment annotation. For a fi Three annotations, on the line before the item definition: - **`@liyi:ignore`** — the function is deliberately excluded from the convention. Don’t infer intent, don’t report it. Use for internal helpers, legacy functions that won’t be touched. -- **`@liyi:trivial`** — the function’s intent is self-evident from its signature. A spec would add no value. Use for simple getters, setters, one-line wrappers. Applied by the agent during inference. +- **`@liyi:trivial`** — the item is too small to carry hidden behavior; a spec would add no value. Use for simple getters/setters, one-line wrappers, and *simple* expressions only — a body is not trivial if its operand order, short-circuit evaluation, nested calls, or cost are load-bearing. Applied by the agent during inference. - **`@liyi:nontrivial`** — a human override: “this looks trivial but I want a spec.” The agent must infer a spec and not re-classify as trivial. The linter treats it the same as an unannotated item. ```python @@ -1612,43 +1666,23 @@ The linter is tested at three levels: --- -## The Agent Skill - -The agent instructions define the protocol. The CI linter enforces it. - -### Minimal instruction (for any AGENTS.md) +## The agent-side design -The full AGENTS.md section is ~300 lines: 10 behavioral rules (the part a human reads once) followed by two JSON schemas (machine-consumed reference that the agent uses to produce valid `.liyi.jsonc` and `triage.json` files). The schemas are not adoption cost — no human needs to memorize them — but they are part of the payload. - -```markdown -## 立意 (Intent Specs) - -When writing or modifying code: -1. For each non-trivial item (function, struct, macro invocation, decorated endpoint, etc.), infer what it SHOULD do (not what it does). Write intent to a sidecar file named `.liyi.jsonc` (e.g., `money.rs` → `money.rs.liyi.jsonc`). Record `source_span` (start/end lines). Do not write `source_hash` or `source_anchor` — the tool fills them in. Do not write `"reviewed"` — that is set by the human via CLI or IDE. Use `"intent": "=doc"` only when the docstring contains behavioral requirements (constraints, error conditions, properties), not just a functional summary — a docstring that says "Returns the sum" is not adequate; one that says "Must reject mismatched currencies with an error" is. For trivial items (simple getters, one-line wrappers), annotate with `@liyi:trivial` instead of writing a spec. Alternatively, when working sidecar-first, use `"intent": "=trivial"` to mark items as trivial without requiring a source annotation. -2. When module-level invariants or governing context are apparent, write a `@liyi:note` block — in the directory's existing module doc (`README.md`, `doc.go`, `mod.rs` doc comment, etc.) or in a dedicated `LIYI.md`, closing embedded blocks with `@liyi:end-note`. A note is a context primitive, not a tracked spec: marker-only, never written to a `.liyi.jsonc` or hashed. Pull a named note into a specific item's context with `@liyi:see `. -3. If a source item has a `@liyi:related ` annotation, record the dependency in `.liyi.jsonc` as `"related": {"": null}`. The tool fills in the requirement's current hash. -4. For each `@liyi:requirement ` block encountered (closed by `@liyi:end-requirement `), ensure it has a corresponding entry in the co-located `.liyi.jsonc` with `"requirement"` and `"source_span"`. (The tool fills in `"source_hash"`.) -5. If a spec has `"related"` edges referencing a requirement, do not overwrite the requirement text during inference. Update the spec (update `source_span`) but preserve the `"related"` edges. Do not write `source_hash` — the tool fills it in. -6. Only generate adversarial tests from items that have a `@liyi:intent` annotation in source or `"reviewed": true` in the sidecar (i.e., human-reviewed intent). When `@liyi:intent` is present in source, use its prose (or the docstring for `=doc`) as the authoritative intent for test generation. -7. Tests should target boundary conditions, error-handling gaps, property violations, and semantic mismatches. Prioritize tests a subtly wrong implementation would fail. -8. Skip items annotated with `@liyi:ignore` or `@liyi:trivial`, files matched by `.liyiignore`, and files bearing a `@liyi:file ignore` directive. Respect `@liyi:nontrivial` — if present, always infer a spec for that item and never override with `@liyi:trivial`. Honor `@liyi:file language=` as the authoritative language for a file when present. -9. Use a different model for test generation than the one that wrote the code, when possible. -10. When `liyi check` reports stale items, choose one of two paths: - - **Direct re-inference** (preferred during interactive editing with few stale items): re-read the source, update `source_span` and `intent` in the sidecar, leave `"reviewed"` unset. Appropriate when you are the agent that just made the change, the number of stale items is small, and the changes are straightforward. - - **Triage** (preferred for batch workflows, CI, or when many items are stale): assess each item — is the change cosmetic, semantic, or an intent violation? Write the assessment to `.liyi/triage.json` following the triage report schema. For cosmetic changes, run `liyi triage --apply` to auto-fix. For semantic changes, propose updated intent in `suggested_intent`. For intent violations, flag for human review. Prefer triage when stale items have `"reviewed": true` or `@liyi:intent` in source — these carry human-vouched intent that deserves explicit assessment, not silent re-inference. -11. Before committing, run `liyi check`. If it reports coverage gaps (missing requirement specs, missing related edges), resolve **all** gaps in the same commit. When running in agent mode, consume the `liyi check --prompt` output and apply its instructions. Do not commit with unresolved coverage gaps — CI will reject it. -``` +Half of the 立意 paradigm relies on cooperation from agents' side: instructions, protocols, prompt and harness engineering. +The deterministic linter and related CI setup serves to both complement and constrain the agents, to provide a unified 立意 workflow as a whole. -### Key principles +### Agent instructions: `AGENTS.md` for adopters -- **Adversarial, not confirmatory.** Find bugs, not confirm correctness. -- **Spec is the referee.** If the spec says one thing and the code does another, the test exposes the gap. The human decides who's right. -- **Model diversity.** Different model for tests than for code, when possible. -- **Never modify source code logic** during the protocol. Only create/update `.liyi.jsonc` files, `@liyi:note` blocks (in docs or doc comments), test files, and annotation comments (`@liyi:trivial`, `@liyi:ignore`, `@liyi:requirement`, `@liyi:related`). Annotation comments are metadata, not logic — adding them does not change program behavior. +The canonical instructions for inclusion in 立意-adopting repositories are +defined in a dedicated appendix section in this design documentation, to be +reproduced by `liyi init` for easy adoption. +It consists of the practical instruction for agents (the part a human reads once) +followed by two JSON schemas (machine-consumed reference that the agent uses to +produce valid `.liyi.jsonc` and `triage.json` files). ### The cognitive load inversion: tool-guided agents -The 11 behavioral rules assume the agent internalizes a complex protocol and executes it reliably. Frontier models (Claude Opus, GPT-4.5) do this well. Budget models (Kimi K2.5, Gemini Flash, GPT-4o-mini) routinely forget steps — dropping AIGC trailers, skipping sidecar updates, ignoring `.liyiignore`. This is not a hypothetical concern; it is empirically observed in production use. +The behavioral rules assume the agent internalizes a complex protocol and executes it reliably. Frontier models (Claude Opus, GPT-4.5) do this well. Budget models (Kimi K2.5, Gemini Flash, GPT-4o-mini) routinely forget steps — dropping AIGC trailers, skipping sidecar updates, ignoring `.liyiignore`. This is not a hypothetical concern; it is empirically observed in production use. The conventional response is "write better instructions." This is necessary but insufficient. The deeper architectural response is to **move protocol knowledge from the agent's context window into the tool's output**, so that the agent's job shifts from "internalize and follow rules" to "run command, parse output, execute instructions." @@ -1786,11 +1820,11 @@ Sets `"reviewed": true` and fixes hashes without prompting. **Repository initialization:** ```bash -liyi init # scaffold AGENTS.md with the 立意 instruction paragraph -liyi init --force # overwrite existing AGENTS.md +liyi init # scaffold AGENTS.md with the 立意 instruction section +liyi init --force # replace the agent instruction block with the current revision ``` -Appends the 立意 agent instruction section (~300 lines: 10 rules + two JSON schemas) to `AGENTS.md` (creates the file if absent). Does not overwrite existing content unless `--force` is given. +Writes the portable agent instruction section (~300 lines: behavioral rules + two JSON schemas) into `AGENTS.md`, creating the file if absent, wrapped in the `START liyi agent instructions rev. N` pragma and its `DON'T EDIT` reminder. The pragma makes the block idempotent and migratable rather than heading-dependent: a second `liyi init` refuses instead of duplicating, and `liyi init --force` replaces an existing block with the current revision. **File initialization:** @@ -1837,7 +1871,7 @@ The scaffold uses two deterministic AST signals to help the agent prioritize whi Items with doc comments get `"_has_doc": true` in their `_hints`, signaling the agent that `"intent": "=doc"` may be appropriate if the docstring contains behavioral requirements. -**2. Item size (body line count).** Available directly from `source_span`: `end - start + 1`. Small items (≤ 3–5 lines of body, depending on language idiom) are likely trivial — simple getters, one-line wrappers, delegating constructors. The scaffold emits `"_body_lines": N` in hints. Items below a threshold get `"_likely_trivial": true`. +**2. Item size (body line count).** Available directly from `source_span`: `end - start + 1`. Small items (≤ 3–5 lines of body, depending on language idiom) are likely trivial — simple getters, one-line wrappers, delegating constructors, and simple expressions. This is a heuristic, not the criterion: a single dense expression can still be non-trivial. The scaffold emits `"_body_lines": N` in hints. Items below a threshold get `"_likely_trivial": true`. **Combined heuristic:** @@ -2151,7 +2185,7 @@ This section estimates the effort to *build* 立意 itself — the linter, the c - A **spec convention** — `@liyi:note` blocks (module intent) + `@liyi:requirement` blocks (named requirements) + `.liyi.jsonc` (item-level intent and requirement tracking, JSONC). - A **dependency model** — `@liyi:related` edges from code items to named requirements, with transitive staleness. - A **triage protocol** (deferred to 0.2.0) — `liyi check --json` provides rich stale-item context; an agent (using whatever model it already has) assesses each item and writes a structured report; `liyi triage --apply` acts on the report. The binary stays deterministic and offline (see *`liyi` is infrastructure; the agent is the brain*). -- **Agent instructions** — 10 behavioral rules + two JSON schemas in AGENTS.md (~300 lines; the schemas are machine-consumed reference, not human-read). +- **Agent instructions** — behavioral rules + two JSON schemas in AGENTS.md (~300 lines; the schemas are machine-consumed reference, not human-read). - A **practice** — establish intent before (or alongside) execution. - A **challenge mechanism** (deferred to 0.2.0) — on-demand semantic verification of code against intent, or intent against requirement, driven by the agent. @@ -2188,7 +2222,7 @@ Each level is independently valuable. Stop wherever the cost outweighs the benef | Level | What you do | What you get | Human cost | |---|---|---|---| -| **0. The instruction** | Copy the 立意 section into AGENTS.md (~300 lines: 10 rules the human reads once, two JSON schemas the agent consumes) | Agent writes `.liyi.jsonc` alongside code. You have a persistent record of what each item is meant to do. | 15 minutes | +| **0. The instruction** | Copy the 立意 section into AGENTS.md (~300 lines: behavioral rules the human reads once, two JSON schemas the agent consumes) | Agent writes `.liyi.jsonc` alongside code. You have a persistent record of what each item is meant to do. | 15 minutes | | **1. The review** | Review inferred intent in PRs — set `"reviewed": true` in sidecar (quick) or add `@liyi:intent` in source (explicit) | The review surface for intent is typically ~10% of the code surface — a few lines of spec per item instead of the full implementation. You catch wrong intent before wrong code gets tested. Careless review undermines adversarial testing quality — see *Why careless review is self-limiting* in the Security Model. | Seconds per item | | **2. The docs** | Add `## 立意` sections to READMEs / doc comments | Module-level invariants are documented, visible in rendered docs, discoverable by agents and humans. This is just good documentation practice. | 5 min per module | | **3. The linter** | Run `liyi check` in CI | Stale specs fail the build. You know which items changed since their intent was written. Deterministic enforcement. | Install a binary | @@ -2464,7 +2498,7 @@ The risk that absorption *prevents* the open convention from thriving — by pul ### 6. Agent reliability spectrum -The project's value proposition relies on agents following the AGENTS.md instruction. Empirical evidence shows a wide reliability spectrum: frontier models (Claude Opus 4.6) follow the full 11-rule protocol with high fidelity; budget models (Kimi K2.5, Gemini Flash) routinely forget AIGC trailers, skip sidecar updates, and ignore edge-case rules. This is not a positioning gap or a tooling gap — it is a fundamental characteristic of the current model landscape. +The project's value proposition relies on agents following the AGENTS.md instruction. Empirical evidence shows a wide reliability spectrum: frontier models (Claude Opus 4.6) follow the full behavioral-rule protocol with high fidelity; budget models (Kimi K2.5, Gemini Flash) routinely forget AIGC trailers, skip sidecar updates, and ignore edge-case rules. This is not a positioning gap or a tooling gap — it is a fundamental characteristic of the current model landscape. **This observation validates the project rather than undermining it.** The entire tooling layer (schema validation, staleness detection, coverage gap enforcement) exists precisely because agent output cannot be blindly trusted — `liyi check` catches what the agent forgets, so unreliable agents make the linter *more* valuable, not less (argued in full in *The cognitive load inversion: tool-guided agents*). @@ -2474,7 +2508,7 @@ The project's value proposition relies on agents following the AGENTS.md instruc 2. **Indirection.** The first actionable instruction is "go read another file" (the contributing guide), which itself says "go read the AIGC policy." Multi-hop retrieval fails silently on weaker models. 3. **Inlined schemas.** ~100 lines of JSON Schema in the middle of the instruction flow wastes context window and confuses models about whether it's an instruction or an example. 4. **Decision trees.** Rule 10 presents a nuanced choice (direct re-inference vs. triage) with criteria that require sophisticated judgment. Budget models need a default path, not a decision matrix. -5. **No minimum viable compliance tier.** All 11 rules read as equally mandatory. There is no separation between "you MUST do these 3 things or CI rejects your commit" and "you SHOULD do these 8 things for best results." +5. **No minimum viable compliance tier.** All the rules read as equally mandatory. There is no separation between "you MUST do the CI-gating essentials or CI rejects your commit" and "you SHOULD do the rest for best results." **The architectural response** is the cognitive load inversion described in *The cognitive load inversion: tool-guided agents*: move protocol knowledge from the agent's context window into the tool's `--prompt` output, and restructure AGENTS.md into tiers (MUST / SHOULD / REFERENCE) that degrade gracefully across model capability. A budget model that only follows the MUST tier — run `liyi check`, add AIGC trailer, use `--prompt` for fix instructions — still produces commits that pass CI. @@ -2611,9 +2645,58 @@ The agent re-infers (updating `source_span`; the tool recomputes `source_hash`), --- ---- -## Appendix: JSON Schema for `.liyi.jsonc` (v0.1) +## Appendix: The prescribed `AGENTS.md` content for Liyi-adopting repositories + + +The content is to be placed in a dedicated section named +"The 立意 (Intent Specs) design pattern for agents", in a 立意-adopting repo's +`AGENTS.md` (the default location supported by `liyi init`) or the repo's +preferred location. The section body is delimited by a DocToc-style pragma — +`START liyi agent instructions rev. N`, where N is a monotonic revision, with a +`DON'T EDIT THIS BLOCK` reminder — so that `liyi migrate` can refresh it in +place without duplicating it. + + + + +When writing or modifying code: + +1. For each non-trivial item (function, struct, macro invocation, decorated endpoint, etc.), infer what it SHOULD do (not what it does). Write intent to a sidecar file named `.liyi.jsonc` (e.g., `money.rs` → `money.rs.liyi.jsonc`). Record `source_span` (start/end lines). Do not write `source_hash` or `source_anchor` — the tool fills them in. Do not write `"reviewed"` — that is set by the human via CLI or IDE. **If you change the `intent` text of a spec that has `"reviewed": true`, you must set `"reviewed"` to `false`** (or delete the field). The human reviewed the *old* intent; your rewrite has not been reviewed. Failing to unset it silently self-approves agent-written intent, bypassing human review. Use `"intent": "=doc"` only when the docstring contains behavioral requirements (constraints, error conditions, properties), not just a functional summary — a docstring that says "Returns the sum" is not adequate; one that says "Must reject mismatched currencies with an error" is. For trivial items (simple getters, one-line wrappers, and simple expressions with no load-bearing operand order, short-circuit side effects, nested-call effects, or cost), annotate with `@liyi:trivial` instead of writing a spec. Alternatively, when working sidecar-first, use `"intent": "=trivial"` to mark items as trivial without requiring a source annotation. Use `"intent": "=self-doc"` for an item that is **not** trivial (it may be substantial — a plain data struct, a recursive helper, a container module) but whose intent is fully intrinsic to its surface form, such that any prose would tautologically restate the name/signature/fields. `=trivial` is a *risk* claim (small enough that nothing hides); `=self-doc` is an *epistemic* claim (nothing implicit hides behind the form) — do not stretch `=trivial` onto substantial code. A `=self-doc` claim holds ONLY if a reader competent in the language but a stranger to this codebase, domain, and session — holding only the item's name, signature, and durable co-located context (enclosing module, in-scope types, a governing `@liyi:note`) — could recover every property any observer (caller, attacker, scheduler, profiler, regulator) could rely on, INCLUDING non-value-domain ones: timing, resource use, termination, information flow, nondeterminism, ordering, idempotency. Side effects are presumptively disqualifying; a sequence `A; B; C` is not self-documenting when its ordering, non-idempotency, or partial-failure semantics are load-bearing and invisible in the sequence. Claiming `=self-doc` obliges you to have checked for these disqualifiers and found none — a false `=self-doc` is a lie about work done, not a judgment call. When `liyi init` emits `_hints` in a sidecar scaffold, use them to prioritize which items warrant deeper investigation (e.g., `git log`, reading tests) within your token/call budget; for unhinted items, infer from source alone. +2. When module-level invariants or governing context are apparent, write a `@liyi:note` block — in the directory's existing module doc (`README.md`, `doc.go`, `mod.rs` doc comment, etc.) or in a dedicated `LIYI.md`. Use the doc markup language's comment syntax for the marker, and close embedded blocks with `@liyi:end-note`. A note is a **context primitive, not a tracked spec**: it is marker-only — never write it to a `.liyi.jsonc`, never hash it, never add a `"reviewed"` flag. Notes apply to their directory subtree (shadowed by deeper notes); to pull a named note into a specific item's context regardless of location, annotate the item with `@liyi:see `. (Optionally name a note: `@liyi:note `; same-named blocks aggregate.) +3. If a source item has a `@liyi:related ` annotation, record the dependency in `.liyi.jsonc` as `"related": {"": null}`. The tool fills in the requirement's current hash. +4. For each `@liyi:requirement ` block encountered (closed by `@liyi:end-requirement `), ensure it has a corresponding entry in the co-located `.liyi.jsonc` with `"requirement"` and `"source_span"`. (The tool fills in `"source_hash"`.) +5. If a spec has `"related"` edges referencing a requirement, do not overwrite the requirement text during inference. Update the spec (update `source_span`) but preserve the `"related"` edges. Do not write `source_hash` — the tool fills it in. +6. Only generate adversarial tests from items that have a `@liyi:intent` annotation in source or `"reviewed": true` in the sidecar (i.e., human-reviewed intent), excluding sentinel intents (`=trivial`, `=self-doc`), which carry no agent-authored intent for a second model to attack. When `@liyi:intent` is present in source, use its prose (or the docstring for `=doc`) as the authoritative intent for test generation. +7. Tests should target boundary conditions, error-handling gaps, property violations, and semantic mismatches. Prioritize tests a subtly wrong implementation would fail. +8. Skip items annotated with `@liyi:ignore` or `@liyi:trivial`, files matched by `.liyiignore`, and files bearing a `@liyi:file ignore` directive. Respect `@liyi:nontrivial` — if present, always infer a spec for that item and never override with `@liyi:trivial` or `"=self-doc"`. Honor `@liyi:file language=` as the authoritative language for a file when present (it overrides extension-based detection). **Exclude clearly-derivative items from inference entirely** — write no sidecar entry for an item whose whole purpose is to exercise or assert another already-specced item, adding no invariant of its own (chiefly tests: a test named for the behavior it checks derives its intent from that behavior's spec). This targets the derivative *shape*, not "tests" by name — a test that encodes a novel invariant the code does not otherwise state does carry independent intent and should be specced. The end state for a derivative item is no entry; do not reach for `"=trivial"` or `"=self-doc"` to file it away. +9. Use a different model for test generation than the one that wrote the code, when possible. +10. When `liyi check` reports stale items, choose one of two paths: + - **Direct re-inference** (preferred during interactive editing with few stale items): re-read the source, update `source_span` and `intent` in the sidecar, leave `"reviewed"` unset. Appropriate when you are the agent that just made the change, the number of stale items is small, and the changes are straightforward. + - **Triage** (preferred for batch workflows, CI, or when many items are stale): assess each item — is the change cosmetic, semantic, or an intent violation? Write the assessment to `.liyi/triage.json` following the triage report schema. For cosmetic changes, run `liyi triage --apply` to auto-fix. For semantic changes, propose updated intent in `suggested_intent`. For intent violations, flag for human review. Prefer triage when stale items have `"reviewed": true` or `@liyi:intent` in source — these carry human-vouched intent that deserves explicit assessment, not silent re-inference. + + In either path, **never compute `source_hash` manually** and **never leave `"reviewed": true` on a spec whose `intent` you changed** (see rule 1). When `liyi check --fix` cannot auto-rehash a stale item (e.g., because it has `"reviewed": true`), update `source_span` and `intent`, **set `"reviewed"` to `false`**, and **delete** the `source_hash` field (or set it to `null`). The human will run `liyi check --fix` or `liyi approve` after reviewing the updated intent. This rule exists because agents consistently produce wrong hashes (trailing-newline differences, encoding mismatches) — the tool reads the actual bytes and is the only authority. +11. Before committing, run `liyi check`. If it reports coverage gaps (missing requirement specs, missing related edges), resolve **all** gaps in the same commit. When running as an agent, prefer `liyi check --prompt` for structured JSON output with per-gap resolution instructions. Do not commit with unresolved coverage gaps — CI will reject it. + +### Key principles of the intent protocol + +- **Adversarial, not confirmatory.** Find bugs, not confirm correctness. +- **Spec is the referee.** If the spec says one thing and the code does another, the test exposes the gap. The human decides who's right. +- **Model diversity.** Different model for tests than for code, when possible. +- **Never modify source code logic** during the protocol. Only create/update `.liyi.jsonc` files, `@liyi:note` blocks (in docs or doc comments), test files, and annotation comments (`@liyi:trivial`, `@liyi:ignore`, `@liyi:requirement`, `@liyi:related`). Annotation comments are metadata, not logic — adding them does not change program behavior. + +### Resolving rule conflicts + +When two normative instructions here appear to conflict, or a task's correct completion turns on an ambiguity these instructions do not resolve: + +1. **Do not guess.** Name the conflicting rules (and any design-doc sections they reference). +2. **If a human is reachable:** ask once — state the candidate resolutions and your recommendation. Consolidate into a single question rather than a series. +3. **If no human is reachable** (batch, CI): take the most conservative reading, leave the affected specs `"reviewed": false`, complete the unambiguous parts, and report the unresolved conflict prominently. +4. **Do not over-apply this.** Where the standard delegates judgment to you — e.g. whether an item qualifies for `=self-doc` under the observer test — reason it out and state your reasoning so a human can veto. That is not a conflict. + +### `.liyi.jsonc` Schema (v0.1) + +Sidecar files must conform to the following JSON Schema. The top-level object has three required fields: `"version"` (must be `"0.1"`), `"source"` (repo-relative path to the source file), and `"specs"` (array of item or requirement entries). Each spec entry is either an **item spec** or a **requirement spec**, distinguished by the presence of `"item"` vs `"requirement"`. ```json { @@ -2672,7 +2755,7 @@ The agent re-infers (updating `source_span`; the tool recomputes `source_hash`), }, "intent": { "type": "string", - "description": "Natural-language description of what the item SHOULD do, or the sentinel value '=doc' meaning the source docstring captures intent, or '=trivial' meaning the item is trivial and needs no intent spec." + "description": "Natural-language description of what the item SHOULD do, or the sentinel value '=doc' meaning the source docstring captures intent, '=trivial' meaning the item is trivial and needs no intent spec, or '=self-doc' meaning the item is self-documenting (intent intrinsic to its surface form) and needs no intent spec." }, "source_span": { "$ref": "#/$defs/sourceSpan" }, "tree_path": { @@ -2739,7 +2822,180 @@ The agent re-infers (updating `source_span`; the tool recomputes `source_hash`), } ``` -This schema ships as `liyi.schema.json` in the linter's release artifacts and is published at the `$id` URL. Editors that support JSON Schema (VSCode, IntelliJ, Neovim with `SchemaStore`) will provide validation and autocompletion for `.liyi.jsonc` files when configured with `"$schema": "https://liyi.run/schema/0.1/liyi.schema.json"` at the top of the sidecar, or via a workspace-level `json.schemas` setting. +### Triage Report Schema (v0.1) + +When `liyi check` reports stale items, the agent assesses each and writes the result to `.liyi/triage.json`. The report must conform to the following JSON Schema (also available at `schema/triage.schema.json`): + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://liyi.run/schema/0.1/triage.schema.json", + "title": "立意 triage report", + "description": "Agent-produced assessment of stale intent specs, validated and consumed by liyi triage subcommands.", + "type": "object", + "required": ["version", "generated", "model", "summary", "items"], + "additionalProperties": false, + "properties": { + "version": { + "type": "string", + "const": "0.1", + "description": "Schema version. Must match the version understood by the liyi binary." + }, + "generated": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of when the triage report was produced." + }, + "model": { + "type": "string", + "description": "Identifier of the model that produced the assessments (e.g. 'claude-sonnet-4-20260514')." + }, + "root": { + "type": "string", + "default": ".", + "description": "Repository root relative to the working directory. Usually '.'." + }, + "summary": { "$ref": "#/$defs/summary" }, + "items": { + "type": "array", + "items": { "$ref": "#/$defs/triageItem" } + } + }, + "$defs": { + "sourceSpan": { + "type": "array", + "items": { "type": "integer", "minimum": 1 }, + "minItems": 2, + "maxItems": 2, + "description": "Closed interval of 1-indexed line numbers [start, end]. start must be <= end." + }, + "verdict": { + "type": "string", + "enum": ["cosmetic", "semantic", "intent-violation", "unclear"], + "description": "cosmetic: no behavioral change (rename, reformat). semantic: code evolved, intent is stale but code is correct. intent-violation: code contradicts declared intent. unclear: model cannot determine with sufficient confidence." + }, + "action": { + "type": "string", + "enum": ["auto-fix", "update-intent", "fix-code-or-update-intent", "manual-review"], + "description": "Recommended action. auto-fix for cosmetic, update-intent for semantic, fix-code-or-update-intent for intent-violation, manual-review for unclear." + }, + "summary": { + "type": "object", + "required": ["total_stale", "cosmetic", "semantic", "intent_violation", "unassessed"], + "additionalProperties": false, + "properties": { + "total_stale": { + "type": "integer", + "minimum": 0, + "description": "Total number of stale items assessed." + }, + "cosmetic": { + "type": "integer", + "minimum": 0, + "description": "Items with verdict 'cosmetic'." + }, + "semantic": { + "type": "integer", + "minimum": 0, + "description": "Items with verdict 'semantic'." + }, + "intent_violation": { + "type": "integer", + "minimum": 0, + "description": "Items with verdict 'intent-violation'." + }, + "unassessed": { + "type": "integer", + "minimum": 0, + "description": "Stale items not yet assessed (should be 0 in a complete report)." + }, + "impacted_transitively": { + "type": "integer", + "minimum": 0, + "description": "Items affected transitively via the related graph." + } + } + }, + "impactEntry": { + "type": "object", + "required": ["source", "item", "relationship", "impact_summary"], + "additionalProperties": false, + "properties": { + "source": { + "type": "string", + "description": "Repo-relative path of the transitively affected source file." + }, + "item": { + "type": "string", + "description": "Name of the transitively affected item." + }, + "relationship": { + "type": "string", + "description": "The related edge that propagates the impact (e.g. 'related:multi-currency-addition')." + }, + "impact_summary": { + "type": "string", + "description": "Why this item is affected (1–2 sentences)." + } + } + }, + "triageItem": { + "type": "object", + "required": ["source", "item", "source_span", "verdict", "confidence", "change_summary", "invariant_summary", "reasoning", "action"], + "additionalProperties": false, + "properties": { + "source": { + "type": "string", + "description": "Repo-relative source path." + }, + "item": { + "type": "string", + "description": "Item name (matches the sidecar spec)." + }, + "source_span": { + "$ref": "#/$defs/sourceSpan", + "description": "Current span from the sidecar." + }, + "verdict": { "$ref": "#/$defs/verdict" }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Model's self-assessed confidence in the verdict (0–1)." + }, + "change_summary": { + "type": "string", + "description": "What changed in the code (1–2 sentences)." + }, + "invariant_summary": { + "type": "string", + "description": "What stayed the same (1–2 sentences)." + }, + "reasoning": { + "type": "string", + "description": "Why the verdict was assigned (2–3 sentences, citable in reviews)." + }, + "action": { "$ref": "#/$defs/action" }, + "suggested_intent": { + "type": ["string", "null"], + "default": null, + "description": "Proposed new intent text. Expected for 'semantic' verdicts; null otherwise." + }, + "impact": { + "type": "array", + "items": { "$ref": "#/$defs/impactEntry" }, + "default": [], + "description": "Transitively affected items via the related graph." + } + } + } + } +} +``` + + +The schemas ship as `liyi.schema.json` and `triage.schema.json` in the linter's release artifacts and are to be published at the `$id` URL. +Editors that support JSON Schema (VSCode, IntelliJ, Neovim with `SchemaStore`) will provide validation and autocompletion for `.liyi.jsonc` files when configured with `"$schema": "https://liyi.run/schema/0.1/liyi.schema.json"` at the top of the sidecar, or via a workspace-level `json.schemas` setting. --- diff --git a/docs/liyi-design.md.liyi.jsonc b/docs/liyi-design.md.liyi.jsonc index 47c86cd..232820d 100644 --- a/docs/liyi-design.md.liyi.jsonc +++ b/docs/liyi-design.md.liyi.jsonc @@ -18,14 +18,14 @@ 406, 408 ], - "source_hash": "sha256:361fd9e237617e9cf0e80df99dd31b06274eca55e15e33384fb4a8f745d07fb9", + "source_hash": "sha256:0dc645decb5df864c85904dbc09dbfbaaea627945632d6e83696bce196c8b7e8", "source_anchor": "" }, { "requirement": "reviewed-semantics", "source_span": [ - 432, - 434 + 438, + 440 ], "source_hash": "sha256:eabc16cfe83d7ed5b52779d4771a04f9ba6fee37c24e4493f2577d907afbd9e0", "source_anchor": "" @@ -33,8 +33,8 @@ { "requirement": "source-span-semantics", "source_span": [ - 456, - 458 + 462, + 464 ], "source_hash": "sha256:879d23ea45fef5ded8141bbaf784171a02ab9be65cf003c6be11c08fa735d20b", "source_anchor": "" @@ -42,8 +42,8 @@ { "requirement": "tool-managed-fields", "source_span": [ - 460, - 462 + 466, + 468 ], "source_hash": "sha256:b1b5caaebc89ce62a3b2e59f7468b813a67caf784a6fe03209c46ab67a2f7dbc", "source_anchor": "" @@ -51,8 +51,8 @@ { "requirement": "span-shift-heuristic", "source_span": [ - 472, - 474 + 478, + 480 ], "source_hash": "sha256:f51dc7ced1fa6597e05d2c82b7878a20babcbf31d09960978a68918fcc1075cf", "source_anchor": "" @@ -60,8 +60,8 @@ { "requirement": "tree-path-fix-behavior", "source_span": [ - 498, - 501 + 504, + 507 ], "source_hash": "sha256:1f5c710fae63a2d2466bcc01a45b30b1f85ab4a22bfb19f961958b1167d47d0c", "source_anchor": "" @@ -69,8 +69,8 @@ { "requirement": "sibling-scan-recovery", "source_span": [ - 503, - 526 + 509, + 532 ], "source_hash": "sha256:9f3c1ac03a88188f97c3ea850988d5f55f7a2f78c34b28104f47da67ba0f64e7", "source_anchor": "" @@ -78,8 +78,8 @@ { "requirement": "tree-path-empty-fallback", "source_span": [ - 530, - 538 + 536, + 544 ], "source_hash": "sha256:93c0520e982193e67ecef00b351b6967775bfcb9d989290fe51795b747decceb", "source_anchor": "" @@ -87,8 +87,8 @@ { "requirement": "requirement-name-uniqueness", "source_span": [ - 690, - 692 + 696, + 698 ], "source_hash": "sha256:fc329b7eb4016b20e9baa89b14470f2953aa6bad5311aa5d4867702994d94f32", "source_anchor": "" @@ -96,8 +96,8 @@ { "requirement": "cycle-detection", "source_span": [ - 820, - 822 + 826, + 828 ], "source_hash": "sha256:e8af30a5974fcccf038c0d07eb98c279b57c9671dad032123424aa041d1262ec", "source_anchor": "" @@ -105,8 +105,8 @@ { "requirement": "requirement-discovery-global", "source_span": [ - 1144, - 1146 + 1198, + 1200 ], "source_hash": "sha256:8fdcf5001d02d4622dd7c8a5bd89090119e35cd2aedf7b8d9d7243d5f4cff359", "source_anchor": "" @@ -114,8 +114,8 @@ { "requirement": "liyi-check-exit-code", "source_span": [ - 1269, - 1275 + 1323, + 1329 ], "source_hash": "sha256:2cb6ab36efd4724e3f7e7021bf36233633357e9fddbd1a231bc7b7ed07137d65", "source_anchor": "" @@ -123,8 +123,8 @@ { "requirement": "marker-normalization", "source_span": [ - 1302, - 1304 + 1356, + 1358 ], "source_hash": "sha256:f1786541ac9be4533a36924a80c578a749024a4980fbc323417c2d5640ed6306", "source_anchor": "" @@ -132,8 +132,8 @@ { "requirement": "quine-escape-in-source", "source_span": [ - 1316, - 1318 + 1370, + 1372 ], "source_hash": "sha256:b929750d0d3c9ce24a6f814d7a1db15f2fc03983efa2647cfcf6a7462d2c8b81", "source_anchor": "" @@ -141,8 +141,8 @@ { "requirement": "markdown-fenced-block-skip", "source_span": [ - 1322, - 1330 + 1376, + 1384 ], "source_hash": "sha256:d75bbcedd7757ae66541f99c329df0460e21a6ac02c056e06156d4aacc8dd749", "source_anchor": "" @@ -150,8 +150,8 @@ { "requirement": "fix-never-modifies-human-fields", "source_span": [ - 1539, - 1541 + 1593, + 1595 ], "source_hash": "sha256:2101e1597493adb25b7743b3b2f983f672f967e2e24ae18ef07ece70ccfb76bb", "source_anchor": "" @@ -159,11 +159,20 @@ { "requirement": "fix-semantic-drift-protection", "source_span": [ - 1543, - 1545 + 1597, + 1599 ], "source_hash": "sha256:7c7bcb74ddf40462f9162ada24feeffa60e080adae3f4a69d37dde5fb23ac81b", "source_anchor": "" + }, + { + "requirement": "agents-md-instructions-section-naming", + "source_span": [ + 2651, + 2659 + ], + "source_hash": "sha256:ba9f88f5edb0b338487e493235b49269cfc4ec74d03ebcfe33e1ec7bdc39d298", + "source_anchor": "" } ] } diff --git a/docs/monorepo-governance.md b/docs/monorepo-governance.md new file mode 100644 index 0000000..eee73e6 --- /dev/null +++ b/docs/monorepo-governance.md @@ -0,0 +1,76 @@ + + +# Monorepo governance boundary — backlog + +**Status:** Backlog. Recorded so the gap is not lost; not yet designed in +detail, and no requirement is declared yet. + +## The gap + +立意's recursive operations cannot tell directories a repository *owns* +from directories it merely *vendors*. In a layout such as + +```text +repo/ # owned +subprojects/* # owned +3rdparty/* # NOT owned +vendor/* # NOT owned +``` + +`liyi migrate` (sidecar schema migration) and `liyi check` / +`liyi check --fix` walk the whole tree. They will therefore read and +rewrite artifacts under `3rdparty/` and `vendor/`, including a vendored +project's `.liyi.jsonc` sidecars and its `AGENTS.md` agent-instruction +block. Rewriting another project's intent artifacts to this repository's +schema/instruction revision is out of bounds. + +## Current state + +- **Agent instructions are already safe.** `liyi migrate` targeting + strictly mirrors `liyi init`: a directory argument contributes exactly + its own `AGENTS.md`, never a recursive walk, so a tree is traversed only + by naming each governed directory. See *`liyi migrate` behavior* in + `docs/liyi-design.md`. +- **Sidecars are not.** `resolve_sidecar_targets` still walks directories + for `.liyi.jsonc`, so `liyi migrate .` can rewrite vendored sidecars. +- **`liyi check` is not.** It walks the whole tree (and `--fix` writes), + so it reports and repairs vendored sidecars too. +- **Ignore files are the wrong shape.** `.liyiignore` / `.gitignore` can + exclude paths, but the desired boundary is "which subtrees are under my + governance", which is an *include* concept, not an ignore list — and a + vendored tree may be committed, hence not gitignored. + +## Open design questions + +1. **What expresses the boundary?** Candidates: + - an explicit *include* list of governed roots (CLI flag and/or a + repo manifest), rather than an exclude list; + - a marker at each governed root (a `.liyi/` directory or a + file-scoped directive) so the boundary travels with the subtree; + - a workspace manifest (`liyi.toml`?) at the repo root that enumerates + or globs governed subtrees. +2. **Which operations become scoped?** Discovery, `check`, `check --fix`, + `migrate`, `init`. Mutating paths matter most; read-only `check` is + lower risk but still noisy across a vendored tree. +3. **Nested VCS boundaries.** The walker already skips VCS internals + (`.git/`, `.hg/`, …), but a nested working tree or submodule is still + walked. Should a nested `.git/` terminate the walk by default? +4. **Relationship to existing work.** How does this interact with the + deferred "workspace-aware requirement queries (monorepo)" item and the + shared requirement namespace? + +## Minimum viable direction + +An opt-in, explicit governed-root list (CLI flag and/or manifest) that +scopes discovery, `check`, `check --fix`, and `migrate` to the listed +subtrees, with the safe default remaining non-recursive for mutating +operations until that boundary exists. + +## References + +- `docs/liyi-design.md` — *`liyi migrate` behavior* (strict agent-file + targeting), `.liyiignore`, and the sidecar discovery rules. +- `docs/next-steps.md` — Tier 5, "Workspace-aware requirement queries + (monorepo)". +- Commit `747b0095299d` — made `liyi migrate` agent-instruction targeting + mirror `liyi init` (no recursion) for exactly this reason. diff --git a/docs/next-steps.md b/docs/next-steps.md index accf96c..bbcb1f7 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -3,68 +3,26 @@ # Prioritized Next Steps -**As of**: 2026-05-30 · **Baseline**: v0.1.0, prompt-mode expansion and approval workflow shipped; doc-comment detection complete for all feasible languages. +**As of**: 2026-09-19 · **Baseline**: v0.1.0; prompt-mode expansion, approval workflow, doc-comment detection, the LSP library refactor, and the `@liyi:note` context MVP shipped. This document synthesizes the existing roadmaps (liyi-design.md, lsp-design.md, -approve-impl.md, init-discover-impl.md, prompt-mode-design.md, -injection-impl.md, sidecar-merge-design.md) and the repo's current state into a -single prioritized backlog. Items are grouped into tiers by impact and -readiness. +init-discover-impl.md, prompt-mode-design.md, injection-impl.md, +file-directive-design.md, note-context-design.md, sidecar-merge-design.md) and +the repo's current state into a single prioritized backlog. Items are grouped +into tiers by impact and readiness. --- -## Tier 1 — Low-hanging fruit (small effort, high polish) - -These can each be done in a single focused session without new design work. - -| # | Item | Source | Why now | -|---|------|--------|---------| -| 1.1 | ~~**Extend `--prompt` to stale/shifted/unreviewed diagnostics**~~ | prompt-mode-design.md | ✅ Done — `liyi check --prompt` now groups actionable diagnostics for `Stale`, `Shifted`, `Unreviewed`, and `ReqChanged` in addition to the original coverage-gap kinds. | -| 1.2 | ~~**Doc-comment detection for remaining languages**~~ | init-discover-impl.md (Phase 2 gap) | ✅ Done — 15/21 languages now have `doc_comment_detector`. Remaining 5 (Bash, Ruby not feasible; JSON/TOML/YAML not applicable). | -| 1.3 | ~~**Disambiguate Rust trait impls & ObjC categories in tree_path**~~ | tree_path resolver audit | ✅ Done — `impl Trait for Foo` now encodes as `impl."Trait for Foo"` (distinct from inherent `impl.Foo`); ObjC `@interface Foo (Cat)` encodes as `class."Foo (Cat)"`. Root cause of a mislabeled sidecar spec where two distinct impl blocks shared one tree_path. See item 2.8 for the remaining collision classes. | - -> **Note on the 14 unreferenced requirements:** `liyi check` currently reports -> 7 requirements from lsp-design.md and 7 from sidecar-merge-design.md with no -> referencing item specs. This is expected — the code that would carry -> `@liyi:related` edges pointing to these requirements doesn't exist yet. -> They will be resolved naturally when the LSP (Tier 3) and sidecar merge -> (Tier 4.2) features are implemented. +> **Note on unreferenced requirements:** `liyi check` currently reports +> requirements in several design documents (notably lsp-design.md, +> sidecar-merge-design.md, file-directive-design.md, and +> note-context-design.md) that have no referencing item specs. This is expected +> — the code that would carry `@liyi:related` edges pointing to these +> requirements doesn't exist yet. They will be resolved naturally when the +> corresponding features (Tiers 2–4) are implemented. ## Tier 2 — Next milestones (moderate effort, unlocks downstream value) -### 2A. Approval workflow follow-through (v0.1.x scope) - -| # | Item | Source | -|---|------|--------| -| 2.1 | ~~**StaleReviewed approval flow**~~ | approve-impl.md | -| 2.2 | ~~**ReqChanged approval flow**~~ | approve-impl.md | - -**Status**: ✅ Done — `liyi approve` now surfaces unreviewed, stale-reviewed, -and requirement-changed items; the TUI shows source diffs for stale-reviewed -items and requirement diffs for req-changed items, and the CLI exposes -`--unreviewed-only`, `--stale-only`, and `--req-only` filters. - -**Rationale**: This milestone is complete; keep it here as a shipped v0.1.x -checkpoint because later roadmap items build on the same review model. - -### 2B. Library refactoring for LSP (Step 0) - -| # | Item | Source | -|---|------|--------| -| 2.3 | ~~**Extract `build_requirement_registry()` as public API**~~ | lsp-design.md Step 0 | -| 2.4 | ~~**Export `RequirementRegistry` / `RequirementRecord`**~~ | lsp-design.md Step 0 | - -**Status**: ✅ Done — `liyi::check` now exposes public `RequirementRegistry` -and `RequirementRecord`, plus `build_requirement_registry()` (pass 1) and -`check_sidecars()` (pass 2) that accept a prebuilt registry. `run_check()` -remains the CLI wrapper and is behavior-compatible. The -`lsp-check-refactor-exposes-registry` requirement is now referenced by -`build_requirement_registry`. - -**Rationale**: This was the *prerequisite* gate for all LSP work — a pure -refactor with no behavioral change. With it landed, the LSP crate can cache -the registry across edits instead of re-scanning the project. - ### 2C. VCS hints (Phase 3 of init-discover) | # | Item | Source | @@ -76,7 +34,7 @@ the registry across edits instead of re-scanning the project. **Rationale**: Phase 3 is fully designed and was explicitly "deferred, not cancelled." VCS hints significantly improve cold-start triage by telling agents which items have churn or bug-fix history. The `git log -L` approach avoids the -git2 dependency. Can be worked in parallel with Tier 2B/2D. +git2 dependency. Can be worked in parallel with Tier 2D/2E. ### 2D. Resolve remaining tree_path name collisions @@ -89,7 +47,7 @@ git2 dependency. Can be worked in parallel with Tier 2B/2D. indistinguishable — the second is unaddressable and silently resolves to the first. This was the root cause of a mislabeled sidecar spec (an inherent `impl Diagnostic` carrying a trait impl's intent). The Rust trait-impl and -Objective-C category cases are fixed (item 1.3); a full audit of all 20 language +Objective-C category cases are already fixed; a full audit of all 20 language configs found these remaining collision classes: | Language(s) | Collision | Frequency | @@ -135,29 +93,10 @@ retire the stale `.h`→C doc comment), and a discovery hook alongside the authority: `docs/file-directive-design.md` (requirements `file-directive-namespace`, `file-language-precedence`, `file-ignore-additive`). -### 2F. `@liyi:note` context primitive + `liyi context` CLI - -| # | Item | Source | -|---|------|--------| -| 2.10 | ~~**Hard-rename `@liyi:module` → `@liyi:note`; add `@liyi:end-note`, `@liyi:see`; `liyi context ` MVP**~~ | `docs/note-context-design.md` — ✅ Done — `@liyi:module` removed from the scanner; `Note`/`EndNote`/`See` markers land in `markers.rs`; the live retrieval-graph resolver (directory scope + shadowing, `@liyi:see`, `=none` opt-out) ships in `context.rs`; `liyi context [:line]` is wired in the CLI. Item-precise `@liyi:see` (tree-sitter) stays file-scoped for now; LSP/MCP context surfaces (Tier 3) and challenge mode (Tier 4.4) remain deferred. | - -Replaces the presence-only `@liyi:module` marker with a marker-only, untracked -**context primitive**: notes carry governing prose injected into a reader's -context, separated from the hash-anchored staleness graph (the "two-graph" -model). Adds `@liyi:end-note` (block bounding) and `@liyi:see ` (item-side -membership). The near-term consumer is a read-only `liyi context ` -command that resolves and prints applicable notes; the LSP/MCP context API -(Tier 3) is the eventual home, and challenge-mode verification (Tier 4.4) is the -deferred semantic check — notes never seed adversarial unit tests. The staleness -engine (`check.rs`) is untouched; the JSON schema is unchanged. This is a -pre-1.0 breaking rename (no external adopters). Design authority: -`docs/note-context-design.md` (requirements `note-is-untracked`, -`note-directory-scope`, `note-see-membership`, `two-graph-separation`, -`context-resolution`). - ## Tier 3 — v0.2 headline: LSP server -Depends on Tier 2B completion. +Builds on the already-landed `liyi::check` library surface, so the server can +cache the requirement registry across edits. | # | Item | Source | Phase | |---|------|--------|-------| @@ -203,14 +142,15 @@ Not designed in detail; captured for completeness. ## Suggested sequencing ``` -Now Tier 2B (LSP library refactor) - Tier 2C (VCS hints) ─── can parallelize ─── Tier 2D (tree_path collision work) +Now Tier 2C (VCS hints) ─── can parallelize ─── Tier 2D (tree_path collision work) + Tier 2E (@liyi:file directives) │ -v0.2 Tier 3.1 → 3.2 → 3.3 (LSP) ← resolves 7 unreferenced lsp-design requirements +v0.2 Tier 3.1 → 3.2 → 3.3 (LSP) │ -Post-MVP Tier 4 items by opportunity ← 4.2 resolves 7 unreferenced merge-design requirements +Post-MVP Tier 4 items by opportunity ``` -Tier 1 is complete. Within Tier 2, 2B must precede Tier 3, while 2C and 2D are -independent of the LSP refactor and can be scheduled in parallel. Within Tier -4, items 4.1–4.3 are independent; 4.4 depends on Tier 3. +Tier 1 is complete and the Tier 2 LSP library refactor has landed, unblocking +Tier 3. The remaining Tier 2 items (2C, 2D, 2E) are independent of the LSP +refactor and can be scheduled in parallel. Within Tier 4, items 4.1–4.3 are +independent; 4.4 depends on Tier 3. diff --git a/schema/liyi.schema.json b/schema/liyi.schema.json index fa9000b..f674400 100644 --- a/schema/liyi.schema.json +++ b/schema/liyi.schema.json @@ -54,7 +54,7 @@ }, "intent": { "type": "string", - "description": "Natural-language description of what the item SHOULD do, or the sentinel value '=doc' meaning the source docstring captures intent, or '=trivial' meaning the item is trivial and needs no intent spec." + "description": "Natural-language description of what the item SHOULD do, or the sentinel value '=doc' meaning the source docstring captures intent, '=trivial' meaning the item is trivial and needs no intent spec, or '=self-doc' meaning the item is self-documenting (intent intrinsic to its surface form) and needs no intent spec." }, "source_span": { "$ref": "#/$defs/sourceSpan" }, "tree_path": { diff --git a/scripts/check-template-sync.py b/scripts/check-template-sync.py new file mode 100644 index 0000000..68f90ee --- /dev/null +++ b/scripts/check-template-sync.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 OR MIT +"""Keep AGENTS.md's portable template in sync with the design doc's block. + +`liyi init` ships the `START liyi agent instructions` block from `AGENTS.md` +unchanged, so that block keeps its section heading and `liyi init` needs no +special-casing. `docs/liyi-design.md` carries the same instruction body +*without* the heading, so the design doc's own section heading and outline +stay in control. This check compares the two, accounting for the heading: +AGENTS.md's block must equal the design doc's block prefixed with the +heading. + +`--sync` materializes AGENTS.md from the design doc's block, adding the +heading, so the design doc is the source of truth. + +Usage: + python3 scripts/check-template-sync.py [--sync] [ROOT] + +Without `--sync` the script exits non-zero and prints a unified diff when +the copies differ. With `--sync` it rewrites AGENTS.md's block from the +design doc's and exits zero. ROOT defaults to the repository root inferred +from this script's location. +""" + +from __future__ import annotations + +import difflib +import sys +from pathlib import Path + +# @liyi:related agents-md-instructions-section-naming +START = "\n" +# @liyi:related agents-md-instructions-section-naming +END = "" +# @liyi:related agents-md-instructions-section-naming +HEADING = "## The 立意 (Intent Specs) design pattern for agents" + + +def read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def extract(text: str, where: str) -> str: + """The template block without the surrounding markers or newlines.""" + try: + start = text.index(START) + len(START) + end = text.index(END, start) + except ValueError: + raise SystemExit(f"template-sync: {where} has no {START} / {END} block") + return text[start:end].strip("\n") + + +def replace(text: str, block: str) -> str: + """Rewrite the existing template block in *text* to *block*.""" + start = text.index(START) + end = text.index(END, start) + len(END) + return text[:start] + f"{START}\n{block}\n{END}" + text[end:] + + +def main() -> int: + args = sys.argv[1:] + sync = "--sync" in args + positional = [arg for arg in args if arg != "--sync"] + if len(positional) > 1: + raise SystemExit("template-sync: at most one ROOT argument is expected") + root = Path(positional[0]).resolve() if positional else Path(__file__).resolve().parent.parent + + agents_path = root / "AGENTS.md" + design_path = root / "docs" / "liyi-design.md" + + agents = read(agents_path) + body = extract(read(design_path), "docs/liyi-design.md") + expected = f"{HEADING}\n\n{body}" + actual = extract(agents, "AGENTS.md") + + if actual == expected: + print("template-sync: AGENTS.md matches the design doc's template block") + return 0 + + if sync: + agents_path.write_text(replace(agents, expected), encoding="utf-8") + print( + "template-sync: wrote the design doc's block into AGENTS.md " + "(section heading added)" + ) + return 0 + + print( + "template-sync: AGENTS.md does not match the design doc's template " + "block (the design doc is the source of truth).\n" + ) + sys.stdout.writelines( + difflib.unified_diff( + expected.splitlines(keepends=True), + actual.splitlines(keepends=True), + fromfile="docs/liyi-design.md (expected, heading added)", + tofile="AGENTS.md", + ) + ) + print( + "\nRe-run with `--sync` (or `make sync-template`) to copy the design " + "doc's block into AGENTS.md." + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-template-sync.py.liyi.jsonc b/scripts/check-template-sync.py.liyi.jsonc new file mode 100644 index 0000000..83dcdda --- /dev/null +++ b/scripts/check-template-sync.py.liyi.jsonc @@ -0,0 +1,52 @@ +// liyi v0.1 spec file +{ + "version": "0.1", + "source": "scripts/check-template-sync.py", + "specs": [ + { + "item": "START", + "reviewed": true, + "intent": "=self-doc", + "source_span": [ + 32, + 32 + ], + "tree_path": "const.START", + "source_hash": "sha256:2a5069ba2b819cef252dde6ec221dd83160f319fe07dff10a6a24b1300e84aac", + "source_anchor": "START = \"\\n\"", + "related": { + "agents-md-instructions-section-naming": "sha256:ba9f88f5edb0b338487e493235b49269cfc4ec74d03ebcfe33e1ec7bdc39d298" + } + }, + { + "item": "END", + "reviewed": true, + "intent": "=self-doc", + "source_span": [ + 34, + 34 + ], + "tree_path": "const.END", + "source_hash": "sha256:5c2d6107a704fe852e8434e1932942623fada1d315683e0bb7be968ff740eb8a", + "source_anchor": "END = \"\"", + "related": { + "agents-md-instructions-section-naming": "sha256:ba9f88f5edb0b338487e493235b49269cfc4ec74d03ebcfe33e1ec7bdc39d298" + } + }, + { + "item": "HEADING", + "reviewed": true, + "intent": "=self-doc", + "source_span": [ + 36, + 36 + ], + "tree_path": "const.HEADING", + "source_hash": "sha256:b2d628369d56e7a4f2c9cde920a380f6845dc0ef9a4de913b2e90c68e4b13146", + "source_anchor": "HEADING = \"## The 立意 (Intent Specs) design pattern for agents\"", + "related": { + "agents-md-instructions-section-naming": "sha256:ba9f88f5edb0b338487e493235b49269cfc4ec74d03ebcfe33e1ec7bdc39d298" + } + } + ] +}