Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
7 changes: 7 additions & 0 deletions .changeset/ten-shrimps-bow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/intent': patch
---

Improve `intent stale` for monorepos by checking repo `_artifacts` coverage, flagging uncovered public workspace packages, and ignoring private workspaces.

The generated skills workflow now opens one grouped review PR with maintainer prompts, includes a workflow version stamp, and `intent stale` warns when maintainers should rerun `intent setup`.
Empty file added .codex
Empty file.
289 changes: 198 additions & 91 deletions packages/intent/meta/templates/workflows/check-skills.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# check-skills.yml — Drop this into your library repo's .github/workflows/
#
# Checks for stale intent skills after a release and opens a review PR
# if any skills need attention. The PR body includes a prompt you can
# paste into Claude Code, Cursor, or any coding agent to update them.
# Checks intent skills after a release and opens or updates one review PR when
# existing skills, artifact coverage, or workspace package coverage need review.
#
# Triggers: new release published, or manual workflow_dispatch.
#
# intent-workflow-version: 2
#
# Template variables (replaced by `intent setup`):
# {{PACKAGE_LABEL}} — e.g. @tanstack/query or my-workspace workspace

Expand All @@ -22,7 +23,7 @@ permissions:

jobs:
check:
name: Check for stale skills
name: Check intent skill coverage
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand All @@ -38,106 +39,212 @@ jobs:
- name: Install intent
run: npm install -g @tanstack/intent

- name: Check staleness
- name: Check skills
id: stale
run: |
OUTPUT=$(intent stale --json 2>&1) || true
echo "$OUTPUT"

