diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d02581d..fa12ded 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -530,8 +530,9 @@ jobs: # Every text pass in the engine (LF normalization, token expansion, # frontmatter quoting) reads a file and writes it back. Run one of # those through a symlink and the link's TARGET — a host file outside - # the repo — is copied into the generated skill. `cp -R` preserves - # symlinks precisely so that cannot happen; assert it stays true. + # the repo — is copied into the generated skill. A link that resolves + # outside its skills source is never read through: its skill is left + # out of every output (decision 0017); assert it stays true. PROJ=$RUNNER_TEMP/symskill SECRET=$RUNNER_TEMP/secret mkdir -p "$PROJ/intelligence"/{rules,agents,skills/evil-skill} "$SECRET" @@ -562,6 +563,9 @@ jobs: grep -rl "TOP-SECRET-HOST-CONTENT" .claude .agents AGENTS.md exit 1 fi - test -L .claude/skills/evil-skill/SKILL.md \ - || { echo "FAIL: symlinked SKILL.md was replaced by a real file"; exit 1; } - echo "OK: symlinked SKILL.md left as a symlink; no host content in outputs" + for tree in .claude/skills .agents/skills; do + if [ -e "$tree/evil-skill" ] || [ -L "$tree/evil-skill" ]; then + echo "FAIL: a skill whose SKILL.md escapes its source reached $tree"; exit 1 + fi + done + echo "OK: escaping SKILL.md left out of every output; no host content in outputs" diff --git a/CHANGELOG.md b/CHANGELOG.md index aaddd67..c728e97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ Legacy Intelligence Sync history remains in its [archive](https://github.com/ain - An unchanged `sync` starts far fewer processes: one walk and one hash batch fingerprint the CLI, sources and generated files; the package descriptor, manifest and lock are read once; and `sync` runs in the dispatcher's own bash. On Windows/Git Bash a cache hit on this repository's project dropped from a median 3.9 to 0.74 seconds with the same output. Cache records from earlier versions are ignored, so the first sync after upgrading renders once. +- Every warning sync and the CLI print is a `WARNING:` line, so `sync --compact`, its unchanged-run replay and `init` keep it — the frontmatter lint's "will be REJECTED at load time" included (#51). +- A symlink in a skills source whose target stays inside that source is rendered as regular files in every skill tree, with quoting, effort and the Codex invocation policy applied; a link that escapes its source, dangles or loops is left out of every output with one `WARNING:` naming the skill and the link. Claude Code, Cursor and Copilot no longer receive raw links, and a skills source directory that is itself a symlink is read everywhere (decision 0017, #44, #47). + ## [0.19.0] ### Added diff --git a/cli/commands/registry.sh b/cli/commands/registry.sh index 36552de..106cbd5 100644 --- a/cli/commands/registry.sh +++ b/cli/commands/registry.sh @@ -51,7 +51,7 @@ case "$sub" in echo " index.yaml reachable — $n package(s):" qmap_keys "$index" "packages" | sed 's/^/ /' elif [ "$force" -eq 1 ]; then - echo " WARN: no index.yaml at $url — recording anyway (--force)." >&2 + echo "WARNING: no index.yaml at $url — recording anyway (--force)." >&2 else echo "ERROR: no index.yaml found at $url — not adding it." >&2 echo " A registry is a git repo with index.yaml at its root, mapping names to sources:" >&2 diff --git a/cli/commands/source.sh b/cli/commands/source.sh index bce4eb6..1987eb3 100644 --- a/cli/commands/source.sh +++ b/cli/commands/source.sh @@ -168,7 +168,7 @@ case "$action" in echo "added: $dir" fi if [ ! -d "$IP_ROOT/$dir" ]; then - echo " WARN: $dir does not exist yet — sync skips a missing source and renders it once the directory appears." >&2 + echo "WARNING: $dir does not exist yet — sync skips a missing source and renders it once the directory appears." >&2 fi print_section "$manifest" "$IP_ROOT" "$section" echo "Run 'intelligence sync' to render it." diff --git a/cli/internal/convert-legacy.sh b/cli/internal/convert-legacy.sh index aad5b72..57bb54f 100644 --- a/cli/internal/convert-legacy.sh +++ b/cli/internal/convert-legacy.sh @@ -435,7 +435,7 @@ while IFS="$LOCK_SEP" read -r pack name url ref mirror; do # Containment before deletion: a mirror value is config input, and rm -rf # must never follow it out of the repository. case "$mirror" in - ""|/*|*..*) [ -n "$mirror" ] && echo " WARN: mirror '$mirror' not repo-contained — left in place" >&2; continue ;; + ""|/*|*..*) [ -n "$mirror" ] && echo "WARNING: mirror '$mirror' not repo-contained — left in place" >&2; continue ;; esac if [ -d "$root/$mirror" ]; then rm -rf "${root:?}/$mirror" diff --git a/cli/internal/package-search.sh b/cli/internal/package-search.sh index 6d39ad7..1dc6e47 100644 --- a/cli/internal/package-search.sh +++ b/cli/internal/package-search.sh @@ -63,7 +63,7 @@ if [ -n "$manifest" ]; then [ -n "$url" ] || continue index="$(_fetch_index "${url#git+}")" if [ -z "$index" ]; then - echo " WARN: registry unreachable or missing index.yaml: $url" >&2 + echo "WARNING: registry unreachable or missing index.yaml: $url" >&2 continue fi emit_index "$index" "$url" diff --git a/cli/internal/package-update.sh b/cli/internal/package-update.sh index b6b80a8..c0a8a55 100644 --- a/cli/internal/package-update.sh +++ b/cli/internal/package-update.sh @@ -85,7 +85,7 @@ while IFS= read -r name; do if [ "$probe_rc" -ne 0 ]; then # No answer is not "no change": reporting up to date here would # freeze the pin exactly as the ref-name comparison used to. - echo " WARN: $name: cannot reach $url — ref '$ref' was not checked" >&2 + echo "WARNING: $name: cannot reach $url — ref '$ref' was not checked" >&2 echo " $name: $ref (not checked — remote unreachable)" continue fi @@ -104,7 +104,7 @@ while IFS= read -r name; do echo " $name: $(short_sha "$ref") (pinned commit)" ;; *) - echo " WARN: $name pins ref '$ref', which $url no longer advertises" >&2 + echo "WARNING: $name pins ref '$ref', which $url no longer advertises" >&2 echo " $name: $ref (unresolvable — gone upstream)" ;; esac diff --git a/cli/lib/registry.sh b/cli/lib/registry.sh index e068b9f..3450047 100644 --- a/cli/lib/registry.sh +++ b/cli/lib/registry.sh @@ -61,7 +61,7 @@ resolve_package_source() { [ -n "$reg_url" ] || continue index="$(_fetch_index "${reg_url#git+}")" if [ -z "$index" ]; then - echo " WARN: registry unreachable or missing index.yaml, skipped: $reg_url" >&2 + echo "WARNING: registry unreachable or missing index.yaml, skipped: $reg_url" >&2 continue fi RES_URL="$(qmap_field "$index" "packages" "$name" "url")" diff --git a/cli/lib/sync-cache.sh b/cli/lib/sync-cache.sh index bea1c01..8df05b1 100644 --- a/cli/lib/sync-cache.sh +++ b/cli/lib/sync-cache.sh @@ -447,14 +447,14 @@ sync_cache_publish() { mv -f "$seal_staged" "$SC_DIRECTORY/seal" || rm -f "$seal_staged" } -# Keep actionable diagnostics, including the multi-line unsynced-directory -# report that compact mode intentionally omits. Never replay renderer progress. +# Keep actionable diagnostics in the same form compact mode keeps; never replay +# renderer progress. sync_cache_report() { awk ' - /^(WARNING:| WARN:)/ { warning=1; print; next } + /^WARNING:/ { warning=1; print; next } warning && /^ / { print; next } { warning=0 } - /^(CONTEXT:|IS_STATUS=ok|=== Done:|=== WARNING:| NOT SYNCED:| Wire one in:)/ { print } + /^(CONTEXT:|IS_STATUS=ok|=== Done:)/ { print } ' "$1" } diff --git a/cli/tests/e2e-compat.sh b/cli/tests/e2e-compat.sh index fdfe4cf..3fe82f6 100644 --- a/cli/tests/e2e-compat.sh +++ b/cli/tests/e2e-compat.sh @@ -2,8 +2,9 @@ # Hermetic e2e for the schema-compatibility gate: a CLI OLDER than the project. # A project stamped a newer minor or patch within the same major syncs with one # warning and is never restamped or re-pinned downward; a newer major is refused -# with the `ahead-of-engine` status and exit 4. No network: the engine-content -# store is seeded from this tree. +# with the `ahead-of-engine` status and exit 4. Compact sync keeps every warning +# within its line contract. No network: the engine-content store is seeded from +# this tree. set -euo pipefail unset CI REPO="${1:-$(cd "$(dirname "$0")/../.." && pwd)}" @@ -245,6 +246,28 @@ else echo " (engine $ENGINE_VER has no lower version to stamp; skipped)" fi +echo "== 7. compact output keeps every warning, the frontmatter checks included ==" +# A description over 1024 characters gets the agent rejected at load time. The +# first sync `init` runs is compact, so a warning compact output drops is one +# nobody reads: a full render and the unchanged run that replays it both carry it. +P6="$OUT/lint-warning" +make_project "$P6" "$ENGINE_VER" +mkdir -p "$P6/intelligence/agents" +awk '{ print } $0 == " agents:" { print " - \"intelligence/agents\"" }' "$P6/intelligence.yaml" > "$OUT/p6.yaml" +mv "$OUT/p6.yaml" "$P6/intelligence.yaml" +long_description="$(printf '%01100d' 0 | tr 0 x)" +printf -- '---\nname: verbose\ndescription: "%s"\n---\n\n# Verbose\n' "$long_description" \ + > "$P6/intelligence/agents/verbose.md" +REJECTED="description is 1100 chars — over the 1024-char limit; the skill/agent will be REJECTED at load time" +xok "$REJECTED" "$P6" sync --compact --force +compact_contract_ok "$OUTPUT" || { echo "FAIL: forced compact output broke its contract"; printf '%s\n' "$OUTPUT"; fail=1; } +printf '%s\n' "$OUTPUT" | grep -q "^WARNING: .*verbose.md:3 $REJECTED" \ + || { echo "FAIL: the rejection warning is not a WARNING: line"; printf '%s\n' "$OUTPUT"; fail=1; } +xok "$REJECTED" "$P6" sync --compact +compact_contract_ok "$OUTPUT" || { echo "FAIL: replayed compact output broke its contract"; printf '%s\n' "$OUTPUT"; fail=1; } +xok "Unchanged: generated files are up to date." "$P6" sync +has "$REJECTED" + if [ "$fail" -eq 0 ]; then echo "e2e-compat: OK" else diff --git a/cli/tests/e2e-lifecycle.sh b/cli/tests/e2e-lifecycle.sh index 5e563d7..03a4938 100644 --- a/cli/tests/e2e-lifecycle.sh +++ b/cli/tests/e2e-lifecycle.sh @@ -899,6 +899,8 @@ printf -- '---\nname: open\ndescription: "Open"\n---\n\n# Open\n' > "$POL/intell || { echo "FAIL: skill-policy sync"; cat "$OUT/policy-sync.txt"; fail=1; } chk grep -Fqx ' allow_implicit_invocation: false' "$POL/.agents/skills/owner-only/agents/openai.yaml" chk grep -Fqx ' display_name: "Kept"' "$POL/.agents/skills/own-policy/agents/openai.yaml" +# A file that already sets the policy is kept byte for byte, never given the key twice. +chk cmp -s "$POL/intelligence/skills/own-policy/agents/openai.yaml" "$POL/.agents/skills/own-policy/agents/openai.yaml" chknot test -e "$POL/.agents/skills/open/agents" chknot test -e "$POL/intelligence/skills/owner-only/agents" # Dropping the field drops the derived file on the next sync. @@ -955,28 +957,178 @@ for skill in contradicts quoted-false inline-true inline-string; do rm -rf "$POL/intelligence/skills/$skill" done chk grep -Fqx ' display_name: "Interface only"' "$POL/.agents/skills/interface-only/agents/openai.yaml" -# A symlinked SKILL.md or agents/openai.yaml is emitted as-is, so nothing is -# read, derived or rewritten through it; the run says so instead of passing it -# silently. Probed: some hosts cannot create a symlink and copy the file instead. -mkdir -p "$OUT/linked-skill" "$POL/intelligence/skills/linked" -printf -- '---\nname: linked\ndescription: "Linked"\ndisable-model-invocation: true\n---\n\n# Linked\n' \ - > "$OUT/linked-skill/SKILL.md" -if ln -s "$OUT/linked-skill/SKILL.md" "$POL/intelligence/skills/linked/SKILL.md" 2>/dev/null \ - && [ -L "$POL/intelligence/skills/linked/SKILL.md" ]; then - pol_skill linked-policy 'interface:' ' display_name: "Linked policy"' - mv "$POL/intelligence/skills/linked-policy/agents/openai.yaml" "$OUT/linked-skill/openai.yaml" - ln -s "$OUT/linked-skill/openai.yaml" "$POL/intelligence/skills/linked-policy/agents/openai.yaml" - (cd "$POL" && IS_SUPPRESS_CLI_NOTE=1 bash "$CLI" sync --force > "$OUT/policy-link.txt" 2>&1) \ - || { echo "FAIL: skill-policy link sync"; cat "$OUT/policy-link.txt"; fail=1; } - chk grep -Fq 'linked/SKILL.md is reached through a symlink — Codex gets no invocation policy derived for it' \ - "$OUT/policy-link.txt" - chknot test -e "$POL/.agents/skills/linked/agents" - chk grep -Fq 'linked-policy/agents/openai.yaml is a symlink — left as-is, its invocation policy is not enforced' \ - "$OUT/policy-link.txt" - chk test -L "$POL/.agents/skills/linked-policy/agents/openai.yaml" - chknot grep -q 'allow_implicit_invocation' "$OUT/linked-skill/openai.yaml" + +echo "== symlinks in a skill source: rendered inside it, left out otherwise ==" +# Decision 0017. A link that resolves inside its own skills source renders as +# the regular file or directory it points at, in every skill tree, with the +# quoting and Codex policy any other file gets. One that resolves outside the +# source (the repository included), nowhere, or to a directory enclosing it is +# left out with one WARNING: line naming the skill and the link itself. No tree +# ever holds a link. Probed: some hosts cannot create a symlink and copy instead. +LNK="$OUT/skill-links" +LS="$LNK/intelligence/skills" +EL="$OUT/skill-links-elsewhere" +mkdir -p "$LS/_shared/aliased/references" "$LS/_shared/assets" "$LS/_shared/agents-dir" \ + "$LS/plain/references" "$EL/outside" "$EL/agents" "$LNK/beside-source/rel-out" +if ln -s ../../_shared/aliased "$LS/plain/probe" 2>/dev/null && [ -L "$LS/plain/probe" ]; then + rm "$LS/plain/probe" + git -C "$LNK" init --quiet + cat > "$LNK/intelligence.yaml" < [frontmatter line] — a minimal skill. + link_skill() { + mkdir -p "$1" + { + printf -- '---\nname: %s\ndescription: "%s"\n' "$2" "$2" + [ -z "${3:-}" ] || printf '%s\n' "$3" + printf -- '---\n\n# %s\n' "$2" + } > "$1/SKILL.md" + } + printf 'ELSEWHERE_MARKER\n' > "$EL/host.txt" + # Inside the source: a whole skill directory, a resource file and + # directory, a link inside a linked directory, SKILL.md itself, an + # authored agents/openai.yaml and the agents directory holding one. + link_skill "$LS/plain" plain + printf 'GUIDE_MARKER\n' > "$LS/_shared/guide.md" + printf '\000\001binary\377' > "$LS/_shared/assets/data.bin" + ln -s ../../_shared/guide.md "$LS/plain/references/guide.md" + ln -s ../_shared/assets "$LS/plain/assets" + link_skill "$LS/_shared/aliased" aliased 'effort: ultra' + ln -s ../../guide.md "$LS/_shared/aliased/references/guide.md" + ln -s _shared/aliased "$LS/aliased" + mkdir -p "$LS/linked-md" + printf -- '---\nname: linked-md\ndescription: Linked: colon\neffort: ultra\ndisable-model-invocation: true\n---\n\n# Linked md\n' \ + > "$LS/_shared/linked-md.md" + ln -s ../_shared/linked-md.md "$LS/linked-md/SKILL.md" + link_skill "$LS/policy-linked" policy-linked 'disable-model-invocation: true' + mkdir -p "$LS/policy-linked/agents" + printf '%s\n' 'policy:' ' allow_implicit_invocation: false' 'interface:' ' display_name: "Linked policy"' \ + > "$LS/_shared/openai.yaml" + ln -s ../../_shared/openai.yaml "$LS/policy-linked/agents/openai.yaml" + link_skill "$LS/agents-linked" agents-linked 'disable-model-invocation: true' + printf '%s\n' 'interface:' ' display_name: "Agents dir"' > "$LS/_shared/agents-dir/openai.yaml" + ln -s ../_shared/agents-dir "$LS/agents-linked/agents" + # Outside the source, nowhere, or enclosing itself. + link_skill "$EL/outside" outside + ln -s "$EL/outside" "$LS/outside" + mkdir -p "$EL/outside-empty" + ln -s "$EL/outside-empty" "$LS/outside-empty" + mkdir -p "$LS/outside-md" + printf -- '---\nname: outside-md\ndescription: "ELSEWHERE_MARKER"\n---\n' > "$EL/outside-md.md" + ln -s "$EL/outside-md.md" "$LS/outside-md/SKILL.md" + link_skill "$LNK/beside-source/rel-out" rel-out + ln -s ../../beside-source/rel-out "$LS/rel-out" + ln -s "$EL/host.txt" "$LS/plain/references/host" + ln -s ../../_shared/missing.md "$LS/plain/references/gone" + ln -s . "$LS/plain/loop" + ln -s missing-skill "$LS/dangling" + link_skill "$LS/esc-policy" esc-policy 'disable-model-invocation: true' + mkdir -p "$LS/esc-policy/agents" + printf '%s\n' 'policy:' ' allow_implicit_invocation: true' > "$EL/openai.yaml" + ln -s "$EL/openai.yaml" "$LS/esc-policy/agents/openai.yaml" + link_skill "$LS/esc-agents" esc-agents 'disable-model-invocation: true' + printf '%s\n' 'policy:' ' allow_implicit_invocation: true' > "$EL/agents/openai.yaml" + ln -s "$EL/agents" "$LS/esc-agents/agents" + # A link an older sync copied verbatim is pruned, not kept. + mkdir -p "$LNK/.claude/skills" + ln -s "$EL/outside" "$LNK/.claude/skills/stale-link" + (cd "$LNK" && IS_SUPPRESS_CLI_NOTE=1 bash "$CLI" sync > "$OUT/skill-links.txt" 2>&1) \ + || { echo "FAIL: skill-links sync"; cat "$OUT/skill-links.txt"; fail=1; } + TREES=("$LNK/.claude/skills" "$LNK/.agents/skills" "$LNK/.cursor/skills" "$LNK/.github/skills") + links_left="$(find "${TREES[@]}" "$LNK/.opencode" "$LNK/.pi" -type l 2>&1)" + [ -z "$links_left" ] || { echo "FAIL: generated output holds symlinks:"; printf '%s\n' "$links_left"; fail=1; } + chknot grep -rq ELSEWHERE_MARKER "${TREES[@]}" "$LNK/.opencode" "$LNK/.pi" "$LNK/AGENTS.md" + for tree in "${TREES[@]}"; do + for skill in plain aliased linked-md policy-linked agents-linked esc-policy esc-agents; do + chk test -f "$tree/$skill/SKILL.md" + done + for skill in outside outside-empty outside-md rel-out dangling stale-link _shared; do + chknot test -e "$tree/$skill" + done + chk grep -Fqx 'GUIDE_MARKER' "$tree/plain/references/guide.md" + chk grep -Fqx 'GUIDE_MARKER' "$tree/aliased/references/guide.md" + chk cmp -s "$LS/_shared/assets/data.bin" "$tree/plain/assets/data.bin" + chknot test -e "$tree/plain/references/host" + chknot test -e "$tree/plain/references/gone" + chknot test -e "$tree/plain/loop" + # SKILL.md behind a link gets the frontmatter quoting every SKILL.md gets. + chk grep -Fqx 'description: "Linked: colon"' "$tree/linked-md/SKILL.md" + done + # ... and the effort each tree renders (#47): Claude's level, the neutral + # value in the shared tree, no key where the tool has no field. + for skill in linked-md aliased; do + chk grep -Fqx 'effort: max' "$LNK/.claude/skills/$skill/SKILL.md" + chk grep -Fqx 'effort: ultra' "$LNK/.agents/skills/$skill/SKILL.md" + chknot grep -q '^effort:' "$LNK/.github/skills/$skill/SKILL.md" + chknot grep -q '^effort:' "$LNK/.cursor/skills/$skill/SKILL.md" + done + for skill in plain aliased linked-md policy-linked agents-linked esc-policy esc-agents; do + chk test -f "$LNK/.opencode/commands/$skill.md" + chk grep -q "intelligence/skills/$skill/SKILL.md" "$LNK/AGENTS.md" + done + for skill in outside outside-empty outside-md rel-out dangling; do + chknot test -e "$LNK/.opencode/commands/$skill.md" + chknot grep -q "intelligence/skills/$skill/" "$LNK/AGENTS.md" + done + # Codex reads the policy from the materialized files: derived for a linked + # SKILL.md, kept byte for byte when a linked file already sets it, added to + # a file in a linked agents directory, and derived in place of one left out. + OPEN="$LNK/.agents/skills" + chk grep -Fqx ' allow_implicit_invocation: false' "$OPEN/linked-md/agents/openai.yaml" + chk cmp -s "$LS/_shared/openai.yaml" "$OPEN/policy-linked/agents/openai.yaml" + chk grep -Fqx ' allow_implicit_invocation: false' "$OPEN/agents-linked/agents/openai.yaml" + chk grep -Fqx ' display_name: "Agents dir"' "$OPEN/agents-linked/agents/openai.yaml" + chknot grep -q 'allow_implicit_invocation' "$LS/_shared/agents-dir/openai.yaml" + for skill in esc-policy esc-agents; do + chk grep -q '^# Generated by intelligence-sync' "$OPEN/$skill/agents/openai.yaml" + chknot grep -q 'allow_implicit_invocation: true' "$OPEN/$skill/agents/openai.yaml" + done + # One warning per link, naming the skill and the path that is the link. + S_REL="intelligence/skills" + WANT_WARNINGS=( + "WARNING: skill 'outside' is left out of every output: $S_REL/outside is a symlink that resolves outside $S_REL" + "WARNING: skill 'outside-empty' is left out of every output: $S_REL/outside-empty is a symlink that resolves outside $S_REL" + "WARNING: skill 'outside-md' is left out of every output: $S_REL/outside-md/SKILL.md is a symlink that resolves outside $S_REL" + "WARNING: skill 'rel-out' is left out of every output: $S_REL/rel-out is a symlink that resolves outside $S_REL" + "WARNING: skill 'dangling' is left out of every output: $S_REL/dangling is a dangling symlink" + "WARNING: skill 'plain' renders without references/host: $S_REL/plain/references/host is a symlink that resolves outside $S_REL" + "WARNING: skill 'plain' renders without references/gone: $S_REL/plain/references/gone is a dangling symlink" + "WARNING: skill 'plain' renders without loop: $S_REL/plain/loop is a symlink to a directory that encloses it" + "WARNING: skill 'esc-policy' renders without agents/openai.yaml: $S_REL/esc-policy/agents/openai.yaml is a symlink that resolves outside $S_REL" + "WARNING: skill 'esc-agents' renders without agents: $S_REL/esc-agents/agents is a symlink that resolves outside $S_REL" + ) + for want in "${WANT_WARNINGS[@]}"; do + [ "$(grep -Fxc -- "$want" "$OUT/skill-links.txt")" = 1 ] \ + || { echo "FAIL: expected exactly one line: $want"; fail=1; } + done + chk test "$(grep -c '^WARNING: skill ' "$OUT/skill-links.txt")" -eq "${#WANT_WARNINGS[@]}" + # The same warnings are compact output's to keep. + (cd "$LNK" && IS_SUPPRESS_CLI_NOTE=1 bash "$CLI" sync --compact --force > "$OUT/skill-links-compact.txt" 2>&1) \ + || { echo "FAIL: skill-links compact sync"; cat "$OUT/skill-links-compact.txt"; fail=1; } + for want in "${WANT_WARNINGS[@]}"; do + chk grep -Fqx -- "$want" "$OUT/skill-links-compact.txt" + done else - echo " NOTE: this host cannot create a symlink — symlinked SKILL.md case skipped" + echo " NOTE: this host cannot create a symlink — symlinks in a skill source not exercised" fi echo "== Copilot output is ignored by default; commit_output keeps it tracked ==" diff --git a/cli/tests/e2e-sync-cache.sh b/cli/tests/e2e-sync-cache.sh index 1ce64f8..0cc08a8 100644 --- a/cli/tests/e2e-sync-cache.sh +++ b/cli/tests/e2e-sync-cache.sh @@ -360,6 +360,10 @@ miss check has 'unquoted colon' hit check has 'unquoted colon' +run --compact +check test "$RC" -eq 0 +check grep -q '^WARNING: .*warning.md:2 unquoted colon' <<< "$OUTPUT" +hit rm "$PROJECT/intelligence/rules/warning.md" miss @@ -372,6 +376,17 @@ miss --force check has 'NOT SYNCED: apps/one/intelligence/rules' hit check has 'NOT SYNCED: apps/one/intelligence/rules' +run --compact +check test "$RC" -eq 0 +check grep -Fxq 'WARNING: NOT SYNCED: apps/one/intelligence/rules' <<< "$OUTPUT" +check grep -Fxq 'WARNING: Wire one in: intelligence source add ' <<< "$OUTPUT" +miss --compact --force +check grep -Fxq 'WARNING: NOT SYNCED: apps/one/intelligence/rules' <<< "$OUTPUT" +check grep -Fxq 'WARNING: Wire one in: intelligence source add ' <<< "$OUTPUT" +hit +run --compact +check test "$RC" -eq 0 +check grep -Fxq 'WARNING: NOT SYNCED: apps/one/intelligence/rules' <<< "$OUTPUT" mkdir -p "$PROJECT/apps/two/intelligence/rules" printf '# Unconfigured\n' > "$PROJECT/apps/two/intelligence/rules/test.md" hit @@ -447,9 +462,12 @@ mkdir "$TMP/external" printf external > "$TMP/external/file" if MSYS=winsymlinks:nativestrict ln -s "$TMP/external/file" "$PROJECT/intelligence/skills/demo/assets/link" 2>/dev/null && [ -L "$PROJECT/intelligence/skills/demo/assets/link" ]; then miss + check has "WARNING: skill 'demo' renders without assets/link" miss rm "$PROJECT/intelligence/skills/demo/assets/link" - miss + # The link never reached an output (decision 0017), so without it inputs + # and outputs are again exactly what the cache recorded. + hit rm -rf "$PROJECT/.intelligence/sync-cache" MSYS=winsymlinks:nativestrict ln -s "$TMP/external" "$PROJECT/.intelligence/sync-cache" miss diff --git a/cli/tests/e2e-sync-check.sh b/cli/tests/e2e-sync-check.sh index ce3a6ef..395b8a6 100644 --- a/cli/tests/e2e-sync-check.sh +++ b/cli/tests/e2e-sync-check.sh @@ -294,11 +294,16 @@ source_link="$PROJECT/intelligence/skills/demo/assets/link" if MSYS=winsymlinks:nativestrict ln -s "$TMP/link-target/file" "$source_link" 2>/dev/null && [ -L "$source_link" ]; then run check test "$RC" -eq 0 - check test -L "$PROJECT/.claude/skills/demo/assets/link" + check test ! -e "$PROJECT/.claude/skills/demo/assets/link" + check test ! -e "$PROJECT/.agents/skills/demo/assets/link" + # Skills never emit escaping links; a hand-edited generated link still + # belongs to the rollback snapshot and must come back untouched. + ln -s "$TMP/link-target/file" "$PROJECT/.claude/skills/demo/assets/link" + ln -s "$TMP/link-target/file" "$PROJECT/.agents/skills/demo/assets/link" save_outputs # The fingerprint cannot carry a link, so every check takes the render path. run --check - check test "$RC" -eq 0 + check test "$RC" -eq 2 check rendered check outputs_kept check test "$(readlink "$PROJECT/.claude/skills/demo/assets/link")" = "$TMP/link-target/file" diff --git a/cli/tests/unit-engine.sh b/cli/tests/unit-engine.sh index 94fb651..560a666 100644 --- a/cli/tests/unit-engine.sh +++ b/cli/tests/unit-engine.sh @@ -644,5 +644,45 @@ rm "$R/rules/always.md" "$R/rules/again.md" "$R/AGENTS.md" want="CONTEXT: always-on=0 bytes (0 rules); custom=$(bytes "$R/rules/scoped.md" "$R/agents/one.md" "$R/skills/a/SKILL.md" "$R/skills/b/SKILL.md") bytes (1 scoped rules, 1 agents, 2 skills); agents-md=0 bytes; agents-md-status=not-generated" chk eq "$(report_context_source_sizes "$R" "$R/intelligence.yaml")" "$want" +echo "== skill_source_inventory: links inside the source render, loops end, failures stop ==" +# Decision 0017. Two directories linked into each other must copy once and stop; +# a linked SKILL.md renders as a regular file; a link that cannot be resolved +# stops the run instead of shortening the list. Probed, like every symlink case. +K="$OUT/links" +mkdir -p "$K/s/_shared/cyc-a" "$K/s/_shared/cyc-b" "$K/s/real" "$K/bin" +printf 'sources:\n skills:\n - "s/"\n' > "$K/intelligence.yaml" +if ln -s ../cyc-b "$K/s/_shared/cyc-a/x" 2>/dev/null && [ -L "$K/s/_shared/cyc-a/x" ]; then + ln -s ../cyc-a "$K/s/_shared/cyc-b/y" + printf -- '---\nname: cyc\n---\n' > "$K/s/_shared/cyc-a/SKILL.md" + printf 'B_MARKER\n' > "$K/s/_shared/cyc-b/b.txt" + ln -s _shared/cyc-a "$K/s/cyc" + printf -- '---\nname: real\ndescription: Real: colon\n---\n' > "$K/s/_shared/real.md" + ln -s ../_shared/real.md "$K/s/real/SKILL.md" + IS_SOURCE_FILES_MEMO=0 read_source_artifact_files "$K" "$K/intelligence.yaml" skills 2> "$K/warnings" + chk eq "${IS_SOURCE_FILES[*]}" "$K/s/cyc/SKILL.md $K/s/real/SKILL.md" + chk eq "$(cat "$K/warnings")" "WARNING: skill 'cyc' renders without x/y: s/_shared/cyc-b/y is a symlink to a directory that encloses it" + chk skill_source_rendered "$K/s//cyc/" + chk eval '! skill_source_rendered "$K/s/_shared"' + copy_skill_bundle_dirs "$K/out" "$K/s/cyc/" "$K/s/real" 2> "$K/copy-warnings" + chk eq "$(find "$K/out" -type l)" "" + chk eq "$(cat "$K/copy-warnings")" "" + chk grep -Fqx 'B_MARKER' "$K/out/cyc/x/b.txt" + chk eq "$(find "$K/out/cyc" | LC_ALL=C sort | sed "s#^$K/out/##" | tr '\n' ' ')" "cyc cyc/SKILL.md cyc/x cyc/x/b.txt " + chk grep -Fqx 'description: "Real: colon"' "$K/out/real/SKILL.md" + chk eq "$(find "$K/s" -type l | wc -l | tr -d ' ')" 4 + # A readlink that fails, or answers with more lines than links, refuses. + for fake in 'exit 1' 'printf "a\nb\n"'; do + printf '#!/bin/sh\n%s\n' "$fake" > "$K/bin/readlink" + chmod +x "$K/bin/readlink" + rc=0 + (PATH="$K/bin:$PATH"; IS_SOURCE_FILES_MEMO=0 read_source_artifact_files "$K" "$K/intelligence.yaml" skills) \ + > /dev/null 2> "$K/refusal" || rc=$? + chk eq "$rc" 1 + chk grep -Fq "cannot resolve the symlinks under '$K/s'" "$K/refusal" + done +else + echo " NOTE: this host cannot create a symlink — skill source links not exercised" +fi + [ "$fail" -eq 0 ] && echo "ENGINE-UNIT: ALL OK" exit "$fail" diff --git a/decisions/0017-render-symlinks-that-stay-inside-their-source.md b/decisions/0017-render-symlinks-that-stay-inside-their-source.md new file mode 100644 index 0000000..2cc154b --- /dev/null +++ b/decisions/0017-render-symlinks-that-stay-inside-their-source.md @@ -0,0 +1,79 @@ +# 0017 — Render symlinks that stay inside their source + +Date: 2026-10-07 +Status: accepted + +## Context + +A skills source can hold symlinks: a whole skill directory +(`skills/x -> ../../shared/x`), its `SKILL.md`, a bundled resource, or the +`agents/openai.yaml` Codex reads. Only project content and local-path packages +can; packages fetched from Git are checked out with `core.symlinks=false`. + +The adapters disagreed about them. Claude, Cursor and Copilot listed skills with +a glob and copied each link verbatim with `cp -R`, so a relative link resolved +in the output only while the output sat at the source's depth, and an absolute +one pointed out of the repository. The shared `.agents/skills` tree, the +`AGENTS.md` skill index and the context report listed skills with +`find -type d`, which skips a linked directory, so Codex, Pi, OpenCode and +Antigravity lost such a skill without a word. A linked `SKILL.md` stayed a +link in every tree, with a warning compact output dropped: no frontmatter +quoting, no layout tokens, no `effort:` rendering, no Codex invocation policy, +so an owner-only skill stayed selectable in Codex. A linked +`agents/openai.yaml` was left unenforced. + +Decision [0006](0006-safe-skill-imports-and-adapter-growth.md) already rules +out copying contents from an escaping symlink automatically. + +## Decision + +1. **A link that stays inside its source renders as what it points at.** A + symlink in a configured skills source is resolved by physical path. When it + reaches a regular file or directory inside the same source directory, sync + renders it exactly like that file or directory, as regular files in every + skill tree. Everything that runs on a skill runs on that copy: frontmatter + quoting, layout tokens, each tool's `effort:` level + ([0015](0015-separate-reasoning-effort-from-the-model-tier.md)), the Codex + invocation policy, and any transform added later. +2. **Any other link is left out, and said so.** A link that resolves outside + its source — elsewhere in the repository included — that dangles, or that + points to a directory enclosing it, is never read through and never copied. + Sync prints one `WARNING:` line per link, naming the skill and the path that + is the link. A skill whose directory or `SKILL.md` is such a link is left out + of every output; any other such link only drops that path from its skill. +3. **One decision per run, for every consumer.** The skills listing settles + every link once, fail-closed: a link it cannot resolve stops the run rather + than shorten the list. The shared tree, the glob-listing adapters, the + `AGENTS.md` index, OpenCode's commands and the frontmatter lint all take the + same answer. +4. **Generated skill trees never hold a link.** A link no listing covered — a + copy from outside every skills source — is removed with a warning, and a + link an earlier sync left in an owned skill tree is pruned. + +## Consequences + +- An alias inside one source (`skills/x -> _shared/x`) and a resource shared by + two skills of one source work in every tool. A skill kept elsewhere is added + as its own source (`intelligence source add`) or a package; both render it in + full. +- A source without links still costs one `find`, which now walks below the + skill directories to see a link inside a bundle, and replaces the separate + `find` the frontmatter lint ran. A source with links resolves them in one + extra process, plus one `readlink` call per link-chain hop for all its file + links together, never per file. +- A source with links already bypasses sync reuse, which refuses to fingerprint + a link ([0010](0010-skip-unchanged-local-sync.md)), so a change behind a link + always renders. +- Rule and agent sources are not covered here. A rule or agent file that is a + link is still followed by the adapters that glob it, wherever it points, while + `AGENTS.md` and the context report skip it; settling that needs its own + decision. + +## Rejected + +- **Emit the link and warn** — what a linked `SKILL.md` used to get. The link + resolves against the output's location instead of the source's, escapes the + repository when absolute, and skips every transform a skill needs, which in + Codex makes an owner-only skill selectable by the model. +- **Leave out every link** — safe, but breaks in-source aliases and resources + shared between skills of one source, which cost nothing to support safely. diff --git a/docs/cli.md b/docs/cli.md index 3e63afc..7a4b132 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -186,10 +186,13 @@ and skill entry points), followed by a numeric rendered `AGENTS.md` byte count and its `generated`, `not-generated`, or `disabled` status. Supporting skill assets are excluded because they load only when a skill reads them. -`--compact` prints that context summary and actionable one-line warnings, -followed by the final machine-readable status and completion line when sync -succeeds. If lifecycle preflight, restore or rendering fails, it prints the -complete captured diagnostics and preserves the original exit code. +`--compact` prints that context summary and every warning, followed by the final +machine-readable status and completion line when sync succeeds. A warning is one +line starting `WARNING:` — among them the frontmatter checks, such as a `name` +over 64 or a `description` over 1024 characters that the tool will reject at +load time. An unchanged run replays the same warnings. If lifecycle preflight, +restore or rendering fails, it prints the complete captured diagnostics and +preserves the original exit code. If the manifest declares packages but `intelligence.lock` is missing, every mutating lifecycle command fails before alignment or restoration. Restore the diff --git a/engine/adapters/claude.sh b/engine/adapters/claude.sh index 9494e3d..1863f59 100644 --- a/engine/adapters/claude.sh +++ b/engine/adapters/claude.sh @@ -63,6 +63,9 @@ sync_claude_skills() { local src d skill_name local -a skill_dirs=() + # The inventory decides which skills render, symlinked ones included + # (decision 0017); the glob keeps this adapter's own listing order. + skill_source_inventory "$repo_root" "$config_file" load_yaml_list "$config_file" "skills" local list="$IS_YAML_LIST" while IFS= read -r src; do @@ -72,6 +75,7 @@ sync_claude_skills() { for d in "$dir"/*/; do [ -d "$d" ] || continue [ -f "$d/SKILL.md" ] || continue + skill_source_rendered "$d" || continue skill_dirs+=("$d") done done <<< "$list" @@ -178,7 +182,7 @@ sync_to_claude() { # commands/, statusline.sh, etc.). rm -rf "$output_dir/rules" "$output_dir/agents" if [ -d "$output_dir/skills" ]; then - find "$output_dir/skills" -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} + + find "$output_dir/skills" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -exec rm -rf {} + fi mkdir -p "$output_dir/rules" "$output_dir/skills" "$output_dir/agents" diff --git a/engine/adapters/copilot.sh b/engine/adapters/copilot.sh index 186a9f4..f513fec 100644 --- a/engine/adapters/copilot.sh +++ b/engine/adapters/copilot.sh @@ -168,6 +168,9 @@ sync_copilot_skills() { local src d skill_name local -a skill_dirs=() + # The inventory decides which skills render, symlinked ones included + # (decision 0017); the glob keeps this adapter's own listing order. + skill_source_inventory "$repo_root" "$config_file" load_yaml_list "$config_file" "skills" local list="$IS_YAML_LIST" while IFS= read -r src; do @@ -177,6 +180,7 @@ sync_copilot_skills() { for d in "$dir"/*/; do [ -d "$d" ] || continue [ -f "$d/SKILL.md" ] || continue + skill_source_rendered "$d" || continue skill_dirs+=("$d") done done <<< "$list" @@ -270,7 +274,7 @@ sync_to_copilot() { # Clean generated content (preserve workflows, etc.) rm -rf "$output_dir/instructions" "$output_dir/prompts" if [ -d "$output_dir/skills" ]; then - find "$output_dir/skills" -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} + + find "$output_dir/skills" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -exec rm -rf {} + fi rm -rf "$output_dir/agents" mkdir -p "$output_dir" "$output_dir/skills" "$output_dir/agents" diff --git a/engine/adapters/cursor.sh b/engine/adapters/cursor.sh index 83c49ea..0f1ddc0 100644 --- a/engine/adapters/cursor.sh +++ b/engine/adapters/cursor.sh @@ -104,6 +104,9 @@ sync_cursor_skills() { local src d skill_name local -a skill_dirs=() + # The inventory decides which skills render, symlinked ones included + # (decision 0017); the glob keeps this adapter's own listing order. + skill_source_inventory "$repo_root" "$config_file" load_yaml_list "$config_file" "skills" local list="$IS_YAML_LIST" while IFS= read -r src; do @@ -113,6 +116,7 @@ sync_cursor_skills() { for d in "$dir"/*/; do [ -d "$d" ] || continue [ -f "$d/SKILL.md" ] || continue + skill_source_rendered "$d" || continue skill_dirs+=("$d") done done <<< "$list" diff --git a/engine/adapters/opencode.sh b/engine/adapters/opencode.sh index 456694b..9c6df39 100644 --- a/engine/adapters/opencode.sh +++ b/engine/adapters/opencode.sh @@ -90,6 +90,9 @@ sync_opencode_commands() { local src d skill_name local -a skill_mds=() + # The inventory decides which skills render, symlinked ones included + # (decision 0017); the glob keeps this adapter's own listing order. + skill_source_inventory "$repo_root" "$config_file" load_yaml_list "$config_file" "skills" local list="$IS_YAML_LIST" while IFS= read -r src; do @@ -99,6 +102,7 @@ sync_opencode_commands() { for d in "$dir"/*/; do [ -d "$d" ] || continue [ -f "$d/SKILL.md" ] || continue + skill_source_rendered "$d" || continue skill_mds+=("${d%/}/SKILL.md") done done <<< "$list" diff --git a/engine/lib/common.sh b/engine/lib/common.sh index b2cdaa4..9dab2ee 100644 --- a/engine/lib/common.sh +++ b/engine/lib/common.sh @@ -452,8 +452,11 @@ copy_md_with_quoted_frontmatter() { # ship support files beside SKILL.md, and SKILL.md bodies point at them by # relative path, so dropping them breaks the skill at runtime). Markdown is # normalized to LF; every other file is copied byte-for-byte so potentially -# binary assets survive. `cp -R` copies symlinks as symlinks (POSIX), so a -# link inside a source skill never leaks host file content into the output. +# binary assets survive. A symlink never reaches the output: the plan +# skill_source_inventory made turns a link inside its source into the regular +# file or directory it points at and leaves any other out, and the flush +# removes a link no plan covered (a copy from outside every skills source), so +# no host file outside the source is ever copied in (decision 0017). # # SKILL.md frontmatter is quoted for EVERY consumer, not only the strict-YAML # ones. `argument-hint: [pr-number]` is a YAML *flow sequence*, so an unquoted @@ -470,7 +473,7 @@ copy_md_with_quoted_frontmatter() { # Usage: copy_skill_bundle "src/skill/dir" "dest/skill/dir" copy_skill_bundle() { _skill_bundles_reset - _skill_bundle_stage "$1" "$2" + _skill_bundle_stage "$1" "$2" || return 1 _skill_bundles_flush open } @@ -478,7 +481,8 @@ copy_skill_bundle() { # copies every source skill directory into /, then one # awk pass quotes and finalizes every bundled markdown file across all # bundles. A later source with the same skill name overwrites file-by-file in -# order, exactly like the sequential per-bundle copies did. +# order, exactly like the sequential per-bundle copies did. A skill with links +# to resolve is copied from its materialized stage, in the same order. copy_skill_bundle_dirs() { copy_skill_bundle_dirs_for open "$@" } @@ -489,13 +493,27 @@ copy_skill_bundle_dirs_for() { local effort_tool="$1" dest_root="$2" shift 2 [ "$#" -gt 0 ] || return 0 - local src dest + local src dest linked=0 local -a srcs=() for src in "$@"; do - srcs+=("${src%/}") + src="${src%/}" + srcs+=("$src") + ! _ski_planned "$src" || linked=1 done mkdir -p "$dest_root" - cp -R "${srcs[@]}" "$dest_root/" + if [ "$linked" = 0 ]; then + cp -R "${srcs[@]}" "$dest_root/" + else + for src in "${srcs[@]}"; do + if _ski_planned "$src"; then + _skill_bundle_materialize "$src" || return 1 + cp -R "$IS_SKI_STAGED" "$dest_root/" || return 1 + rm -rf "${IS_SKI_STAGED%/*}" + else + cp -R "$src" "$dest_root/" || return 1 + fi + done + fi _skill_bundles_reset for src in "${srcs[@]}"; do dest="$dest_root/${src##*/}" @@ -513,24 +531,63 @@ _skill_bundles_reset() { _skill_bundle_stage() { local src="${1%/}" dest="$2" mkdir -p "$dest" - cp -R "$src/." "$dest/" + if _ski_planned "$src"; then + _skill_bundle_materialize "$src" || return 1 + cp -R "$IS_SKI_STAGED/." "$dest/" || return 1 + rm -rf "${IS_SKI_STAGED%/*}" + else + cp -R "$src/." "$dest/" + fi _skill_bundle_note "$dest" } +# _skill_bundle_materialize — copy a skill whose plan holds links +# into a fresh stage and apply the plan there, parents first: each planned link +# is replaced by a copy of the file or directory it resolves to inside its +# source, or removed. Sets IS_SKI_STAGED to /; the caller copies +# it on and removes the stage. Spawns per link, never per file — a source +# without links never comes here. +_skill_bundle_materialize() { + local src="$1" key stage out rec rel act phys first=1 + _ski_key_var "$src" + key="$_SKI_KEY" + stage="$(mktemp -d -t intelligence-skill-XXXXXX)" || return 1 + out="$stage/${key##*/}" + while IFS= read -r rec; do + case "$rec" in + "$key$IS_SKI_US"*) ;; + *) continue ;; + esac + rec="${rec#"$key$IS_SKI_US"}" + rel="${rec%%"$IS_SKI_US"*}" + rec="${rec#*"$IS_SKI_US"}" + act="${rec%%"$IS_SKI_US"*}" + phys="${rec#*"$IS_SKI_US"}" + if [ "$first" = 1 ]; then + first=0 + if [ -z "$rel" ]; then + # The skill directory itself is the link. + mkdir "$out" && cp -R "$phys/." "$out/" || return 1 + continue + fi + cp -R "$src" "$stage/" || return 1 + fi + # The copy holds the link itself here; `rm -f` never follows it and + # refuses a directory, so a plan that no longer matches stops the run. + rm -f "$out/$rel" || return 1 + case "$act" in + f) cp "$phys" "$out/$rel" || return 1 ;; + d) mkdir "$out/$rel" && cp -R "$phys/." "$out/$rel/" || return 1 ;; + esac + done <<< "$IS_SKI_PLAN" + IS_SKI_STAGED="$out" +} + # Record a staged bundle for the flush pass: mark its SKILL.md for # frontmatter quoting and deduplicate the destination. _skill_bundle_note() { local dest="$1" - # A symlinked SKILL.md is left exactly as `cp -R` produced it — a - # symlink. `[ -f ]` follows links, so quoting it would read the link's - # TARGET and write that content into a real file, turning - # `skills/x/SKILL.md -> /etc/…` into a copy of a host file inside the - # output. That is the leak the symlink-preserving copy exists to - # prevent, so skip the rewrite and say so (the same reason - # `find -type f` in the flush never matches a symlink). - if [ -L "$dest/SKILL.md" ]; then - echo " WARN: ${dest##*/}/SKILL.md is a symlink — emitted as-is (frontmatter not quoted, tokens not expanded)" >&2 - elif [ -f "$dest/SKILL.md" ]; then + if [ -f "$dest/SKILL.md" ]; then _SB_QUOTE_LIST="$_SB_QUOTE_LIST$dest/SKILL.md"$'\n' fi # The same skill name from a later source overwrites the earlier copy; @@ -547,11 +604,25 @@ _skill_bundle_note() { # _skill_bundles_flush _skill_bundles_flush() { [ "${#_SB_DESTS[@]}" -gt 0 ] || return 0 - local -a mds=() - local f quote_list="$_SB_QUOTE_LIST" + local -a mds=() links=() + local f listing quote_list="$_SB_QUOTE_LIST" + # Captured, not streamed: a listing cut short could leave a link behind. + listing="$(find "${_SB_DESTS[@]}" \( -type l -o -type f -name '*.md' \) -print)" || { + echo "ERROR: could not list the skill copies under ${_SB_DESTS[0]%/*}" >&2 + return 1 + } while IFS= read -r f; do - [ -n "$f" ] && mds+=("$f") - done < <(find "${_SB_DESTS[@]}" -type f -name '*.md') + [ -n "$f" ] || continue + if [ -L "$f" ]; then links+=("$f"); else mds+=("$f"); fi + done <<< "$listing" + # A link only a copy from outside every skills source can bring: no plan + # resolved it, so it is not followed but removed. + if [ "${#links[@]}" -gt 0 ]; then + rm -f "${links[@]}" || return 1 + for f in "${links[@]}"; do + echo "WARNING: ${f#"${REPO_ROOT:-}"/} was copied as a symlink from outside every skills source — removed; generated output never holds one" >&2 + done + fi _skill_bundles_reset [ "${#mds[@]}" -gt 0 ] || return 0 effort_map_var "$1" @@ -711,19 +782,15 @@ sync_open_skill_dirs() { # when the author's file sets none, and a file whose policy says otherwise, or # that sync cannot read as saying it, refuses the render — an owner-only skill # Codex can still select is the exact failure the field exists to prevent, so -# it never passes as a success. +# it never passes as a success. The tree holds no symlink — copy_skill_bundle_dirs +# renders a link inside its source as a regular file and leaves any other out — +# so a skill whose SKILL.md or agents/openai.yaml is a link in its source is +# read and enforced like any other. open_skill_invocation_policies() { local output_dir="$1" path dir flag rows refusals local -a skill_mds=() policy_dirs=() policies=() authored=() for path in "$output_dir"/*/SKILL.md; do - dir="${path%/SKILL.md}" - # A link is emitted as-is (see _skill_bundle_note): reading through it - # or writing beside it would reach outside the output tree. - if [ -L "$dir" ] || [ -L "$path" ]; then - echo " WARN: ${dir##*/}/SKILL.md is reached through a symlink — Codex gets no invocation policy derived for it" >&2 - elif [ -f "$path" ]; then - skill_mds+=("$path") - fi + [ ! -f "$path" ] || skill_mds+=("$path") done [ "${#skill_mds[@]}" -gt 0 ] || return 0 # Captured, not streamed: a reader cut short must fail the render rather @@ -736,10 +803,6 @@ open_skill_invocation_policies() { [ "$flag" = "true" ] || continue dir="${path%/SKILL.md}" path="$dir/agents" - if [ -L "$path" ] || [ -L "$path/openai.yaml" ]; then - echo " WARN: ${dir##*/}/agents/openai.yaml is a symlink — left as-is, its invocation policy is not enforced" >&2 - continue - fi if [ -s "$path/openai.yaml" ]; then authored+=("$path/openai.yaml") continue @@ -860,6 +923,8 @@ enforce_authored_invocation_policies() { # Lint YAML frontmatter for common pitfalls (unquoted colons, leading tabs). # Print warnings to stderr; do not fail. Strict consumers (Codex CLI) reject # these files with cryptic messages — catching them in sync gives better DX. +# Each finding is a `WARNING:` line: the author has to edit the source, so +# `sync --compact` (the first sync `init` runs) must keep it. # Batched: one awk process lints every file passed. # The engine lints every source once, so this is also where an `effort:` off # IS_EFFORT_LEVELS is reported — read with frontmatter_index's semantics, @@ -892,7 +957,7 @@ lint_frontmatter_files() { } } in_fm && /^\t/ { - printf " WARN: %s:%d leading tab in frontmatter (use spaces)\n", FILENAME, FNR > "/dev/stderr" + printf "WARNING: %s:%d leading tab in frontmatter (use spaces)\n", FILENAME, FNR > "/dev/stderr" } in_fm && /^[a-zA-Z0-9_-]+:[[:space:]]+[^"\047|>[{]/ { value_start = index($0, ":") + 1 @@ -900,10 +965,10 @@ lint_frontmatter_files() { sub(/^[[:space:]]+/, "", value) if (value ~ /:[[:space:]]/ || value ~ /:$/) { col = index(value, ":") + value_start - printf " WARN: %s:%d unquoted colon in value at column %d — wrap value in quotes\n", FILENAME, FNR, col > "/dev/stderr" + printf "WARNING: %s:%d unquoted colon in value at column %d — wrap value in quotes\n", FILENAME, FNR, col > "/dev/stderr" } if (value ~ /"/) { - printf " WARN: %s:%d literal double quote in unquoted value — wrap value in single quotes or escape as \\\" so strict-YAML targets accept it\n", FILENAME, FNR > "/dev/stderr" + printf "WARNING: %s:%d literal double quote in unquoted value — wrap value in single quotes or escape as \\\" so strict-YAML targets accept it\n", FILENAME, FNR > "/dev/stderr" } } # Field-length limits. Both Claude Code and the Agent Skills standard @@ -922,7 +987,7 @@ lint_frontmatter_files() { } limit = (key == "name") ? 64 : 1024 if (length(val) > limit) { - printf " WARN: %s:%d %s is %d chars — over the %d-char limit; the skill/agent will be REJECTED at load time\n", FILENAME, FNR, key, length(val), limit > "/dev/stderr" + printf "WARNING: %s:%d %s is %d chars — over the %d-char limit; the skill/agent will be REJECTED at load time\n", FILENAME, FNR, key, length(val), limit > "/dev/stderr" } } ' "$@" @@ -1655,11 +1720,7 @@ warn_unsynced() { done if [ "$matched" = false ]; then - if [ $warnings -eq 0 ]; then - echo "" - echo "=== WARNING: Unsynced directories ===" - fi - echo " NOT SYNCED: $rel_dir" + echo "WARNING: NOT SYNCED: $rel_dir" >&2 warnings=$((warnings + 1)) fi done < <(find "$repo_root" \( -name ".git" -o -name "node_modules" -o -name "vendor" -o -name "dist" -o -name ".claude" -o -name ".cursor" -o -name ".github" -o -name ".codex" -o -name ".agents" -o -name ".intelligence" \) -prune -o -type d \( -name "rules" -o -name "agents" -o -name "skills" -o -name "Rules" -o -name "Agents" -o -name "Skills" \) -print 2>/dev/null) @@ -1670,13 +1731,366 @@ warn_unsynced() { # and hand-placing an entry in an ordered list decides which artifact # wins. The engine may also run without the CLI around it. if [ "${IS_CLI:-0}" = "1" ]; then - echo " Wire one in: intelligence source add " + echo "WARNING: Wire one in: intelligence source add " >&2 else - echo " Add these paths to sources: in ${config_file##*/}" + echo "WARNING: Add these paths to sources: in ${config_file##*/}" >&2 fi fi } +# --- Skill sources and the symlinks inside them (decision 0017) --- +# +# A symlink in a skills source renders as the regular file or directory it +# resolves to while that stays inside the same source, and is left out with a +# WARNING: line when it resolves outside it, nowhere, or to a directory that +# encloses it. Generated output never holds a link, and nothing is ever copied +# from outside the source (decision 0006). Every consumer takes that decision +# from here, once per run: the skill list read_source_artifact_files returns, +# the membership the glob-listing adapters check (skill_source_rendered), and +# the plan copy_skill_bundle_dirs applies to its copy. A skill rendered from a +# link is an ordinary skill from then on — quoting, token expansion, the +# effort rendering and the Codex invocation policy all run on the output copy. +# +# skill_source_inventory fills, in the caller's +# shell and fail-closed like read_source_artifact_files: +# IS_SKI_LIST //SKILL.md of every rendered skill, one a line, +# sources in manifest order, names in byte order; +# IS_SKI_KEYS "\n/\n…", the same skills as a membership set; +# IS_SKI_PLAN "\n" + one record a line for every link in a rendered skill, +# parents first: / US +# US f|d|x US . f and d materialize the file or +# directory at that path, x leaves the link out; an empty path +# is the skill directory itself. +# The resolution spawns processes only for a source that holds a link, and +# then per source and per link-chain hop, never per file. +IS_SKI_US=$'\x1f' + +skill_source_inventory() { + local repo_root="$1" config_file="$2" memo_key src dir listing + memo_key="$repo_root"$'\n'"$config_file" + if [ "${IS_SOURCE_FILES_MEMO:-0}" = 1 ] && [ "${IS_SKI_KEY:-}" = "$memo_key" ]; then + return 0 + fi + IS_SKI_KEY="" + IS_SKI_LIST="" + IS_SKI_KEYS=$'\n' + IS_SKI_PLAN=$'\n' + load_yaml_list "$config_file" "skills" + while IFS= read -r src; do + [ -n "$src" ] || continue + dir="$repo_root/$src" + while [ "${dir%/}" != "$dir" ]; do dir="${dir%/}"; done + [ -d "$dir" ] || continue + # Every directory and link at any depth: the top level holds the + # skills, and a link anywhere below one is part of its bundle. -H + # lists a source that is itself a link through it, as a glob does. + # Command substitution, so find or sort failing stops the run here + # instead of yielding a short list. + listing="$(find -H "$dir" -mindepth 1 \( -type d -o -type l \) -print | LC_ALL=C sort)" || { + echo "ERROR: cannot enumerate skills under '$dir' — refusing to render a partial list." >&2 + exit 1 + } + [ -n "$listing" ] || continue + _ski_source "$repo_root" "$dir" "$listing" + done <<< "$IS_YAML_LIST" + [ "${IS_SOURCE_FILES_MEMO:-0}" != 1 ] || IS_SKI_KEY="$memo_key" +} + +# skill_source_rendered — the inventory renders this skill. For +# adapters that list a skills source with their own glob. +skill_source_rendered() { + _ski_key_var "$1" + case "${IS_SKI_KEYS:-}" in + *$'\n'"$_SKI_KEY"$'\n'*) return 0 ;; + esac + return 1 +} + +# _ski_key_var — the key a skill is listed and planned under, its +# source and name joined by one slash however the caller spelled a trailing +# slash on either. +_ski_key_var() { + local path="$1" parent + while [ "${path%/}" != "$path" ]; do path="${path%/}"; done + parent="${path%/*}" + while [ -n "$parent" ] && [ "${parent%/}" != "$parent" ]; do parent="${parent%/}"; done + _SKI_KEY="$parent/${path##*/}" +} + +# _ski_planned — the skill has links the copy has to resolve. +_ski_planned() { + _ski_key_var "$1" + case "${IS_SKI_PLAN:-}" in + *$'\n'"$_SKI_KEY$IS_SKI_US"*) return 0 ;; + esac + return 1 +} + +# _ski_source — inventory one skills source. +_ski_source() { + local repo_root="$1" f rel + local -a tops=() links=() + _SKI_DIR="$2" + _SKI_ROOT_REL="${2#"$repo_root"/}" + _SKI_REPO="$repo_root" + while IFS= read -r f; do + [ -n "$f" ] || continue + rel="${f#"$_SKI_DIR"/}" + case "$rel" in + */*) [ ! -L "$f" ] || links+=("$f") ;; + *) + tops+=("$f") + [ ! -L "$f" ] || links+=("$f") + ;; + esac + done <<< "$3" + _SKI_LREL=(); _SKI_LSTAT=(); _SKI_LKIND=(); _SKI_LTREL=(); _SKI_LPHYS=() + [ "${#links[@]}" -eq 0 ] || _ski_classify_links "${links[@]}" + for f in "${tops[@]+"${tops[@]}"}"; do + _ski_skill "${f##*/}" + done +} + +# _ski_classify_links ... — resolve every link of the current source and +# record where it leads: ok (inside the source), outside, dangling, or other +# (neither a file nor a directory). A link that cannot be resolved stops the +# run, like any enumeration that cannot answer. +_ski_classify_links() { + local out root_phys rec kind phys i=0 link + local -a recs=() + out="$(_ski_physical "$_SKI_DIR" "$@")" || { + echo "ERROR: cannot resolve the symlinks under '$_SKI_DIR' — refusing to render a partial list." >&2 + exit 1 + } + while IFS= read -r rec; do recs+=("$rec"); done <<< "$out" + if [ "${#recs[@]}" -ne $(($# + 1)) ]; then + echo "ERROR: cannot resolve the symlinks under '$_SKI_DIR' — refusing to render a partial list." >&2 + exit 1 + fi + root_phys="${recs[0]%/}" + for link in "$@"; do + rec="${recs[i + 1]}" + kind="${rec%%"$IS_SKI_US"*}" + phys="${rec#*"$IS_SKI_US"}" + _SKI_LREL[i]="${link#"$_SKI_DIR"/}" + _SKI_LKIND[i]="$kind" + _SKI_LPHYS[i]="$phys" + _SKI_LTREL[i]="" + case "$kind" in + d|f) + case "$phys" in + "$root_phys"/*) _SKI_LSTAT[i]=ok; _SKI_LTREL[i]="${phys#"$root_phys"/}" ;; + "${root_phys:-/}") _SKI_LSTAT[i]=ok ;; + *) _SKI_LSTAT[i]=outside ;; + esac + ;; + o) _SKI_LSTAT[i]=other ;; + *) _SKI_LSTAT[i]=dangling ;; + esac + i=$((i + 1)) + done +} + +# _ski_physical ... — print the source's physical path, +# then one " US " line per link: d (directory), f +# (regular file), o (anything else) or x (dangling, or a link loop). Runs in a +# subshell so it can change directory: `cd -P` resolves a directory, and a +# file's chain is read with one readlink per hop for all links at once (no +# readlink -f on macOS). +_ski_physical() ( + local dir="$1" link target targets i k n hops=0 parent + local -a kind=() cur=() phys=() pend=() next=() paths=() + shift + n=$# + cd -P -- "$dir" 2>/dev/null || exit 1 + printf '%s\n' "$PWD" + i=0 + for link in "$@"; do + kind[i]=x; phys[i]=""; cur[i]="$link" + if [ -d "$link" ]; then + cd -P -- "$link" 2>/dev/null || exit 1 + kind[i]=d; phys[i]="$PWD" + elif [ -f "$link" ]; then + kind[i]=f; pend+=("$i") + elif [ -e "$link" ]; then + kind[i]=o + fi + i=$((i + 1)) + done + while [ "${#pend[@]}" -gt 0 ]; do + hops=$((hops + 1)) + [ "$hops" -le 40 ] || exit 1 + paths=() + for i in "${pend[@]}"; do paths+=("${cur[i]}"); done + targets="$(readlink "${paths[@]}")" || exit 1 + next=(); k=0 + while IFS= read -r target; do + # More lines than links: a target held a newline. + [ "$k" -lt "${#pend[@]}" ] || exit 1 + i="${pend[k]}" + case "$target" in + /*) cur[i]="$target" ;; + *) cur[i]="${cur[i]%/*}/$target" ;; + esac + [ ! -L "${cur[i]}" ] || next+=("$i") + k=$((k + 1)) + done <<< "$targets" + [ "$k" -eq "${#pend[@]}" ] || exit 1 + pend=("${next[@]+"${next[@]}"}") + done + for ((i = 0; i < n; i++)); do + if [ "${kind[i]}" = f ]; then + parent="${cur[i]%/*}" + cd -P -- "${parent:-/}" 2>/dev/null || exit 1 + phys[i]="${PWD%/}/${cur[i]##*/}" + fi + printf '%s%s%s\n' "${kind[i]}" "$IS_SKI_US" "${phys[i]}" + done +) + +# _ski_link_var — set _SKI_K to the index of the link at that +# path, or -1. +_ski_link_var() { + local k=0 + _SKI_K=-1 + while [ "$k" -lt "${#_SKI_LREL[@]}" ]; do + if [ "${_SKI_LREL[k]}" = "$1" ]; then + _SKI_K="$k" + return 0 + fi + k=$((k + 1)) + done +} + +# _ski_loops — materializing the directory at would +# copy one of the paths (newline-separated) being materialized, the +# link itself included, so it would never end. +_ski_loops() { + local target="$1" open + [ -n "$target" ] || return 0 + while IFS= read -r open; do + case "$open" in "$target"|"$target"/*) return 0 ;; esac + done <<< "$2" + return 1 +} + +# _ski_skill — decide one top-level entry of the current source: a +# rendered skill (with its plan and warnings), a skill left out, or no skill. +_ski_skill() { + local name="$1" key content open="" state rel link reason nl=$'\n' + key="$_SKI_DIR/$name" + _SKI_ENTRIES="$nl" + _SKI_DROPS="" + _ski_link_var "$name" + if [ "$_SKI_K" -ge 0 ]; then + # The skill directory is itself a link. A test through one that + # leaves the source only stats it; nothing is read from outside. + case "${_SKI_LSTAT[_SKI_K]}:${_SKI_LKIND[_SKI_K]}" in + ok:d) ;; + outside:d) + _ski_warn "$name" "" "$name" outside + return 0 + ;; + dangling:*) + _ski_warn "$name" "" "$name" dangling + return 0 + ;; + *) return 0 ;; + esac + content="${_SKI_LTREL[_SKI_K]}" + if _ski_loops "$content" ""; then + [ ! -f "$_SKI_DIR/$name/SKILL.md" ] || _ski_warn "$name" "" "$name" loop + return 0 + fi + _SKI_ENTRIES="$_SKI_ENTRIES$key$IS_SKI_US${IS_SKI_US}d$IS_SKI_US${_SKI_LPHYS[_SKI_K]}$nl" + open="$nl" + else + content="$name" + fi + _ski_expand "$key" "$content" "" "$open" + # A skill is a directory whose SKILL.md renders as a regular file. + case "$_SKI_ENTRIES" in + *"$nl$key${IS_SKI_US}SKILL.md${IS_SKI_US}f$IS_SKI_US"*) state=ok ;; + *"$nl$key${IS_SKI_US}SKILL.md${IS_SKI_US}x$IS_SKI_US"*) state=dropped ;; + *"$nl$key${IS_SKI_US}SKILL.md$IS_SKI_US"*) state=none ;; + *) + state=none + [ ! -f "$_SKI_DIR/$content/SKILL.md" ] || state=ok + ;; + esac + case "$state" in + none) return 0 ;; + dropped) + while IFS="$IS_SKI_US" read -r rel link reason; do + [ "$rel" != "SKILL.md" ] || _ski_warn "$name" "" "$link" "$reason" + done <<< "$_SKI_DROPS" + return 0 + ;; + esac + IS_SKI_LIST="$IS_SKI_LIST$_SKI_DIR/$name/SKILL.md$nl" + IS_SKI_KEYS="$IS_SKI_KEYS$key$nl" + IS_SKI_PLAN="$IS_SKI_PLAN${_SKI_ENTRIES#"$nl"}" + while IFS="$IS_SKI_US" read -r rel link reason; do + [ -z "$rel" ] || _ski_warn "$name" "$rel" "$link" "$reason" + done <<< "$_SKI_DROPS" +} + +# _ski_expand — plan every link inside the +# source directory as it lands at in the skill copy, +# recursing into each directory a link materializes. holds the +# locations of the links already being materialized, for _ski_loops. +_ski_expand() { + local key="$1" content="$2" prefix="$3" open="$4" k=0 rel out location + while [ "$k" -lt "${#_SKI_LREL[@]}" ]; do + rel="${_SKI_LREL[k]}" + case "$rel" in + "$content"/*) ;; + *) k=$((k + 1)); continue ;; + esac + out="${prefix:+$prefix/}${rel#"$content"/}" + case "${_SKI_LSTAT[k]}:${_SKI_LKIND[k]}" in + ok:f) + _SKI_ENTRIES="$_SKI_ENTRIES$key$IS_SKI_US$out${IS_SKI_US}f$IS_SKI_US${_SKI_LPHYS[k]}"$'\n' + ;; + ok:d) + location="${rel%/*}" + if _ski_loops "${_SKI_LTREL[k]}" "$open$location"; then + _ski_drop "$key" "$out" "$rel" loop + else + _SKI_ENTRIES="$_SKI_ENTRIES$key$IS_SKI_US$out${IS_SKI_US}d$IS_SKI_US${_SKI_LPHYS[k]}"$'\n' + _ski_expand "$key" "${_SKI_LTREL[k]}" "$out" "$open$location"$'\n' + fi + ;; + *) _ski_drop "$key" "$out" "$rel" "${_SKI_LSTAT[k]}" ;; + esac + k=$((k + 1)) + done +} + +# _ski_drop — leave a link out. +_ski_drop() { + _SKI_ENTRIES="$_SKI_ENTRIES$1$IS_SKI_US$2${IS_SKI_US}x$IS_SKI_US"$'\n' + _SKI_DROPS="$_SKI_DROPS$2$IS_SKI_US$3$IS_SKI_US$4"$'\n' +} + +# _ski_warn — one WARNING: +# line naming the skill and the link by its own path. An empty +# means the whole skill is left out. +_ski_warn() { + local link="$_SKI_ROOT_REL/$3" what + case "$4" in + outside) what="is a symlink that resolves outside $_SKI_ROOT_REL" ;; + dangling) what="is a dangling symlink" ;; + loop) what="is a symlink to a directory that encloses it" ;; + *) what="is a symlink to neither a file nor a directory" ;; + esac + if [ -n "$2" ]; then + echo "WARNING: skill '$1' renders without $2: $link $what" >&2 + else + echo "WARNING: skill '$1' is left out of every output: $link $what" >&2 + fi +} + # Enumerate one manifest source kind using the same depth and ordering # everywhere. Source-list order is significant; entries inside each directory # use byte-order sorting for cross-platform determinism. The result lands in @@ -1696,6 +2110,9 @@ warn_unsynced() { # replay that answer. Only the engine sets it: a sync never writes into a # source (validate_output_path refuses), so each caller would otherwise re-run # the same find and sort for every source directory. +# +# Skills come from skill_source_inventory, which also decides what a symlink +# inside a skills source renders as. read_source_artifact_files() { local repo_root="$1" local config_file="$2" @@ -1703,8 +2120,15 @@ read_source_artifact_files() { local src f dir listing memo_key="" key_var="" list_var="" joined="" IS_SOURCE_FILES=() + if [ "$section" = "skills" ]; then + skill_source_inventory "$repo_root" "$config_file" + while IFS= read -r f; do + [ -n "$f" ] && IS_SOURCE_FILES+=("$f") + done <<< "$IS_SKI_LIST" + return 0 + fi case "${IS_SOURCE_FILES_MEMO:-0}:$section" in - 1:rules|1:agents|1:skills) + 1:rules|1:agents) key_var="IS_SF_${section}_KEY" list_var="IS_SF_${section}_LIST" memo_key="$repo_root"$'\n'"$config_file" @@ -1731,12 +2155,6 @@ read_source_artifact_files() { exit 1 } ;; - skills) - listing="$(find "$dir" -mindepth 1 -maxdepth 1 -type d -print | LC_ALL=C sort)" || { - echo "ERROR: cannot enumerate $section under '$dir' — refusing to render a partial list." >&2 - exit 1 - } - ;; *) continue ;; esac [ -n "$listing" ] || continue @@ -1744,10 +2162,6 @@ read_source_artifact_files() { # array it fills survives and any failure inside it is this shell's. while IFS= read -r f; do [ -n "$f" ] || continue - if [ "$section" = "skills" ]; then - [ -f "$f/SKILL.md" ] || continue - f="$f/SKILL.md" - fi IS_SOURCE_FILES+=("$f") joined="$joined$f"$'\n' done <<< "$listing" diff --git a/engine/sync.sh b/engine/sync.sh index 7663764..6cdd155 100644 --- a/engine/sync.sh +++ b/engine/sync.sh @@ -113,46 +113,43 @@ while IFS=$'\037' read -r section src state _dir alias holders; do echo "WARNING: sources.$section '$src' $IS_SOURCE_PROBLEM — skipped; 'intelligence status --check' reports it" >&2 done <<< "${IS_YL_UNRESOLVED:-}" + +# Sources stay read-only for the whole run (validate_output_path refuses an +# output inside one), so read_source_artifact_files enumerates each section +# once, in this shell, and its later callers — the agents adapter, the shared +# skill directory, the context report — replay it. Adapters that list sources +# with in-shell globs spawn nothing and keep their own, locale-ordered listing. +# The skills pass also settles every symlink in a skills source and warns once +# about each it leaves out (decision 0017). +# shellcheck disable=SC2034 # read by read_source_artifact_files in lib/common.sh +IS_SOURCE_FILES_MEMO=1 +for section in rules agents skills; do + read_source_artifact_files "$REPO_ROOT" "$CONFIG_FILE" "$section" +done + # Lint frontmatter across all source files (rules, agents, skills). # Catches issues like unquoted colons that strict YAML consumers reject. +# Skills are the rendered ones the pass above settled: SKILL.md behind a link +# inside its source is linted, one that leaves its source is never read. LINT_FILES=() -LINT_SKILL_DIRS=() -for section in rules agents skills; do +LINT_SKILLS=() +[ "${#IS_SOURCE_FILES[@]}" -eq 0 ] || LINT_SKILLS=("${IS_SOURCE_FILES[@]}") +for section in rules agents; do load_yaml_list "$CONFIG_FILE" "$section" while IFS= read -r src; do [ -z "$src" ] && continue src_dir="$REPO_ROOT/$src" [ -d "$src_dir" ] || continue - if [ "$section" = "skills" ]; then - LINT_SKILL_DIRS+=("$src_dir") - else - for f in "$src_dir"/*.md; do - [ -f "$f" ] && LINT_FILES+=("$f") - done - fi + for f in "$src_dir"/*.md; do + [ -f "$f" ] && LINT_FILES+=("$f") + done done <<< "$IS_YAML_LIST" done -# One find for every skills source: it walks its start points in order. -if [ "${#LINT_SKILL_DIRS[@]}" -gt 0 ]; then - while IFS= read -r f; do - [ -n "$f" ] && LINT_FILES+=("$f") - done < <(find "${LINT_SKILL_DIRS[@]}" -mindepth 2 -maxdepth 2 -name 'SKILL.md' 2>/dev/null) -fi +[ "${#LINT_SKILLS[@]}" -eq 0 ] || LINT_FILES+=("${LINT_SKILLS[@]}") if [ "${#LINT_FILES[@]}" -gt 0 ]; then lint_frontmatter_files "${LINT_FILES[@]}" fi -# Sources stay read-only for the whole run (validate_output_path refuses an -# output inside one), so read_source_artifact_files enumerates each section -# once, in this shell, and its later callers — the agents adapter, the shared -# skill directory, the context report — replay it. Adapters that list sources -# with in-shell globs spawn nothing and keep their own, locale-ordered listing. -# shellcheck disable=SC2034 # read by read_source_artifact_files in lib/common.sh -IS_SOURCE_FILES_MEMO=1 -for section in rules agents skills; do - read_source_artifact_files "$REPO_ROOT" "$CONFIG_FILE" "$section" -done - # Adapters come from two places, discovered by filename (minus `.sh`, # `_template` excluded): # diff --git a/intelligence/rules/engine.md b/intelligence/rules/engine.md index 06cc9ea..d5cd4b7 100644 --- a/intelligence/rules/engine.md +++ b/intelligence/rules/engine.md @@ -55,7 +55,8 @@ same change. Tool-specific limits and diagnostics stay inside their adapter. Shared reporting may expose measurements, but it does not interpret adapter policy. Compact sync -preserves generic context measurements and actionable adapter warnings. +preserves generic context measurements and every warning, adapter or engine, in +the one form the shell conventions define for it: a `WARNING:` line. ## Adapters @@ -80,6 +81,13 @@ into a source, the store, the repository root, or outside the repository. Never delete a whole tool root when the adapter owns only subpaths — hand-authored sibling files live there. A full sync is transactional across every selected adapter path. +A symlink in a skills source renders as the file or directory it points at while it +stays inside that source, and is left out with a `WARNING:` line otherwise; no +generated tree holds a link (decision 0017). `skill_source_inventory` settles every +link once per run, and every skill tree takes its answer: `copy_skill_bundle` applies +its plan, and an adapter that globs a skills source filters with +`skill_source_rendered`. + A per-file loop never spawns a process per file: on Git Bash for Windows one fork costs tens of milliseconds, so per-file awk/cp/mv turned large projects into minutes of pure process creation. Batch the loop through the helpers built for diff --git a/intelligence/rules/shell.md b/intelligence/rules/shell.md index b5c0687..bf1da49 100644 --- a/intelligence/rules/shell.md +++ b/intelligence/rules/shell.md @@ -48,6 +48,11 @@ Validate every manifest, registry, package-name, URL and ref value before it rea a Git argument or a filesystem operation. These are untrusted inputs that end up in `rm -rf` targets and in `git` option positions. +A warning is one unindented line on stderr that starts `WARNING:`. `sync --compact` +— the sync `init` runs — and the replay of an unchanged sync keep exactly those lines +and drop anything indented as progress, so a warning written in any other form +vanishes from the output a user or an agent actually reads. + Reuse the parsers and helpers in `engine/lib/common.sh` and `cli/lib/`. A second YAML or frontmatter parser inside a command or an adapter is a drift source, not a convenience. diff --git a/packages/sync/references/adapters.md b/packages/sync/references/adapters.md index 1d73072..1292b4a 100644 --- a/packages/sync/references/adapters.md +++ b/packages/sync/references/adapters.md @@ -178,6 +178,7 @@ Use the engine library instead of copying parsers or file-handling logic. | `copy_skill_bundle(src, dest)` | Copy `SKILL.md` and all resources safely, normalize Markdown, quote free-text frontmatter, and keep a valid `effort:` as written, as the shared skills tree does. | | `copy_skill_bundle_dirs(dest_root, src...)` | Batch form: copy every skill directory into `dest_root/` with one copy and one finalize pass. | | `copy_skill_bundle_dirs_for(tool, dest_root, src...)` | The batch form for a tree one tool reads: `SKILL.md`'s `effort:` becomes that tool's level, or is removed when it has none. | +| `skill_source_rendered(skill_dir)` | Whether sync renders a skill directory an adapter listed with its own glob; a skill left out for its symlinks fails it. | | `sync_open_skill_dirs(root, config, dest)` | Own and populate a shared Agent Skills directory such as `.agents/skills/`, deriving Codex's `agents/openai.yaml` for a skill with `disable-model-invocation: true`. | | `finalize_output_file(file)` | Expand layout tokens and normalize line endings; required for every emitted text file. | | `finalize_output_files(file...)` / `finalize_copy_files(dest, src...)` | Batch forms: finalize in place, or copy-and-finalize into a directory, in one process. | @@ -212,6 +213,8 @@ If a new adapter relies on `AGENTS.md` for always-on rules, its target must requ Every successful sync prints an adapter-agnostic `CONTEXT:` summary with source byte totals and file counts. It separates always-on rules from custom context (scoped rules, agent prompts and skill entry points), then reports a numeric `agents-md` byte count and `generated`, `not-generated`, or `disabled` status; supporting skill assets are excluded until explicitly read. Adapter-specific hard limits and suppression controls stay inside the adapter that owns them. +Print an adapter warning on stderr as one unindented line starting `WARNING:`. `sync --compact`, which `intelligence init` runs, keeps exactly those lines and drops indented progress, so a warning in any other form never reaches the reader. + The Codex adapter checks its `project_doc_max_bytes` default (32 KiB). When generated `AGENTS.md` exceeds it, sync prints the byte count and a sufficient Codex setting. The warning says "may truncate" because a developer can already have a larger override. `targets.codex.warn_project_doc_limit` accepts `true` or omission for the default threshold, a positive byte count to match another effective limit, or `false` to disable the Intelligence warning. Both inline and block target forms work, and the field does not change Codex configuration. The warning remains visible in `sync --compact`. The Antigravity adapter checks the 12,000-character limit its documentation @@ -237,7 +240,9 @@ Skills follow the [Agent Skills standard](https://agentskills.io). Copy each ski copy_skill_bundle "$source_skill_dir" "$output_dir/skills/$skill_name" ``` -Do not use plain `cp` for skill bundles. `copy_skill_bundle` preserves non-Markdown assets, avoids materializing symlink targets, normalizes Markdown, expands layout tokens and quotes `description` and `argument-hint` where strict YAML readers require strings. It renders `effort:` as the shared tree does; a tree only one tool reads uses `copy_skill_bundle_dirs_for `, so the copy carries that tool's level or no effort at all. +Do not use plain `cp` for skill bundles. `copy_skill_bundle` preserves non-Markdown assets, normalizes Markdown, expands layout tokens and quotes `description` and `argument-hint` where strict YAML readers require strings. It renders `effort:` as the shared tree does; a tree only one tool reads uses `copy_skill_bundle_dirs_for `, so the copy carries that tool's level or no effort at all. It never writes a symlink; how a link in a skills source renders is defined in [conventions](conventions.md#skill-body-and-resources). + +An adapter that lists a skills source with its own glob checks each directory with `skill_source_rendered`. A skill whose directory or `SKILL.md` is a link leaving its source is left out of every output, and sync has already warned about it once. Antigravity, Codex, Pi and OpenCode share `.agents/skills/`. Any adapter writing that open-standard directory must call `sync_open_skill_dirs`; it is the single lifecycle owner for immediate skill subdirectories. diff --git a/packages/sync/references/conventions.md b/packages/sync/references/conventions.md index 5f7a67a..2c30a26 100644 --- a/packages/sync/references/conventions.md +++ b/packages/sync/references/conventions.md @@ -311,6 +311,8 @@ skill-name/ The engine copies the complete skill directory. Promote a helper outside the skill only when multiple skills share it. +A symlink inside a skills source renders as the file or directory it points at when that stays inside the same source, so one source can alias a skill or share a resource between skills. A link that leaves its source, dangles or points at a directory enclosing it is left out with a `WARNING:` line; a skill whose directory or `SKILL.md` is such a link is left out entirely. Add a skill kept elsewhere as its own source or package instead of linking it in. Generated skill directories never contain symlinks. + Size limits are backstops, not quotas: | Artifact | Hard cap | Response |