diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ef626c..2565359 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.27.0 — 2026-06-08 + +### fix: ship + deploy the dangling P1/P2/P6/P7 hook scripts; scaffold METALAYER + schemas; document the two-flow bootstrap (BRO-1431) + +Surfaced by building a from-scratch RCS/RSI template (rcs-template) as a gap-discovery probe for bstack's own bootstrap. Found a real safety bug plus two scaffold omissions, and the absence of a generative flow. + +### Fixed + +- **Dangling hook scripts (safety bug).** `settings.json.snippet` wired the P1 (conversation-bridge), P2 (control-gate), P6 (knowledge-catalog-refresh), and P7 (skill-freshness) hooks at `${BROOMVA_WORKSPACE}/scripts/*.sh`, but bstack shipped none of those scripts and bootstrap never copied them. Result: every workspace bootstrapped anywhere but the bstack origin had a **non-functional control gate (P2)** — the safety shield silently no-op'd because Claude Code invoked a script that did not exist. `doctor §7` detected the gap but nothing closed it. Now bstack **ships** all four (`scripts/{control-gate,skill-freshness,conversation-bridge,knowledge-catalog-refresh}-hook.sh`, self-contained + `$CLAUDE_PROJECT_DIR`-portable) and **deploys** them into `$WORKSPACE/scripts/` — `bootstrap` Phase 3.1 and `repair`'s `deploy_workspace_hooks` (idempotent, never overwrites). Dogfood proof: on a fresh workspace with no bstack on PATH, the deployed control-gate blocks `git push --force` (exit 2) and allows `git status` (exit 0). + +### Added + +- **`bootstrap` Phase 2 now scaffolds `METALAYER.md` + `schemas/{state,action,trace,evaluator,egri-event}.schema.json`** (the control-systems manifest + typed interfaces). Templates added under `assets/templates/` + `assets/templates/schemas/`. Previously only CLAUDE/AGENTS/policy/arcs scaffolded; the manifest and typed contract were omitted. +- **Two-flow bootstrap doc (SKILL.md).** Names and sequences the **structured flow** (deterministic scaffold — the lossless floor, no LLM) → **generative flow** (agent-authored, workspace-tailored setup — bespoke, to-the-ceiling) → **verify** (`bstack doctor` gates both). Mirrors the P18 Audience Category-B-projection vs Category-C-generative split, applied to workspace setup. Invariant: never wire a hook whose script isn't deployed. + +### Notes + +- Primitive count unchanged (**20**). This is a bootstrap correctness + completeness fix, not a new P-row. +- Hook-resolution is now consistent for the workspace-resolved hooks (all four deployed into `$WORKSPACE/scripts/`, self-contained on a fresh clone); the L0/L1 audit hooks remain `$BSTACK_REPO`-referenced (already resolving). +- `VERSION` 0.26.0 → 0.27.0. + ## 0.26.0 — 2026-06-05 ### feat: `bstack skills audit --require-tests` — skill-script test gate (BRO-1411 slice 2) diff --git a/SKILL.md b/SKILL.md index 7cc98e2..621d01a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -18,6 +18,8 @@ bstack ships two complementary layers: Installing the substrate without the mode = the workspace has primitives but no entry point to engage them. Invoking the mode without the substrate = wishful thinking. Compounded: `/bstack bootstrap` installs the substrate, then `/autonomous` is the standing operating mode for substantive work units. +Bootstrap itself is **two-flow** — a deterministic structured scaffold (the floor) plus an agent-authored generative tailoring pass (the bespoke layer). See [Two-flow workspace setup](#two-flow-workspace-setup-structured--generative). + ## Quick start Install: @@ -249,6 +251,39 @@ Future sessions inspect this for state. `bootstrap_status: failed` is captured t **Self-application**: when `/bstack bootstrap` is invoked in an existing workspace, the bootstrap itself runs under `/autonomous` discipline — state snapshot, dep-chain trace, validation plan, PR pipeline. The bootstrap that installs the discipline embodies the contract it ships. +**Two-flow**: bootstrap is not a single deterministic pass. The structured scaffold above is the *floor*; the agent then runs a generative tailoring pass on top of it. See [Two-flow workspace setup](#two-flow-workspace-setup-structured--generative) below for the full model and canonical sequence. + +### Two-flow workspace setup (structured + generative) + +`bstack bootstrap` is a *two-flow* operation, not a single deterministic pass. This mirrors the Audience (P18) split bstack already applies to documents — a deterministic Category-B *projection floor* plus a context-aware Category-C *bespoke authoring* layer — now applied to workspace setup itself. + +``` +bstack bootstrap = STRUCTURED flow → GENERATIVE flow → VERIFY + (deterministic, no LLM) (agent-authored, contextual) (deterministic) + scripts + templates tailoring of THIS workspace bstack doctor + + shipped+deployed hooks + + gates + .control +``` + +**1. Structured flow (the floor — reproducible, no LLM).** `bstack bootstrap` runs the idempotent scaffold: installs skills; scaffolds governance from `assets/templates/*` (CLAUDE.md, AGENTS.md, METALAYER.md, `.control/policy.yaml`, `.control/arcs.yaml`, `.control/rcs-parameters.toml`, `schemas/`); **deploys** the hook scripts into the workspace (control-gate / skill-freshness / conversation-bridge / knowledge-catalog-refresh + the L0/L1 audit hooks); wires `.claude/settings.json`; installs the L3 rate gate + CI gate. This flow must be COMPLETE and CORRECT — every wired hook must have a backing script deployed (no dangling references). It is the lossless baseline: same inputs → same workspace, every time. + +**2. Generative flow (the bespoke layer — agent-authored, to-the-ceiling).** After the structured scaffold, the agent does a context-aware pass that templates cannot produce. Concretely the agent: + + a. **Detects the stack + project intent** — signals: language/build files, existing code, README, the user's stated goal. + b. **Tailors the scaffolded governance prose to THIS project** — rewrites the generic CLAUDE.md / AGENTS.md placeholders into project-specific invariants, conventions, and architecture notes (not generic template text). + c. **Authors a project-specific CI workflow** — the structured flow ships the L3-stability gate; the agent generates the test/lint/build job that matches the detected stack. + d. **Fills the Dogfood Plan (Empirical, P11)** with the real entry surfaces + evidence anchors for this project's stack (per [references/dogfood-patterns.md](references/dogfood-patterns.md)). + e. **For RCS/RSI or control-systems repos** — optionally lays down a runnable L0–L3 substrate + a HIERARCHY/instantiation map, so the workspace doesn't merely DESCRIBE a control system, it RUNS one. For ordinary repos, this step is skipped. + f. **Files the initial knowledge-graph entities / decision log (Bookkeeping, P6)** for the new workspace — proactively, never asking permission. + +**3. Verify (deterministic).** `bstack doctor` gates BOTH flows: the structured contract (governance files, hooks wired+deployed, gates, schemas) AND the generative output (the doctor surfaces gaps if the agent's tailoring left a hole). Generative output is always checked by the structured contract — never trusted blind. + +**Key principles:** + +- This mirrors the established Audience (P18) discipline: the STRUCTURED flow is the Category-B *projection floor* (deterministic, lossless, reproducible); the GENERATIVE flow is Category-C *bespoke authoring* (context-aware, to-the-ceiling). The same structured-vs-generative split bstack already applies to documents, now applied to workspace setup. +- The structured flow must never wire a hook whose script isn't deployed — the *dangling-hook* failure mode this work fixes. "Wired but dangling" is forbidden: every hook reference resolves to a real, executable, deployed script. +- The agent runs **structured FIRST** (idempotent floor), **THEN generative** (bespoke), **THEN doctor** (verify). Never generative-without-structured (no floor) or structured-without-generative (generic, untailored workspace). + ### `doctor` — verify primitive contract `scripts/doctor.sh`. Eight check sections: diff --git a/VERSION b/VERSION index 4e8f395..1b58cc1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.26.0 +0.27.0 diff --git a/assets/templates/schemas/action.schema.json b/assets/templates/schemas/action.schema.json new file mode 100644 index 0000000..dc979d3 --- /dev/null +++ b/assets/templates/schemas/action.schema.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Agentic Control Kernel — Action Schema", + "description": "Typed control directive (θ_t) emitted by the LLM agent. Not raw actuation — parameterizes deterministic controllers.", + "type": "object", + "required": ["directive_id", "timestamp", "directive_type"], + "properties": { + "directive_id": { + "type": "string", + "description": "Unique identifier for this control directive" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of directive emission" + }, + "directive_type": { + "type": "string", + "enum": [ + "setpoint_update", + "constraint_update", + "mode_switch", + "parameter_update", + "module_selection", + "experiment_request", + "model_update_trigger", + "plan_update" + ], + "description": "Type of control directive" + }, + "target_controller": { + "type": "string", + "description": "Which controller module this directive targets" + }, + "payload": { + "type": "object", + "description": "Directive-specific payload (setpoints, parameters, etc.)", + "additionalProperties": true + }, + "rationale": { + "type": "string", + "description": "LLM's reasoning for this directive (for audit/ledger)" + }, + "priority": { + "type": "string", + "enum": ["critical", "high", "normal", "low"], + "default": "normal", + "description": "Execution priority" + }, + "requires_approval": { + "type": "boolean", + "default": false, + "description": "Whether this directive needs human approval before execution" + }, + "rollback_directive_id": { + "type": ["string", "null"], + "description": "Directive to execute if this one needs to be rolled back" + }, + "budget_impact": { + "type": "object", + "description": "Estimated resource consumption", + "properties": { + "tokens": { "type": "integer" }, + "compute_s": { "type": "number" }, + "cost_usd": { "type": "number" } + } + } + } +} diff --git a/assets/templates/schemas/egri-event.schema.json b/assets/templates/schemas/egri-event.schema.json new file mode 100644 index 0000000..fb44751 --- /dev/null +++ b/assets/templates/schemas/egri-event.schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://broomva.tech/schemas/egri-event.schema.json", + "title": "EGRI Trial Event", + "description": "Payload for autoany EGRI trial records persisted to Lago via EventKind::Custom with 'egri.' prefix", + "type": "object", + "required": ["event_type", "trial"], + "properties": { + "event_type": { + "type": "string", + "pattern": "^egri\\.", + "description": "Event type with 'egri.' prefix, e.g. 'egri.trial'" + }, + "trial": { + "$ref": "#/$defs/TrialRecord" + }, + "session_id": { + "type": ["string", "null"], + "description": "Optional Arcan session ID for cross-reference" + } + }, + "$defs": { + "TrialRecord": { + "type": "object", + "required": ["trial_id", "timestamp", "parent_state", "mutation", "outcome", "decision"], + "properties": { + "trial_id": { "type": "string" }, + "timestamp": { "type": "string", "format": "date-time" }, + "parent_state": { "type": "string" }, + "mutation": { "$ref": "#/$defs/Mutation" }, + "execution": { "$ref": "#/$defs/ExecutionResult" }, + "outcome": { "$ref": "#/$defs/Outcome" }, + "decision": { "$ref": "#/$defs/Decision" }, + "strategy_notes": { "type": ["string", "null"] } + } + }, + "Mutation": { + "type": "object", + "required": ["operator", "description"], + "properties": { + "operator": { "type": "string" }, + "description": { "type": "string" }, + "diff": { "type": ["string", "null"] }, + "hypothesis": { "type": ["string", "null"] } + } + }, + "ExecutionResult": { + "type": ["object", "null"], + "properties": { + "duration_secs": { "type": "number" }, + "exit_code": { "type": "integer" }, + "error": { "type": ["string", "null"] }, + "output": {} + } + }, + "Outcome": { + "type": "object", + "required": ["score", "constraints_passed"], + "properties": { + "score": {}, + "constraints_passed": { "type": "boolean" }, + "constraint_violations": { "type": "array", "items": { "type": "string" } }, + "evaluator_metadata": {} + } + }, + "Decision": { + "type": "object", + "required": ["action", "reason"], + "properties": { + "action": { "type": "string", "enum": ["promoted", "discarded", "branched", "escalated"] }, + "reason": { "type": "string" }, + "new_state_id": { "type": ["string", "null"] } + } + } + } +} diff --git a/assets/templates/schemas/evaluator.schema.json b/assets/templates/schemas/evaluator.schema.json new file mode 100644 index 0000000..c7c120b --- /dev/null +++ b/assets/templates/schemas/evaluator.schema.json @@ -0,0 +1,114 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Agentic Control Kernel — Evaluator Schema", + "description": "Score vectors and promotion decisions for EGRI-compatible evaluators.", + "type": "object", + "required": ["evaluator_id", "timestamp", "scores", "decision"], + "properties": { + "evaluator_id": { + "type": "string", + "description": "Identifier of the evaluator instance" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "trial_id": { + "type": "string", + "description": "EGRI trial being evaluated" + }, + "controller_version": { + "type": "string", + "description": "Controller version under evaluation" + }, + "scores": { + "type": "object", + "description": "Score vector — scalar or multi-dimensional metrics", + "properties": { + "primary": { + "type": "number", + "description": "Primary objective score (the one driving promotion)" + }, + "secondary": { + "type": "object", + "description": "Additional metrics tracked but not driving promotion", + "additionalProperties": { "type": "number" } + } + }, + "required": ["primary"] + }, + "baseline": { + "type": "object", + "description": "Baseline scores for comparison", + "properties": { + "primary": { "type": "number" }, + "secondary": { + "type": "object", + "additionalProperties": { "type": "number" } + } + } + }, + "constraints": { + "type": "object", + "required": ["all_passed"], + "properties": { + "all_passed": { + "type": "boolean", + "description": "Whether all hard constraints were satisfied" + }, + "violations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "constraint_id": { "type": "string" }, + "measured": { "description": "Measured value" }, + "threshold": { "description": "Threshold that was violated" }, + "severity": { + "type": "string", + "enum": ["hard", "soft"] + } + } + } + } + } + }, + "decision": { + "type": "object", + "required": ["action"], + "properties": { + "action": { + "type": "string", + "enum": ["promoted", "discarded", "branched", "escalated"], + "description": "Promotion decision" + }, + "reason": { + "type": "string", + "description": "Why this decision was made" + }, + "new_controller_version": { + "type": ["string", "null"], + "description": "Version ID of promoted controller (null if discarded)" + }, + "rollback_target": { + "type": ["string", "null"], + "description": "Version to rollback to if this promotion fails in deployment" + } + } + }, + "scenario_coverage": { + "type": "object", + "description": "Which scenarios were evaluated", + "properties": { + "total_scenarios": { "type": "integer" }, + "passed": { "type": "integer" }, + "failed": { "type": "integer" }, + "holdout_passed": { + "type": "integer", + "description": "Anti-gaming holdout scenarios passed" + }, + "holdout_total": { "type": "integer" } + } + } + } +} diff --git a/assets/templates/schemas/state.schema.json b/assets/templates/schemas/state.schema.json new file mode 100644 index 0000000..e01e7cc --- /dev/null +++ b/assets/templates/schemas/state.schema.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Agentic Control Kernel — State Schema", + "description": "Typed plant/belief state for agentic control systems. Separates measured, estimated, and context fields.", + "type": "object", + "required": ["timestamp", "observation_id", "measured"], + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of observation" + }, + "observation_id": { + "type": "string", + "description": "Unique identifier for this observation" + }, + "plant_id": { + "type": "string", + "description": "Identifier of the plant being observed" + }, + "measured": { + "type": "object", + "description": "Directly measured signals from the plant (sensors, metrics, CI results)", + "additionalProperties": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "description": "Measured value (number, string, boolean, or array)" + }, + "unit": { + "type": "string", + "description": "Unit of measurement (e.g., 'ms', 'percent', 'count')" + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Confidence in measurement (1.0 = ground truth)" + } + } + } + }, + "estimated": { + "type": "object", + "description": "Inferred/estimated signals (model predictions, aggregated metrics)", + "additionalProperties": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "description": "Estimated value" + }, + "uncertainty": { + "type": "number", + "minimum": 0, + "description": "Uncertainty bound on estimate" + }, + "estimator": { + "type": "string", + "description": "Name of estimator that produced this value" + } + } + } + }, + "context": { + "type": "object", + "description": "Semantic/contextual fields (branch name, user intent, session info)", + "additionalProperties": true + }, + "constraints": { + "type": "object", + "description": "Currently active constraints on this plant", + "properties": { + "hard": { + "type": "array", + "items": { "type": "string" }, + "description": "Constraints that must never be violated" + }, + "soft": { + "type": "array", + "items": { "type": "string" }, + "description": "Constraints that should be satisfied but can be relaxed" + } + } + } + } +} diff --git a/assets/templates/schemas/trace.schema.json b/assets/templates/schemas/trace.schema.json new file mode 100644 index 0000000..a619e4b --- /dev/null +++ b/assets/templates/schemas/trace.schema.json @@ -0,0 +1,130 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Agentic Control Kernel — Trace Schema", + "description": "Canonical trace entry for the append-only ledger. Compatible with Autoany EGRI ledger format.", + "type": "object", + "required": ["trace_id", "timestamp", "plant_id", "state_snapshot", "action_proposed", "action_applied", "outcome"], + "properties": { + "trace_id": { + "type": "string", + "description": "Unique identifier for this trace entry" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp" + }, + "plant_id": { + "type": "string", + "description": "Identifier of the plant" + }, + "controller_version": { + "type": "string", + "description": "Version/hash of the active controller configuration" + }, + "state_snapshot": { + "type": "object", + "description": "Belief state at decision time (or hash + artifact pointer for large states)", + "properties": { + "hash": { "type": "string" }, + "artifact_path": { "type": "string" }, + "inline": { "type": "object", "additionalProperties": true } + } + }, + "directive": { + "description": "The LLM's control directive θ_t (ref: action.schema.json)", + "type": "object", + "additionalProperties": true + }, + "action_proposed": { + "type": "object", + "description": "Controller's proposed action before safety filtering", + "additionalProperties": true + }, + "action_applied": { + "type": "object", + "description": "Actual action applied after safety shield filtering", + "additionalProperties": true + }, + "shield": { + "type": "object", + "description": "Safety shield results", + "properties": { + "feasible": { + "type": "boolean", + "description": "Whether the shield found a feasible safe action" + }, + "modification_magnitude": { + "type": "number", + "description": "||u_safe - u_proposed|| — how much the shield modified the action" + }, + "certificate": { + "type": "object", + "description": "Safety certificate proving constraint satisfaction", + "additionalProperties": true + }, + "fallback_used": { + "type": "boolean", + "default": false, + "description": "Whether emergency fallback was activated" + } + } + }, + "constraints_checked": { + "type": "array", + "items": { + "type": "object", + "required": ["constraint_id", "satisfied"], + "properties": { + "constraint_id": { "type": "string" }, + "satisfied": { "type": "boolean" }, + "value": { "description": "Measured value for the constraint" }, + "threshold": { "description": "Constraint threshold" } + } + } + }, + "outcome": { + "type": "object", + "required": ["success"], + "properties": { + "success": { + "type": "boolean", + "description": "Whether the action succeeded" + }, + "observation_after": { + "type": "object", + "description": "Plant observation after action", + "additionalProperties": true + }, + "error": { + "type": ["string", "null"], + "description": "Error message if action failed" + } + } + }, + "evaluator_metrics": { + "type": "object", + "description": "Micro-metrics scored by evaluator for this tick", + "properties": { + "cost": { "type": "number" }, + "constraint_violations": { "type": "integer" }, + "latency_ms": { "type": "number" }, + "robustness_indicator": { "type": "number" } + }, + "additionalProperties": true + }, + "egri": { + "type": "object", + "description": "EGRI loop context (if this trace is part of an improvement trial)", + "properties": { + "trial_id": { "type": "string" }, + "parent_state": { "type": "string" }, + "mutation_operator": { "type": "string" }, + "decision": { + "type": "string", + "enum": ["promoted", "discarded", "branched", "escalated", "pending"] + } + } + } + } +} diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 31cb3f2..3cb7647 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -151,6 +151,13 @@ scaffold_governance_file ".control/policy.yaml" "policy.yaml.template" # Closure-contract arcs (the loop DEFINITIONS — the workspace's own editable # copy; compute-arc-status.sh otherwise falls back to the bundled template). scaffold_governance_file ".control/arcs.yaml" "arcs.yaml.template" +# Control-systems manifest (plant/controller/shield/feedback formalization). +scaffold_governance_file "METALAYER.md" "METALAYER.md.template" +# Typed interface schemas (state/action/trace/evaluator/egri-event) — the typed +# contract the control loop validates against. +for _schema in state action trace evaluator egri-event; do + scaffold_governance_file "schemas/${_schema}.schema.json" "schemas/${_schema}.schema.json" +done echo " scaffolded: $scaffolded | preserved: $preserved" @@ -275,6 +282,35 @@ else echo " manual: see assets/templates/settings.json.snippet" fi +# ─── Phase 3.1: deploy workspace-resolved hook scripts ───────────────────── +# settings.json.snippet wires P1/P2/P6/P7 hooks at ${BROOMVA_WORKSPACE}/scripts/*.sh. +# Ship + deploy those scripts so the references resolve. Closes the dangling-hook +# safety gap: a wired-but-undelivered control-gate hook (P2) silently no-ops, +# leaving the safety shield non-functional on every workspace but the bstack +# origin. Idempotent: never overwrites a workspace's existing hook script. +echo "" +echo "=== bstack workspace hook deploy ===" +mkdir -p "$WORKSPACE_DIR/scripts" +WORKSPACE_HOOKS=(control-gate-hook.sh skill-freshness-hook.sh conversation-bridge-hook.sh knowledge-catalog-refresh-hook.sh) +deployed_hooks=0 +for hook in "${WORKSPACE_HOOKS[@]}"; do + src="$SKILL_ROOT/scripts/$hook" + dst="$WORKSPACE_DIR/scripts/$hook" + if [ ! -f "$src" ]; then + echo " [skip] $hook (not shipped in this bstack version)" + continue + fi + if [ -f "$dst" ]; then + echo " [keep] scripts/$hook (existing — preserved)" + elif cp "$src" "$dst" 2>/dev/null && chmod +x "$dst" 2>/dev/null; then + echo " [deploy] scripts/$hook ← bstack/scripts/$hook" + deployed_hooks=$((deployed_hooks + 1)) + else + echo " [warn] could not deploy scripts/$hook (non-fatal)" + fi +done +echo " deployed: $deployed_hooks workspace hook script(s) (P1/P2/P6/P7)" + # ─── Phase 3.5: wire the RCS control loop (L0/L1 audit + L3 gates) ───────── # Closes the split-brain: onboard.sh (the wizard) wired the loop here; the # bootstrap command did not, leaving freshly-bootstrapped workspaces with diff --git a/scripts/control-gate-hook.sh b/scripts/control-gate-hook.sh new file mode 100755 index 0000000..bd8fcdc --- /dev/null +++ b/scripts/control-gate-hook.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# bstack/scripts/control-gate-hook.sh — P2 Control Gate (Claude Code PreToolUse). +# +# THE safety shield. Filters the agent's proposed tool call before it acts. +# This script is SHIPPED by bstack and DEPLOYED into each workspace's scripts/ +# by `bstack bootstrap` (Phase 3.1), so the hook reference in .claude/settings.json +# always resolves — closing the dangling-hook gap where a wired-but-undelivered +# control gate silently no-ops. +# +# Claude Code PreToolUse protocol: +# stdin: { "tool_name", "tool_input": {...}, ... } +# exit 0 -> allow the tool call +# exit 2 -> BLOCK the tool call; stderr is shown to the model +# +# Hard gates (mirrors .control/policy.yaml gates.hard): +# G1 no force-push to a shared branch +# G2 no committing/writing secrets (.env, credentials, keys, .pem) +# G3 no catastrophic deletes (rm -rf /, rm -rf ~) / hard reset to remote +# +# Self-contained: the canonical patterns are embedded; extra Bash deny-patterns +# in .control/policy.yaml under gates.hard[].pattern are merged in automatically. +# Portable: resolves the workspace via $CLAUDE_PROJECT_DIR / $BROOMVA_WORKSPACE / git. + +set -uo pipefail + +INPUT="$(cat 2>/dev/null || echo '{}')" +REPO_ROOT="${CLAUDE_PROJECT_DIR:-${BROOMVA_WORKSPACE:-$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")}}" +POLICY="$REPO_ROOT/.control/policy.yaml" + +command -v python3 >/dev/null 2>&1 || exit 0 # cannot evaluate -> allow (fail-open, non-blocking) + +VERDICT="$(python3 - "$INPUT" "$POLICY" <<'PYEOF' +import sys, json, re + +raw = sys.argv[1] if len(sys.argv) > 1 else "{}" +policy_path = sys.argv[2] if len(sys.argv) > 2 else "" +try: + data = json.loads(raw) +except Exception: + print("ALLOW") + sys.exit(0) + +tool = data.get("tool_name", "") +ti = data.get("tool_input", {}) if isinstance(data.get("tool_input"), dict) else {} + +# embedded canonical hard gates +bash_deny = [ + (r"git\s+push\b.*(--force\b|--force-with-lease\b|\s-f\b)", "G1: force-push is blocked (rewrites shared history)"), + (r"\brm\s+-rf\s+(/|~|\$HOME)(\s|$)", "G3: catastrophic recursive delete is blocked"), + (r"\bgit\s+reset\s+--hard\b.*origin", "G3: hard reset to remote is blocked (discards work)"), + (r"--no-verify\b", "G2: bypassing pre-commit hooks (--no-verify) is blocked"), +] +path_deny = [ + (r"(^|/)\.env(\.|$)", "G2: writing a .env secret file is blocked"), + (r"credentials", "G2: writing a credentials file is blocked"), + (r"(^|/)id_rsa\b", "G2: writing a private SSH key is blocked"), + (r"\.pem$", "G2: writing a .pem key is blocked"), + (r"\.key$", "G2: writing a .key file is blocked"), +] + +# merge extra Bash patterns from policy.yaml gates.hard[].pattern (best effort, no yaml dep) +try: + if policy_path: + with open(policy_path) as f: + for line in f: + m = re.search(r"^\s*pattern:\s*[\"']?(.+?)[\"']?\s*$", line) + if m: + bash_deny.append((m.group(1), "policy.yaml gate")) +except Exception: + pass + +def deny(patterns, value): + for pat, reason in patterns: + try: + if re.search(pat, value): + return reason + except re.error: + continue + return None + +reason = None +if tool == "Bash": + reason = deny(bash_deny, ti.get("command", "")) +elif tool in ("Write", "Edit", "MultiEdit"): + fp = ti.get("file_path") or ti.get("path") or "" + reason = deny(path_deny, fp) + +print("BLOCK:" + reason if reason else "ALLOW") +PYEOF +)" + +if [[ "$VERDICT" == BLOCK:* ]]; then + echo "bstack P2 Control Gate blocked this action — ${VERDICT#BLOCK:}" >&2 + exit 2 +fi +exit 0 diff --git a/scripts/conversation-bridge-hook.sh b/scripts/conversation-bridge-hook.sh new file mode 100755 index 0000000..f61aca5 --- /dev/null +++ b/scripts/conversation-bridge-hook.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# bstack/scripts/conversation-bridge-hook.sh — P1 Conversation Bridge (Stop hook). +# +# Captures the session to the workspace knowledge graph. SHIPPED by bstack and +# DEPLOYED into each workspace by `bstack bootstrap`. Self-contained + graceful: +# - if a richer bridge (scripts/conversation-history.py) is present, run it; +# - otherwise write a minimal session stamp to docs/conversations/ so a fresh +# workspace still captures something. +# Non-blocking, cooldown-throttled, always exit 0. +# +# Claude Code Stop protocol: stdin { "transcript_path", "session_id", ... }. + +set -uo pipefail + +REPO_ROOT="${CLAUDE_PROJECT_DIR:-${BROOMVA_WORKSPACE:-$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")}}" +STAMP="${HOME}/.cache/bstack-bridge-stamp" +COOLDOWN="${BSTACK_BRIDGE_COOLDOWN:-120}" + +# cooldown +now=$(date +%s) +if [ -f "$STAMP" ]; then + if [ "$(uname)" = "Darwin" ]; then last=$(stat -f %m "$STAMP" 2>/dev/null || echo 0); else last=$(stat -c %Y "$STAMP" 2>/dev/null || echo 0); fi + [ $((now - last)) -lt "$COOLDOWN" ] && exit 0 +fi +mkdir -p "$(dirname "$STAMP")"; touch "$STAMP" + +# Prefer a richer bridge if the workspace ships one. +BRIDGE="$REPO_ROOT/scripts/conversation-history.py" +if [ -f "$BRIDGE" ] && command -v python3 >/dev/null 2>&1; then + ( cd "$REPO_ROOT" && python3 "$BRIDGE" >/dev/null 2>&1 ) & + disown 2>/dev/null || true + exit 0 +fi + +# Minimal fallback: append a session stamp to docs/conversations/Conversations.md +INPUT="$(cat 2>/dev/null || echo '{}')" +CONV_DIR="$REPO_ROOT/docs/conversations" +mkdir -p "$CONV_DIR" 2>/dev/null || exit 0 +if command -v python3 >/dev/null 2>&1; then + python3 - "$INPUT" "$CONV_DIR/Conversations.md" <<'PYEOF' 2>/dev/null || true +import sys, json, time +raw = sys.argv[1] if len(sys.argv) > 1 else "{}" +out = sys.argv[2] +try: + data = json.loads(raw) +except Exception: + data = {} +sid = data.get("session_id", "unknown") +ts = time.strftime("%Y-%m-%d %H:%M:%S") +with open(out, "a") as f: + f.write(f"- {ts} — session {sid} (bstack minimal bridge; install knowledge-graph-memory for full capture)\n") +PYEOF +fi +exit 0 diff --git a/scripts/knowledge-catalog-refresh-hook.sh b/scripts/knowledge-catalog-refresh-hook.sh new file mode 100755 index 0000000..ecfaea8 --- /dev/null +++ b/scripts/knowledge-catalog-refresh-hook.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# bstack/scripts/knowledge-catalog-refresh-hook.sh — P6 catalog refresh (Stop hook). +# +# Regenerates the dense, LLM-loadable knowledge index (the catalog that routes +# `/kg load`) after a session. SHIPPED by bstack and DEPLOYED into each workspace +# by `bstack bootstrap`. Self-contained + graceful: runs the bookkeeping index +# only if bookkeeping is installed; otherwise no-ops. Non-blocking, cooldown- +# throttled, always exit 0. + +set -uo pipefail + +REPO_ROOT="${CLAUDE_PROJECT_DIR:-${BROOMVA_WORKSPACE:-$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")}}" +STAMP="${HOME}/.cache/bstack-catalog-stamp" +COOLDOWN="${BSTACK_CATALOG_COOLDOWN:-300}" + +now=$(date +%s) +if [ -f "$STAMP" ]; then + if [ "$(uname)" = "Darwin" ]; then last=$(stat -f %m "$STAMP" 2>/dev/null || echo 0); else last=$(stat -c %Y "$STAMP" 2>/dev/null || echo 0); fi + [ $((now - last)) -lt "$COOLDOWN" ] && exit 0 +fi + +BOOKKEEPING="$REPO_ROOT/skills/bookkeeping/scripts/bookkeeping.py" +if [ -f "$BOOKKEEPING" ] && command -v python3 >/dev/null 2>&1; then + mkdir -p "$(dirname "$STAMP")"; touch "$STAMP" + ( cd "$REPO_ROOT" && python3 "$BOOKKEEPING" index >/dev/null 2>&1 ) & + disown 2>/dev/null || true +fi +exit 0 diff --git a/scripts/repair.sh b/scripts/repair.sh index 914c224..4fec803 100755 --- a/scripts/repair.sh +++ b/scripts/repair.sh @@ -310,6 +310,36 @@ for line in added: PYEOF } +# ── Workspace hook deploy (helper) ───────────────────────────────────────── +# Ships the P1/P2/P6/P7 hook scripts into $WORKSPACE_DIR/scripts/ so the hook +# references wired by settings.json.snippet resolve. Closes the dangling-hook +# safety gap (a wired-but-undelivered control-gate hook silently no-ops) on +# workspaces bootstrapped before these scripts shipped. Idempotent — never +# overwrites a workspace's existing hook script. +deploy_workspace_hooks() { + local hooks=(control-gate-hook.sh skill-freshness-hook.sh conversation-bridge-hook.sh knowledge-catalog-refresh-hook.sh) + local n=0 + for hook in "${hooks[@]}"; do + local src="$SKILL_ROOT/scripts/$hook" + local dst="$WORKSPACE_DIR/scripts/$hook" + [ -f "$src" ] || continue + [ -f "$dst" ] && continue + if [ "$DRY_RUN" = "1" ]; then + echo " [dry-run] would deploy scripts/$hook" + else + mkdir -p "$WORKSPACE_DIR/scripts" + if cp "$src" "$dst" 2>/dev/null && chmod +x "$dst" 2>/dev/null; then + echo " [fix] deployed scripts/$hook (control-gate=P2 safety shield)" + n=$((n + 1)) + else + echo " [warn] could not deploy scripts/$hook (non-fatal)" + fi + fi + done + [ "$n" -gt 0 ] && echo " [fix] deployed $n workspace hook script(s)" + return 0 +} + # ── Run doctor to identify gaps ──────────────────────────────────────────── echo "[bstack repair] running doctor to identify gaps..." echo "" @@ -322,6 +352,10 @@ if [ "$DRY_RUN" = "1" ] || confirm "Merge missing hooks from settings.json.snipp merge_hooks_into_settings fi +# Deploy the hook SCRIPTS the merge just wired references to (idempotent; +# unconditional like the philosophy backfill — closes the dangling-hook gap). +deploy_workspace_hooks + # Backfill templated-since-0.24.0 governance content that even a *compliant* # (pre-0.24.0) workspace can lack — run BEFORE the compliance early-exit, like # the hook merge above, because the Development Philosophy advisory is not a diff --git a/scripts/skill-freshness-hook.sh b/scripts/skill-freshness-hook.sh new file mode 100755 index 0000000..0709399 --- /dev/null +++ b/scripts/skill-freshness-hook.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# bstack/scripts/skill-freshness-hook.sh — P7 Skill Freshness (Claude Code SessionStart). +# +# Nudges the user when installed skills are stale (>= 7d since the last update +# check). Never blocks; stdout from a SessionStart hook is injected as context. +# SHIPPED by bstack and DEPLOYED into each workspace by `bstack bootstrap`. +# +# Marker file: $BROOMVA_P7_HOME or ~/.config/broomva/p7/last-skill-update-check + +set -uo pipefail + +P7_HOME="${BROOMVA_P7_HOME:-$HOME/.config/broomva/p7}" +MARKER="$P7_HOME/last-skill-update-check" +THRESHOLD_DAYS="${BROOMVA_P7_THRESHOLD_DAYS:-7}" +THRESHOLD_SECS=$((THRESHOLD_DAYS * 86400)) + +now=$(date +%s) + +stale=1 +if [ -f "$MARKER" ]; then + if [ "$(uname)" = "Darwin" ]; then + last=$(stat -f %m "$MARKER" 2>/dev/null || echo 0) + else + last=$(stat -c %Y "$MARKER" 2>/dev/null || echo 0) + fi + [ $((now - last)) -lt "$THRESHOLD_SECS" ] && stale=0 +fi + +if [ "$stale" = "1" ]; then + age="never" + if [ -f "$MARKER" ]; then age="$(( (now - last) / 86400 ))d ago"; fi + echo "[bstack P7] Skill freshness check overdue (last: $age)." + echo "[bstack P7] Refresh: npx skills update -g then: mkdir -p \"$P7_HOME\" && touch \"$MARKER\"" +fi +exit 0