# Check if any skills need review
NEEDS_REVIEW=$(echo "$OUTPUT" | node -e "
const input = require('fs').readFileSync('/dev/stdin','utf8');
try {
const reports = JSON.parse(input);
const stale = reports.flatMap(r =>
r.skills.filter(s => s.needsReview).map(s => ({ library: r.library, skill: s.name, reasons: s.reasons }))
);
if (stale.length > 0) {
console.log(JSON.stringify(stale));
}
} catch {}
")

if [ -z "$NEEDS_REVIEW" ]; then
echo "has_stale=false" >> "$GITHUB_OUTPUT"
set +e
intent stale --json > stale.json
STATUS=$?
set -e

cat stale.json

if [ "$STATUS" -ne 0 ]; then
echo "has_review=true" >> "$GITHUB_OUTPUT"
echo "check_failed=true" >> "$GITHUB_OUTPUT"
cat > review-items.json <<'JSON'
[
{
"type": "stale-check-failed",
"library": "{{PACKAGE_LABEL}}",
"subject": "intent stale --json",
"reasons": ["The stale check command failed. Review the workflow logs before updating skills."]
}
]
JSON
else
echo "has_stale=true" >> "$GITHUB_OUTPUT"
# Escape for multiline GH output
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "stale_json<<$EOF" >> "$GITHUB_OUTPUT"
echo "$NEEDS_REVIEW" >> "$GITHUB_OUTPUT"
echo "$EOF" >> "$GITHUB_OUTPUT"
node <<'NODE'
const fs = require('fs')
const reports = JSON.parse(fs.readFileSync('stale.json', 'utf8'))
const items = []

for (const report of reports) {
for (const skill of report.skills ?? []) {
if (!skill?.needsReview) continue
items.push({
type: 'stale-skill',
library: report.library,
subject: skill.name,
reasons: skill.reasons ?? [],
})
}

for (const signal of report.signals ?? []) {
if (signal?.needsReview === false) continue
items.push({
type: signal?.type ?? 'review-signal',
library: signal?.library ?? report.library,
subject:
signal?.packageName ??
signal?.packageRoot ??
signal?.skill ??
signal?.artifactPath ??
report.library,
reasons: signal?.reasons ?? [signal?.message].filter(Boolean),
artifactPath: signal?.artifactPath,
packageName: signal?.packageName,
packageRoot: signal?.packageRoot,
skill: signal?.skill,
})
}
}

fs.writeFileSync('review-items.json', JSON.stringify(items, null, 2) + '\n')
fs.appendFileSync(
process.env.GITHUB_OUTPUT,
`has_review=${items.length > 0 ? 'true' : 'false'}\n`,
)
NODE
fi

- name: Build summary
if: steps.stale.outputs.has_stale == 'true'
id: summary
run: |
node -e "
const stale = JSON.parse(process.env.STALE_JSON);
const lines = stale.map(s =>
'- **' + s.skill + '** (' + s.library + '): ' + s.reasons.join(', ')
);
const summary = lines.join('\n');

const prompt = [
'Review and update the following stale intent skills for {{PACKAGE_LABEL}}:',
'',
...stale.map(s => '- ' + s.skill + ': ' + s.reasons.join(', ')),
'',
'For each stale skill:',
'1. Read the current SKILL.md file',
'2. Check what changed in the library since the skill was last updated',
'3. Update the skill content to reflect current APIs and behavior',
'4. Run \`npx @tanstack/intent validate\` to verify the updated skill',
].join('\n');

// Write outputs
const fs = require('fs');
const env = fs.readFileSync(process.env.GITHUB_OUTPUT, 'utf8');
const eof = require('crypto').randomBytes(15).toString('base64');
fs.appendFileSync(process.env.GITHUB_OUTPUT,
'summary<<' + eof + '\n' + summary + '\n' + eof + '\n' +
'prompt<<' + eof + '\n' + prompt + '\n' + eof + '\n'
);
"
env:
STALE_JSON: ${{ steps.stale.outputs.stale_json }}
{
echo "review_items<<EOF"
cat review-items.json
echo "EOF"
} >> "$GITHUB_OUTPUT"

- name: Open review PR
if: steps.stale.outputs.has_stale == 'true'
- name: Write clean summary
if: steps.stale.outputs.has_review == 'false'
run: |
{
echo "### Intent skill review"
echo ""
echo "No stale skills or coverage gaps found."
} >> "$GITHUB_STEP_SUMMARY"

- name: Build review PR body
if: steps.stale.outputs.has_review == 'true'
run: |
node <<'NODE'
const fs = require('fs')
const items = JSON.parse(fs.readFileSync('review-items.json', 'utf8'))
const grouped = new Map()

for (const item of items) {
grouped.set(item.type, (grouped.get(item.type) ?? 0) + 1)
}

const signalRows = [...grouped.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([type, count]) => `| \`${type}\` | ${count} |`)

const itemRows = items.map((item) => {
const subject = item.subject ? `\`${item.subject}\`` : '-'
const reasons = item.reasons?.length ? item.reasons.join('; ') : '-'
return `| \`${item.type}\` | ${subject} | \`${item.library}\` | ${reasons} |`
})

const prompt = [
'You are helping maintain Intent skills for this repository.',
'',
'Goal:',
'Resolve the Intent skill review signals below while preserving the existing scope, taxonomy, and maintainer-reviewed artifacts.',
'',
'Review signals:',
JSON.stringify(items, null, 2),
'',
'Required workflow:',
'1. Read the existing `_artifacts/*domain_map.yaml`, `_artifacts/*skill_tree.yaml`, and generated `skills/**/SKILL.md` files.',
'2. Read each flagged package package.json, public exports, README/docs if present, and source entry points.',
'3. Compare flagged packages against the existing domains, skills, tasks, packages, covers, sources, tensions, and cross-references in the artifacts.',
'4. For each signal, decide whether it means existing skill coverage, a missing generated skill, a new skill candidate, out-of-scope coverage, or deferred work.',
'',
'Maintainer questions:',
'Before editing skills or artifacts, ask the maintainer:',
'1. For each flagged package, is this package user-facing enough to need agent guidance?',
'2. If yes, should it extend an existing skill or become a new skill?',
'3. If it extends an existing skill, which current skill should own it?',
'4. If it is out of scope, what short reason should be recorded in artifact coverage ignores?',
'5. Are any of these packages experimental or unstable enough to exclude for now?',
'',
'Decision rules:',
'- Do not auto-generate skills.',
'- Do not create broad new skill areas without maintainer confirmation.',
'- Prefer adding package coverage to an existing skill when the package is an implementation variant of an existing domain.',
'- Create a new skill only when the package introduces a distinct developer task or failure mode.',
'- Preserve current naming, path, and package layout conventions.',
'- Keep generated skills under the package-local `skills/` directory.',
'- Keep repo-root `_artifacts` as the reviewed plan.',
'',
'If maintainer confirms updates:',
'1. Update the relevant `_artifacts/*domain_map.yaml` or `_artifacts/*skill_tree.yaml`.',
'2. Update or create `SKILL.md` files only for confirmed coverage changes.',
'3. Keep `sources` aligned between artifact skill entries and SKILL frontmatter.',
'4. Bump `library_version` only for skills whose covered source package version changed.',
'5. Run `npx @tanstack/intent@latest validate` on touched skill directories.',
'6. Summarize every package as one of: existing-skill coverage, new skill, ignored, or deferred.',
].join('\n')

const body = [
'## Intent Skill Review Needed',
'',
'Intent found skills, artifact coverage, or workspace package coverage that need maintainer review.',
'',
'### Summary',
'',
'| Signal | Count |',
'| --- | ---: |',
...signalRows,
'',
'### Review Items',
'',
'| Signal | Subject | Library | Reason |',
'| --- | --- | --- | --- |',
...itemRows,
'',
'### Agent Prompt',
'',
'Paste this into your coding agent:',
'',
'```text',
prompt,
'```',
'',
'This PR is a review reminder only. It does not update skills automatically.',
].join('\n')

fs.writeFileSync('pr-body.md', body + '\n')
fs.writeFileSync(process.env.GITHUB_STEP_SUMMARY, body + '\n')
NODE

- name: Open or update review PR
if: steps.stale.outputs.has_review == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ github.event.release.tag_name || 'manual' }}"
BRANCH="skills/review-${VERSION}"
BASE_BRANCH="${{ github.event.repository.default_branch }}"

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git commit --allow-empty -m "chore: review stale skills for ${VERSION}"
git push origin "$BRANCH"

