playnfrontierAI / core /tools /graph.sh
playnfrontierAI
v3 β€” hardened gates + the memory sky (skeleton current to 2026-09-20)
1a08e9d
Raw History Blame Contribute Delete
5.14 kB
#!/bin/bash
# OMN graph β€” canon link-graph emit + structural lint.
# Nodes: facts/, decisions/, projects/ + watch/learnings/backlog/inbox.md.
# Edges: [[wiki-links]] (resolved by basename anywhere in core, same rule as
# check.sh) + supersedes: frontmatter. Report-only (exit 0) β€” a close.sh hook
# may promote the warns to a gate later (the human's call).
set -u
CORE="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$CORE/graph.json"
FAIL=0
warn() { echo " !! $*"; FAIL=$((FAIL+1)); }
note() { echo " - $*"; }
meta() {
awk -v f="$2" 'BEGIN{n=0}
/^---$/ {n++; next}
n==1 && $1==f":" {sub(/^[^:]+: /,""); print; exit}' "$1"
}
days_since() {
local s
s=$(date -d "$1" +%s 2>/dev/null) || { echo 0; return; }
echo $(( ($(date +%s) - s) / 86400 ))
}
# stale_p <file> <kind> β€” kind: facts|decisions|projects|top (top-level working
# files are never stale here; check.sh gates them via mtime, not frontmatter)
stale_p() {
case "$2" in
facts|decisions|projects)
local iv; iv=$(meta "$1" check); iv=${iv%d}; [ -n "$iv" ] || iv=7
[ "$(days_since "$(meta "$1" updated)")" -gt "$iv" ] ;;
*) return 1 ;;
esac
}
# resolve <ref> β€” path relative to core, or "" (card.md never a target)
resolve() {
local hit
hit=$(find "$CORE" -name "${1##*/}.md" ! -name card.md 2>/dev/null | sort | head -1)
[ -n "$hit" ] && printf '%s' "${hit#"$CORE"/}"
}
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
NODES="$TMP/nodes"; EDGES="$TMP/edges"; LINKS="$TMP/links"
touch "$NODES" "$EDGES" "$LINKS"
# 1. Nodes
for f in "$CORE"/facts/*.md "$CORE"/decisions/*.md "$CORE"/projects/*.md \
"$CORE"/watch.md "$CORE"/learnings.md "$CORE"/backlog.md "$CORE"/inbox.md \
"$CORE"/README.md; do
[ -f "$f" ] || continue
id="${f#"$CORE"/}"; dir=${id%%/*}; [ "$dir" = "$id" ] && dir=top
case "$(meta "$f" status)" in accepted|live|active|superseded|"") ;; *) continue ;; esac
st=$(meta "$f" status); [ -n "$st" ] || st="-"
ti=$(meta "$f" title); [ -n "$ti" ] || ti=$(grep -m1 '^# ' "$f" | sed 's/^# //')
esc=$(printf '%s' "$ti" | sed 's/\\/\\\\/g; s/"/\\"/g')
if stale_p "$f" "$dir"; then stale=true; else stale=false; fi
printf '%s\t%s\t%s\t%s\n' "$id" "$st" "$stale" "$esc" >> "$NODES"
done
# 2. Edges β€” [[wiki-links]] anywhere in node files + supersedes: frontmatter
while IFS=$'\t' read -r id _; do
f="$CORE/$id"
grep -oE '\[\[[^]]+\]\]' "$f" 2>/dev/null | sed -E 's/^\[\[//; s/\]\]$//' | sort -u |
while read -r ref; do
[ -z "$ref" ] && continue
tgt=$(resolve "$ref")
if [ -z "$tgt" ]; then printf 'DEAD\t%s\t%s\n' "$id" "$ref" >> "$LINKS"
elif [ "$tgt" = "$id" ]; then continue
else printf '%s\t%s\twikilink\n' "$id" "$tgt" >> "$EDGES"; fi
done
sup=$(meta "$f" supersedes)
[ -z "$sup" ] && continue
printf '%s\n' "$sup" | grep -oE '\[\[[^]]+\]\]' | sed -E 's/^\[\[//; s/\]\]$//' |
while read -r ref; do
tgt=$(resolve "$ref")
if [ -z "$tgt" ]; then printf 'DEAD\t%s\t%s\n' "$id" "$ref" >> "$LINKS"
else printf '%s\t%s\tsupersedes\n' "$id" "$tgt" >> "$EDGES"; fi
done
done < "$NODES"
# dedup edges per (from,to), supersedes wins over wikilink
sort -t$'\t' -k1,1 -k2,2 -k3,3 "$EDGES" | awk -F'\t' '!seen[$1 FS $2]++' > "$TMP/edges.u"
# 3. Lint
echo "=== OMN graph ($(date +%F)) ==="
while IFS=$'\t' read -r _ src ref; do warn "dead link [[$ref]] in $src"; done < "$LINKS"
# stale neighbors: live source -> stale target
while IFS=$'\t' read -r from to kind; do
tf="$CORE/$to"; tdir=${to%%/*}; [ "$tdir" = "$to" ] && tdir=top
if stale_p "$tf" "$tdir"; then
sf="$CORE/$from"; sdir=${from%%/*}; [ "$sdir" = "$from" ] && sdir=top
stale_p "$sf" "$sdir" || warn "stale neighbor: $from ($kind) -> $to is STALE β€” update or tombstone $to"
fi
done < "$TMP/edges.u"
# orphans + god nodes (in-canon degree)
awk -F'\t' '{d[$1]++; d[$2]++} END{for (k in d) print k}' "$TMP/edges.u" > "$TMP/linked"
awk -F'\t' '{c[$2]++} END{for (k in c) print c[k], k}' "$TMP/edges.u" | sort -rn | head -3 > "$TMP/gods"
comm -23 <(cut -f1 "$NODES" | sort) <(sort "$TMP/linked") > "$TMP/orphans"
if [ -s "$TMP/orphans" ]; then
note "orphans (no canon edges): $(paste -sd' ' "$TMP/orphans")"
else
note "orphans: none"
fi
if [ -s "$TMP/gods" ]; then
note "god nodes (most-linked): $(awk '{printf "%s (%d)%s", $2, $1, (NR<3?", ":"")}' "$TMP/gods")"
fi
printf ' - graph.json written (%d nodes, %d edges)\n' \
"$(wc -l < "$NODES")" "$(wc -l < "$TMP/edges.u")"
# 4. Emit graph.json
{
printf '{\n "generated": "%s",\n' "$(date '+%F %T')"
printf ' "nodes": [\n'
first=1
while IFS=$'\t' read -r id st stale ti; do
[ $first -eq 1 ] || printf ',\n'; first=0
printf ' {"id": "%s", "status": "%s", "stale": %s, "title": "%s"}' "$id" "$st" "$stale" "$ti"
done < "$NODES"
printf '\n ],\n "edges": [\n'
first=1
while IFS=$'\t' read -r from to kind; do
[ $first -eq 1 ] || printf ',\n'; first=0
printf ' {"from": "%s", "to": "%s", "kind": "%s"}' "$from" "$to" "$kind"
done < "$TMP/edges.u"
printf '\n ]\n}\n'
} > "$OUT"
[ "$FAIL" -eq 0 ] && echo "PASS" || echo "REPORT: $FAIL finding(s) β€” report-only, exit 0"
exit 0