Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
1 change: 1 addition & 0 deletions AGENTS.md
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
46 changes: 31 additions & 15 deletions agent/scripts/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
)


Expand Down Expand Up @@ -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
Expand All @@ -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_]')
Expand All @@ -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(
Expand All @@ -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,
}
Expand Down
61 changes: 61 additions & 0 deletions agent/scripts/sync_agent_docs.sh
Original file line number Diff line number Diff line change
@@ -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
13 changes: 6 additions & 7 deletions agent/scripts/trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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(
Expand Down
20 changes: 15 additions & 5 deletions fmcontext.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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"
Expand All @@ -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=""
Expand All @@ -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")
Expand Down Expand Up @@ -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"
Expand Down