From ae46d97d1525abc04798c514990887612db84f2b Mon Sep 17 00:00:00 2001 From: baokhang83 Date: Wed, 15 Jul 2026 06:55:22 +0200 Subject: [PATCH] Refresh marketplace plugins at startup --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .fluencyloop/scripts/add-decision.sh | 65 ++++++ .fluencyloop/scripts/assemble-pr-view.sh | 87 ++++++++ .fluencyloop/scripts/calibration.sh | 190 ++++++++++++++++++ .fluencyloop/scripts/check.sh | 103 ++++++++++ .fluencyloop/scripts/common.sh | 160 +++++++++++++++ .fluencyloop/scripts/init.sh | 92 +++++++++ .fluencyloop/scripts/migrate.sh | 63 ++++++ .fluencyloop/scripts/new-feature.sh | 94 +++++++++ .fluencyloop/scripts/new-plan.sh | 61 ++++++ .fluencyloop/scripts/new-session.sh | 85 ++++++++ .fluencyloop/scripts/slice-context.sh | 118 +++++++++++ .fluencyloop/state.json | 8 + .fluencyloop/templates/constitution.md | 19 ++ .fluencyloop/templates/design.md | 26 +++ .fluencyloop/templates/plan.md | 67 ++++++ .fluencyloop/templates/session.md | 56 ++++++ .gitattributes | 4 + .gitignore | 3 + CHANGELOG.md | 11 + MANIFESTO.md | 15 +- README.md | 17 ++ docs/fluencyloop/constitution.md | 28 +++ .../design.md | 62 ++++++ ...startup-hook-that-refreshes-fluencyloop.md | 81 ++++++++ plugins/fluencyloop/.codex-plugin/plugin.json | 2 +- plugins/fluencyloop/VERSION | 2 +- plugins/fluencyloop/hooks/hooks.json | 18 ++ .../fluencyloop/hooks/refresh-marketplace.ps1 | 40 ++++ .../fluencyloop/hooks/refresh-marketplace.sh | 27 +++ tests/plugin.bats | 35 ++++ 32 files changed, 1633 insertions(+), 10 deletions(-) create mode 100755 .fluencyloop/scripts/add-decision.sh create mode 100755 .fluencyloop/scripts/assemble-pr-view.sh create mode 100755 .fluencyloop/scripts/calibration.sh create mode 100755 .fluencyloop/scripts/check.sh create mode 100755 .fluencyloop/scripts/common.sh create mode 100755 .fluencyloop/scripts/init.sh create mode 100755 .fluencyloop/scripts/migrate.sh create mode 100755 .fluencyloop/scripts/new-feature.sh create mode 100755 .fluencyloop/scripts/new-plan.sh create mode 100755 .fluencyloop/scripts/new-session.sh create mode 100755 .fluencyloop/scripts/slice-context.sh create mode 100644 .fluencyloop/state.json create mode 100644 .fluencyloop/templates/constitution.md create mode 100644 .fluencyloop/templates/design.md create mode 100644 .fluencyloop/templates/plan.md create mode 100644 .fluencyloop/templates/session.md create mode 100644 .gitignore create mode 100644 docs/fluencyloop/constitution.md create mode 100644 docs/fluencyloop/features/refresh-fluencyloop-marketplace-packages-automatically-when/design.md create mode 100644 docs/fluencyloop/features/refresh-fluencyloop-marketplace-packages-automatically-when/sessions/add-a-trusted-codex-startup-hook-that-refreshes-fluencyloop.md create mode 100644 plugins/fluencyloop/hooks/hooks.json create mode 100644 plugins/fluencyloop/hooks/refresh-marketplace.ps1 create mode 100644 plugins/fluencyloop/hooks/refresh-marketplace.sh diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 687c093..3e61d63 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "fluencyloop", "source": ".", "displayName": "FluencyLoop", - "version": "0.2.1", + "version": "0.2.2", "description": "A per-feature loop that designs, teaches, journals decisions, and assembles PR reviews.", "author": { "name": "baokhang83" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 0e70d68..f4ed201 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "fluencyloop", "displayName": "FluencyLoop", - "version": "0.2.1", + "version": "0.2.2", "description": "Stay fluent in the code your AI agent writes with a per-feature design, build, teaching, and review loop.", "author": { "name": "baokhang83", diff --git a/.fluencyloop/scripts/add-decision.sh b/.fluencyloop/scripts/add-decision.sh new file mode 100755 index 0000000..faa51e4 --- /dev/null +++ b/.fluencyloop/scripts/add-decision.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# add-decision.sh — deterministically assemble a `## Decision:` block and append it to the active +# session. The model supplies only the irreducible field values (the taught *why*); the script +# does the mechanical markdown formatting (the bullet schema), so the journal is consistently +# structured and the model never hand-formats it. +# +# Usage: add-decision.sh --where --why [--title ] [--alternative ] +# [--design ] [--constitution <§N>] [--trust ] +# [--session ] +# +# The session defaults to the active feature's last session (state.json); pass --session to target +# a specific file. Emits nothing to the file's schema the model has to remember. + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" +require_fluency + +TITLE=""; WHERE=""; WHY=""; ALT=""; DESIGN=""; CONST="" +TRUST="⚠ not independently verified"; SESSION="" +while [ "$#" -gt 0 ]; do + case "$1" in + --title) shift; TITLE="${1:-}" ;; + --where) shift; WHERE="${1:-}" ;; + --why) shift; WHY="${1:-}" ;; + --alternative) shift; ALT="${1:-}" ;; + --design) shift; DESIGN="${1:-}" ;; + --constitution) shift; CONST="${1:-}" ;; + --trust) shift; case "${1:-}" in + verified|✓*) TRUST="✓ verified" ;; + *) TRUST="⚠ not independently verified" ;; + esac ;; + --session) shift; SESSION="${1:-}" ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac + shift +done + +[ -n "$WHERE" ] || { echo "Error: --where is required (a file/area, never a line number)." >&2; exit 1; } +[ -n "$WHY" ] || { echo "Error: --why is required (the taught rationale)." >&2; exit 1; } + +# Resolve the session file: explicit --session, else the active feature's last session. +if [ -z "$SESSION" ]; then + rel="$(state_get last_session)" + [ -n "$rel" ] && SESSION="$(repo_root)/$rel" +fi +if [ -z "$SESSION" ] || [ ! -f "$SESSION" ]; then + echo "Error: no session file — open one with 'fluencyloop session \"\"' or pass --session." >&2 + exit 1 +fi + +[ -n "$TITLE" ] || TITLE="decision" + +{ + printf '\n## Decision: %s\n\n' "$TITLE" + printf -- '- **where:** %s\n' "\`$WHERE\`" + printf -- '- **why:** %s\n' "$WHY" + [ -n "$ALT" ] && printf -- '- **alternative:** %s\n' "$ALT" + [ -n "$DESIGN" ] && printf -- '- **design:** %s\n' "$DESIGN" + [ -n "$CONST" ] && printf -- '- **constitution:** %s\n' "$CONST" + printf -- '- **trust:** %s\n' "$TRUST" +} >> "$SESSION" + +echo "Appended decision \"$TITLE\" to $SESSION" diff --git a/.fluencyloop/scripts/assemble-pr-view.sh b/.fluencyloop/scripts/assemble-pr-view.sh new file mode 100755 index 0000000..756118b --- /dev/null +++ b/.fluencyloop/scripts/assemble-pr-view.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# assemble-pr-view.sh — Stage 4. A feature IS a branch, so the PR view assembles itself: +# gather the active feature's sessions and emit the raw material for a reviewer-facing +# summary. Deterministic collection only; the skill turns this into prose. +# +# Usage: assemble-pr-view.sh [--json] [--base ] [--slug ] +# --base defaults to the repo's main branch; used only to report the commit range. + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +JSON_MODE=false +BASE="" +FEATURE_SLUG="" +while [ "$#" -gt 0 ]; do + case "$1" in + --json) JSON_MODE=true ;; + --base) shift; BASE="${1:-}" ;; + --slug) shift; FEATURE_SLUG="${1:-}" ;; + esac + shift +done + +require_fluency + +[ -z "$FEATURE_SLUG" ] && FEATURE_SLUG="$(current_feature_slug)" +if [ -z "$FEATURE_SLUG" ]; then + echo "Error: no active feature branch. Checkout feature/ or pass --slug." >&2 + exit 1 +fi + +FEATURE="$(feature_path "$FEATURE_SLUG")" +[ -d "$FEATURE" ] || { echo "Error: feature '$FEATURE_SLUG' not found." >&2; exit 1; } + +# Resolve the base ref for the commit range (best-effort): explicit --base, else the base the +# feature recorded in state.json, else the repo's main/master. +[ -z "$BASE" ] && BASE="$(state_get base_ref)" +if [ -z "$BASE" ]; then + for cand in main master; do + git show-ref --verify --quiet "refs/heads/$cand" && { BASE="$cand"; break; } + done +fi +RANGE="" +COMMIT_COUNT=0 +if [ -n "$BASE" ] && git rev-parse --verify --quiet "$BASE" >/dev/null; then + RANGE="$BASE..HEAD" + COMMIT_COUNT="$(git rev-list --count "$RANGE" 2>/dev/null || echo 0)" +fi + +# Collect the sessions. +shopt -s nullglob +SESSIONS=("$FEATURE/sessions/"*.md) +shopt -u nullglob + +if $JSON_MODE; then + files="" + # Guard the expansion: on bash < 4.4 (macOS ships 3.2) "${arr[@]}" errors under `set -u` + # when the array is empty — which it is for a feature with no sessions yet. + if [ "${#SESSIONS[@]}" -gt 0 ]; then + for s in "${SESSIONS[@]}"; do files+="${files:+, }\"$(json_escape "$s")\""; done + fi + printf '{"feature":"%s","feature_dir":"%s","base":"%s","range":"%s","commits":%s,"session_count":%s,"sessions":[%s]}\n' \ + "$(json_escape "$FEATURE_SLUG")" "$(json_escape "$FEATURE")" \ + "$(json_escape "$BASE")" "$(json_escape "$RANGE")" \ + "$COMMIT_COUNT" "${#SESSIONS[@]}" "$files" + exit 0 +fi + +# Human/markdown form: the raw material for the reviewer summary. +FEATURE_TITLE="$(sed -n 's/^# Design: //p' "$FEATURE/design.md" 2>/dev/null | head -1)" +[ -z "$FEATURE_TITLE" ] && FEATURE_TITLE="$FEATURE_SLUG" + +echo "# PR view — $FEATURE_TITLE" +echo +[ -n "$RANGE" ] && echo "_$COMMIT_COUNT commit(s) over \`$RANGE\`; feature branch \`$(branch_for "$FEATURE_SLUG")\`._" && echo +if [ "${#SESSIONS[@]}" -eq 0 ]; then + echo "_No sessions journaled yet for this feature._" +else + for s in "${SESSIONS[@]}"; do + echo "---" + echo + cat "$s" + echo + done +fi diff --git a/.fluencyloop/scripts/calibration.sh b/.fluencyloop/scripts/calibration.sh new file mode 100755 index 0000000..822d7cc --- /dev/null +++ b/.fluencyloop/scripts/calibration.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# calibration.sh — the per-developer knowledge profile + its engagement ledger. Global +# (FLUENCYLOOP_HOME, default ~/.fluencyloop), never committed. +# +# Profile: under `## Profile`, one `dimension: level` line per domain, level in +# {fluent, familiar, learning, new}. The loop parses it to set teaching depth deterministically. +# +# Adaptation: the loop appends cheap one-line signals (`signal `) to +# an append-only ledger; `compact` deterministically rolls repeated signals into level changes +# (promote on wave-throughs, demote on deeper-asks / corrections). +# +# Usage: calibration.sh [init | show [--json] | edit +# | signal | compact [--dry-run]] + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +CAL="$(calibration_file)" +SIG="$(signals_file)" +ORDER=(new learning familiar fluent) # low -> high; promote = +1, demote = -1 +THRESHOLD=2 # net signals of one sign needed to move a level + +# Write the documented, self-describing starter profile (never clobbers an existing file). +seed() { + mkdir -p "$(dirname "$CAL")" + cat > "$CAL" <<'EOF' +# FluencyLoop calibration + + + +## Levels + +- **fluent** — reasons about it unaided; teach terse, flag only what's checkable. +- **familiar** — knows the shape; confirm, don't re-derive. +- **learning** — actively building it; teach the why and check understanding. +- **new** — first contact; teach from fundamentals. + +## Profile + + +EOF +} + +# Emit `dimension level` pairs from `## Profile`. Portable awk (no gawk-only features): skips +# HTML-comment blocks (so the seed's `` lines don't count), other headings, and +# any line that isn't `: `. +_profile_pairs() { + [ -f "$CAL" ] || return 0 + awk ' + { + if (incomment) { if ($0 ~ /-->/) incomment=0; next } + if ($0 ~ //) incomment=1; next } + if ($0 ~ /^## Profile/) { p=1; next } + if ($0 ~ /^## /) { p=0 } + if (p && $0 ~ /^[A-Za-z0-9][A-Za-z0-9._+-]*:[[:space:]]*(fluent|familiar|learning|new)([[:space:]]|$)/) { + dim=$0; sub(/:.*/, "", dim); gsub(/[[:space:]]/, "", dim) + lvl=$0; sub(/^[^:]*:[[:space:]]*/, "", lvl); sub(/[[:space:]].*/, "", lvl) + print dim, lvl + } + } + ' "$CAL" +} + +levels_json() { + _profile_pairs | awk 'BEGIN{printf "{"} {printf "%s\"%s\":\"%s\"", (n++?",":""), $1, $2} END{print "}"}' +} + +current_level() { _profile_pairs | awk -v d="$1" '$1==d{print $2; exit}'; } + +# Shift a level one step (up|down), clamped to the ends of ORDER. +shift_level() { + local cur="$1" dir="$2" idx=0 i + for i in 0 1 2 3; do [ "${ORDER[$i]}" = "$cur" ] && idx=$i; done + if [ "$dir" = up ]; then idx=$((idx + 1)); [ "$idx" -gt 3 ] && idx=3 + else idx=$((idx - 1)); [ "$idx" -lt 0 ] && idx=0; fi + printf '%s' "${ORDER[$idx]}" +} + +# Set dimension's level in `## Profile`, preserving any trailing note; append the line if the +# dimension isn't there yet. Comment-aware, literal dimension match (no regex-metachar surprises). +apply_level() { + local dim="$1" newlvl="$2" tmp; tmp="$(mktemp)" + awk -v dim="$dim" -v newlvl="$newlvl" ' + BEGIN { done=0 } + { + line=$0 + if (incomment) { if (line ~ /-->/) incomment=0; print; next } + if (line ~ //) incomment=1; print; next } + if (line ~ /^## Profile/) { p=1; print; next } + if (line ~ /^## /) { if (p && !done) { print dim ": " newlvl; done=1 } p=0; print; next } + if (p && !done) { + d=line; sub(/:.*/, "", d); gsub(/[[:space:]]/, "", d) + if (d==dim && line ~ /:[[:space:]]*(fluent|familiar|learning|new)([[:space:]]|$)/) { + rest=line; sub(/^[^:]*:[[:space:]]*[A-Za-z]+/, "", rest) + print dim ": " newlvl rest; done=1; next + } + } + print + } + END { if (!done) print dim ": " newlvl } + ' "$CAL" > "$tmp" && mv "$tmp" "$CAL" +} + +reset_signals() { + mkdir -p "$(dirname "$SIG")" + printf '# FluencyLoop calibration signals — append-only; rolled into levels by: fluencyloop calibration compact\n' > "$SIG" +} + +SUB="${1:-show}"; shift || true + +case "$SUB" in + init) + if [ -f "$CAL" ]; then echo "Calibration profile already exists: $CAL" + else seed; echo "Created calibration profile: $CAL"; fi + ;; + show) + if [ "${1:-}" = "--json" ]; then levels_json + elif [ -f "$CAL" ]; then cat "$CAL" + else echo "No calibration profile yet — run 'fluencyloop calibration init'." >&2; exit 1; fi + ;; + edit) + [ -f "$CAL" ] || seed + exec "${EDITOR:-vi}" "$CAL" + ;; + signal) + # Accept one OR MANY pairs in a single call, so a slice's signals are + # one command (one approval prompt), not N. + if [ "$#" -lt 2 ] || [ $(( $# % 2 )) -ne 0 ]; then + echo "Usage: fluencyloop calibration signal [ ...]" >&2; exit 1 + fi + i=1 + for a in "$@"; do + if [ $(( i % 2 )) -eq 0 ]; then + case "$a" in wave|deeper|correct) ;; *) + echo "signal type must be wave|deeper|correct (got '$a')" >&2; exit 1 ;; + esac + fi + i=$((i + 1)) + done + [ -f "$SIG" ] || reset_signals + today_str="$(today)" + while [ "$#" -ge 2 ]; do + printf '%s %s %s\n' "$today_str" "$1" "$2" >> "$SIG" + shift 2 + done + ;; + compact) + dry=false; [ "${1:-}" = "--dry-run" ] && dry=true + if [ ! -f "$SIG" ]; then echo "No signals to compact."; exit 0; fi + # Net score per dimension: wave +1, deeper/correct -1. + scores="$(awk ' + /^#/ { next } NF < 3 { next } + { s=$3; v=0; if (s=="wave") v=1; else if (s=="deeper"||s=="correct") v=-1; else next; agg[$2]+=v } + END { for (d in agg) print d, agg[d] } + ' "$SIG")" + changed=0 + while read -r dim score; do + [ -z "$dim" ] && continue + if [ "$score" -ge "$THRESHOLD" ]; then dir=up + elif [ "$score" -le "-$THRESHOLD" ]; then dir=down + else continue; fi + cur="$(current_level "$dim")"; [ -z "$cur" ] && cur=new + new="$(shift_level "$cur" "$dir")" + [ "$new" = "$cur" ] && continue + $dry || apply_level "$dim" "$new" + printf '%s %s: %s -> %s\n' "$([ "$dir" = up ] && echo '▲' || echo '▼')" "$dim" "$cur" "$new" + changed=$((changed + 1)) + done < | compact [--dry-run]]" >&2 + exit 1 + ;; +esac diff --git a/.fluencyloop/scripts/check.sh b/.fluencyloop/scripts/check.sh new file mode 100755 index 0000000..4e7a487 --- /dev/null +++ b/.fluencyloop/scripts/check.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# check.sh — the FluencyLoop doctor. A deterministic drift/state detector so skills make cheap +# decisions without the model scanning git. Reports whether the loop is set up, the active +# feature (from state.json), how many commits have landed since the last journaled session +# (un-journaled drift — the signal review/backfill care about), and whether the per-developer +# calibration profile exists. +# +# Usage: check.sh [--json] + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +JSON_MODE=false +for arg in "$@"; do + case "$arg" in + --json) JSON_MODE=true ;; + *) echo "Unknown option: $arg" >&2; exit 1 ;; + esac +done + +ROOT="$(repo_root)" + +# --- is the loop set up here? --- +FLUENCY_DIR="$(fluency_dir)" +FLUENCY_PRESENT=false +[ -n "$FLUENCY_DIR" ] && [ -d "$FLUENCY_DIR" ] && FLUENCY_PRESENT=true + +# --- active feature: from state.json, falling back to the branch --- +BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +FEATURE="$(state_get feature)" +[ -z "$FEATURE" ] && FEATURE="$(current_feature_slug)" +STAGE="$(state_get stage)" +BASE="$(state_get base_ref)"; [ -z "$BASE" ] && BASE="main" +LAST_SESSION="$(state_get last_session)" + +# --- un-journaled drift: commits since the last commit that touched the sessions dir. If no +# session has ever been committed, everything since the base ref counts as un-journaled. --- +UNJOURNALED=0 +if [ -n "$ROOT" ] && [ -n "$FEATURE" ]; then + SESSIONS_DIR="$(feature_path "$FEATURE")/sessions" + LAST_JOURNAL_COMMIT="$(git log -1 --format=%H -- "$SESSIONS_DIR" 2>/dev/null || true)" + if [ -n "$LAST_JOURNAL_COMMIT" ]; then + UNJOURNALED="$(git rev-list --count "$LAST_JOURNAL_COMMIT..HEAD" 2>/dev/null || echo 0)" + elif git rev-parse --verify --quiet "$BASE" >/dev/null 2>&1; then + UNJOURNALED="$(git rev-list --count "$BASE..HEAD" 2>/dev/null || echo 0)" + fi +fi + +# --- per-developer calibration profile (global, never committed) --- +CAL_FILE="$(calibration_file)" +CAL_PRESENT=false +[ -f "$CAL_FILE" ] && CAL_PRESENT=true + +# --- constitution: absent / empty stub / a pointer / populated. It's born from the first plan +# or feature and grows by harvest, so an absent-or-empty constitution is normal, never an error. --- +CONSTITUTION="$(constitution_path)" +CONSTITUTION_STATE="absent" +if [ -n "$CONSTITUTION" ] && [ -f "$CONSTITUTION" ]; then + if grep -q "Source of truth:" "$CONSTITUTION" 2>/dev/null; then + CONSTITUTION_STATE="pointer" + elif grep -qi "none yet" "$CONSTITUTION" 2>/dev/null || ! grep -q "§" "$CONSTITUTION" 2>/dev/null; then + CONSTITUTION_STATE="empty" + else + CONSTITUTION_STATE="present" + fi +fi + +if $JSON_MODE; then + printf '{"fluency":%s,"branch":"%s","feature":"%s","stage":"%s","base_ref":"%s","last_session":"%s","unjournaled_commits":%s,"calibration":%s,"constitution":"%s"}\n' \ + "$FLUENCY_PRESENT" \ + "$(json_escape "$BRANCH")" \ + "$(json_escape "$FEATURE")" \ + "$(json_escape "$STAGE")" \ + "$(json_escape "$BASE")" \ + "$(json_escape "$LAST_SESSION")" \ + "$UNJOURNALED" \ + "$CAL_PRESENT" \ + "$CONSTITUTION_STATE" + exit 0 +fi + +# Human form. +mark() { [ "$1" = true ] && printf 'ok ' || printf 'XX '; } +echo "FluencyLoop check" +echo " $(mark "$FLUENCY_PRESENT") .fluencyloop/ present" +if [ -n "$FEATURE" ]; then + echo " ok active feature: $FEATURE${STAGE:+ (stage: $STAGE)}" +else + echo " XX no active feature" +fi +if [ "$UNJOURNALED" -gt 0 ]; then + echo " !! $UNJOURNALED commit(s) since the last journaled session — un-journaled drift" +else + echo " ok no un-journaled drift" +fi +echo " $(mark "$CAL_PRESENT") calibration profile ($CAL_FILE)" +case "$CONSTITUTION_STATE" in + present) echo " ok constitution: populated" ;; + pointer) echo " ok constitution: points to a source of truth" ;; + *) echo " -- no constitution yet — written from your first plan or feature" ;; +esac diff --git a/.fluencyloop/scripts/common.sh b/.fluencyloop/scripts/common.sh new file mode 100755 index 0000000..8663451 --- /dev/null +++ b/.fluencyloop/scripts/common.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# common.sh — shared helpers for FluencyLoop scripts. +# Deterministic plumbing only: no LLM, no interactivity. Sourced by the other scripts. + +set -euo pipefail + +# --- repo + paths --------------------------------------------------------- + +# Absolute path to the git repo root, or empty if not in a repo. +repo_root() { + git rev-parse --show-toplevel 2>/dev/null || true +} + +# Absolute path to the project's .fluencyloop directory. MACHINE STATE ONLY lives here: +# the vendored scripts/ and templates/ plumbing. Human-facing artifacts live under docs_dir. +fluency_dir() { + local root; root="$(repo_root)" + [ -n "$root" ] && printf '%s/.fluencyloop' "$root" +} + +# Absolute path to the project's human-facing FluencyLoop docs. The constitution, per-feature +# design.md, and session journals live here — visible and committed, namespaced under docs/ so +# they don't collide with a project's own documentation. +docs_dir() { + local root; root="$(repo_root)" + [ -n "$root" ] && printf '%s/docs/fluencyloop' "$root" +} + +# The project constitution (a human doc). Lives under docs_dir now, with the same back-compat +# fallback as feature_path: if only the pre-refactor copy (.fluencyloop/constitution.md) exists, +# resolve to it so init won't orphan it and readers find it until `fluencyloop migrate` runs. +constitution_path() { + local new old + new="$(docs_dir)/constitution.md" + old="$(fluency_dir)/constitution.md" + if [ ! -f "$new" ] && [ -f "$old" ]; then + printf '%s' "$old" + else + printf '%s' "$new" + fi +} + +# Fail unless FluencyLoop has been initialised in this repo. +require_fluency() { + local dir; dir="$(fluency_dir)" + if [ -z "$dir" ] || [ ! -d "$dir" ]; then + echo "Error: FluencyLoop is not initialised here. Run 'fluencyloop init' first." >&2 + exit 1 + fi +} + +# --- text helpers --------------------------------------------------------- + +# Turn a free-text intent into a filesystem/branch-safe slug. +# "Adding Rate Limiting to the Gateway!" -> "adding-rate-limiting-to-the-gateway" +slugify() { + printf '%s' "$1" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' \ + | cut -c1-60 \ + | sed -E 's/-+$//' +} + +# Today, ISO date. +today() { date +%Y-%m-%d; } + +# Minimal JSON string escaper (quotes, backslashes, newlines). +json_escape() { + printf '%s' "$1" | sed -E 's/\\/\\\\/g; s/"/\\"/g' | awk 'BEGIN{ORS=""} {print (NR>1?"\\n":"") $0}' +} + +# Emit a flat JSON object from alternating key value arguments. +# emit_json k1 v1 k2 v2 ... +emit_json() { + local out="{" first=1 + while [ "$#" -ge 2 ]; do + [ "$first" -eq 1 ] || out+="," + first=0 + out+="\"$1\":\"$(json_escape "$2")\"" + shift 2 + done + out+="}" + printf '%s\n' "$out" +} + +# --- feature/branch model ------------------------------------------------- +# A feature IS a branch: feature/. The feature dir mirrors the slug. + +branch_for() { printf 'feature/%s' "$1"; } # slug -> branch name + +# slug -> feature dir. Lives under docs_dir now. Back-compat: if a feature still sits at the +# pre-refactor location (.fluencyloop/features/) and hasn't been migrated, resolve to +# that so existing repos keep working until they run `fluencyloop migrate`. +feature_path() { + local slug="$1" new old + new="$(docs_dir)/features/$slug" + old="$(fluency_dir)/features/$slug" + if [ ! -d "$new" ] && [ -d "$old" ]; then + printf '%s' "$old" + else + printf '%s' "$new" + fi +} + +# slug -> plan dir. A plan is an initiative that spawns several features; it is a committed +# doc (no dedicated branch), and lives under docs_dir alongside features. +plan_path() { printf '%s/plans/%s' "$(docs_dir)" "$1"; } + +# The active feature slug, derived from the current branch (empty if not on one). +current_feature_slug() { + local b; b="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + case "$b" in + feature/*) printf '%s' "${b#feature/}" ;; + *) printf '' ;; + esac +} + +# --- loop state ----------------------------------------------------------- +# One source of truth for the active feature's loop state, so a skill reads it instead of +# re-deriving from git every turn. Machine state, so it lives in .fluencyloop/; committed with +# the feature branch (part of the journal record). Written by new-feature.sh / new-session.sh. + +state_path() { local d; d="$(fluency_dir)"; [ -n "$d" ] && printf '%s/state.json' "$d"; } + +# A repo-relative path (state stores paths relative to the repo root, so they survive a move). +repo_rel() { local root; root="$(repo_root)"; printf '%s' "${1#"$root"/}"; } + +# Read one string field from state.json (empty if the file or key is absent). Safe because we +# control the format write_state emits (one "key": "value" per line, values never contain "). +state_get() { + local f; f="$(state_path)" + [ -f "$f" ] || return 0 + sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" "$f" | head -n1 +} + +# Write state.json from alternating key value arguments (all string-valued), creating it. +# write_state feature "$SLUG" branch "$BRANCH" stage design ... +write_state() { + local f; f="$(state_path)" + [ -n "$f" ] || return 0 + mkdir -p "$(dirname "$f")" + local out="{" first=1 k v + while [ "$#" -ge 2 ]; do + k="$1"; v="$2"; shift 2 + [ "$first" -eq 1 ] || out+="," + first=0 + out+=$'\n '"\"$k\": \"$(json_escape "$v")\"" + done + out+=$'\n}' + printf '%s\n' "$out" > "$f" +} + +# --- calibration ---------------------------------------------------------- +# The per-developer knowledge profile: global, never committed. Lives under FLUENCYLOOP_HOME +# (default ~/.fluencyloop), NOT in the repo — it is the only place person-specific knowledge lives. +calibration_file() { printf '%s/calibration.md' "${FLUENCYLOOP_HOME:-$HOME/.fluencyloop}"; } + +# Append-only ledger of engagement signals (wave/deeper/correct per dimension). Global, never +# committed. `calibration compact` rolls it into level changes in the profile above. +signals_file() { printf '%s/signals.log' "${FLUENCYLOOP_HOME:-$HOME/.fluencyloop}"; } diff --git a/.fluencyloop/scripts/init.sh b/.fluencyloop/scripts/init.sh new file mode 100755 index 0000000..01c2a47 --- /dev/null +++ b/.fluencyloop/scripts/init.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# init.sh — scaffold FluencyLoop into the current repo (Stage 0, once per project). +# Creates .fluencyloop/ for machine state (scripts + templates) and docs/fluencyloop/ for the +# human-facing artifacts (constitution stub; per-feature design + sessions land here later). +# Skills are activated by the coding agent's own installation mechanism; they are never copied +# into a project. +# +# Usage: init.sh [--json] + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +JSON_MODE=false +while [ "$#" -gt 0 ]; do + arg="$1" + case "$arg" in + --json) JSON_MODE=true ;; + *) echo "Unknown option: $arg" >&2; exit 1 ;; + esac + shift +done + +ROOT="$(repo_root)" +if [ -z "$ROOT" ]; then + echo "Error: 'fluencyloop init' must be run inside a git repository." >&2 + exit 1 +fi + +# The distribution root is two levels up from scripts/bash. +DIST_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +FLUENCY="$ROOT/.fluencyloop" # machine state: scripts + templates +DOCS="$(docs_dir)" # human-facing docs: constitution, designs, sessions + +mkdir -p "$FLUENCY/scripts" "$FLUENCY/templates" "$DOCS" + +# Copy scripts + templates into the project so the tool is self-contained per repo. +cp "$DIST_ROOT/scripts/bash/"*.sh "$FLUENCY/scripts/" +cp "$DIST_ROOT/templates/"*.md "$FLUENCY/templates/" + +# Seed the constitution from the template if absent (never clobber an existing one). +CONSTITUTION="$(constitution_path)" +CREATED_CONSTITUTION=false +if [ ! -f "$CONSTITUTION" ]; then + cp "$DIST_ROOT/templates/constitution.md" "$CONSTITUTION" + CREATED_CONSTITUTION=true +fi + +# A feature is a branch, and session journals are committed — but the per-developer +# calibration profile is global and must never be committed. Guard against a project +# accidentally vendoring one. +GITIGNORE="$ROOT/.gitignore" +if ! { [ -f "$GITIGNORE" ] && grep -qxF '.fluencyloop/**/calibration.md' "$GITIGNORE"; }; then + printf '\n# FluencyLoop: calibration is per-developer and never committed\n.fluencyloop/**/calibration.md\n' >> "$GITIGNORE" +fi + +# FluencyLoop writes its state + docs with LF. Pin those paths in .gitattributes so Git doesn't +# warn ("LF will be replaced by CRLF") or convert them on Windows checkouts (autocrlf). Scoped to +# FluencyLoop's own paths — the project's own line-ending policy is left to the project. +GITATTR="$ROOT/.gitattributes" +if ! { [ -f "$GITATTR" ] && grep -qF '.fluencyloop/** text eol=lf' "$GITATTR"; }; then + printf '\n# FluencyLoop writes these LF; pin so Git does not warn/convert on Windows.\n.fluencyloop/** text eol=lf\ndocs/fluencyloop/** text eol=lf\n' >> "$GITATTR" +fi + +# FluencyLoop creates a branch per feature. Set push.autoSetupRemote (repo-local) so the +# first `git push` on a new feature branch sets its upstream automatically — no +# `git push --set-upstream` friction. (git >= 2.37; ignored on older versions.) +AUTO_REMOTE_SET=false +if [ "$(git -C "$ROOT" config --local push.autoSetupRemote 2>/dev/null)" != "true" ]; then + git -C "$ROOT" config --local push.autoSetupRemote true + AUTO_REMOTE_SET=true +fi + +if $JSON_MODE; then + emit_json \ + fluency_dir "$FLUENCY" \ + docs_dir "$DOCS" \ + constitution "$CONSTITUTION" \ + constitution_created "$CREATED_CONSTITUTION" \ + push_autoremote_set "$AUTO_REMOTE_SET" +else + echo "Initialised FluencyLoop" + echo " state: $FLUENCY (scripts + templates)" + echo " docs: $DOCS (constitution, designs, session journals)" + if $AUTO_REMOTE_SET; then + echo " git: push.autoSetupRemote=true (feature branches push without --set-upstream)" + fi + if $CREATED_CONSTITUTION; then + echo " constitution: $CONSTITUTION (empty — written from your first plan or feature)" + fi +fi diff --git a/.fluencyloop/scripts/migrate.sh b/.fluencyloop/scripts/migrate.sh new file mode 100755 index 0000000..9d9e253 --- /dev/null +++ b/.fluencyloop/scripts/migrate.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# migrate.sh — one-time move of human-facing docs from the pre-refactor location +# (.fluencyloop/constitution.md and .fluencyloop/features/) to docs/fluencyloop/. +# Idempotent: safe to run more than once; a no-op once everything is already moved. +# Machine state (scripts/, templates/) is left in .fluencyloop/ untouched. +# +# Usage: migrate.sh [--json] [--dry-run] + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +JSON_MODE=false +DRY_RUN=false +for arg in "$@"; do + case "$arg" in + --json) JSON_MODE=true ;; + --dry-run) DRY_RUN=true ;; + esac +done + +require_fluency + +OLD_FLUENCY="$(fluency_dir)" +DOCS="$(docs_dir)" +MOVED=() + +# Move a tracked path with `git mv` (keeps history) or a plain `mv` if untracked/no git. +move_path() { + local src="$1" dest="$2" + [ -e "$src" ] || return 0 + if [ -e "$dest" ]; then + echo "Skip: $dest already exists (leaving $src in place)." >&2 + return 0 + fi + MOVED+=("$src -> $dest") + $DRY_RUN && return 0 + mkdir -p "$(dirname "$dest")" + if git ls-files --error-unmatch "$src" >/dev/null 2>&1; then + git mv "$src" "$dest" + else + mv "$src" "$dest" + fi +} + +move_path "$OLD_FLUENCY/constitution.md" "$DOCS/constitution.md" +move_path "$OLD_FLUENCY/features" "$DOCS/features" + +if $JSON_MODE; then + files="" + for m in ${MOVED+"${MOVED[@]}"}; do files+="${files:+, }\"$(json_escape "$m")\""; done + printf '{"docs_dir":"%s","dry_run":%s,"moved_count":%s,"moved":[%s]}\n' \ + "$(json_escape "$DOCS")" "$DRY_RUN" "${#MOVED[@]}" "$files" +else + if [ "${#MOVED[@]}" -eq 0 ]; then + echo "Nothing to migrate — docs are already under $DOCS (or none exist yet)." + else + $DRY_RUN && echo "Would migrate (dry run):" || echo "Migrated to $DOCS:" + for m in "${MOVED[@]}"; do echo " $m"; done + $DRY_RUN || echo "Review and commit the move." + fi +fi diff --git a/.fluencyloop/scripts/new-feature.sh b/.fluencyloop/scripts/new-feature.sh new file mode 100755 index 0000000..85ff1dd --- /dev/null +++ b/.fluencyloop/scripts/new-feature.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# new-feature.sh — declare a feature (Stage 2 entry). Deterministic: creates the branch +# and the feature dir with a design.md stub, then reports paths for the skill to fill in. +# A feature IS a branch (feature/); the design/build/journal all live under it. +# +# Usage: new-feature.sh [--json] [--slug ] + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +JSON_MODE=false +SLUG="" +ARGS=() +while [ "$#" -gt 0 ]; do + case "$1" in + --json) JSON_MODE=true ;; + --slug) shift; SLUG="${1:-}" ;; + *) ARGS+=("$1") ;; + esac + shift +done + +require_fluency + +INTENT="${ARGS[*]:-}" +INTENT="$(printf '%s' "$INTENT" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')" +if [ -z "$INTENT" ]; then + echo "Error: a feature needs an intent, e.g. fluencyloop feature \"adding rate limiting\"" >&2 + exit 1 +fi + +[ -z "$SLUG" ] && SLUG="$(slugify "$INTENT")" +BRANCH="$(branch_for "$SLUG")" +FEATURE="$(feature_path "$SLUG")" + +# Switch to the feature branch (create it if new, from the current HEAD). Capture what we +# forked from as the base ref (used later for the PR-view diff). +CREATED_BRANCH=false +BASE_REF="" +if git show-ref --verify --quiet "refs/heads/$BRANCH"; then + git checkout "$BRANCH" >/dev/null 2>&1 +else + BASE_REF="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)" + git checkout -b "$BRANCH" >/dev/null 2>&1 + CREATED_BRANCH=true +fi +# On a re-run the branch already existed: keep the base ref state already recorded. +[ -z "$BASE_REF" ] && BASE_REF="$(state_get base_ref)" +[ -z "$BASE_REF" ] && BASE_REF="main" + +mkdir -p "$FEATURE/sessions" + +DESIGN="$FEATURE/design.md" +CREATED_DESIGN=false +if [ ! -f "$DESIGN" ]; then + TEMPLATE="$(fluency_dir)/templates/design.md" + sed -e "s/{{FEATURE}}/$(printf '%s' "$INTENT" | sed 's/[&/\]/\\&/g')/g" \ + -e "s/{{DATE}}/$(today)/g" \ + "$TEMPLATE" > "$DESIGN" + CREATED_DESIGN=true +fi + +# Record loop state: the single source of truth a skill reads instead of re-scanning git. +# Declaring a feature lands it at the design stage, with no session yet. +write_state \ + feature "$SLUG" \ + branch "$BRANCH" \ + stage "design" \ + last_session "" \ + base_ref "$BASE_REF" \ + updated "$(today)" +STATE="$(state_path)" + +if $JSON_MODE; then + emit_json \ + slug "$SLUG" \ + intent "$INTENT" \ + branch "$BRANCH" \ + branch_created "$CREATED_BRANCH" \ + feature_dir "$FEATURE" \ + design "$DESIGN" \ + design_created "$CREATED_DESIGN" \ + sessions_dir "$FEATURE/sessions" \ + base_ref "$BASE_REF" \ + state "$STATE" +else + echo "Feature: $INTENT" + echo " branch: $BRANCH$($CREATED_BRANCH && echo ' (created)')" + echo " design: $DESIGN$($CREATED_DESIGN && echo ' (stub)')" + echo " sessions: $FEATURE/sessions/" + echo " state: $STATE (stage: design, base: $BASE_REF)" +fi diff --git a/.fluencyloop/scripts/new-plan.sh b/.fluencyloop/scripts/new-plan.sh new file mode 100755 index 0000000..9b4c6ac --- /dev/null +++ b/.fluencyloop/scripts/new-plan.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# new-plan.sh — declare a plan (the planning stage). Deterministic: scaffolds a plan.md for a +# large initiative from the template and reports paths for the skill to fill in. Unlike a +# feature, a plan does NOT create a branch — it is a committed doc on the current branch that +# maps out the architecture, task breakdown, roadmap, and critical path the features build from. +# +# Usage: new-plan.sh [--json] [--slug ] + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +JSON_MODE=false +SLUG="" +ARGS=() +while [ "$#" -gt 0 ]; do + case "$1" in + --json) JSON_MODE=true ;; + --slug) shift; SLUG="${1:-}" ;; + *) ARGS+=("$1") ;; + esac + shift +done + +require_fluency + +INTENT="${ARGS[*]:-}" +INTENT="$(printf '%s' "$INTENT" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')" +if [ -z "$INTENT" ]; then + echo "Error: a plan needs an intent, e.g. fluencyloop plan \"revamp the checkout flow\"" >&2 + exit 1 +fi + +[ -z "$SLUG" ] && SLUG="$(slugify "$INTENT")" +PLAN_DIR="$(plan_path "$SLUG")" +PLAN="$PLAN_DIR/plan.md" + +mkdir -p "$PLAN_DIR" + +CREATED=false +if [ ! -f "$PLAN" ]; then + TEMPLATE="$(fluency_dir)/templates/plan.md" + esc_intent="$(printf '%s' "$INTENT" | sed 's/[&/\]/\\&/g')" + sed -e "s/{{INITIATIVE}}/$esc_intent/g" \ + -e "s/{{DATE}}/$(today)/g" \ + "$TEMPLATE" > "$PLAN" + CREATED=true +fi + +if $JSON_MODE; then + emit_json \ + slug "$SLUG" \ + intent "$INTENT" \ + plan_dir "$PLAN_DIR" \ + plan "$PLAN" \ + created "$CREATED" +else + echo "Plan: $INTENT" + echo " file: $PLAN$($CREATED && echo ' (stub)')" +fi diff --git a/.fluencyloop/scripts/new-session.sh b/.fluencyloop/scripts/new-session.sh new file mode 100755 index 0000000..2e41df0 --- /dev/null +++ b/.fluencyloop/scripts/new-session.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# new-session.sh — open a session inside the active feature (Stage 3). A session is a +# slice of the build; the skill appends decision blocks to it as it teaches. Deterministic: +# creates the file from the template; the LLM supplies the content. +# +# Usage: new-session.sh [--json] [--slug ] + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +JSON_MODE=false +FEATURE_SLUG="" +ARGS=() +while [ "$#" -gt 0 ]; do + case "$1" in + --json) JSON_MODE=true ;; + --slug) shift; FEATURE_SLUG="${1:-}" ;; + *) ARGS+=("$1") ;; + esac + shift +done + +require_fluency + +# Default the feature to whatever the current branch says. +[ -z "$FEATURE_SLUG" ] && FEATURE_SLUG="$(current_feature_slug)" +if [ -z "$FEATURE_SLUG" ]; then + echo "Error: no active feature. Checkout a feature/ branch or pass --slug." >&2 + exit 1 +fi + +FEATURE="$(feature_path "$FEATURE_SLUG")" +if [ ! -d "$FEATURE" ]; then + echo "Error: feature '$FEATURE_SLUG' not found at $FEATURE. Run 'fluencyloop feature' first." >&2 + exit 1 +fi + +INTENT="${ARGS[*]:-}" +INTENT="$(printf '%s' "$INTENT" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')" +if [ -z "$INTENT" ]; then + echo "Error: a session needs an intent, e.g. 'wiring the Redis store'." >&2 + exit 1 +fi + +SESSION_SLUG="$(slugify "$INTENT")" +SESSION="$FEATURE/sessions/$SESSION_SLUG.md" + +CREATED=false +if [ ! -f "$SESSION" ]; then + TEMPLATE="$(fluency_dir)/templates/session.md" + esc_intent="$(printf '%s' "$INTENT" | sed 's/[&/\]/\\&/g')" + sed -e "s/{{SESSION}}/$esc_intent/g" \ + -e "s/{{INTENT}}/$esc_intent/g" \ + -e "s/{{DATE}}/$(today)/g" \ + "$TEMPLATE" > "$SESSION" + CREATED=true +fi + +# Update loop state: opening a session moves the feature to the build stage and points at the +# current session file. Preserve the base ref recorded when the feature was declared. +BASE_REF="$(state_get base_ref)"; [ -z "$BASE_REF" ] && BASE_REF="main" +write_state \ + feature "$FEATURE_SLUG" \ + branch "$(branch_for "$FEATURE_SLUG")" \ + stage "build" \ + last_session "$(repo_rel "$SESSION")" \ + base_ref "$BASE_REF" \ + updated "$(today)" +STATE="$(state_path)" + +if $JSON_MODE; then + emit_json \ + feature "$FEATURE_SLUG" \ + session_slug "$SESSION_SLUG" \ + intent "$INTENT" \ + session "$SESSION" \ + created "$CREATED" \ + state "$STATE" +else + echo "Session: $INTENT" + echo " file: $SESSION$($CREATED && echo ' (created)')" + echo " state: $STATE (stage: build)" +fi diff --git a/.fluencyloop/scripts/slice-context.sh b/.fluencyloop/scripts/slice-context.sh new file mode 100755 index 0000000..4f72190 --- /dev/null +++ b/.fluencyloop/scripts/slice-context.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# slice-context.sh — the changed hunks + metadata for the current slice, so the model identifies +# decisions from the diff instead of re-reading whole files (token-cheap). The slice is everything +# since the last journaled session (the last commit touching the feature's sessions dir), or the +# feature's base ref if none yet, through the working tree — including untracked files. +# +# Usage: slice-context.sh [--json] + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +require_fluency +cd "$(repo_root)" # normalize pathspecs to the repo root regardless of where we're invoked + +JSON_MODE=false +for arg in "$@"; do + case "$arg" in + --json) JSON_MODE=true ;; + *) echo "Unknown option: $arg" >&2; exit 1 ;; + esac +done + +# The slice is the developer's CODE, not FluencyLoop's own bookkeeping — exclude the tool's paths. +EXCLUDE=(-- . ':!.fluencyloop' ':!docs/fluencyloop') + +FEATURE="$(state_get feature)"; [ -z "$FEATURE" ] && FEATURE="$(current_feature_slug)" +BASE_REF="$(state_get base_ref)"; [ -z "$BASE_REF" ] && BASE_REF="main" + +# Where the slice starts: the last journaled session, else the feature's base ref, else HEAD. +SINCE=""; BASE_KIND="base-ref" +if [ -n "$FEATURE" ]; then + SDIR="$(feature_path "$FEATURE")/sessions" + LAST_JOURNAL="$(git log -1 --format=%H -- "$SDIR" 2>/dev/null || true)" + [ -n "$LAST_JOURNAL" ] && { SINCE="$LAST_JOURNAL"; BASE_KIND="last-session"; } +fi +[ -z "$SINCE" ] && SINCE="$BASE_REF" +if ! git rev-parse --verify --quiet "$SINCE" >/dev/null 2>&1; then + SINCE="HEAD"; BASE_KIND="head" +fi + +# Untracked files are part of the slice — render each as an added-file diff (no index changes). +untracked_diff() { + git ls-files --others --exclude-standard -z "${EXCLUDE[@]}" | while IFS= read -r -d '' f; do + git diff --no-index -- /dev/null "$f" 2>/dev/null || true + done +} + +DIFF="$( git diff "$SINCE" "${EXCLUDE[@]}"; untracked_diff )" + +read -r INS DEL TRACKED_FILES </dev/null || printf '%s' "$SINCE")" + +# --- decision pre-filter: cheap heuristics over the slice's ADDED lines, so the model only spends +# a full teaching pass where a real decision probably lives. New imports/deps, new public API / +# exports, new control flow, and change size each contribute; likely_decision = score >= 2. +HEUR="$(printf '%s\n' "$DIFF" | awk ' + function base(p, n,a){ n=split(p,a,"/"); return a[n] } + /^\+\+\+ / { f=$2; sub(/^b\//,"",f); bn=base(f) + manifest = (bn ~ /^(package\.json|pom\.xml|go\.mod|Cargo\.toml|Gemfile|composer\.json|pyproject\.toml|requirements[^ ]*\.txt|Pipfile|build\.gradle(\.kts)?)$/) + next } + /^(---|diff |index |@@|new file|deleted file|similarity|rename|Binary)/ { next } + /^\+/ { + t=substr($0,2); gsub(/^[[:space:]]+/,"",t) + if (t=="") next + iscomment = (t ~ /^(\/\/|#|\*|\/\*|--| + +**Project:** + +## Principles + +_None yet — principles are written from your first plan or feature, and grow as you build._ + + diff --git a/.fluencyloop/templates/design.md b/.fluencyloop/templates/design.md new file mode 100644 index 0000000..6c3504e --- /dev/null +++ b/.fluencyloop/templates/design.md @@ -0,0 +1,26 @@ +# Design: {{FEATURE}} + + + +started: {{DATE}} + +## Class diagram + +```mermaid +classDiagram + class Example +``` + +## Sequence: + +```mermaid +sequenceDiagram + Caller->>Example: request + Example-->>Caller: response +``` diff --git a/.fluencyloop/templates/plan.md b/.fluencyloop/templates/plan.md new file mode 100644 index 0000000..6cec847 --- /dev/null +++ b/.fluencyloop/templates/plan.md @@ -0,0 +1,67 @@ +# Plan: {{INITIATIVE}} + + + +started: {{DATE}} + +## Goal & scope + +- **Goal:** +- **In scope:** +- **Out of scope / non-goals:** + +## Architecture + + + +```mermaid +classDiagram + class Example +``` + +```mermaid +sequenceDiagram + Caller->>Example: request + Example-->>Caller: response +``` + +## Task breakdown + + + +| id | task (feature intent) | size | depends on | +|----|-----------------------|------|------------| +| T1 | | S/M/L | — | +| T2 | | S/M/L | T1 | + +## Roadmap & critical path + + + +- **Milestone / phase 1 — :** T1, … +- **Milestone / phase 2 — :** T2, … +- **Critical path:** T1 → T2 → … *(the chain that gates completion)* + +## Constitution check + + + +- + +## Tickets + + + +- diff --git a/.fluencyloop/templates/session.md b/.fluencyloop/templates/session.md new file mode 100644 index 0000000..32ba35d --- /dev/null +++ b/.fluencyloop/templates/session.md @@ -0,0 +1,56 @@ +# Session: {{SESSION}} + +- **intent:** {{INTENT}} +- **started:** {{DATE}} + + + +--- + +## Knowledge transfer + +_The ground this slice makes understandable — components, roles, and conditions explained, +persisted so the fluency doesn't evaporate with the conversation. About the work, never a person._ + +### Components (role, conditions) + + + +### Hard-won conditions (gotchas, root causes, limitations) + + + +--- + + diff --git a/.gitattributes b/.gitattributes index 155c4e0..79555d1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,3 +10,7 @@ templates/** text eol=lf # Windows batch shim keeps CRLF (cmd.exe is happiest with it). *.cmd text eol=crlf + +# FluencyLoop writes these LF; pin so Git does not warn/convert on Windows. +.fluencyloop/** text eol=lf +docs/fluencyloop/** text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77dcb22 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ + +# FluencyLoop: calibration is per-developer and never committed +.fluencyloop/**/calibration.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3de7985..fde68e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to FluencyLoop are documented here. +## 0.2.2 + +### Added + +- Codex now checks FluencyLoop's supplying marketplace at every session startup through a trusted, + plugin-bundled hook, and installs an available update for the following session. + +### Changed + +- Documented the host-native automatic update behaviour for both Claude Code and Codex. + ## 0.2.1 ### Changed diff --git a/MANIFESTO.md b/MANIFESTO.md index ebb02c8..c3ab8be 100644 --- a/MANIFESTO.md +++ b/MANIFESTO.md @@ -140,7 +140,10 @@ stage command in the agent you use: The skills invoke the bundled deterministic CLI for `init`, feature/session scaffolding, review assembly, and private calibration. The CLI is deliberately not a separate machine-wide install: -the agent's plugin manager owns installation and updates. +the agent's plugin manager owns installation and updates. Claude Code refreshes marketplace +plugins at startup; Codex's FluencyLoop plugin uses a trusted `SessionStart` hook to refresh only +its supplying marketplace and activate an update in the following session. This keeps updates +visible, host-native, and outside the middle of a task. ## What FluencyLoop refuses to be @@ -168,13 +171,13 @@ The design has real costs. 4. Agent surfaces differ. Skills must preserve the workflow's pauses and visuals without assuming every host has the same forms, renderers, or installation model. -## Status: 0.2.1 +## Status: 0.2.2 -0.2.1 is a working cross-agent loop, not a manifesto-only concept: its Claude Code and Codex +0.2.2 is a working cross-agent loop, not a manifesto-only concept: its Claude Code and Codex plugins ship the same deterministic runtime to scaffold and inspect state, construct feature and -session records, assemble review context, and maintain private calibration. The next measure of -success is not a more elaborate process; it is whether real contributors stay more fluent with a -cost they are willing to pay. +session records, assemble review context, maintain private calibration, and check for package +updates through each host's lifecycle. The next measure of success is not a more elaborate +process; it is whether real contributors stay more fluent with a cost they are willing to pay. ## Standing principles diff --git a/README.md b/README.md index 7dd65c2..279c137 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,10 @@ The plugin includes the interactive skills and a bundled `fluencyloop` command f Bash tool. Its skills are intentionally namespaced, for example `/fluencyloop:feature`, so they cannot collide with another plugin's skills. +Claude Code checks enabled marketplace plugins during normal startup and applies available +FluencyLoop updates through its native plugin updater. If a developer has disabled marketplace +auto-updates in Claude Code, they can re-enable them in the marketplace settings. + ### Codex Install FluencyLoop from the same repository marketplace: @@ -86,6 +90,19 @@ codex plugin add fluencyloop@fluencyloop The plugin makes the `$fluencyloop-*` skills available. Its bundled CLI stays private to the plugin and is run by those skills, so it never needs to be copied onto your PATH. +After the first startup-hook release is installed and trusted, Codex checks FluencyLoop's own +marketplace every time a new session starts and installs an available update automatically. Codex +activates that update in the following session, not part-way through the one already running. +The hook never refreshes another plugin. Codex will ask for a one-time hook review; approve it +from `/hooks` to enable this behaviour. + +Existing Codex installations need this one final manual refresh to receive the startup hook: + +```bash +codex plugin marketplace upgrade fluencyloop +codex plugin add fluencyloop@fluencyloop +``` + ## Quickstart Inside the repository you want to work on, invoke the workflow stage in your installed agent. diff --git a/docs/fluencyloop/constitution.md b/docs/fluencyloop/constitution.md new file mode 100644 index 0000000..97e1e58 --- /dev/null +++ b/docs/fluencyloop/constitution.md @@ -0,0 +1,28 @@ +# Constitution + + + +**Project:** FluencyLoop + +## Principles + +§1 **Use the host lifecycle.** Distribution, refresh, and hook execution must use the +capabilities of Claude Code and Codex rather than reintroducing a machine-wide installer. This +keeps ownership and security review with the agent the developer chose. + +§2 **Refresh only at a session boundary.** An update may be checked and installed when an agent +starts, but it takes effect in the next session. The active agent must never have its skills +silently replaced mid-task. + +§3 **Keep automation narrowly scoped.** FluencyLoop may refresh its own marketplace package; it +must not update, configure, or otherwise affect unrelated plugins. + + diff --git a/docs/fluencyloop/features/refresh-fluencyloop-marketplace-packages-automatically-when/design.md b/docs/fluencyloop/features/refresh-fluencyloop-marketplace-packages-automatically-when/design.md new file mode 100644 index 0000000..1083007 --- /dev/null +++ b/docs/fluencyloop/features/refresh-fluencyloop-marketplace-packages-automatically-when/design.md @@ -0,0 +1,62 @@ +## Refresh FluencyLoop marketplace packages automatically when an agent starts + +**Date:** 2026-07-15 + +## Intent + +Make marketplace-installed FluencyLoop packages check for updates whenever a developer starts +Claude Code or Codex, without bringing back a global installer. + +## Design + +Claude Code already refreshes enabled marketplace plugins during normal startup. FluencyLoop uses +that native lifecycle and documents the one host setting that can disable it. + +Codex exposes no built-in marketplace refresh-on-start option, but it does load trusted plugin +`SessionStart` hooks. The FluencyLoop plugin ships a small cross-platform hook. It derives the +marketplace that supplied the currently running FluencyLoop package from Codex's cache path, +refreshes only that marketplace, then asks Codex to install the current FluencyLoop package from +the refreshed snapshot. A version installed during startup is used by the next session, never by +the session already running. + +```mermaid +classDiagram + class ClaudeCode { + +startup plugin sync() + } + class Codex { + +SessionStart(startup) + +marketplace upgrade() + +plugin add() + } + class FluencyLoopPlugin { + +SessionStart hook + } + class Marketplace + + ClaudeCode --> Marketplace : native refresh + Codex --> FluencyLoopPlugin : loads trusted hook + FluencyLoopPlugin --> Codex : refreshes own marketplace and package + Codex --> Marketplace : reads latest snapshot +``` + +```mermaid +sequenceDiagram + actor Developer + participant Codex + participant Hook as FluencyLoop startup hook + participant Marketplace + + Developer->>Codex: start a new session + Codex->>Hook: SessionStart(startup) + Hook->>Marketplace: refresh FluencyLoop marketplace snapshot + Hook->>Codex: install FluencyLoop from refreshed snapshot + Codex-->>Developer: current session continues unchanged + Note over Codex: refreshed package is active next session +``` + +## Constitution check + +- **§1:** Claude uses its native updater; Codex uses its supported plugin-hook lifecycle. +- **§2:** Codex updates only for the following session. +- **§3:** The hook derives and refreshes only FluencyLoop's supplying marketplace. diff --git a/docs/fluencyloop/features/refresh-fluencyloop-marketplace-packages-automatically-when/sessions/add-a-trusted-codex-startup-hook-that-refreshes-fluencyloop.md b/docs/fluencyloop/features/refresh-fluencyloop-marketplace-packages-automatically-when/sessions/add-a-trusted-codex-startup-hook-that-refreshes-fluencyloop.md new file mode 100644 index 0000000..c66a4bb --- /dev/null +++ b/docs/fluencyloop/features/refresh-fluencyloop-marketplace-packages-automatically-when/sessions/add-a-trusted-codex-startup-hook-that-refreshes-fluencyloop.md @@ -0,0 +1,81 @@ +# Session: Add a trusted Codex startup hook that refreshes FluencyLoop updates + +- **intent:** Add a trusted Codex startup hook that refreshes FluencyLoop updates +- **started:** 2026-07-15 + + + +--- + +## Knowledge transfer + +_The ground this slice makes understandable — components, roles, and conditions explained, +persisted so the fluency doesn't evaporate with the conversation. About the work, never a person._ + +### Components (role, conditions) + + + +- **Claude Code plugin updater** — refreshes enabled marketplace plugins during normal startup; + FluencyLoop relies on that native lifecycle instead of shipping a second updater · status: + documented +- **Codex `SessionStart` hook** — runs at a new-session boundary after the user has trusted the + plugin hook; it derives the marketplace from the installed plugin cache and refreshes only that + source · status: documented +- **Codex package cache** — keeps the package loaded for the current session stable; a package + installed by the hook is available when the following session begins · status: documented + +### Hard-won conditions (gotchas, root causes, limitations) + + + +- **Startup is not a hot-reload boundary** — installing a package after Codex has loaded the + current session cannot safely alter the active skills, so the update deliberately takes effect + one session later · status: documented +- **An installed plugin cannot bootstrap itself retroactively** — existing 0.2.1 Codex users must + refresh once manually to receive the new startup hook; later releases are then checked + automatically · status: documented + +--- + + + +## Decision: use host-native startup lifecycles for marketplace refresh + +- **where:** `plugins/fluencyloop/hooks and marketplace distribution` +- **why:** Claude Code already owns marketplace plugin refresh at startup. Codex exposes a trusted SessionStart hook, so refreshing only the package's supplying marketplace keeps the updater scoped, preserves the active session, and avoids restoring a global installer. +- **alternative:** revive fluencyloop self upgrade or install.sh — rejected: it would create a parallel, manual lifecycle outside the agent hosts and still could not safely replace skills in a running session +- **design:** ../design.md +- **constitution:** §1, §2, §3 +- **trust:** ✓ verified diff --git a/plugins/fluencyloop/.codex-plugin/plugin.json b/plugins/fluencyloop/.codex-plugin/plugin.json index 14168fb..1b4bfe7 100644 --- a/plugins/fluencyloop/.codex-plugin/plugin.json +++ b/plugins/fluencyloop/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "fluencyloop", - "version": "0.2.1", + "version": "0.2.2", "description": "Stay fluent in the code your AI agent writes with a per-feature design, build, teaching, and review loop.", "author": { "name": "baokhang83", diff --git a/plugins/fluencyloop/VERSION b/plugins/fluencyloop/VERSION index 0c62199..ee1372d 100644 --- a/plugins/fluencyloop/VERSION +++ b/plugins/fluencyloop/VERSION @@ -1 +1 @@ -0.2.1 +0.2.2 diff --git a/plugins/fluencyloop/hooks/hooks.json b/plugins/fluencyloop/hooks/hooks.json new file mode 100644 index 0000000..942c866 --- /dev/null +++ b/plugins/fluencyloop/hooks/hooks.json @@ -0,0 +1,18 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "bash \"${PLUGIN_ROOT}/hooks/refresh-marketplace.sh\"", + "commandWindows": "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"& (Join-Path $env:PLUGIN_ROOT 'hooks\\refresh-marketplace.ps1')\"", + "timeout": 30, + "statusMessage": "Checking FluencyLoop updates" + } + ] + } + ] + } +} diff --git a/plugins/fluencyloop/hooks/refresh-marketplace.ps1 b/plugins/fluencyloop/hooks/refresh-marketplace.ps1 new file mode 100644 index 0000000..8f352a7 --- /dev/null +++ b/plugins/fluencyloop/hooks/refresh-marketplace.ps1 @@ -0,0 +1,40 @@ +# Refresh the marketplace that supplied this plugin, then install its current FluencyLoop package. +# Codex runs this trusted SessionStart hook. A refreshed package is picked up by the next session. + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$pluginDir = $env:PLUGIN_ROOT +if ([string]::IsNullOrWhiteSpace($pluginDir)) { + $pluginDir = $env:CLAUDE_PLUGIN_ROOT +} +if ([string]::IsNullOrWhiteSpace($pluginDir)) { + exit 0 +} + +$parts = [IO.Path]::GetFullPath($pluginDir) -split '[\\/]' +$cacheIndex = -1 +for ($i = 0; $i -lt ($parts.Length - 2); $i++) { + if ($parts[$i] -eq 'plugins' -and $parts[$i + 1] -eq 'cache') { + $cacheIndex = $i + break + } +} +if ($cacheIndex -lt 0 -or [string]::IsNullOrWhiteSpace($parts[$cacheIndex + 2])) { + exit 0 +} + +$marketplace = $parts[$cacheIndex + 2] +if ($null -eq (Get-Command codex -ErrorAction SilentlyContinue)) { + exit 0 +} + +# A local marketplace has nothing to refresh. Network and policy failures must never prevent an +# agent session from starting, so treat them as a no-op and let the host surface its own diagnostics. +& codex plugin marketplace upgrade $marketplace --json *> $null +if ($LASTEXITCODE -ne 0) { + exit 0 +} + +& codex plugin add "fluencyloop@$marketplace" --json *> $null +exit 0 diff --git a/plugins/fluencyloop/hooks/refresh-marketplace.sh b/plugins/fluencyloop/hooks/refresh-marketplace.sh new file mode 100644 index 0000000..bd1a442 --- /dev/null +++ b/plugins/fluencyloop/hooks/refresh-marketplace.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Refresh the marketplace that supplied this plugin, then install its current FluencyLoop package. +# Codex runs this trusted SessionStart hook. A refreshed package is picked up by the next session. + +set -euo pipefail + +PLUGIN_DIR="${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-}}" +[ -n "$PLUGIN_DIR" ] || exit 0 + +# Installed packages live at $CODEX_HOME/plugins/cache//fluencyloop/. +# Derive the marketplace instead of assuming the self-hosted catalog name: the same package can +# later be distributed through another Codex marketplace. +case "$PLUGIN_DIR" in + */plugins/cache/*/*/*) + CACHE_TAIL="${PLUGIN_DIR#*/plugins/cache/}" + MARKETPLACE="${CACHE_TAIL%%/*}" + ;; + *) exit 0 ;; +esac + +[ -n "$MARKETPLACE" ] || exit 0 +command -v codex >/dev/null 2>&1 || exit 0 + +# A local marketplace has nothing to refresh. Network and policy failures must never prevent an +# agent session from starting, so treat them as a no-op and let the host surface its own diagnostics. +codex plugin marketplace upgrade "$MARKETPLACE" --json >/dev/null 2>&1 || exit 0 +codex plugin add "fluencyloop@$MARKETPLACE" --json >/dev/null 2>&1 || true diff --git a/tests/plugin.bats b/tests/plugin.bats index 50e2228..d9e31f2 100644 --- a/tests/plugin.bats +++ b/tests/plugin.bats @@ -33,6 +33,15 @@ assert codex_entry["policy"] == {"installation": "AVAILABLE", "authentication": assert codex_entry["category"] == "Productivity" assert codex_plugin["skills"] == "./skills/" +hooks = json.loads((dist / "hooks" / "hooks.json").read_text()) +handler, = hooks["hooks"]["SessionStart"][0]["hooks"] +assert hooks["hooks"]["SessionStart"][0]["matcher"] == "startup" +assert handler["type"] == "command" +assert "${PLUGIN_ROOT}/hooks/refresh-marketplace.sh" in handler["command"] +assert "refresh-marketplace.ps1" in handler["commandWindows"] +assert (dist / "hooks" / "refresh-marketplace.sh").is_file() +assert (dist / "hooks" / "refresh-marketplace.ps1").is_file() + for alias, source in { "plan": "fluencyloop-plan", "feature": "fluencyloop-feature", @@ -68,3 +77,29 @@ PY [ "$status" -eq 0 ] [ "$output" = "$(cat "$DIST/VERSION")" ] } + +@test "Codex startup refresh hook is safe outside an installed plugin cache" { + run env PLUGIN_ROOT="$DIST" bash "$DIST/hooks/refresh-marketplace.sh" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "Codex startup refresh hook updates only its supplying marketplace" { + local plugin_root="$BATS_TEST_TMPDIR/plugins/cache/fluencyloop/fluencyloop/0.2.1" + local calls="$BATS_TEST_TMPDIR/codex-calls" + mkdir -p "$plugin_root" + + codex() { + printf '%s\n' "$*" >> "$CODEX_CALLS" + } + export -f codex + export CODEX_CALLS="$calls" + + run env PLUGIN_ROOT="$plugin_root" bash "$DIST/hooks/refresh-marketplace.sh" + [ "$status" -eq 0 ] + [ -z "$output" ] + + run cat "$calls" + [ "$status" -eq 0 ] + [ "$output" = $'plugin marketplace upgrade fluencyloop --json\nplugin add fluencyloop@fluencyloop --json' ] +}