-
Notifications
You must be signed in to change notification settings - Fork 0
tools+hooks: preflight_drift (board-vs-cargo reconciliation) wired into SessionStart #406
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,17 +1,71 @@ | ||
| #!/usr/bin/env bash | ||
| # SessionStart hook — inject workspace bootload context at turn 0. | ||
| # SessionStart hook — inject workspace bootload context + preflight drift signal. | ||
| # Emits JSON on stdout; Claude consumes hookSpecificOutput.additionalContext | ||
| # as a system reminder in the first model turn. | ||
| # | ||
| # Wired from: .claude/settings.json | ||
| # Documented by: .claude/skills/cca2a/SKILL.md | ||
| # Drift tool: .claude/tools/preflight_drift.rs (compile once with rustc to enable) | ||
| set -euo pipefail | ||
|
|
||
| cat <<'JSON' | ||
| { | ||
| "hookSpecificOutput": { | ||
| "hookEventName": "SessionStart", | ||
| "additionalContext": "WORKSPACE BOOTLOAD (lance-graph, via cca2a pattern):\n\n## Mandatory reads this session (in order)\n\n1. .claude/board/LATEST_STATE.md — current contract inventory, recently shipped PRs, queued work, explicit deferrals. What exists.\n2. .claude/board/PR_ARC_INVENTORY.md — APPEND-ONLY decision history per PR (Added / Locked / Deferred / Docs + mutable Confidence line). Why it exists.\n3. .claude/agents/BOOT.md — 19 specialist + 5 meta-agent ensemble, Knowledge Activation trigger table, Handover Protocol. How to coordinate.\n\nDo NOT propose any new type, module, or convention without grepping LATEST_STATE first.\n\n## Governance (never violate)\n\n- APPEND-ONLY: LATEST_STATE + PR_ARC_INVENTORY Edit prompts for approval; Write to append stays unprompted. Only the Confidence line per PR entry is updatable; corrections append as dated lines; reversals get their own PR entry.\n- Model policy: main thread Opus + deep thinking; subagent grindwork (single-source mechanical) → Sonnet; accumulation (multi-source synthesis) → Opus; NEVER Haiku.\n- GitHub reads: zipball to /tmp/sources/ + local grep for 3+ reads per external repo. MCP only for writes (PR, comments, reviews) and single-path reads.\n- Contract zero-dep invariant: lance-graph-contract has no external crate deps.\n- Read before Write: always Read a file before overwriting.\n- No JSON serialization in runtime types (serde is debug-only).\n\n## A2A two layers\n\n- Layer 1 (runtime): contract::a2a_blackboard. Cognitive-cycle bus.\n- Layer 2 (session): knowledge docs + .claude/handovers/*.md. Parallel subagent spawns in one main-thread turn is cheapest.\n\n## Pattern explained\n\n.claude/skills/cca2a/SKILL.md — read once to grok the pattern, skip re-deriving.\n\n## Full spec\n\nCLAUDE.md at workspace root." | ||
| } | ||
| } | ||
| JSON | ||
| REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" | ||
| DRIFT_TOOL="$REPO_ROOT/.claude/tools/preflight_drift" | ||
| DRIFT_SRC="$REPO_ROOT/.claude/tools/preflight_drift.rs" | ||
|
|
||
| # Optional drift signal. Compiled binary at $DRIFT_TOOL → run it. Missing binary | ||
| # but source present → emit a build hint. Neither → silent. Never blocks. | ||
| DRIFT_BLOCK="" | ||
| if [[ -x "$DRIFT_TOOL" ]]; then | ||
| if ! DRIFT_OUT="$(cd "$REPO_ROOT" && "$DRIFT_TOOL" 2>&1)"; then | ||
| DRIFT_BLOCK=$'\n\n## ⚠ Board drift detected (preflight_drift exit 1)\n\n```\n'"$DRIFT_OUT"$'\n```\n\nThe board claims do not match Cargo.toml workspace state. Before any new sprint spawn, update `CLAUDE.md` / `.claude/board/LATEST_STATE.md` to match real counts, OR document why the drift is intentional. The Mandatory Board-Hygiene Rule in CLAUDE.md applies.' | ||
| fi | ||
| elif [[ -f "$DRIFT_SRC" ]]; then | ||
| DRIFT_BLOCK=$'\n\n## ℹ preflight_drift not compiled\n\nBuild once to enable board-vs-cargo drift signal at session start:\n\n```\nrustc '"$DRIFT_SRC"$' -o '"$DRIFT_TOOL"$'\n```' | ||
| fi | ||
|
|
||
| BASE_CONTEXT='WORKSPACE BOOTLOAD (lance-graph, via cca2a pattern): | ||
|
|
||
| ## Mandatory reads this session (in order) | ||
|
|
||
| 1. .claude/board/LATEST_STATE.md — current contract inventory, recently shipped PRs, queued work, explicit deferrals. What exists. | ||
| 2. .claude/board/PR_ARC_INVENTORY.md — APPEND-ONLY decision history per PR (Added / Locked / Deferred / Docs + mutable Confidence line). Why it exists. | ||
| 3. .claude/agents/BOOT.md — 19 specialist + 5 meta-agent ensemble, Knowledge Activation trigger table, Handover Protocol. How to coordinate. | ||
|
|
||
| Do NOT propose any new type, module, or convention without grepping LATEST_STATE first. | ||
|
|
||
| ## Governance (never violate) | ||
|
|
||
| - APPEND-ONLY: LATEST_STATE + PR_ARC_INVENTORY Edit prompts for approval; Write to append stays unprompted. Only the Confidence line per PR entry is updatable; corrections append as dated lines; reversals get their own PR entry. | ||
| - Model policy: main thread Opus + deep thinking; subagent grindwork (single-source mechanical) → Sonnet; accumulation (multi-source synthesis) → Opus; NEVER Haiku. | ||
| - GitHub reads: zipball to /tmp/sources/ + local grep for 3+ reads per external repo. MCP only for writes (PR, comments, reviews) and single-path reads. | ||
| - Contract zero-dep invariant: lance-graph-contract has no external crate deps. | ||
| - Read before Write: always Read a file before overwriting. | ||
| - No JSON serialization in runtime types (serde is debug-only). | ||
|
|
||
| ## A2A two layers | ||
|
|
||
| - Layer 1 (runtime): contract::a2a_blackboard. Cognitive-cycle bus. | ||
| - Layer 2 (session): knowledge docs + .claude/handovers/*.md. Parallel subagent spawns in one main-thread turn is cheapest. | ||
|
|
||
| ## Pattern explained | ||
|
|
||
| .claude/skills/cca2a/SKILL.md — read once to grok the pattern, skip re-deriving. | ||
|
|
||
| ## Full spec | ||
|
|
||
| CLAUDE.md at workspace root.' | ||
|
|
||
| # Compose final additionalContext (base + optional drift block) and JSON-encode | ||
| # via python3 (avoids hand-rolled escaping). python3 is a documented prereq for | ||
| # this workspace's harvest tooling (cf. WoA/.claude/v0.2/tools/*.py). | ||
| FULL_CONTEXT="${BASE_CONTEXT}${DRIFT_BLOCK}" | ||
|
|
||
| python3 - <<PY | ||
| import json | ||
| print(json.dumps({ | ||
| "hookSpecificOutput": { | ||
| "hookEventName": "SessionStart", | ||
| "additionalContext": $(python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' <<< "$FULL_CONTEXT") | ||
| } | ||
| })) | ||
| PY | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,274 @@ | ||
| //! preflight_drift — board-claim vs. cargo-reality reconciliation. | ||
| //! | ||
| //! Compares numeric claims in `.claude/board/LATEST_STATE.md` and `CLAUDE.md` | ||
| //! against actual workspace state (Cargo.toml members/exclude, PR table rows). | ||
| //! Refuses sprint spawn when drift exceeds the threshold. | ||
| //! | ||
| //! Port of `WoA/.claude/v0.2/tools/preflight_drift.py` (Python AST) to the | ||
| //! Rust ecosystem. Zero dependencies — stdlib only, single-file. Build with | ||
| //! rustc preflight_drift.rs -o preflight_drift | ||
| //! Run from repo root: | ||
| //! ./preflight_drift # default threshold = 2 | ||
| //! ./preflight_drift --threshold 0 # zero-drift mode | ||
| //! ./preflight_drift --metric crates # only check one metric | ||
| //! | ||
| //! Exit codes: | ||
| //! 0 no drift above threshold — sprint spawn OK | ||
| //! 1 drift above threshold — refuse to spawn | ||
| //! 2 parse error — warn, do not block | ||
|
|
||
| use std::env; | ||
| use std::fs; | ||
| use std::path::{Path, PathBuf}; | ||
| use std::process::ExitCode; | ||
|
|
||
| fn main() -> ExitCode { | ||
| let args: Vec<String> = env::args().collect(); | ||
| let mut threshold: i64 = 2; | ||
| let mut only: Option<String> = None; | ||
| let mut i = 1; | ||
| while i < args.len() { | ||
| match args[i].as_str() { | ||
| "--threshold" => { | ||
| threshold = args.get(i + 1).and_then(|s| s.parse().ok()).unwrap_or(2); | ||
| i += 2; | ||
| } | ||
| "--metric" => { | ||
| only = args.get(i + 1).cloned(); | ||
| i += 2; | ||
| } | ||
| "-h" | "--help" => { | ||
| eprintln!("{}", HELP); | ||
| return ExitCode::from(0); | ||
| } | ||
| _ => i += 1, | ||
| } | ||
| } | ||
|
|
||
| let repo = match find_repo_root() { | ||
| Some(p) => p, | ||
| None => { | ||
| eprintln!("ERR: no Cargo.toml found walking up from cwd"); | ||
| return ExitCode::from(2); | ||
| } | ||
| }; | ||
|
|
||
| let cargo_toml = repo.join("Cargo.toml"); | ||
| let claude_md = repo.join("CLAUDE.md"); | ||
| let latest_state = repo.join(".claude/board/LATEST_STATE.md"); | ||
|
|
||
| let real = match real_metrics(&cargo_toml) { | ||
| Ok(m) => m, | ||
| Err(e) => { | ||
| eprintln!("ERR: cannot read {}: {e}", cargo_toml.display()); | ||
| return ExitCode::from(2); | ||
| } | ||
| }; | ||
|
|
||
| let claimed = claimed_metrics(&claude_md, &latest_state); | ||
|
|
||
| let mut drift_rows: Vec<DriftRow> = Vec::new(); | ||
| for (key, real_val) in &real { | ||
| if let Some(only_key) = &only { | ||
| if only_key != key { | ||
| continue; | ||
| } | ||
| } | ||
| if let Some(claim_val) = claimed.get(key) { | ||
| let diff = (*real_val - *claim_val).abs(); | ||
| drift_rows.push(DriftRow { | ||
| key: key.clone(), | ||
| claimed: *claim_val, | ||
| real: *real_val, | ||
| drift: diff, | ||
| }); | ||
| } else { | ||
| drift_rows.push(DriftRow { | ||
| key: key.clone(), | ||
| claimed: -1, | ||
| real: *real_val, | ||
| drift: -1, // unknown — claim missing | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| print_report(&drift_rows, threshold); | ||
| if drift_rows.iter().any(|r| r.drift > threshold) { | ||
| ExitCode::from(1) | ||
| } else { | ||
| ExitCode::from(0) | ||
| } | ||
| } | ||
|
|
||
| const HELP: &str = "preflight_drift — board-claim vs. cargo-reality | ||
| --threshold N max acceptable drift per metric (default 2) | ||
| --metric KEY check only this metric (workspace_members|excluded|prs_in_table) | ||
| -h, --help this help"; | ||
|
|
||
| struct DriftRow { | ||
| key: String, | ||
| claimed: i64, | ||
| real: i64, | ||
| drift: i64, | ||
| } | ||
|
|
||
| fn find_repo_root() -> Option<PathBuf> { | ||
| let mut p = env::current_dir().ok()?; | ||
| loop { | ||
| if p.join("Cargo.toml").exists() { | ||
| return Some(p); | ||
| } | ||
| if !p.pop() { | ||
| return None; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| fn real_metrics(cargo_toml: &Path) -> std::io::Result<Vec<(String, i64)>> { | ||
| let text = fs::read_to_string(cargo_toml)?; | ||
| let members = count_array_entries(&text, "members"); | ||
| let excluded = count_array_entries(&text, "exclude"); | ||
| Ok(vec![ | ||
| ("workspace_members".into(), members), | ||
| ("excluded".into(), excluded), | ||
| ("workspace_total".into(), members + excluded), | ||
| ]) | ||
|
Comment on lines
+131
to
+135
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The CLI help advertises Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| // Counts non-comment quoted strings inside the `<name> = [ ... ]` block. | ||
| // Tolerant of trailing commas, mixed indentation, and `#`-prefixed comment | ||
| // lines. Stops at the matching ']'. | ||
| fn count_array_entries(toml: &str, name: &str) -> i64 { | ||
| let mut count: i64 = 0; | ||
| let mut in_block = false; | ||
| let mut depth: i32 = 0; | ||
| for raw in toml.lines() { | ||
| let line = raw.trim_start(); | ||
| if !in_block { | ||
| if line.starts_with(&format!("{name} =")) || line.starts_with(&format!("{name}=")) { | ||
| in_block = true; | ||
| depth = bracket_delta(line); | ||
| count += count_quoted(strip_comment(line)); | ||
| if depth == 0 && line.contains(']') { | ||
| in_block = false; | ||
| } | ||
| } | ||
| continue; | ||
| } | ||
| let body = strip_comment(line); | ||
| count += count_quoted(body); | ||
| depth += bracket_delta(body); | ||
| if depth <= 0 { | ||
| in_block = false; | ||
| } | ||
| } | ||
| count | ||
| } | ||
|
|
||
| fn strip_comment(line: &str) -> &str { | ||
| // Naive: split on first '#' outside quotes. Sufficient for our Cargo.toml | ||
| // shape (no `#` characters inside the quoted crate paths). | ||
| let mut in_quote = false; | ||
| for (i, c) in line.char_indices() { | ||
| match c { | ||
| '"' => in_quote = !in_quote, | ||
| '#' if !in_quote => return &line[..i], | ||
| _ => {} | ||
| } | ||
| } | ||
| line | ||
| } | ||
|
|
||
| fn count_quoted(line: &str) -> i64 { | ||
| // Even-numbered quote-count → quoted-string count is quotes/2. | ||
| let q = line.bytes().filter(|&b| b == b'"').count() as i64; | ||
| q / 2 | ||
| } | ||
|
|
||
| fn bracket_delta(line: &str) -> i32 { | ||
| let mut d = 0; | ||
| let mut in_quote = false; | ||
| for c in line.chars() { | ||
| match c { | ||
| '"' => in_quote = !in_quote, | ||
| '[' if !in_quote => d += 1, | ||
| ']' if !in_quote => d -= 1, | ||
| _ => {} | ||
| } | ||
| } | ||
| d | ||
| } | ||
|
|
||
| fn claimed_metrics( | ||
| claude_md: &Path, | ||
| latest_state: &Path, | ||
| ) -> std::collections::HashMap<String, i64> { | ||
| let mut out = std::collections::HashMap::new(); | ||
| if let Ok(text) = fs::read_to_string(claude_md) { | ||
| // CLAUDE.md canonical phrase: "N crates, M in workspace, K excluded". | ||
| // Match the first occurrence. | ||
| if let Some(caps) = find_crates_phrase(&text) { | ||
| out.insert("workspace_total".into(), caps.0); | ||
| out.insert("workspace_members".into(), caps.1); | ||
| out.insert("excluded".into(), caps.2); | ||
| } | ||
| } | ||
| if let Ok(text) = fs::read_to_string(latest_state) { | ||
| let prs = count_pr_rows(&text); | ||
| if prs > 0 { | ||
| out.insert("prs_in_table".into(), prs); | ||
| } | ||
| } | ||
| out | ||
| } | ||
|
|
||
| fn find_crates_phrase(text: &str) -> Option<(i64, i64, i64)> { | ||
| // Look for: "N crates, M in workspace, K excluded" in any line. | ||
| // Tolerant of whitespace and surrounding markdown. | ||
| for line in text.lines() { | ||
| let l = line.to_ascii_lowercase(); | ||
| if !l.contains("crates") || !l.contains("in workspace") || !l.contains("excluded") { | ||
| continue; | ||
| } | ||
| let nums: Vec<i64> = l | ||
| .split(|c: char| !c.is_ascii_digit()) | ||
| .filter_map(|s| s.parse().ok()) | ||
| .collect(); | ||
| if nums.len() >= 3 { | ||
| return Some((nums[0], nums[1], nums[2])); | ||
| } | ||
| } | ||
| None | ||
| } | ||
|
|
||
| fn count_pr_rows(text: &str) -> i64 { | ||
| // PR table rows in LATEST_STATE.md look like: | ||
| // | **#389** | 2026-05-16 | ... | ||
| // Count lines starting with `| **#` and a digit. | ||
| text.lines() | ||
| .filter(|l| { | ||
| let t = l.trim_start(); | ||
| t.starts_with("| **#") | ||
| && t.bytes() | ||
| .nth(5) | ||
| .map(|b| b.is_ascii_digit()) | ||
| .unwrap_or(false) | ||
| }) | ||
| .count() as i64 | ||
| } | ||
|
|
||
| fn print_report(rows: &[DriftRow], threshold: i64) { | ||
| let any_over = rows.iter().any(|r| r.drift > threshold); | ||
| let banner = if any_over { "DRIFT — refuse to spawn" } else { "OK — within threshold" }; | ||
| println!("preflight_drift: {banner} (threshold={threshold})"); | ||
| println!(" {:<22} {:>10} {:>10} {:>10}", "metric", "claimed", "real", "drift"); | ||
| for r in rows { | ||
| let claimed = if r.claimed < 0 { "—".to_string() } else { r.claimed.to_string() }; | ||
| let drift = if r.drift < 0 { "?".to_string() } else { r.drift.to_string() }; | ||
| let marker = if r.drift > threshold { " <— OVER" } else { "" }; | ||
| println!( | ||
| " {:<22} {:>10} {:>10} {:>10}{}", | ||
| r.key, claimed, r.real, drift, marker | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This branch treats any non-zero
preflight_driftexit as board drift and hardcodesexit 1in the message, but the tool defines exit code 2 for parse/read errors. When the binary fails for non-drift reasons (for example, transient read/parse failure), the session-start context incorrectly tells operators to edit board files instead of reporting a tool failure, which can send remediation in the wrong direction.Useful? React with 👍 / 👎.