Skip to content

Commit 02b066e

Browse files
authored
Merge pull request #406 from AdaWorldAPI/claude/check-att-agents-ousks
tools+hooks: preflight_drift (board-vs-cargo reconciliation) wired into SessionStart
2 parents 4902a78 + 0c170e2 commit 02b066e

3 files changed

Lines changed: 340 additions & 9 deletions

File tree

.claude/hooks/session-start.sh

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,71 @@
11
#!/usr/bin/env bash
2-
# SessionStart hook — inject workspace bootload context at turn 0.
2+
# SessionStart hook — inject workspace bootload context + preflight drift signal.
33
# Emits JSON on stdout; Claude consumes hookSpecificOutput.additionalContext
44
# as a system reminder in the first model turn.
55
#
66
# Wired from: .claude/settings.json
77
# Documented by: .claude/skills/cca2a/SKILL.md
8+
# Drift tool: .claude/tools/preflight_drift.rs (compile once with rustc to enable)
89
set -euo pipefail
910

10-
cat <<'JSON'
11-
{
12-
"hookSpecificOutput": {
13-
"hookEventName": "SessionStart",
14-
"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."
15-
}
16-
}
17-
JSON
11+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
12+
DRIFT_TOOL="$REPO_ROOT/.claude/tools/preflight_drift"
13+
DRIFT_SRC="$REPO_ROOT/.claude/tools/preflight_drift.rs"
14+
15+
# Optional drift signal. Compiled binary at $DRIFT_TOOL → run it. Missing binary
16+
# but source present → emit a build hint. Neither → silent. Never blocks.
17+
DRIFT_BLOCK=""
18+
if [[ -x "$DRIFT_TOOL" ]]; then
19+
if ! DRIFT_OUT="$(cd "$REPO_ROOT" && "$DRIFT_TOOL" 2>&1)"; then
20+
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.'
21+
fi
22+
elif [[ -f "$DRIFT_SRC" ]]; then
23+
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```'
24+
fi
25+
26+
BASE_CONTEXT='WORKSPACE BOOTLOAD (lance-graph, via cca2a pattern):
27+
28+
## Mandatory reads this session (in order)
29+
30+
1. .claude/board/LATEST_STATE.md — current contract inventory, recently shipped PRs, queued work, explicit deferrals. What exists.
31+
2. .claude/board/PR_ARC_INVENTORY.md — APPEND-ONLY decision history per PR (Added / Locked / Deferred / Docs + mutable Confidence line). Why it exists.
32+
3. .claude/agents/BOOT.md — 19 specialist + 5 meta-agent ensemble, Knowledge Activation trigger table, Handover Protocol. How to coordinate.
33+
34+
Do NOT propose any new type, module, or convention without grepping LATEST_STATE first.
35+
36+
## Governance (never violate)
37+
38+
- 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.
39+
- Model policy: main thread Opus + deep thinking; subagent grindwork (single-source mechanical) → Sonnet; accumulation (multi-source synthesis) → Opus; NEVER Haiku.
40+
- 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.
41+
- Contract zero-dep invariant: lance-graph-contract has no external crate deps.
42+
- Read before Write: always Read a file before overwriting.
43+
- No JSON serialization in runtime types (serde is debug-only).
44+
45+
## A2A two layers
46+
47+
- Layer 1 (runtime): contract::a2a_blackboard. Cognitive-cycle bus.
48+
- Layer 2 (session): knowledge docs + .claude/handovers/*.md. Parallel subagent spawns in one main-thread turn is cheapest.
49+
50+
## Pattern explained
51+
52+
.claude/skills/cca2a/SKILL.md — read once to grok the pattern, skip re-deriving.
53+
54+
## Full spec
55+
56+
CLAUDE.md at workspace root.'
57+
58+
# Compose final additionalContext (base + optional drift block) and JSON-encode
59+
# via python3 (avoids hand-rolled escaping). python3 is a documented prereq for
60+
# this workspace's harvest tooling (cf. WoA/.claude/v0.2/tools/*.py).
61+
FULL_CONTEXT="${BASE_CONTEXT}${DRIFT_BLOCK}"
62+
63+
python3 - <<PY
64+
import json
65+
print(json.dumps({
66+
"hookSpecificOutput": {
67+
"hookEventName": "SessionStart",
68+
"additionalContext": $(python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' <<< "$FULL_CONTEXT")
69+
}
70+
}))
71+
PY

.claude/tools/preflight_drift.rs

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
//! preflight_drift — board-claim vs. cargo-reality reconciliation.
2+
//!
3+
//! Compares numeric claims in `.claude/board/LATEST_STATE.md` and `CLAUDE.md`
4+
//! against actual workspace state (Cargo.toml members/exclude, PR table rows).
5+
//! Refuses sprint spawn when drift exceeds the threshold.
6+
//!
7+
//! Port of `WoA/.claude/v0.2/tools/preflight_drift.py` (Python AST) to the
8+
//! Rust ecosystem. Zero dependencies — stdlib only, single-file. Build with
9+
//! rustc preflight_drift.rs -o preflight_drift
10+
//! Run from repo root:
11+
//! ./preflight_drift # default threshold = 2
12+
//! ./preflight_drift --threshold 0 # zero-drift mode
13+
//! ./preflight_drift --metric crates # only check one metric
14+
//!
15+
//! Exit codes:
16+
//! 0 no drift above threshold — sprint spawn OK
17+
//! 1 drift above threshold — refuse to spawn
18+
//! 2 parse error — warn, do not block
19+
20+
use std::env;
21+
use std::fs;
22+
use std::path::{Path, PathBuf};
23+
use std::process::ExitCode;
24+
25+
fn main() -> ExitCode {
26+
let args: Vec<String> = env::args().collect();
27+
let mut threshold: i64 = 2;
28+
let mut only: Option<String> = None;
29+
let mut i = 1;
30+
while i < args.len() {
31+
match args[i].as_str() {
32+
"--threshold" => {
33+
threshold = args.get(i + 1).and_then(|s| s.parse().ok()).unwrap_or(2);
34+
i += 2;
35+
}
36+
"--metric" => {
37+
only = args.get(i + 1).cloned();
38+
i += 2;
39+
}
40+
"-h" | "--help" => {
41+
eprintln!("{}", HELP);
42+
return ExitCode::from(0);
43+
}
44+
_ => i += 1,
45+
}
46+
}
47+
48+
let repo = match find_repo_root() {
49+
Some(p) => p,
50+
None => {
51+
eprintln!("ERR: no Cargo.toml found walking up from cwd");
52+
return ExitCode::from(2);
53+
}
54+
};
55+
56+
let cargo_toml = repo.join("Cargo.toml");
57+
let claude_md = repo.join("CLAUDE.md");
58+
let latest_state = repo.join(".claude/board/LATEST_STATE.md");
59+
60+
let real = match real_metrics(&cargo_toml) {
61+
Ok(m) => m,
62+
Err(e) => {
63+
eprintln!("ERR: cannot read {}: {e}", cargo_toml.display());
64+
return ExitCode::from(2);
65+
}
66+
};
67+
68+
let claimed = claimed_metrics(&claude_md, &latest_state);
69+
70+
let mut drift_rows: Vec<DriftRow> = Vec::new();
71+
for (key, real_val) in &real {
72+
if let Some(only_key) = &only {
73+
if only_key != key {
74+
continue;
75+
}
76+
}
77+
if let Some(claim_val) = claimed.get(key) {
78+
let diff = (*real_val - *claim_val).abs();
79+
drift_rows.push(DriftRow {
80+
key: key.clone(),
81+
claimed: *claim_val,
82+
real: *real_val,
83+
drift: diff,
84+
});
85+
} else {
86+
drift_rows.push(DriftRow {
87+
key: key.clone(),
88+
claimed: -1,
89+
real: *real_val,
90+
drift: -1, // unknown — claim missing
91+
});
92+
}
93+
}
94+
95+
print_report(&drift_rows, threshold);
96+
if drift_rows.iter().any(|r| r.drift > threshold) {
97+
ExitCode::from(1)
98+
} else {
99+
ExitCode::from(0)
100+
}
101+
}
102+
103+
const HELP: &str = "preflight_drift — board-claim vs. cargo-reality
104+
--threshold N max acceptable drift per metric (default 2)
105+
--metric KEY check only this metric (workspace_members|excluded|prs_in_table)
106+
-h, --help this help";
107+
108+
struct DriftRow {
109+
key: String,
110+
claimed: i64,
111+
real: i64,
112+
drift: i64,
113+
}
114+
115+
fn find_repo_root() -> Option<PathBuf> {
116+
let mut p = env::current_dir().ok()?;
117+
loop {
118+
if p.join("Cargo.toml").exists() {
119+
return Some(p);
120+
}
121+
if !p.pop() {
122+
return None;
123+
}
124+
}
125+
}
126+
127+
fn real_metrics(cargo_toml: &Path) -> std::io::Result<Vec<(String, i64)>> {
128+
let text = fs::read_to_string(cargo_toml)?;
129+
let members = count_array_entries(&text, "members");
130+
let excluded = count_array_entries(&text, "exclude");
131+
Ok(vec![
132+
("workspace_members".into(), members),
133+
("excluded".into(), excluded),
134+
("workspace_total".into(), members + excluded),
135+
])
136+
}
137+
138+
// Counts non-comment quoted strings inside the `<name> = [ ... ]` block.
139+
// Tolerant of trailing commas, mixed indentation, and `#`-prefixed comment
140+
// lines. Stops at the matching ']'.
141+
fn count_array_entries(toml: &str, name: &str) -> i64 {
142+
let mut count: i64 = 0;
143+
let mut in_block = false;
144+
let mut depth: i32 = 0;
145+
for raw in toml.lines() {
146+
let line = raw.trim_start();
147+
if !in_block {
148+
if line.starts_with(&format!("{name} =")) || line.starts_with(&format!("{name}=")) {
149+
in_block = true;
150+
depth = bracket_delta(line);
151+
count += count_quoted(strip_comment(line));
152+
if depth == 0 && line.contains(']') {
153+
in_block = false;
154+
}
155+
}
156+
continue;
157+
}
158+
let body = strip_comment(line);
159+
count += count_quoted(body);
160+
depth += bracket_delta(body);
161+
if depth <= 0 {
162+
in_block = false;
163+
}
164+
}
165+
count
166+
}
167+
168+
fn strip_comment(line: &str) -> &str {
169+
// Naive: split on first '#' outside quotes. Sufficient for our Cargo.toml
170+
// shape (no `#` characters inside the quoted crate paths).
171+
let mut in_quote = false;
172+
for (i, c) in line.char_indices() {
173+
match c {
174+
'"' => in_quote = !in_quote,
175+
'#' if !in_quote => return &line[..i],
176+
_ => {}
177+
}
178+
}
179+
line
180+
}
181+
182+
fn count_quoted(line: &str) -> i64 {
183+
// Even-numbered quote-count → quoted-string count is quotes/2.
184+
let q = line.bytes().filter(|&b| b == b'"').count() as i64;
185+
q / 2
186+
}
187+
188+
fn bracket_delta(line: &str) -> i32 {
189+
let mut d = 0;
190+
let mut in_quote = false;
191+
for c in line.chars() {
192+
match c {
193+
'"' => in_quote = !in_quote,
194+
'[' if !in_quote => d += 1,
195+
']' if !in_quote => d -= 1,
196+
_ => {}
197+
}
198+
}
199+
d
200+
}
201+
202+
fn claimed_metrics(
203+
claude_md: &Path,
204+
latest_state: &Path,
205+
) -> std::collections::HashMap<String, i64> {
206+
let mut out = std::collections::HashMap::new();
207+
if let Ok(text) = fs::read_to_string(claude_md) {
208+
// CLAUDE.md canonical phrase: "N crates, M in workspace, K excluded".
209+
// Match the first occurrence.
210+
if let Some(caps) = find_crates_phrase(&text) {
211+
out.insert("workspace_total".into(), caps.0);
212+
out.insert("workspace_members".into(), caps.1);
213+
out.insert("excluded".into(), caps.2);
214+
}
215+
}
216+
if let Ok(text) = fs::read_to_string(latest_state) {
217+
let prs = count_pr_rows(&text);
218+
if prs > 0 {
219+
out.insert("prs_in_table".into(), prs);
220+
}
221+
}
222+
out
223+
}
224+
225+
fn find_crates_phrase(text: &str) -> Option<(i64, i64, i64)> {
226+
// Look for: "N crates, M in workspace, K excluded" in any line.
227+
// Tolerant of whitespace and surrounding markdown.
228+
for line in text.lines() {
229+
let l = line.to_ascii_lowercase();
230+
if !l.contains("crates") || !l.contains("in workspace") || !l.contains("excluded") {
231+
continue;
232+
}
233+
let nums: Vec<i64> = l
234+
.split(|c: char| !c.is_ascii_digit())
235+
.filter_map(|s| s.parse().ok())
236+
.collect();
237+
if nums.len() >= 3 {
238+
return Some((nums[0], nums[1], nums[2]));
239+
}
240+
}
241+
None
242+
}
243+
244+
fn count_pr_rows(text: &str) -> i64 {
245+
// PR table rows in LATEST_STATE.md look like:
246+
// | **#389** | 2026-05-16 | ...
247+
// Count lines starting with `| **#` and a digit.
248+
text.lines()
249+
.filter(|l| {
250+
let t = l.trim_start();
251+
t.starts_with("| **#")
252+
&& t.bytes()
253+
.nth(5)
254+
.map(|b| b.is_ascii_digit())
255+
.unwrap_or(false)
256+
})
257+
.count() as i64
258+
}
259+
260+
fn print_report(rows: &[DriftRow], threshold: i64) {
261+
let any_over = rows.iter().any(|r| r.drift > threshold);
262+
let banner = if any_over { "DRIFT — refuse to spawn" } else { "OK — within threshold" };
263+
println!("preflight_drift: {banner} (threshold={threshold})");
264+
println!(" {:<22} {:>10} {:>10} {:>10}", "metric", "claimed", "real", "drift");
265+
for r in rows {
266+
let claimed = if r.claimed < 0 { "—".to_string() } else { r.claimed.to_string() };
267+
let drift = if r.drift < 0 { "?".to_string() } else { r.drift.to_string() };
268+
let marker = if r.drift > threshold { " <— OVER" } else { "" };
269+
println!(
270+
" {:<22} {:>10} {:>10} {:>10}{}",
271+
r.key, claimed, r.real, drift, marker
272+
);
273+
}
274+
}

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,5 +68,8 @@ crates/thinking-engine/data/Qwopus3.5-27B-v3-BF16-silu/token_embd_4096x4096.u8
6868
# Sub-agent ephemeral worktrees
6969
.claude/worktrees/
7070

71+
# Compiled preflight_drift binary (source stays committed; rustc-built locally)
72+
.claude/tools/preflight_drift
73+
7174
# Test fixtures (public-domain German legal/medical/tax data)
7275
.test/

0 commit comments

Comments
 (0)