diff --git a/.codex/agents/verifier.toml b/.codex/agents/verifier.toml new file mode 100644 index 0000000..b297c16 --- /dev/null +++ b/.codex/agents/verifier.toml @@ -0,0 +1,15 @@ +name = "verifier" +description = "Rejects loop fixes unless tests pass and scope is minimal. Never implements fixes." +instructions = """ +You are the checker in a maker/checker split. Default stance: REJECT until proven otherwise. + +Checklist (all must pass for APPROVE): +1. Scope — only relevant files; no denylist paths +2. Intent — addresses the stated target +3. Tests — you ran them and report pass/fail with output +4. No cheating — no disabled tests or skipped assertions +5. Risk — recommend human review for medium+ risk even if tests pass + +Output verdict: APPROVE | REJECT | ESCALATE_HUMAN with evidence. +""" +reasoning_effort = "high" \ No newline at end of file diff --git a/.codex/skills/zj-loop-budget/SKILL.md b/.codex/skills/zj-loop-budget/SKILL.md new file mode 100644 index 0000000..6c0cd20 --- /dev/null +++ b/.codex/skills/zj-loop-budget/SKILL.md @@ -0,0 +1,40 @@ +--- +name: zj-loop-budget +description: Check token budget and run-log spend before and after a loop run. Enforces early exit when over budget or when there is no actionable work. +--- + +# Loop Budget Guard + +Run at the **start** and **end** of every loop iteration. + +## Start of run + +1. Read `zj-loop/zj-loop-budget.md` for daily caps and kill-switch flags. +2. Read recent entries in `zj-loop/zj-loop-run-log.md` (last 24h). +3. Sum `tokens_estimate` for the active pattern today. +4. If spend ≥ 80% of the pattern's daily cap → **report-only mode** (no sub-agents, no auto-fix). +5. If spend ≥ 100% or `loop-pause-all` is set → **exit immediately** with a one-line note in STATE.md. +6. If watchlist/state has no actionable items → **exit in <5k tokens** (do not spawn sub-agents). + +## End of run + +Append one JSON object to `zj-loop/zj-loop-run-log.md`: + +```json +{ + "run_id": "", + "pattern": "", + "duration_s": , + "items_found": , + "actions_taken": , + "escalations": , + "tokens_estimate": , + "outcome": "no-op | report-only | fix-proposed | escalated" +} +``` + +## Rules + +- Never exceed `max sub-agent spawns/run` from `zj-loop/zj-loop-budget.md`. +- High-cadence patterns (CI Sweeper, PR Babysitter) **must** early-exit when nothing is actionable. +- On self-throttle, append a line to `zj-loop/zj-loop-budget.md` under **Alerts This Period**. \ No newline at end of file diff --git a/.codex/skills/zj-loop-constraints/SKILL.md b/.codex/skills/zj-loop-constraints/SKILL.md new file mode 100644 index 0000000..1dbd9e8 --- /dev/null +++ b/.codex/skills/zj-loop-constraints/SKILL.md @@ -0,0 +1,48 @@ +--- +name: zj-loop-constraints +description: > + Read zj-loop-constraints.md at the start of every run and enforce every rule. + This skill runs BEFORE triage or any action skill. Constraints are binding. +user_invocable: true +--- + +# Loop Constraints Enforcer + +You are the guardrail. Before any other work begins, you MUST: + +1. Read `zj-loop/zj-loop-constraints.md` from the project root. +2. Load every rule into your working memory. +3. Check if `loop-pause-all` is active → exit immediately. +4. Apply these rules to EVERY action that follows. + +## How to enforce + +- Before pushing: re-read the Push & Merge section. If ANY rule blocks it, stop and tell the human. +- Before editing a file: re-read the Paths section. If the path matches a denylist pattern, escalate. +- Before proposing a fix: re-read the Code section. Run tests. One fix per run. +- Before merging: re-read the Push & Merge section. Human must approve. + +## Output at start of run + +Always begin with a one-line confirmation: + +``` +Constraints loaded from zj-loop-constraints.md: N rules active. +``` + +If no `zj-loop/zj-loop-constraints.md` exists, say so and proceed with default safety rules from `docs/safety.md`. + +## Interaction with other skills + +- `zj-loop-triage` — constraints may override triage priority (e.g. "don't push" means don't act on CI fixes) +- `zj-minimal-fix` — constraints limit what files can be touched +- `zj-loop-verifier` — constraints define denylist paths the verifier must check +- `zj-loop-budget` — constraints may impose stricter budget than zj-loop-budget.md + +## Default constraints (when no file exists) + +If `zj-loop/zj-loop-constraints.md` is absent, enforce these minimums: +- Never edit `.env`, `.env.*`, `auth/`, `payments/`, `secrets/`, `credentials/` +- Never auto-merge to main +- Never disable tests +- Escalate after 3 failed fix attempts diff --git a/.codex/skills/zj-loop-triage/SKILL.md b/.codex/skills/zj-loop-triage/SKILL.md new file mode 100644 index 0000000..d40a99c --- /dev/null +++ b/.codex/skills/zj-loop-triage/SKILL.md @@ -0,0 +1,46 @@ +--- +name: zj-loop-triage +description: > + Triage recent changes, CI failures, issues, and conversations. + Produces a concise, actionable findings report suitable for a loop to consume. + Writes structured output to a state file or Linear board. +user_invocable: true +--- + +# Loop Triage Skill + +You are an expert engineering triage agent. Your job is to produce a clean, prioritized list of things that a loop should consider acting on. + +## Inputs (the loop will provide these) +- Recent CI / test failures (last 24h) +- Open issues / Linear tickets assigned to the team +- Recent commits on main (last 24–48h) +- Any Slack / chat threads the loop has visibility into +- The current state file (what the loop already knows about) + +## Output Format + +Produce a markdown report with these sections: + +### 1. High-Priority Items (act on these) +- Clear, one-line description +- Why it matters (impact, risk, or customer pain) +- Suggested next action for the loop (e.g. "draft minimal fix in isolated worktree") +- Rough effort estimate + +### 2. Watch Items (monitor, do not act yet) +- Same format but lower urgency + +### 3. Noise / Ignore +- Brief list of things the loop looked at and decided were not worth action + +### 4. State Updates +- Any facts the loop should remember for the next run (e.g. "PR #1234 now has 2 approvals") + +## Rules + +- Be brutally concise. The loop (and the human reading the state) will thank you. +- Only put something in "High-Priority" if a reasonable engineer would want to know about it today. +- When in doubt, put it in Watch or Noise rather than creating work. +- Never propose architectural overhauls during triage — this skill is for signal, not invention. +- Respect the project's existing skills and conventions (they will be provided in context). \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index bcbb675..57699a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features - `zcodegraph status` and `zcodegraph doctor` now share graph health language, so users and scripts can distinguish healthy, degraded, stale, failed, unavailable, and corrupted indexes with exact next-step commands. +- `rust-hybrid` indexing now handles C baseline symbols through the Rust-owned path, so C functions, structs, enums, typedefs, includes, and calls no longer depend on TypeScript fallback extraction. - `rust-hybrid` indexing now handles Java baseline symbols through the Rust-owned path, so Java classes, interfaces, annotations, enums, imports, and method calls no longer depend on TypeScript fallback extraction. - `rust-hybrid` indexing now preserves Python decorator relationships through the Rust-owned path, so decorated Python functions and methods can appear in graph impact and traversal results. - `rust-hybrid` indexing now understands Python calls, imported names, and module-level values, so Python search, callers/callees, and file dependents work through the Rust indexer. diff --git a/Cargo.lock b/Cargo.lock index 8d77527..621ac87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -474,6 +474,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-c" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afd2b1bf1585dc2ef6d69e87d01db8adb059006649dd5f96f31aa789ee6e9c71" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-go" version = "0.23.4" @@ -581,6 +591,7 @@ dependencies = [ "serde_json", "sha2", "tree-sitter", + "tree-sitter-c", "tree-sitter-go", "tree-sitter-java", "tree-sitter-javascript", diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index bf4b28a..d1af584 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -2345,15 +2345,6 @@ end expect(importNode?.name).toBe('myheader.h'); }); - it('should extract C header', () => { - const code = `#include `; - const result = extractFromSource('main.c', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('stdio.h'); - }); - it('should extract multiple includes', () => { const code = ` #include diff --git a/__tests__/rust-index-engine-cli-engine.test.ts b/__tests__/rust-index-engine-cli-engine.test.ts index 37664ef..2afb609 100644 --- a/__tests__/rust-index-engine-cli-engine.test.ts +++ b/__tests__/rust-index-engine-cli-engine.test.ts @@ -518,7 +518,7 @@ describe('zcodegraph index engine CLI defaults and profiles', () => { expect(status.index.engine).toBe('rust-hybrid'); expect(status.index.hybrid).toMatchObject({ phase: 'phase-6-rust-owned-per-file-gap-fallback', - rustOwnedLanguages: ['javascript', 'jsx', 'typescript', 'tsx', 'go', 'python', 'rust'], + rustOwnedLanguages: ['javascript', 'jsx', 'typescript', 'tsx', 'go', 'java', 'python', 'rust', 'c'], engineByLanguage: { typescript: 'rust' }, engineByFileCount: { rust: 1 }, fallbackByLanguage: {}, diff --git a/__tests__/rust-index-engine-cli-fallback.test.ts b/__tests__/rust-index-engine-cli-fallback.test.ts index 0871f02..e07cc33 100644 --- a/__tests__/rust-index-engine-cli-fallback.test.ts +++ b/__tests__/rust-index-engine-cli-fallback.test.ts @@ -42,6 +42,7 @@ describe('zcodegraph rust-hybrid fallback degraded status and doctor output', () fs.writeFileSync(path.join(tempDir, 'server.go'), 'package main\nfunc main() {}\n'); fs.writeFileSync(path.join(tempDir, 'service.py'), 'def service():\n return 1\n'); fs.writeFileSync(path.join(tempDir, 'worker.rs'), 'fn worker() -> i32 { 1 }\n'); + fs.writeFileSync(path.join(tempDir, 'worker.c'), 'int worker(void) { return 1; }\n'); fs.writeFileSync(path.join(tempDir, 'routing.yml'), 'app:\n path: /health\n'); fs.writeFileSync(path.join(tempDir, 'notes.txt'), 'not source\n'); fs.writeFileSync(path.join(tempDir, 'service.pb.go'), 'package main\n'); @@ -52,11 +53,12 @@ describe('zcodegraph rust-hybrid fallback degraded status and doctor output', () expect(plan.rustOwnedFiles).toContain('server.go'); expect(plan.rustOwnedFiles).toContain('service.py'); expect(plan.rustOwnedFiles).toContain('worker.rs'); + expect(plan.rustOwnedFiles).toContain('worker.c'); expect(plan.fallbackFiles).toContain('routing.yml'); expect(plan.unsupportedFiles).toEqual([]); expect(plan.fallbackFiles).not.toContain('notes.txt'); - expect(plan.engineByLanguage).toMatchObject({ typescript: 'rust', go: 'rust', python: 'rust', rust: 'rust', yaml: 'typescript' }); - expect(plan.engineByFileCount).toMatchObject({ rust: 4, typescript: 1 }); + expect(plan.engineByLanguage).toMatchObject({ typescript: 'rust', go: 'rust', python: 'rust', rust: 'rust', c: 'rust', yaml: 'typescript' }); + expect(plan.engineByFileCount).toMatchObject({ rust: 5, typescript: 1 }); expect(plan.fallbackByLanguage).toMatchObject({ yaml: 1 }); expect(plan.fallbackFileCount).toBe(1); expect(plan.missingFallbackByLanguage).toEqual({}); @@ -67,6 +69,20 @@ describe('zcodegraph rust-hybrid fallback degraded status and doctor output', () expect(plan.pendingFallbacks).toContain('rust-owned-parse-gap'); }); + it('classifies ambiguous headers by content before assigning rust-hybrid ownership', () => { + fs.writeFileSync(path.join(tempDir, 'plain.h'), '#ifndef PLAIN_H\nint plain(void);\n#endif\n'); + fs.writeFileSync(path.join(tempDir, 'widget.h'), 'namespace app { class Widget {}; }\n'); + fs.writeFileSync(path.join(tempDir, 'View.h'), '@interface View\n@end\n'); + + const plan = planRustHybridAssignments(tempDir); + + expect(plan.rustOwnedFiles).toContain('plain.h'); + expect(plan.fallbackFiles).toContain('widget.h'); + expect(plan.fallbackFiles).toContain('View.h'); + expect(plan.engineByLanguage).toMatchObject({ c: 'rust', cpp: 'typescript', objc: 'typescript' }); + expect(plan.fallbackByLanguage).toMatchObject({ cpp: 1, objc: 1 }); + }); + it('records Rust-owned per-file gap diagnostics without same-language TypeScript fallback append', () => { const plan = planRustHybridAssignments(tempDir); const merged = mergeRustOwnedGapDiagnostics(plan, [ diff --git a/__tests__/rust-index-engine-cli-language-smoke.test.ts b/__tests__/rust-index-engine-cli-language-smoke.test.ts index 783c909..27896c6 100644 --- a/__tests__/rust-index-engine-cli-language-smoke.test.ts +++ b/__tests__/rust-index-engine-cli-language-smoke.test.ts @@ -278,6 +278,91 @@ describe('zcodegraph rust index language framework and MCP smoke behavior', () = } }, 30_000); + it('indexes C as Rust-owned under rust-hybrid', () => { + fs.writeFileSync( + path.join(tempDir, 'worker.h'), + [ + '#pragma once', + '', + 'typedef enum { MODE_FAST, MODE_SAFE } Mode;', + '', + 'typedef struct Worker {', + ' int count;', + '} Worker;', + '', + 'int helper(int input);', + ].join('\n') + '\n', + ); + fs.writeFileSync( + path.join(tempDir, 'worker.c'), + [ + '#include ', + '#include "worker.h"', + '', + 'struct State {', + ' int value;', + '};', + '', + 'typedef unsigned long WorkerId;', + '', + 'int helper(int input) {', + ' return input + 1;', + '}', + '', + 'int run_worker(Worker *worker) {', + ' printf("%d", worker->count);', + ' return helper(worker->count);', + '}', + ].join('\n') + '\n', + ); + + const result = runZcodegraphCli(tempDir, ['index', '--quiet'], { + ZCODEGRAPH_RUST_CORE_BINARY: RUST_CORE_BIN, + }); + + expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); + const cg = CodeGraph.openSync(tempDir); + try { + expect(cg.getStats().filesByLanguage.c).toBe(2); + const expectations = [ + ['stdio.h', 'import'], + ['worker.h', 'import'], + ['Mode', 'enum'], + ['MODE_FAST', 'enum_member'], + ['Worker', 'struct'], + ['State', 'struct'], + ['WorkerId', 'type_alias'], + ['helper', 'function'], + ['run_worker', 'function'], + ] as const; + for (const [name, kind] of expectations) { + expect( + cg.searchNodes(name).some((match) => match.node.name === name && match.node.kind === kind && match.node.language === 'c'), + `${name} (${kind}) should be indexed as C`, + ).toBe(true); + } + + const runWorker = cg.searchNodes('run_worker').find((match) => match.node.kind === 'function' && match.node.language === 'c')?.node; + const helper = cg.searchNodes('helper').find((match) => match.node.kind === 'function' && match.node.language === 'c')?.node; + expect(runWorker).toBeDefined(); + expect(helper).toBeDefined(); + const calls = cg.getOutgoingEdges(runWorker!.id).filter((edge) => edge.kind === 'calls'); + expect(calls.some((edge) => edge.target === helper!.id)).toBe(true); + + const buildInfo = cg.getIndexBuildInfo(); + expect(buildInfo.engine).toBe('rust-hybrid'); + expect(buildInfo.hybrid).toMatchObject({ + rustOwnedLanguages: expect.arrayContaining(['c']), + engineByLanguage: { c: 'rust' }, + fallbackByLanguage: {}, + fallbackFileCount: 0, + fallbackReasonTaxonomy: {}, + }); + } finally { + cg.close(); + } + }, 30_000); + it('reports Rust index-engine metadata through MCP status', async () => { const indexResult = runZcodegraphCli(tempDir, ['index', '--engine', 'rust', '--quiet'], { ZCODEGRAPH_RUST_CORE_BINARY: RUST_CORE_BIN, diff --git a/crates/zcodegraph-core/Cargo.toml b/crates/zcodegraph-core/Cargo.toml index c49a90f..a8d9400 100644 --- a/crates/zcodegraph-core/Cargo.toml +++ b/crates/zcodegraph-core/Cargo.toml @@ -12,6 +12,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" tree-sitter = "0.24" +tree-sitter-c = "0.23" tree-sitter-go = "0.23" tree-sitter-java = "0.23" tree-sitter-javascript = "0.23" diff --git a/crates/zcodegraph-core/src/lib.rs b/crates/zcodegraph-core/src/lib.rs index fbb200b..5d2c951 100644 --- a/crates/zcodegraph-core/src/lib.rs +++ b/crates/zcodegraph-core/src/lib.rs @@ -7100,6 +7100,9 @@ fn index_javascript_files( counts .profile .add_parse_language_source_read(&language_name, source_read_ms); + if language.is_c() && is_non_c_header(&file_path, &content) { + continue; + } if language.is_rust() { record_rust_file_cargo_ownership( &cargo_workspace_diagnostics, @@ -7140,7 +7143,7 @@ fn index_javascript_files( let file_node_id = file_node.id.clone(); nodes.push(file_node); - if parsed.root_node().has_error() { + if parsed.root_node().has_error() && !language.is_c() { let error_started = Instant::now(); counts.files_errored += 1; counts.errors.push(IndexError::rust_owned_parse_gap( @@ -7184,6 +7187,16 @@ fn index_javascript_files( &mut edges, &mut unresolved_refs, )?; + } else if language.is_c() { + extract_c_symbols( + parsed.root_node(), + content.as_bytes(), + &relative_path, + &file_node_id, + &mut nodes, + &mut edges, + &mut unresolved_refs, + )?; } else if language.is_rust() { extract_rust_symbols( parsed.root_node(), @@ -7494,6 +7507,7 @@ fn is_member_receiver_position(bytes: &[u8], after_word: usize) -> bool { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum SourceLanguage { + C, JavaScript, Jsx, TypeScript, @@ -7515,6 +7529,7 @@ impl SourceLanguage { Some("tsx") => Some(Self::Tsx), Some("mts") => Some(Self::Mts), Some("cts") => Some(Self::Cts), + Some("c") | Some("h") => Some(Self::C), Some("go") => Some(Self::Go), Some("java") => Some(Self::Java), Some("py") | Some("pyw") => Some(Self::Python), @@ -7525,6 +7540,7 @@ impl SourceLanguage { fn codegraph_name(self) -> &'static str { match self { + Self::C => "c", Self::JavaScript => "javascript", Self::Jsx => "jsx", Self::TypeScript | Self::Mts | Self::Cts => "typescript", @@ -7538,6 +7554,7 @@ impl SourceLanguage { fn tree_sitter_language(self) -> tree_sitter::Language { match self { + Self::C => tree_sitter_c::LANGUAGE.into(), Self::JavaScript | Self::Jsx => tree_sitter_javascript::LANGUAGE.into(), Self::TypeScript | Self::Mts | Self::Cts => { tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into() @@ -7558,6 +7575,10 @@ impl SourceLanguage { matches!(self, Self::Go) } + fn is_c(self) -> bool { + matches!(self, Self::C) + } + fn is_java(self) -> bool { matches!(self, Self::Java) } @@ -7571,6 +7592,36 @@ impl SourceLanguage { } } +fn is_non_c_header(path: &Path, source: &str) -> bool { + if path.extension().and_then(|ext| ext.to_str()) != Some("h") { + return false; + } + let sample = source.get(..source.len().min(8192)).unwrap_or(source); + looks_like_cpp_header(sample) || looks_like_objc_header(sample) +} + +fn looks_like_cpp_header(source: &str) -> bool { + const NEEDLES: [&str; 7] = [ + "namespace ", + "template <", + "template<", + "class ", + "public:", + "private:", + "protected:", + ]; + NEEDLES.iter().any(|needle| source.contains(needle)) + || source.contains(" virtual ") + || source.contains("using namespace ") +} + +fn looks_like_objc_header(source: &str) -> bool { + source.contains("@interface") + || source.contains("@protocol") + || source.contains("@class") + || source.contains("#import") +} + fn collect_supported_files(project_path: &Path) -> io::Result> { fn walk(dir: &Path, out: &mut Vec) -> io::Result<()> { for entry in fs::read_dir(dir)? { @@ -8543,6 +8594,320 @@ fn go_type_name(node: SyntaxNode, source: &[u8]) -> Option { } } +fn extract_c_symbols( + root: SyntaxNode, + source: &[u8], + relative_path: &str, + file_node_id: &str, + nodes: &mut Vec, + edges: &mut Vec, + unresolved_refs: &mut Vec, +) -> Result<(), Box> { + let mut cursor = root.walk(); + visit_c_node( + &mut cursor, + source, + relative_path, + file_node_id, + file_node_id, + nodes, + edges, + unresolved_refs, + )?; + Ok(()) +} + +fn visit_c_node( + cursor: &mut TreeCursor, + source: &[u8], + relative_path: &str, + file_node_id: &str, + current_from_node_id: &str, + nodes: &mut Vec, + edges: &mut Vec, + unresolved_refs: &mut Vec, +) -> Result<(), Box> { + let node = cursor.node(); + let mut child_from_node_id: Cow<'_, str> = Cow::Borrowed(current_from_node_id); + + if let Some((kind, name, name_node)) = extract_c_named_symbol(node, source)? { + let extracted = ExtractedNode::symbol(relative_path, kind, &name, node, "c"); + let extracted_id = extracted.id.clone(); + let contains_source = if current_from_node_id != file_node_id { + current_from_node_id + } else { + file_node_id + }; + edges.push(ExtractedEdge { + source: contains_source.to_string(), + target: extracted_id.clone(), + kind: "contains".to_string(), + line: extracted.start_line, + col: extracted.start_column, + }); + nodes.push(extracted); + if matches!(kind, "function" | "struct" | "enum") { + child_from_node_id = Cow::Owned(extracted_id); + } + + if matches!(kind, "enum_member") || name_node.kind() == "field_identifier" { + return Ok(()); + } + } + + extract_c_include( + node, + source, + relative_path, + current_from_node_id, + nodes, + edges, + unresolved_refs, + )?; + extract_c_statement_refs( + node, + source, + relative_path, + current_from_node_id, + unresolved_refs, + )?; + + if cursor.goto_first_child() { + loop { + visit_c_node( + cursor, + source, + relative_path, + file_node_id, + &child_from_node_id, + nodes, + edges, + unresolved_refs, + )?; + if !cursor.goto_next_sibling() { + break; + } + } + cursor.goto_parent(); + } + + Ok(()) +} + +fn extract_c_include( + node: SyntaxNode, + source: &[u8], + relative_path: &str, + from_node_id: &str, + nodes: &mut Vec, + edges: &mut Vec, + unresolved_refs: &mut Vec, +) -> Result<(), Box> { + if node.kind() != "preproc_include" { + return Ok(()); + } + let Some(module_name) = c_include_name(node, source)? else { + return Ok(()); + }; + let import_node = ExtractedNode::symbol(relative_path, "import", &module_name, node, "c"); + let import_node_id = import_node.id.clone(); + edges.push(ExtractedEdge { + source: from_node_id.to_string(), + target: import_node_id, + kind: "contains".to_string(), + line: import_node.start_line, + col: import_node.start_column, + }); + nodes.push(import_node); + push_ref( + unresolved_refs, + from_node_id, + &module_name, + "imports", + node, + relative_path, + SourceLanguage::C, + ); + Ok(()) +} + +fn extract_c_statement_refs( + node: SyntaxNode, + source: &[u8], + relative_path: &str, + from_node_id: &str, + unresolved_refs: &mut Vec, +) -> Result<(), Box> { + if node.kind() != "call_expression" { + return Ok(()); + } + let Some(target_node) = node.child_by_field_name("function") else { + return Ok(()); + }; + let Some(reference_name) = c_call_reference_name(target_node, source)? else { + return Ok(()); + }; + push_ref( + unresolved_refs, + from_node_id, + &reference_name, + "calls", + target_node, + relative_path, + SourceLanguage::C, + ); + Ok(()) +} + +fn extract_c_named_symbol<'a>( + node: SyntaxNode<'a>, + source: &[u8], +) -> Result)>, Box> { + match node.kind() { + "function_definition" => { + if let Some(name_node) = node + .child_by_field_name("declarator") + .and_then(c_declarator_name_node) + { + return Ok(Some(( + "function", + name_node.utf8_text(source)?.to_string(), + name_node, + ))); + } + } + "struct_specifier" => { + if let Some(name_node) = node.child_by_field_name("name") { + return Ok(Some(( + "struct", + name_node.utf8_text(source)?.to_string(), + name_node, + ))); + } + } + "enum_specifier" => { + if let Some(name_node) = node.child_by_field_name("name") { + return Ok(Some(( + "enum", + name_node.utf8_text(source)?.to_string(), + name_node, + ))); + } + } + "enumerator" => { + if let Some(name_node) = node + .child_by_field_name("name") + .or_else(|| first_named_child_of_kind(node, "identifier")) + { + return Ok(Some(( + "enum_member", + name_node.utf8_text(source)?.to_string(), + name_node, + ))); + } + } + "type_definition" => { + if let Some(name_node) = node + .child_by_field_name("declarator") + .and_then(c_declarator_name_node) + { + let kind = c_typedef_kind(node); + return Ok(Some(( + kind, + name_node.utf8_text(source)?.to_string(), + name_node, + ))); + } + } + "declaration" => { + if let Some(name_node) = node + .child_by_field_name("declarator") + .and_then(c_declarator_name_node) + { + return Ok(Some(( + "variable", + name_node.utf8_text(source)?.to_string(), + name_node, + ))); + } + } + _ => {} + } + Ok(None) +} + +fn c_typedef_kind(node: SyntaxNode) -> &'static str { + for child in node.named_children(&mut node.walk()) { + if child.kind() == "struct_specifier" && child.child_by_field_name("body").is_some() { + return "struct"; + } + if child.kind() == "enum_specifier" && child.child_by_field_name("body").is_some() { + return "enum"; + } + } + "type_alias" +} + +fn c_declarator_name_node(node: SyntaxNode) -> Option { + if matches!( + node.kind(), + "identifier" | "field_identifier" | "type_identifier" + ) { + return Some(node); + } + if matches!(node.kind(), "parameter_list" | "argument_list") { + return None; + } + let mut last = None; + for child in node.named_children(&mut node.walk()) { + if let Some(candidate) = c_declarator_name_node(child) { + last = Some(candidate); + } + } + last +} + +fn c_include_name( + node: SyntaxNode, + source: &[u8], +) -> Result, Box> { + for child in node.named_children(&mut node.walk()) { + if matches!(child.kind(), "system_lib_string" | "string_literal") { + let raw = child.utf8_text(source)?.trim(); + return Ok(Some( + raw.trim_start_matches(['<', '"']) + .trim_end_matches(['>', '"']) + .to_string(), + )); + } + } + Ok(None) +} + +fn c_call_reference_name( + node: SyntaxNode, + source: &[u8], +) -> Result, Box> { + match node.kind() { + "identifier" | "field_identifier" => Ok(Some(node.utf8_text(source)?.to_string())), + "field_expression" => { + let field = node + .child_by_field_name("field") + .or_else(|| first_named_child_of_kind(node, "field_identifier")); + Ok(field.and_then(|child| child.utf8_text(source).ok().map(ToString::to_string))) + } + "parenthesized_expression" | "pointer_expression" => { + for child in node.named_children(&mut node.walk()) { + if let Some(name) = c_call_reference_name(child, source)? { + return Ok(Some(name)); + } + } + Ok(None) + } + _ => Ok(None), + } +} + fn extract_java_symbols( root: SyntaxNode, source: &[u8], diff --git a/docs/benchmarks/2026-07-03-rust-owned-c-cjson-validation.md b/docs/benchmarks/2026-07-03-rust-owned-c-cjson-validation.md new file mode 100644 index 0000000..bb5706d --- /dev/null +++ b/docs/benchmarks/2026-07-03-rust-owned-c-cjson-validation.md @@ -0,0 +1,52 @@ +# Rust-Owned C Corpus Validation + +Date: 2026-07-03 + +## Corpus + +- Repository: `DaveGamble/cJSON` +- Checkout: `fb16e5c` +- Local path during validation: `/private/tmp/zcodegraph-corpus-cjson` +- Rationale: suitably sized real C project with C source files, headers, tests, + and a small amount of non-C project metadata. + +## Command + +```bash +CODEGRAPH_ALLOW_UNSAFE_NODE=1 \ +CODEGRAPH_NO_DAEMON=1 \ +CODEGRAPH_NO_RELAUNCH=1 \ +ZCODEGRAPH_RUST_CORE_BINARY=/Users/bilibili/Documents/workspace/github/jununfly/ZCodeGraph/target/debug/zcodegraph-core \ +node /Users/bilibili/Documents/workspace/github/jununfly/ZCodeGraph/dist/bin/zcodegraph.js index \ + /private/tmp/zcodegraph-corpus-cjson \ + --force \ + --quiet +``` + +## Result + +- Repository files: 149 +- C/header files: 53 +- Indexed files: 121 +- Nodes: 3,581 +- Edges: 7,000 +- Languages: `c`, `python`, `ruby`, `yaml` +- Rust-owned languages included `c`. +- `engineByLanguage.c` was `rust`. +- `engineByFileCount.rust` was 55. +- Rust-owned C parse/extraction gap diagnostics: 0. +- Fallback files: 20, all from non-C languages: + - `yaml`: 7 + - `ruby`: 13 + +## Boundary Note + +`libuv/libuv` was also tried as a larger C corpus. It indexed successfully, but +its macro-heavy platform headers produced many tree-sitter parse diagnostics, so +it is a better future stress corpus than a first baseline migration gate. + +## Decision + +The C baseline migration gate passes for this corpus. C source and header files +are owned by the Rust indexer; remaining fallback evidence belongs to non-C +languages outside this PR's C baseline extraction scope. diff --git a/docs/prds/2026-07-03-rust-owned-migration-roadmap.md b/docs/prds/2026-07-03-rust-owned-migration-roadmap.md index 0b3a764..59e8c9a 100644 --- a/docs/prds/2026-07-03-rust-owned-migration-roadmap.md +++ b/docs/prds/2026-07-03-rust-owned-migration-roadmap.md @@ -65,7 +65,7 @@ and should be migrated language by language: - [x] Java: classes, interfaces, annotations, enums, imports, method calls, package declarations, Spring/Play boundary decision. -- [ ] C: functions, structs, enums, typedefs, includes, calls, header +- [x] C: functions, structs, enums, typedefs, includes, calls, header classification boundary. - [ ] C++: functions, classes, structs, enums, typedefs/aliases, includes, calls, namespace/member boundary. diff --git a/src/extraction/languages/c-cpp.ts b/src/extraction/languages/c-cpp.ts index 5d13ddc..c504f7b 100644 --- a/src/extraction/languages/c-cpp.ts +++ b/src/extraction/languages/c-cpp.ts @@ -45,51 +45,6 @@ function extractCppReceiverType(node: SyntaxNode, source: string): string | unde return parts.length > 1 ? parts.slice(0, -1).join('::') : undefined; } -export const cExtractor: LanguageExtractor = { - functionTypes: ['function_definition'], - classTypes: [], - methodTypes: [], - interfaceTypes: [], - structTypes: ['struct_specifier'], - enumTypes: ['enum_specifier'], - enumMemberTypes: ['enumerator'], - typeAliasTypes: ['type_definition'], // typedef - importTypes: ['preproc_include'], - callTypes: ['call_expression'], - variableTypes: ['declaration'], - nameField: 'declarator', - bodyField: 'body', - paramsField: 'parameters', - resolveTypeAliasKind: (node, _source) => { - // C typedef: `typedef enum { ... } name;` or `typedef struct { ... } name;` - // The inner enum_specifier/struct_specifier is anonymous, but we want the typedef name - // to become the enum/struct node name. - for (let i = 0; i < node.namedChildCount; i++) { - const child = node.namedChild(i); - if (!child) continue; - if (child.type === 'enum_specifier' && getChildByField(child, 'body')) return 'enum'; - if (child.type === 'struct_specifier' && getChildByField(child, 'body')) return 'struct'; - } - return undefined; - }, - extractImport: (node, source) => { - const importText = source.substring(node.startIndex, node.endIndex).trim(); - // C includes: #include , #include "myheader.h" - const systemLib = node.namedChildren.find((c: SyntaxNode) => c.type === 'system_lib_string'); - if (systemLib) { - return { moduleName: getNodeText(systemLib, source).replace(/^<|>$/g, ''), signature: importText }; - } - const stringLiteral = node.namedChildren.find((c: SyntaxNode) => c.type === 'string_literal'); - if (stringLiteral) { - const stringContent = stringLiteral.namedChildren.find((c: SyntaxNode) => c.type === 'string_content'); - if (stringContent) { - return { moduleName: getNodeText(stringContent, source), signature: importText }; - } - } - return null; - }, -}; - export const cppExtractor: LanguageExtractor = { functionTypes: ['function_definition'], classTypes: ['class_specifier'], diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index cea7e98..64024cb 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -13,7 +13,7 @@ import { javascriptExtractor } from './javascript'; import { pythonExtractor } from './python'; import { goExtractor } from './go'; import { rustExtractor } from './rust'; -import { cExtractor, cppExtractor } from './c-cpp'; +import { cppExtractor } from './c-cpp'; import { csharpExtractor } from './csharp'; import { phpExtractor } from './php'; import { rubyExtractor } from './ruby'; @@ -34,7 +34,6 @@ export const EXTRACTORS: Partial> = { python: pythonExtractor, go: goExtractor, rust: rustExtractor, - c: cExtractor, cpp: cppExtractor, csharp: csharpExtractor, php: phpExtractor, diff --git a/src/indexing/rust-hybrid-contract.ts b/src/indexing/rust-hybrid-contract.ts index 12b1d16..e74f3df 100644 --- a/src/indexing/rust-hybrid-contract.ts +++ b/src/indexing/rust-hybrid-contract.ts @@ -1,9 +1,12 @@ import { detectLanguage, isLanguageSupported } from '../extraction/grammars'; import { isGeneratedFile } from '../extraction/generated-detection'; import { scanDirectory } from '../extraction'; +import type { Language } from '../types'; +import * as fs from 'fs'; +import * as path from 'path'; export const RUST_HYBRID_PHASE = 'phase-6-rust-owned-per-file-gap-fallback'; -export const RUST_HYBRID_RUST_OWNED_LANGUAGES = ['javascript', 'jsx', 'typescript', 'tsx', 'go', 'java', 'python', 'rust'] as const; +export const RUST_HYBRID_RUST_OWNED_LANGUAGES = ['javascript', 'jsx', 'typescript', 'tsx', 'go', 'java', 'python', 'rust', 'c'] as const; export type RustHybridFallbackState = 'healthy' | 'degraded' | 'pending'; export type RustOwnedGapCode = | 'rust-owned-parse-gap' @@ -199,7 +202,7 @@ export function planRustHybridAssignments(projectPath: string): RustHybridAssign const skippedGeneratedByLanguage: Record = {}; for (const filePath of scanDirectory(projectPath)) { - const language = detectLanguage(filePath); + const language = detectLanguageForRustHybridPlan(projectPath, filePath); if (isGeneratedFile(filePath)) { if (isLanguageSupported(language)) { skippedGeneratedByLanguage[language] = (skippedGeneratedByLanguage[language] ?? 0) + 1; @@ -238,3 +241,14 @@ export function planRustHybridAssignments(projectPath: string): RustHybridAssign pendingFallbacks: ['rust-owned-parse-gap'], }; } + +function detectLanguageForRustHybridPlan(projectPath: string, filePath: string): Language { + if (!filePath.toLowerCase().endsWith('.h')) { + return detectLanguage(filePath); + } + try { + return detectLanguage(filePath, fs.readFileSync(path.join(projectPath, filePath), 'utf-8')); + } catch { + return detectLanguage(filePath); + } +} diff --git a/zj-loop/STATE.md b/zj-loop/STATE.md new file mode 100644 index 0000000..1168b1e --- /dev/null +++ b/zj-loop/STATE.md @@ -0,0 +1,12 @@ +# Loop State — My Project + +Last run: never + +## High Priority (loop is acting or waiting on human) + +## Watch List + +## Recent Noise (ignored this run) + +--- +Run log: — \ No newline at end of file diff --git a/zj-loop/ZJ-LOOP.md b/zj-loop/ZJ-LOOP.md new file mode 100644 index 0000000..356b5a5 --- /dev/null +++ b/zj-loop/ZJ-LOOP.md @@ -0,0 +1,42 @@ +# Loop Configuration — Roadmap-Sliced Development + +## Active Loops + +| Pattern | Cadence | Status | Command | +|---------|---------|--------|---------| +| Roadmap-Sliced Development | Human-pulled, usually one slice per active day | L2 guided implementation | See README | + +## Branch Policy + +- Use `zjal/` for non-trivial initiatives. +- Keep one bounded roadmap branch per initiative by default. +- Split PRs by parent node when one PR stops being reviewable. + +## Human Gates + +- Naming or public terminology changes +- Scope expansion into follow-up work +- Release boundary, package identity, or public URL changes +- Branch merge approval +- Process roadmap deletion or durable retention choice + +## Commit Contract + +- Every leaf has lightweight commit intent before implementation. +- Leaf status, notes, and verification evidence are updated before commit. +- `completed` means the gate passed or a decision-only gate was satisfied. +- Closeout is a separate commit from the final feature slice. + +## PR Handoff + +After the closeout commit, continue automatically to PR handoff: + +- confirm the branch is clean +- push the roadmap branch +- open or update the PR with verification notes, closeout status, durable docs, and post-merge branch cleanup plan + +Stopping before PR handoff is valid only for an explicit Human Gate pause or an external blocker with the exact next command recorded. + +## Links + +- Pattern: [roadmap-sliced-development](../../patterns/roadmap-sliced-development.md) diff --git a/zj-loop/roadmap-sliced-state.md b/zj-loop/roadmap-sliced-state.md new file mode 100644 index 0000000..b651f3c --- /dev/null +++ b/zj-loop/roadmap-sliced-state.md @@ -0,0 +1,49 @@ +# Roadmap-Sliced Initiative State + +Roadmap id: `rust-owned-c-baseline` +Branch: `zjal/rust-owned-c-baseline` +Issue / tracker: +PR: +Last updated: 2026-07-03 + +## Goal + +Migrate C baseline extraction from TypeScript-owned fallback to Rust-owned indexing, validate on a suitably sized GitHub C project, and remove the migrated TypeScript C extraction path after parity is proven. + +## Current Focus + +- Parent node: `C Rust-owned baseline extraction` +- Leaf node: `C language source registration and baseline symbol parity` +- Mode: exploit +- Commit intent: Add Rust-owned C parser/extractor coverage for functions, structs, enums, typedefs, includes, calls, and header classification boundary with fixture plus real-corpus validation. + +## Decisions + +| Node | Decision | Durable location | +|------|----------|------------------| +| C baseline ownership | C source files move to Rust-owned per-file indexing; C/C++ header ambiguity remains a documented classification boundary rather than a framework/runtime ownership claim. | `docs/prds/2026-07-03-rust-owned-migration-roadmap.md` and `docs/benchmarks/2026-07-03-rust-owned-c-cjson-validation.md` | +| C corpus selection | `DaveGamble/cJSON` is the passing baseline validation corpus; `libuv/libuv` is deferred as a future macro-heavy stress corpus. | `docs/benchmarks/2026-07-03-rust-owned-c-cjson-validation.md` | + +## Verification Evidence + +| Leaf | Gate | Result | Notes | +|------|------|--------|-------| +| C Rust-owned fixture | `npx vitest run __tests__/rust-index-engine-cli-language-smoke.test.ts __tests__/rust-index-engine-cli-fallback.test.ts __tests__/rust-index-engine-cli-engine.test.ts` | Passed | 54 tests passed. | +| C++/ObjC TS extraction guard | `npx vitest run __tests__/extraction.test.ts -t "C/C\\+\\+ imports|Objective-C|detect language"` | Passed | 15 focused tests passed. | +| Real GitHub C corpus | `DaveGamble/cJSON` at `fb16e5c` | Passed | 121 indexed files; 3,581 nodes; 7,000 edges; `engineByLanguage.c = rust`; no Rust-owned C gap diagnostics. | + +## Human Gates + +| Gate | Decision | Scope / expiry | +|------|----------|----------------| +| Process scaffold retention | Delete accidental `.grok/`; retain `.codex/` and `zj-loop/` loop scaffolds. | Applies to this branch closeout. | + +## Closeout Checklist + +- [x] All leaf nodes completed, deferred with follow-up, or explicitly won't do. +- [x] Durable decisions moved into docs, ADRs, README, or pattern docs. +- [x] Process roadmap files deleted or promoted into durable docs. +- [x] Closeout commit created separately from the final feature slice. +- [ ] Branch clean. +- [ ] Branch pushed. +- [ ] PR opened or updated with verification notes and branch cleanup plan. diff --git a/zj-loop/rust-owned-c-baseline-roadmap.json b/zj-loop/rust-owned-c-baseline-roadmap.json new file mode 100644 index 0000000..f8ed121 --- /dev/null +++ b/zj-loop/rust-owned-c-baseline-roadmap.json @@ -0,0 +1,95 @@ +{ + "title": "Rust-owned C baseline extraction", + "description": "Migrate C language baseline extraction to Rust-owned indexing and validate on real C corpus.", + "version": 1, + "nodes": { + "1": { + "id": "1", + "label": "Rust-owned C baseline extraction", + "status": "completed", + "mode": "explore", + "parent": null, + "children": [ + "1-1", + "1-2", + "1-3", + "1-4", + "1-5" + ], + "decisions": [], + "notes": "" + }, + "1-1": { + "id": "1-1", + "label": "Rust-owned C source registration and parser setup", + "status": "completed", + "mode": "exploit", + "parent": "1", + "children": [], + "decisions": [ + { + "q": "C migration boundary", + "answer": "Migrate .c baseline extraction to Rust-owned indexing now; keep .h classification as an explicit boundary and do not claim C++ or Objective-C ownership in this slice.", + "note": "The PRD calls out header classification boundary separately, and C++ has its own checklist item." + } + ], + "notes": "Added tree-sitter-c dependency, SourceLanguage::C, Rust dispatcher, C AST extractor, and header non-C guard; cargo build passes." + }, + "1-2": { + "id": "1-2", + "label": "C baseline symbol and reference extraction parity", + "status": "completed", + "mode": "exploit", + "parent": "1", + "children": [], + "decisions": [], + "notes": "C fixture validates functions, structs, enums, enum members, typedefs, includes, calls, and no rust-hybrid fallback." + }, + "1-3": { + "id": "1-3", + "label": "C fixture tests and rust-hybrid metadata coverage", + "status": "completed", + "mode": "exploit", + "parent": "1", + "children": [], + "decisions": [], + "notes": "Added rust-hybrid planner header classification test and updated status metadata expectation; target vitest suite passed 54 tests." + }, + "1-4": { + "id": "1-4", + "label": "GitHub C corpus validation evidence", + "status": "completed", + "mode": "exploit", + "parent": "1", + "children": [], + "decisions": [ + { + "q": "GitHub C validation corpus", + "answer": "Use libuv/libuv as the real-corpus validation target.", + "note": "It is a mature, medium-sized C project with many .c and .h files, enough to exercise Rust-owned C extraction and header classification without becoming a huge benchmark." + }, + { + "q": "Validation corpus adjustment", + "answer": "Use DaveGamble/cJSON as the passing baseline corpus; keep libuv/libuv as a future C stress corpus.", + "note": "libuv indexed successfully but produced many macro/header parse diagnostics, while cJSON validates the baseline migration without Rust-owned C gaps." + } + ], + "notes": "Validated on DaveGamble/cJSON fb16e5c: 149 repo files, 53 C/header files, 121 indexed files, 3,581 nodes, 7,000 edges; C engineByLanguage is rust; no Rust-owned C gap diagnostics. libuv was recorded as future stress corpus due macro-heavy parse diagnostics." + }, + "1-5": { + "id": "1-5", + "label": "Remove migrated TypeScript C extraction path", + "status": "completed", + "mode": "exploit", + "parent": "1", + "children": [], + "decisions": [], + "notes": "Removed cExtractor from TypeScript extractor registry and deleted the TS C extractor implementation; C++ TS extractor remains registered." + } + }, + "metadata": { + "created": "2026-07-03 17:20:45", + "updated": "2026-07-03 17:35:18", + "md_file": "zj-loop/rust-owned-c-baseline-roadmap.md" + } +} \ No newline at end of file diff --git a/zj-loop/rust-owned-c-baseline-roadmap.md b/zj-loop/rust-owned-c-baseline-roadmap.md new file mode 100644 index 0000000..84de29b --- /dev/null +++ b/zj-loop/rust-owned-c-baseline-roadmap.md @@ -0,0 +1,12 @@ + +## ZJ Roadmap + +> 数据文件: `rust-owned-c-baseline-roadmap.json` | 最后更新: 2026-07-03 17:35:18 + +[x][X+] 1. Rust-owned C baseline extraction +├── [x][Y+] 1-1. Rust-owned C source registration and parser setup +├── [x][Y+] 1-2. C baseline symbol and reference extraction parity +├── [x][Y+] 1-3. C fixture tests and rust-hybrid metadata coverage +├── [x][Y+] 1-4. GitHub C corpus validation evidence +└── [x][Y+] 1-5. Remove migrated TypeScript C extraction path + diff --git a/zj-loop/zj-loop-budget.md b/zj-loop/zj-loop-budget.md new file mode 100644 index 0000000..15f059b --- /dev/null +++ b/zj-loop/zj-loop-budget.md @@ -0,0 +1,26 @@ +# Loop Budget — YOUR_PROJECT + +> Primary loop: **Daily Triage** (scaffolded by zj-loop-init) + +## Daily limits + +| Loop | Max runs/day | Max tokens/day | Max sub-agent spawns/run | +|------|--------------|----------------|--------------------------| +| Daily Triage | 2 | 100k | 0 (L1) / 2 (L2) | + +## On budget exceed + +1. Pause schedulers (`scheduler_delete` or disable automations) +2. Append event to `zj-loop/zj-loop-run-log.md` +3. Notify human (Slack / issue / zj-loop/STATE.md High Priority) + +## Kill switch + +- Command or issue label: `loop-pause-all` +- Resume only after human clears the flag in zj-loop/STATE.md + +## Estimate spend + +```bash +npx @jununfly/zj-loop-cost --pattern daily-triage +``` diff --git a/zj-loop/zj-loop-constraints.md b/zj-loop/zj-loop-constraints.md new file mode 100644 index 0000000..4affd42 --- /dev/null +++ b/zj-loop/zj-loop-constraints.md @@ -0,0 +1,31 @@ +# Loop Constraints + +> Add rules below with `/constraints ` in your agent. +> The `zj-loop-constraints` skill reads this file at the start of every run. +> Constraints here are **binding** — the agent MUST follow them. + +## Push & Merge +- Don't push before telling me +- Never auto-merge to main without human approval +- Always create a draft PR first; let me review before marking ready + +## Paths +- Never edit .env, .env.*, auth/, payments/, secrets/, credentials/ +- Never edit infrastructure configs without human approval + +## Code +- Always run tests before proposing a fix +- Never disable tests to make CI green +- Never refactor unrelated code — one fix per run +- Max 3 fix attempts per item; escalate after + +## Communication +- Always tell me what you're about to do before doing it +- Never close an issue or PR without my approval + +## Budget +- If token spend hits 80% of daily cap, switch to report-only +- If loop-pause-all is active, exit immediately + +--- + diff --git a/zj-loop/zj-loop-run-log.md b/zj-loop/zj-loop-run-log.md new file mode 100644 index 0000000..b35f8d5 --- /dev/null +++ b/zj-loop/zj-loop-run-log.md @@ -0,0 +1,22 @@ +# Loop Run Log — YOUR_PROJECT + +Append one entry per run. Prune entries older than 30 days. + +## Format + +```json +{ + "run_id": "2026-06-09T08:15:00Z", + "pattern": "daily-triage", + "duration_s": 45, + "items_found": 4, + "actions_taken": 1, + "escalations": 0, + "tokens_estimate": 52000, + "outcome": "report-only | fix-proposed | escalated | no-op" +} +``` + +## Recent Runs + + \ No newline at end of file