Phase 1 of substrate-unified-registry: move all references to user
home memory/rules out of plain strings and into content-addressable
path atoms. Public artefacts now contain opaque `{path::NAME}/file.md`
references; the actual home prefix lives only in the path-atom file's
frontmatter, registered in the local kei-registry.
NEW path atoms (`_blocks/path-*.md`):
- `path-user-memory.md` → template `~/.claude/memory`
- `path-user-rules.md` → template `~/.claude/rules`
Both files use frontmatter `type: atom, kind: path, template: ..., expand_at: render`.
BlockMdScanner auto-registers them; DNA index shows them under their
unprefixed names (`user-memory`, `user-rules`) for human lookup, while
the body sha8 makes them content-addressable.
Resolver (`_assembler/src/registry_client.rs`):
- `is_path_atom(conn, name)` — checks DB by name + filename convention
(`_blocks/path-<name>.md`) + frontmatter `kind: path`. Defensive:
filename + frontmatter must BOTH agree.
- `frontmatter_has_kind_path(body)` — minimal YAML parser. Tolerates
CRLF, quoted values, rejects substring matches (`pathological` ≠ `path`).
- 5 unit tests cover positive + 4 negative cases.
Resolver wire-up (`_assembler/src/assembler.rs:147 write_references`):
- For each `references.extra` entry starting with `path:NAME/...`:
- Lookup `NAME` via `is_path_atom`.
- On success: emit `{path::NAME}/<suffix>` — opaque, kit-resolvable.
- On miss: stderr warn + passthrough. Never fatal.
- Non-`path:` refs pass through unchanged. Backward compatible.
- 2 unit tests cover passthrough paths.
Manifest migration (38 manifests touched):
- `~/.claude/rules/<file>` → `path:user-rules/<file>`
- `~/.claude/memory/<file>` → `path:user-memory/<file>`
- 96 references migrated; 1 prose-style reference in security-auditor
left as plain text (lives inside a domain_in description, not in
references.extra — out of scope for this resolver).
Regenerated 38 `_generated/*.md` + 1 new `frontend-validator.md`.
Regenerated `docs/DNA-INDEX.md` (now includes 2 path-atoms by name).
Verification (cited):
- `git ls-files | grep denisparfionovich` → 0 hits outside allowlist
(NOTICE/README byline + `.github/workflows/leak-check.yml` detection
rule).
- `_generated/` contains 99 occurrences of `{path::user-...}/`.
- assembler tests: 29 passed (5 new). kei-registry tests: 10 passed
(8 short_path from earlier commit + 2 unrelated).
- assembler resolver verified end-to-end: ml-implementer.md line
479-485 shows `{path::user-rules}/ml-protocol.md` etc.
What this does NOT do (deferred):
- No registry-DB schema change. Path atoms ride existing Atom block-
type via convention, not via new `BlockType::PathAtom` variant.
- No git-branch tracking (Phase 2 of plan).
- No `kei-registry status` cross-cutting CLI (Phase 3 of plan).
- No path-atom orphan detection CLI (Phase 4).
The path:user-memory and path:user-rules cover 100% of the username-
leak surface from the current manifest set; future categories
(kit-root, registry-db, sync-repo, secrets-env, project-root) can
land additively without architectural changes.
=== STATUS-TRUTH MARKER ===
shipped: functional
stubs: 0
cargo-check: PASS
behaviour-verified: yes
follow-up-required:
- Phase 2 (git-branch tracker hook)
- Phase 3 (kei-registry status subcommand)
- Phase 4 (orphan detection CLI)
- Sync user-side install: ~/.claude/agents/_manifests/ still has
pre-migration absolute paths; will pick up new format on next
`install.sh --add` (out of scope for this commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
240 lines
8.1 KiB
Rust
240 lines
8.1 KiB
Rust
//! Agent assembler — composes markdown from manifest + blocks.
|
|
//! Output is deterministic: same manifest + blocks → byte-identical .md.
|
|
|
|
use crate::manifest::Manifest;
|
|
use crate::registry_client;
|
|
use crate::substrate;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
pub fn assemble(m: &Manifest, blocks_dir: &Path) -> Result<String, String> {
|
|
// Substrate role expansion uses the kit root (parent of _blocks/).
|
|
let root = blocks_dir
|
|
.parent()
|
|
.ok_or_else(|| "blocks_dir has no parent (can't locate _roles/ and _capabilities/)".to_string())?;
|
|
|
|
let mut out = String::new();
|
|
|
|
write_frontmatter(m, &mut out);
|
|
write_role(m, &mut out);
|
|
write_substrate(m, root, &mut out)?;
|
|
write_rule_blocks(m, &mut out)?;
|
|
write_blocks(m, blocks_dir, &mut out)?;
|
|
write_domain_scope(m, &mut out);
|
|
write_handoffs(m, &mut out);
|
|
write_output_format(m, &mut out);
|
|
write_forbidden(m, &mut out);
|
|
write_references(m, &mut out);
|
|
|
|
Ok(out)
|
|
}
|
|
|
|
fn write_substrate(m: &Manifest, root: &Path, out: &mut String) -> Result<(), String> {
|
|
let Some(role) = &m.substrate_role else {
|
|
return Ok(());
|
|
};
|
|
let section = substrate::build_substrate_section(root, role)?;
|
|
out.push_str(§ion);
|
|
Ok(())
|
|
}
|
|
|
|
fn write_frontmatter(m: &Manifest, out: &mut String) {
|
|
let desc = m.description.replace('\n', " ");
|
|
out.push_str("---\n");
|
|
out.push_str(&format!("name: {}\n", m.name));
|
|
out.push_str(&format!("description: {}\n", desc.trim()));
|
|
out.push_str(&format!("tools: {}\n", m.tools.join(", ")));
|
|
out.push_str(&format!("model: {}\n", m.model));
|
|
out.push_str("---\n\n");
|
|
out.push_str(&format!(
|
|
"<!-- GENERATED by _assembler (Rust) from _manifests/{}.toml — DO NOT EDIT. Edit the manifest. -->\n\n",
|
|
m.name
|
|
));
|
|
}
|
|
|
|
fn write_role(m: &Manifest, out: &mut String) {
|
|
out.push_str("# ROLE\n\n");
|
|
out.push_str(m.role.trim());
|
|
out.push_str("\n\n");
|
|
}
|
|
|
|
fn write_blocks(m: &Manifest, blocks_dir: &Path, out: &mut String) -> Result<(), String> {
|
|
for block in &m.blocks {
|
|
let path = blocks_dir.join(format!("{block}.md"));
|
|
let text = fs::read_to_string(&path)
|
|
.map_err(|e| format!("read {}: {e}", path.display()))?;
|
|
out.push_str(text.trim());
|
|
out.push_str("\n\n");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn write_rule_blocks(m: &Manifest, out: &mut String) -> Result<(), String> {
|
|
if m.rule_blocks.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let db_path = registry_client::default_db_path();
|
|
if !db_path.exists() {
|
|
eprintln!("warn [assembler]: registry not found at {} — skipping rule_blocks", db_path.display());
|
|
return Ok(());
|
|
}
|
|
let conn = registry_client::open_read_only(&db_path)?;
|
|
for name in &m.rule_blocks {
|
|
match registry_client::find_rule(&conn, name) {
|
|
Ok(Some(body)) => {
|
|
out.push_str(&format!("<!-- RULE: {name} -->\n"));
|
|
out.push_str(body.trim());
|
|
out.push_str("\n<!-- /RULE -->\n\n");
|
|
}
|
|
Ok(None) => {
|
|
return Err(format!(
|
|
"rule_block '{name}' not found in registry — \
|
|
run `kei-decompose decompose-rules` first or remove from manifest"
|
|
));
|
|
}
|
|
Err(e) => return Err(format!("registry lookup for '{name}': {e}")),
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn write_domain_scope(m: &Manifest, out: &mut String) {
|
|
out.push_str("# DOMAIN SCOPE\n\n**In:**\n");
|
|
for item in &m.domain_in {
|
|
out.push_str(&format!("- {item}\n"));
|
|
}
|
|
out.push_str("\n**Out (hand off):**\n");
|
|
for h in &m.handoff {
|
|
out.push_str(&format!("- `{}` — {}\n", h.target, h.trigger));
|
|
}
|
|
out.push('\n');
|
|
}
|
|
|
|
fn write_handoffs(m: &Manifest, out: &mut String) {
|
|
out.push_str("# HANDOFFS\n\n");
|
|
for h in &m.handoff {
|
|
out.push_str(&format!("- **{}** — {}\n", h.target, h.trigger));
|
|
}
|
|
out.push('\n');
|
|
}
|
|
|
|
fn write_output_format(m: &Manifest, out: &mut String) {
|
|
out.push_str("# OUTPUT FORMAT\n\n```\n");
|
|
out.push_str(&format!("=== {} REPORT ===\n", m.name.to_uppercase()));
|
|
out.push_str("Goal: <one-line>\n");
|
|
out.push_str("Scope: <in / out>\n");
|
|
out.push_str("Plan: <N steps>\n");
|
|
out.push_str("Executed: <files touched, LOC delta>\n");
|
|
out.push_str("Verify: <each criterion pass/fail>\n");
|
|
out.push_str("Evidence grades: <E1-E6 for each major claim>\n");
|
|
out.push_str("Handoffs made: <list>\n");
|
|
for extra in &m.output_extra_fields {
|
|
out.push_str(extra);
|
|
out.push('\n');
|
|
}
|
|
out.push_str("Blockers / next: <list>\n");
|
|
out.push_str("```\n\n");
|
|
}
|
|
|
|
fn write_forbidden(m: &Manifest, out: &mut String) {
|
|
out.push_str("# FORBIDDEN\n\n");
|
|
for item in &m.forbidden_domain {
|
|
out.push_str(&format!("- {item}\n"));
|
|
}
|
|
out.push('\n');
|
|
}
|
|
|
|
fn write_references(m: &Manifest, out: &mut String) {
|
|
out.push_str("# REFERENCES\n\n");
|
|
out.push_str("- `~/.claude/CLAUDE.md` — baseline umbrella\n");
|
|
out.push_str("- `~/.claude/memory/MEMORY.md` — memory index (adjust if your Claude Code user-slug path differs)\n");
|
|
if let Some(mp) = &m.memory_project {
|
|
out.push_str(&format!(
|
|
"- `~/.claude/memory/{mp}` — project memory (adjust path if needed)\n"
|
|
));
|
|
}
|
|
if let Some(pc) = &m.project_claudemd {
|
|
out.push_str(&format!("- `{pc}` — project CLAUDE.md\n"));
|
|
}
|
|
if let Some(refs) = &m.references {
|
|
// Open registry for path-atom resolution. Missing DB is non-fatal —
|
|
// references then fall through unchanged (advisory only).
|
|
let conn = {
|
|
let db_path = registry_client::default_db_path();
|
|
if db_path.exists() {
|
|
registry_client::open_read_only(&db_path).ok()
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
for r in &refs.extra {
|
|
let resolved = resolve_path_atom_ref(r, conn.as_ref());
|
|
out.push_str(&format!("- `{resolved}`\n"));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resolve a `path:NAME/file.md` reference to an opaque content-addressed
|
|
/// form `{path::NAME}/file.md` if `NAME` is a registered path-atom.
|
|
///
|
|
/// Behaviour:
|
|
/// - Input does not start with `path:` → return unchanged.
|
|
/// - Input starts with `path:` but lookup fails (no atom / not a path-atom /
|
|
/// no registry) → emit a stderr warning and return the input unchanged.
|
|
/// This is advisory: the warn surfaces typos in manifests but never
|
|
/// blocks rendering.
|
|
/// - Lookup succeeds → return `{path::NAME}/<suffix>`.
|
|
fn resolve_path_atom_ref(r: &str, conn: Option<&rusqlite::Connection>) -> String {
|
|
let Some(rest) = r.strip_prefix("path:") else {
|
|
return r.to_string();
|
|
};
|
|
let (name, suffix) = match rest.split_once('/') {
|
|
Some((n, s)) => (n, s),
|
|
// `path:NAME` with no `/file` part — atom-only ref. Resolve to
|
|
// `{path::NAME}` so the public output is still opaque.
|
|
None => (rest, ""),
|
|
};
|
|
let Some(c) = conn else {
|
|
eprintln!(
|
|
"warn [assembler]: 'path:{name}' reference but registry DB not open — passing through"
|
|
);
|
|
return r.to_string();
|
|
};
|
|
match registry_client::is_path_atom(c, name) {
|
|
Ok(true) => {
|
|
if suffix.is_empty() {
|
|
format!("{{path::{name}}}")
|
|
} else {
|
|
format!("{{path::{name}}}/{suffix}")
|
|
}
|
|
}
|
|
Ok(false) => {
|
|
eprintln!(
|
|
"warn [assembler]: 'path:{name}' not found in registry as path-atom — passing through"
|
|
);
|
|
r.to_string()
|
|
}
|
|
Err(e) => {
|
|
eprintln!("warn [assembler]: registry lookup for path-atom '{name}': {e}");
|
|
r.to_string()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod write_references_tests {
|
|
use super::resolve_path_atom_ref;
|
|
|
|
#[test]
|
|
fn passthrough_non_path_ref() {
|
|
let r = "~/.claude/memory/foo.md";
|
|
assert_eq!(resolve_path_atom_ref(r, None), r);
|
|
}
|
|
|
|
#[test]
|
|
fn passthrough_path_ref_when_no_db() {
|
|
// No registry conn → emit warn (suppressed in test) + passthrough.
|
|
let r = "path:user-memory/foo.md";
|
|
assert_eq!(resolve_path_atom_ref(r, None), r);
|
|
}
|
|
}
|