gh pr create \
--title "Review stale skills (${VERSION})" \
--body "$(cat <<'PREOF'
## Stale Skills Detected

The following skills may need updates after the latest release:

${{ steps.summary.outputs.summary }}

---

### Update Prompt

Paste this into your coding agent (Claude Code, Cursor, etc.):

~~~
${{ steps.summary.outputs.prompt }}
~~~
git fetch origin "$BRANCH" || true
if git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
git checkout -B "$BRANCH" "origin/$BRANCH"
else
git checkout -b "$BRANCH"
git commit --allow-empty -m "chore: review intent skills for ${VERSION}"
git push origin "$BRANCH"
fi

PREOF
)" \
--head "$BRANCH" \
--base main
PR_URL="$(gh pr list --head "$BRANCH" --json url --jq '.[0].url')"
if [ -n "$PR_URL" ]; then
gh pr edit "$PR_URL" --body-file pr-body.md
else
gh pr create \
--title "Review intent skills (${VERSION})" \
--body-file pr-body.md \
--head "$BRANCH" \
--base "$BASE_BRANCH"
Comment on lines +242 to +250
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

gh CLI --jq empty array null output behavior .[0].url

💡 Result:

When using gh CLI with --jq '.[0].url' on a command that returns an empty JSON array (e.g., gh pr list --json url --jq '.[0].url' when no PRs match), the output is null. This follows standard jq behavior: accessing .[0] on an empty array [] returns null, as there is no element at index 0. gh CLI uses an embedded jq implementation (gojq), which matches standard jq for this case. Official documentation confirms --jq processes JSON output using jq syntax (https://cli.github.com/manual/gh_help_formatting). Past bugs with empty arrays producing null unexpectedly (e.g., [] | unique) were fixed by updating gojq, but direct indexing like .[0] correctly yields null. To safely get the first URL or empty output, use: --jq '.[0].url // empty' or check length first: --jq 'if length > 0 then .[0].url else empty end'.

Citations:


PR_URL becomes the literal string "null" when no PR exists, causing gh pr edit null to fail.

When gh pr list --head "$BRANCH" --json url --jq '.[0].url' finds no matching PRs, it returns an empty JSON array. The jq expression .[0].url on an empty array evaluates to jq's null value, which the gh CLI (via its embedded jq implementation) outputs as the literal string null. This causes PR_URL="null" (a non-empty string), making the condition [ -n "$PR_URL" ] true. The workflow then attempts gh pr edit null --body-file pr-body.md, which fails instead of falling through to create a new PR.

Use --jq '.[0].url // empty' to output an empty string when no PR exists:

-          PR_URL="$(gh pr list --head "$BRANCH" --json url --jq '.[0].url')"
+          PR_URL="$(gh pr list --head "$BRANCH" --json url --jq '.[0].url // empty')"
           if [ -n "$PR_URL" ]; then
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
PR_URL="$(gh pr list --head "$BRANCH" --json url --jq '.[0].url')"
if [ -n "$PR_URL" ]; then
gh pr edit "$PR_URL" --body-file pr-body.md
else
gh pr create \
--title "Review intent skills (${VERSION})" \
--body-file pr-body.md \
--head "$BRANCH" \
--base "$BASE_BRANCH"
PR_URL="$(gh pr list --head "$BRANCH" --json url --jq '.[0].url // empty')"
if [ -n "$PR_URL" ]; then
gh pr edit "$PR_URL" --body-file pr-body.md
else
gh pr create \
--title "Review intent skills (${VERSION})" \
--body-file pr-body.md \
--head "$BRANCH" \
--base "$BASE_BRANCH"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/intent/meta/templates/workflows/check-skills.yml` around lines 241 -
249, PR_URL is getting set to the literal string "null" when no PR exists
because the jq expression returns null, so the [ -n "$PR_URL" ] check passes and
gh pr edit is called; update the PR_URL assignment that uses gh pr list/--jq so
that it yields an empty string instead of "null" when no PR exists (e.g., use
jq's null-coalescing to emit empty), and keep the existing conditional that
tests PR_URL before calling gh pr edit/create (symbols to change: PR_URL
variable assignment and the gh pr list --head ... --json url --jq expression).

fi
51 changes: 0 additions & 51 deletions packages/intent/meta/templates/workflows/notify-intent.yml

This file was deleted.

Loading
Loading