From 08eb87e9a41a429e7dc090a5b7a4b9a503b573d1 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 23 Apr 2026 14:31:38 -0700 Subject: [PATCH 01/16] remove notify workflow template --- .../templates/workflows/notify-intent.yml | 51 ------------- packages/intent/tests/setup.test.ts | 72 ++++++++++--------- 2 files changed, 37 insertions(+), 86 deletions(-) delete mode 100644 packages/intent/meta/templates/workflows/notify-intent.yml diff --git a/packages/intent/meta/templates/workflows/notify-intent.yml b/packages/intent/meta/templates/workflows/notify-intent.yml deleted file mode 100644 index 1bf884d..0000000 --- a/packages/intent/meta/templates/workflows/notify-intent.yml +++ /dev/null @@ -1,51 +0,0 @@ -# notify-intent.yml — Drop this into your library repo's .github/workflows/ -# -# Fires a repository_dispatch event whenever docs or source files change -# on merge to main. This triggers the skill staleness check workflow. -# -# Requirements: -# - A fine-grained PAT with contents:write on this repository stored -# as the INTENT_NOTIFY_TOKEN repository secret. -# -# Template variables (replaced by `intent setup`): -# {{PAYLOAD_PACKAGE}} — e.g. @tanstack/query or my-workspace workspace -# {{DOCS_PATH}} — e.g. docs/** -# {{SRC_PATH}} — e.g. packages/query-core/src/** - -name: Trigger Skill Review - -on: - push: - branches: [main] - paths: - - '{{DOCS_PATH}}' - - '{{SRC_PATH}}' - -jobs: - notify: - name: Trigger Skill Review - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - name: Collect changed files - id: changes - run: | - FILES=$(git diff --name-only HEAD~1 HEAD | jq -R -s -c 'split("\n") | map(select(length > 0))') - echo "files=$FILES" >> "$GITHUB_OUTPUT" - - - name: Dispatch to intent repo - uses: peter-evans/repository-dispatch@v3 - with: - token: ${{ secrets.INTENT_NOTIFY_TOKEN }} - repository: ${{ github.repository }} - event-type: skill-check - client-payload: | - { - "package": "{{PAYLOAD_PACKAGE}}", - "sha": "${{ github.sha }}", - "changed_files": ${{ steps.changes.outputs.files }} - } diff --git a/packages/intent/tests/setup.test.ts b/packages/intent/tests/setup.test.ts index 181cff6..f9dd89e 100644 --- a/packages/intent/tests/setup.test.ts +++ b/packages/intent/tests/setup.test.ts @@ -34,14 +34,14 @@ beforeEach(() => { // Create mock templates mkdirSync(join(metaDir, 'templates', 'workflows'), { recursive: true }) - writeFileSync( - join(metaDir, 'templates', 'workflows', 'notify-intent.yml'), - 'package: {{PAYLOAD_PACKAGE}}\nrepo: {{REPO}}\npaths:\n - {{DOCS_PATH}}\n - {{SRC_PATH}}', - ) writeFileSync( join(metaDir, 'templates', 'workflows', 'check-skills.yml'), 'label: {{PACKAGE_LABEL}}\ninstall: npm install -g @tanstack/intent', ) + writeFileSync( + join(metaDir, 'templates', 'workflows', 'validate-skills.yml'), + 'validate: npx @tanstack/intent@latest validate', + ) }) afterEach(() => { @@ -244,24 +244,39 @@ describe('runSetupGithubActions', () => { expect(result.workflows).toHaveLength(2) expect(result.skipped).toHaveLength(0) - const wfContent = readFileSync( - join(root, '.github', 'workflows', 'notify-intent.yml'), + expect( + existsSync(join(root, '.github', 'workflows', 'notify-intent.yml')), + ).toBe(false) + + const checkContent = readFileSync( + join(root, '.github', 'workflows', 'check-skills.yml'), + 'utf8', + ) + expect(checkContent).toContain('label: @tanstack/query') + expect(checkContent).toContain('install: npm install -g @tanstack/intent') + + const validateContent = readFileSync( + join(root, '.github', 'workflows', 'validate-skills.yml'), 'utf8', ) - expect(wfContent).toContain('package: @tanstack/query') - expect(wfContent).toContain('repo: TanStack/query') - expect(wfContent).toContain('paths:') - expect(wfContent).toContain("'docs/**'") + expect(validateContent).toContain( + 'validate: npx @tanstack/intent@latest validate', + ) }) it('copies templates with defaults when no package.json', () => { const result = runSetupGithubActions(root, metaDir) expect(result.workflows).toHaveLength(2) - const wfPath = join(root, '.github', 'workflows', 'notify-intent.yml') - expect(existsSync(wfPath)).toBe(true) - const content = readFileSync(wfPath, 'utf8') - expect(content).toContain('package: unknown') + expect( + existsSync(join(root, '.github', 'workflows', 'notify-intent.yml')), + ).toBe(false) + expect( + existsSync(join(root, '.github', 'workflows', 'check-skills.yml')), + ).toBe(true) + expect( + existsSync(join(root, '.github', 'workflows', 'validate-skills.yml')), + ).toBe(true) }) it('skips existing workflow files', () => { @@ -316,24 +331,17 @@ describe('runSetupGithubActions', () => { expect(result.workflows).toEqual( expect.arrayContaining([ - join(monoRoot, '.github', 'workflows', 'notify-intent.yml'), join(monoRoot, '.github', 'workflows', 'check-skills.yml'), + join(monoRoot, '.github', 'workflows', 'validate-skills.yml'), ]), ) expect( existsSync(join(monoRoot, 'packages', 'router', '.github', 'workflows')), ).toBe(false) - const notifyContent = readFileSync( - join(monoRoot, '.github', 'workflows', 'notify-intent.yml'), - 'utf8', - ) - expect(notifyContent).toContain('package: @tanstack/router') - expect(notifyContent).toContain('repo: TanStack/router') - expect(notifyContent).toContain("- 'packages/router/docs/**'") - expect(notifyContent).toContain("- 'packages/router/src/**'") - expect(notifyContent).toContain("- 'packages/start/src/**'") - expect(notifyContent).not.toContain('packages/root/src/**') + expect( + existsSync(join(monoRoot, '.github', 'workflows', 'notify-intent.yml')), + ).toBe(false) const checkContent = readFileSync( join(monoRoot, '.github', 'workflows', 'check-skills.yml'), @@ -390,23 +398,17 @@ describe('runSetupGithubActions', () => { expect(result.workflows).toEqual( expect.arrayContaining([ - join(monoRoot, '.github', 'workflows', 'notify-intent.yml'), join(monoRoot, '.github', 'workflows', 'check-skills.yml'), + join(monoRoot, '.github', 'workflows', 'validate-skills.yml'), ]), ) expect( existsSync(join(monoRoot, 'packages', 'router', '.github', 'workflows')), ).toBe(false) - const notifyContent = readFileSync( - join(monoRoot, '.github', 'workflows', 'notify-intent.yml'), - 'utf8', - ) - expect(notifyContent).toContain('package: @tanstack/router') - expect(notifyContent).toContain('repo: TanStack/router') - expect(notifyContent).toContain("- 'packages/router/docs/**'") - expect(notifyContent).toContain("- 'packages/router/src/**'") - expect(notifyContent).toContain("- 'packages/start/src/**'") + expect( + existsSync(join(monoRoot, '.github', 'workflows', 'notify-intent.yml')), + ).toBe(false) rmSync(monoRoot, { recursive: true, force: true }) }) From 97790a167b604736ddc36483c08c34715ae292d7 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 23 Apr 2026 14:38:44 -0700 Subject: [PATCH 02/16] harden check-skills --- .codex | 0 .../meta/templates/workflows/check-skills.yml | 289 ++++++++++++------ packages/intent/tests/setup.test.ts | 27 +- 3 files changed, 223 insertions(+), 93 deletions(-) create mode 100644 .codex diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/packages/intent/meta/templates/workflows/check-skills.yml b/packages/intent/meta/templates/workflows/check-skills.yml index 5e46ca1..959656e 100644 --- a/packages/intent/meta/templates/workflows/check-skills.yml +++ b/packages/intent/meta/templates/workflows/check-skills.yml @@ -1,12 +1,11 @@ # 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. # -# Template variables (replaced by `intent setup`): +# Template variables (replaced by `intent setup-github-actions`): # {{PACKAGE_LABEL}} — e.g. @tanstack/query or my-workspace workspace name: Check Skills @@ -22,7 +21,7 @@ permissions: jobs: check: - name: Check for stale skills + name: Check intent skill coverage runs-on: ubuntu-latest steps: - name: Checkout @@ -38,106 +37,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<> "$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" + fi diff --git a/packages/intent/tests/setup.test.ts b/packages/intent/tests/setup.test.ts index f9dd89e..99831a5 100644 --- a/packages/intent/tests/setup.test.ts +++ b/packages/intent/tests/setup.test.ts @@ -36,7 +36,18 @@ beforeEach(() => { writeFileSync( join(metaDir, 'templates', 'workflows', 'check-skills.yml'), - 'label: {{PACKAGE_LABEL}}\ninstall: npm install -g @tanstack/intent', + [ + 'label: {{PACKAGE_LABEL}}', + 'install: npm install -g @tanstack/intent', + 'signals: report.signals', + 'has_review=true', + 'No stale skills or coverage gaps found.', + 'gh pr list --head "$BRANCH"', + 'gh pr edit "$PR_URL" --body-file pr-body.md', + 'Review intent skills', + 'Ask the maintainer before editing skills or artifacts.', + 'Do not auto-generate skills.', + ].join('\n'), ) writeFileSync( join(metaDir, 'templates', 'workflows', 'validate-skills.yml'), @@ -254,6 +265,18 @@ describe('runSetupGithubActions', () => { ) expect(checkContent).toContain('label: @tanstack/query') expect(checkContent).toContain('install: npm install -g @tanstack/intent') + expect(checkContent).toContain('signals: report.signals') + expect(checkContent).toContain('has_review=true') + expect(checkContent).toContain('No stale skills or coverage gaps found.') + expect(checkContent).toContain('gh pr list --head "$BRANCH"') + expect(checkContent).toContain( + 'gh pr edit "$PR_URL" --body-file pr-body.md', + ) + expect(checkContent).toContain('Review intent skills') + expect(checkContent).toContain( + 'Ask the maintainer before editing skills or artifacts.', + ) + expect(checkContent).toContain('Do not auto-generate skills.') const validateContent = readFileSync( join(root, '.github', 'workflows', 'validate-skills.yml'), @@ -349,6 +372,8 @@ describe('runSetupGithubActions', () => { ) expect(checkContent).toContain('label: @tanstack/router') expect(checkContent).toContain('npm install -g @tanstack/intent') + expect(checkContent).toContain('signals: report.signals') + expect(checkContent).toContain('No stale skills or coverage gaps found.') rmSync(monoRoot, { recursive: true, force: true }) }) From adb9b55da8b7302803e90db5d87a70a117c7892c Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 23 Apr 2026 15:01:42 -0700 Subject: [PATCH 03/16] Add stale `signals` --- .../meta/templates/workflows/check-skills.yml | 2 +- packages/intent/src/commands/stale.ts | 13 ++++++- packages/intent/src/index.ts | 1 + packages/intent/src/staleness.ts | 1 + packages/intent/src/types.ts | 13 +++++++ packages/intent/tests/stale-command.test.ts | 39 +++++++++++++++++++ packages/intent/tests/staleness.test.ts | 1 + 7 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 packages/intent/tests/stale-command.test.ts diff --git a/packages/intent/meta/templates/workflows/check-skills.yml b/packages/intent/meta/templates/workflows/check-skills.yml index 959656e..67c1f66 100644 --- a/packages/intent/meta/templates/workflows/check-skills.yml +++ b/packages/intent/meta/templates/workflows/check-skills.yml @@ -5,7 +5,7 @@ # # Triggers: new release published, or manual workflow_dispatch. # -# Template variables (replaced by `intent setup-github-actions`): +# Template variables (replaced by `intent setup`): # {{PACKAGE_LABEL}} — e.g. @tanstack/query or my-workspace workspace name: Check Skills diff --git a/packages/intent/src/commands/stale.ts b/packages/intent/src/commands/stale.ts index e0d9e10..f6543a5 100644 --- a/packages/intent/src/commands/stale.ts +++ b/packages/intent/src/commands/stale.ts @@ -30,12 +30,23 @@ export async function runStaleCommand( console.log(`${report.library}${vLabel}${driftLabel}`) const stale = report.skills.filter((skill) => skill.needsReview) - if (stale.length === 0) { + const signals = report.signals.filter((signal) => signal.needsReview) + if (stale.length === 0 && signals.length === 0) { console.log(' All skills up-to-date') } else { for (const skill of stale) { console.log(` ⚠ ${skill.name}: ${skill.reasons.join(', ')}`) } + for (const signal of signals) { + const subject = + signal.subject ?? + signal.packageName ?? + signal.packageRoot ?? + signal.skill ?? + signal.artifactPath ?? + signal.type + console.log(` ⚠ ${subject}: ${signal.reasons.join(', ')}`) + } } console.log() diff --git a/packages/intent/src/index.ts b/packages/intent/src/index.ts index 83bd554..2ae6850 100644 --- a/packages/intent/src/index.ts +++ b/packages/intent/src/index.ts @@ -50,4 +50,5 @@ export type { SkillEntry, StalenessReport, SkillStaleness, + StalenessSignal, } from './types.js' diff --git a/packages/intent/src/staleness.ts b/packages/intent/src/staleness.ts index a5ff641..2e2938b 100644 --- a/packages/intent/src/staleness.ts +++ b/packages/intent/src/staleness.ts @@ -212,5 +212,6 @@ export async function checkStaleness( skillVersion, versionDrift, skills, + signals: [], } } diff --git a/packages/intent/src/types.ts b/packages/intent/src/types.ts index 2f1da4a..2ea9235 100644 --- a/packages/intent/src/types.ts +++ b/packages/intent/src/types.ts @@ -77,6 +77,7 @@ export interface StalenessReport { skillVersion: string | null versionDrift: 'major' | 'minor' | 'patch' | null skills: Array + signals: Array } export interface SkillStaleness { @@ -85,6 +86,18 @@ export interface SkillStaleness { needsReview: boolean } +export interface StalenessSignal { + type: string + library?: string + subject?: string + reasons: Array + needsReview: boolean + artifactPath?: string + packageName?: string + packageRoot?: string + skill?: string +} + // --------------------------------------------------------------------------- // Feedback types // --------------------------------------------------------------------------- diff --git a/packages/intent/tests/stale-command.test.ts b/packages/intent/tests/stale-command.test.ts new file mode 100644 index 0000000..91f9561 --- /dev/null +++ b/packages/intent/tests/stale-command.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runStaleCommand } from '../src/commands/stale.js' + +describe('runStaleCommand', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + afterEach(() => { + logSpy.mockClear() + }) + + it('prints review signals in non-json output', async () => { + await runStaleCommand(undefined, {}, async () => ({ + reports: [ + { + library: '@tanstack/router', + currentVersion: '1.0.0', + skillVersion: '1.0.0', + versionDrift: null, + skills: [], + signals: [ + { + type: 'missing-package-coverage', + library: '@tanstack/router', + packageName: '@tanstack/react-start-rsc', + reasons: ['package is not represented in skills or artifacts'], + needsReview: true, + }, + ], + }, + ], + })) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('@tanstack/router') + expect(output).toContain('@tanstack/react-start-rsc') + expect(output).toContain('package is not represented') + expect(output).not.toContain('All skills up-to-date') + }) +}) diff --git a/packages/intent/tests/staleness.test.ts b/packages/intent/tests/staleness.test.ts index 1a44047..82e8aa5 100644 --- a/packages/intent/tests/staleness.test.ts +++ b/packages/intent/tests/staleness.test.ts @@ -93,6 +93,7 @@ describe('checkStaleness', () => { expect(report.currentVersion).toBeNull() expect(report.skillVersion).toBeNull() expect(report.versionDrift).toBeNull() + expect(report.signals).toEqual([]) }) it('defaults library to "unknown" when no name provided', async () => { From a44edc330f75aef5b760e9cc6a262bd0a525b9dd Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 23 Apr 2026 15:11:15 -0700 Subject: [PATCH 04/16] Parse `_artifacts` --- packages/intent/src/artifact-coverage.ts | 183 ++++++++++++++++++ packages/intent/src/index.ts | 6 + packages/intent/src/types.ts | 40 ++++ .../intent/tests/artifact-coverage.test.ts | 162 ++++++++++++++++ 4 files changed, 391 insertions(+) create mode 100644 packages/intent/src/artifact-coverage.ts create mode 100644 packages/intent/tests/artifact-coverage.test.ts diff --git a/packages/intent/src/artifact-coverage.ts b/packages/intent/src/artifact-coverage.ts new file mode 100644 index 0000000..b1211a9 --- /dev/null +++ b/packages/intent/src/artifact-coverage.ts @@ -0,0 +1,183 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { parse as parseYaml } from 'yaml' +import type { + IntentArtifactCoverageIgnore, + IntentArtifactFile, + IntentArtifactSet, + IntentArtifactSkill, + IntentArtifactWarning, +} from './types.js' + +type ArtifactKind = IntentArtifactFile['kind'] + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined +} + +function stringArray(value: unknown): Array { + if (!Array.isArray(value)) return [] + return value.filter((entry): entry is string => typeof entry === 'string') +} + +function detectArtifactKind(fileName: string): ArtifactKind | null { + if (fileName.endsWith('skill_tree.yaml')) return 'skill-tree' + if (fileName.endsWith('domain_map.yaml')) return 'domain-map' + return null +} + +function readArtifactYaml( + artifactPath: string, + warnings: Array, +): Record | null { + let parsed: unknown + try { + parsed = parseYaml(readFileSync(artifactPath, 'utf8')) + } catch (err) { + warnings.push({ + artifactPath, + message: `Invalid YAML: ${err instanceof Error ? err.message : String(err)}`, + }) + return null + } + + if (!isRecord(parsed)) { + warnings.push({ + artifactPath, + message: 'Artifact YAML must contain an object at the top level', + }) + return null + } + + return parsed +} + +function parseArtifactFile( + artifactPath: string, + kind: ArtifactKind, + parsed: Record, +): IntentArtifactFile { + const library = isRecord(parsed.library) ? parsed.library : {} + return { + path: artifactPath, + kind, + libraryName: stringValue(library.name), + libraryVersion: stringValue(library.version), + } +} + +function parseSkills( + artifactPath: string, + kind: ArtifactKind, + parsed: Record, +): Array { + if (!Array.isArray(parsed.skills)) return [] + + const skills: Array = [] + for (const skill of parsed.skills) { + if (!isRecord(skill)) continue + + const packagePath = stringValue(skill.package) + const packages = stringArray(skill.packages) + if (packagePath) { + packages.push(packagePath) + } + + skills.push({ + artifactPath, + artifactKind: kind, + name: stringValue(skill.name), + slug: stringValue(skill.slug), + path: stringValue(skill.path), + package: packagePath, + packages: [...new Set(packages)].sort((a, b) => a.localeCompare(b)), + sources: stringArray(skill.sources), + covers: stringArray(skill.covers), + }) + } + + return skills +} + +function parseCoverageIgnores( + artifactPath: string, + parsed: Record, +): Array { + const coverage = isRecord(parsed.coverage) ? parsed.coverage : null + const ignored = coverage?.ignored_packages + if (!Array.isArray(ignored)) return [] + + const ignores: Array = [] + for (const entry of ignored) { + if (typeof entry === 'string') { + if (entry.trim()) { + ignores.push({ packageName: entry, artifactPath }) + } + continue + } + + if (!isRecord(entry)) continue + const packageName = stringValue(entry.name) + if (!packageName) continue + + ignores.push({ + packageName, + reason: stringValue(entry.reason), + artifactPath, + }) + } + + return ignores +} + +export function readIntentArtifacts(root: string): IntentArtifactSet | null { + const artifactsDir = join(root, '_artifacts') + if (!existsSync(artifactsDir)) return null + + const warnings: Array = [] + const skillTrees: Array = [] + const domainMaps: Array = [] + const skills: Array = [] + const ignoredPackages: Array = [] + + for (const entry of readdirSync(artifactsDir, { withFileTypes: true })) { + if (!entry.isFile()) continue + + const kind = detectArtifactKind(entry.name) + if (!kind) continue + + const artifactPath = join(artifactsDir, entry.name) + const parsed = readArtifactYaml(artifactPath, warnings) + if (!parsed) continue + + const artifactFile = parseArtifactFile(artifactPath, kind, parsed) + if (kind === 'skill-tree') { + skillTrees.push(artifactFile) + } else { + domainMaps.push(artifactFile) + } + + skills.push(...parseSkills(artifactPath, kind, parsed)) + ignoredPackages.push(...parseCoverageIgnores(artifactPath, parsed)) + } + + return { + root, + artifactsDir, + skillTrees: skillTrees.sort((a, b) => a.path.localeCompare(b.path)), + domainMaps: domainMaps.sort((a, b) => a.path.localeCompare(b.path)), + skills: skills.sort((a, b) => + `${a.artifactPath}:${a.slug ?? a.name ?? ''}`.localeCompare( + `${b.artifactPath}:${b.slug ?? b.name ?? ''}`, + ), + ), + ignoredPackages: ignoredPackages.sort((a, b) => + a.packageName.localeCompare(b.packageName), + ), + warnings, + } +} diff --git a/packages/intent/src/index.ts b/packages/intent/src/index.ts index 2ae6850..48f4e69 100644 --- a/packages/intent/src/index.ts +++ b/packages/intent/src/index.ts @@ -1,5 +1,6 @@ export { scanForIntents } from './scanner.js' export { checkStaleness } from './staleness.js' +export { readIntentArtifacts } from './artifact-coverage.js' export { containsSecrets, hasGhCli, @@ -41,6 +42,11 @@ export type { AgentName, FeedbackPayload, IntentConfig, + IntentArtifactCoverageIgnore, + IntentArtifactFile, + IntentArtifactSet, + IntentArtifactSkill, + IntentArtifactWarning, IntentPackage, IntentProjectConfig, MetaFeedbackPayload, diff --git a/packages/intent/src/types.ts b/packages/intent/src/types.ts index 2ea9235..9da6005 100644 --- a/packages/intent/src/types.ts +++ b/packages/intent/src/types.ts @@ -98,6 +98,46 @@ export interface StalenessSignal { skill?: string } +export interface IntentArtifactSet { + root: string + artifactsDir: string + skillTrees: Array + domainMaps: Array + skills: Array + ignoredPackages: Array + warnings: Array +} + +export interface IntentArtifactFile { + path: string + kind: 'skill-tree' | 'domain-map' + libraryName?: string + libraryVersion?: string +} + +export interface IntentArtifactSkill { + artifactPath: string + artifactKind: 'skill-tree' | 'domain-map' + name?: string + slug?: string + path?: string + package?: string + packages: Array + sources: Array + covers: Array +} + +export interface IntentArtifactCoverageIgnore { + packageName: string + reason?: string + artifactPath: string +} + +export interface IntentArtifactWarning { + artifactPath: string + message: string +} + // --------------------------------------------------------------------------- // Feedback types // --------------------------------------------------------------------------- diff --git a/packages/intent/tests/artifact-coverage.test.ts b/packages/intent/tests/artifact-coverage.test.ts new file mode 100644 index 0000000..5b7c124 --- /dev/null +++ b/packages/intent/tests/artifact-coverage.test.ts @@ -0,0 +1,162 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { readIntentArtifacts } from '../src/artifact-coverage.js' + +let root: string + +function writeArtifact(fileName: string, content: string): void { + const artifactsDir = join(root, '_artifacts') + mkdirSync(artifactsDir, { recursive: true }) + writeFileSync(join(artifactsDir, fileName), content) +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'intent-artifacts-')) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +describe('readIntentArtifacts', () => { + it('returns null when _artifacts does not exist', () => { + expect(readIntentArtifacts(root)).toBeNull() + }) + + it('parses package-scoped skill tree package paths', () => { + writeArtifact( + 'skill_tree.yaml', + ` +library: + name: '@tanstack/example-server' + version: '1.2.3' +skills: + - name: 'Server Setup' + slug: 'server-setup' + path: 'packages/server/skills/server-setup/SKILL.md' + package: 'packages/server' + sources: + - 'TanStack/example:docs/server/overview.md' +`, + ) + + const artifacts = readIntentArtifacts(root) + + expect(artifacts?.skillTrees).toHaveLength(1) + expect(artifacts?.skillTrees[0]).toMatchObject({ + kind: 'skill-tree', + libraryName: '@tanstack/example-server', + libraryVersion: '1.2.3', + }) + expect(artifacts?.skills[0]).toMatchObject({ + artifactKind: 'skill-tree', + name: 'Server Setup', + slug: 'server-setup', + path: 'packages/server/skills/server-setup/SKILL.md', + package: 'packages/server', + packages: ['packages/server'], + sources: ['TanStack/example:docs/server/overview.md'], + }) + }) + + it('parses Router Start split skill tree and domain map files', () => { + writeArtifact( + 'start_skill_tree.yaml', + ` +library: + name: '@tanstack/react-start' + version: '1.166.2' +generated_from: + domain_map: '_artifacts/start_domain_map.yaml' +skills: + - name: 'Start Core' + slug: 'start-core' + path: 'skills/start-core/SKILL.md' + package: 'packages/start-client-core' +`, + ) + writeArtifact( + 'start_domain_map.yaml', + ` +library: + name: '@tanstack/react-start' + version: '1.166.2' +skills: + - name: 'Start Setup' + slug: 'start-setup' + packages: + - '@tanstack/react-start' + - '@tanstack/start-plugin-core' + covers: + - tanstackStart Vite plugin + - getRouter() factory pattern +`, + ) + + const artifacts = readIntentArtifacts(root) + + expect(artifacts?.skillTrees.map((file) => file.path)).toEqual([ + join(root, '_artifacts', 'start_skill_tree.yaml'), + ]) + expect(artifacts?.domainMaps.map((file) => file.path)).toEqual([ + join(root, '_artifacts', 'start_domain_map.yaml'), + ]) + expect(artifacts?.skills).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + artifactKind: 'skill-tree', + slug: 'start-core', + packages: ['packages/start-client-core'], + }), + expect.objectContaining({ + artifactKind: 'domain-map', + slug: 'start-setup', + packages: ['@tanstack/react-start', '@tanstack/start-plugin-core'], + covers: [ + 'tanstackStart Vite plugin', + 'getRouter() factory pattern', + ], + }), + ]), + ) + }) + + it('parses coverage ignored packages from strings and objects', () => { + writeArtifact( + 'skill_tree.yaml', + ` +coverage: + ignored_packages: + - '@scope/internal' + - name: 'packages/devtools' + reason: 'internal tooling' +`, + ) + + const artifacts = readIntentArtifacts(root) + + expect(artifacts?.ignoredPackages).toEqual([ + { + packageName: '@scope/internal', + artifactPath: join(root, '_artifacts', 'skill_tree.yaml'), + }, + { + packageName: 'packages/devtools', + reason: 'internal tooling', + artifactPath: join(root, '_artifacts', 'skill_tree.yaml'), + }, + ]) + }) + + it('records invalid YAML warnings instead of throwing', () => { + writeArtifact('skill_tree.yaml', 'skills:\n - name: [broken\n') + + const artifacts = readIntentArtifacts(root) + + expect(artifacts?.warnings).toHaveLength(1) + expect(artifacts?.warnings[0]?.message).toContain('Invalid YAML') + expect(artifacts?.skills).toEqual([]) + }) +}) From 2aaeb5b7fb3465c7fc7f381be851141f27a3484e Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 23 Apr 2026 15:18:11 -0700 Subject: [PATCH 05/16] Add artifact consistency signals --- packages/intent/src/cli-support.ts | 7 +- packages/intent/src/staleness.ts | 223 +++++++++++++++++++++++- packages/intent/tests/staleness.test.ts | 177 +++++++++++++++++++ 3 files changed, 402 insertions(+), 5 deletions(-) diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/cli-support.ts index ea0121f..dc2c402 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/cli-support.ts @@ -83,6 +83,7 @@ export async function resolveStaleTargets( await checkStaleness( context.packageRoot, readPackageName(context.packageRoot), + context.workspaceRoot ?? context.packageRoot, ), ], } @@ -105,7 +106,11 @@ export async function resolveStaleTargets( return { reports: await Promise.all( packageDirs.map((packageDir) => - checkStaleness(packageDir, readPackageName(packageDir)), + checkStaleness( + packageDir, + readPackageName(packageDir), + workspaceRoot, + ), ), ), } diff --git a/packages/intent/src/staleness.ts b/packages/intent/src/staleness.ts index 2e2938b..515ae46 100644 --- a/packages/intent/src/staleness.ts +++ b/packages/intent/src/staleness.ts @@ -1,7 +1,14 @@ -import { readFileSync } from 'node:fs' -import { join, relative, sep } from 'node:path' +import { existsSync, readFileSync } from 'node:fs' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { readIntentArtifacts } from './artifact-coverage.js' import { findSkillFiles, parseFrontmatter } from './utils.js' -import type { SkillStaleness, StalenessReport } from './types.js' +import type { + IntentArtifactSet, + IntentArtifactSkill, + SkillStaleness, + StalenessReport, + StalenessSignal, +} from './types.js' // --------------------------------------------------------------------------- // Helpers @@ -9,6 +16,7 @@ import type { SkillStaleness, StalenessReport } from './types.js' interface SkillMeta { name: string + relName: string filePath: string libraryVersion?: string sources?: Array @@ -126,6 +134,201 @@ function readSyncState(packageDir: string): SyncState | null { } } +// --------------------------------------------------------------------------- +// Artifact signals +// --------------------------------------------------------------------------- + +function normalizeFilePath(path: string): string { + return resolve(path).split(sep).join('/') +} + +function normalizeList(values: Array | undefined): Array { + return [...new Set(values ?? [])].sort((a, b) => a.localeCompare(b)) +} + +function sameStringList( + a: Array | undefined, + b: Array, +): boolean { + const left = normalizeList(a) + const right = normalizeList(b) + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ) +} + +function artifactPackageMatches( + artifact: IntentArtifactSkill, + packageDir: string, + packageName: string, + artifactRoot: string, +): boolean { + const relPackageDir = relative(artifactRoot, packageDir).split(sep).join('/') + if (!relPackageDir || relPackageDir === '') return true + + if (artifact.packages.includes(packageName)) return true + if (artifact.packages.includes(relPackageDir)) return true + if (artifact.path?.startsWith(`${relPackageDir}/`)) return true + + return artifact.packages.length === 0 && artifact.path === undefined +} + +function resolveArtifactSkillPaths( + artifact: IntentArtifactSkill, + packageDir: string, + artifactRoot: string, +): Array { + if (!artifact.path) return [] + + const candidatePaths = [ + isAbsolute(artifact.path) + ? artifact.path + : join(artifactRoot, artifact.path), + isAbsolute(artifact.path) ? artifact.path : join(packageDir, artifact.path), + ] + + if (artifact.package && artifact.path.startsWith('skills/')) { + candidatePaths.push(join(artifactRoot, artifact.package, artifact.path)) + } + + return [...new Set(candidatePaths.map(normalizeFilePath))] +} + +function findMatchingSkill( + artifact: IntentArtifactSkill, + skillMetas: Array, + packageDir: string, + artifactRoot: string, +): SkillMeta | null { + const skillsByPath = new Map( + skillMetas.map((skill) => [normalizeFilePath(skill.filePath), skill]), + ) + + for (const candidatePath of resolveArtifactSkillPaths( + artifact, + packageDir, + artifactRoot, + )) { + const match = skillsByPath.get(candidatePath) + if (match) return match + } + + const skillsByName = new Map() + for (const skill of skillMetas) { + skillsByName.set(skill.name, skill) + skillsByName.set(skill.relName, skill) + } + + return ( + (artifact.slug ? skillsByName.get(artifact.slug) : undefined) ?? + (artifact.name ? skillsByName.get(artifact.name) : undefined) ?? + null + ) +} + +function buildArtifactSignals({ + artifactRoot, + artifacts, + library, + packageDir, + skillMetas, +}: { + artifactRoot: string + artifacts: IntentArtifactSet | null + library: string + packageDir: string + skillMetas: Array +}): Array { + if (!artifacts) return [] + + const artifactFiles = new Map( + [...artifacts.skillTrees, ...artifacts.domainMaps].map((file) => [ + file.path, + file, + ]), + ) + + const signals: Array = artifacts.warnings.map((warning) => ({ + type: 'artifact-parse-warning', + library, + subject: warning.artifactPath, + reasons: [warning.message], + needsReview: true, + artifactPath: warning.artifactPath, + })) + + for (const artifact of artifacts.skills) { + if (!artifactPackageMatches(artifact, packageDir, library, artifactRoot)) { + continue + } + + const subject = artifact.slug ?? artifact.name ?? artifact.path + const matchingSkill = findMatchingSkill( + artifact, + skillMetas, + packageDir, + artifactRoot, + ) + + if (artifact.path && !matchingSkill) { + signals.push({ + type: 'artifact-skill-missing', + library, + subject, + reasons: [ + `artifact skill path does not resolve to a generated SKILL.md (${artifact.path})`, + ], + needsReview: true, + artifactPath: artifact.artifactPath, + skill: artifact.slug ?? artifact.name, + }) + continue + } + + if (!matchingSkill) continue + + if ( + matchingSkill.sources !== undefined && + artifact.sources.length > 0 && + !sameStringList(matchingSkill.sources, artifact.sources) + ) { + signals.push({ + type: 'artifact-source-drift', + library, + subject, + reasons: ['artifact sources differ from SKILL.md frontmatter sources'], + needsReview: true, + artifactPath: artifact.artifactPath, + skill: matchingSkill.name, + }) + } + + const artifactVersion = artifactFiles.get( + artifact.artifactPath, + )?.libraryVersion + if ( + artifactVersion && + matchingSkill.libraryVersion && + artifactVersion !== matchingSkill.libraryVersion + ) { + signals.push({ + type: 'artifact-library-version-drift', + library, + subject, + reasons: [ + `artifact library.version (${artifactVersion}) differs from SKILL.md library_version (${matchingSkill.libraryVersion})`, + ], + needsReview: true, + artifactPath: artifact.artifactPath, + skill: matchingSkill.name, + }) + } + } + + return signals +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -133,6 +336,7 @@ function readSyncState(packageDir: string): SyncState | null { export async function checkStaleness( packageDir: string, packageName?: string, + artifactRoot = packageDir, ): Promise { const skillsDir = join(packageDir, 'skills') const library = packageName ?? 'unknown' @@ -147,6 +351,7 @@ export async function checkStaleness( .join('/') return { name: typeof fm?.name === 'string' ? fm.name : relName, + relName, filePath, libraryVersion: fm?.library_version as string | undefined, sources: Array.isArray(fm?.sources) @@ -155,6 +360,10 @@ export async function checkStaleness( } }) + const artifacts = existsSync(join(artifactRoot, '_artifacts')) + ? readIntentArtifacts(artifactRoot) + : null + // Get the version from frontmatter (use first skill that has it) const skillVersion = skillMetas.find((s) => s.libraryVersion)?.libraryVersion ?? null @@ -212,6 +421,12 @@ export async function checkStaleness( skillVersion, versionDrift, skills, - signals: [], + signals: buildArtifactSignals({ + artifactRoot, + artifacts, + library, + packageDir, + skillMetas, + }), } } diff --git a/packages/intent/tests/staleness.test.ts b/packages/intent/tests/staleness.test.ts index 82e8aa5..06dfee3 100644 --- a/packages/intent/tests/staleness.test.ts +++ b/packages/intent/tests/staleness.test.ts @@ -46,6 +46,12 @@ function writeSyncState(dir: string, state: Record): void { writeFileSync(join(skillsDir, 'sync-state.json'), JSON.stringify(state)) } +function writeArtifact(rootDir: string, fileName: string, content: string): void { + const artifactsDir = join(rootDir, '_artifacts') + mkdirSync(artifactsDir, { recursive: true }) + writeFileSync(join(artifactsDir, fileName), content) +} + function requireFirstSkill(report: Awaited>) { const skill = report.skills[0] expect(skill).toBeDefined() @@ -353,4 +359,175 @@ describe('checkStaleness', () => { expect(report.currentVersion).toBe('3.0.0') expect(report.versionDrift).toBe('major') }) + + it('does not flag matching artifact skill metadata', async () => { + writeSkill(tmpDir, 'core', { + name: 'core', + description: 'Core', + library_version: '1.2.3', + sources: ['docs/core.md'], + }) + writeArtifact( + tmpDir, + 'skill_tree.yaml', + ` +library: + name: '@example/lib' + version: '1.2.3' +skills: + - name: 'Core' + slug: 'core' + path: 'skills/core/SKILL.md' + package: '@example/lib' + sources: + - 'docs/core.md' +`, + ) + mockFetchNotOk() + + const report = await checkStaleness(tmpDir, '@example/lib') + + expect(report.signals).toEqual([]) + }) + + it('flags artifact skills whose generated SKILL.md is missing', async () => { + writeArtifact( + tmpDir, + 'skill_tree.yaml', + ` +library: + name: '@example/lib' + version: '1.2.3' +skills: + - name: 'Missing' + slug: 'missing' + path: 'skills/missing/SKILL.md' + package: '@example/lib' +`, + ) + mockFetchNotOk() + + const report = await checkStaleness(tmpDir, '@example/lib') + + expect(report.signals).toEqual([ + expect.objectContaining({ + type: 'artifact-skill-missing', + subject: 'missing', + needsReview: true, + }), + ]) + }) + + it('flags artifact sources that differ from SKILL.md frontmatter', async () => { + writeSkill(tmpDir, 'core', { + name: 'core', + description: 'Core', + sources: ['docs/current.md'], + }) + writeArtifact( + tmpDir, + 'skill_tree.yaml', + ` +library: + name: '@example/lib' +skills: + - name: 'Core' + slug: 'core' + path: 'skills/core/SKILL.md' + package: '@example/lib' + sources: + - 'docs/artifact.md' +`, + ) + mockFetchNotOk() + + const report = await checkStaleness(tmpDir, '@example/lib') + + expect(report.signals).toEqual([ + expect.objectContaining({ + type: 'artifact-source-drift', + skill: 'core', + needsReview: true, + }), + ]) + }) + + it('flags artifact library version drift against matching SKILL.md frontmatter', async () => { + writeSkill(tmpDir, 'core', { + name: 'core', + description: 'Core', + library_version: '1.0.0', + }) + writeArtifact( + tmpDir, + 'skill_tree.yaml', + ` +library: + name: '@example/lib' + version: '1.2.0' +skills: + - name: 'Core' + slug: 'core' + path: 'skills/core/SKILL.md' + package: '@example/lib' +`, + ) + mockFetchNotOk() + + const report = await checkStaleness(tmpDir, '@example/lib') + + expect(report.signals).toEqual([ + expect.objectContaining({ + type: 'artifact-library-version-drift', + skill: 'core', + needsReview: true, + }), + ]) + }) + + it('flags artifact parse warnings as review signals', async () => { + writeArtifact(tmpDir, 'skill_tree.yaml', 'skills:\n - name: [broken\n') + mockFetchNotOk() + + const report = await checkStaleness(tmpDir, '@example/lib') + + expect(report.signals).toEqual([ + expect.objectContaining({ + type: 'artifact-parse-warning', + needsReview: true, + }), + ]) + }) + + it('uses the workspace artifact root for package-scoped artifact paths', async () => { + const packageDir = join(tmpDir, 'packages', 'router') + writeSkill(packageDir, 'routing', { + name: 'routing', + description: 'Routing', + library_version: '1.0.0', + }) + writeArtifact( + tmpDir, + 'skill_tree.yaml', + ` +library: + name: '@tanstack/router' + version: '1.0.0' +skills: + - name: 'Routing' + slug: 'routing' + path: 'packages/router/skills/routing/SKILL.md' + package: 'packages/router' +`, + ) + mockFetchNotOk() + + const report = await checkStaleness( + packageDir, + '@tanstack/router', + tmpDir, + ) + + expect(report.signals).toEqual([]) + }) }) From 2bfb6749c822d4da4a456bc1e633bdfbb697c981 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 23 Apr 2026 15:25:47 -0700 Subject: [PATCH 06/16] Add missing package coverage and artifact ignore support --- packages/intent/src/cli-support.ts | 67 ++++--- packages/intent/src/staleness.ts | 89 +++++++++ packages/intent/src/workspace-patterns.ts | 7 + packages/intent/tests/cli.test.ts | 174 ++++++++++++++++++ .../intent/tests/workspace-patterns.test.ts | 5 + 5 files changed, 313 insertions(+), 29 deletions(-) diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/cli-support.ts index dc2c402..db49d4a 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/cli-support.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { fail } from './cli-error.js' @@ -47,21 +47,6 @@ export function scanOptionsFromGlobalFlags( return { scope: 'local' } } -function readPackageName(root: string): string { - try { - const pkgJson = JSON.parse( - readFileSync(join(root, 'package.json'), 'utf8'), - ) as { - name?: unknown - } - return typeof pkgJson.name === 'string' - ? pkgJson.name - : relative(process.cwd(), root) || 'unknown' - } catch { - return relative(process.cwd(), root) || 'unknown' - } -} - export async function resolveStaleTargets( targetDir?: string, ): Promise<{ reports: Array }> { @@ -72,7 +57,8 @@ export async function resolveStaleTargets( cwd: process.cwd(), targetPath: targetDir, }) - const { checkStaleness } = await import('./staleness.js') + const { buildWorkspaceCoverageSignals, checkStaleness, readPackageName } = + await import('./staleness.js') if ( context.packageRoot && @@ -97,22 +83,45 @@ export async function resolveStaleTargets( } } - const { findPackagesWithSkills, findWorkspaceRoot } = + const { findPackagesWithSkills, findWorkspacePackages, findWorkspaceRoot } = await import('./workspace-patterns.js') const workspaceRoot = findWorkspaceRoot(resolvedRoot) if (workspaceRoot) { - const packageDirs = findPackagesWithSkills(workspaceRoot) - if (packageDirs.length > 0) { + const packageDirsWithSkills = findPackagesWithSkills(workspaceRoot) + const allPackageDirs = findWorkspacePackages(workspaceRoot) + const reports = await Promise.all( + packageDirsWithSkills.map((packageDir) => + checkStaleness(packageDir, readPackageName(packageDir), workspaceRoot), + ), + ) + const { readIntentArtifacts } = await import('./artifact-coverage.js') + const artifacts = existsSync(join(workspaceRoot, '_artifacts')) + ? readIntentArtifacts(workspaceRoot) + : null + const coverageSignals = buildWorkspaceCoverageSignals({ + artifactRoot: workspaceRoot, + artifacts, + packageDirs: allPackageDirs, + }) + if (coverageSignals.length > 0) { + const workspaceReport = reports[0] + if (workspaceReport) { + workspaceReport.signals.push(...coverageSignals) + } else { + reports.push({ + library: relative(process.cwd(), workspaceRoot) || 'workspace', + currentVersion: null, + skillVersion: null, + versionDrift: null, + skills: [], + signals: coverageSignals, + }) + } + } + + if (reports.length > 0) { return { - reports: await Promise.all( - packageDirs.map((packageDir) => - checkStaleness( - packageDir, - readPackageName(packageDir), - workspaceRoot, - ), - ), - ), + reports, } } } diff --git a/packages/intent/src/staleness.ts b/packages/intent/src/staleness.ts index 515ae46..a2d4a63 100644 --- a/packages/intent/src/staleness.ts +++ b/packages/intent/src/staleness.ts @@ -134,6 +134,21 @@ function readSyncState(packageDir: string): SyncState | null { } } +export function readPackageName(packageDir: string): string { + try { + const pkgJson = JSON.parse( + readFileSync(join(packageDir, 'package.json'), 'utf8'), + ) as { + name?: unknown + } + return typeof pkgJson.name === 'string' + ? pkgJson.name + : relative(process.cwd(), packageDir) || 'unknown' + } catch { + return relative(process.cwd(), packageDir) || 'unknown' + } +} + // --------------------------------------------------------------------------- // Artifact signals // --------------------------------------------------------------------------- @@ -329,6 +344,80 @@ function buildArtifactSignals({ return signals } +function artifactCoversPackage( + artifact: IntentArtifactSkill, + packageDir: string, + packageName: string, + artifactRoot: string, +): boolean { + const relPackageDir = relative(artifactRoot, packageDir).split(sep).join('/') + return ( + artifact.packages.includes(packageName) || + artifact.packages.includes(relPackageDir) || + artifact.package === packageName || + artifact.package === relPackageDir || + artifact.path?.startsWith(`${relPackageDir}/`) === true + ) +} + +function artifactIgnoresPackage( + artifacts: IntentArtifactSet, + packageDir: string, + packageName: string, + artifactRoot: string, +): boolean { + const relPackageDir = relative(artifactRoot, packageDir).split(sep).join('/') + return artifacts.ignoredPackages.some( + (ignored) => + ignored.packageName === packageName || + ignored.packageName === relPackageDir, + ) +} + +export function buildWorkspaceCoverageSignals({ + artifactRoot, + artifacts, + packageDirs, +}: { + artifactRoot: string + artifacts: IntentArtifactSet | null + packageDirs: Array +}): Array { + if (!artifacts) return [] + + const signals: Array = [] + for (const packageDir of packageDirs) { + const packageName = readPackageName(packageDir) + if ( + artifactIgnoresPackage(artifacts, packageDir, packageName, artifactRoot) + ) { + continue + } + + const hasGeneratedSkill = + findSkillFiles(join(packageDir, 'skills')).length > 0 + const hasArtifactCoverage = artifacts.skills.some((artifact) => + artifactCoversPackage(artifact, packageDir, packageName, artifactRoot), + ) + + if (hasGeneratedSkill || hasArtifactCoverage) continue + + signals.push({ + type: 'missing-package-coverage', + library: packageName, + subject: packageName, + reasons: [ + 'workspace package is not represented by generated skills or _artifacts coverage', + ], + needsReview: true, + packageName, + packageRoot: relative(artifactRoot, packageDir).split(sep).join('/'), + }) + } + + return signals +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- diff --git a/packages/intent/src/workspace-patterns.ts b/packages/intent/src/workspace-patterns.ts index 4c3d7d6..04997f8 100644 --- a/packages/intent/src/workspace-patterns.ts +++ b/packages/intent/src/workspace-patterns.ts @@ -292,3 +292,10 @@ export function findPackagesWithSkills(root: string): Array { return existsSync(skillsDir) && findSkillFiles(skillsDir).length > 0 }) } + +export function findWorkspacePackages(root: string): Array { + const patterns = readWorkspacePatterns(root) + if (!patterns) return [] + + return resolveWorkspacePackages(root, patterns) +} diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 0ed00fb..0b33aaa 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -1248,6 +1248,180 @@ describe('cli commands', () => { fetchSpy.mockRestore() }) + it('flags workspace packages missing skill and artifact coverage', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-stale-coverage-')) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { + private: true, + workspaces: ['packages/*'], + }) + writeJson(join(root, 'packages', 'router', 'package.json'), { + name: '@tanstack/router', + }) + writeJson(join(root, 'packages', 'react-start-rsc', 'package.json'), { + name: '@tanstack/react-start-rsc', + }) + writeSkillMd(join(root, 'packages', 'router', 'skills', 'routing'), { + name: 'routing', + description: 'Routing skill', + library_version: '1.0.0', + }) + mkdirSync(join(root, '_artifacts'), { recursive: true }) + writeFileSync( + join(root, '_artifacts', 'skill_tree.yaml'), + [ + 'library:', + " name: '@tanstack/router'", + " version: '1.0.0'", + 'skills:', + " - name: 'Routing'", + " slug: 'routing'", + " path: 'packages/router/skills/routing/SKILL.md'", + " package: 'packages/router'", + ].join('\n'), + ) + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ version: '1.0.0' }), + } as Response) + + process.chdir(root) + + const exitCode = await main(['stale', '--json']) + const output = logSpy.mock.calls.at(-1)?.[0] + const reports = JSON.parse(String(output)) as Array<{ + signals?: Array<{ + type: string + packageName?: string + }> + }> + const signals = reports.flatMap((report) => report.signals ?? []) + + expect(exitCode).toBe(0) + expect(signals).toEqual([ + expect.objectContaining({ + type: 'missing-package-coverage', + packageName: '@tanstack/react-start-rsc', + }), + ]) + + fetchSpy.mockRestore() + }) + + it('does not flag workspace packages ignored in artifact coverage', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-stale-coverage-ignore-'), + ) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { + private: true, + workspaces: ['packages/*'], + }) + writeJson(join(root, 'packages', 'router', 'package.json'), { + name: '@tanstack/router', + }) + writeJson(join(root, 'packages', 'react-start-rsc', 'package.json'), { + name: '@tanstack/react-start-rsc', + }) + writeSkillMd(join(root, 'packages', 'router', 'skills', 'routing'), { + name: 'routing', + description: 'Routing skill', + library_version: '1.0.0', + }) + mkdirSync(join(root, '_artifacts'), { recursive: true }) + writeFileSync( + join(root, '_artifacts', 'skill_tree.yaml'), + [ + 'library:', + " name: '@tanstack/router'", + " version: '1.0.0'", + 'coverage:', + ' ignored_packages:', + " - '@tanstack/react-start-rsc'", + 'skills:', + " - name: 'Routing'", + " slug: 'routing'", + " path: 'packages/router/skills/routing/SKILL.md'", + " package: 'packages/router'", + ].join('\n'), + ) + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ version: '1.0.0' }), + } as Response) + + process.chdir(root) + + const exitCode = await main(['stale', '--json']) + const output = logSpy.mock.calls.at(-1)?.[0] + const reports = JSON.parse(String(output)) as Array<{ + signals?: Array<{ + type: string + packageName?: string + }> + }> + const signals = reports.flatMap((report) => report.signals ?? []) + + expect(exitCode).toBe(0) + expect(signals).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'missing-package-coverage', + packageName: '@tanstack/react-start-rsc', + }), + ]), + ) + + fetchSpy.mockRestore() + }) + + it('flags missing coverage even when no workspace package has generated skills yet', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-stale-all-missing-')) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { + private: true, + workspaces: ['packages/*'], + }) + writeJson(join(root, 'packages', 'react-start-rsc', 'package.json'), { + name: '@tanstack/react-start-rsc', + }) + mkdirSync(join(root, '_artifacts'), { recursive: true }) + writeFileSync( + join(root, '_artifacts', 'skill_tree.yaml'), + [ + 'library:', + " name: '@tanstack/router'", + " version: '1.0.0'", + 'skills: []', + ].join('\n'), + ) + + process.chdir(root) + + const exitCode = await main(['stale', '--json']) + const output = logSpy.mock.calls.at(-1)?.[0] + const reports = JSON.parse(String(output)) as Array<{ + signals?: Array<{ + type: string + packageName?: string + }> + }> + + expect(exitCode).toBe(0) + expect(reports).toHaveLength(1) + expect(reports[0]?.signals).toEqual([ + expect.objectContaining({ + type: 'missing-package-coverage', + packageName: '@tanstack/react-start-rsc', + }), + ]) + }) + it('ignores configured global intent packages when checking staleness', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-stale-global-')) const globalRoot = mkdtempSync( diff --git a/packages/intent/tests/workspace-patterns.test.ts b/packages/intent/tests/workspace-patterns.test.ts index 15f3fdd..aa31f42 100644 --- a/packages/intent/tests/workspace-patterns.test.ts +++ b/packages/intent/tests/workspace-patterns.test.ts @@ -10,6 +10,7 @@ import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' import { findPackagesWithSkills, + findWorkspacePackages, findWorkspaceRoot, readWorkspacePatterns, resolveWorkspacePackages, @@ -263,5 +264,9 @@ describe('workspace helpers', () => { expect(findPackagesWithSkills(root)).toEqual([ join(root, 'packages', 'alpha'), ]) + expect(findWorkspacePackages(root)).toEqual([ + join(root, 'packages', 'alpha'), + join(root, 'packages', 'beta'), + ]) }) }) From 66125c577f63462632e108bbe904b4c99ed082dc Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 23 Apr 2026 15:38:52 -0700 Subject: [PATCH 07/16] Fix workspace-root skill resolution and private package coverage noise --- packages/intent/src/cli-support.ts | 19 ++- packages/intent/src/index.ts | 6 + packages/intent/src/staleness.ts | 21 ++- packages/intent/src/workflow-review.ts | 152 ++++++++++++++++++ packages/intent/tests/cli.test.ts | 145 +++++++++++++++++ packages/intent/tests/workflow-review.test.ts | 132 +++++++++++++++ 6 files changed, 459 insertions(+), 16 deletions(-) create mode 100644 packages/intent/src/workflow-review.ts create mode 100644 packages/intent/tests/workflow-review.test.ts diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/cli-support.ts index db49d4a..067613b 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/cli-support.ts @@ -59,9 +59,12 @@ export async function resolveStaleTargets( }) const { buildWorkspaceCoverageSignals, checkStaleness, readPackageName } = await import('./staleness.js') + const isWorkspaceRootTarget = + context.workspaceRoot !== null && resolvedRoot === context.workspaceRoot if ( context.packageRoot && + !isWorkspaceRootTarget && (context.targetSkillsDir !== null || resolvedRoot !== context.workspaceRoot) ) { return { @@ -75,14 +78,6 @@ export async function resolveStaleTargets( } } - if (existsSync(join(resolvedRoot, 'skills'))) { - return { - reports: [ - await checkStaleness(resolvedRoot, readPackageName(resolvedRoot)), - ], - } - } - const { findPackagesWithSkills, findWorkspacePackages, findWorkspaceRoot } = await import('./workspace-patterns.js') const workspaceRoot = findWorkspaceRoot(resolvedRoot) @@ -126,6 +121,14 @@ export async function resolveStaleTargets( } } + if (existsSync(join(resolvedRoot, 'skills'))) { + return { + reports: [ + await checkStaleness(resolvedRoot, readPackageName(resolvedRoot)), + ], + } + } + const staleResult = await scanIntentsOrFail() return { reports: await Promise.all( diff --git a/packages/intent/src/index.ts b/packages/intent/src/index.ts index 48f4e69..880eb6d 100644 --- a/packages/intent/src/index.ts +++ b/packages/intent/src/index.ts @@ -1,6 +1,12 @@ export { scanForIntents } from './scanner.js' export { checkStaleness } from './staleness.js' export { readIntentArtifacts } from './artifact-coverage.js' +export { + buildStaleReviewBody, + collectStaleReviewItems, + createFailedStaleReviewItem, + type StaleReviewItem, +} from './workflow-review.js' export { containsSecrets, hasGhCli, diff --git a/packages/intent/src/staleness.ts b/packages/intent/src/staleness.ts index a2d4a63..7ac3074 100644 --- a/packages/intent/src/staleness.ts +++ b/packages/intent/src/staleness.ts @@ -135,17 +135,19 @@ function readSyncState(packageDir: string): SyncState | null { } export function readPackageName(packageDir: string): string { + const packageJson = readPackageJson(packageDir) + return typeof packageJson?.name === 'string' + ? packageJson.name + : relative(process.cwd(), packageDir) || 'unknown' +} + +function readPackageJson(packageDir: string): Record | null { try { - const pkgJson = JSON.parse( + return JSON.parse( readFileSync(join(packageDir, 'package.json'), 'utf8'), - ) as { - name?: unknown - } - return typeof pkgJson.name === 'string' - ? pkgJson.name - : relative(process.cwd(), packageDir) || 'unknown' + ) as Record } catch { - return relative(process.cwd(), packageDir) || 'unknown' + return null } } @@ -387,6 +389,9 @@ export function buildWorkspaceCoverageSignals({ const signals: Array = [] for (const packageDir of packageDirs) { + const packageJson = readPackageJson(packageDir) + if (packageJson?.private === true) continue + const packageName = readPackageName(packageDir) if ( artifactIgnoresPackage(artifacts, packageDir, packageName, artifactRoot) diff --git a/packages/intent/src/workflow-review.ts b/packages/intent/src/workflow-review.ts new file mode 100644 index 0000000..ac1ada7 --- /dev/null +++ b/packages/intent/src/workflow-review.ts @@ -0,0 +1,152 @@ +import type { StalenessReport } from './types.js' + +export interface StaleReviewItem { + type: string + library: string + subject: string + reasons: Array + artifactPath?: string + packageName?: string + packageRoot?: string + skill?: string +} + +export function collectStaleReviewItems( + reports: Array, +): Array { + const items: Array = [] + + 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 ?? + signal?.subject ?? + report.library, + reasons: signal?.reasons ?? [], + artifactPath: signal?.artifactPath, + packageName: signal?.packageName, + packageRoot: signal?.packageRoot, + skill: signal?.skill, + }) + } + } + + return items +} + +export function createFailedStaleReviewItem( + library: string, +): StaleReviewItem { + return { + type: 'stale-check-failed', + library, + subject: 'intent stale --json', + reasons: [ + 'The stale check command failed. Review the workflow logs before updating skills.', + ], + } +} + +export function buildStaleReviewBody(items: Array): string { + 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') + + return [ + '## 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') +} diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 0b33aaa..4806ff5 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -1248,6 +1248,75 @@ describe('cli commands', () => { fetchSpy.mockRestore() }) + it('prefers workspace package staleness when the workspace root has a skills directory', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-stale-root-skills-')) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { + private: true, + workspaces: ['packages/*'], + }) + mkdirSync(join(root, 'skills'), { recursive: true }) + writeJson(join(root, 'packages', 'router-core', 'package.json'), { + name: '@tanstack/router-core', + }) + writeSkillMd( + join(root, 'packages', 'router-core', 'skills', 'router-core'), + { + name: 'router-core', + description: 'Router core skill', + library_version: '1.0.0', + }, + ) + mkdirSync(join(root, '_artifacts'), { recursive: true }) + writeFileSync( + join(root, '_artifacts', 'skill_tree.yaml'), + [ + 'library:', + " name: '@tanstack/router'", + " version: '1.0.0'", + 'skills:', + " - name: 'Router Core'", + " slug: 'router-core'", + " path: 'skills/router-core/SKILL.md'", + " package: 'packages/router-core'", + ].join('\n'), + ) + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ version: '1.0.0' }), + } as Response) + + process.chdir(root) + + const exitCode = await main(['stale', '--json']) + const output = logSpy.mock.calls.at(-1)?.[0] + const reports = JSON.parse(String(output)) as Array<{ + library: string + signals?: Array<{ + type: string + skill?: string + }> + }> + const signals = reports.flatMap((report) => report.signals ?? []) + + expect(exitCode).toBe(0) + expect(reports.map((report) => report.library)).toEqual([ + '@tanstack/router-core', + ]) + expect(signals).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'artifact-skill-missing', + skill: 'router-core', + }), + ]), + ) + + fetchSpy.mockRestore() + }) + it('flags workspace packages missing skill and artifact coverage', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-stale-coverage-')) tempDirs.push(root) @@ -1379,6 +1448,82 @@ describe('cli commands', () => { fetchSpy.mockRestore() }) + it('does not flag private workspace packages as missing coverage', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-stale-private-coverage-'), + ) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { + private: true, + workspaces: ['packages/*', 'examples/*'], + }) + writeJson(join(root, 'packages', 'router', 'package.json'), { + name: '@tanstack/router', + }) + writeJson(join(root, 'examples', 'start-rsc', 'package.json'), { + name: 'start-rsc-example', + private: true, + }) + writeJson(join(root, 'packages', 'react-start-rsc', 'package.json'), { + name: '@tanstack/react-start-rsc', + }) + writeSkillMd(join(root, 'packages', 'router', 'skills', 'routing'), { + name: 'routing', + description: 'Routing skill', + library_version: '1.0.0', + }) + mkdirSync(join(root, '_artifacts'), { recursive: true }) + writeFileSync( + join(root, '_artifacts', 'skill_tree.yaml'), + [ + 'library:', + " name: '@tanstack/router'", + " version: '1.0.0'", + 'skills:', + " - name: 'Routing'", + " slug: 'routing'", + " path: 'packages/router/skills/routing/SKILL.md'", + " package: 'packages/router'", + ].join('\n'), + ) + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ version: '1.0.0' }), + } as Response) + + process.chdir(root) + + const exitCode = await main(['stale', '--json']) + const output = logSpy.mock.calls.at(-1)?.[0] + const reports = JSON.parse(String(output)) as Array<{ + signals?: Array<{ + type: string + packageName?: string + }> + }> + const signals = reports.flatMap((report) => report.signals ?? []) + + expect(exitCode).toBe(0) + expect(signals).toEqual([ + expect.objectContaining({ + type: 'missing-package-coverage', + packageName: '@tanstack/react-start-rsc', + }), + ]) + expect(signals).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'missing-package-coverage', + packageName: 'start-rsc-example', + }), + ]), + ) + + fetchSpy.mockRestore() + }) + it('flags missing coverage even when no workspace package has generated skills yet', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-stale-all-missing-')) tempDirs.push(root) diff --git a/packages/intent/tests/workflow-review.test.ts b/packages/intent/tests/workflow-review.test.ts new file mode 100644 index 0000000..13eb31d --- /dev/null +++ b/packages/intent/tests/workflow-review.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' +import { + buildStaleReviewBody, + collectStaleReviewItems, + createFailedStaleReviewItem, +} from '../src/workflow-review.js' +import type { StalenessReport } from '../src/types.js' + +function report(overrides: Partial): StalenessReport { + return { + library: '@tanstack/router', + currentVersion: null, + skillVersion: null, + versionDrift: null, + skills: [], + signals: [], + ...overrides, + } +} + +describe('workflow review helpers', () => { + it('collects stale skills and review signals into one item list', () => { + const items = collectStaleReviewItems([ + report({ + skills: [ + { + name: 'routing', + reasons: ['version drift (1.0.0 -> 1.1.0)'], + needsReview: true, + }, + { + name: 'clean', + reasons: [], + needsReview: false, + }, + ], + signals: [ + { + type: 'missing-package-coverage', + library: '@tanstack/react-start-rsc', + packageName: '@tanstack/react-start-rsc', + packageRoot: 'packages/react-start-rsc', + reasons: ['workspace package is not represented'], + needsReview: true, + }, + { + type: 'artifact-source-drift', + skill: 'start-core', + reasons: ['artifact sources differ'], + needsReview: true, + }, + ], + }), + ]) + + expect(items).toEqual([ + expect.objectContaining({ + type: 'stale-skill', + library: '@tanstack/router', + subject: 'routing', + }), + expect.objectContaining({ + type: 'missing-package-coverage', + library: '@tanstack/react-start-rsc', + subject: '@tanstack/react-start-rsc', + packageRoot: 'packages/react-start-rsc', + }), + expect.objectContaining({ + type: 'artifact-source-drift', + library: '@tanstack/router', + subject: 'start-core', + }), + ]) + }) + + it('returns no review items for clean reports', () => { + expect( + collectStaleReviewItems([ + report({ + skills: [{ name: 'routing', reasons: [], needsReview: false }], + }), + ]), + ).toEqual([]) + }) + + it('builds a grouped review body with maintainer prompt', () => { + const body = buildStaleReviewBody([ + { + type: 'stale-skill', + library: '@tanstack/router', + subject: 'routing', + reasons: ['version drift'], + }, + { + type: 'missing-package-coverage', + library: '@tanstack/react-start-rsc', + subject: '@tanstack/react-start-rsc', + reasons: ['workspace package is not represented'], + packageName: '@tanstack/react-start-rsc', + }, + { + type: 'missing-package-coverage', + library: '@tanstack/start-server-functions', + subject: '@tanstack/start-server-functions', + reasons: ['workspace package is not represented'], + packageName: '@tanstack/start-server-functions', + }, + ]) + + expect(body).toContain('| `missing-package-coverage` | 2 |') + expect(body).toContain('| `stale-skill` | 1 |') + expect(body).toContain('`@tanstack/react-start-rsc`') + expect(body).toContain('Before editing skills or artifacts, ask the maintainer:') + expect(body).toContain('- Do not auto-generate skills.') + expect(body).toContain( + 'Summarize every package as one of: existing-skill coverage, new skill, ignored, or deferred.', + ) + }) + + it('builds a useful failed stale check review item', () => { + const item = createFailedStaleReviewItem('@tanstack/router') + const body = buildStaleReviewBody([item]) + + expect(item).toMatchObject({ + type: 'stale-check-failed', + library: '@tanstack/router', + subject: 'intent stale --json', + }) + expect(body).toContain('| `stale-check-failed` | 1 |') + expect(body).toContain('Review the workflow logs before updating skills.') + }) +}) From 490b93bfecbc77e5ff7b1b84ac6a596168e3e3bd Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 23 Apr 2026 15:45:14 -0700 Subject: [PATCH 08/16] Add workflow update advisory --- .../meta/templates/workflows/check-skills.yml | 2 + packages/intent/src/cli-support.ts | 38 ++++++++- packages/intent/src/commands/stale.ts | 16 +++- packages/intent/tests/setup.test.ts | 2 + packages/intent/tests/stale-command.test.ts | 82 +++++++++++++++++++ 5 files changed, 136 insertions(+), 4 deletions(-) diff --git a/packages/intent/meta/templates/workflows/check-skills.yml b/packages/intent/meta/templates/workflows/check-skills.yml index 67c1f66..dde6584 100644 --- a/packages/intent/meta/templates/workflows/check-skills.yml +++ b/packages/intent/meta/templates/workflows/check-skills.yml @@ -5,6 +5,8 @@ # # 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 diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/cli-support.ts index 067613b..b50669a 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/cli-support.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { fail } from './cli-error.js' @@ -12,11 +12,38 @@ export interface GlobalScanFlags { globalOnly?: boolean } +export interface StaleTargetResult { + reports: Array + workflowAdvisories: Array +} + +export const INTENT_CHECK_SKILLS_WORKFLOW_VERSION = 2 + export function getMetaDir(): string { const thisDir = dirname(fileURLToPath(import.meta.url)) return join(thisDir, '..', 'meta') } +export function getCheckSkillsWorkflowAdvisories(root: string): Array { + const workflowPath = join(root, '.github', 'workflows', 'check-skills.yml') + if (!existsSync(workflowPath)) return [] + + let content: string + try { + content = readFileSync(workflowPath, 'utf8') + } catch { + return [] + } + + const versionMatch = content.match(/intent-workflow-version:\s*(\d+)/) + const installedVersion = versionMatch ? Number(versionMatch[1]) : 0 + if (installedVersion >= INTENT_CHECK_SKILLS_WORKFLOW_VERSION) return [] + + return [ + `Intent workflow update available: run \`npx @tanstack/intent@latest setup\` to refresh ${relative(process.cwd(), workflowPath) || workflowPath}.`, + ] +} + export async function scanIntentsOrFail( options?: ScanOptions, ): Promise { @@ -49,7 +76,7 @@ export function scanOptionsFromGlobalFlags( export async function resolveStaleTargets( targetDir?: string, -): Promise<{ reports: Array }> { +): Promise { const resolvedRoot = targetDir ? resolve(process.cwd(), targetDir) : process.cwd() @@ -57,6 +84,9 @@ export async function resolveStaleTargets( cwd: process.cwd(), targetPath: targetDir, }) + const advisoryRoot = + context.workspaceRoot ?? context.packageRoot ?? resolvedRoot + const workflowAdvisories = getCheckSkillsWorkflowAdvisories(advisoryRoot) const { buildWorkspaceCoverageSignals, checkStaleness, readPackageName } = await import('./staleness.js') const isWorkspaceRootTarget = @@ -75,6 +105,7 @@ export async function resolveStaleTargets( context.workspaceRoot ?? context.packageRoot, ), ], + workflowAdvisories, } } @@ -117,6 +148,7 @@ export async function resolveStaleTargets( if (reports.length > 0) { return { reports, + workflowAdvisories, } } } @@ -126,6 +158,7 @@ export async function resolveStaleTargets( reports: [ await checkStaleness(resolvedRoot, readPackageName(resolvedRoot)), ], + workflowAdvisories, } } @@ -136,5 +169,6 @@ export async function resolveStaleTargets( checkStaleness(pkg.packageRoot, pkg.name), ), ), + workflowAdvisories, } } diff --git a/packages/intent/src/commands/stale.ts b/packages/intent/src/commands/stale.ts index f6543a5..8778c26 100644 --- a/packages/intent/src/commands/stale.ts +++ b/packages/intent/src/commands/stale.ts @@ -5,15 +5,27 @@ export async function runStaleCommand( options: { json?: boolean }, resolveStaleTargets: ( targetDir?: string, - ) => Promise<{ reports: Array }>, + ) => Promise<{ + reports: Array + workflowAdvisories?: Array + }>, ): Promise { - const { reports } = await resolveStaleTargets(targetDir) + const { reports, workflowAdvisories = [] } = await resolveStaleTargets( + targetDir, + ) if (options.json) { console.log(JSON.stringify(reports, null, 2)) return } + for (const advisory of workflowAdvisories) { + console.log(advisory) + } + if (workflowAdvisories.length > 0) { + console.log() + } + if (reports.length === 0) { console.log('No intent-enabled packages found.') return diff --git a/packages/intent/tests/setup.test.ts b/packages/intent/tests/setup.test.ts index 99831a5..92cede4 100644 --- a/packages/intent/tests/setup.test.ts +++ b/packages/intent/tests/setup.test.ts @@ -38,6 +38,7 @@ beforeEach(() => { join(metaDir, 'templates', 'workflows', 'check-skills.yml'), [ 'label: {{PACKAGE_LABEL}}', + '# intent-workflow-version: 2', 'install: npm install -g @tanstack/intent', 'signals: report.signals', 'has_review=true', @@ -264,6 +265,7 @@ describe('runSetupGithubActions', () => { 'utf8', ) expect(checkContent).toContain('label: @tanstack/query') + expect(checkContent).toContain('# intent-workflow-version: 2') expect(checkContent).toContain('install: npm install -g @tanstack/intent') expect(checkContent).toContain('signals: report.signals') expect(checkContent).toContain('has_review=true') diff --git a/packages/intent/tests/stale-command.test.ts b/packages/intent/tests/stale-command.test.ts index 91f9561..8defcc0 100644 --- a/packages/intent/tests/stale-command.test.ts +++ b/packages/intent/tests/stale-command.test.ts @@ -1,11 +1,19 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { getCheckSkillsWorkflowAdvisories } from '../src/cli-support.js' import { runStaleCommand } from '../src/commands/stale.js' describe('runStaleCommand', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const tempDirs: Array = [] afterEach(() => { logSpy.mockClear() + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } }) it('prints review signals in non-json output', async () => { @@ -36,4 +44,78 @@ describe('runStaleCommand', () => { expect(output).toContain('package is not represented') expect(output).not.toContain('All skills up-to-date') }) + + it('prints workflow update advisories in non-json output', async () => { + await runStaleCommand(undefined, {}, async () => ({ + reports: [], + workflowAdvisories: [ + 'Intent workflow update available: run `npx @tanstack/intent@latest setup`.', + ], + })) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('Intent workflow update available') + expect(output).toContain('npx @tanstack/intent@latest setup') + expect(output).toContain('No intent-enabled packages found.') + }) + + it('does not print workflow update advisories in json output', async () => { + await runStaleCommand(undefined, { json: true }, async () => ({ + reports: [], + workflowAdvisories: [ + 'Intent workflow update available: run `npx @tanstack/intent@latest setup`.', + ], + })) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toBe('[]') + }) +}) + +describe('getCheckSkillsWorkflowAdvisories', () => { + const tempDirs: Array = [] + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + function writeWorkflow(content: string): string { + const root = mkdtempSync(join(tmpdir(), 'intent-workflow-advisory-')) + tempDirs.push(root) + const workflowDir = join(root, '.github', 'workflows') + mkdirSync(workflowDir, { recursive: true }) + writeFileSync(join(workflowDir, 'check-skills.yml'), content) + return root + } + + it('advises when the workflow has no intent version stamp', () => { + const root = writeWorkflow('name: Check Skills\n') + + expect(getCheckSkillsWorkflowAdvisories(root)).toEqual([ + expect.stringContaining('Intent workflow update available'), + ]) + }) + + it('advises when the workflow has an old intent version stamp', () => { + const root = writeWorkflow('# intent-workflow-version: 1\n') + + expect(getCheckSkillsWorkflowAdvisories(root)).toEqual([ + expect.stringContaining('npx @tanstack/intent@latest setup'), + ]) + }) + + it('does not advise when the workflow has the current version stamp', () => { + const root = writeWorkflow('# intent-workflow-version: 2\n') + + expect(getCheckSkillsWorkflowAdvisories(root)).toEqual([]) + }) + + it('does not advise when the workflow is absent', () => { + const root = mkdtempSync(join(tmpdir(), 'intent-workflow-advisory-')) + tempDirs.push(root) + + expect(getCheckSkillsWorkflowAdvisories(root)).toEqual([]) + }) }) From b4846157c75efc1f4ac1872f10baddd81889f4ca Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 23 Apr 2026 15:50:18 -0700 Subject: [PATCH 09/16] fix release script --- scripts/create-github-release.mjs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/scripts/create-github-release.mjs b/scripts/create-github-release.mjs index b04962b..a4608cb 100644 --- a/scripts/create-github-release.mjs +++ b/scripts/create-github-release.mjs @@ -204,10 +204,21 @@ function buildReleaseNotes(changedPackages) { return sections.join('\n\n') } -function createReleaseTag() { +function createReleaseTag(changedPackages) { + const versions = [...new Set(changedPackages.map((pkg) => pkg.version))] + if (versions.length === 1) { + const version = versions[0] + return { + tag: `v${version}`, + title: `v${version}`, + } + } + const now = new Date().toISOString() const tag = `release-${now.slice(0, 10)}-${now.slice(11, 13)}${now.slice(14, 16)}` - const title = `Release ${now.slice(0, 10)} ${now.slice(11, 16)}` + const title = `Release ${changedPackages + .map((pkg) => `${pkg.name}@${pkg.version}`) + .join(', ')}` return { tag, title } } @@ -289,7 +300,7 @@ function main() { } const notes = buildReleaseNotes(changedPackages) - const { tag, title } = createReleaseTag() + const { tag, title } = createReleaseTag(changedPackages) const body = createReleaseBody(title, changedPackages, notes) if (isDryRun) { From 74951e4a72f4b8e747ce508429dc6e2e088d9538 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 23 Apr 2026 15:52:12 -0700 Subject: [PATCH 10/16] changeset --- .changeset/ten-shrimps-bow.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/ten-shrimps-bow.md diff --git a/.changeset/ten-shrimps-bow.md b/.changeset/ten-shrimps-bow.md new file mode 100644 index 0000000..4d1f893 --- /dev/null +++ b/.changeset/ten-shrimps-bow.md @@ -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`. \ No newline at end of file From 54f8c23980ce1e10e2c93462821d0dc3c0a71309 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:53:07 +0000 Subject: [PATCH 11/16] ci: apply automated fixes --- .changeset/ten-shrimps-bow.md | 2 +- packages/intent/src/commands/stale.ts | 9 +++------ packages/intent/src/workflow-review.ts | 4 +--- packages/intent/tests/artifact-coverage.test.ts | 5 +---- packages/intent/tests/staleness.test.ts | 12 ++++++------ packages/intent/tests/workflow-review.test.ts | 4 +++- 6 files changed, 15 insertions(+), 21 deletions(-) diff --git a/.changeset/ten-shrimps-bow.md b/.changeset/ten-shrimps-bow.md index 4d1f893..067ae7e 100644 --- a/.changeset/ten-shrimps-bow.md +++ b/.changeset/ten-shrimps-bow.md @@ -4,4 +4,4 @@ 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`. \ No newline at end of file +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`. diff --git a/packages/intent/src/commands/stale.ts b/packages/intent/src/commands/stale.ts index 8778c26..30df6f9 100644 --- a/packages/intent/src/commands/stale.ts +++ b/packages/intent/src/commands/stale.ts @@ -3,16 +3,13 @@ import type { StalenessReport } from '../types.js' export async function runStaleCommand( targetDir: string | undefined, options: { json?: boolean }, - resolveStaleTargets: ( - targetDir?: string, - ) => Promise<{ + resolveStaleTargets: (targetDir?: string) => Promise<{ reports: Array workflowAdvisories?: Array }>, ): Promise { - const { reports, workflowAdvisories = [] } = await resolveStaleTargets( - targetDir, - ) + const { reports, workflowAdvisories = [] } = + await resolveStaleTargets(targetDir) if (options.json) { console.log(JSON.stringify(reports, null, 2)) diff --git a/packages/intent/src/workflow-review.ts b/packages/intent/src/workflow-review.ts index ac1ada7..8c05675 100644 --- a/packages/intent/src/workflow-review.ts +++ b/packages/intent/src/workflow-review.ts @@ -51,9 +51,7 @@ export function collectStaleReviewItems( return items } -export function createFailedStaleReviewItem( - library: string, -): StaleReviewItem { +export function createFailedStaleReviewItem(library: string): StaleReviewItem { return { type: 'stale-check-failed', library, diff --git a/packages/intent/tests/artifact-coverage.test.ts b/packages/intent/tests/artifact-coverage.test.ts index 5b7c124..c7a0945 100644 --- a/packages/intent/tests/artifact-coverage.test.ts +++ b/packages/intent/tests/artifact-coverage.test.ts @@ -114,10 +114,7 @@ skills: artifactKind: 'domain-map', slug: 'start-setup', packages: ['@tanstack/react-start', '@tanstack/start-plugin-core'], - covers: [ - 'tanstackStart Vite plugin', - 'getRouter() factory pattern', - ], + covers: ['tanstackStart Vite plugin', 'getRouter() factory pattern'], }), ]), ) diff --git a/packages/intent/tests/staleness.test.ts b/packages/intent/tests/staleness.test.ts index 06dfee3..e34b7ff 100644 --- a/packages/intent/tests/staleness.test.ts +++ b/packages/intent/tests/staleness.test.ts @@ -46,7 +46,11 @@ function writeSyncState(dir: string, state: Record): void { writeFileSync(join(skillsDir, 'sync-state.json'), JSON.stringify(state)) } -function writeArtifact(rootDir: string, fileName: string, content: string): void { +function writeArtifact( + rootDir: string, + fileName: string, + content: string, +): void { const artifactsDir = join(rootDir, '_artifacts') mkdirSync(artifactsDir, { recursive: true }) writeFileSync(join(artifactsDir, fileName), content) @@ -522,11 +526,7 @@ skills: ) mockFetchNotOk() - const report = await checkStaleness( - packageDir, - '@tanstack/router', - tmpDir, - ) + const report = await checkStaleness(packageDir, '@tanstack/router', tmpDir) expect(report.signals).toEqual([]) }) diff --git a/packages/intent/tests/workflow-review.test.ts b/packages/intent/tests/workflow-review.test.ts index 13eb31d..a5521f8 100644 --- a/packages/intent/tests/workflow-review.test.ts +++ b/packages/intent/tests/workflow-review.test.ts @@ -110,7 +110,9 @@ describe('workflow review helpers', () => { expect(body).toContain('| `missing-package-coverage` | 2 |') expect(body).toContain('| `stale-skill` | 1 |') expect(body).toContain('`@tanstack/react-start-rsc`') - expect(body).toContain('Before editing skills or artifacts, ask the maintainer:') + expect(body).toContain( + 'Before editing skills or artifacts, ask the maintainer:', + ) expect(body).toContain('- Do not auto-generate skills.') expect(body).toContain( 'Summarize every package as one of: existing-skill coverage, new skill, ignored, or deferred.', From a4da4570d11301d0131007c5f0d6cf2062cbbd8e Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 23 Apr 2026 16:08:31 -0700 Subject: [PATCH 12/16] update docs --- docs/cli/intent-scaffold.md | 6 +- docs/cli/intent-setup.md | 34 ++++----- docs/cli/intent-stale.md | 75 +++++++++++++------ docs/config.json | 2 +- .../quick-start-maintainers.md | 49 +++++++----- docs/registry.md | 4 +- packages/intent/README.md | 14 ++-- packages/intent/src/cli-support.ts | 2 +- packages/intent/src/cli.ts | 7 ++ packages/intent/tests/cli.test.ts | 21 ++++++ 10 files changed, 140 insertions(+), 74 deletions(-) diff --git a/docs/cli/intent-scaffold.md b/docs/cli/intent-scaffold.md index d58b8af..5477cf5 100644 --- a/docs/cli/intent-scaffold.md +++ b/docs/cli/intent-scaffold.md @@ -29,9 +29,9 @@ The prompt also includes a post-generation checklist: - Run `npx @tanstack/intent@latest validate` and fix issues - Commit generated `skills/` and `skills/_artifacts/` - Ensure `@tanstack/intent` is in `devDependencies` -- Run setup commands as needed: - - `npx @tanstack/intent@latest edit-package-json` - - `npx @tanstack/intent@latest setup-github-actions` +- Run setup commands as needed: + - `npx @tanstack/intent@latest edit-package-json` + - `npx @tanstack/intent@latest setup` ## Related diff --git a/docs/cli/intent-setup.md b/docs/cli/intent-setup.md index 39ef9b0..1c85bf6 100644 --- a/docs/cli/intent-setup.md +++ b/docs/cli/intent-setup.md @@ -3,17 +3,18 @@ title: setup commands id: intent-setup --- -Intent exposes setup as two separate commands. - -```bash -npx @tanstack/intent@latest edit-package-json -npx @tanstack/intent@latest setup-github-actions -``` +Intent exposes publishing setup as two commands. + +```bash +npx @tanstack/intent@latest edit-package-json +npx @tanstack/intent@latest setup +``` ## Commands -- `edit-package-json`: add or normalize `package.json` entries needed to publish skills -- `setup-github-actions`: copy workflow templates to `.github/workflows` +- `edit-package-json`: add or normalize `package.json` entries needed to publish skills +- `setup`: copy workflow templates to `.github/workflows` +- `setup-github-actions`: legacy alias for `setup` ## What each command changes @@ -22,12 +23,11 @@ npx @tanstack/intent@latest setup-github-actions - Ensures `keywords` includes `tanstack-intent` - Ensures `files` includes required publish entries - Preserves existing indentation -- `setup-github-actions` - - Copies templates from `@tanstack/intent/meta/templates/workflows` to `.github/workflows` - - Applies variable substitution (`PACKAGE_NAME`, `PACKAGE_LABEL`, `PAYLOAD_PACKAGE`, `REPO`, `DOCS_PATH`, `SRC_PATH`, `WATCH_PATHS`) - - Detects the workspace root in monorepos and writes repo-level workflows there - - Generates monorepo-aware watch paths for package `src/` and docs directories - - Skips files that already exist at destination +- `setup` + - Copies templates from `@tanstack/intent/meta/templates/workflows` to `.github/workflows` + - Applies variable substitution (`PACKAGE_NAME`, `PACKAGE_LABEL`, `PAYLOAD_PACKAGE`, `REPO`, `DOCS_PATH`, `SRC_PATH`, `WATCH_PATHS`) + - Detects the workspace root in monorepos and writes repo-level workflows there + - Skips files that already exist at destination ## Required `files` entries @@ -39,12 +39,12 @@ npx @tanstack/intent@latest setup-github-actions ## Common errors - Missing or invalid `package.json` when running `edit-package-json` -- Missing template source when running `setup-github-actions` +- Missing template source when running `setup` ## Notes -- `setup-github-actions` skips existing files -- In monorepos, run `setup-github-actions` from either the repo root or a package directory; Intent writes workflows to the workspace root +- `setup` skips existing files +- In monorepos, run `setup` from either the repo root or a package directory; Intent writes workflows to the workspace root ## Related diff --git a/docs/cli/intent-stale.md b/docs/cli/intent-stale.md index 10b1512..b7f7333 100644 --- a/docs/cli/intent-stale.md +++ b/docs/cli/intent-stale.md @@ -15,11 +15,27 @@ npx @tanstack/intent@latest stale [--json] ## Behavior -- Checks the current package by default, or all skill-bearing packages in the current workspace when run from a monorepo root -- When `dir` is provided, scopes the check to the targeted package or skills directory -- Computes one staleness report per package -- Prints text output by default or JSON with `--json` -- If no packages are found, prints `No intent-enabled packages found.` +- Checks the current package by default, or all skill-bearing packages in the current workspace when run from a monorepo root +- When `dir` is provided, scopes the check to the targeted package or skills directory +- Computes one staleness report per package +- Reads repo-root `_artifacts/*domain_map.yaml` and `_artifacts/*skill_tree.yaml` when present +- Flags public workspace packages that are not represented by generated skills or artifact coverage +- Skips workspace packages with `"private": true` +- Prints text output by default or JSON with `--json` +- Prints a non-failing workflow update reminder when `.github/workflows/check-skills.yml` is missing the current `intent-workflow-version` stamp +- If no packages are found, prints `No intent-enabled packages found.` + +Artifact coverage ignores can be recorded in `_artifacts/*skill_tree.yaml` or `_artifacts/*domain_map.yaml`: + +```yaml +coverage: + ignored_packages: + - '@tanstack/internal-tooling' + - name: packages/devtools-fixture + reason: test fixture only +``` + +Ignored packages are excluded from missing coverage signals. Private workspace packages are excluded automatically. ## JSON report schema @@ -32,15 +48,26 @@ npx @tanstack/intent@latest stale [--json] "currentVersion": "string | null", "skillVersion": "string | null", "versionDrift": "major | minor | patch | null", - "skills": [ - { - "name": "string", - "reasons": ["string"], - "needsReview": true - } - ] - } -] + "skills": [ + { + "name": "string", + "reasons": ["string"], + "needsReview": true + } + ], + "signals": [ + { + "type": "missing-package-coverage", + "library": "string", + "subject": "string", + "reasons": ["string"], + "needsReview": true, + "packageName": "string", + "packageRoot": "string" + } + ] + } +] ``` Report fields: @@ -48,8 +75,9 @@ Report fields: - `library`: package name - `currentVersion`: latest version from npm registry (or `null` if unavailable) - `skillVersion`: `library_version` from skills (or `null`) -- `versionDrift`: `major | minor | patch | null` -- `skills`: array of per-skill checks +- `versionDrift`: `major | minor | patch | null` +- `skills`: array of per-skill checks +- `signals`: array of artifact and workspace coverage checks Skill fields: @@ -57,16 +85,17 @@ Skill fields: - `reasons`: one or more staleness reasons - `needsReview`: boolean (`true` when reasons exist) -Reason generation: - -- `version drift ()` -- `new source ()` when a declared source has no stored sync SHA +Reason generation: + +- `version drift ()` +- `new source ()` when a declared source has no stored sync SHA +- artifact parse warnings, unresolved artifact skill paths, source drift, artifact library version drift, and missing workspace package coverage ## Text output -- Report header format: ` () [ drift]` -- When no skill reasons exist: `All skills up-to-date` -- Otherwise: one warning line per stale skill (`⚠ : , , ...`) +- Report header format: ` () [ drift]` +- When no skill reasons exist: `All skills up-to-date` +- Otherwise: one warning line per stale skill or review signal (`⚠ : , , ...`) ## Common errors diff --git a/docs/config.json b/docs/config.json index b762308..7fee17d 100644 --- a/docs/config.json +++ b/docs/config.json @@ -55,7 +55,7 @@ "to": "cli/intent-validate" }, { - "label": "intent setup-github-actions", + "label": "intent setup", "to": "cli/intent-setup" }, { diff --git a/docs/getting-started/quick-start-maintainers.md b/docs/getting-started/quick-start-maintainers.md index a10cfca..a3456e2 100644 --- a/docs/getting-started/quick-start-maintainers.md +++ b/docs/getting-started/quick-start-maintainers.md @@ -101,8 +101,8 @@ Run these commands to prepare your package for skill publishing: # Update package.json with required fields npx @tanstack/intent@latest edit-package-json -# Copy CI workflow templates (validate + stale checks) -npx @tanstack/intent@latest setup-github-actions +# Copy CI workflow templates (validate + stale checks) +npx @tanstack/intent@latest setup ``` **What these do:** @@ -112,7 +112,7 @@ npx @tanstack/intent@latest setup-github-actions - `files` array entries for `skills/` - For single packages: also adds `!skills/_artifacts` to exclude artifacts from npm - For monorepos: skips the artifacts exclusion (artifacts live at repo root) -- `setup-github-actions` copies workflow templates to `.github/workflows/` for automated validation and staleness checking +- `setup` copies workflow templates to `.github/workflows/` for automated validation and staleness checking ### 5. Ship skills with your package @@ -135,24 +135,19 @@ Consumers who install your library automatically get the skills. They discover l ### 6. Set up CI workflows -After running `setup-github-actions`, you'll have three workflows in `.github/workflows/`: +After running `setup`, you'll have two workflows in `.github/workflows/`: **validate-skills.yml** (runs on PRs touching `skills/`) - Validates SKILL.md frontmatter and structure - Ensures files stay under 500 lines - Runs automatically on every pull request that modifies skills -**check-skills.yml** (runs on release or manual trigger) -- Automatically detects stale skills after you publish a new release -- Opens a review PR with an agent-friendly prompt -- Requires you to copy the prompt into Claude Code, Cursor, or your agent to update skills - -**notify-intent.yml** (runs on docs/source changes to main) -- Sends a webhook to TanStack/intent when your docs or source change -- Enables cross-library skill staleness tracking -- Requires a fine-grained GitHub token (`INTENT_NOTIFY_TOKEN`) secret - -### 7. Update stale skills +**check-skills.yml** (runs on release or manual trigger) +- Automatically detects stale skills and coverage gaps after you publish a new release +- Opens one grouped review PR with an agent-friendly prompt +- Requires you to copy the prompt into Claude Code, Cursor, or your agent to update skills + +### 7. Update stale skills When you publish a new release, `check-skills.yml` automatically opens a PR flagging skills that need review. @@ -162,11 +157,25 @@ Manually check which skills need updates with: npx @tanstack/intent@latest stale ``` -When run from a package, this checks that package's shipped skills. When run from a monorepo root, it checks the workspace packages that ship skills. - -This detects: -- **Version drift** — skill targets an older library version than currently installed -- **New sources** — sources declared in frontmatter that weren't tracked before +When run from a package, this checks that package's shipped skills. When run from a monorepo root, it checks workspace packages with skills and flags public workspace packages missing skill or `_artifacts` coverage. + +This detects: +- **Version drift** — skill targets an older library version than currently installed +- **New sources** — sources declared in frontmatter that weren't tracked before +- **Artifact drift** — `_artifacts` entries that no longer match generated skills +- **Missing package coverage** — public workspace packages not represented by generated skills or artifact coverage + +If a public workspace package is intentionally out of scope for skills, record that decision in repo-root `_artifacts`: + +```yaml +coverage: + ignored_packages: + - '@tanstack/internal-tooling' + - name: packages/devtools-fixture + reason: test fixture only +``` + +Private workspace packages are skipped automatically. **To update stale skills:** 1. Review the PR opened by `check-skills.yml` diff --git a/docs/registry.md b/docs/registry.md index e19f643..ceb7ade 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -59,10 +59,10 @@ npx @tanstack/intent@latest stale Flags skills whose source docs have changed since the skill was last updated. ```bash -npx @tanstack/intent@latest setup-github-actions +npx @tanstack/intent@latest setup ``` -Copies CI workflow templates into your repo so validation and staleness checks run on every push. Catch drift before it ships. +Copies CI workflow templates into your repo so validation and staleness checks run in GitHub Actions. Catch drift before it ships. ## Requesting a library diff --git a/packages/intent/README.md b/packages/intent/README.md index e708d4d..f3704fe 100644 --- a/packages/intent/README.md +++ b/packages/intent/README.md @@ -90,10 +90,10 @@ npx @tanstack/intent@latest stale From a monorepo root, `intent stale` checks every workspace package that ships skills. To scope it to one package, pass a directory like `intent stale packages/router`. -Copy CI workflow templates into your repo so validation and staleness checks run on every push: +Copy CI workflow templates into your repo so validation and staleness checks can run in GitHub Actions: ```bash -npx @tanstack/intent@latest setup-github-actions +npx @tanstack/intent@latest setup ``` ## Compatibility @@ -108,14 +108,14 @@ npx @tanstack/intent@latest setup-github-actions ## Monorepos -- Run `npx @tanstack/intent@latest setup-github-actions` from either the repo root or a package directory. Intent detects the workspace root and writes workflows to the repo-level `.github/workflows/` directory. -- Generated workflows are monorepo-aware: validation loops over workspace packages with skills, staleness checks run from the workspace root, and notify workflows watch package `src/` and docs paths. +- Run `npx @tanstack/intent@latest setup` from either the repo root or a package directory. Intent detects the workspace root and writes workflows to the repo-level `.github/workflows/` directory. +- Generated workflows are monorepo-aware: validation loops over workspace packages with skills, and staleness checks run from the workspace root. - Run `npx @tanstack/intent@latest validate packages//skills` from the repo root to validate one package without root-level packaging warnings. -- Run `npx @tanstack/intent@latest stale` from the repo root to check all workspace packages with skills, or `intent stale packages/` to check one package. +- Run `npx @tanstack/intent@latest stale` from the repo root to check workspace packages with skills and public workspace packages missing skill or `_artifacts` coverage, or `intent stale packages/` to check one package. ## Keeping skills current -The real risk with any derived artifact is staleness. `npx @tanstack/intent@latest stale` flags skills whose source docs have changed, and CI templates catch drift before it ships. +The real risk with any derived artifact is staleness. `npx @tanstack/intent@latest stale` flags skills whose source docs have changed, generated skills that drift from `_artifacts`, and public workspace packages missing coverage. CI templates catch drift before it ships. The feedback loop runs both directions. `npx @tanstack/intent@latest feedback` lets users submit structured reports when a skill produces wrong output — which skill, which version, what broke. That context flows back to the maintainer, and the fix ships to everyone on the next package update. Every support interaction produces an artifact that prevents the same class of problem for all future users — not just the one who reported it. @@ -129,7 +129,7 @@ The feedback loop runs both directions. `npx @tanstack/intent@latest feedback` l | `npx @tanstack/intent@latest meta` | List meta-skills for library maintainers | | `npx @tanstack/intent@latest scaffold` | Print the guided skill generation prompt | | `npx @tanstack/intent@latest validate [dir]` | Validate SKILL.md files | -| `npx @tanstack/intent@latest setup-github-actions` | Copy CI templates into your repo | +| `npx @tanstack/intent@latest setup` | Copy CI templates into your repo | | `npx @tanstack/intent@latest stale [dir] [--json]` | Check skills for version drift | | `npx @tanstack/intent@latest feedback` | Submit skill feedback | diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/cli-support.ts index b50669a..97b3917 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/cli-support.ts @@ -17,7 +17,7 @@ export interface StaleTargetResult { workflowAdvisories: Array } -export const INTENT_CHECK_SKILLS_WORKFLOW_VERSION = 2 +const INTENT_CHECK_SKILLS_WORKFLOW_VERSION = 2 export function getMetaDir(): string { const thisDir = dirname(fileURLToPath(import.meta.url)) diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 249bb52..0929902 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -132,6 +132,13 @@ function createCli(): CAC { await runEditPackageJsonCommand(process.cwd()) }) + cli + .command('setup', 'Copy Intent CI workflow templates into .github/workflows/') + .usage('setup') + .action(async () => { + await runSetupGithubActionsCommand(process.cwd(), getMetaDir()) + }) + cli .command( 'setup-github-actions', diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 4806ff5..95b2925 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -464,6 +464,27 @@ describe('cli commands', () => { expect(output).toContain('Template variables applied:') }) + it('copies github workflow templates with the setup alias', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-setup-alias-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: '@scope/pkg', + version: '1.0.0', + intent: { version: 1, repo: 'scope/pkg', docs: 'docs/' }, + }) + + process.chdir(root) + + const exitCode = await main(['setup']) + const workflowsDir = join(root, '.github', 'workflows') + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(existsSync(workflowsDir)).toBe(true) + expect(output).toContain('Copied workflow:') + expect(output).toContain('Template variables applied:') + }) + it('copies github workflow templates to the workspace root', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-setup-gha-mono-')) tempDirs.push(root) From 675ed04ef5e756ee97a838cb880e08a8257d7ff9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 23:09:39 +0000 Subject: [PATCH 13/16] ci: apply automated fixes --- packages/intent/src/cli.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 0929902..2ea6710 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -133,7 +133,10 @@ function createCli(): CAC { }) cli - .command('setup', 'Copy Intent CI workflow templates into .github/workflows/') + .command( + 'setup', + 'Copy Intent CI workflow templates into .github/workflows/', + ) .usage('setup') .action(async () => { await runSetupGithubActionsCommand(process.cwd(), getMetaDir()) From a46766dcc0e73471aba60ad6b27875b646d8f2f9 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 23 Apr 2026 16:10:58 -0700 Subject: [PATCH 14/16] rm empty codex file --- .codex | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .codex diff --git a/.codex b/.codex deleted file mode 100644 index e69de29..0000000 From 721a3986491829a49de729ced22c1c4ae5018872 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 23 Apr 2026 16:16:50 -0700 Subject: [PATCH 15/16] fix up --- docs/cli/intent-stale.md | 3 +- packages/intent/tests/workflow-review.test.ts | 32 +++++++++++++++++++ scripts/create-github-release.mjs | 13 ++++++-- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/docs/cli/intent-stale.md b/docs/cli/intent-stale.md index b7f7333..b7ebb05 100644 --- a/docs/cli/intent-stale.md +++ b/docs/cli/intent-stale.md @@ -15,7 +15,8 @@ npx @tanstack/intent@latest stale [--json] ## Behavior -- Checks the current package by default, or all skill-bearing packages in the current workspace when run from a monorepo root +- Checks the current package by default +- From a monorepo root, checks workspace packages that ship skills and also reports public workspace packages with no skill or artifact coverage - When `dir` is provided, scopes the check to the targeted package or skills directory - Computes one staleness report per package - Reads repo-root `_artifacts/*domain_map.yaml` and `_artifacts/*skill_tree.yaml` when present diff --git a/packages/intent/tests/workflow-review.test.ts b/packages/intent/tests/workflow-review.test.ts index a5521f8..e9157ce 100644 --- a/packages/intent/tests/workflow-review.test.ts +++ b/packages/intent/tests/workflow-review.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { buildStaleReviewBody, @@ -6,6 +8,8 @@ import { } from '../src/workflow-review.js' import type { StalenessReport } from '../src/types.js' +const repoRoot = join(import.meta.dirname, '..', '..', '..') + function report(overrides: Partial): StalenessReport { return { library: '@tanstack/router', @@ -131,4 +135,32 @@ describe('workflow review helpers', () => { expect(body).toContain('| `stale-check-failed` | 1 |') expect(body).toContain('Review the workflow logs before updating skills.') }) + + it('keeps the generated workflow wired to grouped review items and PR body updates', () => { + const template = readFileSync( + join( + repoRoot, + 'packages', + 'intent', + 'meta', + 'templates', + 'workflows', + 'check-skills.yml', + ), + 'utf8', + ) + + expect(template).toContain('intent stale --json > stale.json') + expect(template).toContain('const reports = JSON.parse') + expect(template).toContain('for (const skill of report.skills ?? [])') + expect(template).toContain('for (const signal of report.signals ?? [])') + expect(template).toContain("type: signal?.type ?? 'review-signal'") + expect(template).toContain( + "fs.writeFileSync('review-items.json', JSON.stringify(items, null, 2) + '\\n')", + ) + expect(template).toContain("fs.writeFileSync('pr-body.md', body + '\\n')") + expect(template).toContain('gh pr edit "$PR_URL" --body-file pr-body.md') + expect(template).toContain('gh pr create \\') + expect(template).toContain('--body-file pr-body.md') + }) }) diff --git a/scripts/create-github-release.mjs b/scripts/create-github-release.mjs index a4608cb..6647db4 100644 --- a/scripts/create-github-release.mjs +++ b/scripts/create-github-release.mjs @@ -41,13 +41,22 @@ function getReleaseCommits() { } function getPreviousGitHubReleaseCommit() { - const output = maybeRun('git tag --list "release-*" --sort=-creatordate') + const output = maybeRun( + 'git tag --list "v*" --list "release-*" --sort=-creatordate', + ) if (!output) { return null } - const [tag] = output.split('\n').filter(Boolean) + const [tag] = output + .split('\n') + .filter((candidate) => /^v\d+\.\d+\.\d+$/.test(candidate)) + .concat( + output + .split('\n') + .filter((candidate) => candidate.startsWith('release-')), + ) if (!tag) { return null From f655d040eba02c296358b915cea46f1290cbd944 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 23 Apr 2026 16:23:18 -0700 Subject: [PATCH 16/16] small fixes --- .../meta/templates/workflows/check-skills.yml | 3 ++- packages/intent/src/cli-support.ts | 25 ++++++++----------- packages/intent/src/commands/stale.ts | 4 +-- packages/intent/src/staleness.ts | 2 +- packages/intent/tests/cli.test.ts | 7 ++++++ packages/intent/tests/stale-command.test.ts | 13 +++++++--- packages/intent/tests/workflow-review.test.ts | 2 ++ 7 files changed, 34 insertions(+), 22 deletions(-) diff --git a/packages/intent/meta/templates/workflows/check-skills.yml b/packages/intent/meta/templates/workflows/check-skills.yml index dde6584..55a9516 100644 --- a/packages/intent/meta/templates/workflows/check-skills.yml +++ b/packages/intent/meta/templates/workflows/check-skills.yml @@ -89,8 +89,9 @@ jobs: signal?.packageRoot ?? signal?.skill ?? signal?.artifactPath ?? + signal?.subject ?? report.library, - reasons: signal?.reasons ?? [signal?.message].filter(Boolean), + reasons: signal?.reasons ?? [], artifactPath: signal?.artifactPath, packageName: signal?.packageName, packageRoot: signal?.packageRoot, diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/cli-support.ts index 97b3917..559a3fa 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/cli-support.ts @@ -17,7 +17,7 @@ export interface StaleTargetResult { workflowAdvisories: Array } -const INTENT_CHECK_SKILLS_WORKFLOW_VERSION = 2 +export const INTENT_CHECK_SKILLS_WORKFLOW_VERSION = 2 export function getMetaDir(): string { const thisDir = dirname(fileURLToPath(import.meta.url)) @@ -95,7 +95,7 @@ export async function resolveStaleTargets( if ( context.packageRoot && !isWorkspaceRootTarget && - (context.targetSkillsDir !== null || resolvedRoot !== context.workspaceRoot) + (context.targetSkillsDir !== null || context.workspaceRoot === null) ) { return { reports: [ @@ -130,19 +130,14 @@ export async function resolveStaleTargets( packageDirs: allPackageDirs, }) if (coverageSignals.length > 0) { - const workspaceReport = reports[0] - if (workspaceReport) { - workspaceReport.signals.push(...coverageSignals) - } else { - reports.push({ - library: relative(process.cwd(), workspaceRoot) || 'workspace', - currentVersion: null, - skillVersion: null, - versionDrift: null, - skills: [], - signals: coverageSignals, - }) - } + reports.push({ + library: relative(process.cwd(), workspaceRoot) || 'workspace', + currentVersion: null, + skillVersion: null, + versionDrift: null, + skills: [], + signals: coverageSignals, + }) } if (reports.length > 0) { diff --git a/packages/intent/src/commands/stale.ts b/packages/intent/src/commands/stale.ts index 30df6f9..a0ba32d 100644 --- a/packages/intent/src/commands/stale.ts +++ b/packages/intent/src/commands/stale.ts @@ -48,12 +48,12 @@ export async function runStaleCommand( } for (const signal of signals) { const subject = - signal.subject ?? signal.packageName ?? signal.packageRoot ?? signal.skill ?? signal.artifactPath ?? - signal.type + signal.subject ?? + report.library console.log(` ⚠ ${subject}: ${signal.reasons.join(', ')}`) } } diff --git a/packages/intent/src/staleness.ts b/packages/intent/src/staleness.ts index 7ac3074..0dfee07 100644 --- a/packages/intent/src/staleness.ts +++ b/packages/intent/src/staleness.ts @@ -182,7 +182,7 @@ function artifactPackageMatches( artifactRoot: string, ): boolean { const relPackageDir = relative(artifactRoot, packageDir).split(sep).join('/') - if (!relPackageDir || relPackageDir === '') return true + if (!relPackageDir) return true if (artifact.packages.includes(packageName)) return true if (artifact.packages.includes(relPackageDir)) return true diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 95b2925..f8e4344 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -1567,6 +1567,11 @@ describe('cli commands', () => { ].join('\n'), ) + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ version: '1.0.0' }), + } as Response) + process.chdir(root) const exitCode = await main(['stale', '--json']) @@ -1586,6 +1591,8 @@ describe('cli commands', () => { packageName: '@tanstack/react-start-rsc', }), ]) + + fetchSpy.mockRestore() }) it('ignores configured global intent packages when checking staleness', async () => { diff --git a/packages/intent/tests/stale-command.test.ts b/packages/intent/tests/stale-command.test.ts index 8defcc0..5028d06 100644 --- a/packages/intent/tests/stale-command.test.ts +++ b/packages/intent/tests/stale-command.test.ts @@ -2,7 +2,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { getCheckSkillsWorkflowAdvisories } from '../src/cli-support.js' +import { + getCheckSkillsWorkflowAdvisories, + INTENT_CHECK_SKILLS_WORKFLOW_VERSION, +} from '../src/cli-support.js' import { runStaleCommand } from '../src/commands/stale.js' describe('runStaleCommand', () => { @@ -99,7 +102,9 @@ describe('getCheckSkillsWorkflowAdvisories', () => { }) it('advises when the workflow has an old intent version stamp', () => { - const root = writeWorkflow('# intent-workflow-version: 1\n') + const root = writeWorkflow( + `# intent-workflow-version: ${INTENT_CHECK_SKILLS_WORKFLOW_VERSION - 1}\n`, + ) expect(getCheckSkillsWorkflowAdvisories(root)).toEqual([ expect.stringContaining('npx @tanstack/intent@latest setup'), @@ -107,7 +112,9 @@ describe('getCheckSkillsWorkflowAdvisories', () => { }) it('does not advise when the workflow has the current version stamp', () => { - const root = writeWorkflow('# intent-workflow-version: 2\n') + const root = writeWorkflow( + `# intent-workflow-version: ${INTENT_CHECK_SKILLS_WORKFLOW_VERSION}\n`, + ) expect(getCheckSkillsWorkflowAdvisories(root)).toEqual([]) }) diff --git a/packages/intent/tests/workflow-review.test.ts b/packages/intent/tests/workflow-review.test.ts index e9157ce..7fb6214 100644 --- a/packages/intent/tests/workflow-review.test.ts +++ b/packages/intent/tests/workflow-review.test.ts @@ -155,6 +155,8 @@ describe('workflow review helpers', () => { expect(template).toContain('for (const skill of report.skills ?? [])') expect(template).toContain('for (const signal of report.signals ?? [])') expect(template).toContain("type: signal?.type ?? 'review-signal'") + expect(template).toContain('signal?.subject ??') + expect(template).not.toContain('signal?.message') expect(template).toContain( "fs.writeFileSync('review-items.json', JSON.stringify(items, null, 2) + '\\n')", )