|
#!/usr/bin/env bash |
|
# Collect the read-only facts the project-summary skill needs about this repository's roadmap: |
|
# every active OpenSpec change with its initiative, stories, artifact set, and task progress; |
|
# every non-terminal initiative with its change counts; the reference edges between active |
|
# changes; transitive dependent counts (the leverage signal); and change-shaped names that are |
|
# mentioned by active artifacts but exist neither as an active change nor in the archive |
|
# (demand for work nobody has proposed yet). Pure bash + awk, no writes anywhere. |
|
set -euo pipefail |
|
|
|
ROOT_ARG="" |
|
|
|
usage() { |
|
printf 'Usage: %s [--root <dir>]\n' "${0##*/}" >&2 |
|
} |
|
|
|
while [[ $# -gt 0 ]]; do |
|
case "$1" in |
|
--root) |
|
[[ -n ${2:-} ]] || { usage; exit 2; } |
|
ROOT_ARG=$2; shift 2 ;; |
|
-h|--help) |
|
usage; exit 0 ;; |
|
*) |
|
printf '%s: unrecognized argument: %s\n' "${0##*/}" "$1" >&2 |
|
usage; exit 2 ;; |
|
esac |
|
done |
|
|
|
resolve_target_root() { |
|
if [[ -n $ROOT_ARG ]]; then |
|
[[ -d $ROOT_ARG ]] || { |
|
printf '%s: --root does not exist: %s\n' "${0##*/}" "$ROOT_ARG" >&2 |
|
exit 1 |
|
} |
|
(cd "$ROOT_ARG" && pwd) |
|
return |
|
fi |
|
local discovered |
|
if discovered=$(git rev-parse --show-toplevel 2>/dev/null) && [[ -n $discovered ]]; then |
|
printf '%s' "$discovered" |
|
return |
|
fi |
|
printf '%s: pass --root <dir> or run inside a Git work tree\n' "${0##*/}" >&2 |
|
exit 1 |
|
} |
|
|
|
ROOT=$(resolve_target_root) |
|
CHANGES_DIR="$ROOT/openspec/changes" |
|
INIT_DIR="$ROOT/docs/product/initiatives" |
|
|
|
[[ -d $CHANGES_DIR ]] || { |
|
printf '%s: no openspec/changes/ under %s\n' "${0##*/}" "$ROOT" >&2 |
|
exit 1 |
|
} |
|
|
|
TMP_DIR=$(mktemp -d) |
|
trap 'rm -rf "$TMP_DIR"' EXIT |
|
|
|
# --- enumerate active and archived change names ------------------------------------------------- |
|
ACTIVE=() |
|
for d in "$CHANGES_DIR"/*/; do |
|
[[ -d $d ]] || continue |
|
name=$(basename "$d") |
|
[[ $name == archive ]] && continue |
|
ACTIVE+=("$name") |
|
done |
|
|
|
if [[ ${#ACTIVE[@]} -eq 0 ]]; then |
|
printf '== CHANGES ==\nno active changes under %s\n' "$CHANGES_DIR" |
|
exit 0 |
|
fi |
|
|
|
ARCHIVED=() |
|
if [[ -d $CHANGES_DIR/archive ]]; then |
|
for d in "$CHANGES_DIR/archive"/*/; do |
|
[[ -d $d ]] || continue |
|
name=$(basename "$d") |
|
# strip a leading YYYY-MM-DD- date prefix |
|
ARCHIVED+=("$(printf '%s' "$name" | sed -E 's/^[0-9]{4}-[0-9]{2}-[0-9]{2}-//')") |
|
done |
|
fi |
|
|
|
in_list() { |
|
local needle=$1; shift |
|
local x |
|
for x in "$@"; do [[ $x == "$needle" ]] && return 0; done |
|
return 1 |
|
} |
|
|
|
count_matches() { |
|
# grep -c prints the count but exits 1 when it is zero; normalize to a plain number. |
|
local pattern=$1 file=$2 |
|
grep -cE "$pattern" "$file" 2>/dev/null || true |
|
} |
|
|
|
artifact_files() { |
|
local dir=$1 f |
|
for f in proposal.md design.md tasks.md; do |
|
if [[ -f $dir/$f ]]; then printf '%s\n' "$dir/$f"; fi |
|
done |
|
return 0 |
|
} |
|
|
|
# --- CHANGES: one row per active change ---------------------------------------------------------- |
|
printf '== CHANGES ==\n' |
|
printf 'change\tinitiative\tstories\tartifacts\ttasks_done\ttasks_total\n' |
|
for c in "${ACTIVE[@]}"; do |
|
dir="$CHANGES_DIR/$c" |
|
initiative='-' |
|
stories='-' |
|
if [[ -f $dir/proposal.md ]]; then |
|
initiative=$(awk ' |
|
/^---[[:space:]]*$/ { fence++; next } |
|
fence == 1 && /^[[:space:]]*initiative:/ { sub(/^[[:space:]]*initiative:[[:space:]]*/, ""); print; exit } |
|
fence >= 2 { exit } |
|
' "$dir/proposal.md") |
|
stories=$(awk ' |
|
/^---[[:space:]]*$/ { fence++; next } |
|
fence == 1 && /^[[:space:]]*stories:/ { |
|
sub(/^[[:space:]]*stories:[[:space:]]*/, "") |
|
gsub(/[][]/, ""); gsub(/[[:space:]]/, "") |
|
print; exit |
|
} |
|
fence >= 2 { exit } |
|
' "$dir/proposal.md") |
|
[[ -n $initiative ]] || initiative='-' |
|
[[ -n $stories ]] || stories='-' |
|
fi |
|
artifacts='' |
|
[[ -f $dir/proposal.md ]] && artifacts+='proposal,' |
|
[[ -f $dir/design.md ]] && artifacts+='design,' |
|
[[ -f $dir/tasks.md ]] && artifacts+='tasks,' |
|
[[ -d $dir/specs ]] && [[ -n $(find "$dir/specs" -name '*.md' -print -quit 2>/dev/null) ]] && artifacts+='specs,' |
|
artifacts=${artifacts%,} |
|
[[ -n $artifacts ]] || artifacts='-' |
|
done_n=0 |
|
total_n=0 |
|
if [[ -f $dir/tasks.md ]]; then |
|
done_n=$(count_matches '^[[:space:]]*- \[x\]' "$dir/tasks.md") |
|
open_n=$(count_matches '^[[:space:]]*- \[ \]' "$dir/tasks.md") |
|
total_n=$((done_n + open_n)) |
|
fi |
|
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$c" "$initiative" "$stories" "$artifacts" "$done_n" "$total_n" |
|
done |
|
|
|
# --- INITIATIVES: one row per non-archived initiative -------------------------------------------- |
|
printf '\n== INITIATIVES ==\n' |
|
printf 'initiative\tstatus\tactive_changes\tarchived_changes\n' |
|
if [[ -d $INIT_DIR ]]; then |
|
for d in "$INIT_DIR"/*/; do |
|
name=$(basename "$d") |
|
[[ $name == archive ]] && continue |
|
status='-' |
|
if [[ -f $d/prd.md ]]; then |
|
status=$(awk ' |
|
/^---[[:space:]]*$/ { fence++; next } |
|
fence == 1 && /^status:/ { sub(/^status:[[:space:]]*/, ""); print; exit } |
|
fence >= 2 { exit } |
|
' "$d/prd.md") |
|
[[ -n $status ]] || status='-' |
|
fi |
|
active_n=0 |
|
archived_n=0 |
|
if [[ -f $d/changes.md ]]; then |
|
counts=$(awk -F'|' ' |
|
NF >= 5 && $2 !~ /Change/ && $2 !~ /^[[:space:]]*-+[[:space:]]*$/ { |
|
state = $4 |
|
gsub(/^[[:space:]]+|[[:space:]]+$/, "", state) |
|
if (state == "active") a++ |
|
else if (state == "archived") r++ |
|
} |
|
END { printf "%d %d", a, r } |
|
' "$d/changes.md") |
|
active_n=${counts% *} |
|
archived_n=${counts#* } |
|
fi |
|
printf '%s\t%s\t%s\t%s\n' "$name" "$status" "$active_n" "$archived_n" |
|
done |
|
fi |
|
|
|
# --- EDGES: active change A mentions active change B (A most likely depends on B) ---------------- |
|
printf '\n== EDGES (referencer -> referenced) ==\n' |
|
: > "$TMP_DIR/edges" |
|
for a in "${ACTIVE[@]}"; do |
|
files=$(artifact_files "$CHANGES_DIR/$a") |
|
[[ -n $files ]] || continue |
|
for b in "${ACTIVE[@]}"; do |
|
[[ $a == "$b" ]] && continue |
|
# word-ish boundary so add-async-work never matches add-async-work-queue |
|
if printf '%s\n' "$files" | xargs grep -lqE "(^|[^a-z0-9-])$b([^a-z0-9-]|\$)" 2>/dev/null; then |
|
printf '%s\t%s\n' "$a" "$b" | tee -a "$TMP_DIR/edges" |
|
fi |
|
done |
|
done |
|
|
|
# --- LEVERAGE: how many active changes sit downstream of each active change ---------------------- |
|
printf '\n== LEVERAGE (dependents among active changes) ==\n' |
|
printf 'change\tdirect_referencers\ttransitive_referencers\n' |
|
{ |
|
for c in "${ACTIVE[@]}"; do printf 'NODE\t%s\n' "$c"; done |
|
cat "$TMP_DIR/edges" |
|
} | awk -F'\t' ' |
|
$1 == "NODE" { nodes[$2]; next } |
|
{ rev[$2] = rev[$2] SUBSEP $1 } |
|
END { |
|
for (n in nodes) { |
|
delete seen; delete queue |
|
qlen = 0; head = 1; direct = 0 |
|
m = split(rev[n], q, SUBSEP) |
|
for (i = 1; i <= m; i++) if (q[i] != "") { queue[++qlen] = q[i]; direct++ } |
|
cnt = 0 |
|
while (head <= qlen) { |
|
v = queue[head++] |
|
if (v in seen) continue |
|
seen[v] = 1; cnt++ |
|
m = split(rev[v], r, SUBSEP) |
|
for (i = 1; i <= m; i++) if (r[i] != "" && !(r[i] in seen)) queue[++qlen] = r[i] |
|
} |
|
printf "%s\t%d\t%d\n", n, direct, cnt |
|
} |
|
} |
|
' | sort -t"$(printf '\t')" -k3,3nr -k2,2nr -k1,1 |
|
|
|
# --- MENTIONS: change-shaped names referenced by active artifacts -------------------------------- |
|
# Names that are neither active nor archived are demand for unproposed work. |
|
printf '\n== MENTIONS OF NON-ACTIVE CHANGE NAMES ==\n' |
|
printf 'name\tstate\treferencing_changes\n' |
|
all_tokens=$( |
|
for c in "${ACTIVE[@]}"; do artifact_files "$CHANGES_DIR/$c"; done \ |
|
| xargs grep -ohE '(add|publish|define|retire|update|remove)(-[a-z0-9]+)+' 2>/dev/null \ |
|
| sort -u |
|
) |
|
for t in $all_tokens; do |
|
in_list "$t" "${ACTIVE[@]}" && continue |
|
state='unproposed' |
|
# ${arr[@]+...} keeps bash 3.2's set -u happy when the archive is empty |
|
in_list "$t" ${ARCHIVED[@]+"${ARCHIVED[@]}"} && state='archived' |
|
refs=0 |
|
for c in "${ACTIVE[@]}"; do |
|
files=$(artifact_files "$CHANGES_DIR/$c") |
|
[[ -n $files ]] || continue |
|
if printf '%s\n' "$files" | xargs grep -lqE "(^|[^a-z0-9-])$t([^a-z0-9-]|\$)" 2>/dev/null; then |
|
refs=$((refs + 1)) |
|
fi |
|
done |
|
[[ $refs -gt 0 ]] || continue |
|
printf '%s\t%s\t%s\n' "$t" "$state" "$refs" |
|
done | sort -t"$(printf '\t')" -k2,2 -k3,3nr |