Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 63 additions & 9 deletions .claude/hooks/session-start.sh
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.'
Comment on lines +19 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish parser failures from actual drift in hook output

This branch treats any non-zero preflight_drift exit as board drift and hardcodes exit 1 in 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 👍 / 👎.

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
274 changes: 274 additions & 0 deletions .claude/tools/preflight_drift.rs
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Implement advertised prs_in_table metric filtering

The CLI help advertises --metric prs_in_table, but this metric is never produced in real_metrics, and the drift loop iterates only real keys, so --metric prs_in_table yields an empty table and exits 0 even when drift exists. This is a silent false-negative path for users trying to validate that specific metric and should be fixed by either generating a real prs_in_table value or removing the advertised option.

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
);
}
}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,8 @@ crates/thinking-engine/data/Qwopus3.5-27B-v3-BF16-silu/token_embd_4096x4096.u8
# Sub-agent ephemeral worktrees
.claude/worktrees/

# Compiled preflight_drift binary (source stays committed; rustc-built locally)
.claude/tools/preflight_drift

# Test fixtures (public-domain German legal/medical/tax data)
.test/