This is an automated email from the ASF dual-hosted git repository. spmallette pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/tinkerpop.git
commit 0f768a63f87f8a47afd393073a8551807c2d3f0f Author: Stephen Mallette <[email protected]> AuthorDate: Sun Aug 9 08:23:19 2026 -0400 Add the beads planning and memory workflow to 3.7-dev Brings over .beads/PRIME.md, the agent hook logic and the structural report from master so agent work on this branch is recorded in the project's shared memory. The Module label vocabulary in PRIME.md now spans every maintained branch, since beads outlive the branch they were written on. Assisted-by: Claude Code:claude-opus-5 --- .beads/.gitignore | 91 ++++++++++++++ .beads/PRIME.md | 300 ++++++++++++++++++++++++++++++++++++++++++++ .beads/config.yaml | 56 +++++++++ .gitignore | 5 +- bin/agent-hooks/claude.json | 50 ++++++++ bin/agent-hooks/kiro.json | 45 +++++++ bin/beads-agent-hook.sh | 186 +++++++++++++++++++++++++++ bin/beads-report.py | 217 ++++++++++++++++++++++++++++++++ pom.xml | 13 ++ 9 files changed, 962 insertions(+), 1 deletion(-) diff --git a/.beads/.gitignore b/.beads/.gitignore new file mode 100644 index 0000000000..9fedddedba --- /dev/null +++ b/.beads/.gitignore @@ -0,0 +1,91 @@ +# Dolt database (managed by Dolt, not git) +dolt/ +embeddeddolt/ +proxieddb/ + +# Runtime files +bd.sock +bd.sock.startlock +sync-state.json +last-touched +.exclusive-lock + +# Daemon runtime (lock, log, pid) +daemon.* + +# Push state (runtime, per-machine) +push-state.json + +# Lock files (various runtime locks) +*.lock + +# Credential key (encryption key for federation peer auth — never commit) +.beads-credential-key + +# Local version tracking (prevents upgrade notification spam after git ops) +.local_version + +proxied_server_client_info.json + +# Worktree redirect file (contains relative path to main repo's .beads/) +# Must not be committed as paths would be wrong in other clones +redirect + +# Sync state (local-only, per-machine) +# These files are machine-specific and should not be shared across clones +.sync.lock +export-state/ +export-state.json +last_pull + +# Ephemeral store (SQLite - wisps/molecules, intentionally not versioned) +ephemeral.sqlite3 +ephemeral.sqlite3-journal +ephemeral.sqlite3-wal +ephemeral.sqlite3-shm + +# Dolt server management (auto-started by bd) +dolt-server.pid +dolt-server.log +dolt-server.lock +dolt-server.port +dolt-server.activity + +# Debug-mode pprof artifacts (written when dolt.debug: true in config.yaml) +dolt-pprof/ + +# Corrupt backup directories (created by bd doctor --fix recovery) +*.corrupt.backup/ + +# Backup data (auto-exported JSONL, local-only) +backup/ + +# Per-project environment file (Dolt connection config, GH#2520) +.env + +# Legacy files (from pre-Dolt versions) +*.db +*.db?* +*.db-journal +*.db-wal +*.db-shm +db.sqlite +bd.db +# NOTE: Do NOT add negation patterns here. +# They would override fork protection in .git/info/exclude. +# Config files (metadata.json, config.yaml) are tracked by git by default +# since no pattern above ignores them. + +# --- TinkerPop additions --- +# metadata.json holds dolt_database, which is the LOCAL database directory name +# (.beads/embeddeddolt/<name>/), not the DoltHub repository. The remote identity +# lives in the Dolt-level `origin` remote inside that database. Clones may +# legitimately differ, so this is per-machine and must not be shared. +metadata.json + +# Generated hook shims (installed by `bd hooks install`, versioned to the bd binary) +hooks/ + +# Agent interaction audit trail — per-contributor, machine-local. Records one +# actor's session activity, so it is neither shared history nor reproducible. +interactions.jsonl diff --git a/.beads/PRIME.md b/.beads/PRIME.md new file mode 100644 index 0000000000..bd0281721e --- /dev/null +++ b/.beads/PRIME.md @@ -0,0 +1,300 @@ +# TinkerPop Beads Workflow + +Beads is TinkerPop's planning system **and its long-term memory**. It records not just what +changed, but why — decisions made, alternatives rejected, and directions abandoned. Treat +every bead as something a contributor will read in three years. This file is what must +survive context compaction. + +## Workflow + +An index. Each line names a section; **the section is the rule, and the section holds the +exceptions.** Do not act on a line here without reading it. + +0. **Think in graph; capture every road not taken — throughout, not a step** — section 0 +1. **Bind to a root bead before you write code** — section 1 +2. **Plan the work with the operator as a dependency graph** — section 2 +3. **Claim before editing, close as work finishes** — section 3 +4. **At merge, close the root and pin the whole subtree** — section 4 +5. **Never rewrite or discard history** — section 5 +6. **Records, edge types and bead IDs follow fixed conventions** — section 6 +7. **Never invent a label** — section 7 + +--- + +## 0. Core rules + +These hold throughout — while planning with the operator and while executing. + +**Think in graph.** Work, decisions, external artifacts and the relations between them are +nodes and edges. The plan lives in beads and nowhere else: not `TodoWrite`, not `TaskCreate`, +not a markdown plan file. Those are session-scoped, so nothing in one is memory. Your harness +may prompt you to use them. Decline. + +**A decision needs a bead to hang off**, so create the root when the conversation starts, not +when the code does. + +**Watch for these five things. They are observable events, not judgment calls:** + +1. **The operator redirects you** — "no, do X instead", "we tried that", "that breaks + providers". Highest signal. Capture every time. +2. **What you built diverged from the JIRA / proposal / dev@ thread.** +3. **An approach was tried and abandoned.** +4. **You presented options** — a decision point exists by construction. +5. **A discovery contradicted an assumption.** + +**Then pick the instrument. The only test is whether a road was not taken:** + +| What happened | Do | +|---|---| +| A specific course was considered and **not taken** — a design, a scope item, a validation step, a target branch, a task you wrote and threw away | Decision bead **plus** its `rejected-alternative` sibling, now | +| Something is simply true, with no fork in it — evidence, a measurement, a discovery, a constraint | `bd comment <root> "..."` | + +**The rejected thing does not have to be a design.** "The operator declined X" is a road not +taken. So is "we were going to target master, we targeted 3.7-dev instead." If you can name +what was *not* done, it is a decision — write both beads. + +**Self-check before writing any comment: name what was *not* done.** If you can name it — a +course declined, a branch not targeted, an approach dropped — it is a decision bead, not a +comment. Wording like "the operator declined" or "X rather than Y" is the tell, but check +what it refers to: a choice about *the work* is a decision, while "the test frames HashMap +instead of OptionsStrategy" is just describing code and stays a comment. + +```bash +# Only when something was actually ruled out. No fork = implementation; the code documents that. +bd create --type=decision --parent=<root> --title="Chose X" --design="why, and what X rules out" +# The sibling is the road not taken — an approach you tried and abandoned counts, and is stronger +# evidence than a hypothetical, because someone already walked it. +bd create --type=decision --parent=<root> --title="Y" --labels="rejected-alternative" \ + --design="why Y was rejected" +bd dep add <decision> <alternative> -t related # never put either of these on a task bead +``` + +**Record what actually happened.** If you cannot point to the moment, do not write the bead. +When you sense a decision you were not party to, create a bead labelled `human` posing the +question instead of inventing an answer — `bd human respond <id>` turns the reply into a +comment. + +--- + +## 1. Start here — bind to a root + +Every session works under one **root bead**. Find it before writing code. + +```bash +bd list --status=all --json # filter client-side: no parent, open/in_progress +bd children <root> # recursive — the whole subtree +``` + +- Show the operator open/`in_progress` beads with **no parent**, most recently updated + first, and ask which one. That is your root for this session. +- **Read `bd children <root>` before resuming work.** It is the only thing that makes you + notice a bead the work has since outgrown. +- **If no root is selected, you are starting something new — create the root before writing + code.** +- A small fix is a lone bead. It is its own root; don't hunt for a parent. +- A human may decline binding to a bead, in which case ignore these rules. + +`bd query "parent=none"` does not work. Filter on the `parent` field client-side. +Re-ask after a compaction rather than guessing. + +--- + +## 2. Plan as a graph, not a list + +The plan is built **with the operator**; what you weigh and reject while building it is +captured as you go (section 0), not once you start executing. + +Tasks are not a checklist. Wire the order between them so the graph itself says what can run +in parallel. + +```bash +bd dep add <task> <blocker> # <task> waits for <blocker> — NOT "task blocks blocker" +bd dep add --file - <<< '{"from":"tp-a","to":"tp-b"}' # wire a whole plan at once +bd ready --parent <root> --exclude-type=decision # what can start now, in YOUR subtree +bd blocked --parent <root> # what is waiting, and on what +bd dep cycles # a plan with a cycle cannot execute +``` + +**Always scope `bd ready` and `bd blocked` to your root.** Unscoped they span the whole +database and hand you other people's work. Exclude decisions too: they are records rather +than work, but they sit `open` until merge and otherwise fill the queue — under one root +here, every "ready" bead was a decision and two were rejected alternatives. + +- **Wire the order in the same pass as `bd create`.** +- **Re-planning is normal; record it.** `bd dep remove` deletes an edge with no trace in the + graph, so a restructure erases the shape you started with. If you rewire because you found + a better path, that is a road not taken — write the decision bead. +- **A task with no blocker asserts it can start immediately.** The absence of an edge is a + claim, not an oversight — decide it deliberately for every task. +- **A blocked task is released when its blocker closes**, so tasks must close as they finish. +- **Link a decision to the work it caused** — `bd dep add <task> <decision> -t caused-by`. + Without it there is no path from a task back to the reasoning that shaped it. + +**Before proceeding to the next step, obtain human approval.** - Show the human a summary of +the beads graph for review. + +--- + +## 3. While working — claim, then close + +Adjusting the plan mid-flight is normal — section 2 applies again when you do, and section 0 +applies the whole time. + +**Before you touch code for a bead, claim it. Every time, no exceptions:** + +```bash +bd update <id> --claim # sets assignee to you, status to in_progress +``` + +That window **is** the memory: a later session runs `bd list --status=in_progress` and learns +what was underway, who had it, and where it stopped. A bead that jumps from `open` straight +to `closed` records that the work happened but never that it was yours, never where you were +when context ran out. + +If you are editing files and nothing is `in_progress`, you have already lost that. Stop and +claim the bead you are actually working on. + +**Close a task the moment its work is done — do not wait for the merge.** Closing is what +releases the tasks that were waiting on it, so a task left `in_progress` out of caution +stalls everything downstream. The root is the exception: it represents the deliverable and +closes at merge (section 4). + +Status is not paperwork. It is both the handoff and the gate. + +--- + +## 4. At merge — close the root, then pin + +Tasks closed as they finished (section 3). What is left at merge is the **root** — the +deliverable — plus any decision beads, which are not work and never closed on their own. + +```bash +bd children <root> # the whole subtree; nothing should still be in_progress +bd close <root> <decision-ids> # whatever the work itself did not close +bd update <id1> <id2> ... -s pinned +bd dolt pull && bd dolt push +``` + +**Pin every bead in the subtree** — root, decisions, records, tasks. No judgment about +which ones matter: the work shipped, so all of it is the project's history. Show the +operator the list first if they want a review gate. + +Pinning is what makes a bead permanent — every destructive operation keys on +`status=closed`, and pinned beads are never eligible. + +Push freely as a checkpoint; pinning is what marks the durable record. + +--- + +## 5. Never + +- **Never `bd flatten`, `bd compact`, or `bd admin compact`.** They rewrite or discard + history irreversibly. `admin compact` destroys `--design` text specifically. `bd gc` only + with `--skip-decay`. +- **Never edit an existing bead's `--design` in place.** Add a comment, or create a new + decision bead with a `supersedes` edge. Field rewrites are invisible to history and lose + the reasoning that was there. +- `bd prune` / `bd purge` / `bd gc` are release-time maintainer operations. Don't run them. +- Don't use `bd edit` — it opens `$EDITOR` and blocks. + +--- + +## 6. Structure + +``` +root (feature/epic/task) + ├─relates-to──▶ record [jira] TINKERPOP-3456 + ├─relates-to──▶ record [pr] apache/tinkerpop#2891 + ├─parent-child─▶ decision "chose X" + │ └─related─▶ decision "Y" [rejected-alternative] + ├─parent-child─▶ task A "implement X" ──caused-by──▶ decision "chose X" + ├─parent-child─▶ task B ──blocks──▶ task A (B waits for A) + └─parent-child─▶ task C (no blocker: starts with A) +``` + +`parent-child` gives membership, `blocks` gives order. A subtree with no `blocks` edges is +a list, and `bd ready` cannot tell you anything useful about it. + +- `--parent` builds the tree. Labels inherit downward — see section 7. +- **`record` beads** hold external artifacts — JIRA, PR, dev@ thread, proposal. Kind is a + **label** (`jira`, `pr`, `dev-list`, `proposal`); the URL or ticket goes in + `--external-ref`. Attach them to the **root**, not to every bead. Create them pinned. + Search first — duplicates are the main risk. +- Records are the only link between beads and code. There is no bead ID in commit messages. +- Only **one dependency type per pair** — `blocks` and `discovered-from` cannot coexist + between the same two beads. +- Never construct a bead ID; use whatever `bd create` returns. Child IDs encode birth + position (`<root>.1.2`) but do not update on reparenting — traverse `parent` for truth, + treat the ID as a hint. + +--- + +## 7. Labels + +Labels are categorization **orthogonal to type and priority** — a bead carries as many as +apply, giving cross-cutting views the tree cannot. + +**Never invent a label.** What is listed below is the entire vocabulary. + +```bash +bd create --labels="gremlin-core,3.8" # at creation +bd label add|remove <id> <label> +bd label list <id> +bd label list-all # what is in use — includes drift; this file is the authority +``` + +**Set module and release labels once on the root** — children inherit them. Labels added to +a root *after* its children exist do not backfill, so label the root first. + +### Structural labels — part of the data model, never optional + +| Label | Why it exists | +|---|---| +| `human` | bd's own contract: `bd human list/respond/dismiss` query this exact string | +| `rejected-alternative` | the chosen decision and the road not taken are **both** `type=decision`; this label is the only thing telling them apart | +| `jira` `pr` `dev-list` `proposal` | which kind of external artifact a `record` bead holds — exactly one per record | + +### Dimensions — descriptive; several may apply + +**Module** — a unit of code. The list spans every maintained branch, so it includes modules +this branch does not have. Beads outlive branches, and one vocabulary keeps the database +readable from all of them. + +``` +gql-gremlin gremlin-annotations gremlin-archetype gremlin-console gremlin-core +gremlin-dotnet gremlin-driver gremlin-go gremlin-groovy gremlin-javascript +gremlin-js gremlin-language gremlin-python gremlin-server gremlin-shaded +gremlin-test gremlin-tools gremlin-util hadoop-gremlin neo4j-gremlin +spark-gremlin sparql-gremlin tinkergraph-gremlin docs +gremlint gremlator gremlin-mcp +``` + +**Release** — the official release version, not the branch - examples: `3.7.7`, `4.0.0-beta.2` + +**Concern** — a cross-cutting property or feature. + +``` +breaking-change deprecation security serialization protocol performance +release build +``` + +--- + +## Essential commands + +```bash +bd children <root> # the subtree, recursive +bd ready --parent <root> --exclude-type=decision # startable now; ALWAYS scope to your root +bd blocked --parent <root> # what is waiting, and on what +bd show <id> # one bead with dependencies +bd query "status=open AND type=decision" +bd comment <id> "..." # a fact with no fork in it (never on a task bead) +bd create --type=... --parent=<root> --design=... --labels=... +bd dep add <task> <blocker> # default type is blocks: <task> waits for <blocker> +bd dep add <a> <b> -t caused-by|related|discovered-from|supersedes +bd dep cycles # a plan with a cycle cannot execute +bd update <id> --claim | -s pinned | --external-ref=TINKERPOP-NNNN +bd search <text> +``` + +Priority is `0-4` (0 = critical), never "high"/"medium"/"low". diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 0000000000..034e24fffc --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1,56 @@ +# Beads Configuration File +# This file configures default behavior for all bd commands in this repository +# All settings can also be set via environment variables (BD_* prefix) +# or overridden with command-line flags + +# Issue prefix for this repository (used by bd init) +# If not set, bd init will auto-detect from directory name +# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. +issue-prefix: "tp" + +# Use no-db mode: JSONL-only, no Dolt database +# When true, bd will use .beads/issues.jsonl as the source of truth +# no-db: false + +# Enable JSON output by default +# json: false + +# Feedback title formatting for mutating commands (create/update/close/dep/edit) +# 0 = hide titles, N > 0 = truncate to N characters +# output: +# title-length: 255 + +# Default actor for audit trails (overridden by BEADS_ACTOR or --actor) +# actor: "" + +# Export events (audit trail) to .beads/events.jsonl on each flush/sync +# When enabled, new events are appended incrementally using a high-water mark. +# Use 'bd export --events' to trigger manually regardless of this setting. +# events-export: false + +# Multi-repo configuration (experimental - bd-307) +# Allows hydrating from multiple repositories and routing writes to the correct database +# repos: +# primary: "." # Primary repo (where this database lives) +# additional: # Additional repos to hydrate from (read-only) +# - ~/beads-planning # Personal planning repo +# - ~/work-planning # Work planning repo + +# JSONL backup (periodic export for off-machine recovery) +# Auto-enabled when a git remote exists. Override explicitly: +# backup: +# enabled: false # Disable auto-backup entirely +# interval: 15m # Minimum time between auto-exports +# git-push: false # Disable git push (export locally only) +# git-repo: "" # Separate git repo for backups (default: project repo) + +# Integration settings (access with 'bd config get/set') +# These are stored in the database, not in this file: +# - jira.url +# - jira.project +# - linear.url +# - linear.api-key +# - github.org +# - github.repo +sync: + remote: "https://doltremoteapi.dolthub.com/tinkerpop/tinkerbeads" diff --git a/.gitignore b/.gitignore index f5b7109e8f..d3b966f9a2 100644 --- a/.gitignore +++ b/.gitignore @@ -50,5 +50,8 @@ gremlinconsoletest.egg-info CLAUDE.md **/.claude/ .kiro/ -.beads/ +.agents/ +.cursor/ +.codex/ +.github/skills/ gremlin-python/src/main/python/build/ diff --git a/bin/agent-hooks/claude.json b/bin/agent-hooks/claude.json new file mode 100644 index 0000000000..9a7230e7ce --- /dev/null +++ b/bin/agent-hooks/claude.json @@ -0,0 +1,50 @@ +{ + "_comment": [ + "Claude Code hook wiring for TinkerPop's beads workflow. Merged into", + ".claude/settings.local.json by 'bin/agent-setup.sh --contributor claude'.", + "All logic lives in bin/beads-agent-hook.sh -- these entries only name an", + "event and invoke it. Entries are identified for re-install and removal by", + "the beads-agent-hook.sh reference in their command.", + "SessionStart matches 'compact' as well as startup/resume/clear, so the", + "workflow is restored after a context compaction, not just at session start.", + "There is deliberately no PreCompact entry: that event accepts only", + "decision/reason, so it has no way to inject context. A reminder issued there", + "cannot reach the model. SessionStart with source=compact does the real work." + ], + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PROJECT_DIR}\"/bin/beads-agent-hook.sh session-start --format=claude", + "timeout": 30 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PROJECT_DIR}\"/bin/beads-agent-hook.sh stop --format=claude", + "timeout": 15 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PROJECT_DIR}\"/bin/beads-agent-hook.sh prompt-submit --format=claude", + "timeout": 15 + } + ] + } + ] + } +} diff --git a/bin/agent-hooks/kiro.json b/bin/agent-hooks/kiro.json new file mode 100644 index 0000000000..d71877351a --- /dev/null +++ b/bin/agent-hooks/kiro.json @@ -0,0 +1,45 @@ +{ + "version": "v1", + "_comment": [ + "Kiro hook wiring for TinkerPop's beads workflow. Copied to", + ".kiro/hooks/tinkerpop-beads.json by 'bin/agent-setup.sh --contributor kiro'.", + "All logic lives in bin/beads-agent-hook.sh -- these entries only name an", + "event and invoke it. Names are prefixed tinkerpop-beads- for re-install.", + "Kiro has no PreCompact trigger, but neither harness can inject context at", + "compaction time, so nothing is lost relative to Claude. Whether Kiro re-fires", + "SessionStart after compacting is untested; if it does not, the workflow is not", + "restored and the agent should be told to run 'bd prime' by hand." + ], + "hooks": [ + { + "name": "tinkerpop-beads-session-start", + "trigger": "SessionStart", + "action": { + "type": "agent", + "prompt": "Run `bin/beads-agent-hook.sh session-start` and follow the workflow it prints. It is TinkerPop's beads workflow: how to bind to a root bead, when to capture decisions, and what never to run. If the command is unavailable, continue without it." + }, + "timeout": 30, + "enabled": true + }, + { + "name": "tinkerpop-beads-stop", + "trigger": "Stop", + "action": { + "type": "agent", + "prompt": "Run `bin/beads-agent-hook.sh stop`. If it prints nothing, say nothing and stop. If it prints a check, act on it before finishing." + }, + "timeout": 15, + "enabled": true + }, + { + "name": "tinkerpop-beads-prompt-submit", + "trigger": "UserPromptSubmit", + "action": { + "type": "agent", + "prompt": "If the operator just redirected you -- rejected an approach, said an alternative was already tried, or told you to do something a different way -- create a decision bead and its rejected-alternative sibling now, while the reasoning is exact. Otherwise ignore this and carry on." + }, + "timeout": 15, + "enabled": true + } + ] +} diff --git a/bin/beads-agent-hook.sh b/bin/beads-agent-hook.sh new file mode 100755 index 0000000000..0e35ff51fc --- /dev/null +++ b/bin/beads-agent-hook.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# Agent hook logic for TinkerPop's beads workflow. +# +# All of the behaviour lives here so it can be read, reviewed and tested without +# a running agent. The per-tool files under bin/agent-hooks/ are thin wiring that +# name an event and invoke this script; bin/agent-setup.sh --contributor installs +# them. Every hook is advisory -- nothing here blocks an agent. +# +# Usage: +# bin/beads-agent-hook.sh <event> [--format=claude|plain] +# +# Events: +# session-start emit the beads workflow (.beads/PRIME.md, via bd prime) +# stop wrap-up checklist, rate limited (see STOP_INTERVAL) +# prompt-submit nudge when the operator's prompt reads as a redirect +# +# Run any event by hand to see exactly what an agent would be shown: +# bin/beads-agent-hook.sh stop +# +set -uo pipefail + +STOP_INTERVAL=${TINKERPOP_BEADS_STOP_INTERVAL:-1800} # seconds; 0 disables limit + +usage() { + awk '/^# Agent hook logic/,/^[^#]/ { if ($0 ~ /^#/) { sub(/^# ?/, ""); print } }' "$0" + exit "${1:-0}" +} + +event="" +format="plain" +for arg in "$@"; do + case "$arg" in + --format=*) format="${arg#--format=}" ;; + -h|--help) usage 0 ;; + -*) echo "unknown option: $arg" >&2; usage 2 ;; + *) event="$arg" ;; + esac +done +[[ -n "$event" ]] || usage 2 + +# Beads is for committers. A contributor without bd installed, or a directory +# that is not a beads workspace, gets silence rather than an error. +command -v bd >/dev/null 2>&1 || exit 0 +bd where >/dev/null 2>&1 || exit 0 + +# Read the hook payload when one is piped in. Agents send JSON on stdin; running +# this by hand from a terminal must not block waiting for input. +read_stdin_payload() { + [[ -t 0 ]] && return 0 + cat 2>/dev/null +} + +# Pull a field out of the agent's JSON payload, tolerating non-JSON input. +payload_field() { + local payload="$1" field="$2" + printf '%s' "$payload" | python3 -c " +import json, sys +raw = sys.stdin.read() +try: + print(json.loads(raw).get('$field', '') or '') +except Exception: + print(raw if '$field' == 'user_prompt' else '') +" 2>/dev/null +} + +# Stop fires after every agent turn, so an unconditional checklist would be +# noise. Fire at most once per STOP_INTERVAL per workspace. State lives in the +# temp dir rather than the repo so nothing is left behind to commit. +stop_is_due() { + [[ "$STOP_INTERVAL" -eq 0 ]] && return 0 + local key stamp now last + key=$(printf '%s' "$PWD" | cksum | cut -d' ' -f1) + stamp="${TMPDIR:-/tmp}/tinkerpop-beads-stop-$key" + now=$(date +%s) + last=$(cat "$stamp" 2>/dev/null || echo 0) + (( now - last < STOP_INTERVAL )) && return 1 + echo "$now" > "$stamp" 2>/dev/null + return 0 +} + +# High-signal redirect language. The cost of a false positive is one extra +# sentence in context, so this errs toward firing. +REDIRECT_RE='(^|[[:space:]])(no,|nope|instead|rather than|we tried|already tried|'\ +'that will not work|that won.t work|that breaks|don.t do|do not do|revert|back out|'\ +'undo that|wrong approach|not what i)([[:space:]]|[[:punct:]]|$)' + +text="" +case "$event" in + session-start) + text=$(bd prime 2>/dev/null) + ;; + + stop) + stop_is_due || exit 0 + # bd prints "No issues found." rather than nothing when the list is empty. + active=$(bd list --status=in_progress 2>/dev/null | grep -v '^No issues found' | head -20) + text="Beads check — did anything get decided this session? + +An alternative that was actually considered and rejected belongs in a decision bead with +its rejected-alternative sibling. An approach you tried and abandoned counts. Anything else +worth remembering goes in a comment on the root bead." + + # Editing files while nothing is claimed means the in_progress window -- the only + # thing a later session can read to resume -- is never being written. These are two + # independent facts, so never let one suppress the other: parallel sessions share + # one actor, so a populated in_progress list is no evidence *your* work is claimed. + dirty=$(git status --porcelain 2>/dev/null | head -1) + if [[ -n "$active" ]]; then + text="$text + +Still in progress: +$active" + if [[ -n "$dirty" ]]; then + text="$text + +You have uncommitted changes. If what you are working on is not in that list, claim it — +'bd update <id> --claim'. Beads claimed by another session or contributor are not yours." + fi + elif [[ -n "$dirty" ]]; then + text="$text + +You have uncommitted changes and NO bead is in_progress anywhere. Claim the bead you are +working on now — 'bd update <id> --claim'. Without it, the next session cannot tell what +was underway or where it stopped." + fi + ;; + + prompt-submit) + prompt=$(payload_field "$(read_stdin_payload)" user_prompt) + [[ -n "$prompt" ]] || exit 0 + if printf '%s' "$prompt" | tr '[:upper:]' '[:lower:]' | grep -Eq "$REDIRECT_RE"; then + text="That prompt reads as a redirect. If an alternative was just rejected, create the +decision bead and its rejected-alternative sibling now, while the reasoning is exact — +operator redirects are the highest-signal capture trigger there is." + fi + ;; + + *) + echo "unknown event: $event" >&2 + usage 2 + ;; +esac + +[[ -n "${text// /}" ]] || exit 0 + +case "$format" in + plain) + printf '%s\n' "$text" + ;; + claude) + case "$event" in + session-start) hook_event="SessionStart" ;; + stop) hook_event="Stop" ;; + prompt-submit) hook_event="UserPromptSubmit" ;; + esac + HOOK_EVENT="$hook_event" HOOK_TEXT="$text" python3 -c " +import json, os +print(json.dumps({'hookSpecificOutput': { + 'hookEventName': os.environ['HOOK_EVENT'], + 'additionalContext': os.environ['HOOK_TEXT'], +}})) +" + ;; + *) + echo "unknown format: $format" >&2 + exit 2 + ;; +esac diff --git a/bin/beads-report.py b/bin/beads-report.py new file mode 100755 index 0000000000..5675ec39cd --- /dev/null +++ b/bin/beads-report.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""Structural report over the beads graph. + +ADVISORY ONLY. This never blocks anything. Run as a gate it would fire constantly +on legitimate work -- a one-line fix has no record bead and no decisions, and that +is correct. + +Two scopes: + + bin/beads-report.py --root tp-abc At merge, before pinning. Reads just that + subtree via `bd children`. Bounded and fast. + + bin/beads-report.py At release, before purging. Reads the whole + database via `bd export`. Its most valuable + output is "here is every piece of rationale + about to be deleted" -- review it, pin what + should survive, then purge. + +Checks divide into two kinds, and the distinction matters: + + OBJECTIVE dangling edge targets; rationale on closed unpinned beads. + Defects and facts. No judgment involved. + + HEURISTIC decision beads with no rejected-alternative neighbour; roots with + several tasks and no decisions. These cannot distinguish "no + decisions were made" from "decisions were not captured", so they + are questions for a human, never verdicts. + +Neither kind can judge whether design text is real reasoning or fluent filler. +Structure is checkable; substance is not. +""" + +import argparse +import collections +import json +import subprocess +import sys + + +def bd(*args): + """Run a bd command with --json and return the parsed result.""" + proc = subprocess.run(["bd", *args, "--json"], capture_output=True, text=True) + if proc.returncode != 0: + sys.exit(f"bd {' '.join(args)} failed: {proc.stderr.strip()}") + if not proc.stdout.strip(): + return [] + return json.loads(proc.stdout) + + +def as_list(payload): + """bd sometimes returns a bare object where a list is expected.""" + if isinstance(payload, dict): + return payload.get("issues", [payload]) + return payload + + +def load_export(): + """Whole database, one record per line. Carries design and comments.""" + proc = subprocess.run(["bd", "export"], capture_output=True, text=True) + if proc.returncode != 0: + sys.exit(f"bd export failed: {proc.stderr.strip()}") + beads = {} + for line in proc.stdout.splitlines(): + line = line.strip() + if not line: + continue + record = json.loads(line) + if record.get("_type") == "issue": + beads[record["id"]] = record + return beads + + +def load_subtree(root): + """One root's subtree, gathered by descending one level at a time. + + `bd children` recurses in its text output but NOT under `--json`, which + returns direct children only -- so a single call silently truncates the + subtree to depth 1. Verified against a four-level tree at bd 1.1.2. + + The records returned carry no `design` field, only comment_count, so + rationale-at-risk detection is weaker in this scope than at release scope.""" + beads = {} + for record in as_list(bd("show", root)): + beads.setdefault(record["id"], record) + + frontier = [root] + seen = {root} + while frontier: + node = frontier.pop() + for child in as_list(bd("children", node)): + cid = child["id"] + beads.setdefault(cid, child) + if cid not in seen: + seen.add(cid) + frontier.append(cid) + return beads + + +def edges_of(beads): + return [ + (bid, dep["depends_on_id"], dep["type"]) + for bid, bead in beads.items() + for dep in bead.get("dependencies", []) + ] + + +def report(title, rows): + print(f"\n{title}") + print(" (none)" if not rows else "\n".join(f" {r}" for r in rows)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("--root", help="limit to one root's subtree (merge scope)") + args = parser.parse_args() + + scoped = bool(args.root) + beads = load_subtree(args.root) if scoped else load_export() + edges = edges_of(beads) + + neighbours = collections.defaultdict(set) + for src, dst, _ in edges: + neighbours[src].add(dst) + neighbours[dst].add(src) + + # --- OBJECTIVE --------------------------------------------------------- + + # Dangling targets are only meaningful against the whole graph; in a subtree + # a "missing" target is usually just outside the scope. + if not scoped: + report("Dangling dependency targets", [ + f"{s} -[{t}]-> {d} (target missing)" + for s, d, t in edges if d not in beads + ]) + + at_risk = [] + for bead in beads.values(): + if bead.get("status") != "closed": + continue # pinned and open beads are not purge-eligible + carried = [] + if bead.get("comment_count") or bead.get("comments"): + n = bead.get("comment_count") or len(bead.get("comments") or []) + carried.append(f"{n} comment(s)") + if (bead.get("design") or "").strip(): + carried.append("design text") + if carried: + at_risk.append(f"{bead['id']:18} {', '.join(carried):22} {bead['title'][:44]}") + report("Rationale on closed beads (lost at next purge -- pin to keep)", at_risk) + if scoped: + print(" note: subtree scope cannot see `design` text; comment counts only") + + # --- HEURISTIC --------------------------------------------------------- + + lonely = [ + f"{b['id']:18} {b['title'][:50]}" + for b in beads.values() + if b.get("issue_type") == "decision" + and not any("rejected-alternative" in (beads[n].get("labels") or []) + for n in neighbours[b["id"]] if n in beads) + ] + report("Decision beads with no rejected-alternative neighbour (suspect)", lonely) + + # `bd children --json` carries a `parent` field; `bd export` does not -- there + # the hierarchy lives only in parent-child edges, which point child -> parent. + parent_of = {b: bead["parent"] for b, bead in beads.items() if bead.get("parent")} + for src, dst, typ in edges: + if typ == "parent-child": + parent_of.setdefault(src, dst) + + children = collections.defaultdict(list) + for bid, parent in parent_of.items(): + children[parent].append(beads[bid]) + + roots = [args.root] if scoped else [ + b for b in beads if b not in parent_of and children.get(b) + ] + + thin = [] + for root in roots: + if root not in beads: + continue + kids = children.get(root, []) + kinds = collections.Counter(k.get("issue_type") for k in kids) + linked = [beads[n] for n in neighbours[root] if n in beads] + flags = [] + if not any(x.get("issue_type") == "record" for x in kids + linked): + flags.append("no record") + if kinds.get("task", 0) >= 3 and not kinds.get("decision"): + flags.append(f"{kinds['task']} tasks, 0 decisions") + if flags: + thin.append(f"{root:18} {', '.join(flags):26} {beads[root]['title'][:34]}") + report("Roots that look thin (a question, not a verdict)", thin) + + scope = f"subtree of {args.root}" if scoped else "whole database" + print(f"\n{len(beads)} beads, {len(edges)} edges, {len(roots)} root(s) [{scope}]") + + +if __name__ == "__main__": + main() diff --git a/pom.xml b/pom.xml index 6c2210a52b..027719b37e 100644 --- a/pom.xml +++ b/pom.xml @@ -474,6 +474,16 @@ limitations under the License. <exclude>**/.project</exclude> <exclude>**/.settings/**</exclude> <exclude>**/.idea/**</exclude> + <exclude>**/.claude/**</exclude> + <exclude>**/.codex/**</exclude> + <exclude>**/.copilot/**</exclude> + <exclude>**/.cursor/**</exclude> + <exclude>**/.kiro/**</exclude> + <exclude>**/.junie/**</exclude> + <exclude>**/.agents/**</exclude> + <!-- Beads planning data: PRIME.md and config.yaml are tracked, and a + committer's local Dolt database sits alongside them untracked --> + <exclude>.beads/**</exclude> <exclude>.repository/**</exclude> <exclude>**/target/**</exclude> <exclude>data/*.txt</exclude> @@ -542,6 +552,9 @@ limitations under the License. <exclude>**/go.sum</exclude> <exclude>**/coverage.out</exclude> <exclude>**/gremlinconsoletest.egg-info/**</exclude> + <!-- Agent skill markdown is guidance for AI agents, kept header-free to maximize signal; + SKILL.md additionally requires frontmatter at the very start of the file --> + <exclude>**/.skills/**/*.md</exclude> </excludes> <licenses> <license implementation="org.apache.rat.analysis.license.ApacheSoftwareLicense20"/>
