From 2a8c612c3b15907f1d0ade77757dc89f1fac95e6 Mon Sep 17 00:00:00 2001 From: Kirk Bowman Date: Fri, 24 Apr 2026 16:54:04 -0500 Subject: [PATCH 1/3] Fix custom functions in folders not shown by Explode XML - analyze.py: change glob() to rglob() so folder-nested CFs are found; filter out folder separator pseudo-files via stub XML existence check; add folder_path to CF data and index loader - fmcontext.sh: fix sanitized body path lookup to be subfolder-relative (flat lookup silently missed nested CFs, misclassifying all as functional); add FolderPath as 7th column to custom_functions.index header and output; replace mapfile with while-read loop for bash 3.2 compatibility on macOS --- agent/scripts/analyze.py | 46 +++++++++++++++++++++++++++------------- fmcontext.sh | 20 ++++++++++++----- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/agent/scripts/analyze.py b/agent/scripts/analyze.py index 83c3423c..3fb19382 100644 --- a/agent/scripts/analyze.py +++ b/agent/scripts/analyze.py @@ -234,7 +234,8 @@ def load_value_lists_index(solution_dir): def load_custom_functions_index(solution_dir): return _parse_index( solution_dir / "custom_functions.index", - ["name", "id", "parameters", "access", "display", "category"], + ["name", "id", "parameters", "access", "display", "category", + "folder_path"], ) @@ -1429,13 +1430,25 @@ def analyze_custom_functions(solution_name): stub_dir = XML_PARSED_DIR / "custom_function_stubs" / solution_name - cf_files = sorted(cf_dir.glob("*.txt")) + # Recurse into folder subdirectories — CFs organised into FileMaker + # folders are nested in "{FolderName} - ID N/" subdirs, and a flat + # glob would silently skip them. + cf_files = sorted(cf_dir.rglob("*.txt")) functions = {} all_cf_names = set() # First pass: collect names and read all content - cf_data = [] # (name, id, text, param_count) — single pass I/O + cf_data = [] # (name, id, text, param_count, folder_path) — single pass I/O for cf_path in cf_files: + # Folder/separator pseudo-files (e.g. "ACCOUNTS - ID 39.txt", + # "-- - ID 37.txt") are emitted at the flat level by fmparse.sh + # alongside the per-folder subdirs. They have no backing stub XML + # and are not real custom functions — skip them. + rel_parent = cf_path.parent.relative_to(cf_dir) + stub_path = stub_dir / rel_parent / f"{cf_path.stem}.xml" + if not stub_path.exists(): + continue + name = cf_path.stem.rsplit(" - ID ", 1)[0] all_cf_names.add(name) cf_id = None @@ -1448,19 +1461,21 @@ def analyze_custom_functions(solution_name): except (OSError, UnicodeDecodeError): continue - # Read param count from stub XML (ObjectList/@membercount) + # Derive the folder path (parent dirs between the solution root and + # the file), stripping " - ID N" suffixes to match scripts/layouts. + folder_path = re.sub(r' - ID \d+', '', str(rel_parent)) if str(rel_parent) != '.' else '' + + # Read param count from stub XML (ObjectList/@membercount). param_count = 0 - stub_path = stub_dir / f"{cf_path.stem}.xml" - if stub_path.exists(): - try: - stub_text = stub_path.read_text(encoding="utf-8") - mc_match = re.search(r'membercount="(\d+)"', stub_text) - if mc_match: - param_count = int(mc_match.group(1)) - except (OSError, UnicodeDecodeError): - pass + try: + stub_text = stub_path.read_text(encoding="utf-8") + mc_match = re.search(r'membercount="(\d+)"', stub_text) + if mc_match: + param_count = int(mc_match.group(1)) + except (OSError, UnicodeDecodeError): + pass - cf_data.append((name, cf_id, text, param_count)) + cf_data.append((name, cf_id, text, param_count, folder_path)) # Patterns for classification _FIELD_REF_RE = re.compile(r'[A-Za-z_][A-Za-z0-9_ ]*::[A-Za-z_]') @@ -1473,7 +1488,7 @@ def analyze_custom_functions(solution_name): re.IGNORECASE, ) - for name, cf_id, text, param_count in cf_data: + for name, cf_id, text, param_count, folder_path in cf_data: # Find references to other custom functions (substring match) deps = sorted( @@ -1487,6 +1502,7 @@ def analyze_custom_functions(solution_name): "id": cf_id, "param_count": param_count, "line_count": line_count, + "folder_path": folder_path, "dependencies": deps, "text": text, } diff --git a/fmcontext.sh b/fmcontext.sh index 873f0e36..60ca4be9 100755 --- a/fmcontext.sh +++ b/fmcontext.sh @@ -145,7 +145,11 @@ mkdir -p "$CONTEXT_DIR" # Discover solutions to process # --------------------------------------------------------------------------- if [[ -z "$SOLUTION_NAME" ]]; then - mapfile -t SOLUTIONS < <( + # Avoid `mapfile` — it requires bash 4+, and macOS ships bash 3.2. + SOLUTIONS=() + while IFS= read -r _solution_line; do + SOLUTIONS+=("$_solution_line") + done < <( find "$XML_PARSED_DIR" -mindepth 2 -maxdepth 2 -type d \ | sed 's|.*/||' | sort -u ) @@ -392,7 +396,7 @@ for SOLUTION in "${SOLUTIONS[@]}"; do # functional – everything else # --------------------------------------------------------------------------- { - echo "# FunctionName|FunctionID|Parameters|Access|Display|Category" + echo "# FunctionName|FunctionID|Parameters|Access|Display|Category|FolderPath" STUB_DIR="$XML_PARSED_DIR/custom_function_stubs/$SOLUTION" SANITIZED_DIR="$XML_PARSED_DIR/custom_functions_sanitized/$SOLUTION" @@ -404,6 +408,8 @@ for SOLUTION in "${SOLUTIONS[@]}"; do cf_access=$(xval 'string(/CustomFunction/@access)' "$file") cf_display=$(xval 'string(/CustomFunction/Display)' "$file") + folder_path=$(get_folder_path "$file" "$XML_PARSED_DIR/custom_function_stubs") + # Extract parameter names from stub param_count=$(xval 'string(/CustomFunction/ObjectList/@membercount)' "$file") params="" @@ -418,9 +424,13 @@ for SOLUTION in "${SOLUTIONS[@]}"; do done fi - # Classify using the sanitized body + # Classify using the sanitized body — derive the body path from the + # stub path so nested-in-folder CFs resolve correctly (flat lookup + # silently misses them and leaves every folder-nested CF mis- + # classified as "functional"). category="functional" - txt_file="$SANITIZED_DIR/${cf_name} - ID ${cf_id}.txt" + stub_rel="${file#"$STUB_DIR/"}" + txt_file="$SANITIZED_DIR/${stub_rel%.xml}.txt" if [[ -f "$txt_file" ]]; then body=$(<"$txt_file") @@ -451,7 +461,7 @@ for SOLUTION in "${SOLUTIONS[@]}"; do fi fi - echo "${cf_name}|${cf_id}|${params}|${cf_access}|${cf_display}|${category}" + echo "${cf_name}|${cf_id}|${params}|${cf_access}|${cf_display}|${category}|${folder_path}" done fi } > "$SOLUTION_CONTEXT_DIR/custom_functions.index" From e8bdbeb457a1686c8e04c47e24f49a5c4945cedf Mon Sep 17 00:00:00 2001 From: Kirk Bowman Date: Fri, 24 Apr 2026 17:22:34 -0500 Subject: [PATCH 2/3] Fix trace.py missing CFs in subfolders and zero-arg CF detection - build_cf_names: use rglob instead of iterdir so custom functions nested inside folder-group subdirectories are discovered - parse_scripts: extend bracket guard to include lines with ']' so zero-argument CF calls on expression-continuation lines are not skipped Made-with: Cursor --- agent/scripts/trace.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/agent/scripts/trace.py b/agent/scripts/trace.py index 8ff25f86..cf3b9ccd 100644 --- a/agent/scripts/trace.py +++ b/agent/scripts/trace.py @@ -212,12 +212,11 @@ def build_cf_names(solution_name): cfs = [] if not cf_dir.exists(): return cfs - for f in cf_dir.iterdir(): - if f.suffix == ".txt": - # Parse "FuncName - ID NNN.txt" - m = re.match(r'^(.+?)\s*-\s*ID\s+(\d+)\.txt$', f.name) - if m: - cfs.append({"name": m.group(1), "id": m.group(2), "path": f}) + for f in cf_dir.rglob("*.txt"): + # Parse "FuncName - ID NNN.txt" + m = re.match(r'^(.+?)\s*-\s*ID\s+(\d+)\.txt$', f.name) + if m: + cfs.append({"name": m.group(1), "id": m.group(2), "path": f}) return cfs @@ -383,7 +382,7 @@ def parse_scripts(solution_name, scripts_index, to_map, cf_names): )) # --- Custom function references in expressions --- - if "[" in line: # Only scan lines with parameters + if "[" in line or "]" in line: # Catch both opening and continuation lines for cf in cf_name_set: # Match CF name with parens (function call) or standalone (zero-param) pattern = re.compile( From 18451ded17897a10498cfd32bf16145304b73c6d Mon Sep 17 00:00:00 2001 From: Kirk Bowman Date: Tue, 28 Apr 2026 23:15:17 -0500 Subject: [PATCH 3/3] Multi-AI agent-instructions support via symlinks Adds AGENTS.md (root) and .github/copilot-instructions.md as symlinks to the canonical .claude/CLAUDE.md so non-Claude AI tools (Aider, Cline, Continue.dev, OpenAI Codex CLI, GitHub Copilot, plus the existing Cursor .cursor/AGENTS.md symlink) find the same project conventions at the paths they expect, without duplicating content. Adds a verifier script (agent/scripts/sync_agent_docs.sh) suitable for pre-commit/CI to confirm all four paths resolve to byte-identical content. Documents the convention in both the canonical doc and ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.7 --- .claude/CLAUDE.md | 10 ++++++ .github/copilot-instructions.md | 1 + AGENTS.md | 1 + ARCHITECTURE.md | 2 +- agent/scripts/sync_agent_docs.sh | 61 ++++++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 1 deletion(-) create mode 120000 .github/copilot-instructions.md create mode 120000 AGENTS.md create mode 100755 agent/scripts/sync_agent_docs.sh diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 59cccb0c..29a46377 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -61,6 +61,16 @@ If `PROJECT.md` exists at the project root, read it at session start. It contain - When writing docs for this project, default audience is END-USERS who download the repo as a tool, NOT collaborative developers/contributors, unless explicitly told otherwise. +## Agent-instructions across multiple AI tools + +The canonical agent-instructions file is `.claude/CLAUDE.md`. Three symlinks point to it so each AI tool finds the same content at the path it expects: + +- `.cursor/AGENTS.md` → Cursor IDE +- `AGENTS.md` (root) → Aider, Cline, Continue.dev, OpenAI Codex CLI, generic AGENTS.md-aware tools +- `.github/copilot-instructions.md` → GitHub Copilot + +Edit `.claude/CLAUDE.md` only. The symlinks pick up the change automatically. Run `agent/scripts/sync_agent_docs.sh` to verify all four paths resolve to the same content (useful as a pre-commit/CI check). + # Overview This project is designed to create FileMaker objects — primarily scripts and calculations — in the clipboard-supported fmxmlsnippet format. Developers reference and use the HR (human-readable) format for scripts. The following folders are used. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 120000 index 00000000..5222d6e7 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +../.claude/CLAUDE.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000..ac55cbdc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +.claude/CLAUDE.md \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 99e2ca9d..40f77e3d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -242,7 +242,7 @@ When extending this project, keep the following principles in mind: 4. **Update documentation together.** When adding a new artifact or changing the pipeline: - Update this file (`ARCHITECTURE.md`) with the new artifact and its role. - - Update `.cursor/AGENTS.md` so the AI knows how to use the new artifact. + - Update the canonical agent-instructions doc at `.claude/CLAUDE.md` so AIs know how to use the new artifact. Three symlinks (`.cursor/AGENTS.md`, root `AGENTS.md`, `.github/copilot-instructions.md`) point to the canonical so Cursor, Aider/Cline/Continue/Codex CLI, and GitHub Copilot all see the same content. Run `agent/scripts/sync_agent_docs.sh` to verify the symlinks are intact. - Update `README.md` if the change affects end-user workflow or project structure. 5. **The step catalog is the single source of truth for step structure.** `agent/catalogs/step-catalog-en.json` is the definitive reference for step XML structure, parameter definitions, enums, and behavioral notes. `snippet_examples/` is archival — it serves as a fallback only for complex steps with `"auto"`/`"unfinished"` catalog status or where the catalog entry remains insufficient. If you add support for a new script step type, add its catalog entry (including behavioral notes in the `notes` field) and a corresponding snippet_examples template for reference. All snippet files must follow the conventions in `agent/snippet_examples/steps/CONVENTIONS.md`. diff --git a/agent/scripts/sync_agent_docs.sh b/agent/scripts/sync_agent_docs.sh new file mode 100755 index 00000000..ae597533 --- /dev/null +++ b/agent/scripts/sync_agent_docs.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Verify the four agent-instructions paths all resolve to the same file content. +# +# Canonical file: .claude/CLAUDE.md +# Symlinks: .cursor/AGENTS.md (Cursor IDE) +# AGENTS.md (root — Aider, Cline, Continue.dev, generic) +# .github/copilot-instructions.md (GitHub Copilot) +# +# All four paths must resolve to the same underlying content. If they diverge +# (e.g. someone replaced a symlink with a regular file), this script fails. +# Useful as a pre-commit check or CI gate. + +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" + +declare -a PATHS=( + "$REPO_ROOT/.claude/CLAUDE.md" + "$REPO_ROOT/.cursor/AGENTS.md" + "$REPO_ROOT/AGENTS.md" + "$REPO_ROOT/.github/copilot-instructions.md" +) + +declare -a missing=() +declare -a hashes=() + +for p in "${PATHS[@]}"; do + if [ ! -e "$p" ]; then + missing+=("$p") + continue + fi + hashes+=("$(md5 -q "$p"):$p") +done + +if [ ${#missing[@]} -gt 0 ]; then + echo "ERROR: missing agent-instructions paths:" >&2 + printf ' %s\n' "${missing[@]}" >&2 + exit 1 +fi + +# Compare hashes — first column should all match +first_hash="${hashes[0]%%:*}" +ok=1 +for entry in "${hashes[@]}"; do + h="${entry%%:*}" + p="${entry#*:}" + if [ "$h" != "$first_hash" ]; then + echo "ERROR: $p has different content from canonical" >&2 + ok=0 + fi +done + +if [ $ok -eq 1 ]; then + echo "OK — all 4 agent-instructions paths resolve to the same content (md5 $first_hash)" +else + echo "" >&2 + echo "Restore symlinks with:" >&2 + echo " ln -sfn .claude/CLAUDE.md AGENTS.md" >&2 + echo " ln -sfn ../.claude/CLAUDE.md .github/copilot-instructions.md" >&2 + echo " ln -sfn ../.claude/CLAUDE.md .cursor/AGENTS.md" >&2 + exit 1 +fi